diff --git a/backend/app/algorithms/__init__.py b/backend/app/algorithms/__init__.py index 87da80414f1ccff549e65b84679eb7cb6c622eef..13baecc637c3b224e87aaaf010f2bd44cd33d6cc 100644 --- a/backend/app/algorithms/__init__.py +++ b/backend/app/algorithms/__init__.py @@ -1,22 +1,17 @@ """Search algorithms package.""" -from .bfs import bfs_search, bfs_search_generator -from .dfs import dfs_search, dfs_search_generator -from .ids import ids_search, ids_search_generator -from .ucs import ucs_search, ucs_search_generator -from .greedy import greedy_search, greedy_search_generator -from .astar import astar_search, astar_search_generator + +from .bfs import bfs_search +from .dfs import dfs_search +from .ids import ids_search +from .ucs import ucs_search +from .greedy import greedy_search +from .astar import astar_search __all__ = [ "bfs_search", - "bfs_search_generator", "dfs_search", - "dfs_search_generator", "ids_search", - "ids_search_generator", "ucs_search", - "ucs_search_generator", "greedy_search", - "greedy_search_generator", "astar_search", - "astar_search_generator", ] diff --git a/backend/app/algorithms/astar.py b/backend/app/algorithms/astar.py index b78be450646b7cc9564193aef8a9ca1678945fdc..b3b928c35041538cfffc19a18f4d882c902b5076 100644 --- a/backend/app/algorithms/astar.py +++ b/backend/app/algorithms/astar.py @@ -1,5 +1,6 @@ """A* Search algorithm.""" -from typing import Tuple, Optional, List, Generator, Callable, TYPE_CHECKING + +from typing import Tuple, Optional, List, Callable, TYPE_CHECKING if TYPE_CHECKING: from ..core.generic_search import GenericSearch @@ -10,9 +11,9 @@ from ..models.state import PathResult, SearchStep def astar_search( - problem: 'GenericSearch', + problem: "GenericSearch", heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float], - visualize: bool = False + visualize: bool = False, ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ A* search using f(n) = g(n) + h(n). @@ -32,7 +33,7 @@ def astar_search( start = problem.initial_state() # Get goal for heuristic calculation - goal = getattr(problem, 'goal', None) + goal = getattr(problem, "goal", None) h_value = heuristic(start, goal) if goal else 0 f_value = 0 + h_value # g(n) + h(n) @@ -48,24 +49,29 @@ def astar_search( # Record step for visualization if visualize: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=frontier.get_states(), + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + steps, + ) # Skip if already explored if node.state in explored: @@ -90,97 +96,12 @@ def astar_search( action=action, path_cost=g_value, depth=node.depth + 1, - priority=f_value # Priority = f(n) = g(n) + h(n) + priority=f_value, # Priority = f(n) = g(n) + h(n) ) frontier.push(child) # No solution found - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] - ), steps - - -def astar_search_generator( - problem: 'GenericSearch', - heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float] -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of A* search that yields steps during execution. - - Args: - problem: The search problem to solve - heuristic: Heuristic function - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - frontier = PriorityQueueFrontier() - start = problem.initial_state() - goal = getattr(problem, 'goal', None) - - h_value = heuristic(start, goal) if goal else 0 - f_value = 0 + h_value - start_node = SearchNode(state=start, path_cost=0, depth=0, priority=f_value) - frontier.push(start_node) - - explored: set = set() - nodes_expanded = 0 - - while not frontier.is_empty(): - node = frontier.pop() - - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ) - - if node.state in explored: - continue - - explored.add(node.state) - nodes_expanded += 1 - - for action in problem.actions(node.state): - child_state = problem.result(node.state, action) - - if child_state not in explored: - step_cost = problem.step_cost(node.state, action, child_state) - g_value = node.path_cost + step_cost - h_value = heuristic(child_state, goal) if goal else 0 - f_value = g_value + h_value - - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=g_value, - depth=node.depth + 1, - priority=f_value - ) - frontier.push(child) - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]), + steps, ) diff --git a/backend/app/algorithms/bfs.py b/backend/app/algorithms/bfs.py index 91600f6cebd0aa1d3b847f6f0aa75b052d256e43..181a75c5c722369000b69123bcaad99b1b8509a4 100644 --- a/backend/app/algorithms/bfs.py +++ b/backend/app/algorithms/bfs.py @@ -1,5 +1,6 @@ """Breadth-First Search algorithm.""" -from typing import Tuple, Optional, List, Generator, TYPE_CHECKING + +from typing import Tuple, Optional, List, TYPE_CHECKING if TYPE_CHECKING: from ..core.generic_search import GenericSearch @@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep def bfs_search( - problem: 'GenericSearch', - visualize: bool = False + problem: "GenericSearch", visualize: bool = False ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Breadth-first search using FIFO queue. @@ -40,24 +40,29 @@ def bfs_search( # Record step for visualization if visualize: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=frontier.get_states(), + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test after pop (standard BFS) if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + steps, + ) # Skip if already explored if node.state in explored: @@ -76,85 +81,12 @@ def bfs_search( parent=node, action=action, path_cost=node.path_cost + step_cost, - depth=node.depth + 1 + depth=node.depth + 1, ) frontier.push(child) # No solution found - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] - ), steps - - -def bfs_search_generator( - problem: 'GenericSearch' -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of BFS that yields steps during execution. - - Args: - problem: The search problem to solve - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - frontier = QueueFrontier() - start = problem.initial_state() - start_node = SearchNode(state=start, path_cost=0, depth=0) - frontier.push(start_node) - - explored: set = set() - nodes_expanded = 0 - - while not frontier.is_empty(): - node = frontier.pop() - - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ) - - if node.state in explored: - continue - - explored.add(node.state) - nodes_expanded += 1 - - for action in problem.actions(node.state): - child_state = problem.result(node.state, action) - if child_state not in explored and not frontier.contains_state(child_state): - step_cost = problem.step_cost(node.state, action, child_state) - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=node.path_cost + step_cost, - depth=node.depth + 1 - ) - frontier.push(child) - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]), + steps, ) diff --git a/backend/app/algorithms/dfs.py b/backend/app/algorithms/dfs.py index 5d79e705f5cc73c19b944e586cc19925ab1aaf50..1720da9ccb2127855a741ab8956000abbedfce29 100644 --- a/backend/app/algorithms/dfs.py +++ b/backend/app/algorithms/dfs.py @@ -1,5 +1,6 @@ """Depth-First Search algorithm.""" -from typing import Tuple, Optional, List, Generator, TYPE_CHECKING + +from typing import Tuple, Optional, List, TYPE_CHECKING if TYPE_CHECKING: from ..core.generic_search import GenericSearch @@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep def dfs_search( - problem: 'GenericSearch', - visualize: bool = False + problem: "GenericSearch", visualize: bool = False ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Depth-first search using LIFO stack. @@ -40,24 +40,29 @@ def dfs_search( # Record step for visualization if visualize: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=frontier.get_states(), + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + steps, + ) # Skip if already explored if node.state in explored: @@ -77,86 +82,12 @@ def dfs_search( parent=node, action=action, path_cost=node.path_cost + step_cost, - depth=node.depth + 1 + depth=node.depth + 1, ) frontier.push(child) # No solution found - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] - ), steps - - -def dfs_search_generator( - problem: 'GenericSearch' -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of DFS that yields steps during execution. - - Args: - problem: The search problem to solve - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - frontier = StackFrontier() - start = problem.initial_state() - start_node = SearchNode(state=start, path_cost=0, depth=0) - frontier.push(start_node) - - explored: set = set() - nodes_expanded = 0 - - while not frontier.is_empty(): - node = frontier.pop() - - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ) - - if node.state in explored: - continue - - explored.add(node.state) - nodes_expanded += 1 - - actions = problem.actions(node.state) - for action in reversed(actions): - child_state = problem.result(node.state, action) - if child_state not in explored and not frontier.contains_state(child_state): - step_cost = problem.step_cost(node.state, action, child_state) - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=node.path_cost + step_cost, - depth=node.depth + 1 - ) - frontier.push(child) - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]), + steps, ) diff --git a/backend/app/algorithms/greedy.py b/backend/app/algorithms/greedy.py index 6b4bbc50e295b8a45a7721dd80ea36a917746e48..29b50f715d4a0e3f37ddf8ba585b78accd3bf9bc 100644 --- a/backend/app/algorithms/greedy.py +++ b/backend/app/algorithms/greedy.py @@ -1,5 +1,6 @@ """Greedy Best-First Search algorithm.""" -from typing import Tuple, Optional, List, Generator, Callable, TYPE_CHECKING + +from typing import Tuple, Optional, List, Callable, TYPE_CHECKING if TYPE_CHECKING: from ..core.generic_search import GenericSearch @@ -10,9 +11,9 @@ from ..models.state import PathResult, SearchStep def greedy_search( - problem: 'GenericSearch', + problem: "GenericSearch", heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float], - visualize: bool = False + visualize: bool = False, ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Greedy best-first search using heuristic only. @@ -33,7 +34,7 @@ def greedy_search( start = problem.initial_state() # Get goal for heuristic calculation (assume single goal) - goal = getattr(problem, 'goal', None) + goal = getattr(problem, "goal", None) h_value = heuristic(start, goal) if goal else 0 start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value) @@ -48,24 +49,29 @@ def greedy_search( # Record step for visualization if visualize: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=frontier.get_states(), + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + steps, + ) # Skip if already explored if node.state in explored: @@ -88,94 +94,12 @@ def greedy_search( action=action, path_cost=node.path_cost + step_cost, depth=node.depth + 1, - priority=h_value # Priority = h(n) only for Greedy + priority=h_value, # Priority = h(n) only for Greedy ) frontier.push(child) # No solution found - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] - ), steps - - -def greedy_search_generator( - problem: 'GenericSearch', - heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float] -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of Greedy search that yields steps during execution. - - Args: - problem: The search problem to solve - heuristic: Heuristic function - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - frontier = PriorityQueueFrontier() - start = problem.initial_state() - goal = getattr(problem, 'goal', None) - - h_value = heuristic(start, goal) if goal else 0 - start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value) - frontier.push(start_node) - - explored: set = set() - nodes_expanded = 0 - - while not frontier.is_empty(): - node = frontier.pop() - - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ) - - if node.state in explored: - continue - - explored.add(node.state) - nodes_expanded += 1 - - for action in problem.actions(node.state): - child_state = problem.result(node.state, action) - - if child_state not in explored: - step_cost = problem.step_cost(node.state, action, child_state) - h_value = heuristic(child_state, goal) if goal else 0 - - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=node.path_cost + step_cost, - depth=node.depth + 1, - priority=h_value - ) - frontier.push(child) - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]), + steps, ) diff --git a/backend/app/algorithms/ids.py b/backend/app/algorithms/ids.py index fd4eb7d75581c4a5e08138cdb0c1766f06475480..65ae7daa7658f7528f9a55c1bc805df179dce2b7 100644 --- a/backend/app/algorithms/ids.py +++ b/backend/app/algorithms/ids.py @@ -1,5 +1,6 @@ """Iterative Deepening Search algorithm.""" -from typing import Tuple, Optional, List, Generator, TYPE_CHECKING + +from typing import Tuple, Optional, List, TYPE_CHECKING if TYPE_CHECKING: from ..core.generic_search import GenericSearch @@ -14,11 +15,11 @@ FAILURE = "failure" def depth_limited_search( - problem: 'GenericSearch', + problem: "GenericSearch", limit: int, visualize: bool = False, steps: Optional[List[SearchStep]] = None, - base_expanded: int = 0 + base_expanded: int = 0, ) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]: """ Depth-limited search - DFS with depth limit. @@ -38,43 +39,54 @@ def depth_limited_search( start_node = SearchNode(state=start, path_cost=0, depth=0) return _recursive_dls( - problem, start_node, limit, set(), visualize, + problem, + start_node, + limit, + set(), + visualize, steps if steps is not None else ([] if visualize else None), - base_expanded + base_expanded, ) def _recursive_dls( - problem: 'GenericSearch', + problem: "GenericSearch", node: SearchNode, limit: int, explored: set, visualize: bool, steps: Optional[List[SearchStep]], - nodes_expanded: int + nodes_expanded: int, ) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]: """Recursive helper for depth-limited search.""" # Record step for visualization if visualize and steps is not None: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=[], # DLS doesn't maintain explicit frontier - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=[], # DLS doesn't maintain explicit frontier + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), "success", nodes_expanded, steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + "success", + nodes_expanded, + steps, + ) # Depth limit reached if node.depth >= limit: @@ -97,7 +109,7 @@ def _recursive_dls( parent=node, action=action, path_cost=node.path_cost + step_cost, - depth=node.depth + 1 + depth=node.depth + 1, ) result, status, nodes_expanded, steps = _recursive_dls( @@ -116,9 +128,7 @@ def _recursive_dls( def ids_search( - problem: 'GenericSearch', - visualize: bool = False, - max_depth: int = 1000 + problem: "GenericSearch", visualize: bool = False, max_depth: int = 1000 ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Iterative deepening search - repeated DLS with increasing depth. @@ -154,102 +164,7 @@ def ids_search( break # No solution found within max_depth - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=total_expanded, - path=[] - ), all_steps - - -def ids_search_generator( - problem: 'GenericSearch', - max_depth: int = 1000 -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of IDS that yields steps during execution. - - Args: - problem: The search problem to solve - max_depth: Maximum depth to search - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - total_expanded = 0 - - for depth in range(max_depth): - # Run DLS and yield steps - for step in _dls_generator(problem, depth, total_expanded): - yield step - total_expanded = step.step_number - - # Check if solution was found at this depth - result, status, expanded, _ = depth_limited_search( - problem, depth, False, None, total_expanded - ) - total_expanded = expanded - - if status == "success" and result is not None: - result.nodes_expanded = total_expanded - return result - elif status == FAILURE: - break - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=total_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=total_expanded, path=[]), + all_steps, ) - - -def _dls_generator( - problem: 'GenericSearch', - limit: int, - base_expanded: int -) -> Generator[SearchStep, None, None]: - """Generator helper for DLS.""" - start = problem.initial_state() - start_node = SearchNode(state=start, path_cost=0, depth=0) - - stack = [(start_node, set())] - nodes_expanded = base_expanded - - while stack: - node, explored = stack.pop() - - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=[n.state for n, _ in stack], - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - if problem.goal_test(node.state): - return - - if node.depth >= limit: - continue - - explored = explored | {node.state} - nodes_expanded += 1 - - for action in reversed(problem.actions(node.state)): - child_state = problem.result(node.state, action) - if child_state not in explored: - step_cost = problem.step_cost(node.state, action, child_state) - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=node.path_cost + step_cost, - depth=node.depth + 1 - ) - stack.append((child, explored)) diff --git a/backend/app/algorithms/ucs.py b/backend/app/algorithms/ucs.py index 5b183b79b05c28360af98d96de642288dfcf6f54..345b60adc7007fb087589731716cf524a4ea7019 100644 --- a/backend/app/algorithms/ucs.py +++ b/backend/app/algorithms/ucs.py @@ -1,5 +1,6 @@ """Uniform Cost Search algorithm.""" -from typing import Tuple, Optional, List, Generator, TYPE_CHECKING + +from typing import Tuple, Optional, List, TYPE_CHECKING if TYPE_CHECKING: from ..core.generic_search import GenericSearch @@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep def ucs_search( - problem: 'GenericSearch', - visualize: bool = False + problem: "GenericSearch", visualize: bool = False ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Uniform Cost Search using priority queue ordered by path cost. @@ -40,24 +40,29 @@ def ucs_search( # Record step for visualization if visualize: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=frontier.get_states(), + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test (after pop for UCS) if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + steps, + ) # Skip if already explored if node.state in explored: @@ -79,88 +84,12 @@ def ucs_search( action=action, path_cost=new_cost, depth=node.depth + 1, - priority=new_cost # Priority = path cost for UCS + priority=new_cost, # Priority = path cost for UCS ) frontier.push(child) # No solution found - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] - ), steps - - -def ucs_search_generator( - problem: 'GenericSearch' -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of UCS that yields steps during execution. - - Args: - problem: The search problem to solve - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - frontier = PriorityQueueFrontier() - start = problem.initial_state() - start_node = SearchNode(state=start, path_cost=0, depth=0, priority=0) - frontier.push(start_node) - - explored: set = set() - nodes_expanded = 0 - - while not frontier.is_empty(): - node = frontier.pop() - - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ) - - if node.state in explored: - continue - - explored.add(node.state) - nodes_expanded += 1 - - for action in problem.actions(node.state): - child_state = problem.result(node.state, action) - step_cost = problem.step_cost(node.state, action, child_state) - new_cost = node.path_cost + step_cost - - if child_state not in explored: - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=new_cost, - depth=node.depth + 1, - priority=new_cost - ) - frontier.push(child) - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]), + steps, ) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index adca052b5e50c1ffd4dc9130aeca38d8f834fb12..6e0118f96b17d2946db3e605452fca6a81215354 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -1,4 +1,5 @@ """API package.""" + from .routes import router __all__ = ["router"] diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 8ce15aba18ba4b49413da4ab3235bd94957cd652..47d71d14b0ebbc5abe8edf2b59d82afd1e14f35a 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,4 +1,5 @@ """API routes for the delivery search application.""" + from fastapi import APIRouter, HTTPException from typing import List @@ -33,42 +34,42 @@ ALGORITHMS = [ AlgorithmInfo( code="BF", name="Breadth-First Search", - description="Explores all nodes at current depth before moving deeper. Finds shortest path in terms of steps." + description="Explores all nodes at current depth before moving deeper. Finds shortest path in terms of steps.", ), AlgorithmInfo( code="DF", name="Depth-First Search", - description="Explores as far as possible along each branch. Memory efficient but may not find optimal path." + description="Explores as far as possible along each branch. Memory efficient but may not find optimal path.", ), AlgorithmInfo( code="ID", name="Iterative Deepening", - description="Combines BFS completeness with DFS space efficiency. Good for unknown depth goals." + description="Combines BFS completeness with DFS space efficiency. Good for unknown depth goals.", ), AlgorithmInfo( code="UC", name="Uniform Cost Search", - description="Expands lowest-cost node first. Always finds the optimal (minimum cost) solution." + description="Expands lowest-cost node first. Always finds the optimal (minimum cost) solution.", ), AlgorithmInfo( code="GR1", name="Greedy (Manhattan)", - description="Uses Manhattan distance heuristic. Fast but may not find optimal path." + description="Uses Manhattan distance heuristic. Fast but may not find optimal path.", ), AlgorithmInfo( code="GR2", name="Greedy (Euclidean)", - description="Uses Euclidean distance heuristic. Fast but may not find optimal path." + description="Uses Euclidean distance heuristic. Fast but may not find optimal path.", ), AlgorithmInfo( code="AS1", name="A* (Manhattan)", - description="A* with Manhattan distance. Optimal and complete with admissible heuristic." + description="A* with Manhattan distance. Optimal and complete with admissible heuristic.", ), AlgorithmInfo( code="AS2", name="A* (Tunnel-Aware)", - description="A* considering tunnel shortcuts. More informed for grids with tunnels." + description="A* considering tunnel shortcuts. More informed for grids with tunnels.", ), ] @@ -95,7 +96,7 @@ async def generate_grid(config: GridConfig): num_stores=config.num_stores, num_destinations=config.num_destinations, num_tunnels=config.num_tunnels, - obstacle_density=config.obstacle_density + obstacle_density=config.obstacle_density, ) # Convert to GridData for frontend @@ -107,14 +108,16 @@ async def generate_grid(config: GridConfig): for s in state.stores ], destinations=[ - DestinationData(id=d.id, position=Position(x=d.position[0], y=d.position[1])) + DestinationData( + id=d.id, position=Position(x=d.position[0], y=d.position[1]) + ) for d in state.destinations ], tunnels=[ TunnelData( entrance1=Position(x=t.entrance1[0], y=t.entrance1[1]), entrance2=Position(x=t.entrance2[0], y=t.entrance2[1]), - cost=t.cost + cost=t.cost, ) for t in state.tunnels ], @@ -122,16 +125,14 @@ async def generate_grid(config: GridConfig): SegmentData( src=Position(x=seg.src[0], y=seg.src[1]), dst=Position(x=seg.dst[0], y=seg.dst[1]), - traffic=seg.traffic + traffic=seg.traffic, ) for seg in state.grid.segments.values() - ] + ], ) return GenerateResponse( - initial_state=initial_state, - traffic=traffic, - parsed=parsed + initial_state=initial_state, traffic=traffic, parsed=parsed ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -148,16 +149,14 @@ async def find_path(request: PathRequest): grid = Grid(width=request.grid_width, height=request.grid_height) for seg in request.segments: grid.add_segment( - (seg.src.x, seg.src.y), - (seg.dst.x, seg.dst.y), - seg.traffic + (seg.src.x, seg.src.y), (seg.dst.x, seg.dst.y), seg.traffic ) # Build tunnels tunnels = [ Tunnel( entrance1=(t.entrance1.x, t.entrance1.y), - entrance2=(t.entrance2.x, t.entrance2.y) + entrance2=(t.entrance2.x, t.entrance2.y), ) for t in request.tunnels ] @@ -170,7 +169,7 @@ async def find_path(request: PathRequest): (request.goal.x, request.goal.y), tunnels, request.strategy.value, - visualize=True + visualize=True, ) metrics.sample() @@ -179,10 +178,10 @@ async def find_path(request: PathRequest): cost=result.cost, nodes_expanded=result.nodes_expanded, runtime_ms=metrics.runtime_ms, - memory_mb=max(0, metrics.memory_mb), + memory_kb=max(0, metrics.memory_kb), cpu_percent=metrics.cpu_percent, path=[Position(x=p[0], y=p[1]) for p in result.path], - steps=[s.to_dict() for s in steps] if steps else None + steps=[s.to_dict() for s in steps] if steps else None, ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -203,7 +202,7 @@ async def create_plan(request: SearchRequest): state.destinations, state.tunnels, request.strategy.value, - request.visualize + request.visualize, ) metrics.sample() @@ -213,8 +212,8 @@ async def create_plan(request: SearchRequest): total_cost=plan_result.total_cost, total_nodes_expanded=plan_result.total_nodes_expanded, runtime_ms=metrics.runtime_ms, - memory_mb=max(0, metrics.memory_mb), - cpu_percent=metrics.cpu_percent + memory_kb=max(0, metrics.memory_kb), + cpu_percent=metrics.cpu_percent, ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -227,7 +226,7 @@ async def compare_algorithms(request: CompareRequest): state = parse_full_state(request.initial_state, request.traffic) results: List[ComparisonResult] = [] - optimal_cost = float('inf') + optimal_cost = float("inf") # Run each algorithm for algo_info in ALGORITHMS: @@ -238,7 +237,7 @@ async def compare_algorithms(request: CompareRequest): state.destinations, state.tunnels, algo_info.code, - visualize=False + visualize=False, ) metrics.sample() @@ -246,25 +245,24 @@ async def compare_algorithms(request: CompareRequest): if algo_info.code in ["UC", "AS1", "AS2"]: optimal_cost = min(optimal_cost, plan_result.total_cost) - results.append(ComparisonResult( - algorithm=algo_info.code, - name=algo_info.name, - plan=plan_result.to_string(), - cost=plan_result.total_cost, - nodes_expanded=plan_result.total_nodes_expanded, - runtime_ms=metrics.runtime_ms, - memory_mb=max(0, metrics.memory_mb), - cpu_percent=metrics.cpu_percent, - is_optimal=False # Will be set below - )) + results.append( + ComparisonResult( + algorithm=algo_info.code, + name=algo_info.name, + plan=plan_result.to_string(), + cost=plan_result.total_cost, + nodes_expanded=plan_result.total_nodes_expanded, + runtime_ms=metrics.runtime_ms, + memory_kb=max(0, metrics.memory_kb), + cpu_percent=metrics.cpu_percent, + is_optimal=False, # Will be set below + ) + ) # Mark optimal solutions for result in results: - result.is_optimal = (result.cost == optimal_cost) + result.is_optimal = result.cost == optimal_cost - return CompareResponse( - comparisons=results, - optimal_cost=optimal_cost - ) + return CompareResponse(comparisons=results, optimal_cost=optimal_cost) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py index 67189d621666f0dc9b5e73435ca8a66f31a490c0..436116dafe707e5ff6bd571833b8a90d9e232502 100644 --- a/backend/app/core/__init__.py +++ b/backend/app/core/__init__.py @@ -1,7 +1,8 @@ """Core search module.""" + from .node import SearchNode from .frontier import Frontier, QueueFrontier, StackFrontier, PriorityQueueFrontier -from .generic_search import GenericSearch, graph_search, graph_search_generator +from .generic_search import GenericSearch, graph_search from .delivery_search import DeliverySearch from .delivery_planner import DeliveryPlanner @@ -13,7 +14,6 @@ __all__ = [ "PriorityQueueFrontier", "GenericSearch", "graph_search", - "graph_search_generator", "DeliverySearch", "DeliveryPlanner", ] diff --git a/backend/app/core/delivery_planner.py b/backend/app/core/delivery_planner.py index b13b85fa8185945c17afd50ca01b5fc4d2283dde..b3e864690a11fdfde1a45db29ba0113c0e3ed8f5 100644 --- a/backend/app/core/delivery_planner.py +++ b/backend/app/core/delivery_planner.py @@ -1,4 +1,5 @@ """DeliveryPlanner - Plans which trucks deliver which packages.""" + from typing import List, Dict, Tuple, Optional from .delivery_search import DeliverySearch from ..models.grid import Grid @@ -18,7 +19,7 @@ class DeliveryPlanner: grid: Grid, stores: List[Store], destinations: List[Destination], - tunnels: Optional[List[Tunnel]] = None + tunnels: Optional[List[Tunnel]] = None, ): """ Initialize the delivery planner. @@ -35,9 +36,7 @@ class DeliveryPlanner: self.tunnels = tunnels or [] def plan( - self, - strategy: str, - visualize: bool = False + self, strategy: str, visualize: bool = False ) -> Tuple[PlanResult, Optional[Dict[int, List[SearchStep]]]]: """ Create delivery plan assigning destinations to stores. @@ -64,7 +63,7 @@ class DeliveryPlanner: best_store: Optional[Store] = None best_result: Optional[PathResult] = None best_steps: Optional[List[SearchStep]] = None - best_cost = float('inf') + best_cost = float("inf") # Try each store for store in self.stores: @@ -74,7 +73,7 @@ class DeliveryPlanner: dest.position, self.tunnels, strategy, - visualize + visualize, ) # Track nodes expanded @@ -92,7 +91,7 @@ class DeliveryPlanner: assignment = DeliveryAssignment( store_id=best_store.id, destination_id=dest.id, - path_result=best_result + path_result=best_result, ) assignments.append(assignment) total_cost += best_result.cost @@ -100,75 +99,14 @@ class DeliveryPlanner: if visualize and best_steps: all_steps[dest.id] = best_steps - return PlanResult( - assignments=assignments, - total_cost=total_cost, - total_nodes_expanded=total_nodes - ), all_steps - - def plan_all_from_store( - self, - store: Store, - strategy: str, - visualize: bool = False - ) -> List[Tuple[Destination, PathResult, Optional[List[SearchStep]]]]: - """ - Plan all deliveries from a single store. - - This variant finds paths from one store to all destinations, - useful for comparing which destinations are closest. - - Args: - store: The store to deliver from - strategy: Search strategy to use - visualize: If True, collect visualization steps - - Returns: - List of (destination, path_result, steps) tuples - """ - results = [] - - for dest in self.destinations: - result, steps = DeliverySearch.path( - self.grid, - store.position, - dest.position, - self.tunnels, - strategy, - visualize - ) - results.append((dest, result, steps)) - - # Sort by cost (closest first) - results.sort(key=lambda x: x[1].cost) - return results - - def plan_sequential( - self, - strategy: str, - visualize: bool = False - ) -> Tuple[PlanResult, Optional[Dict]]: - """ - Plan deliveries where trucks return to store after each delivery. - - For each destination: - 1. Find best store (minimum round-trip or just delivery cost) - 2. Assign to that store - - This is the simplified version as per project spec where - "once a delivery has been made, the truck immediately returns - to the store and can now make a new delivery." - - Args: - strategy: Search strategy to use - visualize: If True, collect visualization steps - - Returns: - Tuple of (PlanResult, Optional visualization data) - """ - # For this simplified version, we use the same logic as plan() - # since each delivery is independent (truck returns to store) - return self.plan(strategy, visualize) + return ( + PlanResult( + assignments=assignments, + total_cost=total_cost, + total_nodes_expanded=total_nodes, + ), + all_steps, + ) @staticmethod def plan_from_state( @@ -177,7 +115,7 @@ class DeliveryPlanner: destinations: List[Destination], tunnels: List[Tunnel], strategy: str, - visualize: bool = False + visualize: bool = False, ) -> Tuple[PlanResult, Optional[Dict]]: """ Static method to create and run planner. diff --git a/backend/app/core/delivery_search.py b/backend/app/core/delivery_search.py index d2f87af3eb51cc238aa27c369e9228871ee1108f..df6dd977236445e01b9ea67b6f139854b3c4e968 100644 --- a/backend/app/core/delivery_search.py +++ b/backend/app/core/delivery_search.py @@ -1,4 +1,5 @@ """DeliverySearch - Search problem for package delivery.""" + from typing import List, Tuple, Optional, Dict from .generic_search import GenericSearch from ..models.grid import Grid @@ -20,7 +21,7 @@ class DeliverySearch(GenericSearch): grid: Grid, start: Tuple[int, int], goal: Tuple[int, int], - tunnels: Optional[List[Tunnel]] = None + tunnels: Optional[List[Tunnel]] = None, ): """ Initialize the delivery search problem. @@ -120,10 +121,7 @@ class DeliverySearch(GenericSearch): raise ValueError(f"Unknown action: {action}") def step_cost( - self, - state: Tuple[int, int], - action: str, - next_state: Tuple[int, int] + self, state: Tuple[int, int], action: str, next_state: Tuple[int, int] ) -> float: """ Return the cost of taking an action. @@ -167,7 +165,7 @@ class DeliverySearch(GenericSearch): goal: Tuple[int, int], tunnels: List[Tunnel], strategy: str, - visualize: bool = False + visualize: bool = False, ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Find path from start to goal using specified strategy. @@ -185,27 +183,3 @@ class DeliverySearch(GenericSearch): """ search = DeliverySearch(grid, start, goal, tunnels) return search.solve(strategy, visualize) - - @staticmethod - def path_string( - grid: Grid, - start: Tuple[int, int], - goal: Tuple[int, int], - tunnels: List[Tunnel], - strategy: str - ) -> str: - """ - Find path and return formatted string. - - Args: - grid: The city grid - start: Starting position - goal: Goal position - tunnels: Available tunnels - strategy: Search strategy - - Returns: - String in format "plan;cost;nodesExpanded" - """ - result, _ = DeliverySearch.path(grid, start, goal, tunnels, strategy) - return result.to_string() diff --git a/backend/app/core/frontier.py b/backend/app/core/frontier.py index d0d6637e19823f0926c68b4b66297d0da256cfd5..36837ff4d3e0cf4e35a0d0c8d370739f1607d185 100644 --- a/backend/app/core/frontier.py +++ b/backend/app/core/frontier.py @@ -1,4 +1,5 @@ """Frontier data structures for search algorithms.""" + from abc import ABC, abstractmethod from collections import deque import heapq diff --git a/backend/app/core/generic_search.py b/backend/app/core/generic_search.py index f2cc1f3fb56652d5d5f4ee09bf52fb5d1beed4e8..71409b4e2176a9fa02bb202617ab2a161e50bc45 100644 --- a/backend/app/core/generic_search.py +++ b/backend/app/core/generic_search.py @@ -1,6 +1,7 @@ """Generic search problem abstract base class.""" + from abc import ABC, abstractmethod -from typing import List, Tuple, Optional, Generator +from typing import List, Tuple, Optional from .node import SearchNode from .frontier import Frontier from ..models.state import PathResult, SearchStep @@ -53,9 +54,7 @@ class GenericSearch(ABC): return 0.0 def solve( - self, - strategy: str, - visualize: bool = False + self, strategy: str, visualize: bool = False ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Solve the search problem using the specified strategy. @@ -86,62 +85,16 @@ class GenericSearch(ABC): # Map strategy codes to search functions strategy_map = { - 'BF': lambda: bfs_search(self, visualize), - 'DF': lambda: dfs_search(self, visualize), - 'ID': lambda: ids_search(self, visualize), - 'UC': lambda: ucs_search(self, visualize), - 'GR1': lambda: greedy_search(self, manhattan_heuristic, visualize), - 'GR2': lambda: greedy_search(self, euclidean_heuristic, visualize), - 'AS1': lambda: astar_search(self, manhattan_heuristic, visualize), - 'AS2': lambda: astar_search(self, tunnel_aware_wrapper, visualize), # Tunnel-aware - } - - if strategy not in strategy_map: - raise ValueError(f"Unknown strategy: {strategy}") - - return strategy_map[strategy]() - - def solve_with_steps( - self, strategy: str - ) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of solve that yields steps for real-time visualization. - - Args: - strategy: Search strategy code - - Yields: - SearchStep objects during search - - Returns: - Final PathResult - """ - from ..algorithms import ( - bfs_search_generator, - dfs_search_generator, - ids_search_generator, - ucs_search_generator, - greedy_search_generator, - astar_search_generator, - ) - from ..heuristics import ( - manhattan_heuristic, - euclidean_heuristic, - ) - - # Wrap instance heuristic to match expected signature (state, goal) -> float - def tunnel_aware_wrapper(state, goal): - return self.heuristic(state) - - strategy_map = { - 'BF': lambda: bfs_search_generator(self), - 'DF': lambda: dfs_search_generator(self), - 'ID': lambda: ids_search_generator(self), - 'UC': lambda: ucs_search_generator(self), - 'GR1': lambda: greedy_search_generator(self, manhattan_heuristic), - 'GR2': lambda: greedy_search_generator(self, euclidean_heuristic), - 'AS1': lambda: astar_search_generator(self, manhattan_heuristic), - 'AS2': lambda: astar_search_generator(self, tunnel_aware_wrapper), + "BF": lambda: bfs_search(self, visualize), + "DF": lambda: dfs_search(self, visualize), + "ID": lambda: ids_search(self, visualize), + "UC": lambda: ucs_search(self, visualize), + "GR1": lambda: greedy_search(self, manhattan_heuristic, visualize), + "GR2": lambda: greedy_search(self, euclidean_heuristic, visualize), + "AS1": lambda: astar_search(self, manhattan_heuristic, visualize), + "AS2": lambda: astar_search( + self, tunnel_aware_wrapper, visualize + ), # Tunnel-aware } if strategy not in strategy_map: @@ -151,9 +104,7 @@ class GenericSearch(ABC): def graph_search( - problem: GenericSearch, - frontier: Frontier, - visualize: bool = False + problem: GenericSearch, frontier: Frontier, visualize: bool = False ) -> Tuple[PathResult, Optional[List[SearchStep]]]: """ Generic graph search algorithm. @@ -180,24 +131,29 @@ def graph_search( # Record step for visualization if visualize: - steps.append(SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - )) + steps.append( + SearchStep( + step_number=nodes_expanded, + current_node=node.state, + action=node.action, + frontier=frontier.get_states(), + explored=list(explored), + current_path=node.get_path(), + path_cost=node.path_cost, + ) + ) # Goal test if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ), steps + return ( + PathResult( + plan=node.get_solution(), + cost=node.path_cost, + nodes_expanded=nodes_expanded, + path=node.get_path(), + ), + steps, + ) # Skip if already explored if node.state in explored: @@ -217,87 +173,12 @@ def graph_search( parent=node, action=action, path_cost=node.path_cost + step_cost, - depth=node.depth + 1 + depth=node.depth + 1, ) frontier.push(child) # No solution found - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] - ), steps - - -def graph_search_generator( - problem: GenericSearch, - frontier: Frontier -) -> Generator[SearchStep, None, PathResult]: - """ - Generator version of graph search that yields steps during execution. - - Args: - problem: The search problem to solve - frontier: The frontier data structure - - Yields: - SearchStep objects - - Returns: - Final PathResult - """ - start = problem.initial_state() - start_node = SearchNode(state=start, path_cost=0, depth=0) - frontier.push(start_node) - explored: set = set() - nodes_expanded = 0 - - while not frontier.is_empty(): - node = frontier.pop() - - # Yield current step - yield SearchStep( - step_number=nodes_expanded, - current_node=node.state, - action=node.action, - frontier=frontier.get_states(), - explored=list(explored), - current_path=node.get_path(), - path_cost=node.path_cost - ) - - # Goal test - if problem.goal_test(node.state): - return PathResult( - plan=node.get_solution(), - cost=node.path_cost, - nodes_expanded=nodes_expanded, - path=node.get_path() - ) - - if node.state in explored: - continue - - explored.add(node.state) - nodes_expanded += 1 - - for action in problem.actions(node.state): - child_state = problem.result(node.state, action) - if child_state not in explored and not frontier.contains_state(child_state): - step_cost = problem.step_cost(node.state, action, child_state) - child = SearchNode( - state=child_state, - parent=node, - action=action, - path_cost=node.path_cost + step_cost, - depth=node.depth + 1 - ) - frontier.push(child) - - return PathResult( - plan="", - cost=float('inf'), - nodes_expanded=nodes_expanded, - path=[] + return ( + PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]), + steps, ) diff --git a/backend/app/core/node.py b/backend/app/core/node.py index 5bdb4ab4f9c0e15ef79b858fa39347c575149bd1..a5437279a1a12eb6ef2d74c59af5913569a5e21f 100644 --- a/backend/app/core/node.py +++ b/backend/app/core/node.py @@ -1,4 +1,5 @@ """SearchNode class for the search tree.""" + from dataclasses import dataclass, field from typing import Optional, List, Tuple, Any @@ -15,15 +16,16 @@ class SearchNode: path_cost: g(n) - cost from start to this node depth: Depth in search tree """ + state: Tuple[int, int] - parent: Optional['SearchNode'] = None + parent: Optional["SearchNode"] = None action: Optional[str] = None path_cost: float = 0.0 depth: int = 0 # For priority queue - lower is better priority: float = field(default=0.0, compare=False) - def __lt__(self, other: 'SearchNode') -> bool: + def __lt__(self, other: "SearchNode") -> bool: """Compare nodes by priority for priority queue.""" return self.priority < other.priority @@ -78,12 +80,8 @@ class SearchNode: return ",".join(actions) if actions else "" def expand( - self, - actions_func, - result_func, - cost_func, - heuristic_func=None - ) -> List['SearchNode']: + self, actions_func, result_func, cost_func, heuristic_func=None + ) -> List["SearchNode"]: """ Expand this node by generating all child nodes. @@ -105,7 +103,7 @@ class SearchNode: parent=self, action=action, path_cost=self.path_cost + step_cost, - depth=self.depth + 1 + depth=self.depth + 1, ) # Set priority if heuristic is provided (for A*) if heuristic_func is not None: @@ -116,4 +114,6 @@ class SearchNode: return children def __repr__(self) -> str: - return f"SearchNode(state={self.state}, depth={self.depth}, cost={self.path_cost})" + return ( + f"SearchNode(state={self.state}, depth={self.depth}, cost={self.path_cost})" + ) diff --git a/backend/app/heuristics/__init__.py b/backend/app/heuristics/__init__.py index 14059e7e1b8242a19e4fe1593d643e47ebd97e46..90a630c6af8f6d0c487511beab2fc82208ec3203 100644 --- a/backend/app/heuristics/__init__.py +++ b/backend/app/heuristics/__init__.py @@ -1,7 +1,11 @@ """Heuristics package for informed search algorithms.""" + from .manhattan import manhattan_heuristic from .euclidean import euclidean_heuristic -from .traffic_weighted import traffic_weighted_heuristic, create_traffic_weighted_heuristic +from .traffic_weighted import ( + traffic_weighted_heuristic, + create_traffic_weighted_heuristic, +) from .tunnel_aware import tunnel_aware_heuristic, create_tunnel_aware_heuristic __all__ = [ diff --git a/backend/app/heuristics/euclidean.py b/backend/app/heuristics/euclidean.py index 2da1e9c7f8e01bed900ab1855c4485fc179396bb..9d214b5427c9d03083f14f948bbf9bb6a4a246a8 100644 --- a/backend/app/heuristics/euclidean.py +++ b/backend/app/heuristics/euclidean.py @@ -1,4 +1,5 @@ """Euclidean distance heuristic.""" + import math from typing import Tuple diff --git a/backend/app/heuristics/manhattan.py b/backend/app/heuristics/manhattan.py index 35d428cc86c8108871d0f718ac71ce8608dc7929..7b0c8d4ecb36ca7a32100f536ad3b68dd81df3ae 100644 --- a/backend/app/heuristics/manhattan.py +++ b/backend/app/heuristics/manhattan.py @@ -1,4 +1,5 @@ """Manhattan distance heuristic.""" + from typing import Tuple diff --git a/backend/app/heuristics/traffic_weighted.py b/backend/app/heuristics/traffic_weighted.py index 75e62b330a53fe672d17a050d3757193229293d4..1bbfb624a2c990d08461ea7d74bdb0a9ea2b115e 100644 --- a/backend/app/heuristics/traffic_weighted.py +++ b/backend/app/heuristics/traffic_weighted.py @@ -1,11 +1,10 @@ """Traffic-weighted Manhattan heuristic.""" + from typing import Tuple def traffic_weighted_heuristic( - state: Tuple[int, int], - goal: Tuple[int, int], - min_traffic: float = 1.0 + state: Tuple[int, int], goal: Tuple[int, int], min_traffic: float = 1.0 ) -> float: """ Traffic-weighted Manhattan distance heuristic. @@ -38,6 +37,8 @@ def create_traffic_weighted_heuristic(min_traffic: float = 1.0): Returns: Heuristic function """ + def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float: return traffic_weighted_heuristic(state, goal, min_traffic) + return heuristic diff --git a/backend/app/heuristics/tunnel_aware.py b/backend/app/heuristics/tunnel_aware.py index c6309048b9aaf145f5b026978291c3e2979acb47..46990a058a48a46a20595ac8bc2ca15f022bf4c6 100644 --- a/backend/app/heuristics/tunnel_aware.py +++ b/backend/app/heuristics/tunnel_aware.py @@ -1,12 +1,11 @@ """Tunnel-aware Manhattan heuristic.""" + from typing import Tuple, List, Optional from .manhattan import manhattan_heuristic def tunnel_aware_heuristic( - state: Tuple[int, int], - goal: Tuple[int, int], - tunnels: Optional[List] = None + state: Tuple[int, int], goal: Tuple[int, int], tunnels: Optional[List] = None ) -> float: """ Tunnel-aware Manhattan distance heuristic. @@ -45,16 +44,16 @@ def tunnel_aware_heuristic( # Path: state -> entrance1 -> (tunnel) -> entrance2 -> goal via_tunnel_1 = ( - manhattan_heuristic(state, entrance1) + - tunnel_cost + - manhattan_heuristic(entrance2, goal) + manhattan_heuristic(state, entrance1) + + tunnel_cost + + manhattan_heuristic(entrance2, goal) ) # Path: state -> entrance2 -> (tunnel) -> entrance1 -> goal via_tunnel_2 = ( - manhattan_heuristic(state, entrance2) + - tunnel_cost + - manhattan_heuristic(entrance1, goal) + manhattan_heuristic(state, entrance2) + + tunnel_cost + + manhattan_heuristic(entrance1, goal) ) best = min(best, via_tunnel_1, via_tunnel_2) @@ -72,6 +71,8 @@ def create_tunnel_aware_heuristic(tunnels: List): Returns: Heuristic function """ + def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float: return tunnel_aware_heuristic(state, goal, tunnels) + return heuristic diff --git a/backend/app/main.py b/backend/app/main.py index 722d29ba1b2895a433eddd4f2e3634e083440510..a428ae444b1d83826f13918198e06c4a663940e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ """FastAPI application entry point.""" + from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -31,7 +32,9 @@ app.include_router(router) static_dir = Path("/app/frontend/dist") if static_dir.exists(): # Mount assets directory - app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets") + app.mount( + "/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets" + ) # Serve index.html for root and all non-API routes @app.get("/") @@ -51,7 +54,9 @@ if static_dir.exists(): # Otherwise serve index.html (SPA routing) return FileResponse(str(static_dir / "index.html")) + else: + @app.get("/") async def root(): """Root endpoint.""" @@ -65,7 +70,7 @@ else: "path": "/api/search/path", "plan": "/api/search/plan", "compare": "/api/search/compare", - } + }, } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6df0a205caf8e712d6f8651b8b07f1469fa3c920..4f21881ae094b70fa1cca4a1fa63a879d0559ff9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,7 +1,15 @@ """Models package - exports all model classes.""" + from .grid import Grid, Segment -from .entities import Store, Destination, Tunnel, Truck -from .state import SearchState, PathResult, DeliveryAssignment, PlanResult, SearchStep, SearchMetrics +from .entities import Store, Destination, Tunnel +from .state import ( + SearchState, + PathResult, + DeliveryAssignment, + PlanResult, + SearchStep, + SearchMetrics, +) from .requests import ( Algorithm, Position, @@ -32,7 +40,6 @@ __all__ = [ "Store", "Destination", "Tunnel", - "Truck", # State models "SearchState", "PathResult", diff --git a/backend/app/models/entities.py b/backend/app/models/entities.py index 017ef2f9749852c15d1a0bb4ef57ffd989caf7fd..e8532f654d45c2d1343e20bc9e905888ae655179 100644 --- a/backend/app/models/entities.py +++ b/backend/app/models/entities.py @@ -1,4 +1,5 @@ """Entity models for stores, destinations, tunnels, and trucks.""" + from dataclasses import dataclass from typing import Tuple @@ -6,39 +7,44 @@ from typing import Tuple @dataclass class Store: """Represents a storage location / starting point for trucks.""" + id: int position: Tuple[int, int] def to_dict(self) -> dict: return { "id": self.id, - "position": {"x": self.position[0], "y": self.position[1]} + "position": {"x": self.position[0], "y": self.position[1]}, } @dataclass class Destination: """Represents a customer destination for package delivery.""" + id: int position: Tuple[int, int] def to_dict(self) -> dict: return { "id": self.id, - "position": {"x": self.position[0], "y": self.position[1]} + "position": {"x": self.position[0], "y": self.position[1]}, } @dataclass class Tunnel: """Represents an underground tunnel connecting two points.""" + entrance1: Tuple[int, int] entrance2: Tuple[int, int] @property def cost(self) -> int: """Tunnel cost is Manhattan distance between entrances.""" - return abs(self.entrance1[0] - self.entrance2[0]) + abs(self.entrance1[1] - self.entrance2[1]) + return abs(self.entrance1[0] - self.entrance2[0]) + abs( + self.entrance1[1] - self.entrance2[1] + ) def get_other_entrance(self, entrance: Tuple[int, int]) -> Tuple[int, int]: """Get the other entrance of the tunnel.""" @@ -56,20 +62,5 @@ class Tunnel: return { "entrance1": {"x": self.entrance1[0], "y": self.entrance1[1]}, "entrance2": {"x": self.entrance2[0], "y": self.entrance2[1]}, - "cost": self.cost - } - - -@dataclass -class Truck: - """Represents a delivery truck.""" - id: int - store_id: int - current_position: Tuple[int, int] - - def to_dict(self) -> dict: - return { - "id": self.id, - "store_id": self.store_id, - "position": {"x": self.current_position[0], "y": self.current_position[1]} + "cost": self.cost, } diff --git a/backend/app/models/grid.py b/backend/app/models/grid.py index 5de0a02812a73e112767403de2a551f636b25d92..e5824521a5826f0bba955cb8b59474af49af9162 100644 --- a/backend/app/models/grid.py +++ b/backend/app/models/grid.py @@ -1,4 +1,5 @@ """Grid and Segment models for the delivery search problem.""" + from dataclasses import dataclass, field from typing import Dict, Tuple, Optional @@ -6,6 +7,7 @@ from typing import Dict, Tuple, Optional @dataclass class Segment: """Represents a road segment between two adjacent grid points.""" + src: Tuple[int, int] dst: Tuple[int, int] traffic: int # 0 = blocked, 1-4 = traffic level @@ -27,11 +29,16 @@ class Segment: @dataclass class Grid: """Represents the city grid with all road segments.""" + width: int height: int - segments: Dict[Tuple[Tuple[int, int], Tuple[int, int]], Segment] = field(default_factory=dict) + segments: Dict[Tuple[Tuple[int, int], Tuple[int, int]], Segment] = field( + default_factory=dict + ) - def get_segment(self, src: Tuple[int, int], dst: Tuple[int, int]) -> Optional[Segment]: + def get_segment( + self, src: Tuple[int, int], dst: Tuple[int, int] + ) -> Optional[Segment]: """Get segment between two points (order doesn't matter).""" key = (src, dst) if src < dst else (dst, src) return self.segments.get(key) @@ -77,8 +84,8 @@ class Grid: { "src": {"x": seg.src[0], "y": seg.src[1]}, "dst": {"x": seg.dst[0], "y": seg.dst[1]}, - "traffic": seg.traffic + "traffic": seg.traffic, } for seg in self.segments.values() - ] + ], } diff --git a/backend/app/models/requests.py b/backend/app/models/requests.py index 5d7134e5a0bb421ff6529f5b5794eb76292545e3..5170adba818c44cb6afe628076d1a0d8d3f68b9f 100644 --- a/backend/app/models/requests.py +++ b/backend/app/models/requests.py @@ -1,4 +1,5 @@ """Pydantic models for API requests and responses.""" + from pydantic import BaseModel, Field from typing import Optional, List, Tuple from enum import Enum @@ -6,10 +7,11 @@ from enum import Enum class Algorithm(str, Enum): """Available search algorithms.""" - BF = "BF" # Breadth-first search - DF = "DF" # Depth-first search - ID = "ID" # Iterative deepening - UC = "UC" # Uniform cost search + + BF = "BF" # Breadth-first search + DF = "DF" # Depth-first search + ID = "ID" # Iterative deepening + UC = "UC" # Uniform cost search GR1 = "GR1" # Greedy with Manhattan heuristic GR2 = "GR2" # Greedy with Euclidean heuristic AS1 = "AS1" # A* with Manhattan heuristic @@ -18,6 +20,7 @@ class Algorithm(str, Enum): class Position(BaseModel): """A position on the grid.""" + x: int y: int @@ -27,6 +30,7 @@ class Position(BaseModel): class SegmentData(BaseModel): """Segment data for API.""" + src: Position dst: Position traffic: int = Field(ge=0, le=4) @@ -34,18 +38,21 @@ class SegmentData(BaseModel): class StoreData(BaseModel): """Store data for API.""" + id: int position: Position class DestinationData(BaseModel): """Destination data for API.""" + id: int position: Position class TunnelData(BaseModel): """Tunnel data for API.""" + entrance1: Position entrance2: Position cost: Optional[int] = None @@ -53,8 +60,10 @@ class TunnelData(BaseModel): # Request Models + class GridConfig(BaseModel): """Configuration for grid generation.""" + width: Optional[int] = Field(None, ge=5, le=50) height: Optional[int] = Field(None, ge=5, le=50) num_stores: Optional[int] = Field(None, ge=1, le=3) @@ -65,6 +74,7 @@ class GridConfig(BaseModel): class SearchRequest(BaseModel): """Request for running a search/plan.""" + initial_state: str traffic: str strategy: Algorithm @@ -73,6 +83,7 @@ class SearchRequest(BaseModel): class PathRequest(BaseModel): """Request for finding a single path.""" + grid_width: int grid_height: int start: Position @@ -84,14 +95,17 @@ class PathRequest(BaseModel): class CompareRequest(BaseModel): """Request for comparing all algorithms.""" + initial_state: str traffic: str # Response Models + class PathData(BaseModel): """Path result data.""" + plan: str cost: float nodes_expanded: int @@ -100,6 +114,7 @@ class PathData(BaseModel): class GridData(BaseModel): """Complete grid state data.""" + width: int height: int stores: List[StoreData] @@ -110,6 +125,7 @@ class GridData(BaseModel): class GenerateResponse(BaseModel): """Response from grid generation.""" + initial_state: str traffic: str parsed: GridData @@ -117,11 +133,12 @@ class GenerateResponse(BaseModel): class SearchResponse(BaseModel): """Response from search/plan execution.""" + plan: str cost: float nodes_expanded: int runtime_ms: float - memory_mb: float + memory_kb: float cpu_percent: float path: List[Position] steps: Optional[List[dict]] = None @@ -129,36 +146,40 @@ class SearchResponse(BaseModel): class PlanResponse(BaseModel): """Response from delivery planning.""" + output: str assignments: List[dict] total_cost: float total_nodes_expanded: int runtime_ms: float - memory_mb: float + memory_kb: float cpu_percent: float class ComparisonResult(BaseModel): """Result of comparing a single algorithm.""" + algorithm: str name: str plan: str cost: float nodes_expanded: int runtime_ms: float - memory_mb: float + memory_kb: float cpu_percent: float is_optimal: bool = False class CompareResponse(BaseModel): """Response from algorithm comparison.""" + comparisons: List[ComparisonResult] optimal_cost: float class AlgorithmInfo(BaseModel): """Information about an algorithm.""" + code: str name: str description: str @@ -166,4 +187,5 @@ class AlgorithmInfo(BaseModel): class AlgorithmsResponse(BaseModel): """List of available algorithms.""" + algorithms: List[AlgorithmInfo] diff --git a/backend/app/models/state.py b/backend/app/models/state.py index 0e53a4fb703ea89931ad3fa9e6dfcd66c6735061..f4b56d38345ce502ec3285f261fd9cd6b152a355 100644 --- a/backend/app/models/state.py +++ b/backend/app/models/state.py @@ -1,4 +1,5 @@ """State models for search and planning results.""" + from dataclasses import dataclass, field from typing import List, Optional, Tuple from .grid import Grid @@ -8,6 +9,7 @@ from .entities import Store, Destination, Tunnel @dataclass class SearchState: """Represents the complete state for a delivery search problem.""" + grid: Grid stores: List[Store] destinations: List[Destination] @@ -25,17 +27,20 @@ class SearchState: "grid": self.grid.to_dict(), "stores": [s.to_dict() for s in self.stores], "destinations": [d.to_dict() for d in self.destinations], - "tunnels": [t.to_dict() for t in self.tunnels] + "tunnels": [t.to_dict() for t in self.tunnels], } @dataclass class PathResult: """Result of finding a path from start to goal.""" + plan: str # Comma-separated actions: "up,down,left,right,tunnel" cost: float # Total traffic cost nodes_expanded: int # Number of nodes expanded during search - path: List[Tuple[int, int]] = field(default_factory=list) # Actual positions in path + path: List[Tuple[int, int]] = field( + default_factory=list + ) # Actual positions in path def to_string(self) -> str: """Format as required: plan;cost;nodesExpanded""" @@ -46,13 +51,14 @@ class PathResult: "plan": self.plan, "cost": self.cost, "nodes_expanded": self.nodes_expanded, - "path": [{"x": p[0], "y": p[1]} for p in self.path] + "path": [{"x": p[0], "y": p[1]} for p in self.path], } @dataclass class DeliveryAssignment: """Assignment of a destination to a store/truck.""" + store_id: int destination_id: int path_result: PathResult @@ -61,13 +67,14 @@ class DeliveryAssignment: return { "store_id": self.store_id, "destination_id": self.destination_id, - "path": self.path_result.to_dict() + "path": self.path_result.to_dict(), } @dataclass class PlanResult: """Result of the complete delivery planning.""" + assignments: List[DeliveryAssignment] total_cost: float total_nodes_expanded: int @@ -85,13 +92,14 @@ class PlanResult: return { "assignments": [a.to_dict() for a in self.assignments], "total_cost": self.total_cost, - "total_nodes_expanded": self.total_nodes_expanded + "total_nodes_expanded": self.total_nodes_expanded, } @dataclass class SearchStep: """Represents a single step in the search process for visualization.""" + step_number: int current_node: Tuple[int, int] action: Optional[str] @@ -108,15 +116,16 @@ class SearchStep: "frontier": [{"x": p[0], "y": p[1]} for p in self.frontier], "explored": [{"x": p[0], "y": p[1]} for p in self.explored], "currentPath": [{"x": p[0], "y": p[1]} for p in self.current_path], - "pathCost": self.path_cost + "pathCost": self.path_cost, } @dataclass class SearchMetrics: """Performance metrics for a search execution.""" + runtime_ms: float - memory_mb: float + memory_kb: float cpu_percent: float nodes_expanded: int path_cost: float @@ -125,9 +134,9 @@ class SearchMetrics: def to_dict(self) -> dict: return { "runtime_ms": self.runtime_ms, - "memory_mb": self.memory_mb, + "memory_kb": self.memory_kb, "cpu_percent": self.cpu_percent, "nodes_expanded": self.nodes_expanded, "path_cost": self.path_cost, - "path_length": self.path_length + "path_length": self.path_length, } diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 9802be9d6d1a0571b3316ac0743c8a019e166bd0..c22a21291d9764c1d121bfbe6b1c3ce13399b7e0 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,4 +1,5 @@ """Services package.""" + from .parser import ( parse_initial_state, parse_traffic, diff --git a/backend/app/services/grid_generator.py b/backend/app/services/grid_generator.py index 834c36e035e9372426de611215140bcda03ca461..688c3dabac34780d84fce97d1de6a877a2dbc98f 100644 --- a/backend/app/services/grid_generator.py +++ b/backend/app/services/grid_generator.py @@ -1,4 +1,5 @@ """Grid generator service for random grid creation.""" + import random from typing import Tuple, List, Set, Optional from ..models.grid import Grid @@ -14,7 +15,7 @@ def gen_grid( num_destinations: Optional[int] = None, num_tunnels: Optional[int] = None, obstacle_density: float = 0.1, - seed: Optional[int] = None + seed: Optional[int] = None, ) -> Tuple[str, str, SearchState]: """ Randomly generate a valid grid configuration. @@ -38,7 +39,9 @@ def gen_grid( width = width or random.randint(5, 15) height = height or random.randint(5, 15) num_stores = num_stores or random.randint(1, 3) - num_destinations = num_destinations or random.randint(1, min(10, width * height // 4)) + num_destinations = num_destinations or random.randint( + 1, min(10, width * height // 4) + ) num_tunnels = num_tunnels or random.randint(0, min(5, width * height // 10)) # Validate constraints @@ -61,7 +64,9 @@ def gen_grid( grid = _generate_traffic(width, height, obstacle_density, stores, destinations) # Create search state - state = SearchState(grid=grid, stores=stores, destinations=destinations, tunnels=tunnels) + state = SearchState( + grid=grid, stores=stores, destinations=destinations, tunnels=tunnels + ) # Format strings initial_state = format_initial_state(width, height, stores, destinations, tunnels) @@ -71,10 +76,7 @@ def gen_grid( def _generate_stores( - width: int, - height: int, - num_stores: int, - occupied: Set[Tuple[int, int]] + width: int, height: int, num_stores: int, occupied: Set[Tuple[int, int]] ) -> List[Store]: """Generate store positions at corners/edges.""" stores = [] @@ -112,10 +114,7 @@ def _generate_stores( def _generate_destinations( - width: int, - height: int, - num_destinations: int, - occupied: Set[Tuple[int, int]] + width: int, height: int, num_destinations: int, occupied: Set[Tuple[int, int]] ) -> List[Destination]: """Generate random destination positions.""" destinations = [] @@ -137,10 +136,7 @@ def _generate_destinations( def _generate_tunnels( - width: int, - height: int, - num_tunnels: int, - occupied: Set[Tuple[int, int]] + width: int, height: int, num_tunnels: int, occupied: Set[Tuple[int, int]] ) -> List[Tunnel]: """Generate random tunnel pairs.""" tunnels = [] @@ -154,18 +150,35 @@ def _generate_tunnels( random.shuffle(available) - # Need at least 2 positions per tunnel - for i in range(min(num_tunnels, len(available) // 2)): - entrance1 = available[i * 2] - entrance2 = available[i * 2 + 1] + i = 0 + attempts = 0 + max_attempts = len(available) * 2 + + while i < num_tunnels and len(available) >= 2 and attempts < max_attempts: + attempts += 1 + idx1 = random.randint(0, len(available) - 1) + entrance1 = available[idx1] - # Ensure tunnels are useful (span reasonable distance) + remaining = [pos for j, pos in enumerate(available) if j != idx1] + if not remaining: + break + + idx2 = random.randint(0, len(remaining) - 1) + entrance2 = remaining[idx2] + + # Ensure tunnels are useful dist = abs(entrance1[0] - entrance2[0]) + abs(entrance1[1] - entrance2[1]) if dist >= 3: # Only create if Manhattan distance >= 3 tunnels.append(Tunnel(entrance1=entrance1, entrance2=entrance2)) occupied.add(entrance1) occupied.add(entrance2) + # Remove used positions from available + available.remove(entrance1) + available.remove(entrance2) + + i += 1 + return tunnels @@ -174,7 +187,7 @@ def _generate_traffic( height: int, obstacle_density: float, stores: List[Store], - destinations: List[Destination] + destinations: List[Destination], ) -> Grid: """ Generate traffic levels for all segments. @@ -212,9 +225,7 @@ def _generate_traffic( def _ensure_connectivity( - grid: Grid, - stores: List[Store], - destinations: List[Destination] + grid: Grid, stores: List[Store], destinations: List[Destination] ) -> None: """ Ensure the grid is connected between stores and destinations. @@ -222,7 +233,9 @@ def _ensure_connectivity( Uses BFS to check connectivity and unblocks segments if needed. """ # Get all important positions - important_positions = [s.position for s in stores] + [d.position for d in destinations] + important_positions = [s.position for s in stores] + [ + d.position for d in destinations + ] if len(important_positions) < 2: return @@ -253,7 +266,7 @@ def _create_path_to( grid: Grid, start: Tuple[int, int], goal: Tuple[int, int], - visited: Set[Tuple[int, int]] + visited: Set[Tuple[int, int]], ) -> None: """Create a path from visited area to goal by unblocking segments.""" # Simple approach: find closest visited cell to goal and unblock path diff --git a/backend/app/services/metrics.py b/backend/app/services/metrics.py index 1344d8a5c1e9dee17952ba214cb9aa3731cf0c5b..586fbba1afb3499563efb4a83e35e01b7cee92bf 100644 --- a/backend/app/services/metrics.py +++ b/backend/app/services/metrics.py @@ -1,4 +1,5 @@ """Performance metrics collection service.""" + import time import psutil from contextlib import contextmanager @@ -17,6 +18,7 @@ class MetricsCollector: self.start_memory: int = 0 self.end_memory: int = 0 self.peak_memory: int = 0 + self.memory_samples: list = [] self.cpu_samples: list = [] self._process = psutil.Process() @@ -25,6 +27,7 @@ class MetricsCollector: self.start_time = time.perf_counter() self.start_memory = self._process.memory_info().rss self.peak_memory = self.start_memory + self.memory_samples = [self.start_memory] self.cpu_samples = [] # Initial CPU sample self._process.cpu_percent() @@ -32,6 +35,7 @@ class MetricsCollector: def sample(self) -> None: """Take a sample of current metrics.""" current_memory = self._process.memory_info().rss + self.memory_samples.append(current_memory) self.peak_memory = max(self.peak_memory, current_memory) self.cpu_samples.append(self._process.cpu_percent()) @@ -39,6 +43,8 @@ class MetricsCollector: """Stop collecting metrics.""" self.end_time = time.perf_counter() self.end_memory = self._process.memory_info().rss + self.memory_samples.append(self.end_memory) + self.peak_memory = max(self.peak_memory, self.end_memory) # Final CPU sample self.cpu_samples.append(self._process.cpu_percent()) @@ -48,9 +54,13 @@ class MetricsCollector: return (self.end_time - self.start_time) * 1000 @property - def memory_mb(self) -> float: - """Get peak memory usage in MB.""" - return (self.peak_memory - self.start_memory) / (1024 * 1024) + def memory_kb(self) -> float: + """Get memory usage in KB (peak minus baseline).""" + if len(self.memory_samples) > 1: + # Use max sample minus start for more accurate peak measurement + max_sample = max(self.memory_samples) + return (max_sample - self.start_memory) / 1024 + return (self.peak_memory - self.start_memory) / 1024 @property def cpu_percent(self) -> float: @@ -59,15 +69,17 @@ class MetricsCollector: return 0.0 return sum(self.cpu_samples) / len(self.cpu_samples) - def to_metrics(self, nodes_expanded: int, path_cost: float, path_length: int) -> SearchMetrics: + def to_metrics( + self, nodes_expanded: int, path_cost: float, path_length: int + ) -> SearchMetrics: """Convert to SearchMetrics object.""" return SearchMetrics( runtime_ms=self.runtime_ms, - memory_mb=max(0, self.memory_mb), # Ensure non-negative + memory_kb=max(0, self.memory_kb), # Ensure non-negative cpu_percent=self.cpu_percent, nodes_expanded=nodes_expanded, path_cost=path_cost, - path_length=path_length + path_length=path_length, ) @@ -90,9 +102,7 @@ def measure_performance() -> Generator[MetricsCollector, None, None]: def run_with_metrics( - func: Callable[..., Any], - *args, - **kwargs + func: Callable[..., Any], *args, **kwargs ) -> Tuple[Any, MetricsCollector]: """ Run a function and collect performance metrics. diff --git a/backend/app/services/parser.py b/backend/app/services/parser.py index a6f6cb88e6c322e0572ea376b0c1a37247141e94..f6a547f97f424007e832a7665050c90e11b26b19 100644 --- a/backend/app/services/parser.py +++ b/backend/app/services/parser.py @@ -1,17 +1,21 @@ """Parser service for initial state and traffic strings.""" + from typing import Tuple, List from ..models.grid import Grid from ..models.entities import Store, Destination, Tunnel from ..models.state import SearchState -def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List[Destination], List[Tunnel]]: +def parse_initial_state( + initial_state: str, +) -> Tuple[int, int, List[Store], List[Destination], List[Tunnel]]: """ Parse the initial state string. Format: m;n;P;S;CustomerX_1,CustomerY_1,CustomerX_2,CustomerY_2,...; - TunnelX_1,TunnelY_1,TunnelX_1',TunnelY_1',TunnelX_2,TunnelY_2,TunnelX_2',TunnelY_2',... + TunnelX_1,TunnelY_1,TunnelX_1',TunnelY_1',TunnelX_2,TunnelY_2,TunnelX_2',TunnelY_2',...; + StoreX_1,StoreY_1,StoreX_2,StoreY_2,... Args: initial_state: The initial state string @@ -19,7 +23,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List Returns: Tuple of (width, height, stores, destinations, tunnels) """ - parts = initial_state.strip().split(';') + parts = initial_state.strip().split(";") # Grid dimensions width = int(parts[0]) # m @@ -32,7 +36,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List # Parse customer locations destinations: List[Destination] = [] if len(parts) > 4 and parts[4]: - customer_coords = parts[4].split(',') + customer_coords = parts[4].split(",") for i in range(0, len(customer_coords), 2): if i + 1 < len(customer_coords): x = int(customer_coords[i]) @@ -43,7 +47,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List # Parse tunnel locations tunnels: List[Tunnel] = [] if len(parts) > 5 and parts[5]: - tunnel_coords = parts[5].split(',') + tunnel_coords = parts[5].split(",") for i in range(0, len(tunnel_coords), 4): if i + 3 < len(tunnel_coords): x1 = int(tunnel_coords[i]) @@ -52,64 +56,20 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List y2 = int(tunnel_coords[i + 3]) tunnels.append(Tunnel(entrance1=(x1, y1), entrance2=(x2, y2))) - # Generate stores (positions need to be provided or generated) - # For now, place stores at corners/edges + # Parse store locations stores: List[Store] = [] - store_positions = _generate_store_positions(width, height, num_stores, destinations, tunnels) - for i, pos in enumerate(store_positions): - stores.append(Store(id=i + 1, position=pos)) + if len(parts) > 6 and parts[6]: + store_coords = parts[6].split(",") + for i in range(0, len(store_coords), 2): + if i + 1 < len(store_coords): + x = int(store_coords[i]) + y = int(store_coords[i + 1]) + store_id = len(stores) + 1 + stores.append(Store(id=store_id, position=(x, y))) return width, height, stores, destinations, tunnels -def _generate_store_positions( - width: int, - height: int, - num_stores: int, - destinations: List[Destination], - tunnels: List[Tunnel] -) -> List[Tuple[int, int]]: - """ - Generate store positions avoiding conflicts. - - Places stores at corners and edges of the grid. - """ - occupied = set() - for dest in destinations: - occupied.add(dest.position) - for tunnel in tunnels: - occupied.add(tunnel.entrance1) - occupied.add(tunnel.entrance2) - - # Preferred positions (corners first, then edges) - preferred = [ - (0, 0), - (width - 1, 0), - (0, height - 1), - (width - 1, height - 1), - (width // 2, 0), - (0, height // 2), - (width - 1, height // 2), - (width // 2, height - 1), - ] - - positions = [] - for pos in preferred: - if pos not in occupied and len(positions) < num_stores: - positions.append(pos) - occupied.add(pos) - - # If still need more positions, find any valid position - if len(positions) < num_stores: - for x in range(width): - for y in range(height): - if (x, y) not in occupied and len(positions) < num_stores: - positions.append((x, y)) - occupied.add((x, y)) - - return positions - - def parse_traffic(traffic_str: str, width: int, height: int) -> Grid: """ Parse the traffic string and create a Grid. @@ -132,11 +92,11 @@ def parse_traffic(traffic_str: str, width: int, height: int) -> Grid: _initialize_default_traffic(grid) return grid - segments = traffic_str.strip().split(';') + segments = traffic_str.strip().split(";") for segment in segments: if not segment: continue - parts = segment.split(',') + parts = segment.split(",") if len(parts) >= 5: src_x = int(parts[0]) src_y = int(parts[1]) @@ -179,10 +139,7 @@ def parse_full_state(initial_state: str, traffic_str: str) -> SearchState: grid = parse_traffic(traffic_str, width, height) return SearchState( - grid=grid, - stores=stores, - destinations=destinations, - tunnels=tunnels + grid=grid, stores=stores, destinations=destinations, tunnels=tunnels ) @@ -191,7 +148,7 @@ def format_initial_state( height: int, stores: List[Store], destinations: List[Destination], - tunnels: List[Tunnel] + tunnels: List[Tunnel], ) -> str: """ Format state back into initial state string. @@ -217,18 +174,28 @@ def format_initial_state( customer_coords = [] for dest in destinations: customer_coords.extend([str(dest.position[0]), str(dest.position[1])]) - parts.append(','.join(customer_coords)) + parts.append(",".join(customer_coords)) # Tunnel coordinates tunnel_coords = [] for tunnel in tunnels: - tunnel_coords.extend([ - str(tunnel.entrance1[0]), str(tunnel.entrance1[1]), - str(tunnel.entrance2[0]), str(tunnel.entrance2[1]) - ]) - parts.append(','.join(tunnel_coords)) + tunnel_coords.extend( + [ + str(tunnel.entrance1[0]), + str(tunnel.entrance1[1]), + str(tunnel.entrance2[0]), + str(tunnel.entrance2[1]), + ] + ) + parts.append(",".join(tunnel_coords)) - return ';'.join(parts) + # Store coordinates + store_coords = [] + for store in stores: + store_coords.extend([str(store.position[0]), str(store.position[1])]) + parts.append(",".join(store_coords)) + + return ";".join(parts) def format_traffic(grid: Grid) -> str: @@ -243,7 +210,5 @@ def format_traffic(grid: Grid) -> str: """ segments = [] for (src, dst), segment in grid.segments.items(): - segments.append( - f"{src[0]},{src[1]},{dst[0]},{dst[1]},{segment.traffic}" - ) - return ';'.join(segments) + segments.append(f"{src[0]},{src[1]},{dst[0]},{dst[1]},{segment.traffic}") + return ";".join(segments) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d94e1d45f407a569165b9c731ec2c73904cdc38e..179e0dcf35a5812c13a065eae5f1904ca1f49a88 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,6 +5,7 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.10" dependencies = [ + "black>=25.11.0", "fastapi>=0.122.0", "httpx>=0.28.1", "psutil>=7.1.3", @@ -16,3 +17,10 @@ dependencies = [ "uvicorn>=0.38.0", "websockets>=15.0.1", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c52ee10afe932ba59410170544434031e0b90c92 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the delivery search backend.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..4d42c51f3596f0c9b3e3292586f106780d8ba99b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,111 @@ +"""Pytest configuration and fixtures for backend tests.""" + +import pytest +from app.models.grid import Grid, Segment +from app.models.entities import Store, Destination, Tunnel +from app.models.state import SearchState + + +@pytest.fixture +def simple_grid(): + """Create a simple 3x3 grid with all segments having traffic level 1.""" + grid = Grid(width=3, height=3) + # Horizontal segments + for x in range(2): + for y in range(3): + grid.add_segment((x, y), (x + 1, y), 1) + # Vertical segments + for x in range(3): + for y in range(2): + grid.add_segment((x, y), (x, y + 1), 1) + return grid + + +@pytest.fixture +def grid_with_blocked(): + """Create a 3x3 grid with some blocked segments.""" + grid = Grid(width=3, height=3) + # Add all segments with traffic level 1 + for x in range(2): + for y in range(3): + grid.add_segment((x, y), (x + 1, y), 1) + for x in range(3): + for y in range(2): + grid.add_segment((x, y), (x, y + 1), 1) + # Block segment from (1,1) to (2,1) + grid.add_segment((1, 1), (2, 1), 0) + return grid + + +@pytest.fixture +def grid_with_varied_traffic(): + """Create a 3x3 grid with varied traffic levels.""" + grid = Grid(width=3, height=3) + # Horizontal segments with different traffic + grid.add_segment((0, 0), (1, 0), 1) + grid.add_segment((1, 0), (2, 0), 2) + grid.add_segment((0, 1), (1, 1), 3) + grid.add_segment((1, 1), (2, 1), 4) + grid.add_segment((0, 2), (1, 2), 1) + grid.add_segment((1, 2), (2, 2), 1) + # Vertical segments + grid.add_segment((0, 0), (0, 1), 2) + grid.add_segment((0, 1), (0, 2), 1) + grid.add_segment((1, 0), (1, 1), 1) + grid.add_segment((1, 1), (1, 2), 2) + grid.add_segment((2, 0), (2, 1), 1) + grid.add_segment((2, 1), (2, 2), 1) + return grid + + +@pytest.fixture +def sample_stores(): + """Create sample stores.""" + return [ + Store(id=1, position=(0, 0)), + Store(id=2, position=(2, 2)), + ] + + +@pytest.fixture +def sample_destinations(): + """Create sample destinations.""" + return [ + Destination(id=1, position=(2, 0)), + Destination(id=2, position=(0, 2)), + Destination(id=3, position=(1, 1)), + ] + + +@pytest.fixture +def sample_tunnels(): + """Create sample tunnels.""" + return [ + Tunnel(entrance1=(0, 0), entrance2=(2, 2)), + Tunnel(entrance1=(0, 2), entrance2=(2, 0)), + ] + + +@pytest.fixture +def sample_search_state(simple_grid, sample_stores, sample_destinations, sample_tunnels): + """Create a complete search state.""" + return SearchState( + grid=simple_grid, + stores=sample_stores, + destinations=sample_destinations, + tunnels=sample_tunnels, + ) + + +@pytest.fixture +def larger_grid(): + """Create a 5x5 grid for more complex tests.""" + grid = Grid(width=5, height=5) + # Add all segments with traffic level 1 + for x in range(4): + for y in range(5): + grid.add_segment((x, y), (x + 1, y), 1) + for x in range(5): + for y in range(4): + grid.add_segment((x, y), (x, y + 1), 1) + return grid diff --git a/backend/tests/test_algorithms.py b/backend/tests/test_algorithms.py new file mode 100644 index 0000000000000000000000000000000000000000..18457d3024f303882265d292a17f0344dbee42ba --- /dev/null +++ b/backend/tests/test_algorithms.py @@ -0,0 +1,315 @@ +"""Tests for search algorithms (BFS, DFS, UCS, A*, Greedy, IDS).""" + +import pytest +from app.algorithms.bfs import bfs_search +from app.algorithms.dfs import dfs_search +from app.algorithms.ucs import ucs_search +from app.algorithms.astar import astar_search +from app.algorithms.greedy import greedy_search +from app.algorithms.ids import ids_search +from app.heuristics import manhattan_heuristic, euclidean_heuristic +from app.core.delivery_search import DeliverySearch +from app.models.grid import Grid + + +class TestBFS: + """Tests for Breadth-First Search.""" + + def test_bfs_finds_path(self, simple_grid): + """Test BFS finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, steps = bfs_search(search, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + assert len(result.path) > 0 + assert result.path[0] == (0, 0) + assert result.path[-1] == (2, 2) + + def test_bfs_shortest_path(self, simple_grid): + """Test BFS finds shortest path by steps.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, _ = bfs_search(search, visualize=False) + + # BFS should find path with minimum steps + # From (0,0) to (2,2) needs at least 4 steps + assert len(result.path) == 5 # 5 nodes including start and end + + def test_bfs_no_path(self): + """Test BFS when no path exists.""" + grid = Grid(width=3, height=3) + # Create disconnected grid - only add vertical segment + grid.add_segment((0, 0), (0, 1), 1) + + search = DeliverySearch(grid, (0, 0), (2, 2), []) + result, _ = bfs_search(search, visualize=False) + + assert result.plan == "" + assert result.cost == float("inf") + assert len(result.path) == 0 + + def test_bfs_same_start_goal(self, simple_grid): + """Test BFS when start equals goal.""" + search = DeliverySearch(simple_grid, (1, 1), (1, 1), []) + result, _ = bfs_search(search, visualize=False) + + assert result.cost == 0 + assert len(result.path) == 1 + assert result.path[0] == (1, 1) + + def test_bfs_visualization(self, simple_grid): + """Test BFS with visualization enabled.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, steps = bfs_search(search, visualize=True) + + assert steps is not None + assert len(steps) > 0 + # First step should have start node + assert steps[0].current_node == (0, 0) + + +class TestDFS: + """Tests for Depth-First Search.""" + + def test_dfs_finds_path(self, simple_grid): + """Test DFS finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, steps = dfs_search(search, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + assert len(result.path) > 0 + assert result.path[0] == (0, 0) + assert result.path[-1] == (2, 2) + + def test_dfs_no_path(self): + """Test DFS when no path exists.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (0, 1), 1) + + search = DeliverySearch(grid, (0, 0), (2, 2), []) + result, _ = dfs_search(search, visualize=False) + + assert result.plan == "" + assert result.cost == float("inf") + + def test_dfs_same_start_goal(self, simple_grid): + """Test DFS when start equals goal.""" + search = DeliverySearch(simple_grid, (1, 1), (1, 1), []) + result, _ = dfs_search(search, visualize=False) + + assert result.cost == 0 + assert len(result.path) == 1 + + +class TestUCS: + """Tests for Uniform Cost Search.""" + + def test_ucs_finds_optimal_path(self, grid_with_varied_traffic): + """Test UCS finds optimal (minimum cost) path.""" + search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), []) + result, _ = ucs_search(search, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + + def test_ucs_prefers_low_traffic(self, grid_with_varied_traffic): + """Test UCS prefers lower traffic segments.""" + search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), []) + ucs_result, _ = ucs_search(search, visualize=False) + bfs_result, _ = bfs_search(search, visualize=False) + + # UCS should find equal or better cost than BFS + assert ucs_result.cost <= bfs_result.cost + + def test_ucs_no_path(self): + """Test UCS when no path exists.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (0, 1), 1) + + search = DeliverySearch(grid, (0, 0), (2, 2), []) + result, _ = ucs_search(search, visualize=False) + + assert result.cost == float("inf") + + def test_ucs_same_start_goal(self, simple_grid): + """Test UCS when start equals goal.""" + search = DeliverySearch(simple_grid, (1, 1), (1, 1), []) + result, _ = ucs_search(search, visualize=False) + + assert result.cost == 0 + + +class TestAStar: + """Tests for A* Search.""" + + def test_astar_manhattan_finds_path(self, simple_grid): + """Test A* with Manhattan heuristic finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, _ = astar_search(search, manhattan_heuristic, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + assert result.path[0] == (0, 0) + assert result.path[-1] == (2, 2) + + def test_astar_euclidean_finds_path(self, simple_grid): + """Test A* with Euclidean heuristic finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, _ = astar_search(search, euclidean_heuristic, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + + def test_astar_optimal(self, grid_with_varied_traffic): + """Test A* finds optimal path (same as UCS with admissible heuristic).""" + search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), []) + astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False) + ucs_result, _ = ucs_search(search, visualize=False) + + # A* with admissible heuristic should find same cost as UCS + assert astar_result.cost == ucs_result.cost + + def test_astar_fewer_nodes_than_ucs(self, larger_grid): + """Test A* expands fewer nodes than UCS.""" + search = DeliverySearch(larger_grid, (0, 0), (4, 4), []) + astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False) + ucs_result, _ = ucs_search(search, visualize=False) + + # A* should expand fewer or equal nodes + assert astar_result.nodes_expanded <= ucs_result.nodes_expanded + + def test_astar_no_path(self): + """Test A* when no path exists.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (0, 1), 1) + + search = DeliverySearch(grid, (0, 0), (2, 2), []) + result, _ = astar_search(search, manhattan_heuristic, visualize=False) + + assert result.cost == float("inf") + + +class TestGreedy: + """Tests for Greedy Best-First Search.""" + + def test_greedy_manhattan_finds_path(self, simple_grid): + """Test Greedy with Manhattan heuristic finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, _ = greedy_search(search, manhattan_heuristic, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + + def test_greedy_euclidean_finds_path(self, simple_grid): + """Test Greedy with Euclidean heuristic finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, _ = greedy_search(search, euclidean_heuristic, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + + def test_greedy_fast_but_suboptimal(self, grid_with_varied_traffic): + """Test Greedy is fast but may not find optimal path.""" + search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), []) + greedy_result, _ = greedy_search(search, manhattan_heuristic, visualize=False) + ucs_result, _ = ucs_search(search, visualize=False) + + # Greedy may find suboptimal path + assert greedy_result.cost >= ucs_result.cost + + def test_greedy_no_path(self): + """Test Greedy when no path exists.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (0, 1), 1) + + search = DeliverySearch(grid, (0, 0), (2, 2), []) + result, _ = greedy_search(search, manhattan_heuristic, visualize=False) + + assert result.cost == float("inf") + + +class TestIDS: + """Tests for Iterative Deepening Search.""" + + def test_ids_finds_path(self, simple_grid): + """Test IDS finds a path.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + result, _ = ids_search(search, visualize=False) + + assert result.plan != "" + assert result.cost < float("inf") + assert result.path[0] == (0, 0) + assert result.path[-1] == (2, 2) + + def test_ids_optimal_steps(self, simple_grid): + """Test IDS finds path with optimal number of steps.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + ids_result, _ = ids_search(search, visualize=False) + bfs_result, _ = bfs_search(search, visualize=False) + + # IDS should find same path length as BFS + assert len(ids_result.path) == len(bfs_result.path) + + def test_ids_no_path(self): + """Test IDS when no path exists.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (0, 1), 1) + + search = DeliverySearch(grid, (0, 0), (2, 2), []) + result, _ = ids_search(search, visualize=False) + + assert result.cost == float("inf") + + def test_ids_same_start_goal(self, simple_grid): + """Test IDS when start equals goal.""" + search = DeliverySearch(simple_grid, (1, 1), (1, 1), []) + result, _ = ids_search(search, visualize=False) + + assert result.cost == 0 + assert len(result.path) == 1 + + +class TestAlgorithmComparison: + """Tests comparing different algorithms.""" + + def test_all_algorithms_find_same_goal(self, simple_grid): + """Test all algorithms reach the same goal.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + + bfs_result, _ = bfs_search(search, visualize=False) + dfs_result, _ = dfs_search(search, visualize=False) + ucs_result, _ = ucs_search(search, visualize=False) + astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False) + greedy_result, _ = greedy_search(search, manhattan_heuristic, visualize=False) + ids_result, _ = ids_search(search, visualize=False) + + # All should find a path ending at goal + assert bfs_result.path[-1] == (2, 2) + assert dfs_result.path[-1] == (2, 2) + assert ucs_result.path[-1] == (2, 2) + assert astar_result.path[-1] == (2, 2) + assert greedy_result.path[-1] == (2, 2) + assert ids_result.path[-1] == (2, 2) + + def test_optimal_algorithms_same_cost(self, grid_with_varied_traffic): + """Test UCS and A* find same optimal cost.""" + search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), []) + + ucs_result, _ = ucs_search(search, visualize=False) + astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False) + + assert ucs_result.cost == astar_result.cost + + def test_visualization_consistency(self, simple_grid): + """Test visualization steps are consistent across algorithms.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + + _, bfs_steps = bfs_search(search, visualize=True) + _, ucs_steps = ucs_search(search, visualize=True) + + # Both should have visualization steps + assert bfs_steps is not None + assert ucs_steps is not None + assert len(bfs_steps) > 0 + assert len(ucs_steps) > 0 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..e7698a2fd28ae33d585d3fbf8bd92043571585ad --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,394 @@ +"""Tests for API routes.""" + +import pytest +from fastapi.testclient import TestClient +from app.main import app + + +@pytest.fixture +def client(): + """Create test client.""" + return TestClient(app) + + +@pytest.fixture +def sample_initial_state(): + """Sample initial state string.""" + return "5;5;2;1;2,2,4,4;;0,0" + + +@pytest.fixture +def sample_traffic(): + """Sample traffic string for 5x5 grid.""" + segments = [] + # Horizontal segments + for x in range(4): + for y in range(5): + segments.append(f"{x},{y},{x+1},{y},1") + # Vertical segments + for x in range(5): + for y in range(4): + segments.append(f"{x},{y},{x},{y+1},1") + return ";".join(segments) + + +class TestHealthEndpoint: + """Tests for health check endpoint.""" + + def test_health_check(self, client): + """Test health check returns ok.""" + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +class TestAlgorithmsEndpoint: + """Tests for algorithms endpoint.""" + + def test_list_algorithms(self, client): + """Test listing available algorithms.""" + response = client.get("/api/algorithms") + assert response.status_code == 200 + + data = response.json() + assert "algorithms" in data + assert len(data["algorithms"]) == 8 # 8 algorithms + + # Check algorithm structure + algo = data["algorithms"][0] + assert "code" in algo + assert "name" in algo + assert "description" in algo + + def test_algorithms_codes(self, client): + """Test all expected algorithm codes are present.""" + response = client.get("/api/algorithms") + data = response.json() + + codes = [a["code"] for a in data["algorithms"]] + expected = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"] + assert set(codes) == set(expected) + + +class TestGenerateGridEndpoint: + """Tests for grid generation endpoint.""" + + def test_generate_default_grid(self, client): + """Test generating grid with default config.""" + response = client.post("/api/grid/generate", json={}) + assert response.status_code == 200 + + data = response.json() + assert "initial_state" in data + assert "traffic" in data + assert "parsed" in data + + def test_generate_custom_grid(self, client): + """Test generating grid with custom config.""" + response = client.post( + "/api/grid/generate", + json={ + "width": 8, + "height": 8, + "num_stores": 2, + "num_destinations": 3, + "num_tunnels": 1, + "obstacle_density": 0.1, + }, + ) + assert response.status_code == 200 + + data = response.json() + parsed = data["parsed"] + assert parsed["width"] == 8 + assert parsed["height"] == 8 + assert len(parsed["stores"]) == 2 + assert len(parsed["destinations"]) == 3 + + def test_generate_grid_parsed_structure(self, client): + """Test parsed grid structure.""" + response = client.post("/api/grid/generate", json={"width": 5, "height": 5}) + data = response.json() + parsed = data["parsed"] + + assert "width" in parsed + assert "height" in parsed + assert "stores" in parsed + assert "destinations" in parsed + assert "tunnels" in parsed + assert "segments" in parsed + + def test_generate_grid_stores_structure(self, client): + """Test store structure in response.""" + response = client.post( + "/api/grid/generate", + json={"width": 5, "height": 5, "num_stores": 1}, + ) + data = response.json() + store = data["parsed"]["stores"][0] + + assert "id" in store + assert "position" in store + assert "x" in store["position"] + assert "y" in store["position"] + + +class TestPathEndpoint: + """Tests for path finding endpoint.""" + + def test_find_path(self, client): + """Test finding path between two points.""" + # First generate a grid + gen_response = client.post( + "/api/grid/generate", + json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1}, + ) + grid_data = gen_response.json()["parsed"] + + # Find path from store to destination + store = grid_data["stores"][0] + dest = grid_data["destinations"][0] + + response = client.post( + "/api/search/path", + json={ + "grid_width": 5, + "grid_height": 5, + "start": store["position"], + "goal": dest["position"], + "segments": grid_data["segments"], + "tunnels": grid_data["tunnels"], + "strategy": "BF", + }, + ) + assert response.status_code == 200 + + data = response.json() + assert "plan" in data + assert "cost" in data + assert "nodes_expanded" in data + assert "path" in data + assert "runtime_ms" in data + assert "memory_kb" in data + + def test_find_path_all_strategies(self, client): + """Test path finding with all strategies.""" + gen_response = client.post( + "/api/grid/generate", + json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1}, + ) + grid_data = gen_response.json()["parsed"] + store = grid_data["stores"][0] + dest = grid_data["destinations"][0] + + strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"] + for strategy in strategies: + response = client.post( + "/api/search/path", + json={ + "grid_width": 5, + "grid_height": 5, + "start": store["position"], + "goal": dest["position"], + "segments": grid_data["segments"], + "tunnels": grid_data["tunnels"], + "strategy": strategy, + }, + ) + assert response.status_code == 200, f"Strategy {strategy} failed" + + def test_find_path_with_visualization(self, client): + """Test path finding returns visualization steps.""" + gen_response = client.post( + "/api/grid/generate", + json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1}, + ) + grid_data = gen_response.json()["parsed"] + store = grid_data["stores"][0] + dest = grid_data["destinations"][0] + + response = client.post( + "/api/search/path", + json={ + "grid_width": 5, + "grid_height": 5, + "start": store["position"], + "goal": dest["position"], + "segments": grid_data["segments"], + "tunnels": grid_data["tunnels"], + "strategy": "BF", + }, + ) + data = response.json() + + # Steps should be included + assert "steps" in data + assert data["steps"] is not None + assert len(data["steps"]) > 0 + + +class TestPlanEndpoint: + """Tests for delivery planning endpoint.""" + + def test_create_plan(self, client, sample_initial_state, sample_traffic): + """Test creating delivery plan.""" + response = client.post( + "/api/search/plan", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + "strategy": "BF", + "visualize": False, + }, + ) + assert response.status_code == 200 + + data = response.json() + assert "output" in data + assert "assignments" in data + assert "total_cost" in data + assert "total_nodes_expanded" in data + assert "runtime_ms" in data + assert "memory_kb" in data + + def test_create_plan_all_strategies(self, client, sample_initial_state, sample_traffic): + """Test planning with all strategies.""" + strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"] + for strategy in strategies: + response = client.post( + "/api/search/plan", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + "strategy": strategy, + "visualize": False, + }, + ) + assert response.status_code == 200, f"Strategy {strategy} failed" + + def test_plan_assignments_structure(self, client, sample_initial_state, sample_traffic): + """Test plan assignments structure.""" + response = client.post( + "/api/search/plan", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + "strategy": "BF", + "visualize": False, + }, + ) + data = response.json() + + # Should have 2 assignments (2 destinations in sample) + assert len(data["assignments"]) == 2 + + assignment = data["assignments"][0] + assert "store_id" in assignment + assert "destination_id" in assignment + assert "path" in assignment + + +class TestCompareEndpoint: + """Tests for algorithm comparison endpoint.""" + + def test_compare_algorithms(self, client, sample_initial_state, sample_traffic): + """Test comparing all algorithms.""" + response = client.post( + "/api/search/compare", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + }, + ) + assert response.status_code == 200 + + data = response.json() + assert "comparisons" in data + assert "optimal_cost" in data + assert len(data["comparisons"]) == 8 # 8 algorithms + + def test_compare_result_structure(self, client, sample_initial_state, sample_traffic): + """Test comparison result structure.""" + response = client.post( + "/api/search/compare", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + }, + ) + data = response.json() + result = data["comparisons"][0] + + assert "algorithm" in result + assert "name" in result + assert "plan" in result + assert "cost" in result + assert "nodes_expanded" in result + assert "runtime_ms" in result + assert "memory_kb" in result + assert "is_optimal" in result + + def test_compare_marks_optimal(self, client, sample_initial_state, sample_traffic): + """Test that optimal solutions are marked.""" + response = client.post( + "/api/search/compare", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + }, + ) + data = response.json() + + # At least one should be marked optimal + optimal_count = sum(1 for r in data["comparisons"] if r["is_optimal"]) + assert optimal_count >= 1 + + # Optimal results should have cost equal to optimal_cost + for result in data["comparisons"]: + if result["is_optimal"]: + assert result["cost"] == data["optimal_cost"] + + def test_compare_ucs_astar_optimal(self, client, sample_initial_state, sample_traffic): + """Test UCS and A* find optimal solutions.""" + response = client.post( + "/api/search/compare", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + }, + ) + data = response.json() + + ucs = next(r for r in data["comparisons"] if r["algorithm"] == "UC") + astar = next(r for r in data["comparisons"] if r["algorithm"] == "AS1") + + # Both should find optimal cost + assert ucs["cost"] == data["optimal_cost"] + assert astar["cost"] == data["optimal_cost"] + + +class TestErrorHandling: + """Tests for API error handling.""" + + def test_invalid_strategy(self, client, sample_initial_state, sample_traffic): + """Test error on invalid strategy.""" + response = client.post( + "/api/search/plan", + json={ + "initial_state": sample_initial_state, + "traffic": sample_traffic, + "strategy": "INVALID", + "visualize": False, + }, + ) + # Should return validation error or internal error + assert response.status_code in [400, 422, 500] + + def test_invalid_grid_dimensions(self, client): + """Test error on invalid grid dimensions.""" + response = client.post( + "/api/grid/generate", + json={"width": -1, "height": 5}, + ) + # Should return validation error + assert response.status_code in [400, 422, 500] diff --git a/backend/tests/test_core.py b/backend/tests/test_core.py new file mode 100644 index 0000000000000000000000000000000000000000..8c8b3bd93610bb7ead3d7dc3a320c2103d186a88 --- /dev/null +++ b/backend/tests/test_core.py @@ -0,0 +1,372 @@ +"""Tests for core modules (delivery_search, delivery_planner).""" + +import pytest +from app.core.delivery_search import DeliverySearch +from app.core.delivery_planner import DeliveryPlanner +from app.core.node import SearchNode +from app.core.frontier import QueueFrontier, StackFrontier, PriorityQueueFrontier +from app.models.grid import Grid +from app.models.entities import Store, Destination, Tunnel + + +class TestSearchNode: + """Tests for SearchNode.""" + + def test_node_creation(self): + """Test basic node creation.""" + node = SearchNode(state=(0, 0), path_cost=0, depth=0) + assert node.state == (0, 0) + assert node.path_cost == 0 + assert node.depth == 0 + assert node.parent is None + assert node.action is None + + def test_node_with_parent(self): + """Test node with parent.""" + parent = SearchNode(state=(0, 0), path_cost=0, depth=0) + child = SearchNode( + state=(1, 0), + parent=parent, + action="right", + path_cost=1, + depth=1, + ) + assert child.parent == parent + assert child.action == "right" + assert child.depth == 1 + + def test_get_path(self): + """Test path reconstruction.""" + root = SearchNode(state=(0, 0), path_cost=0, depth=0) + child1 = SearchNode(state=(1, 0), parent=root, action="right", path_cost=1, depth=1) + child2 = SearchNode(state=(2, 0), parent=child1, action="right", path_cost=2, depth=2) + + path = child2.get_path() + assert path == [(0, 0), (1, 0), (2, 0)] + + def test_get_solution(self): + """Test solution string generation.""" + root = SearchNode(state=(0, 0), path_cost=0, depth=0) + child1 = SearchNode(state=(1, 0), parent=root, action="right", path_cost=1, depth=1) + child2 = SearchNode(state=(1, 1), parent=child1, action="up", path_cost=2, depth=2) + + solution = child2.get_solution() + assert solution == "right,up" + + def test_get_solution_single_node(self): + """Test solution for single node (start = goal).""" + root = SearchNode(state=(0, 0), path_cost=0, depth=0) + assert root.get_solution() == "" + + +class TestFrontiers: + """Tests for frontier data structures.""" + + def test_queue_frontier_fifo(self): + """Test queue frontier is FIFO.""" + frontier = QueueFrontier() + node1 = SearchNode(state=(0, 0), path_cost=0, depth=0) + node2 = SearchNode(state=(1, 0), path_cost=1, depth=1) + node3 = SearchNode(state=(2, 0), path_cost=2, depth=2) + + frontier.push(node1) + frontier.push(node2) + frontier.push(node3) + + assert frontier.pop().state == (0, 0) + assert frontier.pop().state == (1, 0) + assert frontier.pop().state == (2, 0) + + def test_stack_frontier_lifo(self): + """Test stack frontier is LIFO.""" + frontier = StackFrontier() + node1 = SearchNode(state=(0, 0), path_cost=0, depth=0) + node2 = SearchNode(state=(1, 0), path_cost=1, depth=1) + node3 = SearchNode(state=(2, 0), path_cost=2, depth=2) + + frontier.push(node1) + frontier.push(node2) + frontier.push(node3) + + assert frontier.pop().state == (2, 0) + assert frontier.pop().state == (1, 0) + assert frontier.pop().state == (0, 0) + + def test_priority_queue_frontier(self): + """Test priority queue frontier orders by priority.""" + frontier = PriorityQueueFrontier() + # Priority is stored in node.priority attribute + node1 = SearchNode(state=(0, 0), path_cost=5, depth=0) + node1.priority = 5 + node2 = SearchNode(state=(1, 0), path_cost=1, depth=1) + node2.priority = 1 + node3 = SearchNode(state=(2, 0), path_cost=3, depth=2) + node3.priority = 3 + + frontier.push(node1) + frontier.push(node2) + frontier.push(node3) + + assert frontier.pop().state == (1, 0) # Lowest priority first + assert frontier.pop().state == (2, 0) + assert frontier.pop().state == (0, 0) + + def test_frontier_is_empty(self): + """Test frontier empty check.""" + frontier = QueueFrontier() + assert frontier.is_empty() is True + + frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0)) + assert frontier.is_empty() is False + + frontier.pop() + assert frontier.is_empty() is True + + def test_frontier_contains_state(self): + """Test frontier state containment check.""" + frontier = QueueFrontier() + frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0)) + frontier.push(SearchNode(state=(1, 0), path_cost=1, depth=1)) + + assert frontier.contains_state((0, 0)) is True + assert frontier.contains_state((1, 0)) is True + assert frontier.contains_state((2, 0)) is False + + def test_frontier_get_states(self): + """Test getting all states in frontier.""" + frontier = QueueFrontier() + frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0)) + frontier.push(SearchNode(state=(1, 0), path_cost=1, depth=1)) + + states = frontier.get_states() + assert (0, 0) in states + assert (1, 0) in states + + +class TestDeliverySearch: + """Tests for DeliverySearch.""" + + def test_initial_state(self, simple_grid): + """Test initial state is set correctly.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + assert search.initial_state() == (0, 0) + + def test_goal_test(self, simple_grid): + """Test goal test function.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + assert search.goal_test((2, 2)) is True + assert search.goal_test((0, 0)) is False + assert search.goal_test((1, 1)) is False + + def test_actions(self, simple_grid): + """Test available actions.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + + # Corner has 2 actions + actions = search.actions((0, 0)) + assert "up" in actions + assert "right" in actions + assert "down" not in actions + assert "left" not in actions + + # Center has 4 actions + actions = search.actions((1, 1)) + assert len(actions) == 4 + + def test_actions_with_tunnel(self, simple_grid): + """Test tunnel action availability.""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2)) + search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel]) + + actions = search.actions((0, 0)) + assert "tunnel" in actions + + actions = search.actions((1, 1)) + assert "tunnel" not in actions + + def test_result_movement(self, simple_grid): + """Test result of movement actions.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + + assert search.result((1, 1), "up") == (1, 2) + assert search.result((1, 1), "down") == (1, 0) + assert search.result((1, 1), "left") == (0, 1) + assert search.result((1, 1), "right") == (2, 1) + + def test_result_tunnel(self, simple_grid): + """Test result of tunnel action.""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2)) + search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel]) + + assert search.result((0, 0), "tunnel") == (2, 2) + assert search.result((2, 2), "tunnel") == (0, 0) + + def test_step_cost_movement(self, grid_with_varied_traffic): + """Test step cost for movement.""" + search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), []) + + # Cost should equal traffic level + cost = search.step_cost((0, 0), "right", (1, 0)) + assert cost == grid_with_varied_traffic.get_traffic((0, 0), (1, 0)) + + def test_step_cost_tunnel(self, simple_grid): + """Test step cost for tunnel (Manhattan distance).""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2)) + search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel]) + + cost = search.step_cost((0, 0), "tunnel", (2, 2)) + assert cost == tunnel.cost # Manhattan distance = 4 + + def test_solve_all_strategies(self, simple_grid): + """Test solve method with all strategies.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + + strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"] + for strategy in strategies: + result, _ = search.solve(strategy, visualize=False) + assert result.path[-1] == (2, 2), f"Strategy {strategy} failed" + + def test_solve_invalid_strategy(self, simple_grid): + """Test solve with invalid strategy.""" + search = DeliverySearch(simple_grid, (0, 0), (2, 2), []) + + with pytest.raises(ValueError): + search.solve("INVALID", visualize=False) + + def test_path_static_method(self, simple_grid): + """Test static path method.""" + result, steps = DeliverySearch.path( + simple_grid, + (0, 0), + (2, 2), + [], + "BF", + visualize=False, + ) + + assert result.path[0] == (0, 0) + assert result.path[-1] == (2, 2) + + +class TestDeliveryPlanner: + """Tests for DeliveryPlanner.""" + + def test_planner_single_destination(self, simple_grid): + """Test planner with single destination.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + + planner = DeliveryPlanner(simple_grid, stores, destinations, []) + result, _ = planner.plan("BF", visualize=False) + + assert len(result.assignments) == 1 + assert result.assignments[0].store_id == 1 + assert result.assignments[0].destination_id == 1 + assert result.total_cost > 0 + + def test_planner_multiple_destinations(self, simple_grid): + """Test planner with multiple destinations.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [ + Destination(id=1, position=(2, 0)), + Destination(id=2, position=(0, 2)), + Destination(id=3, position=(2, 2)), + ] + + planner = DeliveryPlanner(simple_grid, stores, destinations, []) + result, _ = planner.plan("BF", visualize=False) + + assert len(result.assignments) == 3 + assert result.total_cost > 0 + + def test_planner_multiple_stores(self, simple_grid): + """Test planner assigns to nearest store.""" + stores = [ + Store(id=1, position=(0, 0)), + Store(id=2, position=(2, 2)), + ] + destinations = [Destination(id=1, position=(2, 0))] + + planner = DeliveryPlanner(simple_grid, stores, destinations, []) + result, _ = planner.plan("UC", visualize=False) + + # Should assign to store 1 (closer to destination (2,0)) + assert len(result.assignments) == 1 + + def test_planner_with_tunnels(self, simple_grid): + """Test planner uses tunnels.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + tunnels = [Tunnel(entrance1=(0, 0), entrance2=(2, 2))] + + planner = DeliveryPlanner(simple_grid, stores, destinations, tunnels) + result, _ = planner.plan("UC", visualize=False) + + # Tunnel cost is 4 (Manhattan distance) + # Without tunnel, minimum cost would be 4 (each segment has traffic 1) + assert result.total_cost == 4 + + def test_planner_from_state(self, simple_grid): + """Test static plan_from_state method.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + + result, _ = DeliveryPlanner.plan_from_state( + simple_grid, + stores, + destinations, + [], + "BF", + visualize=False, + ) + + assert len(result.assignments) == 1 + assert result.total_cost > 0 + + def test_planner_total_nodes(self, simple_grid): + """Test total nodes expanded tracking.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [ + Destination(id=1, position=(2, 0)), + Destination(id=2, position=(0, 2)), + ] + + planner = DeliveryPlanner(simple_grid, stores, destinations, []) + result, _ = planner.plan("BF", visualize=False) + + assert result.total_nodes_expanded > 0 + + def test_planner_visualization(self, simple_grid): + """Test planner with visualization.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + + planner = DeliveryPlanner(simple_grid, stores, destinations, []) + result, viz_data = planner.plan("BF", visualize=True) + + assert viz_data is not None + assert 1 in viz_data # Destination ID 1 should have steps + + def test_planner_result_to_string(self, simple_grid): + """Test plan result string formatting.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + + planner = DeliveryPlanner(simple_grid, stores, destinations, []) + result, _ = planner.plan("BF", visualize=False) + + output = result.to_string() + assert isinstance(output, str) + assert len(output) > 0 + + def test_planner_all_strategies(self, simple_grid): + """Test planner works with all strategies.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + + strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"] + for strategy in strategies: + result, _ = DeliveryPlanner.plan_from_state( + simple_grid, stores, destinations, [], strategy, False + ) + assert len(result.assignments) == 1, f"Strategy {strategy} failed" diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..77e3fbc362a81b7663b57dd70eb3d4d2db19050e --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,293 @@ +"""Tests for models (Grid, entities, state).""" + +import pytest +from app.models.grid import Grid, Segment +from app.models.entities import Store, Destination, Tunnel +from app.models.state import SearchState, PathResult, SearchStep + + +class TestSegment: + """Tests for Segment model.""" + + def test_segment_creation(self): + """Test basic segment creation.""" + segment = Segment(src=(0, 0), dst=(1, 0), traffic=2) + assert segment.src == (0, 0) + assert segment.dst == (1, 0) + assert segment.traffic == 2 + + def test_segment_normalization(self): + """Test that segment normalizes direction (src < dst).""" + segment = Segment(src=(1, 0), dst=(0, 0), traffic=1) + assert segment.src == (0, 0) + assert segment.dst == (1, 0) + + def test_segment_is_blocked(self): + """Test blocked segment detection.""" + blocked = Segment(src=(0, 0), dst=(1, 0), traffic=0) + passable = Segment(src=(0, 0), dst=(1, 0), traffic=1) + assert blocked.is_blocked is True + assert passable.is_blocked is False + + def test_segment_get_key(self): + """Test segment key generation.""" + segment = Segment(src=(0, 0), dst=(1, 0), traffic=1) + assert segment.get_key() == ((0, 0), (1, 0)) + + +class TestGrid: + """Tests for Grid model.""" + + def test_grid_creation(self): + """Test basic grid creation.""" + grid = Grid(width=5, height=5) + assert grid.width == 5 + assert grid.height == 5 + assert len(grid.segments) == 0 + + def test_add_segment(self): + """Test adding segments to grid.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (1, 0), 2) + assert len(grid.segments) == 1 + segment = grid.get_segment((0, 0), (1, 0)) + assert segment is not None + assert segment.traffic == 2 + + def test_add_segment_reversed(self): + """Test adding segment with reversed coordinates.""" + grid = Grid(width=3, height=3) + grid.add_segment((1, 0), (0, 0), 2) + # Should still be accessible both ways + segment = grid.get_segment((0, 0), (1, 0)) + assert segment is not None + assert segment.traffic == 2 + + def test_get_traffic(self): + """Test getting traffic level.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (1, 0), 3) + assert grid.get_traffic((0, 0), (1, 0)) == 3 + assert grid.get_traffic((1, 0), (0, 0)) == 3 # Reversed + assert grid.get_traffic((0, 0), (0, 1)) == 0 # Non-existent + + def test_is_blocked(self): + """Test blocked segment detection.""" + grid = Grid(width=3, height=3) + grid.add_segment((0, 0), (1, 0), 0) + grid.add_segment((0, 0), (0, 1), 1) + assert grid.is_blocked((0, 0), (1, 0)) is True + assert grid.is_blocked((0, 0), (0, 1)) is False + assert grid.is_blocked((1, 1), (2, 1)) is True # Non-existent = blocked + + def test_is_valid_position(self): + """Test position validation.""" + grid = Grid(width=3, height=3) + assert grid.is_valid_position((0, 0)) is True + assert grid.is_valid_position((2, 2)) is True + assert grid.is_valid_position((3, 0)) is False + assert grid.is_valid_position((0, 3)) is False + assert grid.is_valid_position((-1, 0)) is False + + def test_get_neighbors(self, simple_grid): + """Test getting neighbors.""" + # Corner (0,0) has 2 neighbors + neighbors = simple_grid.get_neighbors((0, 0)) + assert len(neighbors) == 2 + assert (1, 0) in neighbors + assert (0, 1) in neighbors + + # Center (1,1) has 4 neighbors + neighbors = simple_grid.get_neighbors((1, 1)) + assert len(neighbors) == 4 + + def test_get_neighbors_with_blocked(self, grid_with_blocked): + """Test neighbors exclude blocked paths.""" + # (1,1) normally has 4 neighbors but one path is blocked + neighbors = grid_with_blocked.get_neighbors((1, 1)) + assert (2, 1) not in neighbors # Blocked + assert (0, 1) in neighbors + assert (1, 0) in neighbors + assert (1, 2) in neighbors + + def test_to_dict(self, simple_grid): + """Test grid serialization.""" + result = simple_grid.to_dict() + assert result["width"] == 3 + assert result["height"] == 3 + assert "segments" in result + assert len(result["segments"]) > 0 + + +class TestStore: + """Tests for Store entity.""" + + def test_store_creation(self): + """Test store creation.""" + store = Store(id=1, position=(5, 3)) + assert store.id == 1 + assert store.position == (5, 3) + + def test_store_to_dict(self): + """Test store serialization.""" + store = Store(id=2, position=(1, 2)) + result = store.to_dict() + assert result["id"] == 2 + assert result["position"]["x"] == 1 + assert result["position"]["y"] == 2 + + +class TestDestination: + """Tests for Destination entity.""" + + def test_destination_creation(self): + """Test destination creation.""" + dest = Destination(id=1, position=(3, 4)) + assert dest.id == 1 + assert dest.position == (3, 4) + + def test_destination_to_dict(self): + """Test destination serialization.""" + dest = Destination(id=3, position=(2, 5)) + result = dest.to_dict() + assert result["id"] == 3 + assert result["position"]["x"] == 2 + assert result["position"]["y"] == 5 + + +class TestTunnel: + """Tests for Tunnel entity.""" + + def test_tunnel_creation(self): + """Test tunnel creation.""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5)) + assert tunnel.entrance1 == (0, 0) + assert tunnel.entrance2 == (5, 5) + + def test_tunnel_cost(self): + """Test tunnel cost calculation (Manhattan distance).""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(3, 4)) + assert tunnel.cost == 7 # |3-0| + |4-0| = 7 + + def test_tunnel_cost_same_row(self): + """Test tunnel cost on same row.""" + tunnel = Tunnel(entrance1=(0, 5), entrance2=(10, 5)) + assert tunnel.cost == 10 + + def test_tunnel_cost_same_column(self): + """Test tunnel cost on same column.""" + tunnel = Tunnel(entrance1=(3, 0), entrance2=(3, 7)) + assert tunnel.cost == 7 + + def test_get_other_entrance(self): + """Test getting other entrance.""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5)) + assert tunnel.get_other_entrance((0, 0)) == (5, 5) + assert tunnel.get_other_entrance((5, 5)) == (0, 0) + + def test_get_other_entrance_invalid(self): + """Test error on invalid entrance.""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5)) + with pytest.raises(ValueError): + tunnel.get_other_entrance((1, 1)) + + def test_has_entrance_at(self): + """Test entrance detection.""" + tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5)) + assert tunnel.has_entrance_at((0, 0)) is True + assert tunnel.has_entrance_at((5, 5)) is True + assert tunnel.has_entrance_at((1, 1)) is False + + def test_tunnel_to_dict(self): + """Test tunnel serialization.""" + tunnel = Tunnel(entrance1=(1, 2), entrance2=(4, 6)) + result = tunnel.to_dict() + assert result["entrance1"]["x"] == 1 + assert result["entrance1"]["y"] == 2 + assert result["entrance2"]["x"] == 4 + assert result["entrance2"]["y"] == 6 + assert result["cost"] == 7 + + +class TestSearchState: + """Tests for SearchState model.""" + + def test_search_state_creation(self, simple_grid, sample_stores, sample_destinations, sample_tunnels): + """Test search state creation.""" + state = SearchState( + grid=simple_grid, + stores=sample_stores, + destinations=sample_destinations, + tunnels=sample_tunnels, + ) + assert state.grid == simple_grid + assert len(state.stores) == 2 + assert len(state.destinations) == 3 + assert len(state.tunnels) == 2 + + +class TestPathResult: + """Tests for PathResult model.""" + + def test_path_result_creation(self): + """Test path result creation.""" + result = PathResult( + plan="right,right,up", + cost=5.0, + nodes_expanded=10, + path=[(0, 0), (1, 0), (2, 0), (2, 1)], + ) + assert result.plan == "right,right,up" + assert result.cost == 5.0 + assert result.nodes_expanded == 10 + assert len(result.path) == 4 + + def test_path_result_no_solution(self): + """Test path result when no solution exists.""" + result = PathResult( + plan="", + cost=float("inf"), + nodes_expanded=50, + path=[], + ) + assert result.plan == "" + assert result.cost == float("inf") + assert len(result.path) == 0 + + +class TestSearchStep: + """Tests for SearchStep model.""" + + def test_search_step_creation(self): + """Test search step creation.""" + step = SearchStep( + step_number=5, + current_node=(2, 3), + action="right", + frontier=[(3, 3), (2, 4)], + explored=[(0, 0), (1, 0), (2, 0)], + current_path=[(0, 0), (1, 0), (2, 0), (2, 1), (2, 2), (2, 3)], + path_cost=6.0, + ) + assert step.step_number == 5 + assert step.current_node == (2, 3) + assert step.action == "right" + assert len(step.frontier) == 2 + assert len(step.explored) == 3 + + def test_search_step_to_dict(self): + """Test search step serialization.""" + step = SearchStep( + step_number=0, + current_node=(0, 0), + action=None, + frontier=[(1, 0)], + explored=[], + current_path=[(0, 0)], + path_cost=0.0, + ) + result = step.to_dict() + assert result["stepNumber"] == 0 + assert result["currentNode"]["x"] == 0 + assert result["currentNode"]["y"] == 0 + assert result["action"] is None diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py new file mode 100644 index 0000000000000000000000000000000000000000..730c3e6f6f0a63d85858ede35e8dac97f4b5741b --- /dev/null +++ b/backend/tests/test_services.py @@ -0,0 +1,329 @@ +"""Tests for services (parser, grid_generator, metrics).""" + +import pytest +from app.services.parser import ( + parse_initial_state, + parse_traffic, + parse_full_state, + format_initial_state, + format_traffic, +) +from app.services.grid_generator import gen_grid +from app.services.metrics import MetricsCollector, measure_performance +from app.models.entities import Store, Destination, Tunnel + + +class TestParseInitialState: + """Tests for parse_initial_state function.""" + + def test_parse_basic(self): + """Test parsing basic initial state.""" + initial_state = "5;5;2;1;1,2,3,4;;0,0" + width, height, stores, destinations, tunnels = parse_initial_state(initial_state) + + assert width == 5 + assert height == 5 + assert len(stores) == 1 + assert len(destinations) == 2 + assert len(tunnels) == 0 + + def test_parse_with_tunnels(self): + """Test parsing initial state with tunnels.""" + initial_state = "10;10;3;2;1,1,5,5,8,8;0,0,9,9,2,2,7,7;0,0,9,9" + width, height, stores, destinations, tunnels = parse_initial_state(initial_state) + + assert width == 10 + assert height == 10 + assert len(stores) == 2 + assert len(destinations) == 3 + assert len(tunnels) == 2 + + # Check tunnel entrances + assert tunnels[0].entrance1 == (0, 0) + assert tunnels[0].entrance2 == (9, 9) + assert tunnels[1].entrance1 == (2, 2) + assert tunnels[1].entrance2 == (7, 7) + + def test_parse_store_positions(self): + """Test parsing store positions.""" + initial_state = "5;5;1;2;2,2;;0,0,4,4" + width, height, stores, destinations, tunnels = parse_initial_state(initial_state) + + assert len(stores) == 2 + assert stores[0].position == (0, 0) + assert stores[1].position == (4, 4) + + def test_parse_destination_positions(self): + """Test parsing destination positions.""" + initial_state = "5;5;3;1;1,1,2,2,3,3;;0,0" + width, height, stores, destinations, tunnels = parse_initial_state(initial_state) + + assert len(destinations) == 3 + assert destinations[0].position == (1, 1) + assert destinations[1].position == (2, 2) + assert destinations[2].position == (3, 3) + + def test_parse_empty_tunnels(self): + """Test parsing with no tunnels.""" + initial_state = "3;3;1;1;1,1;;0,0" + width, height, stores, destinations, tunnels = parse_initial_state(initial_state) + + assert len(tunnels) == 0 + + def test_parse_empty_destinations(self): + """Test parsing with no destinations.""" + initial_state = "3;3;0;1;;;0,0" + width, height, stores, destinations, tunnels = parse_initial_state(initial_state) + + assert len(destinations) == 0 + + +class TestParseTraffic: + """Tests for parse_traffic function.""" + + def test_parse_basic_traffic(self): + """Test parsing basic traffic string.""" + traffic_str = "0,0,1,0,2;0,0,0,1,3;1,0,1,1,1" + grid = parse_traffic(traffic_str, 3, 3) + + assert grid.width == 3 + assert grid.height == 3 + assert grid.get_traffic((0, 0), (1, 0)) == 2 + assert grid.get_traffic((0, 0), (0, 1)) == 3 + assert grid.get_traffic((1, 0), (1, 1)) == 1 + + def test_parse_blocked_segment(self): + """Test parsing blocked segment (traffic=0).""" + traffic_str = "0,0,1,0,0;0,0,0,1,1" + grid = parse_traffic(traffic_str, 2, 2) + + assert grid.is_blocked((0, 0), (1, 0)) is True + assert grid.is_blocked((0, 0), (0, 1)) is False + + def test_parse_empty_traffic(self): + """Test parsing empty traffic string - should create default traffic.""" + grid = parse_traffic("", 3, 3) + + assert grid.width == 3 + assert grid.height == 3 + # Should have default traffic level 1 + assert grid.get_traffic((0, 0), (1, 0)) == 1 + assert grid.get_traffic((0, 0), (0, 1)) == 1 + + +class TestParseFullState: + """Tests for parse_full_state function.""" + + def test_parse_full_state(self): + """Test parsing complete state.""" + initial_state = "5;5;2;1;1,1,3,3;;0,0" + traffic_str = "0,0,1,0,2;0,0,0,1,1" + + state = parse_full_state(initial_state, traffic_str) + + assert state.grid.width == 5 + assert state.grid.height == 5 + assert len(state.stores) == 1 + assert len(state.destinations) == 2 + assert len(state.tunnels) == 0 + + +class TestFormatInitialState: + """Tests for format_initial_state function.""" + + def test_format_basic(self): + """Test formatting basic state.""" + stores = [Store(id=1, position=(0, 0))] + destinations = [Destination(id=1, position=(2, 2))] + tunnels = [] + + result = format_initial_state(5, 5, stores, destinations, tunnels) + + assert result == "5;5;1;1;2,2;;0,0" + + def test_format_with_tunnels(self): + """Test formatting state with tunnels.""" + stores = [Store(id=1, position=(0, 0)), Store(id=2, position=(4, 4))] + destinations = [Destination(id=1, position=(2, 2))] + tunnels = [Tunnel(entrance1=(1, 1), entrance2=(3, 3))] + + result = format_initial_state(5, 5, stores, destinations, tunnels) + + assert result == "5;5;1;2;2,2;1,1,3,3;0,0,4,4" + + def test_format_roundtrip(self): + """Test that format and parse are inverses.""" + stores = [Store(id=1, position=(0, 0)), Store(id=2, position=(4, 4))] + destinations = [ + Destination(id=1, position=(1, 1)), + Destination(id=2, position=(3, 3)), + ] + tunnels = [Tunnel(entrance1=(0, 4), entrance2=(4, 0))] + + formatted = format_initial_state(5, 5, stores, destinations, tunnels) + width, height, parsed_stores, parsed_dests, parsed_tunnels = parse_initial_state(formatted) + + assert width == 5 + assert height == 5 + assert len(parsed_stores) == 2 + assert len(parsed_dests) == 2 + assert len(parsed_tunnels) == 1 + assert parsed_stores[0].position == (0, 0) + assert parsed_stores[1].position == (4, 4) + + +class TestFormatTraffic: + """Tests for format_traffic function.""" + + def test_format_traffic(self, simple_grid): + """Test formatting traffic.""" + result = format_traffic(simple_grid) + + # Should contain semicolon-separated segments + assert ";" in result or len(simple_grid.segments) <= 1 + + # Parse it back and verify + parsed_grid = parse_traffic(result, 3, 3) + assert parsed_grid.width == 3 + assert parsed_grid.height == 3 + + +class TestGridGenerator: + """Tests for grid generator.""" + + def test_gen_grid_basic(self): + """Test basic grid generation.""" + initial_state, traffic, state = gen_grid( + width=5, + height=5, + num_stores=1, + num_destinations=2, + obstacle_density=0.0, + seed=42, + ) + + assert state.grid.width == 5 + assert state.grid.height == 5 + assert len(state.stores) == 1 + assert len(state.destinations) == 2 + # Tunnels may be generated randomly since 0 is treated as falsy + + def test_gen_grid_with_tunnels(self): + """Test grid generation with tunnels.""" + initial_state, traffic, state = gen_grid( + width=10, + height=10, + num_stores=2, + num_destinations=3, + num_tunnels=2, + obstacle_density=0.1, + seed=42, + ) + + assert len(state.stores) == 2 + assert len(state.destinations) == 3 + # Tunnels might be fewer if generation fails to find valid positions + assert len(state.tunnels) <= 2 + + def test_gen_grid_reproducible(self): + """Test that same seed produces same grid.""" + result1 = gen_grid(width=5, height=5, seed=12345) + result2 = gen_grid(width=5, height=5, seed=12345) + + assert result1[0] == result2[0] # Same initial_state + assert result1[1] == result2[1] # Same traffic + + def test_gen_grid_stores_limited(self): + """Test that stores are limited to max 3.""" + _, _, state = gen_grid( + width=10, + height=10, + num_stores=10, # Request 10, should get max 3 + num_destinations=1, + seed=42, + ) + + assert len(state.stores) <= 3 + + def test_gen_grid_destinations_limited(self): + """Test that destinations are limited to max 10.""" + _, _, state = gen_grid( + width=10, + height=10, + num_stores=1, + num_destinations=20, # Request 20, should get max 10 + seed=42, + ) + + assert len(state.destinations) <= 10 + + def test_gen_grid_connectivity(self): + """Test that generated grid has connected paths.""" + _, _, state = gen_grid( + width=5, + height=5, + num_stores=1, + num_destinations=1, + obstacle_density=0.2, + seed=42, + ) + + # Should be able to reach destination from store + # This is ensured by _ensure_connectivity in grid_generator + store_pos = state.stores[0].position + dest_pos = state.destinations[0].position + + # BFS to check connectivity + visited = {store_pos} + queue = [store_pos] + while queue: + current = queue.pop(0) + if current == dest_pos: + break + for neighbor in state.grid.get_neighbors(current): + if neighbor not in visited: + visited.add(neighbor) + queue.append(neighbor) + + assert dest_pos in visited, "Destination should be reachable from store" + + +class TestMetricsCollector: + """Tests for MetricsCollector.""" + + def test_metrics_collector_basic(self): + """Test basic metrics collection.""" + collector = MetricsCollector() + collector.start() + + # Do some work + _ = [i**2 for i in range(1000)] + + collector.sample() + collector.stop() + + assert collector.runtime_ms > 0 + assert collector.memory_kb >= 0 + assert collector.cpu_percent >= 0 + + def test_metrics_collector_multiple_samples(self): + """Test multiple samples.""" + collector = MetricsCollector() + collector.start() + + for _ in range(3): + collector.sample() + + collector.stop() + + assert len(collector.memory_samples) >= 3 + assert len(collector.cpu_samples) >= 3 + + def test_measure_performance_context_manager(self): + """Test measure_performance context manager.""" + with measure_performance() as metrics: + # Do some work + _ = sum(range(10000)) + metrics.sample() + + assert metrics.runtime_ms > 0 diff --git a/backend/uv.lock b/backend/uv.lock index 47529c4d34abedcb6e6b9eee96ac15d15a37b6ae..11a07694f91cfe9210045c000477329d7ed1da9e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -39,6 +39,7 @@ name = "backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "black" }, { name = "fastapi" }, { name = "httpx" }, { name = "psutil" }, @@ -53,6 +54,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "black", specifier = ">=25.11.0" }, { name = "fastapi", specifier = ">=0.122.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "psutil", specifier = ">=7.1.3" }, @@ -74,6 +76,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "black" +version = "25.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" }, + { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" }, + { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, + { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, + { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" }, + { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -186,6 +227,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -195,6 +245,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -427,6 +495,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "pytokens" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, +] + [[package]] name = "starlette" version = "0.50.0" diff --git a/frontend/index.html b/frontend/index.html index 072a57e8e46c28ad65b5b51b3405b16c37d91667..5f69b98e6f657d17580cc4d7f2ea9b8dc8178e42 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - frontend + AI Project
diff --git a/frontend/public/graph.svg b/frontend/public/graph.svg new file mode 100644 index 0000000000000000000000000000000000000000..4d1df51cedec00866a1a7b1cd73ce794e65f981d --- /dev/null +++ b/frontend/public/graph.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b2a60bd50538bec9f876511b9cac21e3..0000000000000000000000000000000000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d3ef4f39372779f2437d7b5eb39d2c65d2e67d1..126ea4515ddf21a3d8ece1600911ed40e7598072 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -115,7 +115,7 @@ function App() { className="gap-2" > - {isLoading ? 'Running...' : 'Run Search'} + {isLoading ? 'Running...' : 'Run Search for S1'} diff --git a/frontend/src/store/gridStore.ts b/frontend/src/store/gridStore.ts index efa76c6548a8ba421e2f3f3a54859a8c87eade1a..4d6ddf3ac0574bc9891f64355929e1df3263f1d5 100644 --- a/frontend/src/store/gridStore.ts +++ b/frontend/src/store/gridStore.ts @@ -7,9 +7,18 @@ import type { Algorithm, SearchStep, GridConfig, + Position, } from '../types'; import { generateGrid, createPlan, compareAlgorithms, findPath } from '../api/client'; +// Path with color for visualization +export interface ColoredPath { + storeId: number; + destinationId: number; + path: Position[]; + color: string; +} + interface GridStore { // Grid state grid: GridState | null; @@ -27,6 +36,10 @@ interface GridStore { isPlaying: boolean; playbackSpeed: number; // ms per step + // Plan visualization + showPlanPaths: boolean; + planPaths: ColoredPath[]; + // UI state selectedAlgorithm: Algorithm; isLoading: boolean; @@ -46,8 +59,24 @@ interface GridStore { reset: () => void; nextStep: () => void; prevStep: () => void; + setShowPlanPaths: (show: boolean) => void; + clearPlanPaths: () => void; } +// Colors for different delivery paths +const PATH_COLORS = [ + '#f97316', // orange + '#06b6d4', // cyan + '#ec4899', // pink + '#84cc16', // lime + '#a855f7', // purple + '#14b8a6', // teal + '#f43f5e', // rose + '#eab308', // yellow + '#6366f1', // indigo + '#22c55e', // green +]; + export const useGridStore = create((set, get) => ({ // Initial state grid: null, @@ -60,6 +89,8 @@ export const useGridStore = create((set, get) => ({ steps: [], isPlaying: false, playbackSpeed: 100, + showPlanPaths: false, + planPaths: [], selectedAlgorithm: 'BF', isLoading: false, error: null, @@ -76,6 +107,8 @@ export const useGridStore = create((set, get) => ({ currentStep: 0, steps: [], isPlaying: false, + showPlanPaths: false, + planPaths: [], error: null, }); }, @@ -94,6 +127,8 @@ export const useGridStore = create((set, get) => ({ currentStep: 0, steps: [], isPlaying: false, + showPlanPaths: false, + planPaths: [], isLoading: false, }); } catch (error) { @@ -108,7 +143,7 @@ export const useGridStore = create((set, get) => ({ const { grid, selectedAlgorithm } = get(); if (!grid) return; - set({ isLoading: true, error: null }); + set({ isLoading: true, error: null, showPlanPaths: false, planPaths: [] }); try { const result = await findPath( grid.width, @@ -141,8 +176,19 @@ export const useGridStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const result = await createPlan(initialState, traffic, selectedAlgorithm, visualize); + + // Extract paths for visualization with different colors + const coloredPaths: ColoredPath[] = result.assignments.map((assignment, index) => ({ + storeId: assignment.store_id, + destinationId: assignment.destination_id, + path: assignment.path.path, + color: PATH_COLORS[index % PATH_COLORS.length], + })); + set({ planResult: result, + planPaths: coloredPaths, + showPlanPaths: true, isLoading: false, }); } catch (error) { @@ -215,4 +261,12 @@ export const useGridStore = create((set, get) => ({ set({ currentStep: currentStep - 1 }); } }, + + setShowPlanPaths: (show) => { + set({ showPlanPaths: show }); + }, + + clearPlanPaths: () => { + set({ showPlanPaths: false, planPaths: [] }); + }, })); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 147b6ee0a384d6589c11a77651dd4d2825dccf91..06300b75da89f610c766a5c97fc4b3d539aefb45 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -54,7 +54,7 @@ export interface SearchResult { cost: number; nodesExpanded: number; runtimeMs: number; - memoryMb: number; + memoryKb: number; cpuPercent: number; path: Position[]; steps?: SearchStep[]; @@ -79,7 +79,7 @@ export interface PlanResult { totalCost: number; totalNodesExpanded: number; runtimeMs: number; - memoryMb: number; + memoryKb: number; cpuPercent: number; } @@ -91,7 +91,7 @@ export interface ComparisonResult { cost: number; nodesExpanded: number; runtimeMs: number; - memoryMb: number; + memoryKb: number; cpuPercent: number; isOptimal: boolean; }