""" Validation of configs and tutorial/guide creation for recipes. """ import os import warnings from typing import TypedDict, Tuple, Iterator, Set import networkx as nx from networkx import DiGraph from cooperative_cuisine import ROOT_DIR 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): """Represents a graph for meal creation with edges and layout information.""" meal: str """The name of the meal.""" edges: list[Tuple[str, str]] """A list of tuples representing the edges between cooking steps.""" layout: dict[str, Tuple[float, float]] """A dictionary mapping cooking step names to their layout coordinates.""" class Validation: """Class for performing validation tasks. This class provides methods for performing various validation tasks, such as generating recipe graphs from item information, creating guide graphs for each recipe/meal, asserting the presence of equipment and plate cycles, and retrieving base ingredients and equipment required to create a meal. """ def __init__( self, meals: list[ItemInfo], item_info: dict[str, ItemInfo], order_manager: OrderManager, do_validation: bool, ): self.meals: list[ItemInfo] = meals """A list of ItemInfo objects representing meals.""" self.item_info: dict[str, ItemInfo] = item_info """All ItemInfos from the config.""" self.order_manager: OrderManager = order_manager """For the available meals for orders.""" self.do_validation: bool = do_validation """A boolean indicating whether to perform validation tasks.""" @staticmethod def infer_recipe_graph(item_info) -> DiGraph: """Generate a graph from ingredients and meals and their dependencies. Args: item_info: A dictionary containing information about items. Returns: DiGraph: A directed graph representing the recipe graph. """ 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) -> MealGraphDict: """Create tutorial/guide graphs for each recipe/meal. Args: meal: An instance of ItemInfo representing the meal to create a graph for. Returns: A dictionary containing the meal name, the edges of the graph, and a layout of the graph. """ graph = DiGraph(directed=True, rankdir="LR") root = f"{meal.name}_0" graph.add_node(root) add_queue = [root] # Add "Plate_0" if dishwashing should be part of the recipe start = True while add_queue: current = add_queue.pop() 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 if current_info.needs: if len(current_info.needs) == 1: need = f"{current_info.needs[0]}_{current_index}" add_queue.append(need) if current_info.equipment: equip_id = f"{current_info.equipment.name}_{current_index}" if current_info.equipment.equipment: equip_equip_id = f"{current_info.equipment.equipment.name}_{current_index}" graph.add_edge(equip_equip_id, current) graph.add_edge(equip_id, equip_equip_id) graph.add_edge(need, equip_id) else: graph.add_edge(equip_id, current) graph.add_edge(need, equip_id) else: graph.add_edge(need, current) elif len(current_info.needs) > 1: for idx, item_name in enumerate(current_info.needs): add_queue.append(f"{item_name}_{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}" graph.add_edge(equip_equip_id, current) graph.add_edge(equip_id, equip_equip_id) graph.add_edge(f"{item_name}_{idx}", equip_id) else: graph.add_edge( f"{item_name}_{idx}", current, ) return { "meal": meal.name, "edges": list(graph.edges), "layout": nx.nx_agraph.graphviz_layout(graph, prog="dot"), } def reduce_item_node(self, graph, base_ingredients, item, visited): # until now not called 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): # 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]): # 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): # 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): # until now not called 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): # until now not called 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 @staticmethod def get_layout_requirements(counters: list[Counter]): """Infer layout requirements from a list of counters. Args: counters: A list of Counter objects representing various counters in a layout. Returns: layout_requirements: A set of layout requirements based on the given counters. This static method takes a list of Counter objects as input and returns a set of layout requirements. The layout requirements are determined based on the type of counters and their properties. The method iterates over each counter in the counters list. For each counter, it checks its type using the isinstance() function and performs the required actions: - If the counter is an instance of Dispenser or PlateDispenser, it adds the counter's dispensing name to the layout_requirements set. - If the counter is an instance of CuttingBoard, it adds "CuttingBoard" to the layout_requirements set. - If the counter is an instance of CookingCounter, it adds the counter's name to the layout_requirements set. - If the counter's occupied_by property is not None and the occupied_by object has a "name" attribute, it adds the occupied_by object's name to the layout_requirements set. Finally, the method returns the layout_requirements set. Note: The method uses type checking and attribute checking to determine the layout requirements based on the counters provided. Example usage: ```python counters = [counter1, counter2, counter3] requirements = MyClass.get_layout_requirements(counters) print(requirements) ``` """ 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]) -> Set[str]: """Validates the environment by generating and saving a recipe graph, checking if ordered meals are possible, and returning a set of meals that can be ordered. Args: counters (list[Counter]): A list of counters. Returns: meals_to_be_ordered (set): A set of meals that can be ordered, based on the environment validation. """ if self.do_validation: 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}" ) meals_to_be_ordered = possible_meals.intersection(defined_meals) return meals_to_be_ordered else: return {m.name for m in self.meals} def get_recipe_graphs(self) -> list[MealGraphDict]: """Returns a list of recipe graphs for all available meals. If there are no available meals, an empty list is returned. The recipe graphs are generated and stored in the "generated" directory in the root directory (defined as ROOT_DIR) of the application. If the directory does not exist, it will be created. The recipe graphs are obtained by calling the `get_meal_graph` method for each available meal in the `order_manager`. The results are collected and returned as a list. Returns: list[MealGraphDict]: A list of recipe graphs for all available meals. """ if not self.order_manager.available_meals: return [] os.makedirs(ROOT_DIR / "generated", exist_ok=True) return [ self.get_meal_graph(m) for m in self.order_manager.available_meals.values() ] # # 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