From d679a44cde73c0efe8cffc1dbac139e801bdae2d Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:08:06 -0600 Subject: [PATCH 01/63] In-progress draft of installation logistics modeling framework This is focused on a framework for representing installation actions to start with. Located in Irma folder for now. Irma.py has the basic script for trying things out, and more importantly a draft Scenario class that manages actions and related info. Includes Rudy's graph theory representation and plotting function. Actions.py has the class that covers individual actions. Four YAML files give an outline/beginning of a framework to represent how various vessels/assets, capabilities, and actions can be interrelated and organized. --- famodel/irma/action.py | 245 +++++++++++++++++++++ famodel/irma/actions.yaml | 60 ++++++ famodel/irma/capabilities.yaml | 67 ++++++ famodel/irma/irma.py | 381 +++++++++++++++++++++++++++++++++ famodel/irma/objects.yaml | 26 +++ famodel/irma/vessels.yaml | 33 +++ 6 files changed, 812 insertions(+) create mode 100644 famodel/irma/action.py create mode 100644 famodel/irma/actions.yaml create mode 100644 famodel/irma/capabilities.yaml create mode 100644 famodel/irma/irma.py create mode 100644 famodel/irma/objects.yaml create mode 100644 famodel/irma/vessels.yaml diff --git a/famodel/irma/action.py b/famodel/irma/action.py new file mode 100644 index 00000000..9437ae2e --- /dev/null +++ b/famodel/irma/action.py @@ -0,0 +1,245 @@ +"""Action base class""" + +import numpy as np +import matplotlib.pyplot as plt + +import moorpy as mp +from moorpy.helpers import set_axes_equal +from moorpy import helpers +import yaml +from copy import deepcopy + +#from shapely.geometry import Point, Polygon, LineString +from famodel.seabed import seabed_tools as sbt +from famodel.mooring.mooring import Mooring +from famodel.platform.platform import Platform +from famodel.anchors.anchor import Anchor +from famodel.mooring.connector import Connector +from famodel.substation.substation import Substation +from famodel.cables.cable import Cable +from famodel.cables.dynamic_cable import DynamicCable +from famodel.cables.static_cable import StaticCable +from famodel.cables.cable_properties import getCableProps, getBuoyProps, loadCableProps,loadBuoyProps +from famodel.cables.components import Joint +from famodel.turbine.turbine import Turbine +from famodel.famodel_base import Node + +# Import select required helper functions +from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, + getMoorings, getAnchors, getFromDict, cleanDataTypes, + getStaticCables, getCableDesign, m2nm, loadYAML, + configureAdjuster, route_around_anchors) + + +def incrementer(text): + split_text = text.split()[::-1] + for ind, spl in enumerate(split_text): + try: + split_text[ind] = str(int(spl) + 1) + break + except ValueError: + continue + return " ".join(split_text[::-1]) + + +def increment_name(name): + '''Increments an end integer after a dash in a name''' + name_parts = name.split(sep='-') + + # if no numeric suffix yet, add one + if len(name_parts) == 1 or not name_parts[-1].isdigit(): + name = name+'-0' + # otherwise there must be a suffix, so increment it + else: + name_parts[-1] = str( 1 + int(name_parts[-1])) + + name = '-'.join(name_parts) # reassemble name string + + return name + + +class Action(): + ''' + An Action is a general representation of a marine operations action + that involves manipulating a system/design/structure using assets/ + equipment. The Action base class contains generic routines and parameters. + Specialized routines for performing each action should be set up in + subclasses. + ''' + + def __init__(self, actionType, name, **kwargs): + '''Create an action object... + It must be given a name. + The remaining parameters should correspond to items in the actionType dict... + + Parameters + ---------- + actionType : dict + Dictionary defining the action type (typically taken from a yaml). + name : string + A name for the action. It may be appended with numbers if there + are duplicate names. + kwargs + Additional arguments may depend on the action type and typically + include a list of FAModel objects that are acted upon, or + a list of dependencies (other action names/objects). + + ''' + + # list of things that will be controlled during this action + self.vesselList = [] # all vessels required for the action + self.objectList = [] # all objects that could be acted on + self.dependencies = {} # list of other actions this one depends on + + self.name = name + self.status = 0 # 0, waiting; 1=running; 2=finished + + self.duration = getFromDict(actionType, 'duration', default=3) + ''' + # Create a dictionary of supported object types (with empty entries) + if 'objects' in actionType: #objs = getFromDict(actionType, 'objects', shape=-1, default={}) + for obj in actionType['objects']: # go through keys in objects dictionary + self.objectList[obj] = None # make blank entries with the same names + + + # Process objects according to the action type + if 'objects' in kwargs: #objects = getFromDict(kwargs, objects, default=[]) + for obj in kwargs['objects']: + objType = obj.__class__.__name__.lower() + if objType in self.objectList: + self.objectList[objType] = obj + else: + raise Exception(f"Object type '{objType}' is not in the action's supported list.") + ''' + + # Process objects to be acted upon + # make list of supported object type names + if 'objects' in actionType: + if isinstance(actionType['objects'], list): + supported_objects = actionType['objects'] + elif isinstance(actionType['objects'], dict): + supported_objects = list(actionType['objects'].keys()) + else: + supported_objects = [] + # Add objects to the action's object list as long as they're supported + if 'objects' in kwargs: + for obj in kwargs['objects']: + objType = obj.__class__.__name__.lower() # object class name + if objType in supported_objects: + self.objectList.append(obj) + else: + raise Exception(f"Object type '{objType}' is not in the action's supported list.") + + + # Process dependencies + if 'dependencies' in kwargs: + for dep in kwargs['dependencies']: + self.dependencies[dep.name] = dep + + # Process some optional kwargs depending on the action type + + + + + def addDependency(self, dep): + '''Registers other action as a dependency of this one.''' + self.dependencies[dep.name] = dep + # could see if already a dependency and raise a warning if so... + + + def begin(self): + '''Take control of all objects''' + for vessel in self.vesselList: + vessel._attach_to(self) + for object in self.objectList: + object._attach_to(self) + + + def end(self): + '''Release all objects''' + for vessel in self.vesselList: + vessel._detach_from() + for object in self.objectList: + object._detach_from() + + + def timestep(self): + '''Advance the simulation of this action forward one step in time.''' + + # or maybe this is just + + + +""" +Rough draft ideas back when I imagined subclasses. +Just leaving here in case can be pulled from later. + +class tow(Action): + '''Subclass for towing a floating structure''' + + def __init__(self, object, r_destination): + '''Initialize the tow action, specifying which + structure (Platform type) needs to be towed. + ''' + + self.objects.append(object) + + self.r_finish = np.array(r_destination) + + + + + def assign_vessels(self, v1, v2, v3): + + self.vesselList.append(v1) + self.vesselList.append(v2) + self.vesselList.append(v3) + + self.tow_vessel = v1 + self.support_vessels = [v2, v3] + + + def approximate(self): + '''Generate approximate action characteristics for planning purposes. + ''' + + # (estimate based on vessels, etc... + + + def initiate(self): + '''Triggers the beginning of the action''' + + # Record start position of object + self.r_start = np.array(object.r) + + # + + def timestep(self): + + # controller - make sure things are going in right direction... + # (switch mode if need be) + if self.mode == 0 : # gathering vessels + for ves in self.vesselList: + dr = self.r_start - ves.r + ves.setCourse(dr) # sets vessel velocity + + # if all vessels are stopped (at the object), time to move + if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): + self.mode = 1 + + if self.mode == 1: # towing + for ves in self.vesselList: + dr = self.r_finish - ves.r + ves.setCourse(dr) # sets vessel velocity + + # if all vessels are stopped (at the final location), time to end + if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): + self.mode = 2 + + if self.mode == 2: # finished + self.end() + + # call generic time stepper... + self.timeStep() +""" + \ No newline at end of file diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml new file mode 100644 index 00000000..d852220f --- /dev/null +++ b/famodel/irma/actions.yaml @@ -0,0 +1,60 @@ +# list of marine operations actions +# (Any vessel action list will be checked against this for validity) +# +# +# +# +# + +tow: + function: tow # name of the function to be called... + objects: [platform] # what types of objects get acted on (must be passed in in order) + requirements: [AHV] # ?????? + capabilities: # to be provided by the vessels/requirements somehow... + - deck_space + - bollard_pull + duration: 5 # [hours] default action duration (may be recalculated) + +mooring_hookup: + objects: + mooring: + - pretension + #- depth + - weight + #- failme2 + platform: [] + #failme : [] + duration: 2 # [hours] + + requirements: + - AHV + capabilities: + - deck_space + - bollard_pull + + + +load_mooring: # move mooring from port or vessel onto vessel + objects: [mooring] + requirements: [port, vessel] + capabilities: + - deck_space + - bollard_pull + +lay_mooring: # + objects: [mooring] + requirements: [vessel] + capabilities: + - deck_space + - bollard_pull + +install_anchor: # + objects: [anchor] + requirements: [port, vessel] + capabilities: + - deck_space + - bollard_pull + +#load chain + +#load rope \ No newline at end of file diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml new file mode 100644 index 00000000..6c60bc3d --- /dev/null +++ b/famodel/irma/capabilities.yaml @@ -0,0 +1,67 @@ +# list of capabilities (for vessels, ports, etc) +# (Any vessel action list will be checked against this for validity) +# +# +# (Any vessel capabilities will be checked against this list for validity) + + - name: deck_space + area : 0 # [m2] + + - name: chain_locker + volume : 0 # ? + + - name: rope_spool + volume: 0 # ? + + - name: winch + force : 0 # [t] + + - name: bollard_pull + force : 0 # [t] + + - name: crane + capacity : 0 # [t] + height : 0 # [m] + + - name: stationkeeping + type : 'none' # anchors, dp1 or dp2 + capacity : 0 # [n] + + - name: pump + power : 0 # [kw] + capacity : 0 # [bar] + weight : 0 # [t] + dimensions : 0 # [m] + + - name: hammer + power : 0 # [kw] + capacity : 0 # [kj per blow] + weight : 0 # [t] + dimensions : 0 # [m] + + - name: drilling_socket_machine + power : 0 # [kw] + weight : 0 # [t] + dimensions : 0 # [m] + + - name: torque_machine + power : 0 # [kw] + weight : 0 # [t] + dimensions : 0 # [m] + + - name: grout_pump + power : 0 # [kw] + weight : 0 # [t] + dimensions : 0 # [t] + + - name: container # (used to host control of the power pack and sensors) + weight : 0 # [t] + dimensions : 0 # [m] + + - name: ROV + weight : 0 # [t] + dimensions : 0 # [m] + +# - positioning equipment: accurate placement on the seabed (remote or proximity) +# - monitoring equipment: assess installation performance +# - sonar survey \ No newline at end of file diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py new file mode 100644 index 00000000..9337638c --- /dev/null +++ b/famodel/irma/irma.py @@ -0,0 +1,381 @@ +"""Core code for setting up a IO&M scenario""" + +import os +import numpy as np +import matplotlib.pyplot as plt + +import moorpy as mp +from moorpy.helpers import set_axes_equal +from moorpy import helpers +import yaml +from copy import deepcopy +import string +try: + import raft as RAFT +except: + pass + +#from shapely.geometry import Point, Polygon, LineString +from famodel.seabed import seabed_tools as sbt +from famodel.mooring.mooring import Mooring +from famodel.platform.platform import Platform +from famodel.anchors.anchor import Anchor +from famodel.mooring.connector import Connector +from famodel.substation.substation import Substation +from famodel.cables.cable import Cable +from famodel.cables.dynamic_cable import DynamicCable +from famodel.cables.static_cable import StaticCable +from famodel.cables.cable_properties import getCableProps, getBuoyProps, loadCableProps,loadBuoyProps +from famodel.cables.components import Joint +from famodel.turbine.turbine import Turbine +from famodel.famodel_base import Node + +# Import select required helper functions +from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, + getMoorings, getAnchors, getFromDict, cleanDataTypes, + getStaticCables, getCableDesign, m2nm, loadYAML, + configureAdjuster, route_around_anchors) + +import networkx as nx +from action import Action, increment_name + + + +def loadYAMLtoDict(info, already_dict=False): + '''Reads a list or YAML file and prepares a dictionary''' + + if isinstance(info, str): + + with open(info) as file: + data = yaml.load(file, Loader=yaml.FullLoader) + if not data: + raise Exception(f'File {info} does not exist or cannot be read. Please check filename.') + elif isinstance(info, list): + data = info + else: + raise Exception('loadYAMLtoDict must be passed a filename or list') + + # Go through contents and product the dictionary + info_dict = {} + + if already_dict: + # assuming it's already a dict + info_dict.update(data) + + else: # a list of dicts with name parameters + # So we will convert into a dict based on those names + for entry in data: + if not 'name' in entry: + print(entry) + raise Exception('This entry does not have a required name field.') + + if entry['name'] in info_dict: + print(entry) + raise Exception('This entry has the same name as an existing entry.') + + info_dict[entry['name']] = entry # could make this a copy operation if worried + + return info_dict + + +#def storeState(project,...): + + +#def applyState(): + + + + + +class Scenario(): + + def __init__(self): + '''Initialize a scenario object that can be used for IO&M modeling of + of an offshore energy system. Eventually it will accept user-specified + settings files. + ''' + + # ----- Load database of supported things ----- + + actionTypes = loadYAMLtoDict('actions.yaml', already_dict=True) # Descriptions of actions that can be done + capabilities = loadYAMLtoDict('capabilities.yaml') + vessels = loadYAMLtoDict('vessels.yaml') + objects = loadYAMLtoDict('objects.yaml', already_dict=True) + + + # ----- Validate internal cross references ----- + + # Make sure vessels don't use nonexistent capabilities or actions + for key, ves in vessels.items(): + + if key != ves['name']: + raise Exception(f"Vessel key ({key}) contradicts its name ({ves['name']})") + + # Check capabilities + if not 'capabilities' in ves: + raise Exception(f"Vessel '{key}' is missing a capabilities list.") + + for cap in ves['capabilities']: + if not cap['name'] in capabilities: + raise Exception(f"Vessel '{key}' capability '{cap['name']}' is not in the global capability list.") + + # Could also check the sub-parameters of the capability + for cap_param in cap: + if not cap_param in capabilities[cap['name']]: + raise Exception(f"Vessel '{key}' capability '{cap['name']}' parameter '{cap_param}' is not in the global capability's parameter list.") + + # Check actions + if not 'actions' in ves: + raise Exception(f"Vessel '{key}' is missing an actions list.") + + for act in ves['actions']: + if not act in actionTypes: + raise Exception(f"Vessel '{key}' action '{act}' is not in the global action list.") + + # Make sure actions refer to supported object types/properties and capabilities + for key, act in actionTypes.items(): + + #if key != act['name']: + # raise Exception(f"Action key ({key}) contradicts its name ({act['name']})") + + # Check capabilities + if not 'capabilities' in act: + raise Exception(f"Action '{key}' is missing a capabilities list.") + + for cap in act['capabilities']: + if not cap in capabilities: + raise Exception(f"Action '{key}' capability '{cap}' is not in the global capability list.") + + # Could also check the sub-parameters of the capability + #for cap_param in cap: + # if not cap_param in capabilities[cap['name']]: + # raise Exception(f"Action '{key}' capability '{cap['name']}' parameter '{cap_param}' is not in the global capability's parameter list.") + + # Check objects + if not 'objects' in act: + raise Exception(f"Action '{key}' is missing an objects list.") + + for obj in act['objects']: + if not obj in objects: + raise Exception(f"Action '{key}' object '{obj}' is not in the global objects list.") + + # Could also check the sub-parameters of the object + if isinstance(act['objects'], dict): # if the object + for obj_param in act['objects'][obj]: + if not obj_param in objects[obj]: + raise Exception(f"Action '{key}' object '{obj}' parameter '{obj_param}' is not in the global object's parameter list.") + + + # Store some things + self.actionTypes = actionTypes + + self.capabilities = capabilities + self.vessels = vessels + self.objects = objects + + + # Initialize some things + self.actions = {} + + + def registerAction(self, action): + '''Registers an already created action''' + + # this also handles creation of unique dictionary keys + + while action.name in self.actions: # check if there is already a key with the same name + print(f"Action name '{action.name}' is in the actions list so incrementing it...") + action.name = increment_name(action.name) + + # What about handling of dependencies?? <<< done in the action object, + # but could check that each is in the list already... + for dep in action.dependencies.values(): + if not dep in self.actions.values(): + raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") + + # Add it to the actions dictionary + self.actions[action.name] = action + + + def addAction(self, action_type_name, action_name, **kwargs): + '''Creates and action and adds it to the register''' + + if not action_type_name in self.actionTypes: + raise Exception(f"Specified action type name {'action_type_name'} is not in the list of loaded action types.") + + # Get dictionary of action type information + action_type = self.actionTypes[action_type_name] + + # Create the action + act = Action(action_type, action_name, **kwargs) + + # Register the action + self.registerAction(act) + + return act # return the newly created action object, or its name? + + + def addActionDependencies(self, action, dependencies): + '''Adds dependencies to an action, provided those dependencies have + already been registered in the action list. + ''' + + if not isinstance(dependencies, list): + dependencies = [dependencies] # get into list form if singular + + for dep in dependencies: + # Make sure the dependency is already registered + if dep in self.actions.values(): + action.addDependency(dep) + else: + raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") + + + + def visualizeActions(self): + '''Generate a graph of the action dependencies. + ''' + + # Create the graph + G = nx.DiGraph() + for item, data in self.actions.items(): + for dep in data.dependencies: + G.add_edge(dep, item, duration=data.duration) # Store duration as edge attribute + + # Compute longest path & total duration + longest_path = nx.dag_longest_path(G, weight='duration') + longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs + total_duration = sum(self.actions[node].duration for node in longest_path) + if len(longest_path)>=1: + last_node = longest_path[-1] # Identify last node of the longest path + # Define layout + pos = nx.shell_layout(G) + # Draw all nodes and edges (default gray) + nx.draw(G, pos, with_labels=True, node_size=500, node_color='skyblue', font_size=10, font_weight='bold', font_color='black', edge_color='gray') + + # Highlight longest path in red + nx.draw_networkx_edges(G, pos, edgelist=longest_path_edges, edge_color='red', width=2) + + # Annotate last node with total duration in red + plt.text(pos[last_node][0], pos[last_node][1] - 0.1, f"{total_duration:.2f} hr", fontsize=12, color='red', fontweight='bold', ha='center') + else: + pass + + +if __name__ == '__main__': + '''This is currently a script to explore how some of the workflow could + work. Can move things into functions/methods as they solidify. + ''' + + # ----- Load up a Project ----- + + from famodel.project import Project + + + #%% Section 1: Project without RAFT + print('Creating project without RAFT\n') + print(os.getcwd()) + # create project object + project = Project(file='C:/Code/FAModel/examples/OntologySample200m_1turb.yaml', raft=False) + # create moorpy system of the array, include cables in the system + project.getMoorPyArray(cables=1) + # plot in 3d, using moorpy system for the mooring and cable plots + #project.plot2d() + #project.plot3d() + + ''' + # project.arrayWatchCircle(ang_spacing=20) + # save envelopes from watch circle information for each mooring line + for moor in project.mooringList.values(): + moor.getEnvelope() + + # plot motion envelopes with 2d plot + project.plot2d(save=True, plot_bathymetry=False) + ''' + + + # ----- Initialize the action stuff ----- + + sc = Scenario() # class instance holding most of the info + + + # Parse out the install steps required + + for anchor in project.anchorList.values(): + + # add and register anchor install action(s) + a1 = sc.addAction('install_anchor', 'install_anchor-1', objects=[anchor]) + + # register the actions as necessary for the anchor <<< do this for all objects?? + anchor.install_dependencies = [a1] + + + hookups = [] # list of hookup actions + + for mooring in project.mooringList.values(): + + # note origin and destination + + # --- lay out all the mooring's actions (and their links) + # create load vessel action + a2 = sc.addAction('load_mooring', 'load_mooring', objects=[mooring]) + + # create ship out mooring action + + # create lay mooring action + a3 = sc.addAction('lay_mooring', 'lay_mooring', objects=[mooring], dependencies=[a2]) + sc. addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor + + # mooring could be attached to anchor here - or could be lowered with anchor!! + #(r=r_anch, mooring=mooring, anchor=mooring.anchor...) + # the action creator can record any dependencies related to actions of the anchor + + # create hookup action + a4 = sc.addAction('mooring_hookup', 'mooring_hookup', + objects=[mooring, mooring.attached_to[1]], dependencies=[a2, a3]) + #(r=r, mooring=mooring, platform=platform, depends_on=[a4]) + # the action creator can record any dependencies related to actions of the platform + + hookups.append(a4) + + + # add the FOWT install action + a5 = sc.addAction('tow', 'tow', objects=[list(project.platformList.values())[0]]) + for a in hookups: + sc.addActionDependencies(a, [a5]) # make each hookup action dependent on the FOWT being towed out + + + + # ----- Do some graph analysis ----- + + sc.visualizeActions() + + + + # ----- Run the simulation ----- + ''' + for t in np.arange(8760): + + # run the actions - these will set the modes and velocities of things... + for a in actionList: + if a.status == 0: + pass + #check if the event should be initiated + elif a.status == 1: + a.timestep() # advance the action + # if status == 2: finished, then nothing to do + + # run the time integrator to update the states of things... + for v in self.vesselList: + v.timestep() + + + + # log the state of everything... + ''' + + + + + plt.show() + \ No newline at end of file diff --git a/famodel/irma/objects.yaml b/famodel/irma/objects.yaml new file mode 100644 index 00000000..914d8f4d --- /dev/null +++ b/famodel/irma/objects.yaml @@ -0,0 +1,26 @@ +# list of object types and attributes, a directory to ensure consistency +# (Any object relations will be checked against this list for validity) + +mooring: # object name + - length # list of supported attributes... + - pretension + - weight + +platform: + - mass + - draft + +anchor: + - mass + - length + + + + + +#mooring: +# install sequence: +# ship out mooring +# lay mooring +# attach mooring-anchor (ROV) +# hookup mooring-platform \ No newline at end of file diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml new file mode 100644 index 00000000..60022431 --- /dev/null +++ b/famodel/irma/vessels.yaml @@ -0,0 +1,33 @@ +# list of vessels +# +# + +- name: AHV1 + vessel_type: AHV + actions: + - tow + #- sink + + + capabilities: + - name: deck_space + area : 50 # [m2] + + - name: chain_locker + volume : 10 ? + + - name: bollard_pull + force : 250 [t] + + - name: crane + #unsupported_param : 27 # error handling test + +# - name: unsupported # error handling test +# weight : 0 # [t] +# dimensions : 0 # [m] + + +- name: AHV2 + vessel_type: AHV + capabilities: [] + actions: [] \ No newline at end of file From 1d3582e1a6c3b4f94c512e5e04e442c7097c420c Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:50:36 -0600 Subject: [PATCH 02/63] install modeling: expanding yamls and starting assets classes: - Expanded the yamls, especially vessels.yaml - - vessels.yaml now includes previous work from Rudy, including one example adapted to the new (draft) structure. - - TODO: finish updating vessels.yaml to follow the new structure. - - Switching yamls over from lists (with a name entry) to dictionaries. - Started assets.py, which takes vessel and port classes from Rudy (with some refinements from Ryan) and begins a more combined approach with an Assets base class. Work in progress. --- famodel/irma/actions.yaml | 7 + famodel/irma/assets.py | 505 +++++++++++++++++++++++++++++++++ famodel/irma/capabilities.yaml | 5 +- famodel/irma/irma.py | 28 +- famodel/irma/vessels.yaml | 161 +++++++++-- 5 files changed, 675 insertions(+), 31 deletions(-) create mode 100644 famodel/irma/assets.py diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index d852220f..c9ae1fde 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -41,6 +41,13 @@ load_mooring: # move mooring from port or vessel onto vessel - deck_space - bollard_pull +load_anchor: # move anchor from port or vessel onto vessel + objects: [anchor] + requirements: [port, vessel] + capabilities: + - deck_space + - crane + lay_mooring: # objects: [mooring] requirements: [vessel] diff --git a/famodel/irma/assets.py b/famodel/irma/assets.py new file mode 100644 index 00000000..d9bedf50 --- /dev/null +++ b/famodel/irma/assets.py @@ -0,0 +1,505 @@ +"Classes for vessels and port" +# Adapted from Rudy's original work and Ryan's updates + +import yaml +from copy import deepcopy + +class Asset: + ''' + Base class for vessel or port used in installation or maintenance. + + Attributes + ---------- + name : str + Name of the vessel. + specs : dict + Specifications of the vessel. + state : dict + Current state of the vessel including position, cargo, etc. + ''' + + def __init__(self, file = None, vesselDisc=None): + """ + Initialize a Vessel object from a configuration file. + + Parameters + ---------- + config_file : str + Path to the vessel configuration file. + + Returns + ------- + None + """ + + if vesselDisc is None and file is not None: + with open(file) as f: + vesselDisc = yaml.load(f, Loader=yaml.FullLoader) + elif vesselDisc is not None and file is None: + pass + else: + raise ValueError("Either vesselDisc or file must be provided.") + + # Set up general attributes from inputs + self.name = vesselDisc.get('name', "Unnamed Vessel") + self.type = vesselDisc.get('type', "Untyped Vessel") + self.specs = vesselDisc['specs'] + self.state = { + "spool_storage": self.specs['storage_specs']['max_spool_capacity'], + "deck_storage": self.specs['storage_specs']['max_deck_space'], + "cargo_mass": self.specs['storage_specs']['max_cargo'], + "assigned_materials" : [], + "log": [] + } + + # additional initialization should be done in the vessel or port subclass init + + + def logState(self, time, new_state): + """ + Log and update the vessel state. + + Parameters + ---------- + time : float + Current simulation time. + new_state : dict + New state information to update and log. + + Returns + ------- + None + """ + self.state.update(new_state) + self.state["log"].append({"time": time, "state": new_state}) + + + def getState(self, t): + """ + Retrieve vessel state at a specific time. + + Parameters + ---------- + t : float + Time at which to retrieve the vessel state. + + Returns + ------- + state : dict + The vessel state at time t, or None if no state exists before time t. + """ + return next((log for log in reversed(self.state["log"]) if log["time"] <= t), None) + + + + + + +class Vessel(Asset): + """ + Represents a vessel used in the installation process. + + Attributes + ---------- + name : str + Name of the vessel. + specs : dict + Specifications of the vessel. + state : dict + Current state of the vessel including position, cargo, etc. + """ + + def __init__(self, info): + ''' + Initialize a Vessel object from a configuration file or dict. + + Parameters + ---------- + config_file : str + Path to the vessel configuration file. + ''' + + # Initialize the base class + Asset.__init__(self, info) + + + # Set up the action structures + self.transit_to = Action("transit_to") + self.transit_from = Action("transit_from") + self.mobilize_material = Action("mobilize") + self.install = Action("install") + + def get_mobilize_action(self, pkg): + """ + Mobilize action for the vessel at port. + + This is a collection of code that looked duplicated between the vessel file and the install_helpers file. + More than anything it helps give sense of how to calculate the mobilization time based on the vessel specs and the package of materials. + + Parameters + ---------- + pkg : dict + The package of materials to be mobilized. + + Returns + ------- + Action + Action for mobilizing the vessel. + """ + + # Old vessel mobilize action + # mobilize_material.addItem("load spooling", duration=1) + # mobilize_material.addItem("load line", duration=2, dependencies=[("self", "load spooling")]) + # mobilize_material.addItem("load anchor", duration=1) + # mobilize_material.addItem("load gear", duration=2) + # mobilize_material.addItem("seafasten", duration=3, dependencies=[ + # ("self", "load spooling"), ("self", "load line"), + # ("self", "load anchor"), ("self", "load gear") + # ]) + + # Old Mobilize function + # mobilize_material.addItem("mobilize_vessel", duration=self.specs['vessel_specs']['mobilization_time'], dependencies=[]) + + # Mobilize action from install_helpers + winch_speed = self.specs['storage_specs']['winch_speed']*60 # m/hr + anchor_loading_speed = self.specs['storage_specs']['anchor_loading_speed'] + + self.mobilize_material.addItem("load spooling", duration=1, dependencies=[]) + self.mobilize_material.addItem("load line", duration=0, dependencies=[self.mobilize_material.items["load spooling"]]) # these need to be ActionItems in an Action object + self.mobilize_material.addItem("load anchor", duration=0, dependencies=[]) + self.mobilize_material.addItem("load gear", duration=2, dependencies=[]) + self.mobilize_material.addItem("seafasten", duration=3, dependencies=[ # these need to be ActionItems in an Action object + self.mobilize_material.items["load spooling"], self.mobilize_material.items["load line"], + self.mobilize_material.items["load anchor"], self.mobilize_material.items["load gear"] + ]) + + for key, item in pkg.items(): + item['obj'].inst['mobilized'] = True + if key.startswith("sec"): # agnostic to line type + self.mobilize_material.items["load line"].duration += item['length'] / winch_speed + self.state['spool_storage'] -= item['length'] + + elif key.startswith("anchor"): # anchor + if item['load'] > self.specs['storage_specs']['max_deck_load']: + raise ValueError(f"item {key} has a load higher than what the vessel can withstand.") + + self.mobilize_material.items["load anchor"].duration += anchor_loading_speed # Assuming 1 anchor load = 1 * speed + self.state['deck_storage'] -= item['space'] + + self.state['cargo_mass'] -= item['mass'] # remaining capacity + self.state['assigned_materials'].append(item['obj']) + + return self.mobilize_material + + def mob(self, time, **kwargs): + """ + + This function is not used yet. Example of what considering port location could look like. + + Initialize the vessel and mobilize to port + + Parameters + ---------- + time : float + The current simulation time. + location : str + The target location for mobilization. + + Returns + ------- + None + """ + # Duration of the activity + duration = self.specs['vessel_specs']['mobilization_time'] + + # Vessel location at port + portLocation = kwargs.get('port_r', [0, 0]) + self.r = portLocation + self.state["location"] = self.r + self.state["preceeds"] = "material_mob" + + # Get vessel latest activity + log = self.getState(time) + if not log: + time=duration + else: + time = log["time"] + duration + + self.logState(time=time, new_state=self.state) + + def get_transit_to_action(self, distance2port): + """ + Transit actions for the vessel to a destination from port. + + Parameters + ---------- + distance2port : float + The distance to the site from port. + + Returns + ------- + transit_to : Action + Action for transiting to the site from port. + """ + + self.transit_to.addItem("transit_to_site", duration=distance2port/self.specs['transport_specs']['transit_speed'], dependencies=[self.mobilize_material.items["seafasten"]]) # these need to be ActionItems in an Action object + + return self.transit_to + + + def get_install_action(self, pkg): + """ + Creates an action item for installing a materials package from a vessel. + + Parameters + ---------- + pkg : dict + The package of materials to be installed. + + Returns + ------- + action : dict + The action item for installing the materials. + """ + + # set up structure for filling in based on pkg + self.install.addItem("position onsite", duration=0, dependencies=[]) + self.install.addItem("site survey", duration=0, dependencies=[self.install.items["position onsite"]]) + self.install.addItem("install anchor", duration=0, dependencies=[self.install.items["position onsite"], self.install.items["site survey"]]) + self.install.addItem("rerig deck", duration=0, dependencies=[self.install.items["position onsite"], self.install.items["install anchor"]]) + self.install.addItem("install line", duration=0, dependencies=[self.install.items["install anchor"], self.install.items["rerig deck"]]) + + + def installItem(key): + ''' + NOT A PUBLIC FUNCTION + This function installs an item and its dependencies. + It checks if the item is already installed and if not, it installs its dependencies first. + Then, it installs the item itself and updates the vessel state. + + Parameters + ---------- + key : str + The key of the item to be installed. + + Returns + ------- + None + ''' + item = pkg.get(key) + for dep in item['dependencies']: + if not pkg[dep]['obj'].inst['installed']: + installItem(dep) + + if key.startswith("anchor"): + self.install.items["position onsite"].duration = 2 # from PPI (only once per anchor) + self.install.items["site survey"].duration = 2 # from PPI + if item['obj'].dd['design']['type']=='suction': + pile_fixed = self.specs["vessel_specs"]["pile_fixed_install_time"] + pile_depth = 0.005 * abs(item['obj'].r[-1]) + + self.install.items["install anchor"].duration = pile_fixed + pile_depth + else: + # support for other anchor types + pass + + self.state['deck_storage'] += item.get('space', 0) + + elif key.startswith("sec"): + if self.install.items["install line"].duration ==0: + # first line to install + self.install.items["rerig deck"].duration = self.specs['storage_specs'].get('rerig_deck', 0) + winch_speed = self.specs['storage_specs']['winch_speed']*60 # m/hr + line_fixed = self.specs["vessel_specs"]["line_fixed_install_time"] + line_winch = item['length']/winch_speed + self.install.items["install line"].duration += line_fixed + line_winch + self.install.items["install line"].dependencies = [self.install.items["install anchor"], self.install.items["rerig deck"]] + + self.state['spool_storage'] += item.get('length', 0) + + item['obj'].inst['installed'] = True + self.state['cargo_mass'] += item['mass'] + self.state['assigned_materials'].remove(item['obj']) + + for key in pkg.keys(): + installItem(key) + + return self.install + + def get_transit_from_action(self, distance2port, empty_factor=1.0): + """ + Transit actions for the vessel from a destination to port. + + Parameters + ---------- + distance2port : float + The distance to the site from port. + empty_factor : float, optional + The factor to account for empty return trip. + + Returns + ------- + transit_from : Action + Action for transiting from the site to port. + """ + + self.transit_from.addItem("transit_from_site", duration= empty_factor * distance2port/self.specs['transport_specs']['transit_speed'], dependencies=[self.transit_to.items["transit_to_site"], self.install.items["install anchor"], self.install.items["install line"]]) + + return self.transit_from + + + def logState(self, time, new_state): + """ + Log and update the vessel state. + + Parameters + ---------- + time : float + Current simulation time. + new_state : dict + New state information to update and log. + + Returns + ------- + None + """ + self.state.update(new_state) + self.state["log"].append({"time": time, "state": new_state}) + + + def getState(self, t): + """ + Retrieve vessel state at a specific time. + + Parameters + ---------- + t : float + Time at which to retrieve the vessel state. + + Returns + ------- + state : dict + The vessel state at time t, or None if no state exists before time t. + """ + return next((log for log in reversed(self.state["log"]) if log["time"] <= t), None) + + +"Port base class" + +__author__ = "Rudy Alkarem" + +import yaml +from copy import deepcopy +import numpy as np + +class Port: + ''' + Represents a port for staging and logistics operations. + + Attributes + ---------- + name : str + Name of the port. + capacity : dict + Dictionary containing capacity parameters of the port. + storage : dict + Current storage state of the port. + ''' + + def __init__(self, file): + ''' + Initialize a Port object from a configuration file. + + Parameters + ---------- + config_file : str + Path to the port configuration file. + ''' + + # Initialize the base class + Asset.__init__(self, info) + + + + self.r = [portDisc['location']['lattitude'], portDisc['location']['longitude']] + self.pkgs = {} + + # misc + self.reel_refArea = 13.5 # m^2 + self.reel_refCap = 735 # m + self.chain_refArea = 20.5 # m^2 + self.chain_refLngth = 100 # m + + + def staging(self, pkgs): + """ + Perform staging and update port storage states. + + Parameters + ---------- + pkgs : list + List of packages to be staged at the port. + + Returns + ------- + remaining_pkgs : list or None + Packages that couldn't be staged due to capacity constraints, + or None if all packages were staged successfully. + """ + remainingPkgs = deepcopy(pkgs) + # Get some information about polyester and chain lines + polyLineLength = 0 + chinLineLength = 0 + polyPkgs = {} + chinPkgs = {} + for pkgName, pkg in pkgs.items(): + if pkgName.startswith("sec"): + if pkg["obj"]["type"]["material"]=="polyester": + polyLineLength += pkg["length"] + polyPkgs.append(pkg) + if pkg["obj"]["type"]["material"]=="chain": + chinLineLength += pkg["length"] + chinPkgs.append(pkg) + + # TODO: can we generalize this beyond polyester and chain? Any number of lines and line types? + # Store polyester lines + # Compute number of reels required to roll all polyester lines in pkgs + reelCount = np.ceil(polyLineLength/self.reel_refCap) + reelAreaTot = reelCount * self.reel_refArea + if self.state["yard_storage"] > reelAreaTot: + self.state["yard_storage"] -= reelAreaTot + for key in polyPkgs: + remainingPkgs.pop(key, None) + self.pkgs.update(polyPkgs) + + # Store chains + # Compute area acquired by chains + area_per_unit_meter = self.chain_refArea/self.chain_refLngth # for a pile of 1.5m tall [135mm chain nominal diameter] + chinAreaTot = area_per_unit_meter * chinLineLength + if self.state["yard_storage"] > chinAreaTot: + self.state["yard_storage"] -= chinAreaTot + for key in chinPkgs: + remainingPkgs.pop(key, None) + self.pkgs.update(chinPkgs) + + # remaining packages: + for pkgName, pkg in pkgs.items(): + if pkgName.startswith("anchor"): + if self.state["yard_storage"] > pkg["space"]: + self.state["yard_storage"] -= pkg["space"] + remainingPkgs.pop(pkgName, None) + self.pkgs.append(pkg) + if pkgName.startswith("conn"): + 'add logic to stage clump weights and buoys' + pass + + if remainingPkgs=={}: + remainingPkgs=None + return remainingPkgs + + + + + + + diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 6c60bc3d..6d52d967 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -6,13 +6,16 @@ - name: deck_space area : 0 # [m2] + max_load : 0 # [t] - name: chain_locker volume : 0 # ? - name: rope_spool volume: 0 # ? - + capacity: 0 + speed: 0 + - name: winch force : 0 # [t] diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 9337638c..ed87ebd4 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -39,6 +39,8 @@ import networkx as nx from action import Action, increment_name +from assets import Vessel, Port + def loadYAMLtoDict(info, already_dict=False): @@ -99,7 +101,7 @@ def __init__(self): actionTypes = loadYAMLtoDict('actions.yaml', already_dict=True) # Descriptions of actions that can be done capabilities = loadYAMLtoDict('capabilities.yaml') - vessels = loadYAMLtoDict('vessels.yaml') + vessels = loadYAMLtoDict('vessels.yaml', already_dict=True) objects = loadYAMLtoDict('objects.yaml', already_dict=True) @@ -108,21 +110,21 @@ def __init__(self): # Make sure vessels don't use nonexistent capabilities or actions for key, ves in vessels.items(): - if key != ves['name']: - raise Exception(f"Vessel key ({key}) contradicts its name ({ves['name']})") + #if key != ves['name']: + # raise Exception(f"Vessel key ({key}) contradicts its name ({ves['name']})") # Check capabilities if not 'capabilities' in ves: raise Exception(f"Vessel '{key}' is missing a capabilities list.") - for cap in ves['capabilities']: - if not cap['name'] in capabilities: - raise Exception(f"Vessel '{key}' capability '{cap['name']}' is not in the global capability list.") + for capname, cap in ves['capabilities'].items(): + if not capname in capabilities: + raise Exception(f"Vessel '{key}' capability '{capname}' is not in the global capability list.") # Could also check the sub-parameters of the capability for cap_param in cap: - if not cap_param in capabilities[cap['name']]: - raise Exception(f"Vessel '{key}' capability '{cap['name']}' parameter '{cap_param}' is not in the global capability's parameter list.") + if not cap_param in capabilities[capname]: + raise Exception(f"Vessel '{key}' capability '{capname}' parameter '{cap_param}' is not in the global capability's parameter list.") # Check actions if not 'actions' in ves: @@ -132,6 +134,7 @@ def __init__(self): if not act in actionTypes: raise Exception(f"Vessel '{key}' action '{act}' is not in the global action list.") + # Make sure actions refer to supported object types/properties and capabilities for key, act in actionTypes.items(): @@ -262,6 +265,15 @@ def visualizeActions(self): pass + + def findCompatibleVessels(self): + '''Go through actions and identify which vessels have the required + capabilities (could be based on capability presence, or quantitative. + ''' + + pass + + if __name__ == '__main__': '''This is currently a script to explore how some of the workflow could work. Can move things into functions/methods as they solidify. diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 60022431..d85c6750 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -1,33 +1,150 @@ # list of vessels # -# +# (trying a few different structure ideas) -- name: AHV1 - vessel_type: AHV - actions: - - tow - #- sink + +AHV1: + type: AHV + actions: + tow: {} capabilities: - - name: deck_space - area : 50 # [m2] - - - name: chain_locker - volume : 10 ? - - - name: bollard_pull - force : 250 [t] + deck_space : + area : 50 # [m2] + + chain_locker: + volume : 10 # ? - - name: crane + bollard_pull : + force : 250 #[t] + + crane : {} #unsupported_param : 27 # error handling test + + +MPSV1: # Multi-Purpose Supply Vessel (this example works) + type : MPSV + + vessel_specs: + mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + # could have other things like crew here + max_cargo_mass: 2000 # mT + + transport: + transit_speed: 7.7 # m/s + max_Hs : 5 # [m] + + capabilities: # these entries should fall within the capabilities.yaml definitions + bollard_pull: + force: 100 # mT + deck_space: + area: 1000 # m2 + max_load: 2000 # mT + rope_spool: + capacity: 1000 # mT + speed: 18 # m/min -# - name: unsupported # error handling test -# weight : 0 # [t] -# dimensions : 0 # [m] + actions: # these shuold fall within the actions.yaml definitions + load_mooring: {} + load_anchor: + default_duration: 5 # [hr] + lay_mooring: + default_duration: 3 # [hr] + max_Hs : 3 # [m] + install_anchor: + default_duration: 5 # [hr] + max_Hs : 2 # [m] + #pile_fixed_install_time : 2 # hrs (time it takes to install a pile) + #line_fixed_install_time : 5 # hrs (time it takes to install a line, either anchored or shared) + + + +# ===== entries below this point don't yet work and need to be updated ===== + + +Barge: # Usually with MPSV + type : Barge + specs: + vessel_specs: + mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + transport_specs: + transit_speed: 7.7 # m/s + storage_specs: + max_cargo: 2000 # mT + max_deck_space: 1000 # m2 + max_deck_load: 2000 # mT + max_spool_capacity: 1000 # mT + winch_speed: 18 # m/min + anchor_loading_speed: 5 + storage: + Mode1: + material: ['pile'] + quantity: [6] + Mode2: + material: ['poly', 'chain'] + quantity: [5, 12] + Mode3: + material: ['poly', 'clump'] + quantity: [3, 4] + + +# Vessel general descriptsion +# [storage_space, max_deck_load, anchor_loading_speed, pile_fixed_install_time, +# mobilization_time] are not real data, just filler numbers to get it to run + +### Vessel type structure ### +# VesselType: +# name: # Name of the vessel type +# type: # Type of the vessel (e.g., AHTS, MPSV, Barge, Tug) +# specs: # placeholder +# vessel_specs: # Placeholder for vessel specifications +# pile_fixed_install_time: # Time it takes to install a pile (in hours) +# line_fixed_install_time: # Time it takes to install a line (in hours) +# mobilization_time: # hrs (time it takes to mobilize the vessel) +# bollardPull: # Bollard pull of the vessel (in metric tons) +# transport_specs: # Placeholder for transport specifications +# transit_speed: # Transit speed of the vessel (in meters per second) +# storage_specs: # Placeholder for storage specifications +# max_cargo: # Maximum cargo capacity of the vessel (in metric tons) +# max_deck_space: # Maximum deck space of the vessel (in square meters) +# max_deck_load: # Maximum deck load of the vessel (in metric tons) +# max_spool_capacity: # Maximum spool capacity of the vessel (in metric tons) +# winch_speed: # Winch speed of the vessel (in meters per minute) +# anchor_loading_speed: # Anchor loading speed of the vessel (in unspecified units) +# storage: # Placeholder for storage specifications +# Mode1: # Placeholder for storage mode 1 +# material: # List of materials in storage mode 1 +# quantity: # List of quantities for each material in storage mode 1 +# Mode2: # Placeholder for storage mode 2 +# material: # List of materials in storage mode 2 +# quantity: # List of quantities for each material in storage mode 2 +# etc. # Additional storage modes can be added as needed + +AHTS: # Anchor Handling Towing Supply vessel + name : AHTS + type : AHTS + specs: + vessel_specs: + pile_fixed_install_time : 2 # hrs (time it takes to install a pile) + line_fixed_install_time : 4 # hrs (time it takes to install a line, either anchored or shared) + mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + bollardPull: 200 # mT + transport_specs: + transit_speed: 7.7 # m/s + storage_specs: + max_cargo: 2000 # mT + max_deck_space: 1000 # m2 + max_deck_load: 2000 # mT + max_spool_capacity: 1000 # mT + winch_speed: 11.5 # m/min + anchor_loading_speed: 5 + storage: + Mode1: + material: ['pile', 'poly', 'chain', 'gear'] + quantity: [3, 4, 8, 1] + Mode2: + material: ['poly', 'clump'] + quantity: [4, 2] -- name: AHV2 - vessel_type: AHV - capabilities: [] - actions: [] \ No newline at end of file From 0420b186e6c2b50b916e9fca865e842b3c19ac59 Mon Sep 17 00:00:00 2001 From: Moreno Date: Wed, 13 Aug 2025 15:21:17 -0600 Subject: [PATCH 03/63] Preliminary update to vessels.yaml to include AHTS and Barge --- famodel/irma/irma.py | 4 +- famodel/irma/vessels.yaml | 167 ++++++++++++++++++++++++++------------ 2 files changed, 117 insertions(+), 54 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index ed87ebd4..5b65f9c6 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -292,8 +292,8 @@ def findCompatibleVessels(self): # create moorpy system of the array, include cables in the system project.getMoorPyArray(cables=1) # plot in 3d, using moorpy system for the mooring and cable plots - #project.plot2d() - #project.plot3d() + # project.plot2d() + # project.plot3d() ''' # project.arrayWatchCircle(ang_spacing=20) diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index d85c6750..b7a57812 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -58,35 +58,99 @@ MPSV1: # Multi-Purpose Supply Vessel (this example works) #pile_fixed_install_time : 2 # hrs (time it takes to install a pile) #line_fixed_install_time : 5 # hrs (time it takes to install a line, either anchored or shared) - +AHTS1: # Anchor Handling Towing Supply vessel + type : AHTS + + vessel_specs: + mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + # could have other things like crew here + max_cargo_mass: 2000 # mT + + transport: + transit_speed: 7.7 # m/s + max_Hs : 5 # [m] + + capabilities: # these entries should fall within the capabilities.yaml definitions + bollard_pull: + force: 180 # mT + deck_space: + area: 500 # m2 + max_load: 400 # mT + rope_spool: + capacity: 1000 # mT + speed: 18 # m/min + chain_locker: + volume: 10 # [m3] + + actions: # these shuold fall within the actions.yaml definitions + load_mooring: {} + load_anchor: + default_duration: 5 # [hr] + lay_mooring: + default_duration: 3 # [hr] + max_Hs : 3 # [m] + install_anchor: + default_duration: 5 # [hr] + max_Hs : 2 # [m] + #pile_fixed_install_time : 2 # hrs (time it takes to install a pile) + #line_fixed_install_time : 5 # hrs (time it takes to install a line, either anchored or shared) + +Barge1: # Anchor Handling Towing Supply vessel + type : Barge + + vessel_specs: + mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + # could have other things like crew here + max_cargo_mass: 8000 # mT + + transport: + transit_speed: 4.7 # m/s + max_Hs : 5 # [m] + + capabilities: # these entries should fall within the capabilities.yaml definitions + deck_space: + area: 1500 #[m2] + max_load: 4000 #[t] + winch: + force: 1000 #[t] + pump: + power: 150 #[kW] + capacity: 150 #[bar] + weight: 20 #[t] + dimensions: 5 #[m] + + actions: # these should fall within the actions.yaml definitions + load_mooring: {} + load_anchor: + default_duration: 5 # [hr] # ===== entries below this point don't yet work and need to be updated ===== -Barge: # Usually with MPSV - type : Barge - specs: - vessel_specs: - mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - transport_specs: - transit_speed: 7.7 # m/s - storage_specs: - max_cargo: 2000 # mT - max_deck_space: 1000 # m2 - max_deck_load: 2000 # mT - max_spool_capacity: 1000 # mT - winch_speed: 18 # m/min - anchor_loading_speed: 5 - storage: - Mode1: - material: ['pile'] - quantity: [6] - Mode2: - material: ['poly', 'chain'] - quantity: [5, 12] - Mode3: - material: ['poly', 'clump'] - quantity: [3, 4] +# Barge: # Usually with MPSV + # type : Barge + # specs: + # vessel_specs: + # mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + # transport_specs: + # transit_speed: 7.7 # m/s + # storage_specs: + # max_cargo: 2000 # mT + # max_deck_space: 1000 # m2 + # max_deck_load: 2000 # mT + # max_spool_capacity: 1000 # mT + # winch_speed: 18 # m/min + # anchor_loading_speed: 5 + # storage: + # Mode1: + # material: ['pile'] + # quantity: [6] + # Mode2: + # material: ['poly', 'chain'] + # quantity: [5, 12] + # Mode3: + # material: ['poly', 'clump'] + # quantity: [3, 4] # Vessel general descriptsion @@ -120,31 +184,30 @@ Barge: # Usually with MPSV # material: # List of materials in storage mode 2 # quantity: # List of quantities for each material in storage mode 2 # etc. # Additional storage modes can be added as needed - - -AHTS: # Anchor Handling Towing Supply vessel - name : AHTS - type : AHTS - specs: - vessel_specs: - pile_fixed_install_time : 2 # hrs (time it takes to install a pile) - line_fixed_install_time : 4 # hrs (time it takes to install a line, either anchored or shared) - mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - bollardPull: 200 # mT - transport_specs: - transit_speed: 7.7 # m/s - storage_specs: - max_cargo: 2000 # mT - max_deck_space: 1000 # m2 - max_deck_load: 2000 # mT - max_spool_capacity: 1000 # mT - winch_speed: 11.5 # m/min - anchor_loading_speed: 5 - storage: - Mode1: - material: ['pile', 'poly', 'chain', 'gear'] - quantity: [3, 4, 8, 1] - Mode2: - material: ['poly', 'clump'] - quantity: [4, 2] + +# AHTS: # Anchor Handling Towing Supply vessel + # name : AHTS + # type : AHTS + # specs: + # vessel_specs: + # pile_fixed_install_time : 2 # hrs (time it takes to install a pile) + # line_fixed_install_time : 4 # hrs (time it takes to install a line, either anchored or shared) + # mobilization_time: 2 # hrs (time it takes to mobilize the vessel) + # bollardPull: 200 # mT + # transport_specs: + # transit_speed: 7.7 # m/s + # storage_specs: + # max_cargo: 2000 # mT + # max_deck_space: 1000 # m2 + # max_deck_load: 2000 # mT + # max_spool_capacity: 1000 # mT + # winch_speed: 11.5 # m/min + # anchor_loading_speed: 5 + # storage: + # Mode1: + # material: ['pile', 'poly', 'chain', 'gear'] + # quantity: [3, 4, 8, 1] + # Mode2: + # material: ['poly', 'clump'] + # quantity: [4, 2] From 2386c8ffd3989d31a16b90a077c27a109c6d4b4e Mon Sep 17 00:00:00 2001 From: Moreno Date: Mon, 18 Aug 2025 09:03:20 -0600 Subject: [PATCH 04/63] Update actions, capabilities, objects and vessels YAML files --- famodel/irma/actions.yaml | 271 ++++++++++++++--- famodel/irma/capabilities.yaml | 230 ++++++++++----- famodel/irma/objects.yaml | 21 +- famodel/irma/vessels.yaml | 525 ++++++++++++++++++++------------- 4 files changed, 725 insertions(+), 322 deletions(-) diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index c9ae1fde..145db854 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -1,67 +1,260 @@ -# list of marine operations actions -# (Any vessel action list will be checked against this for validity) -# -# -# -# -# +# This file defines standardized marine operations actions. +# Each entry needs numeric values per specific asset in vessels.yaml. +# Vessel actions will be checked against capabilities/actions for validation. + +# --- Towing & Transport --- tow: - function: tow # name of the function to be called... - objects: [platform] # what types of objects get acted on (must be passed in in order) - requirements: [AHV] # ?????? - capabilities: # to be provided by the vessels/requirements somehow... - - deck_space + objects: [platform] + requirements: [TUG] + capabilities: + - deck_space - bollard_pull - duration: 5 # [hours] default action duration (may be recalculated) + - winch + - positioning_system + duration_h: 5 + Hs_m: 3.0 + description: "Towing floating structures (e.g., floaters, barges) to site; includes station-keeping." -mooring_hookup: - objects: - mooring: - - pretension - #- depth - - weight - #- failme2 - platform: [] - #failme : [] - duration: 2 # [hours] - - requirements: - - AHV +transport_components: + objects: [component] + requirements: [HL, WTIV, CSV] capabilities: - deck_space - - bollard_pull + - crane + - positioning_system + duration_h: 12 + Hs_m:: 2.5 + description: "Transport of large components such as towers, nacelles, blades, or jackets." +# --- Mooring & Anchors --- +install_anchor: + objects: [anchor] + requirements: [AHTS, MSV] + capabilities: + - deck_space + - winch + - bollard_pull + - crane + - pump_subsea # pump_surface, drilling_machine, torque_machine + - positioning_system + - monitoring_system + duration_h: 5 + Hs_m:: 2.5 + description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." -load_mooring: # move mooring from port or vessel onto vessel +retrieve_anchor: + objects: [anchor] + requirements: [AHTS, MSV] + capabilities: + - deck_space + - winch + - bollard_pull + - crane + - pump_subsea + - positioning_system + duration_h: 4 + Hs_m:: 2.5 + description: "Anchor retrieval, including break-out and recovery to deck." + +load_mooring: objects: [mooring] requirements: [port, vessel] capabilities: - deck_space + - winch - bollard_pull + - mooring_work + - positioning_system + duration_h: 2 + Hs_m:: 2.5 + description: "Load-out of mooring lines and components from port or vessel onto vessel." -load_anchor: # move anchor from port or vessel onto vessel - objects: [anchor] - requirements: [port, vessel] +lay_mooring: + objects: [mooring] + requirements: [AHTS, CSV] + capabilities: + - deck_space + - winch + - bollard_pull + - mooring_work + - positioning_system + duration_h: 6 + Hs_m:: 2.5 + description: "Laying mooring lines, tensioning and connection to anchors and floaters." + +mooring_hookup: + objects: + mooring: [pretension] + platform: [wec] + requirements: [AHTS, CSV] + capabilities: + - deck_space + - winch + - bollard_pull + - mooring_work + - positioning_system + - monitoring_system + duration_h: 2 + Hs_m:: 2.5 + description: "Hook-up of mooring lines to floating platforms, including pretensioning." + +# --- Heavy Lift & Installation --- + +install_wec: + objects: [wec] + requirements: [HL, MSV] capabilities: - deck_space - crane + - positioning_system + - monitoring_system + - rov + duration_h: 20 + Hs_m:: 2.0 + description: "Lifting, placement and securement of wave energy converters (WECs) onto moorings, including alignment, connection of power/data umbilicals and verification via ROV." -lay_mooring: # - objects: [mooring] - requirements: [vessel] +install_semisub: + objects: [platform_semisub] + requirements: [AHTS, MSV, ROVSV] capabilities: - deck_space - bollard_pull + - winch + - crane + - positioning_system + - monitoring_system + - rov + - sonar_survey + - pump_surface + - mooring_work + duration_h: 36 + Hs_m:: 2.5 + description: "Wet tow arrival, station-keeping, ballasting/trim, mooring hookup and pretensioning, ROV verification and umbilical connections as needed." -install_anchor: # - objects: [anchor] - requirements: [port, vessel] +install_spar: + objects: [platform_spar] + requirements: [AHTS, MSV, ROVSV] + capabilities: + - deck_space + - bollard_pull + - winch + - positioning_system + - monitoring_system + - rov + - sonar_survey + - pump_surface + - mooring_work + duration_h: 48 + Hs_m:: 2.0 + description: "Arrival and upending via controlled ballasting, station-keeping, fairlead/messenger handling, mooring hookup and pretensioning with ROV confirmation. Heavy-lift support may be used during port integration." + +install_tlp: + objects: [platform_tlp] + requirements: [AHTS, MSV, ROVSV] capabilities: - deck_space - bollard_pull + - winch + - crane + - positioning_system + - monitoring_system + - rov + - sonar_survey + - mooring_work + duration_h: 60 + Hs_m:: 2.0 + description: "Tendon porch alignment, tendon hookup, sequential tensioning to target pretension, verification of offsets/RAOs and ROV checks." + +install_wtg: + objects: [wtg] + requirements: [WTIV] + capabilities: + - deck_space + - crane + - positioning_system + - monitoring_system + duration_h: 24 + Hs_m:: 2.0 + description: "Installation of wind turbine generator including tower, nacelle and blades." -#load chain +# --- Cable Operations --- -#load rope \ No newline at end of file +lay_cable: + objects: [cable] + requirements: [CSV, SURV] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - cable_reel + - sonar_survey + duration_h: 24 + Hs_m:: 2.5 + description: "Laying static/dynamic power cables, including burial where required." + +retrieve_cable: + objects: [cable] + requirements: [CSV, SURV] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - cable_reel + duration_h: 12 + Hs_m:: 2.5 + description: "Cable recovery operations, including cutting, grappling and retrieval." + + # Lay and bury in a single pass using a plough +lay_and_bury_cable: + objects: [cable] + requirements: [CSV] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - cable_reel + - cable_plough + - sonar_survey + duration_h: 30 + Hs_m: 2.5 + description: "Simultaneous lay and plough burial; continuous QA via positioning + MBES/SSS, with post-pass verification." + +# Backfill trench or stabilize cable route using rock placement +backfill_rockdump: + objects: [cable] + requirements: [ROCK, SURV] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - sonar_survey + - rock_placement + duration_h: 16 + Hs_m: 2.5 + description: "Localized rock placement to stabilize exposed cables, infill trenches or provide scour protection. Includes real-time positioning and sonar verification of rock placement." + +# --- Survey & Monitoring --- + +site_survey: + objects: [site] + requirements: [SURV] + capabilities: + - positioning_system + - sonar_survey + - monitoring_system + duration_h: 48 + Hs_m: 3.0 + description: "Pre-installation site survey including bathymetry, sub-bottom profiling and positioning." + +monitor_installation: + objects: [anchor, mooring, platform, cable] + requirements: [SURV, ROVSV] + capabilities: + - positioning_system + - monitoring_system + - rov + duration_h: 12 + Hs_m: 3.0 + description: "Real-time monitoring of installation operations using ROV and sensor packages." diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 6d52d967..4f37eb90 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -1,70 +1,164 @@ -# list of capabilities (for vessels, ports, etc) -# (Any vessel action list will be checked against this for validity) -# -# -# (Any vessel capabilities will be checked against this list for validity) - - - name: deck_space - area : 0 # [m2] - max_load : 0 # [t] - - - name: chain_locker - volume : 0 # ? - - - name: rope_spool - volume: 0 # ? - capacity: 0 - speed: 0 +# This file defines standardized capabilities for vessels and equipment. +# Each entry needs numeric values per specific asset in vessels.yaml. +# Vessel actions will be checked against capabilities/actions for validation. + +# --- Vessel (on-board) --- + + - name: deck_space + # description: Clear usable deck area and allowable load + # fields: + area_m2: 1000 # usable area [m2] + max_load_t: 2000 # allowable deck load [t] + + - name: chain_locker + # description: Chain storage capacity + # fields: + volume_m3: 50 # storage volume [m3] + + - name: line_reel + # description: Chain/rope storage on drum or carousel + # fields: + volume_m3: 20 # storage volume [m3] + rope_capacity_m: 200 # total rope length storage [m] + + - name: cable_reel + # description: Cable storage on drum or carousel + # fields: + volume_m3: 20 # storage volume [m3] + rope_capacity_m: 200 # total rope length stowable [m] - - name: winch - force : 0 # [t] - - - name: bollard_pull - force : 0 # [t] - - - name: crane - capacity : 0 # [t] - height : 0 # [m] - - - name: stationkeeping - type : 'none' # anchors, dp1 or dp2 - capacity : 0 # [n] - - - name: pump - power : 0 # [kw] - capacity : 0 # [bar] - weight : 0 # [t] - dimensions : 0 # [m] - - - name: hammer - power : 0 # [kw] - capacity : 0 # [kj per blow] - weight : 0 # [t] - dimensions : 0 # [m] + - name: winch + # description: Deck winch pulling capability + # fields: + max_line_pull_t: 200 # continuous line pull [t] + brake_load_t: 500 # static brake holding load [t] + speed_mpm: 25 # payout/haul speed [m/min] + + - name: bollard_pull + # description: Towing/holding force capability + # fields: + max_force_t: 2000 # bollard pull [t] + + - name: crane + # description: Main crane lifting capability + # fields: + capacity_t: 500 # SWL at specified radius [t] + hook_height_m: 80 # max hook height [m] + + - name: station_keeping + # description: Vessel station keeping capability (dynamic positioning or anchor-based) + # fields: + type: DP2 # e.g., DP0, DP1, DP2, DP3, anchor_based + + - name: mooring_work + # description: Suitability for anchor/mooring operations + # fields: + line_types: [chain] # e.g., [chain, ropes...] + stern_roller: true # presence of stern roller (optional) + shark_jaws: true # presence of chain stoppers/jaws (optional) + towing_pin_rating_t: 300 # rating of towing pins [t] (optional) + +# --- Equipment (portable) --- + + - name: pump_surface + # description: Surface-connected suction pump + # fields: + power_kW: 0 + pressure_bar: 0 + weight_t: 0 + dimensions_m: [4, 3, 3] # LxWxH + + - name: pump_subsea + # description: Subsea suction pump (electric/hydraulic) + # fields: + power_kW: 0 + pressure_bar: 0 + weight_t: 0 + dimensions_m: [2, 2, 3] # LxWxH + + - name: pump_grout + # description: Grout mixing and pumping unit + # fields: + power_kW: 0 + flow_rate_m3hr: 0 + pressure_bar: 0 + weight_t: 0 + dimensions_m: [2, 2, 2] # LxWxH + + - name: hydraulic_hammer + # description: Impact hammer for pile driving + # fields: + power_kW: 0 + energy_per_blow_kJ: 0 + weight_t: 0 + dimensions_m: [12, 2, 2] # LxWxH + + - name: vibro_hammer + # description: Vibratory hammer + # fields: + power_kW: 0 + centrifugal_force_kN: 0 + weight_t: 0 + dimensions_m: [12, 3, 3] # LxWxH + + - name: drilling_machine + # description: Drilling/rotary socket machine + # fields: + power_kW: 0 + weight_t: 0 + dimensions_m: [10, 2, 2] # LxWxH + + - name: torque_machine + # description: High-torque rotation unit + # fields: + power_kW: 0 + torque_kNm: 0 + weight_t: 0 + dimensions_m: [0, 0, 0] # LxWxH + + - name: cable_plough + # description: + # fields: + power_kW: 0 + weight_t: 0 + dimensions_m: [0, 0, 0] # LxWxH - - name: drilling_socket_machine - power : 0 # [kw] - weight : 0 # [t] - dimensions : 0 # [m] - - - name: torque_machine - power : 0 # [kw] - weight : 0 # [t] - dimensions : 0 # [m] - - - name: grout_pump - power : 0 # [kw] - weight : 0 # [t] - dimensions : 0 # [t] - - - name: container # (used to host control of the power pack and sensors) - weight : 0 # [t] - dimensions : 0 # [m] - - - name: ROV - weight : 0 # [t] - dimensions : 0 # [m] - -# - positioning equipment: accurate placement on the seabed (remote or proximity) -# - monitoring equipment: assess installation performance -# - sonar survey \ No newline at end of file + - name: rock_placement + # description: System for controlled placement of rock for trench backfill, scour protection, and seabed stabilization. + # fields: + placement_method: fall_pipe # e.g., fall_pipe, side_dump, grab + max_depth_m: 0 # maximum operational water depth + accuracy_m: 0 # placement accuracy on seabed + rock_size_range_mm: [50, 200] # min and max rock/gravel size + + - name: container + # description: Control/sensors container for power pack and monitoring + # fields: + weight_t: 20 + dimensions_m: [0, 0, 0] # LxWxH + + - name: rov + # description: Remotely Operated Vehicle + # fields: + class: OBSERVATION # e.g., OBSERVATION, LIGHT, WORK-CLASS + depth_rating_m: 0 + weight_t: 0 + dimensions_m: [0, 0, 0] # LxWxH + + - name: positioning_system + # description: Seabed placement/positioning aids + # fields: + accuracy_m: 0 + methods: [USBL] # e.g., [USBL, LBL, DVL, INS] + + - name: monitoring_system + # description: Installation performance monitoring + # fields: + metrics: [tilt] # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] + sampling_rate_hz: 0 + + - name: sonar_survey + # description: Sonar systems for survey and verification + # fields: + types: [SSS] # e.g., [MBES, SSS, SBP] + resolution_m: 0 diff --git a/famodel/irma/objects.yaml b/famodel/irma/objects.yaml index 914d8f4d..eb15906b 100644 --- a/famodel/irma/objects.yaml +++ b/famodel/irma/objects.yaml @@ -9,14 +9,29 @@ mooring: # object name platform: - mass - draft + - wec anchor: - mass - length - - - +component: + - mass + - length + +wec: + +platform_semisub: + +platform_spar: + +platform_tlp: + +wtg: + +cable: + +site: #mooring: # install sequence: diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index b7a57812..8ca1e18c 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -1,213 +1,314 @@ -# list of vessels -# -# (trying a few different structure ideas) - - -AHV1: - type: AHV - - actions: - tow: {} - - capabilities: - deck_space : - area : 50 # [m2] - - chain_locker: - volume : 10 # ? - - bollard_pull : - force : 250 #[t] - - crane : {} - #unsupported_param : 27 # error handling test - - -MPSV1: # Multi-Purpose Supply Vessel (this example works) - type : MPSV - - vessel_specs: - mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - # could have other things like crew here - max_cargo_mass: 2000 # mT - - transport: - transit_speed: 7.7 # m/s - max_Hs : 5 # [m] - - capabilities: # these entries should fall within the capabilities.yaml definitions - bollard_pull: - force: 100 # mT - deck_space: - area: 1000 # m2 - max_load: 2000 # mT - rope_spool: - capacity: 1000 # mT - speed: 18 # m/min - - actions: # these shuold fall within the actions.yaml definitions - load_mooring: {} - load_anchor: - default_duration: 5 # [hr] - lay_mooring: - default_duration: 3 # [hr] - max_Hs : 3 # [m] - install_anchor: - default_duration: 5 # [hr] - max_Hs : 2 # [m] - #pile_fixed_install_time : 2 # hrs (time it takes to install a pile) - #line_fixed_install_time : 5 # hrs (time it takes to install a line, either anchored or shared) - -AHTS1: # Anchor Handling Towing Supply vessel - type : AHTS - - vessel_specs: - mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - # could have other things like crew here - max_cargo_mass: 2000 # mT - - transport: - transit_speed: 7.7 # m/s - max_Hs : 5 # [m] - - capabilities: # these entries should fall within the capabilities.yaml definitions - bollard_pull: - force: 180 # mT - deck_space: - area: 500 # m2 - max_load: 400 # mT - rope_spool: - capacity: 1000 # mT - speed: 18 # m/min - chain_locker: - volume: 10 # [m3] - - actions: # these shuold fall within the actions.yaml definitions - load_mooring: {} - load_anchor: - default_duration: 5 # [hr] - lay_mooring: - default_duration: 3 # [hr] - max_Hs : 3 # [m] - install_anchor: - default_duration: 5 # [hr] - max_Hs : 2 # [m] - #pile_fixed_install_time : 2 # hrs (time it takes to install a pile) - #line_fixed_install_time : 5 # hrs (time it takes to install a line, either anchored or shared) - -Barge1: # Anchor Handling Towing Supply vessel - type : Barge - - vessel_specs: - mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - # could have other things like crew here - max_cargo_mass: 8000 # mT - - transport: - transit_speed: 4.7 # m/s - max_Hs : 5 # [m] - - capabilities: # these entries should fall within the capabilities.yaml definitions - deck_space: - area: 1500 #[m2] - max_load: 4000 #[t] - winch: - force: 1000 #[t] - pump: - power: 150 #[kW] - capacity: 150 #[bar] - weight: 20 #[t] - dimensions: 5 #[m] - - actions: # these should fall within the actions.yaml definitions - load_mooring: {} - load_anchor: - default_duration: 5 # [hr] - -# ===== entries below this point don't yet work and need to be updated ===== - - -# Barge: # Usually with MPSV - # type : Barge - # specs: - # vessel_specs: - # mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - # transport_specs: - # transit_speed: 7.7 # m/s - # storage_specs: - # max_cargo: 2000 # mT - # max_deck_space: 1000 # m2 - # max_deck_load: 2000 # mT - # max_spool_capacity: 1000 # mT - # winch_speed: 18 # m/min - # anchor_loading_speed: 5 - # storage: - # Mode1: - # material: ['pile'] - # quantity: [6] - # Mode2: - # material: ['poly', 'chain'] - # quantity: [5, 12] - # Mode3: - # material: ['poly', 'clump'] - # quantity: [3, 4] - - -# Vessel general descriptsion -# [storage_space, max_deck_load, anchor_loading_speed, pile_fixed_install_time, -# mobilization_time] are not real data, just filler numbers to get it to run - -### Vessel type structure ### -# VesselType: -# name: # Name of the vessel type -# type: # Type of the vessel (e.g., AHTS, MPSV, Barge, Tug) -# specs: # placeholder -# vessel_specs: # Placeholder for vessel specifications -# pile_fixed_install_time: # Time it takes to install a pile (in hours) -# line_fixed_install_time: # Time it takes to install a line (in hours) -# mobilization_time: # hrs (time it takes to mobilize the vessel) -# bollardPull: # Bollard pull of the vessel (in metric tons) -# transport_specs: # Placeholder for transport specifications -# transit_speed: # Transit speed of the vessel (in meters per second) -# storage_specs: # Placeholder for storage specifications -# max_cargo: # Maximum cargo capacity of the vessel (in metric tons) -# max_deck_space: # Maximum deck space of the vessel (in square meters) -# max_deck_load: # Maximum deck load of the vessel (in metric tons) -# max_spool_capacity: # Maximum spool capacity of the vessel (in metric tons) -# winch_speed: # Winch speed of the vessel (in meters per minute) -# anchor_loading_speed: # Anchor loading speed of the vessel (in unspecified units) -# storage: # Placeholder for storage specifications -# Mode1: # Placeholder for storage mode 1 -# material: # List of materials in storage mode 1 -# quantity: # List of quantities for each material in storage mode 1 -# Mode2: # Placeholder for storage mode 2 -# material: # List of materials in storage mode 2 -# quantity: # List of quantities for each material in storage mode 2 -# etc. # Additional storage modes can be added as needed - -# AHTS: # Anchor Handling Towing Supply vessel - # name : AHTS - # type : AHTS - # specs: - # vessel_specs: - # pile_fixed_install_time : 2 # hrs (time it takes to install a pile) - # line_fixed_install_time : 4 # hrs (time it takes to install a line, either anchored or shared) - # mobilization_time: 2 # hrs (time it takes to mobilize the vessel) - # bollardPull: 200 # mT - # transport_specs: - # transit_speed: 7.7 # m/s - # storage_specs: - # max_cargo: 2000 # mT - # max_deck_space: 1000 # m2 - # max_deck_load: 2000 # mT - # max_spool_capacity: 1000 # mT - # winch_speed: 11.5 # m/min - # anchor_loading_speed: 5 - # storage: - # Mode1: - # material: ['pile', 'poly', 'chain', 'gear'] - # quantity: [3, 4, 8, 1] - # Mode2: - # material: ['poly', 'clump'] - # quantity: [4, 2] +# This file defines standard vessels aligned to current capabilities & actions +# --- Anchor Handling Tug / Supply Vessel (AHTS / AHV) --- + +AHTS_alpha: +# Offshore Tug/Anchor Handling Tug Supply (AHTS) – Towing floating structures, handling and laying anchors/mooring lines, tensioning and positioning support. + type: AHTS + transport: + transit_speed_mps: 4.7 + Hs_m : 5 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 800 + max_load_t: 1500 + bollard_pull: + max_force_t: 200 + winch: + max_line_pull_t: 150 + brake_load_t: 300 + speed_mpm: 20 + crane: + capacity_t: 50 + hook_height_m: 25 + chain_locker: + volume_m3: 150 + line_reel: + volume_m3: 200 + rope_capacity_m: 5000 + pump_subsea: + power_kW: 75 + pressure_bar: 200 + weight_t: 3 + dimensions_m: [2, 1.5, 1.5] + positioning_system: + accuracy_m: 1.0 + methods: [USBL, INS] + monitoring_system: + metrics: [pressure, flow, tilt] + sampling_rate_hz: 10 + actions: + tow: {} + lay_mooring: {} + mooring_hookup: {} + install_anchor: {} + retrieve_anchor: {} + install_semisub: {} + install_spar: {} + install_tlp: {} + +# --- Multipurpose Support Vessel --- + +MPSV_01: +# Multi-Purpose Support Vessel (MSV) – Flexible vessel used for maintenance, diving, construction, or ROV tasks. Combines features of CSV, DSV and ROVSV. + type: MSV + transport: + transit_speed_mps: 4.7 + Hs_m : 5 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 900 + max_load_t: 1500 + crane: + capacity_t: 150 + hook_height_m: 45 + winch: + max_line_pull_t: 60 + brake_load_t: 120 + speed_mpm: 16 + mooring_work: + line_types: [chain] + stern_roller: true + shark_jaws: true + towing_pin_rating_t: 300 + positioning_system: + accuracy_m: 1.0 + methods: [USBL, INS] + monitoring_system: + metrics: [pressure, tilt] + sampling_rate_hz: 10 + rov: + class: OBSERVATION + depth_rating_m: 3000 + weight_t: 7 + dimensions_m: [3, 2, 2] + actions: + install_anchor: {} + retrieve_anchor: {} + mooring_hookup: {} + lay_mooring: {} + install_wec: {} + monitor_installation: {} + +# --- Construction Support Vessel --- + +CSV_A: +# Construction Support Vessel (CSV) – General-purpose vessel supporting subsea construction, cable lay and light installation. Equipped with cranes, moonpools and ROVs. + type: CSV + transport: + transit_speed_mps: 4.7 + Hs_m : 5 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 1200 + max_load_t: 2000 + crane: + capacity_t: 250 + hook_height_m: 60 + winch: + max_line_pull_t: 75 + brake_load_t: 150 + speed_mpm: 18 + positioning_system: + accuracy_m: 0.5 + methods: [USBL, LBL, INS] + sonar_survey: + types: [MBES, SSS] + resolution_m: 0.05 + monitoring_system: + metrics: [pressure, flow, tilt, torque] + sampling_rate_hz: 20 + pump_surface: + power_kW: 150 + pressure_bar: 200 + weight_t: 8 + dimensions_m: [6, 2.5, 2.5] + pump_subsea: + power_kW: 75 + pressure_bar: 200 + weight_t: 3 + dimensions_m: [2, 1.5, 1.5] + rov: + class: WORK-CLASS + depth_rating_m: 3000 + weight_t: 8 + dimensions_m: [3, 2, 2] + actions: + lay_mooring: {} + mooring_hookup: {} + lay_cable: {} + lay_and_bury_cable: {} + monitor_installation: {} + +# --- ROV Support Vessel --- + +ROVSV_X: +# ROV Support Vessel (ROVSV) – Dedicated to operating and supporting Remotely Operated Vehicles (ROVs) for inspection, survey or intervention. + type: ROVSV + transport: + transit_speed_mps: 6.7 + Hs_m : 5 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 600 + max_load_t: 1000 + crane: + capacity_t: 100 + hook_height_m: 35 + rov: + class: WORK-CLASS + depth_rating_m: 3000 + weight_t: 7 + dimensions_m: [3, 2, 2] + positioning_system: + accuracy_m: 0.5 + methods: [USBL, LBL, DVL, INS] + sonar_survey: + types: [MBES, SSS, SBP] + resolution_m: 0.1 + monitoring_system: + metrics: [tilt, video, torque] + sampling_rate_hz: 25 + actions: + monitor_installation: {} + site_survey: {} + +# --- Diving Support Vessel --- + +DSV_Moon: +# Diving Support Vessel (DSV) – Specifically equipped to support saturation diving operations. Includes diving bells, decompression chambers and dynamic positioning. + type: DSV + transport: + transit_speed_mps: 4.7 + Hs_m : 5 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 800 + max_load_t: 1200 + crane: + capacity_t: 150 + hook_height_m: 40 + positioning_system: + accuracy_m: 0.5 + methods: [USBL, LBL, INS] + monitoring_system: + metrics: [video, depth] + sampling_rate_hz: 30 + actions: + monitor_installation: {} + site_survey: {} + +# --- Heavy Lift Vessel --- + +HL_Giant: +# Heavy Lift Vessel (HL) – Used for transporting and installing very large components, like jackets, substations, or monopiles. Equipped with high-capacity cranes (>3000 t). + type: HL + transport: + transit_speed_mps: 4.7 + Hs_m : 7 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 4000 + max_load_t: 8000 + crane: + capacity_t: 5000 + hook_height_m: 150 + positioning_system: + accuracy_m: 1.0 + methods: [USBL, INS] + monitoring_system: + metrics: [position, tilt] + sampling_rate_hz: 5 + actions: + transport_components: {} + install_wec: {} + install_wtg: {} + +# --- Survey Vessel --- + +SURV_Swath: +# Survey Vessel (SURV) – Seabed mapping and soil characterization, positioning and embedment verification of anchors. Equipped with sonar, USBL/LBL, and profiling equipment. + type: SURV + transport: + transit_speed_mps: 5.0 + Hs_m : 6 + station_keeping: + type: DP1 + capabilities: + deck_space: + area_m2: 200 + max_load_t: 200 + positioning_system: + accuracy_m: 0.3 + methods: [USBL, LBL, INS] + sonar_survey: + types: [MBES, SSS, SBP] + resolution_m: 0.05 + monitoring_system: + metrics: [bathymetry] + sampling_rate_hz: 10 + actions: + site_survey: {} + monitor_installation: {} + +# --- Barge --- + +Barge_squid: +# Barge – non-propelled flat-top vessel used for transporting heavy equipment, components and materials. Requires towing or positioning support from tugs or AHTS vessels. + type: BARGE + transport: + transit_speed_mps: 2 # No self-propulsion + Hs_m: 4.0 # Maximum significant wave height for safe transport + station_keeping: + type: anchor_based # Held in position using anchors and winches + capabilities: + deck_space: + area_m2: 3000 + max_load_t: 10000 + container: + weight_t: 20 + dimensions_m: [15, 3, 3] # LxWxH + crane: + capacity_t: 250 + hook_height_m: 40 + actions: + transport_components: {} + install_anchor: {} + retrieve_anchor: {} + install_wec: {} + +# --- Rock Installation Vessel --- + +ROCK_FallPipe: +# Rock Installation Vessel (ROCK) – Placement of rock for scour protection, anchor stabilization, or seabed leveling. Uses fall-pipe or side-dump systems with high precision at depth. + type: ROCK + transport: + transit_speed_mps: 5.5 + Hs_m : 6 + station_keeping: + type: DP2 + capabilities: + deck_space: + area_m2: 1500 + max_load_t: 4000 + positioning_system: + accuracy_m: 0.3 + methods: [USBL, LBL, INS] + monitoring_system: + metrics: [berm_shape] + sampling_rate_hz: 5 + actions: + backfill_rockdump: {} + site_survey: {} From 636a8a1c8307235ef8b99565f8f7b96d73b1d6ab Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:49:20 -0600 Subject: [PATCH 05/63] Starting Task class and outlining more of the process: - Started Task class in task.py to hold groups of actions. - Cleaned up Action class a bit. - Outlined more steps in Irma.py script. --- famodel/irma/action.py | 126 +++++++++++++++++------------------------ famodel/irma/irma.py | 16 ++++++ famodel/irma/task.py | 88 ++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 74 deletions(-) create mode 100644 famodel/irma/task.py diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 9437ae2e..bd9271f0 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -91,10 +91,12 @@ def __init__(self, actionType, name, **kwargs): self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on + self.type = getFromDict(actionType, 'type', dtype=str) self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished self.duration = getFromDict(actionType, 'duration', default=3) + ''' # Create a dictionary of supported object types (with empty entries) if 'objects' in actionType: #objs = getFromDict(actionType, 'objects', shape=-1, default={}) @@ -147,6 +149,31 @@ def addDependency(self, dep): # could see if already a dependency and raise a warning if so... + + def assignAssets(self, assets): + pass + + def calcDurationAndCost(self): + pass + + def evaluateAsset(self, assets): + '''Check whether an asset can perform the task, and if so calculate + the time and cost associated with using that vessel. + asset : vessel or port object(s) + + ''' + pass + + self.assignAssets(assets) + self.calcDurationAndCost() + + + # can store the cost and duration in self as well + + + + # ----- Below are drafts of methods for use by the engine ----- + def begin(self): '''Take control of all objects''' for vessel in self.vesselList: @@ -166,80 +193,31 @@ def end(self): def timestep(self): '''Advance the simulation of this action forward one step in time.''' - # or maybe this is just - - - -""" -Rough draft ideas back when I imagined subclasses. -Just leaving here in case can be pulled from later. - -class tow(Action): - '''Subclass for towing a floating structure''' - - def __init__(self, object, r_destination): - '''Initialize the tow action, specifying which - structure (Platform type) needs to be towed. + # (this is just documenting an idea for possible future implementation) + # Perform the hourly action of the task ''' - - self.objects.append(object) - - self.r_finish = np.array(r_destination) - - - - - def assign_vessels(self, v1, v2, v3): - - self.vesselList.append(v1) - self.vesselList.append(v2) - self.vesselList.append(v3) - - self.tow_vessel = v1 - self.support_vessels = [v2, v3] - - - def approximate(self): - '''Generate approximate action characteristics for planning purposes. - ''' - - # (estimate based on vessels, etc... - - - def initiate(self): - '''Triggers the beginning of the action''' - - # Record start position of object - self.r_start = np.array(object.r) - - # - - def timestep(self): - - # controller - make sure things are going in right direction... - # (switch mode if need be) - if self.mode == 0 : # gathering vessels - for ves in self.vesselList: - dr = self.r_start - ves.r - ves.setCourse(dr) # sets vessel velocity - - # if all vessels are stopped (at the object), time to move - if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): - self.mode = 1 + if self.type == 'tow': + # controller - make sure things are going in right direction... + # (switch mode if need be) + if self.mode == 0 : # gathering vessels + for ves in self.vesselList: + dr = self.r_start - ves.r + ves.setCourse(dr) # sets vessel velocity - if self.mode == 1: # towing - for ves in self.vesselList: - dr = self.r_finish - ves.r - ves.setCourse(dr) # sets vessel velocity + # if all vessels are stopped (at the object), time to move + if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): + self.mode = 1 + + if self.mode == 1: # towing + for ves in self.vesselList: + dr = self.r_finish - ves.r + ves.setCourse(dr) # sets vessel velocity + + # if all vessels are stopped (at the final location), time to end + if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): + self.mode = 2 - # if all vessels are stopped (at the final location), time to end - if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): - self.mode = 2 - - if self.mode == 2: # finished - self.end() - - # call generic time stepper... - self.timeStep() -""" - \ No newline at end of file + if self.mode == 2: # finished + self.end() + ''' + diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 5b65f9c6..50de5128 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -38,6 +38,7 @@ import networkx as nx from action import Action, increment_name +from task import Task from assets import Vessel, Port @@ -138,6 +139,8 @@ def __init__(self): # Make sure actions refer to supported object types/properties and capabilities for key, act in actionTypes.items(): + act['type'] = key + #if key != act['name']: # raise Exception(f"Action key ({key}) contradicts its name ({act['name']})") @@ -364,6 +367,19 @@ def findCompatibleVessels(self): + # ----- Generate tasks (groups of Actions according to specific strategies) ----- + + + + + # ----- Check tasks for suitable vessels and the associated costs/times ----- + + + + # ----- Call the scheduler ----- + # for timing with weather windows and vessel assignments + + # ----- Run the simulation ----- ''' for t in np.arange(8760): diff --git a/famodel/irma/task.py b/famodel/irma/task.py new file mode 100644 index 00000000..cedbad74 --- /dev/null +++ b/famodel/irma/task.py @@ -0,0 +1,88 @@ +"""Action base class""" + +import numpy as np +import matplotlib.pyplot as plt + +import moorpy as mp +from moorpy.helpers import set_axes_equal +from moorpy import helpers +import yaml +from copy import deepcopy + + +# Import select required helper functions +from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, + getMoorings, getAnchors, getFromDict, cleanDataTypes, + getStaticCables, getCableDesign, m2nm, loadYAML, + configureAdjuster, route_around_anchors) + + + +class Task(): + ''' + A Task is a general representation of a set of marine operations + that follow a predefined sequency/strategy. There can be multiple + tasks that achieve the same end, each providing an alternative strategy. + Each Task consists of a set of Actions with internal dependencies. + + + For now, we'll assume each Task must be port-to-port, + i.e. its vessel(s) must start and end at port over the course of the task. + + + + ''' + + def __init__(self, taskType, name, **kwargs): + '''Create an action object... + It must be given a name. + The remaining parameters should correspond to items in the actionType dict... + + Parameters + ---------- + taskType : dict + Dictionary defining the action type (typically taken from a yaml). + name : string + A name for the action. It may be appended with numbers if there + are duplicate names. + kwargs + Additional arguments may depend on the action type and typically + include a list of FAModel objects that are acted upon, or + a list of dependencies (other action names/objects). + + ''' + + # list of things that will be controlled during this action + self.vesselList = [] # all vessels required for the action + self.objectList = [] # all objects that are acted on + self.actionList = [] # all actions that are carried out in this task + self.dependencies = {} # list of other tasks this one depends on + + self.type = getFromDict(taskType, 'type', dtype=str) + self.name = name + self.status = 0 # 0, waiting; 1=running; 2=finished + + self.duration = 0 # duration must be calculated based on lengths of actions + + # what else do we need to initialize the task? + + # internal graph of the actions within this task? + + + def organizeActions(self, actions): + '''Organizes the actions to be done by this task into the proper order + based on the strategy of this type of task... + ''' + + if self.type == 'parallel_anchor_install': + + pass + # make a graph that reflects this strategy? + + + def calcDuration(self): + '''Calculates the duration of the task based on the durations of the + individual actions and their order of operation.''' + + # Does Rudy have graph-based code that can do this? + \ No newline at end of file From 89f8afd0b5328529393ce05484bca2d5240a8a31 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:03:44 -0600 Subject: [PATCH 06/63] New roles/capabilities approach in Actions: - Updated actions.yaml (half way done) to use a nested roles->capabilities dictionary rather than requirements and capabilities lists. - Actions class now has assets and requirements dicts that correspond to roles/capabilities in the YAML. - Draft outlines of Action methods assignAsset and checkAsset --- famodel/irma/action.py | 93 ++++++++++++++++++++++++++-------- famodel/irma/actions.yaml | 42 ++++++++++----- famodel/irma/capabilities.yaml | 3 ++ famodel/irma/irma.py | 15 ++++-- 4 files changed, 116 insertions(+), 37 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index bd9271f0..df070743 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -87,7 +87,8 @@ def __init__(self, actionType, name, **kwargs): ''' # list of things that will be controlled during this action - self.vesselList = [] # all vessels required for the action + self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action + self.requirements = {} # the capabilities required of each asset role assets (same keys as self.assets) self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on @@ -123,6 +124,7 @@ def __init__(self, actionType, name, **kwargs): supported_objects = list(actionType['objects'].keys()) else: supported_objects = [] + # Add objects to the action's object list as long as they're supported if 'objects' in kwargs: for obj in kwargs['objects']: @@ -131,7 +133,15 @@ def __init__(self, actionType, name, **kwargs): self.objectList.append(obj) else: raise Exception(f"Object type '{objType}' is not in the action's supported list.") - + + # Create placeholders for asset roles based on the "requirements" + if 'roles' in actionType: + for asset, caplist in actionType['roles'].items(): + self.assets[asset] = None # each asset role holds a None value until assigned + self.requirements[asset] = {} + for cap in caplist: + self.requirements[asset][cap] = 0 # fill in each required metric with zero to start with? + # Process dependencies if 'dependencies' in kwargs: @@ -149,22 +159,61 @@ def addDependency(self, dep): # could see if already a dependency and raise a warning if so... + def checkAsset(self, role_name, asset): + '''Checks if a specified asset has sufficient capabilities to fulfil + a specified role in this action. + ''' + + # Make sure role_name is valid for this action + if not role_name in self.assets.keys(): + raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") + + for cap, req in self.requirements['role_name']: + if asset.capabilities[cap] >= req: # <<< this is pseudocode. Needs to look at capability numbers! <<< + pass + # so far so good + else: + return False # at least on capability is not met, so return False + + return True - def assignAssets(self, assets): - pass + + def assignAsset(self, role_name, asset): + '''Assigns a vessel or port to a certain role in the action. + + Parameters + ---------- + role_name : string + Name of the asset role being filled (must be in the action's list) + asset : Vessel or Port object + The asset to be registered with the class. + ''' + + # Make sure role_name is valid for this action + if not role_name in self.assets.keys(): + raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") + + self.assets[role_name] = asset + def calcDurationAndCost(self): pass - def evaluateAsset(self, assets): + + def evaluateAssets(self, assets): '''Check whether an asset can perform the task, and if so calculate - the time and cost associated with using that vessel. - asset : vessel or port object(s) + the time and cost associated with using those assets. + + assets : dict + Dictionary of role_name, asset object pairs for assignment to the action. + Each asset is a vessel or port object. ''' - pass - self.assignAssets(assets) + # Assign each specified asset to its respective role + for akey, aval in assets.items(): + self.assignAsset(akey, aval) + self.calcDurationAndCost() @@ -173,7 +222,7 @@ def evaluateAsset(self, assets): # ----- Below are drafts of methods for use by the engine ----- - + """ def begin(self): '''Take control of all objects''' for vessel in self.vesselList: @@ -188,36 +237,36 @@ def end(self): vessel._detach_from() for object in self.objectList: object._detach_from() - + """ def timestep(self): '''Advance the simulation of this action forward one step in time.''' # (this is just documenting an idea for possible future implementation) # Perform the hourly action of the task - ''' + if self.type == 'tow': # controller - make sure things are going in right direction... # (switch mode if need be) if self.mode == 0 : # gathering vessels - for ves in self.vesselList: - dr = self.r_start - ves.r - ves.setCourse(dr) # sets vessel velocity + ves = self.assets['vessel'] + dr = self.r_start - ves.r + ves.setCourse(dr) # sets vessel velocity - # if all vessels are stopped (at the object), time to move - if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): + # if vessel is stopped (at the object), time to move + if np.linalg.norm(ves.v) == 0: self.mode = 1 if self.mode == 1: # towing - for ves in self.vesselList: - dr = self.r_finish - ves.r - ves.setCourse(dr) # sets vessel velocity + ves = self.assets['vessel'] + dr = self.r_finish - ves.r + ves.setCourse(dr) # sets vessel velocity # if all vessels are stopped (at the final location), time to end - if all([np.linalg.norm(ves.v) == 0 for ves in self.vesselList]): + if np.linalg.norm(ves.v) == 0: self.mode = 2 if self.mode == 2: # finished self.end() - ''' + diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 145db854..509e75f7 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -1,13 +1,17 @@ # This file defines standardized marine operations actions. # Each entry needs numeric values per specific asset in vessels.yaml. # Vessel actions will be checked against capabilities/actions for validation. +# +# Old format: requirements and capabilities +# New format: roles, which lists asset roles, each with associated required capabilities # --- Towing & Transport --- tow: objects: [platform] requirements: [TUG] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + vessel: - deck_space - bollard_pull - winch @@ -19,7 +23,8 @@ tow: transport_components: objects: [component] requirements: [HL, WTIV, CSV] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + carrier: # vessel carrying things - deck_space - crane - positioning_system @@ -32,8 +37,10 @@ transport_components: install_anchor: objects: [anchor] requirements: [AHTS, MSV] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + carrier: # vessl that has been carrying the anchor - deck_space + operator: # vessel that lowers and installs the anchor - winch - bollard_pull - crane @@ -47,8 +54,10 @@ install_anchor: retrieve_anchor: objects: [anchor] requirements: [AHTS, MSV] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + carrier: - deck_space + operator: - winch - bollard_pull - crane @@ -57,25 +66,31 @@ retrieve_anchor: duration_h: 4 Hs_m:: 2.5 description: "Anchor retrieval, including break-out and recovery to deck." - + + load_mooring: objects: [mooring] - requirements: [port, vessel] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + carrier1: [] # the port or vessel where the moorings begin + # (no requirements) + carrier2: # the vessel things will be loaded onto - deck_space - winch - - bollard_pull - - mooring_work - positioning_system + operator: # the entity with the crane (like the port or the new vessel) + - crane duration_h: 2 Hs_m:: 2.5 - description: "Load-out of mooring lines and components from port or vessel onto vessel." + description: "Load-out of mooring lines and components from port or vessel onto vessel." + lay_mooring: objects: [mooring] requirements: [AHTS, CSV] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + carrier: # vessel carrying the mooring - deck_space + operator: # vessel laying the mooring - winch - bollard_pull - mooring_work @@ -84,13 +99,16 @@ lay_mooring: Hs_m:: 2.5 description: "Laying mooring lines, tensioning and connection to anchors and floaters." + mooring_hookup: objects: mooring: [pretension] platform: [wec] requirements: [AHTS, CSV] - capabilities: + roles: # The asset roles involved and the capabilities required of each role + carrier: - deck_space + operator: - winch - bollard_pull - mooring_work diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 4f37eb90..b4be526a 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -2,6 +2,9 @@ # Each entry needs numeric values per specific asset in vessels.yaml. # Vessel actions will be checked against capabilities/actions for validation. +# >>> Units to be converted to standard values, with optional converter script +# for allowing conventional unit inputs. <<< + # --- Vessel (on-board) --- - name: deck_space diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 50de5128..92ddaeeb 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -145,10 +145,12 @@ def __init__(self): # raise Exception(f"Action key ({key}) contradicts its name ({act['name']})") # Check capabilities - if not 'capabilities' in act: - raise Exception(f"Action '{key}' is missing a capabilities list.") + #if 'capabilities' in act: + # raise Exception(f"Action '{key}' is missing a capabilities list.") + + if 'capabilities' in act: - for cap in act['capabilities']: + for cap in act['capabilities']: if not cap in capabilities: raise Exception(f"Action '{key}' capability '{cap}' is not in the global capability list.") @@ -157,6 +159,13 @@ def __init__(self): # if not cap_param in capabilities[cap['name']]: # raise Exception(f"Action '{key}' capability '{cap['name']}' parameter '{cap_param}' is not in the global capability's parameter list.") + if 'roles' in act: # look through capabilities listed under each role + for caps in act['roles'].values(): + for cap in caps: + if not cap in capabilities: + raise Exception(f"Action '{key}' capability '{cap}' is not in the global capability list.") + + # Check objects if not 'objects' in act: raise Exception(f"Action '{key}' is missing an objects list.") From cfbdf4f1d9010a41cf83d6f2d2a26502962198de Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Wed, 20 Aug 2025 17:19:02 -0600 Subject: [PATCH 07/63] Updates to Action.py for checking valid asset-action capability and capability capacity --- famodel/irma/action.py | 156 +++++++++++++++++++++++++++++++------- famodel/irma/actions.yaml | 17 +++++ famodel/irma/irma.py | 4 +- 3 files changed, 149 insertions(+), 28 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index df070743..3b9cd433 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -88,7 +88,7 @@ def __init__(self, actionType, name, **kwargs): # list of things that will be controlled during this action self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action - self.requirements = {} # the capabilities required of each asset role assets (same keys as self.assets) + self.requirements = {} # the capabilities required of each role (same keys as self.assets) self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on @@ -97,6 +97,8 @@ def __init__(self, actionType, name, **kwargs): self.status = 0 # 0, waiting; 1=running; 2=finished self.duration = getFromDict(actionType, 'duration', default=3) + + self.supported_objects = [] # list of FAModel object types supported by the action ''' # Create a dictionary of supported object types (with empty entries) @@ -119,30 +121,29 @@ def __init__(self, actionType, name, **kwargs): # make list of supported object type names if 'objects' in actionType: if isinstance(actionType['objects'], list): - supported_objects = actionType['objects'] + self.supported_objects = actionType['objects'] elif isinstance(actionType['objects'], dict): - supported_objects = list(actionType['objects'].keys()) - else: - supported_objects = [] + self.supported_objects = list(actionType['objects'].keys()) # Add objects to the action's object list as long as they're supported if 'objects' in kwargs: for obj in kwargs['objects']: objType = obj.__class__.__name__.lower() # object class name - if objType in supported_objects: + if objType in self.supported_objects: self.objectList.append(obj) else: raise Exception(f"Object type '{objType}' is not in the action's supported list.") # Create placeholders for asset roles based on the "requirements" if 'roles' in actionType: - for asset, caplist in actionType['roles'].items(): - self.assets[asset] = None # each asset role holds a None value until assigned - self.requirements[asset] = {} + for role, caplist in actionType['roles'].items(): + self.requirements[role] = {key: None for key in caplist} # each role requirment holds a dict of capabilities with values set to None for now for cap in caplist: - self.requirements[asset][cap] = 0 # fill in each required metric with zero to start with? - - + # self.requirements[role][cap] = {} # fill in each required capacity with {'metric': 0.0} + self.requirements[role][cap] = {'area_m2': 1000, 'max_load_t': 1600} # dummy values for now, just larger than MPSV_01 values to trigger failure + + self.assets[role] = None # placeholder for the asset assigned to this role + # Process dependencies if 'dependencies' in kwargs: for dep in kwargs['dependencies']: @@ -154,26 +155,69 @@ def __init__(self, actionType, name, **kwargs): def addDependency(self, dep): - '''Registers other action as a dependency of this one.''' + '''Registers other action as a dependency of this one. + + Inputs + ------ + dep : Action + The action to be added as a dependency. + ''' self.dependencies[dep.name] = dep # could see if already a dependency and raise a warning if so... + + def assignObjects(self, objects): + ''' + Adds a list of objects to the actions objects list, + checking they are valid for the actions supported objects + + Inputs + ------ + objects : list + A list of FAModel objects to be added to the action. + ''' + + for obj in objects: + objType = obj.__class__.__name__.lower() # object class name + if objType in self.supported_objects: + if obj in self.objectList: + print(f"Warning: Object '{obj}' is already in the action's object list.") + self.objectList.append(obj) + else: + raise Exception(f"Object type '{objType}' is not in the action's supported list.") + + + + # def setUpCapability(self): + # # WIP: example of what needs to happen to create a metric + + # # figure out how to assign required metrics to capabilies in the roles based on the objects + # for role, caps in self.requirements.items(): + # for cap, metrics in caps.items(): + # for obj in self.objectList: + # # this is for the deck_space capability + # metrics = {'area_m2': obj.area, 'max_load_t': obj.mass / 1000} # / 1000 to convert kg to T + # metrics.update(obj.get_capability_metrics(cap)) + # pass + def checkAsset(self, role_name, asset): '''Checks if a specified asset has sufficient capabilities to fulfil a specified role in this action. - ''' - - # Make sure role_name is valid for this action - if not role_name in self.assets.keys(): - raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") - - for cap, req in self.requirements['role_name']: - if asset.capabilities[cap] >= req: # <<< this is pseudocode. Needs to look at capability numbers! <<< - pass - # so far so good + ''' + + for capability in self.requirements[role_name].keys(): + + if capability in asset['capabilities'].keys(): # check capability + # does this work if there are no metrics in a capability? This should be possible, as not all capabilities will require a constraint. + for metric in self.requirements[role_name][capability].keys(): # loop over the capacity requirements for the capability (if more than one) + if self.requirements[role_name][capability][metric] > asset['capabilities'][capability][metric]: # check capacity + # TODO: can we throw a message here that explains what metric is violated? + return False # the asset does not have the capacity for this capability + return True + else: - return False # at least on capability is not met, so return False + return False # at least one capability is not met return True @@ -192,12 +236,68 @@ def assignAsset(self, role_name, asset): # Make sure role_name is valid for this action if not role_name in self.assets.keys(): raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") - - self.assets[role_name] = asset + + if self.checkAsset(role_name, asset): + self.assets[role_name] = asset + else: + raise Exception(f"The asset does not have the capabilities for role '{role_name}'.") def calcDurationAndCost(self): - pass + '''The structure here is dependent on actions.yaml. + TODO: finish description + ''' + + print('Calculating duration and cost for action:', self.name) + # print(self.type) + + # --- Towing & Transport --- + if self.type == 'tow': + pass + elif self.type == 'transport_components': + pass + + # --- Mooring & Anchors --- + elif self.type == 'install_anchor': + pass + elif self.type == 'retrieve_anchor': + pass + elif self.type == 'load_mooring': + pass + elif self.type == 'lay_mooring': + pass + elif self.type == 'mooring_hookup': + pass + + # --- Heavy Lift & Installation --- + elif self.type == 'install_wec': + pass + elif self.type == 'install_semisub': + pass + elif self.type == 'install_spar': + pass + elif self.type == 'install_tlp': + pass + elif self.type == 'install_wtg': + pass + + # --- Cable Operations --- + elif self.type == 'lay_cable': + pass + elif self.type == 'retrieve_cable': + pass + elif self.type == 'lay_and_bury_cable': + pass + elif self.type == 'backfill_rockdump': + pass + + # --- Survey & Monitoring --- + elif self.type == 'site_survey': + pass + elif self.type == 'monitor_installation': + pass + else: + raise ValueError(f"Action type '{self.type}' not recognized.") def evaluateAssets(self, assets): @@ -209,6 +309,8 @@ def evaluateAssets(self, assets): Each asset is a vessel or port object. ''' + + # error check that assets is a dict of {role_name, asset dict}, and not just an asset dict? # Assign each specified asset to its respective role for akey, aval in assets.items(): diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 509e75f7..f0b2a3fc 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -5,6 +5,23 @@ # Old format: requirements and capabilities # New format: roles, which lists asset roles, each with associated required capabilities +# The code that models and checks these structures is action.calcDurationAndCost(). Structural changes here will not be reflected in the code unless changes are made there as well + +### Example action ### + +# example_action: +# objects: [] or {} "The FAModel object types that are supported in this action" +# requirements: [] "Asset types" **Unused** +# roles: "the roles that assets need to fill. A way a grouping capabilities so multiple assets can be assigned to an action" +# role1: +# - capability 1 +# - capability 2 +# role2: +# - capability 3 +# duration_h: 0.0 "Duration in hours" +# Hs_m: 0.0 "Wave height constraints in meters" +# description: "A description" + # --- Towing & Transport --- tow: diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 92ddaeeb..1373271b 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -300,7 +300,8 @@ def findCompatibleVessels(self): print('Creating project without RAFT\n') print(os.getcwd()) # create project object - project = Project(file='C:/Code/FAModel/examples/OntologySample200m_1turb.yaml', raft=False) + # project = Project(file='C:/Code/FAModel/examples/OntologySample200m_1turb.yaml', raft=False) # for Windows + project = Project(file='../../examples/OntologySample200m_1turb.yaml', raft=False) # for Mac # create moorpy system of the array, include cables in the system project.getMoorPyArray(cables=1) # plot in 3d, using moorpy system for the mooring and cable plots @@ -329,6 +330,7 @@ def findCompatibleVessels(self): # add and register anchor install action(s) a1 = sc.addAction('install_anchor', 'install_anchor-1', objects=[anchor]) + a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) # register the actions as necessary for the anchor <<< do this for all objects?? anchor.install_dependencies = [a1] From e67b2609f47e471cb7ccd8c608e0c317660bda10 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Thu, 21 Aug 2025 11:31:43 -0600 Subject: [PATCH 08/63] Restore removed error check in action.py --- famodel/irma/action.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 3b9cd433..b27573d7 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -206,6 +206,10 @@ def checkAsset(self, role_name, asset): a specified role in this action. ''' + # Make sure role_name is valid for this action + if not role_name in self.assets.keys(): + raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") + for capability in self.requirements[role_name].keys(): if capability in asset['capabilities'].keys(): # check capability From 19c25916b798969fd4e4db8548d049b6ca327701 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Thu, 21 Aug 2025 14:45:05 -0600 Subject: [PATCH 09/63] Adds function descriptions and improved error handling to action.py --- famodel/irma/action.py | 178 ++++++++++++++++++++++++++++++++++------- famodel/irma/irma.py | 2 +- 2 files changed, 148 insertions(+), 32 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index b27573d7..f3cf85cc 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -32,6 +32,19 @@ def incrementer(text): + ''' + Increments the last integer found in a string. + + Inputs + ------ + text : str + The input string to increment. + + Returns + ------- + str + The incremented string. + ''' split_text = text.split()[::-1] for ind, spl in enumerate(split_text): try: @@ -43,7 +56,19 @@ def incrementer(text): def increment_name(name): - '''Increments an end integer after a dash in a name''' + ''' + Increments an end integer after a dash in a name. + + Inputs + ------ + name : str + The input name string. + + Returns + ------- + str + The incremented name string. + ''' name_parts = name.split(sep='-') # if no numeric suffix yet, add one @@ -72,7 +97,7 @@ def __init__(self, actionType, name, **kwargs): It must be given a name. The remaining parameters should correspond to items in the actionType dict... - Parameters + Inputs ---------- actionType : dict Dictionary defining the action type (typically taken from a yaml). @@ -83,7 +108,10 @@ def __init__(self, actionType, name, **kwargs): Additional arguments may depend on the action type and typically include a list of FAModel objects that are acted upon, or a list of dependencies (other action names/objects). - + + Returns + ------- + None ''' # list of things that will be controlled during this action @@ -139,7 +167,7 @@ def __init__(self, actionType, name, **kwargs): for role, caplist in actionType['roles'].items(): self.requirements[role] = {key: None for key in caplist} # each role requirment holds a dict of capabilities with values set to None for now for cap in caplist: - # self.requirements[role][cap] = {} # fill in each required capacity with {'metric': 0.0} + # self.requirements[role][cap] = {} # fill in each required capacity with empty dict self.requirements[role][cap] = {'area_m2': 1000, 'max_load_t': 1600} # dummy values for now, just larger than MPSV_01 values to trigger failure self.assets[role] = None # placeholder for the asset assigned to this role @@ -155,12 +183,17 @@ def __init__(self, actionType, name, **kwargs): def addDependency(self, dep): - '''Registers other action as a dependency of this one. + ''' + Registers other action as a dependency of this one. Inputs ------ dep : Action The action to be added as a dependency. + + Returns + ------- + None ''' self.dependencies[dep.name] = dep # could see if already a dependency and raise a warning if so... @@ -169,12 +202,16 @@ def addDependency(self, dep): def assignObjects(self, objects): ''' Adds a list of objects to the actions objects list, - checking they are valid for the actions supported objects + checking they are valid for the actions supported objects. Inputs ------ objects : list A list of FAModel objects to be added to the action. + + Returns + ------- + None ''' for obj in objects: @@ -189,6 +226,17 @@ def assignObjects(self, objects): # def setUpCapability(self): + # ''' + # Example of what needs to happen to create a metric. + # + # Inputs + # ------ + # None + # + # Returns + # ------- + # None + # ''' # # WIP: example of what needs to happen to create a metric # # figure out how to assign required metrics to capabilies in the roles based on the objects @@ -204,6 +252,20 @@ def assignObjects(self, objects): def checkAsset(self, role_name, asset): '''Checks if a specified asset has sufficient capabilities to fulfil a specified role in this action. + + Inputs + ------ + role_name : string + The name of the role to check. + asset : dict + The asset to check against the role's requirements. + + Returns + ------- + bool + True if the asset meets the role's requirements, False otherwise. + str + A message providing additional information about the check. ''' # Make sure role_name is valid for this action @@ -212,44 +274,62 @@ def checkAsset(self, role_name, asset): for capability in self.requirements[role_name].keys(): - if capability in asset['capabilities'].keys(): # check capability - # does this work if there are no metrics in a capability? This should be possible, as not all capabilities will require a constraint. + if capability in asset['capabilities'].keys(): # check capability is in asset + + # TODO: does this work if there are no metrics in a capability? This should be possible, as not all capabilities will require a constraint. for metric in self.requirements[role_name][capability].keys(): # loop over the capacity requirements for the capability (if more than one) - if self.requirements[role_name][capability][metric] > asset['capabilities'][capability][metric]: # check capacity - # TODO: can we throw a message here that explains what metric is violated? - return False # the asset does not have the capacity for this capability - return True + + if metric not in asset['capabilities'][capability].keys(): # value error because capabilities are defined in capabilities.yaml. This should only be triggered if something has gone wrong (i.e. overwriting values somewhere) + raise ValueError(f"The '{capability}' capability does not have metric: '{metric}'.") + + if self.requirements[role_name][capability][metric] > asset['capabilities'][capability][metric]: # check requirement is met + return False, f"The asset does not have sufficient '{metric}' for '{capability}' capability in '{role_name}' role of '{self.name}' action." + + return True, 'All capabilities in role met' else: - return False # at least one capability is not met - - return True - + return False, f"The asset does not have the '{capability}' capability for '{role_name}' role of '{self.name}' action." # a capability is not met + def assignAsset(self, role_name, asset): - '''Assigns a vessel or port to a certain role in the action. - - Parameters - ---------- + ''' + Assigns a vessel or port to a certain role in the action. + + Inputs + ------ role_name : string Name of the asset role being filled (must be in the action's list) asset : Vessel or Port object The asset to be registered with the class. + + Returns + ------- + None ''' # Make sure role_name is valid for this action if not role_name in self.assets.keys(): raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") - if self.checkAsset(role_name, asset): + assignable, message = self.checkAsset(role_name, asset) + if assignable: self.assets[role_name] = asset else: - raise Exception(f"The asset does not have the capabilities for role '{role_name}'.") + raise Exception(message) # throw error message def calcDurationAndCost(self): - '''The structure here is dependent on actions.yaml. + ''' + Calculates duration and cost for the action. The structure here is dependent on actions.yaml. TODO: finish description + + Inputs + ------ + None + + Returns + ------- + None ''' print('Calculating duration and cost for action:', self.name) @@ -305,13 +385,19 @@ def calcDurationAndCost(self): def evaluateAssets(self, assets): - '''Check whether an asset can perform the task, and if so calculate + ''' + Check whether an asset can perform the task, and if so calculate the time and cost associated with using those assets. - + + Inputs + ------ assets : dict - Dictionary of role_name, asset object pairs for assignment to the action. - Each asset is a vessel or port object. - + Dictionary of {role_name: asset} pairs for assignment of the + assets to the roles in the action. + + Returns + ------- + None ''' # error check that assets is a dict of {role_name, asset dict}, and not just an asset dict? @@ -330,7 +416,17 @@ def evaluateAssets(self, assets): # ----- Below are drafts of methods for use by the engine ----- """ def begin(self): - '''Take control of all objects''' + ''' + Take control of all objects. + + Inputs + ------ + None + + Returns + ------- + None + ''' for vessel in self.vesselList: vessel._attach_to(self) for object in self.objectList: @@ -338,7 +434,17 @@ def begin(self): def end(self): - '''Release all objects''' + ''' + Release all objects. + + Inputs + ------ + None + + Returns + ------- + None + ''' for vessel in self.vesselList: vessel._detach_from() for object in self.objectList: @@ -346,7 +452,17 @@ def end(self): """ def timestep(self): - '''Advance the simulation of this action forward one step in time.''' + ''' + Advance the simulation of this action forward one step in time. + + Inputs + ------ + None + + Returns + ------- + None + ''' # (this is just documenting an idea for possible future implementation) # Perform the hourly action of the task diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 1373271b..f05158ce 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -330,7 +330,7 @@ def findCompatibleVessels(self): # add and register anchor install action(s) a1 = sc.addAction('install_anchor', 'install_anchor-1', objects=[anchor]) - a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) + a1.evaluateAssets({'operator' : sc.vessels["MPSV_01"]}) # example assignment to test the code. # register the actions as necessary for the anchor <<< do this for all objects?? anchor.install_dependencies = [a1] From 5d4936003df1aca001d92af2f83327a6e8134130 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Thu, 21 Aug 2025 15:09:07 -0600 Subject: [PATCH 10/63] Update on vessels day rates, visualizeActions, and action naming in main irma example: - The __main__ example in irma takes the keyname of the objects and assign it to the name of the action. - irma checks for duplicate action names and flag a warning in registerAction method, - day rates of various vessels obtained from ORBIT and other sources are provided in the vessels.yaml file, - Updating visualizeActions method to give equal axis and showcase action starters with a green colored node. --- famodel/irma/irma.py | 31 +++++++++++++++++++++---------- famodel/irma/vessels.yaml | 15 +++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index f05158ce..6a20641b 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -198,10 +198,11 @@ def registerAction(self, action): # this also handles creation of unique dictionary keys - while action.name in self.actions: # check if there is already a key with the same name + if action.name in self.actions: # check if there is already a key with the same name + raise Warning(f"Action '{action.name}' is already registered.") print(f"Action name '{action.name}' is in the actions list so incrementing it...") action.name = increment_name(action.name) - + # What about handling of dependencies?? <<< done in the action object, # but could check that each is in the list already... for dep in action.dependencies.values(): @@ -275,6 +276,16 @@ def visualizeActions(self): plt.text(pos[last_node][0], pos[last_node][1] - 0.1, f"{total_duration:.2f} hr", fontsize=12, color='red', fontweight='bold', ha='center') else: pass + plt.axis('equal') + + # Color first node (without dependencies) green + i = 0 + for node in G.nodes(): + if G.in_degree(node) == 0: # Check if the node has no incoming edges + nx.draw_networkx_nodes(G, pos, nodelist=[node], node_color='green', node_size=500, label='Action starters' if i==0 else None) + i += 1 + plt.legend() + return G @@ -326,11 +337,11 @@ def findCompatibleVessels(self): # Parse out the install steps required - for anchor in project.anchorList.values(): + for akey, anchor in project.anchorList.items(): # add and register anchor install action(s) - a1 = sc.addAction('install_anchor', 'install_anchor-1', objects=[anchor]) - a1.evaluateAssets({'operator' : sc.vessels["MPSV_01"]}) # example assignment to test the code. + a1 = sc.addAction('install_anchor', f'install_anchor-{akey}', objects=[anchor]) + a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) # register the actions as necessary for the anchor <<< do this for all objects?? anchor.install_dependencies = [a1] @@ -338,18 +349,18 @@ def findCompatibleVessels(self): hookups = [] # list of hookup actions - for mooring in project.mooringList.values(): + for mkey, mooring in project.mooringList.items(): # note origin and destination # --- lay out all the mooring's actions (and their links) # create load vessel action - a2 = sc.addAction('load_mooring', 'load_mooring', objects=[mooring]) + a2 = sc.addAction('load_mooring', f'load_mooring-{mkey}', objects=[mooring]) # create ship out mooring action # create lay mooring action - a3 = sc.addAction('lay_mooring', 'lay_mooring', objects=[mooring], dependencies=[a2]) + a3 = sc.addAction('lay_mooring', f'lay_mooring-{mkey}', objects=[mooring], dependencies=[a2]) sc. addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor # mooring could be attached to anchor here - or could be lowered with anchor!! @@ -357,7 +368,7 @@ def findCompatibleVessels(self): # the action creator can record any dependencies related to actions of the anchor # create hookup action - a4 = sc.addAction('mooring_hookup', 'mooring_hookup', + a4 = sc.addAction('mooring_hookup', f'mooring_hookup-{mkey}', objects=[mooring, mooring.attached_to[1]], dependencies=[a2, a3]) #(r=r, mooring=mooring, platform=platform, depends_on=[a4]) # the action creator can record any dependencies related to actions of the platform @@ -374,7 +385,7 @@ def findCompatibleVessels(self): # ----- Do some graph analysis ----- - sc.visualizeActions() + G = sc.visualizeActions() diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 8ca1e18c..310da41b 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -48,6 +48,7 @@ AHTS_alpha: install_semisub: {} install_spar: {} install_tlp: {} + day_rate: 107187 # USD/day taken from ORBIT: https://github.com/WISDEM/ORBIT/blob/dev/library/vessels/example_ahts_vessel.yaml # --- Multipurpose Support Vessel --- @@ -93,6 +94,7 @@ MPSV_01: lay_mooring: {} install_wec: {} monitor_installation: {} + day_rate: 122699 # USD/day taken from ORBIT (for support vessel: https://github.com/WISDEM/ORBIT/blob/dev/library/vessels/example_support_vessel.yaml) # --- Construction Support Vessel --- @@ -145,7 +147,7 @@ CSV_A: lay_cable: {} lay_and_bury_cable: {} monitor_installation: {} - + day_rate: 122699 # USD/day taken from ORBIT (for support vessel: https://github.com/WISDEM/ORBIT/blob/dev/library/vessels/example_support_vessel.yaml) # --- ROV Support Vessel --- ROVSV_X: @@ -180,7 +182,7 @@ ROVSV_X: actions: monitor_installation: {} site_survey: {} - + day_rate: 52500 # USD/day taken from a Nauticus Robotics post on X: https://x.com/nautrobo/status/1840830080748003551 # --- Diving Support Vessel --- DSV_Moon: @@ -207,7 +209,7 @@ DSV_Moon: actions: monitor_installation: {} site_survey: {} - + day_rate: x # USD/day (research needed) # --- Heavy Lift Vessel --- HL_Giant: @@ -235,7 +237,7 @@ HL_Giant: transport_components: {} install_wec: {} install_wtg: {} - + day_rate: 624612 # USD/day taken from Orbit: https://github.com/WISDEM/ORBIT/blob/dev/library/vessels/example_heavy_lift_vessel.yaml # --- Survey Vessel --- SURV_Swath: @@ -262,7 +264,7 @@ SURV_Swath: actions: site_survey: {} monitor_installation: {} - + day_rate: x # USD/day (research needed) # --- Barge --- Barge_squid: @@ -288,7 +290,7 @@ Barge_squid: install_anchor: {} retrieve_anchor: {} install_wec: {} - + day_rate: 147239 # USD/day taken from Orbit: https://github.com/WISDEM/ORBIT/blob/dev/library/vessels/floating_barge.yaml # --- Rock Installation Vessel --- ROCK_FallPipe: @@ -312,3 +314,4 @@ ROCK_FallPipe: actions: backfill_rockdump: {} site_survey: {} + day_rate: x # USD/day (research needed) \ No newline at end of file From 00e5f129b3d422e3e3bc1e3e3f075ce8c735d0c3 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:32:43 -0600 Subject: [PATCH 11/63] Started function to convert units of vessel capability specs - not working yet --- famodel/irma/irma.py | 42 +++++++++++++++++++++++++++++- famodel/irma/spec_conversions.yaml | 19 ++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 famodel/irma/spec_conversions.yaml diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 6a20641b..0cc17e75 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -87,7 +87,46 @@ def loadYAMLtoDict(info, already_dict=False): #def applyState(): - +def unifyUnits(d): + '''Converts any capability specification/metric in supported non-SI units + to be in SI units. Converts the key names as well.''' + + # >>> not working yet <<< + + + # load conversion data from YAML (eventually may want to store this in a class) + with open('spec_conversions.yaml') as file: + data = yaml.load(file, Loader=yaml.FullLoader) + + keys1 = [] + facts = [] # conversion factors + keys2 = [] + + for line in data: + keys1.append(line[0]) + facts.append(line[1]) + keys2.append(line[2]) + + # >>> dcopy = deepcopy(d) + + for asset in d.values(): # loop through each asset's dict + for capability in asset['capabilities'].values(): + for key, val in capability.items(): # look at each capability metric + try: + i = keys1.index(key) # find if key is on the list to convert + + + if keys2[i] in capability.keys(): + raise Exception(f"Specification '{keys2[i]}' already exists") + + capability[keys2[i]] = val * facts[i] # create a new SI entry + + breakpoint() + + del capability[keys1[i]] # remove the original? + + except: + print('not found') class Scenario(): @@ -105,6 +144,7 @@ def __init__(self): vessels = loadYAMLtoDict('vessels.yaml', already_dict=True) objects = loadYAMLtoDict('objects.yaml', already_dict=True) + #unifyUnits(vessels) # (function doesn't work yet!) <<< # ----- Validate internal cross references ----- diff --git a/famodel/irma/spec_conversions.yaml b/famodel/irma/spec_conversions.yaml new file mode 100644 index 00000000..63f269c3 --- /dev/null +++ b/famodel/irma/spec_conversions.yaml @@ -0,0 +1,19 @@ +# This file specifies optional capability specification keys that can be used +# to support inputs in common industry units rather than SI units. + +# format: key name with common unit, conversation factor from common->SI, fundamental key name (SI unit), +- [ area_sqf , 0.092903 , area ] +- [ max_load_t , 9806.7 , max_load ] +- [ volume_cf , 0.028317 , volume ] +- [ max_line_pull_t , 9806.7 , max_line_pull ] # continuous line pull [t] +- [ brake_load_t , 9806.7 , brake_load ] # static brake holding load [t] +- [ speed_mpm , 0.01667 , speed ] # payout/haul speed [m/min] -> m/s +- [ max_force_t , 9806.7 , max_force ] # bollard pull [t] +- [ capacity_t , 9806.7 , capacity ] # SWL at specified radius [t] +- [ hook_height_ft , 0.3048 , hook_height ] # max hook height [m] +- [ towing_pin_rating_t , 9806.7 , towing_pin_rating ] # rating of towing pins [t] (optional) +- [ power_kW , 1000.0 , power ] # W +- [ pressure_bar , 1e5 , pressure ] # Pa +- [ weight_t , 9806.7 , weight ] # N or should this be mass in kg? +- [ centrifugal_force_kN, 1000.0 , centrifugal_force ] +- [ torque_kNm , 1000.0 , torque ] From f750c4a7e602fd75b086ffa5028b266f4e9249ac Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Thu, 21 Aug 2025 17:37:14 -0600 Subject: [PATCH 12/63] Update tasks: - task is given a list of actions that we can do duration and cost analysis on to clump certain actions together. - irma main file updated to generate a task given actions. --- famodel/irma/irma.py | 18 ++++++++-------- famodel/irma/task.py | 51 +++++++++++++++++++++++++------------------- 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 0cc17e75..22f65ba2 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -284,8 +284,7 @@ def addActionDependencies(self, action, dependencies): if dep in self.actions.values(): action.addDependency(dep) else: - raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") - + raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") def visualizeActions(self): @@ -325,10 +324,8 @@ def visualizeActions(self): nx.draw_networkx_nodes(G, pos, nodelist=[node], node_color='green', node_size=500, label='Action starters' if i==0 else None) i += 1 plt.legend() - return G - - - + return G + def findCompatibleVessels(self): '''Go through actions and identify which vessels have the required capabilities (could be based on capability presence, or quantitative. @@ -421,15 +418,18 @@ def findCompatibleVessels(self): for a in hookups: sc.addActionDependencies(a, [a5]) # make each hookup action dependent on the FOWT being towed out + - + # ----- Generate tasks (groups of Actions according to specific strategies) ----- + + t1 = Task(sc.actions, 'install_mooring_system') + # ----- Do some graph analysis ----- G = sc.visualizeActions() - - # ----- Generate tasks (groups of Actions according to specific strategies) ----- + diff --git a/famodel/irma/task.py b/famodel/irma/task.py index cedbad74..da81fad4 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -8,7 +8,7 @@ from moorpy import helpers import yaml from copy import deepcopy - +import networkx as nx # Import select required helper functions from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, @@ -17,7 +17,6 @@ configureAdjuster, route_around_anchors) - class Task(): ''' A Task is a general representation of a set of marine operations @@ -33,43 +32,36 @@ class Task(): ''' - def __init__(self, taskType, name, **kwargs): + def __init__(self, actionList, name, **kwargs): '''Create an action object... - It must be given a name. - The remaining parameters should correspond to items in the actionType dict... + It must be given a name and a list of actions. + The action list should be by default coherent with actionTypes dictionary. Parameters ---------- - taskType : dict - Dictionary defining the action type (typically taken from a yaml). + actionList : list + A list of all actions that are part of this task. name : string A name for the action. It may be appended with numbers if there are duplicate names. kwargs - Additional arguments may depend on the action type and typically - include a list of FAModel objects that are acted upon, or - a list of dependencies (other action names/objects). + Additional arguments may depend on the task type. ''' - # list of things that will be controlled during this action - self.vesselList = [] # all vessels required for the action - self.objectList = [] # all objects that are acted on - self.actionList = [] # all actions that are carried out in this task - self.dependencies = {} # list of other tasks this one depends on - - self.type = getFromDict(taskType, 'type', dtype=str) + self.actionList = actionList # all actions that are carried out in this task self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished self.duration = 0 # duration must be calculated based on lengths of actions - + self.cost = 0 # cost must be calculated based on the cost of individual actions. + # what else do we need to initialize the task? - # internal graph of the actions within this task? + # internal graph of the actions within this task. + self.G = self.getTaskGraph() - - def organizeActions(self, actions): + def organizeActions(self): '''Organizes the actions to be done by this task into the proper order based on the strategy of this type of task... ''' @@ -85,4 +77,19 @@ def calcDuration(self): individual actions and their order of operation.''' # Does Rudy have graph-based code that can do this? - \ No newline at end of file + + def getTaskGraph(self): + '''Generate a graph of the action dependencies. + ''' + + # Create the graph + G = nx.DiGraph() + for item, data in self.actionList.items(): + for dep in data.dependencies: + G.add_edge(dep, item, duration=data.duration) # Store duration as edge attribute + + # Compute longest path & total duration + longest_path = nx.dag_longest_path(G, weight='duration') + longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs + total_duration = sum(self.actionList[node].duration for node in longest_path) + return G \ No newline at end of file From f133c514ecdfc6a7ae651fc9ce88cc54b1b84809 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Thu, 21 Aug 2025 17:56:03 -0600 Subject: [PATCH 13/63] Markdown plaintext in function descriptions for `action.py`. Adds `assignAsset` which is called by `assignAssets` for each `{role : asset}` pair. Sets up structure for checking different asset combinations for action. Removes requirements from `actions.yaml`. --- famodel/irma/action.py | 205 ++++++++++++++++++++++++-------------- famodel/irma/actions.yaml | 19 +--- 2 files changed, 130 insertions(+), 94 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index f3cf85cc..be4d053a 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -37,12 +37,12 @@ def incrementer(text): Inputs ------ - text : str + `text` : `str` The input string to increment. Returns ------- - str + `str` The incremented string. ''' split_text = text.split()[::-1] @@ -61,12 +61,12 @@ def increment_name(name): Inputs ------ - name : str + `name` : `str` The input name string. Returns ------- - str + `str` The incremented name string. ''' name_parts = name.split(sep='-') @@ -99,19 +99,19 @@ def __init__(self, actionType, name, **kwargs): Inputs ---------- - actionType : dict + `actionType` : `dict` Dictionary defining the action type (typically taken from a yaml). - name : string + `name` : `string` A name for the action. It may be appended with numbers if there are duplicate names. - kwargs + `kwargs` Additional arguments may depend on the action type and typically include a list of FAModel objects that are acted upon, or a list of dependencies (other action names/objects). Returns ------- - None + `None` ''' # list of things that will be controlled during this action @@ -124,7 +124,8 @@ def __init__(self, actionType, name, **kwargs): self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished - self.duration = getFromDict(actionType, 'duration', default=3) + self.duration = getFromDict(actionType, 'duration', default=0) # this will be overwritten by calcDurationAndCost. TODO: or should it overwrite any duration calculation? + self.cost = 0 # this will be overwritten by calcDurationAndCost self.supported_objects = [] # list of FAModel object types supported by the action @@ -178,8 +179,6 @@ def __init__(self, actionType, name, **kwargs): self.dependencies[dep.name] = dep # Process some optional kwargs depending on the action type - - def addDependency(self, dep): @@ -188,12 +187,12 @@ def addDependency(self, dep): Inputs ------ - dep : Action + `dep` : `Action` The action to be added as a dependency. Returns ------- - None + `None` ''' self.dependencies[dep.name] = dep # could see if already a dependency and raise a warning if so... @@ -206,12 +205,12 @@ def assignObjects(self, objects): Inputs ------ - objects : list + `objects` : `list` A list of FAModel objects to be added to the action. Returns ------- - None + `None` ''' for obj in objects: @@ -231,11 +230,11 @@ def assignObjects(self, objects): # # Inputs # ------ - # None + # `None` # # Returns # ------- - # None + # `None` # ''' # # WIP: example of what needs to happen to create a metric @@ -250,27 +249,28 @@ def assignObjects(self, objects): def checkAsset(self, role_name, asset): - '''Checks if a specified asset has sufficient capabilities to fulfil + ''' + Checks if a specified asset has sufficient capabilities to fulfil a specified role in this action. Inputs ------ - role_name : string + `role_name` : `string` The name of the role to check. - asset : dict + `asset` : `dict` The asset to check against the role's requirements. Returns ------- - bool + `bool` True if the asset meets the role's requirements, False otherwise. - str + `str` A message providing additional information about the check. ''' # Make sure role_name is valid for this action if not role_name in self.assets.keys(): - raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") + raise Exception(f"The specified role '{role_name}' is not a named in this action.") for capability in self.requirements[role_name].keys(): @@ -289,52 +289,27 @@ def checkAsset(self, role_name, asset): else: return False, f"The asset does not have the '{capability}' capability for '{role_name}' role of '{self.name}' action." # a capability is not met - - - def assignAsset(self, role_name, asset): - ''' - Assigns a vessel or port to a certain role in the action. - - Inputs - ------ - role_name : string - Name of the asset role being filled (must be in the action's list) - asset : Vessel or Port object - The asset to be registered with the class. - - Returns - ------- - None - ''' - - # Make sure role_name is valid for this action - if not role_name in self.assets.keys(): - raise Exception(f"The specified role name '{role_name}' is not a named asset role in this action.") - - assignable, message = self.checkAsset(role_name, asset) - if assignable: - self.assets[role_name] = asset - else: - raise Exception(message) # throw error message def calcDurationAndCost(self): ''' - Calculates duration and cost for the action. The structure here is dependent on actions.yaml. + Calculates duration and cost for the action. The structure here is dependent on `actions.yaml`. TODO: finish description Inputs ------ - None + `None` Returns ------- - None + `None` ''' - print('Calculating duration and cost for action:', self.name) - # print(self.type) - + # Check that all roles in the action are filled + for role_name in self.requirements.keys(): + if self.assets[role_name] is None: + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") + # --- Towing & Transport --- if self.type == 'tow': pass @@ -382,35 +357,113 @@ def calcDurationAndCost(self): pass else: raise ValueError(f"Action type '{self.type}' not recognized.") - - + + return self.duration, self.cost + + def evaluateAssets(self, assets): ''' - Check whether an asset can perform the task, and if so calculate - the time and cost associated with using those assets. + Checks assets for all the roles in the action. This calls `checkAsset()` + for each role/asset pair and then calculates the duration and + cost for the action as if the assets were assigned. Does not assign + the asset(s) to the action. WARNING: this function will clear the values + (but not keys) in `self.assets`. Inputs ------ - assets : dict + `assets` : `dict` Dictionary of {role_name: asset} pairs for assignment of the assets to the roles in the action. Returns ------- - None + `cost` : `float` + Estimated cost of using the asset. + `duration` : `float` + Estimated duration of the action when performed by asset. ''' - - # error check that assets is a dict of {role_name, asset dict}, and not just an asset dict? - # Assign each specified asset to its respective role - for akey, aval in assets.items(): - self.assignAsset(akey, aval) - - self.calcDurationAndCost() + # Check each specified asset for its respective role + for role_name, asset in assets.items(): + assignable, message = self.checkAsset(role_name, asset) + if assignable: + self.assets[role_name] = asset # Assignment required for calcDurationAndCost(), will be cleared later + else: + print('INFO: '+message+' Action cannot be completed by provided asset list.') + return -1, -1 # return negative values to indicate incompatibility. Loop is terminated becasue assets not compatible for roles. + # Check that all roles in the action are filled + for role_name in self.requirements.keys(): + if self.assets[role_name] is None: + + for role_name in assets.keys(): # Clear the assets dictionary + assets[role_name] = None + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? + + + duration, cost = self.calcDurationAndCost() + + for role_name in assets.keys(): # Clear the assets dictionary + assets[role_name] = None + + return duration, cost # values returned here rather than set because will be used to check compatibility and not set properties of action + + + def assignAsset(self, role_name, asset): + ''' + Checks if asset can be assigned to an action. + If yes, assigns asset to role in the action. + + Inputs + ------ + `role_name` : `str` + The name of the role to which the asset will be assigned. + `asset` : `dict` + The asset to be assigned to the role. + + Returns + ------- + `None` + ''' + # Make sure role_name is valid for this action + if not role_name in self.assets.keys(): + raise Exception(f"The specified role name '{role_name}' is not in this action.") + + assignable, message = self.checkAsset(role_name, asset) + if assignable: + self.assets[role_name] = asset + else: + raise Exception(message) # throw error message + + def assignAssets(self, assets): + ''' + Assigns assets to all the roles in the action. This calls + `assignAsset()` for each role/asset pair and then calculates the + duration and cost for the action. Similar to `evaluateAssets()` + however here assets are assigned and duration and cost are + set after evaluation. + + Inputs + ------ + `assets` : `dict` + Dictionary of {role_name: asset} pairs for assignment of the + assets to the roles in the action. + + Returns + ------- + `None` + ''' - # can store the cost and duration in self as well + # Assign each specified asset to its respective role + for role_name, asset in assets.items(): + self.assignAsset(role_name, asset) + # Check that all roles in the action are filled + for role_name in self.requirements.keys(): + if self.assets[role_name] is None: + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? + + self.calcDurationAndCost() # ----- Below are drafts of methods for use by the engine ----- @@ -421,11 +474,11 @@ def begin(self): Inputs ------ - None + `None` Returns ------- - None + `None` ''' for vessel in self.vesselList: vessel._attach_to(self) @@ -439,11 +492,11 @@ def end(self): Inputs ------ - None + `None` Returns ------- - None + `None` ''' for vessel in self.vesselList: vessel._detach_from() @@ -457,11 +510,11 @@ def timestep(self): Inputs ------ - None + `None` Returns ------- - None + `None` ''' # (this is just documenting an idea for possible future implementation) diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index f0b2a3fc..416ddbe3 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -5,7 +5,7 @@ # Old format: requirements and capabilities # New format: roles, which lists asset roles, each with associated required capabilities -# The code that models and checks these structures is action.calcDurationAndCost(). Structural changes here will not be reflected in the code unless changes are made there as well +# The code that models and checks these actions is action.calcDurationAndCost(). Structural changes here will not be reflected in the code unless changes are made there as well ### Example action ### @@ -26,7 +26,6 @@ tow: objects: [platform] - requirements: [TUG] roles: # The asset roles involved and the capabilities required of each role vessel: - deck_space @@ -39,7 +38,6 @@ tow: transport_components: objects: [component] - requirements: [HL, WTIV, CSV] roles: # The asset roles involved and the capabilities required of each role carrier: # vessel carrying things - deck_space @@ -53,7 +51,6 @@ transport_components: install_anchor: objects: [anchor] - requirements: [AHTS, MSV] roles: # The asset roles involved and the capabilities required of each role carrier: # vessl that has been carrying the anchor - deck_space @@ -70,7 +67,6 @@ install_anchor: retrieve_anchor: objects: [anchor] - requirements: [AHTS, MSV] roles: # The asset roles involved and the capabilities required of each role carrier: - deck_space @@ -103,7 +99,6 @@ load_mooring: lay_mooring: objects: [mooring] - requirements: [AHTS, CSV] roles: # The asset roles involved and the capabilities required of each role carrier: # vessel carrying the mooring - deck_space @@ -121,7 +116,6 @@ mooring_hookup: objects: mooring: [pretension] platform: [wec] - requirements: [AHTS, CSV] roles: # The asset roles involved and the capabilities required of each role carrier: - deck_space @@ -139,7 +133,6 @@ mooring_hookup: install_wec: objects: [wec] - requirements: [HL, MSV] capabilities: - deck_space - crane @@ -152,7 +145,6 @@ install_wec: install_semisub: objects: [platform_semisub] - requirements: [AHTS, MSV, ROVSV] capabilities: - deck_space - bollard_pull @@ -170,7 +162,6 @@ install_semisub: install_spar: objects: [platform_spar] - requirements: [AHTS, MSV, ROVSV] capabilities: - deck_space - bollard_pull @@ -187,7 +178,6 @@ install_spar: install_tlp: objects: [platform_tlp] - requirements: [AHTS, MSV, ROVSV] capabilities: - deck_space - bollard_pull @@ -204,7 +194,6 @@ install_tlp: install_wtg: objects: [wtg] - requirements: [WTIV] capabilities: - deck_space - crane @@ -218,7 +207,6 @@ install_wtg: lay_cable: objects: [cable] - requirements: [CSV, SURV] capabilities: - deck_space - positioning_system @@ -231,7 +219,6 @@ lay_cable: retrieve_cable: objects: [cable] - requirements: [CSV, SURV] capabilities: - deck_space - positioning_system @@ -244,7 +231,6 @@ retrieve_cable: # Lay and bury in a single pass using a plough lay_and_bury_cable: objects: [cable] - requirements: [CSV] capabilities: - deck_space - positioning_system @@ -259,7 +245,6 @@ lay_and_bury_cable: # Backfill trench or stabilize cable route using rock placement backfill_rockdump: objects: [cable] - requirements: [ROCK, SURV] capabilities: - deck_space - positioning_system @@ -274,7 +259,6 @@ backfill_rockdump: site_survey: objects: [site] - requirements: [SURV] capabilities: - positioning_system - sonar_survey @@ -285,7 +269,6 @@ site_survey: monitor_installation: objects: [anchor, mooring, platform, cable] - requirements: [SURV, ROVSV] capabilities: - positioning_system - monitoring_system From ab008c00dda66fc86b71469c825546a60cce3a7b Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Thu, 21 Aug 2025 18:04:18 -0600 Subject: [PATCH 14/63] Add checks for assets are assigned to roles that already have been assigned in`action.py` --- famodel/irma/action.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index be4d053a..036d35cc 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -271,6 +271,9 @@ def checkAsset(self, role_name, asset): # Make sure role_name is valid for this action if not role_name in self.assets.keys(): raise Exception(f"The specified role '{role_name}' is not a named in this action.") + + if self.assets[role_name] is not None: + return False, f"Role '{role_name}' is already filled in action '{self.name}'." for capability in self.requirements[role_name].keys(): @@ -429,6 +432,9 @@ def assignAsset(self, role_name, asset): if not role_name in self.assets.keys(): raise Exception(f"The specified role name '{role_name}' is not in this action.") + if self.assets[role_name] is not None: + raise Exception(f"Role '{role_name}' is already filled in action '{self.name}'.") + assignable, message = self.checkAsset(role_name, asset) if assignable: self.assets[role_name] = asset From 0e1a64064b008f12a4500679d518d210f046cacf Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Tue, 26 Aug 2025 16:45:56 -0600 Subject: [PATCH 15/63] Remove default values from actions and capabilities yamls --- famodel/irma/actions.yaml | 72 +++++++++--------- famodel/irma/capabilities.yaml | 135 +++++++++++++++++---------------- 2 files changed, 105 insertions(+), 102 deletions(-) diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 416ddbe3..b1454040 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -32,8 +32,8 @@ tow: - bollard_pull - winch - positioning_system - duration_h: 5 - Hs_m: 3.0 + duration_h + Hs_m description: "Towing floating structures (e.g., floaters, barges) to site; includes station-keeping." transport_components: @@ -43,8 +43,8 @@ transport_components: - deck_space - crane - positioning_system - duration_h: 12 - Hs_m:: 2.5 + duration_h + Hs_m description: "Transport of large components such as towers, nacelles, blades, or jackets." # --- Mooring & Anchors --- @@ -61,8 +61,8 @@ install_anchor: - pump_subsea # pump_surface, drilling_machine, torque_machine - positioning_system - monitoring_system - duration_h: 5 - Hs_m:: 2.5 + duration_h + Hs_m description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." retrieve_anchor: @@ -76,8 +76,8 @@ retrieve_anchor: - crane - pump_subsea - positioning_system - duration_h: 4 - Hs_m:: 2.5 + duration_h + Hs_m description: "Anchor retrieval, including break-out and recovery to deck." @@ -92,8 +92,8 @@ load_mooring: - positioning_system operator: # the entity with the crane (like the port or the new vessel) - crane - duration_h: 2 - Hs_m:: 2.5 + duration_h + Hs_m description: "Load-out of mooring lines and components from port or vessel onto vessel." @@ -107,8 +107,8 @@ lay_mooring: - bollard_pull - mooring_work - positioning_system - duration_h: 6 - Hs_m:: 2.5 + duration_h + Hs_m description: "Laying mooring lines, tensioning and connection to anchors and floaters." @@ -125,8 +125,8 @@ mooring_hookup: - mooring_work - positioning_system - monitoring_system - duration_h: 2 - Hs_m:: 2.5 + duration_h + Hs_m description: "Hook-up of mooring lines to floating platforms, including pretensioning." # --- Heavy Lift & Installation --- @@ -139,8 +139,8 @@ install_wec: - positioning_system - monitoring_system - rov - duration_h: 20 - Hs_m:: 2.0 + duration_h + Hs_m description: "Lifting, placement and securement of wave energy converters (WECs) onto moorings, including alignment, connection of power/data umbilicals and verification via ROV." install_semisub: @@ -156,8 +156,8 @@ install_semisub: - sonar_survey - pump_surface - mooring_work - duration_h: 36 - Hs_m:: 2.5 + duration_h + Hs_m description: "Wet tow arrival, station-keeping, ballasting/trim, mooring hookup and pretensioning, ROV verification and umbilical connections as needed." install_spar: @@ -172,8 +172,8 @@ install_spar: - sonar_survey - pump_surface - mooring_work - duration_h: 48 - Hs_m:: 2.0 + duration_h + Hs_m description: "Arrival and upending via controlled ballasting, station-keeping, fairlead/messenger handling, mooring hookup and pretensioning with ROV confirmation. Heavy-lift support may be used during port integration." install_tlp: @@ -188,8 +188,8 @@ install_tlp: - rov - sonar_survey - mooring_work - duration_h: 60 - Hs_m:: 2.0 + duration_h + Hs_m description: "Tendon porch alignment, tendon hookup, sequential tensioning to target pretension, verification of offsets/RAOs and ROV checks." install_wtg: @@ -199,8 +199,8 @@ install_wtg: - crane - positioning_system - monitoring_system - duration_h: 24 - Hs_m:: 2.0 + duration_h + Hs_m description: "Installation of wind turbine generator including tower, nacelle and blades." # --- Cable Operations --- @@ -213,8 +213,8 @@ lay_cable: - monitoring_system - cable_reel - sonar_survey - duration_h: 24 - Hs_m:: 2.5 + duration_h + Hs_m description: "Laying static/dynamic power cables, including burial where required." retrieve_cable: @@ -224,8 +224,8 @@ retrieve_cable: - positioning_system - monitoring_system - cable_reel - duration_h: 12 - Hs_m:: 2.5 + duration_h + Hs_m description: "Cable recovery operations, including cutting, grappling and retrieval." # Lay and bury in a single pass using a plough @@ -238,8 +238,8 @@ lay_and_bury_cable: - cable_reel - cable_plough - sonar_survey - duration_h: 30 - Hs_m: 2.5 + duration_h + Hs_m description: "Simultaneous lay and plough burial; continuous QA via positioning + MBES/SSS, with post-pass verification." # Backfill trench or stabilize cable route using rock placement @@ -251,8 +251,8 @@ backfill_rockdump: - monitoring_system - sonar_survey - rock_placement - duration_h: 16 - Hs_m: 2.5 + duration_h + Hs_m description: "Localized rock placement to stabilize exposed cables, infill trenches or provide scour protection. Includes real-time positioning and sonar verification of rock placement." # --- Survey & Monitoring --- @@ -263,8 +263,8 @@ site_survey: - positioning_system - sonar_survey - monitoring_system - duration_h: 48 - Hs_m: 3.0 + duration_h + Hs_m description: "Pre-installation site survey including bathymetry, sub-bottom profiling and positioning." monitor_installation: @@ -273,6 +273,6 @@ monitor_installation: - positioning_system - monitoring_system - rov - duration_h: 12 - Hs_m: 3.0 + duration_h + Hs_m description: "Real-time monitoring of installation operations using ROV and sensor packages." diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index b4be526a..43edd0d4 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -2,6 +2,9 @@ # Each entry needs numeric values per specific asset in vessels.yaml. # Vessel actions will be checked against capabilities/actions for validation. +# The code that calculates the values for these capabilities is action.getMetrics(). +# Changes here won't be reflected in Irma unless the action.getMetrics() code is also updated. + # >>> Units to be converted to standard values, with optional converter script # for allowing conventional unit inputs. <<< @@ -10,158 +13,158 @@ - name: deck_space # description: Clear usable deck area and allowable load # fields: - area_m2: 1000 # usable area [m2] - max_load_t: 2000 # allowable deck load [t] + area_m2 # usable area [m2] + max_load_t # allowable deck load [t] - name: chain_locker # description: Chain storage capacity # fields: - volume_m3: 50 # storage volume [m3] + volume_m3 # storage volume [m3] - name: line_reel # description: Chain/rope storage on drum or carousel # fields: - volume_m3: 20 # storage volume [m3] - rope_capacity_m: 200 # total rope length storage [m] + volume_m3 # storage volume [m3] + rope_capacity_m # total rope length storage [m] - - name: cable_reel + - name: cable_reels # description: Cable storage on drum or carousel # fields: - volume_m3: 20 # storage volume [m3] - rope_capacity_m: 200 # total rope length stowable [m] + volume_m3 # storage volume [m3] + cable_capacity_m # total cable length stowable [m] - name: winch # description: Deck winch pulling capability # fields: - max_line_pull_t: 200 # continuous line pull [t] - brake_load_t: 500 # static brake holding load [t] - speed_mpm: 25 # payout/haul speed [m/min] + max_line_pull_t # continuous line pull [t] + brake_load_t # static brake holding load [t] + speed_mpm # payout/haul speed [m/min] - name: bollard_pull # description: Towing/holding force capability # fields: - max_force_t: 2000 # bollard pull [t] + max_force_t # bollard pull [t] - name: crane # description: Main crane lifting capability # fields: - capacity_t: 500 # SWL at specified radius [t] - hook_height_m: 80 # max hook height [m] + capacity_t # SWL at specified radius [t] + hook_height_m # max hook height [m] - name: station_keeping # description: Vessel station keeping capability (dynamic positioning or anchor-based) # fields: - type: DP2 # e.g., DP0, DP1, DP2, DP3, anchor_based + typev # e.g., DP0, DP1, DP2, DP3, anchor_based - name: mooring_work # description: Suitability for anchor/mooring operations # fields: - line_types: [chain] # e.g., [chain, ropes...] - stern_roller: true # presence of stern roller (optional) - shark_jaws: true # presence of chain stoppers/jaws (optional) - towing_pin_rating_t: 300 # rating of towing pins [t] (optional) + line_typesvvv # e.g., [chain, ropes...] + stern_roller # presence of stern roller (optional) + shark_jaws # presence of chain stoppers/jaws (optional) + towing_pin_rating_t # rating of towing pins [t] (optional) # --- Equipment (portable) --- - name: pump_surface # description: Surface-connected suction pump # fields: - power_kW: 0 - pressure_bar: 0 - weight_t: 0 - dimensions_m: [4, 3, 3] # LxWxH + power_kW + pressure_bar + weight_t + dimensions_m # LxWxH - name: pump_subsea # description: Subsea suction pump (electric/hydraulic) # fields: - power_kW: 0 - pressure_bar: 0 - weight_t: 0 - dimensions_m: [2, 2, 3] # LxWxH + power_kW + pressure_bar + weight_t + dimensions_m # LxWxH - name: pump_grout # description: Grout mixing and pumping unit # fields: - power_kW: 0 - flow_rate_m3hr: 0 - pressure_bar: 0 - weight_t: 0 - dimensions_m: [2, 2, 2] # LxWxH + power_kW + flow_rate_m3hr + pressure_bar + weight_t + dimensions_m # LxWxH - name: hydraulic_hammer # description: Impact hammer for pile driving # fields: - power_kW: 0 - energy_per_blow_kJ: 0 - weight_t: 0 - dimensions_m: [12, 2, 2] # LxWxH + power_kW + energy_per_blow_kJ + weight_t + dimensions_m # LxWxH - name: vibro_hammer # description: Vibratory hammer # fields: - power_kW: 0 - centrifugal_force_kN: 0 - weight_t: 0 - dimensions_m: [12, 3, 3] # LxWxH + power_kW + centrifugal_force_kN + weight_t + dimensions_m # LxWxH - name: drilling_machine # description: Drilling/rotary socket machine # fields: - power_kW: 0 - weight_t: 0 - dimensions_m: [10, 2, 2] # LxWxH + power_kW + weight_t + dimensions_m # LxWxH - name: torque_machine # description: High-torque rotation unit # fields: - power_kW: 0 - torque_kNm: 0 - weight_t: 0 - dimensions_m: [0, 0, 0] # LxWxH + power_kW + torque_kNm + weight_t + dimensions_m # LxWxH - name: cable_plough # description: # fields: - power_kW: 0 - weight_t: 0 - dimensions_m: [0, 0, 0] # LxWxH + power_kW + weight_t + dimensions_m # LxWxH - name: rock_placement # description: System for controlled placement of rock for trench backfill, scour protection, and seabed stabilization. # fields: - placement_method: fall_pipe # e.g., fall_pipe, side_dump, grab - max_depth_m: 0 # maximum operational water depth - accuracy_m: 0 # placement accuracy on seabed - rock_size_range_mm: [50, 200] # min and max rock/gravel size + placement_method # e.g., fall_pipe, side_dump, grab + max_depth_m # maximum operational water depth + accuracy_m # placement accuracy on seabed + rock_size_range_mm # min and max rock/gravel size - name: container # description: Control/sensors container for power pack and monitoring # fields: - weight_t: 20 - dimensions_m: [0, 0, 0] # LxWxH + weight_t + dimensions_m # LxWxH - name: rov # description: Remotely Operated Vehicle # fields: - class: OBSERVATION # e.g., OBSERVATION, LIGHT, WORK-CLASS - depth_rating_m: 0 - weight_t: 0 - dimensions_m: [0, 0, 0] # LxWxH + class # e.g., OBSERVATION, LIGHT, WORK-CLASS + depth_rating_m + weight_t + dimensions_m # LxWxH - name: positioning_system # description: Seabed placement/positioning aids # fields: - accuracy_m: 0 - methods: [USBL] # e.g., [USBL, LBL, DVL, INS] + accuracy_m + methods # e.g., [USBL, LBL, DVL, INS] - name: monitoring_system # description: Installation performance monitoring # fields: - metrics: [tilt] # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] - sampling_rate_hz: 0 + metrics # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] + sampling_rate_hz - name: sonar_survey # description: Sonar systems for survey and verification # fields: - types: [SSS] # e.g., [MBES, SSS, SBP] - resolution_m: 0 + types # e.g., [MBES, SSS, SBP] + resolution_m From 0d1e1f7ff8b96a2fef11f9a5279b88c6be471fc9 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Tue, 26 Aug 2025 16:46:56 -0600 Subject: [PATCH 16/63] Add getMetrics function to action.py. Started filling in hard coded calculations for capability requirements and action cost and duration. --- famodel/irma/action.py | 771 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 724 insertions(+), 47 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 036d35cc..958d26aa 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -146,7 +146,13 @@ def __init__(self, actionType, name, **kwargs): raise Exception(f"Object type '{objType}' is not in the action's supported list.") ''' - # Process objects to be acted upon + # Create placeholders for asset roles based on the "requirements" + if 'roles' in actionType: + for role, caplist in actionType['roles'].items(): + self.requirements[role] = {key: {} for key in caplist} # each role requirment holds a dict of capabilities with each capability containing a dict of metrics and values, metrics dict set to empty for now. + self.assets[role] = None # placeholder for the asset assigned to this role + + # Process objects to be acted upon. NOTE: must occur after requirements and assets placeholders have been assigned. # make list of supported object type names if 'objects' in actionType: if isinstance(actionType['objects'], list): @@ -156,22 +162,7 @@ def __init__(self, actionType, name, **kwargs): # Add objects to the action's object list as long as they're supported if 'objects' in kwargs: - for obj in kwargs['objects']: - objType = obj.__class__.__name__.lower() # object class name - if objType in self.supported_objects: - self.objectList.append(obj) - else: - raise Exception(f"Object type '{objType}' is not in the action's supported list.") - - # Create placeholders for asset roles based on the "requirements" - if 'roles' in actionType: - for role, caplist in actionType['roles'].items(): - self.requirements[role] = {key: None for key in caplist} # each role requirment holds a dict of capabilities with values set to None for now - for cap in caplist: - # self.requirements[role][cap] = {} # fill in each required capacity with empty dict - self.requirements[role][cap] = {'area_m2': 1000, 'max_load_t': 1600} # dummy values for now, just larger than MPSV_01 values to trigger failure - - self.assets[role] = None # placeholder for the asset assigned to this role + self.assignObjects(kwargs['objects']) # Process dependencies if 'dependencies' in kwargs: @@ -198,10 +189,680 @@ def addDependency(self, dep): # could see if already a dependency and raise a warning if so... + def getMetrics(self, cap, met, obj): + ''' + Retrieves the minimum metric(s) for a given capability required to act on target object. + A metric is the number(s) associated with a capability. A capability is what an action + role requires and an asset has. + + These minimum metrics are assigned to capabilities in the action's role in `assignObjects`. + + Inputs + ------ + `cap` : `str` + The capability for which the metric is to be retrieved. + `met` : `dict` + The metrics dictionary containing any existing metrics for the capability. + `obj` : FAModel object + The target object on which the capability is to be acted upon. + + Returns + ------- + `metrics` : `dict` + The metrics and values for the specified capability and object. + + ''' + + metrics = met # metrics dict with following form: {metric_1 : required_value_1, ...}. met is assigned here in case values have already been assigned + objType = obj.__class__.__name__.lower() + + """ + Note to devs: + This function contains hard-coded evaluations of all the possible combinations of capabilities and objects. + The intent is we generate the minimum required of a given to work with the object. An + example would be minimum bollard pull required to tow out a platform. The capabilities (and their metrics) + are from capabilities.yaml and the objects are from objects.yaml. There is a decent ammount of assumptions + made here so it is important to document sources where possible. + + Some good preliminary work on this is in https://github.com/FloatingArrayDesign/FAModel/blob/IOandM_development/famodel/installation/03_step1_materialItems.py + + ### Code Explanation ### + This function has the following structure + + ``` + if cap == : + # some comments + + if objType == 'mooring': + metric_value = calc based on obj + elif objType == 'platform': + metric_value = calc based on obj + elif objType == 'anchor': + metric_value = calc based on obj + elif objType == 'component': + metric_value = calc based on obj + elif objType == 'wec': + metric_value = calc based on obj + elif objType == 'platform_semisub': + metric_value = calc based on obj + elif objType == 'platform_spar': + metric_value = calc based on obj + elif objType == 'platform_tlp': + metric_value = calc based on obj + elif objType == 'wtg': + metric_value = calc based on obj + elif objType == 'cable': + metric_value = calc based on obj + elif objType == 'site': + metric_value = calc based on obj + else: + metric_value = -1 + + # Assign the capabilties metrics (keep existing metrics already in dict if larger than calc'ed value) + metrics[] = metric_value if metric_value > metrics.get() else metrics.get() + ``` + + Some of the logic for checking object types can be omitted if it doesnt make sense. For example, the chain_locker capability + only needs to be checked against the Mooring object. The comment `# object logic checked` shows that the logic in that capability + has been thought through. + + A metric_value of -1 indicates the object is not compatible with the capability. This is indicated by a warning printed at the end. + + A completed example of what this can look like is the line_reel capability. + """ + + + if cap == 'deck_space': + # logic for deck_space capability (platforms and sites not compatible) + # TODO: how do we account for an action like load_mooring (which has two roles, + # representing vessels to be loaded). The combined deck space of the carriers + # should be the required deck space for the action. Right now I believe it is + # set up that only one asset can fulfill the capability minimum. + + # object logic checked + if objType == 'mooring': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + metrics['area_m2'] = None if None > metrics.get('area_m2') else metrics.get('area_m2') + metrics['max_load_t'] = None if None > metrics.get('max_load_t') else metrics.get('max_load_t') + + elif cap == 'chain_locker': + # logic for chain_locker capability (only mooring objects compatible) + # object logic checked + vol = 0 + length = 0 + if objType == 'mooring': + for i, sec in enumerate(obj.dd['sections']): # add up the volume and length of all chain in the object + if sec['type']['chain']: + diam = sec['type']['d_nom'] # diameter [m] + vol += 0.0 # TODO: calculate chain_locker volume from sec['L'] and diam. Can we just use volumetric diam here? + length += sec['L'] # length [m] + + else: + vol = -1 + + # Assign the capabilties metrics + metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') + + elif cap == 'line_reel': + # logic for line_reel capability (only mooring objects compatible) + # object logic checked, complete + vol = 0 + length = 0 + if objType == 'mooring': + for i, sec in enumerate(obj.dd['sections']): # add up the volume and length of all non_chain line in the object + if not sec['type']['chain']: # any line type thats not chain + vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] + length += sec['L'] # length [m] + + else: + vol = -1 + length = -1 + + # Assign the capabilties metrics + metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') + metrics['rope_capacity_m'] = length if length > metrics.get('rope_capacity_m') else metrics.get('rope_capacity_m') + + elif cap == 'cable_reel': + # logic for cable_reel capability (only cable objects compatible) + # object logic checked + vol = 0 + length = 0 + if objType == 'cable': + for cable in cables: # TODO: figure out this iteration + if cable is cable and not other thing in cables object: # TODO figure out how to only check cables, not j-tubes or any other parts + vol += cable['L'] * np.pi * (cable['type']['d_nom'] / 2) ** 2 + length += cable['L'] # length [m] + else: + vol = -1 + length = -1 + + # Assign the capabilties metrics + metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') + metrics['cable_capacity_m'] = length if length > metrics.get('cable_capacity_m') else metrics.get('cable_capacity_m') + + elif cap == 'winch': + # logic for winch capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['max_line_pull_t'] = None if None > metrics.get('max_line_pull_t') else metrics.get('max_line_pull_t') + metrics['brake_load_t'] = None if None > metrics.get('brake_load_t') else metrics.get('brake_load_t') + metrics['speed_mpm'] = None if None > metrics.get('speed_mpm') else metrics.get('speed_mpm') + + elif cap == 'bollard_pull': + # per calwave install report (section 7.2): bollard pull can be described as function of vessel speed and load + + # logic for bollard_pull capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['max_force_t'] = None if None > metrics.get('max_force_t') else metrics.get('max_force_t') + + elif cap == 'crane': + # logic for deck_space capability (platforms and sites not compatible) + # object logic checked + if objType == 'mooring': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + metrics['capacity_t'] = None if None > metrics.get('capacity_t') else metrics.get('capacity_t') + metrics['hook_height_m'] = None if None > metrics.get('hook_height_m') else metrics.get('hook_height_m') + + elif cap == 'station_keeping': + # logic for station_keeping capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['typev'] = None if None > metrics.get('typev') else metrics.get('typev') + + elif cap == 'mooring_work': + # logic for mooring_work capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['line_typesvvv'] = None if None > metrics.get('line_typesvvv') else metrics.get('line_typesvvv') + metrics['stern_roller'] = None if None > metrics.get('stern_roller') else metrics.get('stern_roller') + metrics['shark_jaws'] = None if None > metrics.get('shark_jaws') else metrics.get('shark_jaws') + metrics['towing_pin_rating_t'] = None if None > metrics.get('towing_pin_rating_t') else metrics.get('towing_pin_rating_t') + + elif cap == 'pump_surface': + # logic for pump_surface capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'pump_subsea': + # logic for pump_subsea capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'pump_grout': + # logic for pump_grout capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['flow_rate_m3hr'] = None if None > metrics.get('flow_rate_m3hr') else metrics.get('flow_rate_m3hr') + metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'hydraulic_hammer': + # logic for hydraulic_hammer capability (only platform, anchor, and site objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': # for fixed bottom installations + pass + elif objType == 'site': # if site conditions impact hydraulic hammering + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['energy_per_blow_kJ'] = None if None > metrics.get('energy_per_blow_kJ') else metrics.get('energy_per_blow_kJ') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'vibro_hammer': + # logic for vibro_hammer capability (only platform, anchor, and site objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': # for fixed bottom installations + pass + elif objType == 'site': # if site conditions impact vibro hammering + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['centrifugal_force_kN'] = None if None > metrics.get('centrifugal_force_kN') else metrics.get('centrifugal_force_kN') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'drilling_machine': + # logic for drilling_machine capability (only platform, anchor, cable, and site objects compatible) + # Considering drilling both for export cables, interarray, and anchor/fixed platform install + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'cable': + pass + elif objType == 'site': # if site conditions impact drilling + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'torque_machine': + # logic for torque_machine capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['torque_kNm'] = None if None > metrics.get('torque_kNm') else metrics.get('torque_kNm') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'cable_plough': + # logic for cable_plough capability (only cable and site objects compatible) + # object logic checked + if objType == 'cable': + pass + elif objType == 'site': # if site conditions impact cable installation + pass + else: + pass + + # Assign the capabilties metrics + metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'rock_placement': + # logic for rock_placement capability (only platform, anchor, cable, and site objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['placement_method'] = None if None > metrics.get('placement_method') else metrics.get('placement_method') + metrics['max_depth_m'] = None if None > metrics.get('max_depth_m') else metrics.get('max_depth_m') + metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') + metrics['rock_size_range_mm'] = None if None > metrics.get('rock_size_range_mm') else metrics.get('rock_size_range_mm') + + elif cap == 'container': + # logic for container capability (only wec, wtg, and cable objects compatible) + # object logic checked + if objType == 'wec': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'rov': + # logic for rov capability (all compatible) + # object logic checked + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['class'] = None if None > metrics.get('class') else metrics.get('class') + metrics['depth_rating_m'] = None if None > metrics.get('depth_rating_m') else metrics.get('depth_rating_m') + metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'positioning_system': + # logic for positioning_system capability (only platform, anchor, cable, and site objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'cable': + pass + elif objType == 'site': # if positioning aids are impacted by site conditions + pass + else: + pass + + # Assign the capabilties metrics + metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') + metrics['methods'] = None if None > metrics.get('methods') else metrics.get('methods') + + elif cap == 'monitoring_system': + # logic for monitoring_system capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'wec': + pass + elif objType == 'platform_semisub': + pass + elif objType == 'platform_spar': + pass + elif objType == 'platform_tlp': + pass + elif objType == 'wtg': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['metrics'] = None if None > metrics.get('metrics') else metrics.get('metrics') + metrics['sampling_rate_hz'] = None if None > metrics.get('sampling_rate_hz') else metrics.get('sampling_rate_hz') + + elif cap == 'sonar_survey': + # logic for sonar_survey capability (only anchor, cable, and site objects compatible) + # object logic checked + if objType == 'anchor': + pass + elif objType == 'cable': + pass + elif objType == 'site': + pass + else: + pass + + # Assign the capabilties metrics + metrics['types'] = None if None > metrics.get('types') else metrics.get('types') + metrics['resolution_m'] = None if None > metrics.get('resolution_m') else metrics.get('resolution_m') + + else: + raise Exception(f"Unsupported capability '{cap}'.") + + for met in metrics.keys(): + if metrics[met] == -1: + print(f"WARNING: No metrics assigned for '{met}' metric in '{cap}' capability based on object type '{objType}'.") + + + return metrics # return the dict of metrics and required values for the capability + + def assignObjects(self, objects): ''' - Adds a list of objects to the actions objects list, - checking they are valid for the actions supported objects. + Adds a list of objects to the actions objects list and + calculates the required capability metrics, checking objects + are valid for the actions supported objects. + + The minimum capability metrics are used by when checking for + compatibility and assinging assets to the action in `assignAsset`. + Thus this function should only be called in the intialization + process of an action. Inputs ------ @@ -214,38 +875,24 @@ def assignObjects(self, objects): ''' for obj in objects: + + # Check compatibility, set capability metrics based on object, and assign object to action + objType = obj.__class__.__name__.lower() # object class name - if objType in self.supported_objects: - if obj in self.objectList: - print(f"Warning: Object '{obj}' is already in the action's object list.") - self.objectList.append(obj) - else: + if objType not in self.supported_objects: raise Exception(f"Object type '{objType}' is not in the action's supported list.") + else: + if obj in self.objectList: + print(f"Warning: Object '{obj}' is already in the action's object list. Capabilities will be overwritten.") + # Set capability requirements based on object + for role, caplist in self.requirements.items(): + for cap in caplist: + metrics = self.getMetrics(cap, caplist[cap], obj) # pass in the metrics dict for the cap and the obj + self.requirements[role][cap] = metrics # assign metric of capability cap based on value required by obj - # def setUpCapability(self): - # ''' - # Example of what needs to happen to create a metric. - # - # Inputs - # ------ - # `None` - # - # Returns - # ------- - # `None` - # ''' - # # WIP: example of what needs to happen to create a metric - - # # figure out how to assign required metrics to capabilies in the roles based on the objects - # for role, caps in self.requirements.items(): - # for cap, metrics in caps.items(): - # for obj in self.objectList: - # # this is for the deck_space capability - # metrics = {'area_m2': obj.area, 'max_load_t': obj.mass / 1000} # / 1000 to convert kg to T - # metrics.update(obj.get_capability_metrics(cap)) - # pass + self.objectList.append(obj) def checkAsset(self, role_name, asset): @@ -312,6 +959,22 @@ def calcDurationAndCost(self): for role_name in self.requirements.keys(): if self.assets[role_name] is None: raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") + + # Initialize cost and duration + self.cost = 0.0 # [$] + self.duration = 0.0 # [days] + + """ + Note to devs: + The code here calculates the cost and duration of an action. Each action in the actions.yaml has a hardcoded 'model' + here that is used to evaluate the action based on the assets assigned to it. + + This is where a majority of assumptions about the action's behavior are made, so it is key to cite references behind + any abnormal approaches. + + Some good preliminary work on this is in https://github.com/FloatingArrayDesign/FAModel/blob/IOandM_development/famodel/installation/ + and in assets.py + """ # --- Towing & Transport --- if self.type == 'tow': @@ -325,7 +988,21 @@ def calcDurationAndCost(self): elif self.type == 'retrieve_anchor': pass elif self.type == 'load_mooring': - pass + + # Example model assuming line will be winched on to vessel. This can be changed if not most accurate + duration_min = 0 + for obj in self.objectList: + if obj.__class__.__name__.lower() == 'mooring': + for i, sec in enumerate(obj.dd['sections']): # add up the length of all sections in the mooring + duration_min += sec['L'] / self.assets['carrier2']['winch']['speed_mpm'] # duration [minutes] + + self.duration += duration_min / 60 / 24 # convert minutes to days + self.cost += self.duration * self.assets['carrier2']['day_rate'] # cost [$] + + # check for deck space availability, if carrier 1 met transition to carrier 2. + + # think through operator costs, carrier 1 costs. + elif self.type == 'lay_mooring': pass elif self.type == 'mooring_hookup': From b71e4ecaabf25ed09b9f4010e384f28f6274cc03 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Wed, 27 Aug 2025 14:19:36 -0600 Subject: [PATCH 17/63] Change Irma objects to be FAModel objects --- famodel/irma/action.py | 180 ++++++-------------------------------- famodel/irma/objects.yaml | 16 +--- 2 files changed, 31 insertions(+), 165 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 958d26aa..15b8cefa 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -241,20 +241,10 @@ def getMetrics(self, cap, met, obj): metric_value = calc based on obj elif objType == 'component': metric_value = calc based on obj - elif objType == 'wec': - metric_value = calc based on obj - elif objType == 'platform_semisub': - metric_value = calc based on obj - elif objType == 'platform_spar': - metric_value = calc based on obj - elif objType == 'platform_tlp': - metric_value = calc based on obj - elif objType == 'wtg': + elif objType == 'turbine': metric_value = calc based on obj elif objType == 'cable': metric_value = calc based on obj - elif objType == 'site': - metric_value = calc based on obj else: metric_value = -1 @@ -282,13 +272,13 @@ def getMetrics(self, cap, met, obj): # object logic checked if objType == 'mooring': pass + elif objType == 'platform': + pass elif objType == 'anchor': pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass @@ -364,20 +354,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -398,20 +378,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -419,17 +389,17 @@ def getMetrics(self, cap, met, obj): metrics['max_force_t'] = None if None > metrics.get('max_force_t') else metrics.get('max_force_t') elif cap == 'crane': - # logic for deck_space capability (platforms and sites not compatible) + # logic for deck_space capability (all compatible) # object logic checked if objType == 'mooring': pass + elif objType == 'platform': + pass elif objType == 'anchor': pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass @@ -450,20 +420,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -480,20 +440,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -513,20 +463,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -546,20 +486,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -579,20 +509,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -604,14 +524,12 @@ def getMetrics(self, cap, met, obj): metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'hydraulic_hammer': - # logic for hydraulic_hammer capability (only platform, anchor, and site objects compatible) + # logic for hydraulic_hammer capability (only platform and anchor objects compatible) # object logic checked if objType == 'platform': pass elif objType == 'anchor': # for fixed bottom installations pass - elif objType == 'site': # if site conditions impact hydraulic hammering - pass else: pass @@ -622,14 +540,12 @@ def getMetrics(self, cap, met, obj): metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'vibro_hammer': - # logic for vibro_hammer capability (only platform, anchor, and site objects compatible) + # logic for vibro_hammer capability (only platform and anchor objects compatible) # object logic checked if objType == 'platform': pass elif objType == 'anchor': # for fixed bottom installations pass - elif objType == 'site': # if site conditions impact vibro hammering - pass else: pass @@ -640,7 +556,7 @@ def getMetrics(self, cap, met, obj): metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'drilling_machine': - # logic for drilling_machine capability (only platform, anchor, cable, and site objects compatible) + # logic for drilling_machine capability (only platform, anchor, and cable objects compatible) # Considering drilling both for export cables, interarray, and anchor/fixed platform install # object logic checked if objType == 'platform': @@ -649,8 +565,6 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'cable': pass - elif objType == 'site': # if site conditions impact drilling - pass else: pass @@ -669,20 +583,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -693,12 +597,10 @@ def getMetrics(self, cap, met, obj): metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'cable_plough': - # logic for cable_plough capability (only cable and site objects compatible) + # logic for cable_plough capability (only cable objects compatible) # object logic checked if objType == 'cable': pass - elif objType == 'site': # if site conditions impact cable installation - pass else: pass @@ -708,7 +610,7 @@ def getMetrics(self, cap, met, obj): metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'rock_placement': - # logic for rock_placement capability (only platform, anchor, cable, and site objects compatible) + # logic for rock_placement capability (only platform, anchor, and cable objects compatible) # object logic checked if objType == 'platform': pass @@ -716,8 +618,6 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -728,11 +628,11 @@ def getMetrics(self, cap, met, obj): metrics['rock_size_range_mm'] = None if None > metrics.get('rock_size_range_mm') else metrics.get('rock_size_range_mm') elif cap == 'container': - # logic for container capability (only wec, wtg, and cable objects compatible) + # logic for container capability (only platform, turbine, and cable objects compatible) # object logic checked if objType == 'wec': pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass @@ -754,20 +654,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -778,7 +668,7 @@ def getMetrics(self, cap, met, obj): metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'positioning_system': - # logic for positioning_system capability (only platform, anchor, cable, and site objects compatible) + # logic for positioning_system capability (only platform, anchor, and cable objects compatible) # object logic checked if objType == 'platform': pass @@ -786,8 +676,6 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'cable': pass - elif objType == 'site': # if positioning aids are impacted by site conditions - pass else: pass @@ -805,20 +693,10 @@ def getMetrics(self, cap, met, obj): pass elif objType == 'component': pass - elif objType == 'wec': - pass - elif objType == 'platform_semisub': - pass - elif objType == 'platform_spar': - pass - elif objType == 'platform_tlp': - pass - elif objType == 'wtg': + elif objType == 'turbine': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -827,14 +705,12 @@ def getMetrics(self, cap, met, obj): metrics['sampling_rate_hz'] = None if None > metrics.get('sampling_rate_hz') else metrics.get('sampling_rate_hz') elif cap == 'sonar_survey': - # logic for sonar_survey capability (only anchor, cable, and site objects compatible) + # logic for sonar_survey capability (only anchor and cable objects compatible) # object logic checked if objType == 'anchor': pass elif objType == 'cable': pass - elif objType == 'site': - pass else: pass @@ -1017,7 +893,7 @@ def calcDurationAndCost(self): pass elif self.type == 'install_tlp': pass - elif self.type == 'install_wtg': + elif self.type == 'install_turbine': pass # --- Cable Operations --- diff --git a/famodel/irma/objects.yaml b/famodel/irma/objects.yaml index eb15906b..d361b8ed 100644 --- a/famodel/irma/objects.yaml +++ b/famodel/irma/objects.yaml @@ -6,7 +6,7 @@ mooring: # object name - pretension - weight -platform: +platform: # can be wec - mass - draft - wec @@ -18,20 +18,10 @@ anchor: component: - mass - length - -wec: - -platform_semisub: - -platform_spar: - -platform_tlp: - -wtg: + +turbine: cable: - -site: #mooring: # install sequence: From 5434acd64aa34c76d9ed98d315acab5e91145394 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Fri, 29 Aug 2025 13:31:55 -0600 Subject: [PATCH 18/63] Fix YAMLs to be consistent with FAModel objects --- famodel/irma/actions.yaml | 98 ++++++++++++----------- famodel/irma/capabilities.yaml | 138 ++++++++++++++++----------------- 2 files changed, 117 insertions(+), 119 deletions(-) diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index b1454040..6fdd6e67 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -32,8 +32,8 @@ tow: - bollard_pull - winch - positioning_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Towing floating structures (e.g., floaters, barges) to site; includes station-keeping." transport_components: @@ -43,14 +43,14 @@ transport_components: - deck_space - crane - positioning_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Transport of large components such as towers, nacelles, blades, or jackets." # --- Mooring & Anchors --- install_anchor: - objects: [anchor] + objects: [anchor, component] roles: # The asset roles involved and the capabilities required of each role carrier: # vessl that has been carrying the anchor - deck_space @@ -61,12 +61,12 @@ install_anchor: - pump_subsea # pump_surface, drilling_machine, torque_machine - positioning_system - monitoring_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." retrieve_anchor: - objects: [anchor] + objects: [anchor, component] roles: # The asset roles involved and the capabilities required of each role carrier: - deck_space @@ -76,13 +76,13 @@ retrieve_anchor: - crane - pump_subsea - positioning_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Anchor retrieval, including break-out and recovery to deck." load_mooring: - objects: [mooring] + objects: [mooring, component] roles: # The asset roles involved and the capabilities required of each role carrier1: [] # the port or vessel where the moorings begin # (no requirements) @@ -92,13 +92,13 @@ load_mooring: - positioning_system operator: # the entity with the crane (like the port or the new vessel) - crane - duration_h - Hs_m + duration_h: + Hs_m: description: "Load-out of mooring lines and components from port or vessel onto vessel." lay_mooring: - objects: [mooring] + objects: [mooring, component] roles: # The asset roles involved and the capabilities required of each role carrier: # vessel carrying the mooring - deck_space @@ -107,15 +107,13 @@ lay_mooring: - bollard_pull - mooring_work - positioning_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Laying mooring lines, tensioning and connection to anchors and floaters." mooring_hookup: - objects: - mooring: [pretension] - platform: [wec] + objects: [mooring, component, platform] roles: # The asset roles involved and the capabilities required of each role carrier: - deck_space @@ -125,26 +123,26 @@ mooring_hookup: - mooring_work - positioning_system - monitoring_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Hook-up of mooring lines to floating platforms, including pretensioning." # --- Heavy Lift & Installation --- install_wec: - objects: [wec] + objects: [platform] capabilities: - deck_space - crane - positioning_system - monitoring_system - rov - duration_h - Hs_m + duration_h: + Hs_m: description: "Lifting, placement and securement of wave energy converters (WECs) onto moorings, including alignment, connection of power/data umbilicals and verification via ROV." install_semisub: - objects: [platform_semisub] + objects: [platform] capabilities: - deck_space - bollard_pull @@ -156,12 +154,12 @@ install_semisub: - sonar_survey - pump_surface - mooring_work - duration_h - Hs_m + duration_h: + Hs_m: description: "Wet tow arrival, station-keeping, ballasting/trim, mooring hookup and pretensioning, ROV verification and umbilical connections as needed." install_spar: - objects: [platform_spar] + objects: [platform] capabilities: - deck_space - bollard_pull @@ -172,12 +170,12 @@ install_spar: - sonar_survey - pump_surface - mooring_work - duration_h - Hs_m + duration_h: + Hs_m: description: "Arrival and upending via controlled ballasting, station-keeping, fairlead/messenger handling, mooring hookup and pretensioning with ROV confirmation. Heavy-lift support may be used during port integration." install_tlp: - objects: [platform_tlp] + objects: [platform] capabilities: - deck_space - bollard_pull @@ -188,19 +186,19 @@ install_tlp: - rov - sonar_survey - mooring_work - duration_h - Hs_m + duration_h: + Hs_m: description: "Tendon porch alignment, tendon hookup, sequential tensioning to target pretension, verification of offsets/RAOs and ROV checks." install_wtg: - objects: [wtg] + objects: [turbine] capabilities: - deck_space - crane - positioning_system - monitoring_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Installation of wind turbine generator including tower, nacelle and blades." # --- Cable Operations --- @@ -213,8 +211,8 @@ lay_cable: - monitoring_system - cable_reel - sonar_survey - duration_h - Hs_m + duration_h: + Hs_m: description: "Laying static/dynamic power cables, including burial where required." retrieve_cable: @@ -224,8 +222,8 @@ retrieve_cable: - positioning_system - monitoring_system - cable_reel - duration_h - Hs_m + duration_h: + Hs_m: description: "Cable recovery operations, including cutting, grappling and retrieval." # Lay and bury in a single pass using a plough @@ -238,8 +236,8 @@ lay_and_bury_cable: - cable_reel - cable_plough - sonar_survey - duration_h - Hs_m + duration_h: + Hs_m: description: "Simultaneous lay and plough burial; continuous QA via positioning + MBES/SSS, with post-pass verification." # Backfill trench or stabilize cable route using rock placement @@ -251,28 +249,28 @@ backfill_rockdump: - monitoring_system - sonar_survey - rock_placement - duration_h - Hs_m + duration_h: + Hs_m: description: "Localized rock placement to stabilize exposed cables, infill trenches or provide scour protection. Includes real-time positioning and sonar verification of rock placement." # --- Survey & Monitoring --- site_survey: - objects: [site] + objects: [] capabilities: - positioning_system - sonar_survey - monitoring_system - duration_h - Hs_m + duration_h: + Hs_m: description: "Pre-installation site survey including bathymetry, sub-bottom profiling and positioning." monitor_installation: - objects: [anchor, mooring, platform, cable] + objects: [anchor, mooring, component, platform, cable] capabilities: - positioning_system - monitoring_system - rov - duration_h - Hs_m + duration_h: + Hs_m: description: "Real-time monitoring of installation operations using ROV and sensor packages." diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 43edd0d4..15cf8547 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -13,158 +13,158 @@ - name: deck_space # description: Clear usable deck area and allowable load # fields: - area_m2 # usable area [m2] - max_load_t # allowable deck load [t] + area_m2: # usable area [m2] + max_load_t: # allowable deck load [t] - name: chain_locker # description: Chain storage capacity # fields: - volume_m3 # storage volume [m3] + volume_m3: # storage volume [m3] - name: line_reel # description: Chain/rope storage on drum or carousel # fields: - volume_m3 # storage volume [m3] - rope_capacity_m # total rope length storage [m] + volume_m3: # storage volume [m3] + rope_capacity_m: # total rope length storage [m] - - name: cable_reels + - name: cable_reel # description: Cable storage on drum or carousel # fields: - volume_m3 # storage volume [m3] - cable_capacity_m # total cable length stowable [m] - + volume_m3: # storage volume [m3] + cable_capacity_m: # total cable length stowable [m] + - name: winch # description: Deck winch pulling capability # fields: - max_line_pull_t # continuous line pull [t] - brake_load_t # static brake holding load [t] - speed_mpm # payout/haul speed [m/min] + max_line_pull_t: # continuous line pull [t] + brake_load_t: # static brake holding load [t] + speed_mpm: # payout/haul speed [m/min] - name: bollard_pull # description: Towing/holding force capability # fields: - max_force_t # bollard pull [t] + max_force_t: # bollard pull [t] - name: crane # description: Main crane lifting capability # fields: - capacity_t # SWL at specified radius [t] - hook_height_m # max hook height [m] + capacity_t: # SWL at specified radius [t] + hook_height_m: # max hook height [m] - name: station_keeping # description: Vessel station keeping capability (dynamic positioning or anchor-based) # fields: - typev # e.g., DP0, DP1, DP2, DP3, anchor_based + type: # e.g., DP0, DP1, DP2, DP3, anchor_based - name: mooring_work # description: Suitability for anchor/mooring operations # fields: - line_typesvvv # e.g., [chain, ropes...] - stern_roller # presence of stern roller (optional) - shark_jaws # presence of chain stoppers/jaws (optional) - towing_pin_rating_t # rating of towing pins [t] (optional) + line_types: # e.g., [chain, ropes...] + stern_roller: # presence of stern roller (optional) + shark_jaws: # presence of chain stoppers/jaws (optional) + towing_pin_rating_t: # rating of towing pins [t] (optional) # --- Equipment (portable) --- - name: pump_surface # description: Surface-connected suction pump # fields: - power_kW - pressure_bar - weight_t - dimensions_m # LxWxH + power_kW: + pressure_bar: + weight_t: + dimensions_m: # LxWxH - name: pump_subsea # description: Subsea suction pump (electric/hydraulic) # fields: - power_kW - pressure_bar - weight_t - dimensions_m # LxWxH + power_kW: + pressure_bar: + weight_t: + dimensions_m: # LxWxH - name: pump_grout # description: Grout mixing and pumping unit # fields: - power_kW - flow_rate_m3hr - pressure_bar - weight_t - dimensions_m # LxWxH + power_kW: + flow_rate_m3hr: + pressure_bar: + weight_t: + dimensions_m: # LxWxH - name: hydraulic_hammer # description: Impact hammer for pile driving # fields: - power_kW - energy_per_blow_kJ - weight_t - dimensions_m # LxWxH + power_kW: + energy_per_blow_kJ: + weight_t: + dimensions_m: # LxWxH - name: vibro_hammer # description: Vibratory hammer # fields: - power_kW - centrifugal_force_kN - weight_t - dimensions_m # LxWxH + power_kW: + centrifugal_force_kN: + weight_t: + dimensions_m: # LxWxH - name: drilling_machine # description: Drilling/rotary socket machine # fields: - power_kW - weight_t - dimensions_m # LxWxH + power_kW: + weight_t: + dimensions_m: # LxWxH - name: torque_machine # description: High-torque rotation unit # fields: - power_kW - torque_kNm - weight_t - dimensions_m # LxWxH - + power_kW: + torque_kNm: + weight_t: + dimensions_m: # LxWxH + - name: cable_plough # description: # fields: - power_kW - weight_t - dimensions_m # LxWxH - + power_kW: + weight_t: + dimensions_m: # LxWxH + - name: rock_placement # description: System for controlled placement of rock for trench backfill, scour protection, and seabed stabilization. # fields: - placement_method # e.g., fall_pipe, side_dump, grab - max_depth_m # maximum operational water depth - accuracy_m # placement accuracy on seabed - rock_size_range_mm # min and max rock/gravel size + placement_method: # e.g., fall_pipe, side_dump, grab + max_depth_m: # maximum operational water depth + accuracy_m: # placement accuracy on seabed + rock_size_range_mm: # min and max rock/gravel size - name: container # description: Control/sensors container for power pack and monitoring # fields: - weight_t - dimensions_m # LxWxH + weight_t: + dimensions_m: # LxWxH - name: rov # description: Remotely Operated Vehicle # fields: - class # e.g., OBSERVATION, LIGHT, WORK-CLASS - depth_rating_m - weight_t - dimensions_m # LxWxH + class: # e.g., OBSERVATION, LIGHT, WORK-CLASS + depth_rating_m: + weight_t: + dimensions_m: # LxWxH - name: positioning_system # description: Seabed placement/positioning aids # fields: - accuracy_m - methods # e.g., [USBL, LBL, DVL, INS] + accuracy_m: + methods: # e.g., [USBL, LBL, DVL, INS] - name: monitoring_system # description: Installation performance monitoring # fields: - metrics # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] - sampling_rate_hz + metrics: # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] + sampling_rate_hz: - name: sonar_survey # description: Sonar systems for survey and verification # fields: - types # e.g., [MBES, SSS, SBP] - resolution_m + types: # e.g., [MBES, SSS, SBP] + resolution_m: From 7f184959d9051068d48e168034b87f4a48e49c8e Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Sun, 31 Aug 2025 11:50:07 -0600 Subject: [PATCH 19/63] Got units conversion working and small edits to restore code runnability: - Fixed/finished unifyUnits function -- it now (seems to) work... - Uncommented the call to unifyUnits so it's now applied to vessel yamls. - action.py: Commented out a syntax issue and made it so that assignObjects method doesn't also figure out the required capacities (so that code will run, and that could be a separate step anyway). - Moved the test call to evaluateAssets in Irma.py to later in the script after all the actions are set up. More organized order, and lets the existing code still run. --- famodel/irma/action.py | 8 +++++--- famodel/irma/irma.py | 33 +++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 15b8cefa..5de23812 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -331,6 +331,7 @@ def getMetrics(self, cap, met, obj): # object logic checked vol = 0 length = 0 + ''' if objType == 'cable': for cable in cables: # TODO: figure out this iteration if cable is cable and not other thing in cables object: # TODO figure out how to only check cables, not j-tubes or any other parts @@ -339,7 +340,7 @@ def getMetrics(self, cap, met, obj): else: vol = -1 length = -1 - + ''' # Assign the capabilties metrics metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') metrics['cable_capacity_m'] = length if length > metrics.get('cable_capacity_m') else metrics.get('cable_capacity_m') @@ -760,14 +761,15 @@ def assignObjects(self, objects): else: if obj in self.objectList: print(f"Warning: Object '{obj}' is already in the action's object list. Capabilities will be overwritten.") - + ''' # Set capability requirements based on object for role, caplist in self.requirements.items(): for cap in caplist: metrics = self.getMetrics(cap, caplist[cap], obj) # pass in the metrics dict for the cap and the obj self.requirements[role][cap] = metrics # assign metric of capability cap based on value required by obj - + # MH: commenting our for now just so the code will run, but it may be better to make the above a separate step anyway + ''' self.objectList.append(obj) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 22f65ba2..623cf26c 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -110,24 +110,30 @@ def unifyUnits(d): # >>> dcopy = deepcopy(d) for asset in d.values(): # loop through each asset's dict - for capability in asset['capabilities'].values(): - for key, val in capability.items(): # look at each capability metric + + capabilities = {} # new dict of capabilities to built up + + for cap_key, cap_val in asset['capabilities'].items(): + + # make the capability type sub-dictionary + capabilities[cap_key] = {} + + for key, val in cap_val.items(): # look at each capability metric try: i = keys1.index(key) # find if key is on the list to convert - if keys2[i] in capability.keys(): + if keys2[i] in cap_val.keys(): raise Exception(f"Specification '{keys2[i]}' already exists") - capability[keys2[i]] = val * facts[i] # create a new SI entry - - breakpoint() - - del capability[keys1[i]] # remove the original? + capabilities[cap_key][keys2[i]] = val * facts[i] # make converted entry + #capability[keys2[i]] = val * facts[i] # create a new SI entry + #del capability[keys1[i]] # remove the original? except: - print('not found') - + + capabilities[cap_key][key] = val # copy over original form + class Scenario(): @@ -144,7 +150,7 @@ def __init__(self): vessels = loadYAMLtoDict('vessels.yaml', already_dict=True) objects = loadYAMLtoDict('objects.yaml', already_dict=True) - #unifyUnits(vessels) # (function doesn't work yet!) <<< + unifyUnits(vessels) # (function doesn't work yet!) <<< # ----- Validate internal cross references ----- @@ -378,7 +384,6 @@ def findCompatibleVessels(self): # add and register anchor install action(s) a1 = sc.addAction('install_anchor', f'install_anchor-{akey}', objects=[anchor]) - a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) # register the actions as necessary for the anchor <<< do this for all objects?? anchor.install_dependencies = [a1] @@ -436,6 +441,10 @@ def findCompatibleVessels(self): # ----- Check tasks for suitable vessels and the associated costs/times ----- + # preliminary/temporary test of anchor install asset suitability + for akey, anchor in project.anchorList.items(): + for a in anchor.install_dependencies: # go through required actions (should just be the anchor install) + a.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) # see if this example vessel can do it # ----- Call the scheduler ----- From 9786637b96e003b10aa28ad3ee10d06605ef7a76 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Mon, 8 Sep 2025 11:25:06 -0600 Subject: [PATCH 20/63] improve runing of action class: - Commented out placeholder code that has invalid syntax - Added placeholder duration for install_anchor task to calculate install costs - Removed unnecessary clearing of assets dictionary when excpetion is raised --- famodel/irma/action.py | 152 ++++++++++++++++++++++------------------- famodel/irma/irma.py | 14 +++- 2 files changed, 93 insertions(+), 73 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 5de23812..f15023e5 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -286,19 +286,23 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['area_m2'] = None if None > metrics.get('area_m2') else metrics.get('area_m2') - metrics['max_load_t'] = None if None > metrics.get('max_load_t') else metrics.get('max_load_t') + # metrics['area_m2'] = None if None > metrics.get('area_m2') else metrics.get('area_m2') + # metrics['max_load_t'] = None if None > metrics.get('max_load_t') else metrics.get('max_load_t') elif cap == 'chain_locker': # logic for chain_locker capability (only mooring objects compatible) # object logic checked - vol = 0 - length = 0 + if objType == 'mooring': + + # set baseline values for summation + vol = 0 + length = 0 + for i, sec in enumerate(obj.dd['sections']): # add up the volume and length of all chain in the object if sec['type']['chain']: diam = sec['type']['d_nom'] # diameter [m] - vol += 0.0 # TODO: calculate chain_locker volume from sec['L'] and diam. Can we just use volumetric diam here? + vol += 0.0 # TODO: calculate chain_locker volume from sec['L'] and diam. Use Delmar data from Rudy. Can we make function of chain diam? length += sec['L'] # length [m] else: @@ -310,9 +314,13 @@ def getMetrics(self, cap, met, obj): elif cap == 'line_reel': # logic for line_reel capability (only mooring objects compatible) # object logic checked, complete - vol = 0 - length = 0 + if objType == 'mooring': + + # set baseline values for summation + vol = 0 + length = 0 + for i, sec in enumerate(obj.dd['sections']): # add up the volume and length of all non_chain line in the object if not sec['type']['chain']: # any line type thats not chain vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] @@ -362,10 +370,10 @@ def getMetrics(self, cap, met, obj): else: pass - # Assign the capabilties metrics - metrics['max_line_pull_t'] = None if None > metrics.get('max_line_pull_t') else metrics.get('max_line_pull_t') - metrics['brake_load_t'] = None if None > metrics.get('brake_load_t') else metrics.get('brake_load_t') - metrics['speed_mpm'] = None if None > metrics.get('speed_mpm') else metrics.get('speed_mpm') + # # Assign the capabilties metrics + # metrics['max_line_pull_t'] = None if None > metrics.get('max_line_pull_t') else metrics.get('max_line_pull_t') + # metrics['brake_load_t'] = None if None > metrics.get('brake_load_t') else metrics.get('brake_load_t') + # metrics['speed_mpm'] = None if None > metrics.get('speed_mpm') else metrics.get('speed_mpm') elif cap == 'bollard_pull': # per calwave install report (section 7.2): bollard pull can be described as function of vessel speed and load @@ -387,7 +395,7 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['max_force_t'] = None if None > metrics.get('max_force_t') else metrics.get('max_force_t') + # metrics['max_force_t'] = None if None > metrics.get('max_force_t') else metrics.get('max_force_t') elif cap == 'crane': # logic for deck_space capability (all compatible) @@ -408,8 +416,8 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['capacity_t'] = None if None > metrics.get('capacity_t') else metrics.get('capacity_t') - metrics['hook_height_m'] = None if None > metrics.get('hook_height_m') else metrics.get('hook_height_m') + # metrics['capacity_t'] = None if None > metrics.get('capacity_t') else metrics.get('capacity_t') + # metrics['hook_height_m'] = None if None > metrics.get('hook_height_m') else metrics.get('hook_height_m') elif cap == 'station_keeping': # logic for station_keeping capability @@ -429,7 +437,7 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['typev'] = None if None > metrics.get('typev') else metrics.get('typev') + # metrics['type'] = None if None > metrics.get('type') else metrics.get('type') elif cap == 'mooring_work': # logic for mooring_work capability @@ -449,10 +457,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['line_typesvvv'] = None if None > metrics.get('line_typesvvv') else metrics.get('line_typesvvv') - metrics['stern_roller'] = None if None > metrics.get('stern_roller') else metrics.get('stern_roller') - metrics['shark_jaws'] = None if None > metrics.get('shark_jaws') else metrics.get('shark_jaws') - metrics['towing_pin_rating_t'] = None if None > metrics.get('towing_pin_rating_t') else metrics.get('towing_pin_rating_t') + # metrics['line_types'] = None if None > metrics.get('line_types') else metrics.get('line_types') + # metrics['stern_roller'] = None if None > metrics.get('stern_roller') else metrics.get('stern_roller') + # metrics['shark_jaws'] = None if None > metrics.get('shark_jaws') else metrics.get('shark_jaws') + # metrics['towing_pin_rating_t'] = None if None > metrics.get('towing_pin_rating_t') else metrics.get('towing_pin_rating_t') elif cap == 'pump_surface': # logic for pump_surface capability @@ -472,10 +480,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'pump_subsea': # logic for pump_subsea capability @@ -495,10 +503,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'pump_grout': # logic for pump_grout capability @@ -518,11 +526,11 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['flow_rate_m3hr'] = None if None > metrics.get('flow_rate_m3hr') else metrics.get('flow_rate_m3hr') - metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['flow_rate_m3hr'] = None if None > metrics.get('flow_rate_m3hr') else metrics.get('flow_rate_m3hr') + # metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'hydraulic_hammer': # logic for hydraulic_hammer capability (only platform and anchor objects compatible) @@ -535,10 +543,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['energy_per_blow_kJ'] = None if None > metrics.get('energy_per_blow_kJ') else metrics.get('energy_per_blow_kJ') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['energy_per_blow_kJ'] = None if None > metrics.get('energy_per_blow_kJ') else metrics.get('energy_per_blow_kJ') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'vibro_hammer': # logic for vibro_hammer capability (only platform and anchor objects compatible) @@ -551,10 +559,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['centrifugal_force_kN'] = None if None > metrics.get('centrifugal_force_kN') else metrics.get('centrifugal_force_kN') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['centrifugal_force_kN'] = None if None > metrics.get('centrifugal_force_kN') else metrics.get('centrifugal_force_kN') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'drilling_machine': # logic for drilling_machine capability (only platform, anchor, and cable objects compatible) @@ -570,9 +578,9 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'torque_machine': # logic for torque_machine capability @@ -592,10 +600,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['torque_kNm'] = None if None > metrics.get('torque_kNm') else metrics.get('torque_kNm') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['torque_kNm'] = None if None > metrics.get('torque_kNm') else metrics.get('torque_kNm') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'cable_plough': # logic for cable_plough capability (only cable objects compatible) @@ -606,9 +614,9 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'rock_placement': # logic for rock_placement capability (only platform, anchor, and cable objects compatible) @@ -623,10 +631,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['placement_method'] = None if None > metrics.get('placement_method') else metrics.get('placement_method') - metrics['max_depth_m'] = None if None > metrics.get('max_depth_m') else metrics.get('max_depth_m') - metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') - metrics['rock_size_range_mm'] = None if None > metrics.get('rock_size_range_mm') else metrics.get('rock_size_range_mm') + # metrics['placement_method'] = None if None > metrics.get('placement_method') else metrics.get('placement_method') + # metrics['max_depth_m'] = None if None > metrics.get('max_depth_m') else metrics.get('max_depth_m') + # metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') + # metrics['rock_size_range_mm'] = None if None > metrics.get('rock_size_range_mm') else metrics.get('rock_size_range_mm') elif cap == 'container': # logic for container capability (only platform, turbine, and cable objects compatible) @@ -641,8 +649,8 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'rov': # logic for rov capability (all compatible) @@ -663,10 +671,10 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['class'] = None if None > metrics.get('class') else metrics.get('class') - metrics['depth_rating_m'] = None if None > metrics.get('depth_rating_m') else metrics.get('depth_rating_m') - metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') - metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + # metrics['class'] = None if None > metrics.get('class') else metrics.get('class') + # metrics['depth_rating_m'] = None if None > metrics.get('depth_rating_m') else metrics.get('depth_rating_m') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') elif cap == 'positioning_system': # logic for positioning_system capability (only platform, anchor, and cable objects compatible) @@ -681,8 +689,8 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') - metrics['methods'] = None if None > metrics.get('methods') else metrics.get('methods') + # metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') + # metrics['methods'] = None if None > metrics.get('methods') else metrics.get('methods') elif cap == 'monitoring_system': # logic for monitoring_system capability @@ -702,8 +710,8 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['metrics'] = None if None > metrics.get('metrics') else metrics.get('metrics') - metrics['sampling_rate_hz'] = None if None > metrics.get('sampling_rate_hz') else metrics.get('sampling_rate_hz') + # metrics['metrics'] = None if None > metrics.get('metrics') else metrics.get('metrics') + # metrics['sampling_rate_hz'] = None if None > metrics.get('sampling_rate_hz') else metrics.get('sampling_rate_hz') elif cap == 'sonar_survey': # logic for sonar_survey capability (only anchor and cable objects compatible) @@ -716,8 +724,8 @@ def getMetrics(self, cap, met, obj): pass # Assign the capabilties metrics - metrics['types'] = None if None > metrics.get('types') else metrics.get('types') - metrics['resolution_m'] = None if None > metrics.get('resolution_m') else metrics.get('resolution_m') + # metrics['types'] = None if None > metrics.get('types') else metrics.get('types') + # metrics['resolution_m'] = None if None > metrics.get('resolution_m') else metrics.get('resolution_m') else: raise Exception(f"Unsupported capability '{cap}'.") @@ -862,7 +870,11 @@ def calcDurationAndCost(self): # --- Mooring & Anchors --- elif self.type == 'install_anchor': - pass + + # Place holder duration, will need a mini-model to calculate + self.duration += 0.2 # 0.2 days + self.cost += self.duration * (self.assets['carrier']['day_rate'] + self.assets['operator']['day_rate']) + elif self.type == 'retrieve_anchor': pass elif self.type == 'load_mooring': @@ -875,7 +887,7 @@ def calcDurationAndCost(self): duration_min += sec['L'] / self.assets['carrier2']['winch']['speed_mpm'] # duration [minutes] self.duration += duration_min / 60 / 24 # convert minutes to days - self.cost += self.duration * self.assets['carrier2']['day_rate'] # cost [$] + self.cost += self.duration * (self.assets['carrier1']['day_rate'] + self.assets['carrier2']['day_rate'] + self.assets['operator']['day_rate']) # cost of all assets involved for the duration of the action [$] # check for deck space availability, if carrier 1 met transition to carrier 2. @@ -953,9 +965,7 @@ def evaluateAssets(self, assets): # Check that all roles in the action are filled for role_name in self.requirements.keys(): if self.assets[role_name] is None: - - for role_name in assets.keys(): # Clear the assets dictionary - assets[role_name] = None + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 623cf26c..8f8a2026 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -382,8 +382,12 @@ def findCompatibleVessels(self): for akey, anchor in project.anchorList.items(): + ## Test action.py for anchor install + # add and register anchor install action(s) a1 = sc.addAction('install_anchor', f'install_anchor-{akey}', objects=[anchor]) + duration, cost = a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"], 'operator':sc.vessels["AHTS_alpha"]}) + print(f'Anchor install action {a1.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') # register the actions as necessary for the anchor <<< do this for all objects?? anchor.install_dependencies = [a1] @@ -396,14 +400,20 @@ def findCompatibleVessels(self): # note origin and destination # --- lay out all the mooring's actions (and their links) + + ## Test action.py for mooring load + # create load vessel action a2 = sc.addAction('load_mooring', f'load_mooring-{mkey}', objects=[mooring]) - + # duration, cost = a2.evaluateAssets({'carrier2' : sc.vessels["HL_Giant"], 'carrier1' : sc.vessels["Barge_squid"], 'operator' : sc.vessels["HL_Giant"]}) + # print(f'Mooring load action {a2.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + # create ship out mooring action # create lay mooring action a3 = sc.addAction('lay_mooring', f'lay_mooring-{mkey}', objects=[mooring], dependencies=[a2]) - sc. addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor + sc.addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor + # mooring could be attached to anchor here - or could be lowered with anchor!! #(r=r_anch, mooring=mooring, anchor=mooring.anchor...) From 1a2bee4b0e8dc1a7426ab367506b984533a8548f Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Tue, 9 Sep 2025 13:46:57 -0600 Subject: [PATCH 21/63] Adds get_row function to tasks: - get_row function structure added to task class - get_row returns a len(assets)x2 array of zeros for runability of code. This will need to be changed with a real calcualtion of costs and durations - Irma constructs a task asset matrix by adding up these rows - The task asset matrix built by irma is rows = tasks, columns = assets --- famodel/irma/irma.py | 16 +++++++++++++--- famodel/irma/task.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 8f8a2026..cd4b9ceb 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -436,8 +436,9 @@ def findCompatibleVessels(self): # ----- Generate tasks (groups of Actions according to specific strategies) ----- - + tasks = [] t1 = Task(sc.actions, 'install_mooring_system') + tasks.append(t1) # ----- Do some graph analysis ----- @@ -455,8 +456,17 @@ def findCompatibleVessels(self): for akey, anchor in project.anchorList.items(): for a in anchor.install_dependencies: # go through required actions (should just be the anchor install) a.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) # see if this example vessel can do it - - + + + # ----- Generate the task_asset_matrix for scheduler ----- + # UNUSED FOR NOW + task_asset_matrix = np.zeros((len(tasks), len(sc.vessels), 2)) + for i, task in enumerate(tasks): + row = task.get_row(sc.vessels) + if row.shape != (len(sc.vessels), 2): + raise Exception(f"Task '{task.name}' get_row output has wrong shape {row.shape}, should be {(2, len(sc.vessels))}") + task_asset_matrix[i, :] = row + # ----- Call the scheduler ----- # for timing with weather windows and vessel assignments diff --git a/famodel/irma/task.py b/famodel/irma/task.py index da81fad4..cafc4059 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -92,4 +92,38 @@ def getTaskGraph(self): longest_path = nx.dag_longest_path(G, weight='duration') longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs total_duration = sum(self.actionList[node].duration for node in longest_path) - return G \ No newline at end of file + return G + + def get_row(self, assets): + '''Get a matrix of (cost, duration) tuples for each asset to perform this task. Will be a row in the task_asset matrix. + + Parameters + ---------- + assets : list + A list of all assets available to perform the task. + + Returns + ------- + matrix : array-like + A 2D array of (cost, duration) tuples indicating the cost and duration for each asset to perform this task. + Must be 2x len(assets). + + ''' + + matrix = np.zeros((len(assets), 2)) + # TODO: build row of matrix that holds (cost, duration) tuple of asset / assets (?) to complete task + + # Could look something like... + ''' + for i, asset in enumerate(assets): + for action in self.actionList: # can we do this without the double loop? + if asset in action.roles: + action = self.actionList[asset.name] + matrix[i, 0] = action.cost + matrix[i, 1] = action.duration + else: + matrix[i, 0] = -1 # negative cost/duration means asset cannot perform task + matrix[i, 1] = -1 + ''' + + return np.zeros((len(assets), 2)) # placeholder, replace with actual matrix \ No newline at end of file From ff6047015e2eba243c02fae9e3bc791e3820fb7c Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Tue, 9 Sep 2025 17:02:33 -0600 Subject: [PATCH 22/63] Begining the scheduler object: - Builds a scheduler class - Example code in main block allows scheduler to be run standalone with example of tasks and assets - Eventually main code block will need to be replaced by irma - Code runs, does not produce optimal solution. Constraints need to be finished and checked they are working properly. --- famodel/irma/scheduler.py | 552 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 552 insertions(+) create mode 100644 famodel/irma/scheduler.py diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py new file mode 100644 index 00000000..c9c3c847 --- /dev/null +++ b/famodel/irma/scheduler.py @@ -0,0 +1,552 @@ +# author: @rdavies, 9-8-2025 + +# Scheduler class for managing actions and tasks +# WIP, to be merged into Irma later on + +''' +--- TODO List --- +- [] How to expand this to multiple assets per task? +- [] Eventually enable parallel tasks and multiple assets per task +- [] Convert input tasks and assets from dicts to Task and Asset objects + - [] When tasks and assets are converted from lists to objects, update the type hints for task and asset list at class initialization. +- [] Add a delay cost, i.e. a cost for each time period where X = 0 +- [] Do we want to return any form of info dictionary? +- [] Figure out if this can be parallelized +''' + +from famodel.irma.task import Task +from famodel.irma.assets import Asset +from scipy import optimize +from scipy.optimize import milp +import numpy as np +import os + +wordy = 1 # level of verbosity for print statements + +class Scheduler: + + # Inputs are strictly typed, as this is an integer programming problem (ignored by python at runtime, but helpful for readability and syntax checking). + def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, weather : list[int] = [], period_duration : float = 1, **kwargs): + ''' + Initializes the Scheduler with assets, tasks, and constraints. + + Inputs + ------ + task_asset_matrix : array-like + A 3D array of (cost, duration) tuples indicating the cost and duration for each asset to perform each task. + Must be len(tasks) x len(assets) x 2. + tasks : list + A list of Task objects to be scheduled. + assets : list + A list of Asset objects to be scheduled. + task_dependencies : dict + A dictionary mapping each task to a list of its dependencies. + weather : list + A list of weather windows. The length of this list defines the number of discrete time periods available for scheduling. + period_duration : float + The duration of each scheduling period. Used for converting from periods to real time. + kwargs : dict + Additional keyword arguments for future extensions. + + Returns + ------- + None + ''' + + if wordy > 0: + print("Initializing Scheduler...") + + self.task_asset_matrix = task_asset_matrix + self.tasks = tasks + self.assets = assets + self.weather = weather + self.task_dependencies = task_dependencies + + self.num_tasks = len(self.tasks) + self.num_assets = len(self.assets) + + self.period_duration = period_duration # duration of each scheduling period. Used for converting from periods to real time. + self.num_periods = len(weather) # number of scheduling periods + + # --- Check for valid inputs --- + + # check for valid task_asset_matrix dimensions (must be len(tasks) x len(assets) x 2) + if self.task_asset_matrix.ndim != 3 or self.task_asset_matrix.shape[0] != len(self.tasks) or self.task_asset_matrix.shape[1] != len(self.assets) or self.task_asset_matrix.shape[2] != 2: + raise ValueError("task_asset_matrix must be a 3D array with shape (len(tasks), len(assets), 2).") + + # check for integer matrix, try to correct + if self.task_asset_matrix.dtype != np.dtype('int'): + try: + self.task_asset_matrix = self.task_asset_matrix.astype(int) + except: + raise ValueError("task_asset_matrix must be a 3D array of integers with shape (len(tasks), len(assets), 2).") + else: + print("Input task_asset_matrix was not integer. Converted to integer type.") + + # check for valid tasks and assets + if not all(isinstance(task, str) for task in self.tasks): + raise ValueError("All elements in tasks must be strings.") + if not all(isinstance(asset, dict) for asset in self.assets): + raise ValueError("All elements in assets must be dictionaries.") + + # check for valid weather + if not all(isinstance(w, int) and w >= 0 for w in self.weather): + raise ValueError("All elements in weather must be non-negative integers representing weather severity levels.") + + # check period duration is valid + if self.period_duration <= 0: + raise ValueError("period_duration must be positive non-zero.") + + # --- Debug helpers --- + # make a list of indicies to help with building constraints + self.x_indices = [] + for p in range(self.num_periods): + for t in range(self.num_tasks): + for a in range(self.num_assets): + self.x_indices.append(f"x_[{p}][{t}][{a}]") + + if wordy > 0: + print(f"Scheduler initialized with {self.num_periods} time periods, {self.num_tasks} tasks, and {self.num_assets} assets.") + + def set_up_optimizer(self, goal : str = "cost"): + ''' + Workspace for building out an optimizer. Right now, assuming the goal is minimize cost. This could easily be reworked to minimize duration, or some other value. + + This is a binary-integer linear programming problem, which can be solved with scipy.optimize.milp. + + Inputs + ------ + goal : str + The optimization goal, minimize either "cost" or "duration". Default is "cost". + + Returns + ------- + values : np.ndarray + The values vector for the optimization problem. + constraints : list + A list of constraints for the optimization problem. + integrality : np.ndarray + An array that sets decision variables as integers. + bounds : scipy.optimize.Bounds + The bounds for the decision variables (0-1). + ''' + + if wordy > 0: + print("Setting up the optimizer...") + + # Solves a problem of the form minimize: v^T * x + # subject to: A_ub * x <= b_ub + # A_eq * x == b_eq + # A_lb * x >= b_lb + # lb <= x <= ub # These are constrained as integers on range 0-1 + + # --- Check and Process Inputs --- + if goal == "cost": + goal_index = 0 + elif goal == "duration": + goal_index = 1 + else: + raise ValueError("goal must be either 'cost' or 'duration'.") + + # --- Build the objective function --- + + # v^T * x + + # shape of V = shape of X, these will be flattened to equal-length vectors for the solver + V = np.zeros((self.num_periods, self.num_tasks, self.num_assets), dtype=int) # Values matrix: value of asset a assigned to task t in period p + X = np.zeros(V.shape, dtype=int) # Decision variable matrix: X[p, t, a] = 1 if asset a is assigned to task t in period p, else 0 + + # Values vector: In every planning period, the value of assigning asset a to task t is the same. Constraints determine which periods are chosen. + # Note: Intentionally using values here instead of "cost" to avoid confusion between the program 'cost' of a pairing (which could be financial cost, duration, or some other target metric for minimization) to the solver and the financial cost of a asset-task pairing. + for p in range(self.num_periods): + for t in range(self.num_tasks): + for a in range(self.num_assets): + V[p, t, a] = self.task_asset_matrix[t, a, goal_index] # cost + + + if wordy > 1: + print("Values matrix V (periods x tasks x assets) of length " + str(V.flatten().shape[0]) + " created") + print("Decision variable matrix X (periods x tasks x assets) of same shape initialized to zeros.") + + # Decision variable start as 0's, nothing decided. Constrainted to 0 or 1 by bounds and integrality in flattening objective function section. + + # --- Flatten the objective function for the solver --- + + # values vector (horizontal vector) + values = V.flatten() # Flatten the values tensor (num_periods x num_tasks x num_assets) into a 1D array for the solver. Solver requires 1D problem + + # decision vars (vertical vector) + decision_vars = X.flatten() # Flatten the decision variable tensor (num_periods x num_tasks x num_assets) into a 1D array for the solver. Solver requires 1D problem. + + # lb <= x <= ub + # Constrain decision variables to be 0 or 1 + bounds = optimize.Bounds(0, 1) # 0 <= x_i <= 1 + integrality = np.full_like(decision_vars, True) # x_i are integers + + if wordy > 0: + print("Bounds and integrality for decision variables set. Begining to build constraints...") + + # --- build the constraints --- + + # A_ub * x <= b_ub + # A_eq * x == b_eq + # A_lb * x >= b_lb + + ''' + A note on constraints: There are two constraint matrices, the equality constraints (A_eq, b_eq) and the upper bound constraints (A_ub, b_ub). + Each row in the coefficient matrices corresponds to a constraint, and each column corresponds to a decision variable. Thus the number of columns + is equal to the number of decision variables (num_periods * num_tasks * num_assets), and the number of rows is equal to the number of constraints. + Similarly, the length of the limits matrices (b_eq, b_ub) is equal to the number of constraints. + + The equality constraints are expressed in the form A_eq * x = b_eq. Where A_eq is the coefficient matrix and b_eq is the limits matrix. + For example, the constraints 5x+3y=15 and x-y=1 can be expressed as: + A_eq = [[5, 3], + [1, -1]] + b_eq = [15, 1] + + Similarly, the upper bound constraints are expressed in the form A_ub * x <= b_ub. Where A_ub is the coefficient matrix and b_ub is the limits matrix. + For example, the constraints 2x+3y<=12 and x+y<=5 can be expressed as: + A_ub = [[2, 3], + [1, 1]] + b_ub = [12, 5] + + The lower bound constraints (A_lb and b_lb) follow the same form as the upper bound constraints. + + The lower and upper bound constraints on the decision variables (lb <= x <= ub) is handled above, limiting them to integer values of 0 or 1. + + The shape of decision vars is: + period_0_task_0_asset_0, period_0_task_0_asset_1, ..., period_0_task_1_asset_0, ..., period_1_task_0_asset_0, ... + or equivalently: + period_0_task_0_asset_0, ..., period_0_task_0_asset_max, period_0_task_1_asset_0, ..., period_0_task_max_asset_max, period_1_task_0_asset_0, ..., period_max_task_max_asset_max, + + Constraints column (decision variable) indexing used in definitions below: + x = x_000, ..., x_00a, x_001, ..., x_00a, ..., x_0t1, ..., x_0ta, # task asset pairings in period 0 + x_100, ..., x_10a, x_101, ..., x_10a, ..., x_1t1, ..., x_1ta, # task asset pairings in period 1 + ..., + x_p00, ..., x_p0a, x_p21, ..., x_p2a, ..., x_pt1, ..., x_pta # task asset pairings in period p + + where: + - 0:p is the period index, 0:t is the task index, and 0:a is the asset index + - x is the flattened decision variable tensor X[p, t, a] + ''' + + # Empty list of constraint coefficient matrices + A_ub_list = [] + A_eq_list = [] + A_lb_list = [] + + # Empty list of constraint limit vectors + b_ub_list = [] + b_eq_list = [] + b_lb_list = [] + + # 0) Total number of assignments needs to be less than or equal to the number of periods available + ''' + the sum of the total amount of periods assigned to tasks cannot be more than the total number of periods available: + (x_000 + ... + x_pta) <= N + ''' + + # 1 row + A_ub_0 = np.ones((1, len(decision_vars)), dtype=int) # Every period assigned to a task counts as 1 towards the total assigned periods. This assumes one pair per period + b_ub_0 = np.array([self.num_periods]) + + A_ub_list.append(A_ub_0) + b_ub_list.append(b_ub_0) + + if wordy > 0: + print("Constraint 0 built.") + + # 1) asset can only be assigned to a task if asset is capable of performing the task (value of pairing is non-negative) + ''' + if task j cannot be performed by asset k, then x_pjk = 0 for all periods p + + (x_0jk + ... + x_pjk) = 0 # for all tasks j in range(0:t) and assets k in range(0:a) where task_asset_matrix[j, k, goal_index] < 0 + ''' + + mask = np.zeros(X.shape, dtype=int) + + for p in range(self.num_periods): + mask[p,:,:] = self.task_asset_matrix[:, :, goal_index] < 0 # Create a mask of invalid task-asset pairings where cost is negative (indicating invalid) + + # 1 row + A_eq_1 = mask.flatten().reshape(1, -1) # Flatten the mask to match the decision variable shape, and reshape to be a single row + b_eq_1 = np.zeros(A_eq_1.shape[0], dtype=int) + + if wordy > 2: # example debugging code for looking at indicies. Can be applied to any constraint matrix if row index is adjusted accordingly + print(f"Task {t}:") + for i in range(len(self.x_indices)): + print(f" {self.x_indices[i]}: {A_eq_1[0, i]}") + + A_eq_list.append(A_eq_1) + b_eq_list.append(b_eq_1) + + # 2) task dependencies must be respected (i.e., a task cannot start until all its dependencies have been satisfied) + # TODO: enforce task dependencies + + # 3) assets cannot be assigned in a time period where the weather is above the maximum capacity + # TODO: weather is disabled until this is added + + # 4) assets cannot be assigned to multiple tasks in the same time period + ''' + this is a simplification, eventually we may want to allow multiple assets per task, or parallel tasks + + Sum of all pairings in a period must be <= 1: + (x_000 + ... + x_0ta) <= 1 # for period 0 + (x_100 + ... + x_1ta) <= 1 # for period 1 + ... + (x_p00 + ... + x_pta) <= 1 # for period p + ''' + + # num_periods rows + A_ub_4 = np.zeros((self.num_periods, len(decision_vars)), dtype=int) + b_ub_4 = np.ones(self.num_periods, dtype=int) # right-hand side is 1 for each period + + for p in range(self.num_periods): + # Create a mask for all variables in period p + mask = np.zeros(X.shape, dtype=int) + mask[p, :, :] = 1 # Set all task-asset pairings in period p to 1 (so they are included in the sum) + A_ub_4[p, :] = mask.flatten() + + A_ub_list.append(A_ub_4) + b_ub_list.append(b_ub_4) + + if wordy > 0: + print("Constraint 4 built.") + + # 5) The total number of tasks assigned cannot be greater than the number of tasks available + # TODO: enforce task limits + + # 6) The total number of assets assigned cannot be greater than the number of assets available + # TODO: enforce asset limits + + # 7) There is a penalty associated with a time period with no assigned tasks + # TODO: This delay costs enforces tasks are finished as soon as possible + + # 8) All tasks must be assigned to a time period once + ''' + + The sum of all decision variables for each task must equal 1, indicating that each task is assigned to exactly one time period. This does not support parallel tasks. + + (x_000 + ... + x_p0a) = 1 # for task 0 + (x_010 + ... + x_p1a) = 1 # for task 1 + ... + (x_pta + ... + x_pta) = 1 # for task t + ''' + + # num_tasks rows + A_eq_8 = np.zeros((self.num_tasks, len(decision_vars)), dtype=int) + b_eq_8 = np.ones(self.num_tasks, dtype=int) + + for t in range(self.num_tasks): + # Create a mask for all variables for task t + mask = np.zeros(X.shape, dtype=int) + mask[:, t, :] = 1 # Set all periods and assets for task t to 1 (so they are included in the sum) + A_eq_8[t, :] = mask.flatten() + + if wordy > 0: + print(f"Task {t}:") + for i in range(len(self.x_indices)): + print(f" {self.x_indices[i]}: {A_eq_8[t, i]}") + + A_eq_list.append(A_eq_8) + b_eq_list.append(b_eq_8) + + if wordy > 0: + print("Constraint 8 built.") + + # 9) A task must be assigned to the number of time periods equal to its duration for the asset assigned to it + # TODO: this enforces task lengths for assets that are assigned + + + + if wordy > 0: + print("All constraints built. Stacking and checking constraints...") + + # --- Build the constraints --- + # A series of linear constraints required by the solver by stacking the constraint matrices and limits vectors + # The number of rows in these matrices is equal to the number of constraints, so they can be vertically stacked + + # Check num columns of all constraint matrices matches number of decision variables before stacking + for i, A in enumerate(A_ub_list): + if A.size > 0 and A.shape[1] != decision_vars.shape[0]: + raise ValueError(f"Upper bound constraint matrix {i} has incorrect number of columns. Expected {decision_vars.shape[0]}, got {A.shape[1]}.") + for i, A in enumerate(A_eq_list): + if A.size > 0 and A.shape[1] != decision_vars.shape[0]: + raise ValueError(f"Equality constraint matrix {i} has incorrect number of columns. Expected {decision_vars.shape[0]}, got {A.shape[1]}.") + for i, A in enumerate(A_lb_list): + if A.size > 0 and A.shape[1] != decision_vars.shape[0]: + raise ValueError(f"Lower bound constraint matrix {i} has incorrect number of columns. Expected {decision_vars.shape[0]}, got {A.shape[1]}.") + + # Stack, check shapes of final matrices and vectors, and save the number of constraints for later use + if len(A_ub_list) > 0: + A_ub = np.vstack(A_ub_list) # upperbound coefficient matrix + b_ub = np.concatenate(b_ub_list) # upperbound limits vector + if A_ub.shape[0] != b_ub.shape[0]: + raise ValueError(f"A_ub and b_ub have inconsistent number of rows. A_ub has {A_ub.shape[0]}, b_ub has {b_ub.shape[0]}.") + self.num_ub_constraints = A_ub.shape[0] + else: + self.num_ub_constraints = 0 + + if len(A_eq_list) > 0: + A_eq = np.vstack(A_eq_list) # equality coefficient matrix + b_eq = np.concatenate(b_eq_list) # equality limits vector + if A_eq.shape[0] != b_eq.shape[0]: + raise ValueError(f"A_eq and b_eq have inconsistent number of rows. A_eq has {A_eq.shape[0]}, b_eq has {b_eq.shape[0]}.") + self.num_eq_constraints = A_eq.shape[0] + else: + self.num_eq_constraints = 0 + + if len(A_lb_list) > 0: + A_lb = np.vstack(A_lb_list) # lowerbound coefficient matrix + b_lb = np.concatenate(b_lb_list) # lowerbound limits vector + if A_lb.shape[0] != b_lb.shape[0]: + raise ValueError(f"A_lb and b_lb have inconsistent number of rows. A_lb has {A_lb.shape[0]}, b_lb has {b_lb.shape[0]}.") + self.num_lb_constraints = A_lb.shape[0] + else: + self.num_lb_constraints = 0 + + if wordy > 0: + print(f"Final constraint matrices built with {self.num_ub_constraints} upperbound constraints, {self.num_eq_constraints} equality constraints, and {self.num_lb_constraints} lowerbound constraints.") + + # Build constraint objects if they exist + constraints = [] + if self.num_ub_constraints > 0: + constraints.append(optimize.LinearConstraint(A = A_ub, ub = b_ub)) + if self.num_eq_constraints > 0: + constraints.append(optimize.LinearConstraint(A = A_eq, lb = b_eq, ub = b_eq)) # equality constraints have same lower and upper bounds (thuis equality) + if self.num_lb_constraints > 0: + constraints.append(optimize.LinearConstraint(A = A_lb, lb = b_lb)) + + # --- Save the optimization problem parameters for later use --- + self.values = values + self.constraints = constraints + self.integrality = integrality + self.bounds = bounds + + if wordy > 0: + print("Optimizer set up complete.") + + def optimize(self, threads = -1): + ''' + Run the optimizer + + Inputs + ------ + threads : int, None + Number of threads to use (<0 or None to auto-detect). + + Returns + ------- + None + ''' + + # --- set up the optimizer --- + # if the optimizer has not been set up yet, set it up + if not hasattr(self, 'values') or not hasattr(self, 'constraints') or not hasattr(self, 'integrality') or not hasattr(self, 'bounds'): + self.set_up_optimizer() + + if wordy > 0: + print("Starting optimization...") + + # --- Check for valid inputs --- + if not isinstance(threads, int) and threads is not None: + raise ValueError("threads must be an integer or None.") + + # detect max number of threads on system if requested for passing into solver + if threads < 0 or threads is None: + threads = os.cpu_count() + if threads is None: + raise ValueError("Could not detect number of CPU threads on system.") + + # --- call the solver --- + res = milp( + c=self.values, # milp function doesnt not care about the shape of values, just that it is a 1D array + constraints=self.constraints, + integrality=self.integrality, # milp function doesnt not care about the shape of values, just that it is a 1D array + bounds=self.bounds + ) + + if wordy > 0: + print("Solver complete. Analyzing results...") + print("Results: \n", res) + + # --- process the results --- + if res.success: + # Reshape the flat result back into the (num_periods, num_tasks, num_assets) shape + + if wordy > 0: + print("Decision variable [periods][tasks][assets]:") + for i in range(len(self.x_indices)): + print(f" {self.x_indices[i]}: {res.x[i]}") + + X_optimal = res.x.reshape((self.num_periods, self.num_tasks, self.num_assets)) + self.schedule = X_optimal + if wordy > 0: + print("Optimization successful. The following schedule was generated:") + for p in range(self.num_periods): + print_string = f"Period {p+1}:" + whitespace = " " * (3 - len(str(p+1))) # adjust spacing for single vs double digit periods. Limited to 99 periods. + print_string += whitespace + displayed = False + for t in range(self.num_tasks): + for a in range(self.num_assets): + if X_optimal[p, t, a] == 1: + task_name = self.tasks[t] + asset_name = self.assets[a]['name'] if 'name' in self.assets[a] else f"Asset {a+1}" + cost = self.task_asset_matrix[t, a, 0] + duration = self.task_asset_matrix[t, a, 1] + print_string += f"Task '{task_name}' assigned to Asset '{asset_name}' (Cost: {cost}, Duration: {duration})" + displayed = True + break # break because we only support one asset per task per period for now + else: + print_string += "No assignment" + displayed = True + break # break because we only support one asset per task per period for now + if displayed: + break + + print(print_string) + + if wordy > 0: + print("Optimization complete.") + + +if __name__ == "__main__": + + # A dummy system to test the scheduler + + # 21 weather periods = 21 time periods + weather = [3]*5 + [2]*1 + [1]*10 + [2]*3 + [3]*2 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration + + # Example tasks, assets, dependencies, and task_asset_matrix. Eventually try with more tasks than assets, more assets than tasks, etc. + tasks = [ + "task1", + "task2", + "task3" + ] + assets = [ + {"name": "asset1", "max_weather" : 3}, + {"name": "asset2", "max_weather" : 2}, + {"name": "asset3", "max_weather" : 1} + ] + + # task dependencies + task_dependencies = { + "task1": [], + "task2": ["task1_start"], + "task3": ["task1_end", "task2_start"] + } + + # cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid + task_asset_matrix = np.array([ + [(1000, 5), (2000, 3), (1500, 4)], # task 1: asset 1, asset 2, asset 3 + [(1200, 4), ( -1,-1), (1800, 3)], # task 2: asset 1, asset 2, asset 3 + [(1100, 6), (2100, 4), (1600, 5)] # task 3: asset 1, asset 2, asset 3 + ]) + + # Find the minimum time period duration based on the task_asset_matrix + min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration + + # Sandbox for building out the scheduler + scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, weather, min_duration) + scheduler.optimize() \ No newline at end of file From 16b96a1e9080b397d62d31567c5d77ffddc20eab Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Wed, 10 Sep 2025 09:23:24 -0600 Subject: [PATCH 23/63] Working on new Task-related methods in Scenario: - Added Scenario methods to add and register Tasks. - Added a action_sequence input in Task that provides the ordering of actions within the task, including parallel/serial bits. - Started an example "strategy implementer" function in irma that sets up one Task for anchor installation to start with. - Added ti/tf placeholders in Action and Task for future timing. - All work in progress... --- famodel/irma/action.py | 2 + famodel/irma/irma.py | 137 +++++++++++++++++++++++++++++++++++++++-- famodel/irma/task.py | 51 ++++++++++++--- 3 files changed, 175 insertions(+), 15 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index f15023e5..6e6a96a5 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -126,6 +126,8 @@ def __init__(self, actionType, name, **kwargs): self.duration = getFromDict(actionType, 'duration', default=0) # this will be overwritten by calcDurationAndCost. TODO: or should it overwrite any duration calculation? self.cost = 0 # this will be overwritten by calcDurationAndCost + self.ti = 0 # action start time [h?] + self.tf = 0 # action end time [h?] self.supported_objects = [] # list of FAModel object types supported by the action diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index cd4b9ceb..d23c9d68 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -237,6 +237,7 @@ def __init__(self): # Initialize some things self.actions = {} + self.tasks = {} def registerAction(self, action): @@ -331,7 +332,35 @@ def visualizeActions(self): i += 1 plt.legend() return G + + + def registerTask(self, task): + '''Registers an already created task''' + + # this also handles creation of unique dictionary keys + + if task.name in self.tasks: # check if there is already a key with the same name + raise Warning(f"Action '{task.name}' is already registered.") + print(f"Task name '{task.name}' is in the tasks list so incrementing it...") + task.name = increment_name(task.name) + + # Add it to the actions dictionary + self.tasks[task.name] = task + + + def addTask(self, actions, action_sequence, task_name, **kwargs): + '''Creates a task and adds it to the register''' + + # Create the action + task = Task(actions, action_sequence, task_name, **kwargs) + + # Register the action + self.registerTask(task) + return task + + + def findCompatibleVessels(self): '''Go through actions and identify which vessels have the required capabilities (could be based on capability presence, or quantitative. @@ -340,6 +369,101 @@ def findCompatibleVessels(self): pass + def figureOutTaskRelationships(self): + '''Calculate timing within tasks and then figure out constraints + between tasks. + ''' + + # Figure out task durations (for a given set of asset assignments?) + for task in self.tasks.values(): + task.calcTiming() + + # Figure out timing constraints between tasks based on action dependencies + n = len(self.tasks) + dt_min = np.zeros((n,n)) # matrix of required time offsets between tasks + + for i1, task1 in enumerate(self.tasks.values()): + for i2, task2 in enumerate(self.tasks.values()): + # look at all action dependencies from tasks 1 to 2 and + # identify the limiting case (the largest time offset)... + dt_min_1_2, dt_min_2_1 = findTaskDependencies(task1, task2) + + # for now, just look in one direction + dt_min[i1, i2] = dt_min_1_2 + + return dt_min + + +def findTaskDependencies(task1, task2): + '''Finds any time dependency between the actions of two tasks. + Returns the minimum time separation required from task 1 to task 2, + and from task 2 to task 1. I + ''' + + time_1_to_2 = [] + time_2_to_1 = [] + + # Look for any dependencies where act2 depends on act1: + #for i1, act1 in enumerate(task1.actions.values()): + # for i2, act2 in enumerate(task2.actions.values()): + for a1, act1 in task1.actions.items(): + for a2, act2 in task2.actions.items(): + + if a1 in act2.dependencies: # if act2 depends on act1 + time_1_to_2.append(task1.actions_ti[a1] + act1.duration + - task2.actions_ti[a2]) + + if a2 in act1.dependencies: # if act2 depends on act1 + time_2_to_1.append(task2.actions_ti[a2] + act2.duration + - task1.actions_ti[a1]) + + print(time_1_to_2) + print(time_2_to_1) + + dt_min_1_2 = min(time_1_to_2) # minimum time required from t1 start to t2 start + dt_min_2_1 = min(time_2_to_1) # minimum time required from t2 start to t1 start + + if dt_min_1_2 + dt_min_2_1 > 0: + print(f"The timing between these two tasks seems to be impossible...") + + breakpoint() + return dt_min_1_2, dt_min_2_1 + + +def implementStrategy_staged(sc): + '''This sets up Tasks in a way that implements a staged installation + strategy where all of one thing is done before all of a next thing. + ''' + + # ----- Create a Task for all the anchor installs ----- + + # gather the relevant actions + acts = [] + for action in sc.actions.values(): + if action.type == 'install_anchor': + acts.append(action) + + # create a dictionary of dependencies indicating that these actions are all in series + act_sequence = {} # key is action name, value is a list of what action names are to be completed before it + for i in range(len(acts)): + if i==0: # first action has no dependencies + act_sequence[acts[i].name] = [] + else: # remaining actions are just a linear sequence + act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + + sc.addTask(acts, act_sequence, 'install_all_anchors') + + # ----- Create a Task for all the mooring installs ----- + + + + + # ----- Create a Task for the platform tow-out and hookup ----- + + + + + if __name__ == '__main__': '''This is currently a script to explore how some of the workflow could work. Can move things into functions/methods as they solidify. @@ -436,17 +560,18 @@ def findCompatibleVessels(self): # ----- Generate tasks (groups of Actions according to specific strategies) ----- - tasks = [] - t1 = Task(sc.actions, 'install_mooring_system') - tasks.append(t1) + + #t1 = Task(sc.actions, 'install_mooring_system') # ----- Do some graph analysis ----- G = sc.visualizeActions() + # make some tasks with one strategy... + implementStrategy_staged(sc) - + # dt_min = sc.figureOutTaskRelationships() @@ -460,8 +585,8 @@ def findCompatibleVessels(self): # ----- Generate the task_asset_matrix for scheduler ----- # UNUSED FOR NOW - task_asset_matrix = np.zeros((len(tasks), len(sc.vessels), 2)) - for i, task in enumerate(tasks): + task_asset_matrix = np.zeros((len(sc.tasks), len(sc.vessels), 2)) + for i, task in enumerate(sc.tasks.values()): row = task.get_row(sc.vessels) if row.shape != (len(sc.vessels), 2): raise Exception(f"Task '{task.name}' get_row output has wrong shape {row.shape}, should be {(2, len(sc.vessels))}") diff --git a/famodel/irma/task.py b/famodel/irma/task.py index cafc4059..baf3f377 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -32,15 +32,19 @@ class Task(): ''' - def __init__(self, actionList, name, **kwargs): + def __init__(self, actions, action_sequence, name, **kwargs): '''Create an action object... It must be given a name and a list of actions. The action list should be by default coherent with actionTypes dictionary. Parameters ---------- - actionList : list + actions : list A list of all actions that are part of this task. + action_sequence : dict + A dictionary where each key is the name of each action, and the values are + each a list of which actions (by name) must be completed before the current + one. name : string A name for the action. It may be appended with numbers if there are duplicate names. @@ -49,18 +53,35 @@ def __init__(self, actionList, name, **kwargs): ''' - self.actionList = actionList # all actions that are carried out in this task + # Make a dict by name of all actions that are carried out in this task + self.actions = {} + for act in actions: + self.actions[act.name] = act + + + # Create a graph of the sequence of actions in this task based on action_sequence + + # >>> Rudy to do <<< + self.name = name + self.status = 0 # 0, waiting; 1=running; 2=finished + self.actions_ti = {} # relative start time of each action [h] + self.t_actions = {} # timing of task's actions, relative to t1 [h] + # t_actions is a dict with keys same as action names, and entries of [t1, t2] + self.duration = 0 # duration must be calculated based on lengths of actions self.cost = 0 # cost must be calculated based on the cost of individual actions. - + self.ti =0 # task start time [h?] + self.tf =0 # task end time [h?] + # what else do we need to initialize the task? # internal graph of the actions within this task. self.G = self.getTaskGraph() + def organizeActions(self): '''Organizes the actions to be done by this task into the proper order based on the strategy of this type of task... @@ -77,6 +98,15 @@ def calcDuration(self): individual actions and their order of operation.''' # Does Rudy have graph-based code that can do this? + + + + + + + + + def getTaskGraph(self): '''Generate a graph of the action dependencies. @@ -84,15 +114,17 @@ def getTaskGraph(self): # Create the graph G = nx.DiGraph() - for item, data in self.actionList.items(): + for item, data in self.actions.items(): for dep in data.dependencies: G.add_edge(dep, item, duration=data.duration) # Store duration as edge attribute # Compute longest path & total duration longest_path = nx.dag_longest_path(G, weight='duration') longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs - total_duration = sum(self.actionList[node].duration for node in longest_path) + + total_duration = sum(self.actions[node].duration for node in longest_path) return G + def get_row(self, assets): '''Get a matrix of (cost, duration) tuples for each asset to perform this task. Will be a row in the task_asset matrix. @@ -116,9 +148,9 @@ def get_row(self, assets): # Could look something like... ''' for i, asset in enumerate(assets): - for action in self.actionList: # can we do this without the double loop? + for action in self.actions: # can we do this without the double loop? if asset in action.roles: - action = self.actionList[asset.name] + action = self.actions[asset.name] matrix[i, 0] = action.cost matrix[i, 1] = action.duration else: @@ -126,4 +158,5 @@ def get_row(self, assets): matrix[i, 1] = -1 ''' - return np.zeros((len(assets), 2)) # placeholder, replace with actual matrix \ No newline at end of file + return np.zeros((len(assets), 2)) # placeholder, replace with actual matrix + From be1ea51963a4c68278d9d8350870e1de6c4cb878 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Wed, 10 Sep 2025 15:27:04 -0600 Subject: [PATCH 24/63] Updates to scheduler constraints and start of docs: - Adds constraint 10 to scheduler, enforcing tasks cannot be assigned to periods where they don't have enough time to be completed - Adds ideas for constraints 2 and 9, but further work is needed - Adds a scheduluerREADME with theory information. This can be generated automatically from the docstrings in constraint explainations. - Adds a more complex example case to the main block of file for testing --- famodel/irma/scheduler.py | 343 ++++++++++++++++++++++++-------- famodel/irma/schedulerREADME.md | 90 +++++++++ 2 files changed, 354 insertions(+), 79 deletions(-) create mode 100644 famodel/irma/schedulerREADME.md diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index c9c3c847..30485aca 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -12,6 +12,9 @@ - [] Add a delay cost, i.e. a cost for each time period where X = 0 - [] Do we want to return any form of info dictionary? - [] Figure out if this can be parallelized +- [] Consolidate the loops in the constraints building section +- [] Figure out how to determine which constraint is violated if the problem is infeasible +- [] Add testing ''' from famodel.irma.task import Task @@ -34,7 +37,7 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset ------ task_asset_matrix : array-like A 3D array of (cost, duration) tuples indicating the cost and duration for each asset to perform each task. - Must be len(tasks) x len(assets) x 2. + Must be len(tasks) x len(assets) x 2. NOTE: The duration must be in units of scheduling periods (same as weather period length). tasks : list A list of Task objects to be scheduled. assets : list @@ -61,12 +64,7 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset self.assets = assets self.weather = weather self.task_dependencies = task_dependencies - - self.num_tasks = len(self.tasks) - self.num_assets = len(self.assets) - self.period_duration = period_duration # duration of each scheduling period. Used for converting from periods to real time. - self.num_periods = len(weather) # number of scheduling periods # --- Check for valid inputs --- @@ -97,8 +95,17 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset if self.period_duration <= 0: raise ValueError("period_duration must be positive non-zero.") + # --- Process inputs --- + + self.num_tasks = len(self.tasks) + self.num_assets = len(self.assets) + self.num_periods = len(weather) # number of scheduling periods + + # Checks for negative duration and cost in task_asset_matrix (0 cost or duration permitted) + self.num_valid_ta_pairs = int(np.sum((self.task_asset_matrix[:,:,0] >=0) | (self.task_asset_matrix[:,:,1] >= 0))) # number of valid task-asset pairs (cost*duration >= 0) + # --- Debug helpers --- - # make a list of indicies to help with building constraints + # make a list of indices to help with building constraints self.x_indices = [] for p in range(self.num_periods): for t in range(self.num_tasks): @@ -181,7 +188,7 @@ def set_up_optimizer(self, goal : str = "cost"): # lb <= x <= ub # Constrain decision variables to be 0 or 1 bounds = optimize.Bounds(0, 1) # 0 <= x_i <= 1 - integrality = np.full_like(decision_vars, True) # x_i are integers + integrality = np.ones(decision_vars.shape) # x_i are int. So set integrality to 1 if wordy > 0: print("Bounds and integrality for decision variables set. Begining to build constraints...") @@ -195,7 +202,7 @@ def set_up_optimizer(self, goal : str = "cost"): ''' A note on constraints: There are two constraint matrices, the equality constraints (A_eq, b_eq) and the upper bound constraints (A_ub, b_ub). Each row in the coefficient matrices corresponds to a constraint, and each column corresponds to a decision variable. Thus the number of columns - is equal to the number of decision variables (num_periods * num_tasks * num_assets), and the number of rows is equal to the number of constraints. + is equal to the number of decision variables (P * T * A), and the number of rows is equal to the number of constraints. Similarly, the length of the limits matrices (b_eq, b_ub) is equal to the number of constraints. The equality constraints are expressed in the form A_eq * x = b_eq. Where A_eq is the coefficient matrix and b_eq is the limits matrix. @@ -215,18 +222,16 @@ def set_up_optimizer(self, goal : str = "cost"): The lower and upper bound constraints on the decision variables (lb <= x <= ub) is handled above, limiting them to integer values of 0 or 1. The shape of decision vars is: - period_0_task_0_asset_0, period_0_task_0_asset_1, ..., period_0_task_1_asset_0, ..., period_1_task_0_asset_0, ... - or equivalently: - period_0_task_0_asset_0, ..., period_0_task_0_asset_max, period_0_task_1_asset_0, ..., period_0_task_max_asset_max, period_1_task_0_asset_0, ..., period_max_task_max_asset_max, + x_{p,t,a} for p in 0:P, t in 0:T, a in 0:A Constraints column (decision variable) indexing used in definitions below: - x = x_000, ..., x_00a, x_001, ..., x_00a, ..., x_0t1, ..., x_0ta, # task asset pairings in period 0 - x_100, ..., x_10a, x_101, ..., x_10a, ..., x_1t1, ..., x_1ta, # task asset pairings in period 1 + x = x_000, ..., x_00A, x_001, ..., x_00A, ..., x_0T1, ..., x_0TA, # task asset pairings in period 0 + x_100, ..., x_10A, x_101, ..., x_10A, ..., x_1T1, ..., x_1TA, # task asset pairings in period 1 ..., - x_p00, ..., x_p0a, x_p21, ..., x_p2a, ..., x_pt1, ..., x_pta # task asset pairings in period p + x_P00, ..., x_P0A, x_P21, ..., x_P2A, ..., x_PT1, ..., x_PTA # task asset pairings in period p where: - - 0:p is the period index, 0:t is the task index, and 0:a is the asset index + - p is the period index (0 to P), t is the task index (0 to T), and a is the asset index (0 to A) - x is the flattened decision variable tensor X[p, t, a] ''' @@ -243,7 +248,7 @@ def set_up_optimizer(self, goal : str = "cost"): # 0) Total number of assignments needs to be less than or equal to the number of periods available ''' the sum of the total amount of periods assigned to tasks cannot be more than the total number of periods available: - (x_000 + ... + x_pta) <= N + (x_000 + ... + x_pta) <= P ''' # 1 row @@ -260,13 +265,13 @@ def set_up_optimizer(self, goal : str = "cost"): ''' if task j cannot be performed by asset k, then x_pjk = 0 for all periods p - (x_0jk + ... + x_pjk) = 0 # for all tasks j in range(0:t) and assets k in range(0:a) where task_asset_matrix[j, k, goal_index] < 0 + (x_0jk + ... + x_pjk) = 0 # for all tasks j in range(0:t) and assets k in range(0:a) where task_asset_matrix[j, k, goal_index] <= 0 ''' mask = np.zeros(X.shape, dtype=int) for p in range(self.num_periods): - mask[p,:,:] = self.task_asset_matrix[:, :, goal_index] < 0 # Create a mask of invalid task-asset pairings where cost is negative (indicating invalid) + mask[p,:,:] = self.task_asset_matrix[:, :, goal_index] <= 0 # Create a mask of invalid task-asset pairings where cost is negative (indicating invalid) # 1 row A_eq_1 = mask.flatten().reshape(1, -1) # Flatten the mask to match the decision variable shape, and reshape to be a single row @@ -281,88 +286,234 @@ def set_up_optimizer(self, goal : str = "cost"): b_eq_list.append(b_eq_1) # 2) task dependencies must be respected (i.e., a task cannot start until all its dependencies have been satisfied) - # TODO: enforce task dependencies + # ''' + # This enforces task dependencies by ensuring that a task can only be assigned to a time period if all its dependencies have been completed in previous periods. + # TODO: right now this doesnt necessarily enforce that the dependency task has been completed, just that it was assigned in a previous period. This will need to change when + # tasks are assigned to the number of periods their duration is. + + # The period of the dependency task must be less than the period of the current task: + # p * x_pra < p * x_pta # for each task t in tasks, for all dependencies r in task_dependencies[t], for all assets a in assets, for all periods p in periods + # ''' + + # # Number of task dependency rows + # A_lb_2 = np.zeros((len(self.task_dependencies), len(decision_vars)), dtype=int) + # b_lb_2 = np.zeros(len(self.task_dependencies), dtype=int) + + # # Extract dependencies from task_dependencies dict + # index = 0 + # for task, deps in self.task_dependencies.items(): + + # # if the task has dependencies, build the constraint + # if len(deps) > 0: + + # # get task index by matching task name and index in self.tasks + # if task not in self.tasks: + # raise ValueError(f"Task '{task}' in task_dependencies not found in tasks list.") + + # t = self.tasks.index(task) + + # mask = np.zeros(X.shape, dtype=int) + + # for dep in deps: + # # get task index by matching dependency name and index in self.tasks + # if dep not in self.tasks: + # raise ValueError(f"Dependency task '{dep}' for task '{task}' not found in tasks list.") + # r = self.tasks.index(dep) # get index of dependency + + # # TODO: need to figure out how to enforce / track temporal ordering of tasks + + # A_lb_2[index, :] = mask.flatten() + # index += 1 + + # if wordy > 2: + # print("A_lb_2^T:") + # print(" T1 T2 ") # Header for 2 tasks + # for i in range(A_lb_2.transpose().shape[0]): + # pstring = str(self.x_indices[i]) + # for column in A_lb_2.transpose()[i]: + # pstring += f"{ column:5}" + # print(pstring) + # print("b_lb_2: ", b_lb_2) + + # if wordy > 0: + # print("Constraint 2 built.") # 3) assets cannot be assigned in a time period where the weather is above the maximum capacity # TODO: weather is disabled until this is added # 4) assets cannot be assigned to multiple tasks in the same time period ''' - this is a simplification, eventually we may want to allow multiple assets per task, or parallel tasks - - Sum of all pairings in a period must be <= 1: - (x_000 + ... + x_0ta) <= 1 # for period 0 - (x_100 + ... + x_1ta) <= 1 # for period 1 + this is a simplification, eventually we want to allow multiple assets per task, or parallel tasks + Sum of all asset-period pairs must be <= 1: + + (x_000 + ... + x_pt0) <= 1 # for asset 0 + (x_001 + ... + x_pt1) <= 1 # for asset 1 ... - (x_p00 + ... + x_pta) <= 1 # for period p + (x_00a + ... + x_pta) <= 1 # for asset t ''' - # num_periods rows - A_ub_4 = np.zeros((self.num_periods, len(decision_vars)), dtype=int) - b_ub_4 = np.ones(self.num_periods, dtype=int) # right-hand side is 1 for each period + # num-periods * num_assets rows + A_ub_4 = np.zeros((self.num_periods * self.num_assets, len(decision_vars)), dtype=int) + b_ub_4 = np.ones(self.num_periods * self.num_assets, dtype=int) # right-hand side is 1 for each asset + index = 0 for p in range(self.num_periods): - # Create a mask for all variables in period p - mask = np.zeros(X.shape, dtype=int) - mask[p, :, :] = 1 # Set all task-asset pairings in period p to 1 (so they are included in the sum) - A_ub_4[p, :] = mask.flatten() - + for a in range(self.num_assets): + # Create a mask for all variables for asset a + mask = np.zeros(X.shape, dtype=int) + mask[p, :, a] = 1 # Set all periods and tasks for asset a to 1 (so they are included in the sum) + + A_ub_4[index, :] = mask.flatten() + index += 1 + + if wordy > 2: + print("A_ub_4^T:") + print(" P1A1 P1A2 P2A1") # Header for 2 tasks and 2 assets example with T2A2 invalid + for i in range(A_ub_4.transpose().shape[0]): + pstring = str(self.x_indices[i]) + for column in A_ub_4.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_ub_4: ", b_ub_4) + A_ub_list.append(A_ub_4) b_ub_list.append(b_ub_4) if wordy > 0: print("Constraint 4 built.") - # 5) The total number of tasks assigned cannot be greater than the number of tasks available + # 5) The total number of tasks assigned cannot be greater than the number of tasks available (NOTE: Is this necessary or is it already enforced by the fact that there t = number of tasks?) # TODO: enforce task limits - # 6) The total number of assets assigned cannot be greater than the number of assets available + # 6) The total number of assets assigned cannot be greater than the number of assets available (NOTE: Is this necessary or is it already enforced by the fact that there a = number of assets?) # TODO: enforce asset limits - # 7) There is a penalty associated with a time period with no assigned tasks - # TODO: This delay costs enforces tasks are finished as soon as possible + # 7) Ensure tasks are assigned as early as possible + ''' + A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. + ''' + # TODO: implement this constraint - # 8) All tasks must be assigned to a time period once + # 8) All tasks must be assigned to at least one time period ''' - The sum of all decision variables for each task must equal 1, indicating that each task is assigned to exactly one time period. This does not support parallel tasks. + The sum of all decision variables for each task must be greater than 1, indicating all tasks were assigned at least once: - (x_000 + ... + x_p0a) = 1 # for task 0 - (x_010 + ... + x_p1a) = 1 # for task 1 + (x_000 + ... + x_p0a) >= 1 # for task 0 + (x_010 + ... + x_p1a) >= 1 # for task 1 ... - (x_pta + ... + x_pta) = 1 # for task t + (x_0t0 + ... + x_pta) >= 1 # for task t ''' # num_tasks rows - A_eq_8 = np.zeros((self.num_tasks, len(decision_vars)), dtype=int) - b_eq_8 = np.ones(self.num_tasks, dtype=int) + A_lb_8 = np.zeros((self.num_tasks, len(decision_vars)), dtype=int) + b_lb_8 = np.ones(self.num_tasks, dtype=int) for t in range(self.num_tasks): # Create a mask for all variables for task t mask = np.zeros(X.shape, dtype=int) mask[:, t, :] = 1 # Set all periods and assets for task t to 1 (so they are included in the sum) - A_eq_8[t, :] = mask.flatten() + A_lb_8[t, :] = mask.flatten() - if wordy > 0: + if wordy > 2: print(f"Task {t}:") for i in range(len(self.x_indices)): - print(f" {self.x_indices[i]}: {A_eq_8[t, i]}") + print(f" {self.x_indices[i]}: {A_lb_8[t, i]}") - A_eq_list.append(A_eq_8) - b_eq_list.append(b_eq_8) + A_lb_list.append(A_lb_8) + b_lb_list.append(b_lb_8) if wordy > 0: print("Constraint 8 built.") - # 9) A task must be assigned to the number of time periods equal to its duration for the asset assigned to it - # TODO: this enforces task lengths for assets that are assigned + # # 9) A task must be assigned to the continuous number of time periods equal to its duration for the asset assigned to it + # ''' + # This ensures the duration of a task-asset pair is respected. If a task has a duration of 3 periods, it must be assigned to 3 consecutive periods. + + # (x_ijk + x_(i+1)jk + ... + x_(i+d-1)jk) >= d # for all tasks j in range(0:t) and assets k in range(0:a) where d is the duration of task j with asset k, and i is the period index. This formulation does not allow for multiple assets per task + + # ''' + + # # num task-asset pairings rows + # A_eq_9 = np.zeros((self.num_valid_ta_pairs, len(decision_vars)), dtype=int) + # b_eq_9 = np.zeros(self.num_valid_ta_pairs, dtype=int) + + # # Loop through tasks and assets + # pair_i = 0 + # for t in range(self.num_tasks): + # for a in range(self.num_assets): + + # duration = self.task_asset_matrix[t, a, 1] # duration of task t with asset a + # if duration > 0: # If valid pairing, make constraint + + # # Create a mask for all variables for task t and asset a + # mask = np.zeros(X.shape, dtype=int) + # for p in range(self.num_periods): + # mask[p:p+duration, t, a] = 1 + + # b_eq_9[pair_i] = self.task_asset_matrix[t, a, 1] # Duration + # A_eq_9[pair_i, :] = mask.flatten() + # pair_i += 1 + + # if wordy > 0: + # # Print out the constraint matrix for debugging + # print("A_eq_9^T:") + # print(" T1A1 T1A2 T2A1") # Header for 2 tasks and 2 assets example with T2A2 invalid + # for i in range(A_eq_9.transpose().shape[0]): + # pstring = str(self.x_indices[i]) + # for column in A_eq_9.transpose()[i]: + # pstring += f"{ column:5}" + # print(pstring) + # print("b_eq_9: ", b_eq_9) + + # A_eq_list.append(A_eq_9) + # b_eq_list.append(b_eq_9) + + # if wordy > 0: + # print("Constraint 9 built.") + + # 10) A task duration plus the first time period it is assigned to must be less than the total number of time periods available + ''' + This ensures that a task is not assigned to a period that would cause it to exceed the total number of periods available. + (p * x_{p,t,a} + d_{t,a} * x_{p,t,a}) <= P # for all t in 0..T, a in 0..A, p in 0..P + ''' + + # num_periods rows + A_ub_10 = np.zeros((self.num_periods, len(decision_vars)), dtype=int) + b_ub_10 = np.ones(self.num_periods, dtype=int) * self.num_periods + + for p in range(self.num_periods): + # Create a mask for the period + mask = np.zeros(X.shape, dtype=int) + + # Loop through pairs + for t in range(self.num_tasks): + for a in range(self.num_assets): + duration = self.task_asset_matrix[t, a, 1] # duration of task t with asset a + if duration > 0: + mask[p, t, a] = p + duration # Set the specific variable to i + d_jk + + A_ub_10[p, :] = mask.flatten() + + if wordy > 2: + print(f"Period {p}:") + for i in range(len(self.x_indices)): + print(f" {self.x_indices[i]}: {A_ub_10[p, i]}") + print("Upper bound limit: ", b_ub_10[p]) + + A_ub_list.append(A_ub_10) + b_ub_list.append(b_ub_10) + if wordy > 0: + print("Constraint 10 built.") + + # --- End Constraints --- if wordy > 0: print("All constraints built. Stacking and checking constraints...") - # --- Build the constraints --- + # --- Assemble the SciPy Constraints --- # A series of linear constraints required by the solver by stacking the constraint matrices and limits vectors # The number of rows in these matrices is equal to the number of constraints, so they can be vertically stacked @@ -474,7 +625,7 @@ def optimize(self, threads = -1): if res.success: # Reshape the flat result back into the (num_periods, num_tasks, num_assets) shape - if wordy > 0: + if wordy > 1: print("Decision variable [periods][tasks][assets]:") for i in range(len(self.x_indices)): print(f" {self.x_indices[i]}: {res.x[i]}") @@ -487,7 +638,6 @@ def optimize(self, threads = -1): print_string = f"Period {p+1}:" whitespace = " " * (3 - len(str(p+1))) # adjust spacing for single vs double digit periods. Limited to 99 periods. print_string += whitespace - displayed = False for t in range(self.num_tasks): for a in range(self.num_assets): if X_optimal[p, t, a] == 1: @@ -495,58 +645,93 @@ def optimize(self, threads = -1): asset_name = self.assets[a]['name'] if 'name' in self.assets[a] else f"Asset {a+1}" cost = self.task_asset_matrix[t, a, 0] duration = self.task_asset_matrix[t, a, 1] - print_string += f"Task '{task_name}' assigned to Asset '{asset_name}' (Cost: {cost}, Duration: {duration})" - displayed = True - break # break because we only support one asset per task per period for now - else: - print_string += "No assignment" - displayed = True - break # break because we only support one asset per task per period for now - if displayed: - break + print_string += f"Asset '{asset_name}' assigned to Task '{task_name}' (Cost: {cost}, Duration: {duration})" print(print_string) if wordy > 0: - print("Optimization complete.") + print("Optimization function complete.") if __name__ == "__main__": - # A dummy system to test the scheduler + os.system("clear") # for clearing terminal on Mac - # 21 weather periods = 21 time periods - weather = [3]*5 + [2]*1 + [1]*10 + [2]*3 + [3]*2 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration + # A simple dummy system to test the scheduler + + # 10 weather periods = 10 time periods + weather = [1]*5 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration # Example tasks, assets, dependencies, and task_asset_matrix. Eventually try with more tasks than assets, more assets than tasks, etc. tasks = [ "task1", - "task2", - "task3" + "task2" ] assets = [ {"name": "asset1", "max_weather" : 3}, - {"name": "asset2", "max_weather" : 2}, - {"name": "asset3", "max_weather" : 1} + {"name": "asset2", "max_weather" : 2} ] # task dependencies task_dependencies = { - "task1": [], - "task2": ["task1_start"], - "task3": ["task1_end", "task2_start"] + "task1": ["task1"], + "task2": [] } # cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid task_asset_matrix = np.array([ - [(1000, 5), (2000, 3), (1500, 4)], # task 1: asset 1, asset 2, asset 3 - [(1200, 4), ( -1,-1), (1800, 3)], # task 2: asset 1, asset 2, asset 3 - [(1100, 6), (2100, 4), (1600, 5)] # task 3: asset 1, asset 2, asset 3 + [(1000, 2), (2000, 3)], # task 1: asset 1, asset 2 + [(1200, 5), ( -1,-1)] # task 2: asset 1, asset 2 ]) + # optimal assignment: task 1 with asset 1 in periods 1-2, task 2 with asset 1 in period 3 + # Find the minimum time period duration based on the task_asset_matrix min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration # Sandbox for building out the scheduler scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, weather, min_duration) - scheduler.optimize() \ No newline at end of file + scheduler.optimize() + + + + # # A more complex dummy system to test the scheduler (uncomment and comment out above to run) + + # # 10 weather periods = 10 time periods + # weather = [1]*5 + [2]*1 + [3]*1 + [1]*3 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration + + # # Example tasks, assets, dependencies, and task_asset_matrix. Eventually try with more tasks than assets, more assets than tasks, etc. + # tasks = [ + # "task1", + # "task2", + # "task3" + # ] + # assets = [ + # {"name": "asset1", "max_weather" : 3}, + # {"name": "asset2", "max_weather" : 2}, + # {"name": "asset3", "max_weather" : 1}, + # {"name": "asset4", "max_weather" : 1} + # ] + + # # task dependencies + # task_dependencies = { + # "task1": [], + # "task2": ["task1"], + # "task3": ["task1"] + # } + + # # random cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid + # task_asset_matrix = np.array([ + # [(3000, 2), (2000, 3), (1000, 4), (4000, 5)], # task 1: asset 1, asset 2, asset 3, asset 4 + # [(1200, 5), ( -1,-1), ( -1,-1), ( -1,-1)], # task 2: asset 1, asset 2, asset 3, asset 4 + # [(2500, 3), (1500, 2), ( -1,-1), ( -1,-1)] # task 3: asset 1, asset 2, asset 3, asset 4 + # ]) + + # # optimal assignment: task 1 with asset 1 in periods 1-2, task 2 with asset 1 in period 3 + + # # Find the minimum time period duration based on the task_asset_matrix + # min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration + + # # Sandbox for building out the scheduler + # scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, weather, min_duration) + # scheduler.optimize() \ No newline at end of file diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md new file mode 100644 index 00000000..be8195fd --- /dev/null +++ b/famodel/irma/schedulerREADME.md @@ -0,0 +1,90 @@ +# Scheduler Mathematical Formulation + +This document describes the mathematical formulation of the scheduling problem solved by the `Scheduler` class. + +## Sets and Indices +- $P$: Set of periods, $p = 1, \ldots, P$ +- $T$: Set of tasks, $t = 1, \ldots, T$ +- $A$: Set of assets, $a = 1, \ldots, A$ +- $R$: set of task requirements/dependencies, $r =1, \ldots, R \text{ where } R < T$ + +## Parameters +- $v_{t,a}$: Value of assigning asset $a$ to task $t$. Can be either cost or duration depending on user input. +- $c_{t,a}$: Cost of assigning asset $a$ to task $t$ +- $d_{t,a}$: Duration (in periods) required for asset $a$ to complete task $t$ + +## Decision Variables +- $x_{p,t,a} \in \{0,1\}$: 1 if asset $a$ is assigned to task $t$ in period $p$, 0 otherwise + +## Objective Function +Minimize total cost: + +$$ +\min \sum_{p \in P} \sum_{t \in T} \sum_{a \in A} \left( c_{t,a} \right) x_{p,t,a} +$$ + +## Constraints + +### 0. Total Assignment Limit +The sum of all assignments cannot exceed the number of periods: +$$ +\sum_{p \in P} \sum_{t \in T} \sum_{a \in A} x_{p,t,a} \leq P +$$ + +### 1. Task-Asset Validity +Only valid task-asset pairs can be assigned: +$$ +x_{p,t,a} = 0 \quad \forall p, t, a \text{ where } c_{t,a} < 0 \text{ or } d_{t,a} < 0 +$$ + +### 2. Task Dependencies (**Disabled/In-progress**) +Tasks with dependencies must be scheduled after their dependencies are completed. This might need to be reworked, still figuring out the best way to enforce temporal constraints. +$$ +p * x_{p,r,a} < p * x_{p,t,a} \quad t \in T, \forall r \in R_t, p \in P, a \in A +$$ + +### 3. Weather Constraints (**TODO**) +Assets cannot be assigned in periods where weather exceeds their capability. + +### 4. Asset Cannot Be Assigned to Multiple Tasks in Same Period +Each asset can be assigned to at most one task in each period: +$$ +\sum_{t \in T} x_{p,t,a} \leq 1 \quad \forall p \in P, a \in A +$$ + +### 5. Task Assignment Limit (**TODO**) +The total number of tasks assigned cannot exceed the number of tasks available. + +### 6. Asset Assignment Limit (**TODO**) +The total number of assets assigned cannot exceed the number of assets available. + +### 7. Early Assignment Constraint (**TODO**) +A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. + +### 8. All Tasks Must Be Assigned +Each task must be assigned at least once: +$$ +\sum_{p \in P} \sum_{a \in A} x_{p,t,a} \geq 1 \quad \forall t \in T +$$ + +### 9. Assignment Duration (**Disabled/In-progress**) +Each task-asset pair must be assigned for exactly its required duration: +$$ +\sum_{p \in P} x_{p,t,a} = d_{t,a} \quad \forall t \in T, a \in A \text{ with } d_{t,a} > 0 +$$ + +### 10. Assignment Window +A task cannot be assigned to periods that would exceed the available time window: +$$ +x_{p,t,a} = 0 \quad \forall p, t, a \text{ where } p + d_{t,a} > P +$$ + +--- + +**Notes:** +- Constraints marked **TODO** are not yet implemented in the code but are (likely) necessary for an optimal solution +- Constraints marked **Disabled/In-progress** are works in progress that if enabled cause an infeasible solution to be generated. +- Additional constraints (e.g., weather, dependencies) should be added. +- This approach isn't finalized. We may need additional decision variables if we want to have multiple objectives + - For example, one way to force earlier scheduling is to add a start-time decision variable that gives a penalty for later start-times + - Did not implement this because we may not want to force earlier start-times \ No newline at end of file From 118cd02bfbdfd213f62bdeca49d7cbc4fcf0dc38 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Thu, 11 Sep 2025 10:18:09 -0600 Subject: [PATCH 25/63] adding a sequence graph to the Task class --- famodel/irma/irma.py | 5 +- famodel/irma/task.py | 145 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index d23c9d68..beb1f0ec 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -450,7 +450,10 @@ def implementStrategy_staged(sc): act_sequence[acts[i].name] = [] else: # remaining actions are just a linear sequence act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) - + # Just for testing different examples. + # act_sequence = {'install_anchor-fowt0a': [], + # 'install_anchor-fowt0b': ['install_anchor-fowt0a'], + # 'install_anchor-fowt0c': ['install_anchor-fowt0a']} sc.addTask(acts, act_sequence, 'install_all_anchors') # ----- Create a Task for all the mooring installs ----- diff --git a/famodel/irma/task.py b/famodel/irma/task.py index baf3f377..ddc26055 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -20,7 +20,7 @@ class Task(): ''' A Task is a general representation of a set of marine operations - that follow a predefined sequency/strategy. There can be multiple + that follow a predefined sequence/strategy. There can be multiple tasks that achieve the same end, each providing an alternative strategy. Each Task consists of a set of Actions with internal dependencies. @@ -60,9 +60,8 @@ def __init__(self, actions, action_sequence, name, **kwargs): # Create a graph of the sequence of actions in this task based on action_sequence - - # >>> Rudy to do <<< - + self.getSequenceGraph(action_sequence) + self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished @@ -104,11 +103,145 @@ def calcDuration(self): + def getSequenceGraph(self, action_sequence): + '''Generate a multi-directed graph that visalizes action sequencing within the task. + Build a MultiDiGraph with nodes: + Start -> CP1 -> CP2 -> ... -> End + Checkpoints are computed from action "levels": + level(a) = 1 if no prerequisites. + level(a) = 1 + max(level(p) for p in prerequisites) 1 + the largest level among a’s prerequisites. + Number of checkpoints = max(level) - 1. + ''' + + # Compute levels + levels: dict[str, int] = {} + def level_of(a: str, b: set[str]) -> int: + '''Return the level of action a. b is the set of actions currently being explored''' + + # If we have already computed the level, return it + if a in levels: + return levels[a] + + # The action cannot be its own prerequisite + if a in b: + raise ValueError(f"Cycle detected in action sequence at '{a}' in task '{self.name}'. The action cannot be its own prerequisite.") + + b.add(a) + + # Look up prerequisites for action a. + pres = action_sequence.get(a, []) + if not pres: + lv = 1 # No prerequisites, level 1 + else: + # If a prerequisites name is not in the dict, treat it as a root (level 1) + lv = 1 + max(level_of(p, b) if p in action_sequence else 1 for p in pres) + + # b.remove(a) # if you want to unmark a from the explored dictionary, b, uncomment this line. + levels[a] = lv + return lv + + for a in action_sequence: + level_of(a, set()) + + max_level = max(levels.values(), default=1) + num_cps = max(0, max_level - 1) + + H = nx.MultiDiGraph() + + # Add the Start -> [checkpoints] -> End nodes + H.add_node("Start") + for i in range(1, num_cps + 1): + H.add_node(f"CP{i}") + H.add_node("End") + + shells = [["Start"]] + if num_cps > 0: + # Middle shells + cps = [f"CP{i}" for i in range(1, num_cps + 1)] + shells.append(cps) + shells.append(["End"]) + + pos = nx.shell_layout(H, nlist=shells) + + xmin, xmax = -2.0, 2.0 + pos["Start"] = (xmin, 0) + pos["End"] = (xmax, 0) + + # Add action edges + # Convention: + # level 1 actions: Start -> CP1 (or Start -> End if no CPs) + # level L actions (2 <= L < max_level): CP{L-1} -> CP{L} + # level == max_level actions: CP{num_cps} -> End + for action, lv in levels.items(): + action = self.actions[action] + if num_cps == 0: + # No checkpoints: all actions from Start to End + H.add_edge("Start", "End", key=action, duration=action.duration, cost=action.cost) + else: + if lv == 1: + H.add_edge("Start", "CP1", key=action, duration=action.duration, cost=action.cost) + elif lv < max_level: + H.add_edge(f"CP{lv-1}", f"CP{lv}", key=action, duration=action.duration, cost=action.cost) + else: # lv == max_level + H.add_edge(f"CP{num_cps}", "End", key=action, duration=action.duration, cost=action.cost) + + fig, ax = plt.subplots() + # pos = nx.shell_layout(G) + nx.draw(H, pos, with_labels=True, node_size=500, node_color="lightblue", edge_color='white') + + # Group edges by unique (u, v) pairs + for (u, v) in set((u, v) for u, v, _ in H.edges(keys=True)): + # get all edges between u and v (dict keyed by edge key) + edge_dict = H.get_edge_data(u, v) # {key: {attrdict}, ...} + n = len(edge_dict) + + # curvature values spread between -0.3 and +0.3 + if n==1: + rads = [0] + else: + rads = np.linspace(-0.3, 0.3, n) + + # draw each edge + durations = [d.get("duration", 0.0) for d in edge_dict.values()] + scale = max(max(durations), 0.0001) + width_scale = 4.0 / scale # normalize largest to ~4px + + for rad, (k, d) in zip(rads, edge_dict.items()): + nx.draw_networkx_edges( + H, pos, edgelist=[(u, v)], ax=ax, + connectionstyle=f"arc3,rad={rad}", + arrows=True, arrowstyle="-|>", + edge_color="gray", + width=max(0.5, d.get("duration", []) * width_scale), + ) + + # --- after drawing edges --- + edge_labels = {} + for u, v, k, d in H.edges(keys=True, data=True): + # each edge may have a unique key; include it in the label if desired + label = k.name + edge_labels[(u, v, k)] = label + + nx.draw_networkx_edge_labels( + H, + pos, + edge_labels=edge_labels, + font_size=8, + label_pos=0.5, # position along edge (0=start, 0.5=middle, 1=end) + rotate=False # keep labels horizontal + ) + + ax.axis("off") + plt.tight_layout() + plt.show() + self.sequence_graph = H + return H + - def getTaskGraph(self): + def getTaskGraph(self, plot=True): '''Generate a graph of the action dependencies. ''' @@ -122,7 +255,7 @@ def getTaskGraph(self): longest_path = nx.dag_longest_path(G, weight='duration') longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs - total_duration = sum(self.actions[node].duration for node in longest_path) + total_duration = sum(self.actions[node].duration for node in longest_path) return G From 9018f791900d2070bca47746b074dfd12d227f23 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Thu, 11 Sep 2025 12:24:09 -0600 Subject: [PATCH 26/63] Scheduler.py console printing improvements: - Adds printing of the constraint matrix for each constraint if wordy = 2 - Additional printing possible with wordy = 3 - Wordy = 0 disables all printing - Fixes typo in docs for constriant 2 --- famodel/irma/scheduler.py | 52 +++++++++++++++++++++++++++++---- famodel/irma/schedulerREADME.md | 2 +- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 30485aca..14cb00c6 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -24,7 +24,7 @@ import numpy as np import os -wordy = 1 # level of verbosity for print statements +wordy = 2 # level of verbosity for print statements class Scheduler: @@ -255,6 +255,15 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_0 = np.ones((1, len(decision_vars)), dtype=int) # Every period assigned to a task counts as 1 towards the total assigned periods. This assumes one pair per period b_ub_0 = np.array([self.num_periods]) + if wordy > 1: + print("A_ub_0^T:") + for i in range(A_ub_0.transpose().shape[0]): + pstring = str(self.x_indices[i]) + for column in A_ub_0.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_ub_0: ", b_ub_0) + A_ub_list.append(A_ub_0) b_ub_list.append(b_ub_0) @@ -282,9 +291,21 @@ def set_up_optimizer(self, goal : str = "cost"): for i in range(len(self.x_indices)): print(f" {self.x_indices[i]}: {A_eq_1[0, i]}") + if wordy > 1: + print("A_eq_1^T:") + for i in range(A_eq_1.transpose().shape[0]): + pstring = str(self.x_indices[i]) + for column in A_eq_1.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_eq_1: ", b_eq_1) + A_eq_list.append(A_eq_1) b_eq_list.append(b_eq_1) + if wordy > 0: + print("Constraint 1 built.") + # 2) task dependencies must be respected (i.e., a task cannot start until all its dependencies have been satisfied) # ''' # This enforces task dependencies by ensuring that a task can only be assigned to a time period if all its dependencies have been completed in previous periods. @@ -325,7 +346,7 @@ def set_up_optimizer(self, goal : str = "cost"): # A_lb_2[index, :] = mask.flatten() # index += 1 - # if wordy > 2: + # if wordy > 1: # print("A_lb_2^T:") # print(" T1 T2 ") # Header for 2 tasks # for i in range(A_lb_2.transpose().shape[0]): @@ -366,9 +387,9 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_4[index, :] = mask.flatten() index += 1 - if wordy > 2: + if wordy > 1: print("A_ub_4^T:") - print(" P1A1 P1A2 P2A1") # Header for 2 tasks and 2 assets example with T2A2 invalid + print(" P1A1 P1A2 P2A1 P2A2 P3A1 P3A2 P4A1 P4A2 P5A1 P5A2") # header for 5 periods and 2 assets example for i in range(A_ub_4.transpose().shape[0]): pstring = str(self.x_indices[i]) for column in A_ub_4.transpose()[i]: @@ -420,6 +441,16 @@ def set_up_optimizer(self, goal : str = "cost"): for i in range(len(self.x_indices)): print(f" {self.x_indices[i]}: {A_lb_8[t, i]}") + if wordy > 1: + print("A_lb_8^T:") + print(" T1 T2") # Header for 2 tasks + for i in range(A_lb_8.transpose().shape[0]): + pstring = str(self.x_indices[i]) + for column in A_lb_8.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_lb_8: ", b_lb_8) + A_lb_list.append(A_lb_8) b_lb_list.append(b_lb_8) @@ -455,7 +486,7 @@ def set_up_optimizer(self, goal : str = "cost"): # A_eq_9[pair_i, :] = mask.flatten() # pair_i += 1 - # if wordy > 0: + # if wordy > 1: # # Print out the constraint matrix for debugging # print("A_eq_9^T:") # print(" T1A1 T1A2 T2A1") # Header for 2 tasks and 2 assets example with T2A2 invalid @@ -502,6 +533,17 @@ def set_up_optimizer(self, goal : str = "cost"): print(f" {self.x_indices[i]}: {A_ub_10[p, i]}") print("Upper bound limit: ", b_ub_10[p]) + + if wordy > 1: + print("A_ub_10^T:") + print(" P1 P2 P3 P4 P5") # Header for 5 periods + for i in range(A_ub_10.transpose().shape[0]): + pstring = str(self.x_indices[i]) + for column in A_ub_10.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_ub_10: ", b_ub_10) + A_ub_list.append(A_ub_10) b_ub_list.append(b_ub_10) diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md index be8195fd..80994497 100644 --- a/famodel/irma/schedulerREADME.md +++ b/famodel/irma/schedulerREADME.md @@ -40,7 +40,7 @@ $$ ### 2. Task Dependencies (**Disabled/In-progress**) Tasks with dependencies must be scheduled after their dependencies are completed. This might need to be reworked, still figuring out the best way to enforce temporal constraints. $$ -p * x_{p,r,a} < p * x_{p,t,a} \quad t \in T, \forall r \in R_t, p \in P, a \in A +p * x_{p,r,a} + d_r < p * x_{p,t,a} \quad t \in T, \forall r \in R_t, p \in P, a \in A $$ ### 3. Weather Constraints (**TODO**) From bd13d26fc1ed0d7b7169d200a5a08efc6e12f2ad Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Thu, 11 Sep 2025 15:30:21 -0600 Subject: [PATCH 27/63] Updating duration, actions_ti, and other features in Task.py based on the sequence graph: - sequence graph simplified (without labels) and with a title that describes the actions that are done by each checkpoint (and/or end) node. - sequence graph calculates the total duration based on the thickest edge (largest duration) between the checkpoint nodes. - assigns the duration back to task self while also populating actions_ti relative starting time of each action in the self. --- famodel/irma/irma.py | 4 -- famodel/irma/task.py | 135 ++++++++++++++++++++++++------------------- 2 files changed, 77 insertions(+), 62 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index beb1f0ec..080351f9 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -450,10 +450,6 @@ def implementStrategy_staged(sc): act_sequence[acts[i].name] = [] else: # remaining actions are just a linear sequence act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) - # Just for testing different examples. - # act_sequence = {'install_anchor-fowt0a': [], - # 'install_anchor-fowt0b': ['install_anchor-fowt0a'], - # 'install_anchor-fowt0c': ['install_anchor-fowt0a']} sc.addTask(acts, act_sequence, 'install_all_anchors') # ----- Create a Task for all the mooring installs ----- diff --git a/famodel/irma/task.py b/famodel/irma/task.py index ddc26055..c67c5fb1 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -59,16 +59,11 @@ def __init__(self, actions, action_sequence, name, **kwargs): self.actions[act.name] = act - # Create a graph of the sequence of actions in this task based on action_sequence - self.getSequenceGraph(action_sequence) self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished - self.actions_ti = {} # relative start time of each action [h] - self.t_actions = {} # timing of task's actions, relative to t1 [h] - # t_actions is a dict with keys same as action names, and entries of [t1, t2] self.duration = 0 # duration must be calculated based on lengths of actions self.cost = 0 # cost must be calculated based on the cost of individual actions. @@ -77,9 +72,14 @@ def __init__(self, actions, action_sequence, name, **kwargs): # what else do we need to initialize the task? - # internal graph of the actions within this task. - self.G = self.getTaskGraph() - + # Create a graph of the sequence of actions in this task based on action_sequence + self.getSequenceGraph(action_sequence, plot=True) # this also updates duration + + self.cost = sum(action.cost for action in self.actions.values()) + 0 + print(f"---------------------- Initializing Task '{self.name} ----------------------") + print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.") + print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ") def organizeActions(self): '''Organizes the actions to be done by this task into the proper order @@ -103,7 +103,7 @@ def calcDuration(self): - def getSequenceGraph(self, action_sequence): + def getSequenceGraph(self, action_sequence, plot=True): '''Generate a multi-directed graph that visalizes action sequencing within the task. Build a MultiDiGraph with nodes: Start -> CP1 -> CP2 -> ... -> End @@ -164,7 +164,7 @@ def level_of(a: str, b: set[str]) -> int: pos = nx.shell_layout(H, nlist=shells) - xmin, xmax = -2.0, 2.0 + xmin, xmax = -2.0, 2.0 # maybe would need to change those later on. pos["Start"] = (xmin, 0) pos["End"] = (xmax, 0) @@ -186,57 +186,76 @@ def level_of(a: str, b: set[str]) -> int: else: # lv == max_level H.add_edge(f"CP{num_cps}", "End", key=action, duration=action.duration, cost=action.cost) - fig, ax = plt.subplots() - # pos = nx.shell_layout(G) - nx.draw(H, pos, with_labels=True, node_size=500, node_color="lightblue", edge_color='white') - - # Group edges by unique (u, v) pairs - for (u, v) in set((u, v) for u, v, _ in H.edges(keys=True)): - # get all edges between u and v (dict keyed by edge key) - edge_dict = H.get_edge_data(u, v) # {key: {attrdict}, ...} - n = len(edge_dict) - # curvature values spread between -0.3 and +0.3 - if n==1: - rads = [0] - else: - rads = np.linspace(-0.3, 0.3, n) - - # draw each edge - durations = [d.get("duration", 0.0) for d in edge_dict.values()] - scale = max(max(durations), 0.0001) - width_scale = 4.0 / scale # normalize largest to ~4px - - for rad, (k, d) in zip(rads, edge_dict.items()): - nx.draw_networkx_edges( - H, pos, edgelist=[(u, v)], ax=ax, - connectionstyle=f"arc3,rad={rad}", - arrows=True, arrowstyle="-|>", - edge_color="gray", - width=max(0.5, d.get("duration", []) * width_scale), - ) - - # --- after drawing edges --- - edge_labels = {} - for u, v, k, d in H.edges(keys=True, data=True): - # each edge may have a unique key; include it in the label if desired - label = k.name - edge_labels[(u, v, k)] = label - - nx.draw_networkx_edge_labels( - H, - pos, - edge_labels=edge_labels, - font_size=8, - label_pos=0.5, # position along edge (0=start, 0.5=middle, 1=end) - rotate=False # keep labels horizontal - ) - - ax.axis("off") - plt.tight_layout() - plt.show() + # 3. Compute cumulative start time for each level + level_groups = {} + for action, lv in levels.items(): + level_groups.setdefault(lv, []).append(action) + level_durations = {lv: max(self.actions[a].duration for a in acts) + for lv, acts in level_groups.items()} + + task_duration = sum(level_durations.values()) + + level_start_time = {} + elapsed = 0.0 + cp_string = [] + for lv in range(1, max_level + 1): + level_start_time[lv] = elapsed + elapsed += level_durations.get(lv, 0.0) + # also collect all actions at this level for title + acts = [a for a, l in levels.items() if l == lv] + if acts and lv <= num_cps: + cp_string.append(f"CP{lv}: {', '.join(acts)}") + elif acts and lv > num_cps: + cp_string.append(f"End: {', '.join(acts)}") + + # Assign to self: + self.duration = task_duration + self.actions_ti = {a: level_start_time[lv] for a, lv in levels.items()} self.sequence_graph = H + + title_str = f"Task {self.name}. Duration {self.duration:.2f} : " + " | ".join(cp_string) + + if plot: + fig, ax = plt.subplots() + # pos = nx.shell_layout(G) + nx.draw(H, pos, with_labels=True, node_size=500, node_color="lightblue", edge_color='white') + + label_positions = {} # to store label positions for each edge + # Group edges by unique (u, v) pairs + for (u, v) in set((u, v) for u, v, _ in H.edges(keys=True)): + # get all edges between u and v (dict keyed by edge key) + edge_dict = H.get_edge_data(u, v) # {key: {attrdict}, ...} + n = len(edge_dict) + + # curvature values spread between -0.3 and +0.3 [helpful to visualize multiple edges] + if n==1: + rads = [0] + offsets = [0.5] + else: + rads = np.linspace(-0.3, 0.3, n) + offsets = np.linspace(0.2, 0.8, n) + + # draw each edge + durations = [d.get("duration", 0.0) for d in edge_dict.values()] + scale = max(max(durations), 0.0001) # avoid div by zero + width_scale = 4.0 / scale # normalize largest to ~4px + + for rad, offset, (k, d) in zip(rads, offsets, edge_dict.items()): + nx.draw_networkx_edges( + H, pos, edgelist=[(u, v)], ax=ax, + connectionstyle=f"arc3,rad={rad}", + arrows=True, arrowstyle="-|>", + edge_color="gray", + width=max(0.5, d.get("duration", []) * width_scale), + ) + label_positions[(u, v, k)] = offset # store position for edge label + + ax.set_title(title_str, fontsize=12, fontweight="bold") + ax.axis("off") + plt.tight_layout() + return H From ae7171e47f6dd11a14d45e4331c98ff4dfaf4dbf Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Thu, 11 Sep 2025 15:31:39 -0600 Subject: [PATCH 28/63] Updating duration, actions_ti, and other features in Task.py based on the sequence graph: - sequence graph simplified (without labels) and with a title that describes the actions that are done by each checkpoint (and/or end) node. - sequence graph calculates the total duration based on the thickest edge (largest duration) between the checkpoint nodes. - assigns the duration back to task self while also populating actions_ti relative starting time of each action in the self. + removed a zero --- famodel/irma/task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/famodel/irma/task.py b/famodel/irma/task.py index c67c5fb1..ce0c0313 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -76,7 +76,7 @@ def __init__(self, actions, action_sequence, name, **kwargs): self.getSequenceGraph(action_sequence, plot=True) # this also updates duration self.cost = sum(action.cost for action in self.actions.values()) - 0 + print(f"---------------------- Initializing Task '{self.name} ----------------------") print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.") print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ") From 36d1ac1e1427818da21652e2118f48500f5afed5 Mon Sep 17 00:00:00 2001 From: RyanDavies19 Date: Fri, 12 Sep 2025 15:00:50 -0600 Subject: [PATCH 29/63] scheduler.py refactor for four decision variables: - changes from single 3D decision tensor to 4 2D decision variables - updates constraints accordingly and adds new constraints - updates and polishes documentation to reflect complete, inprogress, and todo constraints --- famodel/irma/scheduler.py | 681 ++++++++++++++++++++------------ famodel/irma/schedulerREADME.md | 144 +++++-- 2 files changed, 522 insertions(+), 303 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 14cb00c6..3d34fc8e 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -9,7 +9,7 @@ - [] Eventually enable parallel tasks and multiple assets per task - [] Convert input tasks and assets from dicts to Task and Asset objects - [] When tasks and assets are converted from lists to objects, update the type hints for task and asset list at class initialization. -- [] Add a delay cost, i.e. a cost for each time period where X = 0 +- [] Add a delay cost, i.e. a cost for each time period where X = 0 <-- do we want this? might not be needed - [] Do we want to return any form of info dictionary? - [] Figure out if this can be parallelized - [] Consolidate the loops in the constraints building section @@ -97,23 +97,24 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset # --- Process inputs --- - self.num_tasks = len(self.tasks) - self.num_assets = len(self.assets) - self.num_periods = len(weather) # number of scheduling periods + self.T = len(self.tasks) + self.A = len(self.assets) + self.P = len(weather) # number of scheduling periods + self.S = self.P # number of start times - # Checks for negative duration and cost in task_asset_matrix (0 cost or duration permitted) - self.num_valid_ta_pairs = int(np.sum((self.task_asset_matrix[:,:,0] >=0) | (self.task_asset_matrix[:,:,1] >= 0))) # number of valid task-asset pairs (cost*duration >= 0) + # Checks for negative duration and cost in task_asset_matrix (0 cost and duration permitted) + self.num_valid_ta_pairs = int(np.sum((self.task_asset_matrix[:,:,0] >=0) & (self.task_asset_matrix[:,:,1] >= 0))) # number of valid task-asset pairs (cost and duration >= 0) # --- Debug helpers --- # make a list of indices to help with building constraints - self.x_indices = [] - for p in range(self.num_periods): - for t in range(self.num_tasks): - for a in range(self.num_assets): - self.x_indices.append(f"x_[{p}][{t}][{a}]") + self.Xta_indices = [f"Xta_[{t}][{a}]" for t in range(self.T) for a in range(self.A)] + self.Xtp_indices = [f"Xtp_[{t}][{p}]" for t in range(self.T) for p in range(self.P)] + self.Xap_indices = [f"Xap_[{a}][{p}]" for a in range(self.A) for p in range(self.P)] + self.Xts_indices = [f"Xts_[{t}][{s}]" for t in range(self.T) for s in range(self.S)] + self.X_indices = self.Xta_indices + self.Xtp_indices + self.Xap_indices + self.Xts_indices if wordy > 0: - print(f"Scheduler initialized with {self.num_periods} time periods, {self.num_tasks} tasks, and {self.num_assets} assets.") + print(f"Scheduler initialized with {self.P} time periods, {self.T} tasks, {self.A} assets, and {self.S} start times.") def set_up_optimizer(self, goal : str = "cost"): ''' @@ -159,36 +160,36 @@ def set_up_optimizer(self, goal : str = "cost"): # v^T * x - # shape of V = shape of X, these will be flattened to equal-length vectors for the solver - V = np.zeros((self.num_periods, self.num_tasks, self.num_assets), dtype=int) # Values matrix: value of asset a assigned to task t in period p - X = np.zeros(V.shape, dtype=int) # Decision variable matrix: X[p, t, a] = 1 if asset a is assigned to task t in period p, else 0 + # Decision variables: + # Xta = task asset pairs + # Xtp = task period pairs + # Xap = period asset pairs + # Xts = task start-time pairs + num_variables = (self.T * self.A) + (self.T * self.P) + (self.A * self.P) + (self.T * self.S) # number of decision variables + + self.Xta_start = 0 # starting index of Xta in the flattened decision variable vector + self.Xta_end = self.Xta_start + self.T * self.A # ending index of Xta in the flattened decision variable vector + self.Xtp_start = self.Xta_end # starting index of Xtp in the flattened decision variable vector + self.Xtp_end = self.Xtp_start + self.T * self.P # ending index of Xtp in the flattened decision variable vector + self.Xap_start = self.Xtp_end # starting index of Xap in the flattened decision variable vector + self.Xap_end = self.Xap_start + self.A * self.P # ending index of Xap in the flattened decision variable vector + self.Xts_start = self.Xap_end # starting index of Xts in the flattened decision variable vector + self.Xts_end = self.Xts_start + self.T * self.S # ending index of Xts in the flattened decision variable vector # Values vector: In every planning period, the value of assigning asset a to task t is the same. Constraints determine which periods are chosen. # Note: Intentionally using values here instead of "cost" to avoid confusion between the program 'cost' of a pairing (which could be financial cost, duration, or some other target metric for minimization) to the solver and the financial cost of a asset-task pairing. - for p in range(self.num_periods): - for t in range(self.num_tasks): - for a in range(self.num_assets): - V[p, t, a] = self.task_asset_matrix[t, a, goal_index] # cost + values = np.zeros(num_variables, dtype=int) # NOTE: enforces discrete cost and duration + values[self.Xta_start:self.Xta_end] = self.task_asset_matrix[:, :, goal_index].flatten() # Set the cost or duration for the task-asset pair - - if wordy > 1: - print("Values matrix V (periods x tasks x assets) of length " + str(V.flatten().shape[0]) + " created") - print("Decision variable matrix X (periods x tasks x assets) of same shape initialized to zeros.") - - # Decision variable start as 0's, nothing decided. Constrainted to 0 or 1 by bounds and integrality in flattening objective function section. - - # --- Flatten the objective function for the solver --- - - # values vector (horizontal vector) - values = V.flatten() # Flatten the values tensor (num_periods x num_tasks x num_assets) into a 1D array for the solver. Solver requires 1D problem + # The rest of values (for start/period variables) remains zero because they do not impact cost or duration - # decision vars (vertical vector) - decision_vars = X.flatten() # Flatten the decision variable tensor (num_periods x num_tasks x num_assets) into a 1D array for the solver. Solver requires 1D problem. + if wordy > 1: + print("Values vector of length " + str(values.shape[0]) + " created") # lb <= x <= ub # Constrain decision variables to be 0 or 1 bounds = optimize.Bounds(0, 1) # 0 <= x_i <= 1 - integrality = np.ones(decision_vars.shape) # x_i are int. So set integrality to 1 + integrality = np.ones(num_variables, dtype=int) # x_i are int. So set integrality to 1 if wordy > 0: print("Bounds and integrality for decision variables set. Begining to build constraints...") @@ -201,8 +202,8 @@ def set_up_optimizer(self, goal : str = "cost"): ''' A note on constraints: There are two constraint matrices, the equality constraints (A_eq, b_eq) and the upper bound constraints (A_ub, b_ub). - Each row in the coefficient matrices corresponds to a constraint, and each column corresponds to a decision variable. Thus the number of columns - is equal to the number of decision variables (P * T * A), and the number of rows is equal to the number of constraints. + Each row in the coefficient matrices corresponds to a constraint, and each column corresponds to a decision variable. Thus the number of columns + is equal to the number of decision variables (T*A + T*P + T*S), and the number of rows is equal to the number of constraints. Similarly, the length of the limits matrices (b_eq, b_ub) is equal to the number of constraints. The equality constraints are expressed in the form A_eq * x = b_eq. Where A_eq is the coefficient matrix and b_eq is the limits matrix. @@ -221,18 +222,20 @@ def set_up_optimizer(self, goal : str = "cost"): The lower and upper bound constraints on the decision variables (lb <= x <= ub) is handled above, limiting them to integer values of 0 or 1. - The shape of decision vars is: - x_{p,t,a} for p in 0:P, t in 0:T, a in 0:A + The indexing of decision variables are: + Xta = [Xta_00, ..., Xta_0A, Xta_10, ..., Xta_1A, ..., Xta_T0, ..., Xta_TA] # task asset pairs + Xtp = [Xtp_00, ..., Xtp_0P, Xtp_10, ..., Xtp_1P, ..., Xtp_T0, ..., Xtp_TP] # task period pairs + Xap = [Xap_00, ..., Xap_0P, Xap_10, ..., Xap_1P, ..., Xap_A0, ..., Xap_AP] # asset period pairs + Xts = [Xts_00, ..., Xts_0S, Xts_10, ..., Xts_1S, ..., Xts_T0, ..., Xts_TS] # task start-time pairs + + The global decision variable is then: + X = [Xta, Xtp, Xap, Xts] - Constraints column (decision variable) indexing used in definitions below: - x = x_000, ..., x_00A, x_001, ..., x_00A, ..., x_0T1, ..., x_0TA, # task asset pairings in period 0 - x_100, ..., x_10A, x_101, ..., x_10A, ..., x_1T1, ..., x_1TA, # task asset pairings in period 1 - ..., - x_P00, ..., x_P0A, x_P21, ..., x_P2A, ..., x_PT1, ..., x_PTA # task asset pairings in period p + The starting indices of each section in this global variable are saved as self.Xta_start, self.Xtp_start, self.Xap_start, and self.Xts_start. + While the values vector is only nonzero for self.Xta_start:self.Xta_end, the constraints will leverage all decision variables. where: - - p is the period index (0 to P), t is the task index (0 to T), and a is the asset index (0 to A) - - x is the flattened decision variable tensor X[p, t, a] + - t is the task index (0 to T), a is the asset index (0 to A), p is the period index (0 to P), and s is the start time index (0 to S) ''' # Empty list of constraint coefficient matrices @@ -248,17 +251,23 @@ def set_up_optimizer(self, goal : str = "cost"): # 0) Total number of assignments needs to be less than or equal to the number of periods available ''' the sum of the total amount of periods assigned to tasks cannot be more than the total number of periods available: - (x_000 + ... + x_pta) <= P + (Xtp_00 + ... + Xtp_TP) <= P ''' + # TODO: I dont know if this is necessary + + # 1 row - A_ub_0 = np.ones((1, len(decision_vars)), dtype=int) # Every period assigned to a task counts as 1 towards the total assigned periods. This assumes one pair per period - b_ub_0 = np.array([self.num_periods]) + A_ub_0 = np.zeros((1, num_variables), dtype=int) # Every period assigned to a task counts as 1 towards the total assigned periods. This assumes one pair per period + b_ub_0 = np.array([self.P], dtype=int) + + # Set the coefficients for the Xtp variables to 1 + A_ub_0[0, self.Xtp_start:self.Xtp_end] = 1 if wordy > 1: print("A_ub_0^T:") - for i in range(A_ub_0.transpose().shape[0]): - pstring = str(self.x_indices[i]) + for i in range(self.Xtp_start, self.Xtp_end): + pstring = str(self.X_indices[i]) for column in A_ub_0.transpose()[i]: pstring += f"{ column:5}" print(pstring) @@ -272,29 +281,21 @@ def set_up_optimizer(self, goal : str = "cost"): # 1) asset can only be assigned to a task if asset is capable of performing the task (value of pairing is non-negative) ''' - if task j cannot be performed by asset k, then x_pjk = 0 for all periods p + if task t cannot be performed by asset a, then Xta_ta = 0 - (x_0jk + ... + x_pjk) = 0 # for all tasks j in range(0:t) and assets k in range(0:a) where task_asset_matrix[j, k, goal_index] <= 0 + (Xta_00 + ... + Xta_TA) = 0 # for all tasks t in range(0:T) and assets a in range(0:A) where task_asset_matrix[t, a, goal_index] <= 0 ''' - - mask = np.zeros(X.shape, dtype=int) - - for p in range(self.num_periods): - mask[p,:,:] = self.task_asset_matrix[:, :, goal_index] <= 0 # Create a mask of invalid task-asset pairings where cost is negative (indicating invalid) # 1 row - A_eq_1 = mask.flatten().reshape(1, -1) # Flatten the mask to match the decision variable shape, and reshape to be a single row - b_eq_1 = np.zeros(A_eq_1.shape[0], dtype=int) + A_eq_1 = np.zeros((1, num_variables), dtype=int) + b_eq_1 = np.zeros(1, dtype=int) - if wordy > 2: # example debugging code for looking at indicies. Can be applied to any constraint matrix if row index is adjusted accordingly - print(f"Task {t}:") - for i in range(len(self.x_indices)): - print(f" {self.x_indices[i]}: {A_eq_1[0, i]}") + A_eq_1[0,self.Xta_start:self.Xta_end] = (self.task_asset_matrix[:, :, goal_index] <= 0).flatten() # Create a mask of invalid task-asset pairings where cost is negative (indicating invalid) if wordy > 1: print("A_eq_1^T:") - for i in range(A_eq_1.transpose().shape[0]): - pstring = str(self.x_indices[i]) + for i in range(self.Xta_start,self.Xta_end): + pstring = str(self.X_indices[i]) for column in A_eq_1.transpose()[i]: pstring += f"{ column:5}" print(pstring) @@ -309,143 +310,165 @@ def set_up_optimizer(self, goal : str = "cost"): # 2) task dependencies must be respected (i.e., a task cannot start until all its dependencies have been satisfied) # ''' # This enforces task dependencies by ensuring that a task can only be assigned to a time period if all its dependencies have been completed in previous periods. - # TODO: right now this doesnt necessarily enforce that the dependency task has been completed, just that it was assigned in a previous period. This will need to change when - # tasks are assigned to the number of periods their duration is. - # The period of the dependency task must be less than the period of the current task: - # p * x_pra < p * x_pta # for each task t in tasks, for all dependencies r in task_dependencies[t], for all assets a in assets, for all periods p in periods - # ''' - - # # Number of task dependency rows - # A_lb_2 = np.zeros((len(self.task_dependencies), len(decision_vars)), dtype=int) - # b_lb_2 = np.zeros(len(self.task_dependencies), dtype=int) - - # # Extract dependencies from task_dependencies dict - # index = 0 - # for task, deps in self.task_dependencies.items(): - - # # if the task has dependencies, build the constraint - # if len(deps) > 0: - - # # get task index by matching task name and index in self.tasks - # if task not in self.tasks: - # raise ValueError(f"Task '{task}' in task_dependencies not found in tasks list.") - - # t = self.tasks.index(task) - - # mask = np.zeros(X.shape, dtype=int) - - # for dep in deps: - # # get task index by matching dependency name and index in self.tasks - # if dep not in self.tasks: - # raise ValueError(f"Dependency task '{dep}' for task '{task}' not found in tasks list.") - # r = self.tasks.index(dep) # get index of dependency - - # # TODO: need to figure out how to enforce / track temporal ordering of tasks - - # A_lb_2[index, :] = mask.flatten() - # index += 1 - - # if wordy > 1: - # print("A_lb_2^T:") - # print(" T1 T2 ") # Header for 2 tasks - # for i in range(A_lb_2.transpose().shape[0]): - # pstring = str(self.x_indices[i]) - # for column in A_lb_2.transpose()[i]: - # pstring += f"{ column:5}" - # print(pstring) - # print("b_lb_2: ", b_lb_2) - - # if wordy > 0: - # print("Constraint 2 built.") - - # 3) assets cannot be assigned in a time period where the weather is above the maximum capacity - # TODO: weather is disabled until this is added + # The general idea is that Xts_ds < Xtp_tp where d is the task that task t is dependent on. - # 4) assets cannot be assigned to multiple tasks in the same time period + # 3) at least one asset must be assigned to each task ''' - this is a simplification, eventually we want to allow multiple assets per task, or parallel tasks - Sum of all asset-period pairs must be <= 1: - - (x_000 + ... + x_pt0) <= 1 # for asset 0 - (x_001 + ... + x_pt1) <= 1 # for asset 1 + Sum of all task-asset pairs must be >= 1 for each task: + (Xta_00 + ... + Xta_0A) >= 1 # for task 0 + (Xta_10 + ... + Xta_1A) >= 1 # for task 1 ... - (x_00a + ... + x_pta) <= 1 # for asset t + (Xta_T0 + ... + Xta_TA) >= 1 # for task T ''' - - # num-periods * num_assets rows - A_ub_4 = np.zeros((self.num_periods * self.num_assets, len(decision_vars)), dtype=int) - b_ub_4 = np.ones(self.num_periods * self.num_assets, dtype=int) # right-hand side is 1 for each asset - index = 0 - for p in range(self.num_periods): - for a in range(self.num_assets): - # Create a mask for all variables for asset a - mask = np.zeros(X.shape, dtype=int) - mask[p, :, a] = 1 # Set all periods and tasks for asset a to 1 (so they are included in the sum) + # num_tasks rows + A_lb_3 = np.zeros((self.T, num_variables), dtype=int) + b_lb_3 = np.ones(self.T, dtype=int) - A_ub_4[index, :] = mask.flatten() - index += 1 + for t in range (self.T): + # set the coefficient for each task t to one + A_lb_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t if wordy > 1: + print("A_lb_3^T:") + print(" T1 T2") # Header for 2 tasks + for i in range(self.Xta_start,self.Xta_end): + pstring = str(self.X_indices[i]) + for column in A_lb_3.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_lb_3: ", b_lb_3) + + A_lb_list.append(A_lb_3) + b_lb_list.append(b_lb_3) + + if wordy > 0: + print("Constraint 3 built.") + + # 4) assets cannot be assigned to multiple tasks in the same time period + ''' + This means the sum of each asset-period pair in a given period must be less than 1 + + (Xap_00 + ... + Xap_A0) <= 1 # for period 0 + (Xap_01 + ... + Xap_A1) <= 1 # for period 1 + ... + (Xap_0P + ... + Xap_AP) <= 1 # for period P + ''' + + A_ub_4 = np.zeros((self.P, num_variables), dtype=int) + b_ub_4 = np.ones(self.P, dtype=int) + + for p in range (self.P): + # set the coefficient for each period p to one + A_ub_4[p, (self.Xap_start + p * self.A):(self.Xap_start + p * self.A + self.A)] = 1 + + if wordy > 1: print("A_ub_4^T:") - print(" P1A1 P1A2 P2A1 P2A2 P3A1 P3A2 P4A1 P4A2 P5A1 P5A2") # header for 5 periods and 2 assets example - for i in range(A_ub_4.transpose().shape[0]): - pstring = str(self.x_indices[i]) + print(" P1 P2 P3 P4 P5") # Header for 5 periods + for i in range(self.Xap_start,self.Xap_end): + pstring = str(self.X_indices[i]) for column in A_ub_4.transpose()[i]: pstring += f"{ column:5}" print(pstring) print("b_ub_4: ", b_ub_4) - + A_ub_list.append(A_ub_4) b_ub_list.append(b_ub_4) if wordy > 0: print("Constraint 4 built.") - # 5) The total number of tasks assigned cannot be greater than the number of tasks available (NOTE: Is this necessary or is it already enforced by the fact that there t = number of tasks?) - # TODO: enforce task limits + # 5) Every task must be assigned to at least one time period + ''' + Sum of all task-period pairs for each task must be >= 1: + + (Xtp_00 + ... + Xtp_0P) >= 1 # for task 0 + (Xtp_10 + ... + Xtp_1P) >= 1 # for task 1 + ... + (Xtp_T0 + ... + Xtp_TP) >= 1 # for task T + ''' + + A_eq_5 = np.zeros((self.T, num_variables), dtype=int) + b_eq_5 = np.ones(self.T, dtype=int) + + for t in range (self.T): + # set the coefficient for each task t to one + A_eq_5[t, (self.Xtp_start + t * self.P):(self.Xtp_start + t * self.P + self.P)] = 1 # Set the coefficients for the Xtp variables to 1 for each task t + + if wordy > 1: + print("A_eq_5^T:") + print(" T1 T2") # Header for 2 tasks + for i in range(self.Xtp_start,self.Xtp_end): + pstring = str(self.X_indices[i]) + for column in A_eq_5.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_eq_5: ", b_eq_5) + + A_eq_list.append(A_eq_5) + b_eq_list.append(b_eq_5) + + if wordy > 0: + print("Constraint 5 built.") + + # 6) The total number of assets assigned cannot be greater than the number of assets available but must be greater than the number of tasks. + ''' + Sum of all asset-period pairs must be >= T: + + A >= (Xap_00 + ... + Xap_AP) >= T + ''' + A_6 = np.zeros((1, num_variables), dtype=int) + b_lb_6 = np.array([self.T], dtype=int) + b_ub_6 = np.array([self.A], dtype=int) + + A_6[0,self.Xap_start:self.Xap_end] = 1 - # 6) The total number of assets assigned cannot be greater than the number of assets available (NOTE: Is this necessary or is it already enforced by the fact that there a = number of assets?) - # TODO: enforce asset limits + if wordy > 1: + print("A_6^T:") + for i in range(self.Xap_start,self.Xap_end): + pstring = str(self.X_indices[i]) + for column in A_6.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_lb_6: ", b_lb_6) + print("b_ub_6: ", b_ub_6) + + A_lb_list.append(A_6) + b_lb_list.append(b_lb_6) + A_ub_list.append(A_6) + b_ub_list.append(b_ub_6) + + if wordy > 0: + print("Constraint 6 built.") # 7) Ensure tasks are assigned as early as possible ''' A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. ''' - # TODO: implement this constraint # 8) All tasks must be assigned to at least one time period ''' - The sum of all decision variables for each task must be greater than 1, indicating all tasks were assigned at least once: + The sum of all task-period decision variables for each task must be greater than 1, indicating all tasks were assigned at least once: - (x_000 + ... + x_p0a) >= 1 # for task 0 - (x_010 + ... + x_p1a) >= 1 # for task 1 + (Xtp_00 + ... + Xtp_0P) >= 1 # for task 0 + (Xtp_10 + ... + Xtp_1P) >= 1 # for task 1 ... - (x_0t0 + ... + x_pta) >= 1 # for task t + (Xtp_T0 + ... + Xtp_TP) >= 1 # for task T ''' # num_tasks rows - A_lb_8 = np.zeros((self.num_tasks, len(decision_vars)), dtype=int) - b_lb_8 = np.ones(self.num_tasks, dtype=int) - - for t in range(self.num_tasks): - # Create a mask for all variables for task t - mask = np.zeros(X.shape, dtype=int) - mask[:, t, :] = 1 # Set all periods and assets for task t to 1 (so they are included in the sum) - A_lb_8[t, :] = mask.flatten() + A_lb_8 = np.zeros((self.T, num_variables), dtype=int) + b_lb_8 = np.ones(self.T, dtype=int) - if wordy > 2: - print(f"Task {t}:") - for i in range(len(self.x_indices)): - print(f" {self.x_indices[i]}: {A_lb_8[t, i]}") + A_lb_8[:,self.Xtp_start:self.Xtp_end] = 1 if wordy > 1: print("A_lb_8^T:") - print(" T1 T2") # Header for 2 tasks - for i in range(A_lb_8.transpose().shape[0]): - pstring = str(self.x_indices[i]) + print(" T1 T2") # Header for 2 tasks + for i in range(self.Xtp_start,self.Xtp_end): + pstring = str(self.X_indices[i]) for column in A_lb_8.transpose()[i]: pstring += f"{ column:5}" print(pstring) @@ -457,88 +480,39 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 8 built.") - # # 9) A task must be assigned to the continuous number of time periods equal to its duration for the asset assigned to it - # ''' - # This ensures the duration of a task-asset pair is respected. If a task has a duration of 3 periods, it must be assigned to 3 consecutive periods. - - # (x_ijk + x_(i+1)jk + ... + x_(i+d-1)jk) >= d # for all tasks j in range(0:t) and assets k in range(0:a) where d is the duration of task j with asset k, and i is the period index. This formulation does not allow for multiple assets per task + # 9) TODO: Empty constraint: fill me in later - # ''' - - # # num task-asset pairings rows - # A_eq_9 = np.zeros((self.num_valid_ta_pairs, len(decision_vars)), dtype=int) - # b_eq_9 = np.zeros(self.num_valid_ta_pairs, dtype=int) - - # # Loop through tasks and assets - # pair_i = 0 - # for t in range(self.num_tasks): - # for a in range(self.num_assets): - - # duration = self.task_asset_matrix[t, a, 1] # duration of task t with asset a - # if duration > 0: # If valid pairing, make constraint - - # # Create a mask for all variables for task t and asset a - # mask = np.zeros(X.shape, dtype=int) - # for p in range(self.num_periods): - # mask[p:p+duration, t, a] = 1 - - # b_eq_9[pair_i] = self.task_asset_matrix[t, a, 1] # Duration - # A_eq_9[pair_i, :] = mask.flatten() - # pair_i += 1 - - # if wordy > 1: - # # Print out the constraint matrix for debugging - # print("A_eq_9^T:") - # print(" T1A1 T1A2 T2A1") # Header for 2 tasks and 2 assets example with T2A2 invalid - # for i in range(A_eq_9.transpose().shape[0]): - # pstring = str(self.x_indices[i]) - # for column in A_eq_9.transpose()[i]: - # pstring += f"{ column:5}" - # print(pstring) - # print("b_eq_9: ", b_eq_9) - - # A_eq_list.append(A_eq_9) - # b_eq_list.append(b_eq_9) - - # if wordy > 0: - # print("Constraint 9 built.") - - # 10) A task duration plus the first time period it is assigned to must be less than the total number of time periods available + # 10) A task duration plus the start-time it is assigned to must be less than the total number of time periods available ''' This ensures that a task is not assigned to a period that would cause it to exceed the total number of periods available. - (p * x_{p,t,a} + d_{t,a} * x_{p,t,a}) <= P # for all t in 0..T, a in 0..A, p in 0..P + (Xts * s + d_ta) <= P # for all tasks t in range(0:T) where d is the duration of task-asset pair ta ''' - - # num_periods rows - A_ub_10 = np.zeros((self.num_periods, len(decision_vars)), dtype=int) - b_ub_10 = np.ones(self.num_periods, dtype=int) * self.num_periods - - for p in range(self.num_periods): - # Create a mask for the period - mask = np.zeros(X.shape, dtype=int) - - # Loop through pairs - for t in range(self.num_tasks): - for a in range(self.num_assets): - duration = self.task_asset_matrix[t, a, 1] # duration of task t with asset a - if duration > 0: - mask[p, t, a] = p + duration # Set the specific variable to i + d_jk - - A_ub_10[p, :] = mask.flatten() - - if wordy > 2: - print(f"Period {p}:") - for i in range(len(self.x_indices)): - print(f" {self.x_indices[i]}: {A_ub_10[p, i]}") - print("Upper bound limit: ", b_ub_10[p]) - + rows = [] + for t in range(self.T): + for a in range(self.A): + duration = self.task_asset_matrix[t, a, 1] # duration of task t with asset a + if duration > 0: # If valid pairing, make constraint + row = np.zeros(num_variables, dtype=int) + for s in range(self.S): + row[self.Xts_start + t * self.S + s] = duration + s + row[self.Xta_start + t * self.A + a] = 1 + rows.append(row) + + A_ub_10 = np.vstack(rows) + b_ub_10 = np.ones(A_ub_10.shape[0], dtype=int) * (self.P) # -1 becasue we are also counting the index of the TA pair + if wordy > 1: print("A_ub_10^T:") - print(" P1 P2 P3 P4 P5") # Header for 5 periods - for i in range(A_ub_10.transpose().shape[0]): - pstring = str(self.x_indices[i]) + print(" T1A1 T1A2 T2A1") # Header for 3 task-asset pairs example with T2A2 invalid + for i in range(self.Xta_start,self.Xta_end): + pstring = str(self.X_indices[i]) + for column in A_ub_10.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + for i in range(self.Xts_start,self.Xts_end): + pstring = str(self.X_indices[i]) for column in A_ub_10.transpose()[i]: pstring += f"{ column:5}" print(pstring) @@ -550,6 +524,190 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 10 built.") + # 11) The total number of task period pairs must be greater than or equal to the number of task-start time pairs + ''' + This ensures that the task start-time decision variable is non-zero if a task is assigned to any period. + + (Xtp_00 + ... + Xtp_TP) >= (Xts_00 + ... + Xts_TS) # for all tasks t in range(0:T) + ''' + A_lb_11 = np.zeros((self.T, num_variables), dtype=int) + b_lb_11 = np.ones(self.T, dtype=int) * 2 + + for t in range(self.T): + A_lb_11[t, (self.Xtp_start + t * self.P):(self.Xtp_start + t * self.P + self.P)] = 1 + A_lb_11[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 + + if wordy > 1: + print("A_lb_11^T:") + print(" T1 T2") # Header for 2 tasks + for i in range(self.Xtp_start,self.Xts_end): + pstring = str(self.X_indices[i]) + for column in A_lb_11.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_lb_11: ", b_lb_11) + + A_lb_list.append(A_lb_11) + b_lb_list.append(b_lb_11) + + if wordy > 0: + print("Constraint 11 built.") + + # 12) The period an asset is assigned to must match the period the task in the task-asset pair is assigned to + ''' + This ensures the chosen task and asset in a task asset pair are assigned to the same period. This means that if an asset + is assigned to a task, then the corresponding task-period and asset-period pairs must be equal. + + if Xta = 1, then Xtp = Xap, else if Xta = 0, then Xtp and Xap can be anything. This requires two constriants: + + Xtp[t, p] - Xap[a, p] <= 1 - Xta[t, a] --> Xtp[t, p] - Xap[a, p] + Xta[t, a] <= 1 + Xtp[t, p] - Xap[a, p] >= -(1 - Xta[t, a]) --> Xtp[t, p] - Xap[a, p] + Xta[t, a] >= 0 + + ''' + + A_12 = np.zeros((self.T * self.A * self.P, num_variables), dtype=int) + b_ub_12 = np.ones(self.T * self.A * self.P, dtype=int) + b_lb_12 = np.zeros(self.T * self.A * self.P, dtype=int) + + row = 0 + for t in range(self.T): + for a in range(self.A): + for p in range(self.P): + A_12[row, self.Xtp_start + t * self.P + p] = 1 + A_12[row, self.Xap_start + a * self.P + p] = -1 + A_12[row, self.Xta_start + t * self.A + a] = 1 + + row += 1 + + if wordy > 1: + print("A_12^T:") + for i in range(self.Xta_start,self.Xap_end): + pstring = str(self.X_indices[i]) + for column in A_12.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_ub_12: ", b_ub_12) + print("b_lb_12: ", b_lb_12) + + A_ub_list.append(A_12) + b_ub_list.append(b_ub_12) + A_lb_list.append(A_12) + b_lb_list.append(b_lb_12) + + if wordy > 0: + print("Constraint 12 built.") + + # 13) The total number of asset period pairs must be greater than or equal to the number of task-period pairs + ''' + This ensures that the 0 asset-period pairs solution is not selected + + (Xap_00 + ... + Xap_AP) >= (Xtp_00 + ... + Xtp_TP) # for all periods p in range(0:P) + ''' + A_lb_13 = np.zeros((self.P, num_variables), dtype=int) + b_lb_13 = np.ones(self.P, dtype=int) * 2 + + for p in range(self.P): + A_lb_13[p, (self.Xap_start + p * self.A):(self.Xap_start + p * self.A + self.A)] = 1 + A_lb_13[p, (self.Xtp_start + p):(self.Xtp_start + p + self.P)] = 1 + + if wordy > 1: + print("A_lb_13^T:") + print(" P1 P2 P3 P4 P5") # Header for 5 periods + for i in range(self.Xtp_start,self.Xap_end): + pstring = str(self.X_indices[i]) + for column in A_lb_13.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_lb_13: ", b_lb_13) + + A_lb_list.append(A_lb_13) + b_lb_list.append(b_lb_13) + + if wordy > 0: + print("Constraint 13 built.") + + # 14) if a task-starttime pair is selected, the corresponding task-period pair must be selected for the period equal to the start time plus the duration of the task + ''' + This ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time plus the duration of the task is also selected. + Xts[t, s] <= Xtp[t, s : s + d] # for all tasks t in range(0:T) and start times s in range(0:S) where d is the duration of task t with the asset assigned to it + ''' + + # TODO: commenting out this constraint allows the optimizer to find an optimal solution + + # TODO: this is very very close. The Xtp are being assigned blocks equal to the starttime + duration. But it is causing the optimizer to fail...? + + rows = [] + vec = [] + for t in range(self.T): + for a in range(self.A): + duration = self.task_asset_matrix[t, a, 1] + if duration > 0: # If valid pairing, make constraint + for s in range(self.S): + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + t * self.A + a] = 1 + row[self.Xts_start + t * self.S + s] = -1 + row[self.Xtp_start + t * self.P + s : self.Xtp_start + t * self.P + s + duration] = 1 + rows.append(row) + vec.append(duration) + + A_ub_14 = np.vstack(rows) + b_ub_14 = np.array(vec, dtype=int) + + if wordy > 1: + print("A_ub_14^T:") + print(" T1A1S1 T1A2S1 ...") # Header for 3 task-asset pairs example with T2A2 invalid + for i in range(self.Xta_start,self.Xta_end): + pstring = str(self.X_indices[i]) + for column in A_ub_14.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + for i in range(self.Xtp_start,self.Xtp_end): + pstring = str(self.X_indices[i]) + for column in A_ub_14.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + for i in range(self.Xts_start,self.Xts_end): + pstring = str(self.X_indices[i]) + for column in A_ub_14.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_ub_14: ", b_ub_14) + + A_ub_list.append(A_ub_14) + b_ub_list.append(b_ub_14) + + if wordy > 0: + print("Constraint 14 built.") + + # 15) the number of task-starttime pairs must be equal to the number of tasks + ''' + This ensures that each task is assigned a start time. + + (Xts_00 + ... + Xts_TS) = T + ''' + A_eq_15 = np.zeros((1, num_variables), dtype=int) + b_eq_15 = np.array([self.T], dtype=int) + + A_eq_15[0,self.Xts_start:self.Xts_end] = 1 + if wordy > 1: + print("A_eq_15^T:") + for i in range(self.Xts_start,self.Xts_end): + pstring = str(self.X_indices[i]) + for column in A_eq_15.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_eq_15: ", b_eq_15) + + A_eq_list.append(A_eq_15) + b_eq_list.append(b_eq_15) + + if wordy > 0: + print("Constraint 15 built.") + + # 16) assets cannot be assigned in a time period where the weather is above the maximum capacity + # TODO: weather is disabled until this is added + + # --- End Constraints --- if wordy > 0: @@ -561,14 +719,14 @@ def set_up_optimizer(self, goal : str = "cost"): # Check num columns of all constraint matrices matches number of decision variables before stacking for i, A in enumerate(A_ub_list): - if A.size > 0 and A.shape[1] != decision_vars.shape[0]: - raise ValueError(f"Upper bound constraint matrix {i} has incorrect number of columns. Expected {decision_vars.shape[0]}, got {A.shape[1]}.") + if A.size > 0 and A.shape[1] != num_variables: + raise ValueError(f"Upper bound constraint matrix {i} has incorrect number of columns. Expected {num_variables}, got {A.shape[1]}.") for i, A in enumerate(A_eq_list): - if A.size > 0 and A.shape[1] != decision_vars.shape[0]: - raise ValueError(f"Equality constraint matrix {i} has incorrect number of columns. Expected {decision_vars.shape[0]}, got {A.shape[1]}.") + if A.size > 0 and A.shape[1] != num_variables: + raise ValueError(f"Equality constraint matrix {i} has incorrect number of columns. Expected {num_variables}, got {A.shape[1]}.") for i, A in enumerate(A_lb_list): - if A.size > 0 and A.shape[1] != decision_vars.shape[0]: - raise ValueError(f"Lower bound constraint matrix {i} has incorrect number of columns. Expected {decision_vars.shape[0]}, got {A.shape[1]}.") + if A.size > 0 and A.shape[1] != num_variables: + raise ValueError(f"Lower bound constraint matrix {i} has incorrect number of columns. Expected {num_variables}, got {A.shape[1]}.") # Stack, check shapes of final matrices and vectors, and save the number of constraints for later use if len(A_ub_list) > 0: @@ -669,27 +827,28 @@ def optimize(self, threads = -1): if wordy > 1: print("Decision variable [periods][tasks][assets]:") - for i in range(len(self.x_indices)): - print(f" {self.x_indices[i]}: {res.x[i]}") + for i in range(len(self.X_indices)): + print(f" {self.X_indices[i]}: {int(res.x[i])}") - X_optimal = res.x.reshape((self.num_periods, self.num_tasks, self.num_assets)) - self.schedule = X_optimal if wordy > 0: print("Optimization successful. The following schedule was generated:") - for p in range(self.num_periods): - print_string = f"Period {p+1}:" - whitespace = " " * (3 - len(str(p+1))) # adjust spacing for single vs double digit periods. Limited to 99 periods. - print_string += whitespace - for t in range(self.num_tasks): - for a in range(self.num_assets): - if X_optimal[p, t, a] == 1: - task_name = self.tasks[t] - asset_name = self.assets[a]['name'] if 'name' in self.assets[a] else f"Asset {a+1}" - cost = self.task_asset_matrix[t, a, 0] - duration = self.task_asset_matrix[t, a, 1] - print_string += f"Asset '{asset_name}' assigned to Task '{task_name}' (Cost: {cost}, Duration: {duration})" - - print(print_string) + + x_opt = res.x # or whatever your result object is + Xta = x_opt[self.Xta_start:self.Xta_end].reshape((self.T, self.A)) + Xtp = x_opt[self.Xtp_start:self.Xtp_end].reshape((self.T, self.P)) + Xap = x_opt[self.Xap_start:self.Xap_end].reshape((self.A, self.P)) + Xts = x_opt[self.Xts_start:self.Xts_end].reshape((self.T, self.S)) + + for p in range(self.P): + pstring = f"Period {p}: " + for t in range(self.T): + if Xtp[t, p] > 0: + # Find assigned asset for this task + a_assigned = np.argmax(Xta[t, :]) # assumes only one asset per task + cost = task_asset_matrix[t, a_assigned, 0] + duration = task_asset_matrix[t, a_assigned, 1] + pstring +=f"Asset {a_assigned} assigned to task {t} (cost: {cost}, duration: {duration}) | " + print(pstring) if wordy > 0: print("Optimization function complete.") @@ -723,7 +882,7 @@ def optimize(self, threads = -1): # cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid task_asset_matrix = np.array([ [(1000, 2), (2000, 3)], # task 1: asset 1, asset 2 - [(1200, 5), ( -1,-1)] # task 2: asset 1, asset 2 + [(1200, 3), ( -1,-1)] # task 2: asset 1, asset 2 ]) # optimal assignment: task 1 with asset 1 in periods 1-2, task 2 with asset 1 in period 3 diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md index 80994497..01b3115e 100644 --- a/famodel/irma/schedulerREADME.md +++ b/famodel/irma/schedulerREADME.md @@ -1,90 +1,150 @@ -# Scheduler Mathematical Formulation +# Scheduler Mathematical Formulation (as implemented in scheduler.py) -This document describes the mathematical formulation of the scheduling problem solved by the `Scheduler` class. +This document describes the mathematical formulation of the scheduling problem solved by the `Scheduler` class, using multiple decision variables and following the numbering and naming conventions in `scheduler.py`. Incomplete constraints are marked as **TODO**. ## Sets and Indices -- $P$: Set of periods, $p = 1, \ldots, P$ -- $T$: Set of tasks, $t = 1, \ldots, T$ -- $A$: Set of assets, $a = 1, \ldots, A$ -- $R$: set of task requirements/dependencies, $r =1, \ldots, R \text{ where } R < T$ +- $T$: Set of tasks, $t = 0, \ldots, T-1$ +- $A$: Set of assets, $a = 0, \ldots, A-1$ +- $P$: Set of periods, $p = 0, \ldots, P-1$ +- $S$: Set of possible start periods, $s = 0, \ldots, S-1$ ($S = P$) ## Parameters -- $v_{t,a}$: Value of assigning asset $a$ to task $t$. Can be either cost or duration depending on user input. - $c_{t,a}$: Cost of assigning asset $a$ to task $t$ - $d_{t,a}$: Duration (in periods) required for asset $a$ to complete task $t$ ## Decision Variables -- $x_{p,t,a} \in \{0,1\}$: 1 if asset $a$ is assigned to task $t$ in period $p$, 0 otherwise +- $X_{t,a} \in \{0,1\}$: 1 if task $t$ is assigned to asset $a$, 0 otherwise +- $X_{t,p} \in \{0,1\}$: 1 if task $t$ is active in period $p$, 0 otherwise +- $X_{a,p} \in \{0,1\}$: 1 if asset $a$ is used in period $p$, 0 otherwise +- $X_{t,s} \in \{0,1\}$: 1 if task $t$ starts at period $s$, 0 otherwise ## Objective Function -Minimize total cost: - +Minimize total cost (cost is only determined by task-asset assignment): $$ -\min \sum_{p \in P} \sum_{t \in T} \sum_{a \in A} \left( c_{t,a} \right) x_{p,t,a} +\min \sum_{t=0}^{T-1} \sum_{a=0}^{A-1} c_{t,a} X_{t,a} $$ ## Constraints +The below constraints are formulated such that they can be made into three giant matricies of upper and lower bounds and equalities. +When added together, these are the upperbound constraint, the lower bound constraint, and the equality constraint. The solver +attempts to solve the object objective function subject to: + +subject to: +$$ +\text{1) } A_{ub} \text{ } x \text{ } \leq b_{ub} \\ +\text{2) } A_{eq} \text{ } x \text{ } = b_{eq} \\ +\text{3) } A_{lb} \text{ } x \text{ } \geq b_{lb} \\ +\text{4) } 0 \leq \text{ } x \text{ } \leq 1 \\ +$$ + ### 0. Total Assignment Limit -The sum of all assignments cannot exceed the number of periods: +The sum of all task-period assignments cannot exceed the number of periods: $$ -\sum_{p \in P} \sum_{t \in T} \sum_{a \in A} x_{p,t,a} \leq P +\sum_{t=0}^{T-1} \sum_{p=0}^{P-1} X_{t,p} \leq P $$ ### 1. Task-Asset Validity Only valid task-asset pairs can be assigned: $$ -x_{p,t,a} = 0 \quad \forall p, t, a \text{ where } c_{t,a} < 0 \text{ or } d_{t,a} < 0 +X_{t,a} = 0 \quad \forall t, a \text{ where } c_{t,a} < 0 \text{ or } d_{t,a} < 0 $$ -### 2. Task Dependencies (**Disabled/In-progress**) -Tasks with dependencies must be scheduled after their dependencies are completed. This might need to be reworked, still figuring out the best way to enforce temporal constraints. +### 2. Task Dependencies (**TODO**) +Tasks with dependencies must be scheduled after their dependencies are completed. Thus the starttime of a task must be greater than the end time of all the dependent tasks. + +The general idea is that $X_{tp}(d,p) < X_{t,s}(t,s)$ where $d$ is the task that task $t$ is dependent on and where $p = s-1$ + +### 3. At Least One Asset Per Task +Sum of all task-asset pairs must be >= 1 for each task: $$ -p * x_{p,r,a} + d_r < p * x_{p,t,a} \quad t \in T, \forall r \in R_t, p \in P, a \in A +\sum_{a=0}^{A-1} X_{t,a} \geq 1 \quad \forall t $$ -### 3. Weather Constraints (**TODO**) -Assets cannot be assigned in periods where weather exceeds their capability. - ### 4. Asset Cannot Be Assigned to Multiple Tasks in Same Period -Each asset can be assigned to at most one task in each period: +This means the sum of each asset-period pair in a given period must be less or equal to than 1. This prohibits multiple assets in a period. $$ -\sum_{t \in T} x_{p,t,a} \leq 1 \quad \forall p \in P, a \in A +\sum_{t=0}^{A-1} X_{a,p} \leq 1 \quad \forall p $$ -### 5. Task Assignment Limit (**TODO**) -The total number of tasks assigned cannot exceed the number of tasks available. -### 6. Asset Assignment Limit (**TODO**) -The total number of assets assigned cannot exceed the number of assets available. +### 5. Every task must be assigned to at least one time period +Sum of all task-period pairs for each task must be >= 1: +$$ +\sum_{p=0}^{P-1} X_{t,p} \geq 1 \quad \forall t +$$ -### 7. Early Assignment Constraint (**TODO**) +### 6. The total number of assets assigned cannot be greater than the number of assets available but must be greater than the number of tasks. +Sum of all asset-period pairs must be >= T: +$$ +T \leq \sum_{a=0}^{A-1} \sum_{p=0}^{P-1} X_{a,p} \leq A +$$ + +### 7. Early Assignment Constraint (**TODO**) A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. +This could be enforced with a penality multiplied by the Xts decision variable in the objective function. + +### 8. All Tasks Must Be Assigned to At Least One Period +The sum of all task-period decision variables for each task must be greater than 1, indicating all tasks were assigned at least once: +$$ +\sum_{p=0}^{P-1} X_{t,p} \geq 1 \quad \forall t +$$ + +### 9. Empty + +### 10. A task duration plus the start-time it is assigned to must be less than the total number of time periods available +This ensures that a task is not assigned to a period that would cause it to exceed the total number of periods available. +$$ +X_{t,s} = 0 \quad \forall t, a, s \text{ where } d_{t,a} > 0,\ s + d_{t,a} > P +$$ -### 8. All Tasks Must Be Assigned -Each task must be assigned at least once: +Note: this constraint is working, but $X_{t,p}$ is not currently being forced to match $X_{t,s}$ so it is not reflected in the final results (which check for $X_{tp} \neq 0$). If you look at the results generated you will see the $X_{t,s}$ decision variable respects this constraint, but the $X_{t,p}$ does not. Constraint 14 aims to force $X_{t,p}$ to start blocks of time assignments at $X_{t,s}$. + +### 11. The total number of task period pairs must be greater than or equal to the number of task-start time pairs +This ensures that the task start-time decision variable is non-zero if a task is assigned to any period. $$ -\sum_{p \in P} \sum_{a \in A} x_{p,t,a} \geq 1 \quad \forall t \in T +\sum_{p=0}^{P-1} X_{t,p} \geq \sum_{s=0}^{S-1} X_{t,s} \quad \forall t $$ -### 9. Assignment Duration (**Disabled/In-progress**) -Each task-asset pair must be assigned for exactly its required duration: +### 12. The period an asset is assigned to must match the period the task in the task-asset pair is assigned to +This ensures the chosen task and asset in a task asset pair are assigned to the same period. This means that if an asset +is assigned to a task, then the corresponding task-period and asset-period pairs must be equal. + +if $X_{t,a} = 1$, then $X_{t,p} = X_{a,p}$, else if $X_{t,a} = 0$, then $X_{t,p}$ and $X_{a,p}$ can be anything. This requires two constriants: $$ -\sum_{p \in P} x_{p,t,a} = d_{t,a} \quad \forall t \in T, a \in A \text{ with } d_{t,a} > 0 +X_{t,p} - X_{a,p} + X_{t,a} \leq 1 \\ +X_{t,p} - X_{a,p} + X_{t,a} \geq 0 \quad \forall t, a, p $$ -### 10. Assignment Window -A task cannot be assigned to periods that would exceed the available time window: +### 13. The total number of asset period pairs must be greater than or equal to the number of task-period pairs +This ensures that the 0 asset-period pairs solution is not selected $$ -x_{p,t,a} = 0 \quad \forall p, t, a \text{ where } p + d_{t,a} > P +\sum_{a=0}^{A-1} X_{a,p} \geq \sum_{t=0}^{T-1} X_{t,p} \quad \forall p $$ +### 14. If a task-starttime pair is selected, the corresponding task-period pair must be selected for the period equal to the start time plus the duration of the task (**In progress/Close**) +This ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time plus the duration of the task is also selected. +`Xts[t, s] <= Xtp[t, s : s + d]` for all tasks t in `range(0:T)` and start times s in `range(0:S)` where d is the duration of task t with the asset assigned to it + +This constraint is very close. The matrix is being generated correctly, but the values in it (1, -1, 2, etc.) need to be decided so that the blocks of time are enforced only when a task asset pair is selected. Otherwise it should allow the optimization to continue. + +### 15. The number of task-starttime pairs must be equal to the number of tasks +This ensures that each task is assigned a start time. +$$ +\sum_{s=0}^{S-1} X_{t,s} = 1 \quad \forall t +$$ + +### 16. Weather Constraints (**TODO**) +Assets cannot be assigned in periods where weather exceeds their capability. + --- **Notes:** -- Constraints marked **TODO** are not yet implemented in the code but are (likely) necessary for an optimal solution -- Constraints marked **Disabled/In-progress** are works in progress that if enabled cause an infeasible solution to be generated. -- Additional constraints (e.g., weather, dependencies) should be added. -- This approach isn't finalized. We may need additional decision variables if we want to have multiple objectives - - For example, one way to force earlier scheduling is to add a start-time decision variable that gives a penalty for later start-times - - Did not implement this because we may not want to force earlier start-times \ No newline at end of file +- $d_{t,a}$ is the duration for asset $a$ assigned to task $t$. If multiple assets are possible, $X_{t,a}$ determines which duration applies. +- This approach separates assignment, activity, and start variables for clarity and easier constraint management. +- Constraints marked **TODO** are not yet implemented in the code but are probably necessary for a truely opptimal solution. +- Constraints marked **In-progress** have code written but it is not yet working +- Constraints not marked **TODO** or **In-progress** are complete +- Constraints can be extended for parallel tasks, multiple assets per task, or other requirements as needed. +- One of the better references to understand this approach is `Irwan et al. 2017 `_ +- The `scheduler.py` file also has some TODO's, which are focused on software development. \ No newline at end of file From 0cf170f3354730ecf59c82cbe00cb35f2d17fe8d Mon Sep 17 00:00:00 2001 From: Matt Hall Date: Tue, 16 Sep 2025 13:38:48 -0600 Subject: [PATCH 30/63] IRMA: Adding a couple more tasks in the staged strategy --- famodel/irma/irma.py | 46 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 080351f9..845ef1e8 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -311,9 +311,11 @@ def visualizeActions(self): if len(longest_path)>=1: last_node = longest_path[-1] # Identify last node of the longest path # Define layout - pos = nx.shell_layout(G) + pos = nx.shell_layout(G) # Draw all nodes and edges (default gray) - nx.draw(G, pos, with_labels=True, node_size=500, node_color='skyblue', font_size=10, font_weight='bold', font_color='black', edge_color='gray') + nx.draw(G, pos, with_labels=True, node_size=500, + node_color='skyblue', font_size=10, font_weight='bold', + font_color='black', edge_color='gray') # Highlight longest path in red nx.draw_networkx_edges(G, pos, edgelist=longest_path_edges, edge_color='red', width=2) @@ -450,16 +452,56 @@ def implementStrategy_staged(sc): act_sequence[acts[i].name] = [] else: # remaining actions are just a linear sequence act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + + # create the task, passing in the sequence of actions sc.addTask(acts, act_sequence, 'install_all_anchors') # ----- Create a Task for all the mooring installs ----- + # gather the relevant actions + acts = [] + # first load each mooring + for action in sc.actions.values(): + if action.type == 'load_mooring': + acts.append(action) + # next lay each mooring (eventually route logic could be added) + for action in sc.actions.values(): + if action.type == 'lay_mooring': + acts.append(action) + # create a dictionary of dependencies indicating that these actions are all in series + act_sequence = {} # key is action name, value is a list of what action names are to be completed before it + for i in range(len(acts)): + if i==0: # first action has no dependencies + act_sequence[acts[i].name] = [] + else: # remaining actions are just a linear sequence + act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + + # create the task, passing in the sequence of actions + sc.addTask(acts, act_sequence, 'install_all_moorings') # ----- Create a Task for the platform tow-out and hookup ----- + # gather the relevant actions + acts = [] + # first tow out the platform + acts.append(sc.actions['tow']) + # next hook up each mooring + for action in sc.actions.values(): + if action.type == 'mooring_hookup': + acts.append(action) + + # create a dictionary of dependencies indicating that these actions are all in series + act_sequence = {} # key is action name, value is a list of what action names are to be completed before it + for i in range(len(acts)): + if i==0: # first action has no dependencies + act_sequence[acts[i].name] = [] + else: # remaining actions are just a linear sequence + act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + # create the task, passing in the sequence of actions + sc.addTask(acts, act_sequence, 'tow_and_hookup') From 3e5cc8429b33b0a899d5cb93d3e9987c56ae5e32 Mon Sep 17 00:00:00 2001 From: Moreno Date: Fri, 3 Oct 2025 13:32:16 -0600 Subject: [PATCH 31/63] Add calwave modules, charts and modifications --- examples/OntologySample200m_1turb.yaml | 1 - .../anchors_famodel/capacity_suction.py | 2 +- famodel/irma/action.py | 6 +- famodel/irma/calwave_action.py | 1136 +++++++++++++++++ famodel/irma/calwave_actions.yaml | 377 ++++++ famodel/irma/calwave_bathymetry.txt | 14 + famodel/irma/calwave_capabilities.yaml | 175 +++ famodel/irma/calwave_chart.py | 313 +++++ famodel/irma/calwave_chart1.py | 356 ++++++ famodel/irma/calwave_chart2.py | 281 ++++ famodel/irma/calwave_irma.py | 694 ++++++++++ famodel/irma/calwave_objects.yaml | 33 + famodel/irma/calwave_ontology.yaml | 257 ++++ famodel/irma/calwave_task1.py | 137 ++ famodel/irma/calwave_task1b.py | 435 +++++++ famodel/irma/calwave_vessels.yaml | 117 ++ famodel/irma/irma.py | 31 +- 17 files changed, 4357 insertions(+), 8 deletions(-) create mode 100644 famodel/irma/calwave_action.py create mode 100644 famodel/irma/calwave_actions.yaml create mode 100644 famodel/irma/calwave_bathymetry.txt create mode 100644 famodel/irma/calwave_capabilities.yaml create mode 100644 famodel/irma/calwave_chart.py create mode 100644 famodel/irma/calwave_chart1.py create mode 100644 famodel/irma/calwave_chart2.py create mode 100644 famodel/irma/calwave_irma.py create mode 100644 famodel/irma/calwave_objects.yaml create mode 100644 famodel/irma/calwave_ontology.yaml create mode 100644 famodel/irma/calwave_task1.py create mode 100644 famodel/irma/calwave_task1b.py create mode 100644 famodel/irma/calwave_vessels.yaml diff --git a/examples/OntologySample200m_1turb.yaml b/examples/OntologySample200m_1turb.yaml index c55aad76..e14b69a4 100644 --- a/examples/OntologySample200m_1turb.yaml +++ b/examples/OntologySample200m_1turb.yaml @@ -1299,4 +1299,3 @@ anchor_types: L : 16.4 # length of pile [m] D : 5.45 # diameter of pile [m] zlug : 9.32 # embedded depth of padeye [m] - diff --git a/famodel/anchors/anchors_famodel/capacity_suction.py b/famodel/anchors/anchors_famodel/capacity_suction.py index d16b75a1..818d9ed1 100644 --- a/famodel/anchors/anchors_famodel/capacity_suction.py +++ b/famodel/anchors/anchors_famodel/capacity_suction.py @@ -40,7 +40,7 @@ def getCapacitySuction(D, L, zlug, H, V, soil_type, gamma, Su0=None, k=None, phi Maximum vertical capacity [kN] ''' - lambdap = L/D; m = 2/3; # Suction pile slenderness ratio + lambdap = L/D; m = 2/3; # Suction pile slenderness ratio t = (6.35 + D*20)/1e3 # Suction pile wall thickness (m), API RP2A-WSD rlug = D/2 # Radial position of the lug thetalug = 5 # Angle of tilt misaligment, default is 5. (deg) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 6e6a96a5..7b90a04a 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -115,8 +115,8 @@ def __init__(self, actionType, name, **kwargs): ''' # list of things that will be controlled during this action - self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action - self.requirements = {} # the capabilities required of each role (same keys as self.assets) + self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action + self.requirements = {} # capabilities required of each role (same keys as self.assets) self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on @@ -827,7 +827,7 @@ def checkAsset(self, role_name, asset): else: return False, f"The asset does not have the '{capability}' capability for '{role_name}' role of '{self.name}' action." # a capability is not met - + def calcDurationAndCost(self): ''' diff --git a/famodel/irma/calwave_action.py b/famodel/irma/calwave_action.py new file mode 100644 index 00000000..8c1dfa64 --- /dev/null +++ b/famodel/irma/calwave_action.py @@ -0,0 +1,1136 @@ +"""Action base class""" + +import numpy as np +import matplotlib.pyplot as plt + +import moorpy as mp +from moorpy.helpers import set_axes_equal +from moorpy import helpers +import yaml +from copy import deepcopy + +#from shapely.geometry import Point, Polygon, LineString +from famodel.seabed import seabed_tools as sbt +from famodel.mooring.mooring import Mooring +from famodel.platform.platform import Platform +from famodel.anchors.anchor import Anchor +from famodel.mooring.connector import Connector +from famodel.substation.substation import Substation +from famodel.cables.cable import Cable +from famodel.cables.dynamic_cable import DynamicCable +from famodel.cables.static_cable import StaticCable +from famodel.cables.cable_properties import getCableProps, getBuoyProps, loadCableProps,loadBuoyProps +from famodel.cables.components import Joint +from famodel.turbine.turbine import Turbine +from famodel.famodel_base import Node + +# Import select required helper functions +from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, + getMoorings, getAnchors, getFromDict, cleanDataTypes, + getStaticCables, getCableDesign, m2nm, loadYAML, + configureAdjuster, route_around_anchors) + + +def incrementer(text): + ''' + Increments the last integer found in a string. + + Inputs + ------ + `text` : `str` + The input string to increment. + + Returns + ------- + `str` + The incremented string. + ''' + split_text = text.split()[::-1] + for ind, spl in enumerate(split_text): + try: + split_text[ind] = str(int(spl) + 1) + break + except ValueError: + continue + return " ".join(split_text[::-1]) + + +def increment_name(name): + ''' + Increments an end integer after a dash in a name. + + Inputs + ------ + `name` : `str` + The input name string. + + Returns + ------- + `str` + The incremented name string. + ''' + name_parts = name.split(sep='-') + + # if no numeric suffix yet, add one + if len(name_parts) == 1 or not name_parts[-1].isdigit(): + name = name+'-0' + # otherwise there must be a suffix, so increment it + else: + name_parts[-1] = str( 1 + int(name_parts[-1])) + + name = '-'.join(name_parts) # reassemble name string + + return name + + +class Action(): + ''' + An Action is a general representation of a marine operations action + that involves manipulating a system/design/structure using assets/ + equipment. The Action base class contains generic routines and parameters. + Specialized routines for performing each action should be set up in + subclasses. + ''' + + def __init__(self, actionType, name, **kwargs): + '''Create an action object... + It must be given a name. + The remaining parameters should correspond to items in the actionType dict... + + Inputs + ---------- + `actionType` : `dict` + Dictionary defining the action type (typically taken from a yaml). + `name` : `string` + A name for the action. It may be appended with numbers if there + are duplicate names. + `kwargs` + Additional arguments may depend on the action type and typically + include a list of FAModel objects that are acted upon, or + a list of dependencies (other action names/objects). + + Returns + ------- + `None` + ''' + + # list of things that will be controlled during this action + self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action + self.requirements = {} # capabilities required of each role (same keys as self.assets) + self.objectList = [] # all objects that could be acted on + self.dependencies = {} # list of other actions this one depends on + + self.type = getFromDict(actionType, 'type', dtype=str) + self.name = name + self.status = 0 # 0, waiting; 1=running; 2=finished + + self.duration = getFromDict(actionType, 'duration', default=0) # this will be overwritten by calcDurationAndCost. TODO: or should it overwrite any duration calculation? + self.cost = 0 # this will be overwritten by calcDurationAndCost + self.ti = 0 # action start time [h?] + self.tf = 0 # action end time [h?] + + self.supported_objects = [] # list of FAModel object types supported by the action + + ''' + # Create a dictionary of supported object types (with empty entries) + if 'objects' in actionType: #objs = getFromDict(actionType, 'objects', shape=-1, default={}) + for obj in actionType['objects']: # go through keys in objects dictionary + self.objectList[obj] = None # make blank entries with the same names + + + # Process objects according to the action type + if 'objects' in kwargs: #objects = getFromDict(kwargs, objects, default=[]) + for obj in kwargs['objects']: + objType = obj.__class__.__name__.lower() + if objType in self.objectList: + self.objectList[objType] = obj + else: + raise Exception(f"Object type '{objType}' is not in the action's supported list.") + ''' + + # Create placeholders for asset roles based on the "requirements" + if 'roles' in actionType: + for role, caplist in actionType['roles'].items(): + self.requirements[role] = {key: {} for key in caplist} # each role requirment holds a dict of capabilities with each capability containing a dict of metrics and values, metrics dict set to empty for now. + self.assets[role] = None # placeholder for the asset assigned to this role + + # Process objects to be acted upon. NOTE: must occur after requirements and assets placeholders have been assigned. + # make list of supported object type names + if 'objects' in actionType: + if isinstance(actionType['objects'], list): + self.supported_objects = actionType['objects'] + elif isinstance(actionType['objects'], dict): + self.supported_objects = list(actionType['objects'].keys()) + + # Add objects to the action's object list as long as they're supported + if 'objects' in kwargs: + self.assignObjects(kwargs['objects']) + + # Process dependencies + if 'dependencies' in kwargs: + for dep in kwargs['dependencies']: + self.dependencies[dep.name] = dep + + # Process some optional kwargs depending on the action type + + + def addDependency(self, dep): + ''' + Registers other action as a dependency of this one. + + Inputs + ------ + `dep` : `Action` + The action to be added as a dependency. + + Returns + ------- + `None` + ''' + self.dependencies[dep.name] = dep + # could see if already a dependency and raise a warning if so... + + + def getMetrics(self, cap, met, obj): + ''' + Retrieves the minimum metric(s) for a given capability required to act on target object. + A metric is the number(s) associated with a capability. A capability is what an action + role requires and an asset has. + + These minimum metrics are assigned to capabilities in the action's role in `assignObjects`. + + Inputs + ------ + `cap` : `str` + The capability for which the metric is to be retrieved. + `met` : `dict` + The metrics dictionary containing any existing metrics for the capability. + `obj` : FAModel object + The target object on which the capability is to be acted upon. + + Returns + ------- + `metrics` : `dict` + The metrics and values for the specified capability and object. + + ''' + + metrics = met # metrics dict with following form: {metric_1 : required_value_1, ...}. met is assigned here in case values have already been assigned + objType = obj.__class__.__name__.lower() + + """ + Note to devs: + This function contains hard-coded evaluations of all the possible combinations of capabilities and objects. + The intent is we generate the minimum required of a given to work with the object. An + example would be minimum bollard pull required to tow out a platform. The capabilities (and their metrics) + are from capabilities.yaml and the objects are from objects.yaml. There is a decent ammount of assumptions + made here so it is important to document sources where possible. + + Some good preliminary work on this is in https://github.com/FloatingArrayDesign/FAModel/blob/IOandM_development/famodel/installation/03_step1_materialItems.py + + ### Code Explanation ### + This function has the following structure + + ``` + if cap == : + # some comments + + if objType == 'mooring': + metric_value = calc based on obj + elif objType == 'platform': + metric_value = calc based on obj + elif objType == 'anchor': + metric_value = calc based on obj + elif objType == 'component': + metric_value = calc based on obj + elif objType == 'turbine': + metric_value = calc based on obj + elif objType == 'cable': + metric_value = calc based on obj + else: + metric_value = -1 + + # Assign the capabilties metrics (keep existing metrics already in dict if larger than calc'ed value) + metrics[] = metric_value if metric_value > metrics.get() else metrics.get() + ``` + + Some of the logic for checking object types can be omitted if it doesnt make sense. For example, the chain_locker capability + only needs to be checked against the Mooring object. The comment `# object logic checked` shows that the logic in that capability + has been thought through. + + A metric_value of -1 indicates the object is not compatible with the capability. This is indicated by a warning printed at the end. + + A completed example of what this can look like is the line_reel capability. + """ + + + if cap == 'deck_space': + # logic for deck_space capability (platforms and sites not compatible) + # TODO: how do we account for an action like load_mooring (which has two roles, + # representing vessels to be loaded). The combined deck space of the carriers + # should be the required deck space for the action. Right now I believe it is + # set up that only one asset can fulfill the capability minimum. + + # object logic checked + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['area_m2'] = None if None > metrics.get('area_m2') else metrics.get('area_m2') + # metrics['max_load_t'] = None if None > metrics.get('max_load_t') else metrics.get('max_load_t') + + elif cap == 'chain_locker': + # logic for chain_locker capability (only mooring objects compatible) + # object logic checked + + if objType == 'mooring': + + # set baseline values for summation + vol = 0 + length = 0 + + for i, sec in enumerate(obj.dd['sections']): # add up the volume and length of all chain in the object + if sec['type']['chain']: + diam = sec['type']['d_nom'] # diameter [m] + vol += 0.0 # TODO: calculate chain_locker volume from sec['L'] and diam. Use Delmar data from Rudy. Can we make function of chain diam? + length += sec['L'] # length [m] + + else: + vol = -1 + + # Assign the capabilties metrics + metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') + + elif cap == 'line_reel': + # logic for line_reel capability (only mooring objects compatible) + # object logic checked, complete + + if objType == 'mooring': + + # set baseline values for summation + vol = 0 + length = 0 + + for i, sec in enumerate(obj.dd['sections']): # add up the volume and length of all non_chain line in the object + if not sec['type']['chain']: # any line type thats not chain + vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] + length += sec['L'] # length [m] + + else: + vol = -1 + length = -1 + + # Assign the capabilties metrics + metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') + metrics['rope_capacity_m'] = length if length > metrics.get('rope_capacity_m') else metrics.get('rope_capacity_m') + + elif cap == 'cable_reel': + # logic for cable_reel capability (only cable objects compatible) + # object logic checked + vol = 0 + length = 0 + ''' + if objType == 'cable': + for cable in cables: # TODO: figure out this iteration + if cable is cable and not other thing in cables object: # TODO figure out how to only check cables, not j-tubes or any other parts + vol += cable['L'] * np.pi * (cable['type']['d_nom'] / 2) ** 2 + length += cable['L'] # length [m] + else: + vol = -1 + length = -1 + ''' + # Assign the capabilties metrics + metrics['volume_m3'] = vol if vol > metrics.get('volume_m3') else metrics.get('volume_m3') + metrics['cable_capacity_m'] = length if length > metrics.get('cable_capacity_m') else metrics.get('cable_capacity_m') + + elif cap == 'winch': + # logic for winch capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # # Assign the capabilties metrics + # metrics['max_line_pull_t'] = None if None > metrics.get('max_line_pull_t') else metrics.get('max_line_pull_t') + # metrics['brake_load_t'] = None if None > metrics.get('brake_load_t') else metrics.get('brake_load_t') + # metrics['speed_mpm'] = None if None > metrics.get('speed_mpm') else metrics.get('speed_mpm') + + elif cap == 'bollard_pull': + # per calwave install report (section 7.2): bollard pull can be described as function of vessel speed and load + + # logic for bollard_pull capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['max_force_t'] = None if None > metrics.get('max_force_t') else metrics.get('max_force_t') + + elif cap == 'crane': + # logic for deck_space capability (all compatible) + # object logic checked + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['capacity_t'] = None if None > metrics.get('capacity_t') else metrics.get('capacity_t') + # metrics['hook_height_m'] = None if None > metrics.get('hook_height_m') else metrics.get('hook_height_m') + + elif cap == 'station_keeping': + # logic for station_keeping capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['type'] = None if None > metrics.get('type') else metrics.get('type') + + elif cap == 'mooring_work': + # logic for mooring_work capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['line_types'] = None if None > metrics.get('line_types') else metrics.get('line_types') + # metrics['stern_roller'] = None if None > metrics.get('stern_roller') else metrics.get('stern_roller') + # metrics['shark_jaws'] = None if None > metrics.get('shark_jaws') else metrics.get('shark_jaws') + # metrics['towing_pin_rating_t'] = None if None > metrics.get('towing_pin_rating_t') else metrics.get('towing_pin_rating_t') + + elif cap == 'pump_surface': + # logic for pump_surface capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'pump_subsea': + # logic for pump_subsea capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'pump_grout': + # logic for pump_grout capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['flow_rate_m3hr'] = None if None > metrics.get('flow_rate_m3hr') else metrics.get('flow_rate_m3hr') + # metrics['pressure_bar'] = None if None > metrics.get('pressure_bar') else metrics.get('pressure_bar') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'hydraulic_hammer': + # logic for hydraulic_hammer capability (only platform and anchor objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': # for fixed bottom installations + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['energy_per_blow_kJ'] = None if None > metrics.get('energy_per_blow_kJ') else metrics.get('energy_per_blow_kJ') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'vibro_hammer': + # logic for vibro_hammer capability (only platform and anchor objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': # for fixed bottom installations + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['centrifugal_force_kN'] = None if None > metrics.get('centrifugal_force_kN') else metrics.get('centrifugal_force_kN') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'drilling_machine': + # logic for drilling_machine capability (only platform, anchor, and cable objects compatible) + # Considering drilling both for export cables, interarray, and anchor/fixed platform install + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'torque_machine': + # logic for torque_machine capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['torque_kNm'] = None if None > metrics.get('torque_kNm') else metrics.get('torque_kNm') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'cable_plough': + # logic for cable_plough capability (only cable objects compatible) + # object logic checked + if objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['power_kW'] = None if None > metrics.get('power_kW') else metrics.get('power_kW') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'rock_placement': + # logic for rock_placement capability (only platform, anchor, and cable objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['placement_method'] = None if None > metrics.get('placement_method') else metrics.get('placement_method') + # metrics['max_depth_m'] = None if None > metrics.get('max_depth_m') else metrics.get('max_depth_m') + # metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') + # metrics['rock_size_range_mm'] = None if None > metrics.get('rock_size_range_mm') else metrics.get('rock_size_range_mm') + + elif cap == 'container': + # logic for container capability (only platform, turbine, and cable objects compatible) + # object logic checked + if objType == 'wec': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'rov': + # logic for rov capability (all compatible) + # object logic checked + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['class'] = None if None > metrics.get('class') else metrics.get('class') + # metrics['depth_rating_m'] = None if None > metrics.get('depth_rating_m') else metrics.get('depth_rating_m') + # metrics['weight_t'] = None if None > metrics.get('weight_t') else metrics.get('weight_t') + # metrics['dimensions_m'] = None if None > metrics.get('dimensions_m') else metrics.get('dimensions_m') + + elif cap == 'positioning_system': + # logic for positioning_system capability (only platform, anchor, and cable objects compatible) + # object logic checked + if objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['accuracy_m'] = None if None > metrics.get('accuracy_m') else metrics.get('accuracy_m') + # metrics['methods'] = None if None > metrics.get('methods') else metrics.get('methods') + + elif cap == 'monitoring_system': + # logic for monitoring_system capability + if objType == 'mooring': + pass + elif objType == 'platform': + pass + elif objType == 'anchor': + pass + elif objType == 'component': + pass + elif objType == 'turbine': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['metrics'] = None if None > metrics.get('metrics') else metrics.get('metrics') + # metrics['sampling_rate_hz'] = None if None > metrics.get('sampling_rate_hz') else metrics.get('sampling_rate_hz') + + elif cap == 'sonar_survey': + # logic for sonar_survey capability (only anchor and cable objects compatible) + # object logic checked + if objType == 'anchor': + pass + elif objType == 'cable': + pass + else: + pass + + # Assign the capabilties metrics + # metrics['types'] = None if None > metrics.get('types') else metrics.get('types') + # metrics['resolution_m'] = None if None > metrics.get('resolution_m') else metrics.get('resolution_m') + + else: + raise Exception(f"Unsupported capability '{cap}'.") + + for met in metrics.keys(): + if metrics[met] == -1: + print(f"WARNING: No metrics assigned for '{met}' metric in '{cap}' capability based on object type '{objType}'.") + + + return metrics # return the dict of metrics and required values for the capability + + + def assignObjects(self, objects): + ''' + Adds a list of objects to the actions objects list and + calculates the required capability metrics, checking objects + are valid for the actions supported objects. + + The minimum capability metrics are used by when checking for + compatibility and assinging assets to the action in `assignAsset`. + Thus this function should only be called in the intialization + process of an action. + + Inputs + ------ + `objects` : `list` + A list of FAModel objects to be added to the action. + + Returns + ------- + `None` + ''' + + for obj in objects: + + # Check compatibility, set capability metrics based on object, and assign object to action + + objType = obj.__class__.__name__.lower() # object class name + if objType not in self.supported_objects: + raise Exception(f"Object type '{objType}' is not in the action's supported list.") + else: + if obj in self.objectList: + print(f"Warning: Object '{obj}' is already in the action's object list. Capabilities will be overwritten.") + ''' + # Set capability requirements based on object + for role, caplist in self.requirements.items(): + for cap in caplist: + metrics = self.getMetrics(cap, caplist[cap], obj) # pass in the metrics dict for the cap and the obj + + self.requirements[role][cap] = metrics # assign metric of capability cap based on value required by obj + # MH: commenting our for now just so the code will run, but it may be better to make the above a separate step anyway + ''' + self.objectList.append(obj) + + + def checkAsset(self, role_name, asset): + ''' + Checks if a specified asset has sufficient capabilities to fulfil + a specified role in this action. + + Inputs + ------ + `role_name` : `string` + The name of the role to check. + `asset` : `dict` + The asset to check against the role's requirements. + + Returns + ------- + `bool` + True if the asset meets the role's requirements, False otherwise. + `str` + A message providing additional information about the check. + ''' + + # Make sure role_name is valid for this action + if not role_name in self.assets.keys(): + raise Exception(f"The specified role '{role_name}' is not a named in this action.") + + if self.assets[role_name] is not None: + return False, f"Role '{role_name}' is already filled in action '{self.name}'." + + for capability in self.requirements[role_name].keys(): + + if capability in asset['capabilities'].keys(): # check capability is in asset + + # TODO: does this work if there are no metrics in a capability? This should be possible, as not all capabilities will require a constraint. + for metric in self.requirements[role_name][capability].keys(): # loop over the capacity requirements for the capability (if more than one) + + if metric not in asset['capabilities'][capability].keys(): # value error because capabilities are defined in capabilities.yaml. This should only be triggered if something has gone wrong (i.e. overwriting values somewhere) + raise ValueError(f"The '{capability}' capability does not have metric: '{metric}'.") + + if self.requirements[role_name][capability][metric] > asset['capabilities'][capability][metric]: # check requirement is met + return False, f"The asset does not have sufficient '{metric}' for '{capability}' capability in '{role_name}' role of '{self.name}' action." + + return True, 'All capabilities in role met' + + else: + return False, f"The asset does not have the '{capability}' capability for '{role_name}' role of '{self.name}' action." # a capability is not met + + + def calcDurationAndCost(self): + ''' + Calculates duration and cost for the action. The structure here is dependent on `actions.yaml`. + TODO: finish description + + Inputs + ------ + `None` + + Returns + ------- + `None` + ''' + + # Check that all roles in the action are filled + for role_name in self.requirements.keys(): + if self.assets[role_name] is None: + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") + + # Initialize cost and duration + self.cost = 0.0 # [$] + self.duration = 0.0 # [days] + + """ + Note to devs: + The code here calculates the cost and duration of an action. Each action in the actions.yaml has a hardcoded 'model' + here that is used to evaluate the action based on the assets assigned to it. + + This is where a majority of assumptions about the action's behavior are made, so it is key to cite references behind + any abnormal approaches. + + Some good preliminary work on this is in https://github.com/FloatingArrayDesign/FAModel/blob/IOandM_development/famodel/installation/ + and in assets.py + """ + + # --- Mobilization --- + if self.type == 'mobilize': + pass + elif self.type == 'demobilize': + pass + elif self.type == 'load_cargo': + pass + + # --- Towing & Transport --- + elif self.type == 'tow': + pass + elif self.type == 'transit': + pass + elif self.type == 'transit_tug': + pass + elif self.type == 'at_site_support': + pass + elif self.type == 'transport_components': + pass + + # --- Mooring & Anchors --- + elif self.type == 'install_anchor': + + # Place holder duration, will need a mini-model to calculate + self.duration += 0.2 # 0.2 days + self.cost += self.duration * (self.assets['carrier']['day_rate'] + self.assets['operator']['day_rate']) + + elif self.type == 'retrieve_anchor': + pass + elif self.type == 'load_mooring': + + # Example model assuming line will be winched on to vessel. This can be changed if not most accurate + duration_min = 0 + for obj in self.objectList: + if obj.__class__.__name__.lower() == 'mooring': + for i, sec in enumerate(obj.dd['sections']): # add up the length of all sections in the mooring + duration_min += sec['L'] / self.assets['carrier2']['winch']['speed_mpm'] # duration [minutes] + + self.duration += duration_min / 60 / 24 # convert minutes to days + self.cost += self.duration * (self.assets['carrier1']['day_rate'] + self.assets['carrier2']['day_rate'] + self.assets['operator']['day_rate']) # cost of all assets involved for the duration of the action [$] + + # check for deck space availability, if carrier 1 met transition to carrier 2. + + # think through operator costs, carrier 1 costs. + + elif self.type == 'install_mooring': + pass + elif self.type == 'mooring_hookup': + pass + + # --- Heavy Lift & Installation --- + elif self.type == 'install_wec': + pass + elif self.type == 'install_semisub': + pass + elif self.type == 'install_spar': + pass + elif self.type == 'install_tlp': + pass + elif self.type == 'install_turbine': + pass + + # --- Cable Operations --- + elif self.type == 'lay_cable': + pass + elif self.type == 'cable_hookup': + pass + elif self.type == 'retrieve_cable': + pass + elif self.type == 'lay_and_bury_cable': + pass + elif self.type == 'backfill_rockdump': + pass + + # --- Survey & Monitoring --- + elif self.type == 'site_survey': + pass + elif self.type == 'monitor_installation': + pass + else: + raise ValueError(f"Action type '{self.type}' not recognized.") + + return self.duration, self.cost + + + def evaluateAssets(self, assets): + ''' + Checks assets for all the roles in the action. This calls `checkAsset()` + for each role/asset pair and then calculates the duration and + cost for the action as if the assets were assigned. Does not assign + the asset(s) to the action. WARNING: this function will clear the values + (but not keys) in `self.assets`. + + Inputs + ------ + `assets` : `dict` + Dictionary of {role_name: asset} pairs for assignment of the + assets to the roles in the action. + + Returns + ------- + `cost` : `float` + Estimated cost of using the asset. + `duration` : `float` + Estimated duration of the action when performed by asset. + ''' + + # Check each specified asset for its respective role + for role_name, asset in assets.items(): + assignable, message = self.checkAsset(role_name, asset) + if assignable: + self.assets[role_name] = asset # Assignment required for calcDurationAndCost(), will be cleared later + else: + print('INFO: '+message+' Action cannot be completed by provided asset list.') + return -1, -1 # return negative values to indicate incompatibility. Loop is terminated becasue assets not compatible for roles. + + # Check that all roles in the action are filled + for role_name in self.requirements.keys(): + if self.assets[role_name] is None: + + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? + + + duration, cost = self.calcDurationAndCost() + + for role_name in assets.keys(): # Clear the assets dictionary + assets[role_name] = None + + return duration, cost # values returned here rather than set because will be used to check compatibility and not set properties of action + + + def assignAsset(self, role_name, asset): + ''' + Checks if asset can be assigned to an action. + If yes, assigns asset to role in the action. + + Inputs + ------ + `role_name` : `str` + The name of the role to which the asset will be assigned. + `asset` : `dict` + The asset to be assigned to the role. + + Returns + ------- + `None` + ''' + # Make sure role_name is valid for this action + if not role_name in self.assets.keys(): + raise Exception(f"The specified role name '{role_name}' is not in this action.") + + if self.assets[role_name] is not None: + raise Exception(f"Role '{role_name}' is already filled in action '{self.name}'.") + + assignable, message = self.checkAsset(role_name, asset) + if assignable: + self.assets[role_name] = asset + else: + raise Exception(message) # throw error message + + def assignAssets(self, assets): + ''' + Assigns assets to all the roles in the action. This calls + `assignAsset()` for each role/asset pair and then calculates the + duration and cost for the action. Similar to `evaluateAssets()` + however here assets are assigned and duration and cost are + set after evaluation. + + Inputs + ------ + `assets` : `dict` + Dictionary of {role_name: asset} pairs for assignment of the + assets to the roles in the action. + + Returns + ------- + `None` + ''' + + # Assign each specified asset to its respective role + for role_name, asset in assets.items(): + self.assignAsset(role_name, asset) + + # Check that all roles in the action are filled + for role_name in self.requirements.keys(): + if self.assets[role_name] is None: + raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? + + self.calcDurationAndCost() + + + # ----- Below are drafts of methods for use by the engine ----- + """ + def begin(self): + ''' + Take control of all objects. + + Inputs + ------ + `None` + + Returns + ------- + `None` + ''' + for vessel in self.vesselList: + vessel._attach_to(self) + for object in self.objectList: + object._attach_to(self) + + + def end(self): + ''' + Release all objects. + + Inputs + ------ + `None` + + Returns + ------- + `None` + ''' + for vessel in self.vesselList: + vessel._detach_from() + for object in self.objectList: + object._detach_from() + """ + + def timestep(self): + ''' + Advance the simulation of this action forward one step in time. + + Inputs + ------ + `None` + + Returns + ------- + `None` + ''' + + # (this is just documenting an idea for possible future implementation) + # Perform the hourly action of the task + + if self.type == 'tow': + # controller - make sure things are going in right direction... + # (switch mode if need be) + if self.mode == 0 : # gathering vessels + ves = self.assets['vessel'] + dr = self.r_start - ves.r + ves.setCourse(dr) # sets vessel velocity + + # if vessel is stopped (at the object), time to move + if np.linalg.norm(ves.v) == 0: + self.mode = 1 + + if self.mode == 1: # towing + ves = self.assets['vessel'] + dr = self.r_finish - ves.r + ves.setCourse(dr) # sets vessel velocity + + # if all vessels are stopped (at the final location), time to end + if np.linalg.norm(ves.v) == 0: + self.mode = 2 + + if self.mode == 2: # finished + self.end() + + diff --git a/famodel/irma/calwave_actions.yaml b/famodel/irma/calwave_actions.yaml new file mode 100644 index 00000000..a91093bd --- /dev/null +++ b/famodel/irma/calwave_actions.yaml @@ -0,0 +1,377 @@ +# This file defines standardized marine operations actions. +# Each entry needs numeric values per specific asset in vessels.yaml. +# Vessel actions will be checked against capabilities/actions for validation. +# +# Old format: requirements and capabilities +# New format: roles, which lists asset roles, each with associated required capabilities + +# The code that models and checks these actions is action.calcDurationAndCost(). Structural changes here will not be reflected in the code unless changes are made there as well + +### Example action ### + +# example_action: +# objects: [] or {} "The FAModel object types that are supported in this action" +# requirements: [] "Asset types" **Unused** +# roles: "the roles that assets need to fill. A way a grouping capabilities so multiple assets can be assigned to an action" +# role1: +# - capability 1 +# - capability 2 +# role2: +# - capability 3 +# duration_h: 0.0 "Duration in hours" +# Hs_m: 0.0 "Wave height constraints in meters" +# description: "A description" + + +# --- Mobilization --- + +mobilize: + objects: [] + roles: + operator: + - deck_space + duration_h: + Hs_m: + description: "Mobilization of vessel in homeport" + +demobilize: + objects: [] + roles: + operator: + - deck_space + capabilities: [] + default_duration_h: 1.0 + description: "Demobilization of vessel in homeport" + +load_cargo: + objects: [anchor, mooring, cable, platform, component] + roles: # The asset roles involved and the capabilities required of each role + #carrier1: [] # the port or vessel where the moorings begin + # (no requirements) + #carrier2: # the vessel things will be loaded onto + #- deck_space + #- winch + #- positioning_system + operator: # the entity with the crane (like the port or the new vessel) + - crane + - deck_space + duration_h: + Hs_m: + description: "Load-out of mooring systems and components from port or vessel onto vessel." + +# --- Towing & Transport --- + +transit: + objects: [] + roles: + carrier: + - engine + # operator: + # - bollard_pull + #capabilities: [engine] + duration_h: 1.0 + description: "Transit of self-propelled vessel/tugboat" + +transit_tug: + objects: [] + roles: + carrier: + - engine + operator: + - bollard_pull + #capabilities: [bollard_pull] + default_duration_h: 1.0 + description: "Transit of tugged barge" + +transit_deployport: + objects: [] + roles: + carrier: + - engine + operator: + - bollard_pull + # capabilities: [engine] + default_duration_h: 1.0 + +tow: + objects: [platform] + roles: # The asset roles involved and the capabilities required of each role + carrier: + - engine + operator: + - bollard_pull + - winch + - positioning_system + duration_h: + Hs_m: + description: "Towing floating structures (e.g., floaters, barges) to site; includes station-keeping." + +transport_components: + objects: [component] + roles: # The asset roles involved and the capabilities required of each role + carrier: # vessel carrying things + - engine + - bollard_pull + - deck_space + - crane + - positioning_system + duration_h: + Hs_m: + description: "Transport of large components such as towers, nacelles, blades, or jackets." + +at_site_support: + objects: [] + roles: # The asset roles involved and the capabilities required of each role + # tug: # vessel carrying things + # - bollard_pull + # - deck_space + # - winch + # - positioning_system + # - monitoring_system + operator: + - engine + duration_h: + Hs_m: + description: "Transport of vessel around the site to provide support." + +# --- Mooring & Anchors --- + +install_anchor: + objects: [anchor, component] + roles: # The asset roles involved and the capabilities required of each role + carrier: # vessel that provides propulsion + - engine + operator: # vessel that carries, lowers and installs the anchor + - bollard_pull + - deck_space + - winch + - crane + - pump_subsea # pump_surface, drilling_machine, torque_machine + - positioning_system + - monitoring_system + duration_h: + Hs_m: + description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." + +retrieve_anchor: + objects: [anchor, component] + roles: # The asset roles involved and the capabilities required of each role + carrier: + - engine + operator: + - bollard_pull + - deck_space + - winch + - bollard_pull + - crane + - pump_subsea + - positioning_system + duration_h: + Hs_m: + description: "Anchor retrieval, including break-out and recovery to deck." + +install_mooring: + objects: [mooring, component] + roles: # The asset roles involved and the capabilities required of each role + carrier: # vessel carrying the mooring + - engine + operator: # vessel laying the mooring + - bollard_pull + - deck_space + - winch + - bollard_pull + - mooring_work + - positioning_system + duration_h: + Hs_m: + description: "Laying mooring lines, tensioning and connection to anchors and floaters." + + +mooring_hookup: + objects: [mooring, component, platform] + roles: # The asset roles involved and the capabilities required of each role + carrier: + - deck_space + operator: + - winch + - bollard_pull + - mooring_work + - positioning_system + - monitoring_system + duration_h: + Hs_m: + description: "Hook-up of mooring lines to floating platforms, including pretensioning." + +# --- Heavy Lift & Installation --- + +install_wec: + objects: [platform] + capabilities: + - deck_space + - crane + - positioning_system + - monitoring_system + - rov + duration_h: + Hs_m: + description: "Lifting, placement and securement of wave energy converters (WECs) onto moorings, including alignment, connection of power/data umbilicals and verification via ROV." + +install_semisub: + objects: [platform] + capabilities: + - deck_space + - bollard_pull + - winch + - crane + - positioning_system + - monitoring_system + - rov + - sonar_survey + - pump_surface + - mooring_work + duration_h: + Hs_m: + description: "Wet tow arrival, station-keeping, ballasting/trim, mooring hookup and pretensioning, ROV verification and umbilical connections as needed." + +install_spar: + objects: [platform] + capabilities: + - deck_space + - bollard_pull + - winch + - positioning_system + - monitoring_system + - rov + - sonar_survey + - pump_surface + - mooring_work + duration_h: + Hs_m: + description: "Arrival and upending via controlled ballasting, station-keeping, fairlead/messenger handling, mooring hookup and pretensioning with ROV confirmation. Heavy-lift support may be used during port integration." + +install_tlp: + objects: [platform] + capabilities: + - deck_space + - bollard_pull + - winch + - crane + - positioning_system + - monitoring_system + - rov + - sonar_survey + - mooring_work + duration_h: + Hs_m: + description: "Tendon porch alignment, tendon hookup, sequential tensioning to target pretension, verification of offsets/RAOs and ROV checks." + +install_wtg: + objects: [turbine] + capabilities: + - deck_space + - crane + - positioning_system + - monitoring_system + duration_h: + Hs_m: + description: "Installation of wind turbine generator including tower, nacelle and blades." + +# --- Cable Operations --- + +lay_cable: + objects: [cable] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - cable_reel + - sonar_survey + duration_h: + Hs_m: + description: "Laying static/dynamic power cables, including burial where required." + +cable_hookup: + objects: [cable, component, platform] + roles: # The asset roles involved and the capabilities required of each role + carrier: + - deck_space + operator: + - winch + - bollard_pull + - mooring_work + - positioning_system + - monitoring_system + duration_h: + Hs_m: + description: "Hook-up of cable to floating platforms, including pretensioning." + +retrieve_cable: + objects: [cable] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - cable_reel + duration_h: + Hs_m: + description: "Cable recovery operations, including cutting, grappling and retrieval." + + # Lay and bury in a single pass using a plough +lay_and_bury_cable: + objects: [cable] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - cable_reel + - cable_plough + - sonar_survey + duration_h: + Hs_m: + description: "Simultaneous lay and plough burial; continuous QA via positioning + MBES/SSS, with post-pass verification." + +# Backfill trench or stabilize cable route using rock placement +backfill_rockdump: + objects: [cable] + capabilities: + - deck_space + - positioning_system + - monitoring_system + - sonar_survey + - rock_placement + duration_h: + Hs_m: + description: "Localized rock placement to stabilize exposed cables, infill trenches or provide scour protection. Includes real-time positioning and sonar verification of rock placement." + +# --- Survey & Monitoring --- + +site_survey: + objects: [] + capabilities: + - positioning_system + - sonar_survey + - monitoring_system + duration_h: + Hs_m: + description: "Pre-installation site survey including bathymetry, sub-bottom profiling and positioning." + +monitor_installation: + objects: [anchor, mooring, component, platform, cable] + roles: + support: + - positioning_system + - monitoring_system + - rov + duration_h: + Hs_m: + description: "Real-time monitoring of installation operations using ROV and sensor packages." + +diver_support: + objects: [] + capabilities: + - positioning_system + - sonar_survey + - monitoring_system + duration_h: + Hs_m: + description: "Divers site survey including monitoring and positioning." \ No newline at end of file diff --git a/famodel/irma/calwave_bathymetry.txt b/famodel/irma/calwave_bathymetry.txt new file mode 100644 index 00000000..b186375f --- /dev/null +++ b/famodel/irma/calwave_bathymetry.txt @@ -0,0 +1,14 @@ +--- MoorPy Bathymetry Input File --- +nGridX 8 +nGridY 5 + -2500 -2000 -1500 -1000 -500 0 1000 2500 +-2500 200.1 199.7 240 219 202 198 204 210 +-2000 207 205 210 207 205 201 211 220 +-1500 203 198 207 199 195 204 207 214 +-1000 200.4 207 201 190 199 201 203 205 + -800 210.7 198.9 185 188 193 189 177 194 + + + + + diff --git a/famodel/irma/calwave_capabilities.yaml b/famodel/irma/calwave_capabilities.yaml new file mode 100644 index 00000000..10ef9824 --- /dev/null +++ b/famodel/irma/calwave_capabilities.yaml @@ -0,0 +1,175 @@ +# This file defines standardized capabilities for vessels and equipment. +# Each entry needs numeric values per specific asset in vessels.yaml. +# Vessel actions will be checked against capabilities/actions for validation. + +# The code that calculates the values for these capabilities is action.getMetrics(). +# Changes here won't be reflected in Irma unless the action.getMetrics() code is also updated. + +# >>> Units to be converted to standard values, with optional converter script +# for allowing conventional unit inputs. <<< + +# --- Vessel (on-board) --- + - name: engine + # description: Engine on-board of the vessel + # fields: + power_hp: # power [horsepower] + speed_kn: # speed [knot] + + - name: bollard_pull + # description: Towing/holding force capability + # fields: + max_force_t: # bollard pull [t] + + - name: deck_space + # description: Clear usable deck area and allowable load + # fields: + area_m2: # usable area [m2] + max_load_t: # allowable deck load [t] + + - name: chain_locker + # description: Chain storage capacity + # fields: + volume_m3: # storage volume [m3] + + - name: line_reel + # description: Chain/rope storage on drum or carousel + # fields: + volume_m3: # storage volume [m3] + rope_capacity_m: # total rope length storage [m] + + - name: cable_reel + # description: Cable storage on drum or carousel + # fields: + volume_m3: # storage volume [m3] + cable_capacity_m: # total cable length stowable [m] + + - name: winch + # description: Deck winch pulling capability + # fields: + max_line_pull_t: # continuous line pull [t] + brake_load_t: # static brake holding load [t] + speed_mpm: # payout/haul speed [m/min] + + - name: crane + # description: Main crane lifting capability + # fields: + capacity_t: # SWL at specified radius [t] + hook_height_m: # max hook height [m] + + - name: station_keeping + # description: Vessel station keeping capability (dynamic positioning or anchor-based) + # fields: + type: # e.g., DP0, DP1, DP2, DP3, anchor_based + + - name: mooring_work + # description: Suitability for anchor/mooring operations + # fields: + line_types: # e.g., [chain, ropes...] + stern_roller: # presence of stern roller (optional) + shark_jaws: # presence of chain stoppers/jaws (optional) + towing_pin_rating_t: # rating of towing pins [t] (optional) + +# --- Equipment (portable) --- + + - name: pump_surface + # description: Surface-connected suction pump + # fields: + power_kW: + pressure_bar: + weight_t: + dimensions_m: # LxWxH + + - name: pump_subsea + # description: Subsea suction pump (electric/hydraulic) + # fields: + power_kW: + pressure_bar: + weight_t: + dimensions_m: # LxWxH + + - name: pump_grout + # description: Grout mixing and pumping unit + # fields: + power_kW: + flow_rate_m3hr: + pressure_bar: + weight_t: + dimensions_m: # LxWxH + + - name: hydraulic_hammer + # description: Impact hammer for pile driving + # fields: + power_kW: + energy_per_blow_kJ: + weight_t: + dimensions_m: # LxWxH + + - name: vibro_hammer + # description: Vibratory hammer + # fields: + power_kW: + centrifugal_force_kN: + weight_t: + dimensions_m: # LxWxH + + - name: drilling_machine + # description: Drilling/rotary socket machine + # fields: + power_kW: + weight_t: + dimensions_m: # LxWxH + + - name: torque_machine + # description: High-torque rotation unit + # fields: + power_kW: + torque_kNm: + weight_t: + dimensions_m: # LxWxH + + - name: cable_plough + # description: + # fields: + power_kW: + weight_t: + dimensions_m: # LxWxH + + - name: rock_placement + # description: System for controlled placement of rock for trench backfill, scour protection, and seabed stabilization. + # fields: + placement_method: # e.g., fall_pipe, side_dump, grab + max_depth_m: # maximum operational water depth + accuracy_m: # placement accuracy on seabed + rock_size_range_mm: # min and max rock/gravel size + + - name: container + # description: Control/sensors container for power pack and monitoring + # fields: + weight_t: + dimensions_m: # LxWxH + + - name: rov + # description: Remotely Operated Vehicle + # fields: + class: # e.g., OBSERVATION, LIGHT, WORK-CLASS + depth_rating_m: + weight_t: + dimensions_m: # LxWxH + + - name: positioning_system + # description: Seabed placement/positioning aids + # fields: + accuracy_m: + methods: # e.g., [USBL, LBL, DVL, INS] + + - name: monitoring_system + # description: Installation performance monitoring + # fields: + metrics: # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] + sampling_rate_hz: + + - name: sonar_survey + # description: Sonar systems for survey and verification + # fields: + types: # e.g., [MBES, SSS, SBP] + resolution_m: diff --git a/famodel/irma/calwave_chart.py b/famodel/irma/calwave_chart.py new file mode 100644 index 00000000..d68c5393 --- /dev/null +++ b/famodel/irma/calwave_chart.py @@ -0,0 +1,313 @@ + +import math +from dataclasses import dataclass +from typing import List, Optional, Dict, Any, Union, Tuple +import matplotlib.pyplot as plt + +# =============================== +# Data structures +# =============================== +@dataclass +class Bubble: + action: str # action name (matches YAML if desired) + duration_hr: float # used to space bubbles proportionally along the row + label_time: Union[str, float] # text inside the bubble (e.g., 0.0, 0.1, 'A1') + capabilities: Union[List[str], Dict[str, List[str]]] # shown below bubble; list or dict-by-role + period: Optional[Tuple[float, float]] = None # (start_time, end_time) + +@dataclass +class VesselTimeline: + vessel: str + bubbles: List[Bubble] + +@dataclass +class Task: + name: str + vessels: List[VesselTimeline] + +# =============================== +# Helper: format capabilities nicely (supports roles or flat list) +# =============================== + +def _capabilities_to_text(capabilities: Union[List[str], Dict[str, List[str]]]) -> str: + if isinstance(capabilities, dict): + parts = [] + for role, caps in capabilities.items(): + if not caps: + parts.append(f'{role}: (none)') + else: + parts.append(f"{role}: " + ', '.join(caps)) + return '\n'.join(parts) + if isinstance(capabilities, list): + return ', '.join(capabilities) if capabilities else '(none)' + return str(capabilities) + +# =============================== +# Core plotters +# =============================== + +def _accumulate_starts(durations: List[float]) -> List[float]: + starts = [0.0] + for d in durations[:-1]: + starts.append(starts[-1] + d) + return starts + +def plot_task(task: Task, outpath: Optional[str] = None, dpi: int = 200, + show_title: bool = True) -> None: + """ + Render a Gantt-like chart for a single Task with one timeline per vessel. + • Vessel name on the left (vertical label) + • Bubble per action: title above, time inside, capabilities below + • Horizontal spacing ∝ duration_hr + """ + # Determine a common total time window across vessels (max of sums) + row_totals = [sum(b.duration_hr for b in v.bubbles) for v in task.vessels] + total = max(row_totals) if row_totals else 0.0 + + # Figure size heuristics + nrows = max(1, len(task.vessels)) + est_bubbles = sum(len(v.bubbles) for v in task.vessels) + fig_h = max(3.0, 2.4*nrows) + fig_w = max(10.0, 0.5*est_bubbles + 0.6*total) + + fig, axes = plt.subplots(nrows=nrows, ncols=1, figsize=(fig_w, fig_h), sharex=True, layout='constrained') + if nrows == 1: + axes = [axes] + + for ax, vessel in zip(axes, task.vessels): + starts = [] + durations = [] + current_time = 0.0 + for b in vessel.bubbles: + if b.period: + starts.append(b.period[0]) + durations.append(b.period[1] - b.period[0]) + else: + starts.append(current_time) + durations.append(b.duration_hr) + current_time += b.duration_hr + y = 0.5 + + # Timeline baseline with arrow + ax.annotate('', xy=(total, y), xytext=(0, y), arrowprops=dict(arrowstyle='-|>', lw=2)) + + # Light bars to hint segment spans + for s, d in zip(starts, durations): + ax.plot([s, s + d], [y, y], lw=6, alpha=0.15) + + # Bubbles + for i, (s, d, b) in enumerate(zip(starts, durations, vessel.bubbles)): + x = s + d/2 + # bubble marker + ax.plot(x, y, 'o', ms=45) + # time INSIDE bubble (overlayed text) + ax.text(x, y, f'{b.label_time}', ha='center', va='center', fontsize=20, color='white', weight='bold') + # title ABOVE bubble + title_offset = 0.28 if i % 2 else 0.20 + ax.text(x, y + title_offset, b.action, ha='center', va='bottom', fontsize=10) + # capabilities BELOW bubble + caps_txt = _capabilities_to_text(b.capabilities) + ax.text(x, y - 0.26, caps_txt, ha='center', va='top', fontsize=8, wrap=True) + + # Vessel label on the left, vertical + ax.text(-0.02*total if total > 0 else -1, y, vessel.vessel, ha='right', va='center', rotation=90, fontsize=10) + + # Cosmetics + ax.set_ylim(0, 1) + ax.set_yticks([]) + for spine in ['top', 'right', 'left']: + ax.spines[spine].set_visible(False) + + axes[-1].set_xlim(-0.02*total, total*1.02 if total > 0 else 1) + axes[-1].set_xlabel('Hours (proportional)') + if show_title: + axes[0].set_title(task.name, loc='left', fontsize=12) + + if outpath: + plt.savefig(outpath, dpi=dpi, bbox_inches='tight') + plt.show() + +# ========= Adapters from Scenario → chart ========= + +def _vessel_name_from_assigned(action) -> str: + """Pick a vessel label from the roles you assigned in evaluateAssets.""" + aa = getattr(action, 'assigned_assets', {}) or {} + # order of preference (tweak if your roles differ) + for key in ('vessel', 'carrier', 'operator'): + v = aa.get(key) + if v is not None: + return getattr(v, 'name', str(v)) + # fallback: unknown bucket + return 'unknown' + +def _caps_from_type_spec(action) -> dict: + """Turn the action type's roles spec into a {role: [caps]} dict for display.""" + spec = getattr(action, 'type_spec', {}) or {} + roles = spec.get('roles') or {} + # be defensive: ensure it's a dict[str, list[str]] + if not isinstance(roles, dict): + return {'roles': []} + clean = {} + for r, caps in roles.items(): + clean[r] = list(caps or []) + return clean + +def _label_for(action) -> str: + """Label inside the bubble. You can change to action.type or object name etc.""" + # show duration with one decimal if present; else use the action's short name + dur = getattr(action, 'duration', None) + if isinstance(dur, (int, float)) and dur >= 0: + return f"{dur:.1f}" + return action.name + +def scenario_to_chart_task(sc, start_times: dict[str, float], title: str): + """ + Convert Scenario actions + a start-time map into a calwave_chart.Task for plotting. + - start_times: {action_name: t0} from your scheduler + - action.duration must be set (via evaluateAssets or by you) + """ + # 1) bucket actions by vessel label + buckets: dict[str, list[Bubble]] = {} + + for a in sc.actions.values(): + # period + t0 = start_times.get(a.name, None) + dur = float(getattr(a, 'duration', 0.0) or 0.0) + period = None + if t0 is not None: + period = (float(t0), float(t0) + dur) + + bubble = Bubble( + action=a.name, + duration_hr=dur, + label_time=_label_for(a), + capabilities=_caps_from_type_spec(a), # roles/caps from the type spec + period=period + ) + vessel_label = _vessel_name_from_assigned(a) + buckets.setdefault(vessel_label, []).append(bubble) + + # 2) sort bubbles per vessel by start time (or keep input order if no schedule) + vessels = [] + for vname, bubbles in buckets.items(): + bubbles_sorted = sorted( + bubbles, + key=lambda b: (9999.0 if b.period is None else b.period[0]) + ) + vessels.append(VesselTimeline(vessel=vname, bubbles=bubbles_sorted)) + + # 3) stable vessel ordering: San Diego, Jag, Beyster, then others + order_hint = {'San_Diego': 0, 'San Diego': 0, 'Jag': 1, 'Beyster': 2} + vessels.sort(key=lambda vt: order_hint.get(vt.vessel, 10)) + + return Task(name=title, vessels=vessels) + +# Optional convenience: do everything after scheduling +def stage_and_plot(sc, start_times: dict[str, float], title: str, outpath: str | None = None, dpi: int = 200): + t = scenario_to_chart_task(sc, start_times, title) + plot_task(t, outpath=outpath, dpi=dpi, show_title=True) + + +if __name__ == '__main__': + # Support vessel monitors 4 anchors + Support = VesselTimeline( + vessel='Beyster', + bubbles=[ + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', + capabilities={'vessel': []}, + period=(5.0, 6.0)), + Bubble(action='transit_site A2', duration_hr=0.5, label_time='0.5', + capabilities={'carrier': []}, + period=(6.5, 7.0)), + # Monitor each anchor install (x4) + Bubble(action='site_survey A2', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(7.0, 8.0)), + Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(8.0, 8.5)), + Bubble(action='site_survey A1', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(8.5, 9.5)), + Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(9.5, 10.0)), + Bubble(action='site_survey A4', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(10.0, 11.0)), + Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(11.0, 11.5)), + Bubble(action='site_survey A3', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(11.5, 12.5)), + Bubble(action='transit_homeport', duration_hr=0.75, label_time='0.75', + capabilities=[], + period=(12.5, 13.25)), + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(13.25, 14.25)), + ] + ) + + # Tug (Jar) stages/load moorings; lay/install handled by San_Diego per your note + Tug = VesselTimeline( + vessel='Jar', + bubbles=[ + Bubble(action='transit_site', duration_hr=0.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, + period=(2.0, 6.5)), + Bubble(action='at_site_support', duration_hr=5.5, label_time='5.5', + capabilities=[], period=(6.5, 12.0)), + Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, + period=(12.0, 16.5)), + ] + ) + + # Barge performs lay_mooring and install_anchor for 4 anchors + Barge = VesselTimeline( + vessel='San_Diego', + bubbles=[ + Bubble(action='mobilize', duration_hr=2.0, label_time='2.0', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(0.0, 2.0)), + # Anchor 2 + Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(2.0, 6.5)), + Bubble(action='install_anchor A2', duration_hr=1.0, label_time='1.0', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(6.5, 7.5)), + # Anchor 1 + Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(7.5, 8.0)), + Bubble(action='install_anchor A1', duration_hr=1.0, label_time='1.5', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(8.0, 9.0)), + # Anchor 4 + Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(9.0, 9.5)), + Bubble(action='install_anchor A4', duration_hr=1.0, label_time='1.0', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(9.5, 10.5)), + # Anchor 3 + Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(10.5, 11.0)), + Bubble(action='install_anchor A3', duration_hr=1.0, label_time='1.0', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(11.0, 12.0)), + Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(12.0, 16.5)), + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(16.5, 17.0)), + ] + ) + + t = Task(name='Task 1 — Anchor installation plan (x4 anchors)', vessels=[Support, Tug, Barge]) + plot_task(t, outpath=None) \ No newline at end of file diff --git a/famodel/irma/calwave_chart1.py b/famodel/irma/calwave_chart1.py new file mode 100644 index 00000000..5583714d --- /dev/null +++ b/famodel/irma/calwave_chart1.py @@ -0,0 +1,356 @@ + +import math +from dataclasses import dataclass +from typing import List, Optional, Dict, Any, Union, Tuple +import matplotlib.pyplot as plt + +# =============================== +# Data structures +# =============================== +@dataclass +class Bubble: + action: str # action name (matches YAML if desired) + duration_hr: float # used to space bubbles proportionally along the row + label_time: Union[str, float] # text inside the bubble (e.g., 0.0, 0.1, 'A1') + capabilities: Union[List[str], Dict[str, List[str]]] # shown below bubble; list or dict-by-role + period: Optional[Tuple[float, float]] = None # (start_time, end_time) + +@dataclass +class VesselTimeline: + vessel: str + bubbles: List[Bubble] + +@dataclass +class Task: + name: str + vessels: List[VesselTimeline] + +# =============================== +# Helper: format capabilities nicely (supports roles or flat list) +# =============================== + +def _capabilities_to_text(capabilities: Union[List[str], Dict[str, List[str]]]) -> str: + if isinstance(capabilities, dict): + parts = [] + for role, caps in capabilities.items(): + if not caps: + parts.append(f'{role}: (none)') + else: + parts.append(f"{role}: " + ', '.join(caps)) + return '\n'.join(parts) + if isinstance(capabilities, list): + return ', '.join(capabilities) if capabilities else '(none)' + return str(capabilities) + +# =============================== +# Core plotters +# =============================== + +def _accumulate_starts(durations: List[float]) -> List[float]: + starts = [0.0] + for d in durations[:-1]: + starts.append(starts[-1] + d) + return starts + +def plot_task(task: Task, outpath: Optional[str] = None, dpi: int = 200, + show_title: bool = True) -> None: + """ + Render a Gantt-like chart for a single Task with one timeline per vessel. + • One axes with a horizontal lane per vessel (vessel names as y-tick labels) + • Bubble per action: title above, time inside, capabilities below + • Horizontal placement uses b.period when available, otherwise cumulative within vessel + """ + import matplotlib.pyplot as plt + from matplotlib.patches import Circle + + # --- figure geometry (stable/sane) --- + nrows = max(1, len(task.vessels)) + fig_h = max(3.0, 1.2 + 1.6 * nrows) + fig_w = 16.0 + + plt.rcdefaults() # avoid stray global styles from other modules + plt.close('all') + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi) + + # --- build y lanes (top -> bottom) --- + vessels_top_to_bottom = task.vessels # keep given order + y_positions = list(range(nrows))[::-1] + name_to_y = {vt.vessel: y_positions[i] for i, vt in enumerate(vessels_top_to_bottom[::-1])} + + ax.set_yticks(y_positions) + ax.set_yticklabels([vt.vessel for vt in vessels_top_to_bottom[::-1]]) + ax.tick_params(axis='y', labelrotation=0) + + # baselines that span the axes width (independent of x limits) + for yi in y_positions: + ax.plot([0, 1], [yi, yi], transform=ax.get_xaxis_transform(), color='k', lw=1) + + if show_title: + ax.set_title(task.name, loc='left', fontsize=12, pad=8) + + # --- gather periods, compute x-range --- + x_min, x_max = 0.0, 0.0 + per_row = {vt.vessel: [] for vt in task.vessels} + + for vt in task.vessels: + t_cursor = 0.0 + for b in vt.bubbles: + if b.period: + s, e = float(b.period[0]), float(b.period[1]) + else: + # fall back to cumulative placement within the row + s = t_cursor + e = s + float(b.duration_hr or 0.0) + per_row[vt.vessel].append((s, e, b)) + x_min = min(x_min, s) + x_max = max(x_max, e) + t_cursor = e + + # --- drawing helpers (always in DATA coords) --- + def _cap_text(caps: dict) -> str: + return _capabilities_to_text(caps) if isinstance(caps, dict) else "" + + def _draw_bubble(x0, x1, y, b): + xc = 0.5 * (x0 + x1) + w = max(0.001, (x1 - x0)) + radius = 0.22 + 0.02 * w # visual tweak similar to the newer plotter + + circ = Circle((xc, y), radius=radius, facecolor='#1976d2', edgecolor='k', lw=1.0, zorder=3) + ax.add_patch(circ) + + # label INSIDE bubble (duration/time) + ax.text(xc, y, f"{b.label_time}", ha='center', va='center', + fontsize=9, color='white', weight='bold', transform=ax.transData, clip_on=False) + + # title ABOVE bubble + ax.text(xc, y + (radius + 0.18), b.action, ha='center', va='bottom', + fontsize=9, transform=ax.transData, clip_on=False) + + # capabilities BELOW bubble + caps_txt = _cap_text(b.capabilities) + if caps_txt: + ax.text(xc, y - (radius + 0.24), caps_txt, ha='center', va='top', + fontsize=7, transform=ax.transData, clip_on=False) + + # --- draw bubbles, stagger titles a bit when very close --- + for v in task.vessels: + items = sorted(per_row[v.vessel], key=lambda t: t[0]) + y = name_to_y[v.vessel] + last_x = -1e9 + alt = 0 + for s, e, b in items: + _draw_bubble(s, e, y, b) + + # if adjacent centers are too close, nudge the title slightly up/down + xc = 0.5 * (s + e) + if abs(xc - last_x) < 0.6: # hours; tweak if your labels still collide + alt ^= 1 + bump = 0.28 if alt else -0.28 + ax.text(xc, y + bump, b.action, ha='center', va='bottom', + fontsize=9, transform=ax.transData, clip_on=False) + last_x = xc + + # --- axes cosmetics & limits --- + if x_max <= x_min: + x_max = x_min + 1.0 + pad = 0.3 * (x_max - x_min) + ax.set_xlim(x_min - pad, x_max + pad) + ax.set_ylim(y_positions[-1] - 1, y_positions[0] + 1) + + ax.set_xlabel('Hours (proportional)') + ax.grid(False) + for spine in ['top', 'right', 'left']: + ax.spines[spine].set_visible(False) + + fig = ax.figure + fig.subplots_adjust(left=0.08, right=0.98, top=0.90, bottom=0.14) + + if outpath: + fig.savefig(outpath, dpi=dpi) + else: + plt.show() + +# ========= Adapters from Scenario → chart ========= + +def _vessel_name_from_assigned(action) -> str: + """Pick a vessel label from the roles you assigned in evaluateAssets.""" + aa = getattr(action, 'assigned_assets', {}) or {} + # order of preference (tweak if your roles differ) + for key in ('vessel', 'carrier', 'operator'): + v = aa.get(key) + if v is not None: + return getattr(v, 'name', str(v)) + # fallback: unknown bucket + return 'unknown' + +def _caps_from_type_spec(action) -> dict: + """Turn the action type's roles spec into a {role: [caps]} dict for display.""" + spec = getattr(action, 'type_spec', {}) or {} + roles = spec.get('roles') or {} + # be defensive: ensure it's a dict[str, list[str]] + if not isinstance(roles, dict): + return {'roles': []} + clean = {} + for r, caps in roles.items(): + clean[r] = list(caps or []) + return clean + +def _label_for(action) -> str: + """Label inside the bubble. You can change to action.type or object name etc.""" + # show duration with one decimal if present; else use the action's short name + dur = getattr(action, 'duration', None) + if isinstance(dur, (int, float)) and dur >= 0: + return f"{dur:.1f}" + return action.name + +def scenario_to_chart_task(sc, start_times: dict[str, float], title: str): + """ + Convert Scenario actions + a start-time map into a calwave_chart.Task for plotting. + - start_times: {action_name: t0} from your scheduler + - action.duration must be set (via evaluateAssets or by you) + """ + # 1) bucket actions by vessel label + buckets: dict[str, list[Bubble]] = {} + + for a in sc.actions.values(): + # period + t0 = start_times.get(a.name, None) + dur = float(getattr(a, 'duration', 0.0) or 0.0) + period = None + if t0 is not None: + period = (float(t0), float(t0) + dur) + + bubble = Bubble( + action=a.name, + duration_hr=dur, + label_time=_label_for(a), + capabilities=_caps_from_type_spec(a), # roles/caps from the type spec + period=period + ) + vessel_label = _vessel_name_from_assigned(a) + buckets.setdefault(vessel_label, []).append(bubble) + + # 2) sort bubbles per vessel by start time (or keep input order if no schedule) + vessels = [] + for vname, bubbles in buckets.items(): + bubbles_sorted = sorted( + bubbles, + key=lambda b: (9999.0 if b.period is None else b.period[0]) + ) + vessels.append(VesselTimeline(vessel=vname, bubbles=bubbles_sorted)) + + # 3) stable vessel ordering: San Diego, Jag, Beyster, then others + order_hint = {'San_Diego': 0, 'San Diego': 0, 'Jag': 1, 'Beyster': 2} + vessels.sort(key=lambda vt: order_hint.get(vt.vessel, 10)) + + return Task(name=title, vessels=vessels) + +# Optional convenience: do everything after scheduling +def stage_and_plot(sc, start_times: dict[str, float], title: str, outpath: str | None = None, dpi: int = 200): + t = scenario_to_chart_task(sc, start_times, title) + plot_task(t, outpath=outpath, dpi=dpi, show_title=True) + + +if __name__ == '__main__': + # Support vessel monitors 4 anchors + Support = VesselTimeline( + vessel='Beyster', + bubbles=[ + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', + capabilities={'vessel': []}, + period=(5.0, 6.0)), + Bubble(action='transit_site A2', duration_hr=0.5, label_time='0.5', + capabilities={'carrier': []}, + period=(6.5, 7.0)), + # Monitor each anchor install (x4) + Bubble(action='site_survey A2', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(7.0, 8.0)), + Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(8.0, 8.5)), + Bubble(action='site_survey A1', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(8.5, 9.5)), + Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(9.5, 10.0)), + Bubble(action='site_survey A4', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(10.0, 11.0)), + Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(11.0, 11.5)), + Bubble(action='site_survey A3', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(11.5, 12.5)), + Bubble(action='transit_homeport', duration_hr=0.75, label_time='0.75', + capabilities=[], + period=(12.5, 13.25)), + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', + capabilities=[], + period=(13.25, 14.25)), + ] + ) + + # Tug (Jar) stages/load moorings; lay/install handled by San_Diego per your note + Tug = VesselTimeline( + vessel='Jar', + bubbles=[ + Bubble(action='transit_site', duration_hr=0.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, + period=(2.0, 6.5)), + Bubble(action='at_site_support', duration_hr=5.5, label_time='5.5', + capabilities=[], period=(6.5, 12.0)), + Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, + period=(12.0, 16.5)), + ] + ) + + # Barge performs lay_mooring and install_anchor for 4 anchors + Barge = VesselTimeline( + vessel='San_Diego', + bubbles=[ + Bubble(action='mobilize', duration_hr=2.0, label_time='2.0', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(0.0, 2.0)), + # Anchor 2 + Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(2.0, 6.5)), + Bubble(action='install_anchor A2', duration_hr=1.0, label_time='1.0', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(6.5, 7.5)), + # Anchor 1 + Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(7.5, 8.0)), + Bubble(action='install_anchor A1', duration_hr=1.0, label_time='1.5', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(8.0, 9.0)), + # Anchor 4 + Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(9.0, 9.5)), + Bubble(action='install_anchor A4', duration_hr=1.0, label_time='1.0', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(9.5, 10.5)), + # Anchor 3 + Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', + capabilities=[], + period=(10.5, 11.0)), + Bubble(action='install_anchor A3', duration_hr=1.0, label_time='1.0', + capabilities={'carrier': ['deck_space'], 'operator': []}, + period=(11.0, 12.0)), + Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(12.0, 16.5)), + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', + capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, + period =(16.5, 17.0)), + ] + ) + + t = Task(name='Task 1 — Anchor installation plan (x4 anchors)', vessels=[Support, Tug, Barge]) + plot_task(t, outpath=None) \ No newline at end of file diff --git a/famodel/irma/calwave_chart2.py b/famodel/irma/calwave_chart2.py new file mode 100644 index 00000000..7016c81e --- /dev/null +++ b/famodel/irma/calwave_chart2.py @@ -0,0 +1,281 @@ + +import math +from dataclasses import dataclass +from typing import List, Optional, Dict, Any, Union, Tuple +import matplotlib.pyplot as plt + +# =============================== +# Data structures +# =============================== +@dataclass +class Bubble: + action: str # action name (matches YAML if desired) + duration_hr: float # used to space bubbles proportionally along the row + label_time: Union[str, float] # text inside the bubble (e.g., 0.0, 0.1, 'A1') + capabilities: Union[List[str], Dict[str, List[str]]] # shown below bubble; list or dict-by-role + period: Optional[Tuple[float, float]] = None # (start_time, end_time) + +@dataclass +class VesselTimeline: + vessel: str + bubbles: List[Bubble] + +@dataclass +class Task: + name: str + vessels: List[VesselTimeline] + +# =============================== +# Helper: format capabilities nicely (supports roles or flat list) +# =============================== + +def _capabilities_to_text(capabilities: Union[List[str], Dict[str, List[str]]]) -> str: + if isinstance(capabilities, dict): + parts = [] + for role, caps in capabilities.items(): + if not caps: + parts.append(f'{role}: (none)') + else: + parts.append(f"{role}: " + ', '.join(caps)) + return '\n'.join(parts) + if isinstance(capabilities, list): + return ', '.join(capabilities) if capabilities else '(none)' + return str(capabilities) + +# =============================== +# Core plotter (single-axes, multiple lanes) +# =============================== + +def plot_task(task: Task, outpath: Optional[str] = None, dpi: int = 200, + show_title: bool = True) -> None: + """ + Render a Gantt-like chart for a single Task with one axes and one horizontal lane per vessel. + • Vessel names as y-tick labels (structure like calwave_chart1) + • Visual styling aligned with calwave_chart: baseline arrows, light span bars, circle bubbles + with time inside, title above, capabilities below, and consistent font sizes. + • Horizontal placement uses Bubble.period when available; otherwise cumulative within vessel. + """ + from matplotlib.patches import FancyArrow + + # --- figure geometry --- + nrows = max(1, len(task.vessels)) + fig_h = max(3.0, 1.2 + 1.6*nrows) + fig_w = 16.0 + + plt.rcdefaults() + plt.close('all') + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi) + + # --- y lanes (top -> bottom keeps given order) --- + vessels_top_to_bottom = task.vessels + y_positions = list(range(nrows))[::-1] + name_to_y = {vt.vessel: y_positions[i] for i, vt in enumerate(vessels_top_to_bottom[::-1])} + + ax.set_yticks(y_positions) + ax.set_yticklabels([vt.vessel for vt in vessels_top_to_bottom[::-1]]) + ax.tick_params(axis='y', labelrotation=0) + + if show_title: + ax.set_title(task.name, loc='left', fontsize=12, pad=8) + + # --- gather periods, compute x-range --- + x_min, x_max = 0.0, 0.0 + per_row: Dict[str, List[Tuple[float, float, Bubble]]] = {vt.vessel: [] for vt in task.vessels} + + for vt in task.vessels: + t_cursor = 0.0 + for b in vt.bubbles: + if b.period: + s, e = float(b.period[0]), float(b.period[1]) + else: + s = t_cursor + e = s + float(b.duration_hr or 0.0) + per_row[vt.vessel].append((s, e, b)) + x_min = min(x_min, s) + x_max = max(x_max, e) + t_cursor = e + + # --- drawing helpers --- + def _draw_lane_baseline(y_val: float): + # Baseline with arrow (like calwave_chart) spanning current x-lims later + ax.annotate('', xy=(x_max, y_val), xytext=(x_min, y_val), + arrowprops=dict(arrowstyle='-|>', lw=2)) + + def _draw_span_hint(s: float, e: float, y_val: float): + ax.plot([s, e], [y_val, y_val], lw=6, alpha=0.15, color='k') + + def _draw_bubble(s: float, e: float, y_val: float, b: Bubble, i_in_row: int): + xc = 0.5*(s + e) + # Bubble marker (match calwave_chart size) + ax.plot(xc, y_val, 'o', ms=45, color=plt.rcParams['axes.prop_cycle'].by_key()['color'][0], zorder=3) + # Time/label inside bubble + ax.text(xc, y_val, f'{b.label_time}', ha='center', va='center', fontsize=20, + color='white', weight='bold') + # Title above (alternate small offset pattern as in calwave_chart) + title_offset = 0.28 if (i_in_row % 2) else 0.20 + ax.text(xc, y_val + title_offset, b.action, ha='center', va='bottom', fontsize=10) + # Capabilities below + caps_txt = _capabilities_to_text(b.capabilities) + if caps_txt: + ax.text(xc, y_val - 0.26, caps_txt, ha='center', va='top', fontsize=8, wrap=True) + + # --- draw per lane --- + for vt in task.vessels: + y = name_to_y[vt.vessel] + items = sorted(per_row[vt.vessel], key=lambda t: t[0]) + + # Lane baseline w/ arrow + _draw_lane_baseline(y) + + # Span hints and bubbles + for j, (s, e, b) in enumerate(items): + _draw_span_hint(s, e, y) + _draw_bubble(s, e, y, b, j) + + # --- axes cosmetics & limits --- + if x_max <= x_min: + x_max = x_min + 1.0 + pad = 0.02*(x_max - x_min) if (x_max - x_min) > 0 else 0.02 + ax.set_xlim(x_min - pad, x_max + pad) + + ax.set_xlabel('Hours (proportional)') + ax.grid(False) + for spine in ['top', 'right', 'left']: + ax.spines[spine].set_visible(False) + + # y limits and a little margin + ax.set_ylim(min(y_positions) - 0.5, max(y_positions) + 0.5) + + fig = ax.figure + fig.subplots_adjust(left=0.10, right=0.98, top=0.90, bottom=0.14) + + if outpath: + fig.savefig(outpath, dpi=dpi, bbox_inches='tight') + else: + plt.show() + +# ========= Adapters from Scenario → chart ========= + +def _vessel_name_from_assigned(action) -> str: + """Pick a vessel label from the roles you assigned in evaluateAssets.""" + aa = getattr(action, 'assigned_assets', {}) or {} + # order of preference (tweak if your roles differ) + for key in ('vessel', 'carrier', 'operator'): + v = aa.get(key) + if v is not None: + return getattr(v, 'name', str(v)) + # fallback: unknown bucket + return 'unknown' + + +def _caps_from_type_spec(action) -> dict: + """Turn the action type's roles spec into a {role: [caps]} dict for display.""" + spec = getattr(action, 'type_spec', {}) or {} + roles = spec.get('roles') or {} + if not isinstance(roles, dict): + return {'roles': []} + clean: Dict[str, List[str]] = {} + for r, caps in roles.items(): + clean[r] = list(caps or []) + return clean + + +def _label_for(action) -> str: + """Label inside the bubble. You can change to action.type or object name etc.""" + dur = getattr(action, 'duration', None) + if isinstance(dur, (int, float)) and dur >= 0: + return f"{dur:.1f}" + return action.name + + +def scenario_to_chart_task(sc, start_times: dict[str, float], title: str): + """ + Convert Scenario actions + a start-time map into a calwave_chart.Task for plotting. + - start_times: {action_name: t0} from your scheduler + - action.duration must be set (via evaluateAssets or by you) + """ + buckets: dict[str, list[Bubble]] = {} + + for a in sc.actions.values(): + t0 = start_times.get(a.name, None) + dur = float(getattr(a, 'duration', 0.0) or 0.0) + period = None + if t0 is not None: + period = (float(t0), float(t0) + dur) + + bubble = Bubble( + action=a.name, + duration_hr=dur, + label_time=_label_for(a), + capabilities=_caps_from_type_spec(a), + period=period + ) + vessel_label = _vessel_name_from_assigned(a) + buckets.setdefault(vessel_label, []).append(bubble) + + vessels: List[VesselTimeline] = [] + for vname, bubbles in buckets.items(): + bubbles_sorted = sorted( + bubbles, + key=lambda b: (9999.0 if b.period is None else b.period[0]) + ) + vessels.append(VesselTimeline(vessel=vname, bubbles=bubbles_sorted)) + + order_hint = {'San_Diego': 0, 'San Diego': 0, 'Jag': 1, 'Beyster': 2} + vessels.sort(key=lambda vt: order_hint.get(vt.vessel, 10)) + + return Task(name=title, vessels=vessels) + +def stage_and_plot(sc, start_times: dict[str, float], title: str, + outpath: str | None = None, dpi: int = 200): + t = scenario_to_chart_task(sc, start_times, title) + plot_task(t, outpath=outpath, dpi=dpi, show_title=True) + + +if __name__ == '__main__': + # Demo scene (unchanged structure) + Support = VesselTimeline( + vessel='Beyster', + bubbles=[ + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', capabilities={'vessel': []}, period=(5.0, 6.0)), + Bubble(action='transit_A2', duration_hr=0.5, label_time='0.5', capabilities={'carrier': []}, period=(6.5, 7.0)), + Bubble(action='site_survey_A2', duration_hr=1.0, label_time='1.0', capabilities=[], period=(7.0, 8.0)), + Bubble(action='transit_A1', duration_hr=0.5, label_time='0.5', capabilities=[], period=(8.0, 8.5)), + Bubble(action='site_survey_A1', duration_hr=1.0, label_time='1.0', capabilities=[], period=(8.5, 9.5)), + Bubble(action='transit_A4', duration_hr=0.5, label_time='0.5', capabilities=[], period=(9.5, 10.0)), + Bubble(action='site_survey_A4', duration_hr=1.0, label_time='1.0', capabilities=[], period=(10.0, 11.0)), + Bubble(action='transit_A3', duration_hr=0.5, label_time='0.5', capabilities=[], period=(11.0, 11.5)), + Bubble(action='site_survey_A3', duration_hr=1.0, label_time='1.0', capabilities=[], period=(11.5, 12.5)), + Bubble(action='transit', duration_hr=0.75, label_time='0.75', capabilities=[], period=(12.5, 13.25)), + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', capabilities=[], period=(13.25, 14.25)), + ] + ) + + Tug = VesselTimeline( + vessel='Jar', + bubbles=[ + Bubble(action='transit', duration_hr=0.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, period=(2.0, 6.5)), + Bubble(action='at_site_support', duration_hr=5.5, label_time='5.5', capabilities=[], period=(6.5, 12.0)), + Bubble(action='transit', duration_hr=4.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, period=(12.0, 16.5)), + ] + ) + + Barge = VesselTimeline( + vessel='San_Diego', + bubbles=[ + Bubble(action='mobilize', duration_hr=2.0, label_time='2.0', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(0.0, 2.0)), + Bubble(action='transit_tug', duration_hr=4.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(2.0, 6.5)), + Bubble(action='install_anchor A2', duration_hr=1.0, label_time='1.0', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(6.5, 7.5)), + Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', capabilities=[], period=(7.5, 8.0)), + Bubble(action='install_anchor A1', duration_hr=1.0, label_time='1.5', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(8.0, 9.0)), + Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', capabilities=[], period=(9.0, 9.5)), + Bubble(action='install_anchor A4', duration_hr=1.0, label_time='1.0', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(9.5, 10.5)), + Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', capabilities=[], period=(10.5, 11.0)), + Bubble(action='install_anchor A3', duration_hr=1.0, label_time='1.0', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(11.0, 12.0)), + Bubble(action='transit_tug', duration_hr=4.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(12.0, 16.5)), + Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(16.5, 17.0)), + ] + ) + + t = Task(name='Task 1 — Anchor installation plan (x4 anchors)', vessels=[Support, Tug, Barge]) + plot_task(t, outpath=None) diff --git a/famodel/irma/calwave_irma.py b/famodel/irma/calwave_irma.py new file mode 100644 index 00000000..84340b9d --- /dev/null +++ b/famodel/irma/calwave_irma.py @@ -0,0 +1,694 @@ +"""Core code for setting up a IO&M scenario""" + +import os +import numpy as np +import matplotlib.pyplot as plt + +import moorpy as mp +from moorpy.helpers import set_axes_equal +from moorpy import helpers +import yaml +from copy import deepcopy +import string +try: + import raft as RAFT +except: + pass + +#from shapely.geometry import Point, Polygon, LineString +from famodel.seabed import seabed_tools as sbt +from famodel.mooring.mooring import Mooring +from famodel.platform.platform import Platform +from famodel.anchors.anchor import Anchor +from famodel.mooring.connector import Connector +from famodel.substation.substation import Substation +from famodel.cables.cable import Cable +from famodel.cables.dynamic_cable import DynamicCable +from famodel.cables.static_cable import StaticCable +from famodel.cables.cable_properties import getCableProps, getBuoyProps, loadCableProps,loadBuoyProps +from famodel.cables.components import Joint +from famodel.turbine.turbine import Turbine +from famodel.famodel_base import Node + +# Import select required helper functions +from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, + getMoorings, getAnchors, getFromDict, cleanDataTypes, + getStaticCables, getCableDesign, m2nm, loadYAML, + configureAdjuster, route_around_anchors) + +import networkx as nx +from calwave_action import Action, increment_name +from task import Task + +from assets import Vessel, Port + + + +def loadYAMLtoDict(info, already_dict=False): + '''Reads a list or YAML file and prepares a dictionary''' + + if isinstance(info, str): + + with open(info) as file: + data = yaml.load(file, Loader=yaml.FullLoader) + if not data: + raise Exception(f'File {info} does not exist or cannot be read. Please check filename.') + elif isinstance(info, list): + data = info + else: + raise Exception('loadYAMLtoDict must be passed a filename or list') + + # Go through contents and product the dictionary + info_dict = {} + + if already_dict: + # assuming it's already a dict + info_dict.update(data) + + else: # a list of dicts with name parameters + # So we will convert into a dict based on those names + for entry in data: + if not 'name' in entry: + print(entry) + raise Exception('This entry does not have a required name field.') + + if entry['name'] in info_dict: + print(entry) + raise Exception('This entry has the same name as an existing entry.') + + info_dict[entry['name']] = entry # could make this a copy operation if worried + + return info_dict + + +#def storeState(project,...): + + +#def applyState(): + + +def unifyUnits(d): + '''Converts any capability specification/metric in supported non-SI units + to be in SI units. Converts the key names as well.''' + + # >>> not working yet <<< + + + # load conversion data from YAML (eventually may want to store this in a class) + with open('spec_conversions.yaml') as file: + data = yaml.load(file, Loader=yaml.FullLoader) + + keys1 = [] + facts = [] # conversion factors + keys2 = [] + + for line in data: + keys1.append(line[0]) + facts.append(line[1]) + keys2.append(line[2]) + + # >>> dcopy = deepcopy(d) + + for asset in d.values(): # loop through each asset's dict + + capabilities = {} # new dict of capabilities to built up + + for cap_key, cap_val in asset['capabilities'].items(): + + # make the capability type sub-dictionary + capabilities[cap_key] = {} + + for key, val in cap_val.items(): # look at each capability metric + try: + i = keys1.index(key) # find if key is on the list to convert + + + if keys2[i] in cap_val.keys(): + raise Exception(f"Specification '{keys2[i]}' already exists") + + capabilities[cap_key][keys2[i]] = val * facts[i] # make converted entry + #capability[keys2[i]] = val * facts[i] # create a new SI entry + #del capability[keys1[i]] # remove the original? + + except: + + capabilities[cap_key][key] = val # copy over original form + + +class Scenario(): + + def __init__(self): + '''Initialize a scenario object that can be used for IO&M modeling of + of an offshore energy system. Eventually it will accept user-specified + settings files. + ''' + + # ----- Load database of supported things ----- + + actionTypes = loadYAMLtoDict('calwave_actions.yaml', already_dict=True) # Descriptions of actions that can be done + capabilities = loadYAMLtoDict('calwave_capabilities.yaml') + vessels = loadYAMLtoDict('calwave_vessels.yaml', already_dict=True) + objects = loadYAMLtoDict('calwave_objects.yaml', already_dict=True) + + unifyUnits(vessels) # (function doesn't work yet!) <<< + + # ----- Validate internal cross references ----- + + # Make sure vessels don't use nonexistent capabilities or actions + for key, ves in vessels.items(): + + #if key != ves['name']: + # raise Exception(f"Vessel key ({key}) contradicts its name ({ves['name']})") + + # Check capabilities + if not 'capabilities' in ves: + raise Exception(f"Vessel '{key}' is missing a capabilities list.") + + for capname, cap in ves['capabilities'].items(): + if not capname in capabilities: + raise Exception(f"Vessel '{key}' capability '{capname}' is not in the global capability list.") + + # Could also check the sub-parameters of the capability + for cap_param in cap: + if not cap_param in capabilities[capname]: + raise Exception(f"Vessel '{key}' capability '{capname}' parameter '{cap_param}' is not in the global capability's parameter list.") + + # Check actions + if not 'actions' in ves: + raise Exception(f"Vessel '{key}' is missing an actions list.") + + for act in ves['actions']: + if not act in actionTypes: + raise Exception(f"Vessel '{key}' action '{act}' is not in the global action list.") + + + # Make sure actions refer to supported object types/properties and capabilities + for key, act in actionTypes.items(): + + act['type'] = key + + #if key != act['name']: + # raise Exception(f"Action key ({key}) contradicts its name ({act['name']})") + + # Check capabilities + #if 'capabilities' in act: + # raise Exception(f"Action '{key}' is missing a capabilities list.") + + if 'capabilities' in act: + + for cap in act['capabilities']: + if not cap in capabilities: + raise Exception(f"Action '{key}' capability '{cap}' is not in the global capability list.") + + # Could also check the sub-parameters of the capability + #for cap_param in cap: + # if not cap_param in capabilities[cap['name']]: + # raise Exception(f"Action '{key}' capability '{cap['name']}' parameter '{cap_param}' is not in the global capability's parameter list.") + + if 'roles' in act: # look through capabilities listed under each role + for caps in act['roles'].values(): + for cap in caps: + if not cap in capabilities: + raise Exception(f"Action '{key}' capability '{cap}' is not in the global capability list.") + + + # Check objects + if not 'objects' in act: + raise Exception(f"Action '{key}' is missing an objects list.") + + for obj in act['objects']: + if not obj in objects: + raise Exception(f"Action '{key}' object '{obj}' is not in the global objects list.") + + # Could also check the sub-parameters of the object + if isinstance(act['objects'], dict): # if the object + for obj_param in act['objects'][obj]: + if not obj_param in objects[obj]: + raise Exception(f"Action '{key}' object '{obj}' parameter '{obj_param}' is not in the global object's parameter list.") + + + # Store some things + self.actionTypes = actionTypes + + self.capabilities = capabilities + self.vessels = vessels + self.objects = objects + + + # Initialize some things + self.actions = {} + self.tasks = {} + + + def registerAction(self, action): + '''Registers an already created action''' + + # this also handles creation of unique dictionary keys + + if action.name in self.actions: # check if there is already a key with the same name + raise Warning(f"Action '{action.name}' is already registered.") + print(f"Action name '{action.name}' is in the actions list so incrementing it...") + action.name = increment_name(action.name) + + # What about handling of dependencies?? <<< done in the action object, + # but could check that each is in the list already... + for dep in action.dependencies.values(): + if not dep in self.actions.values(): + raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") + + # Add it to the actions dictionary + self.actions[action.name] = action + + + def addAction(self, action_type_name, action_name, **kwargs): + '''Creates and action and adds it to the register''' + + if not action_type_name in self.actionTypes: + raise Exception(f"Specified action type name {action_type_name} is not in the list of loaded action types.") + + # Get dictionary of action type information + action_type = self.actionTypes[action_type_name] + + # Create the action + act = Action(action_type, action_name, **kwargs) + + # Register the action + self.registerAction(act) + + return act # return the newly created action object, or its name? + + + def addActionDependencies(self, action, dependencies): + '''Adds dependencies to an action, provided those dependencies have + already been registered in the action list. + ''' + + if not isinstance(dependencies, list): + dependencies = [dependencies] # get into list form if singular + + for dep in dependencies: + # Make sure the dependency is already registered + if dep in self.actions.values(): + action.addDependency(dep) + else: + raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") + + + def visualizeActions(self): + '''Generate a graph of the action dependencies. + ''' + + # Create the graph + G = nx.DiGraph() + for item, data in self.actions.items(): + for dep in data.dependencies: + G.add_edge(dep, item, duration=data.duration) # Store duration as edge attribute + + # Compute longest path & total duration + longest_path = nx.dag_longest_path(G, weight='duration') + longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs + total_duration = sum(self.actions[node].duration for node in longest_path) + if len(longest_path)>=1: + last_node = longest_path[-1] # Identify last node of the longest path + # Define layout + pos = nx.shell_layout(G) + # Draw all nodes and edges (default gray) + nx.draw(G, pos, with_labels=True, node_size=500, + node_color='skyblue', font_size=10, font_weight='bold', + font_color='black', edge_color='gray') + + # Highlight longest path in red + nx.draw_networkx_edges(G, pos, edgelist=longest_path_edges, edge_color='red', width=2) + + # Annotate last node with total duration in red + plt.text(pos[last_node][0], pos[last_node][1] - 0.1, f"{total_duration:.2f} hr", fontsize=12, color='red', fontweight='bold', ha='center') + else: + pass + plt.axis('equal') + + # Color first node (without dependencies) green + i = 0 + for node in G.nodes(): + if G.in_degree(node) == 0: # Check if the node has no incoming edges + nx.draw_networkx_nodes(G, pos, nodelist=[node], node_color='green', node_size=500, label='Action starters' if i==0 else None) + i += 1 + plt.legend() + return G + + + def registerTask(self, task): + '''Registers an already created task''' + + # this also handles creation of unique dictionary keys + + if task.name in self.tasks: # check if there is already a key with the same name + raise Warning(f"Action '{task.name}' is already registered.") + print(f"Task name '{task.name}' is in the tasks list so incrementing it...") + task.name = increment_name(task.name) + + # Add it to the actions dictionary + self.tasks[task.name] = task + + + def addTask(self, actions, action_sequence, task_name, **kwargs): + '''Creates a task and adds it to the register''' + + # Create the action + task = Task(actions, action_sequence, task_name, **kwargs) + + # Register the action + self.registerTask(task) + + return task + + + + def findCompatibleVessels(self): + '''Go through actions and identify which vessels have the required + capabilities (could be based on capability presence, or quantitative. + ''' + + pass + + + def figureOutTaskRelationships(self): + '''Calculate timing within tasks and then figure out constraints + between tasks. + ''' + + # Figure out task durations (for a given set of asset assignments?) + for task in self.tasks.values(): + task.calcTiming() + + # Figure out timing constraints between tasks based on action dependencies + n = len(self.tasks) + dt_min = np.zeros((n,n)) # matrix of required time offsets between tasks + + for i1, task1 in enumerate(self.tasks.values()): + for i2, task2 in enumerate(self.tasks.values()): + # look at all action dependencies from tasks 1 to 2 and + # identify the limiting case (the largest time offset)... + dt_min_1_2, dt_min_2_1 = findTaskDependencies(task1, task2) + + # for now, just look in one direction + dt_min[i1, i2] = dt_min_1_2 + + return dt_min + + +def findTaskDependencies(task1, task2): + '''Finds any time dependency between the actions of two tasks. + Returns the minimum time separation required from task 1 to task 2, + and from task 2 to task 1. I + ''' + + time_1_to_2 = [] + time_2_to_1 = [] + + # Look for any dependencies where act2 depends on act1: + #for i1, act1 in enumerate(task1.actions.values()): + # for i2, act2 in enumerate(task2.actions.values()): + for a1, act1 in task1.actions.items(): + for a2, act2 in task2.actions.items(): + + if a1 in act2.dependencies: # if act2 depends on act1 + time_1_to_2.append(task1.actions_ti[a1] + act1.duration + - task2.actions_ti[a2]) + + if a2 in act1.dependencies: # if act2 depends on act1 + time_2_to_1.append(task2.actions_ti[a2] + act2.duration + - task1.actions_ti[a1]) + + print(time_1_to_2) + print(time_2_to_1) + + dt_min_1_2 = min(time_1_to_2) # minimum time required from t1 start to t2 start + dt_min_2_1 = min(time_2_to_1) # minimum time required from t2 start to t1 start + + if dt_min_1_2 + dt_min_2_1 > 0: + print(f"The timing between these two tasks seems to be impossible...") + + breakpoint() + return dt_min_1_2, dt_min_2_1 + + +def implementStrategy_staged(sc): + '''This sets up Tasks in a way that implements a staged installation + strategy where all of one thing is done before all of a next thing. + ''' + + # ----- Create a Task for all the anchor installs ----- + + # gather the relevant actions + acts = [] + for action in sc.actions.values(): + if action.type == 'install_anchor': + acts.append(action) + + # create a dictionary of dependencies indicating that these actions are all in series + act_sequence = {} # key is action name, value is a list of what action names are to be completed before it + for i in range(len(acts)): + if i==0: # first action has no dependencies + act_sequence[acts[i].name] = [] + else: # remaining actions are just a linear sequence + act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + + # create the task, passing in the sequence of actions + sc.addTask(acts, act_sequence, 'install_all_anchors') + + # ----- Create a Task for all the mooring installs ----- + + # gather the relevant actions + acts = [] + # first load each mooring + for action in sc.actions.values(): + if action.type == 'load_mooring': + acts.append(action) + # next lay each mooring (eventually route logic could be added) + for action in sc.actions.values(): + if action.type == 'lay_mooring': + acts.append(action) + + # create a dictionary of dependencies indicating that these actions are all in series + act_sequence = {} # key is action name, value is a list of what action names are to be completed before it + for i in range(len(acts)): + if i==0: # first action has no dependencies + act_sequence[acts[i].name] = [] + else: # remaining actions are just a linear sequence + act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + + # create the task, passing in the sequence of actions + sc.addTask(acts, act_sequence, 'install_all_moorings') + + + # ----- Create a Task for the platform tow-out and hookup ----- + + # gather the relevant actions + acts = [] + # first tow out the platform + acts.append(sc.actions['tow']) + # next hook up each mooring + for action in sc.actions.values(): + if action.type == 'mooring_hookup': + acts.append(action) + + # create a dictionary of dependencies indicating that these actions are all in series + act_sequence = {} # key is action name, value is a list of what action names are to be completed before it + for i in range(len(acts)): + if i==0: # first action has no dependencies + act_sequence[acts[i].name] = [] + else: # remaining actions are just a linear sequence + act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) + + # create the task, passing in the sequence of actions + sc.addTask(acts, act_sequence, 'tow_and_hookup') + + + +if __name__ == '__main__': + '''This is currently a script to explore how some of the workflow could + work. Can move things into functions/methods as they solidify. + ''' + + # ----- Load up a Project ----- + + from famodel.project import Project + + + #%% Section 1: Project without RAFT + print('Creating project without RAFT\n') + print(os.getcwd()) + # create project object + # project = Project(file='C:/Code/FAModel/examples/OntologySample200m_1turb.yaml', raft=False) # for Windows + project = Project(file='calwave_ontology.yaml', raft=False) # for Mac + # create moorpy system of the array, include cables in the system + project.getMoorPyArray(cables=1) + # plot in 3d, using moorpy system for the mooring and cable plots + # project.plot2d() + # project.plot3d() + + ''' + # project.arrayWatchCircle(ang_spacing=20) + # save envelopes from watch circle information for each mooring line + for moor in project.mooringList.values(): + moor.getEnvelope() + + # plot motion envelopes with 2d plot + project.plot2d(save=True, plot_bathymetry=False) + ''' + + + # ----- Initialize the action stuff ----- + + sc = Scenario() # class instance holding most of the info + + + # Parse out the install steps required + + for akey, anchor in project.anchorList.items(): + + ## Test action.py for anchor install + + # add and register anchor install action(s) + a1 = sc.addAction('install_anchor', f'install_anchor-{akey}', objects=[anchor]) + duration, cost = a1.evaluateAssets({ + 'carrier' : sc.vessels["Jag"], + 'operator':sc.vessels["San_Diego"] + }) + print(f'Anchor install action {a1.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + + # register the actions as necessary for the anchor <<< do this for all objects?? + anchor.install_dependencies = [a1] + + + hookups = [] # list of hookup actions + + for mkey, mooring in project.mooringList.items(): + + # note origin and destination + + # --- lay out all the mooring's actions (and their links) + + ## Test action.py for mooring load + + # create load vessel action + a2 = sc.addAction('load_mooring', f'load_mooring-{mkey}', objects=[mooring]) + #duration, cost = a2.evaluateAssets({'carrier2' : sc.vessels["HL_Giant"], 'carrier1' : sc.vessels["Barge_squid"], 'operator' : sc.vessels["HL_Giant"]}) + print(f'Mooring load action {a2.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + + # create ship out mooring action + + # create lay mooring action + a3 = sc.addAction('lay_mooring', f'lay_mooring-{mkey}', objects=[mooring], dependencies=[a2]) + sc.addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor + print(f'Lay mooring action {a3.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + + # mooring could be attached to anchor here - or could be lowered with anchor!! + #(r=r_anch, mooring=mooring, anchor=mooring.anchor...) + # the action creator can record any dependencies related to actions of the anchor + + # create hookup action + a4 = sc.addAction('mooring_hookup', f'mooring_hookup-{mkey}', + objects=[mooring, mooring.attached_to[1]], dependencies=[a2, a3]) + #(r=r, mooring=mooring, platform=platform, depends_on=[a4]) + # the action creator can record any dependencies related to actions of the platform + + hookups.append(a4) + + + # add the FOWT install action + a5 = sc.addAction('tow', 'tow', objects=[list(project.platformList.values())[0]]) + for a in hookups: + sc.addActionDependencies(a, [a5]) # make each hookup action dependent on the FOWT being towed out + + + + # ----- Generate tasks (groups of Actions according to specific strategies) ----- + + #t1 = Task(sc.actions, 'install_mooring_system') + + # ----- Do some graph analysis ----- + + G = sc.visualizeActions() + + # make some tasks with one strategy... + implementStrategy_staged(sc) + + + # dt_min = sc.figureOutTaskRelationships() + + + + # ----- Check tasks for suitable vessels and the associated costs/times ----- + + # preliminary/temporary test of anchor install asset suitability + for akey, anchor in project.anchorList.items(): + for a in anchor.install_dependencies: # go through required actions (should just be the anchor install) + a.evaluateAssets({'carrier' : sc.vessels["San_Diego"]}) # see if this example vessel can do it + + + # ----- Generate the task_asset_matrix for scheduler ----- + # UNUSED FOR NOW + task_asset_matrix = np.zeros((len(sc.tasks), len(sc.vessels), 2)) + for i, task in enumerate(sc.tasks.values()): + row = task.get_row(sc.vessels) + if row.shape != (len(sc.vessels), 2): + raise Exception(f"Task '{task.name}' get_row output has wrong shape {row.shape}, should be {(2, len(sc.vessels))}") + task_asset_matrix[i, :] = row + + # ----- Call the scheduler ----- + # for timing with weather windows and vessel assignments + + records = [] + for task in sc.tasks.values(): + print('XXXXXXX') + print(task.name) + for act in task.actions.values(): + print(f" {act.name}: duration: {act.duration} start time: {task.actions_ti[act.name]}") + # start = float(task.actions_ti[name]) # start time [hr] + # dur = float(act.duration) # duration [hr] + # end = start + dur + + # records.append({ + # 'task' : task.name, + # 'action' : name, + # 'duration_hr': dur, + # 'time_label' : f'{start:.1f}–{end:.1f} hr', + # 'periods' : [(start, end)], # ready for future split periods + # 'start_hr' : start, # optional but handy + # 'end_hr' : end + # }) + + # Example: + # for r in records: + # print(f"{r['task']} :: {r['action']} duration_hr={r['duration_hr']:.1f} " + # f"start={r['start_hr']:.1f} label='{r['time_label']}' periods={r['periods']}") + + + # ----- Run the simulation ----- + ''' + for t in np.arange(8760): + + # run the actions - these will set the modes and velocities of things... + for a in actionList: + if a.status == 0: + pass + #check if the event should be initiated + elif a.status == 1: + a.timestep() # advance the action + # if status == 2: finished, then nothing to do + + # run the time integrator to update the states of things... + for v in self.vesselList: + v.timestep() + + + + # log the state of everything... + ''' + + + + + plt.show() + \ No newline at end of file diff --git a/famodel/irma/calwave_objects.yaml b/famodel/irma/calwave_objects.yaml new file mode 100644 index 00000000..3bdccf4f --- /dev/null +++ b/famodel/irma/calwave_objects.yaml @@ -0,0 +1,33 @@ +# list of object types and attributes, a directory to ensure consistency +# (Any object relations will be checked against this list for validity) + +mooring: # object name + - length # list of supported attributes... + - pretension + - weight + +platform: # can be wec + - mass + - draft + - wec + +anchor: + - mass + - length + +component: + - mass + - length + +turbine: + +cable: + +vessel: + +#mooring: +# install sequence: +# ship out mooring +# lay mooring +# attach mooring-anchor (ROV) +# hookup mooring-platform \ No newline at end of file diff --git a/famodel/irma/calwave_ontology.yaml b/famodel/irma/calwave_ontology.yaml new file mode 100644 index 00000000..7056b323 --- /dev/null +++ b/famodel/irma/calwave_ontology.yaml @@ -0,0 +1,257 @@ +type: draft/example of floating array ontology under construction +name: +comments: +# Site condition information +site: + general: + water_depth : 200 # [m] uniform water depth + rho_water : 1025.0 # [kg/m^3] water density + rho_air : 1.225 # [kg/m^3] air density + mu_air : 1.81e-05 # air dynamic viscosity + #... + + boundaries: # project or lease area boundary, via file or vertex list + file: # filename of x-y vertex coordinates [m] + x_y: # list of polygon vertices in order [m] + - [-3000, -3000] + - [-3000, 3000] + - [3000, 3000] + - [3000, -3000] + + bathymetry: + file: './calwave_bathymetry.txt' + + seabed: + x : [-10901, 0, 10000] + y : [-10900, 0, 10000 ] + + type_array: + - [mud_soft , mud_firm , mud_soft] + - [mud_soft , mud_firm , mud_soft] + - [mud_soft , mud_firm , mud_soft] + + soil_types: + mud_soft: + Su0 : [2.39] # [kPa] + k : [1.41] # [kPa/m] + depth: [0] # [m] + mud_firm: + Su0 : [23.94] # [kPa] + k : [2.67] # [kPa/m] + depth: [0] # [m] + + metocean: + extremes: # extreme values for specified return periods (in years) + keys : [ Hs , Tp , WindSpeed, TI, Shear, Gamma, CurrentSpeed ] + data : + 1: [ 1 ,2 ,3 ] + 10: [ 1 , 2 , 3 ] + 50: [ 1 , 2 , 3 ] + 500: [ 1 , 2 , 3 ] + + probabalistic_bins: + keys : [ prob , Hs , Tp, WindSpeed, TI, Shear, Gamma, CurrentSpeed, WindDir, WaveDir, CurrentDir ] + data : + - [ 0.010 , 1 , 1 ] + - [ 0.006 , 1 , 1 ] + - [ 0.005 , 1 , 1 ] + + time_series : + file: 'metocean_timeseries.csv' + + resource : + file: 'windresource' + + RAFT_cases: + keys : [wind_speed, wind_heading, turbulence, turbine_status, yaw_misalign, wave_spectrum, wave_period, wave_height, wave_heading ] + data : # m/s deg % or e.g. IIB_NTM string deg string (s) (m) (deg) + - [ 0, 0, 0, operating, 0, JONSWAP, 12, 6, 0 ] + # - [ 16, 0, IIB_NTM, operating, 0, JONSWAP, 12, 6, 30 ] + # - [ 10.59, 0, 0.05, operating, 0, JONSWAP, 15.75, 11.86, 0 ] + + RAFT_settings: + min_freq : 0.001 # [Hz] lowest frequency to consider, also the frequency bin width + max_freq : 0.20 # [Hz] highest frequency to consider + XiStart : 0 # sets initial amplitude of each DOF for all frequencies + nIter : 4 # sets how many iterations to perform in Model.solveDynamics() + +# ----- Array-level inputs ----- + +# Wind turbine array layout +array: + keys : [ID, topsideID, platformID, mooringID, x_location, y_location, heading_adjust] + data : # ID# ID# ID# [m] [m] [deg] + - [wec, 1, 1, ms1, -1600, -1600, 180 ] + +# ----- turbines and platforms ----- + +topsides: + + - type : turbine + mRNA : 991000 # [kg] RNA mass + IxRNA : 0 # [kg-m2] RNA moment of inertia about local x axis (assumed to be identical to rotor axis for now, as approx) [kg-m^2] + IrRNA : 0 # [kg-m2] RNA moment of inertia about local y or z axes [kg-m^2] + xCG_RNA : 0 # [m] x location of RNA center of mass [m] (Actual is ~= -0.27 m) + hHub : 150.0 # [m] hub height above water line [m] + Fthrust : 1500.0E3 # [N] temporary thrust force to use + + I_drivetrain: 318628138.0 # full rotor + drivetrain inertia as felt on the high-speed shaft + + nBlades : 3 # number of blades + Zhub : 150.0 # hub height [m] + Rhub : 3.97 # hub radius [m] + precone : 4.0 # [deg] + shaft_tilt : 6.0 # [deg] + overhang : -12.0313 # [m] + aeroMod : 1 # 0 aerodynamics off; 1 aerodynamics on + +platform: + + type : WEC + potModMaster : 1 # [int] master switch for potMod variables; 0=keeps all member potMod vars the same, 1=turns all potMod vars to False (no HAMS), 2=turns all potMod vars to True (no strip) + dlsMax : 5.0 # maximum node splitting section amount for platform members; can't be 0 + qtfPath : 'IEA-15-240-RWT-UMaineSemi.12d' # path to the qtf file for the platform + rFair : 58 + zFair : -14 + + members: # list all members here + + - name : center_column # [-] an identifier (no longer has to be number) + type : 2 # [-] + rA : [ 0, 0, -20] # [m] end A coordinates + rB : [ 0, 0, 15] # [m] and B coordinates + shape : circ # [-] circular or rectangular + gamma : 0.0 # [deg] twist angle about the member's z-axis + potMod : True # [bool] Whether to model the member with potential flow (BEM model) plus viscous drag or purely strip theory + # --- outer shell including hydro--- + stations : [0, 1] # [-] location of stations along axis. Will be normalized such that start value maps to rA and end value to rB + d : 10.0 # [m] diameters if circular or side lengths if rectangular (can be pairs) + t : 0.05 # [m] wall thicknesses (scalar or list of same length as stations) + Cd : 0.6 # [-] transverse drag coefficient (optional, scalar or list of same length as stations) + Ca : 0.93 # [-] transverse added mass coefficient (optional, scalar or list of same length as stations) + CdEnd : 0.6 # [-] end axial drag coefficient (optional, scalar or list of same length as stations) + CaEnd : 1.0 # [-] end axial added mass coefficient (optional, scalar or list of same length as stations) + rho_shell : 7850 # [kg/m3] + # --- handling of end caps or any internal structures if we need them --- + cap_stations : [ 0 ] # [m] location along member of any inner structures (in same scaling as set by 'stations') + cap_t : [ 0.001 ] # [m] thickness of any internal structures + cap_d_in : [ 0 ] # [m] inner diameter of internal structures (0 for full cap/bulkhead, >0 for a ring shape) + + + - name : outer_column # [-] an identifier (no longer has to be number) + type : 2 # [-] + rA : [51.75, 0, -20] # [m] end A coordinates + rB : [51.75, 0, 15] # [m] and B coordinates + heading : [ 60, 180, 300] # [deg] heading rotation of column about z axis (for repeated members) + shape : circ # [-] circular or rectangular + gamma : 0.0 # [deg] twist angle about the member's z-axis + potMod : True # [bool] Whether to model the member with potential flow (BEM model) plus viscous drag or purely strip theory + # --- outer shell including hydro--- + stations : [0, 35] # [-] location of stations along axis. Will be normalized such that start value maps to rA and end value to rB + d : 12.5 # [m] diameters if circular or side lengths if rectangular (can be pairs) + t : 0.05 # [m] wall thicknesses (scalar or list of same length as stations) + Cd : 0.6 # [-] transverse drag coefficient (optional, scalar or list of same length as stations) + Ca : 0.93 # [-] transverse added mass coefficient (optional, scalar or list of same length as stations) + CdEnd : 1.0 # [-] end axial drag coefficient (optional, scalar or list of same length as stations) + CaEnd : 0.7 # value of 3.0 gives more heave response # [-] end axial added mass coefficient (optional, scalar or list of same length as stations) + rho_shell : 7850 # [kg/m3] + # --- ballast --- + l_fill : 1.4 # [m] + rho_fill : 5000 # [kg/m3] + # --- handling of end caps or any internal structures if we need them --- + cap_stations : [ 0 ] # [m] location along member of any inner structures (in same scaling as set by 'stations') + cap_t : [ 0.001 ] # [m] thickness of any internal structures + cap_d_in : [ 0 ] # [m] inner diameter of internal structures (0 for full cap/bulkhead, >0 for a ring shape) + + + - name : pontoon # [-] an identifier (no longer has to be number) + type : 2 # [-] + rA : [ 5 , 0, -16.5] # [m] end A coordinates + rB : [ 45.5, 0, -16.5] # [m] and B coordinates + heading : [ 60, 180, 300] # [deg] heading rotation of column about z axis (for repeated members) + shape : rect # [-] circular or rectangular + gamma : 0.0 # [deg] twist angle about the member's z-axis + potMod : False # [bool] Whether to model the member with potential flow (BEM model) plus viscous drag or purely strip theory + # --- outer shell including hydro--- + stations : [0, 40.5] # [-] location of stations along axis. Will be normalized such that start value maps to rA and end value to rB + d : [12.4, 7.0] # [m] diameters if circular or side lengths if rectangular (can be pairs) + t : 0.05 # [m] wall thicknesses (scalar or list of same length as stations) + Cd : [1.5, 2.2 ] # [-] transverse drag coefficient (optional, scalar or list of same length as stations) + Ca : [2.2, 0.2 ] # [-] transverse added mass coefficient (optional, scalar or list of same length as stations) + CdEnd : 0.0 # [-] end axial drag coefficient (optional, scalar or list of same length as stations) + CaEnd : 0.0 # [-] end axial added mass coefficient (optional, scalar or list of same length as stations) + rho_shell : 7850 # [kg/m3] + l_fill : 40.5 # [m] + rho_fill : 1025.0 # [kg/m3] + + + - name : upper_support # [-] an identifier (no longer has to be number) + type : 2 # [-] + rA : [ 5 , 0, 14.545] # [m] end A coordinates + rB : [ 45.5, 0, 14.545] # [m] and B coordinates + heading : [ 60, 180, 300] # [deg] heading rotation of column about z axis (for repeated members) + shape : circ # [-] circular or rectangular + gamma : 0.0 # [deg] twist angle about the member's z-axis + potMod : False # [bool] Whether to model the member with potential flow (BEM model) plus viscous drag or purely strip theory + # --- outer shell including hydro--- + stations : [0, 1] # [-] location of stations along axis. Will be normalized such that start value maps to rA and end value to rB + d : 0.91 # [m] diameters if circular or side lengths if rectangular (can be pairs) + t : 0.01 # [m] wall thicknesses (scalar or list of same length as stations) + Cd : 0.0 # [-] transverse drag coefficient (optional, scalar or list of same length as stations) + Ca : 0.0 # [-] transverse added mass coefficient (optional, scalar or list of same length as stations) + CdEnd : 0.0 # [-] end axial drag coefficient (optional, scalar or list of same length as stations) + CaEnd : 0.0 # [-] end axial added mass coefficient (optional, scalar or list of same length as stations) + rho_shell : 7850 # [kg/m3] + + +# ----- Mooring system ----- + +# Mooring system descriptions (each for an individual FOWT with no sharing) +mooring_systems: + + ms1: + name: 2-line semi-taut polyester mooring system with a third line shared + + keys: [MooringConfigID, heading, anchorType, lengthAdjust] + data: + - [ semitaut-poly_1, 0 , suction1, 0 ] + - [ semitaut-poly_1, 90 , suction1, 0 ] + - [ semitaut-poly_1, 180 , suction1, 0 ] + - [ semitaut-poly_1, 270 , suction1, 0 ] + + +# Mooring line configurations +mooring_line_configs: + + semitaut-poly_1: # mooring line configuration identifier + + name: Semitaut polyester configuration 1 # descriptive name + + span: 200 + + sections: #in order from anchor to fairlead + - mooringFamily: chain # ID of a mooring line section type + d_nom: .1549 + length: 10.7 # [m] usntretched length of line section + adjustable: True # flags that this section could be adjusted to accommodate different spacings... + - connectorType: h_link + - mooringFamily: polyester # ID of a mooring line section type + d_nom: .182 + length: 199.8 # [m] length (unstretched) + +# Mooring connector properties +mooring_connector_types: + + h_link: + m : 140 # [kg] component mass + v : 0.13 # [m^3] component volumetric displacement + +# Anchor type properties +anchor_types: + + suction1: + type : suction_pile + L : 16.4 # length of pile [m] + D : 5.45 # diameter of pile [m] + zlug : 9.32 # embedded depth of padeye [m] + diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py new file mode 100644 index 00000000..2828b981 --- /dev/null +++ b/famodel/irma/calwave_task1.py @@ -0,0 +1,137 @@ +# task1_calwave.py +# Build CalWave Task 1 (Anchor installation) from PDF routes and action table. + +from action import Action +from task import Task +from calwave_irma import Scenario +# If you use Scenario/Project to add actions + hold vessels, import it: +from famodel.project import Project + +# ---- Constants from CalWave_IOM_Summary.pdf ---- +# Transit times [h] (one-way) +SAN_DIEGO_NationalCity_to_TestSite = 4.5 +JAG_NationalCity_to_TestSite = 4.5 +BEYSTER_PointLoma_to_TestSite = 0.8 + +# At site support transit +JAG_TestSite = 7.5 + +# Mirror returns +SAN_DIEGO_TestSite_to_Home = SAN_DIEGO_NationalCity_to_TestSite +JAG_TestSite_to_Home = JAG_NationalCity_to_TestSite +BEYSTER_TestSite_to_Home = BEYSTER_PointLoma_to_TestSite + +def build_task1_calwave(sc): + """ + sc: scenario/project object that exposes: + - sc.vessels['San_Diego'], sc.vessels['Jag'], sc.vessels['Beyster'] + - sc.addAction(action_type_name, name, objects=None) -> Action + Returns: + task (Task) + """ + + # --- Create Actions --- + a_mob_sd = sc.addAction('mobilize', 'mobilize_sandiego') + a_mob_bys = sc.addAction('mobilize', 'mobilize_beyster') + + a_load_cargo = sc.addAction('load_cargo', 'load_cargo_task1', objects=[]) # add anchors and moorings? + + a_tr_site_sd = sc.addAction('transit_tug', 'transit_site_sandiego') + a_tr_site_jag = sc.addAction('transit', 'transit_site_jag') + a_tr_site_bys = sc.addAction('transit', 'transit_site_beyster') + a_tr_at_site_jag = sc.addAction('at_site_support', 'at_site_jag') + + a_install_anchor = sc.addAction('install_anchor', 'install_anchor_task1', objects=[]) + a_install_mooring = sc.addAction('install_mooring', 'install_mooring_task1', objects=[]) + + a_monitor = sc.addAction('monitor_installation', 'monitor_installation_task1') + + a_tr_home_sd = sc.addAction('transit_tug', 'transit_home_sandiego') + a_tr_home_jag = sc.addAction('transit', 'transit_home_jag') + a_tr_home_bys = sc.addAction('transit', 'transit_home_beyster') + + # --- Assign assets and compute durations/costs where needed --- + # Mobilize / Load: let evaluateAssets compute time/cost from capabilities + a_mob_sd.evaluateAssets( {'operator': sc.vessels['San_Diego']} ) + a_mob_bys.evaluateAssets( {'operator': sc.vessels['Beyster']} ) + a_load_cargo.evaluateAssets( {'operator': sc.vessels['San_Diego']} ) + + # Transit site: set duration from PDF table; still assign a vessel so costing works (if your calc uses day rate) + a_tr_site_sd.duration = SAN_DIEGO_NationalCity_to_TestSite + a_tr_site_jag.duration = JAG_NationalCity_to_TestSite + a_tr_site_bys.duration = BEYSTER_PointLoma_to_TestSite + a_tr_at_site_jag.duration = JAG_TestSite + # Optionally call evaluateAssets to pick up cost models: + a_tr_site_sd.evaluateAssets( {'carrier': sc.vessels['San_Diego']} ) + a_tr_site_jag.evaluateAssets( {'operator': sc.vessels['Jag']} ) + a_tr_site_bys.evaluateAssets( {'carrier': sc.vessels['Beyster']} ) + + # Install: vessel + tug + a_install_anchor.evaluateAssets( {'operator': sc.vessels['San_Diego'], 'carrier': sc.vessels['Jag']} ) + a_install_mooring.evaluateAssets( {'operator': sc.vessels['San_Diego'], 'carrier': sc.vessels['Jag']} ) + a_tr_at_site_jag.evaluateAssets( {'operator': sc.vessels['Jag']}) # Need to include this when the tug is included in install anchor and mooring + + # Monitor (Beyster) + a_monitor.evaluateAssets( {'support': sc.vessels['Beyster']} ) + + # Transit homeport: set duration from PDF and assign asset for costing + a_tr_home_sd.duration = SAN_DIEGO_TestSite_to_Home + a_tr_home_jag.duration = JAG_TestSite_to_Home + a_tr_home_bys.duration = BEYSTER_TestSite_to_Home + # Optionally call evaluateAssets to pick up cost models: + a_tr_home_sd.evaluateAssets( {'carrier': sc.vessels['San_Diego']} ) + a_tr_home_jag.evaluateAssets( {'operator': sc.vessels['Jag']} ) + a_tr_home_bys.evaluateAssets( {'carrier': sc.vessels['Beyster']} ) + + # --- Compose the action list for the Task --- + actions = [ + a_mob_sd, a_mob_bys, + a_load_cargo, + a_tr_site_sd, + a_tr_site_jag, a_tr_site_bys, + a_tr_at_site_jag, + a_install_anchor, a_install_mooring, + a_monitor, + a_tr_home_sd, + a_tr_home_jag, a_tr_home_bys, + ] + + # --- Define the sequencing (dependencies) --- + # Keys are action names; values are lists of prerequisite action names. + action_sequence = { + # Mobilize and load + a_mob_sd.name: [], + a_mob_bys.name: [], + a_load_cargo.name: [a_mob_sd.name], + + # Transit to site (can be parallel), but after loading + #a_tr_site_sd.name: [a_load_cargo.name], + a_tr_site_jag.name: [a_mob_sd.name], # tug stays on site for support + a_tr_site_bys.name: [a_mob_bys.name], # support can leave when ready + + # Installs: require everyone onsite + a_install_anchor.name: [a_tr_site_jag.name, a_tr_site_jag.name], + a_install_mooring.name: [a_install_anchor.name], # mooring after anchors + + # Monitoring: runs during/after install (simplify: after mooring) + a_monitor.name: [a_install_mooring.name, a_tr_site_bys.name], + + # Transit homeport: everyone returns after monitoring + #a_tr_home_sd.name: [a_monitor.name], + a_tr_home_jag.name: [a_monitor.name], + a_tr_home_bys.name: [a_monitor.name], + } + + # --- Build and return the Task (plots sequence with CPs) --- + task = Task(actions=actions, action_sequence=action_sequence, name='CalWave_Task1_AnchorInstall') + return task + +if __name__ == '__main__': + # Example skeleton: adjust to your actual Scenario/Project initializer + project = Project(file='calwave_ontology.yaml', raft=False) # for Mac + # create moorpy system of the array, include cables in the system + project.getMoorPyArray(cables=1) + sc = Scenario() + task = build_task1_calwave(sc) + # (The Task constructor will plot the sequence graph automatically) + pass diff --git a/famodel/irma/calwave_task1b.py b/famodel/irma/calwave_task1b.py new file mode 100644 index 00000000..03e2eda6 --- /dev/null +++ b/famodel/irma/calwave_task1b.py @@ -0,0 +1,435 @@ +# calwave_task1.py +# Build CalWave Task 1 (Anchor installation) following the theory flow: +# 1) addAction → structure only (type, name, objects, deps) +# 2) evaluateAssets → assign vessels/roles (+ durations/costs) +# 3) (schedule/plot handled by your existing tooling) + +from famodel.project import Project +from calwave_irma import Scenario +from calwave_chart2 import Bubble, VesselTimeline, Task, plot_task + +sc = Scenario() # now sc exists in *this* session + +# list vessel keys +print(list(sc.vessels.keys())) + +# get a vessel name (dict-of-dicts access) +print(sc.vessels['San_Diego']) + +# ------- Transit durations from plan [h] (one-way); tune as needed ------- +SAN_DIEGO_TO_SITE = 4.5 +JAG_TO_SITE = 4.5 +BEYSTER_TO_SITE = 1.8 + +AT_SITE_SUPPORT_BLOCK = 7.5 # Jag on-station support window (fixed block) + +SAN_DIEGO_TO_HOME = SAN_DIEGO_TO_SITE +JAG_TO_HOME = JAG_TO_SITE +BEYSTER_TO_HOME = BEYSTER_TO_SITE + +# ---------- Helpers: map & normalize Project objects ---------- +def map_project_objects(project): + """ + Return A, M dicts with stable keys A1..A4 / M1..M4 mapped from project lists. + Ensures each object has .type ('anchor'/'mooring') and .name for nice labels. + """ + # Choose a stable order (sorted by key) + a_keys = sorted(project.anchorList.keys()) # e.g., ['weca','wecb','wecc','wecd'] + m_keys = sorted(project.mooringList.keys()) + + A = {f'A{i+1}': project.anchorList[k] for i, k in enumerate(a_keys)} + M = {f'M{i+1}': project.mooringList[k] for i, k in enumerate(m_keys)} + + # Normalize .type and .name (some libs don't set these) + for k, obj in A.items(): + if getattr(obj, 'type', None) != 'anchor': + setattr(obj, 'type', 'anchor') + if not hasattr(obj, 'name'): + setattr(obj, 'name', k) + for k, obj in M.items(): + if getattr(obj, 'type', None) != 'mooring': + setattr(obj, 'type', 'mooring') + if not hasattr(obj, 'name'): + setattr(obj, 'name', k) + return A, M + +def eval_set(a, roles, duration=None, **params): + """ + Convenience: call evaluateAssets with roles/params and optionally set .duration. + Always stores assigned_assets for plotting/scheduling attribution. + """ + # Your Action.evaluateAssets may return (duration, cost); we still set explicit duration if passed. + res = a.evaluateAssets(roles | params) + if duration is not None: + a.duration = float(duration) + elif isinstance(res, tuple) and len(res) > 0 and res[0] is not None: + try: + a.duration = float(res[0]) + except Exception: + pass + # keep roles visible on the action + a.assigned_assets = roles + return a + +# ---------- Core builder ---------- +def build_task1_calwave(sc: Scenario, project: Project): + """ + Creates Task 1 actions + dependencies (no scheduling/plotting here). + Generic vessel actions are objectless; domain actions carry domain objects. + """ + # Real domain instances + A, M = map_project_objects(project) + + # --- Pre-ops (objectless) --- + mob_sd = sc.addAction('mobilize', 'mobilize_SanDiego') + + tr_sd = sc.addAction('transit_tug', 'transit_site_SanDiego', + dependencies=[mob_sd]) + tr_jag = sc.addAction('transit', 'transit_site_Jag', + dependencies=[mob_sd]) + + mob_by = sc.addAction('mobilize', 'mobilize_Beyster', + dependencies=[mob_sd]) + tr_by = sc.addAction('transit', 'transit_site_Beyster', + dependencies=[mob_by]) + + # Jag support window (objectless) + sup_jag = sc.addAction('at_site_support', 'at_site_support_Jag', + dependencies=[tr_jag]) + + # --- On-site (domain objects REQUIRED) --- + inst, trans_tug, trans, mon = [], [], [], [] + for i in range(1, 5): + ak, sk = f'A{i}', f'S{i}' + a_inst = sc.addAction('install_anchor', f'install_anchor-{ak}', + objects=[A[ak]], + dependencies=[tr_sd]) # SD on site + Jag supporting + a_trans_tug = sc.addAction('transit_tug', f'transit_tug-{ak}', + # objects=[], + dependencies=[a_inst]) + a_trans = sc.addAction('transit', f'transit-{ak}', + # objects=[], + dependencies=[a_inst]) + a_mon = sc.addAction('monitor_installation', f'monitor_installation-{sk}', + objects=[A[ak]], + dependencies=[a_trans_tug]) + inst.append(a_inst) + trans_tug.append(a_trans_tug) + trans.append(a_trans) + mon.append(a_mon) + + mon_last = mon[-1] + + # --- Post-ops (objectless) --- + home_sd = sc.addAction('transit_tug', 'transit_homeport_SanDiego', + dependencies=mon) + home_jag = sc.addAction('transit', 'transit_homeport_Jag', + dependencies=mon) + home_by = sc.addAction('transit', 'transit_homeport_Beyster', + dependencies=mon) + + # --- Pre-ops (objectless) --- + demob_sd = sc.addAction('demobilize', 'demobilize_SanDiego', + dependencies=[home_sd]) + demob_by = sc.addAction('demobilize', 'demobilize_Beyster', + dependencies=[home_by]) + + # Return a simple list for downstream evaluate/schedule/plot steps + return { + 'mobilize': [mob_sd, mob_by], + 'transit_site': [tr_sd, tr_jag, tr_by], + 'support': [sup_jag], + 'install': inst, + 'transit_tug': trans_tug, + 'transit': trans, + 'monitor': mon, + 'transit_homeport': [home_sd, home_jag, home_by], + 'demobilize': [demob_sd, demob_by] + } + +# ---------- Evaluation step (assign vessels & durations) ---------- +def evaluate_task1(sc: Scenario, actions: dict): + """ + Assign vessels/roles and set durations where the evaluator doesn't. + Keeps creation and evaluation clearly separated. + """ + V = sc.vessels # shorthand + + # Mobilize + eval_set(actions['mobilize'][0], {'operator': V['San_Diego']}, duration=2.0) + eval_set(actions['mobilize'][1], {'operator': V['Beyster']}, duration=1.0) + + # Transit to site + tr_sd, tr_jag, tr_by = actions['transit_site'] + eval_set(tr_sd, {'carrier': V['Jag'], 'operator': V['San_Diego']}, duration=SAN_DIEGO_TO_SITE) + eval_set(tr_jag, {'carrier': V['Jag']}, duration=JAG_TO_SITE) + eval_set(tr_by, {'carrier': V['Beyster']}, duration=BEYSTER_TO_SITE) + + # Jag support block (fixed) + eval_set(actions['support'][0], {'operator': V['Jag']}, duration=AT_SITE_SUPPORT_BLOCK) + + # Install / Lay (San Diego operates; durations can come from evaluator or set defaults) + for a_inst in actions['install']: + eval_set(a_inst, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + if not getattr(a_inst, 'duration', 0): + a_inst.duration = 1.2 # h, placeholder if evaluator didn’t set it + for a_trans_tug in actions['transit_tug']: + eval_set(a_trans_tug, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + if not getattr(a_trans_tug, 'duration', 0): + a_trans_tug.duration = 2.0 # h, placeholder + for a_trans in actions['transit']: + eval_set(a_trans, {'carrier': V['Beyster']}) + if not getattr(a_trans, 'duration', 0): + a_trans.duration = 1.0 # h, placeholder + + # Monitor (Beyster) + for a_mon in actions['monitor']: + eval_set(a_mon, {'support': V['Beyster']}) + if not getattr(a_mon, 'duration', 0): + a_mon.duration = 2.0 # h, placeholder + + # Transit home + home_sd, home_jag, home_by = actions['transit_homeport'] + eval_set(home_sd, {'carrier': V['Jag'], 'operator': V['San_Diego']}, duration=SAN_DIEGO_TO_HOME) + eval_set(home_jag, {'carrier': V['Jag']}, duration=JAG_TO_HOME) + eval_set(home_by, {'carrier': V['Beyster']}, duration=BEYSTER_TO_HOME) + + # Demobilize + eval_set(actions['demobilize'][0], {'operator': V['San_Diego']}, duration=2.0) + eval_set(actions['demobilize'][1], {'operator': V['Beyster']}, duration=1.0) + +def _action_resources(a) -> set[str]: + """ + Return the set of resource keys (vessel names) this action occupies. + We look in assigned_assets for any vessel-like roles. + If nothing is assigned, we put the action into an 'unknown' pool (no blocking effect). + """ + aa = getattr(a, 'assigned_assets', {}) or {} + keys = [] + for role in ('vessel', 'carrier', 'operator'): + v = aa.get(role) + if v is not None: + keys.append(getattr(v, 'name', str(v))) + return set(keys) if keys else {'unknown'} + +def schedule_actions(actions_by_name: dict[str, object]) -> dict[str, float]: + """ + Compute earliest-start times for all actions given durations and dependencies, + with single-resource constraints per vessel (i.e., a vessel can't overlap itself). + Returns: {action_name: start_time_hours} + """ + # Build dependency maps + deps = {name: [d if isinstance(d, str) else getattr(d, 'name', str(d)) + for d in getattr(a, 'dependencies', [])] + for name, a in actions_by_name.items()} + indeg = {name: len(dlist) for name, dlist in deps.items()} + children = {name: [] for name in actions_by_name} + for child, dlist in deps.items(): + for parent in dlist: + if parent not in children: + children[parent] = [] + children[parent].append(child) + + # Ready queue + ready = sorted([n for n, k in indeg.items() if k == 0]) + + start, finish = {}, {} + avail = {} # vessel_name -> time available + + scheduled = [] + + while ready: + name = ready.pop(0) + a = actions_by_name[name] + scheduled.append(name) + + # Dependency readiness + dep_ready = 0.0 + for d in deps[name]: + if d not in finish: + raise RuntimeError(f"Dependency '{d}' of '{name}' has no finish time; check graph.") + dep_ready = max(dep_ready, finish[d]) + + # Resource readiness (all vessels the action occupies) + res_keys = _action_resources(a) + res_ready = max(avail.get(r, 0.0) for r in res_keys) if res_keys else 0.0 + + # Start at the latest of dependency- and resource-readiness + s = max(dep_ready, res_ready) + d = float(getattr(a, 'duration', 0.0) or 0.0) + f = s + d + + start[name] = s + finish[name] = f + + # Block all involved resources until 'f' + for r in res_keys: + avail[r] = f + + # Release children + for c in children.get(name, []): + indeg[c] -= 1 + if indeg[c] == 0: + ready.append(c) + ready.sort() + + if len(scheduled) != len(actions_by_name): + missing = [n for n in actions_by_name if n not in scheduled] + raise RuntimeError(f"Cycle or missing predecessors detected; unscheduled: {missing}") + + return start + +# --------------------------- Scenario indexes ------------------------------ + +def build_indexes(sc): + '''Create quick lookups from Scenario content. + Returns a dict with: + - vessel_keys: set of vessel names as stored in sc.vessels + - type_to_vessel: map of unique vessel type -> vessel key (only if unique) + - action_to_vessel: map of action base-name -> vessel key declared in YAML + ''' + vessel_keys = set((getattr(sc, 'vessels', {}) or {}).keys()) + + # type -> vessel (only if unique across vessels) + type_count = {} + type_first = {} + for vkey, vdesc in (getattr(sc, 'vessels', {}) or {}).items(): + vtype = vdesc.get('type') + if isinstance(vtype, str) and vtype: + type_count[vtype] = type_count.get(vtype, 0) + 1 + type_first.setdefault(vtype, vkey) + type_to_vessel = {t: v for t, v in type_first.items() if type_count.get(t, 0) == 1} + + # actions listed under each vessel in YAML (if present) + action_to_vessel = {} + for vkey, vdesc in (getattr(sc, 'vessels', {}) or {}).items(): + v_actions = vdesc.get('actions') or {} + for aname in v_actions.keys(): + action_to_vessel.setdefault(aname, vkey) + + return { + 'vessel_keys': vessel_keys, + 'type_to_vessel': type_to_vessel, + 'action_to_vessel': action_to_vessel, + } + +# --------------------------- Lane resolution -------------------------------- + +def lane_from_action_name(aname: str, vessel_keys: set[str]) -> str | None: + # direct match of any vessel key as a token or suffix + # examples: 'mobilize_San_Diego', 'transit_site_Beyster', 'foo-Jag' + tokens = [aname] + for sep in ('_', '-', ':'): + tokens.extend(aname.split(sep)) + tokens = [t for t in tokens if t] + for vk in vessel_keys: + if vk in tokens or aname.endswith(vk) or aname.replace('_', '').endswith(vk.replace('_', '')): + return vk + return None + +def lane_from_assigned_assets(action, vessel_keys: set[str], type_to_vessel: dict[str, str]) -> str | None: + aa = getattr(action, 'assigned_assets', {}) or {} + + def pick(asset): + # direct vessel key string + if isinstance(asset, str) and asset in vessel_keys: + return asset + # object with .name equal to a vessel key + nm = getattr(asset, 'name', None) + if isinstance(nm, str) and nm in vessel_keys: + return nm + # dict with an explicit key equal to a vessel key + if isinstance(asset, dict): + for k in ('key', 'name', 'vessel', 'vessel_name', 'display_name', 'id', 'ID'): + v = asset.get(k) + if isinstance(v, str) and v in vessel_keys: + return v + # dict with a type that uniquely maps to a vessel + vtype = asset.get('type') + if isinstance(vtype, str) and vtype in type_to_vessel: + return type_to_vessel[vtype] + # object with .type that uniquely maps + vtype = getattr(asset, 'type', None) + if isinstance(vtype, str) and vtype in type_to_vessel: + return type_to_vessel[vtype] + return None + + # try roles in a reasonable priority + for role in ('vessel', 'operator', 'carrier', 'carrier1', 'carrier2', 'support'): + lane = pick(aa.get(role)) + if lane: + return lane + return None + +# --------------------------- Task builder ----------------------------------- + +def task_from_scenario(sc, start_times: dict, title: str, show_unknown: bool = False) -> Task: + idx = build_indexes(sc) + vessel_keys = idx['vessel_keys'] + type_to_vessel = idx['type_to_vessel'] + action_to_vessel = idx['action_to_vessel'] + + buckets = {} + warnings = [] + + for a in (getattr(sc, 'actions', {}) or {}).values(): + aname = a.name + t0 = float(start_times.get(aname, 0.0)) + dur = float(getattr(a, 'duration', 0.0) or 0.0) + + lane = ( + lane_from_action_name(aname, vessel_keys) + or action_to_vessel.get(aname) + or lane_from_assigned_assets(a, vessel_keys, type_to_vessel) + ) + if not lane: + if show_unknown: + lane = 'unknown' + else: + warnings.append(f'No lane for action {aname}; dropping from plot') + continue + + b = Bubble( + action=aname, + duration_hr=dur, + label_time=f'{dur:.1f}', + capabilities=[], + period=(t0, t0 + dur) + ) + buckets.setdefault(lane, []).append(b) + + # assemble rows and keep only vessels that exist in Scenario + lanes = [] + for vname, blist in buckets.items(): + if vname != 'unknown' and vname not in vessel_keys: + warnings.append(f'Lane {vname} not in Scenario.vessels; skipping') + continue + blist.sort(key=lambda b: b.period[0]) + lanes.append(VesselTimeline(vessel=vname, bubbles=blist)) + + # order rows according to the order in Scenario (if you prefer a fixed order, hardcode it) + order_map = {vk: i for i, vk in enumerate(sc.vessels.keys())} + lanes.sort(key=lambda vt: order_map.get(vt.vessel, 999)) + + return Task(name=title, vessels=lanes) + +if __name__ == '__main__': + # 1) Load ontology that mirrors the sample schema (mooring_systems + mooring_line_configs) + project = Project(file='calwave_ontology.yaml', raft=False) + project.getMoorPyArray(cables=1) + + # 2) Scenario with CalWave catalogs + sc = Scenario() + + # 3) Build (structure only) + actions = build_task1_calwave(sc, project) + + # 4) Evaluate (assign vessels/roles + durations) + evaluate_task1(sc, actions) + + # 5) Schedule (replace with proper scheduler call) + start_times = schedule_actions(sc.actions) # <- dict {action_name: t_star} + + # 6) plot with the calwave_chart2 visual + task = task_from_scenario(sc, start_times, title='CalWave Task 1', show_unknown=False) + plot_task(task) diff --git a/famodel/irma/calwave_vessels.yaml b/famodel/irma/calwave_vessels.yaml new file mode 100644 index 00000000..cd8477b0 --- /dev/null +++ b/famodel/irma/calwave_vessels.yaml @@ -0,0 +1,117 @@ +# This file defines vessels for the WEC CalWave as defined in doc Task 5.4 Comprehensive IO&M and Testing Plan + +San_Diego: + # Crane barge for anchor handling + type: crane_barge + transport: + transit_speed_mps: 2.6 # ~5 kts, from doc + Hs_m: 3 + station_keeping: + type: tug_assist # not self-propelled + capabilities: + bollard_pull: + max_force_t: 30 + deck_space: + area_m2: 800 + max_load_t: 1500 + crane: + capacity_t: 150 + hook_height_m: 40 + winch: + max_line_pull_t: 150 + brake_load_t: 300 + speed_mpm: 20 + monitoring_system: + metrics: [load, angle] + sampling_rate_hz: 1 + actions: + mobilize: {} + demobilize: {} + load_cargo: {} + transit_tug: {} + install_anchor: {} + retrieve_anchor: {} + install_mooring: {} + day_rate: 60000 # USD/day estimate + +Jag: + # Pacific Maritime Group tugboat assisting DB San Diego + type: tug + transport: + transit_speed_mps: 5.1 # ~10 kts + Hs_m: 3.5 + station_keeping: + type: conventional + capabilities: + engine: + power_hp: 300 + speed_kn: 5 + # bollard_pull: + # max_force_t: 30 + winch: + max_line_pull_t: 50 + brake_load_t: 100 + speed_mpm: 20 + actions: + tow: {} + transit: {} + at_site_support: {} + day_rate: 25000 + +Beyster: + # Primary support vessel + type: research_vessel + transport: + transit_speed_mps: 12.9 # 25 kts cruise, from doc + Hs_m: 2.5 + station_keeping: + type: DP1 # Volvo DPS system + capabilities: + engine: + power_hp: 300 + speed_kn: 5 + deck_space: + area_m2: 17.8 # from doc (192 ft²) + max_load_t: 5 + crane: + capacity_t: 0.30 # starboard knuckle crane, from doc + hook_height_m: 5.2 + # a_frame: + # capacity_t: 2.5 # stern A-frame, from doc + # hook_height_m: 5 # check doc + positioning_system: + accuracy_m: 1.0 + methods: [DPS, GPS] + monitoring_system: + metrics: [pressure, video, comms] + sampling_rate_hz: 5 + actions: + tow: {} + transit: {} + lay_cable: {} + mooring_hookup: {} + install_wec: {} + monitor_installation: {} + day_rate: 15000 + +Boston_Whaler: + # 19 ft Boston Whaler, support vessel + type: research_vessel + transport: + transit_speed_mps: 10.3 # ~20 kts cruise + Hs_m: 1.5 + station_keeping: + type: manual + capabilities: + deck_space: + area_m2: 4 + max_load_t: 1.1 # ~1134 kg payload, from doc + # propulsion: + # outboard_hp: 150 # from doc + monitoring_system: + metrics: [visual, diver_support] + sampling_rate_hz: 1 + actions: + diver_support: {} + tow: {} + day_rate: 5000 diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 845ef1e8..e6abd2b7 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -570,15 +570,15 @@ def implementStrategy_staged(sc): # create load vessel action a2 = sc.addAction('load_mooring', f'load_mooring-{mkey}', objects=[mooring]) - # duration, cost = a2.evaluateAssets({'carrier2' : sc.vessels["HL_Giant"], 'carrier1' : sc.vessels["Barge_squid"], 'operator' : sc.vessels["HL_Giant"]}) - # print(f'Mooring load action {a2.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + #duration, cost = a2.evaluateAssets({'carrier2' : sc.vessels["HL_Giant"], 'carrier1' : sc.vessels["Barge_squid"], 'operator' : sc.vessels["HL_Giant"]}) + print(f'Mooring load action {a2.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') # create ship out mooring action # create lay mooring action a3 = sc.addAction('lay_mooring', f'lay_mooring-{mkey}', objects=[mooring], dependencies=[a2]) sc.addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor - + print(f'Lay mooring action {a3.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') # mooring could be attached to anchor here - or could be lowered with anchor!! #(r=r_anch, mooring=mooring, anchor=mooring.anchor...) @@ -636,6 +636,31 @@ def implementStrategy_staged(sc): # ----- Call the scheduler ----- # for timing with weather windows and vessel assignments + records = [] + for task in sc.tasks.values(): + print('XXXXXXX') + print(task.name) + for act in task.actions.values(): + print(f" {act.name}: duration: {act.duration} start time: {task.actions_ti[act.name]}") + # start = float(task.actions_ti[name]) # start time [hr] + # dur = float(act.duration) # duration [hr] + # end = start + dur + + # records.append({ + # 'task' : task.name, + # 'action' : name, + # 'duration_hr': dur, + # 'time_label' : f'{start:.1f}–{end:.1f} hr', + # 'periods' : [(start, end)], # ready for future split periods + # 'start_hr' : start, # optional but handy + # 'end_hr' : end + # }) + + # Example: + # for r in records: + # print(f"{r['task']} :: {r['action']} duration_hr={r['duration_hr']:.1f} " + # f"start={r['start_hr']:.1f} label='{r['time_label']}' periods={r['periods']}") + # ----- Run the simulation ----- ''' From 5c3ec25edc3bd49e8b4fb2ef52be49c440463c9d Mon Sep 17 00:00:00 2001 From: Moreno Date: Mon, 20 Oct 2025 12:53:43 -0600 Subject: [PATCH 32/63] CalWave task 1 - anchor installation plan files upgrades and updates with new task and chart files --- famodel/irma/calwave_action.py | 415 +++++++++++++-- famodel/irma/calwave_actions.yaml | 44 +- famodel/irma/calwave_anchor installation.py | 216 ++++++++ famodel/irma/calwave_capabilities.yaml | 9 +- famodel/irma/calwave_chart.py | 531 ++++++++++---------- famodel/irma/calwave_chart1.py | 356 ------------- famodel/irma/calwave_chart2.py | 281 ----------- famodel/irma/calwave_irma.py | 40 +- famodel/irma/calwave_task.py | 226 +++++++++ famodel/irma/calwave_task1.py | 137 ----- famodel/irma/calwave_task1b.py | 435 ---------------- famodel/irma/calwave_vessels.yaml | 70 +-- 12 files changed, 1158 insertions(+), 1602 deletions(-) create mode 100644 famodel/irma/calwave_anchor installation.py delete mode 100644 famodel/irma/calwave_chart1.py delete mode 100644 famodel/irma/calwave_chart2.py create mode 100644 famodel/irma/calwave_task.py delete mode 100644 famodel/irma/calwave_task1.py delete mode 100644 famodel/irma/calwave_task1b.py diff --git a/famodel/irma/calwave_action.py b/famodel/irma/calwave_action.py index 8c1dfa64..992c7998 100644 --- a/famodel/irma/calwave_action.py +++ b/famodel/irma/calwave_action.py @@ -120,6 +120,8 @@ def __init__(self, actionType, name, **kwargs): self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on + self.actionType = actionType # <— keep the YAML dict on the instance + self.type = getFromDict(actionType, 'type', dtype=str) self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished @@ -842,7 +844,7 @@ def calcDurationAndCost(self): ------- `None` ''' - + # Check that all roles in the action are filled for role_name in self.requirements.keys(): if self.assets[role_name] is None: @@ -850,8 +852,8 @@ def calcDurationAndCost(self): # Initialize cost and duration self.cost = 0.0 # [$] - self.duration = 0.0 # [days] - + self.duration = 0.0 # [h] + """ Note to devs: The code here calculates the cost and duration of an action. Each action in the actions.yaml has a hardcoded 'model' @@ -875,10 +877,282 @@ def calcDurationAndCost(self): # --- Towing & Transport --- elif self.type == 'tow': pass - elif self.type == 'transit': - pass - elif self.type == 'transit_tug': - pass + + elif self.type == 'transit_linehaul_self': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + vessel = self.assets.get('vessel') or self.assets.get('operator') or self.assets.get('carrier') + if vessel is None: + raise ValueError('transit_linehaul_self: no vessel assigned.') + + tr = vessel['transport'] + + # distance + dist_m = float(tr['site_distance_m']) + + # speed: linehaul uses transport.cruise_speed_mps + speed_mps = float(tr['cruise_speed_mps']) + + dur_h = dist_m/speed_mps/3600.0 + self.duration += dur_h + # cost + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + + elif self.type == 'transit_linehaul_tug': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + tug = self.assets.get('operator') or self.assets.get('vessel') + barge = self.assets.get('carrier') + if tug is None or barge is None: + raise ValueError('transit_linehaul_tug: need tug (operator) and barge (carrier).') + + tr_b = barge.get('transport', {}) + tr_t = tug.get('transport', {}) + + # distance: prefer barge’s transport + dist_m = float(tr_b.get('site_distance_m', tr_t['site_distance_m'])) + + # speed for convoy linehaul: barge (operator) cruise speed + operator = self.assets.get('operator') or self.assets.get('vessel') + if operator is None: + raise ValueError('transit_linehaul_tug: operator (barge) missing.') + + speed_mps = float(operator['transport']['cruise_speed_mps']) + + dur_h = dist_m/speed_mps/3600.0 + self.duration += dur_h + + # cost + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + elif self.type == 'transit_onsite_self': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + # vessel (Beyster) required + vessel = self.assets.get('vessel') or self.assets.get('operator') or self.assets.get('carrier') + if vessel is None: + raise ValueError('transit_onsite_self: no vessel assigned.') + + # NEW: quick vessel print + try: + print(f"[onsite_self] {self.name}: vessel={vessel.get('type')}") + except Exception: + pass + + # destination anchor from objects (required) + if not self.objectList: + raise ValueError('transit_onsite_self: destination anchor missing in objects.') + dest = self.objectList[0] + r_dest = getattr(dest, 'r', None) + + # NEW: print dest + try: + print(f"[onsite_self] {self.name}: r_dest={r_dest}") + except Exception: + pass + + # infer start from dependency chain (BFS up to depth 3) + r_start = None + from collections import deque + q, seen = deque(), set() + for dep in self.dependencies.values(): + q.append((dep, 0)); seen.add(id(dep)) + while q: + node, depth = q.popleft() + if node.objectList and hasattr(node.objectList[0], 'r'): + r_start = node.objectList[0].r + break + # if depth < 3: + # for nxt in node.dependencies.values(): + # if id(nxt) in seen: continue + # seen.add(id(nxt)); q.append((nxt, depth+1)) + + # NEW: print BFS result + try: + print(f"[onsite_self] {self.name}: r_start(BFS)={r_start}") + except Exception: + pass + + # CHANGED: fallback for first onsite leg → try centroid, else keep old zero-distance fallback + if r_start is None and r_dest is not None: + # NEW: centroid read (linehaul_to_site should set it on this action) + cent = (getattr(self, 'meta', {}) or {}).get('anchor_centroid') + if cent is None: + cent = (getattr(self, 'params', {}) or {}).get('anchor_centroid') + if cent is not None and len(cent) >= 2: + r_start = (float(cent[0]), float(cent[1])) + try: + print(f"[onsite_self] {self.name}: using centroid as r_start={r_start}") + except Exception: + pass + else: + # ORIGINAL behavior: assume zero in-field distance + r_start = r_dest + try: + print(f"[warn] {self.name}: could not infer start from deps; assuming zero in-field distance.") + except Exception: + pass + + # 2D distance [m] + from math import hypot + dx = float(r_dest[0]) - float(r_start[0]) + dy = float(r_dest[1]) - float(r_start[1]) + dist_m = hypot(dx, dy) + + # NEW: print distance + try: + print(f"[onsite_self] {self.name}: dist_m={dist_m:.1f} (start={r_start} → dest={r_dest})") + except Exception: + pass + + # onsite speed from capabilities.engine (SI) + cap_eng = vessel.get('capabilities', {}).get('engine', {}) + speed_mps = float(cap_eng['site_speed_mps']) + + self.duration += dist_m/speed_mps/3600.0 + + # NEW: print duration increment + try: + print(f"[onsite_self] {self.name}: speed_mps={speed_mps:.3f}, dT_h={dist_m/speed_mps/3600.0:.3f}, total={self.duration:.3f}") + except Exception: + pass + + # cost + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + elif self.type == 'transit_onsite_tug': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + # assets required (operator = San_Diego tug; carrier = Jag barge) + operator = self.assets.get('operator') or self.assets.get('vessel') + carrier = self.assets.get('carrier') + if operator is None and carrier is None: + raise ValueError('transit_onsite_tug: no operator/carrier assigned.') + + # quick prints + try: + op_name = operator.get('type') if operator else None + ca_name = carrier.get('type') if carrier else None + print(f"[onsite_tug] {self.name}: operator={op_name} carrier={ca_name}") + except Exception: + pass + + # destination anchor from objects (required) + if not self.objectList: + raise ValueError('transit_onsite_tug: destination anchor missing in objects.') + dest = self.objectList[0] + r_dest = getattr(dest, 'r', None) + + try: + print(f"[onsite_tug] {self.name}: r_dest={r_dest}") + except Exception: + pass + + # infer start from dependency chain (BFS up to depth 3) + r_start = None + from collections import deque + q, seen = deque(), set() + for dep in self.dependencies.values(): + q.append((dep, 0)); seen.add(id(dep)) + while q: + node, depth = q.popleft() + if node.objectList and hasattr(node.objectList[0], 'r'): + r_start = node.objectList[0].r + break + # if depth < 3: + # for nxt in node.dependencies.values(): + # if id(nxt) in seen: continue + # seen.add(id(nxt)); q.append((nxt, depth+1)) + + try: + print(f"[onsite_tug] {self.name}: r_start(BFS)={r_start}") + except Exception: + pass + + # fallback for first onsite leg: use centroid if present, else zero-distance fallback + if r_start is None and r_dest is not None: + cent = (getattr(self, 'meta', {}) or {}).get('anchor_centroid') + if cent is None: + cent = (getattr(self, 'params', {}) or {}).get('anchor_centroid') + if cent is not None and len(cent) >= 2: + r_start = (float(cent[0]), float(cent[1])) + try: + print(f"[onsite_tug] {self.name}: using centroid as r_start={r_start}") + except Exception: + pass + else: + r_start = r_dest + try: + print(f"[warn] {self.name}: could not infer start from deps; assuming zero in-field distance.") + except Exception: + pass + + # 2D distance [m] + from math import hypot + dx = float(r_dest[0]) - float(r_start[0]) + dy = float(r_dest[1]) - float(r_start[1]) + dist_m = hypot(dx, dy) + + try: + print(f"[onsite_tug] {self.name}: dist_m={dist_m:.1f} (start={r_start} → dest={r_dest})") + except Exception: + pass + + # speed for convoy onsite: barge (operator) site speed + operator = self.assets.get('operator') or self.assets.get('vessel') + if operator is None: + raise ValueError('transit_onsite_tug: operator (barge) missing.') + + cap_eng = operator.get('capabilities', {}).get('bollard_pull', {}) + speed_mps = float(cap_eng['site_speed_mps']) + + self.duration += dist_m/speed_mps/3600.0 + + try: + print(f"[onsite_tug] {self.name}: speed_mps={speed_mps:.3f}, dT_h={dist_m/speed_mps/3600.0:.3f}, total={self.duration:.3f}") + except Exception: + pass + + # cost (unchanged) + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + elif self.type == 'at_site_support': pass elif self.type == 'transport_components': @@ -886,29 +1160,54 @@ def calcDurationAndCost(self): # --- Mooring & Anchors --- elif self.type == 'install_anchor': - - # Place holder duration, will need a mini-model to calculate - self.duration += 0.2 # 0.2 days - self.cost += self.duration * (self.assets['carrier']['day_rate'] + self.assets['operator']['day_rate']) - - elif self.type == 'retrieve_anchor': - pass - elif self.type == 'load_mooring': - - # Example model assuming line will be winched on to vessel. This can be changed if not most accurate - duration_min = 0 - for obj in self.objectList: - if obj.__class__.__name__.lower() == 'mooring': - for i, sec in enumerate(obj.dd['sections']): # add up the length of all sections in the mooring - duration_min += sec['L'] / self.assets['carrier2']['winch']['speed_mpm'] # duration [minutes] + # YAML override (no model if present) + default_duration = None + try: + default_duration = getFromDict(self.actionType, 'duration_h', dtype=float) + except ValueError: + default_duration = None + + if default_duration is not None: + computed_duration_h = default_duration + + else: + # Expect an anchor object in self.objectList + if not self.objectList: + raise ValueError("install_anchor: no anchor object provided in 'objects'.") + + # 1) Relevant metrics for cost and duration + anchor = self.objectList[0] + L = anchor.dd['design']['L'] + depth_m = abs(float(anchor.r[2])) + + # 2) Winch vertical speed [mps] + v_mpm = float(self.assets['carrier']['capabilities']['winch']['speed_mpm']) + t_lower_min = depth_m/v_mpm + + # 3) Penetration time ~ proportional to L + rate_pen = 15. # [min] per [m] + t_pen_min = L*rate_pen + + # 4) Connection / release (fixed) + t_ops_min = 15 + + duration_min = t_lower_min + t_pen_min + t_ops_min + computed_duration_h = duration_min/60.0 # [h] + + # print(f'[install_anchor] yaml_duration={yaml_duration} -> used={computed_duration_h} h') - self.duration += duration_min / 60 / 24 # convert minutes to days - self.cost += self.duration * (self.assets['carrier1']['day_rate'] + self.assets['carrier2']['day_rate'] + self.assets['operator']['day_rate']) # cost of all assets involved for the duration of the action [$] - - # check for deck space availability, if carrier 1 met transition to carrier 2. - - # think through operator costs, carrier 1 costs. + # Duration addition + self.duration += computed_duration_h + + # Cost assessment + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + + self.cost += self.duration*rate_per_hour + elif self.type == 'retrieve_anchor': + pass elif self.type == 'install_mooring': pass elif self.type == 'mooring_hookup': @@ -941,8 +1240,66 @@ def calcDurationAndCost(self): # --- Survey & Monitoring --- elif self.type == 'site_survey': pass + elif self.type == 'monitor_installation': - pass + # 1) YAML override first + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + # --- find the paired install --- + ref_install = getattr(self, 'paired_install', None) + + # fallback: BFS through deps to find an install on the same anchor + if ref_install is None: + anchor_obj = self.objectList[0] if self.objectList else None + from collections import deque + q, seen = deque(), set() + for dep in self.dependencies.values(): + q.append((dep, 0)); seen.add(id(dep)) + while q: + node, depth = q.popleft() + if getattr(node, 'type', None) == 'install_anchor': + if anchor_obj and node.objectList and node.objectList[0] is anchor_obj: + ref_install = node + break + if ref_install is None: + ref_install = node + if depth < 3: + for nxt in node.dependencies.values(): + if id(nxt) in seen: continue + seen.add(id(nxt)); q.append((nxt, depth+1)) + + # --- get install duration, compute-on-demand if needed (no side effects) --- + inst_dur = 0.0 + if ref_install is not None: + inst_dur = float(getattr(ref_install, 'duration', 0.0) or 0.0) + + # if not computed yet, safely compute and restore + if inst_dur <= 0.0 and not getattr(ref_install, '_in_monitor_pull', False): + try: + ref_install._in_monitor_pull = True # guard re-entrancy + prev_cost = ref_install.cost + prev_dur = ref_install.duration + d, _ = ref_install.calcDurationAndCost() + inst_dur = float(d) if d is not None else 0.0 + # restore to avoid double counting later + ref_install.cost = prev_cost + ref_install.duration = prev_dur + finally: + ref_install._in_monitor_pull = False + + self.duration += inst_dur + + # cost (same pattern you use elsewhere) + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration * rate_per_hour + return self.duration, self.cost + else: raise ValueError(f"Action type '{self.type}' not recognized.") diff --git a/famodel/irma/calwave_actions.yaml b/famodel/irma/calwave_actions.yaml index a91093bd..83ce4433 100644 --- a/famodel/irma/calwave_actions.yaml +++ b/famodel/irma/calwave_actions.yaml @@ -40,7 +40,7 @@ demobilize: operator: - deck_space capabilities: [] - default_duration_h: 1.0 + duration_h: 1.0 description: "Demobilization of vessel in homeport" load_cargo: @@ -61,37 +61,41 @@ load_cargo: # --- Towing & Transport --- -transit: - objects: [] +transit_linehaul_self: + objects: [anchor] roles: - carrier: + vessel: - engine - # operator: - # - bollard_pull - #capabilities: [engine] - duration_h: 1.0 - description: "Transit of self-propelled vessel/tugboat" + duration_h: + description: "Self-propelled line-haul between port and site" -transit_tug: - objects: [] +transit_linehaul_tug: + objects: [anchor] roles: carrier: - engine operator: - bollard_pull - #capabilities: [bollard_pull] - default_duration_h: 1.0 - description: "Transit of tugged barge" + duration_h: + description: "Tugged line-haul convoy (tug + barge) between port and site" + +transit_onsite_self: + objects: [anchor] + roles: + vessel: + - engine + duration_h: + description: "Self-propelled in-field move between site locations" -transit_deployport: - objects: [] +transit_onsite_tug: + objects: [anchor] roles: carrier: - engine operator: - bollard_pull - # capabilities: [engine] - default_duration_h: 1.0 + duration_h: + description: "Tug + barge in-field move between site locations" tow: objects: [platform] @@ -149,7 +153,7 @@ install_anchor: - pump_subsea # pump_surface, drilling_machine, torque_machine - positioning_system - monitoring_system - duration_h: + duration_h: Hs_m: description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." @@ -362,7 +366,7 @@ monitor_installation: - positioning_system - monitoring_system - rov - duration_h: + duration_h: 4.5 Hs_m: description: "Real-time monitoring of installation operations using ROV and sensor packages." diff --git a/famodel/irma/calwave_anchor installation.py b/famodel/irma/calwave_anchor installation.py new file mode 100644 index 00000000..92d98007 --- /dev/null +++ b/famodel/irma/calwave_anchor installation.py @@ -0,0 +1,216 @@ +# calwave_task1.py +# Build CalWave Task 1 (Anchor installation) following the theory flow: +# 1) addAction → structure only (type, name, objects, deps) +# 2) evaluateAssets → assign vessels/roles (+ durations/costs) +# 3) (schedule/plot handled by your existing tooling) + +from famodel.project import Project +from calwave_irma import Scenario +import calwave_chart as chart +from calwave_task import Task + +sc = Scenario() # now sc exists in *this* session + +def eval_set(a, roles, duration=None, **params): + """ + Convenience: call evaluateAssets with roles/params and optionally set .duration. + Always stores assigned_assets for plotting/scheduling attribution. + """ + # Your Action.evaluateAssets may return (duration, cost); we still set explicit duration if passed. + res = a.evaluateAssets(roles | params) + if duration is not None: + a.duration = float(duration) + elif isinstance(res, tuple) and len(res) > 0 and res[0] is not None: + try: + a.duration = float(res[0]) + except Exception: + pass + # keep roles visible on the action + a.assigned_assets = roles + return a + +# ---------- Core builder ---------- +def build_task1_calwave(sc: Scenario, project: Project): + """ + Creates Task 1 actions + dependencies (no scheduling/plotting here). + """ + + # --- Pre-ops --- + mob_sd = sc.addAction('mobilize', 'mobilize_SanDiego') + linehaul_convoy = sc.addAction( + 'transit_linehaul_tug', 'linehaul_to_site_convoy', + dependencies=[mob_sd]) + + mob_by = sc.addAction( + 'mobilize', 'mobilize_Beyster', + #dependencies=[mob_sd] + ) + linehaul_by = sc.addAction( + 'transit_linehaul_self', 'linehaul_to_site_Beyster', + dependencies=[mob_sd]) + + # --- Compute anchor centroid (x,y) for first onsite leg start --- + anchors_all = list(project.anchorList.values()) + rs = [getattr(a, 'r', None) for a in anchors_all if getattr(a, 'r', None) is not None] + xs = [float(r[0]) for r in rs] + ys = [float(r[1]) for r in rs] + anchor_centroid = (sum(xs)/len(xs), sum(ys)/len(ys)) if xs and ys else None + try: + print('[task1] anchor_centroid =', anchor_centroid) + except Exception: + pass + + # --- On-site (domain objects REQUIRED) --- + installs, onsite_tug, onsite_by, monitors = [], [], [], [] + + # first convoy leg starts after the linehaul convoy reaches site + prev_for_next_tug = linehaul_convoy + # Beyster’s first in-field leg starts after her own linehaul + prev_for_by = linehaul_by + + for i, (key, anchor) in enumerate(project.anchorList.items(), start=1): + # 1) Onsite convoy (tug + barge) to this anchor + a_tug = sc.addAction( + 'transit_onsite_tug', f'transit_convoy-{key}', + objects=[anchor], + dependencies=[prev_for_next_tug] # first = linehaul_convoy; then = previous install + ) + + # 2) Beyster to this anchor (after previous monitor), independent of tug + a_by = sc.addAction( + 'transit_onsite_self', f'transit_Beyster-{key}', + objects=[anchor], + dependencies=[prev_for_by, prev_for_next_tug] + ) + + # Inject centroid for the FIRST onsite legs only (centroid → first anchor) + if i == 1 and anchor_centroid is not None: + a_by.meta = getattr(a_by, 'meta', {}) or {} + a_by.meta['anchor_centroid'] = anchor_centroid + a_tug.meta = getattr(a_tug, 'meta', {}) or {} + a_tug.meta['anchor_centroid'] = anchor_centroid + + # 3) Install at this anchor (wait for both tug+barge and Beyster on station) + a_inst = sc.addAction( + 'install_anchor', f'install_anchor-{key}', + objects=[anchor], + dependencies=[a_tug, a_by] + ) + + # 4) Monitor at this anchor (while anchor is installed) + a_mon = sc.addAction( + 'monitor_installation', f'monitor_installation-{key}', + objects=[anchor], + dependencies=[a_tug] + ) + + # collect handles + onsite_tug.append(a_tug) + installs.append(a_inst) + onsite_by.append(a_by) + monitors.append(a_mon) + + # chain next legs: + prev_for_next_tug = a_inst # next convoy starts from this installed anchor + prev_for_by = a_mon # or set to a_inst if you want Beyster to move immediately post-install + + + # --- Post-ops (objectless) --- + linehome_convoy = sc.addAction( + 'transit_linehaul_tug', 'linehaul_to_home_convoy', + dependencies=monitors) + + linehome_by = sc.addAction( + 'transit_linehaul_self', 'transit_to_home_Beyster', + dependencies=monitors) + + # --- Post-ops --- + demob_sd = sc.addAction( + 'demobilize', 'demobilize_SanDiego', + dependencies=[linehome_convoy]) + + demob_by = sc.addAction( + 'demobilize', 'demobilize_Beyster', + dependencies=[linehome_by]) + + # Return a simple list for downstream evaluate/schedule/plot steps + return { + 'mobilize': [mob_sd, mob_by], + 'linehaul_to_site': [linehaul_convoy, linehaul_by], + 'install': installs, + 'onsite_tug': onsite_tug, + 'onsite_by': onsite_by, + 'monitor': monitors, + 'linehaul_to_home': [linehome_convoy, linehome_by], + 'demobilize': [demob_sd, demob_by]} + +# ---------- Evaluation step (assign vessels & durations) ---------- +def evaluate_task1(sc: Scenario, actions: dict): + """ + Assign vessels/roles and set durations where the evaluator doesn't. + Keeps creation and evaluation clearly separated. + """ + V = sc.vessels # shorthand + + # Mobilize + eval_set(actions['mobilize'][0], {'operator': V['San_Diego']}, duration=3.0) + eval_set(actions['mobilize'][1], {'operator': V['Beyster']}, duration=1.0) + + # Transit to site + convoy_to_site, beyster_to_site = actions['linehaul_to_site'] + eval_set(convoy_to_site, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + eval_set(beyster_to_site, {'vessel': V['Beyster']}) + + # Onsite convoy (tug+barge) + for a_tug in actions['onsite_tug']: + eval_set(a_tug, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + + # Install (Jag carries, San_Diego operates the install) + for a_inst in actions['install']: + eval_set(a_inst, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + + # Onsite self-propelled (Beyster) + for a_by in actions['onsite_by']: + eval_set(a_by, {'vessel': V['Beyster']}) + + # Monitor (Beyster as support) + for a_mon in actions['monitor']: + eval_set(a_mon, {'support': V['Beyster']}) + + # Transit to home + convoy_to_home, beyster_to_home = actions['linehaul_to_home'] + eval_set(convoy_to_home, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + eval_set(beyster_to_home, {'vessel': V['Beyster']}) + + # Demobilize + eval_set(actions['demobilize'][0], {'operator': V['San_Diego']}, duration=3.0) + eval_set(actions['demobilize'][1], {'operator': V['Beyster']}, duration=1.0) + + +if __name__ == '__main__': + # 1) Load ontology that mirrors the sample schema (mooring_systems + mooring_line_configs) + project = Project(file='calwave_ontology.yaml', raft=False) + project.getMoorPyArray(cables=1) + + # 2) Scenario with CalWave catalogs + sc = Scenario() + + # 3) Build (structure only) + actions = build_task1_calwave(sc, project) + + # 4) Evaluate (assign vessels/roles + durations) + evaluate_task1(sc, actions) + + # 5) schedule once, in the Task + calwave_task1 = Task.from_scenario( + sc, + strategy='levels', # or 'levels' + enforce_resources=False, # keep single-resource blocking if you want it + resource_roles=('vessel', 'carrier', 'operator')) + + # 6) build the chart input directly from the Task and plot + chart_view = chart.view_from_task(calwave_task1, sc, title='CalWave Task 1 - Anchor installation plan') + chart.plot_task(chart_view) + + + diff --git a/famodel/irma/calwave_capabilities.yaml b/famodel/irma/calwave_capabilities.yaml index 10ef9824..a92a6d44 100644 --- a/famodel/irma/calwave_capabilities.yaml +++ b/famodel/irma/calwave_capabilities.yaml @@ -12,19 +12,20 @@ - name: engine # description: Engine on-board of the vessel # fields: - power_hp: # power [horsepower] - speed_kn: # speed [knot] + power_hp: # power [horsepower] + site_speed_mps: # speed [m/s] - name: bollard_pull # description: Towing/holding force capability # fields: max_force_t: # bollard pull [t] + site_speed_mps: # speed [m/s] - name: deck_space # description: Clear usable deck area and allowable load # fields: - area_m2: # usable area [m2] - max_load_t: # allowable deck load [t] + area_m2: # usable area [m2] + max_load_t: # allowable deck load [t] - name: chain_locker # description: Chain storage capacity diff --git a/famodel/irma/calwave_chart.py b/famodel/irma/calwave_chart.py index d68c5393..0b473689 100644 --- a/famodel/irma/calwave_chart.py +++ b/famodel/irma/calwave_chart.py @@ -1,19 +1,19 @@ -import math from dataclasses import dataclass -from typing import List, Optional, Dict, Any, Union, Tuple +from typing import List, Optional, Dict, Tuple import matplotlib.pyplot as plt # =============================== # Data structures # =============================== + @dataclass class Bubble: - action: str # action name (matches YAML if desired) - duration_hr: float # used to space bubbles proportionally along the row - label_time: Union[str, float] # text inside the bubble (e.g., 0.0, 0.1, 'A1') - capabilities: Union[List[str], Dict[str, List[str]]] # shown below bubble; list or dict-by-role - period: Optional[Tuple[float, float]] = None # (start_time, end_time) + action: str + duration_hr: float + label_time: str + period: Optional[Tuple[float, float]] = None + category: Optional[str] = None # new: action category for coloring @dataclass class VesselTimeline: @@ -26,288 +26,273 @@ class Task: vessels: List[VesselTimeline] # =============================== -# Helper: format capabilities nicely (supports roles or flat list) +# Color palette + categorization # =============================== -def _capabilities_to_text(capabilities: Union[List[str], Dict[str, List[str]]]) -> str: - if isinstance(capabilities, dict): - parts = [] - for role, caps in capabilities.items(): - if not caps: - parts.append(f'{role}: (none)') - else: - parts.append(f"{role}: " + ', '.join(caps)) - return '\n'.join(parts) - if isinstance(capabilities, list): - return ', '.join(capabilities) if capabilities else '(none)' - return str(capabilities) +# User-requested color scheme +ACTION_TYPE_COLORS: Dict[str, str] = { + 'Mobilization': '#d62728', # red + 'Towing & Transport': '#2ca02c', # green + 'Mooring & Anchors': '#0056d6', # blue + 'Heavy Lift & Installation': '#ffdd00', # yellow + 'Cable Operations': '#9467bd', # purple + 'Survey & Monitoring': '#ff7f0e', # orange + 'Other': '#1f77b4'} # fallback color (matplotlib default) + + +# Keyword buckets → chart categories +CAT_KEYS = [ + ('Mobilization', ('mobilize', 'demobilize')), + ('Towing & Transport', ('transit', 'towing', 'tow', 'convoy', 'linehaul')), + ('Mooring & Anchors', ('anchor', 'mooring', 'pretension', 'pre-tension')), + ('Survey & Monitoring', ('monitor', 'survey', 'inspection', 'rov', 'divers')), + ('Heavy Lift & Installation', ('install_wec', 'install device', 'install', 'heavy-lift', 'lift', 'lower', 'recover_wec', 'recover device')), + ('Cable Operations', ('cable', 'umbilical', 'splice', 'connect', 'wet-mate', 'dry-mate'))] + + +def view_from_task(sched_task, sc, title: str | None = None): + """ + Minimal map: scheduler Task -> chart view Task + Show an action on multiple lanes if it uses multiple assets. + + Rules per role value: + • If str and in sc.vessels → use as key. + • Else if object → resolve by identity to sc.vessels. + • Else if dict → try ['name'] as key; else if ['type'] is unique in sc.vessels, use that key. + • Add the bubble to every resolved lane (deduped). + • Skip actions with dur<=0 or with no resolvable lanes. + """ + # reverse lookup for identity → key + id2key = {id(obj): key for key, obj in sc.vessels.items()} + + # unique type → key (used only if type is unique in catalog) + type_counts = {} + for k, obj in sc.vessels.items(): + t = obj.get('type') if isinstance(obj, dict) else getattr(obj, 'type', None) + if t: + type_counts[t] = type_counts.get(t, 0) + 1 + unique_type2key = {} + for k, obj in sc.vessels.items(): + t = obj.get('type') if isinstance(obj, dict) else getattr(obj, 'type', None) + if t and type_counts.get(t) == 1: + unique_type2key[t] = k + + buckets = {} + + for a in sched_task.actions.values(): + dur = float(getattr(a, 'duration', 0.0) or 0.0) + if dur <= 0.0: + continue + + aa = getattr(a, 'assigned_assets', {}) or {} + + # collect ALL candidate roles → multiple lanes allowed + lane_keys = set() + for role in ('vessel', 'carrier', 'operator', 'support'): + if role not in aa: + continue + v = aa[role] + + # resolve lane key + lane = None + if isinstance(v, str): + lane = v if v in sc.vessels else None + elif v is not None: + lane = id2key.get(id(v)) + if lane is None and isinstance(v, dict): + nm = v.get('name') + if isinstance(nm, str) and nm in sc.vessels: + lane = nm + else: + t = v.get('type') + if t in unique_type2key: + lane = unique_type2key[t] + if lane: + lane_keys.add(lane) + + if not lane_keys: + continue + + t0 = float(getattr(a, 'start_hr', 0.0) or 0.0) + t1 = float(getattr(a, 'end_hr', t0) or 0.0) + + # Color code for action categories based on CAT_KEYS + def cat_for(act): + s = f"{getattr(act, 'type', '')} {getattr(act, 'name', '')}".lower().replace('_', ' ') + for cat, keys in CAT_KEYS: + if any(k in s for k in keys): + return cat + return 'Other' + + # one bubble per lane (same fields) + for lane in lane_keys: + b = Bubble( + action=a.name, + duration_hr=dur, + label_time=getattr(a, 'label_time', f'{dur:.1f}'), + period=(t0, t1), + category=cat_for(a)) + + buckets.setdefault(lane, []).append(b) + + # preserve sc.vessels order; only include lanes with content + lanes = [] + for vname in sc.vessels.keys(): + blist = sorted(buckets.get(vname, []), key=lambda b: b.period[0]) + if blist: + lanes.append(VesselTimeline(vessel=vname, bubbles=blist)) + + return Task(name=title or getattr(sched_task, 'name', 'Task'), vessels=lanes) # =============================== -# Core plotters +# Core plotter (single-axes, multiple lanes) # =============================== -def _accumulate_starts(durations: List[float]) -> List[float]: - starts = [0.0] - for d in durations[:-1]: - starts.append(starts[-1] + d) - return starts - def plot_task(task: Task, outpath: Optional[str] = None, dpi: int = 200, show_title: bool = True) -> None: """ - Render a Gantt-like chart for a single Task with one timeline per vessel. - • Vessel name on the left (vertical label) - • Bubble per action: title above, time inside, capabilities below - • Horizontal spacing ∝ duration_hr + Render a Gantt-like chart for a single Task with one axes and one horizontal lane per vessel. + • Vessel names as y-tick labels + • Baseline arrows, light span bars, circle bubbles with time inside, title above, + and consistent font sizes. + • Horizontal placement uses Bubble.period when available; otherwise cumulative within vessel. + • Bubbles are colored by Bubble.category (legend added). """ - # Determine a common total time window across vessels (max of sums) - row_totals = [sum(b.duration_hr for b in v.bubbles) for v in task.vessels] - total = max(row_totals) if row_totals else 0.0 + from matplotlib.lines import Line2D + from matplotlib.patches import Circle + + # --- figure geometry --- + nrows = max(1, len(task.vessels)) + fig_h = max(3.0, 1.2 + 1.6*nrows) + fig_w = 16.0 + + plt.rcdefaults() + plt.close('all') + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi) - # Figure size heuristics + # --- y lanes (top -> bottom keeps given order) --- + vessels_top_to_bottom = task.vessels nrows = max(1, len(task.vessels)) - est_bubbles = sum(len(v.bubbles) for v in task.vessels) - fig_h = max(3.0, 2.4*nrows) - fig_w = max(10.0, 0.5*est_bubbles + 0.6*total) - - fig, axes = plt.subplots(nrows=nrows, ncols=1, figsize=(fig_w, fig_h), sharex=True, layout='constrained') - if nrows == 1: - axes = [axes] - - for ax, vessel in zip(axes, task.vessels): - starts = [] - durations = [] - current_time = 0.0 - for b in vessel.bubbles: + y_positions = list(range(nrows))[::-1] + name_to_y = {vt.vessel: y_positions[i] for i, vt in enumerate(vessels_top_to_bottom[::-1])} + + ax.set_yticks(y_positions) + ax.set_yticklabels([]) + ax.tick_params(axis='y', labelrotation=0) + + if show_title: + ax.set_title(task.name, loc='left', fontsize=16, pad=12) + + # --- gather periods, compute x-range --- + x_min, x_max = 0.0, 0.0 + per_row: Dict[str, List[Tuple[float, float, Bubble]]] = {vt.vessel: [] for vt in task.vessels} + + for vt in task.vessels: + t_cursor = 0.0 + for b in vt.bubbles: if b.period: - starts.append(b.period[0]) - durations.append(b.period[1] - b.period[0]) + s, e = float(b.period[0]), float(b.period[1]) else: - starts.append(current_time) - durations.append(b.duration_hr) - current_time += b.duration_hr - y = 0.5 - - # Timeline baseline with arrow - ax.annotate('', xy=(total, y), xytext=(0, y), arrowprops=dict(arrowstyle='-|>', lw=2)) - - # Light bars to hint segment spans - for s, d in zip(starts, durations): - ax.plot([s, s + d], [y, y], lw=6, alpha=0.15) - - # Bubbles - for i, (s, d, b) in enumerate(zip(starts, durations, vessel.bubbles)): - x = s + d/2 - # bubble marker - ax.plot(x, y, 'o', ms=45) - # time INSIDE bubble (overlayed text) - ax.text(x, y, f'{b.label_time}', ha='center', va='center', fontsize=20, color='white', weight='bold') - # title ABOVE bubble - title_offset = 0.28 if i % 2 else 0.20 - ax.text(x, y + title_offset, b.action, ha='center', va='bottom', fontsize=10) - # capabilities BELOW bubble - caps_txt = _capabilities_to_text(b.capabilities) - ax.text(x, y - 0.26, caps_txt, ha='center', va='top', fontsize=8, wrap=True) - - # Vessel label on the left, vertical - ax.text(-0.02*total if total > 0 else -1, y, vessel.vessel, ha='right', va='center', rotation=90, fontsize=10) - - # Cosmetics - ax.set_ylim(0, 1) - ax.set_yticks([]) - for spine in ['top', 'right', 'left']: - ax.spines[spine].set_visible(False) + s = t_cursor + e = s + float(b.duration_hr or 0.0) + per_row[vt.vessel].append((s, e, b)) + x_min = min(x_min, s) + x_max = max(x_max, e) + t_cursor = e - axes[-1].set_xlim(-0.02*total, total*1.02 if total > 0 else 1) - axes[-1].set_xlabel('Hours (proportional)') - if show_title: - axes[0].set_title(task.name, loc='left', fontsize=12) + # --- drawing helpers --- + def _draw_lane_baseline(y_val: float): + ax.annotate('', xy=(x_max, y_val), xytext=(x_min, y_val), + arrowprops=dict(arrowstyle='-|>', lw=2)) - if outpath: - plt.savefig(outpath, dpi=dpi, bbox_inches='tight') - plt.show() + def _draw_span_hint(s: float, e: float, y_val: float): + ax.plot([s, e], [y_val, y_val], lw=6, alpha=0.15, color='k') + + def _bubble_face_color(b: Bubble) -> str: + cat = b.category or 'Other' + return ACTION_TYPE_COLORS.get(cat, ACTION_TYPE_COLORS['Other']) + + def _text_color_for_face(face: str) -> str: + return 'black' if face.lower() in ('#ffdd00', 'yellow') else 'white' + + def _draw_bubble(s: float, e: float, y_val: float, b: Bubble, i_in_row: int): + xc = 0.5*(s + e) + face = _bubble_face_color(b) + txtc = _text_color_for_face(face) + ax.plot(xc, y_val, 'o', ms=45, color=face, zorder=3) + ax.text(xc, y_val, f'{b.label_time}', ha='center', va='center', fontsize=20, + color=txtc, weight='bold') + title_offset = 0.30 if (i_in_row % 2) else 0.20 + ax.text(xc, y_val + title_offset, b.action, ha='center', va='bottom', fontsize=10) + # caps_txt = _capabilities_to_text(b.capabilities) + # if caps_txt: + # ax.text(xc, y_val - 0.26, caps_txt, ha='center', va='top', fontsize=8, wrap=True) + + # --- draw per lane --- + seen_cats: set[str] = set() + for vt in task.vessels: + y = name_to_y[vt.vessel] + items = sorted(per_row[vt.vessel], key=lambda t: t[0]) + _draw_lane_baseline(y) + for j, (s, e, b) in enumerate(items): + _draw_span_hint(s, e, y) + _draw_bubble(s, e, y, b, j) + seen_cats.add(b.category or 'Other') + + # --- legend --- + handles = [] + legend_cats = [c for c in ACTION_TYPE_COLORS.keys() if c in seen_cats] + # if you prefer to always show all categories, replace the line above with: legend_cats = list(ACTION_TYPE_COLORS.keys()) + for cat in legend_cats: + handles.append(Line2D([0], [0], marker='o', linestyle='none', markersize=12, + markerfacecolor=ACTION_TYPE_COLORS[cat], markeredgecolor='none', label=cat)) + if handles: + # Place the legend below the x-axis label (bottom center) + fig_ = ax.figure + fig_.legend(handles=handles, + loc='lower center', + bbox_to_anchor=(0.5, -0.12), # move below the axis label + ncol=3, + title='Action Types', + frameon=False) + + # --- axes cosmetics & limits --- + if x_max <= x_min: + x_max = x_min + 1.0 + pad = 0.02*(x_max - x_min) if (x_max - x_min) > 0 else 0.5 + ax.set_xlim(x_min - pad, x_max + pad) -# ========= Adapters from Scenario → chart ========= - -def _vessel_name_from_assigned(action) -> str: - """Pick a vessel label from the roles you assigned in evaluateAssets.""" - aa = getattr(action, 'assigned_assets', {}) or {} - # order of preference (tweak if your roles differ) - for key in ('vessel', 'carrier', 'operator'): - v = aa.get(key) - if v is not None: - return getattr(v, 'name', str(v)) - # fallback: unknown bucket - return 'unknown' - -def _caps_from_type_spec(action) -> dict: - """Turn the action type's roles spec into a {role: [caps]} dict for display.""" - spec = getattr(action, 'type_spec', {}) or {} - roles = spec.get('roles') or {} - # be defensive: ensure it's a dict[str, list[str]] - if not isinstance(roles, dict): - return {'roles': []} - clean = {} - for r, caps in roles.items(): - clean[r] = list(caps or []) - return clean - -def _label_for(action) -> str: - """Label inside the bubble. You can change to action.type or object name etc.""" - # show duration with one decimal if present; else use the action's short name - dur = getattr(action, 'duration', None) - if isinstance(dur, (int, float)) and dur >= 0: - return f"{dur:.1f}" - return action.name - -def scenario_to_chart_task(sc, start_times: dict[str, float], title: str): - """ - Convert Scenario actions + a start-time map into a calwave_chart.Task for plotting. - - start_times: {action_name: t0} from your scheduler - - action.duration must be set (via evaluateAssets or by you) - """ - # 1) bucket actions by vessel label - buckets: dict[str, list[Bubble]] = {} + # Draw circled vessel names at the same y positions + x_name = x_min - 3*pad # small left offset inside the axes + + # After you have vessels_top_to_bottom, name_to_y, x_min/x_max, pad, left_extra, x_name... + max_len = max(len(vt.vessel) for vt in vessels_top_to_bottom) # longest label + + # make the circle tighter/looser: + circle_pad = 0.18 + + for vt in vessels_top_to_bottom[::-1]: + y = name_to_y[vt.vessel] + fixed_text = vt.vessel.center(max_len) # pad with spaces to max length + ax.text( + x_name, y, fixed_text, + ha='center', va='center', zorder=6, clip_on=False, + fontsize=12, color='black', fontfamily='monospace', # <- key: monospace + bbox=dict(boxstyle='circle,pad={:.2f}'.format(circle_pad), + facecolor='lightgrey', edgecolor='tomato', linewidth=6)) - for a in sc.actions.values(): - # period - t0 = start_times.get(a.name, None) - dur = float(getattr(a, 'duration', 0.0) or 0.0) - period = None - if t0 is not None: - period = (float(t0), float(t0) + dur) - - bubble = Bubble( - action=a.name, - duration_hr=dur, - label_time=_label_for(a), - capabilities=_caps_from_type_spec(a), # roles/caps from the type spec - period=period - ) - vessel_label = _vessel_name_from_assigned(a) - buckets.setdefault(vessel_label, []).append(bubble) - - # 2) sort bubbles per vessel by start time (or keep input order if no schedule) - vessels = [] - for vname, bubbles in buckets.items(): - bubbles_sorted = sorted( - bubbles, - key=lambda b: (9999.0 if b.period is None else b.period[0]) - ) - vessels.append(VesselTimeline(vessel=vname, bubbles=bubbles_sorted)) - - # 3) stable vessel ordering: San Diego, Jag, Beyster, then others - order_hint = {'San_Diego': 0, 'San Diego': 0, 'Jag': 1, 'Beyster': 2} - vessels.sort(key=lambda vt: order_hint.get(vt.vessel, 10)) - - return Task(name=title, vessels=vessels) - -# Optional convenience: do everything after scheduling -def stage_and_plot(sc, start_times: dict[str, float], title: str, outpath: str | None = None, dpi: int = 200): - t = scenario_to_chart_task(sc, start_times, title) - plot_task(t, outpath=outpath, dpi=dpi, show_title=True) - - -if __name__ == '__main__': - # Support vessel monitors 4 anchors - Support = VesselTimeline( - vessel='Beyster', - bubbles=[ - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', - capabilities={'vessel': []}, - period=(5.0, 6.0)), - Bubble(action='transit_site A2', duration_hr=0.5, label_time='0.5', - capabilities={'carrier': []}, - period=(6.5, 7.0)), - # Monitor each anchor install (x4) - Bubble(action='site_survey A2', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(7.0, 8.0)), - Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(8.0, 8.5)), - Bubble(action='site_survey A1', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(8.5, 9.5)), - Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(9.5, 10.0)), - Bubble(action='site_survey A4', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(10.0, 11.0)), - Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(11.0, 11.5)), - Bubble(action='site_survey A3', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(11.5, 12.5)), - Bubble(action='transit_homeport', duration_hr=0.75, label_time='0.75', - capabilities=[], - period=(12.5, 13.25)), - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(13.25, 14.25)), - ] - ) - - # Tug (Jar) stages/load moorings; lay/install handled by San_Diego per your note - Tug = VesselTimeline( - vessel='Jar', - bubbles=[ - Bubble(action='transit_site', duration_hr=0.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, - period=(2.0, 6.5)), - Bubble(action='at_site_support', duration_hr=5.5, label_time='5.5', - capabilities=[], period=(6.5, 12.0)), - Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, - period=(12.0, 16.5)), - ] - ) + ax.set_xlabel('Timeline (h)') + ax.grid(False) + for spine in ['top', 'right', 'left']: + ax.spines[spine].set_visible(False) - # Barge performs lay_mooring and install_anchor for 4 anchors - Barge = VesselTimeline( - vessel='San_Diego', - bubbles=[ - Bubble(action='mobilize', duration_hr=2.0, label_time='2.0', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(0.0, 2.0)), - # Anchor 2 - Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(2.0, 6.5)), - Bubble(action='install_anchor A2', duration_hr=1.0, label_time='1.0', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(6.5, 7.5)), - # Anchor 1 - Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(7.5, 8.0)), - Bubble(action='install_anchor A1', duration_hr=1.0, label_time='1.5', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(8.0, 9.0)), - # Anchor 4 - Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(9.0, 9.5)), - Bubble(action='install_anchor A4', duration_hr=1.0, label_time='1.0', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(9.5, 10.5)), - # Anchor 3 - Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(10.5, 11.0)), - Bubble(action='install_anchor A3', duration_hr=1.0, label_time='1.0', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(11.0, 12.0)), - Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(12.0, 16.5)), - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(16.5, 17.0)), - ] - ) - - t = Task(name='Task 1 — Anchor installation plan (x4 anchors)', vessels=[Support, Tug, Barge]) - plot_task(t, outpath=None) \ No newline at end of file + ax.set_ylim(min(y_positions) - 0.5, max(y_positions) + 0.5) + + fig = ax.figure + # Add extra bottom margin to make space for the legend below the x-axis label + fig.subplots_adjust(left=0.10, right=0.98, top=0.90, bottom=0.15) + + if outpath: + fig.savefig(outpath, dpi=dpi, bbox_inches='tight') + else: + plt.show() diff --git a/famodel/irma/calwave_chart1.py b/famodel/irma/calwave_chart1.py deleted file mode 100644 index 5583714d..00000000 --- a/famodel/irma/calwave_chart1.py +++ /dev/null @@ -1,356 +0,0 @@ - -import math -from dataclasses import dataclass -from typing import List, Optional, Dict, Any, Union, Tuple -import matplotlib.pyplot as plt - -# =============================== -# Data structures -# =============================== -@dataclass -class Bubble: - action: str # action name (matches YAML if desired) - duration_hr: float # used to space bubbles proportionally along the row - label_time: Union[str, float] # text inside the bubble (e.g., 0.0, 0.1, 'A1') - capabilities: Union[List[str], Dict[str, List[str]]] # shown below bubble; list or dict-by-role - period: Optional[Tuple[float, float]] = None # (start_time, end_time) - -@dataclass -class VesselTimeline: - vessel: str - bubbles: List[Bubble] - -@dataclass -class Task: - name: str - vessels: List[VesselTimeline] - -# =============================== -# Helper: format capabilities nicely (supports roles or flat list) -# =============================== - -def _capabilities_to_text(capabilities: Union[List[str], Dict[str, List[str]]]) -> str: - if isinstance(capabilities, dict): - parts = [] - for role, caps in capabilities.items(): - if not caps: - parts.append(f'{role}: (none)') - else: - parts.append(f"{role}: " + ', '.join(caps)) - return '\n'.join(parts) - if isinstance(capabilities, list): - return ', '.join(capabilities) if capabilities else '(none)' - return str(capabilities) - -# =============================== -# Core plotters -# =============================== - -def _accumulate_starts(durations: List[float]) -> List[float]: - starts = [0.0] - for d in durations[:-1]: - starts.append(starts[-1] + d) - return starts - -def plot_task(task: Task, outpath: Optional[str] = None, dpi: int = 200, - show_title: bool = True) -> None: - """ - Render a Gantt-like chart for a single Task with one timeline per vessel. - • One axes with a horizontal lane per vessel (vessel names as y-tick labels) - • Bubble per action: title above, time inside, capabilities below - • Horizontal placement uses b.period when available, otherwise cumulative within vessel - """ - import matplotlib.pyplot as plt - from matplotlib.patches import Circle - - # --- figure geometry (stable/sane) --- - nrows = max(1, len(task.vessels)) - fig_h = max(3.0, 1.2 + 1.6 * nrows) - fig_w = 16.0 - - plt.rcdefaults() # avoid stray global styles from other modules - plt.close('all') - fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi) - - # --- build y lanes (top -> bottom) --- - vessels_top_to_bottom = task.vessels # keep given order - y_positions = list(range(nrows))[::-1] - name_to_y = {vt.vessel: y_positions[i] for i, vt in enumerate(vessels_top_to_bottom[::-1])} - - ax.set_yticks(y_positions) - ax.set_yticklabels([vt.vessel for vt in vessels_top_to_bottom[::-1]]) - ax.tick_params(axis='y', labelrotation=0) - - # baselines that span the axes width (independent of x limits) - for yi in y_positions: - ax.plot([0, 1], [yi, yi], transform=ax.get_xaxis_transform(), color='k', lw=1) - - if show_title: - ax.set_title(task.name, loc='left', fontsize=12, pad=8) - - # --- gather periods, compute x-range --- - x_min, x_max = 0.0, 0.0 - per_row = {vt.vessel: [] for vt in task.vessels} - - for vt in task.vessels: - t_cursor = 0.0 - for b in vt.bubbles: - if b.period: - s, e = float(b.period[0]), float(b.period[1]) - else: - # fall back to cumulative placement within the row - s = t_cursor - e = s + float(b.duration_hr or 0.0) - per_row[vt.vessel].append((s, e, b)) - x_min = min(x_min, s) - x_max = max(x_max, e) - t_cursor = e - - # --- drawing helpers (always in DATA coords) --- - def _cap_text(caps: dict) -> str: - return _capabilities_to_text(caps) if isinstance(caps, dict) else "" - - def _draw_bubble(x0, x1, y, b): - xc = 0.5 * (x0 + x1) - w = max(0.001, (x1 - x0)) - radius = 0.22 + 0.02 * w # visual tweak similar to the newer plotter - - circ = Circle((xc, y), radius=radius, facecolor='#1976d2', edgecolor='k', lw=1.0, zorder=3) - ax.add_patch(circ) - - # label INSIDE bubble (duration/time) - ax.text(xc, y, f"{b.label_time}", ha='center', va='center', - fontsize=9, color='white', weight='bold', transform=ax.transData, clip_on=False) - - # title ABOVE bubble - ax.text(xc, y + (radius + 0.18), b.action, ha='center', va='bottom', - fontsize=9, transform=ax.transData, clip_on=False) - - # capabilities BELOW bubble - caps_txt = _cap_text(b.capabilities) - if caps_txt: - ax.text(xc, y - (radius + 0.24), caps_txt, ha='center', va='top', - fontsize=7, transform=ax.transData, clip_on=False) - - # --- draw bubbles, stagger titles a bit when very close --- - for v in task.vessels: - items = sorted(per_row[v.vessel], key=lambda t: t[0]) - y = name_to_y[v.vessel] - last_x = -1e9 - alt = 0 - for s, e, b in items: - _draw_bubble(s, e, y, b) - - # if adjacent centers are too close, nudge the title slightly up/down - xc = 0.5 * (s + e) - if abs(xc - last_x) < 0.6: # hours; tweak if your labels still collide - alt ^= 1 - bump = 0.28 if alt else -0.28 - ax.text(xc, y + bump, b.action, ha='center', va='bottom', - fontsize=9, transform=ax.transData, clip_on=False) - last_x = xc - - # --- axes cosmetics & limits --- - if x_max <= x_min: - x_max = x_min + 1.0 - pad = 0.3 * (x_max - x_min) - ax.set_xlim(x_min - pad, x_max + pad) - ax.set_ylim(y_positions[-1] - 1, y_positions[0] + 1) - - ax.set_xlabel('Hours (proportional)') - ax.grid(False) - for spine in ['top', 'right', 'left']: - ax.spines[spine].set_visible(False) - - fig = ax.figure - fig.subplots_adjust(left=0.08, right=0.98, top=0.90, bottom=0.14) - - if outpath: - fig.savefig(outpath, dpi=dpi) - else: - plt.show() - -# ========= Adapters from Scenario → chart ========= - -def _vessel_name_from_assigned(action) -> str: - """Pick a vessel label from the roles you assigned in evaluateAssets.""" - aa = getattr(action, 'assigned_assets', {}) or {} - # order of preference (tweak if your roles differ) - for key in ('vessel', 'carrier', 'operator'): - v = aa.get(key) - if v is not None: - return getattr(v, 'name', str(v)) - # fallback: unknown bucket - return 'unknown' - -def _caps_from_type_spec(action) -> dict: - """Turn the action type's roles spec into a {role: [caps]} dict for display.""" - spec = getattr(action, 'type_spec', {}) or {} - roles = spec.get('roles') or {} - # be defensive: ensure it's a dict[str, list[str]] - if not isinstance(roles, dict): - return {'roles': []} - clean = {} - for r, caps in roles.items(): - clean[r] = list(caps or []) - return clean - -def _label_for(action) -> str: - """Label inside the bubble. You can change to action.type or object name etc.""" - # show duration with one decimal if present; else use the action's short name - dur = getattr(action, 'duration', None) - if isinstance(dur, (int, float)) and dur >= 0: - return f"{dur:.1f}" - return action.name - -def scenario_to_chart_task(sc, start_times: dict[str, float], title: str): - """ - Convert Scenario actions + a start-time map into a calwave_chart.Task for plotting. - - start_times: {action_name: t0} from your scheduler - - action.duration must be set (via evaluateAssets or by you) - """ - # 1) bucket actions by vessel label - buckets: dict[str, list[Bubble]] = {} - - for a in sc.actions.values(): - # period - t0 = start_times.get(a.name, None) - dur = float(getattr(a, 'duration', 0.0) or 0.0) - period = None - if t0 is not None: - period = (float(t0), float(t0) + dur) - - bubble = Bubble( - action=a.name, - duration_hr=dur, - label_time=_label_for(a), - capabilities=_caps_from_type_spec(a), # roles/caps from the type spec - period=period - ) - vessel_label = _vessel_name_from_assigned(a) - buckets.setdefault(vessel_label, []).append(bubble) - - # 2) sort bubbles per vessel by start time (or keep input order if no schedule) - vessels = [] - for vname, bubbles in buckets.items(): - bubbles_sorted = sorted( - bubbles, - key=lambda b: (9999.0 if b.period is None else b.period[0]) - ) - vessels.append(VesselTimeline(vessel=vname, bubbles=bubbles_sorted)) - - # 3) stable vessel ordering: San Diego, Jag, Beyster, then others - order_hint = {'San_Diego': 0, 'San Diego': 0, 'Jag': 1, 'Beyster': 2} - vessels.sort(key=lambda vt: order_hint.get(vt.vessel, 10)) - - return Task(name=title, vessels=vessels) - -# Optional convenience: do everything after scheduling -def stage_and_plot(sc, start_times: dict[str, float], title: str, outpath: str | None = None, dpi: int = 200): - t = scenario_to_chart_task(sc, start_times, title) - plot_task(t, outpath=outpath, dpi=dpi, show_title=True) - - -if __name__ == '__main__': - # Support vessel monitors 4 anchors - Support = VesselTimeline( - vessel='Beyster', - bubbles=[ - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', - capabilities={'vessel': []}, - period=(5.0, 6.0)), - Bubble(action='transit_site A2', duration_hr=0.5, label_time='0.5', - capabilities={'carrier': []}, - period=(6.5, 7.0)), - # Monitor each anchor install (x4) - Bubble(action='site_survey A2', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(7.0, 8.0)), - Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(8.0, 8.5)), - Bubble(action='site_survey A1', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(8.5, 9.5)), - Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(9.5, 10.0)), - Bubble(action='site_survey A4', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(10.0, 11.0)), - Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(11.0, 11.5)), - Bubble(action='site_survey A3', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(11.5, 12.5)), - Bubble(action='transit_homeport', duration_hr=0.75, label_time='0.75', - capabilities=[], - period=(12.5, 13.25)), - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', - capabilities=[], - period=(13.25, 14.25)), - ] - ) - - # Tug (Jar) stages/load moorings; lay/install handled by San_Diego per your note - Tug = VesselTimeline( - vessel='Jar', - bubbles=[ - Bubble(action='transit_site', duration_hr=0.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, - period=(2.0, 6.5)), - Bubble(action='at_site_support', duration_hr=5.5, label_time='5.5', - capabilities=[], period=(6.5, 12.0)), - Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, - period=(12.0, 16.5)), - ] - ) - - # Barge performs lay_mooring and install_anchor for 4 anchors - Barge = VesselTimeline( - vessel='San_Diego', - bubbles=[ - Bubble(action='mobilize', duration_hr=2.0, label_time='2.0', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(0.0, 2.0)), - # Anchor 2 - Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(2.0, 6.5)), - Bubble(action='install_anchor A2', duration_hr=1.0, label_time='1.0', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(6.5, 7.5)), - # Anchor 1 - Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(7.5, 8.0)), - Bubble(action='install_anchor A1', duration_hr=1.0, label_time='1.5', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(8.0, 9.0)), - # Anchor 4 - Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(9.0, 9.5)), - Bubble(action='install_anchor A4', duration_hr=1.0, label_time='1.0', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(9.5, 10.5)), - # Anchor 3 - Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', - capabilities=[], - period=(10.5, 11.0)), - Bubble(action='install_anchor A3', duration_hr=1.0, label_time='1.0', - capabilities={'carrier': ['deck_space'], 'operator': []}, - period=(11.0, 12.0)), - Bubble(action='transit_homeport', duration_hr=4.5, label_time='4.5', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(12.0, 16.5)), - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', - capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, - period =(16.5, 17.0)), - ] - ) - - t = Task(name='Task 1 — Anchor installation plan (x4 anchors)', vessels=[Support, Tug, Barge]) - plot_task(t, outpath=None) \ No newline at end of file diff --git a/famodel/irma/calwave_chart2.py b/famodel/irma/calwave_chart2.py deleted file mode 100644 index 7016c81e..00000000 --- a/famodel/irma/calwave_chart2.py +++ /dev/null @@ -1,281 +0,0 @@ - -import math -from dataclasses import dataclass -from typing import List, Optional, Dict, Any, Union, Tuple -import matplotlib.pyplot as plt - -# =============================== -# Data structures -# =============================== -@dataclass -class Bubble: - action: str # action name (matches YAML if desired) - duration_hr: float # used to space bubbles proportionally along the row - label_time: Union[str, float] # text inside the bubble (e.g., 0.0, 0.1, 'A1') - capabilities: Union[List[str], Dict[str, List[str]]] # shown below bubble; list or dict-by-role - period: Optional[Tuple[float, float]] = None # (start_time, end_time) - -@dataclass -class VesselTimeline: - vessel: str - bubbles: List[Bubble] - -@dataclass -class Task: - name: str - vessels: List[VesselTimeline] - -# =============================== -# Helper: format capabilities nicely (supports roles or flat list) -# =============================== - -def _capabilities_to_text(capabilities: Union[List[str], Dict[str, List[str]]]) -> str: - if isinstance(capabilities, dict): - parts = [] - for role, caps in capabilities.items(): - if not caps: - parts.append(f'{role}: (none)') - else: - parts.append(f"{role}: " + ', '.join(caps)) - return '\n'.join(parts) - if isinstance(capabilities, list): - return ', '.join(capabilities) if capabilities else '(none)' - return str(capabilities) - -# =============================== -# Core plotter (single-axes, multiple lanes) -# =============================== - -def plot_task(task: Task, outpath: Optional[str] = None, dpi: int = 200, - show_title: bool = True) -> None: - """ - Render a Gantt-like chart for a single Task with one axes and one horizontal lane per vessel. - • Vessel names as y-tick labels (structure like calwave_chart1) - • Visual styling aligned with calwave_chart: baseline arrows, light span bars, circle bubbles - with time inside, title above, capabilities below, and consistent font sizes. - • Horizontal placement uses Bubble.period when available; otherwise cumulative within vessel. - """ - from matplotlib.patches import FancyArrow - - # --- figure geometry --- - nrows = max(1, len(task.vessels)) - fig_h = max(3.0, 1.2 + 1.6*nrows) - fig_w = 16.0 - - plt.rcdefaults() - plt.close('all') - fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi) - - # --- y lanes (top -> bottom keeps given order) --- - vessels_top_to_bottom = task.vessels - y_positions = list(range(nrows))[::-1] - name_to_y = {vt.vessel: y_positions[i] for i, vt in enumerate(vessels_top_to_bottom[::-1])} - - ax.set_yticks(y_positions) - ax.set_yticklabels([vt.vessel for vt in vessels_top_to_bottom[::-1]]) - ax.tick_params(axis='y', labelrotation=0) - - if show_title: - ax.set_title(task.name, loc='left', fontsize=12, pad=8) - - # --- gather periods, compute x-range --- - x_min, x_max = 0.0, 0.0 - per_row: Dict[str, List[Tuple[float, float, Bubble]]] = {vt.vessel: [] for vt in task.vessels} - - for vt in task.vessels: - t_cursor = 0.0 - for b in vt.bubbles: - if b.period: - s, e = float(b.period[0]), float(b.period[1]) - else: - s = t_cursor - e = s + float(b.duration_hr or 0.0) - per_row[vt.vessel].append((s, e, b)) - x_min = min(x_min, s) - x_max = max(x_max, e) - t_cursor = e - - # --- drawing helpers --- - def _draw_lane_baseline(y_val: float): - # Baseline with arrow (like calwave_chart) spanning current x-lims later - ax.annotate('', xy=(x_max, y_val), xytext=(x_min, y_val), - arrowprops=dict(arrowstyle='-|>', lw=2)) - - def _draw_span_hint(s: float, e: float, y_val: float): - ax.plot([s, e], [y_val, y_val], lw=6, alpha=0.15, color='k') - - def _draw_bubble(s: float, e: float, y_val: float, b: Bubble, i_in_row: int): - xc = 0.5*(s + e) - # Bubble marker (match calwave_chart size) - ax.plot(xc, y_val, 'o', ms=45, color=plt.rcParams['axes.prop_cycle'].by_key()['color'][0], zorder=3) - # Time/label inside bubble - ax.text(xc, y_val, f'{b.label_time}', ha='center', va='center', fontsize=20, - color='white', weight='bold') - # Title above (alternate small offset pattern as in calwave_chart) - title_offset = 0.28 if (i_in_row % 2) else 0.20 - ax.text(xc, y_val + title_offset, b.action, ha='center', va='bottom', fontsize=10) - # Capabilities below - caps_txt = _capabilities_to_text(b.capabilities) - if caps_txt: - ax.text(xc, y_val - 0.26, caps_txt, ha='center', va='top', fontsize=8, wrap=True) - - # --- draw per lane --- - for vt in task.vessels: - y = name_to_y[vt.vessel] - items = sorted(per_row[vt.vessel], key=lambda t: t[0]) - - # Lane baseline w/ arrow - _draw_lane_baseline(y) - - # Span hints and bubbles - for j, (s, e, b) in enumerate(items): - _draw_span_hint(s, e, y) - _draw_bubble(s, e, y, b, j) - - # --- axes cosmetics & limits --- - if x_max <= x_min: - x_max = x_min + 1.0 - pad = 0.02*(x_max - x_min) if (x_max - x_min) > 0 else 0.02 - ax.set_xlim(x_min - pad, x_max + pad) - - ax.set_xlabel('Hours (proportional)') - ax.grid(False) - for spine in ['top', 'right', 'left']: - ax.spines[spine].set_visible(False) - - # y limits and a little margin - ax.set_ylim(min(y_positions) - 0.5, max(y_positions) + 0.5) - - fig = ax.figure - fig.subplots_adjust(left=0.10, right=0.98, top=0.90, bottom=0.14) - - if outpath: - fig.savefig(outpath, dpi=dpi, bbox_inches='tight') - else: - plt.show() - -# ========= Adapters from Scenario → chart ========= - -def _vessel_name_from_assigned(action) -> str: - """Pick a vessel label from the roles you assigned in evaluateAssets.""" - aa = getattr(action, 'assigned_assets', {}) or {} - # order of preference (tweak if your roles differ) - for key in ('vessel', 'carrier', 'operator'): - v = aa.get(key) - if v is not None: - return getattr(v, 'name', str(v)) - # fallback: unknown bucket - return 'unknown' - - -def _caps_from_type_spec(action) -> dict: - """Turn the action type's roles spec into a {role: [caps]} dict for display.""" - spec = getattr(action, 'type_spec', {}) or {} - roles = spec.get('roles') or {} - if not isinstance(roles, dict): - return {'roles': []} - clean: Dict[str, List[str]] = {} - for r, caps in roles.items(): - clean[r] = list(caps or []) - return clean - - -def _label_for(action) -> str: - """Label inside the bubble. You can change to action.type or object name etc.""" - dur = getattr(action, 'duration', None) - if isinstance(dur, (int, float)) and dur >= 0: - return f"{dur:.1f}" - return action.name - - -def scenario_to_chart_task(sc, start_times: dict[str, float], title: str): - """ - Convert Scenario actions + a start-time map into a calwave_chart.Task for plotting. - - start_times: {action_name: t0} from your scheduler - - action.duration must be set (via evaluateAssets or by you) - """ - buckets: dict[str, list[Bubble]] = {} - - for a in sc.actions.values(): - t0 = start_times.get(a.name, None) - dur = float(getattr(a, 'duration', 0.0) or 0.0) - period = None - if t0 is not None: - period = (float(t0), float(t0) + dur) - - bubble = Bubble( - action=a.name, - duration_hr=dur, - label_time=_label_for(a), - capabilities=_caps_from_type_spec(a), - period=period - ) - vessel_label = _vessel_name_from_assigned(a) - buckets.setdefault(vessel_label, []).append(bubble) - - vessels: List[VesselTimeline] = [] - for vname, bubbles in buckets.items(): - bubbles_sorted = sorted( - bubbles, - key=lambda b: (9999.0 if b.period is None else b.period[0]) - ) - vessels.append(VesselTimeline(vessel=vname, bubbles=bubbles_sorted)) - - order_hint = {'San_Diego': 0, 'San Diego': 0, 'Jag': 1, 'Beyster': 2} - vessels.sort(key=lambda vt: order_hint.get(vt.vessel, 10)) - - return Task(name=title, vessels=vessels) - -def stage_and_plot(sc, start_times: dict[str, float], title: str, - outpath: str | None = None, dpi: int = 200): - t = scenario_to_chart_task(sc, start_times, title) - plot_task(t, outpath=outpath, dpi=dpi, show_title=True) - - -if __name__ == '__main__': - # Demo scene (unchanged structure) - Support = VesselTimeline( - vessel='Beyster', - bubbles=[ - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', capabilities={'vessel': []}, period=(5.0, 6.0)), - Bubble(action='transit_A2', duration_hr=0.5, label_time='0.5', capabilities={'carrier': []}, period=(6.5, 7.0)), - Bubble(action='site_survey_A2', duration_hr=1.0, label_time='1.0', capabilities=[], period=(7.0, 8.0)), - Bubble(action='transit_A1', duration_hr=0.5, label_time='0.5', capabilities=[], period=(8.0, 8.5)), - Bubble(action='site_survey_A1', duration_hr=1.0, label_time='1.0', capabilities=[], period=(8.5, 9.5)), - Bubble(action='transit_A4', duration_hr=0.5, label_time='0.5', capabilities=[], period=(9.5, 10.0)), - Bubble(action='site_survey_A4', duration_hr=1.0, label_time='1.0', capabilities=[], period=(10.0, 11.0)), - Bubble(action='transit_A3', duration_hr=0.5, label_time='0.5', capabilities=[], period=(11.0, 11.5)), - Bubble(action='site_survey_A3', duration_hr=1.0, label_time='1.0', capabilities=[], period=(11.5, 12.5)), - Bubble(action='transit', duration_hr=0.75, label_time='0.75', capabilities=[], period=(12.5, 13.25)), - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', capabilities=[], period=(13.25, 14.25)), - ] - ) - - Tug = VesselTimeline( - vessel='Jar', - bubbles=[ - Bubble(action='transit', duration_hr=0.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, period=(2.0, 6.5)), - Bubble(action='at_site_support', duration_hr=5.5, label_time='5.5', capabilities=[], period=(6.5, 12.0)), - Bubble(action='transit', duration_hr=4.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': [], 'operator': []}, period=(12.0, 16.5)), - ] - ) - - Barge = VesselTimeline( - vessel='San_Diego', - bubbles=[ - Bubble(action='mobilize', duration_hr=2.0, label_time='2.0', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(0.0, 2.0)), - Bubble(action='transit_tug', duration_hr=4.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(2.0, 6.5)), - Bubble(action='install_anchor A2', duration_hr=1.0, label_time='1.0', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(6.5, 7.5)), - Bubble(action='transit_site A1', duration_hr=0.5, label_time='0.5', capabilities=[], period=(7.5, 8.0)), - Bubble(action='install_anchor A1', duration_hr=1.0, label_time='1.5', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(8.0, 9.0)), - Bubble(action='transit_site A4', duration_hr=0.5, label_time='0.5', capabilities=[], period=(9.0, 9.5)), - Bubble(action='install_anchor A4', duration_hr=1.0, label_time='1.0', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(9.5, 10.5)), - Bubble(action='transit_site A3', duration_hr=0.5, label_time='0.5', capabilities=[], period=(10.5, 11.0)), - Bubble(action='install_anchor A3', duration_hr=1.0, label_time='1.0', capabilities={'carrier': ['deck_space'], 'operator': []}, period=(11.0, 12.0)), - Bubble(action='transit_tug', duration_hr=4.5, label_time='4.5', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(12.0, 16.5)), - Bubble(action='mobilize', duration_hr=1.0, label_time='1.0', capabilities={'carrier1': [], 'carrier2': ['deck_space', 'winch', 'positioning_system'], 'operator': ['crane']}, period=(16.5, 17.0)), - ] - ) - - t = Task(name='Task 1 — Anchor installation plan (x4 anchors)', vessels=[Support, Tug, Barge]) - plot_task(t, outpath=None) diff --git a/famodel/irma/calwave_irma.py b/famodel/irma/calwave_irma.py index 84340b9d..38ed5442 100644 --- a/famodel/irma/calwave_irma.py +++ b/famodel/irma/calwave_irma.py @@ -3,47 +3,11 @@ import os import numpy as np import matplotlib.pyplot as plt - -import moorpy as mp -from moorpy.helpers import set_axes_equal -from moorpy import helpers -import yaml -from copy import deepcopy -import string -try: - import raft as RAFT -except: - pass - -#from shapely.geometry import Point, Polygon, LineString -from famodel.seabed import seabed_tools as sbt -from famodel.mooring.mooring import Mooring -from famodel.platform.platform import Platform -from famodel.anchors.anchor import Anchor -from famodel.mooring.connector import Connector -from famodel.substation.substation import Substation -from famodel.cables.cable import Cable -from famodel.cables.dynamic_cable import DynamicCable -from famodel.cables.static_cable import StaticCable -from famodel.cables.cable_properties import getCableProps, getBuoyProps, loadCableProps,loadBuoyProps -from famodel.cables.components import Joint -from famodel.turbine.turbine import Turbine -from famodel.famodel_base import Node - -# Import select required helper functions -from famodel.helpers import (check_headings, head_adjust, getCableDD, getDynamicCables, - getMoorings, getAnchors, getFromDict, cleanDataTypes, - getStaticCables, getCableDesign, m2nm, loadYAML, - configureAdjuster, route_around_anchors) - import networkx as nx from calwave_action import Action, increment_name from task import Task - -from assets import Vessel, Port - - - +import yaml + def loadYAMLtoDict(info, already_dict=False): '''Reads a list or YAML file and prepares a dictionary''' diff --git a/famodel/irma/calwave_task.py b/famodel/irma/calwave_task.py new file mode 100644 index 00000000..e07ab4b7 --- /dev/null +++ b/famodel/irma/calwave_task.py @@ -0,0 +1,226 @@ +"""Enhanced Task class for CalWave scheduling + +- Adds earliest-start (critical-path) scheduler with single-resource constraints +- Keeps legacy level-based checkpoint scheduler (via getSequenceGraph) + +Style: single quotes, spaces around + and -, no spaces around * or / +""" + +from collections import defaultdict + +class Task: + def __init__(self, actions, action_sequence, **kwargs): + ''' + Create a Task from a list of actions and a dependency map. + + Parameters + ---------- + actions : list + All Action objects that are part of this task. + action_sequence : dict or None + {action_name: [predecessor_name, ...]}. + If None, dependencies are inferred from Action.dependencies. + kwargs : + Optional tuning: + • strategy='earliest' | 'levels' + • enforce_resources=True | False + • resource_roles=('vessel','carrier','operator') + ''' + # ---- options with sensible defaults (all via kwargs) ---- + strategy = kwargs.get('strategy', 'earliest') + enforce_resources = kwargs.get('enforce_resources', True) + resource_roles = kwargs.get('resource_roles', ('vessel', 'carrier', 'operator')) + + # ---- core storage ---- + self.actions = {a.name: a for a in actions} + # allow None → infer solely from Action.dependencies + self.action_sequence = {k: list(v) for k, v in (action_sequence or {}).items()} + self.actions_ti = {} + self.duration = 0.0 + self.cost = 0.0 + self.ti = 0.0 + self.tf = 0.0 + self.resource_roles = tuple(resource_roles) + + # ---- scheduling ---- + if strategy == 'levels': + self._schedule_by_levels() + else: + self._schedule_by_earliest(enforce_resources=enforce_resources) + + # ---- roll-ups ---- + self.cost = sum(float(getattr(a, 'cost', 0.0) or 0.0) for a in self.actions.values()) + + # -------- Convenience constructors / helpers (build deps inside the class) -------- + + @staticmethod + def _names_from_dependencies(a): + deps = [] + for d in list(getattr(a, 'dependencies', []) or []): + deps.append(d if isinstance(d, str) else getattr(d, 'name', str(d))) + seen = set() + clean = [] + for dn in deps: + if dn != a.name and dn not in seen: + clean.append(dn); seen.add(dn) + return clean + + @classmethod + def from_scenario(cls, sc, **kwargs): + actions = list(sc.actions.values()) + base = {a.name: cls._names_from_dependencies(a) for a in actions} + extra = kwargs.pop('extra_dependencies', None) or {} + for k, v in extra.items(): + base.setdefault(k, []) + for d in v: + if d != k and d not in base[k]: + base[k].append(d) + return cls(actions=actions, action_sequence=base, **kwargs) + + # --------------------------- Resource & Scheduling --------------------------- + + def _action_resources(self, a): + '''Return set of resource keys (e.g., vessel names) this action occupies. + Looks into a.assigned_assets for roles in self.resource_roles. + If none assigned, returns {'unknown'} to avoid blocking anything real. + ''' + aa = getattr(a, 'assigned_assets', {}) or {} + keys = [] + for role in self.resource_roles: + v = aa.get(role) + if v is not None: + keys.append(getattr(v, 'name', str(v))) + return set(keys) if keys else {'unknown'} + + def _schedule_by_earliest(self, enforce_resources=True): + '''Earliest-start (critical-path) schedule with optional single-resource blocking.''' + # Merge dependencies from action attributes and explicit action_sequence + deps = {} + for name, a in self.actions.items(): + dlist = [] + # from Action.dependencies (may be objects or names) + for d in list(getattr(a, 'dependencies', []) or []): + dlist.append(d if isinstance(d, str) else getattr(d, 'name', str(d))) + # from explicit dict + dlist.extend(self.action_sequence.get(name, [])) + # hygiene + seen = set() + clean = [] + for d in dlist: + if d != name and d not in seen: + clean.append(d) + seen.add(d) + deps[name] = clean + + # ensure all nodes present + for name in self.actions.keys(): + deps.setdefault(name, []) + + # Build children and indegrees + children = {n: [] for n in self.actions} + indeg = {n: len(dl) for n, dl in deps.items()} + for child, dlist in deps.items(): + for parent in dlist: + children.setdefault(parent, []).append(child) + + # Ready queue (roots) + ready = sorted([n for n, k in indeg.items() if k == 0]) + + start, finish = {}, {} + avail = {} # resource -> available time + scheduled = [] + + while ready: + name = ready.pop(0) + a = self.actions[name] + scheduled.append(name) + + dep_ready = 0.0 + for d in deps[name]: + if d not in finish: + raise RuntimeError(f"Dependency '{d}' of '{name}' missing finish time.") + dep_ready = max(dep_ready, finish[d]) + + if enforce_resources: + res_keys = self._action_resources(a) + res_ready = max(avail.get(r, 0.0) for r in res_keys) if res_keys else 0.0 + else: + res_ready = 0.0 + + s = max(dep_ready, res_ready) + dur = float(getattr(a, 'duration', 0.0) or 0.0) + f = s + dur + + start[name] = s + finish[name] = f + + if enforce_resources: + for r in self._action_resources(a): + avail[r] = f + + # release children + for c in children.get(name, []): + indeg[c] -= 1 + if indeg[c] == 0: + ready.append(c) + ready.sort() + + if len(scheduled) != len(self.actions): + missing = [n for n in self.actions if n not in scheduled] + raise RuntimeError(f'Cycle or missing predecessors; unscheduled: {missing}') + + # Stamp fields on actions and compute task duration + self.actions_ti = start + for a in self.actions.values(): + a.start_hr = start[a.name] + dur = float(getattr(a, 'duration', 0.0) or 0.0) + a.end_hr = a.start_hr + dur + a.period = (a.start_hr, a.end_hr) + a.label_time = f'{dur:.1f}' + self.duration = max((finish[n] for n in finish), default=0.0) + self.tf = self.ti + self.duration + + def _schedule_by_levels(self): + '''Wrapper that reuses the legacy level-based sequence graph to set starts.''' + # Build levels using provided sequence dict + levels = {} + + def level_of(a, path): + if a in levels: + return levels[a] + if a in path: + raise ValueError(f"Cycle detected at '{a}'.") + path.add(a) + pres = self.action_sequence.get(a, []) + if not pres: + lv = 1 + else: + lv = 1 + max(level_of(p, path) if p in self.action_sequence else 1 for p in pres) + levels[a] = lv + return lv + + for name in self.action_sequence: + level_of(name, set()) + + max_level = max(levels.values(), default=1) + groups = defaultdict(list) + for n, lv in levels.items(): + groups[lv].append(n) + level_dur = {lv: max(self.actions[a].duration for a in acts) for lv, acts in groups.items()} + + t = 0.0 + starts = {} + for lv in range(1, max_level + 1): + for n in groups.get(lv, []): + starts[n] = t + t += level_dur.get(lv, 0.0) + + self.actions_ti = starts + self.duration = sum(level_dur.values()) + for a in self.actions.values(): + a.start_hr = starts.get(a.name, 0.0) + dur = float(getattr(a, 'duration', 0.0) or 0.0) + a.end_hr = a.start_hr + dur + a.period = (a.start_hr, a.end_hr) + a.label_time = f'{dur:.1f}' + self.tf = self.ti + self.duration diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py deleted file mode 100644 index 2828b981..00000000 --- a/famodel/irma/calwave_task1.py +++ /dev/null @@ -1,137 +0,0 @@ -# task1_calwave.py -# Build CalWave Task 1 (Anchor installation) from PDF routes and action table. - -from action import Action -from task import Task -from calwave_irma import Scenario -# If you use Scenario/Project to add actions + hold vessels, import it: -from famodel.project import Project - -# ---- Constants from CalWave_IOM_Summary.pdf ---- -# Transit times [h] (one-way) -SAN_DIEGO_NationalCity_to_TestSite = 4.5 -JAG_NationalCity_to_TestSite = 4.5 -BEYSTER_PointLoma_to_TestSite = 0.8 - -# At site support transit -JAG_TestSite = 7.5 - -# Mirror returns -SAN_DIEGO_TestSite_to_Home = SAN_DIEGO_NationalCity_to_TestSite -JAG_TestSite_to_Home = JAG_NationalCity_to_TestSite -BEYSTER_TestSite_to_Home = BEYSTER_PointLoma_to_TestSite - -def build_task1_calwave(sc): - """ - sc: scenario/project object that exposes: - - sc.vessels['San_Diego'], sc.vessels['Jag'], sc.vessels['Beyster'] - - sc.addAction(action_type_name, name, objects=None) -> Action - Returns: - task (Task) - """ - - # --- Create Actions --- - a_mob_sd = sc.addAction('mobilize', 'mobilize_sandiego') - a_mob_bys = sc.addAction('mobilize', 'mobilize_beyster') - - a_load_cargo = sc.addAction('load_cargo', 'load_cargo_task1', objects=[]) # add anchors and moorings? - - a_tr_site_sd = sc.addAction('transit_tug', 'transit_site_sandiego') - a_tr_site_jag = sc.addAction('transit', 'transit_site_jag') - a_tr_site_bys = sc.addAction('transit', 'transit_site_beyster') - a_tr_at_site_jag = sc.addAction('at_site_support', 'at_site_jag') - - a_install_anchor = sc.addAction('install_anchor', 'install_anchor_task1', objects=[]) - a_install_mooring = sc.addAction('install_mooring', 'install_mooring_task1', objects=[]) - - a_monitor = sc.addAction('monitor_installation', 'monitor_installation_task1') - - a_tr_home_sd = sc.addAction('transit_tug', 'transit_home_sandiego') - a_tr_home_jag = sc.addAction('transit', 'transit_home_jag') - a_tr_home_bys = sc.addAction('transit', 'transit_home_beyster') - - # --- Assign assets and compute durations/costs where needed --- - # Mobilize / Load: let evaluateAssets compute time/cost from capabilities - a_mob_sd.evaluateAssets( {'operator': sc.vessels['San_Diego']} ) - a_mob_bys.evaluateAssets( {'operator': sc.vessels['Beyster']} ) - a_load_cargo.evaluateAssets( {'operator': sc.vessels['San_Diego']} ) - - # Transit site: set duration from PDF table; still assign a vessel so costing works (if your calc uses day rate) - a_tr_site_sd.duration = SAN_DIEGO_NationalCity_to_TestSite - a_tr_site_jag.duration = JAG_NationalCity_to_TestSite - a_tr_site_bys.duration = BEYSTER_PointLoma_to_TestSite - a_tr_at_site_jag.duration = JAG_TestSite - # Optionally call evaluateAssets to pick up cost models: - a_tr_site_sd.evaluateAssets( {'carrier': sc.vessels['San_Diego']} ) - a_tr_site_jag.evaluateAssets( {'operator': sc.vessels['Jag']} ) - a_tr_site_bys.evaluateAssets( {'carrier': sc.vessels['Beyster']} ) - - # Install: vessel + tug - a_install_anchor.evaluateAssets( {'operator': sc.vessels['San_Diego'], 'carrier': sc.vessels['Jag']} ) - a_install_mooring.evaluateAssets( {'operator': sc.vessels['San_Diego'], 'carrier': sc.vessels['Jag']} ) - a_tr_at_site_jag.evaluateAssets( {'operator': sc.vessels['Jag']}) # Need to include this when the tug is included in install anchor and mooring - - # Monitor (Beyster) - a_monitor.evaluateAssets( {'support': sc.vessels['Beyster']} ) - - # Transit homeport: set duration from PDF and assign asset for costing - a_tr_home_sd.duration = SAN_DIEGO_TestSite_to_Home - a_tr_home_jag.duration = JAG_TestSite_to_Home - a_tr_home_bys.duration = BEYSTER_TestSite_to_Home - # Optionally call evaluateAssets to pick up cost models: - a_tr_home_sd.evaluateAssets( {'carrier': sc.vessels['San_Diego']} ) - a_tr_home_jag.evaluateAssets( {'operator': sc.vessels['Jag']} ) - a_tr_home_bys.evaluateAssets( {'carrier': sc.vessels['Beyster']} ) - - # --- Compose the action list for the Task --- - actions = [ - a_mob_sd, a_mob_bys, - a_load_cargo, - a_tr_site_sd, - a_tr_site_jag, a_tr_site_bys, - a_tr_at_site_jag, - a_install_anchor, a_install_mooring, - a_monitor, - a_tr_home_sd, - a_tr_home_jag, a_tr_home_bys, - ] - - # --- Define the sequencing (dependencies) --- - # Keys are action names; values are lists of prerequisite action names. - action_sequence = { - # Mobilize and load - a_mob_sd.name: [], - a_mob_bys.name: [], - a_load_cargo.name: [a_mob_sd.name], - - # Transit to site (can be parallel), but after loading - #a_tr_site_sd.name: [a_load_cargo.name], - a_tr_site_jag.name: [a_mob_sd.name], # tug stays on site for support - a_tr_site_bys.name: [a_mob_bys.name], # support can leave when ready - - # Installs: require everyone onsite - a_install_anchor.name: [a_tr_site_jag.name, a_tr_site_jag.name], - a_install_mooring.name: [a_install_anchor.name], # mooring after anchors - - # Monitoring: runs during/after install (simplify: after mooring) - a_monitor.name: [a_install_mooring.name, a_tr_site_bys.name], - - # Transit homeport: everyone returns after monitoring - #a_tr_home_sd.name: [a_monitor.name], - a_tr_home_jag.name: [a_monitor.name], - a_tr_home_bys.name: [a_monitor.name], - } - - # --- Build and return the Task (plots sequence with CPs) --- - task = Task(actions=actions, action_sequence=action_sequence, name='CalWave_Task1_AnchorInstall') - return task - -if __name__ == '__main__': - # Example skeleton: adjust to your actual Scenario/Project initializer - project = Project(file='calwave_ontology.yaml', raft=False) # for Mac - # create moorpy system of the array, include cables in the system - project.getMoorPyArray(cables=1) - sc = Scenario() - task = build_task1_calwave(sc) - # (The Task constructor will plot the sequence graph automatically) - pass diff --git a/famodel/irma/calwave_task1b.py b/famodel/irma/calwave_task1b.py deleted file mode 100644 index 03e2eda6..00000000 --- a/famodel/irma/calwave_task1b.py +++ /dev/null @@ -1,435 +0,0 @@ -# calwave_task1.py -# Build CalWave Task 1 (Anchor installation) following the theory flow: -# 1) addAction → structure only (type, name, objects, deps) -# 2) evaluateAssets → assign vessels/roles (+ durations/costs) -# 3) (schedule/plot handled by your existing tooling) - -from famodel.project import Project -from calwave_irma import Scenario -from calwave_chart2 import Bubble, VesselTimeline, Task, plot_task - -sc = Scenario() # now sc exists in *this* session - -# list vessel keys -print(list(sc.vessels.keys())) - -# get a vessel name (dict-of-dicts access) -print(sc.vessels['San_Diego']) - -# ------- Transit durations from plan [h] (one-way); tune as needed ------- -SAN_DIEGO_TO_SITE = 4.5 -JAG_TO_SITE = 4.5 -BEYSTER_TO_SITE = 1.8 - -AT_SITE_SUPPORT_BLOCK = 7.5 # Jag on-station support window (fixed block) - -SAN_DIEGO_TO_HOME = SAN_DIEGO_TO_SITE -JAG_TO_HOME = JAG_TO_SITE -BEYSTER_TO_HOME = BEYSTER_TO_SITE - -# ---------- Helpers: map & normalize Project objects ---------- -def map_project_objects(project): - """ - Return A, M dicts with stable keys A1..A4 / M1..M4 mapped from project lists. - Ensures each object has .type ('anchor'/'mooring') and .name for nice labels. - """ - # Choose a stable order (sorted by key) - a_keys = sorted(project.anchorList.keys()) # e.g., ['weca','wecb','wecc','wecd'] - m_keys = sorted(project.mooringList.keys()) - - A = {f'A{i+1}': project.anchorList[k] for i, k in enumerate(a_keys)} - M = {f'M{i+1}': project.mooringList[k] for i, k in enumerate(m_keys)} - - # Normalize .type and .name (some libs don't set these) - for k, obj in A.items(): - if getattr(obj, 'type', None) != 'anchor': - setattr(obj, 'type', 'anchor') - if not hasattr(obj, 'name'): - setattr(obj, 'name', k) - for k, obj in M.items(): - if getattr(obj, 'type', None) != 'mooring': - setattr(obj, 'type', 'mooring') - if not hasattr(obj, 'name'): - setattr(obj, 'name', k) - return A, M - -def eval_set(a, roles, duration=None, **params): - """ - Convenience: call evaluateAssets with roles/params and optionally set .duration. - Always stores assigned_assets for plotting/scheduling attribution. - """ - # Your Action.evaluateAssets may return (duration, cost); we still set explicit duration if passed. - res = a.evaluateAssets(roles | params) - if duration is not None: - a.duration = float(duration) - elif isinstance(res, tuple) and len(res) > 0 and res[0] is not None: - try: - a.duration = float(res[0]) - except Exception: - pass - # keep roles visible on the action - a.assigned_assets = roles - return a - -# ---------- Core builder ---------- -def build_task1_calwave(sc: Scenario, project: Project): - """ - Creates Task 1 actions + dependencies (no scheduling/plotting here). - Generic vessel actions are objectless; domain actions carry domain objects. - """ - # Real domain instances - A, M = map_project_objects(project) - - # --- Pre-ops (objectless) --- - mob_sd = sc.addAction('mobilize', 'mobilize_SanDiego') - - tr_sd = sc.addAction('transit_tug', 'transit_site_SanDiego', - dependencies=[mob_sd]) - tr_jag = sc.addAction('transit', 'transit_site_Jag', - dependencies=[mob_sd]) - - mob_by = sc.addAction('mobilize', 'mobilize_Beyster', - dependencies=[mob_sd]) - tr_by = sc.addAction('transit', 'transit_site_Beyster', - dependencies=[mob_by]) - - # Jag support window (objectless) - sup_jag = sc.addAction('at_site_support', 'at_site_support_Jag', - dependencies=[tr_jag]) - - # --- On-site (domain objects REQUIRED) --- - inst, trans_tug, trans, mon = [], [], [], [] - for i in range(1, 5): - ak, sk = f'A{i}', f'S{i}' - a_inst = sc.addAction('install_anchor', f'install_anchor-{ak}', - objects=[A[ak]], - dependencies=[tr_sd]) # SD on site + Jag supporting - a_trans_tug = sc.addAction('transit_tug', f'transit_tug-{ak}', - # objects=[], - dependencies=[a_inst]) - a_trans = sc.addAction('transit', f'transit-{ak}', - # objects=[], - dependencies=[a_inst]) - a_mon = sc.addAction('monitor_installation', f'monitor_installation-{sk}', - objects=[A[ak]], - dependencies=[a_trans_tug]) - inst.append(a_inst) - trans_tug.append(a_trans_tug) - trans.append(a_trans) - mon.append(a_mon) - - mon_last = mon[-1] - - # --- Post-ops (objectless) --- - home_sd = sc.addAction('transit_tug', 'transit_homeport_SanDiego', - dependencies=mon) - home_jag = sc.addAction('transit', 'transit_homeport_Jag', - dependencies=mon) - home_by = sc.addAction('transit', 'transit_homeport_Beyster', - dependencies=mon) - - # --- Pre-ops (objectless) --- - demob_sd = sc.addAction('demobilize', 'demobilize_SanDiego', - dependencies=[home_sd]) - demob_by = sc.addAction('demobilize', 'demobilize_Beyster', - dependencies=[home_by]) - - # Return a simple list for downstream evaluate/schedule/plot steps - return { - 'mobilize': [mob_sd, mob_by], - 'transit_site': [tr_sd, tr_jag, tr_by], - 'support': [sup_jag], - 'install': inst, - 'transit_tug': trans_tug, - 'transit': trans, - 'monitor': mon, - 'transit_homeport': [home_sd, home_jag, home_by], - 'demobilize': [demob_sd, demob_by] - } - -# ---------- Evaluation step (assign vessels & durations) ---------- -def evaluate_task1(sc: Scenario, actions: dict): - """ - Assign vessels/roles and set durations where the evaluator doesn't. - Keeps creation and evaluation clearly separated. - """ - V = sc.vessels # shorthand - - # Mobilize - eval_set(actions['mobilize'][0], {'operator': V['San_Diego']}, duration=2.0) - eval_set(actions['mobilize'][1], {'operator': V['Beyster']}, duration=1.0) - - # Transit to site - tr_sd, tr_jag, tr_by = actions['transit_site'] - eval_set(tr_sd, {'carrier': V['Jag'], 'operator': V['San_Diego']}, duration=SAN_DIEGO_TO_SITE) - eval_set(tr_jag, {'carrier': V['Jag']}, duration=JAG_TO_SITE) - eval_set(tr_by, {'carrier': V['Beyster']}, duration=BEYSTER_TO_SITE) - - # Jag support block (fixed) - eval_set(actions['support'][0], {'operator': V['Jag']}, duration=AT_SITE_SUPPORT_BLOCK) - - # Install / Lay (San Diego operates; durations can come from evaluator or set defaults) - for a_inst in actions['install']: - eval_set(a_inst, {'carrier': V['Jag'], 'operator': V['San_Diego']}) - if not getattr(a_inst, 'duration', 0): - a_inst.duration = 1.2 # h, placeholder if evaluator didn’t set it - for a_trans_tug in actions['transit_tug']: - eval_set(a_trans_tug, {'carrier': V['Jag'], 'operator': V['San_Diego']}) - if not getattr(a_trans_tug, 'duration', 0): - a_trans_tug.duration = 2.0 # h, placeholder - for a_trans in actions['transit']: - eval_set(a_trans, {'carrier': V['Beyster']}) - if not getattr(a_trans, 'duration', 0): - a_trans.duration = 1.0 # h, placeholder - - # Monitor (Beyster) - for a_mon in actions['monitor']: - eval_set(a_mon, {'support': V['Beyster']}) - if not getattr(a_mon, 'duration', 0): - a_mon.duration = 2.0 # h, placeholder - - # Transit home - home_sd, home_jag, home_by = actions['transit_homeport'] - eval_set(home_sd, {'carrier': V['Jag'], 'operator': V['San_Diego']}, duration=SAN_DIEGO_TO_HOME) - eval_set(home_jag, {'carrier': V['Jag']}, duration=JAG_TO_HOME) - eval_set(home_by, {'carrier': V['Beyster']}, duration=BEYSTER_TO_HOME) - - # Demobilize - eval_set(actions['demobilize'][0], {'operator': V['San_Diego']}, duration=2.0) - eval_set(actions['demobilize'][1], {'operator': V['Beyster']}, duration=1.0) - -def _action_resources(a) -> set[str]: - """ - Return the set of resource keys (vessel names) this action occupies. - We look in assigned_assets for any vessel-like roles. - If nothing is assigned, we put the action into an 'unknown' pool (no blocking effect). - """ - aa = getattr(a, 'assigned_assets', {}) or {} - keys = [] - for role in ('vessel', 'carrier', 'operator'): - v = aa.get(role) - if v is not None: - keys.append(getattr(v, 'name', str(v))) - return set(keys) if keys else {'unknown'} - -def schedule_actions(actions_by_name: dict[str, object]) -> dict[str, float]: - """ - Compute earliest-start times for all actions given durations and dependencies, - with single-resource constraints per vessel (i.e., a vessel can't overlap itself). - Returns: {action_name: start_time_hours} - """ - # Build dependency maps - deps = {name: [d if isinstance(d, str) else getattr(d, 'name', str(d)) - for d in getattr(a, 'dependencies', [])] - for name, a in actions_by_name.items()} - indeg = {name: len(dlist) for name, dlist in deps.items()} - children = {name: [] for name in actions_by_name} - for child, dlist in deps.items(): - for parent in dlist: - if parent not in children: - children[parent] = [] - children[parent].append(child) - - # Ready queue - ready = sorted([n for n, k in indeg.items() if k == 0]) - - start, finish = {}, {} - avail = {} # vessel_name -> time available - - scheduled = [] - - while ready: - name = ready.pop(0) - a = actions_by_name[name] - scheduled.append(name) - - # Dependency readiness - dep_ready = 0.0 - for d in deps[name]: - if d not in finish: - raise RuntimeError(f"Dependency '{d}' of '{name}' has no finish time; check graph.") - dep_ready = max(dep_ready, finish[d]) - - # Resource readiness (all vessels the action occupies) - res_keys = _action_resources(a) - res_ready = max(avail.get(r, 0.0) for r in res_keys) if res_keys else 0.0 - - # Start at the latest of dependency- and resource-readiness - s = max(dep_ready, res_ready) - d = float(getattr(a, 'duration', 0.0) or 0.0) - f = s + d - - start[name] = s - finish[name] = f - - # Block all involved resources until 'f' - for r in res_keys: - avail[r] = f - - # Release children - for c in children.get(name, []): - indeg[c] -= 1 - if indeg[c] == 0: - ready.append(c) - ready.sort() - - if len(scheduled) != len(actions_by_name): - missing = [n for n in actions_by_name if n not in scheduled] - raise RuntimeError(f"Cycle or missing predecessors detected; unscheduled: {missing}") - - return start - -# --------------------------- Scenario indexes ------------------------------ - -def build_indexes(sc): - '''Create quick lookups from Scenario content. - Returns a dict with: - - vessel_keys: set of vessel names as stored in sc.vessels - - type_to_vessel: map of unique vessel type -> vessel key (only if unique) - - action_to_vessel: map of action base-name -> vessel key declared in YAML - ''' - vessel_keys = set((getattr(sc, 'vessels', {}) or {}).keys()) - - # type -> vessel (only if unique across vessels) - type_count = {} - type_first = {} - for vkey, vdesc in (getattr(sc, 'vessels', {}) or {}).items(): - vtype = vdesc.get('type') - if isinstance(vtype, str) and vtype: - type_count[vtype] = type_count.get(vtype, 0) + 1 - type_first.setdefault(vtype, vkey) - type_to_vessel = {t: v for t, v in type_first.items() if type_count.get(t, 0) == 1} - - # actions listed under each vessel in YAML (if present) - action_to_vessel = {} - for vkey, vdesc in (getattr(sc, 'vessels', {}) or {}).items(): - v_actions = vdesc.get('actions') or {} - for aname in v_actions.keys(): - action_to_vessel.setdefault(aname, vkey) - - return { - 'vessel_keys': vessel_keys, - 'type_to_vessel': type_to_vessel, - 'action_to_vessel': action_to_vessel, - } - -# --------------------------- Lane resolution -------------------------------- - -def lane_from_action_name(aname: str, vessel_keys: set[str]) -> str | None: - # direct match of any vessel key as a token or suffix - # examples: 'mobilize_San_Diego', 'transit_site_Beyster', 'foo-Jag' - tokens = [aname] - for sep in ('_', '-', ':'): - tokens.extend(aname.split(sep)) - tokens = [t for t in tokens if t] - for vk in vessel_keys: - if vk in tokens or aname.endswith(vk) or aname.replace('_', '').endswith(vk.replace('_', '')): - return vk - return None - -def lane_from_assigned_assets(action, vessel_keys: set[str], type_to_vessel: dict[str, str]) -> str | None: - aa = getattr(action, 'assigned_assets', {}) or {} - - def pick(asset): - # direct vessel key string - if isinstance(asset, str) and asset in vessel_keys: - return asset - # object with .name equal to a vessel key - nm = getattr(asset, 'name', None) - if isinstance(nm, str) and nm in vessel_keys: - return nm - # dict with an explicit key equal to a vessel key - if isinstance(asset, dict): - for k in ('key', 'name', 'vessel', 'vessel_name', 'display_name', 'id', 'ID'): - v = asset.get(k) - if isinstance(v, str) and v in vessel_keys: - return v - # dict with a type that uniquely maps to a vessel - vtype = asset.get('type') - if isinstance(vtype, str) and vtype in type_to_vessel: - return type_to_vessel[vtype] - # object with .type that uniquely maps - vtype = getattr(asset, 'type', None) - if isinstance(vtype, str) and vtype in type_to_vessel: - return type_to_vessel[vtype] - return None - - # try roles in a reasonable priority - for role in ('vessel', 'operator', 'carrier', 'carrier1', 'carrier2', 'support'): - lane = pick(aa.get(role)) - if lane: - return lane - return None - -# --------------------------- Task builder ----------------------------------- - -def task_from_scenario(sc, start_times: dict, title: str, show_unknown: bool = False) -> Task: - idx = build_indexes(sc) - vessel_keys = idx['vessel_keys'] - type_to_vessel = idx['type_to_vessel'] - action_to_vessel = idx['action_to_vessel'] - - buckets = {} - warnings = [] - - for a in (getattr(sc, 'actions', {}) or {}).values(): - aname = a.name - t0 = float(start_times.get(aname, 0.0)) - dur = float(getattr(a, 'duration', 0.0) or 0.0) - - lane = ( - lane_from_action_name(aname, vessel_keys) - or action_to_vessel.get(aname) - or lane_from_assigned_assets(a, vessel_keys, type_to_vessel) - ) - if not lane: - if show_unknown: - lane = 'unknown' - else: - warnings.append(f'No lane for action {aname}; dropping from plot') - continue - - b = Bubble( - action=aname, - duration_hr=dur, - label_time=f'{dur:.1f}', - capabilities=[], - period=(t0, t0 + dur) - ) - buckets.setdefault(lane, []).append(b) - - # assemble rows and keep only vessels that exist in Scenario - lanes = [] - for vname, blist in buckets.items(): - if vname != 'unknown' and vname not in vessel_keys: - warnings.append(f'Lane {vname} not in Scenario.vessels; skipping') - continue - blist.sort(key=lambda b: b.period[0]) - lanes.append(VesselTimeline(vessel=vname, bubbles=blist)) - - # order rows according to the order in Scenario (if you prefer a fixed order, hardcode it) - order_map = {vk: i for i, vk in enumerate(sc.vessels.keys())} - lanes.sort(key=lambda vt: order_map.get(vt.vessel, 999)) - - return Task(name=title, vessels=lanes) - -if __name__ == '__main__': - # 1) Load ontology that mirrors the sample schema (mooring_systems + mooring_line_configs) - project = Project(file='calwave_ontology.yaml', raft=False) - project.getMoorPyArray(cables=1) - - # 2) Scenario with CalWave catalogs - sc = Scenario() - - # 3) Build (structure only) - actions = build_task1_calwave(sc, project) - - # 4) Evaluate (assign vessels/roles + durations) - evaluate_task1(sc, actions) - - # 5) Schedule (replace with proper scheduler call) - start_times = schedule_actions(sc.actions) # <- dict {action_name: t_star} - - # 6) plot with the calwave_chart2 visual - task = task_from_scenario(sc, start_times, title='CalWave Task 1', show_unknown=False) - plot_task(task) diff --git a/famodel/irma/calwave_vessels.yaml b/famodel/irma/calwave_vessels.yaml index cd8477b0..26e4f5d4 100644 --- a/famodel/irma/calwave_vessels.yaml +++ b/famodel/irma/calwave_vessels.yaml @@ -4,13 +4,16 @@ San_Diego: # Crane barge for anchor handling type: crane_barge transport: - transit_speed_mps: 2.6 # ~5 kts, from doc + homeport: national_city + site_distance_m: 41114 # distance to site + cruise_speed_mps: 2.5 # ~5 kts, from doc Hs_m: 3 station_keeping: type: tug_assist # not self-propelled capabilities: bollard_pull: max_force_t: 30 + site_speed_mps: 0.5 deck_space: area_m2: 800 max_load_t: 1500 @@ -28,7 +31,8 @@ San_Diego: mobilize: {} demobilize: {} load_cargo: {} - transit_tug: {} + transit_linehaul_tug: {} + transit_onsite_tug: {} install_anchor: {} retrieve_anchor: {} install_mooring: {} @@ -38,14 +42,16 @@ Jag: # Pacific Maritime Group tugboat assisting DB San Diego type: tug transport: - transit_speed_mps: 5.1 # ~10 kts + homeport: national_city + site_distance_m: 41114 # distance to site + cruise_speed_mps: 3.1 # Hs_m: 3.5 station_keeping: type: conventional capabilities: engine: power_hp: 300 - speed_kn: 5 + site_speed_mps: 1.0 # bollard_pull: # max_force_t: 30 winch: @@ -54,7 +60,8 @@ Jag: speed_mpm: 20 actions: tow: {} - transit: {} + transit_linehaul_tug: {} + transit_onsite_tug: {} at_site_support: {} day_rate: 25000 @@ -62,14 +69,16 @@ Beyster: # Primary support vessel type: research_vessel transport: - transit_speed_mps: 12.9 # 25 kts cruise, from doc + homeport: point_loma + site_distance_m: 30558 # distance to site + cruise_speed_mps: 12.9 # 25 kts cruise, from doc Hs_m: 2.5 station_keeping: type: DP1 # Volvo DPS system capabilities: engine: power_hp: 300 - speed_kn: 5 + site_speed_mps: 0.5 deck_space: area_m2: 17.8 # from doc (192 ft²) max_load_t: 5 @@ -87,31 +96,34 @@ Beyster: sampling_rate_hz: 5 actions: tow: {} - transit: {} + transit_linehaul_self: {} + transit_onsite_self: {} lay_cable: {} mooring_hookup: {} install_wec: {} monitor_installation: {} day_rate: 15000 -Boston_Whaler: - # 19 ft Boston Whaler, support vessel - type: research_vessel - transport: - transit_speed_mps: 10.3 # ~20 kts cruise - Hs_m: 1.5 - station_keeping: - type: manual - capabilities: - deck_space: - area_m2: 4 - max_load_t: 1.1 # ~1134 kg payload, from doc - # propulsion: - # outboard_hp: 150 # from doc - monitoring_system: - metrics: [visual, diver_support] - sampling_rate_hz: 1 - actions: - diver_support: {} - tow: {} - day_rate: 5000 +# Boston_Whaler: + # # 19 ft Boston Whaler, support vessel + # type: research_vessel + # transport: + # homeport: sio_pier + # site_distance_m: 555 # distance to site + # transit_speed_mps: 10.3 # ~20 kts cruise + # Hs_m: 1.5 + # station_keeping: + # type: manual + # capabilities: + # deck_space: + # area_m2: 4 + # max_load_t: 1.1 # ~1134 kg payload, from doc + # # propulsion: + # # outboard_hp: 150 # from doc + # monitoring_system: + # metrics: [visual, diver_support] + # sampling_rate_hz: 1 + # actions: + # diver_support: {} + # tow: {} + # day_rate: 5000 From f04d37659c1bcc8ad305a2b999a02bdf24a76cce Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Wed, 22 Oct 2025 10:19:22 -0600 Subject: [PATCH 33/63] adding terminology markdown --- famodel/irma/terminologies.md | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 famodel/irma/terminologies.md diff --git a/famodel/irma/terminologies.md b/famodel/irma/terminologies.md new file mode 100644 index 00000000..76de0446 --- /dev/null +++ b/famodel/irma/terminologies.md @@ -0,0 +1,48 @@ +## Key Terminologies + +### Actions +- **Definition**: The smallest unit of work that the system simulates. +- **Purpose**: Represents a specific action to be performed, such as transporting an anchor, installing a mooring, or deploying a WEC. +- **Examples**: + - "Anchor installation" + - "Mooring deployment" + +### Tasks +- **Definition**: A logical group of one or more actions that are bounded by a "From-To Port" constraint. +- **Purpose**: Represents a higher-level grouping of actions that are executed together as part of a specific phase of the installation process. +- **Examples**: + - "Anchor Installation Task" + - "Mooring Deployment Task" + +### Dependencies +- **Definition**: Logical constraints that determine the dependencies between actions. +- **Purpose**: Ensures that actions are executed in a given order based on logical and physical requirements. +- **Examples**: + - "Anchor installation depends on anchor transport to the site." + - "Mooring deployment depends on anchor installation." + +### Action Sequencing +- **Definition**: The process of determining the sequence in which actions take place within a task. +- **Purpose**: Ensures that actions are executed in a logical and efficient order, respecting dependencies and resource constraints. + +### Capabilities +- **Definition**: The specific functionality that an asset (e.g., vessel, port) can perform. +- **Purpose**: Determines which assets are suitable for specific actions. +- **Examples**: + - A vessel with "crane" capability can install anchors. + +### Metrics +- **Definition**: Quantifiable measurements of assets based on their capabilities. +- **Purpose**: Used to evaluate and compare assets for suitability and efficiency in performing actions. +- **Examples**: + - **Speed**: The transit speed of a vessel. + - **Capacity**: The cargo capacity of a vessel. + +### Roles +- **Definition**: Functional assignments of assets in the context of actions. +- **Purpose**: Specifies how each asset contributes to the completion of an action. +- **Examples**: + - **Carrier**: Assigned to carry specific equipment or materials. + - **Operator**: Assigned to operate machinery or perform specialized tasks. + +--- \ No newline at end of file From 0e1f84be050e2806b95cd639ce5f85c37ae883ad Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Wed, 22 Oct 2025 12:41:16 -0600 Subject: [PATCH 34/63] changing the name from calwave_anch installation.py (it had a space) to task1_calwave.py --- famodel/irma/{calwave_anchor installation.py => task1_calwave.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename famodel/irma/{calwave_anchor installation.py => task1_calwave.py} (100%) diff --git a/famodel/irma/calwave_anchor installation.py b/famodel/irma/task1_calwave.py similarity index 100% rename from famodel/irma/calwave_anchor installation.py rename to famodel/irma/task1_calwave.py From dd8084aa21b64bda8e1a47980a8064b74933b090 Mon Sep 17 00:00:00 2001 From: Stein Date: Tue, 21 Oct 2025 15:17:16 -0600 Subject: [PATCH 35/63] Updates to scheduler.py to get it running how it should - Commenting out a lot of constraints that were deemed redundant or not useful (e.g., Constraint 8 was the same thing as Constraint 5, 14a is essentially 11) - Updating the constraint A and b values in some of the other constraints - - Added Constraint 16 for proper duration handling - Right now, had to comment out Constraint 12 to get things to run how we need them to be run - - There is a coupling effect that goes on with Constraint 12 and Xtp and Xap values that may render this X vector parameterization wrong - - Still need to look into it more, but there's a good chance we don't need to use the Xap variables --- famodel/irma/scheduler.py | 314 ++++++++++++++++++++++++++++++-------- 1 file changed, 251 insertions(+), 63 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 3d34fc8e..54e93a8d 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -24,7 +24,7 @@ import numpy as np import os -wordy = 2 # level of verbosity for print statements +wordy = 1 # level of verbosity for print statements class Scheduler: @@ -256,7 +256,7 @@ def set_up_optimizer(self, goal : str = "cost"): # TODO: I dont know if this is necessary - + """ # 1 row A_ub_0 = np.zeros((1, num_variables), dtype=int) # Every period assigned to a task counts as 1 towards the total assigned periods. This assumes one pair per period b_ub_0 = np.array([self.P], dtype=int) @@ -278,7 +278,7 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 0 built.") - + """ # 1) asset can only be assigned to a task if asset is capable of performing the task (value of pairing is non-negative) ''' if task t cannot be performed by asset a, then Xta_ta = 0 @@ -290,8 +290,17 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_1 = np.zeros((1, num_variables), dtype=int) b_eq_1 = np.zeros(1, dtype=int) - A_eq_1[0,self.Xta_start:self.Xta_end] = (self.task_asset_matrix[:, :, goal_index] <= 0).flatten() # Create a mask of invalid task-asset pairings where cost is negative (indicating invalid) - + rows = [] + for t in range(self.T): + for a in range(self.A): + if self.task_asset_matrix[t, a, goal_index] <= 0: # Invalid pairing + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + t * self.A + a] = 1 + rows.append(row) + + A_eq_1 = np.vstack(rows) + b_eq_1 = np.zeros(A_eq_1.shape[0], dtype=int) + if wordy > 1: print("A_eq_1^T:") for i in range(self.Xta_start,self.Xta_end): @@ -346,22 +355,75 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 3 built.") - # 4) assets cannot be assigned to multiple tasks in the same time period + # 4) each asset can only be used by one task per time period ''' - This means the sum of each asset-period pair in a given period must be less than 1 - - (Xap_00 + ... + Xap_A0) <= 1 # for period 0 - (Xap_01 + ... + Xap_A1) <= 1 # for period 1 - ... - (Xap_0P + ... + Xap_AP) <= 1 # for period P + Multiple tasks can be assigned to the same asset (Xta[t1,a] = Xta[t2,a] = 1), + but they cannot use it simultaneously in the same period. + + The relationship is enforced through Constraint 12: + Xtp[t,p] + Xta[t,a] - Xap[a,p] ≤ 1 and ≥ 0 + + This means: if Xtp[t,p] = 1 AND Xta[t,a] = 1, then Xap[a,p] = 1 + Since Xap[a,p] is binary, it can only be 1 for one reason. + + We ensure: Xap[a,p] ≤ 1 for each asset a, period p + This constraint is automatically satisfied for binary variables, but we include it explicitly. + + The key insight: if Constraint 12 is working correctly, it should prevent conflicts + by ensuring that if multiple tasks are assigned to the same asset and try to be + active simultaneously, the Xap[a,p] variable relationships will prevent this. ''' + + # Ensure each asset can be active in at most one context per period + # (This is automatic for binary variables but explicit for clarity) + A_ub_4 = np.zeros((self.A * self.P, num_variables), dtype=int) + b_ub_4 = np.ones(self.A * self.P, dtype=int) - A_ub_4 = np.zeros((self.P, num_variables), dtype=int) - b_ub_4 = np.ones(self.P, dtype=int) - - for p in range (self.P): - # set the coefficient for each period p to one - A_ub_4[p, (self.Xap_start + p * self.A):(self.Xap_start + p * self.A + self.A)] = 1 + row = 0 + for a in range(self.A): + for p in range(self.P): + A_ub_4[row, self.Xap_start + a * self.P + p] = 1 # Xap[a,p] ≤ 1 + row += 1 + + # Add temporal conflict prevention for tasks that could share assets + # For each asset that multiple tasks could use, add constraints to prevent + # simultaneous usage by different tasks + rows_4b = [] + bounds_4b = [] + + for a in range(self.A): + tasks_for_asset = [t for t in range(self.T) if self.task_asset_matrix[t, a, 1] > 0] + + if len(tasks_for_asset) > 1: # Multiple tasks could use this asset + for p in range(self.P): + # Create a constraint involving ALL tasks that could use this asset + # Σ(Xtp[t,p] for t in tasks_for_asset) + Σ(Xta[t,a] for t in tasks_for_asset) ≤ bound + # Logic: If tasks are assigned to asset a, at most 1 can be active in period p + row = np.zeros(num_variables, dtype=int) + + # Add all task-period variables for this period + for t in tasks_for_asset: + row[self.Xtp_start + t * self.P + p] = 1 # Xtp[t,p] + + # Add all task-asset variables for this asset + for t in tasks_for_asset: + row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + + rows_4b.append(row) + + # Calculate bound: if all tasks assigned to asset a, max 1 can be active + num_tasks = len(tasks_for_asset) + max_active_when_all_assigned = 1 # Only 1 task can use asset per period + max_assignments = num_tasks # All could potentially be assigned to the asset + bound = max_active_when_all_assigned + max_assignments + bounds_4b.append(bound) + + if rows_4b: + A_ub_4b = np.vstack(rows_4b) + b_ub_4b = np.array(bounds_4b, dtype=int) + + A_ub_4 = np.vstack([A_ub_4, A_ub_4b]) + b_ub_4 = np.concatenate([b_ub_4, b_ub_4b]) if wordy > 1: print("A_ub_4^T:") @@ -388,36 +450,38 @@ def set_up_optimizer(self, goal : str = "cost"): ... (Xtp_T0 + ... + Xtp_TP) >= 1 # for task T ''' - - A_eq_5 = np.zeros((self.T, num_variables), dtype=int) - b_eq_5 = np.ones(self.T, dtype=int) + # NOTE: Constraint 5 is redundant with Constraint 16, which provides exact duration matching + """ + A_lb_5 = np.zeros((self.T, num_variables), dtype=int) + b_lb_5 = np.ones(self.T, dtype=int) for t in range (self.T): # set the coefficient for each task t to one - A_eq_5[t, (self.Xtp_start + t * self.P):(self.Xtp_start + t * self.P + self.P)] = 1 # Set the coefficients for the Xtp variables to 1 for each task t + A_lb_5[t, (self.Xtp_start + t * self.P):(self.Xtp_start + t * self.P + self.P)] = 1 # Set the coefficients for the Xtp variables to 1 for each task t if wordy > 1: - print("A_eq_5^T:") + print("A_lb_5^T:") print(" T1 T2") # Header for 2 tasks for i in range(self.Xtp_start,self.Xtp_end): pstring = str(self.X_indices[i]) - for column in A_eq_5.transpose()[i]: + for column in A_lb_5.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_eq_5: ", b_eq_5) + print("b_lb_5: ", b_lb_5) - A_eq_list.append(A_eq_5) - b_eq_list.append(b_eq_5) + A_lb_list.append(A_lb_5) + b_lb_list.append(b_lb_5) if wordy > 0: print("Constraint 5 built.") - + """ # 6) The total number of assets assigned cannot be greater than the number of assets available but must be greater than the number of tasks. ''' Sum of all asset-period pairs must be >= T: A >= (Xap_00 + ... + Xap_AP) >= T ''' + """ A_6 = np.zeros((1, num_variables), dtype=int) b_lb_6 = np.array([self.T], dtype=int) b_ub_6 = np.array([self.A], dtype=int) @@ -441,7 +505,7 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 6 built.") - + """ # 7) Ensure tasks are assigned as early as possible ''' A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. @@ -457,7 +521,7 @@ def set_up_optimizer(self, goal : str = "cost"): ... (Xtp_T0 + ... + Xtp_TP) >= 1 # for task T ''' - + """ # num_tasks rows A_lb_8 = np.zeros((self.T, num_variables), dtype=int) b_lb_8 = np.ones(self.T, dtype=int) @@ -479,7 +543,7 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 8 built.") - + """ # 9) TODO: Empty constraint: fill me in later # 10) A task duration plus the start-time it is assigned to must be less than the total number of time periods available @@ -494,14 +558,15 @@ def set_up_optimizer(self, goal : str = "cost"): for a in range(self.A): duration = self.task_asset_matrix[t, a, 1] # duration of task t with asset a if duration > 0: # If valid pairing, make constraint - row = np.zeros(num_variables, dtype=int) for s in range(self.S): - row[self.Xts_start + t * self.S + s] = duration + s - row[self.Xta_start + t * self.A + a] = 1 - rows.append(row) + if s + duration > self.P: + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s] = 1 + row[self.Xta_start + t * self.A + a] = 1 + rows.append(row) A_ub_10 = np.vstack(rows) - b_ub_10 = np.ones(A_ub_10.shape[0], dtype=int) * (self.P) # -1 becasue we are also counting the index of the TA pair + b_ub_10 = np.ones(A_ub_10.shape[0], dtype=int) # Each infeasible combination: Xta + Xts <= 1 if wordy > 1: print("A_ub_10^T:") @@ -530,6 +595,7 @@ def set_up_optimizer(self, goal : str = "cost"): (Xtp_00 + ... + Xtp_TP) >= (Xts_00 + ... + Xts_TS) # for all tasks t in range(0:T) ''' + """ A_lb_11 = np.zeros((self.T, num_variables), dtype=int) b_lb_11 = np.ones(self.T, dtype=int) * 2 @@ -552,7 +618,7 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 11 built.") - + """ # 12) The period an asset is assigned to must match the period the task in the task-asset pair is assigned to ''' This ensures the chosen task and asset in a task asset pair are assigned to the same period. This means that if an asset @@ -564,7 +630,7 @@ def set_up_optimizer(self, goal : str = "cost"): Xtp[t, p] - Xap[a, p] >= -(1 - Xta[t, a]) --> Xtp[t, p] - Xap[a, p] + Xta[t, a] >= 0 ''' - + """ A_12 = np.zeros((self.T * self.A * self.P, num_variables), dtype=int) b_ub_12 = np.ones(self.T * self.A * self.P, dtype=int) b_lb_12 = np.zeros(self.T * self.A * self.P, dtype=int) @@ -578,7 +644,35 @@ def set_up_optimizer(self, goal : str = "cost"): A_12[row, self.Xta_start + t * self.A + a] = 1 row += 1 + """ + + rows_ub = [] + rows_lb = [] + for t in range(self.T): + for a in range(self.A): + # Only create constraints for valid task-asset pairs + if self.task_asset_matrix[t, a, 1] > 0: # Valid pairing (duration > 0) + for p in range(self.P): + row = np.zeros(num_variables, dtype=int) + row[self.Xtp_start + t * self.P + p] = 1 # Xtp[t,p] + row[self.Xap_start + a * self.P + p] = -1 # -Xap[a,p] + row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + + rows_ub.append(row.copy()) # Upper bound constraint + rows_lb.append(row.copy()) # Lower bound constraint + """ + if rows_ub: + A_ub_12 = np.vstack(rows_ub) + b_ub_12 = np.ones(len(rows_ub), dtype=int) + A_lb_12 = np.vstack(rows_lb) + b_lb_12 = np.zeros(len(rows_lb), dtype=int) + + A_ub_list.append(A_ub_12) + b_ub_list.append(b_ub_12) + A_lb_list.append(A_lb_12) + b_lb_list.append(b_lb_12) + """ if wordy > 1: print("A_12^T:") for i in range(self.Xta_start,self.Xap_end): @@ -588,12 +682,12 @@ def set_up_optimizer(self, goal : str = "cost"): print(pstring) print("b_ub_12: ", b_ub_12) print("b_lb_12: ", b_lb_12) - + ''' A_ub_list.append(A_12) b_ub_list.append(b_ub_12) A_lb_list.append(A_12) b_lb_list.append(b_lb_12) - + ''' if wordy > 0: print("Constraint 12 built.") @@ -603,6 +697,7 @@ def set_up_optimizer(self, goal : str = "cost"): (Xap_00 + ... + Xap_AP) >= (Xtp_00 + ... + Xtp_TP) # for all periods p in range(0:P) ''' + """ A_lb_13 = np.zeros((self.P, num_variables), dtype=int) b_lb_13 = np.ones(self.P, dtype=int) * 2 @@ -625,7 +720,7 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 13 built.") - + """ # 14) if a task-starttime pair is selected, the corresponding task-period pair must be selected for the period equal to the start time plus the duration of the task ''' This ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time plus the duration of the task is also selected. @@ -635,60 +730,116 @@ def set_up_optimizer(self, goal : str = "cost"): # TODO: commenting out this constraint allows the optimizer to find an optimal solution # TODO: this is very very close. The Xtp are being assigned blocks equal to the starttime + duration. But it is causing the optimizer to fail...? + rows_14a = [] + vec_14a = [] + rows_14b = [] + vec_14b = [] + #rows = [] + #vec = [] + + # 14a) Simple start time to period mapping: Xts[t,s] <= Xtp[t,s] + for t in range(self.T): + for s in range(self.S): + if s < self.P: # Only if start time is within valid periods + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + row[self.Xtp_start + t * self.P + s] = -1 # -Xtp[t,s] + rows_14a.append(row) + vec_14a.append(0) # Xts[t,s] - Xtp[t,s] <= 0 - rows = [] - vec = [] + ''' for t in range(self.T): for a in range(self.A): duration = self.task_asset_matrix[t, a, 1] if duration > 0: # If valid pairing, make constraint - for s in range(self.S): + for s in range(min(self.S, self.P - duration + 1)): row = np.zeros(num_variables, dtype=int) row[self.Xta_start + t * self.A + a] = 1 row[self.Xts_start + t * self.S + s] = -1 - row[self.Xtp_start + t * self.P + s : self.Xtp_start + t * self.P + s + duration] = 1 + start_idx = self.Xtp_start + t * self.P + s + end_idx = min(start_idx + duration, self.Xtp_start + (t + 1) * self.P) + row[start_idx:end_idx] = 1 + #row[self.Xtp_start + t * self.P + s : self.Xtp_start + t * self.P + s + duration] = 1 rows.append(row) - vec.append(duration) + vec.append(1) + ''' + # 14b) Duration enforcement: if task t uses asset a and starts at s, + # then it must be active for duration periods + for t in range(self.T): + for a in range(self.A): + duration = self.task_asset_matrix[t, a, 1] + if duration > 0: # Valid task-asset pairing + for s in range(min(self.S, self.P - duration + 1)): # Valid start times + for p in range(s, min(s + duration, self.P)): # Each period in duration + row = np.zeros(num_variables, dtype=int) + # If Xta[t,a] = 1 AND Xts[t,s] = 1, then Xtp[t,p] = 1 + # Constraint: Xta[t,a] + Xts[t,s] - Xtp[t,p] <= 1 + row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + row[self.Xtp_start + t * self.P + p] = -1 # -Xtp[t,p] + rows_14b.append(row) + vec_14b.append(1) # Xta[t,a] + Xts[t,s] - Xtp[t,p] <= 1 + - A_ub_14 = np.vstack(rows) - b_ub_14 = np.array(vec, dtype=int) + #A_lb_14 = np.vstack(rows) + #b_lb_14 = np.array(vec, dtype=int) + + if rows_14a: + A_ub_14a = np.vstack(rows_14a) + b_ub_14a = np.array(vec_14a, dtype=int) + A_ub_list.append(A_ub_14a) + b_ub_list.append(b_ub_14a) + + if rows_14b: + A_ub_14b = np.vstack(rows_14b) + b_ub_14b = np.array(vec_14b, dtype=int) + A_ub_list.append(A_ub_14b) + b_ub_list.append(b_ub_14b) if wordy > 1: - print("A_ub_14^T:") + print("A_lb_14^T:") print(" T1A1S1 T1A2S1 ...") # Header for 3 task-asset pairs example with T2A2 invalid for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) - for column in A_ub_14.transpose()[i]: + for column in A_lb_14.transpose()[i]: pstring += f"{ column:5}" print(pstring) for i in range(self.Xtp_start,self.Xtp_end): pstring = str(self.X_indices[i]) - for column in A_ub_14.transpose()[i]: + for column in A_lb_14.transpose()[i]: pstring += f"{ column:5}" print(pstring) for i in range(self.Xts_start,self.Xts_end): pstring = str(self.X_indices[i]) - for column in A_ub_14.transpose()[i]: + for column in A_lb_14.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_ub_14: ", b_ub_14) + print("b_lb_14: ", b_ub_14) - A_ub_list.append(A_ub_14) - b_ub_list.append(b_ub_14) + #A_lb_list.append(A_lb_14) + #b_lb_list.append(b_lb_14) if wordy > 0: print("Constraint 14 built.") - + # 15) the number of task-starttime pairs must be equal to the number of tasks ''' This ensures that each task is assigned a start time. - (Xts_00 + ... + Xts_TS) = T + (Xts_00 + ... + Xts_TS) = 1 + ''' ''' A_eq_15 = np.zeros((1, num_variables), dtype=int) b_eq_15 = np.array([self.T], dtype=int) A_eq_15[0,self.Xts_start:self.Xts_end] = 1 + ''' + A_eq_15 = np.zeros((self.T, num_variables), dtype=int) + b_eq_15 = np.ones(self.T, dtype=int) + + for t in range(self.T): + A_eq_15[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 + if wordy > 1: print("A_eq_15^T:") for i in range(self.Xts_start,self.Xts_end): @@ -704,7 +855,42 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 15 built.") - # 16) assets cannot be assigned in a time period where the weather is above the maximum capacity + # 16) Each task must be active for exactly the duration of its assigned asset + ''' + This constraint works together with Constraint 14b to ensure proper duration handling: + - Constraint 14b: Ensures tasks are active during their assigned duration periods (lower bound) + - Constraint 16: Ensures tasks are active for exactly their total duration (upper bound) + + For each task t, the sum of Xtp periods must equal the duration of the assigned asset: + sum(Xtp[t,p] for p in P) = sum(Xta[t,a] * duration[t,a] for a in A) + ''' + rows_16 = [] + vec_16 = [] + + for t in range(self.T): + row = np.zeros(num_variables, dtype=int) + # Left side: sum of all periods for task t + for p in range(self.P): + row[self.Xtp_start + t * self.P + p] = 1 + # Right side: subtract duration * assignment for each asset + for a in range(self.A): + duration = self.task_asset_matrix[t, a, 1] + if duration > 0: + row[self.Xta_start + t * self.A + a] = -duration + + rows_16.append(row) + vec_16.append(0) # sum(Xtp) - sum(duration * Xta) = 0 + + if rows_16: + A_eq_16 = np.vstack(rows_16) + b_eq_16 = np.array(vec_16, dtype=int) + A_eq_list.append(A_eq_16) + b_eq_list.append(b_eq_16) + + if wordy > 0: + print("Constraint 16 built.") + + # 17) assets cannot be assigned in a time period where the weather is above the maximum capacity # TODO: weather is disabled until this is added @@ -825,7 +1011,7 @@ def optimize(self, threads = -1): if res.success: # Reshape the flat result back into the (num_periods, num_tasks, num_assets) shape - if wordy > 1: + if wordy > 0: print("Decision variable [periods][tasks][assets]:") for i in range(len(self.X_indices)): print(f" {self.X_indices[i]}: {int(res.x[i])}") @@ -848,6 +1034,8 @@ def optimize(self, threads = -1): cost = task_asset_matrix[t, a_assigned, 0] duration = task_asset_matrix[t, a_assigned, 1] pstring +=f"Asset {a_assigned} assigned to task {t} (cost: {cost}, duration: {duration}) | " + else: + pstring += " "*53 + "| " print(pstring) if wordy > 0: @@ -861,7 +1049,7 @@ def optimize(self, threads = -1): # A simple dummy system to test the scheduler # 10 weather periods = 10 time periods - weather = [1]*5 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration + weather = [1]*10 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration # Example tasks, assets, dependencies, and task_asset_matrix. Eventually try with more tasks than assets, more assets than tasks, etc. tasks = [ @@ -881,8 +1069,8 @@ def optimize(self, threads = -1): # cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid task_asset_matrix = np.array([ - [(1000, 2), (2000, 3)], # task 1: asset 1, asset 2 - [(1200, 3), ( -1,-1)] # task 2: asset 1, asset 2 + [(1000, 2), (2000, 3)], # task 0: asset 0, asset 1 + [(1200, 3), ( -1,-1)] # task 1: asset 0, asset 1 ]) # optimal assignment: task 1 with asset 1 in periods 1-2, task 2 with asset 1 in period 3 @@ -893,7 +1081,7 @@ def optimize(self, threads = -1): # Sandbox for building out the scheduler scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, weather, min_duration) scheduler.optimize() - + a = 2 # # A more complex dummy system to test the scheduler (uncomment and comment out above to run) From e97942b51fdb39ad436df0c64eae43cec716d1d4 Mon Sep 17 00:00:00 2001 From: Stein Date: Thu, 23 Oct 2025 10:58:02 -0600 Subject: [PATCH 36/63] Added constraints to scheduler for dependencies and weather - Constraint 2 now implements constraints for dependencies, which are parameterized by 'task_dependencies' and 'dependency_type' - - Many dependency types to choose from, but the most common in 'finish_start', where task B starts depending on when task A finishes - - does some semi-intricate checking to loop through each task and task dependency (type) - Constraint 17 (used to be commented 16) implements weather constraint based on the max weather of each asset - Also implemented the old Constraint 7 into the objective/values, since it's not a hard and fast constraint, but wants to prioritize earlier start times of tasks - - gives the values vector increasing nonzero values for later start times -> penalizing them - Initial start to updating the README --- famodel/irma/scheduler.py | 334 +++++++++++++++++++++++++++----- famodel/irma/schedulerREADME.md | 7 +- 2 files changed, 293 insertions(+), 48 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 54e93a8d..3e7ab4b3 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -29,7 +29,7 @@ class Scheduler: # Inputs are strictly typed, as this is an integer programming problem (ignored by python at runtime, but helpful for readability and syntax checking). - def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, weather : list[int] = [], period_duration : float = 1, **kwargs): + def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, weather : list[int] = [], period_duration : float = 1, **kwargs): ''' Initializes the Scheduler with assets, tasks, and constraints. @@ -44,6 +44,14 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset A list of Asset objects to be scheduled. task_dependencies : dict A dictionary mapping each task to a list of its dependencies. + dependency_types : dict + A dictionary mapping each task dependency pair to its type: + - "finish_start" (default): dependent task starts after prerequisite finishes + - "start_start": dependent task starts when prerequisite starts + - "finish_finish": dependent task finishes when prerequisite finishes + - "start_finish": dependent task finishes when prerequisite starts + - "offset": dependent task starts/finishes with time offset (requires offset value) + - "same_asset": dependent task must use same asset as prerequisite weather : list A list of weather windows. The length of this list defines the number of discrete time periods available for scheduling. period_duration : float @@ -64,6 +72,7 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset self.assets = assets self.weather = weather self.task_dependencies = task_dependencies + self.dependency_types = dependency_types self.period_duration = period_duration # duration of each scheduling period. Used for converting from periods to real time. # --- Check for valid inputs --- @@ -181,7 +190,19 @@ def set_up_optimizer(self, goal : str = "cost"): values = np.zeros(num_variables, dtype=int) # NOTE: enforces discrete cost and duration values[self.Xta_start:self.Xta_end] = self.task_asset_matrix[:, :, goal_index].flatten() # Set the cost or duration for the task-asset pair - # The rest of values (for start/period variables) remains zero because they do not impact cost or duration + # Add small penalties for later start times (Constraint 7 implementation) + # This encourages the solver to choose earlier start times when possible + max_task_cost = np.max(self.task_asset_matrix[:, :, goal_index]) + early_start_penalty_factor = max_task_cost * 0.001 # Very small penalty (0.1% of max cost) + + for t in range(self.T): + for s in range(self.S): + # Add small penalty proportional to start time + # Later start times get higher penalties + penalty = int(early_start_penalty_factor * s) + values[self.Xts_start + t * self.S + s] = penalty + + # The rest of values (for period variables) remains zero because they do not impact cost or duration if wordy > 1: print("Values vector of length " + str(values.shape[0]) + " created") @@ -287,9 +308,6 @@ def set_up_optimizer(self, goal : str = "cost"): ''' # 1 row - A_eq_1 = np.zeros((1, num_variables), dtype=int) - b_eq_1 = np.zeros(1, dtype=int) - rows = [] for t in range(self.T): for a in range(self.A): @@ -298,29 +316,174 @@ def set_up_optimizer(self, goal : str = "cost"): row[self.Xta_start + t * self.A + a] = 1 rows.append(row) - A_eq_1 = np.vstack(rows) - b_eq_1 = np.zeros(A_eq_1.shape[0], dtype=int) - - if wordy > 1: - print("A_eq_1^T:") - for i in range(self.Xta_start,self.Xta_end): - pstring = str(self.X_indices[i]) - for column in A_eq_1.transpose()[i]: - pstring += f"{ column:5}" - print(pstring) - print("b_eq_1: ", b_eq_1) - - A_eq_list.append(A_eq_1) - b_eq_list.append(b_eq_1) + if rows: # Only create constraint if there are invalid pairings + A_eq_1 = np.vstack(rows) + b_eq_1 = np.zeros(A_eq_1.shape[0], dtype=int) + + if wordy > 1: + print("A_eq_1^T:") + for i in range(self.Xta_start,self.Xta_end): + pstring = str(self.X_indices[i]) + for column in A_eq_1.transpose()[i]: + pstring += f"{ column:5}" + print(pstring) + print("b_eq_1: ", b_eq_1) + + A_eq_list.append(A_eq_1) + b_eq_list.append(b_eq_1) if wordy > 0: print("Constraint 1 built.") # 2) task dependencies must be respected (i.e., a task cannot start until all its dependencies have been satisfied) - # ''' - # This enforces task dependencies by ensuring that a task can only be assigned to a time period if all its dependencies have been completed in previous periods. + ''' + This enforces task dependencies by ensuring that a task can only be assigned to a time period if all its dependencies have been completed in previous periods. + + Different dependency types: + - finish_start: Task B starts after Task A finishes + - start_start: Task B starts when Task A starts + - finish_finish: Task B finishes when Task A finishes + - start_finish: Task B finishes when Task A starts + - same_asset: Task B must use the same asset as Task A + + For finish_start dependencies (most common): + If task t depends on task d, then task t cannot start before task d finishes. + + Using start times: Xts[t,s] = 1 implies Xts[d,sd] = 1 where sd + duration_d <= s + + Constraint: For all valid start times s for task t, if Xts[t,s] = 1, + then there must exist some start time sd for task d such that Xts[d,sd] = 1 + and sd + duration_d <= s + + Implementation: Xts[t,s] <= sum(Xts[d,sd] for sd where sd + duration_d <= s) + ''' + + rows_2 = [] + vec_2 = [] + + # Convert task names to indices for easier processing + task_name_to_index = {task: i for i, task in enumerate(self.tasks)} + + for task_name, dependencies in self.task_dependencies.items(): + if task_name not in task_name_to_index: + continue # Skip if task not in our task list + + t = task_name_to_index[task_name] # dependent task index + + for dep_task_name in dependencies: + if dep_task_name not in task_name_to_index: + continue # Skip if dependency not in our task list + + d = task_name_to_index[dep_task_name] # dependency task index + + # Get dependency type (default to finish_start) + dep_key = f"{dep_task_name}->{task_name}" + dep_type = self.dependency_types.get(dep_key, "finish_start") + + if dep_type == "finish_start": + # Task t cannot start until task d finishes + # For each possible start time s of task t + for s in range(self.S): + # Task t can start at time s only if task d has already finished + # Find minimum duration of task d across all possible assets + min_duration_d = float('inf') + for a_d in range(self.A): + duration_d = self.task_asset_matrix[d, a_d, 1] + if duration_d > 0: # Valid task-asset pairing + min_duration_d = min(min_duration_d, duration_d) + + if min_duration_d == float('inf'): + continue # No valid asset for dependency task + + # Task d must finish before time s + # So task d must start at latest at time (s - min_duration_d) + # But we need to account for the actual duration based on asset choice + + # For this constraint: if task t starts at time s, then task d must have started + # and finished before time s + latest_start_d = s - min_duration_d + + if latest_start_d < 0: + # Task t cannot start at time s because task d cannot finish in time + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] = 0 (cannot start) + rows_2.append(row) + vec_2.append(0) # Xts[t,s] <= 0, so Xts[t,s] = 0 + else: + # Task t can start at time s only if task d starts at time <= latest_start_d + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + + # Add all valid start times for task d + has_valid_dep_start = False + for sd in range(min(latest_start_d + 1, self.S)): # sd from 0 to latest_start_d + row[self.Xts_start + d * self.S + sd] = -1 # -Xts[d,sd] + has_valid_dep_start = True + + if has_valid_dep_start: + rows_2.append(row) + vec_2.append(0) # Xts[t,s] - sum(Xts[d,valid_sd]) <= 0 + + elif dep_type == "start_start": + # Task t starts when task d starts (same start time) + for s in range(self.S): + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + row[self.Xts_start + d * self.S + s] = -1 # -Xts[d,s] + rows_2.append(row) + vec_2.append(0) # Xts[t,s] - Xts[d,s] <= 0, so Xts[t,s] <= Xts[d,s] + + elif dep_type == "finish_finish": + # Task t finishes when task d finishes + # This requires both tasks to have the same end time + for s_t in range(self.S): + for a_t in range(self.A): + duration_t = self.task_asset_matrix[t, a_t, 1] + if duration_t > 0: # Valid pairing for task t + end_time_t = s_t + duration_t + + # Find start times for task d that result in same end time + for s_d in range(self.S): + for a_d in range(self.A): + duration_d = self.task_asset_matrix[d, a_d, 1] + if duration_d > 0: # Valid pairing for task d + end_time_d = s_d + duration_d + + if end_time_t == end_time_d: + # If task t starts at s_t with asset a_t AND task d starts at s_d with asset a_d, + # then they finish at the same time (constraint satisfied) + continue + else: + # Prevent this combination + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] + row[self.Xta_start + t * self.A + a_t] = 1 # Xta[t,a_t] + row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] + row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] + rows_2.append(row) + vec_2.append(3) # At most 3 of these 4 can be 1 simultaneously + + elif dep_type == "same_asset": + # Task t must use the same asset as task d + for a in range(self.A): + # If both tasks can use asset a + if (self.task_asset_matrix[t, a, 1] > 0 and + self.task_asset_matrix[d, a, 1] > 0): + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + row[self.Xta_start + d * self.A + a] = -1 # -Xta[d,a] + rows_2.append(row) + vec_2.append(0) # Xta[t,a] - Xta[d,a] <= 0, so if t uses a, then d must use a + + # Build constraint matrices if we have any dependency constraints + if rows_2: + A_ub_2 = np.vstack(rows_2) + b_ub_2 = np.array(vec_2, dtype=int) + A_ub_list.append(A_ub_2) + b_ub_list.append(b_ub_2) - # The general idea is that Xts_ds < Xtp_tp where d is the task that task t is dependent on. + if wordy > 0: + print("Constraint 2 built.") # 3) at least one asset must be assigned to each task ''' @@ -505,6 +668,24 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 6 built.") + + # 7) Ensure tasks are assigned as early as possible + ''' + A task cannot be assigned if it could have been assigned in an earlier period. + This encourages the solver to assign tasks to the earliest possible periods. + + Practical implementation: Rather than hard constraints (which can cause infeasibility), + we add this preference to the objective function by giving later start times + higher costs. This encourages early scheduling without making the problem infeasible. + ''' + + # No hard constraints for Constraint 7 - implemented in objective function + # The preference for earlier start times will be added as small penalties + # in the objective function coefficients for Xts variables + + if wordy > 0: + print("Constraint 7 built (implemented as objective function preference).") + """ # 7) Ensure tasks are assigned as early as possible ''' @@ -890,8 +1071,54 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 16 built.") - # 17) assets cannot be assigned in a time period where the weather is above the maximum capacity - # TODO: weather is disabled until this is added + # 17) Weather constraints: task-asset pairs cannot be assigned in periods with incompatible weather + ''' + Assets have maximum weather conditions they can operate in (stored as 'max_weather' in asset dict). + If the weather in period p exceeds an asset's max_weather capability, then no task can be + assigned to that asset in that period. + + For each asset a, period p, and task t: + If weather[p] > asset[a]['max_weather'], then Xta[t,a] + Xtp[t,p] <= 1 + + This prevents simultaneous assignment of both the task-asset pair AND the task-period pair + when weather conditions are incompatible. + ''' + rows_17 = [] + vec_17 = [] + + for a in range(self.A): + asset_max_weather = self.assets[a].get('max_weather', float('inf')) # Default to no weather limit + + for p in range(self.P): + period_weather = self.weather[p] + + if period_weather > asset_max_weather: + # Weather in period p is too severe for asset a + for t in range(self.T): + # Check if this task-asset pair is valid (positive duration and cost) + if (self.task_asset_matrix[t, a, 0] >= 0 and + self.task_asset_matrix[t, a, 1] > 0): + + # Prevent task t from using asset a in period p due to weather + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + row[self.Xtp_start + t * self.P + p] = 1 # Xtp[t,p] + + rows_17.append(row) + vec_17.append(1) # Xta[t,a] + Xtp[t,p] <= 1 (can't have both = 1) + + # Build constraint matrices if we have any weather constraints + if rows_17: + A_ub_17 = np.vstack(rows_17) + b_ub_17 = np.array(vec_17, dtype=int) + A_ub_list.append(A_ub_17) + b_ub_list.append(b_ub_17) + + if wordy > 0: + print(f"Constraint 17 built with {len(rows_17)} weather restrictions.") + else: + if wordy > 0: + print("Constraint 17 built (no weather restrictions needed).") # --- End Constraints --- @@ -1026,16 +1253,18 @@ def optimize(self, threads = -1): Xts = x_opt[self.Xts_start:self.Xts_end].reshape((self.T, self.S)) for p in range(self.P): - pstring = f"Period {p}: " + weather_condition = self.weather[p] + pstring = f"Period {p} (weather {weather_condition}): " for t in range(self.T): if Xtp[t, p] > 0: # Find assigned asset for this task a_assigned = np.argmax(Xta[t, :]) # assumes only one asset per task - cost = task_asset_matrix[t, a_assigned, 0] - duration = task_asset_matrix[t, a_assigned, 1] - pstring +=f"Asset {a_assigned} assigned to task {t} (cost: {cost}, duration: {duration}) | " + cost = self.task_asset_matrix[t, a_assigned, 0] + duration = self.task_asset_matrix[t, a_assigned, 1] + asset_name = self.assets[a_assigned].get('name', f'Asset {a_assigned}') + pstring += f"{asset_name} assigned to task {t} (cost: {cost}, duration: {duration}) | " else: - pstring += " "*53 + "| " + pstring += " "*60 + "| " print(pstring) if wordy > 0: @@ -1046,40 +1275,48 @@ def optimize(self, threads = -1): os.system("clear") # for clearing terminal on Mac - # A simple dummy system to test the scheduler + # A simple dummy system to test the scheduler with weather constraints - # 10 weather periods = 10 time periods - weather = [1]*10 # Three weather types for now. Example weather windows. The length of each window is equal to min_duration + # Weather periods with varying conditions (1=calm, 2=moderate, 3=severe) + weather = [1, 1, 2, 3, 1] # 6 time periods with different weather conditions - # Example tasks, assets, dependencies, and task_asset_matrix. Eventually try with more tasks than assets, more assets than tasks, etc. + # Example tasks, assets, dependencies, and task_asset_matrix tasks = [ "task1", "task2" ] assets = [ - {"name": "asset1", "max_weather" : 3}, - {"name": "asset2", "max_weather" : 2} + {"name": "heavy_asset", "max_weather": 3}, # Can work in all weather conditions + {"name": "light_asset", "max_weather": 1} # Can only work in calm weather (1) ] # task dependencies task_dependencies = { - "task1": ["task1"], - "task2": [] + "task1": [], # task1 has no dependencies + "task2": ["task1"] # task2 depends on task1 + } + + # dependency types (optional - defaults to "finish_start" if not specified) + dependency_types = { + "task1->task2": "finish_start" # task2 starts after task1 finishes } # cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid task_asset_matrix = np.array([ - [(1000, 2), (2000, 3)], # task 0: asset 0, asset 1 - [(1200, 3), ( -1,-1)] # task 1: asset 0, asset 1 + [(2000, 2), (1000, 3)], # task1: heavy_asset (expensive but fast), light_asset (cheap but slow) + [(1500, 3), (-1, -1)] # task2: both assets can do it, light_asset is cheaper ]) - # optimal assignment: task 1 with asset 1 in periods 1-2, task 2 with asset 1 in period 3 + # Expected behavior with weather constraints: + # - light_asset can only work in periods 0,1,5 (weather=1) + # - heavy_asset can work in any period (max_weather=3) + # - task2 depends on task1 (finish_start dependency) # Find the minimum time period duration based on the task_asset_matrix min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration # Sandbox for building out the scheduler - scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, weather, min_duration) + scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration) scheduler.optimize() a = 2 @@ -1104,9 +1341,16 @@ def optimize(self, threads = -1): # # task dependencies # task_dependencies = { - # "task1": [], - # "task2": ["task1"], - # "task3": ["task1"] + # "task1": [], # task1 has no dependencies + # "task2": ["task1"], # task2 depends on task1 + # "task3": ["task1", "task2"] # task3 depends on both task1 and task2 + # } + + # # dependency types (optional - demonstrates different types) + # dependency_types = { + # "task1->task2": "finish_start", # task2 starts after task1 finishes (default) + # "task1->task3": "start_start", # task3 starts when task1 starts + # "task2->task3": "same_asset" # task3 must use same asset as task2 # } # # random cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid @@ -1122,5 +1366,5 @@ def optimize(self, threads = -1): # min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration # # Sandbox for building out the scheduler - # scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, weather, min_duration) + # scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration) # scheduler.optimize() \ No newline at end of file diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md index 01b3115e..dc5825ed 100644 --- a/famodel/irma/schedulerREADME.md +++ b/famodel/irma/schedulerREADME.md @@ -1,6 +1,6 @@ # Scheduler Mathematical Formulation (as implemented in scheduler.py) -This document describes the mathematical formulation of the scheduling problem solved by the `Scheduler` class, using multiple decision variables and following the numbering and naming conventions in `scheduler.py`. Incomplete constraints are marked as **TODO**. +This document describes the mathematical formulation of the scheduling problem solved by the `Scheduler` class, using multiple decision variables and following the numbering and naming conventions in `scheduler.py`. ## Sets and Indices - $T$: Set of tasks, $t = 0, \ldots, T-1$ @@ -15,13 +15,14 @@ This document describes the mathematical formulation of the scheduling problem s ## Decision Variables - $X_{t,a} \in \{0,1\}$: 1 if task $t$ is assigned to asset $a$, 0 otherwise - $X_{t,p} \in \{0,1\}$: 1 if task $t$ is active in period $p$, 0 otherwise -- $X_{a,p} \in \{0,1\}$: 1 if asset $a$ is used in period $p$, 0 otherwise - $X_{t,s} \in \{0,1\}$: 1 if task $t$ starts at period $s$, 0 otherwise +$x = [X_{t,a} X_{t,p} X_{t,s}] $ + ## Objective Function Minimize total cost (cost is only determined by task-asset assignment): $$ -\min \sum_{t=0}^{T-1} \sum_{a=0}^{A-1} c_{t,a} X_{t,a} +\min \sum c_{t,a} x $$ ## Constraints From 7f38a21e912add2e55f85f0d9b1bd7efe1fe4d1e Mon Sep 17 00:00:00 2001 From: Stein Date: Thu, 23 Oct 2025 12:48:46 -0600 Subject: [PATCH 37/63] Updates to schedulerREADME to reflect new changes - Finalizing updates to all the new constraints with a write up in the scheduler README, in order - Deleted all of Ryan's old constraints that I figured didn't apply any more (either didn't make sense, or were redundant) - Kept the numbering scheme the same for now - Need to address a problem in the future (probably) regarding the duration of tasks that have the same asset --- famodel/irma/scheduler.py | 191 +------------------------------- famodel/irma/schedulerREADME.md | 156 +++++++++++++++----------- 2 files changed, 92 insertions(+), 255 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 3e7ab4b3..ca2be3a8 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -269,37 +269,6 @@ def set_up_optimizer(self, goal : str = "cost"): b_eq_list = [] b_lb_list = [] - # 0) Total number of assignments needs to be less than or equal to the number of periods available - ''' - the sum of the total amount of periods assigned to tasks cannot be more than the total number of periods available: - (Xtp_00 + ... + Xtp_TP) <= P - ''' - - # TODO: I dont know if this is necessary - - """ - # 1 row - A_ub_0 = np.zeros((1, num_variables), dtype=int) # Every period assigned to a task counts as 1 towards the total assigned periods. This assumes one pair per period - b_ub_0 = np.array([self.P], dtype=int) - - # Set the coefficients for the Xtp variables to 1 - A_ub_0[0, self.Xtp_start:self.Xtp_end] = 1 - - if wordy > 1: - print("A_ub_0^T:") - for i in range(self.Xtp_start, self.Xtp_end): - pstring = str(self.X_indices[i]) - for column in A_ub_0.transpose()[i]: - pstring += f"{ column:5}" - print(pstring) - print("b_ub_0: ", b_ub_0) - - A_ub_list.append(A_ub_0) - b_ub_list.append(b_ub_0) - - if wordy > 0: - print("Constraint 0 built.") - """ # 1) asset can only be assigned to a task if asset is capable of performing the task (value of pairing is non-negative) ''' if task t cannot be performed by asset a, then Xta_ta = 0 @@ -541,13 +510,13 @@ def set_up_optimizer(self, goal : str = "cost"): # (This is automatic for binary variables but explicit for clarity) A_ub_4 = np.zeros((self.A * self.P, num_variables), dtype=int) b_ub_4 = np.ones(self.A * self.P, dtype=int) - + ''' row = 0 for a in range(self.A): for p in range(self.P): A_ub_4[row, self.Xap_start + a * self.P + p] = 1 # Xap[a,p] ≤ 1 row += 1 - + ''' # Add temporal conflict prevention for tasks that could share assets # For each asset that multiple tasks could use, add constraints to prevent # simultaneous usage by different tasks @@ -604,129 +573,6 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 4 built.") - # 5) Every task must be assigned to at least one time period - ''' - Sum of all task-period pairs for each task must be >= 1: - - (Xtp_00 + ... + Xtp_0P) >= 1 # for task 0 - (Xtp_10 + ... + Xtp_1P) >= 1 # for task 1 - ... - (Xtp_T0 + ... + Xtp_TP) >= 1 # for task T - ''' - # NOTE: Constraint 5 is redundant with Constraint 16, which provides exact duration matching - """ - A_lb_5 = np.zeros((self.T, num_variables), dtype=int) - b_lb_5 = np.ones(self.T, dtype=int) - - for t in range (self.T): - # set the coefficient for each task t to one - A_lb_5[t, (self.Xtp_start + t * self.P):(self.Xtp_start + t * self.P + self.P)] = 1 # Set the coefficients for the Xtp variables to 1 for each task t - - if wordy > 1: - print("A_lb_5^T:") - print(" T1 T2") # Header for 2 tasks - for i in range(self.Xtp_start,self.Xtp_end): - pstring = str(self.X_indices[i]) - for column in A_lb_5.transpose()[i]: - pstring += f"{ column:5}" - print(pstring) - print("b_lb_5: ", b_lb_5) - - A_lb_list.append(A_lb_5) - b_lb_list.append(b_lb_5) - - if wordy > 0: - print("Constraint 5 built.") - """ - # 6) The total number of assets assigned cannot be greater than the number of assets available but must be greater than the number of tasks. - ''' - Sum of all asset-period pairs must be >= T: - - A >= (Xap_00 + ... + Xap_AP) >= T - ''' - """ - A_6 = np.zeros((1, num_variables), dtype=int) - b_lb_6 = np.array([self.T], dtype=int) - b_ub_6 = np.array([self.A], dtype=int) - - A_6[0,self.Xap_start:self.Xap_end] = 1 - - if wordy > 1: - print("A_6^T:") - for i in range(self.Xap_start,self.Xap_end): - pstring = str(self.X_indices[i]) - for column in A_6.transpose()[i]: - pstring += f"{ column:5}" - print(pstring) - print("b_lb_6: ", b_lb_6) - print("b_ub_6: ", b_ub_6) - - A_lb_list.append(A_6) - b_lb_list.append(b_lb_6) - A_ub_list.append(A_6) - b_ub_list.append(b_ub_6) - - if wordy > 0: - print("Constraint 6 built.") - - # 7) Ensure tasks are assigned as early as possible - ''' - A task cannot be assigned if it could have been assigned in an earlier period. - This encourages the solver to assign tasks to the earliest possible periods. - - Practical implementation: Rather than hard constraints (which can cause infeasibility), - we add this preference to the objective function by giving later start times - higher costs. This encourages early scheduling without making the problem infeasible. - ''' - - # No hard constraints for Constraint 7 - implemented in objective function - # The preference for earlier start times will be added as small penalties - # in the objective function coefficients for Xts variables - - if wordy > 0: - print("Constraint 7 built (implemented as objective function preference).") - - """ - # 7) Ensure tasks are assigned as early as possible - ''' - A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. - ''' - - # 8) All tasks must be assigned to at least one time period - ''' - - The sum of all task-period decision variables for each task must be greater than 1, indicating all tasks were assigned at least once: - - (Xtp_00 + ... + Xtp_0P) >= 1 # for task 0 - (Xtp_10 + ... + Xtp_1P) >= 1 # for task 1 - ... - (Xtp_T0 + ... + Xtp_TP) >= 1 # for task T - ''' - """ - # num_tasks rows - A_lb_8 = np.zeros((self.T, num_variables), dtype=int) - b_lb_8 = np.ones(self.T, dtype=int) - - A_lb_8[:,self.Xtp_start:self.Xtp_end] = 1 - - if wordy > 1: - print("A_lb_8^T:") - print(" T1 T2") # Header for 2 tasks - for i in range(self.Xtp_start,self.Xtp_end): - pstring = str(self.X_indices[i]) - for column in A_lb_8.transpose()[i]: - pstring += f"{ column:5}" - print(pstring) - print("b_lb_8: ", b_lb_8) - - A_lb_list.append(A_lb_8) - b_lb_list.append(b_lb_8) - - if wordy > 0: - print("Constraint 8 built.") - """ - # 9) TODO: Empty constraint: fill me in later - # 10) A task duration plus the start-time it is assigned to must be less than the total number of time periods available ''' This ensures that a task is not assigned to a period that would cause it to exceed the total number of periods available. @@ -872,36 +718,6 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 12 built.") - # 13) The total number of asset period pairs must be greater than or equal to the number of task-period pairs - ''' - This ensures that the 0 asset-period pairs solution is not selected - - (Xap_00 + ... + Xap_AP) >= (Xtp_00 + ... + Xtp_TP) # for all periods p in range(0:P) - ''' - """ - A_lb_13 = np.zeros((self.P, num_variables), dtype=int) - b_lb_13 = np.ones(self.P, dtype=int) * 2 - - for p in range(self.P): - A_lb_13[p, (self.Xap_start + p * self.A):(self.Xap_start + p * self.A + self.A)] = 1 - A_lb_13[p, (self.Xtp_start + p):(self.Xtp_start + p + self.P)] = 1 - - if wordy > 1: - print("A_lb_13^T:") - print(" P1 P2 P3 P4 P5") # Header for 5 periods - for i in range(self.Xtp_start,self.Xap_end): - pstring = str(self.X_indices[i]) - for column in A_lb_13.transpose()[i]: - pstring += f"{ column:5}" - print(pstring) - print("b_lb_13: ", b_lb_13) - - A_lb_list.append(A_lb_13) - b_lb_list.append(b_lb_13) - - if wordy > 0: - print("Constraint 13 built.") - """ # 14) if a task-starttime pair is selected, the corresponding task-period pair must be selected for the period equal to the start time plus the duration of the task ''' This ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time plus the duration of the task is also selected. @@ -997,9 +813,6 @@ def set_up_optimizer(self, goal : str = "cost"): print(pstring) print("b_lb_14: ", b_ub_14) - #A_lb_list.append(A_lb_14) - #b_lb_list.append(b_lb_14) - if wordy > 0: print("Constraint 14 built.") diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md index dc5825ed..1259622a 100644 --- a/famodel/irma/schedulerREADME.md +++ b/famodel/irma/schedulerREADME.md @@ -24,6 +24,7 @@ Minimize total cost (cost is only determined by task-asset assignment): $$ \min \sum c_{t,a} x $$ +The $c$ vector also contains 'cost' penalties for later start times $(X_{t,s})$ to prioritize tasks starting as early as they can (used to be Constraint 7) ## Constraints @@ -39,113 +40,136 @@ $$ \text{4) } 0 \leq \text{ } x \text{ } \leq 1 \\ $$ -### 0. Total Assignment Limit -The sum of all task-period assignments cannot exceed the number of periods: -$$ -\sum_{t=0}^{T-1} \sum_{p=0}^{P-1} X_{t,p} \leq P -$$ - ### 1. Task-Asset Validity Only valid task-asset pairs can be assigned: $$ X_{t,a} = 0 \quad \forall t, a \text{ where } c_{t,a} < 0 \text{ or } d_{t,a} < 0 $$ -### 2. Task Dependencies (**TODO**) -Tasks with dependencies must be scheduled after their dependencies are completed. Thus the starttime of a task must be greater than the end time of all the dependent tasks. - -The general idea is that $X_{tp}(d,p) < X_{t,s}(t,s)$ where $d$ is the task that task $t$ is dependent on and where $p = s-1$ - ### 3. At Least One Asset Per Task Sum of all task-asset pairs must be >= 1 for each task: $$ \sum_{a=0}^{A-1} X_{t,a} \geq 1 \quad \forall t $$ -### 4. Asset Cannot Be Assigned to Multiple Tasks in Same Period -This means the sum of each asset-period pair in a given period must be less or equal to than 1. This prohibits multiple assets in a period. +### 15. The number of task-starttime pairs must be equal to the number of tasks +This ensures that each task is assigned exactly 1 start time. $$ -\sum_{t=0}^{A-1} X_{a,p} \leq 1 \quad \forall p +\sum_{s=0}^{S-1} X_{t,s} = 1 \quad \forall t $$ +### 10. A task cannot start in a period where its duration would exceed the maximum number of time periods +This ensures that a task is not assigned to a period that would cause it to exceed the total number of periods available. -### 5. Every task must be assigned to at least one time period -Sum of all task-period pairs for each task must be >= 1: $$ -\sum_{p=0}^{P-1} X_{t,p} \geq 1 \quad \forall t +X_{t,a}[t,a] + X_{t,s} <= 1 \quad \forall t, a, s \text{ where } d_{t,a} > 0,\ s + d_{t,a} > P $$ -### 6. The total number of assets assigned cannot be greater than the number of assets available but must be greater than the number of tasks. -Sum of all asset-period pairs must be >= T: +When a task-asset pair is assigned, then for each start time of that task, it $(X_{t,s})$ has to be zero under these conditions, where s+d>P + +### 14a. A task must occupy the same period that it starts in +This ensures that the task start-time decision variable is non-zero if a task is assigned to any period. $$ -T \leq \sum_{a=0}^{A-1} \sum_{p=0}^{P-1} X_{a,p} \leq A +X_{t,p}[t,s] \geq X_{t,s}[t,s] \quad \forall t $$ -### 7. Early Assignment Constraint (**TODO**) -A task cannot be assigned if it could have been assigned in an earlier period. This encourages the solver to assign tasks to the earliest possible periods. -This could be enforced with a penality multiplied by the Xts decision variable in the objective function. +In every start time for each task, the corresponding period must be equal to that start time decision variable -### 8. All Tasks Must Be Assigned to At Least One Period -The sum of all task-period decision variables for each task must be greater than 1, indicating all tasks were assigned at least once: -$$ -\sum_{p=0}^{P-1} X_{t,p} \geq 1 \quad \forall t -$$ +### 14b. A task-asset assignment must be active for the duration required by that assignment -### 9. Empty +14a ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time is selected. -### 10. A task duration plus the start-time it is assigned to must be less than the total number of time periods available -This ensures that a task is not assigned to a period that would cause it to exceed the total number of periods available. -$$ -X_{t,s} = 0 \quad \forall t, a, s \text{ where } d_{t,a} > 0,\ s + d_{t,a} > P -$$ +14b ensures that if a task is assigned a start time, the number of periods equal to the duration of the task are also turned on. -Note: this constraint is working, but $X_{t,p}$ is not currently being forced to match $X_{t,s}$ so it is not reflected in the final results (which check for $X_{tp} \neq 0$). If you look at the results generated you will see the $X_{t,s}$ decision variable respects this constraint, but the $X_{t,p}$ does not. Constraint 14 aims to force $X_{t,p}$ to start blocks of time assignments at $X_{t,s}$. +$ X_{t,a}[t,a] + X_{t,s}[t,s] - X_{t,p}[t,p] <= 1 \quad \forall t,a,s,p(s asset[a]['max_weather'], then $X_{t,a}[t,a] + X_{t,p}[t,p] <= 1$ + +Meaning, do not turn on the task in a period whether the period's weather is greater than the maximum allowable weather capability of the asset + +### 2. Task Dependencies +Tasks with dependencies must be scheduled according to their dependency rules. + +We have a set of different dependency type options: +- Finish-Start: the dependent task starts after the prerequisite task finishes +- Start-Start: the dependent task starts when the prerequisite task starts +- Finish-Finish: the dependent task finishes when the prerequisite task finishes +- Same-Asset: the dependent task must use the same asset as the prerequisite task + +For all valid start times s for task t, if $ X_{t,s}[t,s]=1 $, then there is some other start time $s_d$ for task d so that $ X_{t,s}[d,s_d]=1 $ and $s_d + duration <= s$ + +$ X_{t,s}[t,s] <= \sum X_{t,s}[d,s_d] $ from $s$ to $sd+duration$ + --- **Notes:** - $d_{t,a}$ is the duration for asset $a$ assigned to task $t$. If multiple assets are possible, $X_{t,a}$ determines which duration applies. - This approach separates assignment, activity, and start variables for clarity and easier constraint management. -- Constraints marked **TODO** are not yet implemented in the code but are probably necessary for a truely opptimal solution. -- Constraints marked **In-progress** have code written but it is not yet working -- Constraints not marked **TODO** or **In-progress** are complete + - Constraints can be extended for parallel tasks, multiple assets per task, or other requirements as needed. - One of the better references to understand this approach is `Irwan et al. 2017 `_ - The `scheduler.py` file also has some TODO's, which are focused on software development. \ No newline at end of file From 5759f80b05de110c40499a656cd5bd628bae9c9b Mon Sep 17 00:00:00 2001 From: Matt Hall Date: Fri, 24 Oct 2025 11:00:44 -0600 Subject: [PATCH 38/63] Editing schedulerREADME.md so equations render on GitHub --- famodel/irma/schedulerREADME.md | 43 +++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md index 1259622a..bfbd6905 100644 --- a/famodel/irma/schedulerREADME.md +++ b/famodel/irma/schedulerREADME.md @@ -21,9 +21,11 @@ $x = [X_{t,a} X_{t,p} X_{t,s}] $ ## Objective Function Minimize total cost (cost is only determined by task-asset assignment): + $$ \min \sum c_{t,a} x $$ + The $c$ vector also contains 'cost' penalties for later start times $(X_{t,s})$ to prioritize tasks starting as early as they can (used to be Constraint 7) ## Constraints @@ -33,6 +35,7 @@ When added together, these are the upperbound constraint, the lower bound constr attempts to solve the object objective function subject to: subject to: + $$ \text{1) } A_{ub} \text{ } x \text{ } \leq b_{ub} \\ \text{2) } A_{eq} \text{ } x \text{ } = b_{eq} \\ @@ -42,18 +45,21 @@ $$ ### 1. Task-Asset Validity Only valid task-asset pairs can be assigned: + $$ X_{t,a} = 0 \quad \forall t, a \text{ where } c_{t,a} < 0 \text{ or } d_{t,a} < 0 $$ ### 3. At Least One Asset Per Task Sum of all task-asset pairs must be >= 1 for each task: + $$ \sum_{a=0}^{A-1} X_{t,a} \geq 1 \quad \forall t $$ ### 15. The number of task-starttime pairs must be equal to the number of tasks This ensures that each task is assigned exactly 1 start time. + $$ \sum_{s=0}^{S-1} X_{t,s} = 1 \quad \forall t $$ @@ -69,6 +75,7 @@ When a task-asset pair is assigned, then for each start time of that task, it $( ### 14a. A task must occupy the same period that it starts in This ensures that the task start-time decision variable is non-zero if a task is assigned to any period. + $$ X_{t,p}[t,s] \geq X_{t,s}[t,s] \quad \forall t $$ @@ -77,21 +84,19 @@ In every start time for each task, the corresponding period must be equal to tha ### 14b. A task-asset assignment must be active for the duration required by that assignment -14a ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time is selected. +14a ensured that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time is selected. 14b ensures that if a task is assigned a start time, the number of periods equal to the duration of the task are also turned on. -$ X_{t,a}[t,a] + X_{t,s}[t,s] - X_{t,p}[t,p] <= 1 \quad \forall t,a,s,p(s`_ -- The `scheduler.py` file also has some TODO's, which are focused on software development. \ No newline at end of file +- The `scheduler.py` file also has some TODO's, which are focused on software development. From 9e045445d2993fd422d1d987a52b510bece836d8 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Fri, 24 Oct 2025 12:01:59 -0600 Subject: [PATCH 39/63] mobilize duration hard-coded based on vessel used, sequence yaml file ability in task, assign asset for task 1 instead of just evaluating them: - mobizilize calcDuration is hardcoded based on the vessel used. - extract sequence yaml file from task.py so the user can edit things like durations. - update from the sequence yaml in task.py to enable user-defined durations. - changing site_distance_m to route_length_m to avoid confusion. - calwave main task1 example assigns assets to actions instead of just evaluating them. --- famodel/irma/calwave_action.py | 28 ++++- famodel/irma/calwave_chart.py | 2 +- famodel/irma/calwave_task.py | 109 +++++++++++++++++- .../{task1_calwave.py => calwave_task1.py} | 66 +++++------ famodel/irma/calwave_vessels.yaml | 8 +- 5 files changed, 159 insertions(+), 54 deletions(-) rename famodel/irma/{task1_calwave.py => calwave_task1.py} (76%) diff --git a/famodel/irma/calwave_action.py b/famodel/irma/calwave_action.py index 992c7998..61a13126 100644 --- a/famodel/irma/calwave_action.py +++ b/famodel/irma/calwave_action.py @@ -868,9 +868,29 @@ def calcDurationAndCost(self): # --- Mobilization --- if self.type == 'mobilize': - pass + # Hard-coded example of mobilization times based on vessel type + durations = { + 'crane_barge': 3.0, + 'research_vessel': 1.0 + } + for role_name, vessel in self.assets.items(): + vessel_type = vessel['type'].lower() + for key, duration in durations.items(): + if key in vessel_type: + self.duration += duration + break + elif self.type == 'demobilize': - pass + # Hard-coded example of demobilization times based on vessel type + durations = { + 'crane_barge': 3.0, + 'research_vessel': 1.0 + } + for role_name, vessel in self.assets.items(): + vessel_type = vessel['type'].lower() + for key, duration in durations.items(): + if key in vessel_type: + self.duration += duration elif self.type == 'load_cargo': pass @@ -893,7 +913,7 @@ def calcDurationAndCost(self): tr = vessel['transport'] # distance - dist_m = float(tr['site_distance_m']) + dist_m = float(tr['route_length_m']) # speed: linehaul uses transport.cruise_speed_mps speed_mps = float(tr['cruise_speed_mps']) @@ -925,7 +945,7 @@ def calcDurationAndCost(self): tr_t = tug.get('transport', {}) # distance: prefer barge’s transport - dist_m = float(tr_b.get('site_distance_m', tr_t['site_distance_m'])) + dist_m = float(tr_b.get('route_length_m', tr_t['route_length_m'])) # speed for convoy linehaul: barge (operator) cruise speed operator = self.assets.get('operator') or self.assets.get('vessel') diff --git a/famodel/irma/calwave_chart.py b/famodel/irma/calwave_chart.py index 0b473689..69a556db 100644 --- a/famodel/irma/calwave_chart.py +++ b/famodel/irma/calwave_chart.py @@ -84,7 +84,7 @@ def view_from_task(sched_task, sc, title: str | None = None): if dur <= 0.0: continue - aa = getattr(a, 'assigned_assets', {}) or {} + aa = getattr(a, 'assets', {}) or {} # collect ALL candidate roles → multiple lanes allowed lane_keys = set() diff --git a/famodel/irma/calwave_task.py b/famodel/irma/calwave_task.py index e07ab4b7..dcc47428 100644 --- a/famodel/irma/calwave_task.py +++ b/famodel/irma/calwave_task.py @@ -7,14 +7,17 @@ """ from collections import defaultdict +import yaml class Task: - def __init__(self, actions, action_sequence, **kwargs): + def __init__(self, name, actions, action_sequence, **kwargs): ''' Create a Task from a list of actions and a dependency map. Parameters ---------- + name : str + Name of the task. actions : list All Action objects that are part of this task. action_sequence : dict or None @@ -32,6 +35,7 @@ def __init__(self, actions, action_sequence, **kwargs): resource_roles = kwargs.get('resource_roles', ('vessel', 'carrier', 'operator')) # ---- core storage ---- + self.name = name self.actions = {a.name: a for a in actions} # allow None → infer solely from Action.dependencies self.action_sequence = {k: list(v) for k, v in (action_sequence or {}).items()} @@ -41,12 +45,13 @@ def __init__(self, actions, action_sequence, **kwargs): self.ti = 0.0 self.tf = 0.0 self.resource_roles = tuple(resource_roles) - + self.enforce_resources = enforce_resources + self.strategy = strategy # ---- scheduling ---- - if strategy == 'levels': + if self.strategy == 'levels': self._schedule_by_levels() else: - self._schedule_by_earliest(enforce_resources=enforce_resources) + self._schedule_by_earliest(enforce_resources=self.enforce_resources) # ---- roll-ups ---- self.cost = sum(float(getattr(a, 'cost', 0.0) or 0.0) for a in self.actions.values()) @@ -66,7 +71,7 @@ def _names_from_dependencies(a): return clean @classmethod - def from_scenario(cls, sc, **kwargs): + def from_scenario(cls, sc, name, **kwargs): actions = list(sc.actions.values()) base = {a.name: cls._names_from_dependencies(a) for a in actions} extra = kwargs.pop('extra_dependencies', None) or {} @@ -75,7 +80,7 @@ def from_scenario(cls, sc, **kwargs): for d in v: if d != k and d not in base[k]: base[k].append(d) - return cls(actions=actions, action_sequence=base, **kwargs) + return cls(name=name, actions=actions, action_sequence=base, **kwargs) # --------------------------- Resource & Scheduling --------------------------- @@ -224,3 +229,95 @@ def level_of(a, path): a.period = (a.start_hr, a.end_hr) a.label_time = f'{dur:.1f}' self.tf = self.ti + self.duration + + def extractSeqYaml(self, output_file=None): + """ + Extract the sequence of actions into a YAML file for user editing. + + Args: + output_file (str): The name of the output YAML file. + """ + # Write the sequence data to a YAML file + if output_file is None: + output_file = f"{self.name}_sequence.yaml" + + # Build the YAML: + task_data = [] + for action_name, action in self.actions.items(): + roles = list(action.requirements.keys()) + deps = list(action.dependencies.keys()) + asset_types = [] + for role, asset in action.assets.items(): + asset_types.append(asset['type']) + + entry = { + 'action': action_name, + 'duration': round(float(action.duration), 2), + 'roles': roles, + 'assets': asset_types, + 'dependencies': deps, + } + task_data.append(entry) + + yaml_dict = {self.name: task_data} + + with open(output_file, 'w') as yaml_file: + yaml.dump(yaml_dict, yaml_file, sort_keys=False) + + print(f"Task sequence YAML file generated: {output_file}") + + def update_from_SeqYaml(self, input_file=None): + """ + Update the Task object based on a user-edited YAML file. + + Args + ---- + input_file : str, optional + The name of the YAML file (default: _sequence.yaml). + """ + if input_file is None: + input_file = f"{self.name}_sequence.yaml" + + # Load YAML content + with open(input_file, "r") as yaml_file: + seq_data = yaml.safe_load(yaml_file) + + if self.name not in seq_data: + raise ValueError(f"Task name '{self.name}' not found in YAML file.") + + updated_actions = seq_data[self.name] + + # Reset internal attributes + self.actions_ti = {} + self.duration = 0.0 + self.cost = 0.0 + self.ti = 0.0 + self.tf = 0.0 + + # Update each action from YAML + for entry in updated_actions: + a_name = entry["action"] + if a_name not in self.actions: + print(f"Skipping unknown action '{a_name}' (not in current task).") + continue + + a = self.actions[a_name] + + # Update action duration + a.duration = float(entry.get("duration", getattr(a, "duration", 0.0))) + + # TODO: Update dependencies + # TODO: Update roles + # TODO: Update assets + # TODO: Update cost + + # ---- re-scheduling ---- + if self.strategy == 'levels': + self._schedule_by_levels() + else: + self._schedule_by_earliest(enforce_resources=self.enforce_resources) + + # ---- re-roll-ups ---- + self.cost = sum(float(getattr(a, 'cost', 0.0) or 0.0) for a in self.actions.values()) + + print(f"Task '{self.name}' successfully updated from YAML file: {input_file}") diff --git a/famodel/irma/task1_calwave.py b/famodel/irma/calwave_task1.py similarity index 76% rename from famodel/irma/task1_calwave.py rename to famodel/irma/calwave_task1.py index 92d98007..8163f2fc 100644 --- a/famodel/irma/task1_calwave.py +++ b/famodel/irma/calwave_task1.py @@ -11,24 +11,6 @@ sc = Scenario() # now sc exists in *this* session -def eval_set(a, roles, duration=None, **params): - """ - Convenience: call evaluateAssets with roles/params and optionally set .duration. - Always stores assigned_assets for plotting/scheduling attribution. - """ - # Your Action.evaluateAssets may return (duration, cost); we still set explicit duration if passed. - res = a.evaluateAssets(roles | params) - if duration is not None: - a.duration = float(duration) - elif isinstance(res, tuple) and len(res) > 0 and res[0] is not None: - try: - a.duration = float(res[0]) - except Exception: - pass - # keep roles visible on the action - a.assigned_assets = roles - return a - # ---------- Core builder ---------- def build_task1_calwave(sc: Scenario, project: Project): """ @@ -144,8 +126,8 @@ def build_task1_calwave(sc: Scenario, project: Project): 'linehaul_to_home': [linehome_convoy, linehome_by], 'demobilize': [demob_sd, demob_by]} -# ---------- Evaluation step (assign vessels & durations) ---------- -def evaluate_task1(sc: Scenario, actions: dict): +# ---------- Assignment step (assign vessels & durations) ---------- +def assign_actions(sc: Scenario, actions: dict): """ Assign vessels/roles and set durations where the evaluator doesn't. Keeps creation and evaluation clearly separated. @@ -153,38 +135,38 @@ def evaluate_task1(sc: Scenario, actions: dict): V = sc.vessels # shorthand # Mobilize - eval_set(actions['mobilize'][0], {'operator': V['San_Diego']}, duration=3.0) - eval_set(actions['mobilize'][1], {'operator': V['Beyster']}, duration=1.0) - + actions['mobilize'][0].assignAssets({'operator': V['San_Diego']}) + actions['mobilize'][1].assignAssets({'operator': V['Beyster']}) + # Transit to site convoy_to_site, beyster_to_site = actions['linehaul_to_site'] - eval_set(convoy_to_site, {'carrier': V['Jag'], 'operator': V['San_Diego']}) - eval_set(beyster_to_site, {'vessel': V['Beyster']}) + convoy_to_site.assignAssets({'carrier': V['Jag'], 'operator': V['San_Diego']}) + beyster_to_site.assignAssets({'vessel': V['Beyster']}) # Onsite convoy (tug+barge) for a_tug in actions['onsite_tug']: - eval_set(a_tug, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + a_tug.assignAssets({'carrier': V['Jag'], 'operator': V['San_Diego']}) # Install (Jag carries, San_Diego operates the install) for a_inst in actions['install']: - eval_set(a_inst, {'carrier': V['Jag'], 'operator': V['San_Diego']}) + a_inst.assignAssets({'carrier': V['Jag'], 'operator': V['San_Diego']}) # Onsite self-propelled (Beyster) for a_by in actions['onsite_by']: - eval_set(a_by, {'vessel': V['Beyster']}) + a_by.assignAssets({'vessel': V['Beyster']}) # Monitor (Beyster as support) for a_mon in actions['monitor']: - eval_set(a_mon, {'support': V['Beyster']}) + a_mon.assignAssets({'support': V['Beyster']}) # Transit to home convoy_to_home, beyster_to_home = actions['linehaul_to_home'] - eval_set(convoy_to_home, {'carrier': V['Jag'], 'operator': V['San_Diego']}) - eval_set(beyster_to_home, {'vessel': V['Beyster']}) + convoy_to_home.assignAssets({'carrier': V['Jag'], 'operator': V['San_Diego']}) + beyster_to_home.assignAssets({'vessel': V['Beyster']}) # Demobilize - eval_set(actions['demobilize'][0], {'operator': V['San_Diego']}, duration=3.0) - eval_set(actions['demobilize'][1], {'operator': V['Beyster']}, duration=1.0) + actions['demobilize'][0].assignAssets({'operator': V['San_Diego']}) + actions['demobilize'][1].assignAssets({'operator': V['Beyster']}) if __name__ == '__main__': @@ -198,19 +180,25 @@ def evaluate_task1(sc: Scenario, actions: dict): # 3) Build (structure only) actions = build_task1_calwave(sc, project) - # 4) Evaluate (assign vessels/roles + durations) - evaluate_task1(sc, actions) + # 4) Assign (assign vessels/roles) + assign_actions(sc, actions) # 5) schedule once, in the Task calwave_task1 = Task.from_scenario( sc, - strategy='levels', # or 'levels' + name='calwave_task1', + strategy='earliest', # 'earliest' or 'levels' enforce_resources=False, # keep single-resource blocking if you want it resource_roles=('vessel', 'carrier', 'operator')) - - # 6) build the chart input directly from the Task and plot + + # 6) Extract Task1 sequencing info + # calwave_task1.extractSeqYaml() + + # 7) update Task1 if needed + calwave_task1.update_from_SeqYaml() # uncomment to re-apply sequencing from YAML + # 8) build the chart input directly from the Task and plot chart_view = chart.view_from_task(calwave_task1, sc, title='CalWave Task 1 - Anchor installation plan') - chart.plot_task(chart_view) + chart.plot_task(chart_view, outpath='calwave_task1_chart.png') diff --git a/famodel/irma/calwave_vessels.yaml b/famodel/irma/calwave_vessels.yaml index 26e4f5d4..ee508854 100644 --- a/famodel/irma/calwave_vessels.yaml +++ b/famodel/irma/calwave_vessels.yaml @@ -5,7 +5,7 @@ San_Diego: type: crane_barge transport: homeport: national_city - site_distance_m: 41114 # distance to site + route_length_m: 41114 # distance to site cruise_speed_mps: 2.5 # ~5 kts, from doc Hs_m: 3 station_keeping: @@ -43,7 +43,7 @@ Jag: type: tug transport: homeport: national_city - site_distance_m: 41114 # distance to site + route_length_m: 41114 # distance to site cruise_speed_mps: 3.1 # Hs_m: 3.5 station_keeping: @@ -70,7 +70,7 @@ Beyster: type: research_vessel transport: homeport: point_loma - site_distance_m: 30558 # distance to site + route_length_m: 30558 # distance to site cruise_speed_mps: 12.9 # 25 kts cruise, from doc Hs_m: 2.5 station_keeping: @@ -109,7 +109,7 @@ Beyster: # type: research_vessel # transport: # homeport: sio_pier - # site_distance_m: 555 # distance to site + # route_length_m: 555 # distance to site # transit_speed_mps: 10.3 # ~20 kts cruise # Hs_m: 1.5 # station_keeping: From 5ff399fc7c8b3e1fcdf315ac631fd0353267375d Mon Sep 17 00:00:00 2001 From: Stein Date: Tue, 28 Oct 2025 15:40:48 -0600 Subject: [PATCH 40/63] Adding 'asset_group' capability to the scheduler - Eliminated the lower bound constraint for Constraint 3, which allowed multiple assets to be assigned to the same task - - It is now an equality constraint, where each asset (column in the task-asset matrix) corresponds to an 'Asset Group', which is a combination of assets that is used to perform each task - - This prevents multiple asset groups from being used by the same task, but this means that many asset groups need to be defined to cover all asset combination possibilities - - - Another script is in the works to generate the task-asset matrix based on asset capabilities and task requirements, but for now, we can just look at the example at the bottom of the scheduler.py script - We initialize new asset groups, which generate dictionaries in '_initialize_asset_groups', which are now used by Constraint 4 to ensure that the same asset used in different asset groups is not used by multiple tasks in the same time period - A small print help - Initial commit for the scheduler_tutorial.ipynb to detail how the scheduler works --- famodel/irma/scheduler.py | 247 ++++++++++++++-------- scheduler_tutorial.ipynb | 427 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 585 insertions(+), 89 deletions(-) create mode 100644 scheduler_tutorial.ipynb diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index ca2be3a8..42cd86d7 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -29,19 +29,19 @@ class Scheduler: # Inputs are strictly typed, as this is an integer programming problem (ignored by python at runtime, but helpful for readability and syntax checking). - def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, weather : list[int] = [], period_duration : float = 1, **kwargs): + def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, weather : list[int] = [], period_duration : float = 1, asset_groups : list[dict] = [], **kwargs): ''' Initializes the Scheduler with assets, tasks, and constraints. Inputs ------ task_asset_matrix : array-like - A 3D array of (cost, duration) tuples indicating the cost and duration for each asset to perform each task. - Must be len(tasks) x len(assets) x 2. NOTE: The duration must be in units of scheduling periods (same as weather period length). + A 3D array of (cost, duration) tuples indicating the cost and duration for each asset group to perform each task. + Must be len(tasks) x len(asset_groups) x 2. NOTE: The duration must be in units of scheduling periods (same as weather period length). tasks : list A list of Task objects to be scheduled. assets : list - A list of Asset objects to be scheduled. + A list of individual Asset objects. Used for pre-processing and conflict detection within asset groups. task_dependencies : dict A dictionary mapping each task to a list of its dependencies. dependency_types : dict @@ -56,6 +56,10 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset A list of weather windows. The length of this list defines the number of discrete time periods available for scheduling. period_duration : float The duration of each scheduling period. Used for converting from periods to real time. + asset_groups : list[dict] + A list of dictionaries defining asset groups. Each dictionary maps group names to lists of individual asset names. + Example: [{'group1': ['asset_0']}, {'group2': ['asset_0', 'asset_1']}] + The task_asset_matrix dimensions must match len(asset_groups). kwargs : dict Additional keyword arguments for future extensions. @@ -69,7 +73,8 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset self.task_asset_matrix = task_asset_matrix self.tasks = tasks - self.assets = assets + self.assets = assets # Individual assets for conflict detection + self.asset_groups = asset_groups # Asset groups for scheduling self.weather = weather self.task_dependencies = task_dependencies self.dependency_types = dependency_types @@ -77,21 +82,21 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset # --- Check for valid inputs --- - # check for valid task_asset_matrix dimensions (must be len(tasks) x len(assets) x 2) - if self.task_asset_matrix.ndim != 3 or self.task_asset_matrix.shape[0] != len(self.tasks) or self.task_asset_matrix.shape[1] != len(self.assets) or self.task_asset_matrix.shape[2] != 2: - raise ValueError("task_asset_matrix must be a 3D array with shape (len(tasks), len(assets), 2).") + # check for valid task_asset_matrix dimensions (must be len(tasks) x len(asset_groups) x 2) + if self.task_asset_matrix.ndim != 3 or self.task_asset_matrix.shape[0] != len(self.tasks) or self.task_asset_matrix.shape[1] != len(self.asset_groups) or self.task_asset_matrix.shape[2] != 2: + raise ValueError(f"task_asset_matrix must be a 3D array with shape (len(tasks), len(asset_groups), 2). Expected: ({len(self.tasks)}, {len(self.asset_groups)}, 2), got: {self.task_asset_matrix.shape}") # check for integer matrix, try to correct if self.task_asset_matrix.dtype != np.dtype('int'): try: self.task_asset_matrix = self.task_asset_matrix.astype(int) except: - raise ValueError("task_asset_matrix must be a 3D array of integers with shape (len(tasks), len(assets), 2).") + raise ValueError("task_asset_matrix must be a 3D array of integers with shape (len(tasks), len(asset_groups), 2).") else: print("Input task_asset_matrix was not integer. Converted to integer type.") # check for valid tasks and assets - if not all(isinstance(task, str) for task in self.tasks): + if not all(isinstance(task, str) or isinstance(task, dict) for task in self.tasks): raise ValueError("All elements in tasks must be strings.") if not all(isinstance(asset, dict) for asset in self.assets): raise ValueError("All elements in assets must be dictionaries.") @@ -107,23 +112,64 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset # --- Process inputs --- self.T = len(self.tasks) - self.A = len(self.assets) + self.A = len(self.asset_groups) # A now represents number of asset groups self.P = len(weather) # number of scheduling periods self.S = self.P # number of start times + + # Initialize asset group mappings for conflict detection + self._initialize_asset_groups() # Checks for negative duration and cost in task_asset_matrix (0 cost and duration permitted) - self.num_valid_ta_pairs = int(np.sum((self.task_asset_matrix[:,:,0] >=0) & (self.task_asset_matrix[:,:,1] >= 0))) # number of valid task-asset pairs (cost and duration >= 0) + self.num_valid_ta_pairs = int(np.sum((self.task_asset_matrix[:,:,0] >=0) & (self.task_asset_matrix[:,:,1] >= 0))) # number of valid task-asset group pairs (cost and duration >= 0) # --- Debug helpers --- # make a list of indices to help with building constraints - self.Xta_indices = [f"Xta_[{t}][{a}]" for t in range(self.T) for a in range(self.A)] + self.Xta_indices = [f"Xtag_[{t}][{ag}]" for t in range(self.T) for ag in range(self.A)] # task-asset group self.Xtp_indices = [f"Xtp_[{t}][{p}]" for t in range(self.T) for p in range(self.P)] - self.Xap_indices = [f"Xap_[{a}][{p}]" for a in range(self.A) for p in range(self.P)] + self.Xap_indices = [f"Xagp_[{ag}][{p}]" for ag in range(self.A) for p in range(self.P)] # asset group-period self.Xts_indices = [f"Xts_[{t}][{s}]" for t in range(self.T) for s in range(self.S)] self.X_indices = self.Xta_indices + self.Xtp_indices + self.Xap_indices + self.Xts_indices if wordy > 0: - print(f"Scheduler initialized with {self.P} time periods, {self.T} tasks, {self.A} assets, and {self.S} start times.") + print(f"Scheduler initialized with {self.P} time periods, {self.T} tasks, {self.A} asset groups, and {self.S} start times.") + + def _initialize_asset_groups(self): + ''' + Initialize asset group mappings for conflict detection. + + Creates mappings to track: + - Which individual assets belong to which asset groups + - Which asset groups each individual asset participates in + - Individual asset name to index mappings + ''' + # Create individual asset name to index mapping + self.individual_asset_name_to_index = {} + for i, asset in enumerate(self.assets): + asset_name = asset.get('name', f'Asset_{i}') + self.individual_asset_name_to_index[asset_name] = i + + # Create mapping: asset_group_id -> list of individual asset indices + self.asset_group_to_individual_assets = {} + # Create mapping: individual_asset_index -> list of asset_group_ids it belongs to + self.individual_asset_to_asset_groups = {i: [] for i in range(len(self.assets))} + + for group_id, group_dict in enumerate(self.asset_groups): + for group_name, individual_asset_names in group_dict.items(): + self.asset_group_to_individual_assets[group_id] = [] + + for asset_name in individual_asset_names: + if asset_name in self.individual_asset_name_to_index: + individual_asset_idx = self.individual_asset_name_to_index[asset_name] + self.asset_group_to_individual_assets[group_id].append(individual_asset_idx) + self.individual_asset_to_asset_groups[individual_asset_idx].append(group_id) + else: + print(f"Warning: Individual asset '{asset_name}' in group '{group_name}' not found in assets list") + + if wordy > 1: + print(f"Asset group mappings initialized:") + for group_id, individual_asset_indices in self.asset_group_to_individual_assets.items(): + individual_asset_names = [self.assets[i].get('name', f'Asset_{i}') for i in individual_asset_indices] + print(f" Asset Group {group_id}: {individual_asset_names}") def set_up_optimizer(self, goal : str = "cost"): ''' @@ -454,108 +500,121 @@ def set_up_optimizer(self, goal : str = "cost"): if wordy > 0: print("Constraint 2 built.") - # 3) at least one asset must be assigned to each task + # 3) exactly one asset must be assigned to each task ''' - Sum of all task-asset pairs must be >= 1 for each task: - (Xta_00 + ... + Xta_0A) >= 1 # for task 0 - (Xta_10 + ... + Xta_1A) >= 1 # for task 1 + Sum of all task-asset pairs must be = 1 for each task: + (Xta_00 + ... + Xta_0A) = 1 # for task 0 + (Xta_10 + ... + Xta_1A) = 1 # for task 1 ... - (Xta_T0 + ... + Xta_TA) >= 1 # for task T + (Xta_T0 + ... + Xta_TA) = 1 # for task T + + This ensures each task is assigned to exactly one asset group. ''' # num_tasks rows - A_lb_3 = np.zeros((self.T, num_variables), dtype=int) - b_lb_3 = np.ones(self.T, dtype=int) + A_eq_3 = np.zeros((self.T, num_variables), dtype=int) + b_eq_3 = np.ones(self.T, dtype=int) for t in range (self.T): # set the coefficient for each task t to one - A_lb_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t + A_eq_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t if wordy > 1: - print("A_lb_3^T:") + print("A_eq_3^T:") print(" T1 T2") # Header for 2 tasks for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) - for column in A_lb_3.transpose()[i]: + for column in A_eq_3.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_lb_3: ", b_lb_3) + print("b_eq_3: ", b_eq_3) - A_lb_list.append(A_lb_3) - b_lb_list.append(b_lb_3) + A_eq_list.append(A_eq_3) + b_eq_list.append(b_eq_3) if wordy > 0: print("Constraint 3 built.") - # 4) each asset can only be used by one task per time period + # 4) Individual asset conflict prevention within asset groups ''' - Multiple tasks can be assigned to the same asset (Xta[t1,a] = Xta[t2,a] = 1), - but they cannot use it simultaneously in the same period. + We need to ensure that individual assets used within different asset groups + are not assigned to tasks that occur at the same time. - The relationship is enforced through Constraint 12: - Xtp[t,p] + Xta[t,a] - Xap[a,p] ≤ 1 and ≥ 0 + For each individual asset, each period, and each pair of tasks that could + potentially use that individual asset (through their asset group assignments): - This means: if Xtp[t,p] = 1 AND Xta[t,a] = 1, then Xap[a,p] = 1 - Since Xap[a,p] is binary, it can only be 1 for one reason. + We create constraints of the form: + Xta[task1,ag1] + Xta[task2,ag2] + Xtp[task1,period] + Xtp[task2,period] ≤ 3 - We ensure: Xap[a,p] ≤ 1 for each asset a, period p - This constraint is automatically satisfied for binary variables, but we include it explicitly. + Where: + - ag1 and ag2 are asset groups that both contain the same individual asset + - This prevents: task1 assigned to ag1 AND task2 assigned to ag2 AND + both tasks active in the same period (which would conflict on the shared individual asset) - The key insight: if Constraint 12 is working correctly, it should prevent conflicts - by ensuring that if multiple tasks are assigned to the same asset and try to be - active simultaneously, the Xap[a,p] variable relationships will prevent this. + Examples: + - Xta[0,0] + Xta[1,0] + Xtp[0,p] + Xtp[1,p] ≤ 3 (same asset group) + - Xta[0,1] + Xta[1,0] + Xtp[0,p] + Xtp[1,p] ≤ 3 (different groups sharing heavy_asset) ''' - # Ensure each asset can be active in at most one context per period - # (This is automatic for binary variables but explicit for clarity) - A_ub_4 = np.zeros((self.A * self.P, num_variables), dtype=int) - b_ub_4 = np.ones(self.A * self.P, dtype=int) - ''' - row = 0 - for a in range(self.A): - for p in range(self.P): - A_ub_4[row, self.Xap_start + a * self.P + p] = 1 # Xap[a,p] ≤ 1 - row += 1 - ''' - # Add temporal conflict prevention for tasks that could share assets - # For each asset that multiple tasks could use, add constraints to prevent - # simultaneous usage by different tasks - rows_4b = [] - bounds_4b = [] + rows_4 = [] + bounds_4 = [] - for a in range(self.A): - tasks_for_asset = [t for t in range(self.T) if self.task_asset_matrix[t, a, 1] > 0] + # For each individual asset, create constraints to prevent conflicts + for individual_asset_idx in range(len(self.assets)): + individual_asset_name = self.assets[individual_asset_idx].get('name', f'Asset_{individual_asset_idx}') - if len(tasks_for_asset) > 1: # Multiple tasks could use this asset - for p in range(self.P): - # Create a constraint involving ALL tasks that could use this asset - # Σ(Xtp[t,p] for t in tasks_for_asset) + Σ(Xta[t,a] for t in tasks_for_asset) ≤ bound - # Logic: If tasks are assigned to asset a, at most 1 can be active in period p - row = np.zeros(num_variables, dtype=int) - - # Add all task-period variables for this period - for t in tasks_for_asset: - row[self.Xtp_start + t * self.P + p] = 1 # Xtp[t,p] + # Find all asset groups that contain this individual asset + asset_groups_containing_this_asset = self.individual_asset_to_asset_groups[individual_asset_idx] + + if len(asset_groups_containing_this_asset) > 0: + # For each time period + for period_idx in range(self.P): - # Add all task-asset variables for this asset - for t in tasks_for_asset: - row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + # Find all valid (task, asset_group) pairs that could use this individual asset + valid_task_asset_group_pairs = [] - rows_4b.append(row) + for task_idx in range(self.T): + for asset_group_idx in asset_groups_containing_this_asset: + # Check if this task can use this asset group (valid pairing) + if self.task_asset_matrix[task_idx, asset_group_idx, 1] > 0: # valid duration > 0 + valid_task_asset_group_pairs.append((task_idx, asset_group_idx)) - # Calculate bound: if all tasks assigned to asset a, max 1 can be active - num_tasks = len(tasks_for_asset) - max_active_when_all_assigned = 1 # Only 1 task can use asset per period - max_assignments = num_tasks # All could potentially be assigned to the asset - bound = max_active_when_all_assigned + max_assignments - bounds_4b.append(bound) - - if rows_4b: - A_ub_4b = np.vstack(rows_4b) - b_ub_4b = np.array(bounds_4b, dtype=int) - - A_ub_4 = np.vstack([A_ub_4, A_ub_4b]) - b_ub_4 = np.concatenate([b_ub_4, b_ub_4b]) + # Create pairwise constraints between all combinations that could conflict + for i, (task1, ag1) in enumerate(valid_task_asset_group_pairs): + for j, (task2, ag2) in enumerate(valid_task_asset_group_pairs[i+1:], i+1): + + # Skip constraints that violate constraint 3 (same task, different asset groups) + # Constraint 3 already ensures exactly one asset group per task + if task1 == task2: + if wordy > 2: + print(f" Skipping redundant constraint: Task {task1} with groups {ag1} and {ag2} " + f"(already prevented by constraint 3)") + continue + + # Create constraint to prevent task1 and task2 from using this individual asset simultaneously + row = np.zeros(num_variables, dtype=int) + + # Add the four variables that create the conflict scenario + row[self.Xta_start + task1 * self.A + ag1] = 1 # Xta[task1,ag1] + row[self.Xta_start + task2 * self.A + ag2] = 1 # Xta[task2,ag2] + row[self.Xtp_start + task1 * self.P + period_idx] = 1 # Xtp[task1,period] + row[self.Xtp_start + task2 * self.P + period_idx] = 1 # Xtp[task2,period] + + rows_4.append(row) + bounds_4.append(3) # Sum ≤ 3 prevents all 4 from being 1 simultaneously + + if wordy > 1: + print(f" Conflict constraint for {individual_asset_name} in period {period_idx}:") + print(f" Xta[{task1},{ag1}] + Xta[{task2},{ag2}] + Xtp[{task1},{period_idx}] + Xtp[{task2},{period_idx}] ≤ 3") + + # Create constraint matrix + if rows_4: + A_ub_4 = np.vstack(rows_4) + b_ub_4 = np.array(bounds_4, dtype=int) + else: + # If no individual asset conflicts possible, create empty constraint matrix + A_ub_4 = np.zeros((0, num_variables), dtype=int) + b_ub_4 = np.array([], dtype=int) if wordy > 1: print("A_ub_4^T:") @@ -1067,7 +1126,8 @@ def optimize(self, threads = -1): for p in range(self.P): weather_condition = self.weather[p] - pstring = f"Period {p} (weather {weather_condition}): " + pstring = f"Period {p:2d} (weather {weather_condition:2d}): " + for t in range(self.T): if Xtp[t, p] > 0: # Find assigned asset for this task @@ -1075,9 +1135,14 @@ def optimize(self, threads = -1): cost = self.task_asset_matrix[t, a_assigned, 0] duration = self.task_asset_matrix[t, a_assigned, 1] asset_name = self.assets[a_assigned].get('name', f'Asset {a_assigned}') - pstring += f"{asset_name} assigned to task {t} (cost: {cost}, duration: {duration}) | " + + # Format with fixed widths for proper alignment + task_info = f"{asset_name:<15} → Task {t:2d} (cost: {cost:6.0f}, dur: {duration:2d})" + pstring += f"{task_info:<50} | " else: - pstring += " "*60 + "| " + # Empty slot with proper spacing + pstring += f"{'':50} | " + print(pstring) if wordy > 0: @@ -1102,6 +1167,10 @@ def optimize(self, threads = -1): {"name": "heavy_asset", "max_weather": 3}, # Can work in all weather conditions {"name": "light_asset", "max_weather": 1} # Can only work in calm weather (1) ] + asset_groups = [ + {'group1': ['heavy_asset']}, + {'group2': ['light_asset', 'heavy_asset']}, + ] # task dependencies task_dependencies = { @@ -1129,7 +1198,7 @@ def optimize(self, threads = -1): min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration # Sandbox for building out the scheduler - scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration) + scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration, asset_groups=asset_groups) scheduler.optimize() a = 2 diff --git a/scheduler_tutorial.ipynb b/scheduler_tutorial.ipynb new file mode 100644 index 00000000..30082f75 --- /dev/null +++ b/scheduler_tutorial.ipynb @@ -0,0 +1,427 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a122639d", + "metadata": {}, + "source": [ + "## IRMA Scheduler Tutorial\n", + "\n", + "Welcome to the Interactive Tutorial for the **IRMA (Installation Resource Management & Analytics) Scheduler**!\n", + "\n", + "This notebook aims to guide new users/readers through the Mixed Integer Linear Programming (MILP) of the scheduler.\n", + "\n", + "### Introduction\n", + "Imagine you are planning the schedule of installation activities for the deployment of an offshore system. \n", + "\n", + "📋 **Many Tasks**: Discrete work activities that need to be completed (e.g., anchor installation, cable laying) \n", + "🚢 **Multiple Assets**: Resources (typically vessels) that can perform certain Tasks based on their capabilities (e.g., heavy-lift vessel, cable-laying vessel, support vessel) \n", + "⚙️ **Task-Asset Assignments**: Multiple Assets can be assigned to each Task, where each assignment has an associated cost and time duration \n", + "🌊 **Weather Windows**: Assets can only work in suitable sea conditions (e.g., wind speed limits, wave height limits) \n", + "💰 **Time and Cost**: The schedule should aim to minimize the total cost of all tasks performed\n", + "\n", + "How can the scheduler figure out what assets to assign to what tasks and when to perform each task to minimize time and/or cost?\n", + "\n", + "### Mixed Integer Linear Programming (MILP)\n", + "\n", + "A type of optimization problem that uses a mixture of integer, binary, and continuous variables subject to linear constraints to minimize an objective. \n", + "\n", + "As an example, let's say you own a truck delivery company with 3 trucks and you need to decide which trucks to send out for delivery, where each truck (x_i) has a cost of delivery and a time duration. The goal is to minimize cost of delivery, under a constraint that the total delivery time needs to be at least 12 hours. The only decisions are to either send out the truck for delivery (1) or not (0).\n", + "\n", + "Minimize \n", + "$$ 500x_1 + 400x_2 + 300x_3 $$\n", + "\n", + "where\n", + "\n", + "$$ 7x_1 + 6x_2 + 4x_3 \\geq 12 $$\n", + "\n", + "A MILP solver will realize that it needs at least two trucks for delivery and also figure out that Truck 2 ($x_2$) and Truck 3 ($x_3$) will not satisfy the constraint and neither will Truck 1 ($x_1$) and Truck 3 ($x_3$). That leaves the options of Truck 1 and Truck 2, or all three trucks. It will choose only Truck 1 and Truck 2 since that minimizes cost. (This also assumes that each truck can only be used once).\n", + "\n", + "This tutorial only considers binary variables (for now).\n", + "\n", + "$$x \\in \\{0,1\\}$$\n", + "\n", + "to determine what decisions to make to minimize the objective of the scheduler." + ] + }, + { + "cell_type": "markdown", + "id": "dfbed4e7", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First, let's import the necessary libraries and set up our environment." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85932d75", + "metadata": {}, + "outputs": [], + "source": [ + "# Standard libraries\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "import sys\n", + "import os\n", + "\n", + "# Add the FAModel path for imports\n", + "sys.path.append(os.path.join(os.getcwd(), 'famodel'))\n", + "\n", + "# Import the IRMA scheduler\n", + "from famodel.irma.scheduler import Scheduler\n", + "\n", + "# Set up plotting\n", + "plt.style.use('default')\n", + "plt.rcParams['figure.figsize'] = [12, 8]\n", + "\n", + "print(\"✅ Libraries imported successfully!\")\n", + "print(\"📊 Ready to explore the IRMA Scheduler!\")" + ] + }, + { + "cell_type": "markdown", + "id": "5fa0ce33", + "metadata": {}, + "source": [ + "## Simple Case: Two Tasks, Two Assets\n", + "\n", + "Let's start with the most basic scenario to understand the fundamentals:\n", + "\n", + "### Tasks\n", + "\n", + "Let's say that the installation of an offshore system requires two tasks: installing a mooring line, and installing an anchor, where each task has certain requirements that are needed to complete the task." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96182f3d", + "metadata": {}, + "outputs": [], + "source": [ + "tasks = [\n", + " {\n", + " 'name': \"install_mooring\",\n", + " 'requirements': ['mooring_reel', 'positioning']\n", + " },\n", + " {\n", + " 'name': \"install_anchor\",\n", + " 'requirements': ['anchor_handling','positioning']\n", + " }\n", + "]\n", + "\n", + "# Display task information\n", + "task_df = pd.DataFrame(tasks)\n", + "print(task_df)" + ] + }, + { + "cell_type": "markdown", + "id": "3312e500", + "metadata": {}, + "source": [ + "## Assets\n", + "\n", + "And that there are two vessels (assets) that could potentially be used to perform these installations, each with their own set of capabilities, daily cost, and an integer value to represent what weather conditions it can operate in. For example, the Multi-Purpose Supply Vessel (MPSV) cannot operate in wave heights greater than 2 m, but the Anchor Handling Tug Supply Vessel (AHTS) can, but no greater than 4 m." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f7436a7", + "metadata": {}, + "outputs": [], + "source": [ + "# Define our installation vessel\n", + "assets = [\n", + " {\n", + " 'name': 'AHTS', \n", + " 'capabilities': ['anchor_handling', 'mooring_reel', 'positioning'],\n", + " 'daily_cost': 50000,\n", + " 'max_weather': 2\n", + " },\n", + " {\n", + " 'name': 'MPSV', \n", + " 'capabilities': ['mooring_reel', 'positioning'],\n", + " 'daily_cost': 25000,\n", + " 'max_weather': 1\n", + " }\n", + "]\n", + "\n", + "print(\"🚢 Asset Definition:\")\n", + "asset_df = pd.DataFrame(assets)\n", + "print(asset_df)" + ] + }, + { + "cell_type": "markdown", + "id": "bb6e9b04", + "metadata": {}, + "source": [ + "## The Task-Asset Matrix\n", + "\n", + "Through a process that is still yet to be determined (TODO...Stein has something started), we can generate a **Task-Asset Matrix** that defines the cost and duration to perform each task by each set of assets.\n", + "\n", + "Each row of the task-asset matrix represents a different task and each column of the task-asset matrix represents a combination of assets.\n", + "\n", + "Entries with values of -1 represent task-asset pairs that are not feasible. Something like installing an anchor with a kayak." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "109e75be", + "metadata": {}, + "outputs": [], + "source": [ + "# | AHTS | MPSV | AHTS+MPSV |\n", + "# Install Mooring | (c, d) | (c, d) | (c, d) |\n", + "# Install Anchor | (c, d) | (c, d) | (c, d) |\n", + "task_asset_matrix = np.array([\n", + " [(2000, 2), (1000, 3), (2500, 3)],\n", + " [(1500, 3), (-1, -1), (4000, 2)]\n", + "])\n", + "\n", + "print(\"🔗 Task-Asset Compatibility:\")\n", + "print(task_asset_matrix)" + ] + }, + { + "cell_type": "markdown", + "id": "3f952cc3", + "metadata": {}, + "source": [ + "## Asset Groups\n", + "\n", + "Different combinations of assets can be used for each task, and each produce a different cost and duration to perform the task based on the capabilities of the assets and the requirements of the task.\n", + "\n", + "The matrix generation process will filter out asset combinations that do not make sense (i.e., overlapping capabilities, maximum number of assets involved, extremely high costs, etc.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f52ed642", + "metadata": {}, + "outputs": [], + "source": [ + "asset_groups = [\n", + " {\n", + " 'assets': ['AHTS'], \n", + " },\n", + " {\n", + " 'assets': ['MPSV'], \n", + " },\n", + " {\n", + " 'assets': ['AHTS','MPSV'], \n", + " },\n", + "]\n", + "\n", + "print(\"🚢 Asset Groups\")\n", + "asset_group_df = pd.DataFrame(asset_groups)\n", + "print(asset_group_df)" + ] + }, + { + "cell_type": "markdown", + "id": "47d60811", + "metadata": {}, + "source": [ + "## Time Periods & Weather\n", + "\n", + "We can also define the planning horizon (timeline) as a set of time periods with given weather conditions. Time periods could be any duration of time (e.g., hours, days, weeks, etc.).\n", + "\n", + "Good weather is normally designated by a 1, OK weather is designated by a 2, and bad weather is designated by a 3" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34d54959", + "metadata": {}, + "outputs": [], + "source": [ + "weather = [1, 1, 1, 1, 1] # Start by defining 5 time periods, each with good weather\n", + "\n", + "print(\"📅 Planning Horizon:\")\n", + "weather_df = pd.DataFrame({\n", + " 'Weather_Condition': weather,\n", + " 'Description': ['Good weather'] * len(weather)\n", + "})\n", + "print(weather_df)" + ] + }, + { + "cell_type": "markdown", + "id": "81284df5", + "metadata": {}, + "source": [ + "## Running the Simple Scheduler\n", + "\n", + "Now let's create and run our first scheduler instance, which simply sets up many variables within the Scheduler class." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c920c16f", + "metadata": {}, + "outputs": [], + "source": [ + "# Create the scheduler for our simple scenario\n", + "print(\"🔧 Creating scheduler for simple scenario...\")\n", + "\n", + "scheduler = Scheduler(\n", + " tasks=tasks,\n", + " assets=assets,\n", + " task_asset_matrix=task_asset_matrix,\n", + " weather=weather,\n", + " asset_groups=asset_groups,\n", + " wordy=1 # Enable some debug output\n", + ")\n", + "\n", + "print(\"✅ Scheduler created successfully!\")" + ] + }, + { + "cell_type": "markdown", + "id": "d9604377", + "metadata": {}, + "source": [ + "## Decision Variables\n", + "\n", + "The initialization sets up the decision variables involved in the MILP optimization. They include:\n", + "\n", + "- $X_{t,a} \\in \\{0,1\\}$: 1 if task $t$ is assigned to asset group $a$, 0 otherwise\n", + "- $X_{t,p} \\in \\{0,1\\}$: 1 if task $t$ is active in period $p$, 0 otherwise\n", + "- $X_{t,s} \\in \\{0,1\\}$: 1 if task $t$ starts at period $s$, 0 otherwise\n", + "\n", + "The only 'decisions' are whether certain asset groups are assigned to certain tasks $(X_{t,a})$, and when the task starts $(X_{t,s})$. The $X_{t,p}$ are also included to help organize the constraints in determining what periods each task occupy, based on the duration of the task-asset combination defined in the task-asset matrix.\n", + "\n", + "The decision variable vector $x$ then follows the form of \n", + "\n", + "$$ x = [X_{t,a}, X_{t,p}, X_{t,s}] $$\n", + "\n", + "where the length depends on the number of tasks $T$, the number of asset groups $A$, and the number of periods $P$" + ] + }, + { + "cell_type": "markdown", + "id": "fab8e41a", + "metadata": {}, + "source": [ + "## Constraints\n", + "\n", + "Now that the scheduler and decision variables initialized, we need to ensure the constraints are well defined to include in the MILP optimization.\n", + "\n", + "In an MILP optimization, constraints are set to be linear and follow the form of \n", + "\n", + "$$\n", + " A_{ub} \\text{ } x \\text{ } \\leq b_{ub} \\\\\n", + " A_{eq} \\text{ } x \\text{ } = b_{eq} \\\\\n", + " A_{lb} \\text{ } x \\text{ } \\geq b_{lb} \\\\\n", + "$$\n", + "\n", + "where $A$ and $b$ represent large vectors that when multipled by the binary decision variables of the $x$ vector, need to satisfy the constraint according to $b$." + ] + }, + { + "cell_type": "markdown", + "id": "8178632c", + "metadata": {}, + "source": [ + "In the example at the top of this page, we would structure the constraint as the following:\n", + "\n", + "$$\n", + "\\begin{bmatrix}\n", + "7 & 6 & 4\n", + "\\end{bmatrix}\n", + "\\begin{bmatrix}\n", + "x_1 \\\\ x_2 \\\\ x_3\n", + "\\end{bmatrix}\n", + "\\ge 12\n", + "$$\n", + "\n", + "But now we need to develop many other constraints that fit this format and can be applied to our decision variables to ensure the problem solves how we want it to" + ] + }, + { + "cell_type": "markdown", + "id": "06e0ba5a", + "metadata": {}, + "source": [ + "### Constraint 1: An asset group can only be assigned to a task if the asset group can perform the task\n", + "\n", + "Prevents Xta variables that correspond to invalid entries in the task-asset matrix from being turned on." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a5ac49f", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "06088598", + "metadata": {}, + "outputs": [], + "source": [ + "# Solve the optimization problem\n", + "print(\"🚀 Solving the optimization problem...\\n\")\n", + "\n", + "result_simple = scheduler_simple.solve()\n", + "\n", + "if result_simple.success:\n", + " print(f\"\\n🎉 Optimization successful!\")\n", + " print(f\"💰 Total cost: {result_simple.fun:.0f}\")\n", + " \n", + " # Decode and display the solution\n", + " solution = scheduler_simple.decode_solution(result_simple.x)\n", + " \n", + " print(\"\\n📋 Optimal Schedule:\")\n", + " for task_idx, assignment in solution['task_assignments'].items():\n", + " task_name = tasks_simple[task_idx]['name']\n", + " asset_idx = assignment['asset_group'] # In simple case, asset_group = asset index\n", + " asset_name = assets_simple[asset_idx]['name']\n", + " periods = assignment['periods']\n", + " \n", + " print(f\" 📌 {task_name}:\")\n", + " print(f\" 🚢 Assigned to: {asset_name}\")\n", + " print(f\" ⏰ Active in periods: {periods}\")\n", + " print(f\" ⏱️ Duration: {len(periods)} periods\")\n", + " \n", + "else:\n", + " print(f\"❌ Optimization failed: {result_simple.message}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "famodel-env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 246ece7243a22feb4d581df895f8f4a9a663e7cf Mon Sep 17 00:00:00 2001 From: Stein Date: Wed, 29 Oct 2025 14:40:07 -0600 Subject: [PATCH 41/63] Scheduler updates, working scheduler_tutorial.ipynb - commented out the Xap variable lines - added a bunch of self variables to save constraint rows - constraint 2 is now in an if statement in case there are not any dependencies specified - commenting out constraint 12 - using the minimum weather maximum of the asset group to set the weather constraints - some updates to the schedulerREADME from new task-asset group matrix defining - full working scheduler_tutorial that is all up to speed and produces the right outputs --- famodel/irma/scheduler.py | 373 +++++++------- famodel/irma/schedulerREADME.md | 19 +- scheduler_tutorial.ipynb | 849 +++++++++++++++++++++++++++++--- 3 files changed, 981 insertions(+), 260 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 42cd86d7..48c7c681 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -220,15 +220,16 @@ def set_up_optimizer(self, goal : str = "cost"): # Xtp = task period pairs # Xap = period asset pairs # Xts = task start-time pairs - num_variables = (self.T * self.A) + (self.T * self.P) + (self.A * self.P) + (self.T * self.S) # number of decision variables + #num_variables = (self.T * self.A) + (self.T * self.P) + (self.A * self.P) + (self.T * self.S) # number of decision variables + num_variables = (self.T * self.A) + (self.T * self.P) + (self.T * self.S) # number of decision variables self.Xta_start = 0 # starting index of Xta in the flattened decision variable vector self.Xta_end = self.Xta_start + self.T * self.A # ending index of Xta in the flattened decision variable vector self.Xtp_start = self.Xta_end # starting index of Xtp in the flattened decision variable vector self.Xtp_end = self.Xtp_start + self.T * self.P # ending index of Xtp in the flattened decision variable vector - self.Xap_start = self.Xtp_end # starting index of Xap in the flattened decision variable vector - self.Xap_end = self.Xap_start + self.A * self.P # ending index of Xap in the flattened decision variable vector - self.Xts_start = self.Xap_end # starting index of Xts in the flattened decision variable vector + #self.Xap_start = self.Xtp_end # starting index of Xap in the flattened decision variable vector + #self.Xap_end = self.Xap_start + self.A * self.P # ending index of Xap in the flattened decision variable vector + self.Xts_start = self.Xtp_end # starting index of Xts in the flattened decision variable vector self.Xts_end = self.Xts_start + self.T * self.S # ending index of Xts in the flattened decision variable vector # Values vector: In every planning period, the value of assigning asset a to task t is the same. Constraints determine which periods are chosen. @@ -332,20 +333,20 @@ def set_up_optimizer(self, goal : str = "cost"): rows.append(row) if rows: # Only create constraint if there are invalid pairings - A_eq_1 = np.vstack(rows) - b_eq_1 = np.zeros(A_eq_1.shape[0], dtype=int) + self.A_eq_1 = np.vstack(rows) + self.b_eq_1 = np.zeros(self.A_eq_1.shape[0], dtype=int) if wordy > 1: print("A_eq_1^T:") for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) - for column in A_eq_1.transpose()[i]: + for column in self.A_eq_1.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_eq_1: ", b_eq_1) + print("b_eq_1: ", self.b_eq_1) - A_eq_list.append(A_eq_1) - b_eq_list.append(b_eq_1) + A_eq_list.append(self.A_eq_1) + b_eq_list.append(self.b_eq_1) if wordy > 0: print("Constraint 1 built.") @@ -372,133 +373,117 @@ def set_up_optimizer(self, goal : str = "cost"): Implementation: Xts[t,s] <= sum(Xts[d,sd] for sd where sd + duration_d <= s) ''' - - rows_2 = [] - vec_2 = [] - - # Convert task names to indices for easier processing - task_name_to_index = {task: i for i, task in enumerate(self.tasks)} - - for task_name, dependencies in self.task_dependencies.items(): - if task_name not in task_name_to_index: - continue # Skip if task not in our task list - - t = task_name_to_index[task_name] # dependent task index + if self.task_dependencies: + + rows_2 = [] + vec_2 = [] + + # Convert task names to indices for easier processing + task_name_to_index = {task.get('name', task) if isinstance(task, dict) else task: i for i, task in enumerate(self.tasks)} - for dep_task_name in dependencies: - if dep_task_name not in task_name_to_index: - continue # Skip if dependency not in our task list + for task_name, dependencies in self.task_dependencies.items(): + if task_name not in task_name_to_index: + continue # Skip if task not in our task list - d = task_name_to_index[dep_task_name] # dependency task index - - # Get dependency type (default to finish_start) - dep_key = f"{dep_task_name}->{task_name}" - dep_type = self.dependency_types.get(dep_key, "finish_start") + t = task_name_to_index[task_name] # dependent task index - if dep_type == "finish_start": - # Task t cannot start until task d finishes - # For each possible start time s of task t - for s in range(self.S): - # Task t can start at time s only if task d has already finished - # Find minimum duration of task d across all possible assets - min_duration_d = float('inf') - for a_d in range(self.A): - duration_d = self.task_asset_matrix[d, a_d, 1] - if duration_d > 0: # Valid task-asset pairing - min_duration_d = min(min_duration_d, duration_d) + for dep_task_name in dependencies: + if dep_task_name not in task_name_to_index: + continue # Skip if dependency not in our task list - if min_duration_d == float('inf'): - continue # No valid asset for dependency task - - # Task d must finish before time s - # So task d must start at latest at time (s - min_duration_d) - # But we need to account for the actual duration based on asset choice - - # For this constraint: if task t starts at time s, then task d must have started - # and finished before time s - latest_start_d = s - min_duration_d + d = task_name_to_index[dep_task_name] # dependency task index + + # Get dependency type (default to finish_start) + dep_key = f"{dep_task_name}->{task_name}" + dep_type = self.dependency_types.get(dep_key, "finish_start") + + if dep_type == "finish_start": + # Task t cannot start until task d finishes + # We need to create constraints for each possible asset-duration combination + # If task d uses asset a_d with duration dur_d and starts at time sd, + # then task t cannot start before time (sd + dur_d) - if latest_start_d < 0: - # Task t cannot start at time s because task d cannot finish in time - row = np.zeros(num_variables, dtype=int) - row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] = 0 (cannot start) - rows_2.append(row) - vec_2.append(0) # Xts[t,s] <= 0, so Xts[t,s] = 0 - else: - # Task t can start at time s only if task d starts at time <= latest_start_d - row = np.zeros(num_variables, dtype=int) - row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] - - # Add all valid start times for task d - has_valid_dep_start = False - for sd in range(min(latest_start_d + 1, self.S)): # sd from 0 to latest_start_d - row[self.Xts_start + d * self.S + sd] = -1 # -Xts[d,sd] - has_valid_dep_start = True - - if has_valid_dep_start: - rows_2.append(row) - vec_2.append(0) # Xts[t,s] - sum(Xts[d,valid_sd]) <= 0 - - elif dep_type == "start_start": - # Task t starts when task d starts (same start time) - for s in range(self.S): - row = np.zeros(num_variables, dtype=int) - row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] - row[self.Xts_start + d * self.S + s] = -1 # -Xts[d,s] - rows_2.append(row) - vec_2.append(0) # Xts[t,s] - Xts[d,s] <= 0, so Xts[t,s] <= Xts[d,s] - - elif dep_type == "finish_finish": - # Task t finishes when task d finishes - # This requires both tasks to have the same end time - for s_t in range(self.S): - for a_t in range(self.A): - duration_t = self.task_asset_matrix[t, a_t, 1] - if duration_t > 0: # Valid pairing for task t - end_time_t = s_t + duration_t + for a_d in range(self.A): + duration_d = self.task_asset_matrix[d, a_d, 1] + if duration_d <= 0: # Skip invalid asset-task pairings + continue - # Find start times for task d that result in same end time - for s_d in range(self.S): - for a_d in range(self.A): - duration_d = self.task_asset_matrix[d, a_d, 1] - if duration_d > 0: # Valid pairing for task d - end_time_d = s_d + duration_d - - if end_time_t == end_time_d: - # If task t starts at s_t with asset a_t AND task d starts at s_d with asset a_d, - # then they finish at the same time (constraint satisfied) - continue - else: - # Prevent this combination - row = np.zeros(num_variables, dtype=int) - row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] - row[self.Xta_start + t * self.A + a_t] = 1 # Xta[t,a_t] - row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] - row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] - rows_2.append(row) - vec_2.append(3) # At most 3 of these 4 can be 1 simultaneously - - elif dep_type == "same_asset": - # Task t must use the same asset as task d - for a in range(self.A): - # If both tasks can use asset a - if (self.task_asset_matrix[t, a, 1] > 0 and - self.task_asset_matrix[d, a, 1] > 0): + for sd in range(self.S): # For each possible start time of dependency task + finish_time_d = sd + duration_d # When task d finishes + + # Task t cannot start before task d finishes + for s in range(min(finish_time_d, self.S)): # All start times before finish + # Create constraint: if task d uses asset a_d and starts at sd, + # then task t cannot start at time s + # Constraint: Xta[d,a_d] + Xts[d,sd] + Xts[t,s] <= 2 + # Logical: (Xta[d,a_d]=1 AND Xts[d,sd]=1) → Xts[t,s]=0 + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] + row[self.Xts_start + d * self.S + sd] = 1 # Xts[d,sd] + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + rows_2.append(row) + vec_2.append(2) # At most 2 of these 3 can be 1 simultaneously + + elif dep_type == "start_start": + # Task t starts when task d starts (same start time) + for s in range(self.S): row = np.zeros(num_variables, dtype=int) - row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] - row[self.Xta_start + d * self.A + a] = -1 # -Xta[d,a] + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + row[self.Xts_start + d * self.S + s] = -1 # -Xts[d,s] rows_2.append(row) - vec_2.append(0) # Xta[t,a] - Xta[d,a] <= 0, so if t uses a, then d must use a - - # Build constraint matrices if we have any dependency constraints - if rows_2: - A_ub_2 = np.vstack(rows_2) - b_ub_2 = np.array(vec_2, dtype=int) - A_ub_list.append(A_ub_2) - b_ub_list.append(b_ub_2) + vec_2.append(0) # Xts[t,s] - Xts[d,s] <= 0, so Xts[t,s] <= Xts[d,s] + + elif dep_type == "finish_finish": + # Task t finishes when task d finishes + # This requires both tasks to have the same end time + for s_t in range(self.S): + for a_t in range(self.A): + duration_t = self.task_asset_matrix[t, a_t, 1] + if duration_t > 0: # Valid pairing for task t + end_time_t = s_t + duration_t + + # Find start times for task d that result in same end time + for s_d in range(self.S): + for a_d in range(self.A): + duration_d = self.task_asset_matrix[d, a_d, 1] + if duration_d > 0: # Valid pairing for task d + end_time_d = s_d + duration_d + + if end_time_t == end_time_d: + # If task t starts at s_t with asset a_t AND task d starts at s_d with asset a_d, + # then they finish at the same time (constraint satisfied) + continue + else: + # Prevent this combination + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] + row[self.Xta_start + t * self.A + a_t] = 1 # Xta[t,a_t] + row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] + row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] + rows_2.append(row) + vec_2.append(3) # At most 3 of these 4 can be 1 simultaneously + + elif dep_type == "same_asset": + # Task t must use the same asset as task d + for a in range(self.A): + # If both tasks can use asset a + if (self.task_asset_matrix[t, a, 1] > 0 and + self.task_asset_matrix[d, a, 1] > 0): + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] + row[self.Xta_start + d * self.A + a] = -1 # -Xta[d,a] + rows_2.append(row) + vec_2.append(0) # Xta[t,a] - Xta[d,a] <= 0, so if t uses a, then d must use a + + # Build constraint matrices if we have any dependency constraints + if rows_2: + self.A_ub_2 = np.vstack(rows_2) + self.b_ub_2 = np.array(vec_2, dtype=int) + A_ub_list.append(self.A_ub_2) + b_ub_list.append(self.b_ub_2) - if wordy > 0: - print("Constraint 2 built.") + if wordy > 0: + print("Constraint 2 built.") # 3) exactly one asset must be assigned to each task ''' @@ -512,25 +497,25 @@ def set_up_optimizer(self, goal : str = "cost"): ''' # num_tasks rows - A_eq_3 = np.zeros((self.T, num_variables), dtype=int) - b_eq_3 = np.ones(self.T, dtype=int) + self.A_eq_3 = np.zeros((self.T, num_variables), dtype=int) + self.b_eq_3 = np.ones(self.T, dtype=int) for t in range (self.T): # set the coefficient for each task t to one - A_eq_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t + self.A_eq_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t if wordy > 1: print("A_eq_3^T:") print(" T1 T2") # Header for 2 tasks for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) - for column in A_eq_3.transpose()[i]: + for column in self.A_eq_3.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_eq_3: ", b_eq_3) + print("b_eq_3: ", self.b_eq_3) - A_eq_list.append(A_eq_3) - b_eq_list.append(b_eq_3) + A_eq_list.append(self.A_eq_3) + b_eq_list.append(self.b_eq_3) if wordy > 0: print("Constraint 3 built.") @@ -609,25 +594,25 @@ def set_up_optimizer(self, goal : str = "cost"): # Create constraint matrix if rows_4: - A_ub_4 = np.vstack(rows_4) - b_ub_4 = np.array(bounds_4, dtype=int) + self.A_ub_4 = np.vstack(rows_4) + self.b_ub_4 = np.array(bounds_4, dtype=int) else: # If no individual asset conflicts possible, create empty constraint matrix - A_ub_4 = np.zeros((0, num_variables), dtype=int) - b_ub_4 = np.array([], dtype=int) + self.A_ub_4 = np.zeros((0, num_variables), dtype=int) + self.b_ub_4 = np.array([], dtype=int) if wordy > 1: print("A_ub_4^T:") print(" P1 P2 P3 P4 P5") # Header for 5 periods for i in range(self.Xap_start,self.Xap_end): pstring = str(self.X_indices[i]) - for column in A_ub_4.transpose()[i]: + for column in self.A_ub_4.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_ub_4: ", b_ub_4) + print("b_ub_4: ", self.b_ub_4) - A_ub_list.append(A_ub_4) - b_ub_list.append(b_ub_4) + A_ub_list.append(self.A_ub_4) + b_ub_list.append(self.b_ub_4) if wordy > 0: print("Constraint 4 built.") @@ -651,26 +636,26 @@ def set_up_optimizer(self, goal : str = "cost"): row[self.Xta_start + t * self.A + a] = 1 rows.append(row) - A_ub_10 = np.vstack(rows) - b_ub_10 = np.ones(A_ub_10.shape[0], dtype=int) # Each infeasible combination: Xta + Xts <= 1 + self.A_ub_10 = np.vstack(rows) + self.b_ub_10 = np.ones(self.A_ub_10.shape[0], dtype=int) # Each infeasible combination: Xta + Xts <= 1 if wordy > 1: print("A_ub_10^T:") print(" T1A1 T1A2 T2A1") # Header for 3 task-asset pairs example with T2A2 invalid for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) - for column in A_ub_10.transpose()[i]: + for column in self.A_ub_10.transpose()[i]: pstring += f"{ column:5}" print(pstring) for i in range(self.Xts_start,self.Xts_end): pstring = str(self.X_indices[i]) - for column in A_ub_10.transpose()[i]: + for column in self.A_ub_10.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_ub_10: ", b_ub_10) + print("b_ub_10: ", self.b_ub_10) - A_ub_list.append(A_ub_10) - b_ub_list.append(b_ub_10) + A_ub_list.append(self.A_ub_10) + b_ub_list.append(self.b_ub_10) if wordy > 0: print("Constraint 10 built.") @@ -731,7 +716,7 @@ def set_up_optimizer(self, goal : str = "cost"): row += 1 """ - + """ rows_ub = [] rows_lb = [] @@ -747,7 +732,7 @@ def set_up_optimizer(self, goal : str = "cost"): rows_ub.append(row.copy()) # Upper bound constraint rows_lb.append(row.copy()) # Lower bound constraint - """ + if rows_ub: A_ub_12 = np.vstack(rows_ub) b_ub_12 = np.ones(len(rows_ub), dtype=int) @@ -758,7 +743,7 @@ def set_up_optimizer(self, goal : str = "cost"): b_ub_list.append(b_ub_12) A_lb_list.append(A_lb_12) b_lb_list.append(b_lb_12) - """ + if wordy > 1: print("A_12^T:") for i in range(self.Xta_start,self.Xap_end): @@ -768,15 +753,15 @@ def set_up_optimizer(self, goal : str = "cost"): print(pstring) print("b_ub_12: ", b_ub_12) print("b_lb_12: ", b_lb_12) - ''' + A_ub_list.append(A_12) b_ub_list.append(b_ub_12) A_lb_list.append(A_12) b_lb_list.append(b_lb_12) - ''' + if wordy > 0: print("Constraint 12 built.") - + """ # 14) if a task-starttime pair is selected, the corresponding task-period pair must be selected for the period equal to the start time plus the duration of the task ''' This ensures that if a task is assigned a start time, the corresponding task-period pair for the period equal to the start time plus the duration of the task is also selected. @@ -841,36 +826,36 @@ def set_up_optimizer(self, goal : str = "cost"): #b_lb_14 = np.array(vec, dtype=int) if rows_14a: - A_ub_14a = np.vstack(rows_14a) - b_ub_14a = np.array(vec_14a, dtype=int) - A_ub_list.append(A_ub_14a) - b_ub_list.append(b_ub_14a) + self.A_ub_14a = np.vstack(rows_14a) + self.b_ub_14a = np.array(vec_14a, dtype=int) + A_ub_list.append(self.A_ub_14a) + b_ub_list.append(self.b_ub_14a) if rows_14b: - A_ub_14b = np.vstack(rows_14b) - b_ub_14b = np.array(vec_14b, dtype=int) - A_ub_list.append(A_ub_14b) - b_ub_list.append(b_ub_14b) + self.A_ub_14b = np.vstack(rows_14b) + self.b_ub_14b = np.array(vec_14b, dtype=int) + A_ub_list.append(self.A_ub_14b) + b_ub_list.append(self.b_ub_14b) if wordy > 1: print("A_lb_14^T:") print(" T1A1S1 T1A2S1 ...") # Header for 3 task-asset pairs example with T2A2 invalid for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) - for column in A_lb_14.transpose()[i]: + for column in self.A_lb_14.transpose()[i]: pstring += f"{ column:5}" print(pstring) for i in range(self.Xtp_start,self.Xtp_end): pstring = str(self.X_indices[i]) - for column in A_lb_14.transpose()[i]: + for column in self.A_lb_14.transpose()[i]: pstring += f"{ column:5}" print(pstring) for i in range(self.Xts_start,self.Xts_end): pstring = str(self.X_indices[i]) - for column in A_lb_14.transpose()[i]: + for column in self.A_lb_14.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_lb_14: ", b_ub_14) + print("b_lb_14: ", self.b_ub_14) if wordy > 0: print("Constraint 14 built.") @@ -887,23 +872,23 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_15[0,self.Xts_start:self.Xts_end] = 1 ''' - A_eq_15 = np.zeros((self.T, num_variables), dtype=int) - b_eq_15 = np.ones(self.T, dtype=int) + self.A_eq_15 = np.zeros((self.T, num_variables), dtype=int) + self.b_eq_15 = np.ones(self.T, dtype=int) for t in range(self.T): - A_eq_15[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 + self.A_eq_15[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 if wordy > 1: print("A_eq_15^T:") for i in range(self.Xts_start,self.Xts_end): pstring = str(self.X_indices[i]) - for column in A_eq_15.transpose()[i]: + for column in self.A_eq_15.transpose()[i]: pstring += f"{ column:5}" print(pstring) - print("b_eq_15: ", b_eq_15) + print("b_eq_15: ", self.b_eq_15) - A_eq_list.append(A_eq_15) - b_eq_list.append(b_eq_15) + A_eq_list.append(self.A_eq_15) + b_eq_list.append(self.b_eq_15) if wordy > 0: print("Constraint 15 built.") @@ -935,10 +920,10 @@ def set_up_optimizer(self, goal : str = "cost"): vec_16.append(0) # sum(Xtp) - sum(duration * Xta) = 0 if rows_16: - A_eq_16 = np.vstack(rows_16) - b_eq_16 = np.array(vec_16, dtype=int) - A_eq_list.append(A_eq_16) - b_eq_list.append(b_eq_16) + self.A_eq_16 = np.vstack(rows_16) + self.b_eq_16 = np.array(vec_16, dtype=int) + A_eq_list.append(self.A_eq_16) + b_eq_list.append(self.b_eq_16) if wordy > 0: print("Constraint 16 built.") @@ -959,19 +944,28 @@ def set_up_optimizer(self, goal : str = "cost"): vec_17 = [] for a in range(self.A): - asset_max_weather = self.assets[a].get('max_weather', float('inf')) # Default to no weather limit + # Determine the weather capability of asset group a + # An asset group can only operate in conditions that ALL its individual assets can handle + # So we take the minimum max_weather across all individual assets in the group + asset_group_max_weather = float('inf') # Start with no limit + + if a in self.asset_group_to_individual_assets: + individual_asset_indices = self.asset_group_to_individual_assets[a] + for individual_asset_idx in individual_asset_indices: + individual_max_weather = self.assets[individual_asset_idx].get('max_weather', float('inf')) + asset_group_max_weather = min(asset_group_max_weather, individual_max_weather) for p in range(self.P): period_weather = self.weather[p] - if period_weather > asset_max_weather: - # Weather in period p is too severe for asset a + if period_weather > asset_group_max_weather: + # Weather in period p is too severe for asset group a for t in range(self.T): # Check if this task-asset pair is valid (positive duration and cost) if (self.task_asset_matrix[t, a, 0] >= 0 and self.task_asset_matrix[t, a, 1] > 0): - # Prevent task t from using asset a in period p due to weather + # Prevent task t from using asset group a in period p due to weather row = np.zeros(num_variables, dtype=int) row[self.Xta_start + t * self.A + a] = 1 # Xta[t,a] row[self.Xtp_start + t * self.P + p] = 1 # Xtp[t,p] @@ -981,10 +975,10 @@ def set_up_optimizer(self, goal : str = "cost"): # Build constraint matrices if we have any weather constraints if rows_17: - A_ub_17 = np.vstack(rows_17) - b_ub_17 = np.array(vec_17, dtype=int) - A_ub_list.append(A_ub_17) - b_ub_list.append(b_ub_17) + self.A_ub_17 = np.vstack(rows_17) + self.b_ub_17 = np.array(vec_17, dtype=int) + A_ub_list.append(self.A_ub_17) + b_ub_list.append(self.b_ub_17) if wordy > 0: print(f"Constraint 17 built with {len(rows_17)} weather restrictions.") @@ -1110,7 +1104,7 @@ def optimize(self, threads = -1): if res.success: # Reshape the flat result back into the (num_periods, num_tasks, num_assets) shape - if wordy > 0: + if wordy > 5: print("Decision variable [periods][tasks][assets]:") for i in range(len(self.X_indices)): print(f" {self.X_indices[i]}: {int(res.x[i])}") @@ -1121,7 +1115,7 @@ def optimize(self, threads = -1): x_opt = res.x # or whatever your result object is Xta = x_opt[self.Xta_start:self.Xta_end].reshape((self.T, self.A)) Xtp = x_opt[self.Xtp_start:self.Xtp_end].reshape((self.T, self.P)) - Xap = x_opt[self.Xap_start:self.Xap_end].reshape((self.A, self.P)) + #Xap = x_opt[self.Xap_start:self.Xap_end].reshape((self.A, self.P)) Xts = x_opt[self.Xts_start:self.Xts_end].reshape((self.T, self.S)) for p in range(self.P): @@ -1200,6 +1194,7 @@ def optimize(self, threads = -1): # Sandbox for building out the scheduler scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration, asset_groups=asset_groups) scheduler.optimize() + a = 2 diff --git a/famodel/irma/schedulerREADME.md b/famodel/irma/schedulerREADME.md index bfbd6905..0a3f4747 100644 --- a/famodel/irma/schedulerREADME.md +++ b/famodel/irma/schedulerREADME.md @@ -112,24 +112,11 @@ This prevents extra periods beyond the requirement from being set. Overall, the combination of Constraints 15-14b-16 ensure tasks are active when they should be -(**TODO**) Handle cases with multiple assets per task - -The problem with this constraint is that it assumes only 1 asset is assigned per task, and it uses that task-asset duration to set the constraint. But, what if asset 0 and asset 1 are both used for task 0? How is the duration set? - - -1. Ensure that each task only involves 1 asset in its definition. Would just require an update to Constraint 3 to an equals not a inequality. Would likely involve more dependencies to define, but it seems to be the cleanest. - - Could set 'cases' instead of 'assets' and update Constraint 3 to equality -2. Updating the duration of the task based on the combination of assets. Would require some changes to 14b (not bad) and just an updated duration in 16. - - Take the maximum duration (3 over 2) - - Average them in a way (2 + 3 = 1.25) - - Have additional duration information stored somewhere else - - Sum them (make them sequential) like if $d_{t,a}[0,0]=2$ and $d_{t,a}[0,1]=3$, then this task duration would require 5 periods. - ### 12. (NOT INCLUDED ANYMORE) An asset that a task is assigned to must also occur in the same period(s) that the task occurs in -This constraint is commented out and not used anymore because it created an adverse coupling of variables. +This constraint is commented out and not used anymore because it created an adverse coupling of variables. But, I wanted to keep some explanation in here for why this happened. It was intended to ensure that a task and asset in a task-asset pair were both assigned to the same period (and updated the Xap variables accordingly) @@ -142,8 +129,8 @@ However, this created a problem that when $X_{t,a}[0,0] = 1$, this constraint co Therefore, we have decided to eliminate the need for the Xap variable since they seem redundant after this investigation. -### 4. Asset Cannot Be Assigned to Multiple Tasks in Same Period -Ensures that if multiple tasks are assigned to the same asset, then only one can be active in any period p (not using any Xap variables) +### 4. Asset that is part of multiple Asset Groups Cannot Be Assigned to Multiple Tasks in Same Period +Ensures that if multiple tasks have an asset that are in different asset groups assigned to them, then only one can be active in any period p (not using any Xap variables) $$ \sum_{t=0}^{T-1} X_{t,p}[t,p] + \sum_{t=0}^{T-1} X_{t,a}[t,a] <= 1 + T \quad \forall a,p diff --git a/scheduler_tutorial.ipynb b/scheduler_tutorial.ipynb index 30082f75..7c072f56 100644 --- a/scheduler_tutorial.ipynb +++ b/scheduler_tutorial.ipynb @@ -5,13 +5,13 @@ "id": "a122639d", "metadata": {}, "source": [ - "## IRMA Scheduler Tutorial\n", + "# IRMA Scheduler Tutorial\n", "\n", "Welcome to the Interactive Tutorial for the **IRMA (Installation Resource Management & Analytics) Scheduler**!\n", "\n", "This notebook aims to guide new users/readers through the Mixed Integer Linear Programming (MILP) of the scheduler.\n", "\n", - "### Introduction\n", + "## Introduction\n", "Imagine you are planning the schedule of installation activities for the deployment of an offshore system. \n", "\n", "📋 **Many Tasks**: Discrete work activities that need to be completed (e.g., anchor installation, cable laying) \n", @@ -20,24 +20,30 @@ "🌊 **Weather Windows**: Assets can only work in suitable sea conditions (e.g., wind speed limits, wave height limits) \n", "💰 **Time and Cost**: The schedule should aim to minimize the total cost of all tasks performed\n", "\n", - "How can the scheduler figure out what assets to assign to what tasks and when to perform each task to minimize time and/or cost?\n", - "\n", + "How can the scheduler figure out what assets to assign to what tasks and when to perform each task to minimize time and/or cost?" + ] + }, + { + "cell_type": "markdown", + "id": "eb9a2e12", + "metadata": {}, + "source": [ "### Mixed Integer Linear Programming (MILP)\n", "\n", "A type of optimization problem that uses a mixture of integer, binary, and continuous variables subject to linear constraints to minimize an objective. \n", "\n", - "As an example, let's say you own a truck delivery company with 3 trucks and you need to decide which trucks to send out for delivery, where each truck (x_i) has a cost of delivery and a time duration. The goal is to minimize cost of delivery, under a constraint that the total delivery time needs to be at least 12 hours. The only decisions are to either send out the truck for delivery (1) or not (0).\n", + "As an example, let's say you own a truck delivery company with 3 trucks and you need to decide which trucks to send out for delivery, where each truck ($x_i$) has a cost of delivery and a time duration. The goal is to minimize cost of delivery, under a constraint that the total delivery time needs to be at least 12 hours. The only decisions are to either send out the truck for delivery (1) or not (0).\n", "\n", - "Minimize \n", + "Minimize the cost function\n", "$$ 500x_1 + 400x_2 + 300x_3 $$\n", "\n", - "where\n", + "subject to specific time constraints (a little counterintuitive that we need a lower bound on time, but it'll help with the whole tutorial)\n", "\n", "$$ 7x_1 + 6x_2 + 4x_3 \\geq 12 $$\n", "\n", - "A MILP solver will realize that it needs at least two trucks for delivery and also figure out that Truck 2 ($x_2$) and Truck 3 ($x_3$) will not satisfy the constraint and neither will Truck 1 ($x_1$) and Truck 3 ($x_3$). That leaves the options of Truck 1 and Truck 2, or all three trucks. It will choose only Truck 1 and Truck 2 since that minimizes cost. (This also assumes that each truck can only be used once).\n", + "A MILP solver will realize that it needs at least two trucks for delivery to satisfy the time constraint and it will also figure out that Truck 2 ($x_2$) and Truck 3 ($x_3$) will not satisfy the constraint and neither will Truck 1 ($x_1$) and Truck 3 ($x_3$). That leaves the options of Truck 1 and Truck 2, or all three trucks. It will choose only Truck 1 and Truck 2 since that minimizes cost. (This also assumes that each truck can only be used once).\n", "\n", - "This tutorial only considers binary variables (for now).\n", + "This tutorial only considers binary variables (for now),\n", "\n", "$$x \\in \\{0,1\\}$$\n", "\n", @@ -124,7 +130,7 @@ "id": "3312e500", "metadata": {}, "source": [ - "## Assets\n", + "### Assets\n", "\n", "And that there are two vessels (assets) that could potentially be used to perform these installations, each with their own set of capabilities, daily cost, and an integer value to represent what weather conditions it can operate in. For example, the Multi-Purpose Supply Vessel (MPSV) cannot operate in wave heights greater than 2 m, but the Anchor Handling Tug Supply Vessel (AHTS) can, but no greater than 4 m." ] @@ -162,7 +168,7 @@ "id": "bb6e9b04", "metadata": {}, "source": [ - "## The Task-Asset Matrix\n", + "### The Task-Asset Matrix\n", "\n", "Through a process that is still yet to be determined (TODO...Stein has something started), we can generate a **Task-Asset Matrix** that defines the cost and duration to perform each task by each set of assets.\n", "\n", @@ -195,7 +201,7 @@ "id": "3f952cc3", "metadata": {}, "source": [ - "## Asset Groups\n", + "### Asset Groups\n", "\n", "Different combinations of assets can be used for each task, and each produce a different cost and duration to perform the task based on the capabilities of the assets and the requirements of the task.\n", "\n", @@ -231,7 +237,7 @@ "id": "47d60811", "metadata": {}, "source": [ - "## Time Periods & Weather\n", + "### Time Periods & Weather\n", "\n", "We can also define the planning horizon (timeline) as a set of time periods with given weather conditions. Time periods could be any duration of time (e.g., hours, days, weeks, etc.).\n", "\n", @@ -260,7 +266,7 @@ "id": "81284df5", "metadata": {}, "source": [ - "## Running the Simple Scheduler\n", + "### Running the Simple Scheduler\n", "\n", "Now let's create and run our first scheduler instance, which simply sets up many variables within the Scheduler class." ] @@ -281,7 +287,7 @@ " task_asset_matrix=task_asset_matrix,\n", " weather=weather,\n", " asset_groups=asset_groups,\n", - " wordy=1 # Enable some debug output\n", + " wordy=0 # Enable some debug output\n", ")\n", "\n", "print(\"✅ Scheduler created successfully!\")" @@ -292,21 +298,33 @@ "id": "d9604377", "metadata": {}, "source": [ - "## Decision Variables\n", + "## Understanding the Mathematical Constraints\n", "\n", - "The initialization sets up the decision variables involved in the MILP optimization. They include:\n", + "Before we dive into the results of the scheduler, let's understand how the IRMA scheduler actually works under the hood\n", "\n", - "- $X_{t,a} \\in \\{0,1\\}$: 1 if task $t$ is assigned to asset group $a$, 0 otherwise\n", - "- $X_{t,p} \\in \\{0,1\\}$: 1 if task $t$ is active in period $p$, 0 otherwise\n", - "- $X_{t,s} \\in \\{0,1\\}$: 1 if task $t$ starts at period $s$, 0 otherwise\n", + "### Decision Variables:\n", "\n", - "The only 'decisions' are whether certain asset groups are assigned to certain tasks $(X_{t,a})$, and when the task starts $(X_{t,s})$. The $X_{t,p}$ are also included to help organize the constraints in determining what periods each task occupy, based on the duration of the task-asset combination defined in the task-asset matrix.\n", + "The scheduler initialization uses three types of binary decision variables in the MILP optimization (each can be 0 or 1):\n", "\n", - "The decision variable vector $x$ then follows the form of \n", + "**📋 Task-Asset Assignment Variables** `Xta[t,a]`: determines whether task $t$ is assigned to asset group $a$\n", + "- `Xta[0,1] = 1` means \"Task 0 is assigned to Asset 1\"\n", + "- `Xta[0,1] = 0` means \"Task 0 is NOT assigned to Asset 1\"\n", + "\n", + "**⏰ Task-Period Activity Variables** `Xtp[t,p]`: determines if task $t$ is active in period $p$\n", + "- `Xtp[0,3] = 1` means \"Task 0 is active during Period 3\" \n", + "- `Xtp[0,3] = 0` means \"Task 0 is NOT active during Period 3\"\n", + "\n", + "**🚀 Task Start Time Variables** `Xts[t,s]`: determines if task $t$ starts at period $s$\n", + "- `Xts[0,2] = 1` means \"Task 0 starts at Period 2\"\n", + "- `Xts[0,2] = 0` means \"Task 0 does NOT start at Period 2\"\n", + "\n", + "The only 'decisions' are whether certain asset groups are assigned to certain tasks $(X_{t,a})$, and when the task starts $(X_{t,vas}r)i$a.bles The $X_{t,p}$ are also included to help organize the constraints in determining what periods each task occupy, based on the duration of the task-asset combination defined in the task-asset matrix.\n", + "\n", + "The full decision variable vector $x$ then follows the form of \n", "\n", "$$ x = [X_{t,a}, X_{t,p}, X_{t,s}] $$\n", "\n", - "where the length depends on the number of tasks $T$, the number of asset groups $A$, and the number of periods $P$" + "where the length depends on the number of tasks $T$, the number of asset groups $A$, and the number of periods $P$\n" ] }, { @@ -314,11 +332,9 @@ "id": "fab8e41a", "metadata": {}, "source": [ - "## Constraints\n", + "### Constraints\n", "\n", - "Now that the scheduler and decision variables initialized, we need to ensure the constraints are well defined to include in the MILP optimization.\n", - "\n", - "In an MILP optimization, constraints are set to be linear and follow the form of \n", + "Each constraint ensures the solution makes logical sense. In an MILP optimization, constraints are set to be linear and follow the form of \n", "\n", "$$\n", " A_{ub} \\text{ } x \\text{ } \\leq b_{ub} \\\\\n", @@ -334,7 +350,7 @@ "id": "8178632c", "metadata": {}, "source": [ - "In the example at the top of this page, we would structure the constraint as the following:\n", + "In the example at the top of this tutortial, we would structure the constraint as the following:\n", "\n", "$$\n", "\\begin{bmatrix}\n", @@ -346,26 +362,556 @@ "\\ge 12\n", "$$\n", "\n", - "But now we need to develop many other constraints that fit this format and can be applied to our decision variables to ensure the problem solves how we want it to" + "This format is used to define many other constraints necessary for a logical sotion for the scheduler. Each constraint is explained below. But first, we run the `set_up_optimizer()` function to create all the constraints that we can then analyze through this tutorial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00766591", + "metadata": {}, + "outputs": [], + "source": [ + "scheduler.set_up_optimizer()" + ] + }, + { + "cell_type": "markdown", + "id": "8b8d4ec3", + "metadata": {}, + "source": [ + "#### Constraint 1: Task-Asset Validity 🔒\n", + "\n", + "**English**: An asset group can only be assigned to a task if the asset group can perform the task.\n", + "\n", + "**Impact**: Prevents impossible assignments (Xta variables that correspond to invalid entries in the task-asset matrix from being turned on) that would break the physics of the problem.\n", + "\n", + "**Math**: \n", + "$$\n", + "X_{t,a} = 0 \\quad \\text{for all } (t,a) \\text{ where } c_{t,a} < 0 \\text{ or } d_{t,a} < 0\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41a2e1e6", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 1: Task-Asset Validity Matrix Construction\n", + "print(\"🔒 Constraint 1: Task-Asset Validity\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_eq_1'):\n", + " print(f\"A_eq_1 matrix equations (shape: {scheduler.A_eq_1.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_eq_1, scheduler.b_eq_1)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x = {b_val}\")\n", + "else:\n", + " print(\"No invalid task-asset pairs found - no constraints needed\")" + ] + }, + { + "cell_type": "markdown", + "id": "325b590e", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that\n", + "\n", + "$$\n", + "X_{t,a}[1,1] = 0\n", + "$$\n", + "\n", + "since that is the only entry of the task-asset matrix that is infeasible" + ] + }, + { + "cell_type": "markdown", + "id": "15391d22", + "metadata": {}, + "source": [ + "#### Constraint 3: Exactly One Asset Per Task ⚖️\n", + "\n", + "**English**: Each task must be assigned to exactly one asset group\n", + "\n", + "**Impact**: Prevents a task from being unassigned (would never complete) or over-assigned (physically impossible).\n", + "\n", + "**Math**: \n", + "$$\n", + "\\sum_{a=0}^{A-1} X_{t,a} = 1 \\quad \\forall t \\in \\{0, 1, \\ldots, T-1\\}\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36b17bb8", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 3: Exactly One Asset Per Task Matrix Construction\n", + "print(\"⚖️ Constraint 3: Exactly One Asset Per Task\")\n", + "print()\n", + "\n", + "print(f\"A_eq_3 matrix equations (shape: {scheduler.A_eq_3.shape}):\")\n", + "for i, (row, b_val) in enumerate(zip(scheduler.A_eq_3, scheduler.b_eq_3)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x = {b_val}\")" + ] + }, + { + "cell_type": "markdown", + "id": "2b1a8158", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that\n", + "\n", + "$$ X_{t,a}[0,0] + X_{t,a}[0,1] + X_{t,a}[0,2] = 1 $$ and $$ X_{t,a}[1,0] + X_{t,a}[1,1] + X_{t,a}[1,2] = 1 $$\n", + "\n", + "which doesn't allow more than 1 asset group assignment per task\n" + ] + }, + { + "cell_type": "markdown", + "id": "a4283498", + "metadata": {}, + "source": [ + "#### Constraint 15: Each Task Must Have A Start Time 🚀\n", + "\n", + "**English**: Every task must start exactly once\n", + "\n", + "**Impact**: Tasks must start to be completed, and they can only start once.\n", + "\n", + "**Math**: \n", + "$$\n", + "\\sum_{s=0}^{S-1} X_{t,s} = 1 \\quad \\forall t \\in \\{0, 1, \\ldots, T-1\\}\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50d925d8", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 15: Each Task Must Have A Start Time Matrix Construction\n", + "print(\"🚀 Constraint 15: Each Task Must Have A Start Time\")\n", + "print()\n", + "\n", + "print(f\"A_eq_15 matrix equations (shape: {scheduler.A_eq_15.shape}):\")\n", + "for i, (row, b_val) in enumerate(zip(scheduler.A_eq_15, scheduler.b_eq_15)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x = {b_val}\")" + ] + }, + { + "cell_type": "markdown", + "id": "78cec84a", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that\n", + "\n", + "$$ X_{t,s}[0,0] + X_{t,s}[0,1] + X_{t,s}[0,2] + X_{t,s}[0,3] + X_{t,s}[0,4] = 1 $$ and $$ X_{t,s}[1,0] + X_{t,s}[1,1] + X_{t,s}[1,2] + X_{t,s}[1,3] + X_{t,s}[1,4] = 1 $$\n", + "\n", + "which doesn't allow more than 1 start time per task" + ] + }, + { + "cell_type": "markdown", + "id": "828e65f8", + "metadata": {}, + "source": [ + "#### Constraint 10: Task Duration Must Not Exceed Planning Horizon 📅\n", + "\n", + "**English**: A task cannot start at a time where its duration would extend beyond the available planning periods.\n", + "\n", + "**Impact**: Prevents scheduling tasks that would run past the end of the planning window, ensuring all work completes within the defined timeframe.\n", + "\n", + "**Math**: \n", + "$$\n", + "X_{t,a}[t,a] + X_{t,s}[t,s] \\leq 1 \\quad \\forall t, a, s \\quad \\text{ where } \\quad d_{t,a} > 0 \\text{ and } s + d_{t,a} > P\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37f32f59", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 10: Task Duration Must Not Exceed Planning Horizon Matrix Construction\n", + "print(\"📅 Constraint 10: Task Duration Must Not Exceed Planning Horizon\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_ub_10'):\n", + " print(f\"A_ub_10 matrix equations (shape: {scheduler.A_ub_10.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_ub_10, scheduler.b_ub_10)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + "else:\n", + " print(\"No duration violations found - all tasks can complete within planning horizon\")" + ] + }, + { + "cell_type": "markdown", + "id": "82b1b5fd", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that \n", + "\n", + "$ X_{t,a}[0,0] + X_{t,s}[0,4] \\leq 1 \\quad X_{t,a}[0,1] + X_{t,s}[0,3] \\leq 1 \\quad X_{t,a}[0,1] + X_{t,s}[0,4] \\leq 1 $\n", + "\n", + "which says that if the first asset group is assigned to the first task, then the first task cannot start in the last period (because its duration is 2 periods). Similarly, if the second asset group is assigned to the first task, then the first task cannot start in the fourth or fifth periods (because its duration is 3 periods)." + ] + }, + { + "cell_type": "markdown", + "id": "547fcd6a", + "metadata": {}, + "source": [ + "#### Constraint 14a: Task Must Be Active When It Starts ⏰\n", + "\n", + "**English**: If a task starts in a specific period, it must also be active in that same period.\n", + "\n", + "**Impact**: Links start time decisions to activity periods - ensures that when a task starts, it's immediately active.\n", + "\n", + "**Math**: \n", + "$$\n", + "X_{t,s}[t,s] \\leq X_{t,p}[t,p] \\quad \\forall t, s \\quad \\text{ where } \\quad s = p \\text{ and } s < P\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "864b9a1e", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 14a: Task Must Be Active When It Starts Matrix Construction\n", + "print(\"⏰ Constraint 14a: Task Must Be Active When It Starts\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_ub_14a'):\n", + " print(f\"A_ub_14a matrix equations (shape: {scheduler.A_ub_14a.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_ub_14a, scheduler.b_ub_14a)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + "else:\n", + " print(\"No start-time to period mapping constraints needed\")" + ] + }, + { + "cell_type": "markdown", + "id": "4be12261", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that \n", + "\n", + "$ -X_{t,p}[0,0] + X_{t,s}[0,0] \\leq 0 \\quad -X_{t,p}[0,1] + X_{t,s}[0,1] \\leq 0 \\quad -X_{t,p}[0,2] + X_{t,s}[0,2] \\leq 0 $\n", + "\n", + "which says that if the first task starts in the first time period, then the Xtp variable that corresponds to that start period must also be turned on." + ] + }, + { + "cell_type": "markdown", + "id": "0d13c74a", + "metadata": {}, + "source": [ + "#### Constraint 14b: Task Activity Must Match Duration ⏱️\n", + "\n", + "**English**: If a task is assigned to an asset and starts at a specific time, it must be active for exactly the duration required by that task-asset combination.\n", + "\n", + "**Impact**: Ensures tasks run for their complete required duration based on the chosen asset assignment and start time.\n", + "\n", + "**Math**: \n", + "$$\n", + "X_{t,a}[t,a] + X_{t,s}[t,s] - X_{t,p}[t,p] \\leq 1 \\quad \\forall t, a, s, p \\quad \\text{ where } \\quad d_{t,a} > 0 \\text{ and } s \\leq p < s + d_{t,a}\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8fdfa7d", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 14b: Task Activity Must Match Duration Matrix Construction\n", + "print(\"⏱️ Constraint 14b: Task Activity Must Match Duration\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_ub_14b'):\n", + " print(f\"A_ub_14b matrix equations (shape: {scheduler.A_ub_14b.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_ub_14b, scheduler.b_ub_14b)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + "else:\n", + " print(\"No duration enforcement constraints needed\")" + ] + }, + { + "cell_type": "markdown", + "id": "5a86ce07", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that \n", + "\n", + "$ X_{t,a}[0,0] - X_{t,p}[0,0] + X_{t,s}[0,0] \\leq 1 \\quad X_{t,a}[0,0] - X_{t,p}[0,1] + X_{t,s}[0,0] \\leq 1 \\quad X_{t,a}[0,0] - X_{t,p}[0,1] + X_{t,s}[0,1] \\leq 1$\n", + "\n", + "which ensures that for each case when a task-asset group starts in a certain time period, the Xtp variables that would align with the start time period and the duration of the task are turned on. The list of constraints follows a pattern where it loops through all task-asset group options, and then loops through all start time options, and uses a -1 coefficient on the Xtp variables that equate to the duration of the task. Constraint 14a only does the first Xtp variable corresponding to the start time. Constraint 14b ensures the Xtp variables that align with the duration of the task are also turned on." + ] + }, + { + "cell_type": "markdown", + "id": "2140b7de", + "metadata": {}, + "source": [ + "#### Constraint 16: Each Task Active For Exactly Its Duration ⚖️\n", + "\n", + "**English**: The total number of periods a task is active must exactly equal the duration required by its assigned asset group.\n", + "\n", + "**Impact**: Prevents tasks from being active for longer or shorter than required, working with Constraint 14b to ensure precise duration matching.\n", + "\n", + "**Math**: \n", + "$$\n", + "\\sum_{p=0}^{P-1} X_{t,p} = \\sum_{a=0}^{A-1} X_{t,a} \\cdot d_{t,a} \\quad \\forall t\n", + "$$\n", + "\n", + "**Note**: Constraint 14b ensures tasks are active during their assigned periods, but doesn't necessarily prevent periods from being active outside of the ones that it specifies in this constraint. For example, if Task 0 uses Asset 1 with duration=3 and starts in Period 2, Constraint 14b ensures Periods 2, 3, and 4 are turned on, but doesn't prevent Task 0 from being active in Periods 0, 1, or 5. Constraint 16 ensures the sum of Xtp variables equals the duration exactly. This method does not seem the cleanest right now, but no other methods were found that were cleaner.\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1bc91077", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 16: Each Task Active For Exactly Its Duration Matrix Construction\n", + "print(\"⚖️ Constraint 16: Each Task Active For Exactly Its Duration\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_eq_16'):\n", + " print(f\"A_eq_16 matrix equations (shape: {scheduler.A_eq_16.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_eq_16, scheduler.b_eq_16)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x = {b_val}\")\n", + "else:\n", + " print(\"No duration matching constraints needed\")" + ] + }, + { + "cell_type": "markdown", + "id": "e47ebb3a", + "metadata": {}, + "source": [ + "In this example, we are constraining the system so that \n", + "\n", + "$$ -2X_{t,a}[0,0] - 3X_{t,a}[0,1] -3X_{t,a}[0,2] + X_{t,p}[0,0] + X_{t,p}[0,1] + X_{t,p}[0,2] + X_{t,p}[0,3] + X_{t,p}[0,4] = 0 $$\n", + "$$ -3X_{t,a}[1,0] - 3X_{t,a}[1,2] + X_{t,p}[1,0] + X_{t,p}[1,1] + X_{t,p}[1,2] + X_{t,p}[1,3] + X_{t,p}[1,4] = 0 $$\n", + "\n", + "which ensures that no matter which Xta pair is selected, per task, that sum of the Xtp variables must equal the corresponding coefficient. If the first asset group is assigned to the first task, then there needs to be only two Xtp[0,p] variables turned on. Constraint 3 ensures that multiple Xta variables per task are not selected. This constraint is used because while Constraint 14b ensures the proper Xtp variables are turned on based on the task's duration, it has no control over the other Xtp variables outside of the start time period plus duration. This constraint provides the upper bound on those Xtp variables." + ] + }, + { + "cell_type": "markdown", + "id": "3261ea5b", + "metadata": {}, + "source": [ + "#### Constraint 4: Asset Conflict Prevention 🚫\n", + "\n", + "**English**: Individual assets cannot be used by multiple tasks simultaneously, even when those assets are part of different asset groups.\n", + "\n", + "**Impact**: Prevents physical resource conflicts where the same vessel would need to be in two places at once, ensuring realistic scheduling.\n", + "\n", + "**Math**: \n", + "$$\n", + "X_{t,a}[t_1,a_1] + X_{t,a}[t_2,a_2] + X_{t,p}[t_1,p] + X_{t,p}[t_2,p] \\leq 3 \\quad \\forall t_1, t_2, a_1, a_2, p \\text{ where individual assets overlap in asset groups}\n", + "$$\n", + "\n", + "**Implementation** for the current example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "506a0fbd", + "metadata": {}, + "outputs": [], + "source": [ + "# Constraint 4: Asset Conflict Prevention Matrix Construction\n", + "print(\"🚫 Constraint 4: Asset Conflict Prevention\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_ub_4'):\n", + " print(f\"A_ub_4 matrix equations (shape: {scheduler.A_ub_4.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_ub_4, scheduler.b_ub_4)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + "else:\n", + " print(\"No individual asset conflicts found - no constraints needed\")" ] }, { "cell_type": "markdown", - "id": "06e0ba5a", + "id": "ef463b1c", "metadata": {}, "source": [ - "### Constraint 1: An asset group can only be assigned to a task if the asset group can perform the task\n", + "In this example, we are constraining the system so that \n", + "\n", + "$ X_{t,a}[0,0] + X_{t,a}[1,0] + X_{t,p}[0,0] + X_{t,p}[1,0] \\leq 3 $\n", + "\n", + "which ensures that if different tasks use the same asset (that are included in different asset groups), then that asset can only be used for one time period. In this case, if both tasks use the first asset group (which has the same assets between each other), then only one task can have their Xtp variables turned on because assets can't be doing two things at once. We also have other constraints like\n", + "\n", + "$ X_{t,a}[0,0] + X_{t,a}[1,2] + X_{t,p}[0,0] + X_{t,p}[1,0] \\leq 3 $\n", "\n", - "Prevents Xta variables that correspond to invalid entries in the task-asset matrix from being turned on." + "which is used because the third asset group has at least one of the same assets that the first asset group has, and so the same rules apply. The implementation checks for similar assets in different asset groups and creates the constraints based on those overlaps. The bound value of 3 in this example is a function of the number of tasks (1 + T)." + ] + }, + { + "cell_type": "markdown", + "id": "4482266b", + "metadata": {}, + "source": [ + "### Constraint Summary 🔗\n", + "\n", + "1. **Constraint 1** ensures only valid assignments are possible\n", + "2. **Constraint 3** ensures every task gets exactly one asset\n", + "3. **Constraint 15** ensures every task starts exactly once \n", + "4. **Constraint 10** ensures the task duration does not exceed planning horizon\n", + "5. **Constraint 14a** links start time to activity period\n", + "6. **Constraint 14b** links start times to activity periods with correct duration\n", + "7. **Constraint 16** equates total activity periods to correct duration\n", + "8. **Constraint 4** prevents resource conflicts between asset groups" + ] + }, + { + "cell_type": "markdown", + "id": "4736b501", + "metadata": {}, + "source": [ + "### Objectives and other MILP Inputs\n", + "\n", + "Beyond the constraint matrices, the MILP optimizer requires three additional key inputs: the objective values vector, variable bounds, and integrality specifications.\n", + "\n", + "#### 1. Objective Values Vector\n", + "\n", + "The `values` vector defines the coefficients for the objective function that the optimizer seeks to minimize. In IRMA's scheduler, this represents the cost or penalty associated with each decision variable.\n", + "\n", + "Each element corresponds to a decision variable and represents the \"cost\" of setting that variable to 1.\n", + "\n", + "$$\\text{minimize } \\mathbf{c}^T \\mathbf{x} = \\sum_{i} c_i x_i$$\n", + "\n", + "where $c_i$ is the cost coefficient and $x_i$ is the binary decision variable.\n", + "\n", + "The costs of each task-asset combination are included as entries to the values vector for each coresponding Xta variable.\n", + "\n", + "Penalties are also included for each Xts variable to incentivize earlier Xts variables to be selected rather than later." ] }, { "cell_type": "code", "execution_count": null, - "id": "5a5ac49f", + "id": "193c7cc0", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "print(scheduler.values)" + ] + }, + { + "cell_type": "markdown", + "id": "bf52c3c9", + "metadata": {}, + "source": [ + "### 2. Bounds\n", + "\n", + "The `bounds` parameter defines the lower and upper limits for each decision variable. In our case, all decision variables are binary (0 or 1), so bounds are set as:\n", + "```python\n", + "bounds = optimize.Bounds(0, 1) # 0 ≤ x_i ≤ 1\n", + "```\n", + "\n", + "**Mathematical Form**: For each variable $x_i$:\n", + "$$0 \\leq x_i \\leq 1$$\n", + "\n", + "\n", + "### 3. Integrality\n", + "\n", + "The `integrality` parameter specifies which variables must take integer values. It forces variables to be integers rather than continuous. In our case, we are only working with binary integers so integrality is set to 1 for all variables:\n", + "```python\n", + "integrality = np.ones(num_variables, dtype=int) # All variables are integers\n", + "```\n", + "\n", + "**Mathematical Form**: Each variable $x_i$ must satisfy:\n", + "$$x_i \\in \\{0, 1\\}$$\n", + "\n", + "Combined with bounds [0,1], this creates binary decision variables that are either \"not selected\" (0) or \"selected\" (1).\n" + ] + }, + { + "cell_type": "markdown", + "id": "2c0bb658", + "metadata": {}, + "source": [ + "## Running the Code\n", + "\n", + "When you run `scheduler.optimize()`, the optimization engine:\n", + "\n", + "1. **Builds** the constraint matrices (A_ub, A_eq, b_ub, b_eq) and MILP inputs\n", + "2. **Solves** the MILP problem using scipy.optimize.milp\n", + "3. **Returns** optimal values for all decision variables\n", + "4. **Decodes** the solution into human-readable schedules\n", + "\n", + "An example of how the optimizer is called:" + ] + }, + { + "cell_type": "markdown", + "id": "5c4a053c", + "metadata": {}, + "source": [ + "```python\n", + "from scipy.optimize import milp\n", + "\n", + "res = milp(\n", + " c=values, # Objective function coefficients\n", + " constraints=constraints, # List of LinearConstraint objects\n", + " integrality=integrality, # Integer specification for each variable\n", + " bounds=bounds # Variable bounds (0 to 1 for binary)\n", + ")\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "eee8140e", + "metadata": {}, + "source": [ + "This formulates and solves the complete Mixed Integer Linear Programming problem:\n", + "- **Minimize**: $\\mathbf{c}^T \\mathbf{x}$ (total cost)\n", + "- **Subject to**: All constraint equations\n", + "- **Where**: $x_i \\in \\{0, 1\\}$ for all $i$ (binary decisions)\n", + "\n", + "Let's see this in action with our simple example!" + ] }, { "cell_type": "code", @@ -377,29 +923,222 @@ "# Solve the optimization problem\n", "print(\"🚀 Solving the optimization problem...\\n\")\n", "\n", - "result_simple = scheduler_simple.solve()\n", - "\n", - "if result_simple.success:\n", - " print(f\"\\n🎉 Optimization successful!\")\n", - " print(f\"💰 Total cost: {result_simple.fun:.0f}\")\n", - " \n", - " # Decode and display the solution\n", - " solution = scheduler_simple.decode_solution(result_simple.x)\n", - " \n", - " print(\"\\n📋 Optimal Schedule:\")\n", - " for task_idx, assignment in solution['task_assignments'].items():\n", - " task_name = tasks_simple[task_idx]['name']\n", - " asset_idx = assignment['asset_group'] # In simple case, asset_group = asset index\n", - " asset_name = assets_simple[asset_idx]['name']\n", - " periods = assignment['periods']\n", - " \n", - " print(f\" 📌 {task_name}:\")\n", - " print(f\" 🚢 Assigned to: {asset_name}\")\n", - " print(f\" ⏰ Active in periods: {periods}\")\n", - " print(f\" ⏱️ Duration: {len(periods)} periods\")\n", + "result = scheduler.optimize()" + ] + }, + { + "cell_type": "markdown", + "id": "8fea43f1", + "metadata": {}, + "source": [ + "The results of the optimization provide a schedule for installation that minimizes cost and sastisfies all constraints!\n", + "\n", + "- The optimization follows our penalty of starting tasks as soon as possible\n", + "- It decides to schedule the \"Install Mooring\" task in the first 3 periods using the MPSV asset\n", + "- It decides to schedule the \"Install Anchor\" task also in the first 3 periods but using a separate vessel, the AHTS asset, which is allowed.\n", + "- This combination of task-asset group assignments minimized cost and kept all of our logical constraints honored.\n", + "\n", + "Now let's adjust the weather to see how that impacts the schedule and the limits of each asset group" + ] + }, + { + "cell_type": "markdown", + "id": "72cf987f", + "metadata": {}, + "source": [ + "### Constraint 17: Weather Restrictions 🌊\n", + "\n", + "**English**: Asset groups cannot be assigned to tasks during periods when weather conditions exceed their operational limits.\n", + "\n", + "**Impact**: Prevents scheduling tasks during unsuitable weather conditions, ensuring safety and operational limits are respected.\n", + "\n", + "**Math**:\n", + "$$\n", + "X_{t,a}[t,a] + X_{t,p}[t,p] \\leq 1 \\quad \\forall t, a, p \\quad \\text{ where } \\quad w_p > \\text{max\\_weather}_a\n", + "$$\n", + "\n", + "where:\n", + "- $w_p$ = weather severity in period $p$\n", + "- $\\text{max\\_weather}_a$ = minimum weather capability across all individual assets in asset group $a$\n", + "\n", + "**Implementation** for our current example (WHILE UPDATING OUR WEATHER CONDITIONS):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "96ec542e", + "metadata": {}, + "outputs": [], + "source": [ + "#scheduler.weather = [1, 2, 3, 3, 1]\n", + "scheduler.weather = [2, 3, 1, 1, 1]\n", + "scheduler.set_up_optimizer()\n", + "\n", + "# Constraint 17: Weather Restrictions Matrix Construction\n", + "print(\"🌊 Constraint 17: Weather Restrictions\")\n", + "print()\n", + "\n", + "if hasattr(scheduler, 'A_ub_17'):\n", + " print(f\"A_ub_17 matrix equations (shape: {scheduler.A_ub_17.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler.A_ub_17, scheduler.b_ub_17)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + "else:\n", + " print(\"No weather restrictions needed - all asset groups can work in all conditions\")" + ] + }, + { + "cell_type": "markdown", + "id": "ddf4089c", + "metadata": {}, + "source": [ + "In this example, we have adjusted the weather conditions of the scenario (1, 2, 2, 3, 1), which adds additional constraints: \n", + "\n", + "$ X_{t,a}[0,0] + X_{t,p}[0,1] \\leq 1 $\n", + "\n", + "which does not allow the first task-asset group combination to be active in the second period, since that period has weather conditions that exceed the allowances of the minimum asset in that asset group. Similarly, there are other constraints like\n", + "\n", + "$ X_{t,a}[0,2] + X_{t,p}[0,0] \\leq 1 \\quad X_{t,a}[0,2] + X_{t,p}[0,1] \\leq 1 $\n", + "\n", + "which does not allow the second asset group combined with the first task to be active in the first or second periods, since those periods have weather conditions that exceed the minimum allowance of the asset in that asset group.\n", + "\n", + "Now, let's rerun the scheduler and see what impact it has." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83598fda", + "metadata": {}, + "outputs": [], + "source": [ + "# Solve the optimization problem\n", + "print(\"🚀 Solving the optimization problem with adjusted weather conditions\\n\")\n", + "\n", + "result = scheduler.optimize()" + ] + }, + { + "cell_type": "markdown", + "id": "3721fb07", + "metadata": {}, + "source": [ + "The results of this new optimization show how the weather impacts the schedule!\n", + "\n", + "- Bad weather in period 1 does not allow any task-asset combination from being scheduled in that period\n", + "- This means that the tasks have to happen in periods 2, 3, and 4\n", + "\n", + "Now let's adjust the weather back to the original calm status and add in dependency constraints" + ] + }, + { + "cell_type": "markdown", + "id": "caba0c18", + "metadata": {}, + "source": [ + "### Constraint 2: Task Dependencies 🔗\n", + "\n", + "**English**: Tasks must be completed in a specific order based on their dependencies. For example, a task cannot start until all its prerequisite tasks have been completed.\n", + "\n", + "**Impact**: Ensures logical sequencing of installation activities - for example, anchors must be installed before mooring lines can be connected to them.\n", + "\n", + "**Math**: For finish_start dependency (most common type):\n", + "$$\n", + "X_{t,s}[t,s] \\leq \\sum_{s_d=0}^{s-d_{min}} X_{t,s}[d,s_d] \\quad \\forall t, s \\quad \\text{ where task } t \\text{ depends on task } d\n", + "$$\n", + "\n", + "where:\n", + "- $d_{min}$ = minimum duration of dependency task $d$ across all possible assets\n", + "- Task $t$ can only start at time $s$ if task $d$ started early enough to finish before time $s$\n", + "\n", + "**Implementation** for our example, which requires re-initializing the scheduler:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef062653", + "metadata": {}, + "outputs": [], + "source": [ + "# Let's create a new scheduler instance with task dependencies\n", + "print(\"🔗 Constraint 2: Task Dependencies\")\n", + "print()\n", + "\n", + "# Define task dependencies: mooring installation depends on anchor installation\n", + "task_dependencies = {\n", + " 'install_mooring': ['install_anchor'] # Mooring installation depends on anchor installation\n", + "}\n", + "\n", + "dependency_types = {\n", + " 'install_anchor->install_mooring': 'finish_start' # Anchor must finish before mooring starts\n", + "}\n", + "\n", + "print(\"📋 Task Dependencies:\")\n", + "print(f\" install_mooring depends on: {task_dependencies['install_mooring']}\")\n", + "print(f\" Dependency type: {dependency_types['install_anchor->install_mooring']}\")\n", + "print()\n", + "\n", + "# Create scheduler with dependencies\n", + "scheduler_with_deps = Scheduler(\n", + " tasks=tasks,\n", + " assets=assets,\n", + " task_asset_matrix=task_asset_matrix,\n", + " weather=[1, 1, 1, 1, 1], # Reset to good weather\n", + " asset_groups=asset_groups,\n", + " task_dependencies=task_dependencies,\n", + " dependency_types=dependency_types,\n", + " wordy=0\n", + ")\n", + "\n", + "scheduler_with_deps.set_up_optimizer()\n", + "\n", + "if hasattr(scheduler_with_deps, 'A_ub_2'):\n", + " print(f\"\\nA_ub_2 matrix equations (shape: {scheduler_with_deps.A_ub_2.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler_with_deps.A_ub_2, scheduler_with_deps.b_ub_2)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", " \n", + " print(f\"\\nConstraint 2 ensures that the mooring installation task cannot start\")\n", + " print(f\"until the anchor installation task has been completed, maintaining logical\")\n", + " print(f\"installation sequencing.\")\n", "else:\n", - " print(f\"❌ Optimization failed: {result_simple.message}\")" + " print(\"No task dependencies defined - no constraints needed\")" + ] + }, + { + "cell_type": "markdown", + "id": "02b1e346", + "metadata": {}, + "source": [ + "In this example, we have updated the dependencies to ensure that Task 0 (Install Mooring) is only done after Task 1 (Install Anchor) is completed, which adds many additional constraints, like:\n", + "\n", + "$ X_{t,a}[1,0] + X_{t,s}[0,0] + X_{t,s}[1,0] \\leq 2 \\quad X_{t,a}[1,0] + X_{t,s}[0,1] + X_{t,s}[1,0] \\leq 2 \\quad X_{t,a}[1,0] + X_{t,s}[0,2] + X_{t,s}[1,0] \\leq 2$\n", + "\n", + "which ensures that if the second task is active with the first asset group, then the first task cannot start in periods 0, 1, or 2 if the second task starts in period 0. (The names of first and second are confusing here, but it's written correctly).\n", + "\n", + "This same logic applies for all other start times for each feasible task-asset combination for the dependent task, blocking it from occupying time periods that would violate the constraint." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ea6a663", + "metadata": {}, + "outputs": [], + "source": [ + "scheduler_with_deps.optimize()" + ] + }, + { + "cell_type": "markdown", + "id": "5e056bfb", + "metadata": {}, + "source": [ + "## A More Complicated Case\n", + "\n", + "Provide an example of a more involved schedule problem with many more assets and tasks..." ] } ], From d983e5d98357f46edf0b660a0660dbc28a020b7d Mon Sep 17 00:00:00 2001 From: Stein Date: Wed, 29 Oct 2025 14:54:16 -0600 Subject: [PATCH 42/63] Accidentally put the scheduler_tutorial.ipynb in the main directory moving to irma folder --- scheduler_tutorial.ipynb => famodel/irma/scheduler_tutorial.ipynb | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename scheduler_tutorial.ipynb => famodel/irma/scheduler_tutorial.ipynb (100%) diff --git a/scheduler_tutorial.ipynb b/famodel/irma/scheduler_tutorial.ipynb similarity index 100% rename from scheduler_tutorial.ipynb rename to famodel/irma/scheduler_tutorial.ipynb From 6fa840d3a4141fcce9ede56734db9556b6192602 Mon Sep 17 00:00:00 2001 From: Stein Date: Wed, 29 Oct 2025 15:06:41 -0600 Subject: [PATCH 43/63] Adding an example file to run scheduler runs/tests - Initial script to hopefully store any kind of test script that can run through the scheduler through its development - This may turn into a test script. It may turn into multiple example files...not sure yet - This is now the second working example of what's currently included (the first is what's at the bottom of scheduler.py) --- famodel/irma/scheduler_example.py | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 famodel/irma/scheduler_example.py diff --git a/famodel/irma/scheduler_example.py b/famodel/irma/scheduler_example.py new file mode 100644 index 00000000..010fdcd4 --- /dev/null +++ b/famodel/irma/scheduler_example.py @@ -0,0 +1,72 @@ +from famodel.irma.scheduler import Scheduler +import numpy as np + + + +# weather +weather = [1, 1, 1, 1, 1] + +# tasks +tasks = [ +{ + 'name': "install_mooring", + 'requirements': ['mooring_reel', 'positioning'] +}, +{ + 'name': "install_anchor", + 'requirements': ['anchor_handling','positioning'] +} +] + +# assets +assets = [ +{ + 'name': 'AHTS', + 'capabilities': ['anchor_handling', 'mooring_reel', 'positioning'], + 'daily_cost': 50000, + 'max_weather': 2 +}, +{ + 'name': 'MPSV', + 'capabilities': ['mooring_reel', 'positioning'], + 'daily_cost': 25000, + 'max_weather': 1 +} +] + +# task-asset matrix +task_asset_matrix = np.array([ + [(2000, 2), (1000, 3), (2500, 3)], + [(1500, 3), (-1, -1), (4000, 2)] +]) + +# asset groups +asset_groups = [ +{ + 'assets': ['AHTS'], +}, +{ + 'assets': ['MPSV'], +}, +{ + 'assets': ['AHTS','MPSV'], +}, +] + +# task dependencies +task_dependencies = { +'install_mooring': ['install_anchor'] # Mooring installation depends on anchor installation +} + +# dependency types +dependency_types = { + 'install_anchor->install_mooring': 'finish_start' # Anchor must finish before mooring starts +} + +# calculate the minimum duration +min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration + +# intialize and run the scheduler +scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration, asset_groups=asset_groups) +scheduler.optimize() + From b4a80e57ea58ed578f10e648618264d8921ff53e Mon Sep 17 00:00:00 2001 From: Stein Date: Wed, 29 Oct 2025 16:17:47 -0600 Subject: [PATCH 44/63] Adding feature to allow dependency offsets - A new 'offsets' dictionary can be included as input to the scheduler, with a key corresponding to a dependency type - The concept is relatively simple and the implementation is not that bad - - The dependency constraint just adds the period value of an offset corresponding to a dependency type and essentially just updates the for loop of the number of start times to consider while making constraints - - - for example, if there was an offset of 1 period for one task to start after another task finished, then the constraints would make more rows to correspond to higher start time constraints - - does this for multiple dependency types and updates the constraints fittingly - also updated some printing out of things - updated examples and the jupyter notebook tutorial to include dependency offsets - however, this doesn't ensure exact offsets - that might come in the next commit --- famodel/irma/scheduler.py | 98 ++++++++++++++++++++------- famodel/irma/scheduler_example.py | 8 ++- famodel/irma/scheduler_tutorial.ipynb | 64 +++++++++++++++++ 3 files changed, 143 insertions(+), 27 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 48c7c681..8470b46d 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -29,7 +29,7 @@ class Scheduler: # Inputs are strictly typed, as this is an integer programming problem (ignored by python at runtime, but helpful for readability and syntax checking). - def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, weather : list[int] = [], period_duration : float = 1, asset_groups : list[dict] = [], **kwargs): + def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, offsets = {}, weather : list[int] = [], period_duration : float = 1, asset_groups : list[dict] = [], **kwargs): ''' Initializes the Scheduler with assets, tasks, and constraints. @@ -52,6 +52,12 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset - "start_finish": dependent task finishes when prerequisite starts - "offset": dependent task starts/finishes with time offset (requires offset value) - "same_asset": dependent task must use same asset as prerequisite + offsets : dict + A dictionary mapping each task dependency pair to its time offset (in periods). + Used with dependency types to specify minimum time delays. For example: + - With "finish_start": dependent task starts at least 'offset' periods after prerequisite finishes + - With "start_start": dependent task starts at least 'offset' periods after prerequisite starts + Example: {"task1->task2": 3} means task2 must wait 3 periods after task1 constraint is satisfied weather : list A list of weather windows. The length of this list defines the number of discrete time periods available for scheduling. period_duration : float @@ -78,6 +84,7 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset self.weather = weather self.task_dependencies = task_dependencies self.dependency_types = dependency_types + self.offsets = offsets self.period_duration = period_duration # duration of each scheduling period. Used for converting from periods to real time. # --- Check for valid inputs --- @@ -397,11 +404,14 @@ def set_up_optimizer(self, goal : str = "cost"): dep_key = f"{dep_task_name}->{task_name}" dep_type = self.dependency_types.get(dep_key, "finish_start") + # Get time offset (default to 0) + offset = self.offsets.get(dep_key, 0) + if dep_type == "finish_start": - # Task t cannot start until task d finishes + # Task t cannot start until task d finishes + offset periods # We need to create constraints for each possible asset-duration combination # If task d uses asset a_d with duration dur_d and starts at time sd, - # then task t cannot start before time (sd + dur_d) + # then task t cannot start before time (sd + dur_d + offset) for a_d in range(self.A): duration_d = self.task_asset_matrix[d, a_d, 1] @@ -409,10 +419,10 @@ def set_up_optimizer(self, goal : str = "cost"): continue for sd in range(self.S): # For each possible start time of dependency task - finish_time_d = sd + duration_d # When task d finishes + finish_time_d = sd + duration_d + offset # When task d finishes + offset - # Task t cannot start before task d finishes - for s in range(min(finish_time_d, self.S)): # All start times before finish + # Task t cannot start before task d finishes + offset + for s in range(min(finish_time_d, self.S)): # All start times before finish + offset # Create constraint: if task d uses asset a_d and starts at sd, # then task t cannot start at time s # Constraint: Xta[d,a_d] + Xts[d,sd] + Xts[t,s] <= 2 @@ -425,36 +435,43 @@ def set_up_optimizer(self, goal : str = "cost"): vec_2.append(2) # At most 2 of these 3 can be 1 simultaneously elif dep_type == "start_start": - # Task t starts when task d starts (same start time) - for s in range(self.S): - row = np.zeros(num_variables, dtype=int) - row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] - row[self.Xts_start + d * self.S + s] = -1 # -Xts[d,s] - rows_2.append(row) - vec_2.append(0) # Xts[t,s] - Xts[d,s] <= 0, so Xts[t,s] <= Xts[d,s] + # Task t starts when task d starts + offset periods + # Task t cannot start before task d starts + offset + for s_d in range(self.S): # For each possible start time of dependency task + earliest_start_t = s_d + offset # Earliest start time for task t + + # Task t cannot start before earliest_start_t + for s_t in range(min(earliest_start_t, self.S)): # All start times before allowed + # Create constraint: if task d starts at s_d, then task t cannot start at s_t + # Constraint: Xts[d,s_d] + Xts[t,s_t] <= 1 + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] + row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] + rows_2.append(row) + vec_2.append(1) # At most 1 of these 2 can be 1 simultaneously elif dep_type == "finish_finish": - # Task t finishes when task d finishes - # This requires both tasks to have the same end time + # Task t finishes when task d finishes + offset periods + # This requires task t to finish at (task d finish time + offset) for s_t in range(self.S): for a_t in range(self.A): duration_t = self.task_asset_matrix[t, a_t, 1] if duration_t > 0: # Valid pairing for task t end_time_t = s_t + duration_t - # Find start times for task d that result in same end time + # Find start times for task d that result in compatible end times for s_d in range(self.S): for a_d in range(self.A): duration_d = self.task_asset_matrix[d, a_d, 1] if duration_d > 0: # Valid pairing for task d end_time_d = s_d + duration_d - if end_time_t == end_time_d: + if end_time_t == end_time_d + offset: # If task t starts at s_t with asset a_t AND task d starts at s_d with asset a_d, - # then they finish at the same time (constraint satisfied) + # then they finish with correct offset (constraint satisfied) continue else: - # Prevent this combination + # Prevent this combination if it doesn't satisfy offset row = np.zeros(num_variables, dtype=int) row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] row[self.Xta_start + t * self.A + a_t] = 1 # Xta[t,a_t] @@ -1128,14 +1145,39 @@ def optimize(self, threads = -1): a_assigned = np.argmax(Xta[t, :]) # assumes only one asset per task cost = self.task_asset_matrix[t, a_assigned, 0] duration = self.task_asset_matrix[t, a_assigned, 1] - asset_name = self.assets[a_assigned].get('name', f'Asset {a_assigned}') + + # Get asset group information + asset_group = self.asset_groups[a_assigned] + if isinstance(asset_group, dict): + # Handle different asset group formats + if 'assets' in asset_group: + # Format: {'assets': ['asset1', 'asset2']} + asset_list = asset_group['assets'] + if isinstance(asset_list, list) and len(asset_list) > 0: + asset_name = f"Group({', '.join(asset_list)})" + else: + asset_name = f"Asset Group {a_assigned}" + else: + # Format: {'group_name': ['asset1', 'asset2']} + group_names = list(asset_group.keys()) + if group_names: + group_name = group_names[0] # Take first group name + asset_list = asset_group[group_name] + if isinstance(asset_list, list): + asset_name = f"{group_name}({', '.join(asset_list)})" + else: + asset_name = group_name + else: + asset_name = f"Asset Group {a_assigned}" + else: + asset_name = f"Asset Group {a_assigned}" # Format with fixed widths for proper alignment - task_info = f"{asset_name:<15} → Task {t:2d} (cost: {cost:6.0f}, dur: {duration:2d})" - pstring += f"{task_info:<50} | " + task_info = f"{asset_name:<20} → Task {t:2d} (cost: {cost:6.0f}, dur: {duration:2d})" + pstring += f"{task_info:<55} | " else: # Empty slot with proper spacing - pstring += f"{'':50} | " + pstring += f"{'':55} | " print(pstring) @@ -1162,8 +1204,8 @@ def optimize(self, threads = -1): {"name": "light_asset", "max_weather": 1} # Can only work in calm weather (1) ] asset_groups = [ - {'group1': ['heavy_asset']}, - {'group2': ['light_asset', 'heavy_asset']}, + {'assets': ['heavy_asset']}, + {'assets': ['light_asset', 'heavy_asset']}, ] # task dependencies @@ -1176,6 +1218,10 @@ def optimize(self, threads = -1): dependency_types = { "task1->task2": "finish_start" # task2 starts after task1 finishes } + + # offsets (optional - defaults to 0 if not specified) + # In this example, no offset is needed - task2 can start immediately after task1 finishes + offsets = {} # cost and duration tuples for each task-asset pair. -1 indicates asset-task paring is invalid task_asset_matrix = np.array([ @@ -1192,7 +1238,7 @@ def optimize(self, threads = -1): min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration # Sandbox for building out the scheduler - scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration, asset_groups=asset_groups) + scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, offsets, weather, min_duration, asset_groups=asset_groups) scheduler.optimize() a = 2 diff --git a/famodel/irma/scheduler_example.py b/famodel/irma/scheduler_example.py index 010fdcd4..fa914028 100644 --- a/famodel/irma/scheduler_example.py +++ b/famodel/irma/scheduler_example.py @@ -63,10 +63,16 @@ 'install_anchor->install_mooring': 'finish_start' # Anchor must finish before mooring starts } +offsets = { + 'install_anchor->install_mooring': 0 # Mooring installation to start 1 period after Anchor installation +} + # calculate the minimum duration min_duration = np.min(task_asset_matrix[:, :, 1][task_asset_matrix[:, :, 1] > 0]) # minimum non-zero duration # intialize and run the scheduler -scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, weather, min_duration, asset_groups=asset_groups) +scheduler = Scheduler(task_asset_matrix, tasks, assets, task_dependencies, dependency_types, offsets, weather, min_duration, asset_groups=asset_groups) scheduler.optimize() +a = 2 + diff --git a/famodel/irma/scheduler_tutorial.ipynb b/famodel/irma/scheduler_tutorial.ipynb index 7c072f56..adeba461 100644 --- a/famodel/irma/scheduler_tutorial.ipynb +++ b/famodel/irma/scheduler_tutorial.ipynb @@ -1131,6 +1131,70 @@ "scheduler_with_deps.optimize()" ] }, + { + "cell_type": "markdown", + "id": "0f487893", + "metadata": {}, + "source": [ + "### Constraint 2+: Task Dependencies with Offsets\n", + "\n", + "We now showcase how we can add offsets to the dependencies.\n", + "\n", + "As a basic example, how would the scheduling work if Task 0 (Install Mooring) were to start one period after Task 1 (Install Anchor) starts?\n", + "\n", + "Let's run another example:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34f584ef", + "metadata": {}, + "outputs": [], + "source": [ + "# Define task dependencies: mooring installation depends on anchor installation\n", + "task_dependencies = {\n", + " 'install_mooring': ['install_anchor'] # Mooring installation depends on anchor installation\n", + "}\n", + "\n", + "dependency_types = {\n", + " 'install_anchor->install_mooring': 'start_start' # Anchor must finish before mooring starts\n", + "}\n", + "\n", + "offsets = {\n", + " 'install_anchor->install_mooring': 1 # Mooring installation to start 1 period after Anchor installation\n", + "}\n", + "\n", + "# Create scheduler with dependencies\n", + "scheduler_with_offset_dep = Scheduler(\n", + " tasks=tasks,\n", + " assets=assets,\n", + " task_asset_matrix=task_asset_matrix,\n", + " task_dependencies=task_dependencies,\n", + " dependency_types=dependency_types,\n", + " offsets=offsets,\n", + " weather=[1, 1, 1, 1, 1], # Reset to good weather\n", + " asset_groups=asset_groups,\n", + " wordy=0\n", + ")\n", + "\n", + "scheduler_with_offset_dep.set_up_optimizer()\n", + "\n", + "if hasattr(scheduler_with_deps, 'A_ub_2'):\n", + " print(f\"\\nA_ub_2 matrix equations (shape: {scheduler_with_deps.A_ub_2.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler_with_deps.A_ub_2, scheduler_with_deps.b_ub_2)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + " \n", + " print(f\"\\nConstraint 2 ensures that the mooring installation task cannot start\")\n", + " print(f\"until the anchor installation task has been completed, maintaining logical\")\n", + " print(f\"installation sequencing.\")\n", + "else:\n", + " print(\"No task dependencies defined - no constraints needed\")\n", + "\n", + "scheduler_with_offset_dep.optimize()" + ] + }, { "cell_type": "markdown", "id": "5e056bfb", From 58cf84473e36b09ce7cef3655a99083db08f1d6b Mon Sep 17 00:00:00 2001 From: Stein Date: Wed, 29 Oct 2025 17:02:35 -0600 Subject: [PATCH 45/63] Updating dependency offset feature for 'minimum' and 'exact' - Realized that the previous commit didn't explicitly set the offset of one task to be exactly the specified amount after the task it's dependent on - This required splitting up the inputs into 'minimum' and 'exact' to allow for each scenario - - I'm not sure off-hand if we'll need each scenario, but I guess it's good to have just in case - Large change to the creation of Constraint 2, which mainly just split the process up into whether it's 'minimum' or 'exact' and used some nifty math to ensure that will be the case - Also added a new _parse_offset function to differentiate between different offset types and allow for different input formats (which are listed in the documentation of __init__) - Things are tested in scheduler_example and in the scheduler_tutorial, relatively - - I have this gut feeling that later tests/examples may encounter some small issues, but for the most part, the implementation should be good --- famodel/irma/scheduler.py | 155 ++++++++++++++++++++------ famodel/irma/scheduler_example.py | 5 +- famodel/irma/scheduler_tutorial.ipynb | 133 ++++++++++++++++++---- 3 files changed, 234 insertions(+), 59 deletions(-) diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 8470b46d..38fee4d4 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -54,10 +54,17 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset - "same_asset": dependent task must use same asset as prerequisite offsets : dict A dictionary mapping each task dependency pair to its time offset (in periods). - Used with dependency types to specify minimum time delays. For example: - - With "finish_start": dependent task starts at least 'offset' periods after prerequisite finishes - - With "start_start": dependent task starts at least 'offset' periods after prerequisite starts - Example: {"task1->task2": 3} means task2 must wait 3 periods after task1 constraint is satisfied + Can specify either minimum delays or exact timing requirements: + + **Format Options:** + 1. Simple: {"task1->task2": 3} - defaults to minimum offset + 2. Tuple: {"task1->task2": (3, "exact")} - specify offset type + 3. Dict: {"task1->task2": {"value": 3, "type": "minimum"}} + + **Offset Types:** + - "minimum": dependent task waits AT LEAST offset periods (default) + - "exact": dependent task waits EXACTLY offset periods + weather : list A list of weather windows. The length of this list defines the number of discrete time periods available for scheduling. period_duration : float @@ -140,6 +147,42 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset if wordy > 0: print(f"Scheduler initialized with {self.P} time periods, {self.T} tasks, {self.A} asset groups, and {self.S} start times.") + + def _parse_offset(self, dep_key): + """ + Parse offset value and type from the offsets dictionary. + + Returns: + tuple: (offset_value, offset_type) where offset_type is 'minimum' or 'exact' + """ + if dep_key not in self.offsets: + return 0, 'minimum' # Default: no offset, minimum type + + offset_spec = self.offsets[dep_key] + + # Handle different input formats + if isinstance(offset_spec, (int, float)): + # Simple format: {"task1->task2": 3} + return int(offset_spec), 'minimum' + elif isinstance(offset_spec, tuple) and len(offset_spec) == 2: + # Tuple format: {"task1->task2": (3, "exact")} + value, offset_type = offset_spec + if offset_type not in ['minimum', 'exact']: + raise ValueError(f"Invalid offset type '{offset_type}'. Must be 'minimum' or 'exact'") + return int(value), offset_type + elif isinstance(offset_spec, dict): + # Dictionary format: {"task1->task2": {"value": 3, "type": "minimum"}} + if 'value' not in offset_spec: + raise ValueError(f"Offset specification for '{dep_key}' missing 'value' key") + value = int(offset_spec['value']) + offset_type = offset_spec.get('type', 'minimum') + if offset_type not in ['minimum', 'exact']: + raise ValueError(f"Invalid offset type '{offset_type}'. Must be 'minimum' or 'exact'") + return value, offset_type + else: + raise ValueError(f"Invalid offset specification for '{dep_key}'. Must be int, tuple, or dict") + + def _initialize_asset_groups(self): ''' Initialize asset group mappings for conflict detection. @@ -404,14 +447,11 @@ def set_up_optimizer(self, goal : str = "cost"): dep_key = f"{dep_task_name}->{task_name}" dep_type = self.dependency_types.get(dep_key, "finish_start") - # Get time offset (default to 0) - offset = self.offsets.get(dep_key, 0) + # Parse offset value and type + offset_value, offset_type = self._parse_offset(dep_key) if dep_type == "finish_start": - # Task t cannot start until task d finishes + offset periods - # We need to create constraints for each possible asset-duration combination - # If task d uses asset a_d with duration dur_d and starts at time sd, - # then task t cannot start before time (sd + dur_d + offset) + # Handle both minimum and exact offset types for finish_start dependencies for a_d in range(self.A): duration_d = self.task_asset_matrix[d, a_d, 1] @@ -419,36 +459,79 @@ def set_up_optimizer(self, goal : str = "cost"): continue for sd in range(self.S): # For each possible start time of dependency task - finish_time_d = sd + duration_d + offset # When task d finishes + offset + finish_time_d = sd + duration_d # When task d finishes - # Task t cannot start before task d finishes + offset - for s in range(min(finish_time_d, self.S)): # All start times before finish + offset - # Create constraint: if task d uses asset a_d and starts at sd, - # then task t cannot start at time s - # Constraint: Xta[d,a_d] + Xts[d,sd] + Xts[t,s] <= 2 - # Logical: (Xta[d,a_d]=1 AND Xts[d,sd]=1) → Xts[t,s]=0 - row = np.zeros(num_variables, dtype=int) - row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] - row[self.Xts_start + d * self.S + sd] = 1 # Xts[d,sd] - row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] - rows_2.append(row) - vec_2.append(2) # At most 2 of these 3 can be 1 simultaneously + if offset_type == "minimum": + # Task t cannot start before (finish_time_d + offset_value) + earliest_start_t = finish_time_d + offset_value + for s in range(min(earliest_start_t, self.S)): # All start times before minimum allowed + # Constraint: Xta[d,a_d] + Xts[d,sd] + Xts[t,s] <= 2 + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] + row[self.Xts_start + d * self.S + sd] = 1 # Xts[d,sd] + row[self.Xts_start + t * self.S + s] = 1 # Xts[t,s] + rows_2.append(row) + vec_2.append(2) # At most 2 of these 3 can be 1 simultaneously + + elif offset_type == "exact": + # Task t must start exactly at (finish_time_d + offset_value) + exact_start_t = finish_time_d + offset_value + if exact_start_t < self.S: # Valid start time + # Constraint: if task d uses asset a_d and starts at sd, + # then task t must start at exact_start_t + # Constraint: Xta[d,a_d] + Xts[d,sd] - Xts[t,exact_start_t] <= 1 + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] + row[self.Xts_start + d * self.S + sd] = 1 # Xts[d,sd] + row[self.Xts_start + t * self.S + exact_start_t] = -1 # -Xts[t,exact_start_t] + rows_2.append(row) + vec_2.append(1) # Xta[d,a_d] + Xts[d,sd] - Xts[t,exact_start_t] <= 1 + + # Also prevent task t from starting at any other time when d is active + for s_other in range(self.S): + if s_other != exact_start_t: + row = np.zeros(num_variables, dtype=int) + row[self.Xta_start + d * self.A + a_d] = 1 # Xta[d,a_d] + row[self.Xts_start + d * self.S + sd] = 1 # Xts[d,sd] + row[self.Xts_start + t * self.S + s_other] = 1 # Xts[t,s_other] + rows_2.append(row) + vec_2.append(2) # At most 2 of these 3 can be 1 simultaneously elif dep_type == "start_start": - # Task t starts when task d starts + offset periods - # Task t cannot start before task d starts + offset + # Handle both minimum and exact offset types for start_start dependencies + for s_d in range(self.S): # For each possible start time of dependency task - earliest_start_t = s_d + offset # Earliest start time for task t - - # Task t cannot start before earliest_start_t - for s_t in range(min(earliest_start_t, self.S)): # All start times before allowed - # Create constraint: if task d starts at s_d, then task t cannot start at s_t - # Constraint: Xts[d,s_d] + Xts[t,s_t] <= 1 - row = np.zeros(num_variables, dtype=int) - row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] - row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] - rows_2.append(row) - vec_2.append(1) # At most 1 of these 2 can be 1 simultaneously + if offset_type == "minimum": + # Task t cannot start before (task d start time + offset_value) + earliest_start_t = s_d + offset_value + for s_t in range(min(earliest_start_t, self.S)): # All start times before minimum allowed + # Constraint: Xts[d,s_d] + Xts[t,s_t] <= 1 + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] + row[self.Xts_start + t * self.S + s_t] = 1 # Xts[t,s_t] + rows_2.append(row) + vec_2.append(1) # At most 1 of these 2 can be 1 simultaneously + + elif offset_type == "exact": + # Task t must start exactly at (task d start time + offset_value) + exact_start_t = s_d + offset_value + if exact_start_t < self.S: # Valid start time + # Constraint: if task d starts at s_d, then task t must start at exact_start_t + # Constraint: Xts[d,s_d] - Xts[t,exact_start_t] <= 0 + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] + row[self.Xts_start + t * self.S + exact_start_t] = -1 # -Xts[t,exact_start_t] + rows_2.append(row) + vec_2.append(0) # Xts[d,s_d] - Xts[t,exact_start_t] <= 0 + + # Also prevent task t from starting at any other time when d starts at s_d + for s_other in range(self.S): + if s_other != exact_start_t: + row = np.zeros(num_variables, dtype=int) + row[self.Xts_start + d * self.S + s_d] = 1 # Xts[d,s_d] + row[self.Xts_start + t * self.S + s_other] = 1 # Xts[t,s_other] + rows_2.append(row) + vec_2.append(1) # At most 1 of these 2 can be 1 simultaneously elif dep_type == "finish_finish": # Task t finishes when task d finishes + offset periods diff --git a/famodel/irma/scheduler_example.py b/famodel/irma/scheduler_example.py index fa914028..daa74376 100644 --- a/famodel/irma/scheduler_example.py +++ b/famodel/irma/scheduler_example.py @@ -60,11 +60,12 @@ # dependency types dependency_types = { - 'install_anchor->install_mooring': 'finish_start' # Anchor must finish before mooring starts + 'install_anchor->install_mooring': 'start_start' # Anchor must finish before mooring starts } offsets = { - 'install_anchor->install_mooring': 0 # Mooring installation to start 1 period after Anchor installation + #'install_anchor->install_mooring': 1 # Mooring installation to start 1 period after Anchor installation + 'install_anchor->install_mooring': (1, 'exact') # Tuple format: (value, type) } # calculate the minimum duration diff --git a/famodel/irma/scheduler_tutorial.ipynb b/famodel/irma/scheduler_tutorial.ipynb index adeba461..32247128 100644 --- a/famodel/irma/scheduler_tutorial.ipynb +++ b/famodel/irma/scheduler_tutorial.ipynb @@ -1133,22 +1133,44 @@ }, { "cell_type": "markdown", - "id": "0f487893", + "id": "1fff95eb", "metadata": {}, "source": [ - "### Constraint 2+: Task Dependencies with Offsets\n", + "### Constraint 2+: Task Dependencies with Offset Types\n", "\n", - "We now showcase how we can add offsets to the dependencies.\n", + "The IRMA scheduler now supports two types of time offsets: **minimum** and **exact**.\n", "\n", - "As a basic example, how would the scheduling work if Task 0 (Install Mooring) were to start one period after Task 1 (Install Anchor) starts?\n", + "- **Minimum**: Task must wait **at least** the specified number of periods\n", + "- **Exact**: Task must start/finish **exactly** the specified number of periods later\n", "\n", - "Let's run another example:" + "Format Options:\n", + "\n", + "```python\n", + "# Option 1: Simple format (defaults to minimum)\n", + "offsets = {\n", + " 'task1->task2': 3 # Minimum 3 periods\n", + "}\n", + "\n", + "# Option 2: Tuple format (specify type)\n", + "offsets = {\n", + " 'task1->task2': (3, 'exact'), # Exactly 3 periods\n", + " 'task1->task3': (2, 'minimum') # At least 2 periods\n", + "}\n", + "\n", + "# Option 3: Dictionary format (most explicit)\n", + "offsets = {\n", + " 'task1->task2': {'value': 3, 'type': 'exact'},\n", + " 'task1->task3': {'value': 2, 'type': 'minimum'}\n", + "}\n", + "```\n", + "\n", + "Let's demonstrate the difference with a practical example:" ] }, { "cell_type": "code", "execution_count": null, - "id": "34f584ef", + "id": "4d924838", "metadata": {}, "outputs": [], "source": [ @@ -1158,41 +1180,110 @@ "}\n", "\n", "dependency_types = {\n", - " 'install_anchor->install_mooring': 'start_start' # Anchor must finish before mooring starts\n", + " 'install_anchor->install_mooring': 'start_start' # Mooring starts relative to when anchor starts\n", "}\n", "\n", - "offsets = {\n", - " 'install_anchor->install_mooring': 1 # Mooring installation to start 1 period after Anchor installation\n", + "# Test 1: Minimum offset (default behavior)\n", + "print(\"\\n📋 Test 1: MINIMUM Offset\")\n", + "print(\"Task setup: mooring starts AT LEAST 1 period after anchor starts\")\n", + "\n", + "offsets_min = {\n", + " 'install_anchor->install_mooring': 1 # Simple format defaults to minimum\n", "}\n", "\n", - "# Create scheduler with dependencies\n", - "scheduler_with_offset_dep = Scheduler(\n", + "scheduler_min = Scheduler(\n", " tasks=tasks,\n", " assets=assets,\n", " task_asset_matrix=task_asset_matrix,\n", " task_dependencies=task_dependencies,\n", " dependency_types=dependency_types,\n", - " offsets=offsets,\n", - " weather=[1, 1, 1, 1, 1], # Reset to good weather\n", + " offsets=offsets_min,\n", + " weather=[1, 1, 1, 1, 1],\n", " asset_groups=asset_groups,\n", " wordy=0\n", ")\n", "\n", - "scheduler_with_offset_dep.set_up_optimizer()\n", + "scheduler_min.set_up_optimizer()\n", "\n", - "if hasattr(scheduler_with_deps, 'A_ub_2'):\n", - " print(f\"\\nA_ub_2 matrix equations (shape: {scheduler_with_deps.A_ub_2.shape}):\")\n", - " for i, (row, b_val) in enumerate(zip(scheduler_with_deps.A_ub_2, scheduler_with_deps.b_ub_2)):\n", + "if hasattr(scheduler_min, 'A_ub_2'):\n", + " print(f\"\\nA_ub_2 matrix equations (shape: {scheduler_min.A_ub_2.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler_min.A_ub_2, scheduler_min.b_ub_2)):\n", " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", " print(f\"{row_str} x ≤ {b_val}\")\n", " \n", - " print(f\"\\nConstraint 2 ensures that the mooring installation task cannot start\")\n", - " print(f\"until the anchor installation task has been completed, maintaining logical\")\n", - " print(f\"installation sequencing.\")\n", + " print(f\"\\nConstraint 2 can ensure that the mooring installation task cannot start\")\n", + " print(f\"until AT LEAST 1 period after the anchor installation task has been\")\n", + " print(f\"completed, maintaining logical installation sequencing.\")\n", "else:\n", " print(\"No task dependencies defined - no constraints needed\")\n", "\n", - "scheduler_with_offset_dep.optimize()" + "result_min = scheduler_min.optimize()" + ] + }, + { + "cell_type": "markdown", + "id": "38f7e788", + "metadata": {}, + "source": [ + "**Notes**:\n", + "- This minimum example does coincidentally allows Task 0 to start 1 period after the start of Task 1, but it wasn't required too.\n", + "- Using the dependency type of 'finish_start' creates 1's in Xta variables in the constraints. Using 'start_start' does not include 1's in the constraint row" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa908935", + "metadata": {}, + "outputs": [], + "source": [ + "# Test 2: Exact offset\n", + "print(\"\\n📋 Test 2: EXACT Offset\")\n", + "print(\"Task setup: mooring starts EXACTLY 1 period after anchor starts\")\n", + "\n", + "offsets_exact = {\n", + " 'install_anchor->install_mooring': (1, 'exact') # Tuple format: (value, type)\n", + "}\n", + "\n", + "scheduler_exact = Scheduler(\n", + " tasks=tasks,\n", + " assets=assets,\n", + " task_asset_matrix=task_asset_matrix,\n", + " task_dependencies=task_dependencies,\n", + " dependency_types=dependency_types,\n", + " offsets=offsets_exact,\n", + " weather=[1, 1, 1, 1, 1],\n", + " asset_groups=asset_groups,\n", + " wordy=0\n", + ")\n", + "\n", + "scheduler_exact.set_up_optimizer()\n", + "\n", + "if hasattr(scheduler_exact, 'A_ub_2'):\n", + " print(f\"\\nA_ub_2 matrix equations (shape: {scheduler_exact.A_ub_2.shape}):\")\n", + " for i, (row, b_val) in enumerate(zip(scheduler_exact.A_ub_2, scheduler_exact.b_ub_2)):\n", + " row_str = '[' + ' '.join([str(val) for val in row]) + ']'\n", + " print(f\"{row_str} x ≤ {b_val}\")\n", + " \n", + " print(f\"\\nConstraint 2 can also ensure that the mooring installation task cannot start\")\n", + " print(f\"until EXACTLY 1 period after the anchor installation task has been completed, \")\n", + " print(f\"maintaining logical installation sequencing.\")\n", + "else:\n", + " print(\"No task dependencies defined - no constraints needed\")\n", + "\n", + "result_exact = scheduler_exact.optimize()\n" + ] + }, + { + "cell_type": "markdown", + "id": "2969cb3f", + "metadata": {}, + "source": [ + "Can add more explanation for how the constraints look for different dependency offsets.\n", + "\n", + "They mainly update the $X_{t,s}$ variables to ensure each task does not start in a period that would violate the dependency.\n", + "\n", + "There are some -1's in the 'exact' method to ensure that a certain start period does exactly happen for a task" ] }, { From 6356ba41a40845d6fe8cf018841d8a13dda37aac Mon Sep 17 00:00:00 2001 From: Stein Date: Thu, 30 Oct 2025 20:25:54 -0600 Subject: [PATCH 46/63] Updated the scheduler tutorial to explain more about the dependency offset constraints - explained differences between 'minimum' and 'exact' in how the constraints work --- famodel/irma/scheduler_tutorial.ipynb | 28 +++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/famodel/irma/scheduler_tutorial.ipynb b/famodel/irma/scheduler_tutorial.ipynb index 32247128..abb7fe5b 100644 --- a/famodel/irma/scheduler_tutorial.ipynb +++ b/famodel/irma/scheduler_tutorial.ipynb @@ -1225,9 +1225,15 @@ "id": "38f7e788", "metadata": {}, "source": [ + "In this example, we have additional inputs that specify that Task 0 (Install Mooring) must be done AT LEAST 1 period (offset) after Task 1 (Install Anchor) starts. The constraints that it makes are like the following:\n", + "\n", + "$ X_{t,s}[0,0] + X_{t,s}[1,0] \\leq 1 \\quad X_{t,s}[0,0] + X_{t,s}[1,1] \\leq 1 \\quad X_{t,s}[0,1] + X_{t,s}[1,1] \\leq 1 $\n", + "\n", + "which says that if Task 1 starts in period 0, then Task 0 cannot start in period 0 (but it could start anywhere else). Similarly, if Task 1 starts in period 1, then Task 0 cannot start in period 0 nor 1.\n", + "\n", "**Notes**:\n", - "- This minimum example does coincidentally allows Task 0 to start 1 period after the start of Task 1, but it wasn't required too.\n", - "- Using the dependency type of 'finish_start' creates 1's in Xta variables in the constraints. Using 'start_start' does not include 1's in the constraint row" + "- Task 0 does not have to start exactly 1 period after Task 1 starts (for this case), but it will try too since there are penalty objective values on later start times\n", + "- Using the dependency type of 'finish_start' creates 1's in Xta variables in the constraints because the duration of the 'finish' depends on the duration of the specific task-asset group combination. Using 'start_start' does not include 1's in the constraint row because it does not require information about the duration." ] }, { @@ -1271,7 +1277,7 @@ "else:\n", " print(\"No task dependencies defined - no constraints needed\")\n", "\n", - "result_exact = scheduler_exact.optimize()\n" + "result_exact = scheduler_exact.optimize()" ] }, { @@ -1279,11 +1285,21 @@ "id": "2969cb3f", "metadata": {}, "source": [ - "Can add more explanation for how the constraints look for different dependency offsets.\n", + "In this example, we run the same scenario but specify that Task 0 MUST start EXACTLY 1 period after the start of Task 1. The constraints are made like the following\n", "\n", - "They mainly update the $X_{t,s}$ variables to ensure each task does not start in a period that would violate the dependency.\n", + "$$ -X_{t,s}[0,1] + X_{t,s}[1,0] \\leq 0 $$\n", "\n", - "There are some -1's in the 'exact' method to ensure that a certain start period does exactly happen for a task" + "$$ X_{t,s}[0,0] + X_{t,s}[1,0] \\leq 1 \\quad X_{t,s}[0,2] + X_{t,s}[1,0] \\leq 1 \\quad X_{t,s}[0,3] + X_{t,s}[1,0] \\leq 1 $$\n", + "\n", + "which says in the first equation first, if Task 1 starts in period 0, then Task 0 must start in period 1. And in the second set of equations, if Task 1 starts in period 0, then Task 0 cannot start in any other period (0, 2, 3, 4)." + ] + }, + { + "cell_type": "markdown", + "id": "677af285", + "metadata": {}, + "source": [ + "Can add more explanations/examples for different dependency types and offsets" ] }, { From 96c8ffb0d16fef82e8b2c2de211008c5a9310bea Mon Sep 17 00:00:00 2001 From: Stein Date: Fri, 31 Oct 2025 14:29:03 -0600 Subject: [PATCH 47/63] New script to generate task-asset matrices (and wordy help) - updated all of Ryan's old 'wordy' sections to print more clearly what the constraints are - the task_asset_generator script is not meant for a permanent solution; it's meant to provide some kind of example of what we will need to do to generate task-asset matrix information based on certain task and asset inputs TaskAssetGroupGenerator - A new class to help organize task and asset input info in creating the proper inputs to the scheduler WORKFLOW 0) Start with a list of tasks and assets in a certain format - The user can input a certain 'strategy' (like a LineDesign configuration) to specify what tasks are needed - Meaning, the parameterization of tasks should be defined by the user (or at least the strategy of how the tasks should be parameterized) 1) 'Validate' the task and asset definitions - makes sure the input format is how it should be and all entries are standardized 2) Generate feasible asset groups - make 'groups' of each individual asset (like normal), and then build combinations of each asset (up to a maximum group size) and evaluate whether that group is operationally feasible - operationally feasible is based off of weather, capabilities, overlapping capabilities, total cost, etc. - if feasible, then we can calculate their group properties (to match the dictionary format of asset groups) - - sum costs, minimum weather, etc. - (!! we can change these later !!) 3) Match tasks to asset groups - determine if asset group has the capabilities to perform each task (and works with its weather rating) - if they're compatible, calculate the cost and duration (!! can also be updated later !!) - creates a list of feasible groups per task that get stored in 'task_asset_matches' 4) Build task-asset matrix - builds the matrix off of 'task_asset_matches' with -1's as defaults - the 'sparseness' is a pre-processing step in evaluating the feasibility of each task-asset combo --- famodel/irma/scheduler.py | 164 ++++++++- famodel/irma/task_asset_generator.py | 486 +++++++++++++++++++++++++++ 2 files changed, 648 insertions(+), 2 deletions(-) create mode 100644 famodel/irma/task_asset_generator.py diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index 38fee4d4..fbceae14 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -24,7 +24,7 @@ import numpy as np import os -wordy = 1 # level of verbosity for print statements +wordy = 2 # level of verbosity for print statements class Scheduler: @@ -387,6 +387,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.b_eq_1 = np.zeros(self.A_eq_1.shape[0], dtype=int) if wordy > 1: + ''' print("A_eq_1^T:") for i in range(self.Xta_start,self.Xta_end): pstring = str(self.X_indices[i]) @@ -394,6 +395,13 @@ def set_up_optimizer(self, goal : str = "cost"): pstring += f"{ column:5}" print(pstring) print("b_eq_1: ", self.b_eq_1) + ''' + print("Constraint 1 details:") + for i, row in enumerate(self.A_eq_1): + xta_idx = np.where(row == 1)[0][0] - self.Xta_start + t = xta_idx // self.A + a = xta_idx % self.A + print(f" Invalid pairing: Xta[{t},{a}] = 0") A_eq_list.append(self.A_eq_1) b_eq_list.append(self.b_eq_1) @@ -582,6 +590,52 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_2) b_ub_list.append(self.b_ub_2) + if wordy > 1: + print("Constraint 2 details:") + if hasattr(self, 'A_ub_2'): + for i, row in enumerate(self.A_ub_2): + # Find the variables that are non-zero in this constraint + xta_indices = np.where(row[self.Xta_start:self.Xta_start + self.T * self.A] != 0)[0] + xts_indices = np.where(row[self.Xts_start:self.Xts_start + self.T * self.S] != 0)[0] + + if len(xta_indices) > 0 or len(xts_indices) > 0: + constraint_parts = [] + + # Add Xta terms + for xta_idx in xta_indices: + coeff = row[self.Xta_start + xta_idx] + t = xta_idx // self.A + a = xta_idx % self.A + if coeff == 1: + constraint_parts.append(f"Xta[{t},{a}]") + elif coeff == -1: + constraint_parts.append(f"-Xta[{t},{a}]") + else: + constraint_parts.append(f"{coeff}*Xta[{t},{a}]") + + # Add Xts terms + for xts_idx in xts_indices: + coeff = row[self.Xts_start + xts_idx] + t = xts_idx // self.S + s = xts_idx % self.S + if coeff == 1: + constraint_parts.append(f"Xts[{t},{s}]") + elif coeff == -1: + constraint_parts.append(f"-Xts[{t},{s}]") + else: + constraint_parts.append(f"{coeff}*Xts[{t},{s}]") + + if constraint_parts: + constraint_eq = " + ".join(constraint_parts).replace("+ -", "- ") + bound = self.b_ub_2[i] + print(f" Dependency constraint: {constraint_eq} ≤ {bound}") + + if i >= 4: # Limit output to avoid too much detail + remaining = len(self.A_ub_2) - i - 1 + if remaining > 0: + print(f" ... and {remaining} more dependency constraints") + break + if wordy > 0: print("Constraint 2 built.") @@ -605,6 +659,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.A_eq_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t if wordy > 1: + ''' print("A_eq_3^T:") print(" T1 T2") # Header for 2 tasks for i in range(self.Xta_start,self.Xta_end): @@ -613,6 +668,11 @@ def set_up_optimizer(self, goal : str = "cost"): pstring += f"{ column:5}" print(pstring) print("b_eq_3: ", self.b_eq_3) + ''' + print("Constraint 3 details:") + for t in range(self.T): + asset_vars = [f"Xta[{t},{a}]" for a in range(self.A)] + print(f" Task {t} assignment: {' + '.join(asset_vars)} = 1") A_eq_list.append(self.A_eq_3) b_eq_list.append(self.b_eq_3) @@ -643,6 +703,9 @@ def set_up_optimizer(self, goal : str = "cost"): rows_4 = [] bounds_4 = [] + + if wordy > 1: + print('Constraint 4 details:') # For each individual asset, create constraints to prevent conflicts for individual_asset_idx in range(len(self.assets)): @@ -689,7 +752,7 @@ def set_up_optimizer(self, goal : str = "cost"): bounds_4.append(3) # Sum ≤ 3 prevents all 4 from being 1 simultaneously if wordy > 1: - print(f" Conflict constraint for {individual_asset_name} in period {period_idx}:") + #print(f" Conflict constraint for {individual_asset_name} in period {period_idx}:") print(f" Xta[{task1},{ag1}] + Xta[{task2},{ag2}] + Xtp[{task1},{period_idx}] + Xtp[{task2},{period_idx}] ≤ 3") # Create constraint matrix @@ -701,6 +764,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.A_ub_4 = np.zeros((0, num_variables), dtype=int) self.b_ub_4 = np.array([], dtype=int) + ''' if wordy > 1: print("A_ub_4^T:") print(" P1 P2 P3 P4 P5") # Header for 5 periods @@ -710,6 +774,7 @@ def set_up_optimizer(self, goal : str = "cost"): pstring += f"{ column:5}" print(pstring) print("b_ub_4: ", self.b_ub_4) + ''' A_ub_list.append(self.A_ub_4) b_ub_list.append(self.b_ub_4) @@ -740,6 +805,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.b_ub_10 = np.ones(self.A_ub_10.shape[0], dtype=int) # Each infeasible combination: Xta + Xts <= 1 if wordy > 1: + ''' print("A_ub_10^T:") print(" T1A1 T1A2 T2A1") # Header for 3 task-asset pairs example with T2A2 invalid for i in range(self.Xta_start,self.Xta_end): @@ -753,6 +819,22 @@ def set_up_optimizer(self, goal : str = "cost"): pstring += f"{ column:5}" print(pstring) print("b_ub_10: ", self.b_ub_10) + ''' + print("Constraint 10 details:") + for i, row in enumerate(self.A_ub_10): + # Find the Xta and Xts variables that are 1 in this row + xta_indices = np.where(row[self.Xta_start:self.Xta_start + self.T * self.A] == 1)[0] + xts_indices = np.where(row[self.Xts_start:self.Xts_start + self.T * self.S] == 1)[0] + + if len(xta_indices) > 0 and len(xts_indices) > 0: + xta_idx = xta_indices[0] + xts_idx = xts_indices[0] + t_ta = xta_idx // self.A + a = xta_idx % self.A + t_ts = xts_idx // self.S + s = xts_idx % self.S + duration = self.task_asset_matrix[t_ta, a, 1] + print(f" Task {t_ta} exceeds period limit: Xta[{t_ta},{a}] + Xts[{t_ts},{s}] ≤ 1 (start {s} + duration {duration} > {self.P})") A_ub_list.append(self.A_ub_10) b_ub_list.append(self.b_ub_10) @@ -938,6 +1020,7 @@ def set_up_optimizer(self, goal : str = "cost"): b_ub_list.append(self.b_ub_14b) if wordy > 1: + ''' print("A_lb_14^T:") print(" T1A1S1 T1A2S1 ...") # Header for 3 task-asset pairs example with T2A2 invalid for i in range(self.Xta_start,self.Xta_end): @@ -956,6 +1039,39 @@ def set_up_optimizer(self, goal : str = "cost"): pstring += f"{ column:5}" print(pstring) print("b_lb_14: ", self.b_ub_14) + ''' + print("Constraint 14a details:") + if hasattr(self, 'A_ub_14a'): + for i, row in enumerate(self.A_ub_14a): + xts_indices = np.where(row[self.Xts_start:self.Xts_start + self.T * self.S] == 1)[0] + xtp_indices = np.where(row[self.Xtp_start:self.Xtp_start + self.T * self.P] == -1)[0] + if len(xts_indices) > 0 and len(xtp_indices) > 0: + xts_idx = xts_indices[0] + xtp_idx = xtp_indices[0] + t_ts = xts_idx // self.S + s = xts_idx % self.S + t_tp = xtp_idx // self.P + p = xtp_idx % self.P + print(f" Start-period mapping: Xts[{t_ts},{s}] - Xtp[{t_tp},{p}] ≤ 0") + + print("Constraint 14b details:") + if hasattr(self, 'A_ub_14b'): + for i, row in enumerate(self.A_ub_14b): + xta_indices = np.where(row[self.Xta_start:self.Xta_start + self.T * self.A] == 1)[0] + xts_indices = np.where(row[self.Xts_start:self.Xts_start + self.T * self.S] == 1)[0] + xtp_indices = np.where(row[self.Xtp_start:self.Xtp_start + self.T * self.P] == -1)[0] + + if len(xta_indices) > 0 and len(xts_indices) > 0 and len(xtp_indices) > 0: + xta_idx = xta_indices[0] + xts_idx = xts_indices[0] + xtp_idx = xtp_indices[0] + t_ta = xta_idx // self.A + a = xta_idx % self.A + t_ts = xts_idx // self.S + s = xts_idx % self.S + t_tp = xtp_idx // self.P + p = xtp_idx % self.P + print(f" Duration enforcement: Xta[{t_ta},{a}] + Xts[{t_ts},{s}] - Xtp[{t_tp},{p}] ≤ 1") if wordy > 0: print("Constraint 14 built.") @@ -979,6 +1095,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.A_eq_15[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 if wordy > 1: + ''' print("A_eq_15^T:") for i in range(self.Xts_start,self.Xts_end): pstring = str(self.X_indices[i]) @@ -986,6 +1103,11 @@ def set_up_optimizer(self, goal : str = "cost"): pstring += f"{ column:5}" print(pstring) print("b_eq_15: ", self.b_eq_15) + ''' + print("Constraint 15 details:") + for t in range(self.T): + start_vars = [f"Xts[{t},{s}]" for s in range(self.S)] + print(f" Task {t} start assignment: {' + '.join(start_vars)} = 1") A_eq_list.append(self.A_eq_15) b_eq_list.append(self.b_eq_15) @@ -1025,6 +1147,18 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_list.append(self.A_eq_16) b_eq_list.append(self.b_eq_16) + if wordy > 1: + print("Constraint 16 details:") + for t in range(self.T): + period_vars = [f"Xtp[{t},{p}]" for p in range(self.P)] + asset_terms = [] + for a in range(self.A): + duration = self.task_asset_matrix[t, a, 1] + if duration > 0: + asset_terms.append(f"{duration}*Xta[{t},{a}]") + if asset_terms: + print(f" Task {t} duration: {' + '.join(period_vars)} = {' + '.join(asset_terms)}") + if wordy > 0: print("Constraint 16 built.") @@ -1080,6 +1214,32 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_17) b_ub_list.append(self.b_ub_17) + if wordy > 1: + print("Constraint 17 details:") + for i, row in enumerate(self.A_ub_17): + xta_indices = np.where(row[self.Xta_start:self.Xta_start + self.T * self.A] == 1)[0] + xtp_indices = np.where(row[self.Xtp_start:self.Xtp_start + self.T * self.P] == 1)[0] + + if len(xta_indices) > 0 and len(xtp_indices) > 0: + xta_idx = xta_indices[0] + xtp_idx = xtp_indices[0] + t_ta = xta_idx // self.A + a = xta_idx % self.A + t_tp = xtp_idx // self.P + p = xtp_idx % self.P + + # Get weather info + period_weather = self.weather[p] if p < len(self.weather) else 0 + asset_max_weather = self.asset_groups[a].get('max_weather', float('inf')) + + print(f" Weather constraint: Xta[{t_ta},{a}] + Xtp[{t_tp},{p}] ≤ 1 (weather {period_weather} > max {asset_max_weather})") + + if i >= 4: # Limit output to avoid too much detail + remaining = len(rows_17) - i - 1 + if remaining > 0: + print(f" ... and {remaining} more weather constraints") + break + if wordy > 0: print(f"Constraint 17 built with {len(rows_17)} weather restrictions.") else: diff --git a/famodel/irma/task_asset_generator.py b/famodel/irma/task_asset_generator.py new file mode 100644 index 00000000..872d1370 --- /dev/null +++ b/famodel/irma/task_asset_generator.py @@ -0,0 +1,486 @@ +""" +Capability-Based Asset Group Generator for MILP Scheduler + +This module provides intelligent asset group generation based on capability matching, +designed to work with offshore installation scheduling problems. It creates sparse +task-asset matrices by pre-filtering operationally feasible combinations. + +Key Features: +- Capability-based matching between tasks and assets +- Smart pre-filtering to avoid 2^N explosion +- Strategic batch support for aggregated installation tasks +- Sparse matrix generation for computational efficiency +- Operational feasibility validation + +Strategic batch support allows multiple task alternatives like: +- "install_1_anchor" vs "install_4_anchors" with mutual exclusion +- Pre-aggregated task strategies for large-scale projects +""" + +import numpy as np +from itertools import combinations + + +class TaskAssetGroupGenerator: + """ + Generator for asset group assignments to tasks based on shared capabilities. + + Creates sparse task-asset matrices by matching task capability requirements + to asset capabilities, with support for flexible batch sizes per task. + + Features: + - Configurable batch sizes (anchors per task) + - Capability-based asset matching + - Smart pre-filtering for computational efficiency + - Automatic handling of remainder tasks for uneven divisions + """ + + def __init__(self, max_group_size=3): + """Initialize the generator with constraints.""" + self.max_group_size = max_group_size + self.verbose = False + + def generate_asset_groups(self, task_definitions, asset_definitions): + """Generate asset groups and return scheduler-ready inputs.""" + if self.verbose: + print("=== Capability-Based Asset Group Generation ===") + + # === VALIDATION PHASE === + validated_tasks = self._validate_task_definitions(task_definitions) + validated_assets = self._validate_asset_definitions(asset_definitions) + + if self.verbose: + print(f"\n=== VALIDATION RESULTS ===") + print(f"Validated tasks: {len(validated_tasks)}") + print(f"Validated assets: {len(validated_assets)}") + + # === ASSET GROUP GENERATION === + asset_groups = self._generate_feasible_asset_groups(validated_assets) + + if self.verbose: + print(f"\n=== ASSET GROUP RESULTS ===") + print(f"Feasible asset groups: {len(asset_groups)}") + + # === TASK-ASSET MATCHING === + task_asset_matches = self._match_tasks_to_asset_groups(validated_tasks, asset_groups) + task_asset_matrix = self._build_sparse_matrix(validated_tasks, asset_groups, task_asset_matches) + + # === SCHEDULER INPUT PREPARATION === + scheduler_inputs = { + "task_asset_matrix": task_asset_matrix, + "tasks": [task["name"] for task in validated_tasks], + "assets": [group["name"] for group in asset_groups], + "asset_groups": asset_groups + } + + if self.verbose: + self._print_efficiency_stats(scheduler_inputs) + + return scheduler_inputs + + def _validate_task_definitions(self, task_definitions): + """Validate and standardize task capability requirements.""" + validated_tasks = [] + + if self.verbose: + print(f"\n=== TASK VALIDATION ===") + + for task_name, requirements in task_definitions.items(): + if "required_capabilities" not in requirements: + raise ValueError(f"Task '{task_name}' missing required_capabilities") + + # === TASK CONFIGURATION === + validated_task = { + "name": task_name, + "required_capabilities": set(requirements["required_capabilities"]), + "min_weather_rating": requirements.get("min_weather_rating", 1), + "max_duration": requirements.get("max_duration", 24), + "complexity_factor": requirements.get("complexity_factor", 1.0), + "batch_size": requirements.get("batch_size", 1) + } + validated_tasks.append(validated_task) + + if self.verbose: + batch_info = f" (batch_size={validated_task['batch_size']})" if validated_task['batch_size'] > 1 else "" + print(f" Task: {task_name} requires {validated_task['required_capabilities']}{batch_info}") + + return validated_tasks + + def _validate_asset_definitions(self, asset_definitions): + """Validate and standardize asset capabilities.""" + validated_assets = [] + + if self.verbose: + print(f"\n=== ASSET VALIDATION ===") + + for asset_name, capabilities in asset_definitions.items(): + if "capabilities" not in capabilities: + raise ValueError(f"Asset '{asset_name}' missing capabilities") + + # === ASSET CONFIGURATION === + validated_asset = { + "name": asset_name, + "capabilities": set(capabilities["capabilities"]), + "max_weather": capabilities.get("max_weather", 1), + "base_cost": capabilities.get("base_cost", 10000), + "daily_rate": capabilities.get("daily_rate", 5000), + "availability": capabilities.get("availability", 1.0) + } + validated_assets.append(validated_asset) + + if self.verbose: + print(f" Asset: {asset_name} provides {validated_asset['capabilities']}") + + return validated_assets + + def _generate_feasible_asset_groups(self, validated_assets): + """Generate all operationally feasible asset group combinations.""" + asset_groups = [] + + if self.verbose: + print(f"\n=== ASSET GROUP GENERATION ===") + + # === INDIVIDUAL ASSET GROUPS === + for asset in validated_assets: + group = { + "name": asset["name"], + "assets": [asset["name"]], + "combined_capabilities": asset["capabilities"], + "min_weather": asset["max_weather"], + "total_cost": asset["base_cost"], + "total_daily_rate": asset["daily_rate"], + "group_size": 1 + } + asset_groups.append(group) + + if self.verbose: + print(f" Individual asset groups: {len(asset_groups)}") + + # === MULTI-ASSET COMBINATIONS === + combination_count = 0 + for size in range(2, min(len(validated_assets) + 1, self.max_group_size + 1)): + for combo in combinations(validated_assets, size): + if self._is_operationally_feasible(combo): + group_props = self._calculate_group_properties(combo) + asset_groups.append(group_props) + combination_count += 1 + + if self.verbose: + print(f" Multi-asset combinations: {combination_count}") + print(f" Total asset groups: {len(asset_groups)}") + + return asset_groups + + def _is_operationally_feasible(self, asset_combination): + """Apply operational feasibility filters to asset combinations.""" + + # === FEASIBILITY FILTER 1: Weather compatibility === + # All assets must handle similar weather conditions + weather_ratings = [asset["max_weather"] for asset in asset_combination] + if max(weather_ratings) - min(weather_ratings) > 2: + return False + + # === FEASIBILITY FILTER 2: Capability overlap === + # Avoid redundant capabilities (inefficient combinations) + all_capabilities = [asset["capabilities"] for asset in asset_combination] + total_capabilities = set().union(*all_capabilities) + individual_count = sum(len(caps) for caps in all_capabilities) + + # Reject if overlap is too high (more than 70% overlap) + overlap_ratio = (individual_count - len(total_capabilities)) / individual_count + if overlap_ratio > 0.7: + return False + + # === FEASIBILITY FILTER 3: Cost efficiency === + # Combination shouldn't be extremely expensive + total_cost = sum(asset["base_cost"] for asset in asset_combination) + avg_individual_cost = total_cost / len(asset_combination) + if avg_individual_cost > 100000: + return False + + # === FEASIBILITY FILTER 4: Group size limits === + # Practical limits on group size + if len(asset_combination) > self.max_group_size: + return False + + return True + + def _calculate_group_properties(self, asset_combination): + """Calculate combined properties for an asset group.""" + assets = list(asset_combination) + asset_names = [asset["name"] for asset in assets] + + # === CAPABILITY COMBINATION === + combined_capabilities = set() + for asset in assets: + combined_capabilities.update(asset["capabilities"]) + + # === GROUP PROPERTY CALCULATION === + min_weather = min(asset["max_weather"] for asset in assets) + total_cost = sum(asset["base_cost"] for asset in assets) + total_daily_rate = sum(asset["daily_rate"] for asset in assets) + group_name = "+".join(asset_names) + + return { + "name": group_name, + "assets": asset_names, + "combined_capabilities": combined_capabilities, + "min_weather": min_weather, + "total_cost": total_cost, + "total_daily_rate": total_daily_rate, + "group_size": len(assets) + } + + def _match_tasks_to_asset_groups(self, validated_tasks, asset_groups): + """Match tasks to feasible asset groups based on capability requirements.""" + task_asset_matches = {} + + for task in validated_tasks: + task_name = task["name"] + feasible_groups = [] + + for group in asset_groups: + if self._can_group_handle_task(group, task): + # Calculate cost and duration for this task-group combination + cost, duration = self._calculate_task_cost_duration(task, group) + feasible_groups.append({ + "group_name": group["name"], + "cost": cost, + "duration": duration + }) + + task_asset_matches[task_name] = feasible_groups + + if self.verbose: + print(f" Task '{task_name}' can be handled by {len(feasible_groups)} asset groups") + + return task_asset_matches + + def _can_group_handle_task(self, asset_group, task): + """Check if an asset group can handle a specific task.""" + # Capability check + required_caps = task["required_capabilities"] + available_caps = asset_group["combined_capabilities"] + + if not required_caps.issubset(available_caps): + return False + + # Weather rating check + if asset_group["min_weather"] < task["min_weather_rating"]: + return False + + return True + + def _calculate_task_cost_duration(self, task, asset_group): + """Calculate cost and duration for a task-asset group combination.""" + + # === DURATION CALCULATION === + base_duration = task["max_duration"] + complexity_factor = task["complexity_factor"] + batch_size = task.get("batch_size", 1) + + # Duration scales with complexity and batch size, but with efficiency gains + batch_efficiency = 1.0 if batch_size == 1 else (batch_size * 0.8) # 20% efficiency gain for batches + duration = base_duration * complexity_factor * batch_efficiency + + # === COST CALCULATION === + setup_cost = asset_group["total_cost"] * 0.1 # 10% of asset cost as setup + operational_cost = asset_group["total_daily_rate"] * (duration / 24) # Daily rate prorated + batch_cost_factor = batch_size * 0.9 # 10% cost efficiency for larger batches + + total_cost = (setup_cost + operational_cost) * batch_cost_factor + + return round(total_cost, 2), round(duration, 2) + + def _build_sparse_matrix(self, validated_tasks, asset_groups, task_asset_matches): + """Build sparse task-asset matrix avoiding mostly (-1,-1) entries.""" + num_tasks = len(validated_tasks) + num_groups = len(asset_groups) + + # Initialize with infeasible values using object array + matrix = np.empty((num_tasks, num_groups), dtype=object) + matrix.fill((-1, -1)) + + # Fill in feasible combinations + for task_idx, task in enumerate(validated_tasks): + task_name = task["name"] + feasible_groups = task_asset_matches.get(task_name, []) + + for match in feasible_groups: + # Find asset group index + group_idx = next(i for i, group in enumerate(asset_groups) + if group["name"] == match["group_name"]) + matrix[task_idx, group_idx] = (match["cost"], match["duration"]) + + return matrix + + def _print_efficiency_stats(self, scheduler_inputs): + """Print efficiency statistics about the generated matrix.""" + matrix = scheduler_inputs["task_asset_matrix"] + total_entries = matrix.size + + # Count feasible entries by checking each element + feasible_entries = 0 + for i in range(matrix.shape[0]): + for j in range(matrix.shape[1]): + if matrix[i, j] != (-1, -1): + feasible_entries += 1 + + sparsity = 1 - (feasible_entries / total_entries) + + print(f"\n=== Efficiency Statistics ===") + print(f"Task-Asset Matrix: {matrix.shape}") + print(f"Total entries: {total_entries}") + print(f"Feasible entries: {feasible_entries} ({feasible_entries/total_entries:.1%})") + print(f"Sparsity: {sparsity:.1%} (reduced computational load)") + print(f"Asset groups: {len(scheduler_inputs['assets'])}") + +def generate_capability_based_groups(task_definitions, asset_definitions, max_group_size=3, verbose=True): + """ + Convenience function to generate capability-based asset groups. + + Args: + task_definitions (dict): Task capability requirements + asset_definitions (dict): Asset capabilities + max_group_size (int): Maximum assets per group + verbose (bool): Print detailed output + + Returns: + dict: Scheduler inputs ready for use with the MILP scheduler + + Example: + task_defs = { + "install_anchor_task_1": { + "required_capabilities": ["anchor_handling", "positioning"], + "min_weather_rating": 1, + "max_duration": 12, + "batch_size": 1 + } + } + + asset_defs = { + "anchor_vessel": { + "capabilities": ["anchor_handling", "positioning"], + "max_weather": 2, + "base_cost": 30000 + } + } + + scheduler_inputs = generate_capability_based_groups(task_defs, asset_defs) + """ + generator = TaskAssetGroupGenerator(max_group_size=max_group_size) + generator.verbose = verbose + return generator.generate_asset_groups(task_definitions, asset_definitions) + + +if __name__ == "__main__": + # Configurable anchor installation demo + # + # CONFIGURATION PARAMETERS: + # - num_anchors: Total number of anchors to install for your project + # - anchors_per_task: Batch size - how many anchors each task will install + # + # EXAMPLES: + # - num_anchors=100, anchors_per_task=1 → 100 individual tasks + # - num_anchors=100, anchors_per_task=4 → 25 batch tasks (4 anchors each) + # - num_anchors=100, anchors_per_task=100 → 1 mega-batch task + # - num_anchors=7, anchors_per_task=3 → 2 tasks (3 anchors) + 1 task (1 anchor) + + num_anchors = 4 # Total number of units to install + anchors_per_task = 1 # Batch size: anchors installed per task + + # Calculate strategy based on batch size + if anchors_per_task == 1: + strategy = 1 # Individual tasks + elif anchors_per_task > 1 and anchors_per_task < num_anchors: + strategy = 2 # Intermediate batches + elif anchors_per_task == num_anchors: + strategy = 3 + else: + raise ValueError("Input strategy is not yet supported") + + print(f"=== Anchor Installation Demo ===") + print(f"Total anchors: {num_anchors}") + print(f"Anchors per task: {anchors_per_task}") + print(f"Strategy: {strategy}\n") + + task_definitions = {} # initialize the dictionary of tasks + + if strategy == 1: + # Strategy 1: Multiple individual anchor installation tasks + num_tasks = num_anchors // anchors_per_task + print(f"Strategy 1: {num_tasks} tasks, each installing {anchors_per_task} anchor(s)") + + for i in range(1, num_tasks + 1): + task_name = f"install_anchor_{i}" + task_definitions[task_name] = { + "required_capabilities": ["anchor_handling", "positioning"], + "max_duration": 12 * anchors_per_task, # Scale duration with batch size + "batch_size": anchors_per_task + } + + elif strategy == 2: + # Strategy 2: Intermediate batches + num_batches = num_anchors // anchors_per_task + print(f"Strategy 2: {num_batches} batch tasks, each installing {anchors_per_task} anchors") + + for i in range(1, num_batches + 1): + task_name = f"install_batch_{i}" + task_definitions[task_name] = { + "required_capabilities": ["anchor_handling", "positioning"], + "max_duration": 8 * anchors_per_task, # Batch efficiency + "batch_size": anchors_per_task + } + + # Handle remainder anchors + remainder = num_anchors % anchors_per_task + if remainder > 0: + task_name = f"install_batch_{num_batches + 1}" + task_definitions[task_name] = { + "required_capabilities": ["anchor_handling", "positioning"], + "max_duration": remainder * 8, + "batch_size": remainder + } + + elif strategy == 3: + # Single batch for all anchors + print(f"Strategy 3: 1 batch task installing {num_anchors} anchors at once") + task_definitions["install_anchors"] = { + "required_capabilities": ["anchor_handling", "positioning"], + "max_duration": num_anchors * 8, # Batch efficiency: 8 hours per anchor + "batch_size": num_anchors + } + + else: + raise ValueError("Strategy must be 1, 2, or 3") + + # Same asset definitions for both strategies + asset_definitions = { + "anchor_vessel": { + "capabilities": ["anchor_handling", "positioning"], + "max_weather": 2, + "base_cost": 30000, + "daily_rate": 15000 + }, + "positioning_vessel": { + "capabilities": ["positioning"], + "max_weather": 3, + "base_cost": 15000, + "daily_rate": 6000 + } + } + + # Generate asset groups for anchor tasks + scheduler_inputs = generate_capability_based_groups( + task_definitions, + asset_definitions, + max_group_size=2 + ) + + print(f"=== Results ===") + print(f"Total anchors configured: {num_anchors}") + print(f"Anchors per task: {anchors_per_task}") + print(f"Generated tasks: {scheduler_inputs['tasks']}") + print(f"Number of tasks: {len(scheduler_inputs['tasks'])}") + print(f"Asset groups: {len(scheduler_inputs['assets'])}") + print(f"Matrix shape: {scheduler_inputs['task_asset_matrix'].shape}") \ No newline at end of file From b8b2acb6663ca548087c76f20e5ad7ba5472208b Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Mon, 3 Nov 2025 10:16:23 -0700 Subject: [PATCH 48/63] Generic Task Moduel: - StageActions: This method stages the action_sequence for a proper execution order (in the task initialization, it is called if an action sequence is not provided). Currently, it takes an argument 'from_deps' to compute the sequence based on the inter-action dependencies but the idea is to enable other strategies later to be built to create the action_sequence in the Task for more autonomy. - calcDuration - This organizes the actions to be done by this task into the proper order based on the action_sequence. This is used to fill out the self.actions_ti (the initial time of individual actions within the task), self.ti, and self.tf. Right now, when we're initializing the task, we assume a ti of zero. - calcCost - This calculate the total cost of the task based on the costs of individual actions (a simple roll-up). This updates self.cost. - Both calcDuration and calcCost assumes that the individual action.duration and action.cost have already been computed (using calcCostandDuration in the action class but I'm also thinking of dividing this method into two different methods - one for cost and one for duration to match with Task.py and also task-assets matrix in the scheduler. - updateTaskTime: This method updates the starting and finishing time of the whole task and the starting time of individual actions based on a new start time passed to Task (could be helpful later during task scheduling using the sceduler. --- famodel/irma/calwave_task1.py | 32 ++-- famodel/irma/task.py | 292 +++++++++++----------------------- 2 files changed, 108 insertions(+), 216 deletions(-) diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py index 8163f2fc..04910dde 100644 --- a/famodel/irma/calwave_task1.py +++ b/famodel/irma/calwave_task1.py @@ -7,7 +7,8 @@ from famodel.project import Project from calwave_irma import Scenario import calwave_chart as chart -from calwave_task import Task +# from calwave_task import Task # calwave_task module (Felipe) +from task import Task as Task # generic Task module ( Rudy ) sc = Scenario() # now sc exists in *this* session @@ -183,21 +184,22 @@ def assign_actions(sc: Scenario, actions: dict): # 4) Assign (assign vessels/roles) assign_actions(sc, actions) - # 5) schedule once, in the Task - calwave_task1 = Task.from_scenario( - sc, - name='calwave_task1', - strategy='earliest', # 'earliest' or 'levels' - enforce_resources=False, # keep single-resource blocking if you want it - resource_roles=('vessel', 'carrier', 'operator')) - - # 6) Extract Task1 sequencing info - # calwave_task1.extractSeqYaml() + # # 5) schedule once, in the Task + # calwave_task1 = Task.from_scenario( + # sc, + # name='calwave_task1', + # strategy='earliest', # 'earliest' or 'levels' + # enforce_resources=False, # keep single-resource blocking if you want it + # resource_roles=('vessel', 'carrier', 'operator')) + + + # 5) Build Task + task1 = Task(name='calwave_task1', actions=sc.actions) + + # task1.updateTaskTime(newStart=10) - # 7) update Task1 if needed - calwave_task1.update_from_SeqYaml() # uncomment to re-apply sequencing from YAML - # 8) build the chart input directly from the Task and plot - chart_view = chart.view_from_task(calwave_task1, sc, title='CalWave Task 1 - Anchor installation plan') + # 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) + chart_view = chart.view_from_task(task1, sc, title='CalWave Task 1 - Anchor installation plan') chart.plot_task(chart_view, outpath='calwave_task1_chart.png') diff --git a/famodel/irma/task.py b/famodel/irma/task.py index ce0c0313..4fbeb775 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -32,250 +32,140 @@ class Task(): ''' - def __init__(self, actions, action_sequence, name, **kwargs): + def __init__(self, name, actions, action_sequence=None, **kwargs): '''Create an action object... It must be given a name and a list of actions. The action list should be by default coherent with actionTypes dictionary. Parameters ---------- + name : string + A name for the action. It may be appended with numbers if there + are duplicate names. actions : list A list of all actions that are part of this task. - action_sequence : dict + action_sequence : dict, optional A dictionary where each key is the name of each action, and the values are each a list of which actions (by name) must be completed before the current - one. - name : string - A name for the action. It may be appended with numbers if there - are duplicate names. + one. If None, the action_sequence will be built by calling self.stageActions(from_deps=True) + [building from the dependencies of each action]. kwargs Additional arguments may depend on the task type. ''' - - # Make a dict by name of all actions that are carried out in this task - self.actions = {} - for act in actions: - self.actions[act.name] = act + self.name = name - + self.actions = {a.name: a for a in actions.values()} + + if action_sequence is None: + self.stageActions(from_deps=True) + else: + self.action_sequence = {k: list(v) for k, v in action_sequence.items()} + + self.status = 0 # 0, waiting; 1=running; 2=finished self.actions_ti = {} # relative start time of each action [h] - self.duration = 0 # duration must be calculated based on lengths of actions - self.cost = 0 # cost must be calculated based on the cost of individual actions. - self.ti =0 # task start time [h?] - self.tf =0 # task end time [h?] - - # what else do we need to initialize the task? - - # Create a graph of the sequence of actions in this task based on action_sequence - self.getSequenceGraph(action_sequence, plot=True) # this also updates duration - - self.cost = sum(action.cost for action in self.actions.values()) + self.duration = 0.0 # duration must be calculated based on lengths of actions + self.cost = 0.0 # cost must be calculated based on the cost of individual actions. + self.ti = 0.0 # task start time [h?] + self.tf = 0.0 # task end time [h?] - print(f"---------------------- Initializing Task '{self.name} ----------------------") + # Calculate duration and cost + self.calcDuration() # organizes actions and calculates duration + self.calcCost() + + print(f"---------------------- Initializing Task '{self.name} ----------------------") print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.") print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ") - def organizeActions(self): - '''Organizes the actions to be done by this task into the proper order - based on the strategy of this type of task... + def stageActions(self, from_deps=True): ''' - - if self.type == 'parallel_anchor_install': - - pass - # make a graph that reflects this strategy? - - - def calcDuration(self): - '''Calculates the duration of the task based on the durations of the - individual actions and their order of operation.''' - - # Does Rudy have graph-based code that can do this? - - - - - - - def getSequenceGraph(self, action_sequence, plot=True): - '''Generate a multi-directed graph that visalizes action sequencing within the task. - Build a MultiDiGraph with nodes: - Start -> CP1 -> CP2 -> ... -> End - - Checkpoints are computed from action "levels": - level(a) = 1 if no prerequisites. - level(a) = 1 + max(level(p) for p in prerequisites) 1 + the largest level among a’s prerequisites. - Number of checkpoints = max(level) - 1. - ''' - - # Compute levels - levels: dict[str, int] = {} - def level_of(a: str, b: set[str]) -> int: - '''Return the level of action a. b is the set of actions currently being explored''' + This method stages the action_sequence for a proper execution order. - # If we have already computed the level, return it - if a in levels: - return levels[a] + Parameters + ---------- + from_deps : bool + If True, builds the action_sequence from the dependencies of each action. + More options will be added in the future. + ''' + if from_deps: + # build from dependencies + def getDeps(action): + deps = [] + for dep in action.dependencies: + deps.append(dep) + return deps - # The action cannot be its own prerequisite - if a in b: - raise ValueError(f"Cycle detected in action sequence at '{a}' in task '{self.name}'. The action cannot be its own prerequisite.") - - b.add(a) - - # Look up prerequisites for action a. - pres = action_sequence.get(a, []) - if not pres: - lv = 1 # No prerequisites, level 1 - else: - # If a prerequisites name is not in the dict, treat it as a root (level 1) - lv = 1 + max(level_of(p, b) if p in action_sequence else 1 for p in pres) + self.action_sequence = {self.actions[name].name: getDeps(self.actions[name]) for name in self.actions} - # b.remove(a) # if you want to unmark a from the explored dictionary, b, uncomment this line. - levels[a] = lv - return lv - - for a in action_sequence: - level_of(a, set()) - - max_level = max(levels.values(), default=1) - num_cps = max(0, max_level - 1) - - H = nx.MultiDiGraph() - - # Add the Start -> [checkpoints] -> End nodes - H.add_node("Start") - for i in range(1, num_cps + 1): - H.add_node(f"CP{i}") - H.add_node("End") - - shells = [["Start"]] - if num_cps > 0: - # Middle shells - cps = [f"CP{i}" for i in range(1, num_cps + 1)] - shells.append(cps) - shells.append(["End"]) - - pos = nx.shell_layout(H, nlist=shells) - - xmin, xmax = -2.0, 2.0 # maybe would need to change those later on. - pos["Start"] = (xmin, 0) - pos["End"] = (xmax, 0) - - # Add action edges - # Convention: - # level 1 actions: Start -> CP1 (or Start -> End if no CPs) - # level L actions (2 <= L < max_level): CP{L-1} -> CP{L} - # level == max_level actions: CP{num_cps} -> End - for action, lv in levels.items(): - action = self.actions[action] - if num_cps == 0: - # No checkpoints: all actions from Start to End - H.add_edge("Start", "End", key=action, duration=action.duration, cost=action.cost) - else: - if lv == 1: - H.add_edge("Start", "CP1", key=action, duration=action.duration, cost=action.cost) - elif lv < max_level: - H.add_edge(f"CP{lv-1}", f"CP{lv}", key=action, duration=action.duration, cost=action.cost) - else: # lv == max_level - H.add_edge(f"CP{num_cps}", "End", key=action, duration=action.duration, cost=action.cost) + def calcDuration(self): + '''Organizes the actions to be done by this task into the proper order + based on the action_sequence. This is used to fill out + self.actions_ti, self.ti, and self.tf. This method assumes that action.duration + have already been evaluated for each action in self.actions. + ''' + # Initialize dictionaries to hold start and finish times + starts = {} + finishes = {} - # 3. Compute cumulative start time for each level - level_groups = {} - for action, lv in levels.items(): - level_groups.setdefault(lv, []).append(action) - - level_durations = {lv: max(self.actions[a].duration for a in acts) - for lv, acts in level_groups.items()} + # Iterate through actions in the sequence + for action, dep_actions in self.action_sequence.items(): + # Calculate start time as the max finish time of dependencies + starts[action] = max((finishes[dep] for dep in dep_actions), default=0) - task_duration = sum(level_durations.values()) + # get duration from actions + duration = self.actions[action].duration # in hours - level_start_time = {} - elapsed = 0.0 - cp_string = [] - for lv in range(1, max_level + 1): - level_start_time[lv] = elapsed - elapsed += level_durations.get(lv, 0.0) - # also collect all actions at this level for title - acts = [a for a, l in levels.items() if l == lv] - if acts and lv <= num_cps: - cp_string.append(f"CP{lv}: {', '.join(acts)}") - elif acts and lv > num_cps: - cp_string.append(f"End: {', '.join(acts)}") + # Calculate finish time + finishes[action] = starts[action] + duration - # Assign to self: - self.duration = task_duration - self.actions_ti = {a: level_start_time[lv] for a, lv in levels.items()} - self.sequence_graph = H - - title_str = f"Task {self.name}. Duration {self.duration:.2f} : " + " | ".join(cp_string) + # Update self.actions_ti with relative start times + self.actions_ti = starts - if plot: - fig, ax = plt.subplots() - # pos = nx.shell_layout(G) - nx.draw(H, pos, with_labels=True, node_size=500, node_color="lightblue", edge_color='white') + # Set task start time and finish time + self.ti = min(starts.values(), default=0) + self.tf = max(finishes.values(), default=0) - label_positions = {} # to store label positions for each edge - # Group edges by unique (u, v) pairs - for (u, v) in set((u, v) for u, v, _ in H.edges(keys=True)): - # get all edges between u and v (dict keyed by edge key) - edge_dict = H.get_edge_data(u, v) # {key: {attrdict}, ...} - n = len(edge_dict) + # Task duration + self.duration = self.tf - self.ti - # curvature values spread between -0.3 and +0.3 [helpful to visualize multiple edges] - if n==1: - rads = [0] - offsets = [0.5] - else: - rads = np.linspace(-0.3, 0.3, n) - offsets = np.linspace(0.2, 0.8, n) - - # draw each edge - durations = [d.get("duration", 0.0) for d in edge_dict.values()] - scale = max(max(durations), 0.0001) # avoid div by zero - width_scale = 4.0 / scale # normalize largest to ~4px + def calcCost(self): + '''Calculates the total cost of the task based on the costs of individual actions. + Updates self.cost accordingly. This method assumes that action.cost has + already been evaluated for each action in self.actions. + ''' + total_cost = 0.0 + for action in self.actions.values(): + total_cost += action.cost + self.cost = total_cost + return self.cost - for rad, offset, (k, d) in zip(rads, offsets, edge_dict.items()): - nx.draw_networkx_edges( - H, pos, edgelist=[(u, v)], ax=ax, - connectionstyle=f"arc3,rad={rad}", - arrows=True, arrowstyle="-|>", - edge_color="gray", - width=max(0.5, d.get("duration", []) * width_scale), - ) - label_positions[(u, v, k)] = offset # store position for edge label + def updateTaskTime(self, newStart=0.0): + '''Update the start time of all actions based on a new task start time. - ax.set_title(title_str, fontsize=12, fontweight="bold") - ax.axis("off") - plt.tight_layout() + Parameters + ---------- + newStart : float + The new start time for the task. All action start times will be adjusted accordingly. + ''' + # Calculate the time shift + time_shift = newStart - self.ti - return H - - + # Update task start and finish times + self.ti = newStart + self.tf += time_shift - def getTaskGraph(self, plot=True): - '''Generate a graph of the action dependencies. - ''' - - # Create the graph - G = nx.DiGraph() - for item, data in self.actions.items(): - for dep in data.dependencies: - G.add_edge(dep, item, duration=data.duration) # Store duration as edge attribute + # Update action start times + for action in self.actions_ti: + self.actions_ti[action] += time_shift + - # Compute longest path & total duration - longest_path = nx.dag_longest_path(G, weight='duration') - longest_path_edges = list(zip(longest_path, longest_path[1:])) # Convert path into edge pairs - total_duration = sum(self.actions[node].duration for node in longest_path) - return G def get_row(self, assets): From 793667e18e278e7db55b52e7675fa5c3331bae47 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Mon, 3 Nov 2025 14:13:29 -0700 Subject: [PATCH 49/63] Resurrecting Sequence Graph Capabilities to Task Class --- famodel/irma/calwave_task1.py | 5 +- famodel/irma/task.py | 151 +++++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py index 04910dde..956b2216 100644 --- a/famodel/irma/calwave_task1.py +++ b/famodel/irma/calwave_task1.py @@ -3,7 +3,7 @@ # 1) addAction → structure only (type, name, objects, deps) # 2) evaluateAssets → assign vessels/roles (+ durations/costs) # 3) (schedule/plot handled by your existing tooling) - +import matplotlib.pyplot as plt from famodel.project import Project from calwave_irma import Scenario import calwave_chart as chart @@ -195,7 +195,8 @@ def assign_actions(sc: Scenario, actions: dict): # 5) Build Task task1 = Task(name='calwave_task1', actions=sc.actions) - + task1.getSequenceGraph() + plt.show() # task1.updateTaskTime(newStart=10) # 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) diff --git a/famodel/irma/task.py b/famodel/irma/task.py index 4fbeb775..4993cb91 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -81,7 +81,156 @@ def __init__(self, name, actions, action_sequence=None, **kwargs): print(f"---------------------- Initializing Task '{self.name} ----------------------") print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.") print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ") - + + def getSequenceGraph(self, action_sequence=None, plot=True): + '''Generate a multi-directed graph that visalizes action sequencing within the task. + Build a MultiDiGraph with nodes: + Start -> CP1 -> CP2 -> ... -> End + + Checkpoints are computed from action "levels": + level(a) = 1 if no prerequisites. + level(a) = 1 + max(level(p) for p in prerequisites) 1 + the largest level among a’s prerequisites. + Number of checkpoints = max(level) - 1. + ''' + if action_sequence is None: + action_sequence = self.action_sequence + # Compute levels + levels: dict[str, int] = {} + def level_of(a: str, b: set[str]) -> int: + '''Return the level of action a. b is the set of actions currently being explored''' + + # If we have already computed the level, return it + if a in levels: + return levels[a] + + if a in b: + raise ValueError(f"Cycle detected in action sequence at '{a}' in task '{self.name}'. The action cannot be its own prerequisite.") + + b.add(a) + + # Look up prerequisites for action a. + pres = action_sequence.get(a, []) + if not pres: + lv = 1 # No prerequisites, level 1 + else: + # If a prerequisites name is not in the dict, treat it as a root (level 1) + lv = 1 + max(level_of(p, b) if p in action_sequence else 1 for p in pres) + # b.remove(a) # if you want to unmark a from the explored dictionary, b, uncomment this line. + levels[a] = lv + return lv + + for a in action_sequence: + level_of(a, set()) + + max_level = max(levels.values(), default=1) + num_cps = max(0, max_level - 1) + + H = nx.MultiDiGraph() + + # Add the Start -> [checkpoints] -> End nodes + H.add_node("Start") + for i in range(1, num_cps + 1): + H.add_node(f"CP{i}") + H.add_node("End") + + shells = [["Start"]] + if num_cps > 0: + # Middle shells + cps = [f"CP{i}" for i in range(1, num_cps + 1)] + shells.append(cps) + shells.append(["End"]) + + pos = nx.shell_layout(H, nlist=shells) + + xmin, xmax = -2.0, 2.0 # maybe would need to change those later on. + pos["Start"] = (xmin, 0) + pos["End"] = (xmax, 0) + + # Add action edges + # Convention: + # level 1 actions: Start -> CP1 (or Start -> End if no CPs) + # level L actions (2 <= L < max_level): CP{L-1} -> CP{L} + # level == max_level actions: CP{num_cps} -> End + for action, lv in levels.items(): + action = self.actions[action] + if num_cps == 0: + # No checkpoints: all actions from Start to End + H.add_edge("Start", "End", key=action, duration=action.duration, cost=action.cost) + else: + if lv == 1: + H.add_edge("Start", "CP1", key=action, duration=action.duration, cost=action.cost) + elif lv < max_level: + H.add_edge(f"CP{lv-1}", f"CP{lv}", key=action, duration=action.duration, cost=action.cost) + else: # lv == max_level + H.add_edge(f"CP{num_cps}", "End", key=action, duration=action.duration, cost=action.cost) + # 3. Compute cumulative start time for each level + level_groups = {} + for action, lv in levels.items(): + level_groups.setdefault(lv, []).append(action) + + level_durations = {lv: max(self.actions[a].duration for a in acts) + for lv, acts in level_groups.items()} + + + task_duration = sum(level_durations.values()) + level_start_time = {} + elapsed = 0.0 + cp_string = [] + for lv in range(1, max_level + 1): + level_start_time[lv] = elapsed + elapsed += level_durations.get(lv, 0.0) + # also collect all actions at this level for title + acts = [a for a, l in levels.items() if l == lv] + if acts and lv <= num_cps: + cp_string.append(f"CP{lv}: {', '.join(acts)}") + elif acts and lv > num_cps: + cp_string.append(f"End: {', '.join(acts)}") + # Assign to self: + self.sequence_graph = H + title_str = f"Task {self.name}. Duration {self.duration:.2f} : " + " | ".join(cp_string) + if plot: + fig, ax = plt.subplots() + # pos = nx.shell_layout(G) + nx.draw(H, pos, with_labels=True, node_size=500, node_color="lightblue", edge_color='white') + + label_positions = {} # to store label positions for each edge + # Group edges by unique (u, v) pairs + for (u, v) in set((u, v) for u, v, _ in H.edges(keys=True)): + # get all edges between u and v (dict keyed by edge key) + edge_dict = H.get_edge_data(u, v) # {key: {attrdict}, ...} + n = len(edge_dict) + + # curvature values spread between -0.3 and +0.3 [helpful to visualize multiple edges] + if n==1: + rads = [0] + offsets = [0.5] + else: + rads = np.linspace(-0.3, 0.3, n) + offsets = np.linspace(0.2, 0.8, n) + + # draw each edge + durations = [d.get("duration", 0.0) for d in edge_dict.values()] + scale = max(max(durations), 0.0001) # avoid div by zero + width_scale = 4.0 / scale # normalize largest to ~4px + + for rad, offset, (k, d) in zip(rads, offsets, edge_dict.items()): + nx.draw_networkx_edges( + H, pos, edgelist=[(u, v)], ax=ax, + connectionstyle=f"arc3,rad={rad}", + arrows=True, arrowstyle="-|>", + edge_color="gray", + width=max(0.5, d.get("duration", []) * width_scale), + ) + label_positions[(u, v, k)] = offset # store position for edge label + + ax.set_title(title_str, fontsize=12, fontweight="bold") + ax.axis("off") + plt.tight_layout() + + return H + + + def stageActions(self, from_deps=True): ''' This method stages the action_sequence for a proper execution order. From ae35d72ab3d2d9b5d375e3fc8a486e1c7cbd6f54 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Mon, 3 Nov 2025 14:24:10 -0700 Subject: [PATCH 50/63] Resurrecting Sequence Graph Capabilities to Task Class2: - fixing other instances where Tasks are generated in irma.py with names being mentioned as the first argument, - sequence Graph updates duration and actions_ti - allowing actions fed into tasks to be either list or dictionary for flexibility --- famodel/irma/irma.py | 10 +++++----- famodel/irma/task.py | 9 ++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index e6abd2b7..040c8588 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -350,11 +350,11 @@ def registerTask(self, task): self.tasks[task.name] = task - def addTask(self, actions, action_sequence, task_name, **kwargs): + def addTask(self, task_name, actions, action_sequence, **kwargs): '''Creates a task and adds it to the register''' # Create the action - task = Task(actions, action_sequence, task_name, **kwargs) + task = Task(task_name, actions, action_sequence, **kwargs) # Register the action self.registerTask(task) @@ -454,7 +454,7 @@ def implementStrategy_staged(sc): act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) # create the task, passing in the sequence of actions - sc.addTask(acts, act_sequence, 'install_all_anchors') + sc.addTask('install_all_anchors', acts, act_sequence) # ----- Create a Task for all the mooring installs ----- @@ -478,7 +478,7 @@ def implementStrategy_staged(sc): act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) # create the task, passing in the sequence of actions - sc.addTask(acts, act_sequence, 'install_all_moorings') + sc.addTask('install_all_moorings', acts, act_sequence) # ----- Create a Task for the platform tow-out and hookup ----- @@ -501,7 +501,7 @@ def implementStrategy_staged(sc): act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) # create the task, passing in the sequence of actions - sc.addTask(acts, act_sequence, 'tow_and_hookup') + sc.addTask('tow_and_hookup', acts, act_sequence) diff --git a/famodel/irma/task.py b/famodel/irma/task.py index 4993cb91..ce7a5620 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -58,7 +58,12 @@ def __init__(self, name, actions, action_sequence=None, **kwargs): self.name = name - self.actions = {a.name: a for a in actions.values()} + + if isinstance(actions, dict): + self.actions = actions + elif isinstance(actions, list): + self.actions = {a.name: a for a in actions} + if action_sequence is None: self.stageActions(from_deps=True) @@ -186,6 +191,8 @@ def level_of(a: str, b: set[str]) -> int: elif acts and lv > num_cps: cp_string.append(f"End: {', '.join(acts)}") # Assign to self: + self.duration = task_duration + self.actions_ti = {a: level_start_time[lv] for a, lv in levels.items()} self.sequence_graph = H title_str = f"Task {self.name}. Duration {self.duration:.2f} : " + " | ".join(cp_string) if plot: From b3edf7a516526d36370b4e5e2e595d874281abe5 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Mon, 3 Nov 2025 14:40:23 -0700 Subject: [PATCH 51/63] Gantt Chart support for Task Class --- famodel/irma/calwave_task1.py | 14 ++- famodel/irma/calwave_vessels.yaml | 3 + famodel/irma/task.py | 136 +++++++++++++++++++++++++++++- famodel/irma/vessels.yaml | 21 +++-- 4 files changed, 162 insertions(+), 12 deletions(-) diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py index 956b2216..f71b7c67 100644 --- a/famodel/irma/calwave_task1.py +++ b/famodel/irma/calwave_task1.py @@ -8,7 +8,9 @@ from calwave_irma import Scenario import calwave_chart as chart # from calwave_task import Task # calwave_task module (Felipe) -from task import Task as Task # generic Task module ( Rudy ) +from task import Task # generic Task module ( Rudy ) + +import matplotlib.pyplot as plt sc = Scenario() # now sc exists in *this* session @@ -195,11 +197,15 @@ def assign_actions(sc: Scenario, actions: dict): # 5) Build Task task1 = Task(name='calwave_task1', actions=sc.actions) - task1.getSequenceGraph() + + task1.updateTaskTime(newStart=10) + + # 6) Build the Gantt chart + task1.GanttChart(color_by='asset') plt.show() - # task1.updateTaskTime(newStart=10) - # 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) + # Old chart building code: + # 7) 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) chart_view = chart.view_from_task(task1, sc, title='CalWave Task 1 - Anchor installation plan') chart.plot_task(chart_view, outpath='calwave_task1_chart.png') diff --git a/famodel/irma/calwave_vessels.yaml b/famodel/irma/calwave_vessels.yaml index ee508854..f3e4111b 100644 --- a/famodel/irma/calwave_vessels.yaml +++ b/famodel/irma/calwave_vessels.yaml @@ -2,6 +2,7 @@ San_Diego: # Crane barge for anchor handling + name: San_Diego type: crane_barge transport: homeport: national_city @@ -40,6 +41,7 @@ San_Diego: Jag: # Pacific Maritime Group tugboat assisting DB San Diego + name: Jag type: tug transport: homeport: national_city @@ -67,6 +69,7 @@ Jag: Beyster: # Primary support vessel + name: Beyster type: research_vessel transport: homeport: point_loma diff --git a/famodel/irma/task.py b/famodel/irma/task.py index ce7a5620..70a9c352 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -318,7 +318,7 @@ def updateTaskTime(self, newStart=0.0): # Update action start times for action in self.actions_ti: - self.actions_ti[action] += time_shift + self.actions_ti[action] += self.ti @@ -358,3 +358,137 @@ def get_row(self, assets): return np.zeros((len(assets), 2)) # placeholder, replace with actual matrix + + def GanttChart(self, start_at_zero=True, color_by=None): + '''Generate a Gantt chart for the task showing the schedule of actions. + + Returns + ------- + fig : matplotlib.figure.Figure + The figure object containing the Gantt chart. + ax : matplotlib.axes.Axes + The axes object containing the Gantt chart. + ''' + + # --- color palette --- + colors = [ + "lime", "orange", "magenta", "blue", + "red", "yellow", "cyan", "purple" + ] + + fig, ax = plt.subplots(figsize=(10, 10)) + + # Prepare data for Gantt chart + action_names = list(self.actions.keys()) + start_times = [self.actions_ti[name] for name in action_names] + durations = [self.actions[name].duration for name in action_names] + + # Get asset information from action.assets + all_assets = set() + all_roles = set() + for action in self.actions.values(): + for role, asset in action.assets.items(): + all_assets.add(asset['name']) + all_roles.add(role) + + # Assign colors + if color_by == 'asset': + asset_list = list(all_assets) + color_dict = {asset: colors[i] for i, asset in enumerate(asset_list)} + elif color_by == 'role': + # Flip the colors + colors = colors[::-1] + role_list = list(all_roles) + color_dict = {role: colors[i] for i, role in enumerate(role_list)} + + # Generate vertical lines to indicate the start and finish of the whole task + ax.axvline(x=self.ti, ymin=0, ymax=len(action_names), color='black', linestyle='-', linewidth=2.0) + ax.axvline(x=self.tf, ymin=0, ymax=len(action_names), color='black', linestyle='-', linewidth=2.0) + + # Create bars for each action + ht = 0.4 + for i, (name, start, duration) in enumerate(zip(action_names, start_times, durations)): + opp_i = len(action_names) - i - 1 # to have first action on top + action = self.actions[name] + assets = list({asset['name'] for asset in action.assets.values()}) + roles = list({role for role in action.assets.keys()}) + + assets = list(set(assets)) # Remove duplicates from assets + + n_assets = len(assets) + n_roles = len(roles) + + if color_by is None: + ax.barh(opp_i, duration, color='cyan', left=start, height=ht, align='center') + elif color_by == 'asset': + # Compute vertical offsets if multiple assets + if n_assets == 0: + # No assets info + ax.barh(i, duration, left=start, height=ht, color='cyan', align='center') + else: + sub_ht = ht / n_assets + for j, asset in enumerate(assets): + bottom = opp_i - ht/2 + j * sub_ht + color = color_dict.get(asset, 'gray') + ax.barh(bottom + sub_ht/2, duration, left=start, height=sub_ht * 0.9, + color=color, edgecolor='k', linewidth=0.3, align='center') + elif color_by == 'role': + # Compute vertical offsets if multiple roles + if n_roles == 0: + # No roles info + ax.barh(opp_i, duration, left=start, height=ht, color='cyan', align='center') + else: + sub_ht = ht / n_roles + for j, role in enumerate(roles): + bottom = opp_i - ht/2 + j * sub_ht + color = color_dict.get(role, 'gray') + ax.barh(bottom + sub_ht/2, duration, left=start, height=sub_ht * 0.9, + color=color, edgecolor='k', linewidth=0.3, align='center') + else: + color_by = None + raise Warning(f"color_by option '{color_by}' not recognized. Use 'asset', 'role'. None will be used") + + ax.text(self.ti, opp_i, f' {name}', va='center', ha='left', color='black') + ax.axhline(y=opp_i - ht/2, xmin=0, xmax=self.tf, color='gray', linestyle='--', linewidth=0.5) + ax.axhline(y=opp_i + ht/2, xmin=0, xmax=self.tf, color='gray', linestyle='--', linewidth=0.5) + ax.axvline(x=start, ymin=0, ymax=len(action_names), color='gray', linestyle='--', linewidth=0.5) + + # Set y-ticks and labels + ax.set_yticks(range(len(action_names))) + ax.set_yticklabels([]) + + # Set labels and title + ax.set_xlabel('time (hrs.)') + ax.set_title(f'Gantt Chart for Task: {self.name}') + + if color_by == 'asset': + handles = [plt.Rectangle((0, 0), 1, 1, color=color_dict[a]) for a in all_assets] + ax.legend(handles, all_assets, title='Assets', bbox_to_anchor=(1.02, 1), loc='upper right') + elif color_by == 'role': + handles = [plt.Rectangle((0, 0), 1, 1, color=color_dict[a]) for a in all_roles] + ax.legend(handles, all_roles, title='Roles', bbox_to_anchor=(1.02, 1), loc='upper right') + + if start_at_zero: + ax.set_xlim(0, self.tf + 1) + # Create a grid and adjust layout + # ax.grid(True) + plt.tight_layout() + return fig, ax + + def chart(self, start_at_zero=True): + '''Generate a chart grouped by asset showing when each asset is active across all actions. + + Parameters + ---------- + start_at_zero : bool, optional + If True, the x-axis starts at zero. Defaults to True. + + Returns + ------- + fig : matplotlib.figure.Figure + The figure object containing the Gantt chart. + ax : matplotlib.axes.Axes + The axes object containing the Gantt chart. + ''' + pass + \ No newline at end of file diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 310da41b..1f22f586 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -3,7 +3,8 @@ # --- Anchor Handling Tug / Supply Vessel (AHTS / AHV) --- AHTS_alpha: -# Offshore Tug/Anchor Handling Tug Supply (AHTS) – Towing floating structures, handling and laying anchors/mooring lines, tensioning and positioning support. + # Offshore Tug/Anchor Handling Tug Supply (AHTS) – Towing floating structures, handling and laying anchors/mooring lines, tensioning and positioning support. + name: AHTS_alpha type: AHTS transport: transit_speed_mps: 4.7 @@ -53,7 +54,8 @@ AHTS_alpha: # --- Multipurpose Support Vessel --- MPSV_01: -# Multi-Purpose Support Vessel (MSV) – Flexible vessel used for maintenance, diving, construction, or ROV tasks. Combines features of CSV, DSV and ROVSV. + # Multi-Purpose Support Vessel (MSV) – Flexible vessel used for maintenance, diving, construction, or ROV tasks. Combines features of CSV, DSV and ROVSV. + name: MPSV_01 type: MSV transport: transit_speed_mps: 4.7 @@ -99,7 +101,8 @@ MPSV_01: # --- Construction Support Vessel --- CSV_A: -# Construction Support Vessel (CSV) – General-purpose vessel supporting subsea construction, cable lay and light installation. Equipped with cranes, moonpools and ROVs. + # Construction Support Vessel (CSV) – General-purpose vessel supporting subsea construction, cable lay and light installation. Equipped with cranes, moonpools and ROVs. + name: CSV_A type: CSV transport: transit_speed_mps: 4.7 @@ -151,7 +154,8 @@ CSV_A: # --- ROV Support Vessel --- ROVSV_X: -# ROV Support Vessel (ROVSV) – Dedicated to operating and supporting Remotely Operated Vehicles (ROVs) for inspection, survey or intervention. + # ROV Support Vessel (ROVSV) – Dedicated to operating and supporting Remotely Operated Vehicles (ROVs) for inspection, survey or intervention. + name: ROVSV_X type: ROVSV transport: transit_speed_mps: 6.7 @@ -186,7 +190,8 @@ ROVSV_X: # --- Diving Support Vessel --- DSV_Moon: -# Diving Support Vessel (DSV) – Specifically equipped to support saturation diving operations. Includes diving bells, decompression chambers and dynamic positioning. + # Diving Support Vessel (DSV) – Specifically equipped to support saturation diving operations. Includes diving bells, decompression chambers and dynamic positioning. + name: DSV_Moon type: DSV transport: transit_speed_mps: 4.7 @@ -213,7 +218,8 @@ DSV_Moon: # --- Heavy Lift Vessel --- HL_Giant: -# Heavy Lift Vessel (HL) – Used for transporting and installing very large components, like jackets, substations, or monopiles. Equipped with high-capacity cranes (>3000 t). + # Heavy Lift Vessel (HL) – Used for transporting and installing very large components, like jackets, substations, or monopiles. Equipped with high-capacity cranes (>3000 t). + name: HL_Giant type: HL transport: transit_speed_mps: 4.7 @@ -268,7 +274,8 @@ SURV_Swath: # --- Barge --- Barge_squid: -# Barge – non-propelled flat-top vessel used for transporting heavy equipment, components and materials. Requires towing or positioning support from tugs or AHTS vessels. + # Barge – non-propelled flat-top vessel used for transporting heavy equipment, components and materials. Requires towing or positioning support from tugs or AHTS vessels. + name: Barge_squid type: BARGE transport: transit_speed_mps: 2 # No self-propulsion From 9f410b816a4bb6845623d831acaa725b614955c6 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:19:27 -0700 Subject: [PATCH 52/63] Expanding Task.init sequencing options: - Made action_sequence input support both dict and string options. - Moved the dependency-based sequencing code into .init. - Added a 'series' option as default, putting every action in order. - Removed some of my now-redundant code in Irma implementStrategy_staged. --- famodel/irma/irma.py | 37 +++++----------------- famodel/irma/task.py | 74 ++++++++++++++++++++++++++++++-------------- 2 files changed, 58 insertions(+), 53 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 040c8588..c97746b8 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -444,17 +444,10 @@ def implementStrategy_staged(sc): for action in sc.actions.values(): if action.type == 'install_anchor': acts.append(action) - - # create a dictionary of dependencies indicating that these actions are all in series - act_sequence = {} # key is action name, value is a list of what action names are to be completed before it - for i in range(len(acts)): - if i==0: # first action has no dependencies - act_sequence[acts[i].name] = [] - else: # remaining actions are just a linear sequence - act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) - + # create the task, passing in the sequence of actions - sc.addTask('install_all_anchors', acts, act_sequence) + sc.addTask('install_all_anchors', acts, action_sequence='series') + # ----- Create a Task for all the mooring installs ----- @@ -468,17 +461,9 @@ def implementStrategy_staged(sc): for action in sc.actions.values(): if action.type == 'lay_mooring': acts.append(action) - - # create a dictionary of dependencies indicating that these actions are all in series - act_sequence = {} # key is action name, value is a list of what action names are to be completed before it - for i in range(len(acts)): - if i==0: # first action has no dependencies - act_sequence[acts[i].name] = [] - else: # remaining actions are just a linear sequence - act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) - + # create the task, passing in the sequence of actions - sc.addTask('install_all_moorings', acts, act_sequence) + sc.addTask('install_all_moorings', acts, action_sequence='series') # ----- Create a Task for the platform tow-out and hookup ----- @@ -491,17 +476,9 @@ def implementStrategy_staged(sc): for action in sc.actions.values(): if action.type == 'mooring_hookup': acts.append(action) - - # create a dictionary of dependencies indicating that these actions are all in series - act_sequence = {} # key is action name, value is a list of what action names are to be completed before it - for i in range(len(acts)): - if i==0: # first action has no dependencies - act_sequence[acts[i].name] = [] - else: # remaining actions are just a linear sequence - act_sequence[acts[i].name] = [ acts[i-1].name ] # (previous action must be done first) - + # create the task, passing in the sequence of actions - sc.addTask('tow_and_hookup', acts, act_sequence) + sc.addTask('tow_and_hookup', acts, action_sequence='series') diff --git a/famodel/irma/task.py b/famodel/irma/task.py index 70a9c352..9c1e64ed 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -32,7 +32,7 @@ class Task(): ''' - def __init__(self, name, actions, action_sequence=None, **kwargs): + def __init__(self, name, actions, action_sequence='series', **kwargs): '''Create an action object... It must be given a name and a list of actions. The action list should be by default coherent with actionTypes dictionary. @@ -44,33 +44,60 @@ def __init__(self, name, actions, action_sequence=None, **kwargs): are duplicate names. actions : list A list of all actions that are part of this task. - action_sequence : dict, optional - A dictionary where each key is the name of each action, and the values are + action_sequence : string or dict, optional + If a dictionary, each key is the name of each action, and the values are each a list of which actions (by name) must be completed before the current - one. If None, the action_sequence will be built by calling self.stageActions(from_deps=True) - [building from the dependencies of each action]. + one. + If a string, indicates which approach is used for automatically + setting the sequence of actions: + 'series': one after the other based on the order in actions (default), + 'dependencies': based on the dependencies of each action. kwargs Additional arguments may depend on the task type. ''' - - self.name = name + # Save the task's dictionary of actions if isinstance(actions, dict): self.actions = actions - elif isinstance(actions, list): + elif isinstance(actions, list): # turn list into a dict based on name self.actions = {a.name: a for a in actions} - - if action_sequence is None: - self.stageActions(from_deps=True) - else: + # --- Set up the sequence of actions --- + # key is action name, value is a list of what action names are to be completed before it + + if isinstance(action_sequence, dict): # Full dict provided (use directly) self.action_sequence = {k: list(v) for k, v in action_sequence.items()} - - + + elif isinstance(action_sequence, str): + self.action_sequence = {} + + if action_sequence == 'series': # Puts the actions in linear sequence + for i in range(len(actions)): + if i==0: # first action has no dependencies + self.action_sequence[actions[i].name] = [] + else: # previous action must be done first + self.action_sequence[actions[i].name] = [ actions[i-1].name ] + + elif action_sequence == 'dependencies': # Sequences based on the dependencies of each action + + def getDeps(action): + deps = [] + for dep in action.dependencies: + deps.append(dep) + return deps + + self.action_sequence = {self.actions[name].name: getDeps(self.actions[name]) for name in self.actions} + else: + raise Exception("Action_sequence must be either 'series' or 'dependencies', or a dict.") + else: + raise Exception("Action_sequence must be either a string or dict.") + + + # Initialize some task variables self.status = 0 # 0, waiting; 1=running; 2=finished self.actions_ti = {} # relative start time of each action [h] @@ -86,6 +113,7 @@ def __init__(self, name, actions, action_sequence=None, **kwargs): print(f"---------------------- Initializing Task '{self.name} ----------------------") print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.") print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ") + def getSequenceGraph(self, action_sequence=None, plot=True): '''Generate a multi-directed graph that visalizes action sequencing within the task. @@ -272,6 +300,7 @@ def calcDuration(self): # Iterate through actions in the sequence for action, dep_actions in self.action_sequence.items(): # Calculate start time as the max finish time of dependencies + # (set as zero if the action does not depend on other actions in the task) starts[action] = max((finishes[dep] for dep in dep_actions), default=0) # get duration from actions @@ -283,12 +312,9 @@ def calcDuration(self): # Update self.actions_ti with relative start times self.actions_ti = starts - # Set task start time and finish time - self.ti = min(starts.values(), default=0) - self.tf = max(finishes.values(), default=0) - # Task duration - self.duration = self.tf - self.ti + self.duration = max(finishes.values()) + def calcCost(self): '''Calculates the total cost of the task based on the costs of individual actions. @@ -301,20 +327,21 @@ def calcCost(self): self.cost = total_cost return self.cost - def updateTaskTime(self, newStart=0.0): + + def updateStartTime(self, newStart=0.0): '''Update the start time of all actions based on a new task start time. + This requires that the task's duration and relative action start times are + already calculated. Parameters ---------- newStart : float The new start time for the task. All action start times will be adjusted accordingly. ''' - # Calculate the time shift - time_shift = newStart - self.ti # Update task start and finish times self.ti = newStart - self.tf += time_shift + self.tf = newStart + self.duration # Update action start times for action in self.actions_ti: @@ -475,6 +502,7 @@ def GanttChart(self, start_at_zero=True, color_by=None): plt.tight_layout() return fig, ax + def chart(self, start_at_zero=True): '''Generate a chart grouped by asset showing when each asset is active across all actions. From 1f0feb7d0b3f142334c3c36f099fc85a0cdcb3b0 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Wed, 5 Nov 2025 12:47:58 -0700 Subject: [PATCH 53/63] changing calwave task 1 example to run with task.py changes --- famodel/irma/calwave_task1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py index f71b7c67..404ddde0 100644 --- a/famodel/irma/calwave_task1.py +++ b/famodel/irma/calwave_task1.py @@ -196,9 +196,9 @@ def assign_actions(sc: Scenario, actions: dict): # 5) Build Task - task1 = Task(name='calwave_task1', actions=sc.actions) + task1 = Task(name='calwave_task1', actions=sc.actions, action_sequence='dependencies') - task1.updateTaskTime(newStart=10) + task1.updateStartTime(newStart=10) # 6) Build the Gantt chart task1.GanttChart(color_by='asset') From 6aeb87e68190992fe6b430149b59713e76dbc320 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:17:57 -0700 Subject: [PATCH 54/63] Bringing in case study updates into main code Updated the following files with the calwave_ additions: - action.py - actions.yaml - capabilities.yaml --- famodel/irma/action.py | 443 +++++++++++++++++++++++++++++++-- famodel/irma/actions.yaml | 138 +++++++++- famodel/irma/capabilities.yaml | 23 +- famodel/irma/task.py | 1 - 4 files changed, 574 insertions(+), 31 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 7b90a04a..d00f6f60 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -120,6 +120,8 @@ def __init__(self, actionType, name, **kwargs): self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on + self.actionType = actionType # <— keep the YAML dict on the instance + self.type = getFromDict(actionType, 'type', dtype=str) self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished @@ -842,7 +844,7 @@ def calcDurationAndCost(self): ------- `None` ''' - + # Check that all roles in the action are filled for role_name in self.requirements.keys(): if self.assets[role_name] is None: @@ -850,8 +852,8 @@ def calcDurationAndCost(self): # Initialize cost and duration self.cost = 0.0 # [$] - self.duration = 0.0 # [days] - + self.duration = 0.0 # [h] + """ Note to devs: The code here calculates the cost and duration of an action. Each action in the actions.yaml has a hardcoded 'model' @@ -863,24 +865,324 @@ def calcDurationAndCost(self): Some good preliminary work on this is in https://github.com/FloatingArrayDesign/FAModel/blob/IOandM_development/famodel/installation/ and in assets.py """ + + # --- Mobilization --- + if self.type == 'mobilize': + # Hard-coded example of mobilization times based on vessel type + durations = { + 'crane_barge': 3.0, + 'research_vessel': 1.0 + } + for role_name, vessel in self.assets.items(): + vessel_type = vessel['type'].lower() + for key, duration in durations.items(): + if key in vessel_type: + self.duration += duration + break + + elif self.type == 'demobilize': + # Hard-coded example of demobilization times based on vessel type + durations = { + 'crane_barge': 3.0, + 'research_vessel': 1.0 + } + for role_name, vessel in self.assets.items(): + vessel_type = vessel['type'].lower() + for key, duration in durations.items(): + if key in vessel_type: + self.duration += duration + elif self.type == 'load_cargo': + pass # --- Towing & Transport --- - if self.type == 'tow': - pass - elif self.type == 'transport_components': + elif self.type == 'tow': pass + + elif self.type == 'transit_linehaul_self': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + vessel = self.assets.get('vessel') or self.assets.get('operator') or self.assets.get('carrier') + if vessel is None: + raise ValueError('transit_linehaul_self: no vessel assigned.') + + tr = vessel['transport'] + + # distance + dist_m = float(tr['route_length_m']) + + # speed: linehaul uses transport.cruise_speed_mps + speed_mps = float(tr['cruise_speed_mps']) + + dur_h = dist_m/speed_mps/3600.0 + self.duration += dur_h + # cost + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + + elif self.type == 'transit_linehaul_tug': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + tug = self.assets.get('operator') or self.assets.get('vessel') + barge = self.assets.get('carrier') + if tug is None or barge is None: + raise ValueError('transit_linehaul_tug: need tug (operator) and barge (carrier).') + + tr_b = barge.get('transport', {}) + tr_t = tug.get('transport', {}) + + # distance: prefer barge’s transport + dist_m = float(tr_b.get('route_length_m', tr_t['route_length_m'])) + + # speed for convoy linehaul: barge (operator) cruise speed + operator = self.assets.get('operator') or self.assets.get('vessel') + if operator is None: + raise ValueError('transit_linehaul_tug: operator (barge) missing.') + + speed_mps = float(operator['transport']['cruise_speed_mps']) - # --- Mooring & Anchors --- - elif self.type == 'install_anchor': + dur_h = dist_m/speed_mps/3600.0 + + + self.duration += dur_h + + # cost + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + elif self.type == 'transit_onsite_self': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + # vessel (Beyster) required + vessel = self.assets.get('vessel') or self.assets.get('operator') or self.assets.get('carrier') + if vessel is None: + raise ValueError('transit_onsite_self: no vessel assigned.') + + # NEW: quick vessel print + try: + print(f"[onsite_self] {self.name}: vessel={vessel.get('type')}") + except Exception: + pass + + # destination anchor from objects (required) + if not self.objectList: + raise ValueError('transit_onsite_self: destination anchor missing in objects.') + dest = self.objectList[0] + r_dest = getattr(dest, 'r', None) + + # NEW: print dest + try: + print(f"[onsite_self] {self.name}: r_dest={r_dest}") + except Exception: + pass + + # infer start from dependency chain (BFS up to depth 3) + r_start = None + from collections import deque + q, seen = deque(), set() + for dep in self.dependencies.values(): + q.append((dep, 0)); seen.add(id(dep)) + while q: + node, depth = q.popleft() + if node.objectList and hasattr(node.objectList[0], 'r'): + r_start = node.objectList[0].r + break + # if depth < 3: + # for nxt in node.dependencies.values(): + # if id(nxt) in seen: continue + # seen.add(id(nxt)); q.append((nxt, depth+1)) + + # NEW: print BFS result + try: + print(f"[onsite_self] {self.name}: r_start(BFS)={r_start}") + except Exception: + pass + + # CHANGED: fallback for first onsite leg → try centroid, else keep old zero-distance fallback + if r_start is None and r_dest is not None: + # NEW: centroid read (linehaul_to_site should set it on this action) + cent = (getattr(self, 'meta', {}) or {}).get('anchor_centroid') + if cent is None: + cent = (getattr(self, 'params', {}) or {}).get('anchor_centroid') + if cent is not None and len(cent) >= 2: + r_start = (float(cent[0]), float(cent[1])) + try: + print(f"[onsite_self] {self.name}: using centroid as r_start={r_start}") + except Exception: + pass + else: + # ORIGINAL behavior: assume zero in-field distance + r_start = r_dest + try: + print(f"[warn] {self.name}: could not infer start from deps; assuming zero in-field distance.") + except Exception: + pass + + # 2D distance [m] + from math import hypot + dx = float(r_dest[0]) - float(r_start[0]) + dy = float(r_dest[1]) - float(r_start[1]) + dist_m = hypot(dx, dy) + + # NEW: print distance + try: + print(f"[onsite_self] {self.name}: dist_m={dist_m:.1f} (start={r_start} → dest={r_dest})") + except Exception: + pass + + # onsite speed from capabilities.engine (SI) + cap_eng = vessel.get('capabilities', {}).get('engine', {}) + speed_mps = float(cap_eng['site_speed_mps']) - # Place holder duration, will need a mini-model to calculate - self.duration += 0.2 # 0.2 days - self.cost += self.duration * (self.assets['carrier']['day_rate'] + self.assets['operator']['day_rate']) + self.duration += dist_m/speed_mps/3600.0 + + # NEW: print duration increment + try: + print(f"[onsite_self] {self.name}: speed_mps={speed_mps:.3f}, dT_h={dist_m/speed_mps/3600.0:.3f}, total={self.duration:.3f}") + except Exception: + pass + + # cost + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + elif self.type == 'transit_onsite_tug': + # YAML override + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + # assets required (operator = San_Diego tug; carrier = Jag barge) + operator = self.assets.get('operator') or self.assets.get('vessel') + carrier = self.assets.get('carrier') + if operator is None and carrier is None: + raise ValueError('transit_onsite_tug: no operator/carrier assigned.') + + # quick prints + try: + op_name = operator.get('type') if operator else None + ca_name = carrier.get('type') if carrier else None + print(f"[onsite_tug] {self.name}: operator={op_name} carrier={ca_name}") + except Exception: + pass + + # destination anchor from objects (required) + if not self.objectList: + raise ValueError('transit_onsite_tug: destination anchor missing in objects.') + dest = self.objectList[0] + r_dest = getattr(dest, 'r', None) + + try: + print(f"[onsite_tug] {self.name}: r_dest={r_dest}") + except Exception: + pass + + # infer start from dependency chain (BFS up to depth 3) + r_start = None + from collections import deque + q, seen = deque(), set() + for dep in self.dependencies.values(): + q.append((dep, 0)); seen.add(id(dep)) + while q: + node, depth = q.popleft() + if node.objectList and hasattr(node.objectList[0], 'r'): + r_start = node.objectList[0].r + break + # if depth < 3: + # for nxt in node.dependencies.values(): + # if id(nxt) in seen: continue + # seen.add(id(nxt)); q.append((nxt, depth+1)) + + try: + print(f"[onsite_tug] {self.name}: r_start(BFS)={r_start}") + except Exception: + pass + + # fallback for first onsite leg: use centroid if present, else zero-distance fallback + if r_start is None and r_dest is not None: + cent = (getattr(self, 'meta', {}) or {}).get('anchor_centroid') + if cent is None: + cent = (getattr(self, 'params', {}) or {}).get('anchor_centroid') + if cent is not None and len(cent) >= 2: + r_start = (float(cent[0]), float(cent[1])) + try: + print(f"[onsite_tug] {self.name}: using centroid as r_start={r_start}") + except Exception: + pass + else: + r_start = r_dest + try: + print(f"[warn] {self.name}: could not infer start from deps; assuming zero in-field distance.") + except Exception: + pass + + # 2D distance [m] + from math import hypot + dx = float(r_dest[0]) - float(r_start[0]) + dy = float(r_dest[1]) - float(r_start[1]) + dist_m = hypot(dx, dy) + + try: + print(f"[onsite_tug] {self.name}: dist_m={dist_m:.1f} (start={r_start} → dest={r_dest})") + except Exception: + pass + + # speed for convoy onsite: barge (operator) site speed + operator = self.assets.get('operator') or self.assets.get('vessel') + if operator is None: + raise ValueError('transit_onsite_tug: operator (barge) missing.') + + cap_eng = operator.get('capabilities', {}).get('bollard_pull', {}) + speed_mps = float(cap_eng['site_speed_mps']) - elif self.type == 'retrieve_anchor': + self.duration += dist_m/speed_mps/3600.0 + + try: + print(f"[onsite_tug] {self.name}: speed_mps={speed_mps:.3f}, dT_h={dist_m/speed_mps/3600.0:.3f}, total={self.duration:.3f}") + except Exception: + pass + + # cost (unchanged) + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration*rate_per_hour + return self.duration, self.cost + + elif self.type == 'at_site_support': + pass + elif self.type == 'transport_components': pass - elif self.type == 'load_mooring': + # --- Mooring & Anchors --- + + elif self.type == 'load_mooring': # Example model assuming line will be winched on to vessel. This can be changed if not most accurate duration_min = 0 for obj in self.objectList: @@ -891,11 +1193,56 @@ def calcDurationAndCost(self): self.duration += duration_min / 60 / 24 # convert minutes to days self.cost += self.duration * (self.assets['carrier1']['day_rate'] + self.assets['carrier2']['day_rate'] + self.assets['operator']['day_rate']) # cost of all assets involved for the duration of the action [$] - # check for deck space availability, if carrier 1 met transition to carrier 2. - - # think through operator costs, carrier 1 costs. + elif self.type == 'install_anchor': + # YAML override (no model if present) + default_duration = None + try: + default_duration = getFromDict(self.actionType, 'duration_h', dtype=float) + except ValueError: + default_duration = None + + if default_duration is not None: + computed_duration_h = default_duration + + else: + # Expect an anchor object in self.objectList + if not self.objectList: + raise ValueError("install_anchor: no anchor object provided in 'objects'.") + + # 1) Relevant metrics for cost and duration + anchor = self.objectList[0] + L = anchor.dd['design']['L'] + depth_m = abs(float(anchor.r[2])) + + # 2) Winch vertical speed [mps] + v_mpm = float(self.assets['carrier']['capabilities']['winch']['speed_mpm']) + t_lower_min = depth_m/v_mpm + + # 3) Penetration time ~ proportional to L + rate_pen = 15. # [min] per [m] + t_pen_min = L*rate_pen + + # 4) Connection / release (fixed) + t_ops_min = 15 + + duration_min = t_lower_min + t_pen_min + t_ops_min + computed_duration_h = duration_min/60.0 # [h] + + # print(f'[install_anchor] yaml_duration={yaml_duration} -> used={computed_duration_h} h') + + # Duration addition + self.duration += computed_duration_h + + # Cost assessment + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + + self.cost += self.duration*rate_per_hour - elif self.type == 'lay_mooring': + elif self.type == 'retrieve_anchor': + pass + elif self.type == 'install_mooring': pass elif self.type == 'mooring_hookup': pass @@ -915,6 +1262,8 @@ def calcDurationAndCost(self): # --- Cable Operations --- elif self.type == 'lay_cable': pass + elif self.type == 'cable_hookup': + pass elif self.type == 'retrieve_cable': pass elif self.type == 'lay_and_bury_cable': @@ -925,8 +1274,66 @@ def calcDurationAndCost(self): # --- Survey & Monitoring --- elif self.type == 'site_survey': pass + elif self.type == 'monitor_installation': - pass + # 1) YAML override first + try: + v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v + except ValueError: + try: + v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v + except ValueError: + # --- find the paired install --- + ref_install = getattr(self, 'paired_install', None) + + # fallback: BFS through deps to find an install on the same anchor + if ref_install is None: + anchor_obj = self.objectList[0] if self.objectList else None + from collections import deque + q, seen = deque(), set() + for dep in self.dependencies.values(): + q.append((dep, 0)); seen.add(id(dep)) + while q: + node, depth = q.popleft() + if getattr(node, 'type', None) == 'install_anchor': + if anchor_obj and node.objectList and node.objectList[0] is anchor_obj: + ref_install = node + break + if ref_install is None: + ref_install = node + if depth < 3: + for nxt in node.dependencies.values(): + if id(nxt) in seen: continue + seen.add(id(nxt)); q.append((nxt, depth+1)) + + # --- get install duration, compute-on-demand if needed (no side effects) --- + inst_dur = 0.0 + if ref_install is not None: + inst_dur = float(getattr(ref_install, 'duration', 0.0) or 0.0) + + # if not computed yet, safely compute and restore + if inst_dur <= 0.0 and not getattr(ref_install, '_in_monitor_pull', False): + try: + ref_install._in_monitor_pull = True # guard re-entrancy + prev_cost = ref_install.cost + prev_dur = ref_install.duration + d, _ = ref_install.calcDurationAndCost() + inst_dur = float(d) if d is not None else 0.0 + # restore to avoid double counting later + ref_install.cost = prev_cost + ref_install.duration = prev_dur + finally: + ref_install._in_monitor_pull = False + + self.duration += inst_dur + + # cost (same pattern you use elsewhere) + rate_per_hour = 0.0 + for _, asset in self.assets.items(): + rate_per_hour += float(asset['day_rate'])/24.0 + self.cost += self.duration * rate_per_hour + return self.duration, self.cost + else: raise ValueError(f"Action type '{self.type}' not recognized.") diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 6fdd6e67..58ad4003 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -22,13 +22,90 @@ # Hs_m: 0.0 "Wave height constraints in meters" # description: "A description" + +# --- Mobilization --- + +mobilize: + objects: [] + roles: + operator: + - deck_space + duration_h: + Hs_m: + description: "Mobilization of vessel in homeport" + +demobilize: + objects: [] + roles: + operator: + - deck_space + capabilities: [] + duration_h: 1.0 + description: "Demobilization of vessel in homeport" + +load_cargo: + objects: [anchor, mooring, cable, platform, component] + roles: # The asset roles involved and the capabilities required of each role + #carrier1: [] # the port or vessel where the moorings begin + # (no requirements) + #carrier2: # the vessel things will be loaded onto + #- deck_space + #- winch + #- positioning_system + operator: # the entity with the crane (like the port or the new vessel) + - crane + - deck_space + duration_h: + Hs_m: + description: "Load-out of mooring systems and components from port or vessel onto vessel." + + # --- Towing & Transport --- + +transit_linehaul_self: + objects: [anchor] + roles: + vessel: + - engine + duration_h: + description: "Self-propelled line-haul between port and site" + +transit_linehaul_tug: + objects: [anchor] + roles: + carrier: + - engine + operator: + - bollard_pull + duration_h: + description: "Tugged line-haul convoy (tug + barge) between port and site" + +transit_onsite_self: + objects: [anchor] + roles: + vessel: + - engine + duration_h: + description: "Self-propelled in-field move between site locations" + +transit_onsite_tug: + objects: [anchor] + roles: + carrier: + - engine + operator: + - bollard_pull + duration_h: + description: "Tug + barge in-field move between site locations" + + tow: objects: [platform] roles: # The asset roles involved and the capabilities required of each role - vessel: - - deck_space + carrier: + - engine + operator: - bollard_pull - winch - positioning_system @@ -40,6 +117,8 @@ transport_components: objects: [component] roles: # The asset roles involved and the capabilities required of each role carrier: # vessel carrying things + - engine + - bollard_pull - deck_space - crane - positioning_system @@ -47,12 +126,30 @@ transport_components: Hs_m: description: "Transport of large components such as towers, nacelles, blades, or jackets." +at_site_support: + objects: [] + roles: # The asset roles involved and the capabilities required of each role + # tug: # vessel carrying things + # - bollard_pull + # - deck_space + # - winch + # - positioning_system + # - monitoring_system + operator: + - engine + duration_h: + Hs_m: + description: "Transport of vessel around the site to provide support." + # --- Mooring & Anchors --- install_anchor: objects: [anchor, component] roles: # The asset roles involved and the capabilities required of each role - carrier: # vessl that has been carrying the anchor + carrier: # vessel that provides propulsion + - engine + operator: # vessel that carries, lowers and installs the anchor + - bollard_pull - deck_space operator: # vessel that lowers and installs the anchor - winch @@ -69,8 +166,10 @@ retrieve_anchor: objects: [anchor, component] roles: # The asset roles involved and the capabilities required of each role carrier: - - deck_space + - engine operator: + - bollard_pull + - deck_space - winch - bollard_pull - crane @@ -101,8 +200,10 @@ lay_mooring: objects: [mooring, component] roles: # The asset roles involved and the capabilities required of each role carrier: # vessel carrying the mooring - - deck_space + - engine operator: # vessel laying the mooring + - bollard_pull + - deck_space - winch - bollard_pull - mooring_work @@ -215,6 +316,20 @@ lay_cable: Hs_m: description: "Laying static/dynamic power cables, including burial where required." +cable_hookup: + objects: [cable, component, platform] + roles: # The asset roles involved and the capabilities required of each role + carrier: + - deck_space + operator: + - winch + - bollard_pull + - mooring_work + - positioning_system + - monitoring_system + duration_h: + Hs_m: + description: "Hook-up of cable to floating platforms, including pretensioning." retrieve_cable: objects: [cable] capabilities: @@ -267,10 +382,21 @@ site_survey: monitor_installation: objects: [anchor, mooring, component, platform, cable] - capabilities: + roles: + support: - positioning_system - monitoring_system - rov duration_h: Hs_m: description: "Real-time monitoring of installation operations using ROV and sensor packages." + +diver_support: + objects: [] + capabilities: + - positioning_system + - sonar_survey + - monitoring_system + duration_h: + Hs_m: + description: "Divers site survey including monitoring and positioning." \ No newline at end of file diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 15cf8547..1d22fda8 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -9,12 +9,23 @@ # for allowing conventional unit inputs. <<< # --- Vessel (on-board) --- + - name: engine + # description: Engine on-board of the vessel + # fields: + power_hp: # power [horsepower] + site_speed_mps: # speed [m/s] + - name: bollard_pull + # description: Towing/holding force capability + # fields: + max_force_t: # bollard pull [t] + site_speed_mps: # speed [m/s] + - name: deck_space # description: Clear usable deck area and allowable load # fields: - area_m2: # usable area [m2] - max_load_t: # allowable deck load [t] + area_m2: # usable area [m2] + max_load_t: # allowable deck load [t] - name: chain_locker # description: Chain storage capacity @@ -40,10 +51,10 @@ brake_load_t: # static brake holding load [t] speed_mpm: # payout/haul speed [m/min] - - name: bollard_pull - # description: Towing/holding force capability - # fields: - max_force_t: # bollard pull [t] + + + + - name: crane # description: Main crane lifting capability diff --git a/famodel/irma/task.py b/famodel/irma/task.py index 9c1e64ed..c4282fbb 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -519,4 +519,3 @@ def chart(self, start_at_zero=True): The axes object containing the Gantt chart. ''' pass - \ No newline at end of file From 58eb102989ff8340d3b72aa7d128246770b19f76 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:21:27 -0700 Subject: [PATCH 55/63] IRMA updates to work with seabedtools move --- famodel/irma/action.py | 1 - famodel/irma/irma.py | 1 - 2 files changed, 2 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index d00f6f60..d0adf0fb 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -10,7 +10,6 @@ from copy import deepcopy #from shapely.geometry import Point, Polygon, LineString -from famodel.seabed import seabed_tools as sbt from famodel.mooring.mooring import Mooring from famodel.platform.platform import Platform from famodel.anchors.anchor import Anchor diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index c97746b8..b5e33274 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -16,7 +16,6 @@ pass #from shapely.geometry import Point, Polygon, LineString -from famodel.seabed import seabed_tools as sbt from famodel.mooring.mooring import Mooring from famodel.platform.platform import Platform from famodel.anchors.anchor import Anchor From 2cbac528c110242f3acf530a86418e109070ee4c Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Fri, 7 Nov 2025 16:43:35 -0700 Subject: [PATCH 56/63] UNDER PROGRESS: removing roles from actions and work on requirements and capabilitiy mapping - Examples won't work with this commit: - roles are replaced with requirements dictionary in the init of action.py. The keys are the requirements that will be mapped to asset capabilities and values are booleans of True and False (depending on the objects and material constraints described in requirements.yaml. - requirements.yaml dictionary now should be inputted when creating the action . - roles still exist in checkAsset, evaluateAsset, assignAsset, etc., this is work in progress. - removed stageAction from Task.py. - clear assets after assigning them in action and task - update task finish time in task.calcDuration function - updated actions.yaml, capabilities.yaml, and requirements.yaml. --- famodel/irma/action.py | 100 +++++++++-- famodel/irma/actions.yaml | 304 +++++++++++++-------------------- famodel/irma/calwave_action.py | 21 ++- famodel/irma/calwave_task1.py | 5 +- famodel/irma/capabilities.yaml | 36 +++- famodel/irma/requirements.yaml | 187 ++++++++++++++++++++ famodel/irma/task.py | 37 ++-- 7 files changed, 458 insertions(+), 232 deletions(-) create mode 100644 famodel/irma/requirements.yaml diff --git a/famodel/irma/action.py b/famodel/irma/action.py index d0adf0fb..a16248c6 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -91,7 +91,7 @@ class Action(): subclasses. ''' - def __init__(self, actionType, name, **kwargs): + def __init__(self, actionType, name, allReq, **kwargs): '''Create an action object... It must be given a name. The remaining parameters should correspond to items in the actionType dict... @@ -103,6 +103,9 @@ def __init__(self, actionType, name, **kwargs): `name` : `string` A name for the action. It may be appended with numbers if there are duplicate names. + `allReq` : `dict` + A dicitonary of all possible requirements (capabilities) that is needed + for mapping/assigning requirements to assets. `kwargs` Additional arguments may depend on the action type and typically include a list of FAModel objects that are acted upon, or @@ -114,13 +117,15 @@ def __init__(self, actionType, name, **kwargs): ''' # list of things that will be controlled during this action - self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action - self.requirements = {} # capabilities required of each role (same keys as self.assets) + self.assetList = [] # list of assigned assets (vessels or ports) required to perform the action + self.requirements = {} # dictionary of requirements (keys) and a boolean (True/False) indicating whether they're needed or not (values) self.objectList = [] # all objects that could be acted on + self.materialList = [] # all materials that could be acted on self.dependencies = {} # list of other actions this one depends on self.actionType = actionType # <— keep the YAML dict on the instance - + self.allReq = allReq # <— keep the full requirements dict on the instance + self.type = getFromDict(actionType, 'type', dtype=str) self.name = name self.status = 0 # 0, waiting; 1=running; 2=finished @@ -149,11 +154,10 @@ def __init__(self, actionType, name, **kwargs): raise Exception(f"Object type '{objType}' is not in the action's supported list.") ''' - # Create placeholders for asset roles based on the "requirements" - if 'roles' in actionType: - for role, caplist in actionType['roles'].items(): - self.requirements[role] = {key: {} for key in caplist} # each role requirment holds a dict of capabilities with each capability containing a dict of metrics and values, metrics dict set to empty for now. - self.assets[role] = None # placeholder for the asset assigned to this role + # Create placeholders for asset based on the "requirements" + if 'requirements' in actionType: + reqList = actionType['requirements'] + self.requirements = {req: False for req in reqList} # initialize all requirements to True (needed) # Process objects to be acted upon. NOTE: must occur after requirements and assets placeholders have been assigned. # make list of supported object type names @@ -167,6 +171,9 @@ def __init__(self, actionType, name, **kwargs): if 'objects' in kwargs: self.assignObjects(kwargs['objects']) + # Based on the assigned objects, update what requirements/capabilities are needed + self.updateRequirements() + # Process dependencies if 'dependencies' in kwargs: for dep in kwargs['dependencies']: @@ -174,6 +181,38 @@ def __init__(self, actionType, name, **kwargs): # Process some optional kwargs depending on the action type + def updateRequirements(self): + ''' + Updates requirements based on the assigned objects or materials. + ''' + if not self.objectList: + raise Exception("No objects assigned to action; cannot update requirements.") + if not self.requirements: + raise Warning("No requirements defined for action; cannot update requirements.") + return + + for req in self.requirements.keys(): + # Does this requirement require specific objects or material? + objReq = self.allReq[req]['objects'] + matReq = self.allReq[req]['material'] + if objReq: + for obj in self.objectList: + if obj in self.allReq[req]['objects']: + objType = obj.__class__.__name__.lower() + if matReq: + if objType=='mooring': + for sec in obj.dd['sections']: + if sec['type'] in matReq: + self.requirements[req] = True + break + else: # TODO: need to figure out how to deal with different objects + pass + else: + self.requirements[req] = True + + # If there are no specific object or material requirements, just set to True + if not (objReq or matReq): + self.requirements[req] = True def addDependency(self, dep): ''' @@ -780,10 +819,29 @@ def assignObjects(self, objects): self.requirements[role][cap] = metrics # assign metric of capability cap based on value required by obj # MH: commenting our for now just so the code will run, but it may be better to make the above a separate step anyway + # RA: under progress, this is to be handled in updateRequirements now. ''' self.objectList.append(obj) - + def assignMaterials(self, materials): + ''' + Adds a list of materials to the actions materials list. + + Inputs + ------ + `materials` : `list` + A list of material dicts to be added to the action. + + Returns + ------- + `None` + ''' + + for mat in materials: + if mat in self.materialList: + print(f"Warning: Material '{mat['name']}' is already in the action's material list.") + self.materialList.append(mat) + def checkAsset(self, role_name, asset): ''' Checks if a specified asset has sufficient capabilities to fulfil @@ -806,7 +864,7 @@ def checkAsset(self, role_name, asset): # Make sure role_name is valid for this action if not role_name in self.assets.keys(): - raise Exception(f"The specified role '{role_name}' is not a named in this action.") + raise Exception(f"The specified role '{role_name}' is not named in this action.") if self.assets[role_name] is not None: return False, f"Role '{role_name}' is already filled in action '{self.name}'." @@ -1379,8 +1437,8 @@ def evaluateAssets(self, assets): duration, cost = self.calcDurationAndCost() - for role_name in assets.keys(): # Clear the assets dictionary - assets[role_name] = None + # Clear assets assigned for evaluation + self.clearAssets() return duration, cost # values returned here rather than set because will be used to check compatibility and not set properties of action @@ -1444,7 +1502,21 @@ def assignAssets(self, assets): self.calcDurationAndCost() - + def clearAssets(self): + ''' + Clears all assigned assets from the action. + + Inputs + ------ + `None` + + Returns + ------- + `None` + ''' + for role_name in self.assets.keys(): + self.assets[role_name] = None + # ----- Below are drafts of methods for use by the engine ----- """ def begin(self): diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 58ad4003..2b095d24 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -1,9 +1,12 @@ +# ====================================================================== +# actions.yaml +# ---------------------------------------------------------------------- # This file defines standardized marine operations actions. # Each entry needs numeric values per specific asset in vessels.yaml. -# Vessel actions will be checked against capabilities/actions for validation. +# Action requirements will be checked against vessel capabilities for evaluation and assignment. # -# Old format: requirements and capabilities -# New format: roles, which lists asset roles, each with associated required capabilities +# Old format: roles, which lists asset roles, each with associated required capabilities +# New format: list of requirements # The code that models and checks these actions is action.calcDurationAndCost(). Structural changes here will not be reflected in the code unless changes are made there as well @@ -11,50 +14,34 @@ # example_action: # objects: [] or {} "The FAModel object types that are supported in this action" -# requirements: [] "Asset types" **Unused** -# roles: "the roles that assets need to fill. A way a grouping capabilities so multiple assets can be assigned to an action" -# role1: -# - capability 1 -# - capability 2 -# role2: -# - capability 3 +# requirements: [] "List of capability requirements that assets must meet to perform this action" # duration_h: 0.0 "Duration in hours" # Hs_m: 0.0 "Wave height constraints in meters" # description: "A description" - # --- Mobilization --- mobilize: objects: [] - roles: - operator: - - deck_space + requirements: + - storage duration_h: Hs_m: description: "Mobilization of vessel in homeport" demobilize: objects: [] - roles: - operator: - - deck_space + requirements: + - storage capabilities: [] duration_h: 1.0 description: "Demobilization of vessel in homeport" load_cargo: objects: [anchor, mooring, cable, platform, component] - roles: # The asset roles involved and the capabilities required of each role - #carrier1: [] # the port or vessel where the moorings begin - # (no requirements) - #carrier2: # the vessel things will be loaded onto - #- deck_space - #- winch - #- positioning_system - operator: # the entity with the crane (like the port or the new vessel) - - crane - - deck_space + requirements: + - lifting + - storage duration_h: Hs_m: description: "Load-out of mooring systems and components from port or vessel onto vessel." @@ -62,39 +49,31 @@ load_cargo: # --- Towing & Transport --- - transit_linehaul_self: objects: [anchor] - roles: - vessel: - - engine + requirements: + - propulsion duration_h: description: "Self-propelled line-haul between port and site" transit_linehaul_tug: objects: [anchor] - roles: - carrier: - - engine - operator: - - bollard_pull + requirements: + - propulsion duration_h: description: "Tugged line-haul convoy (tug + barge) between port and site" transit_onsite_self: objects: [anchor] - roles: - vessel: - - engine + requirements: + - propulsion duration_h: description: "Self-propelled in-field move between site locations" transit_onsite_tug: objects: [anchor] - roles: - carrier: - - engine - operator: + requirements: + - propulsion - bollard_pull duration_h: description: "Tug + barge in-field move between site locations" @@ -102,41 +81,30 @@ transit_onsite_tug: tow: objects: [platform] - roles: # The asset roles involved and the capabilities required of each role - carrier: - - engine - operator: + requirements: + - propulsion - bollard_pull - - winch - - positioning_system + - station_keeping duration_h: Hs_m: description: "Towing floating structures (e.g., floaters, barges) to site; includes station-keeping." transport_components: objects: [component] - roles: # The asset roles involved and the capabilities required of each role - carrier: # vessel carrying things - - engine - - bollard_pull - - deck_space - - crane - - positioning_system + requirements: + - propulsion + - bollard_pull + - storage + - lifting + - station_keeping duration_h: Hs_m: description: "Transport of large components such as towers, nacelles, blades, or jackets." at_site_support: objects: [] - roles: # The asset roles involved and the capabilities required of each role - # tug: # vessel carrying things - # - bollard_pull - # - deck_space - # - winch - # - positioning_system - # - monitoring_system - operator: - - engine + requirements: + - propulsion duration_h: Hs_m: description: "Transport of vessel around the site to provide support." @@ -145,52 +113,47 @@ at_site_support: install_anchor: objects: [anchor, component] - roles: # The asset roles involved and the capabilities required of each role - carrier: # vessel that provides propulsion - - engine - operator: # vessel that carries, lowers and installs the anchor - - bollard_pull - - deck_space - operator: # vessel that lowers and installs the anchor - - winch - - bollard_pull - - crane - - pump_subsea # pump_surface, drilling_machine, torque_machine - - positioning_system + requirements: + - storage + - anchor_handling + - anchor_embedding + - station_keeping - monitoring_system + - survey duration_h: Hs_m: description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." retrieve_anchor: objects: [anchor, component] - roles: # The asset roles involved and the capabilities required of each role - carrier: - - engine - operator: - - bollard_pull - - deck_space - - winch - - bollard_pull - - crane - - pump_subsea - - positioning_system + requirements: + - storage + - anchor_removal + - anchor_handling + - station_keeping duration_h: Hs_m: description: "Anchor retrieval, including break-out and recovery to deck." +load_cargo: + object: [anchor, mooring, cable, platform, component] + requirements: + - chain_storage + - rope_storage + - line_handling + - lifting + - storage + - station_keeping + - cable_storage + - cable_handling load_mooring: objects: [mooring, component] - roles: # The asset roles involved and the capabilities required of each role - carrier1: [] # the port or vessel where the moorings begin - # (no requirements) - carrier2: # the vessel things will be loaded onto - - deck_space - - winch - - positioning_system - operator: # the entity with the crane (like the port or the new vessel) - - crane + requirements: + - chain_storage + - rope_storage + - line_handling + - station_keeping duration_h: Hs_m: description: "Load-out of mooring lines and components from port or vessel onto vessel." @@ -198,16 +161,12 @@ load_mooring: lay_mooring: objects: [mooring, component] - roles: # The asset roles involved and the capabilities required of each role - carrier: # vessel carrying the mooring - - engine - operator: # vessel laying the mooring + requirements: + - propulsion - bollard_pull - - deck_space - - winch - - bollard_pull - - mooring_work - - positioning_system + - chain_storage + - rope_storage + - station_keeping duration_h: Hs_m: description: "Laying mooring lines, tensioning and connection to anchors and floaters." @@ -215,14 +174,13 @@ lay_mooring: mooring_hookup: objects: [mooring, component, platform] - roles: # The asset roles involved and the capabilities required of each role - carrier: - - deck_space - operator: - - winch + requirements: + - chain_storage + - rope_storage + - line_handling - bollard_pull - mooring_work - - positioning_system + - station_keeping - monitoring_system duration_h: Hs_m: @@ -232,111 +190,95 @@ mooring_hookup: install_wec: objects: [platform] - capabilities: - - deck_space - - crane - - positioning_system + requirements: + - storage + - platform_handling + - station_keeping - monitoring_system - - rov duration_h: Hs_m: description: "Lifting, placement and securement of wave energy converters (WECs) onto moorings, including alignment, connection of power/data umbilicals and verification via ROV." install_semisub: objects: [platform] - capabilities: - - deck_space + requirements: + - storage + - lifting + - pumping - bollard_pull - - winch - - crane - - positioning_system + - station_keeping - monitoring_system - - rov - - sonar_survey - - pump_surface - - mooring_work duration_h: Hs_m: description: "Wet tow arrival, station-keeping, ballasting/trim, mooring hookup and pretensioning, ROV verification and umbilical connections as needed." install_spar: objects: [platform] - capabilities: - - deck_space + requirements: + - storage + - lifting + - pumping - bollard_pull - - winch - - positioning_system + - station_keeping - monitoring_system - - rov - - sonar_survey - - pump_surface - - mooring_work duration_h: Hs_m: description: "Arrival and upending via controlled ballasting, station-keeping, fairlead/messenger handling, mooring hookup and pretensioning with ROV confirmation. Heavy-lift support may be used during port integration." install_tlp: objects: [platform] - capabilities: - - deck_space + requirements: + - storage + - lifting + - pumping - bollard_pull - - winch - - crane - - positioning_system + - station_keeping - monitoring_system - - rov - - sonar_survey - - mooring_work duration_h: Hs_m: description: "Tendon porch alignment, tendon hookup, sequential tensioning to target pretension, verification of offsets/RAOs and ROV checks." install_wtg: objects: [turbine] - capabilities: - - deck_space - - crane - - positioning_system + requirements: + - storage + - lifting + - station_keeping - monitoring_system duration_h: Hs_m: description: "Installation of wind turbine generator including tower, nacelle and blades." - # --- Cable Operations --- lay_cable: objects: [cable] - capabilities: - - deck_space - - positioning_system + requirements: + - cable_storage + - cable_laying + - station_keeping - monitoring_system - - cable_reel - - sonar_survey duration_h: Hs_m: description: "Laying static/dynamic power cables, including burial where required." cable_hookup: objects: [cable, component, platform] - roles: # The asset roles involved and the capabilities required of each role - carrier: - - deck_space - operator: - - winch + requirements: + - cable_storage + - cable_handling - bollard_pull - - mooring_work - - positioning_system + - station_keeping - monitoring_system duration_h: Hs_m: description: "Hook-up of cable to floating platforms, including pretensioning." retrieve_cable: objects: [cable] - capabilities: - - deck_space - - positioning_system + requirements: + - cable_storage + - cable_handling + - station_keeping - monitoring_system - - cable_reel duration_h: Hs_m: description: "Cable recovery operations, including cutting, grappling and retrieval." @@ -344,13 +286,11 @@ retrieve_cable: # Lay and bury in a single pass using a plough lay_and_bury_cable: objects: [cable] - capabilities: - - deck_space - - positioning_system + requirements: + - propulsion + - cable_storage + - station_keeping - monitoring_system - - cable_reel - - cable_plough - - sonar_survey duration_h: Hs_m: description: "Simultaneous lay and plough burial; continuous QA via positioning + MBES/SSS, with post-pass verification." @@ -358,11 +298,11 @@ lay_and_bury_cable: # Backfill trench or stabilize cable route using rock placement backfill_rockdump: objects: [cable] - capabilities: - - deck_space - - positioning_system + requirements: + - storage + - propulsion + - station_keeping - monitoring_system - - sonar_survey - rock_placement duration_h: Hs_m: @@ -372,9 +312,9 @@ backfill_rockdump: site_survey: objects: [] - capabilities: - - positioning_system - - sonar_survey + requirements: + - survey + - station_keeping - monitoring_system duration_h: Hs_m: @@ -382,20 +322,18 @@ site_survey: monitor_installation: objects: [anchor, mooring, component, platform, cable] - roles: - support: - - positioning_system + requirements: + - station_keeping - monitoring_system - - rov duration_h: Hs_m: description: "Real-time monitoring of installation operations using ROV and sensor packages." diver_support: objects: [] - capabilities: - - positioning_system - - sonar_survey + requirements: + - survey + - station_keeping - monitoring_system duration_h: Hs_m: diff --git a/famodel/irma/calwave_action.py b/famodel/irma/calwave_action.py index 61a13126..9ba4aa20 100644 --- a/famodel/irma/calwave_action.py +++ b/famodel/irma/calwave_action.py @@ -10,7 +10,7 @@ from copy import deepcopy #from shapely.geometry import Point, Polygon, LineString -from famodel.seabed import seabed_tools as sbt +import famodel.seabed_tools as sbt from famodel.mooring.mooring import Mooring from famodel.platform.platform import Platform from famodel.anchors.anchor import Anchor @@ -117,6 +117,7 @@ def __init__(self, actionType, name, **kwargs): # list of things that will be controlled during this action self.assets = {} # dict of named roles for the vessel(s) or port required to perform the action self.requirements = {} # capabilities required of each role (same keys as self.assets) + self.requirements2 = {} # capabilities required for the action self.objectList = [] # all objects that could be acted on self.dependencies = {} # list of other actions this one depends on @@ -154,6 +155,8 @@ def __init__(self, actionType, name, **kwargs): if 'roles' in actionType: for role, caplist in actionType['roles'].items(): self.requirements[role] = {key: {} for key in caplist} # each role requirment holds a dict of capabilities with each capability containing a dict of metrics and values, metrics dict set to empty for now. + for key in caplist: + self.requirements2[key] = {} # all requirements diction needed for the action. self.assets[role] = None # placeholder for the asset assigned to this role # Process objects to be acted upon. NOTE: must occur after requirements and assets placeholders have been assigned. @@ -1430,7 +1433,21 @@ def assignAssets(self, assets): raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? self.calcDurationAndCost() - + + def clearAssets(self): + ''' + Clears all assigned assets from the action. + + Inputs + ------ + `None` + + Returns + ------- + `None` + ''' + for role_name in self.assets.keys(): + self.assets[role_name] = None # ----- Below are drafts of methods for use by the engine ----- """ diff --git a/famodel/irma/calwave_task1.py b/famodel/irma/calwave_task1.py index 404ddde0..995202c7 100644 --- a/famodel/irma/calwave_task1.py +++ b/famodel/irma/calwave_task1.py @@ -198,7 +198,10 @@ def assign_actions(sc: Scenario, actions: dict): # 5) Build Task task1 = Task(name='calwave_task1', actions=sc.actions, action_sequence='dependencies') - task1.updateStartTime(newStart=10) + # Check assets + # task1.checkAssets(sc.vessels) + + # task1.updateStartTime(newStart=0) # 6) Build the Gantt chart task1.GanttChart(color_by='asset') diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 1d22fda8..7c026eab 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -1,3 +1,6 @@ +# ====================================================================== +# capabilities.yaml +# ---------------------------------------------------------------------- # This file defines standardized capabilities for vessels and equipment. # Each entry needs numeric values per specific asset in vessels.yaml. # Vessel actions will be checked against capabilities/actions for validation. @@ -62,18 +65,33 @@ capacity_t: # SWL at specified radius [t] hook_height_m: # max hook height [m] - - name: station_keeping - # description: Vessel station keeping capability (dynamic positioning or anchor-based) + - name: station_keeping_by_dynamic_positioning + # description: DP vessel capability for station keeping # fields: - type: # e.g., DP0, DP1, DP2, DP3, anchor_based + type: + # TODO: maybe information about thrusters here? - - name: mooring_work - # description: Suitability for anchor/mooring operations + - name: station_keeping_by_anchor + # description: Anchor-based station keeping capability # fields: - line_types: # e.g., [chain, ropes...] - stern_roller: # presence of stern roller (optional) - shark_jaws: # presence of chain stoppers/jaws (optional) - towing_pin_rating_t: # rating of towing pins [t] (optional) + max_hold_force_t: # maximum holding force [t] + + - name: station_keeping_by_bowt + # description: Station keeping by bowt + # fields: + max_hold_force_t: # maximum holding force [t] + + - name: stern_roller + # description: Stern roller for overboarding/lowering lines/cables over stern + # fields: + diameter_m: # roller diameter [m] + width_m: # roller width [m] + + - name: shark_jaws + # description: Chain stoppers/jaws for holding chain under tension + # fields: + max_load_t: # maximum holding load [t] + # --- Equipment (portable) --- diff --git a/famodel/irma/requirements.yaml b/famodel/irma/requirements.yaml new file mode 100644 index 00000000..3ad711c7 --- /dev/null +++ b/famodel/irma/requirements.yaml @@ -0,0 +1,187 @@ +# ====================================================================== +# requirements.yaml +# ---------------------------------------------------------------------- +# This file maps requirements and optional capabilities to marine operations actions. +# Each entry lists optional capabilities that an asset can have to fulfil the requirement. + +# Example Entry: +# - name: chain_storage +# description: "Storage capacity for equipment or materials" +# objects: [chain] +# capabilities: +# - chain_locker +# - deck_space + + +# --- Propulsion & Towage ------------------------------------------------ + +- name: propulsion + description: "Ability to provide self-propelled motion or maneuvering thrust." + capabilities: + - engine + +- name: bollard_pull + description: "Ability to exert or resist towline force during towing or station-keeping." + capabilities: + - bollard_pull + +- name: station_keeping + description: "Ability to maintain position and heading against wind, wave, and current forces." + capabilities: + - station_keeping_by_dynamic_positioning + - station_keeping_by_anchor + - station_keeping_by_bowt + + +# --- Storage & Transport ------------------------------------------------ + +- name: storage + description: "General onboard deck or cargo storage capacity for components or equipment." + objects: [anchor, mooring, cable, platform, component] + capabilities: + - deck_space + +- name: chain_storage + description: "Dedicated storage capacity for chain sections in the mooring line." + objects: [mooring] + materials: [chain] + capabilities: + - chain_locker + - deck_space + +- name: rope_storage + description: "Dedicated storage capacity for rope sections in the mooring line." + objects: [mooring] + materials: [rope] + capabilities: + - line_reel + - deck_space + +- name: cable_storage + description: "Dedicated storage capacity for electrical cables or umbilicals on reels." + objects: [cable] + capabilities: + - cable_reel + - deck_space + +- name: line_handling + description: "Ability to deploy, recover, or tension mooring lines." + objects: [mooring] + capabilities: + - winch + - crane + - shark_jaws + - stern_roller + +- name: cable_handling + description: "Ability to deploy, recover, and control subsea cables under tension." + objects: [cable] + capabilities: + - winch + - crane + - cable_reel + - stern_roller + +- name: lifting + description: "Ability to lift and move heavy components vertically and horizontally." + objects: [anchor, mooring, cable, platform, component] + capabilities: + - crane + +- name: anchor_handling + description: "Capability to overboard, lower, orient, deploy, or recover anchors." + objects: [anchor] + capabilities: + - winch + - crane + - stern_roller + +- name: anchor_embedding + description: "Capability to embed anchors into seabed using mechanical, hydraulic, or suction means." + objects: [anchor] + capabilities: + - bullard_pull + - propulsion + - pump_subsea + - pump_surface + - hydraulic_hammer + - vibro_hammer + - torque_machine + - drilling_machine + +- name: anchor_removal + description: "Capability to extract anchors from seabed via reverse suction or pulling." + objects: [anchor] + capabilities: + - winch + - crane + - pump_subsea + - pump_surface + + +# --- Mooring Systems --------------------------------------------------- + +- name: mooring_work + description: "Specialized capability for mooring hookup, tensioning, and verification." + objects: [mooring, component] + capabilities: + - winch + - shark_jaws + - stern_roller + + +# --- Platform Handling & Heavy Lift ------------------------------------ + +- name: platform_handling + description: "Capability to position, ballast, and secure floating structures." + objects: [platform] + capabilities: + - crane + - pumping + +- name: pumping + description: "Capability to perform controlled ballasting, de-ballasting, or fluid transfer." + capabilities: + - pump_surface + - pump_subsea + - pump_grout + + +# --- Cable & Subsea Work ------------------------------------------------ + +- name: cable_laying + description: "Capability to lay subsea cables." + objects: [cable] + capabilities: + - winch + - cable_plough + - stern_roller + +- name: rock_placement + description: "Capability to place rock or gravel for cable protection, scour prevention, or trench backfill." + capabilities: + - rock_placement + - crane + + +# --- Monitoring & Survey ------------------------------------------------ + +- name: monitoring_system + description: "Capability to monitor parameters during installation (pressure, torque, position, etc.)." + capabilities: + - monitoring_system + - rov + - container + +- name: positioning_system + description: "Capability to provide high-accuracy seabed positioning or navigation." + capabilities: + - positioning_system + - sonar_survey + +- name: survey + description: "Capability for site or installation survey using sonar, ROV, or acoustic tools." + capabilities: + - sonar_survey + - rov + - monitoring_system diff --git a/famodel/irma/task.py b/famodel/irma/task.py index c4282fbb..e5c8c660 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -76,6 +76,7 @@ def __init__(self, name, actions, action_sequence='series', **kwargs): self.action_sequence = {} if action_sequence == 'series': # Puts the actions in linear sequence + actions = list(self.actions.values()) for i in range(len(actions)): if i==0: # first action has no dependencies self.action_sequence[actions[i].name] = [] @@ -264,28 +265,6 @@ def level_of(a: str, b: set[str]) -> int: return H - - - def stageActions(self, from_deps=True): - ''' - This method stages the action_sequence for a proper execution order. - - Parameters - ---------- - from_deps : bool - If True, builds the action_sequence from the dependencies of each action. - More options will be added in the future. - ''' - if from_deps: - # build from dependencies - def getDeps(action): - deps = [] - for dep in action.dependencies: - deps.append(dep) - return deps - - self.action_sequence = {self.actions[name].name: getDeps(self.actions[name]) for name in self.actions} - def calcDuration(self): '''Organizes the actions to be done by this task into the proper order @@ -315,6 +294,8 @@ def calcDuration(self): # Task duration self.duration = max(finishes.values()) + # Update task finish time + self.tf = self.ti + self.duration def calcCost(self): '''Calculates the total cost of the task based on the costs of individual actions. @@ -348,7 +329,17 @@ def updateStartTime(self, newStart=0.0): self.actions_ti[action] += self.ti - + def clearAssets(self): + ''' + Clear all assigned assets from all actions in the task. + This resets the asset assignments and re-evaluates the actions. + ''' + for action in self.actions.values(): + action.clearAssets() + + # Reinitialize duration and cost after clearing assets. + self.duration = 0 + self.cost = 0 def get_row(self, assets): From 69de4b7a0533abae0ebac9cb89b34596b054e5c5 Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Sun, 9 Nov 2025 13:52:45 -0700 Subject: [PATCH 57/63] continue working on action.py to remove roles, data structure fixes, main example in action.py: - removing previous format of needing a role for assets to perform in main methods of the action class. Mainly in - - checkAsset: checks if requirements are met by the asset capabilities and if not provides a full description of what cap was not met. - - evaluateAsset: temporarily assigns (if assignable) an asset to emphasize that all requirements are met to calculate duration and cost. - - assignAssets and assignAsset: not only do we check if the asset is assignable, but we also check if assigning that asset makes any sense by comparing what this asset can provide versus what requirements have not been met yet (e.g. by previous assets). For example, it makes no sense to assign two barges that each fulfil all requirements. But maybe later on, we could assign 'metrics' to req-cap and then it might make sense to assign two assets that fulfil the same requirements (for example two barges, each holding half the required amount of anchors). - - calcDurationAndCost: there's a lot of work that needs to be done on this function still to remove all roles format. I've only fixed the install_anchor example (with a short-term fix) to get an example to run. - There is a 'main' example inside Action.py to test these different methods. Action now is consistent with itself (except most of the calcDurationAndCost method). - I'm not using updating Requirement function for now. It's left for future work. - local fixes to irma.py but main example still won't work. --- famodel/irma/action.py | 269 ++++++++++++++++++++++++--------- famodel/irma/actions.yaml | 17 +-- famodel/irma/irma.py | 6 +- famodel/irma/requirements.yaml | 44 +++--- famodel/irma/vessels.yaml | 15 +- 5 files changed, 239 insertions(+), 112 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index a16248c6..0cf2128e 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -154,10 +154,11 @@ def __init__(self, actionType, name, allReq, **kwargs): raise Exception(f"Object type '{objType}' is not in the action's supported list.") ''' - # Create placeholders for asset based on the "requirements" + # Determine requirements based on action type if 'requirements' in actionType: reqList = actionType['requirements'] - self.requirements = {req: False for req in reqList} # initialize all requirements to True (needed) + self.requirements = {req: True for req in reqList} # initialize all requirements to True (needed) + self.requirements_met = {req: False for req in reqList} # dictionary to track if requirements are met (by assigned assets). Initialized to False. # Process objects to be acted upon. NOTE: must occur after requirements and assets placeholders have been assigned. # make list of supported object type names @@ -172,7 +173,8 @@ def __init__(self, actionType, name, allReq, **kwargs): self.assignObjects(kwargs['objects']) # Based on the assigned objects, update what requirements/capabilities are needed - self.updateRequirements() + if False: # let's assume for now that all requirements are True. + self.updateRequirements() # Process dependencies if 'dependencies' in kwargs: @@ -185,6 +187,7 @@ def updateRequirements(self): ''' Updates requirements based on the assigned objects or materials. ''' + # RA: let's rethink this function or brainstorm more. if not self.objectList: raise Exception("No objects assigned to action; cannot update requirements.") if not self.requirements: @@ -842,50 +845,76 @@ def assignMaterials(self, materials): print(f"Warning: Material '{mat['name']}' is already in the action's material list.") self.materialList.append(mat) - def checkAsset(self, role_name, asset): + def checkAsset(self, asset): ''' Checks if a specified asset has sufficient capabilities to fulfil - a specified role in this action. + all requirements in this action. Inputs ------ - `role_name` : `string` - The name of the role to check. `asset` : `dict` - The asset to check against the role's requirements. + The asset to check against the requirements. Returns ------- `bool` - True if the asset meets the role's requirements, False otherwise. + True if the asset meets the requirements, False otherwise. `str` A message providing additional information about the check. ''' - # Make sure role_name is valid for this action - if not role_name in self.assets.keys(): - raise Exception(f"The specified role '{role_name}' is not named in this action.") + requirements_met = {} + for req, needed in self.requirements.items(): + if needed: + has_cap = any(cap in asset['capabilities'] for cap in self.allReq[req]['capabilities']) + requirements_met[req] = has_cap + else: + requirements_met[req] = True # requirement not needed, so considered met + + assignable = all(requirements_met.values()) + + # message: + if assignable: + message = "Asset meets all required capabilities." + else: + unmet = [req for req, met in requirements_met.items() if not met] + detailed = [] + for req in unmet: + expected = self.allReq[req]['capabilities'] + detailed.append(f"- {req}: requires one of {expected}.") + detailed_msg = "\n".join(detailed) + + detailed_msg += f"\nAsset has the following capabilities: {[cap for cap in asset['capabilities'].keys()]}" + message = "Asset does not meet the following required capabilities:\n" + detailed_msg + - if self.assets[role_name] is not None: - return False, f"Role '{role_name}' is already filled in action '{self.name}'." + return assignable, message + + # Old method: + # # Make sure role_name is valid for this action + # if not role_name in self.assets.keys(): + # raise Exception(f"The specified role '{role_name}' is not named in this action.") + + # if self.assets[role_name] is not None: + # return False, f"Role '{role_name}' is already filled in action '{self.name}'." - for capability in self.requirements[role_name].keys(): + # for capability in self.requirements[role_name].keys(): - if capability in asset['capabilities'].keys(): # check capability is in asset + # if capability in asset['capabilities'].keys(): # check capability is in asset - # TODO: does this work if there are no metrics in a capability? This should be possible, as not all capabilities will require a constraint. - for metric in self.requirements[role_name][capability].keys(): # loop over the capacity requirements for the capability (if more than one) + # # TODO: does this work if there are no metrics in a capability? This should be possible, as not all capabilities will require a constraint. + # for metric in self.requirements[role_name][capability].keys(): # loop over the capacity requirements for the capability (if more than one) - if metric not in asset['capabilities'][capability].keys(): # value error because capabilities are defined in capabilities.yaml. This should only be triggered if something has gone wrong (i.e. overwriting values somewhere) - raise ValueError(f"The '{capability}' capability does not have metric: '{metric}'.") + # if metric not in asset['capabilities'][capability].keys(): # value error because capabilities are defined in capabilities.yaml. This should only be triggered if something has gone wrong (i.e. overwriting values somewhere) + # raise ValueError(f"The '{capability}' capability does not have metric: '{metric}'.") - if self.requirements[role_name][capability][metric] > asset['capabilities'][capability][metric]: # check requirement is met - return False, f"The asset does not have sufficient '{metric}' for '{capability}' capability in '{role_name}' role of '{self.name}' action." + # if self.requirements[role_name][capability][metric] > asset['capabilities'][capability][metric]: # check requirement is met + # return False, f"The asset does not have sufficient '{metric}' for '{capability}' capability in '{role_name}' role of '{self.name}' action." - return True, 'All capabilities in role met' + # return True, 'All capabilities in role met' - else: - return False, f"The asset does not have the '{capability}' capability for '{role_name}' role of '{self.name}' action." # a capability is not met + # else: + # return False, f"The asset does not have the '{capability}' capability for '{role_name}' role of '{self.name}' action." # a capability is not met def calcDurationAndCost(self): @@ -903,9 +932,9 @@ def calcDurationAndCost(self): ''' # Check that all roles in the action are filled - for role_name in self.requirements.keys(): - if self.assets[role_name] is None: - raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") + for req, met in self.requirements_met.items(): + if not met: + raise Exception(f"Requirement '{req}' is not met in action '{self.name}'. Cannot calculate duration and cost.") # Initialize cost and duration self.cost = 0.0 # [$] @@ -925,29 +954,30 @@ def calcDurationAndCost(self): # --- Mobilization --- if self.type == 'mobilize': - # Hard-coded example of mobilization times based on vessel type + # Hard-coded example of mobilization times based on vessel type - from the calwave installation example. durations = { 'crane_barge': 3.0, 'research_vessel': 1.0 } - for role_name, vessel in self.assets.items(): - vessel_type = vessel['type'].lower() + for asset in self.assetList: + asset_type = asset['type'].lower() for key, duration in durations.items(): - if key in vessel_type: + if key in asset_type: self.duration += duration break elif self.type == 'demobilize': - # Hard-coded example of demobilization times based on vessel type + # Hard-coded example of demobilization times based on vessel type - from the calwave installation example. durations = { 'crane_barge': 3.0, 'research_vessel': 1.0 } - for role_name, vessel in self.assets.items(): - vessel_type = vessel['type'].lower() + for asset in self.assetList: + asset_type = asset['type'].lower() for key, duration in durations.items(): - if key in vessel_type: + if key in asset_type: self.duration += duration + elif self.type == 'load_cargo': pass @@ -956,6 +986,7 @@ def calcDurationAndCost(self): pass elif self.type == 'transit_linehaul_self': + # TODO: RA: Needs to be updated based on new format (no roles)! - Note to dev: try to reduce (try/except) statements # YAML override try: v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v @@ -986,6 +1017,7 @@ def calcDurationAndCost(self): elif self.type == 'transit_linehaul_tug': + # TODO: RA: Needs to be updated based on new format (no roles)! - Note to dev: try to reduce (try/except) statements # YAML override try: v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v @@ -1024,6 +1056,7 @@ def calcDurationAndCost(self): return self.duration, self.cost elif self.type == 'transit_onsite_self': + # TODO: RA: Needs to be updated based on new format (no roles)! - Note to dev: try to reduce (try/except) statements # YAML override try: v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v @@ -1128,6 +1161,7 @@ def calcDurationAndCost(self): return self.duration, self.cost elif self.type == 'transit_onsite_tug': + # TODO: RA: Needs to be updated based on new format (no roles)! - Note to dev: try to reduce (try/except) statements # YAML override try: v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v @@ -1240,6 +1274,7 @@ def calcDurationAndCost(self): # --- Mooring & Anchors --- elif self.type == 'load_mooring': + # TODO: RA: Needs to be updated based on new format (no roles)! # Example model assuming line will be winched on to vessel. This can be changed if not most accurate duration_min = 0 for obj in self.objectList: @@ -1272,7 +1307,16 @@ def calcDurationAndCost(self): depth_m = abs(float(anchor.r[2])) # 2) Winch vertical speed [mps] - v_mpm = float(self.assets['carrier']['capabilities']['winch']['speed_mpm']) + # TODO: RA: work needs to be done to determine which capability is used to perform the action based on the req-cap matrix. + # TODO: RA: Also, what if the anchor is using 'barge' for 'storage' (anchor is in the barge) but another asset has the winch? This is not a problem if the other asset uses the crane to install the anchor. + winch = True + if winch: + # Find the asset that has the winch capability + for asset in self.assetList: + if 'winch' in asset['capabilities']: + v_mpm = float(asset['capabilities']['winch']['speed_mpm']) + break + # v_mpm = float(self.assets['carrier']['capabilities']['winch']['speed_mpm']) t_lower_min = depth_m/v_mpm # 3) Penetration time ~ proportional to L @@ -1292,7 +1336,7 @@ def calcDurationAndCost(self): # Cost assessment rate_per_hour = 0.0 - for _, asset in self.assets.items(): + for asset in self.assetList: rate_per_hour += float(asset['day_rate'])/24.0 self.cost += self.duration*rate_per_hour @@ -1333,6 +1377,7 @@ def calcDurationAndCost(self): pass elif self.type == 'monitor_installation': + # TODO: RA: Needs to be updated based on new format (no roles)! - Note to dev: try to reduce (try/except) statements # 1) YAML override first try: v = getFromDict(self.actionType, 'duration_h', dtype=float); self.duration += v @@ -1420,19 +1465,22 @@ def evaluateAssets(self, assets): ''' # Check each specified asset for its respective role - for role_name, asset in assets.items(): - assignable, message = self.checkAsset(role_name, asset) + for asset in assets: + assignable, message = self.checkAsset(asset) if assignable: - self.assets[role_name] = asset # Assignment required for calcDurationAndCost(), will be cleared later + self.assetList.append(asset) # Assignment required for calcDurationAndCost(), will be cleared later + self.requirements_met = {req: True for req in self.requirements_met.keys()} # all requirements met. Will be clearer later + [] else: print('INFO: '+message+' Action cannot be completed by provided asset list.') return -1, -1 # return negative values to indicate incompatibility. Loop is terminated becasue assets not compatible for roles. - # Check that all roles in the action are filled - for role_name in self.requirements.keys(): - if self.assets[role_name] is None: + # RA: This is not needed now as we evaluate requirements being met in checkAsset: + # # Check that all roles in the action are filled + # for role_name in self.requirements.keys(): + # if self.assets[role_name] is None: - raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? + # raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? duration, cost = self.calcDurationAndCost() @@ -1443,48 +1491,88 @@ def evaluateAssets(self, assets): return duration, cost # values returned here rather than set because will be used to check compatibility and not set properties of action - def assignAsset(self, role_name, asset): + def assignAsset(self, asset): ''' Checks if asset can be assigned to an action. If yes, assigns asset to role in the action. Inputs ------ - `role_name` : `str` - The name of the role to which the asset will be assigned. `asset` : `dict` - The asset to be assigned to the role. + The asset to be assigned. Returns ------- `None` ''' - # Make sure role_name is valid for this action - if not role_name in self.assets.keys(): - raise Exception(f"The specified role name '{role_name}' is not in this action.") + # RA: we removed roles, we don't do this anymore. + # # Make sure role_name is valid for this action + # if not role_name in self.assets.keys(): + # raise Exception(f"The specified role name '{role_name}' is not in this action.") - if self.assets[role_name] is not None: - raise Exception(f"Role '{role_name}' is already filled in action '{self.name}'.") + # New Method: RA - assignable, message = self.checkAsset(role_name, asset) - if assignable: - self.assets[role_name] = asset - else: - raise Exception(message) # throw error message + # Let's check the asset first + ok, msg = self.checkAsset(asset) + + if not ok: + raise Exception(f"Asset '{asset['type']}' cannot be assigned to action '{self.name}': {msg}") + + # Now, does it make sense to assign this asset if it's only meeting requirements that have already been met? + # Which requirements are currently unmet: + unmet = [req for req, met in self.requirements_met.items() if not met] + + # If no requirements remain unmet, then adding this asset is pointless + if not unmet: + raise Exception(f"All requirements for action '{self.name}' are already met. Asset '{asset['type']}' cannot be assigned.") + + # Now, determine whether this asset provides something we need + assetCaps = set(asset['capabilities'].keys()) + neededCaps = set() + for req in unmet: + neededCaps.update(self.allReq[req]['capabilities']) + + # We can check if asset provides any needed capabilities by 'intersecting' the two sets + if len(assetCaps.intersection(neededCaps)) == 0: + raise Exception( + f"Asset '{asset['name']}' does not provide any needed capabilities.\n" + f"Unmet requirements: {unmet}\n" + f"Asset capabilities: {assetCaps}\n" + f"Needed capabilities: {neededCaps}" + ) + + # if we reach here, asset is useful. + self.assetList.append(asset) + + # Update requirements_met based on this asset + for req in unmet: + if any(cap in assetCaps for cap in self.allReq[req]['capabilities']): + self.requirements_met[req] = True + + + # Old Method: + # if self.assets[role_name] is not None: + # raise Exception(f"Role '{role_name}' is already filled in action '{self.name}'.") + + # assignable, message = self.checkAsset(role_name, asset) + # if assignable: + # self.assets[role_name] = asset + # else: + # raise Exception(message) # throw error message def assignAssets(self, assets): ''' Assigns assets to all the roles in the action. This calls - `assignAsset()` for each role/asset pair and then calculates the - duration and cost for the action. Similar to `evaluateAssets()` + `assignAsset()` that calculates the + duration and cost for the action (if assignable). Similar to `evaluateAssets()` however here assets are assigned and duration and cost are set after evaluation. Inputs ------ - `assets` : `dict` - Dictionary of {role_name: asset} pairs for assignment of the - assets to the roles in the action. + `assets` : `list` + list of assets for assignment of the + assets to the requirements in the action. Returns ------- @@ -1492,13 +1580,14 @@ def assignAssets(self, assets): ''' # Assign each specified asset to its respective role - for role_name, asset in assets.items(): - self.assignAsset(role_name, asset) + for asset in assets: + self.assignAsset(asset) - # Check that all roles in the action are filled - for role_name in self.requirements.keys(): - if self.assets[role_name] is None: - raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? + # RA: we already check that inside calcDurationAndCost. + # # Check that all roles in the action are filled + # for role_name in self.requirements.keys(): + # if self.assets[role_name] is None: + # raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? self.calcDurationAndCost() @@ -1514,8 +1603,8 @@ def clearAssets(self): ------- `None` ''' - for role_name in self.assets.keys(): - self.assets[role_name] = None + self.assetList = [] + self.requirements_met = {req: False for req in self.requirements_met.keys()} # ----- Below are drafts of methods for use by the engine ----- """ @@ -1596,3 +1685,39 @@ def timestep(self): self.end() +if __name__ == "__main__": + + + # simple example + from famodel.project import Project + from famodel.irma.irma import Scenario + + project = Project(file='../../examples/OntologySample200m_1turb.yaml', raft=False) + sc = Scenario() # class instance holding most of the info + akey = 'fowt0a' + anchor = project.anchorList[akey] + act = sc.addAction('install_anchor', f'install_anchor-{akey}', sc.requirements, objects=[anchor]) + + # Check asset + asset1 = sc.vessels['AHTS_alpha'] + asset2 = sc.vessels['Barge_squid'] + act.requirements['station_keeping'] = False # <<< temporary fix, station_keeping is not listed under capabilities in vessels.yaml for some reason! investigate. + assignable_AHTS, message_AHTS = act.checkAsset(asset1) + assignable_BRGE, message_BRGE = act.checkAsset(asset2) + + print(message_AHTS) + print(message_BRGE) + + assert assignable_AHTS==True, "Asset AHTS_alpha should be assignable to install_anchor action." + assert assignable_BRGE==False, "Asset Barge_squid should NOT be assignable to install_anchor action." + + # Evaluate asset + duration, cost = act.evaluateAssets([asset1]) + print(f"Case1: Evaluated duration: {duration} h, cost: ${cost}") + duration, cost = act.evaluateAssets([asset2]) + print(f"Case2: Evaluated duration: {duration} h, cost: ${cost}") + + # Assign asset + act.assignAsset(asset1) + assert abs(act.duration - 4.5216) < 0.01, "Assigned duration does not match expected value." + assert abs(act.cost - 20194.7886) < 0.01, "Assigned cost does not match expected value." \ No newline at end of file diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 2b095d24..90818581 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -40,8 +40,14 @@ demobilize: load_cargo: objects: [anchor, mooring, cable, platform, component] requirements: + - chain_storage + - rope_storage + - line_handling - lifting - storage + - station_keeping + - cable_storage + - cable_handling duration_h: Hs_m: description: "Load-out of mooring systems and components from port or vessel onto vessel." @@ -135,17 +141,6 @@ retrieve_anchor: Hs_m: description: "Anchor retrieval, including break-out and recovery to deck." -load_cargo: - object: [anchor, mooring, cable, platform, component] - requirements: - - chain_storage - - rope_storage - - line_handling - - lifting - - storage - - station_keeping - - cable_storage - - cable_handling load_mooring: objects: [mooring, component] diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index b5e33274..ec960fee 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -145,6 +145,7 @@ def __init__(self): # ----- Load database of supported things ----- actionTypes = loadYAMLtoDict('actions.yaml', already_dict=True) # Descriptions of actions that can be done + requirements = loadYAMLtoDict('requirements.yaml', already_dict=True) # Descriptions of requirements that can be done capabilities = loadYAMLtoDict('capabilities.yaml') vessels = loadYAMLtoDict('vessels.yaml', already_dict=True) objects = loadYAMLtoDict('objects.yaml', already_dict=True) @@ -229,6 +230,7 @@ def __init__(self): # Store some things self.actionTypes = actionTypes + self.requirements = requirements self.capabilities = capabilities self.vessels = vessels self.objects = objects @@ -259,7 +261,7 @@ def registerAction(self, action): self.actions[action.name] = action - def addAction(self, action_type_name, action_name, **kwargs): + def addAction(self, action_type_name, action_name, allReq, **kwargs): '''Creates and action and adds it to the register''' if not action_type_name in self.actionTypes: @@ -269,7 +271,7 @@ def addAction(self, action_type_name, action_name, **kwargs): action_type = self.actionTypes[action_type_name] # Create the action - act = Action(action_type, action_name, **kwargs) + act = Action(action_type, action_name, allReq, **kwargs) # Register the action self.registerAction(act) diff --git a/famodel/irma/requirements.yaml b/famodel/irma/requirements.yaml index 3ad711c7..3deb4475 100644 --- a/famodel/irma/requirements.yaml +++ b/famodel/irma/requirements.yaml @@ -5,7 +5,7 @@ # Each entry lists optional capabilities that an asset can have to fulfil the requirement. # Example Entry: -# - name: chain_storage +# chain_storage: # description: "Storage capacity for equipment or materials" # objects: [chain] # capabilities: @@ -15,17 +15,17 @@ # --- Propulsion & Towage ------------------------------------------------ -- name: propulsion +propulsion: description: "Ability to provide self-propelled motion or maneuvering thrust." capabilities: - engine -- name: bollard_pull +bollard_pull: description: "Ability to exert or resist towline force during towing or station-keeping." capabilities: - bollard_pull -- name: station_keeping +station_keeping: description: "Ability to maintain position and heading against wind, wave, and current forces." capabilities: - station_keeping_by_dynamic_positioning @@ -35,13 +35,13 @@ # --- Storage & Transport ------------------------------------------------ -- name: storage +storage: description: "General onboard deck or cargo storage capacity for components or equipment." objects: [anchor, mooring, cable, platform, component] capabilities: - deck_space -- name: chain_storage +chain_storage: description: "Dedicated storage capacity for chain sections in the mooring line." objects: [mooring] materials: [chain] @@ -49,7 +49,7 @@ - chain_locker - deck_space -- name: rope_storage +rope_storage: description: "Dedicated storage capacity for rope sections in the mooring line." objects: [mooring] materials: [rope] @@ -57,14 +57,14 @@ - line_reel - deck_space -- name: cable_storage +cable_storage: description: "Dedicated storage capacity for electrical cables or umbilicals on reels." objects: [cable] capabilities: - cable_reel - deck_space -- name: line_handling +line_handling: description: "Ability to deploy, recover, or tension mooring lines." objects: [mooring] capabilities: @@ -73,7 +73,7 @@ - shark_jaws - stern_roller -- name: cable_handling +cable_handling: description: "Ability to deploy, recover, and control subsea cables under tension." objects: [cable] capabilities: @@ -82,13 +82,13 @@ - cable_reel - stern_roller -- name: lifting +lifting: description: "Ability to lift and move heavy components vertically and horizontally." objects: [anchor, mooring, cable, platform, component] capabilities: - crane -- name: anchor_handling +anchor_handling: description: "Capability to overboard, lower, orient, deploy, or recover anchors." objects: [anchor] capabilities: @@ -96,7 +96,7 @@ - crane - stern_roller -- name: anchor_embedding +anchor_embedding: description: "Capability to embed anchors into seabed using mechanical, hydraulic, or suction means." objects: [anchor] capabilities: @@ -109,7 +109,7 @@ - torque_machine - drilling_machine -- name: anchor_removal +anchor_removal: description: "Capability to extract anchors from seabed via reverse suction or pulling." objects: [anchor] capabilities: @@ -121,7 +121,7 @@ # --- Mooring Systems --------------------------------------------------- -- name: mooring_work +mooring_work: description: "Specialized capability for mooring hookup, tensioning, and verification." objects: [mooring, component] capabilities: @@ -132,14 +132,14 @@ # --- Platform Handling & Heavy Lift ------------------------------------ -- name: platform_handling +platform_handling: description: "Capability to position, ballast, and secure floating structures." objects: [platform] capabilities: - crane - pumping -- name: pumping +pumping: description: "Capability to perform controlled ballasting, de-ballasting, or fluid transfer." capabilities: - pump_surface @@ -149,7 +149,7 @@ # --- Cable & Subsea Work ------------------------------------------------ -- name: cable_laying +cable_laying: description: "Capability to lay subsea cables." objects: [cable] capabilities: @@ -157,7 +157,7 @@ - cable_plough - stern_roller -- name: rock_placement +rock_placement: description: "Capability to place rock or gravel for cable protection, scour prevention, or trench backfill." capabilities: - rock_placement @@ -166,20 +166,20 @@ # --- Monitoring & Survey ------------------------------------------------ -- name: monitoring_system +monitoring_system: description: "Capability to monitor parameters during installation (pressure, torque, position, etc.)." capabilities: - monitoring_system - rov - container -- name: positioning_system +positioning_system: description: "Capability to provide high-accuracy seabed positioning or navigation." capabilities: - positioning_system - sonar_survey -- name: survey +survey: description: "Capability for site or installation survey using sonar, ROV, or acoustic tools." capabilities: - sonar_survey diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 1f22f586..80e50dd8 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -73,11 +73,16 @@ MPSV_01: max_line_pull_t: 60 brake_load_t: 120 speed_mpm: 16 - mooring_work: - line_types: [chain] - stern_roller: true - shark_jaws: true - towing_pin_rating_t: 300 + # mooring_work: + # line_types: [chain] + # stern_roller: true + # shark_jaws: true + # towing_pin_rating_t: 300 + stern_roller: + diameter_m: 1.5 # m <> + width_m: 3.0 # m + shark_jaws: + max_load_t: 200 # t positioning_system: accuracy_m: 1.0 methods: [USBL, INS] From 501fdc4e1174ad80b2598291137670340b703b27 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Mon, 10 Nov 2025 09:02:34 -0700 Subject: [PATCH 58/63] IRMA ongoing requirements work - now accounting for specs Some edits to add/restore account for capability specs (aka metrics) in the setting of requirements and checking of asset suitability. irma.py - Scenario.addAction now prepares a nested dict for all the possible requirement -> capability -> specs, initialized to zeros. Action class: - Action.requirements is now a nested dictionary that includes the capabilities and specs for each requirement. - Action.updateRequirements now fills in required specification values based on the objects involved. Just a start so far - lots to be added. - New method checkAssets, checks whether a combination of one or more assets can meet all required capability specs. (Can probably replace checkAsset.) - Commented out materialList and allReq - maybe not needed anymore. - Edited script at bottom to try out the updates capabilities.yaml: removed name field, to make names keys. requirements.yaml: made a couple small edits. --- famodel/irma/action.py | 190 +++++++++++++++++++++++++++++---- famodel/irma/actions.yaml | 8 +- famodel/irma/capabilities.yaml | 58 +++++----- famodel/irma/irma.py | 32 +++++- famodel/irma/requirements.yaml | 11 +- 5 files changed, 234 insertions(+), 65 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 0cf2128e..04ff79b7 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -91,7 +91,7 @@ class Action(): subclasses. ''' - def __init__(self, actionType, name, allReq, **kwargs): + def __init__(self, actionType, name, **kwargs): # allReq, **kwargs): '''Create an action object... It must be given a name. The remaining parameters should correspond to items in the actionType dict... @@ -118,13 +118,13 @@ def __init__(self, actionType, name, allReq, **kwargs): # list of things that will be controlled during this action self.assetList = [] # list of assigned assets (vessels or ports) required to perform the action - self.requirements = {} # dictionary of requirements (keys) and a boolean (True/False) indicating whether they're needed or not (values) + self.requirements = {} # dictionary of requirements (keys) and associated required capabilities self.objectList = [] # all objects that could be acted on - self.materialList = [] # all materials that could be acted on + #self.materialList = [] # all materials that could be acted on self.dependencies = {} # list of other actions this one depends on self.actionType = actionType # <— keep the YAML dict on the instance - self.allReq = allReq # <— keep the full requirements dict on the instance + #self.allReq = allReq # <— keep the full requirements dict on the instance self.type = getFromDict(actionType, 'type', dtype=str) self.name = name @@ -156,10 +156,10 @@ def __init__(self, actionType, name, allReq, **kwargs): # Determine requirements based on action type if 'requirements' in actionType: - reqList = actionType['requirements'] - self.requirements = {req: True for req in reqList} # initialize all requirements to True (needed) - self.requirements_met = {req: False for req in reqList} # dictionary to track if requirements are met (by assigned assets). Initialized to False. - + self.requirements = actionType['requirements'] # copy over the requirements with zero-valued capability specs + #self.requirements = {req: True for req in actionType['requirements']} # initialize all requirements to True (needed) + self.requirements_met = {req: False for req in actionType['requirements']} # dictionary to track if requirements are met (by assigned assets). Initialized to False. + # Process objects to be acted upon. NOTE: must occur after requirements and assets placeholders have been assigned. # make list of supported object type names if 'objects' in actionType: @@ -173,8 +173,7 @@ def __init__(self, actionType, name, allReq, **kwargs): self.assignObjects(kwargs['objects']) # Based on the assigned objects, update what requirements/capabilities are needed - if False: # let's assume for now that all requirements are True. - self.updateRequirements() + self.updateRequirements() # Process dependencies if 'dependencies' in kwargs: @@ -183,6 +182,7 @@ def __init__(self, actionType, name, allReq, **kwargs): # Process some optional kwargs depending on the action type + def updateRequirements(self): ''' Updates requirements based on the assigned objects or materials. @@ -193,7 +193,8 @@ def updateRequirements(self): if not self.requirements: raise Warning("No requirements defined for action; cannot update requirements.") return - + + ''' for req in self.requirements.keys(): # Does this requirement require specific objects or material? objReq = self.allReq[req]['objects'] @@ -216,7 +217,85 @@ def updateRequirements(self): # If there are no specific object or material requirements, just set to True if not (objReq or matReq): self.requirements[req] = True + ''' + + # ----- Fill in required capabilities and their specifications ----- + # Note: this will eventually be populated with calculations for all + # requirement types and capability types, drawing/building from what's + # in getMetrics (which will no longer be used). + + def printNotSupported(st): + '''Prints that a certain thing isn't supported yet in this method.''' + print(f"{st} is not currently supported in Action.updateRequirements.") + + # Go through every requirement (each may involve different calculations, even + # if for the same capabilities) + for reqname, req in self.requirements.items(): + + if reqname == 'chain_storage': # Storage specifically for chain + + chain_L = 0 + chain_vol = 0 + + for obj in self.objectList: + if isinstance(obj, Mooring): + for sec in obj.dd['sections']: + if 'chain' in sec['type']['material']: # if chain section + chain_vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] + chain_L += sec['L'] # length [m] + + req['chain_locker']['volume_m3'] += chain_vol # <<< replace with proper estimate + req['deck_space']['area_m2'] += chain_vol*4.0 # <<< replace with proper estimate + + + elif reqname == 'storage': # Generic storage, such as for anchors + + for obj in self.objectList: + if isinstance(obj, Anchor): + + A = 30 * obj.dd['design']['L'] * obj.dd['design']['D'] # <<< replace with proper estimate + + req['deck_space']['area_m2'] += A + + + elif reqname == 'anchor_embedding': + + for obj in self.objectList: + if isinstance(obj, Anchor): + + if obj.dd['type'] == 'DEA': + + req['bollard_pull']['max_force_t'] = 270 # <<< replace with proper estimate + + elif obj.dd['type'] == 'suction': + + req['pump_subsea']['pressure_bar'] = 12 # <<< replace with proper estimate + + else: + printNotSupported(f"Anchor type {obj.dd['type']}") + + # to be continued... + + else: + printNotSupported(f"Requirement {reqname}") + + # Make a copy of the requirements dict that only keeps entries > 0 + new_reqs = {} + + for reqname, req in self.requirements.items(): + for capname, cap in req.items(): + for key, val in cap.items(): + if val > 0: + if not reqname in new_reqs: + new_reqs[reqname] = {} + if not capname in new_reqs[reqname]: + new_reqs[reqname][capname] = {} + new_reqs[reqname][capname][key] = val + + self.requirements = new_reqs + + def addDependency(self, dep): ''' Registers other action as a dependency of this one. @@ -305,8 +384,7 @@ def getMetrics(self, cap, met, obj): A completed example of what this can look like is the line_reel capability. """ - - + if cap == 'deck_space': # logic for deck_space capability (platforms and sites not compatible) # TODO: how do we account for an action like load_mooring (which has two roles, @@ -845,6 +923,72 @@ def assignMaterials(self, materials): print(f"Warning: Material '{mat['name']}' is already in the action's material list.") self.materialList.append(mat) + + + def checkAssets(self, assets): + ''' + Checks if a specified set of assets has sufficient capabilities and + specs to fulfill all requirements in this action. + + Parameters + ---------- + asset : list of assets + ''' + + # Sum up the asset capabilities and their specs (not sure this is useful/valid) + asset_caps = {} + for asset in assets: + for cap, specs in asset['capabilities'].items(): + if not cap in asset_caps: # add the capability entry if absent + asset_caps[cap] = {} + for key, val in specs.items(): + if key in asset_caps[cap]: + asset_caps[cap][key] += val # add to the spec + else: + asset_caps[cap][key] = val # create the spec + + print('Combined asset specs are as follows:') + for cap, specs in asset_caps.items(): + print(f' Capability {cap}') + for key, val in specs.items(): + print(f' Total spec {key} = {val}') + + + + requirements_met = {} + for req, caps in self.requirements.items(): # go through each requirement + + # The following logic should mark a requirement as met if any one of + # the requirement's needed capabilities has all of its specs by the + # combined spec values of the assets + + requirements_met[req] = False # start assume it is not met + + for cap, specs in caps.items(): # go throuch capability of the requirement + if cap in asset_caps: # check capability is in asset + requirements_met[req] = True # assume met, unless we find a shortfall + + for key, val in specs.items(): # go through each spec for this capability + + if val == 0: # if zero value, no spec required, move on + pass + elif key in asset_caps[cap]: # if the spec is included in the asset capacities + if asset_caps[cap][key] < val: # if spec is too small, fail + # note: may need to add handling for lists/strings, or standardize specs more + requirements_met[req] = False + break + else: # if spec is missing, fail + requirements_met[req] = False + print(f"Warning: capability '{cap}' does not have metric '{key}'.") + break + + if requirements_met[req] == False: + print(f"Warning: requirement '{req}' is not met.") + + # (could copy over some informative pritn statements from checkAsset) + return all(requirements_met.values()) + + def checkAsset(self, asset): ''' Checks if a specified asset has sufficient capabilities to fulfil @@ -1573,12 +1717,12 @@ def assignAssets(self, assets): `assets` : `list` list of assets for assignment of the assets to the requirements in the action. - - Returns - ------- - `None` ''' + #MHnote: this should at some point have logic that figures out + # which asset(s) meet which requirements, and then store that + # somewhere. + # Assign each specified asset to its respective role for asset in assets: self.assignAsset(asset) @@ -1591,6 +1735,7 @@ def assignAssets(self, assets): self.calcDurationAndCost() + def clearAssets(self): ''' Clears all assigned assets from the action. @@ -1696,11 +1841,18 @@ def timestep(self): sc = Scenario() # class instance holding most of the info akey = 'fowt0a' anchor = project.anchorList[akey] - act = sc.addAction('install_anchor', f'install_anchor-{akey}', sc.requirements, objects=[anchor]) + #act = sc.addAction('install_anchor', f'install_anchor-{akey}', sc.requirements, objects=[anchor]) + act = sc.addAction('install_anchor', f'install_anchor-{akey}', objects=[anchor]) + # Check asset asset1 = sc.vessels['AHTS_alpha'] asset2 = sc.vessels['Barge_squid'] + + print(act.checkAssets([asset1]) ) + print(act.checkAssets([asset2]) ) + print(act.checkAssets([asset1, asset2])) + ''' act.requirements['station_keeping'] = False # <<< temporary fix, station_keeping is not listed under capabilities in vessels.yaml for some reason! investigate. assignable_AHTS, message_AHTS = act.checkAsset(asset1) assignable_BRGE, message_BRGE = act.checkAsset(asset2) @@ -1710,7 +1862,7 @@ def timestep(self): assert assignable_AHTS==True, "Asset AHTS_alpha should be assignable to install_anchor action." assert assignable_BRGE==False, "Asset Barge_squid should NOT be assignable to install_anchor action." - + ''' # Evaluate asset duration, cost = act.evaluateAssets([asset1]) print(f"Case1: Evaluated duration: {duration} h, cost: ${cost}") diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index 90818581..df309e47 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -50,7 +50,7 @@ load_cargo: - cable_handling duration_h: Hs_m: - description: "Load-out of mooring systems and components from port or vessel onto vessel." + description: "Load-out of generic components from port or vessel onto vessel." # --- Towing & Transport --- @@ -199,7 +199,7 @@ install_semisub: requirements: - storage - lifting - - pumping + #- pumping - bollard_pull - station_keeping - monitoring_system @@ -212,7 +212,7 @@ install_spar: requirements: - storage - lifting - - pumping + #- pumping - bollard_pull - station_keeping - monitoring_system @@ -225,7 +225,7 @@ install_tlp: requirements: - storage - lifting - - pumping + #- pumping - bollard_pull - station_keeping - monitoring_system diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 7c026eab..2fb2ea8b 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -12,82 +12,78 @@ # for allowing conventional unit inputs. <<< # --- Vessel (on-board) --- - - name: engine +engine: # description: Engine on-board of the vessel # fields: power_hp: # power [horsepower] site_speed_mps: # speed [m/s] - - name: bollard_pull +bollard_pull: # description: Towing/holding force capability # fields: max_force_t: # bollard pull [t] site_speed_mps: # speed [m/s] - - name: deck_space +deck_space: # description: Clear usable deck area and allowable load # fields: area_m2: # usable area [m2] max_load_t: # allowable deck load [t] - - name: chain_locker +chain_locker: # description: Chain storage capacity # fields: volume_m3: # storage volume [m3] - - name: line_reel +line_reel: # description: Chain/rope storage on drum or carousel # fields: volume_m3: # storage volume [m3] rope_capacity_m: # total rope length storage [m] - - name: cable_reel +cable_reel: # description: Cable storage on drum or carousel # fields: volume_m3: # storage volume [m3] cable_capacity_m: # total cable length stowable [m] - - name: winch +winch: # description: Deck winch pulling capability # fields: max_line_pull_t: # continuous line pull [t] brake_load_t: # static brake holding load [t] speed_mpm: # payout/haul speed [m/min] - - - - - - name: crane +crane: # description: Main crane lifting capability # fields: capacity_t: # SWL at specified radius [t] hook_height_m: # max hook height [m] - - name: station_keeping_by_dynamic_positioning +station_keeping_by_dynamic_positioning: # description: DP vessel capability for station keeping # fields: type: # TODO: maybe information about thrusters here? - - name: station_keeping_by_anchor +station_keeping_by_anchor: # description: Anchor-based station keeping capability # fields: max_hold_force_t: # maximum holding force [t] - - name: station_keeping_by_bowt +station_keeping_by_bowt: # description: Station keeping by bowt # fields: max_hold_force_t: # maximum holding force [t] - - name: stern_roller +stern_roller: # description: Stern roller for overboarding/lowering lines/cables over stern # fields: diameter_m: # roller diameter [m] width_m: # roller width [m] - - name: shark_jaws +shark_jaws: # description: Chain stoppers/jaws for holding chain under tension # fields: max_load_t: # maximum holding load [t] @@ -95,7 +91,7 @@ # --- Equipment (portable) --- - - name: pump_surface +pump_surface: # description: Surface-connected suction pump # fields: power_kW: @@ -103,7 +99,7 @@ weight_t: dimensions_m: # LxWxH - - name: pump_subsea +pump_subsea: # description: Subsea suction pump (electric/hydraulic) # fields: power_kW: @@ -111,7 +107,7 @@ weight_t: dimensions_m: # LxWxH - - name: pump_grout +pump_grout: # description: Grout mixing and pumping unit # fields: power_kW: @@ -120,7 +116,7 @@ weight_t: dimensions_m: # LxWxH - - name: hydraulic_hammer +hydraulic_hammer: # description: Impact hammer for pile driving # fields: power_kW: @@ -128,7 +124,7 @@ weight_t: dimensions_m: # LxWxH - - name: vibro_hammer +vibro_hammer: # description: Vibratory hammer # fields: power_kW: @@ -136,14 +132,14 @@ weight_t: dimensions_m: # LxWxH - - name: drilling_machine +drilling_machine: # description: Drilling/rotary socket machine # fields: power_kW: weight_t: dimensions_m: # LxWxH - - name: torque_machine +torque_machine: # description: High-torque rotation unit # fields: power_kW: @@ -151,14 +147,14 @@ weight_t: dimensions_m: # LxWxH - - name: cable_plough +cable_plough: # description: # fields: power_kW: weight_t: dimensions_m: # LxWxH - - name: rock_placement +rock_placement: # description: System for controlled placement of rock for trench backfill, scour protection, and seabed stabilization. # fields: placement_method: # e.g., fall_pipe, side_dump, grab @@ -166,13 +162,13 @@ accuracy_m: # placement accuracy on seabed rock_size_range_mm: # min and max rock/gravel size - - name: container +container: # description: Control/sensors container for power pack and monitoring # fields: weight_t: dimensions_m: # LxWxH - - name: rov +rov: # description: Remotely Operated Vehicle # fields: class: # e.g., OBSERVATION, LIGHT, WORK-CLASS @@ -180,19 +176,19 @@ weight_t: dimensions_m: # LxWxH - - name: positioning_system +positioning_system: # description: Seabed placement/positioning aids # fields: accuracy_m: methods: # e.g., [USBL, LBL, DVL, INS] - - name: monitoring_system +monitoring_system: # description: Installation performance monitoring # fields: metrics: # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] sampling_rate_hz: - - name: sonar_survey +sonar_survey: # description: Sonar systems for survey and verification # fields: types: # e.g., [MBES, SSS, SBP] diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index ec960fee..c28be3a3 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -146,7 +146,7 @@ def __init__(self): actionTypes = loadYAMLtoDict('actions.yaml', already_dict=True) # Descriptions of actions that can be done requirements = loadYAMLtoDict('requirements.yaml', already_dict=True) # Descriptions of requirements that can be done - capabilities = loadYAMLtoDict('capabilities.yaml') + capabilities = loadYAMLtoDict('capabilities.yaml', already_dict=True) vessels = loadYAMLtoDict('vessels.yaml', already_dict=True) objects = loadYAMLtoDict('objects.yaml', already_dict=True) @@ -257,11 +257,21 @@ def registerAction(self, action): if not dep in self.actions.values(): raise Exception(f"New action '{action.name}' has a dependency '{dep.name}' this is not in the action list.") + # Check that all the requirements of all actions conform to the + # options in requirements.yaml. + for reqname, req in action.requirements.items(): + if reqname in self.requirements: # ensure this requirement is listed + for cap in req: + if not cap in self.capabilities: + raise Exception(f"Requirement '{reqname}' capability '{cap}' is not in the global capability list.") + else: + raise Exception(f"Action {action.name} requirement {reqname} is not in requirements.yaml") + # Add it to the actions dictionary self.actions[action.name] = action - def addAction(self, action_type_name, action_name, allReq, **kwargs): + def addAction(self, action_type_name, action_name, **kwargs): '''Creates and action and adds it to the register''' if not action_type_name in self.actionTypes: @@ -270,8 +280,24 @@ def addAction(self, action_type_name, action_name, allReq, **kwargs): # Get dictionary of action type information action_type = self.actionTypes[action_type_name] + # Initialize full zero-valued dictionary of possible required capability specs + reqs = {} # Start a dictionary to hold the requirements -> capabilities -> specs + for req in action_type['requirements']: + reqs[req] = {} + #print(f' {req}') + # add the caps of the req + for cap in self.requirements[req]['capabilities']: + reqs[req][cap] = {} + #print(f' {cap}') + # add the specs of the capability + for spec in self.capabilities[cap]: + reqs[req][cap][spec] = 0 + #print(f' {spec} = 0') + # swap in the filled-out dict + action_type['requirements'] = reqs + # Create the action - act = Action(action_type, action_name, allReq, **kwargs) + act = Action(action_type, action_name, **kwargs) # Register the action self.registerAction(act) diff --git a/famodel/irma/requirements.yaml b/famodel/irma/requirements.yaml index 3deb4475..4b2b6bc7 100644 --- a/famodel/irma/requirements.yaml +++ b/famodel/irma/requirements.yaml @@ -100,8 +100,8 @@ anchor_embedding: description: "Capability to embed anchors into seabed using mechanical, hydraulic, or suction means." objects: [anchor] capabilities: - - bullard_pull - - propulsion + - bollard_pull + - engine - pump_subsea - pump_surface - hydraulic_hammer @@ -139,12 +139,7 @@ platform_handling: - crane - pumping -pumping: - description: "Capability to perform controlled ballasting, de-ballasting, or fluid transfer." - capabilities: - - pump_surface - - pump_subsea - - pump_grout +# (removed pumping. requirements should be based on the purpose they serve) # --- Cable & Subsea Work ------------------------------------------------ From ade0cc580fcad62f86b503a4e5a1f57249e09a9e Mon Sep 17 00:00:00 2001 From: Yuksel-Rudy Date: Mon, 10 Nov 2025 15:49:22 -0700 Subject: [PATCH 59/63] more work on Action: - fixing anchor type from suction_pile to suction in the example ontology file we use in the main script of action.py (this is based on the new anchor terminology). - evaluateAssets + assignAssets functionality in Action.py still won't work (WiP) as they are calling calcDurationAndCost function which we need to work on. - work in action.py includes: - - checkAssets function corrected to set requirement to meet if one or more capabilities are available (and are capable to provide the necessary specs demanded by the project). - - I've divided anchor_handling into anchor_overboarding, anchor_lowering, and anchor_orient to show an example of how a capability within each of these requirements are needed be fulfilled by the combined capabilities of all assets. - - These calculations in updateRequirements based on the install_anchor action requirements are set as an example but the numbers could change (example of doing such calculations). - Other changes include: - - changing bollard pull requirement to towing. - - stern_roller does not have diameter_m but just width_m spec. --- examples/OntologySample200m_1turb.yaml | 2 +- famodel/irma/action.py | 128 ++++++++++++++++++------- famodel/irma/actions.yaml | 5 +- famodel/irma/capabilities.yaml | 9 +- famodel/irma/requirements.yaml | 32 ++++++- famodel/irma/vessels.yaml | 3 +- 6 files changed, 133 insertions(+), 46 deletions(-) diff --git a/examples/OntologySample200m_1turb.yaml b/examples/OntologySample200m_1turb.yaml index b8712b74..6087e3b3 100644 --- a/examples/OntologySample200m_1turb.yaml +++ b/examples/OntologySample200m_1turb.yaml @@ -1295,7 +1295,7 @@ anchor_types: zlug : 10 # embedded depth of padeye [m] suction1: - type : suction_pile + type : suction L : 16.4 # length of pile [m] D : 5.45 # diameter of pile [m] zlug : 9.32 # embedded depth of padeye [m] diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 04ff79b7..6ae7da74 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -258,6 +258,30 @@ def printNotSupported(st): req['deck_space']['area_m2'] += A + elif reqname == 'anchor_overboarding' or reqname == 'anchor_lowering': + for obj in self.objectList: + if isinstance(obj, Anchor): + + if obj.mass: + mass = obj.mass / 1e3 # tonnes + else: # rough estimate based on size + wall_thickness = (6.35 + obj.dd['design']['D']*20)/1e3 # Suction pile wall thickness (m), API RP2A-WSD. It changes for different anchor concepts + mass = (np.pi * ((obj.dd['design']['D']/2)**2 - (obj.dd['design']['D']/2 - wall_thickness)**2) * obj.dd['design']['L'] * 7850) / 1e3 # rough mass estimate [tonne] + req['crane']['capacity_t'] = mass * 1.2 # <<< replace with proper estimate + req['crane']['hook_height_m'] = obj.dd['design']['L'] * 1.2 # <<< replace with proper estimate + if reqname == 'anchor_overboarding': + req['stern_roller']['width_m'] = obj.dd['design']['D'] * 1.2 # <<< replace with proper estimate + else: # anchor lowering + req['winch']['max_line_pull_t'] = mass * 1.2 # <<< replace with proper estimate + req['winch']['speed_mpm'] = 0.0001 # <<< replace with proper estimate [m/min]. RA: I just put a very small number here to indicate winch is needed (but it doesn't matter how fast the winch is). + + elif reqname == 'anchor_orienting': + for obj in self.objectList: + if isinstance(obj, Anchor): + + # req['winch']['max_line_pull_t'] = + req['rov']['depth_rating_m'] = abs(obj.r[-1]) * 1.2 # <<< replace with proper estimate + req['divers']['max_depth_m'] = abs(obj.r[-1]) * 1.2 # <<< replace with proper estimate / basically, if anchor is too deep, divers might not be an option elif reqname == 'anchor_embedding': @@ -936,6 +960,11 @@ def checkAssets(self, assets): ''' # Sum up the asset capabilities and their specs (not sure this is useful/valid) + + # Here's a list of specs we might want to take the max of instead of sum: Add more as needed + specs_to_max = ['hook_height_m', 'depth_rating_m', + 'max_depth_m', 'accuracy_m', + 'speed_mpm', 'capacity_t'] # capacity_t is here because it doesn't make sense to have two cranes to lift a single anchor. asset_caps = {} for asset in assets: for cap, specs in asset['capabilities'].items(): @@ -943,7 +972,10 @@ def checkAssets(self, assets): asset_caps[cap] = {} for key, val in specs.items(): if key in asset_caps[cap]: - asset_caps[cap][key] += val # add to the spec + if key in specs_to_max: + asset_caps[cap][key] = max(asset_caps[cap][key], val) + else: + asset_caps[cap][key] += val # add to the spec else: asset_caps[cap][key] = val # create the spec @@ -965,28 +997,47 @@ def checkAssets(self, assets): requirements_met[req] = False # start assume it is not met for cap, specs in caps.items(): # go throuch capability of the requirement - if cap in asset_caps: # check capability is in asset - requirements_met[req] = True # assume met, unless we find a shortfall - - for key, val in specs.items(): # go through each spec for this capability + if cap not in asset_caps: # assets don't have this capability, move on + continue + + # Let's check if this capability is sufficient + capable = True + for key, val in specs.items(): # go through each spec for this capability - if val == 0: # if zero value, no spec required, move on - pass - elif key in asset_caps[cap]: # if the spec is included in the asset capacities - if asset_caps[cap][key] < val: # if spec is too small, fail - # note: may need to add handling for lists/strings, or standardize specs more - requirements_met[req] = False - break - else: # if spec is missing, fail - requirements_met[req] = False - print(f"Warning: capability '{cap}' does not have metric '{key}'.") - break + if val == 0: # if zero value, no spec required, move on + continue + if key not in asset_caps[cap]: # if the spec is missing, fail + capable = False + print(f"Warning: capability '{cap}' does not have metric '{key}'.") + break + if asset_caps[cap][key] < val: # if spec is too small, fail + # note: may need to add handling for lists/strings, or standardize specs more + capable = False + print(f"Warning: capability '{cap}' does not meet metric '{key}' requirement of {val:.2f} (has {asset_caps[cap][key]:.2f}).") + break - if requirements_met[req] == False: - print(f"Warning: requirement '{req}' is not met.") + if capable: + requirements_met[req] = True # one capability fully satisfies the requirement + break # no need to check other capabilities for this requirement + + if not requirements_met[req]: + print(f"Requirement '{req}' is not met by asset(s): {assets}.") - # (could copy over some informative pritn statements from checkAsset) - return all(requirements_met.values()) + assignable = all(requirements_met.values()) + + # message: + if assignable: + message = "Asset meets all required capabilities." + else: + unmet = [req for req, met in requirements_met.items() if not met] + detailed = [] + for req in unmet: + expected = [cap for cap in self.requirements[req].keys()] + detailed.append(f"- {req}: {expected}.") + detailed_msg = "\n".join(detailed) + + message = "Asset does not meet the following required capabilities:\n" + detailed_msg + return assignable, message def checkAsset(self, asset): @@ -1609,15 +1660,17 @@ def evaluateAssets(self, assets): ''' # Check each specified asset for its respective role - for asset in assets: - assignable, message = self.checkAsset(asset) - if assignable: - self.assetList.append(asset) # Assignment required for calcDurationAndCost(), will be cleared later - self.requirements_met = {req: True for req in self.requirements_met.keys()} # all requirements met. Will be clearer later - [] - else: - print('INFO: '+message+' Action cannot be completed by provided asset list.') - return -1, -1 # return negative values to indicate incompatibility. Loop is terminated becasue assets not compatible for roles. + + if not isinstance(assets, list): + assets = [assets] + + assignable, message = self.checkAssets(assets) + if assignable: + self.assetList.extend(assets) # Assignment required for calcDurationAndCost(), will be cleared later + self.requirements_met = {req: True for req in self.requirements_met.keys()} # all requirements met. Will be clearer later + else: + print('INFO: '+message+' Action cannot be completed by provided asset list.') + return -1, -1 # return negative values to indicate incompatibility. Loop is terminated becasue assets not compatible for roles. # RA: This is not needed now as we evaluate requirements being met in checkAsset: # # Check that all roles in the action are filled @@ -1849,9 +1902,13 @@ def timestep(self): asset1 = sc.vessels['AHTS_alpha'] asset2 = sc.vessels['Barge_squid'] - print(act.checkAssets([asset1]) ) - print(act.checkAssets([asset2]) ) - print(act.checkAssets([asset1, asset2])) + _, msg1 = act.checkAssets([asset1]) + _, msg2 = act.checkAssets([asset2]) + _, msg12 = act.checkAssets([asset1, asset2]) + + print(msg1 ) + print(msg2 ) + print(msg12) ''' act.requirements['station_keeping'] = False # <<< temporary fix, station_keeping is not listed under capabilities in vessels.yaml for some reason! investigate. assignable_AHTS, message_AHTS = act.checkAsset(asset1) @@ -1862,14 +1919,17 @@ def timestep(self): assert assignable_AHTS==True, "Asset AHTS_alpha should be assignable to install_anchor action." assert assignable_BRGE==False, "Asset Barge_squid should NOT be assignable to install_anchor action." - ''' + # Evaluate asset duration, cost = act.evaluateAssets([asset1]) print(f"Case1: Evaluated duration: {duration} h, cost: ${cost}") duration, cost = act.evaluateAssets([asset2]) print(f"Case2: Evaluated duration: {duration} h, cost: ${cost}") + duration, cost = act.evaluateAssets([asset1, asset2]) + print(f"Case3: Evaluated duration: {duration} h, cost: ${cost}") # Assign asset act.assignAsset(asset1) assert abs(act.duration - 4.5216) < 0.01, "Assigned duration does not match expected value." - assert abs(act.cost - 20194.7886) < 0.01, "Assigned cost does not match expected value." \ No newline at end of file + assert abs(act.cost - 20194.7886) < 0.01, "Assigned cost does not match expected value." + ''' \ No newline at end of file diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index df309e47..c2098fb8 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -121,7 +121,9 @@ install_anchor: objects: [anchor, component] requirements: - storage - - anchor_handling + - anchor_overboarding + - anchor_lowering + - anchor_orienting - anchor_embedding - station_keeping - monitoring_system @@ -135,7 +137,6 @@ retrieve_anchor: requirements: - storage - anchor_removal - - anchor_handling - station_keeping duration_h: Hs_m: diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 2fb2ea8b..6d6263bd 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -58,7 +58,7 @@ winch: crane: # description: Main crane lifting capability # fields: - capacity_t: # SWL at specified radius [t] + capacity_t: # SWL at specified radius [t] <<< field description is non-sensical. hook_height_m: # max hook height [m] station_keeping_by_dynamic_positioning: @@ -80,7 +80,6 @@ station_keeping_by_bowt: stern_roller: # description: Stern roller for overboarding/lowering lines/cables over stern # fields: - diameter_m: # roller diameter [m] width_m: # roller width [m] shark_jaws: @@ -176,6 +175,12 @@ rov: weight_t: dimensions_m: # LxWxH +divers: + # description: Diver support system + # fields: + max_depth_m: + diver_count: + positioning_system: # description: Seabed placement/positioning aids # fields: diff --git a/famodel/irma/requirements.yaml b/famodel/irma/requirements.yaml index 4b2b6bc7..aa8426c9 100644 --- a/famodel/irma/requirements.yaml +++ b/famodel/irma/requirements.yaml @@ -20,8 +20,8 @@ propulsion: capabilities: - engine -bollard_pull: - description: "Ability to exert or resist towline force during towing or station-keeping." +towing: + description: "Ability to exert or resist towline force during towing." capabilities: - bollard_pull @@ -88,14 +88,36 @@ lifting: capabilities: - crane -anchor_handling: - description: "Capability to overboard, lower, orient, deploy, or recover anchors." +anchor_overboarding: + description: "Capability to overboard an anchor." objects: [anchor] capabilities: - - winch - crane - stern_roller +anchor_lowering: + description: "Capability to lower an anchor to seabed." + objects: [anchor] + capabilities: + - crane + - winch + +anchor_orienting: + description: "Capability to orient an anchor during installation." + objects: [anchor] + capabilities: + - winch + - rov + - divers + +# anchor_handling: +# description: "Capability to overboard, lower, orient, deploy, or recover anchors." +# objects: [anchor] +# capabilities: +# - winch +# - crane +# - stern_roller + anchor_embedding: description: "Capability to embed anchors into seabed using mechanical, hydraulic, or suction means." objects: [anchor] diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 80e50dd8..1e07f72b 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -79,8 +79,7 @@ MPSV_01: # shark_jaws: true # towing_pin_rating_t: 300 stern_roller: - diameter_m: 1.5 # m <> - width_m: 3.0 # m + width_m: 3.0 # m <> shark_jaws: max_load_t: 200 # t positioning_system: From 1a05f4c56da4c316cd668d493c38c54610514353 Mon Sep 17 00:00:00 2001 From: Stein Date: Tue, 11 Nov 2025 14:25:37 -0700 Subject: [PATCH 60/63] Action updateRequirements real-life data - Added (mostly) deck space requirements to Action.updateRequirements() for chain, rope, and anchors based on external data - - Still some things to go over, like the orientation of a suction pile, and how standardized rope reel deck space amounts are (and whether they depend on the asset) - Also some updates to irma.py to work with findTaskDependencies, which works, but can be extracted to inputs to the scheduler --- famodel/irma/action.py | 38 ++++++++++++++++++++++++++++++-------- famodel/irma/irma.py | 22 ++++++++++++++-------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index 6ae7da74..ae1a5cd5 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -240,21 +240,43 @@ def printNotSupported(st): for obj in self.objectList: if isinstance(obj, Mooring): - for sec in obj.dd['sections']: - if 'chain' in sec['type']['material']: # if chain section - chain_vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] - chain_L += sec['L'] # length [m] + for sec in obj.dd['subcomponents']: + if 'L' in sec.keys(): + if 'chain' in sec['type']['material']: # if chain section + chain_vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 * (2) # volume [m^3] + chain_L += sec['L'] # length [m] req['chain_locker']['volume_m3'] += chain_vol # <<< replace with proper estimate - req['deck_space']['area_m2'] += chain_vol*4.0 # <<< replace with proper estimate + req['deck_space']['area_m2'] += chain_L*0.205 # m^2 + + + elif reqname == 'rope_storage': # Storage specifically for chain + + rope_L = 0 + rope_vol = 0 + + for obj in self.objectList: + if isinstance(obj, Mooring): + for sec in obj.dd['subcomponents']: + if 'L' in sec.keys(): + if 'rope' in sec['type']['material'] or 'polyester' in sec['type']['material']: + rope_vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] + rope_L += sec['L'] # length [m] + + req['line_reel']['volume_m3'] += rope_vol + req['deck_space']['area_m2'] += np.ceil((0.0184*rope_L)/13.5)*13.5 # m^2 elif reqname == 'storage': # Generic storage, such as for anchors for obj in self.objectList: if isinstance(obj, Anchor): - - A = 30 * obj.dd['design']['L'] * obj.dd['design']['D'] # <<< replace with proper estimate + + if 'suction' in obj.dd['type']: + # if the suction piles are to be laying down + A = (obj.dd['design']['L']+(10/3.28084)) * (obj.dd['design']['D']+(10/3.28084)) + # if the suction piles are to be standing up # <<<<<< how to implement this? Depends on the asset assignment + # A = (obj.dd['design']['D']+(10/3.28084))**2 req['deck_space']['area_m2'] += A @@ -273,7 +295,7 @@ def printNotSupported(st): req['stern_roller']['width_m'] = obj.dd['design']['D'] * 1.2 # <<< replace with proper estimate else: # anchor lowering req['winch']['max_line_pull_t'] = mass * 1.2 # <<< replace with proper estimate - req['winch']['speed_mpm'] = 0.0001 # <<< replace with proper estimate [m/min]. RA: I just put a very small number here to indicate winch is needed (but it doesn't matter how fast the winch is). + req['winch']['speed_mpm'] = 18 # meters per minute elif reqname == 'anchor_orienting': for obj in self.objectList: diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index c28be3a3..115ef0e1 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -404,8 +404,8 @@ def figureOutTaskRelationships(self): ''' # Figure out task durations (for a given set of asset assignments?) - for task in self.tasks.values(): - task.calcTiming() + #for task in self.tasks.values(): + #task.calcTiming() # Figure out timing constraints between tasks based on action dependencies n = len(self.tasks) @@ -449,13 +449,13 @@ def findTaskDependencies(task1, task2): print(time_1_to_2) print(time_2_to_1) - dt_min_1_2 = min(time_1_to_2) # minimum time required from t1 start to t2 start - dt_min_2_1 = min(time_2_to_1) # minimum time required from t2 start to t1 start + dt_min_1_2 = min(time_1_to_2) if time_1_to_2 else 0 # minimum time required from t1 start to t2 start + dt_min_2_1 = min(time_2_to_1) if time_2_to_1 else 0 # minimum time required from t2 start to t1 start if dt_min_1_2 + dt_min_2_1 > 0: print(f"The timing between these two tasks seems to be impossible...") - breakpoint() + #breakpoint() return dt_min_1_2, dt_min_2_1 @@ -616,9 +616,15 @@ def implementStrategy_staged(sc): implementStrategy_staged(sc) - # dt_min = sc.figureOutTaskRelationships() - - + dt_min = sc.figureOutTaskRelationships() + ''' + # inputs for scheduler + offsets_min = {} # min: 'taskA->taskB': offset max: 'taskA->taskB': (offset, 'exact') + for taskA_index, taskA_name in enumerate(sc.tasks.keys()): + for taskB_index, taskB_name in enumerate(sc.tasks.keys()): + if dt_min[taskA_index, taskB_index] != 0: + offsets_min[f'{taskA_name}->{taskB_name}'] = dt_min[taskA_index, taskB_index] + ''' # ----- Check tasks for suitable vessels and the associated costs/times ----- From 47eb99accd685ba6ae8d8089b74f6dcbcfa8dc65 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Mon, 24 Nov 2025 10:13:59 -0700 Subject: [PATCH 61/63] A lot of IRMA changes to get task asset assignment working: Too many changes to detail, but key points are: Action class: - new Action.dependsOn method for easy dependency check. - expanded/adjusted Action.updateRequirements with more reqs and more consistent calcs. - expanded/adjusted Action.calcDurationAndCost with more actions and a more consistent approach to summing costs and durations. - Added Action.durations and .costs (with an s) to store itemized values. Requirements: - Added "direction" to storage-related reqs. +1 means loading, -1 unloading. - Added "selected_capacity" field to say which capacity will be used. - Added "assigned_asset" field to say which asset will be used to meet that capacity. Task class: - Task.act_sequence is the actions in sequence form (a list in order) - Added a bunch of logic in Task.init to figure out the overall requirements of the task considering all its actions. Currently only works for serial action sequences (no parallel actions). - Adjusted updateStartTime and renamed to setStartTime. - Added Task.chart, which is a slightly streamlined version of calwave_chart, as a Task method. - Added helper functions for processing dependencies, checking asset suitability, etc. imra.py example script: - Some temporary additions for computing general properties of Project class objects for use in IRMA. To be moved eventually. - Added some code to demo the asset assignment and make a Felipe-style plot. Edits to some YAMLs to get thigs working and deal with a couple outlier entries. --- famodel/irma/action.py | 297 +++++++-- famodel/irma/actions.yaml | 56 +- famodel/irma/capabilities.yaml | 1 + famodel/irma/irma.py | 142 ++-- famodel/irma/objects.yaml | 2 + famodel/irma/requirements.yaml | 20 +- famodel/irma/task.py | 1102 +++++++++++++++++++++++++++++++- famodel/irma/vessels.yaml | 8 + 8 files changed, 1467 insertions(+), 161 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index ae1a5cd5..c8ec5590 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -183,9 +183,29 @@ def __init__(self, actionType, name, **kwargs): # allReq, **kwargs): # Process some optional kwargs depending on the action type + def dependsOn(self, act, recur=0): + '''Returns True if this action depends on the passed in action. + This looks through all dependencies, not just immediate.''' + + if recur > 10: + print("WARNGING, there seems to be a recursive action dependency...") + breakpoint() + + if act.name in self.dependencies: + return True + else: # Recursive search through dependent tasks + for act2 in self.dependencies.values(): + if act2.dependsOn(act, recur=recur+1): + return True + + return False + + def updateRequirements(self): ''' Updates requirements based on the assigned objects or materials. + Note: any requirements whose values are not set in this method will be + subsequently removed from consideration. ''' # RA: let's rethink this function or brainstorm more. if not self.objectList: @@ -226,14 +246,31 @@ def updateRequirements(self): def printNotSupported(st): '''Prints that a certain thing isn't supported yet in this method.''' - print(f"{st} is not currently supported in Action.updateRequirements.") + print(f"{st} is not yet in Action.updateRequirements.") # Go through every requirement (each may involve different calculations, even # if for the same capabilities) - for reqname, req in self.requirements.items(): + for reqname_full, vals in self.requirements.items(): + + reqname = vals['base'] # name of requirement without direction suffix + req = vals['capabilities'] # should rename this to caps + + + if reqname == 'towing': + + mass = 1 + + for obj in self.objectList: + try: + mass += obj.props['mass'] + except: + pass + + req['bollard_pull']['max_force_t'] = 0.01*mass/1e4 # <<< can add a better calculation for towing force required + - if reqname == 'chain_storage': # Storage specifically for chain + elif reqname == 'chain_storage': # Storage specifically for chain chain_L = 0 chain_vol = 0 @@ -321,7 +358,20 @@ def printNotSupported(st): else: printNotSupported(f"Anchor type {obj.dd['type']}") - # to be continued... + elif reqname == 'line_handling': + req['winch']['max_line_pull_t'] = 1 + req['crane']['capacity_t'] = 27 # should set to mooring weight <<< + #req[''][''] + + elif reqname == 'subsea_connection': + + for obj in self.objectList: + if isinstance(obj, Mooring): + + depth = abs(obj.rA[2]) # depth assumed needed for the connect/disconnect work + req['rov']['depth_rating_m'] = depth + if depth < 200: # don't consider divers if deeper than this + req['divers']['max_depth_m'] = depth # else: printNotSupported(f"Requirement {reqname}") @@ -330,14 +380,15 @@ def printNotSupported(st): new_reqs = {} for reqname, req in self.requirements.items(): - for capname, cap in req.items(): + for capname, cap in req['capabilities'].items(): for key, val in cap.items(): if val > 0: if not reqname in new_reqs: - new_reqs[reqname] = {} - if not capname in new_reqs[reqname]: - new_reqs[reqname][capname] = {} - new_reqs[reqname][capname][key] = val + new_reqs[reqname] = {'base':req['base'], 'capabilities':{}, + 'direction':req['direction']} + if not capname in new_reqs[reqname]['capabilities']: + new_reqs[reqname]['capabilities'][capname] = {} + new_reqs[reqname]['capabilities'][capname][key] = val self.requirements = new_reqs @@ -1007,10 +1058,15 @@ def checkAssets(self, assets): for key, val in specs.items(): print(f' Total spec {key} = {val}') - + # <<< maybe instead of all this we should do an approach that looks by asset + # because that could then also be used to decide asset assignment + # to each requirement >>> requirements_met = {} - for req, caps in self.requirements.items(): # go through each requirement + for req, vals in self.requirements.items(): # go through each requirement + + caps = vals['capabilities'] + dir = vals['direction'] # The following logic should mark a requirement as met if any one of # the requirement's needed capabilities has all of its specs by the @@ -1043,7 +1099,8 @@ def checkAssets(self, assets): break # no need to check other capabilities for this requirement if not requirements_met[req]: - print(f"Requirement '{req}' is not met by asset(s): {assets}.") + print(f"Requirement '{req}' is not met by asset(s):") + # print(f"{assets}.") assignable = all(requirements_met.values()) @@ -1149,13 +1206,19 @@ def calcDurationAndCost(self): ''' # Check that all roles in the action are filled + ''' for req, met in self.requirements_met.items(): if not met: raise Exception(f"Requirement '{req}' is not met in action '{self.name}'. Cannot calculate duration and cost.") - - # Initialize cost and duration - self.cost = 0.0 # [$] - self.duration = 0.0 # [h] + ''' + + if len(self.assetList) == 0: + raise Exception(f"Cannot calculate action {self.name} because no assets have been succesfully assigned.") + + + # Initialize itimized cost and duration dictionaries + self.costs = {} # [$] + self.durations = {} # [h] """ Note to devs: @@ -1176,12 +1239,16 @@ def calcDurationAndCost(self): 'crane_barge': 3.0, 'research_vessel': 1.0 } + mob_times = [] # store time of each vessel (the next lines of code could maybe be simplified) for asset in self.assetList: asset_type = asset['type'].lower() for key, duration in durations.items(): if key in asset_type: - self.duration += duration - break + mob_times.append(duration) + + # vessels mobilize in parallel so store the max time + self.durations['mobilize'] = max(mob_times) + elif self.type == 'demobilize': # Hard-coded example of demobilization times based on vessel type - from the calwave installation example. @@ -1189,18 +1256,30 @@ def calcDurationAndCost(self): 'crane_barge': 3.0, 'research_vessel': 1.0 } + mob_times for asset in self.assetList: asset_type = asset['type'].lower() for key, duration in durations.items(): if key in asset_type: - self.duration += duration + mob_times.append(duration) + + # vessels demobilize in parallel so store the max time + self.durations['demobilize'] = max(mob_times) + elif self.type == 'load_cargo': pass # --- Towing & Transport --- elif self.type == 'tow': - pass + + req = self.requirements['towing'] # look at bollard pull requirement + + distance = 2500 # <<< need to eventually compute distances based on positions + + speed = req['assigned_assets'][0]['capabilities']['bollard_pull']['site_speed_mps'] + + self.durations['tow'] = distance / speed / 60 / 60 # duration [hr] elif self.type == 'transit_linehaul_self': # TODO: RA: Needs to be updated based on new format (no roles)! - Note to dev: try to reduce (try/except) statements @@ -1211,7 +1290,7 @@ def calcDurationAndCost(self): try: v = getFromDict(self.actionType, 'default_duration_h', dtype=float); self.duration += v except ValueError: - vessel = self.assets.get('vessel') or self.assets.get('operator') or self.assets.get('carrier') + vessel = self.assetList[0] # MH: using first asset for now <<< if vessel is None: raise ValueError('transit_linehaul_self: no vessel assigned.') @@ -1227,7 +1306,7 @@ def calcDurationAndCost(self): self.duration += dur_h # cost rate_per_hour = 0.0 - for _, asset in self.assets.items(): + for _, asset in self.assetList: rate_per_hour += float(asset['day_rate'])/24.0 self.cost += self.duration*rate_per_hour return self.duration, self.cost @@ -1491,17 +1570,34 @@ def calcDurationAndCost(self): # --- Mooring & Anchors --- elif self.type == 'load_mooring': - # TODO: RA: Needs to be updated based on new format (no roles)! - # Example model assuming line will be winched on to vessel. This can be changed if not most accurate - duration_min = 0 - for obj in self.objectList: - if obj.__class__.__name__.lower() == 'mooring': - for i, sec in enumerate(obj.dd['sections']): # add up the length of all sections in the mooring - duration_min += sec['L'] / self.assets['carrier2']['winch']['speed_mpm'] # duration [minutes] - self.duration += duration_min / 60 / 24 # convert minutes to days - self.cost += self.duration * (self.assets['carrier1']['day_rate'] + self.assets['carrier2']['day_rate'] + self.assets['operator']['day_rate']) # cost of all assets involved for the duration of the action [$] - + # total mooring length that needs to be loaded + L = 0 + m = 0 + + # list of just the mooring objects (should be all of them) + moorings = [obj for obj in self.objectList if isinstance(obj, Mooring)] + + # for obj in self.objectList: + # if isinstance(obj, Mooring): + # L += obj.props['length'] + + req = self.requirements['line_handling'] # look at line handling requirement + + if 'winch' in req['selected_capability']: # using a winch to load + + speed = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + + L = sum([mooring['length'] for mooring in moorings]) + + self.durations['load mooring by winch'] = L / speed / 60 # duration [hr] + + elif 'crane' in req['selected_capability']: # using a crane to load + + # temporarily estimate 1h per crane loading <<< + self.durations['load mooring by crane'] = 1.0 * len(moorings) + + elif self.type == 'install_anchor': # YAML override (no model if present) default_duration = None @@ -1524,46 +1620,90 @@ def calcDurationAndCost(self): depth_m = abs(float(anchor.r[2])) # 2) Winch vertical speed [mps] - # TODO: RA: work needs to be done to determine which capability is used to perform the action based on the req-cap matrix. # TODO: RA: Also, what if the anchor is using 'barge' for 'storage' (anchor is in the barge) but another asset has the winch? This is not a problem if the other asset uses the crane to install the anchor. - winch = True - if winch: - # Find the asset that has the winch capability - for asset in self.assetList: - if 'winch' in asset['capabilities']: - v_mpm = float(asset['capabilities']['winch']['speed_mpm']) - break - # v_mpm = float(self.assets['carrier']['capabilities']['winch']['speed_mpm']) - t_lower_min = depth_m/v_mpm + req = self.requirements['anchor_lowering'] # calculate the time for anchor lowering + + v_mpm = None + if 'winch' in req['selected_capability']: # using a winch to lower + v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + elif 'crane' in req['selected_capability']: # using a crane to lower + v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed_mpm'] + if v_mpm: # there is only a lowering time if a winch or crane is involved + self.durations['anchor lowering'] = depth_m/v_mpm /60 # [h] + # 3) Penetration time ~ proportional to L - rate_pen = 15. # [min] per [m] - t_pen_min = L*rate_pen + if 'anchor_embedding' in self.requirements: + req = self.requirements['anchor_embedding'] + if 'pump_subsea' in req['selected_capability']: # using a winch to lower + specs = req['assigned_assets'][0]['capabilities']['pump_subsea'] # pump specs + embed_speed = 0.1*specs['power_kW']/(np.pi/4*anchor.dd['design']['D']**2) # <<< example of more specific calculation + else: + embed_speed = 0.07 # embedment rate [m/min] + self.durations['anchor embedding'] = L*embed_speed / 60 # 4) Connection / release (fixed) - t_ops_min = 15 + self.durations['anchor release'] = 15/60 + + elif self.type == 'retrieve_anchor': + pass + elif self.type == 'lay_mooring': #'install_mooring': + + mooring = self.objectList[0] # assume there's one mooring for now + + # find installation depth of end A (assuming that's the end to be hooked up now) + depth = abs(mooring.rA[2]) + # Note: Eventually could have logic in here to figure out if the mooring was + # already lowered/attached with the anchor in a previous step (based on the + # previous action, which assets/objects were involved, attachments, etc.). + + if 'line_handling' in self.requirements: + req = self.requirements['line_handling'] # calculate the time for paying out line - duration_min = t_lower_min + t_pen_min + t_ops_min - computed_duration_h = duration_min/60.0 # [h] + # note: some of this code is repeated and could be put in a function + v_mpm = None + if 'winch' in req['selected_capability']: # using a winch to lower + v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + elif 'crane' in req['selected_capability']: # using a crane to lower + v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed_mpm'] - # print(f'[install_anchor] yaml_duration={yaml_duration} -> used={computed_duration_h} h') + if v_mpm: # there is only a lowering time if a winch or crane is involved + self.durations['mooring line lowering'] = depth/v_mpm /60 # [h] + + if 'subsea_connection' in self.requirements: + req = self.requirements['subsea_connection'] + if 'rov' in req['selected_capability']: + time = 1 + depth/500 + elif 'divers' in req['selected_capability']: + time = 1 + depth/100 + + self.durations['mooring-anchor connection'] = time - # Duration addition - self.duration += computed_duration_h - # Cost assessment - rate_per_hour = 0.0 - for asset in self.assetList: - rate_per_hour += float(asset['day_rate'])/24.0 - self.cost += self.duration*rate_per_hour - - elif self.type == 'retrieve_anchor': - pass - elif self.type == 'install_mooring': - pass elif self.type == 'mooring_hookup': - pass + + mooring = self.objectList[0] # assume there's one mooring for now + + # find resting depth of end A (assuming that's the end to be hooked up now) + depth = abs(mooring.rA[2]) + + if 'line_handling' in self.requirements: + req = self.requirements['line_handling'] # calculate the time for paying out line + + # note: some of this code is repeated and could be put in a function + v_mpm = None + if 'winch' in req['selected_capability']: # using a winch to lower + v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + elif 'crane' in req['selected_capability']: # using a crane to lower + v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed_mpm'] + + if v_mpm: # there is only a lowering time if a winch or crane is involved + self.durations['mooring line retrieval'] = depth/v_mpm /60 # [h] + + # >>> tensioning could be added <<< + self.durations['generic hookup and tensioning time'] = 1 + # --- Heavy Lift & Installation --- elif self.type == 'install_wec': @@ -1648,17 +1788,37 @@ def calcDurationAndCost(self): # cost (same pattern you use elsewhere) rate_per_hour = 0.0 - for _, asset in self.assets.items(): + for _, asset in self.assetList: rate_per_hour += float(asset['day_rate'])/24.0 self.cost += self.duration * rate_per_hour - return self.duration, self.cost else: raise ValueError(f"Action type '{self.type}' not recognized.") + + # Sum up duration + self.duration = sum(self.durations.values()) + + # Add cost of all assets involved for the duration of the action [$] + for asset in self.assetList: + self.costs[f"{asset['name']} day rate"] = self.duration * asset['day_rate'] + + # Sum up cost + #self.cost += self.duration * sum([asset['day_rate'] for asset in self.assetList]) + self.cost = sum(self.costs.values()) + return self.duration, self.cost + + def setStartTime(self, start_time): + '''Update the start time of the action [in h]. + ''' + # Update task start and finish times + self.ti = start_time + self.tf = start_time + self.duration + + def evaluateAssets(self, assets): ''' Checks assets for all the roles in the action. This calls `checkAsset()` @@ -1798,17 +1958,16 @@ def assignAssets(self, assets): # which asset(s) meet which requirements, and then store that # somewhere. + ''' # Assign each specified asset to its respective role for asset in assets: self.assignAsset(asset) - # RA: we already check that inside calcDurationAndCost. - # # Check that all roles in the action are filled - # for role_name in self.requirements.keys(): - # if self.assets[role_name] is None: - # raise Exception(f"Role '{role_name}' is not filled in action '{self.name}'. Cannot calculate duration and cost.") # possibly just a warning and not an exception? - + + self.calcDurationAndCost() + ''' + self.assetList = assets def clearAssets(self): diff --git a/famodel/irma/actions.yaml b/famodel/irma/actions.yaml index c2098fb8..4ad8f3df 100644 --- a/famodel/irma/actions.yaml +++ b/famodel/irma/actions.yaml @@ -6,14 +6,14 @@ # Action requirements will be checked against vessel capabilities for evaluation and assignment. # # Old format: roles, which lists asset roles, each with associated required capabilities -# New format: list of requirements +# New format: list of requirements, with optional -in/-out suffix # The code that models and checks these actions is action.calcDurationAndCost(). Structural changes here will not be reflected in the code unless changes are made there as well ### Example action ### # example_action: -# objects: [] or {} "The FAModel object types that are supported in this action" +# objects: [] "The FAModel object types that are expected for this action" # requirements: [] "List of capability requirements that assets must meet to perform this action" # duration_h: 0.0 "Duration in hours" # Hs_m: 0.0 "Wave height constraints in meters" @@ -39,14 +39,15 @@ demobilize: load_cargo: objects: [anchor, mooring, cable, platform, component] + # still have to figure out the aspect of loading from a port requirements: - - chain_storage - - rope_storage + - chain_storage-in + - rope_storage-in - line_handling - lifting - - storage + - storage-in - station_keeping - - cable_storage + - cable_storage-in - cable_handling duration_h: Hs_m: @@ -80,7 +81,7 @@ transit_onsite_tug: objects: [anchor] requirements: - propulsion - - bollard_pull + - towing duration_h: description: "Tug + barge in-field move between site locations" @@ -89,7 +90,7 @@ tow: objects: [platform] requirements: - propulsion - - bollard_pull + - towing - station_keeping duration_h: Hs_m: @@ -99,7 +100,6 @@ transport_components: objects: [component] requirements: - propulsion - - bollard_pull - storage - lifting - station_keeping @@ -120,14 +120,14 @@ at_site_support: install_anchor: objects: [anchor, component] requirements: - - storage + - storage-out - anchor_overboarding - anchor_lowering - anchor_orienting - anchor_embedding - station_keeping - monitoring_system - - survey + # - survey <-- typically done before installation? duration_h: Hs_m: description: "Anchor installation (suction, driven, helical, DEA, SEPLA) with tensioning and verification." @@ -135,7 +135,7 @@ install_anchor: retrieve_anchor: objects: [anchor, component] requirements: - - storage + - storage-in - anchor_removal - station_keeping duration_h: @@ -146,8 +146,8 @@ retrieve_anchor: load_mooring: objects: [mooring, component] requirements: - - chain_storage - - rope_storage + - chain_storage-in + - rope_storage-in - line_handling - station_keeping duration_h: @@ -159,22 +159,23 @@ lay_mooring: objects: [mooring, component] requirements: - propulsion - - bollard_pull - - chain_storage - - rope_storage + - chain_storage-out + - rope_storage-out + - line_handling - station_keeping + - subsea_connection duration_h: Hs_m: - description: "Laying mooring lines, tensioning and connection to anchors and floaters." + description: "Laying mooring lines and connection to anchors." mooring_hookup: objects: [mooring, component, platform] requirements: - - chain_storage - - rope_storage + - chain_storage # <-- what is this for? + - rope_storage # <-- what is this for? - line_handling - - bollard_pull + - towing # <-- may want to tweak this to be a more generic term that includes bollard pull - mooring_work - station_keeping - monitoring_system @@ -201,7 +202,7 @@ install_semisub: - storage - lifting #- pumping - - bollard_pull + - towing - station_keeping - monitoring_system duration_h: @@ -214,7 +215,7 @@ install_spar: - storage - lifting #- pumping - - bollard_pull + - towing - station_keeping - monitoring_system duration_h: @@ -227,7 +228,7 @@ install_tlp: - storage - lifting #- pumping - - bollard_pull + - towing - station_keeping - monitoring_system duration_h: @@ -249,7 +250,7 @@ install_wtg: lay_cable: objects: [cable] requirements: - - cable_storage + - cable_storage-out - cable_laying - station_keeping - monitoring_system @@ -262,16 +263,17 @@ cable_hookup: requirements: - cable_storage - cable_handling - - bollard_pull + - towing - station_keeping - monitoring_system duration_h: Hs_m: description: "Hook-up of cable to floating platforms, including pretensioning." + retrieve_cable: objects: [cable] requirements: - - cable_storage + - cable_storage-in - cable_handling - station_keeping - monitoring_system diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index 6d6263bd..a2c748c9 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -60,6 +60,7 @@ crane: # fields: capacity_t: # SWL at specified radius [t] <<< field description is non-sensical. hook_height_m: # max hook height [m] + speed_mpm: # crane speed [m/s] station_keeping_by_dynamic_positioning: # description: DP vessel capability for station keeping diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 115ef0e1..87d242eb 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -42,7 +42,7 @@ from assets import Vessel, Port - + def loadYAMLtoDict(info, already_dict=False): '''Reads a list or YAML file and prepares a dictionary''' @@ -260,19 +260,19 @@ def registerAction(self, action): # Check that all the requirements of all actions conform to the # options in requirements.yaml. for reqname, req in action.requirements.items(): - if reqname in self.requirements: # ensure this requirement is listed - for cap in req: + if req['base'] in self.requirements: # ensure this requirement is listed + for cap in req['capabilities']: if not cap in self.capabilities: raise Exception(f"Requirement '{reqname}' capability '{cap}' is not in the global capability list.") else: - raise Exception(f"Action {action.name} requirement {reqname} is not in requirements.yaml") + raise Exception(f"Action {action.name} requirement {req['base']} is not in requirements.yaml") # Add it to the actions dictionary self.actions[action.name] = action def addAction(self, action_type_name, action_name, **kwargs): - '''Creates and action and adds it to the register''' + '''Creates an action and adds it to the register''' if not action_type_name in self.actionTypes: raise Exception(f"Specified action type name {'action_type_name'} is not in the list of loaded action types.") @@ -283,15 +283,35 @@ def addAction(self, action_type_name, action_name, **kwargs): # Initialize full zero-valued dictionary of possible required capability specs reqs = {} # Start a dictionary to hold the requirements -> capabilities -> specs for req in action_type['requirements']: - reqs[req] = {} - #print(f' {req}') + + # make sure it's a valid requirement + if '-in' in req: + req_dir = 1 # this means the req is for storage and storage is being filled + req_base = req[:-3] # this is the name of the req as in requirements.yaml, no suffix + elif '-out' in req: + req_dir = -1 + req_base = req[:-4] + else: + req_dir = 0 + req_base = req + + # Make sure the requirement and its direction are supported in the requirements yaml + if not req_base in self.requirements: + raise Exception(f"Requirement '{req_base}' is not in the requirements yaml.") + if abs(req_dir) > 0 and ('directions' not in self.requirements[req_base] + or req_dir not in self.requirements[req_base]['directions']): + raise Exception(f"Requirement '{req_base}' direction '{req_dir}' is not supported in the requirements yaml.") + + # Make the new requirements entry + reqs[req] = {'base':req_base, 'direction':req_dir, 'capabilities':{}} + # add the caps of the req - for cap in self.requirements[req]['capabilities']: - reqs[req][cap] = {} + for cap in self.requirements[req_base]['capabilities']: + reqs[req]['capabilities'][cap] = {} #print(f' {cap}') # add the specs of the capability for spec in self.capabilities[cap]: - reqs[req][cap][spec] = 0 + reqs[req]['capabilities'][cap][spec] = 0 #print(f' {spec} = 0') # swap in the filled-out dict action_type['requirements'] = reqs @@ -399,7 +419,7 @@ def findCompatibleVessels(self): def figureOutTaskRelationships(self): - '''Calculate timing within tasks and then figure out constraints + '''Calculate time constraints between tasks. ''' @@ -449,8 +469,10 @@ def findTaskDependencies(task1, task2): print(time_1_to_2) print(time_2_to_1) - dt_min_1_2 = min(time_1_to_2) if time_1_to_2 else 0 # minimum time required from t1 start to t2 start - dt_min_2_1 = min(time_2_to_1) if time_2_to_1 else 0 # minimum time required from t2 start to t1 start + # TODO: provide cleaner handling of whether or not there is a time constraint in either direction <<< + + dt_min_1_2 = min(time_1_to_2) if time_1_to_2 else -np.inf # minimum time required from t1 start to t2 start + dt_min_2_1 = min(time_2_to_1) if time_2_to_1 else -np.inf # minimum time required from t2 start to t1 start if dt_min_1_2 + dt_min_2_1 > 0: print(f"The timing between these two tasks seems to be impossible...") @@ -541,13 +563,34 @@ def implementStrategy_staged(sc): project.plot2d(save=True, plot_bathymetry=False) ''' - - # ----- Initialize the action stuff ----- + # Tally up some object properties (eventually make this built-in Project stuff) + for mooring in project.mooringList.values(): + # sum up mooring quantities of interest + L = 0 # length + m = 0 # mass + V = 0 # volume + + for sec in mooring.sections(): # add up the length of all sections in the mooring + L += sec['L'] + m += sec['L'] * sec['type']['m'] + V += sec['L'] * np.pi/4 * sec['type']['d_vol']**2 + + mooring.props = {} + mooring.props['length'] = L + mooring.props['pretension'] = 0 # <<< get this from MoorPy once this is moved into Mooring class? + mooring.props['weight'] = 9.8*(m - 1025*V) + mooring.props['mass'] = m + mooring.props['volume'] = V + + print("should do the same for platforms and anchors...") # <<< sc = Scenario() # class instance holding most of the info - # Parse out the install steps required + # ----- Create the interrelated actions (including their individual requirements) ----- + print("===== Create Actions =====") + # When an action is created, its requirements will be calculated based on + # the nature of the action and the objects involved. for akey, anchor in project.anchorList.items(): @@ -555,8 +598,8 @@ def implementStrategy_staged(sc): # add and register anchor install action(s) a1 = sc.addAction('install_anchor', f'install_anchor-{akey}', objects=[anchor]) - duration, cost = a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"], 'operator':sc.vessels["AHTS_alpha"]}) - print(f'Anchor install action {a1.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + #duration, cost = a1.evaluateAssets({'carrier' : sc.vessels["MPSV_01"], 'operator':sc.vessels["AHTS_alpha"]}) + #print(f'Anchor install action {a1.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') # register the actions as necessary for the anchor <<< do this for all objects?? anchor.install_dependencies = [a1] @@ -575,14 +618,14 @@ def implementStrategy_staged(sc): # create load vessel action a2 = sc.addAction('load_mooring', f'load_mooring-{mkey}', objects=[mooring]) #duration, cost = a2.evaluateAssets({'carrier2' : sc.vessels["HL_Giant"], 'carrier1' : sc.vessels["Barge_squid"], 'operator' : sc.vessels["HL_Giant"]}) - print(f'Mooring load action {a2.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + #print(f'Mooring load action {a2.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') # create ship out mooring action # create lay mooring action a3 = sc.addAction('lay_mooring', f'lay_mooring-{mkey}', objects=[mooring], dependencies=[a2]) sc.addActionDependencies(a3, mooring.attached_to[0].install_dependencies) # in case of shared anchor - print(f'Lay mooring action {a3.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') + #print(f'Lay mooring action {a3.name} duration: {duration:.2f} days, cost: ${cost:,.0f}') # mooring could be attached to anchor here - or could be lowered with anchor!! #(r=r_anch, mooring=mooring, anchor=mooring.anchor...) @@ -603,20 +646,44 @@ def implementStrategy_staged(sc): sc.addActionDependencies(a, [a5]) # make each hookup action dependent on the FOWT being towed out - - # ----- Generate tasks (groups of Actions according to specific strategies) ----- - - #t1 = Task(sc.actions, 'install_mooring_system') - # ----- Do some graph analysis ----- - G = sc.visualizeActions() + #G = sc.visualizeActions() - # make some tasks with one strategy... + # ----- Generate tasks (sequences of Actions following specific strategies) ----- + print('Generating tasks') + # Call one of the task strategy implementers, which will create the tasks + # (The created tasks also contain information about their summed requirements) implementStrategy_staged(sc) - - dt_min = sc.figureOutTaskRelationships() + + # ----- Try assigning assets to the tasks ----- + print('Trying to assign assets to tasks') + for task in sc.tasks.values(): + print(f"--- Looking at task {task.name} ---") + if task.checkAssets([sc.vessels['AHTS_alpha']], display=1)[0]: + print('Assigned AHTS') + task.assignAssets([sc.vessels['AHTS_alpha']]) + elif task.checkAssets([sc.vessels['CSV_A']], display=1)[0]: + print('Assigned CSV_A') + task.assignAssets([sc.vessels['CSV_A']]) + else: + task.checkAssets([sc.vessels['AHTS_alpha'], sc.vessels['HL_Giant'], sc.vessels['CSV_A']], display=1) + print('assigning the kitchen sink') + task.assignAssets([sc.vessels['AHTS_alpha'], sc.vessels['HL_Giant'], sc.vessels['CSV_A']]) + + # Calculation durations of the actions, and then of the task + for a in task.actions.values(): + a.calcDurationAndCost() + task.calcDuration() + + + # Example task time adjustment and plot + sc.tasks['tow_and_hookup'].setStartTime(5) + sc.tasks['tow_and_hookup'].chart() + + #dt_min = sc.figureOutTaskRelationships() + ''' # inputs for scheduler offsets_min = {} # min: 'taskA->taskB': offset max: 'taskA->taskB': (offset, 'exact') @@ -627,11 +694,11 @@ def implementStrategy_staged(sc): ''' # ----- Check tasks for suitable vessels and the associated costs/times ----- - + ''' # preliminary/temporary test of anchor install asset suitability for akey, anchor in project.anchorList.items(): for a in anchor.install_dependencies: # go through required actions (should just be the anchor install) - a.evaluateAssets({'carrier' : sc.vessels["MPSV_01"]}) # see if this example vessel can do it + a.evaluateAssets([sc.vessels["MPSV_01"]]) # see if this example vessel can do it # ----- Generate the task_asset_matrix for scheduler ----- @@ -642,16 +709,16 @@ def implementStrategy_staged(sc): if row.shape != (len(sc.vessels), 2): raise Exception(f"Task '{task.name}' get_row output has wrong shape {row.shape}, should be {(2, len(sc.vessels))}") task_asset_matrix[i, :] = row - + ''' # ----- Call the scheduler ----- # for timing with weather windows and vessel assignments records = [] for task in sc.tasks.values(): - print('XXXXXXX') + print('') print(task.name) for act in task.actions.values(): - print(f" {act.name}: duration: {act.duration} start time: {task.actions_ti[act.name]}") + print(f" {act.name}: duration: {act.duration:8.2f} start time: {task.actions_ti[act.name]:8.2f}") # start = float(task.actions_ti[name]) # start time [hr] # dur = float(act.duration) # duration [hr] # end = start + dur @@ -689,13 +756,8 @@ def implementStrategy_staged(sc): for v in self.vesselList: v.timestep() - - # log the state of everything... - ''' + ''' - - - plt.show() \ No newline at end of file diff --git a/famodel/irma/objects.yaml b/famodel/irma/objects.yaml index d361b8ed..b4283587 100644 --- a/famodel/irma/objects.yaml +++ b/famodel/irma/objects.yaml @@ -5,6 +5,8 @@ mooring: # object name - length # list of supported attributes... - pretension - weight + - mass + - volume platform: # can be wec - mass diff --git a/famodel/irma/requirements.yaml b/famodel/irma/requirements.yaml index aa8426c9..5c1f255c 100644 --- a/famodel/irma/requirements.yaml +++ b/famodel/irma/requirements.yaml @@ -3,6 +3,8 @@ # ---------------------------------------------------------------------- # This file maps requirements and optional capabilities to marine operations actions. # Each entry lists optional capabilities that an asset can have to fulfil the requirement. +# A requirement's list of capabilities has "or" logic; only one of them needs +# to be satisfied. # Example Entry: # chain_storage: @@ -38,6 +40,7 @@ station_keeping: storage: description: "General onboard deck or cargo storage capacity for components or equipment." objects: [anchor, mooring, cable, platform, component] + directions : [-1, 1, 0] capabilities: - deck_space @@ -45,6 +48,7 @@ chain_storage: description: "Dedicated storage capacity for chain sections in the mooring line." objects: [mooring] materials: [chain] + directions : [-1, 1, 0] capabilities: - chain_locker - deck_space @@ -53,6 +57,7 @@ rope_storage: description: "Dedicated storage capacity for rope sections in the mooring line." objects: [mooring] materials: [rope] + directions : [-1, 1, 0] capabilities: - line_reel - deck_space @@ -60,6 +65,7 @@ rope_storage: cable_storage: description: "Dedicated storage capacity for electrical cables or umbilicals on reels." objects: [cable] + directions : [-1, 1, 0] capabilities: - cable_reel - deck_space @@ -70,8 +76,8 @@ line_handling: capabilities: - winch - crane - - shark_jaws - - stern_roller + # - shark_jaws <<< each capability should be an "or" option. Maybe these can be specs. + # - stern_roller cable_handling: description: "Ability to deploy, recover, and control subsea cables under tension." @@ -79,8 +85,8 @@ cable_handling: capabilities: - winch - crane - - cable_reel - - stern_roller + #- cable_reel + #- stern_roller lifting: description: "Ability to lift and move heavy components vertically and horizontally." @@ -151,6 +157,12 @@ mooring_work: - shark_jaws - stern_roller +subsea_connection: + description: "Capability to connect or disconnect a mooring component under water (such as to an anchor)." + objects: [mooring] + capabilities: + - rov + - divers # --- Platform Handling & Heavy Lift ------------------------------------ diff --git a/famodel/irma/task.py b/famodel/irma/task.py index e5c8c660..8843e818 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -59,7 +59,8 @@ def __init__(self, name, actions, action_sequence='series', **kwargs): self.name = name - + print(f" Initializing Task '{self.name}") + # Save the task's dictionary of actions if isinstance(actions, dict): self.actions = actions @@ -69,6 +70,7 @@ def __init__(self, name, actions, action_sequence='series', **kwargs): # --- Set up the sequence of actions --- # key is action name, value is a list of what action names are to be completed before it + if isinstance(action_sequence, dict): # Full dict provided (use directly) self.action_sequence = {k: list(v) for k, v in action_sequence.items()} @@ -106,7 +108,7 @@ def getDeps(action): self.cost = 0.0 # cost must be calculated based on the cost of individual actions. self.ti = 0.0 # task start time [h?] self.tf = 0.0 # task end time [h?] - + ''' # Calculate duration and cost self.calcDuration() # organizes actions and calculates duration self.calcCost() @@ -114,8 +116,587 @@ def getDeps(action): print(f"---------------------- Initializing Task '{self.name} ----------------------") print(f"Task '{self.name}' initialized with duration = {self.duration:.2f} h.") print(f"Task '{self.name}' initialized with cost = ${self.cost:.2f} ") + ''' + + # --- Make a list that conveys the action sequence (similar format as Mooring subcomponents) + act_sequence = dependenciesToSequence(self.action_sequence) + # (contents of sequence or action names/keys) + + # >>> temporarily hard coded here >>> + # Here's a list of specs we might want to take the max of instead of sum: Add more as needed + specs_to_max = ['hook_height_m', 'depth_rating_m', + 'max_depth_m', 'accuracy_m', + 'speed_mpm', 'capacity_t'] + + + # ----- Get Task requirements ----- + + # Go through each series step in the task action sequence and figure out its requirements + # (storage capacities will add, for example) + + # A Task-level dependency dict that describes the overally requirements + # when all actions are combined in sequence + self.requirements = {} + + #req_bases = {} # accumulation of all the requirements over the task's action sequence (maxes, etc.) + # + req_sequences = [[]] # sequence of totalled up requirements at each step + # capacity specs will add/subtract, while others will be instantaneous + # req_sequences is as nested list-list-dict-dict-dict of breakdown -> i_step -> req -> capacity* -> spec + # Whenever there are multiply capacities in a req, the number of + # breakdowns multiplies (branches) by the number of capacities. + # For storage-y reqs, there will only be one capacity listed in the final data. + + for i, step in enumerate(act_sequence): # go through each step in the action sequence + + #print(f" === Step {i} ==== ({len(req_sequences)} breakdowns)") + + for j in range(len(req_sequences)): + #if i == 0: # first step, start with a blank dict + req_sequences[j].append({}) # for the reqs to log at this step + #else: # copy over the last step's requirements as a starting point + # req_sequences[j].append(deepcopy(req_sequences[j][i-1])) + + # ----- Parallel actions case ----- + if isinstance(step, list): # parallel actions + # Currently, this approach just sums up the requirements/capabilities across + # parallel actions. This has the implication that one requirement must be + # fulfilled by only one type of capability. I.e. chain storage can't be + # divided between both deck space and chain locker. + + # A constraint to be considered is for parallel actions to be performed + # by separate vessels. That would require more thought, then implementation. + + for step_act in step: + + act = self.actions[step_act] + + # Go through requirements of the single action at this step + for req in act.requirements.values(): + + #print(f" Requirement {req['base']}") + + # is this requirement related to storage? + storey = req['base'] in ['storage','chain_storage','rope_storage','cable_storage'] + + nold = len(req_sequences) # number of possible breakdowns SO FAR + + # Iterate for each possible requirements breakdown + for j in range(nold): + #print(f" j={j}") + + # Add an entry for this requirement if one doesn't already exist from last step + if not req['base'] in req_sequences[j][i]: + req_sequences[j][i][req['base']] = {} # add entry for this requirement + + ncaps = len(req['capabilities']) + + for k, cap in enumerate(req['capabilities']): # go through capabilities in the req + + #print(f" k={k} - capability is {cap}") + + # force to use the same storage as established previously if unloading + if storey and req['direction'] == -1: # if unloading + # look through prevous actions... + doNothing = True + for iprev in range(i-1, -1, -1): + if isinstance(act_sequence[iprev], list): # if parallel actions here + for act2_name in act_sequence[iprev]: + act2 = self.actions[act_sequence[iprev]] + if act.dependsOn(act2): # if dependency, look for related action + for req2 in act2.requirements.values(): + # if the same storage requirement gets added to or loaded + if req['base'] == req2['base'] and req2['direction']==1: + # Check if the current capability is what was loaded to + if cap in req_sequences[j][iprev][req['base']]: + doNothing = False # flag that this is the case to keep + break + else: + act2 = self.actions[act_sequence[iprev]] + if act.dependsOn(act2): # if dependency, look for related action + for req2 in act2.requirements.values(): + # if the same storage requirement gets added to or loaded + if req['base'] == req2['base'] and req2['direction']==1: + # Check if the current capability is what was loaded to + if cap in req_sequences[j][iprev][req['base']]: + doNothing = False # flag that this is the case to keep + break + + # this must mean we aren't unloading from a prevoiusly loaded capacity in this + # particular loop, so skip it + if doNothing: + continue # skip the rest of this + + # make a copy of things if it's a storage-y requirement and being added to + # (there are only options when adding to storage, not when removing) + if k < ncaps-1 and storey and req['direction'] == 1: + # I guess we need to make a copy of everything that happened before this, + this_req_sequence = deepcopy(req_sequences[j]) + + else: # otherwise (i.e. on the last one) work with the current sequence + this_req_sequence = req_sequences[j] + + # If this capacity isn't already stored at this req in this step (i.e. this is parallel action 0) + #if not cap in this_req_sequence[i][req['base']]: + new_cap = {} # add an entry for the capacity's specs + #else: # otherwise use the existing one + + + if i==0: # first step (starts off the values) + + for spec, val in req['capabilities'][cap].items(): + new_cap[spec] = val # add the specs + + else: # subsequent steps (accumulates some things) + + # -- add/subtract capability specs depending on direction -- + + last_specs = {} + + if storey: # if it's a storage related spec, make sure to work with prevous values + for iprev in range(i-1, -1, -1): # look for last time this requirement's capacity came up + if req['base'] in req_sequences[j][iprev] and cap in req_sequences[j][iprev][req['base']]: + last_specs = req_sequences[j][iprev][req['base']][cap] # cap value in previous step + break + + for spec, val in req['capabilities'][cap].items(): # go through specs of the capability + + if spec in this_req_sequence[i][req['base']][cap]: # check if it's already here (from a parallel action) + last_val = this_req_sequence[i][req['base']][cap][spec] + elif spec in last_specs: # otherwise use the previous value if available, so that we add to it + last_val = last_specs[spec] + else: + last_val = 0 + + # note: this logic should be good for storagey reqs, but unsure for others, e.g. cranes + + if req['direction'] == 0 or spec in specs_to_max: + new_cap[spec] = max(last_val, val) + + elif req['direction'] == 1: # add to the previous value + new_cap[spec] = last_val + val # add to previous value + + elif req['direction'] == -1: # subtract from the previous value + new_cap[spec] = last_val - val # subtract from previous value + + else: + raise Exception("Invalid direction value (must be 0, 1, or -1).") + + + this_req_sequence[i][req['base']][cap] = new_cap # add this req's info (may overwrite in parallel case) + + # Append this as a new possible sequence + if k < ncaps-1 and storey and req['direction'] == 1: + req_sequences.append(this_req_sequence) + # Note: if k==0 then the existing req sequence has already been adjusted + + + # ----- normal case, single action ----- + else: + act = self.actions[step] + + # Go through requirements of the single action at this step + for req in act.requirements.values(): + + #print(f" Requirement {req['base']}") + # is this requirement related to storage? + storey = req['base'] in ['storage','chain_storage','rope_storage','cable_storage'] + + nold = len(req_sequences) # number of possible breakdowns SO FAR + + # >>> bifurcate the current branch of the req_sequences, + # adding n-1 new branches where n is the number of capabilities + # (each which represents one possibility for satisfying the req) + #n = len(req['capabilities']) + + # Iterate for each possible requirements breakdown + for j in range(nold): + #print(f" j={j}") + + # Add an entry for this requirement if one doesn't already exist from last step + #if not req['base'] in req_sequences[j][i]: + req_sequences[j][i][req['base']] = {} # add entry for this requirement + + ncaps = len(req['capabilities']) + + for k, cap in enumerate(req['capabilities']): # go through capabilities in the req + # for k in range(len(req['capabilities'])-1, -1, -1): # go through capabilities in the req + #cap req['capabilities'].keys() + + #print(f" k={k} - capability is {cap}") + + # force to use the same storage as established previously if unloading: + if storey and req['direction'] == -1: # if unloading + keepThisCapability = False + for iprev in range(i-1, -1, -1): # look through prevous actions... + act2 = self.actions[act_sequence[iprev]] + if act.dependsOn(act2): # do something special here? + + for req2 in act2.requirements.values(): + # if the same storage requirement gets added to or loaded + if req['base'] == req2['base'] and req2['direction']==1: + # Check if the current capability is what was loaded to + if cap in req_sequences[j][iprev][req['base']]: + #if i==4 and j==1: + # breakpoint() + keepThisCapability = True # flag that this is the case to keep + break + + # this must mean we aren't unloading from a prevoiusly loaded capacity in this + # particular loop, so kip it + if not keepThisCapability: + #if act.name=='lay_mooring-fowt0a': + # breakpoint() + #print(f"WARNING - action {act.name} involves unloading storage but the prior load action was not found") + continue # skip adding this capability + + # >>> still need to add support for parallel actions <<< + + # make a copy of things if it's a storage-y requirement and being added to + # (there are only options when adding to storage, not when removing) + #if k < ncaps-1 and storey and req['direction'] == 1: <<< old one + if k < ncaps-1 and not (storey and req['direction'] == -1): + # I guess we need to make a copy of everything that happened before this, + if j>20000: + breakpoint() + this_req_sequence = deepcopy(req_sequences[j]) + + else: # otherwise (i.e. on the last one) work with the current sequence + this_req_sequence = req_sequences[j] + + new_cap = {} # add an entry for the capacity's specs + + if i==0: # first step (starts off the values) + + for spec, val in req['capabilities'][cap].items(): + new_cap[spec] = val # add the specs + + else: # subsequent steps (accumulates some things) + + # -- add/subtract capability specs depending on direction -- + # if this req and cap already exist + + last_specs = {} + + if storey: # if it's a storage related spec, make sure to work with prevous values + for iprev in range(i-1, -1, -1): # look for last time this requirement's capacity came up + if req['base'] in req_sequences[j][iprev] and cap in req_sequences[j][iprev][req['base']]: + last_specs = req_sequences[j][iprev][req['base']][cap] # cap value in previous step + break + + #if not cap in req_sequences[j][i][req['base']]: # if capacity doesn't exist in past + # req_sequences[j][i][req['base']][cap] = {} # add a blank for it + + for spec, val in req['capabilities'][cap].items(): # go through specs of the capability + + if spec in last_specs: + last_val = last_specs[spec] + #if spec in req_sequences[j][i][req['base']][cap]: + # last_val = req_sequences[j][i][req['base']][cap][spec] + else: + last_val = 0 + + if req['direction'] == 0 or spec in specs_to_max: + new_cap[spec] = max(last_val, val) + + elif req['direction'] == 1: # add to the previous value + new_cap[spec] = last_val + val # add to previous value + + elif req['direction'] == -1: # subtract from the previous value + new_cap[spec] = last_val - val # subtract from previous value + + else: + raise Exception("Invalid direction value (must be 0, 1, or -1).") + + #print(f" {act.name} {req['base']} {cap} {spec} ") + + #... also check if a spec value is going to go below zero, leave at zero ^^^ + # also distinguish between stock and flow specs, e.g. some to max vs add/subtract ^^^ + + #if act.name == 'mooring_hookup-fowt0a': + # breakpoint() + + this_req_sequence[i][req['base']][cap] = new_cap # add this req's info + + #if j > 40: + # breakpoint() + + # Append this as a new possible sequence + #if k < ncaps-1 and storey and req['direction'] == 1: + if k < ncaps-1 and not (storey and req['direction'] == -1): + req_sequences.append(this_req_sequence) + # Note: if k==0 then the existing req sequence has already been adjusted + + print(f"Task requirements processed. There are {len(req_sequences)} possible combinations.") + + + # Go through the requirements sequence and find the maximum values + # These become the overall requirements of the task. + task_reqs = [] + for j in range(len(req_sequences)): + task_reqs.append({}) # An empty dictionary of requirements for this breakdown + + for i, rs in enumerate(req_sequences[j]): + + for req, caps in rs.items(): + + # if req not already in the list, add it + if not req in task_reqs[j]: + task_reqs[j][req] = {} + + # go through req capabilities + for cap, specs in caps.items(): + if not cap in task_reqs[j][req]: # if cap not in the list, + task_reqs[j][req][cap] = {} # add it + + # go through capability specs and take the maxes + for spec, val in specs.items(): + if spec in task_reqs[j][req][cap]: + last_val = task_reqs[j][req][cap][spec] + else: + last_val = 0 + + # Retain the max value of the spec + task_reqs[j][req][cap][spec] = max(last_val, val) + + if len(req_sequences) > 20000: + breakpoint() + print("there's a lot of options") + + # Save things + self.act_sequence = act_sequence + self.req_sequences = req_sequences + self.task_reqs = task_reqs + + + + def checkAssets(self, assets, display=0): + ''' + Checks if a specified set of assets has sufficient capabilities and + specs to fulfill all requirements of this task. + + Parameters + ---------- + assets : list of assets + ''' + + # this should evaluate the assets w.r.t. self.task_reqs + + + + # Sum up the asset capabilities and their specs (not sure this is useful/valid) + + # Here's a list of specs we might want to take the max of instead of sum: Add more as needed + ''' + specs_to_max = ['hook_height_m', 'depth_rating_m', + 'max_depth_m', 'accuracy_m', + 'speed_mpm', 'capacity_t'] # capacity_t is here because it doesn't make sense to have two cranes to lift a single anchor. + ''' + asset_caps = combineCapabilities(assets, display=display-1) + + # <<< maybe instead of all this we should do an approach that looks by asset + # because that could then also be used to decide asset assignment + # to each requirement >>> + + + # See if summed asset capabilities satisfy any of the n task_req breakdowns + # .>>> an output of this could also be assigning assets to action requirements!! >>> + + requirements_met = [] + assignable = [] + + for i in range(len(self.task_reqs)): + + if display > 2: print(f"Task {self.name} requirements breakdown #{i}:") + + requirements_met.append({}) + + requirements_met[i] = doCapsMeetRequirements(asset_caps, self.task_reqs[i]) + ''' + + for req, caps in self.task_reqs[i].items(): # go through each requirement + + requirements_met[i][req] = False # start assume it is not met + # Let's check if each capability is sufficiently provided for + capable = True # starting with optimism... + + for cap, specs in caps.items(): # go throuch each capability of the requirement + + if cap not in asset_caps: # assets don't have this capability, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' is missing from the assets.") + break + + for key, val in specs.items(): # go through each spec for this capability + + if val == 0: # if zero value, no spec required, move on + continue + if key not in asset_caps[cap]: # if the spec is missing, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' does not have spec '{key}'.") + break + if asset_caps[cap][key] < val: # if spec is too small, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' does not meet spec '{key}' requirement of {val:.2f} (has {asset_caps[cap][key]:.2f}).") + break + + # Final call on whether requirement can be met + if capable: + requirements_met[i][req] = True + else: + requirements_met[i][req] = False + if display > 1: print(f"Requirement '{req}' is not met by asset(s):") + if display > 2: print(f"{assets}.") + break + ''' + # Check if all requirements are met by the assets for this breakdown + assignable.append(all(requirements_met[i].values())) + if display > 1: print(f" Suitability is {assignable[i]}.") + + if display > 0: + print(f"Finished checking assets. {sum(assignable)} of {len(assignable)} requirement breakdowns are feasible.") + ''' + if self.name =='install_all_anchors': + for i in range(len(self.task_reqs)): + + + print(doCapsMeetRequirements(asset_caps, self.task_reqs[i])) + + if not 'divers' in self.task_reqs[i]['anchor_orienting']: + print(i) + printStruct(self.task_reqs[i]) + + breakpoint() + ''' + ''' (Older method that looks at any capability being satisfied) + requirements_met = {} + for req, vals in self.requirements.items(): # go through each requirement + + caps = vals['capabilities'] + dir = vals['direction'] + + # The following logic should mark a requirement as met if any one of + # the requirement's needed capabilities has all of its specs by the + # combined spec values of the assets + + requirements_met[req] = False # start assume it is not met + + for cap, specs in caps.items(): # go throuch capability of the requirement + if cap not in asset_caps: # assets don't have this capability, move on + continue + + # Let's check if this capability is sufficient + capable = True + for key, val in specs.items(): # go through each spec for this capability + + if val == 0: # if zero value, no spec required, move on + continue + if key not in asset_caps[cap]: # if the spec is missing, fail + capable = False + print(f"Warning: capability '{cap}' does not have metric '{key}'.") + break + if asset_caps[cap][key] < val: # if spec is too small, fail + # note: may need to add handling for lists/strings, or standardize specs more + capable = False + print(f"Warning: capability '{cap}' does not meet metric '{key}' requirement of {val:.2f} (has {asset_caps[cap][key]:.2f}).") + break + + if capable: + requirements_met[req] = True # one capability fully satisfies the requirement + break # no need to check other capabilities for this requirement + + if not requirements_met[req]: + print(f"Requirement '{req}' is not met by asset(s): {assets}.") + + assignable = all(requirements_met.values()) + + # message: + if assignable: + message = "Asset meets all required capabilities." + else: + unmet = [req for req, met in requirements_met.items() if not met] + detailed = [] + for req in unmet: + expected = [cap for cap in self.requirements[req].keys()] + detailed.append(f"- {req}: {expected}.") + detailed_msg = "\n".join(detailed) + + message = "Asset does not meet the following required capabilities:\n" + detailed_msg + ''' + + + # return bool of if any req breakdowns can be satisfied, and a list of which ones + return any(assignable), assignable + + + def assignAssets(self, assets, display=0): + '''Figures out an assignment of the asset capabilities to the task's + steps' requirements, including each action's requirements.''' + + doable, indices = self.checkAssets(assets) + + if doable: + + # sum up combined asset capabilities + asset_caps = combineCapabilities(assets) + + # take the first requirement breakdown that works + ind = indices.index(True) # get the index of the first true value + + # Select that breakdown (update Task's/actions' requirements) <<< can be turned into method + # Go through and delete any requirements in the actions that don't correspond to this breakdown + # traverse action sequence + for i, step in enumerate(self.act_sequence): # go through each step in the action sequence + + if isinstance(step, list): # Parallel actions case + for j in len(step): # each parallel action + pass # (we don't actually know how to handle this yet) <<< + + else: # normal case (one action at a time) + + action = self.actions[self.act_sequence[i]] # this step's action + reqs = self.req_sequences[ind][i] # altered/active requirements at this step + + for areq in action.requirements.values(): + if not areq['base'] in reqs: + raise Exception(f"Action {action.name} somehow has a req that isn't in the task's req_sequence") + + # Create selected_capabilities (or clear it if it already exists) + areq['selected_capability'] = {} + + for acap, aspecs in areq['capabilities'].items(): # cycle through action's reqs capability keys + if acap in reqs[areq['base']]: # if this capability is listed, it means we plan to use it + areq['selected_capability'][acap] = aspecs # so copy it over + # (there should only be one capability selected per requirement) + + # Note which asset(s) are planned to fill this req + for ass in assets: + met = checkCapability(areq['selected_capability'], [ass], acap) + if met: + areq['assigned_assets'] = [ass] + break + + if not met: + met = checkCapability(areq['selected_capability'], assets, acap) + if met: + areq['assigned_assets'] = assets + else: + raise Exception(f"Task {self.name} could not satisfy action {action.name} capability {acap} with the available assets.") + + + action.assignAssets(assets) + + self.assetList = assets + + if display > 0: + print(f"For the task {self.name}, assigned the assets:") + print([asset['name'] for asset in assets]) + else: + print("This asset assignment is not feasible for the task.") + + def getSequenceGraph(self, action_sequence=None, plot=True): '''Generate a multi-directed graph that visalizes action sequencing within the task. Build a MultiDiGraph with nodes: @@ -297,6 +878,7 @@ def calcDuration(self): # Update task finish time self.tf = self.ti + self.duration + def calcCost(self): '''Calculates the total cost of the task based on the costs of individual actions. Updates self.cost accordingly. This method assumes that action.cost has @@ -309,7 +891,7 @@ def calcCost(self): return self.cost - def updateStartTime(self, newStart=0.0): + def setStartTime(self, start_time): '''Update the start time of all actions based on a new task start time. This requires that the task's duration and relative action start times are already calculated. @@ -321,12 +903,12 @@ def updateStartTime(self, newStart=0.0): ''' # Update task start and finish times - self.ti = newStart - self.tf = newStart + self.duration + self.ti = start_time + self.tf = start_time + self.duration - # Update action start times - for action in self.actions_ti: - self.actions_ti[action] += self.ti + # Update action times + for name, action in self.actions.items(): + action.setStartTime(start_time + self.actions_ti[name]) def clearAssets(self): @@ -394,7 +976,7 @@ def GanttChart(self, start_at_zero=True, color_by=None): "red", "yellow", "cyan", "purple" ] - fig, ax = plt.subplots(figsize=(10, 10)) + fig, ax = plt.subplots(figsize=(6, 6)) # Prepare data for Gantt chart action_names = list(self.actions.keys()) @@ -402,23 +984,25 @@ def GanttChart(self, start_at_zero=True, color_by=None): durations = [self.actions[name].duration for name in action_names] # Get asset information from action.assets + all_assets = [asset['name'] for asset in self.assetList] # list of asset names + ''' all_assets = set() - all_roles = set() + #all_roles = set() for action in self.actions.values(): - for role, asset in action.assets.items(): - all_assets.add(asset['name']) - all_roles.add(role) - + for asset in action.assetList: + all_assets.add(asset) + #all_roles.add(role) + ''' # Assign colors if color_by == 'asset': - asset_list = list(all_assets) - color_dict = {asset: colors[i] for i, asset in enumerate(asset_list)} + color_dict = {asset: colors[i] for i, asset in enumerate(all_assets)} + ''' elif color_by == 'role': # Flip the colors colors = colors[::-1] role_list = list(all_roles) color_dict = {role: colors[i] for i, role in enumerate(role_list)} - + ''' # Generate vertical lines to indicate the start and finish of the whole task ax.axvline(x=self.ti, ymin=0, ymax=len(action_names), color='black', linestyle='-', linewidth=2.0) ax.axvline(x=self.tf, ymin=0, ymax=len(action_names), color='black', linestyle='-', linewidth=2.0) @@ -428,13 +1012,13 @@ def GanttChart(self, start_at_zero=True, color_by=None): for i, (name, start, duration) in enumerate(zip(action_names, start_times, durations)): opp_i = len(action_names) - i - 1 # to have first action on top action = self.actions[name] - assets = list({asset['name'] for asset in action.assets.values()}) - roles = list({role for role in action.assets.keys()}) + assets = list({asset['name'] for asset in action.assetList}) + #roles = list({role for role in action.assets.keys()}) assets = list(set(assets)) # Remove duplicates from assets n_assets = len(assets) - n_roles = len(roles) + #n_roles = len(roles) if color_by is None: ax.barh(opp_i, duration, color='cyan', left=start, height=ht, align='center') @@ -450,6 +1034,7 @@ def GanttChart(self, start_at_zero=True, color_by=None): color = color_dict.get(asset, 'gray') ax.barh(bottom + sub_ht/2, duration, left=start, height=sub_ht * 0.9, color=color, edgecolor='k', linewidth=0.3, align='center') + ''' elif color_by == 'role': # Compute vertical offsets if multiple roles if n_roles == 0: @@ -462,6 +1047,7 @@ def GanttChart(self, start_at_zero=True, color_by=None): color = color_dict.get(role, 'gray') ax.barh(bottom + sub_ht/2, duration, left=start, height=sub_ht * 0.9, color=color, edgecolor='k', linewidth=0.3, align='center') + ''' else: color_by = None raise Warning(f"color_by option '{color_by}' not recognized. Use 'asset', 'role'. None will be used") @@ -482,10 +1068,11 @@ def GanttChart(self, start_at_zero=True, color_by=None): if color_by == 'asset': handles = [plt.Rectangle((0, 0), 1, 1, color=color_dict[a]) for a in all_assets] ax.legend(handles, all_assets, title='Assets', bbox_to_anchor=(1.02, 1), loc='upper right') + ''' elif color_by == 'role': handles = [plt.Rectangle((0, 0), 1, 1, color=color_dict[a]) for a in all_roles] ax.legend(handles, all_roles, title='Roles', bbox_to_anchor=(1.02, 1), loc='upper right') - + ''' if start_at_zero: ax.set_xlim(0, self.tf + 1) # Create a grid and adjust layout @@ -510,3 +1097,476 @@ def chart(self, start_at_zero=True): The axes object containing the Gantt chart. ''' pass + + + def chart(self, title='', outpath='', dpi=200): + ''' + Render a Gantt-like chart for a single ChartTask with one axes and one horizontal lane per vessel. + • Vessel names as y-tick labels + • Baseline arrows, light span bars, circle bubbles with time inside, title above, + and consistent font sizes. + • Horizontal placement uses Bubble.period when available; otherwise cumulative within vessel. + • Bubbles are colored by Bubble.category (legend added). + + Show an action on multiple lanes if it uses multiple assets. + Skip actions with dur<=0 or with no resolvable lanes. + ''' + + # MH: unsure how much of this up-front stuff is needed + + from dataclasses import dataclass + from typing import List, Optional, Dict, Tuple + import matplotlib.pyplot as plt + + # Data structures + + @dataclass + class Bubble: + action: str + duration_hr: float + label_time: str + period: Optional[Tuple[float, float]] = None + category: Optional[str] = None # new: action category for coloring + + @dataclass + class VesselTimeline: + vessel: str + bubbles: List[Bubble] + + # Color palette + categorization + + # User-requested color scheme + ACTION_TYPE_COLORS: Dict[str, str] = { + 'Mobilization': '#d62728', # red + 'Towing & Transport': '#2ca02c', # green + 'Mooring & Anchors': '#0056d6', # blue + 'Heavy Lift & Installation': '#ffdd00', # yellow + 'Cable Operations': '#9467bd', # purple + 'Survey & Monitoring': '#ff7f0e', # orange + 'Other': '#1f77b4'} # fallback color (matplotlib default) + + + # Keyword buckets → chart categories + CAT_KEYS = [ + ('Mobilization', ('mobilize', 'demobilize')), + ('Towing & Transport', ('transit', 'towing', 'tow', 'convoy', 'linehaul')), + ('Mooring & Anchors', ('anchor', 'mooring', 'pretension', 'pre-tension')), + ('Survey & Monitoring', ('monitor', 'survey', 'inspection', 'rov', 'divers')), + ('Heavy Lift & Installation', ('install_wec', 'install device', 'install', 'heavy-lift', 'lift', 'lower', 'recover_wec', 'recover device')), + ('Cable Operations', ('cable', 'umbilical', 'splice', 'connect', 'wet-mate', 'dry-mate'))] + + + + # MH: making a vessels dict to fit with what this was looking for (quick temporary solution) + vessels = { ves['name'] : ves for ves in self.assetList } + + # reverse lookup for identity → key + id2key = {id(obj): key for key, obj in vessels.items()} + + # unique type → key (used only if type is unique in catalog) + type_counts = {} + for k, obj in vessels.items(): + t = obj.get('type') if isinstance(obj, dict) else getattr(obj, 'type', None) + if t: + type_counts[t] = type_counts.get(t, 0) + 1 + unique_type2key = {} + for k, obj in vessels.items(): + t = obj.get('type') if isinstance(obj, dict) else getattr(obj, 'type', None) + if t and type_counts.get(t) == 1: + unique_type2key[t] = k + + buckets = {} + + for a in self.actions.values(): + if a.duration <= 0.0: + continue # skip if no duration + + aa = getattr(a, 'assets', {}) or {} + + # collect ALL candidate roles → multiple lanes allowed + lane_keys = set() + for v in a.assetList: + + # resolve lane key + lane = id2key.get(id(v)) + if lane is None and isinstance(v, dict): + nm = v.get('name') + if isinstance(nm, str) and nm in vessels: + lane = nm + else: + t = v.get('type') + if t in unique_type2key: + lane = unique_type2key[t] + if lane: + lane_keys.add(lane) + + if not lane_keys: + continue + + # Color code for action categories based on CAT_KEYS + def cat_for(act): + s = f"{getattr(act, 'type', '')} {getattr(act, 'name', '')}".lower().replace('_', ' ') + for cat, keys in CAT_KEYS: + if any(k in s for k in keys): + return cat + return 'Other' + + # one bubble per lane (same fields) + for lane in lane_keys: + b = Bubble( + action=a.name, + duration_hr=a.duration, + label_time=getattr(a, 'label_time', f'{a.duration:.1f}'), + period=( a.ti, a.tf ), + category=cat_for(a)) + + buckets.setdefault(lane, []).append(b) + #breakpoint() + #print('hi') + + # preserve sc.vessels order; only include lanes with content + lanes = [] + for vname in vessels.keys(): + blist = sorted(buckets.get(vname, []), key=lambda b: b.period[0]) + if blist: + lanes.append(VesselTimeline(vessel=vname, bubbles=blist)) + + # Core plotter (single-axes, multiple lanes) + + from matplotlib.lines import Line2D + from matplotlib.patches import Circle + + # --- figure geometry --- + nrows = max(1, len(lanes)) + fig_h = max(3.0, 0.8 + 1*nrows) + fig_w = 5 + + plt.rcdefaults() + plt.close('all') + fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi) + + # --- y lanes (top -> bottom keeps given order) --- + vessels_top_to_bottom = lanes # <<< this seems to just make a duplicate reference for the same data + nrows = max(1, len(lanes)) + y_positions = list(range(nrows))[::-1] + name_to_y = {vt.vessel: y_positions[i] for i, vt in enumerate(vessels_top_to_bottom[::-1])} + + ax.set_yticks(y_positions) + ax.set_yticklabels([]) + ax.tick_params(axis='y', labelrotation=0) + + if len(title) > 0: + ax.set_title(title, loc='left', pad=6) + + # --- gather periods, compute x-range --- + x_min, x_max = 0.0, 0.0 + per_row: Dict[str, List[Tuple[float, float, Bubble]]] = {vt.vessel: [] for vt in lanes} + + for vt in lanes: + t_cursor = 0.0 + for b in vt.bubbles: + if b.period: + s, e = float(b.period[0]), float(b.period[1]) + else: + s = t_cursor + e = s + float(b.duration_hr or 0.0) + per_row[vt.vessel].append((s, e, b)) + x_min = min(x_min, s) + x_max = max(x_max, e) + t_cursor = e + + # --- drawing helpers --- + def _draw_lane_baseline(y_val: float): + ax.annotate('', xy=(x_max, y_val), xytext=(x_min, y_val), + arrowprops=dict(arrowstyle='-|>', lw=2)) + + def _draw_span_hint(s: float, e: float, y_val: float): + ax.plot([s, e], [y_val, y_val], lw=4, alpha=0.15, color='k') + + def _bubble_face_color(b: Bubble) -> str: + cat = b.category or 'Other' + return ACTION_TYPE_COLORS.get(cat, ACTION_TYPE_COLORS['Other']) + + def _text_color_for_face(face: str) -> str: + return 'black' if face.lower() in ('#ffdd00', 'yellow') else 'white' + + def _draw_bubble(s: float, e: float, y_val: float, b: Bubble, i_in_row: int): + xc = 0.5*(s + e) + face = _bubble_face_color(b) + txtc = _text_color_for_face(face) + ax.plot(xc, y_val, 'o', ms=22, color=face, zorder=3) + ax.text(xc, y_val, f'{b.label_time}', ha='center', va='center', fontsize=10, + color=txtc, weight='bold') + title_offset = 0.30 if (i_in_row % 2) else 0.20 + ax.text(xc, y_val + title_offset, b.action, ha='center', va='bottom', fontsize=6) + # caps_txt = _capabilities_to_text(b.capabilities) + # if caps_txt: + # ax.text(xc, y_val - 0.26, caps_txt, ha='center', va='top', fontsize=8, wrap=True) + + # --- draw per lane --- + seen_cats: set[str] = set() + for vt in lanes: + y = name_to_y[vt.vessel] + items = sorted(per_row[vt.vessel], key=lambda t: t[0]) + _draw_lane_baseline(y) + for j, (s, e, b) in enumerate(items): + _draw_span_hint(s, e, y) + _draw_bubble(s, e, y, b, j) + seen_cats.add(b.category or 'Other') + + # --- legend --- + handles = [] + legend_cats = [c for c in ACTION_TYPE_COLORS.keys() if c in seen_cats] + # if you prefer to always show all categories, replace the line above with: legend_cats = list(ACTION_TYPE_COLORS.keys()) + for cat in legend_cats: + handles.append(Line2D([0], [0], marker='o', linestyle='none', markersize=6, + markerfacecolor=ACTION_TYPE_COLORS[cat], markeredgecolor='none', label=cat)) + if handles: + # Place the legend below the x-axis label (bottom center) + fig_ = ax.figure + fig_.legend(handles=handles, + loc='lower center', + bbox_to_anchor=(0.5, -0.12), # move below the axis label + ncol=3, + title='Action Types', + frameon=False) + + # --- axes cosmetics & limits --- + if x_max <= x_min: + x_max = x_min + 1.0 + pad = 0.02*(x_max - x_min) if (x_max - x_min) > 0 else 0.5 + ax.set_xlim(x_min - pad, x_max + pad) + + # Draw circled vessel names at the same y positions + x_name = x_min - 3*pad # small left offset inside the axes + + # After you have vessels_top_to_bottom, name_to_y, x_min/x_max, pad, left_extra, x_name... + max_len = max(len(vt.vessel) for vt in vessels_top_to_bottom) # longest label + + # make the circle tighter/looser: + circle_pad = 0.18 + + for vt in vessels_top_to_bottom[::-1]: + y = name_to_y[vt.vessel] + fixed_text = vt.vessel.center(max_len) # pad with spaces to max length + ax.text( + x_name, y, fixed_text, + ha='center', va='center', zorder=6, clip_on=False, + fontsize=6, color='black', fontfamily='monospace', # <- key: monospace + bbox=dict(boxstyle='circle,pad={:.2f}'.format(circle_pad), + facecolor='lightgrey', edgecolor='tomato', linewidth=3)) + + ax.set_xlabel('Timeline (h)') + ax.grid(False) + for spine in ['top', 'right', 'left']: + ax.spines[spine].set_visible(False) + + ax.set_ylim(min(y_positions) - 0.5, max(y_positions) + 0.5) + + fig = ax.figure + # Add extra bottom margin to make space for the legend below the x-axis label + fig.subplots_adjust(left=0.10, right=0.98, top=0.90, bottom=0.15) + + if outpath: + fig.savefig(outpath, dpi=dpi, bbox_inches='tight') + else: + plt.show() + + + + + + +def dependenciesToSequence(dependencies): + ''' + Receive a dictinoary of item dependencies that define a sequence, + and generate a nested list that follows that sequence. + + Example: + B A G D F H + C E + + dependencies = {'a': ['c'], 'b': [], + 'c': [], 'd': ['g'], + 'e': ['d'], 'f': ['d'], + 'g': ['a'], 'h': ['e','f']} + ''' + + n = len(dependencies) # number of actions in this task + acts = list(dependencies.keys()) # list of action names + deps = list(dependencies.values()) # dependencies of each action + si = np.zeros(n)-1 # step index of action (-1 = TBD) + + sequence = [[]] # create first step slot in the sequence + for i in range(n): # go through action: dependencies + if len(deps[i])==0: # no dependency, it's in first (0) step + si[i] = 0 # mark as being in the first step + sequence[0].append(acts[i]) + + for j in range(1,n): # look for step j actions + #print(f"Step {j} ----") + sequence.append([]) # create next step slot in the sequence + for i in range(n): # go through action: dependencies + #print(f" Action {i}") + if si[i] < 0: # only look at actions that aren't yet sequenced + if any([prev_act in deps[i] for prev_act in sequence[j-1]]): + si[i] = j + sequence[j].append(acts[i]) + + # Clean up the sequence + clean_sequence = [] + for step in sequence: + if len(step) == 1: + clean_sequence.append(step[0]) # add single entry by itself (not a list) + elif len(step) == 0: + break # if we've hit an empty step, we're at the end + else: + clean_sequence.append(step) + + return clean_sequence + + +def combineCapabilities(assets, display=0): + '''Combines the capabilies across multiple assets.''' + + specs_to_max = ['hook_height_m', 'depth_rating_m', + 'max_depth_m', 'accuracy_m', + 'speed_mpm', 'capacity_t'] + + asset_caps = {} + for asset in assets: + for cap, specs in asset['capabilities'].items(): + if not cap in asset_caps: # add the capability entry if absent + asset_caps[cap] = {} + for key, val in specs.items(): + if key in asset_caps[cap]: + if key in specs_to_max: + asset_caps[cap][key] = max(asset_caps[cap][key], val) + else: + asset_caps[cap][key] += val # add to the spec + else: + asset_caps[cap][key] = val # create the spec + + if display > 0: + print('Combined asset specs are as follows:') + for cap, specs in asset_caps.items(): + print(f' Capability {cap}') + for key, val in specs.items(): + print(f' Total spec {key} = {val}') + + return asset_caps + + +def checkCapability(required_capability, assets, capability_name, display=0): + '''Check if the required capability can be met by the combination + of the assets specified.''' + + # required_capability is assumed tobe a dict of cap_name : specs, meaning capability_name is probably redundant <<< + + asset_caps = combineCapabilities(assets) + + + # See if summed asset capabilities satisfy any of the n task_req breakdowns + # .>>> an output of this could also be assigning assets to action requirements!! >>> + + requirements_met = [] + assignable = [] + + # Let's check if each capability is sufficiently provided for + capable = True # starting with optimism... + + for cap, specs in required_capability.items(): # go throuch each capability of the requirement + + if not cap == capability_name: + breakpoint() + print('there is a contradiction...') + + + if cap not in asset_caps: # assets don't have this capability, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' is missing from the assets.") + break + + for key, val in specs.items(): # go through each spec for this capability + + if val == 0: # if zero value, no spec required, move on + continue + if key not in asset_caps[cap]: # if the spec is missing, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' does not have spec '{key}'.") + break + if asset_caps[cap][key] < val: # if spec is too small, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' does not meet spec '{key}' requirement of {val:.2f} (has {asset_caps[cap][key]:.2f}).") + break + + # Final call on whether requirement can be met + if capable: + return True + else: + return False + + +def doCapsMeetRequirements(asset_caps, requirements, display=0): + '''Checks if asset capabilities collectively can satisfy the listed + requirements.''' + + requirements_met = {} # dictionary of requirements being met True/False + + for req, caps in requirements.items(): # go through each requirement + + requirements_met[req] = False # start assume it is not met + + # Let's check if each capability is sufficiently provided for + capable = True # starting with optimism... + + for cap, specs in caps.items(): # go throuch each capability of the requirement + + if cap not in asset_caps: # assets don't have this capability, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' is missing from the assets.") + break + + for key, val in specs.items(): # go through each spec for this capability + + if val == 0: # if zero value, no spec required, move on + continue + if key not in asset_caps[cap]: # if the spec is missing, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' does not have spec '{key}'.") + break + if asset_caps[cap][key] < val: # if spec is too small, fail + capable = False + if display > 2: print(f"Warning: capability '{cap}' does not meet spec '{key}' requirement of {val:.2f} (has {asset_caps[cap][key]:.2f}).") + break + # Final call on whether requirement can be met + if capable: + requirements_met[req] = True + else: + requirements_met[req] = False + if display > 1: print(f"Requirement '{req}' is not met by asset(s):") + if display > 2: print(f"{assets}.") + + return requirements_met + + +def printStruct(t, s=0): + + if not isinstance(t,dict) and not isinstance(t,list): + print(" "*s+str(t)) + else: + for key in t: + if isinstance(t,dict) and not isinstance(t[key],dict) and not isinstance(t[key],list): + print(" "*s+str(key)+" : "+str(t[key])) + else: + print(" "*s+str(key)) + if not isinstance(t,list): + printStruct(t[key], s=s+2) + + +if __name__ == '__main__': + pass + + + + \ No newline at end of file diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 1e07f72b..4d307352 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -17,6 +17,7 @@ AHTS_alpha: max_load_t: 1500 bollard_pull: max_force_t: 200 + site_speed_mps: 1.5 # <<< temporary value added winch: max_line_pull_t: 150 brake_load_t: 300 @@ -24,6 +25,7 @@ AHTS_alpha: crane: capacity_t: 50 hook_height_m: 25 + speed_mpm: 10 # <<< chain_locker: volume_m3: 150 line_reel: @@ -69,6 +71,7 @@ MPSV_01: crane: capacity_t: 150 hook_height_m: 45 + speed_mpm: 10 # <<< winch: max_line_pull_t: 60 brake_load_t: 120 @@ -120,6 +123,7 @@ CSV_A: crane: capacity_t: 250 hook_height_m: 60 + speed_mpm: 10 # <<< winch: max_line_pull_t: 75 brake_load_t: 150 @@ -173,6 +177,7 @@ ROVSV_X: crane: capacity_t: 100 hook_height_m: 35 + speed_mpm: 10 # <<< rov: class: WORK-CLASS depth_rating_m: 3000 @@ -209,6 +214,7 @@ DSV_Moon: crane: capacity_t: 150 hook_height_m: 40 + speed_mpm: 10 # <<< positioning_system: accuracy_m: 0.5 methods: [USBL, LBL, INS] @@ -237,6 +243,7 @@ HL_Giant: crane: capacity_t: 5000 hook_height_m: 150 + speed_mpm: 10 # <<< positioning_system: accuracy_m: 1.0 methods: [USBL, INS] @@ -296,6 +303,7 @@ Barge_squid: crane: capacity_t: 250 hook_height_m: 40 + speed_mpm: 10 # <<< actions: transport_components: {} install_anchor: {} From eae9888df6db92aeb4379ffbd51ae616e57b8e83 Mon Sep 17 00:00:00 2001 From: Matt Hall <5151457+mattEhall@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:32:52 -0700 Subject: [PATCH 62/63] Switching to SI units and enabling unit converter: - Enabled the unit converter when loading YAMLs in Irma.py. - capabilities.yaml: - - changed spec names to use fundamental SI units. - - commented out some weight/dimension specs that may not be necessary. - - added "type" values to denote how each spec should be treated (this is for upcoming changes not yet implemented) - Changed associated keys/calculations in action.py to mainly use SI units. - added a printStruct function to nicely display nested lists/dicts. --- famodel/irma/action.py | 78 ++++++++++-------- famodel/irma/capabilities.yaml | 128 ++++++++++++++--------------- famodel/irma/irma.py | 39 ++++++--- famodel/irma/objects.yaml | 2 +- famodel/irma/spec_conversions.yaml | 11 ++- famodel/irma/task.py | 22 +---- famodel/irma/vessels.yaml | 8 +- 7 files changed, 149 insertions(+), 139 deletions(-) diff --git a/famodel/irma/action.py b/famodel/irma/action.py index c8ec5590..0e5c07ef 100644 --- a/famodel/irma/action.py +++ b/famodel/irma/action.py @@ -30,6 +30,8 @@ configureAdjuster, route_around_anchors) +t2N = 9806.7 # conversion factor from t to N + def incrementer(text): ''' Increments the last integer found in a string. @@ -267,7 +269,7 @@ def printNotSupported(st): except: pass - req['bollard_pull']['max_force_t'] = 0.01*mass/1e4 # <<< can add a better calculation for towing force required + req['bollard_pull']['max_force'] = 0.0001*mass*t2N # <<< can add a better calculation for towing force required elif reqname == 'chain_storage': # Storage specifically for chain @@ -283,8 +285,8 @@ def printNotSupported(st): chain_vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 * (2) # volume [m^3] chain_L += sec['L'] # length [m] - req['chain_locker']['volume_m3'] += chain_vol # <<< replace with proper estimate - req['deck_space']['area_m2'] += chain_L*0.205 # m^2 + req['chain_locker']['volume'] += chain_vol # <<< replace with proper estimate + req['deck_space']['area'] += chain_L*0.205 # m^2 elif reqname == 'rope_storage': # Storage specifically for chain @@ -300,8 +302,8 @@ def printNotSupported(st): rope_vol += sec['L'] * np.pi * (sec['type']['d_nom'] / 2) ** 2 # volume [m^3] rope_L += sec['L'] # length [m] - req['line_reel']['volume_m3'] += rope_vol - req['deck_space']['area_m2'] += np.ceil((0.0184*rope_L)/13.5)*13.5 # m^2 + req['line_reel']['volume'] += rope_vol + req['deck_space']['area'] += np.ceil((0.0184*rope_L)/13.5)*13.5 # m^2 elif reqname == 'storage': # Generic storage, such as for anchors @@ -315,32 +317,32 @@ def printNotSupported(st): # if the suction piles are to be standing up # <<<<<< how to implement this? Depends on the asset assignment # A = (obj.dd['design']['D']+(10/3.28084))**2 - req['deck_space']['area_m2'] += A + req['deck_space']['area'] += A elif reqname == 'anchor_overboarding' or reqname == 'anchor_lowering': for obj in self.objectList: if isinstance(obj, Anchor): if obj.mass: - mass = obj.mass / 1e3 # tonnes + mass = obj.mass # [kg] else: # rough estimate based on size wall_thickness = (6.35 + obj.dd['design']['D']*20)/1e3 # Suction pile wall thickness (m), API RP2A-WSD. It changes for different anchor concepts - mass = (np.pi * ((obj.dd['design']['D']/2)**2 - (obj.dd['design']['D']/2 - wall_thickness)**2) * obj.dd['design']['L'] * 7850) / 1e3 # rough mass estimate [tonne] - req['crane']['capacity_t'] = mass * 1.2 # <<< replace with proper estimate - req['crane']['hook_height_m'] = obj.dd['design']['L'] * 1.2 # <<< replace with proper estimate + mass = (np.pi * ((obj.dd['design']['D']/2)**2 - (obj.dd['design']['D']/2 - wall_thickness)**2) * obj.dd['design']['L'] * 7850) # rough mass estimate [kg] + req['crane']['capacity'] = mass * 1.2 # <<< replace with proper estimate + req['crane']['hook_height'] = obj.dd['design']['L'] * 1.2 # <<< replace with proper estimate if reqname == 'anchor_overboarding': - req['stern_roller']['width_m'] = obj.dd['design']['D'] * 1.2 # <<< replace with proper estimate + req['stern_roller']['width'] = obj.dd['design']['D'] * 1.2 # <<< replace with proper estimate else: # anchor lowering - req['winch']['max_line_pull_t'] = mass * 1.2 # <<< replace with proper estimate - req['winch']['speed_mpm'] = 18 # meters per minute + req['winch']['max_line_pull'] = mass * 1.2 # <<< replace with proper estimate + req['winch']['speed'] = 0.3 # [m/s] elif reqname == 'anchor_orienting': for obj in self.objectList: if isinstance(obj, Anchor): # req['winch']['max_line_pull_t'] = - req['rov']['depth_rating_m'] = abs(obj.r[-1]) * 1.2 # <<< replace with proper estimate - req['divers']['max_depth_m'] = abs(obj.r[-1]) * 1.2 # <<< replace with proper estimate / basically, if anchor is too deep, divers might not be an option + req['rov']['depth_rating'] = abs(obj.r[-1]) * 1.2 # <<< replace with proper estimate + req['divers']['max_depth'] = abs(obj.r[-1]) * 1.2 # <<< replace with proper estimate / basically, if anchor is too deep, divers might not be an option elif reqname == 'anchor_embedding': @@ -349,18 +351,18 @@ def printNotSupported(st): if obj.dd['type'] == 'DEA': - req['bollard_pull']['max_force_t'] = 270 # <<< replace with proper estimate + req['bollard_pull']['max_force'] = 270*t2N # <<< replace with proper estimate elif obj.dd['type'] == 'suction': - req['pump_subsea']['pressure_bar'] = 12 # <<< replace with proper estimate + req['pump_subsea']['pressure'] = 1.2e5 # <<< replace with proper estimate else: printNotSupported(f"Anchor type {obj.dd['type']}") elif reqname == 'line_handling': - req['winch']['max_line_pull_t'] = 1 - req['crane']['capacity_t'] = 27 # should set to mooring weight <<< + req['winch']['max_line_pull'] = 1*t2N + req['crane']['capacity'] = 27*t2N # should set to mooring weight <<< #req[''][''] elif reqname == 'subsea_connection': @@ -369,9 +371,9 @@ def printNotSupported(st): if isinstance(obj, Mooring): depth = abs(obj.rA[2]) # depth assumed needed for the connect/disconnect work - req['rov']['depth_rating_m'] = depth + req['rov']['depth_rating'] = depth if depth < 200: # don't consider divers if deeper than this - req['divers']['max_depth_m'] = depth # + req['divers']['max_depth'] = depth # else: printNotSupported(f"Requirement {reqname}") @@ -1035,9 +1037,9 @@ def checkAssets(self, assets): # Sum up the asset capabilities and their specs (not sure this is useful/valid) # Here's a list of specs we might want to take the max of instead of sum: Add more as needed - specs_to_max = ['hook_height_m', 'depth_rating_m', - 'max_depth_m', 'accuracy_m', - 'speed_mpm', 'capacity_t'] # capacity_t is here because it doesn't make sense to have two cranes to lift a single anchor. + specs_to_max = ['hook_height', 'depth_rating', + 'max_depth', 'accuracy', + 'speed', 'capacity'] # capacity_t is here because it doesn't make sense to have two cranes to lift a single anchor. asset_caps = {} for asset in assets: for cap, specs in asset['capabilities'].items(): @@ -1193,7 +1195,11 @@ def checkAsset(self, asset): def calcDurationAndCost(self): ''' - Calculates duration and cost for the action. The structure here is dependent on `actions.yaml`. + Calculates duration and cost for the action, based on the time for + each requirement to be performed based on the selected capability + and the assigned asset(s) that meeting that capability. + The durations of each requirement are assumed to add (i.e. each is + done in series rather than parallel). <<< MH: is this okay? <<< TODO: finish description Inputs @@ -1277,7 +1283,7 @@ def calcDurationAndCost(self): distance = 2500 # <<< need to eventually compute distances based on positions - speed = req['assigned_assets'][0]['capabilities']['bollard_pull']['site_speed_mps'] + speed = req['assigned_assets'][0]['capabilities']['bollard_pull']['site_speed'] self.durations['tow'] = distance / speed / 60 / 60 # duration [hr] @@ -1439,7 +1445,7 @@ def calcDurationAndCost(self): # onsite speed from capabilities.engine (SI) cap_eng = vessel.get('capabilities', {}).get('engine', {}) - speed_mps = float(cap_eng['site_speed_mps']) + speed_mps = float(cap_eng['site_speed']) self.duration += dist_m/speed_mps/3600.0 @@ -1546,7 +1552,7 @@ def calcDurationAndCost(self): raise ValueError('transit_onsite_tug: operator (barge) missing.') cap_eng = operator.get('capabilities', {}).get('bollard_pull', {}) - speed_mps = float(cap_eng['site_speed_mps']) + speed_mps = float(cap_eng['site_speed']) self.duration += dist_m/speed_mps/3600.0 @@ -1586,7 +1592,7 @@ def calcDurationAndCost(self): if 'winch' in req['selected_capability']: # using a winch to load - speed = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + speed = req['assigned_assets'][0]['capabilities']['winch']['speed']*60 # [m/min] L = sum([mooring['length'] for mooring in moorings]) @@ -1625,9 +1631,9 @@ def calcDurationAndCost(self): v_mpm = None if 'winch' in req['selected_capability']: # using a winch to lower - v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed']*60 # [m/min] elif 'crane' in req['selected_capability']: # using a crane to lower - v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed_mpm'] + v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed']*60 # [m/min] if v_mpm: # there is only a lowering time if a winch or crane is involved self.durations['anchor lowering'] = depth_m/v_mpm /60 # [h] @@ -1637,7 +1643,7 @@ def calcDurationAndCost(self): req = self.requirements['anchor_embedding'] if 'pump_subsea' in req['selected_capability']: # using a winch to lower specs = req['assigned_assets'][0]['capabilities']['pump_subsea'] # pump specs - embed_speed = 0.1*specs['power_kW']/(np.pi/4*anchor.dd['design']['D']**2) # <<< example of more specific calculation + embed_speed = 1E-4*specs['power']/(np.pi/4*anchor.dd['design']['D']**2) # <<< example of more specific calculation else: embed_speed = 0.07 # embedment rate [m/min] self.durations['anchor embedding'] = L*embed_speed / 60 @@ -1663,9 +1669,9 @@ def calcDurationAndCost(self): # note: some of this code is repeated and could be put in a function v_mpm = None if 'winch' in req['selected_capability']: # using a winch to lower - v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed']*60 # [m/min] elif 'crane' in req['selected_capability']: # using a crane to lower - v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed_mpm'] + v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed']*60 # [m/min] if v_mpm: # there is only a lowering time if a winch or crane is involved self.durations['mooring line lowering'] = depth/v_mpm /60 # [h] @@ -1694,9 +1700,9 @@ def calcDurationAndCost(self): # note: some of this code is repeated and could be put in a function v_mpm = None if 'winch' in req['selected_capability']: # using a winch to lower - v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed_mpm'] + v_mpm = req['assigned_assets'][0]['capabilities']['winch']['speed']*60 # [m/min] elif 'crane' in req['selected_capability']: # using a crane to lower - v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed_mpm'] + v_mpm = req['assigned_assets'][0]['capabilities']['crane']['speed']*60 # [m/min] if v_mpm: # there is only a lowering time if a winch or crane is involved self.durations['mooring line retrieval'] = depth/v_mpm /60 # [h] diff --git a/famodel/irma/capabilities.yaml b/famodel/irma/capabilities.yaml index a2c748c9..822f5c8b 100644 --- a/famodel/irma/capabilities.yaml +++ b/famodel/irma/capabilities.yaml @@ -2,65 +2,69 @@ # capabilities.yaml # ---------------------------------------------------------------------- # This file defines standardized capabilities for vessels and equipment. +# The applicable specifications are listed beneach each capability type. # Each entry needs numeric values per specific asset in vessels.yaml. # Vessel actions will be checked against capabilities/actions for validation. +# A unit conversion capability exists so that vessel capability specs can have +# other units (denoted after the spec name with an underscore, e.g., capacity_t) -# The code that calculates the values for these capabilities is action.getMetrics(). -# Changes here won't be reflected in Irma unless the action.getMetrics() code is also updated. +# Each specification field has an entry describing its type: +# - capacity (adds, e.g. for deck space) +# - normal (takes maximum value, e.g. for rating) +# - minimum (takes minimum value, e.g. for accuracy) +# - bool (just whether it exists or not) -# >>> Units to be converted to standard values, with optional converter script -# for allowing conventional unit inputs. <<< # --- Vessel (on-board) --- engine: # description: Engine on-board of the vessel # fields: - power_hp: # power [horsepower] - site_speed_mps: # speed [m/s] + power: capacity # power [W] + site_speed: normal # speed [m/s] bollard_pull: # description: Towing/holding force capability # fields: - max_force_t: # bollard pull [t] - site_speed_mps: # speed [m/s] + max_force: capacity # bollard pull [N] + site_speed: normal # speed [m/s] deck_space: # description: Clear usable deck area and allowable load # fields: - area_m2: # usable area [m2] - max_load_t: # allowable deck load [t] + area: capacity # usable area [m2] + max_load: normal # allowable deck load [N] chain_locker: # description: Chain storage capacity # fields: - volume_m3: # storage volume [m3] + volume: capacity # storage volume [m3] line_reel: # description: Chain/rope storage on drum or carousel # fields: - volume_m3: # storage volume [m3] - rope_capacity_m: # total rope length storage [m] + volume: capacity # storage volume [m3] + length_capacity: normal # total rope length storage [m] cable_reel: # description: Cable storage on drum or carousel # fields: - volume_m3: # storage volume [m3] - cable_capacity_m: # total cable length stowable [m] + volume: capacity # storage volume [m3] + length_capacity: normal # total cable length stowable [m] winch: # description: Deck winch pulling capability # fields: - max_line_pull_t: # continuous line pull [t] - brake_load_t: # static brake holding load [t] - speed_mpm: # payout/haul speed [m/min] + max_line_pull: capacity # continuous line pull [N] + brake_load: normal # static brake holding load [N] + speed: normal # payout/haul speed [m/a] crane: # description: Main crane lifting capability # fields: - capacity_t: # SWL at specified radius [t] <<< field description is non-sensical. - hook_height_m: # max hook height [m] - speed_mpm: # crane speed [m/s] + capacity: capacity # lifting force of crane [N] + hook_height: normal # max hook height [m] + speed: normal # crane speed [m/s] station_keeping_by_dynamic_positioning: # description: DP vessel capability for station keeping @@ -71,22 +75,22 @@ station_keeping_by_dynamic_positioning: station_keeping_by_anchor: # description: Anchor-based station keeping capability # fields: - max_hold_force_t: # maximum holding force [t] + max_hold_force: normal # maximum holding force [N] station_keeping_by_bowt: # description: Station keeping by bowt # fields: - max_hold_force_t: # maximum holding force [t] + max_hold_force: normal # maximum holding force [N] stern_roller: # description: Stern roller for overboarding/lowering lines/cables over stern # fields: - width_m: # roller width [m] + width: normal # roller width [m] shark_jaws: # description: Chain stoppers/jaws for holding chain under tension # fields: - max_load_t: # maximum holding load [t] + max_load: normal # maximum holding load [N] # --- Equipment (portable) --- @@ -94,108 +98,96 @@ shark_jaws: pump_surface: # description: Surface-connected suction pump # fields: - power_kW: - pressure_bar: - weight_t: - dimensions_m: # LxWxH + power: normal + pressure: normal pump_subsea: # description: Subsea suction pump (electric/hydraulic) # fields: - power_kW: - pressure_bar: - weight_t: - dimensions_m: # LxWxH + power: normal + pressure: normal pump_grout: # description: Grout mixing and pumping unit # fields: - power_kW: - flow_rate_m3hr: - pressure_bar: - weight_t: - dimensions_m: # LxWxH + power: normal + flow_rate: capacity + pressure: normal hydraulic_hammer: # description: Impact hammer for pile driving # fields: - power_kW: - energy_per_blow_kJ: - weight_t: - dimensions_m: # LxWxH + power: normal + energy_per_blow_kJ: normal vibro_hammer: # description: Vibratory hammer # fields: - power_kW: - centrifugal_force_kN: - weight_t: - dimensions_m: # LxWxH + power: normal + centrifugal_force: normal drilling_machine: # description: Drilling/rotary socket machine # fields: - power_kW: - weight_t: - dimensions_m: # LxWxH + power: normal torque_machine: # description: High-torque rotation unit # fields: - power_kW: - torque_kNm: - weight_t: - dimensions_m: # LxWxH + power: normal + torque: normal cable_plough: # description: # fields: - power_kW: - weight_t: - dimensions_m: # LxWxH + power: normal rock_placement: # description: System for controlled placement of rock for trench backfill, scour protection, and seabed stabilization. # fields: - placement_method: # e.g., fall_pipe, side_dump, grab - max_depth_m: # maximum operational water depth - accuracy_m: # placement accuracy on seabed - rock_size_range_mm: # min and max rock/gravel size + #placement_method: # e.g., fall_pipe, side_dump, grab + fall_pipe_method: bool # whether this is the method used + side_dump_method: bool # whether this is the method used + grab_method: bool # whether this is the method used + max_depth: normal # maximum operational water depth + accuracy_m: minimum # placement accuracy on seabed + rock_size_min: normal # min rock/gravel size + rock_size_max: minimum # max rock/gravel size container: # description: Control/sensors container for power pack and monitoring # fields: - weight_t: + weight: dimensions_m: # LxWxH rov: # description: Remotely Operated Vehicle # fields: class: # e.g., OBSERVATION, LIGHT, WORK-CLASS - depth_rating_m: - weight_t: + depth_rating: normal + weight: dimensions_m: # LxWxH divers: # description: Diver support system # fields: - max_depth_m: - diver_count: + max_depth: normal + diver_count: normal positioning_system: # description: Seabed placement/positioning aids # fields: - accuracy_m: + accuracy: minimum methods: # e.g., [USBL, LBL, DVL, INS] monitoring_system: # description: Installation performance monitoring # fields: metrics: # e.g., [pressure, flow, tilt, torque, bathymetry, berm_shape...] - sampling_rate_hz: + sampling_rate: normal sonar_survey: # description: Sonar systems for survey and verification # fields: types: # e.g., [MBES, SSS, SBP] - resolution_m: + resolution: minimum diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 87d242eb..19de6bcc 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -80,6 +80,20 @@ def loadYAMLtoDict(info, already_dict=False): return info_dict +def printStruct(t, s=0): + '''Prints a nested list/dictionary data structure with nice indenting.''' + + if not isinstance(t,dict) and not isinstance(t,list): + print(" "*s+str(t)) + else: + for key in t: + if isinstance(t,dict) and not isinstance(t[key],dict) and not isinstance(t[key],list): + print(" "*s+str(key)+" : "+str(t[key])) + else: + print(" "*s+str(key)) + if not isinstance(t,list): + printStruct(t[key], s=s+2) + #def storeState(project,...): @@ -90,9 +104,6 @@ def unifyUnits(d): '''Converts any capability specification/metric in supported non-SI units to be in SI units. Converts the key names as well.''' - # >>> not working yet <<< - - # load conversion data from YAML (eventually may want to store this in a class) with open('spec_conversions.yaml') as file: data = yaml.load(file, Loader=yaml.FullLoader) @@ -103,12 +114,10 @@ def unifyUnits(d): for line in data: keys1.append(line[0]) - facts.append(line[1]) + facts.append(float(line[1])) keys2.append(line[2]) - - # >>> dcopy = deepcopy(d) - for asset in d.values(): # loop through each asset's dict + for name, asset in d.items(): # loop through each asset's dict capabilities = {} # new dict of capabilities to built up @@ -125,6 +134,8 @@ def unifyUnits(d): if keys2[i] in cap_val.keys(): raise Exception(f"Specification '{keys2[i]}' already exists") + print(f"Converting from {key} to {keys2[i]}") + capabilities[cap_key][keys2[i]] = val * facts[i] # make converted entry #capability[keys2[i]] = val * facts[i] # create a new SI entry #del capability[keys1[i]] # remove the original? @@ -132,7 +143,10 @@ def unifyUnits(d): except: capabilities[cap_key][key] = val # copy over original form - + + # Copy over the standardized capability dict for this asset + asset['capabilities'] = capabilities + class Scenario(): @@ -171,7 +185,8 @@ def __init__(self): # Could also check the sub-parameters of the capability for cap_param in cap: if not cap_param in capabilities[capname]: - raise Exception(f"Vessel '{key}' capability '{capname}' parameter '{cap_param}' is not in the global capability's parameter list.") + #raise Exception(f"Vessel '{key}' capability '{capname}' parameter '{cap_param}' is not in the global capability's parameter list.") + print(f"Warning: Vessel '{key}' capability '{capname}' parameter '{cap_param}' is not in the global capability's parameter list.") # Check actions if not 'actions' in ves: @@ -528,7 +543,6 @@ def implementStrategy_staged(sc): # create the task, passing in the sequence of actions sc.addTask('tow_and_hookup', acts, action_sequence='series') - if __name__ == '__main__': @@ -669,6 +683,11 @@ def implementStrategy_staged(sc): task.assignAssets([sc.vessels['CSV_A']]) else: task.checkAssets([sc.vessels['AHTS_alpha'], sc.vessels['HL_Giant'], sc.vessels['CSV_A']], display=1) + + from task import combineCapabilities + asset_caps = combineCapabilities([sc.vessels['AHTS_alpha'], sc.vessels['HL_Giant'], sc.vessels['CSV_A']], display=1) + + breakpoint() print('assigning the kitchen sink') task.assignAssets([sc.vessels['AHTS_alpha'], sc.vessels['HL_Giant'], sc.vessels['CSV_A']]) diff --git a/famodel/irma/objects.yaml b/famodel/irma/objects.yaml index b4283587..8107c37d 100644 --- a/famodel/irma/objects.yaml +++ b/famodel/irma/objects.yaml @@ -2,7 +2,7 @@ # (Any object relations will be checked against this list for validity) mooring: # object name - - length # list of supported attributes... + - length # list of supported attributes... - pretension - weight - mass diff --git a/famodel/irma/spec_conversions.yaml b/famodel/irma/spec_conversions.yaml index 63f269c3..86d958f7 100644 --- a/famodel/irma/spec_conversions.yaml +++ b/famodel/irma/spec_conversions.yaml @@ -3,17 +3,26 @@ # format: key name with common unit, conversation factor from common->SI, fundamental key name (SI unit), - [ area_sqf , 0.092903 , area ] +- [ area_m2 , 1.0 , area ] - [ max_load_t , 9806.7 , max_load ] +- [ volume_m3 , 1.0 , volume ] - [ volume_cf , 0.028317 , volume ] +- [ width_ft , -1 , width ] # width [ft] - [ max_line_pull_t , 9806.7 , max_line_pull ] # continuous line pull [t] - [ brake_load_t , 9806.7 , brake_load ] # static brake holding load [t] +- [ site_speed_mps , 1.0 , site_speed ] - [ speed_mpm , 0.01667 , speed ] # payout/haul speed [m/min] -> m/s +- [ max_hold_force_t , 9806.7 , max_hold_force ] # - [ max_force_t , 9806.7 , max_force ] # bollard pull [t] +- [ length_capacity_m , 1.0 , length_capacity ] # length a reel can store, etc. - [ capacity_t , 9806.7 , capacity ] # SWL at specified radius [t] +- [ hook_height_m , 1.0 , hook_height ] # max hook height [m] - [ hook_height_ft , 0.3048 , hook_height ] # max hook height [m] - [ towing_pin_rating_t , 9806.7 , towing_pin_rating ] # rating of towing pins [t] (optional) +- [ power_hp , -1 , power ] # W - [ power_kW , 1000.0 , power ] # W -- [ pressure_bar , 1e5 , pressure ] # Pa +- [ pressure_bar , 1.0e5 , pressure ] # Pa +- [ flow_rate_m3hr , 0.00027777,flow_rate ] # cubic m per hour to per s - [ weight_t , 9806.7 , weight ] # N or should this be mass in kg? - [ centrifugal_force_kN, 1000.0 , centrifugal_force ] - [ torque_kNm , 1000.0 , torque ] diff --git a/famodel/irma/task.py b/famodel/irma/task.py index 8843e818..cce18556 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -672,13 +672,13 @@ def assignAssets(self, assets, display=0): # (there should only be one capability selected per requirement) # Note which asset(s) are planned to fill this req - for ass in assets: + for ass in assets: # try individual assets met = checkCapability(areq['selected_capability'], [ass], acap) if met: areq['assigned_assets'] = [ass] break - if not met: + if not met: # try ALL assets combined met = checkCapability(areq['selected_capability'], assets, acap) if met: areq['assigned_assets'] = assets @@ -1545,28 +1545,12 @@ def doCapsMeetRequirements(asset_caps, requirements, display=0): else: requirements_met[req] = False if display > 1: print(f"Requirement '{req}' is not met by asset(s):") - if display > 2: print(f"{assets}.") + if display > 2: print(f"{asset_caps}.") return requirements_met -def printStruct(t, s=0): - - if not isinstance(t,dict) and not isinstance(t,list): - print(" "*s+str(t)) - else: - for key in t: - if isinstance(t,dict) and not isinstance(t[key],dict) and not isinstance(t[key],list): - print(" "*s+str(key)+" : "+str(t[key])) - else: - print(" "*s+str(key)) - if not isinstance(t,list): - printStruct(t[key], s=s+2) - if __name__ == '__main__': pass - - - \ No newline at end of file diff --git a/famodel/irma/vessels.yaml b/famodel/irma/vessels.yaml index 4d307352..347ef97f 100644 --- a/famodel/irma/vessels.yaml +++ b/famodel/irma/vessels.yaml @@ -30,7 +30,7 @@ AHTS_alpha: volume_m3: 150 line_reel: volume_m3: 200 - rope_capacity_m: 5000 + length_capacity_m: 5000 pump_subsea: power_kW: 75 pressure_bar: 200 @@ -93,7 +93,7 @@ MPSV_01: sampling_rate_hz: 10 rov: class: OBSERVATION - depth_rating_m: 3000 + depth_rating: 3000 weight_t: 7 dimensions_m: [3, 2, 2] actions: @@ -149,7 +149,7 @@ CSV_A: dimensions_m: [2, 1.5, 1.5] rov: class: WORK-CLASS - depth_rating_m: 3000 + depth_rating: 3000 weight_t: 8 dimensions_m: [3, 2, 2] actions: @@ -180,7 +180,7 @@ ROVSV_X: speed_mpm: 10 # <<< rov: class: WORK-CLASS - depth_rating_m: 3000 + depth_rating: 3000 weight_t: 7 dimensions_m: [3, 2, 2] positioning_system: From 43bca4a07b04d7fa41567416553577832e608a65 Mon Sep 17 00:00:00 2001 From: Stein Date: Mon, 8 Dec 2025 11:26:27 -0700 Subject: [PATCH 63/63] Getting Irma.py to work with the scheduler Run code - Set up code to run the scheduler after everything else in Irma.py was run: tasks, assets, asset_groups (which can be automated later), the task-asset-group matrix, and then dependencies and time offsets - - The dependency offset times are the output of sc.figureOutTaskRelationships - - The task-asset matrix cost and duration values come from task.calcDuration and calcCost and checks each asset group validity Time intervals - Implemented a duration interval in Task.calcDuration() to set the duration to the nearest interval - Task earliest start and finish times are calculated using dependencies, but also to get the last (finish) time to set the number of periods (as a function of the time interval too) - findTaskDependencies now use the time interval too to calculate dt_min - - It also updates to the minimum of time_1_to_2 (still ignoring the other way for now), but ensures that it returns -2.3 over -4.5, since the absolute value is the value we care about here scheduler - updated the wordy parameter to a self parameter - Made a new Gantt chart-style print output (for now) --- famodel/irma/irma.py | 137 ++++++++++++++++++++++++++++++++---- famodel/irma/scheduler.py | 144 ++++++++++++++++++++++++++------------ famodel/irma/task.py | 7 +- 3 files changed, 228 insertions(+), 60 deletions(-) diff --git a/famodel/irma/irma.py b/famodel/irma/irma.py index 19de6bcc..a08019ef 100644 --- a/famodel/irma/irma.py +++ b/famodel/irma/irma.py @@ -40,6 +40,7 @@ from task import Task from assets import Vessel, Port +from scheduler import Scheduler @@ -433,7 +434,7 @@ def findCompatibleVessels(self): pass - def figureOutTaskRelationships(self): + def figureOutTaskRelationships(self, time_interval=0.5): '''Calculate time constraints between tasks. ''' @@ -450,7 +451,7 @@ def figureOutTaskRelationships(self): for i2, task2 in enumerate(self.tasks.values()): # look at all action dependencies from tasks 1 to 2 and # identify the limiting case (the largest time offset)... - dt_min_1_2, dt_min_2_1 = findTaskDependencies(task1, task2) + dt_min_1_2, dt_min_2_1 = findTaskDependencies(task1, task2, time_interval=time_interval) # for now, just look in one direction dt_min[i1, i2] = dt_min_1_2 @@ -458,7 +459,7 @@ def figureOutTaskRelationships(self): return dt_min -def findTaskDependencies(task1, task2): +def findTaskDependencies(task1, task2, time_interval=0.5): '''Finds any time dependency between the actions of two tasks. Returns the minimum time separation required from task 1 to task 2, and from task 2 to task 1. I @@ -481,13 +482,23 @@ def findTaskDependencies(task1, task2): time_2_to_1.append(task2.actions_ti[a2] + act2.duration - task1.actions_ti[a1]) - print(time_1_to_2) - print(time_2_to_1) + #print(time_1_to_2) + #print(time_2_to_1) # TODO: provide cleaner handling of whether or not there is a time constraint in either direction <<< - dt_min_1_2 = min(time_1_to_2) if time_1_to_2 else -np.inf # minimum time required from t1 start to t2 start - dt_min_2_1 = min(time_2_to_1) if time_2_to_1 else -np.inf # minimum time required from t2 start to t1 start + # Calculate minimum times (rounded to nearest interval) + if time_1_to_2: + raw_dt_min_1_2 = min(time_1_to_2, key=abs) + dt_min_1_2 = np.round(raw_dt_min_1_2 / time_interval) * time_interval + else: + dt_min_1_2 = -np.inf + + if time_2_to_1: + raw_dt_min_2_1 = min(time_2_to_1, key=abs) + dt_min_2_1 = np.round(raw_dt_min_2_1 / time_interval) * time_interval + else: + dt_min_2_1 = -np.inf if dt_min_1_2 + dt_min_2_1 > 0: print(f"The timing between these two tasks seems to be impossible...") @@ -545,6 +556,16 @@ def implementStrategy_staged(sc): sc.addTask('tow_and_hookup', acts, action_sequence='series') + + + + + + + + + + if __name__ == '__main__': '''This is currently a script to explore how some of the workflow could work. Can move things into functions/methods as they solidify. @@ -647,7 +668,7 @@ def implementStrategy_staged(sc): # create hookup action a4 = sc.addAction('mooring_hookup', f'mooring_hookup-{mkey}', - objects=[mooring, mooring.attached_to[1]], dependencies=[a2, a3]) + objects=[mooring, mooring.attached_to[1]], dependencies=[a3]) #(r=r, mooring=mooring, platform=platform, depends_on=[a4]) # the action creator can record any dependencies related to actions of the platform @@ -698,10 +719,12 @@ def implementStrategy_staged(sc): # Example task time adjustment and plot - sc.tasks['tow_and_hookup'].setStartTime(5) - sc.tasks['tow_and_hookup'].chart() + #sc.tasks['tow_and_hookup'].setStartTime(5) + #sc.tasks['tow_and_hookup'].chart() + + time_interval = 0.25 - #dt_min = sc.figureOutTaskRelationships() + dt_min = sc.figureOutTaskRelationships(time_interval=time_interval) ''' # inputs for scheduler @@ -730,8 +753,96 @@ def implementStrategy_staged(sc): task_asset_matrix[i, :] = row ''' # ----- Call the scheduler ----- - # for timing with weather windows and vessel assignments + # for timing with weather windows and vessel assignments + + tasks_scheduler = list(sc.tasks.keys()) + + for asset in sc.vessels.values(): + asset['max_weather'] = asset['transport']['Hs_m'] + assets_scheduler = list(sc.vessels.values()) + + # >>>>> TODO: make this automated to find all possible combinations of "realistic" asset groups + asset_groups_scheduler = [ + {'group1': ['AHTS_alpha']}, + {'group2': ['CSV_A']}, + {'group3': ['AHTS_alpha', 'CSV_A', 'HL_Giant']} + ] + + task_asset_matrix_scheduler = np.zeros([len(tasks_scheduler), len(asset_groups_scheduler), 2], dtype=int) + for i,task in enumerate(sc.tasks.values()): + for j,asset_group in enumerate(asset_groups_scheduler): + # Extract asset list from the dictionary - values() returns a list containing one list + asset_names = list(asset_group.values())[0] + asset_list = [sc.vessels[asset_name] for asset_name in asset_names] + #task.checkAssets([sc.vessels['AHTS_alpha'], sc.vessels['HL_Giant'], sc.vessels['CSV_A']], display=1) + if not task.checkAssets(asset_list, display=0)[0]: + task_asset_matrix_scheduler[i,j] = (-1, -1) + else: + task.assignAssets(asset_list) + task.calcDuration(duration_interval=time_interval) + task.calcCost() + duration_int = int(round(task.duration / time_interval)) + task_asset_matrix_scheduler[i,j] = (task.cost, duration_int) + task.clearAssets() + + + task_dependencies = {} + dependency_types = {} + offsets = {} + for i, task1 in enumerate(sc.tasks.values()): + for j, task2 in enumerate(sc.tasks.values()): + offset = dt_min[i,j] + if i != j and offset != -np.inf: + if task2.name not in task_dependencies: + task_dependencies[task2.name] = [] + task_dependencies[task2.name].append(task1.name) + dependency_types[task1.name + '->' + task2.name] = 'start_start' + offsets[task1.name + '->' + task2.name] = offset / time_interval + + for task in sc.tasks.values(): + task.calcDuration() # ensure the durations of each task are calculated + + task_start_times = {} + task_finish_times = {} + task_list = list(sc.tasks.keys()) + + for task_name in task_list: + # Find earliest start time based on dependencies + earliest_start = 0 + for i, t1_name in enumerate(task_list): + j = task_list.index(task_name) + if i != j and dt_min[i, j] != -np.inf: + # This task depends on t1 + earliest_start = max(earliest_start, + task_finish_times.get(t1_name, 0) + dt_min[i, j]) + + task_start_times[task_name] = earliest_start + task_finish_times[task_name] = earliest_start + sc.tasks[task_name].duration + #weather = np.arange(0, max(task_finish_times.values())+ time_interval, time_interval) + weather = [int(x) for x in np.ones(int(max(task_finish_times.values()) / time_interval), dtype=int)] + + scheduler = Scheduler( + tasks=tasks_scheduler, + assets=assets_scheduler, + asset_groups=asset_groups_scheduler, + task_asset_matrix=task_asset_matrix_scheduler, + task_dependencies=task_dependencies, + dependency_types=dependency_types, + offsets=offsets, + weather=weather, + period_duration=time_interval, + wordy=1 + ) + + scheduler.set_up_optimizer() + + result = scheduler.optimize() + + a = 2 + + + ''' records = [] for task in sc.tasks.values(): print('') @@ -757,7 +868,7 @@ def implementStrategy_staged(sc): # print(f"{r['task']} :: {r['action']} duration_hr={r['duration_hr']:.1f} " # f"start={r['start_hr']:.1f} label='{r['time_label']}' periods={r['periods']}") - + ''' # ----- Run the simulation ----- ''' for t in np.arange(8760): diff --git a/famodel/irma/scheduler.py b/famodel/irma/scheduler.py index fbceae14..f2c5525d 100644 --- a/famodel/irma/scheduler.py +++ b/famodel/irma/scheduler.py @@ -24,12 +24,10 @@ import numpy as np import os -wordy = 2 # level of verbosity for print statements - class Scheduler: # Inputs are strictly typed, as this is an integer programming problem (ignored by python at runtime, but helpful for readability and syntax checking). - def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, offsets = {}, weather : list[int] = [], period_duration : float = 1, asset_groups : list[dict] = [], **kwargs): + def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], assets : list[dict] = [], task_dependencies = {}, dependency_types = {}, offsets = {}, weather : list[int] = [], period_duration : float = 1, asset_groups : list[dict] = [], wordy=0, **kwargs): ''' Initializes the Scheduler with assets, tasks, and constraints. @@ -80,8 +78,9 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset ------- None ''' + self.wordy = wordy - if wordy > 0: + if self.wordy > 0: print("Initializing Scheduler...") self.task_asset_matrix = task_asset_matrix @@ -144,7 +143,7 @@ def __init__(self, task_asset_matrix : np.ndarray, tasks : list[str] = [], asset self.Xts_indices = [f"Xts_[{t}][{s}]" for t in range(self.T) for s in range(self.S)] self.X_indices = self.Xta_indices + self.Xtp_indices + self.Xap_indices + self.Xts_indices - if wordy > 0: + if self.wordy > 0: print(f"Scheduler initialized with {self.P} time periods, {self.T} tasks, {self.A} asset groups, and {self.S} start times.") @@ -215,7 +214,7 @@ def _initialize_asset_groups(self): else: print(f"Warning: Individual asset '{asset_name}' in group '{group_name}' not found in assets list") - if wordy > 1: + if self.wordy > 1: print(f"Asset group mappings initialized:") for group_id, individual_asset_indices in self.asset_group_to_individual_assets.items(): individual_asset_names = [self.assets[i].get('name', f'Asset_{i}') for i in individual_asset_indices] @@ -244,7 +243,7 @@ def set_up_optimizer(self, goal : str = "cost"): The bounds for the decision variables (0-1). ''' - if wordy > 0: + if self.wordy > 0: print("Setting up the optimizer...") # Solves a problem of the form minimize: v^T * x @@ -301,7 +300,7 @@ def set_up_optimizer(self, goal : str = "cost"): # The rest of values (for period variables) remains zero because they do not impact cost or duration - if wordy > 1: + if self.wordy > 1: print("Values vector of length " + str(values.shape[0]) + " created") # lb <= x <= ub @@ -309,7 +308,7 @@ def set_up_optimizer(self, goal : str = "cost"): bounds = optimize.Bounds(0, 1) # 0 <= x_i <= 1 integrality = np.ones(num_variables, dtype=int) # x_i are int. So set integrality to 1 - if wordy > 0: + if self.wordy > 0: print("Bounds and integrality for decision variables set. Begining to build constraints...") # --- build the constraints --- @@ -386,7 +385,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.A_eq_1 = np.vstack(rows) self.b_eq_1 = np.zeros(self.A_eq_1.shape[0], dtype=int) - if wordy > 1: + if self.wordy > 1: ''' print("A_eq_1^T:") for i in range(self.Xta_start,self.Xta_end): @@ -406,7 +405,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_list.append(self.A_eq_1) b_eq_list.append(self.b_eq_1) - if wordy > 0: + if self.wordy > 0: print("Constraint 1 built.") # 2) task dependencies must be respected (i.e., a task cannot start until all its dependencies have been satisfied) @@ -590,7 +589,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_2) b_ub_list.append(self.b_ub_2) - if wordy > 1: + if self.wordy > 1: print("Constraint 2 details:") if hasattr(self, 'A_ub_2'): for i, row in enumerate(self.A_ub_2): @@ -636,7 +635,7 @@ def set_up_optimizer(self, goal : str = "cost"): print(f" ... and {remaining} more dependency constraints") break - if wordy > 0: + if self.wordy > 0: print("Constraint 2 built.") # 3) exactly one asset must be assigned to each task @@ -658,7 +657,7 @@ def set_up_optimizer(self, goal : str = "cost"): # set the coefficient for each task t to one self.A_eq_3[t, (self.Xta_start + t * self.A):(self.Xta_start + t * self.A + self.A)] = 1 # Set the coefficients for the Xta variables to 1 for each task t - if wordy > 1: + if self.wordy > 1: ''' print("A_eq_3^T:") print(" T1 T2") # Header for 2 tasks @@ -677,7 +676,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_list.append(self.A_eq_3) b_eq_list.append(self.b_eq_3) - if wordy > 0: + if self.wordy > 0: print("Constraint 3 built.") # 4) Individual asset conflict prevention within asset groups @@ -704,7 +703,7 @@ def set_up_optimizer(self, goal : str = "cost"): rows_4 = [] bounds_4 = [] - if wordy > 1: + if self.wordy > 1: print('Constraint 4 details:') # For each individual asset, create constraints to prevent conflicts @@ -734,7 +733,7 @@ def set_up_optimizer(self, goal : str = "cost"): # Skip constraints that violate constraint 3 (same task, different asset groups) # Constraint 3 already ensures exactly one asset group per task if task1 == task2: - if wordy > 2: + if self.wordy > 2: print(f" Skipping redundant constraint: Task {task1} with groups {ag1} and {ag2} " f"(already prevented by constraint 3)") continue @@ -751,7 +750,7 @@ def set_up_optimizer(self, goal : str = "cost"): rows_4.append(row) bounds_4.append(3) # Sum ≤ 3 prevents all 4 from being 1 simultaneously - if wordy > 1: + if self.wordy > 1: #print(f" Conflict constraint for {individual_asset_name} in period {period_idx}:") print(f" Xta[{task1},{ag1}] + Xta[{task2},{ag2}] + Xtp[{task1},{period_idx}] + Xtp[{task2},{period_idx}] ≤ 3") @@ -765,7 +764,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.b_ub_4 = np.array([], dtype=int) ''' - if wordy > 1: + if self.wordy > 1: print("A_ub_4^T:") print(" P1 P2 P3 P4 P5") # Header for 5 periods for i in range(self.Xap_start,self.Xap_end): @@ -779,7 +778,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_4) b_ub_list.append(self.b_ub_4) - if wordy > 0: + if self.wordy > 0: print("Constraint 4 built.") # 10) A task duration plus the start-time it is assigned to must be less than the total number of time periods available @@ -804,7 +803,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.A_ub_10 = np.vstack(rows) self.b_ub_10 = np.ones(self.A_ub_10.shape[0], dtype=int) # Each infeasible combination: Xta + Xts <= 1 - if wordy > 1: + if self.wordy > 1: ''' print("A_ub_10^T:") print(" T1A1 T1A2 T2A1") # Header for 3 task-asset pairs example with T2A2 invalid @@ -839,7 +838,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_10) b_ub_list.append(self.b_ub_10) - if wordy > 0: + if self.wordy > 0: print("Constraint 10 built.") # 11) The total number of task period pairs must be greater than or equal to the number of task-start time pairs @@ -856,7 +855,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_lb_11[t, (self.Xtp_start + t * self.P):(self.Xtp_start + t * self.P + self.P)] = 1 A_lb_11[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 - if wordy > 1: + if self.wordy > 1: print("A_lb_11^T:") print(" T1 T2") # Header for 2 tasks for i in range(self.Xtp_start,self.Xts_end): @@ -869,7 +868,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_lb_list.append(A_lb_11) b_lb_list.append(b_lb_11) - if wordy > 0: + if self.wordy > 0: print("Constraint 11 built.") """ # 12) The period an asset is assigned to must match the period the task in the task-asset pair is assigned to @@ -926,7 +925,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_lb_list.append(A_lb_12) b_lb_list.append(b_lb_12) - if wordy > 1: + if self.wordy > 1: print("A_12^T:") for i in range(self.Xta_start,self.Xap_end): pstring = str(self.X_indices[i]) @@ -941,7 +940,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_lb_list.append(A_12) b_lb_list.append(b_lb_12) - if wordy > 0: + if self.wordy > 0: print("Constraint 12 built.") """ # 14) if a task-starttime pair is selected, the corresponding task-period pair must be selected for the period equal to the start time plus the duration of the task @@ -1019,7 +1018,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_14b) b_ub_list.append(self.b_ub_14b) - if wordy > 1: + if self.wordy > 1: ''' print("A_lb_14^T:") print(" T1A1S1 T1A2S1 ...") # Header for 3 task-asset pairs example with T2A2 invalid @@ -1073,7 +1072,7 @@ def set_up_optimizer(self, goal : str = "cost"): p = xtp_idx % self.P print(f" Duration enforcement: Xta[{t_ta},{a}] + Xts[{t_ts},{s}] - Xtp[{t_tp},{p}] ≤ 1") - if wordy > 0: + if self.wordy > 0: print("Constraint 14 built.") # 15) the number of task-starttime pairs must be equal to the number of tasks @@ -1094,7 +1093,7 @@ def set_up_optimizer(self, goal : str = "cost"): for t in range(self.T): self.A_eq_15[t, (self.Xts_start + t * self.S):(self.Xts_start + t * self.S + self.S)] = 1 - if wordy > 1: + if self.wordy > 1: ''' print("A_eq_15^T:") for i in range(self.Xts_start,self.Xts_end): @@ -1112,7 +1111,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_list.append(self.A_eq_15) b_eq_list.append(self.b_eq_15) - if wordy > 0: + if self.wordy > 0: print("Constraint 15 built.") # 16) Each task must be active for exactly the duration of its assigned asset @@ -1147,7 +1146,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_eq_list.append(self.A_eq_16) b_eq_list.append(self.b_eq_16) - if wordy > 1: + if self.wordy > 1: print("Constraint 16 details:") for t in range(self.T): period_vars = [f"Xtp[{t},{p}]" for p in range(self.P)] @@ -1159,7 +1158,7 @@ def set_up_optimizer(self, goal : str = "cost"): if asset_terms: print(f" Task {t} duration: {' + '.join(period_vars)} = {' + '.join(asset_terms)}") - if wordy > 0: + if self.wordy > 0: print("Constraint 16 built.") # 17) Weather constraints: task-asset pairs cannot be assigned in periods with incompatible weather @@ -1214,7 +1213,7 @@ def set_up_optimizer(self, goal : str = "cost"): A_ub_list.append(self.A_ub_17) b_ub_list.append(self.b_ub_17) - if wordy > 1: + if self.wordy > 1: print("Constraint 17 details:") for i, row in enumerate(self.A_ub_17): xta_indices = np.where(row[self.Xta_start:self.Xta_start + self.T * self.A] == 1)[0] @@ -1240,16 +1239,16 @@ def set_up_optimizer(self, goal : str = "cost"): print(f" ... and {remaining} more weather constraints") break - if wordy > 0: + if self.wordy > 0: print(f"Constraint 17 built with {len(rows_17)} weather restrictions.") else: - if wordy > 0: + if self.wordy > 0: print("Constraint 17 built (no weather restrictions needed).") # --- End Constraints --- - if wordy > 0: + if self.wordy > 0: print("All constraints built. Stacking and checking constraints...") # --- Assemble the SciPy Constraints --- @@ -1295,7 +1294,7 @@ def set_up_optimizer(self, goal : str = "cost"): else: self.num_lb_constraints = 0 - if wordy > 0: + if self.wordy > 0: print(f"Final constraint matrices built with {self.num_ub_constraints} upperbound constraints, {self.num_eq_constraints} equality constraints, and {self.num_lb_constraints} lowerbound constraints.") # Build constraint objects if they exist @@ -1313,7 +1312,7 @@ def set_up_optimizer(self, goal : str = "cost"): self.integrality = integrality self.bounds = bounds - if wordy > 0: + if self.wordy > 0: print("Optimizer set up complete.") def optimize(self, threads = -1): @@ -1335,7 +1334,7 @@ def optimize(self, threads = -1): if not hasattr(self, 'values') or not hasattr(self, 'constraints') or not hasattr(self, 'integrality') or not hasattr(self, 'bounds'): self.set_up_optimizer() - if wordy > 0: + if self.wordy > 0: print("Starting optimization...") # --- Check for valid inputs --- @@ -1356,7 +1355,7 @@ def optimize(self, threads = -1): bounds=self.bounds ) - if wordy > 0: + if self.wordy > 0: print("Solver complete. Analyzing results...") print("Results: \n", res) @@ -1364,12 +1363,12 @@ def optimize(self, threads = -1): if res.success: # Reshape the flat result back into the (num_periods, num_tasks, num_assets) shape - if wordy > 5: + if self.wordy > 5: print("Decision variable [periods][tasks][assets]:") for i in range(len(self.X_indices)): print(f" {self.X_indices[i]}: {int(res.x[i])}") - if wordy > 0: + if self.wordy > 0: print("Optimization successful. The following schedule was generated:") x_opt = res.x # or whatever your result object is @@ -1377,7 +1376,7 @@ def optimize(self, threads = -1): Xtp = x_opt[self.Xtp_start:self.Xtp_end].reshape((self.T, self.P)) #Xap = x_opt[self.Xap_start:self.Xap_end].reshape((self.A, self.P)) Xts = x_opt[self.Xts_start:self.Xts_end].reshape((self.T, self.S)) - + for p in range(self.P): weather_condition = self.weather[p] pstring = f"Period {p:2d} (weather {weather_condition:2d}): " @@ -1423,8 +1422,65 @@ def optimize(self, threads = -1): pstring += f"{'':55} | " print(pstring) + + # NEW: Compact Gantt-style visualization + if self.wordy > 0: + print("\n" + "="*80) + print("GANTT CHART VIEW (Tasks as rows, Periods as columns)") + print("="*80) + + # Header row with period numbers + header = "Task Name |" + for p in range(self.P): + header += f"{p%10}" + header += "| Asset Group" + print(header) + print("-" * len(header)) + + # Each task gets a row + for t in range(self.T): + task_name = self.tasks[t] if t < len(self.tasks) else f"Task{t}" + row = f"{task_name:<20}|" + + # Find which asset group is assigned to this task + a_assigned = np.argmax(Xta[t, :]) + + for p in range(self.P): + if Xtp[t, p] > 0: + # Use a character to indicate this task is active + row += "█" + else: + row += " " + row += "|" + + # Add asset group information at the end of the row + asset_group = self.asset_groups[a_assigned] + if isinstance(asset_group, dict): + group_names = list(asset_group.keys()) + if group_names: + group_name = group_names[0] + asset_list = asset_group[group_name] + if isinstance(asset_list, list): + row += f" {group_name}: {', '.join(asset_list)}" + else: + row += f" {group_name}" + else: + row += f" Group {a_assigned}" + else: + row += f" Group {a_assigned}" + + print(row) + + # Footer with weather conditions + weather_row = "Weather |" + for p in range(self.P): + weather_row += f"{self.weather[p]%10}" + weather_row += "|" + print("-" * len(header)) + print(weather_row) + print("="*80 + "\n") - if wordy > 0: + if self.wordy > 0: print("Optimization function complete.") diff --git a/famodel/irma/task.py b/famodel/irma/task.py index cce18556..8c66a2f9 100644 --- a/famodel/irma/task.py +++ b/famodel/irma/task.py @@ -847,7 +847,7 @@ def level_of(a: str, b: set[str]) -> int: return H - def calcDuration(self): + def calcDuration(self, duration_interval=0.5): '''Organizes the actions to be done by this task into the proper order based on the action_sequence. This is used to fill out self.actions_ti, self.ti, and self.tf. This method assumes that action.duration @@ -872,8 +872,9 @@ def calcDuration(self): # Update self.actions_ti with relative start times self.actions_ti = starts - # Task duration - self.duration = max(finishes.values()) + # Task duration (rounded to nearest interval) + raw_duration = max(finishes.values()) + self.duration = np.round(raw_duration / duration_interval) * duration_interval # Update task finish time self.tf = self.ti + self.duration