diff --git a/cooperative_cuisine/environment.py b/cooperative_cuisine/environment.py index 28036d32034f1a82155fc8c93fe09639ab562f3d..5410e5e4355a3d29fa14bc4e7b8fca79ace95004 100644 --- a/cooperative_cuisine/environment.py +++ b/cooperative_cuisine/environment.py @@ -4,7 +4,6 @@ import inspect import json import logging import sys -import warnings from collections import defaultdict from datetime import timedelta, datetime from pathlib import Path @@ -21,9 +20,6 @@ from cooperative_cuisine.counter_factory import ( ) from cooperative_cuisine.counters import ( PlateConfig, - PlateDispenser, - CuttingBoard, - CookingCounter, ) from cooperative_cuisine.effects import EffectManager from cooperative_cuisine.hooks import ( @@ -220,8 +216,19 @@ class Environment: """Counters that needs to be called in the step function via the `progress` method.""" self.overwrite_counters(self.counters) - # TODO Maybe validation can be turned off in config... - meals_to_be_ordered = self.validate_environment() + self.recipe_validation = RecipeValidation( + meals=[m for m in self.item_info.values() if m.type == ItemType.Meal] + if self.environment_config["meals"]["all"] + else [ + self.item_info[m] + for m in self.environment_config["meals"]["list"] + if self.item_info[m].type == ItemType.Meal + ], + item_info=self.item_info, + order_manager=self.order_manager, + ) + + meals_to_be_ordered = self.recipe_validation.validate_environment() assert meals_to_be_ordered, "Need possible meals for order generation." available_meals = {meal: self.item_info[meal] for meal in meals_to_be_ordered} @@ -241,17 +248,6 @@ class Environment: self.info_msgs_per_player: dict[str, list[InfoMsg]] = defaultdict(list) - self.recipe_validation = RecipeValidation( - meals=[m for m in self.item_info.values() if m.type == ItemType.Meal] - if self.environment_config["meals"]["all"] - else [ - self.item_info[m] - for m in self.environment_config["meals"]["list"] - if self.item_info[m].type == ItemType.Meal - ], - item_info=self.item_info, - ) - self.hook( ENV_INITIALIZED, environment_config=env_config, diff --git a/cooperative_cuisine/recipes.py b/cooperative_cuisine/recipes.py index dbd5d1c33bdb5db5b88a5d72b362a712e5a7dfef..cdf34b259801011beff9492b3dfb58a60f72c65f 100644 --- a/cooperative_cuisine/recipes.py +++ b/cooperative_cuisine/recipes.py @@ -1,12 +1,21 @@ import os +import warnings from concurrent.futures import ThreadPoolExecutor -from typing import TypedDict, Tuple +from typing import TypedDict, Tuple, Iterator import networkx as nx -from networkx import DiGraph +from networkx import DiGraph, Graph from cooperative_cuisine import ROOT_DIR -from cooperative_cuisine.items import ItemInfo +from cooperative_cuisine.counters import ( + Dispenser, + CuttingBoard, + CookingCounter, + PlateDispenser, + Counter, +) +from cooperative_cuisine.items import ItemInfo, ItemType, Item +from cooperative_cuisine.orders import OrderManager class MealGraphDict(TypedDict): @@ -16,43 +25,67 @@ class MealGraphDict(TypedDict): class RecipeValidation: - def __init__(self, meals, item_info): + def __init__(self, meals, item_info, order_manager): self.meals: list[ItemInfo] = meals self.item_info: dict[str, ItemInfo] = item_info + self.order_manager: OrderManager = order_manager - def get_meal_graph(self, meal: ItemInfo) -> MealGraphDict: - graph = DiGraph( - directed=True, rankdir="LR", graph_attr={"nslimit": "0", "nslimit1": "2"} - ) + @staticmethod + def infer_recipe_graph(item_info) -> DiGraph: + colors = { + ItemType.Ingredient: "black", + ItemType.Equipment: "red", + ItemType.Meal: "green", + ItemType.Waste: "brown", + } + + graph = DiGraph(directed=True) + for item_name, item_info in item_info.items(): + graph.add_node(item_name, color=colors.get(item_info.type, "blue")) + if item_info.equipment is None: + for item in item_info.needs: + graph.add_edge(item, item_name) + else: + if len(item_info.needs) > 0: + for item in item_info.needs: + graph.add_edge(item, item_info.equipment.name) + graph.add_edge(item_info.equipment.name, item_name) + else: + graph.add_edge(item_name, item_info.equipment.name) + return graph + + def get_meal_graph(self, meal: ItemInfo) -> tuple[Graph, dict[str, list[float]]]: + graph = DiGraph(directed=True, rankdir="LR") - root = f"{meal.name}_0" + root = meal.name + "_0" graph.add_node(root) - add_queue = [root] + add_queue = ["Plate_0", root] start = True while add_queue: current = add_queue.pop() - current_info, current_index = current.rsplit("_", maxsplit=1) - current_info = self.item_info[current_info] + current_info = self.item_info[current.split("_")[0]] + current_index = current.split("_")[-1] if start: graph.add_edge("Plate_0", current) current = "Plate_0" start = False - # maybe reduce? double code fragments if current_info.needs: if len(current_info.needs) == 1: - need = f"{current_info.needs[0]}_{current_index}" + need = current_info.needs[0] + f"_{current_index}" add_queue.append(need) if current_info.equipment: - equip_id = f"{current_info.equipment.name}_{current_index}" + equip_id = current_info.equipment.name + f"_{current_index}" if current_info.equipment.equipment: - equip_equip_id = f"{current_info.equipment.equipment.name}_{current_index}" - + equip_equip_id = ( + current_info.equipment.equipment.name + + f"_{current_index}" + ) graph.add_edge(equip_equip_id, current) graph.add_edge(equip_id, equip_equip_id) graph.add_edge(need, equip_id) @@ -64,30 +97,194 @@ class RecipeValidation: elif len(current_info.needs) > 1: for idx, item_name in enumerate(current_info.needs): - need = f"{item_name}_{idx}" - add_queue.append(need) + add_queue.append(item_name + f"_{idx}") if current_info.equipment and current_info.equipment.equipment: - equip_id = f"{current_info.equipment.name}_{current_index}" - equip_equip_id = f"{current_info.equipment.equipment.name}_{current_index}" + equip_id = current_info.equipment.name + f"_{current_index}" + equip_equip_id = ( + current_info.equipment.equipment.name + + f"_{current_index}" + ) graph.add_edge(equip_equip_id, current) graph.add_edge(equip_id, equip_equip_id) - graph.add_edge(need, equip_id) + graph.add_edge(item_name + f"_{idx}", equip_id) else: - graph.add_edge(need, current) - return { - "meal": meal.name, - "edges": list(graph.edges), - "layout": nx.nx_agraph.graphviz_layout(graph, prog="dot"), - } + graph.add_edge( + item_name + f"_{idx}", + current, + ) + + agraph = nx.nx_agraph.to_agraph(graph) + layout = nx.nx_agraph.graphviz_layout(graph, prog="dot") + agraph.draw( + ROOT_DIR / "generated" / f"recipe_graph_{meal.name}.png", + format="png", + prog="dot", + ) + + return graph, layout + + def reduce_item_node(self, graph, base_ingredients, item, visited): + visited.append(item) + if item in base_ingredients: + return True + else: + return all( + self.reduce_item_node(graph, base_ingredients, pred, visited) + for pred in graph.predecessors(item) + if pred not in visited + ) + + def assert_equipment_is_present(self, counters): + # TODO until now not called + expected = set( + name + for name, info in self.item_info.items() + if info.type == ItemType.Equipment and "Plate" not in info.name + ) + counters = set(c.__class__.__name__ for c in counters).union( + set(c.name for c in counters if hasattr(c, "name")) + ) + items = set( + c.occupied_by.name + for c in counters + if c.occupied_by is not None and isinstance(c.occupied_by, Item) + ) + for equipment in expected: + if equipment not in counters and equipment not in items: + raise ValueError( + f"Equipment '{equipment}' from config files not found in the environment layout.\n" + f"Config Equipment: {sorted(expected)}\n" + f"Layout Counters: {sorted(counters)}\n" + f"Layout Items: {sorted(items)}" + ) + + def assert_plate_cycle_present(self, counters: list[Counter]): + # TODO until now not called + for plate in ["Plate", "DirtyPlate"]: + if plate not in self.item_info: + raise ValueError(f"{plate} not found in item info") + + relevant_counters = ["PlateDispenser", "ServingWindow"] + for counter in counters: + if isinstance(counter, PlateDispenser): + if counter.plate_config.return_dirty: + relevant_counters = [ + "PlateDispenser", + "ServingWindow", + "Sink", + "SinkAddon", + ] + + counter_names = [c.__class__.__name__ for c in counters] + for counter in relevant_counters: + if counter not in counter_names: + raise ValueError(f"{counter} not found in counters") + + @staticmethod + def assert_no_orphans(graph: DiGraph): + # TODO until now not called + orphans = [ + n + for n in graph.nodes() + if graph.in_degree(n) == 0 and graph.out_degree(n) == 0 + ] + if orphans: + raise ValueError( + f"Expected all items to be part of a recipe, but found orphans: {orphans}" + ) + + @staticmethod + def assert_roots_are_dispensable(graph, base_ingredients): + root_nodes = [ + n for n in graph.nodes() if graph.in_degree(n) == 0 and "Plate" not in n + ] + if set(root_nodes) != set(base_ingredients): + raise ValueError( + f"Expected root nodes in the recipe graph and dispensable items to be identical, but found\n " + f"Root nodes: {sorted(root_nodes)}\n" + f"Dispensable items: {sorted(base_ingredients)}" + ) + + def assert_meals_are_reducible(self, graph, base_ingredients): + meals = [n for n in graph.nodes() if self.item_info[n].type == ItemType.Meal] + + for meal in meals: + visited = [] + if not self.reduce_item_node(graph, base_ingredients, meal, visited): + raise ValueError( + f"Meal '{meal}' can not be reduced to base ingredients" + ) + + def get_requirements(self, item_name: str) -> Iterator[str]: + """ + Get all base ingredients and equipment required to create the given meal. + """ + item = self.item_info[item_name] + is_equipment = item.type == ItemType.Equipment + is_base_ingredient = item.type == ItemType.Ingredient and not item.needs + + if is_equipment or is_base_ingredient: + yield item_name + for need in item.needs: + yield from self.get_requirements(need) + if item.equipment is not None: + yield from self.get_requirements(item.equipment.name) + + def get_item_info_requirements(self) -> dict[str, set[str]]: + recipes = {} + for item_name, item_info in self.item_info.items(): + if item_info.type == ItemType.Meal: + requirements = set(r for r in self.get_requirements(item_name)) + recipes[item_name] = requirements | {"Plate"} + return recipes + + def get_layout_requirements(self, counters: list[Counter]): + layout_requirements = set() + for counter in counters: + if isinstance(counter, (Dispenser, PlateDispenser)): + layout_requirements.add(counter.dispensing.name) + if isinstance(counter, CuttingBoard): + layout_requirements.add("CuttingBoard") + if isinstance(counter, CookingCounter): + layout_requirements.add(counter.name) + if counter.occupied_by is not None and hasattr(counter.occupied_by, "name"): + layout_requirements.add(counter.occupied_by.name) + return layout_requirements + + def validate_environment(self, counters: list[Counter]): + graph = self.infer_recipe_graph(self.item_info) + os.makedirs(ROOT_DIR / "generated", exist_ok=True) + nx.nx_agraph.to_agraph(graph).draw( + ROOT_DIR / "generated" / "recipe_graph.png", format="png", prog="dot" + ) + + expected = self.get_item_info_requirements() + present = self.get_layout_requirements(counters) + possible_meals = set(meal for meal in expected if expected[meal] <= present) + defined_meals = set(map(lambda i: i.name, self.meals)) + + # print(f"Ordered meals: {defined_meals}, Possible meals: {possible_meals}") + if len(defined_meals - possible_meals) > 0: + warnings.warn( + f"Ordered meals are not possible: {defined_meals - possible_meals}" + ) - def validate_item_info(self): - """TODO""" - raise NotImplementedError + meals_to_be_ordered = possible_meals.intersection(defined_meals) + return meals_to_be_ordered + # print("FINAL MEALS:", meals_to_be_ordered) - def get_recipe_graphs(self) -> list[MealGraphDict]: + def get_recipe_graphs(self) -> list: os.makedirs(ROOT_DIR / "generated", exist_ok=True) - with ThreadPoolExecutor(max_workers=len(self.meals)) as executor: - graph_dicts = list(executor.map(self.get_meal_graph, self.meals)) + # time_start = time.time() + with ThreadPoolExecutor( + max_workers=len(self.order_manager.available_meals) + ) as executor: + graph_dicts = list( + executor.map( + self.get_meal_graph, self.order_manager.available_meals.values() + ) + ) + # print("DURATION", time.time() - time_start) return graph_dicts