"""
This is a wrapper for a networkx workflow graph.
It adds some convenient functionalities
"""
import traceback
import networkx as nx
import yaml
from laborchestrator.logging_manager import StandardLogger as Logger
from laborchestrator.structures import Schedule, ScheduledAssignment, ProcessStep, SMProcess
from datetime import datetime
from typing import Optional, List, Dict, NamedTuple, Iterable
from pydantic import BaseModel, ConfigDict
[docs]
class Resource(NamedTuple):
Type: str
Tag: str
Preferred: str
[docs]
class Node(NamedTuple):
Idx: str
Duration: float
RequiredResources: List[Resource] # f.e. [('StorageResource', 'origin', 'Carousel'),
# ('MoverResource', 'main', 'Mover'),
# ('IncubationResource', 'target', 'Incubator2')]
StartTime: str
Finish: str
WaitToStartCost: Optional[float] = None
Labware: list[str] = []
[docs]
class Edge(NamedTuple):
Head: str
Tail: str
WaitCost: Optional[float] = None
MaxWaitingTime: Optional[float] = None
MinWaitingTime: Optional[float] = None
[docs]
class Graph(NamedTuple):
Nodes: List[Node]
Edges: List[Edge]
[docs]
class _DeviceConfig(BaseModel):
capacity: int
min_capacity: Optional[int] = None
process_capacity: Optional[int] = None
allows_overlap: Optional[bool] = None
model_config = ConfigDict(extra="ignore")
[docs]
class _LabConfig(BaseModel):
sila_servers: Dict[str, Dict[str, _DeviceConfig]]
pythonlab_translation: Dict[str, str]
model_config = ConfigDict(extra="ignore")
[docs]
def parse_job_shop_config(config_file: str) -> list[dict[str, object]]:
"""
Parses the information about available devices from a lab config yaml into a list of dictionaries .
"""
with open(config_file, "r") as handle:
raw_config = yaml.safe_load(handle)
config = _LabConfig.model_validate(raw_config)
job_shop: list[dict[str, object]] = []
for device_type, devices in config.sila_servers.items():
if device_type not in config.pythonlab_translation:
raise ValueError(f"Missing pythonlab_translation for {device_type}")
type_name = config.pythonlab_translation[device_type]
for device_name, params in devices.items():
max_capacity = params.capacity
if max_capacity <= 0:
raise ValueError(f"capacity must be > 0 for {device_name}")
min_capacity = params.min_capacity if params.min_capacity is not None else 1
process_capacity = params.process_capacity if params.process_capacity is not None else max_capacity
allows_overlap = params.allows_overlap if params.allows_overlap is not None else True
job_shop.append(
{
"Name": device_name,
"Type": type_name,
"MaxCapacity": max_capacity,
"ProcessingCapacity": process_capacity,
"MinCapacity": min_capacity,
"AllowsOverlap": allows_overlap,
}
)
return job_shop
[docs]
class Assignment:
start: datetime
machine_assignments: Dict[str, str] # f.e. {origin: Carousel, main: Mover, target: Incubator1}
machine_prior: List[str] # additional precedence constraints
[docs]
def create_sila_structure_from_jobs(jobs: Iterable[ProcessStep], wfg: nx.DiGraph):
g = Graph([], [])
job_by_name = {j.name: j for j in jobs}
for j in jobs:
requirements = []
for d in j.used_devices:
try:
type_str = d.device_type.__name__
except Exception as ex:
type_str = str(d.device_type)
# if the step is finished no other device should be scheduled on it
if j.start:
preference = d.name
else:
preference = d.preferred
requirements.append(
Resource(Type=type_str, Tag=d.tag, Preferred=str(preference))
)
g.Nodes.append(Node(j.name, j.duration, requirements, str(j.start), str(j.finish), j.wait_to_start_costs, j.cont_names))
connected = set() # avoid double edges
for prior in j.prior:
priors = [prior]
# we also add all edges of precedences that are induced by non-step-nodes
# therefore search for all steps that are connect that way
while priors:
p = priors.pop()
if p in job_by_name:
if p not in connected:
min_wait = j.min_wait[prior] if prior in j.min_wait else None
g.Edges.append(Edge(j.name, p, j.wait_cost[prior], j.max_wait[prior], min_wait))
connected.add(p)
else:
priors.extend([idx for idx in wfg.predecessors(p)
# being connected through steps that might never happen does not count
# if not (idx in job_by_name and job_by_name[idx].opacity < 1)])
if not wfg.nodes[idx]['opacity'] < 1])
return g
[docs]
def add_waiting_dummies(g: Graph, processes: list[SMProcess]):
dummy_machine = Resource("Dummy", "main", "DummyDump")
for process in processes:
if not process.min_start or process.min_start < datetime.now():
continue
waiting_left = process.min_start - datetime.now()
# create a dummy_task
dummy = Node(Idx=f"dummy_{process.name}", Duration=10, RequiredResources=[dummy_machine],
StartTime="None", Finish="None", Labware=[])
g.Nodes.append(dummy)
# add waiting constraints to this dummy task
for start_step in process.starting_nodes:
dummy_edge = Edge(Head=start_step.name, Tail=dummy.Idx, MinWaitingTime=waiting_left.total_seconds(),
MaxWaitingTime=float('inf'))
g.Edges.append(dummy_edge)
Logger.info(f"Process {process.name} has {waiting_left} to wait")
[docs]
def create_schedule_from_sila_struct(schedule_response) -> Schedule:
try:
schedule = Schedule()
for elem in schedule_response:
assign = ScheduledAssignment(
# remove the timezone info. it is not needed for anything, except for the sila standard
start=elem.StartTime.replace(tzinfo=None),
participating={m.Tag: m.MachineName for m in elem.AssignedMachines},
machine_prior=elem.MachinePrecedences,
)
if "dummy" in elem.ProcessStepId:
Logger.debug(f"removing {elem.ProcessStepId} from schedule")
continue
schedule[elem.ProcessStepId] = assign
return schedule
except Exception as ex:
Logger.error(f"Could not retrieve schedule from response: {ex}\n{traceback.format_exc()}")