Spaces:
Sleeping
Sleeping
feat: update with latest changes
Browse files- Add store positions to initial_state format for consistent plan/compare behavior
- Change memory tracking from MB to KB for better precision
- Add plan paths visualization on grid with colored routes
- Clear plan paths when generating new grid
- Update favicon to graph icon and title to 'AI Project'
- Add comprehensive unit tests for backend
This view is limited to 50 files because it contains too many changes.
See raw diff
- backend/app/algorithms/__init__.py +7 -12
- backend/app/algorithms/astar.py +29 -108
- backend/app/algorithms/bfs.py +27 -95
- backend/app/algorithms/dfs.py +27 -96
- backend/app/algorithms/greedy.py +29 -105
- backend/app/algorithms/ids.py +39 -124
- backend/app/algorithms/ucs.py +27 -98
- backend/app/api/__init__.py +1 -0
- backend/app/api/routes.py +42 -44
- backend/app/core/__init__.py +2 -2
- backend/app/core/delivery_planner.py +15 -77
- backend/app/core/delivery_search.py +4 -30
- backend/app/core/frontier.py +1 -0
- backend/app/core/generic_search.py +38 -157
- backend/app/core/node.py +10 -10
- backend/app/heuristics/__init__.py +5 -1
- backend/app/heuristics/euclidean.py +1 -0
- backend/app/heuristics/manhattan.py +1 -0
- backend/app/heuristics/traffic_weighted.py +4 -3
- backend/app/heuristics/tunnel_aware.py +10 -9
- backend/app/main.py +7 -2
- backend/app/models/__init__.py +10 -3
- backend/app/models/entities.py +10 -19
- backend/app/models/grid.py +11 -4
- backend/app/models/requests.py +29 -7
- backend/app/models/state.py +18 -9
- backend/app/services/__init__.py +1 -0
- backend/app/services/grid_generator.py +39 -26
- backend/app/services/metrics.py +19 -9
- backend/app/services/parser.py +41 -76
- backend/pyproject.toml +8 -0
- backend/tests/__init__.py +1 -0
- backend/tests/conftest.py +111 -0
- backend/tests/test_algorithms.py +315 -0
- backend/tests/test_api.py +394 -0
- backend/tests/test_core.py +372 -0
- backend/tests/test_models.py +293 -0
- backend/tests/test_services.py +329 -0
- backend/uv.lock +77 -0
- frontend/index.html +2 -2
- frontend/public/graph.svg +29 -0
- frontend/public/vite.svg +0 -1
- frontend/src/App.tsx +1 -1
- frontend/src/api/client.ts +3 -3
- frontend/src/components/Grid/Grid.tsx +72 -10
- frontend/src/components/Info/GroupInfo.tsx +9 -41
- frontend/src/components/Stats/ComparisonDashboard.tsx +2 -2
- frontend/src/components/Stats/MetricsPanel.tsx +1 -1
- frontend/src/components/Stats/PlanResultsModal.tsx +61 -27
- frontend/src/store/gridStore.ts +55 -1
backend/app/algorithms/__init__.py
CHANGED
|
@@ -1,22 +1,17 @@
|
|
| 1 |
"""Search algorithms package."""
|
| 2 |
-
|
| 3 |
-
from .
|
| 4 |
-
from .
|
| 5 |
-
from .
|
| 6 |
-
from .
|
| 7 |
-
from .
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"bfs_search",
|
| 11 |
-
"bfs_search_generator",
|
| 12 |
"dfs_search",
|
| 13 |
-
"dfs_search_generator",
|
| 14 |
"ids_search",
|
| 15 |
-
"ids_search_generator",
|
| 16 |
"ucs_search",
|
| 17 |
-
"ucs_search_generator",
|
| 18 |
"greedy_search",
|
| 19 |
-
"greedy_search_generator",
|
| 20 |
"astar_search",
|
| 21 |
-
"astar_search_generator",
|
| 22 |
]
|
|
|
|
| 1 |
"""Search algorithms package."""
|
| 2 |
+
|
| 3 |
+
from .bfs import bfs_search
|
| 4 |
+
from .dfs import dfs_search
|
| 5 |
+
from .ids import ids_search
|
| 6 |
+
from .ucs import ucs_search
|
| 7 |
+
from .greedy import greedy_search
|
| 8 |
+
from .astar import astar_search
|
| 9 |
|
| 10 |
__all__ = [
|
| 11 |
"bfs_search",
|
|
|
|
| 12 |
"dfs_search",
|
|
|
|
| 13 |
"ids_search",
|
|
|
|
| 14 |
"ucs_search",
|
|
|
|
| 15 |
"greedy_search",
|
|
|
|
| 16 |
"astar_search",
|
|
|
|
| 17 |
]
|
backend/app/algorithms/astar.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""A* Search algorithm."""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
from ..core.generic_search import GenericSearch
|
|
@@ -10,9 +11,9 @@ from ..models.state import PathResult, SearchStep
|
|
| 10 |
|
| 11 |
|
| 12 |
def astar_search(
|
| 13 |
-
problem:
|
| 14 |
heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
|
| 15 |
-
visualize: bool = False
|
| 16 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 17 |
"""
|
| 18 |
A* search using f(n) = g(n) + h(n).
|
|
@@ -32,7 +33,7 @@ def astar_search(
|
|
| 32 |
start = problem.initial_state()
|
| 33 |
|
| 34 |
# Get goal for heuristic calculation
|
| 35 |
-
goal = getattr(problem,
|
| 36 |
|
| 37 |
h_value = heuristic(start, goal) if goal else 0
|
| 38 |
f_value = 0 + h_value # g(n) + h(n)
|
|
@@ -48,24 +49,29 @@ def astar_search(
|
|
| 48 |
|
| 49 |
# Record step for visualization
|
| 50 |
if visualize:
|
| 51 |
-
steps.append(
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
# Goal test
|
| 62 |
if problem.goal_test(node.state):
|
| 63 |
-
return
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
# Skip if already explored
|
| 71 |
if node.state in explored:
|
|
@@ -90,97 +96,12 @@ def astar_search(
|
|
| 90 |
action=action,
|
| 91 |
path_cost=g_value,
|
| 92 |
depth=node.depth + 1,
|
| 93 |
-
priority=f_value # Priority = f(n) = g(n) + h(n)
|
| 94 |
)
|
| 95 |
frontier.push(child)
|
| 96 |
|
| 97 |
# No solution found
|
| 98 |
-
return
|
| 99 |
-
plan="",
|
| 100 |
-
|
| 101 |
-
nodes_expanded=nodes_expanded,
|
| 102 |
-
path=[]
|
| 103 |
-
), steps
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def astar_search_generator(
|
| 107 |
-
problem: 'GenericSearch',
|
| 108 |
-
heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float]
|
| 109 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 110 |
-
"""
|
| 111 |
-
Generator version of A* search that yields steps during execution.
|
| 112 |
-
|
| 113 |
-
Args:
|
| 114 |
-
problem: The search problem to solve
|
| 115 |
-
heuristic: Heuristic function
|
| 116 |
-
|
| 117 |
-
Yields:
|
| 118 |
-
SearchStep objects
|
| 119 |
-
|
| 120 |
-
Returns:
|
| 121 |
-
Final PathResult
|
| 122 |
-
"""
|
| 123 |
-
frontier = PriorityQueueFrontier()
|
| 124 |
-
start = problem.initial_state()
|
| 125 |
-
goal = getattr(problem, 'goal', None)
|
| 126 |
-
|
| 127 |
-
h_value = heuristic(start, goal) if goal else 0
|
| 128 |
-
f_value = 0 + h_value
|
| 129 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=f_value)
|
| 130 |
-
frontier.push(start_node)
|
| 131 |
-
|
| 132 |
-
explored: set = set()
|
| 133 |
-
nodes_expanded = 0
|
| 134 |
-
|
| 135 |
-
while not frontier.is_empty():
|
| 136 |
-
node = frontier.pop()
|
| 137 |
-
|
| 138 |
-
yield SearchStep(
|
| 139 |
-
step_number=nodes_expanded,
|
| 140 |
-
current_node=node.state,
|
| 141 |
-
action=node.action,
|
| 142 |
-
frontier=frontier.get_states(),
|
| 143 |
-
explored=list(explored),
|
| 144 |
-
current_path=node.get_path(),
|
| 145 |
-
path_cost=node.path_cost
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
if problem.goal_test(node.state):
|
| 149 |
-
return PathResult(
|
| 150 |
-
plan=node.get_solution(),
|
| 151 |
-
cost=node.path_cost,
|
| 152 |
-
nodes_expanded=nodes_expanded,
|
| 153 |
-
path=node.get_path()
|
| 154 |
-
)
|
| 155 |
-
|
| 156 |
-
if node.state in explored:
|
| 157 |
-
continue
|
| 158 |
-
|
| 159 |
-
explored.add(node.state)
|
| 160 |
-
nodes_expanded += 1
|
| 161 |
-
|
| 162 |
-
for action in problem.actions(node.state):
|
| 163 |
-
child_state = problem.result(node.state, action)
|
| 164 |
-
|
| 165 |
-
if child_state not in explored:
|
| 166 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 167 |
-
g_value = node.path_cost + step_cost
|
| 168 |
-
h_value = heuristic(child_state, goal) if goal else 0
|
| 169 |
-
f_value = g_value + h_value
|
| 170 |
-
|
| 171 |
-
child = SearchNode(
|
| 172 |
-
state=child_state,
|
| 173 |
-
parent=node,
|
| 174 |
-
action=action,
|
| 175 |
-
path_cost=g_value,
|
| 176 |
-
depth=node.depth + 1,
|
| 177 |
-
priority=f_value
|
| 178 |
-
)
|
| 179 |
-
frontier.push(child)
|
| 180 |
-
|
| 181 |
-
return PathResult(
|
| 182 |
-
plan="",
|
| 183 |
-
cost=float('inf'),
|
| 184 |
-
nodes_expanded=nodes_expanded,
|
| 185 |
-
path=[]
|
| 186 |
)
|
|
|
|
| 1 |
"""A* Search algorithm."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple, Optional, List, Callable, TYPE_CHECKING
|
| 4 |
|
| 5 |
if TYPE_CHECKING:
|
| 6 |
from ..core.generic_search import GenericSearch
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def astar_search(
|
| 14 |
+
problem: "GenericSearch",
|
| 15 |
heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
|
| 16 |
+
visualize: bool = False,
|
| 17 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 18 |
"""
|
| 19 |
A* search using f(n) = g(n) + h(n).
|
|
|
|
| 33 |
start = problem.initial_state()
|
| 34 |
|
| 35 |
# Get goal for heuristic calculation
|
| 36 |
+
goal = getattr(problem, "goal", None)
|
| 37 |
|
| 38 |
h_value = heuristic(start, goal) if goal else 0
|
| 39 |
f_value = 0 + h_value # g(n) + h(n)
|
|
|
|
| 49 |
|
| 50 |
# Record step for visualization
|
| 51 |
if visualize:
|
| 52 |
+
steps.append(
|
| 53 |
+
SearchStep(
|
| 54 |
+
step_number=nodes_expanded,
|
| 55 |
+
current_node=node.state,
|
| 56 |
+
action=node.action,
|
| 57 |
+
frontier=frontier.get_states(),
|
| 58 |
+
explored=list(explored),
|
| 59 |
+
current_path=node.get_path(),
|
| 60 |
+
path_cost=node.path_cost,
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
|
| 64 |
# Goal test
|
| 65 |
if problem.goal_test(node.state):
|
| 66 |
+
return (
|
| 67 |
+
PathResult(
|
| 68 |
+
plan=node.get_solution(),
|
| 69 |
+
cost=node.path_cost,
|
| 70 |
+
nodes_expanded=nodes_expanded,
|
| 71 |
+
path=node.get_path(),
|
| 72 |
+
),
|
| 73 |
+
steps,
|
| 74 |
+
)
|
| 75 |
|
| 76 |
# Skip if already explored
|
| 77 |
if node.state in explored:
|
|
|
|
| 96 |
action=action,
|
| 97 |
path_cost=g_value,
|
| 98 |
depth=node.depth + 1,
|
| 99 |
+
priority=f_value, # Priority = f(n) = g(n) + h(n)
|
| 100 |
)
|
| 101 |
frontier.push(child)
|
| 102 |
|
| 103 |
# No solution found
|
| 104 |
+
return (
|
| 105 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
|
| 106 |
+
steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
)
|
backend/app/algorithms/bfs.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Breadth-First Search algorithm."""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
from ..core.generic_search import GenericSearch
|
|
@@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep
|
|
| 10 |
|
| 11 |
|
| 12 |
def bfs_search(
|
| 13 |
-
problem:
|
| 14 |
-
visualize: bool = False
|
| 15 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 16 |
"""
|
| 17 |
Breadth-first search using FIFO queue.
|
|
@@ -40,24 +40,29 @@ def bfs_search(
|
|
| 40 |
|
| 41 |
# Record step for visualization
|
| 42 |
if visualize:
|
| 43 |
-
steps.append(
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# Goal test after pop (standard BFS)
|
| 54 |
if problem.goal_test(node.state):
|
| 55 |
-
return
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Skip if already explored
|
| 63 |
if node.state in explored:
|
|
@@ -76,85 +81,12 @@ def bfs_search(
|
|
| 76 |
parent=node,
|
| 77 |
action=action,
|
| 78 |
path_cost=node.path_cost + step_cost,
|
| 79 |
-
depth=node.depth + 1
|
| 80 |
)
|
| 81 |
frontier.push(child)
|
| 82 |
|
| 83 |
# No solution found
|
| 84 |
-
return
|
| 85 |
-
plan="",
|
| 86 |
-
|
| 87 |
-
nodes_expanded=nodes_expanded,
|
| 88 |
-
path=[]
|
| 89 |
-
), steps
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
def bfs_search_generator(
|
| 93 |
-
problem: 'GenericSearch'
|
| 94 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 95 |
-
"""
|
| 96 |
-
Generator version of BFS that yields steps during execution.
|
| 97 |
-
|
| 98 |
-
Args:
|
| 99 |
-
problem: The search problem to solve
|
| 100 |
-
|
| 101 |
-
Yields:
|
| 102 |
-
SearchStep objects
|
| 103 |
-
|
| 104 |
-
Returns:
|
| 105 |
-
Final PathResult
|
| 106 |
-
"""
|
| 107 |
-
frontier = QueueFrontier()
|
| 108 |
-
start = problem.initial_state()
|
| 109 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 110 |
-
frontier.push(start_node)
|
| 111 |
-
|
| 112 |
-
explored: set = set()
|
| 113 |
-
nodes_expanded = 0
|
| 114 |
-
|
| 115 |
-
while not frontier.is_empty():
|
| 116 |
-
node = frontier.pop()
|
| 117 |
-
|
| 118 |
-
yield SearchStep(
|
| 119 |
-
step_number=nodes_expanded,
|
| 120 |
-
current_node=node.state,
|
| 121 |
-
action=node.action,
|
| 122 |
-
frontier=frontier.get_states(),
|
| 123 |
-
explored=list(explored),
|
| 124 |
-
current_path=node.get_path(),
|
| 125 |
-
path_cost=node.path_cost
|
| 126 |
-
)
|
| 127 |
-
|
| 128 |
-
if problem.goal_test(node.state):
|
| 129 |
-
return PathResult(
|
| 130 |
-
plan=node.get_solution(),
|
| 131 |
-
cost=node.path_cost,
|
| 132 |
-
nodes_expanded=nodes_expanded,
|
| 133 |
-
path=node.get_path()
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
if node.state in explored:
|
| 137 |
-
continue
|
| 138 |
-
|
| 139 |
-
explored.add(node.state)
|
| 140 |
-
nodes_expanded += 1
|
| 141 |
-
|
| 142 |
-
for action in problem.actions(node.state):
|
| 143 |
-
child_state = problem.result(node.state, action)
|
| 144 |
-
if child_state not in explored and not frontier.contains_state(child_state):
|
| 145 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 146 |
-
child = SearchNode(
|
| 147 |
-
state=child_state,
|
| 148 |
-
parent=node,
|
| 149 |
-
action=action,
|
| 150 |
-
path_cost=node.path_cost + step_cost,
|
| 151 |
-
depth=node.depth + 1
|
| 152 |
-
)
|
| 153 |
-
frontier.push(child)
|
| 154 |
-
|
| 155 |
-
return PathResult(
|
| 156 |
-
plan="",
|
| 157 |
-
cost=float('inf'),
|
| 158 |
-
nodes_expanded=nodes_expanded,
|
| 159 |
-
path=[]
|
| 160 |
)
|
|
|
|
| 1 |
"""Breadth-First Search algorithm."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple, Optional, List, TYPE_CHECKING
|
| 4 |
|
| 5 |
if TYPE_CHECKING:
|
| 6 |
from ..core.generic_search import GenericSearch
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def bfs_search(
|
| 14 |
+
problem: "GenericSearch", visualize: bool = False
|
|
|
|
| 15 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 16 |
"""
|
| 17 |
Breadth-first search using FIFO queue.
|
|
|
|
| 40 |
|
| 41 |
# Record step for visualization
|
| 42 |
if visualize:
|
| 43 |
+
steps.append(
|
| 44 |
+
SearchStep(
|
| 45 |
+
step_number=nodes_expanded,
|
| 46 |
+
current_node=node.state,
|
| 47 |
+
action=node.action,
|
| 48 |
+
frontier=frontier.get_states(),
|
| 49 |
+
explored=list(explored),
|
| 50 |
+
current_path=node.get_path(),
|
| 51 |
+
path_cost=node.path_cost,
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
|
| 55 |
# Goal test after pop (standard BFS)
|
| 56 |
if problem.goal_test(node.state):
|
| 57 |
+
return (
|
| 58 |
+
PathResult(
|
| 59 |
+
plan=node.get_solution(),
|
| 60 |
+
cost=node.path_cost,
|
| 61 |
+
nodes_expanded=nodes_expanded,
|
| 62 |
+
path=node.get_path(),
|
| 63 |
+
),
|
| 64 |
+
steps,
|
| 65 |
+
)
|
| 66 |
|
| 67 |
# Skip if already explored
|
| 68 |
if node.state in explored:
|
|
|
|
| 81 |
parent=node,
|
| 82 |
action=action,
|
| 83 |
path_cost=node.path_cost + step_cost,
|
| 84 |
+
depth=node.depth + 1,
|
| 85 |
)
|
| 86 |
frontier.push(child)
|
| 87 |
|
| 88 |
# No solution found
|
| 89 |
+
return (
|
| 90 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
|
| 91 |
+
steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
)
|
backend/app/algorithms/dfs.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Depth-First Search algorithm."""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
from ..core.generic_search import GenericSearch
|
|
@@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep
|
|
| 10 |
|
| 11 |
|
| 12 |
def dfs_search(
|
| 13 |
-
problem:
|
| 14 |
-
visualize: bool = False
|
| 15 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 16 |
"""
|
| 17 |
Depth-first search using LIFO stack.
|
|
@@ -40,24 +40,29 @@ def dfs_search(
|
|
| 40 |
|
| 41 |
# Record step for visualization
|
| 42 |
if visualize:
|
| 43 |
-
steps.append(
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# Goal test
|
| 54 |
if problem.goal_test(node.state):
|
| 55 |
-
return
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Skip if already explored
|
| 63 |
if node.state in explored:
|
|
@@ -77,86 +82,12 @@ def dfs_search(
|
|
| 77 |
parent=node,
|
| 78 |
action=action,
|
| 79 |
path_cost=node.path_cost + step_cost,
|
| 80 |
-
depth=node.depth + 1
|
| 81 |
)
|
| 82 |
frontier.push(child)
|
| 83 |
|
| 84 |
# No solution found
|
| 85 |
-
return
|
| 86 |
-
plan="",
|
| 87 |
-
|
| 88 |
-
nodes_expanded=nodes_expanded,
|
| 89 |
-
path=[]
|
| 90 |
-
), steps
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def dfs_search_generator(
|
| 94 |
-
problem: 'GenericSearch'
|
| 95 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 96 |
-
"""
|
| 97 |
-
Generator version of DFS that yields steps during execution.
|
| 98 |
-
|
| 99 |
-
Args:
|
| 100 |
-
problem: The search problem to solve
|
| 101 |
-
|
| 102 |
-
Yields:
|
| 103 |
-
SearchStep objects
|
| 104 |
-
|
| 105 |
-
Returns:
|
| 106 |
-
Final PathResult
|
| 107 |
-
"""
|
| 108 |
-
frontier = StackFrontier()
|
| 109 |
-
start = problem.initial_state()
|
| 110 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 111 |
-
frontier.push(start_node)
|
| 112 |
-
|
| 113 |
-
explored: set = set()
|
| 114 |
-
nodes_expanded = 0
|
| 115 |
-
|
| 116 |
-
while not frontier.is_empty():
|
| 117 |
-
node = frontier.pop()
|
| 118 |
-
|
| 119 |
-
yield SearchStep(
|
| 120 |
-
step_number=nodes_expanded,
|
| 121 |
-
current_node=node.state,
|
| 122 |
-
action=node.action,
|
| 123 |
-
frontier=frontier.get_states(),
|
| 124 |
-
explored=list(explored),
|
| 125 |
-
current_path=node.get_path(),
|
| 126 |
-
path_cost=node.path_cost
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
if problem.goal_test(node.state):
|
| 130 |
-
return PathResult(
|
| 131 |
-
plan=node.get_solution(),
|
| 132 |
-
cost=node.path_cost,
|
| 133 |
-
nodes_expanded=nodes_expanded,
|
| 134 |
-
path=node.get_path()
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
if node.state in explored:
|
| 138 |
-
continue
|
| 139 |
-
|
| 140 |
-
explored.add(node.state)
|
| 141 |
-
nodes_expanded += 1
|
| 142 |
-
|
| 143 |
-
actions = problem.actions(node.state)
|
| 144 |
-
for action in reversed(actions):
|
| 145 |
-
child_state = problem.result(node.state, action)
|
| 146 |
-
if child_state not in explored and not frontier.contains_state(child_state):
|
| 147 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 148 |
-
child = SearchNode(
|
| 149 |
-
state=child_state,
|
| 150 |
-
parent=node,
|
| 151 |
-
action=action,
|
| 152 |
-
path_cost=node.path_cost + step_cost,
|
| 153 |
-
depth=node.depth + 1
|
| 154 |
-
)
|
| 155 |
-
frontier.push(child)
|
| 156 |
-
|
| 157 |
-
return PathResult(
|
| 158 |
-
plan="",
|
| 159 |
-
cost=float('inf'),
|
| 160 |
-
nodes_expanded=nodes_expanded,
|
| 161 |
-
path=[]
|
| 162 |
)
|
|
|
|
| 1 |
"""Depth-First Search algorithm."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple, Optional, List, TYPE_CHECKING
|
| 4 |
|
| 5 |
if TYPE_CHECKING:
|
| 6 |
from ..core.generic_search import GenericSearch
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def dfs_search(
|
| 14 |
+
problem: "GenericSearch", visualize: bool = False
|
|
|
|
| 15 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 16 |
"""
|
| 17 |
Depth-first search using LIFO stack.
|
|
|
|
| 40 |
|
| 41 |
# Record step for visualization
|
| 42 |
if visualize:
|
| 43 |
+
steps.append(
|
| 44 |
+
SearchStep(
|
| 45 |
+
step_number=nodes_expanded,
|
| 46 |
+
current_node=node.state,
|
| 47 |
+
action=node.action,
|
| 48 |
+
frontier=frontier.get_states(),
|
| 49 |
+
explored=list(explored),
|
| 50 |
+
current_path=node.get_path(),
|
| 51 |
+
path_cost=node.path_cost,
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
|
| 55 |
# Goal test
|
| 56 |
if problem.goal_test(node.state):
|
| 57 |
+
return (
|
| 58 |
+
PathResult(
|
| 59 |
+
plan=node.get_solution(),
|
| 60 |
+
cost=node.path_cost,
|
| 61 |
+
nodes_expanded=nodes_expanded,
|
| 62 |
+
path=node.get_path(),
|
| 63 |
+
),
|
| 64 |
+
steps,
|
| 65 |
+
)
|
| 66 |
|
| 67 |
# Skip if already explored
|
| 68 |
if node.state in explored:
|
|
|
|
| 82 |
parent=node,
|
| 83 |
action=action,
|
| 84 |
path_cost=node.path_cost + step_cost,
|
| 85 |
+
depth=node.depth + 1,
|
| 86 |
)
|
| 87 |
frontier.push(child)
|
| 88 |
|
| 89 |
# No solution found
|
| 90 |
+
return (
|
| 91 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
|
| 92 |
+
steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
)
|
backend/app/algorithms/greedy.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Greedy Best-First Search algorithm."""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
from ..core.generic_search import GenericSearch
|
|
@@ -10,9 +11,9 @@ from ..models.state import PathResult, SearchStep
|
|
| 10 |
|
| 11 |
|
| 12 |
def greedy_search(
|
| 13 |
-
problem:
|
| 14 |
heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
|
| 15 |
-
visualize: bool = False
|
| 16 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 17 |
"""
|
| 18 |
Greedy best-first search using heuristic only.
|
|
@@ -33,7 +34,7 @@ def greedy_search(
|
|
| 33 |
start = problem.initial_state()
|
| 34 |
|
| 35 |
# Get goal for heuristic calculation (assume single goal)
|
| 36 |
-
goal = getattr(problem,
|
| 37 |
|
| 38 |
h_value = heuristic(start, goal) if goal else 0
|
| 39 |
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value)
|
|
@@ -48,24 +49,29 @@ def greedy_search(
|
|
| 48 |
|
| 49 |
# Record step for visualization
|
| 50 |
if visualize:
|
| 51 |
-
steps.append(
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
# Goal test
|
| 62 |
if problem.goal_test(node.state):
|
| 63 |
-
return
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
# Skip if already explored
|
| 71 |
if node.state in explored:
|
|
@@ -88,94 +94,12 @@ def greedy_search(
|
|
| 88 |
action=action,
|
| 89 |
path_cost=node.path_cost + step_cost,
|
| 90 |
depth=node.depth + 1,
|
| 91 |
-
priority=h_value # Priority = h(n) only for Greedy
|
| 92 |
)
|
| 93 |
frontier.push(child)
|
| 94 |
|
| 95 |
# No solution found
|
| 96 |
-
return
|
| 97 |
-
plan="",
|
| 98 |
-
|
| 99 |
-
nodes_expanded=nodes_expanded,
|
| 100 |
-
path=[]
|
| 101 |
-
), steps
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def greedy_search_generator(
|
| 105 |
-
problem: 'GenericSearch',
|
| 106 |
-
heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float]
|
| 107 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 108 |
-
"""
|
| 109 |
-
Generator version of Greedy search that yields steps during execution.
|
| 110 |
-
|
| 111 |
-
Args:
|
| 112 |
-
problem: The search problem to solve
|
| 113 |
-
heuristic: Heuristic function
|
| 114 |
-
|
| 115 |
-
Yields:
|
| 116 |
-
SearchStep objects
|
| 117 |
-
|
| 118 |
-
Returns:
|
| 119 |
-
Final PathResult
|
| 120 |
-
"""
|
| 121 |
-
frontier = PriorityQueueFrontier()
|
| 122 |
-
start = problem.initial_state()
|
| 123 |
-
goal = getattr(problem, 'goal', None)
|
| 124 |
-
|
| 125 |
-
h_value = heuristic(start, goal) if goal else 0
|
| 126 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value)
|
| 127 |
-
frontier.push(start_node)
|
| 128 |
-
|
| 129 |
-
explored: set = set()
|
| 130 |
-
nodes_expanded = 0
|
| 131 |
-
|
| 132 |
-
while not frontier.is_empty():
|
| 133 |
-
node = frontier.pop()
|
| 134 |
-
|
| 135 |
-
yield SearchStep(
|
| 136 |
-
step_number=nodes_expanded,
|
| 137 |
-
current_node=node.state,
|
| 138 |
-
action=node.action,
|
| 139 |
-
frontier=frontier.get_states(),
|
| 140 |
-
explored=list(explored),
|
| 141 |
-
current_path=node.get_path(),
|
| 142 |
-
path_cost=node.path_cost
|
| 143 |
-
)
|
| 144 |
-
|
| 145 |
-
if problem.goal_test(node.state):
|
| 146 |
-
return PathResult(
|
| 147 |
-
plan=node.get_solution(),
|
| 148 |
-
cost=node.path_cost,
|
| 149 |
-
nodes_expanded=nodes_expanded,
|
| 150 |
-
path=node.get_path()
|
| 151 |
-
)
|
| 152 |
-
|
| 153 |
-
if node.state in explored:
|
| 154 |
-
continue
|
| 155 |
-
|
| 156 |
-
explored.add(node.state)
|
| 157 |
-
nodes_expanded += 1
|
| 158 |
-
|
| 159 |
-
for action in problem.actions(node.state):
|
| 160 |
-
child_state = problem.result(node.state, action)
|
| 161 |
-
|
| 162 |
-
if child_state not in explored:
|
| 163 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 164 |
-
h_value = heuristic(child_state, goal) if goal else 0
|
| 165 |
-
|
| 166 |
-
child = SearchNode(
|
| 167 |
-
state=child_state,
|
| 168 |
-
parent=node,
|
| 169 |
-
action=action,
|
| 170 |
-
path_cost=node.path_cost + step_cost,
|
| 171 |
-
depth=node.depth + 1,
|
| 172 |
-
priority=h_value
|
| 173 |
-
)
|
| 174 |
-
frontier.push(child)
|
| 175 |
-
|
| 176 |
-
return PathResult(
|
| 177 |
-
plan="",
|
| 178 |
-
cost=float('inf'),
|
| 179 |
-
nodes_expanded=nodes_expanded,
|
| 180 |
-
path=[]
|
| 181 |
)
|
|
|
|
| 1 |
"""Greedy Best-First Search algorithm."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple, Optional, List, Callable, TYPE_CHECKING
|
| 4 |
|
| 5 |
if TYPE_CHECKING:
|
| 6 |
from ..core.generic_search import GenericSearch
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def greedy_search(
|
| 14 |
+
problem: "GenericSearch",
|
| 15 |
heuristic: Callable[[Tuple[int, int], Tuple[int, int]], float],
|
| 16 |
+
visualize: bool = False,
|
| 17 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 18 |
"""
|
| 19 |
Greedy best-first search using heuristic only.
|
|
|
|
| 34 |
start = problem.initial_state()
|
| 35 |
|
| 36 |
# Get goal for heuristic calculation (assume single goal)
|
| 37 |
+
goal = getattr(problem, "goal", None)
|
| 38 |
|
| 39 |
h_value = heuristic(start, goal) if goal else 0
|
| 40 |
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=h_value)
|
|
|
|
| 49 |
|
| 50 |
# Record step for visualization
|
| 51 |
if visualize:
|
| 52 |
+
steps.append(
|
| 53 |
+
SearchStep(
|
| 54 |
+
step_number=nodes_expanded,
|
| 55 |
+
current_node=node.state,
|
| 56 |
+
action=node.action,
|
| 57 |
+
frontier=frontier.get_states(),
|
| 58 |
+
explored=list(explored),
|
| 59 |
+
current_path=node.get_path(),
|
| 60 |
+
path_cost=node.path_cost,
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
|
| 64 |
# Goal test
|
| 65 |
if problem.goal_test(node.state):
|
| 66 |
+
return (
|
| 67 |
+
PathResult(
|
| 68 |
+
plan=node.get_solution(),
|
| 69 |
+
cost=node.path_cost,
|
| 70 |
+
nodes_expanded=nodes_expanded,
|
| 71 |
+
path=node.get_path(),
|
| 72 |
+
),
|
| 73 |
+
steps,
|
| 74 |
+
)
|
| 75 |
|
| 76 |
# Skip if already explored
|
| 77 |
if node.state in explored:
|
|
|
|
| 94 |
action=action,
|
| 95 |
path_cost=node.path_cost + step_cost,
|
| 96 |
depth=node.depth + 1,
|
| 97 |
+
priority=h_value, # Priority = h(n) only for Greedy
|
| 98 |
)
|
| 99 |
frontier.push(child)
|
| 100 |
|
| 101 |
# No solution found
|
| 102 |
+
return (
|
| 103 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
|
| 104 |
+
steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
)
|
backend/app/algorithms/ids.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Iterative Deepening Search algorithm."""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
from ..core.generic_search import GenericSearch
|
|
@@ -14,11 +15,11 @@ FAILURE = "failure"
|
|
| 14 |
|
| 15 |
|
| 16 |
def depth_limited_search(
|
| 17 |
-
problem:
|
| 18 |
limit: int,
|
| 19 |
visualize: bool = False,
|
| 20 |
steps: Optional[List[SearchStep]] = None,
|
| 21 |
-
base_expanded: int = 0
|
| 22 |
) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
|
| 23 |
"""
|
| 24 |
Depth-limited search - DFS with depth limit.
|
|
@@ -38,43 +39,54 @@ def depth_limited_search(
|
|
| 38 |
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 39 |
|
| 40 |
return _recursive_dls(
|
| 41 |
-
problem,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
steps if steps is not None else ([] if visualize else None),
|
| 43 |
-
base_expanded
|
| 44 |
)
|
| 45 |
|
| 46 |
|
| 47 |
def _recursive_dls(
|
| 48 |
-
problem:
|
| 49 |
node: SearchNode,
|
| 50 |
limit: int,
|
| 51 |
explored: set,
|
| 52 |
visualize: bool,
|
| 53 |
steps: Optional[List[SearchStep]],
|
| 54 |
-
nodes_expanded: int
|
| 55 |
) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
|
| 56 |
"""Recursive helper for depth-limited search."""
|
| 57 |
|
| 58 |
# Record step for visualization
|
| 59 |
if visualize and steps is not None:
|
| 60 |
-
steps.append(
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
|
| 70 |
# Goal test
|
| 71 |
if problem.goal_test(node.state):
|
| 72 |
-
return
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
# Depth limit reached
|
| 80 |
if node.depth >= limit:
|
|
@@ -97,7 +109,7 @@ def _recursive_dls(
|
|
| 97 |
parent=node,
|
| 98 |
action=action,
|
| 99 |
path_cost=node.path_cost + step_cost,
|
| 100 |
-
depth=node.depth + 1
|
| 101 |
)
|
| 102 |
|
| 103 |
result, status, nodes_expanded, steps = _recursive_dls(
|
|
@@ -116,9 +128,7 @@ def _recursive_dls(
|
|
| 116 |
|
| 117 |
|
| 118 |
def ids_search(
|
| 119 |
-
problem:
|
| 120 |
-
visualize: bool = False,
|
| 121 |
-
max_depth: int = 1000
|
| 122 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 123 |
"""
|
| 124 |
Iterative deepening search - repeated DLS with increasing depth.
|
|
@@ -154,102 +164,7 @@ def ids_search(
|
|
| 154 |
break
|
| 155 |
|
| 156 |
# No solution found within max_depth
|
| 157 |
-
return
|
| 158 |
-
plan="",
|
| 159 |
-
|
| 160 |
-
nodes_expanded=total_expanded,
|
| 161 |
-
path=[]
|
| 162 |
-
), all_steps
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def ids_search_generator(
|
| 166 |
-
problem: 'GenericSearch',
|
| 167 |
-
max_depth: int = 1000
|
| 168 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 169 |
-
"""
|
| 170 |
-
Generator version of IDS that yields steps during execution.
|
| 171 |
-
|
| 172 |
-
Args:
|
| 173 |
-
problem: The search problem to solve
|
| 174 |
-
max_depth: Maximum depth to search
|
| 175 |
-
|
| 176 |
-
Yields:
|
| 177 |
-
SearchStep objects
|
| 178 |
-
|
| 179 |
-
Returns:
|
| 180 |
-
Final PathResult
|
| 181 |
-
"""
|
| 182 |
-
total_expanded = 0
|
| 183 |
-
|
| 184 |
-
for depth in range(max_depth):
|
| 185 |
-
# Run DLS and yield steps
|
| 186 |
-
for step in _dls_generator(problem, depth, total_expanded):
|
| 187 |
-
yield step
|
| 188 |
-
total_expanded = step.step_number
|
| 189 |
-
|
| 190 |
-
# Check if solution was found at this depth
|
| 191 |
-
result, status, expanded, _ = depth_limited_search(
|
| 192 |
-
problem, depth, False, None, total_expanded
|
| 193 |
-
)
|
| 194 |
-
total_expanded = expanded
|
| 195 |
-
|
| 196 |
-
if status == "success" and result is not None:
|
| 197 |
-
result.nodes_expanded = total_expanded
|
| 198 |
-
return result
|
| 199 |
-
elif status == FAILURE:
|
| 200 |
-
break
|
| 201 |
-
|
| 202 |
-
return PathResult(
|
| 203 |
-
plan="",
|
| 204 |
-
cost=float('inf'),
|
| 205 |
-
nodes_expanded=total_expanded,
|
| 206 |
-
path=[]
|
| 207 |
)
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
def _dls_generator(
|
| 211 |
-
problem: 'GenericSearch',
|
| 212 |
-
limit: int,
|
| 213 |
-
base_expanded: int
|
| 214 |
-
) -> Generator[SearchStep, None, None]:
|
| 215 |
-
"""Generator helper for DLS."""
|
| 216 |
-
start = problem.initial_state()
|
| 217 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 218 |
-
|
| 219 |
-
stack = [(start_node, set())]
|
| 220 |
-
nodes_expanded = base_expanded
|
| 221 |
-
|
| 222 |
-
while stack:
|
| 223 |
-
node, explored = stack.pop()
|
| 224 |
-
|
| 225 |
-
yield SearchStep(
|
| 226 |
-
step_number=nodes_expanded,
|
| 227 |
-
current_node=node.state,
|
| 228 |
-
action=node.action,
|
| 229 |
-
frontier=[n.state for n, _ in stack],
|
| 230 |
-
explored=list(explored),
|
| 231 |
-
current_path=node.get_path(),
|
| 232 |
-
path_cost=node.path_cost
|
| 233 |
-
)
|
| 234 |
-
|
| 235 |
-
if problem.goal_test(node.state):
|
| 236 |
-
return
|
| 237 |
-
|
| 238 |
-
if node.depth >= limit:
|
| 239 |
-
continue
|
| 240 |
-
|
| 241 |
-
explored = explored | {node.state}
|
| 242 |
-
nodes_expanded += 1
|
| 243 |
-
|
| 244 |
-
for action in reversed(problem.actions(node.state)):
|
| 245 |
-
child_state = problem.result(node.state, action)
|
| 246 |
-
if child_state not in explored:
|
| 247 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 248 |
-
child = SearchNode(
|
| 249 |
-
state=child_state,
|
| 250 |
-
parent=node,
|
| 251 |
-
action=action,
|
| 252 |
-
path_cost=node.path_cost + step_cost,
|
| 253 |
-
depth=node.depth + 1
|
| 254 |
-
)
|
| 255 |
-
stack.append((child, explored))
|
|
|
|
| 1 |
"""Iterative Deepening Search algorithm."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple, Optional, List, TYPE_CHECKING
|
| 4 |
|
| 5 |
if TYPE_CHECKING:
|
| 6 |
from ..core.generic_search import GenericSearch
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
def depth_limited_search(
|
| 18 |
+
problem: "GenericSearch",
|
| 19 |
limit: int,
|
| 20 |
visualize: bool = False,
|
| 21 |
steps: Optional[List[SearchStep]] = None,
|
| 22 |
+
base_expanded: int = 0,
|
| 23 |
) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
|
| 24 |
"""
|
| 25 |
Depth-limited search - DFS with depth limit.
|
|
|
|
| 39 |
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 40 |
|
| 41 |
return _recursive_dls(
|
| 42 |
+
problem,
|
| 43 |
+
start_node,
|
| 44 |
+
limit,
|
| 45 |
+
set(),
|
| 46 |
+
visualize,
|
| 47 |
steps if steps is not None else ([] if visualize else None),
|
| 48 |
+
base_expanded,
|
| 49 |
)
|
| 50 |
|
| 51 |
|
| 52 |
def _recursive_dls(
|
| 53 |
+
problem: "GenericSearch",
|
| 54 |
node: SearchNode,
|
| 55 |
limit: int,
|
| 56 |
explored: set,
|
| 57 |
visualize: bool,
|
| 58 |
steps: Optional[List[SearchStep]],
|
| 59 |
+
nodes_expanded: int,
|
| 60 |
) -> Tuple[Optional[PathResult], str, int, Optional[List[SearchStep]]]:
|
| 61 |
"""Recursive helper for depth-limited search."""
|
| 62 |
|
| 63 |
# Record step for visualization
|
| 64 |
if visualize and steps is not None:
|
| 65 |
+
steps.append(
|
| 66 |
+
SearchStep(
|
| 67 |
+
step_number=nodes_expanded,
|
| 68 |
+
current_node=node.state,
|
| 69 |
+
action=node.action,
|
| 70 |
+
frontier=[], # DLS doesn't maintain explicit frontier
|
| 71 |
+
explored=list(explored),
|
| 72 |
+
current_path=node.get_path(),
|
| 73 |
+
path_cost=node.path_cost,
|
| 74 |
+
)
|
| 75 |
+
)
|
| 76 |
|
| 77 |
# Goal test
|
| 78 |
if problem.goal_test(node.state):
|
| 79 |
+
return (
|
| 80 |
+
PathResult(
|
| 81 |
+
plan=node.get_solution(),
|
| 82 |
+
cost=node.path_cost,
|
| 83 |
+
nodes_expanded=nodes_expanded,
|
| 84 |
+
path=node.get_path(),
|
| 85 |
+
),
|
| 86 |
+
"success",
|
| 87 |
+
nodes_expanded,
|
| 88 |
+
steps,
|
| 89 |
+
)
|
| 90 |
|
| 91 |
# Depth limit reached
|
| 92 |
if node.depth >= limit:
|
|
|
|
| 109 |
parent=node,
|
| 110 |
action=action,
|
| 111 |
path_cost=node.path_cost + step_cost,
|
| 112 |
+
depth=node.depth + 1,
|
| 113 |
)
|
| 114 |
|
| 115 |
result, status, nodes_expanded, steps = _recursive_dls(
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
def ids_search(
|
| 131 |
+
problem: "GenericSearch", visualize: bool = False, max_depth: int = 1000
|
|
|
|
|
|
|
| 132 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 133 |
"""
|
| 134 |
Iterative deepening search - repeated DLS with increasing depth.
|
|
|
|
| 164 |
break
|
| 165 |
|
| 166 |
# No solution found within max_depth
|
| 167 |
+
return (
|
| 168 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=total_expanded, path=[]),
|
| 169 |
+
all_steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/algorithms/ucs.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Uniform Cost Search algorithm."""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
from ..core.generic_search import GenericSearch
|
|
@@ -10,8 +11,7 @@ from ..models.state import PathResult, SearchStep
|
|
| 10 |
|
| 11 |
|
| 12 |
def ucs_search(
|
| 13 |
-
problem:
|
| 14 |
-
visualize: bool = False
|
| 15 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 16 |
"""
|
| 17 |
Uniform Cost Search using priority queue ordered by path cost.
|
|
@@ -40,24 +40,29 @@ def ucs_search(
|
|
| 40 |
|
| 41 |
# Record step for visualization
|
| 42 |
if visualize:
|
| 43 |
-
steps.append(
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
|
| 53 |
# Goal test (after pop for UCS)
|
| 54 |
if problem.goal_test(node.state):
|
| 55 |
-
return
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Skip if already explored
|
| 63 |
if node.state in explored:
|
|
@@ -79,88 +84,12 @@ def ucs_search(
|
|
| 79 |
action=action,
|
| 80 |
path_cost=new_cost,
|
| 81 |
depth=node.depth + 1,
|
| 82 |
-
priority=new_cost # Priority = path cost for UCS
|
| 83 |
)
|
| 84 |
frontier.push(child)
|
| 85 |
|
| 86 |
# No solution found
|
| 87 |
-
return
|
| 88 |
-
plan="",
|
| 89 |
-
|
| 90 |
-
nodes_expanded=nodes_expanded,
|
| 91 |
-
path=[]
|
| 92 |
-
), steps
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def ucs_search_generator(
|
| 96 |
-
problem: 'GenericSearch'
|
| 97 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 98 |
-
"""
|
| 99 |
-
Generator version of UCS that yields steps during execution.
|
| 100 |
-
|
| 101 |
-
Args:
|
| 102 |
-
problem: The search problem to solve
|
| 103 |
-
|
| 104 |
-
Yields:
|
| 105 |
-
SearchStep objects
|
| 106 |
-
|
| 107 |
-
Returns:
|
| 108 |
-
Final PathResult
|
| 109 |
-
"""
|
| 110 |
-
frontier = PriorityQueueFrontier()
|
| 111 |
-
start = problem.initial_state()
|
| 112 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0, priority=0)
|
| 113 |
-
frontier.push(start_node)
|
| 114 |
-
|
| 115 |
-
explored: set = set()
|
| 116 |
-
nodes_expanded = 0
|
| 117 |
-
|
| 118 |
-
while not frontier.is_empty():
|
| 119 |
-
node = frontier.pop()
|
| 120 |
-
|
| 121 |
-
yield SearchStep(
|
| 122 |
-
step_number=nodes_expanded,
|
| 123 |
-
current_node=node.state,
|
| 124 |
-
action=node.action,
|
| 125 |
-
frontier=frontier.get_states(),
|
| 126 |
-
explored=list(explored),
|
| 127 |
-
current_path=node.get_path(),
|
| 128 |
-
path_cost=node.path_cost
|
| 129 |
-
)
|
| 130 |
-
|
| 131 |
-
if problem.goal_test(node.state):
|
| 132 |
-
return PathResult(
|
| 133 |
-
plan=node.get_solution(),
|
| 134 |
-
cost=node.path_cost,
|
| 135 |
-
nodes_expanded=nodes_expanded,
|
| 136 |
-
path=node.get_path()
|
| 137 |
-
)
|
| 138 |
-
|
| 139 |
-
if node.state in explored:
|
| 140 |
-
continue
|
| 141 |
-
|
| 142 |
-
explored.add(node.state)
|
| 143 |
-
nodes_expanded += 1
|
| 144 |
-
|
| 145 |
-
for action in problem.actions(node.state):
|
| 146 |
-
child_state = problem.result(node.state, action)
|
| 147 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 148 |
-
new_cost = node.path_cost + step_cost
|
| 149 |
-
|
| 150 |
-
if child_state not in explored:
|
| 151 |
-
child = SearchNode(
|
| 152 |
-
state=child_state,
|
| 153 |
-
parent=node,
|
| 154 |
-
action=action,
|
| 155 |
-
path_cost=new_cost,
|
| 156 |
-
depth=node.depth + 1,
|
| 157 |
-
priority=new_cost
|
| 158 |
-
)
|
| 159 |
-
frontier.push(child)
|
| 160 |
-
|
| 161 |
-
return PathResult(
|
| 162 |
-
plan="",
|
| 163 |
-
cost=float('inf'),
|
| 164 |
-
nodes_expanded=nodes_expanded,
|
| 165 |
-
path=[]
|
| 166 |
)
|
|
|
|
| 1 |
"""Uniform Cost Search algorithm."""
|
| 2 |
+
|
| 3 |
+
from typing import Tuple, Optional, List, TYPE_CHECKING
|
| 4 |
|
| 5 |
if TYPE_CHECKING:
|
| 6 |
from ..core.generic_search import GenericSearch
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def ucs_search(
|
| 14 |
+
problem: "GenericSearch", visualize: bool = False
|
|
|
|
| 15 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 16 |
"""
|
| 17 |
Uniform Cost Search using priority queue ordered by path cost.
|
|
|
|
| 40 |
|
| 41 |
# Record step for visualization
|
| 42 |
if visualize:
|
| 43 |
+
steps.append(
|
| 44 |
+
SearchStep(
|
| 45 |
+
step_number=nodes_expanded,
|
| 46 |
+
current_node=node.state,
|
| 47 |
+
action=node.action,
|
| 48 |
+
frontier=frontier.get_states(),
|
| 49 |
+
explored=list(explored),
|
| 50 |
+
current_path=node.get_path(),
|
| 51 |
+
path_cost=node.path_cost,
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
|
| 55 |
# Goal test (after pop for UCS)
|
| 56 |
if problem.goal_test(node.state):
|
| 57 |
+
return (
|
| 58 |
+
PathResult(
|
| 59 |
+
plan=node.get_solution(),
|
| 60 |
+
cost=node.path_cost,
|
| 61 |
+
nodes_expanded=nodes_expanded,
|
| 62 |
+
path=node.get_path(),
|
| 63 |
+
),
|
| 64 |
+
steps,
|
| 65 |
+
)
|
| 66 |
|
| 67 |
# Skip if already explored
|
| 68 |
if node.state in explored:
|
|
|
|
| 84 |
action=action,
|
| 85 |
path_cost=new_cost,
|
| 86 |
depth=node.depth + 1,
|
| 87 |
+
priority=new_cost, # Priority = path cost for UCS
|
| 88 |
)
|
| 89 |
frontier.push(child)
|
| 90 |
|
| 91 |
# No solution found
|
| 92 |
+
return (
|
| 93 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
|
| 94 |
+
steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
)
|
backend/app/api/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""API package."""
|
|
|
|
| 2 |
from .routes import router
|
| 3 |
|
| 4 |
__all__ = ["router"]
|
|
|
|
| 1 |
"""API package."""
|
| 2 |
+
|
| 3 |
from .routes import router
|
| 4 |
|
| 5 |
__all__ = ["router"]
|
backend/app/api/routes.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""API routes for the delivery search application."""
|
|
|
|
| 2 |
from fastapi import APIRouter, HTTPException
|
| 3 |
from typing import List
|
| 4 |
|
|
@@ -33,42 +34,42 @@ ALGORITHMS = [
|
|
| 33 |
AlgorithmInfo(
|
| 34 |
code="BF",
|
| 35 |
name="Breadth-First Search",
|
| 36 |
-
description="Explores all nodes at current depth before moving deeper. Finds shortest path in terms of steps."
|
| 37 |
),
|
| 38 |
AlgorithmInfo(
|
| 39 |
code="DF",
|
| 40 |
name="Depth-First Search",
|
| 41 |
-
description="Explores as far as possible along each branch. Memory efficient but may not find optimal path."
|
| 42 |
),
|
| 43 |
AlgorithmInfo(
|
| 44 |
code="ID",
|
| 45 |
name="Iterative Deepening",
|
| 46 |
-
description="Combines BFS completeness with DFS space efficiency. Good for unknown depth goals."
|
| 47 |
),
|
| 48 |
AlgorithmInfo(
|
| 49 |
code="UC",
|
| 50 |
name="Uniform Cost Search",
|
| 51 |
-
description="Expands lowest-cost node first. Always finds the optimal (minimum cost) solution."
|
| 52 |
),
|
| 53 |
AlgorithmInfo(
|
| 54 |
code="GR1",
|
| 55 |
name="Greedy (Manhattan)",
|
| 56 |
-
description="Uses Manhattan distance heuristic. Fast but may not find optimal path."
|
| 57 |
),
|
| 58 |
AlgorithmInfo(
|
| 59 |
code="GR2",
|
| 60 |
name="Greedy (Euclidean)",
|
| 61 |
-
description="Uses Euclidean distance heuristic. Fast but may not find optimal path."
|
| 62 |
),
|
| 63 |
AlgorithmInfo(
|
| 64 |
code="AS1",
|
| 65 |
name="A* (Manhattan)",
|
| 66 |
-
description="A* with Manhattan distance. Optimal and complete with admissible heuristic."
|
| 67 |
),
|
| 68 |
AlgorithmInfo(
|
| 69 |
code="AS2",
|
| 70 |
name="A* (Tunnel-Aware)",
|
| 71 |
-
description="A* considering tunnel shortcuts. More informed for grids with tunnels."
|
| 72 |
),
|
| 73 |
]
|
| 74 |
|
|
@@ -95,7 +96,7 @@ async def generate_grid(config: GridConfig):
|
|
| 95 |
num_stores=config.num_stores,
|
| 96 |
num_destinations=config.num_destinations,
|
| 97 |
num_tunnels=config.num_tunnels,
|
| 98 |
-
obstacle_density=config.obstacle_density
|
| 99 |
)
|
| 100 |
|
| 101 |
# Convert to GridData for frontend
|
|
@@ -107,14 +108,16 @@ async def generate_grid(config: GridConfig):
|
|
| 107 |
for s in state.stores
|
| 108 |
],
|
| 109 |
destinations=[
|
| 110 |
-
DestinationData(
|
|
|
|
|
|
|
| 111 |
for d in state.destinations
|
| 112 |
],
|
| 113 |
tunnels=[
|
| 114 |
TunnelData(
|
| 115 |
entrance1=Position(x=t.entrance1[0], y=t.entrance1[1]),
|
| 116 |
entrance2=Position(x=t.entrance2[0], y=t.entrance2[1]),
|
| 117 |
-
cost=t.cost
|
| 118 |
)
|
| 119 |
for t in state.tunnels
|
| 120 |
],
|
|
@@ -122,16 +125,14 @@ async def generate_grid(config: GridConfig):
|
|
| 122 |
SegmentData(
|
| 123 |
src=Position(x=seg.src[0], y=seg.src[1]),
|
| 124 |
dst=Position(x=seg.dst[0], y=seg.dst[1]),
|
| 125 |
-
traffic=seg.traffic
|
| 126 |
)
|
| 127 |
for seg in state.grid.segments.values()
|
| 128 |
-
]
|
| 129 |
)
|
| 130 |
|
| 131 |
return GenerateResponse(
|
| 132 |
-
initial_state=initial_state,
|
| 133 |
-
traffic=traffic,
|
| 134 |
-
parsed=parsed
|
| 135 |
)
|
| 136 |
except Exception as e:
|
| 137 |
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -148,16 +149,14 @@ async def find_path(request: PathRequest):
|
|
| 148 |
grid = Grid(width=request.grid_width, height=request.grid_height)
|
| 149 |
for seg in request.segments:
|
| 150 |
grid.add_segment(
|
| 151 |
-
(seg.src.x, seg.src.y),
|
| 152 |
-
(seg.dst.x, seg.dst.y),
|
| 153 |
-
seg.traffic
|
| 154 |
)
|
| 155 |
|
| 156 |
# Build tunnels
|
| 157 |
tunnels = [
|
| 158 |
Tunnel(
|
| 159 |
entrance1=(t.entrance1.x, t.entrance1.y),
|
| 160 |
-
entrance2=(t.entrance2.x, t.entrance2.y)
|
| 161 |
)
|
| 162 |
for t in request.tunnels
|
| 163 |
]
|
|
@@ -170,7 +169,7 @@ async def find_path(request: PathRequest):
|
|
| 170 |
(request.goal.x, request.goal.y),
|
| 171 |
tunnels,
|
| 172 |
request.strategy.value,
|
| 173 |
-
visualize=True
|
| 174 |
)
|
| 175 |
metrics.sample()
|
| 176 |
|
|
@@ -179,10 +178,10 @@ async def find_path(request: PathRequest):
|
|
| 179 |
cost=result.cost,
|
| 180 |
nodes_expanded=result.nodes_expanded,
|
| 181 |
runtime_ms=metrics.runtime_ms,
|
| 182 |
-
|
| 183 |
cpu_percent=metrics.cpu_percent,
|
| 184 |
path=[Position(x=p[0], y=p[1]) for p in result.path],
|
| 185 |
-
steps=[s.to_dict() for s in steps] if steps else None
|
| 186 |
)
|
| 187 |
except Exception as e:
|
| 188 |
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -203,7 +202,7 @@ async def create_plan(request: SearchRequest):
|
|
| 203 |
state.destinations,
|
| 204 |
state.tunnels,
|
| 205 |
request.strategy.value,
|
| 206 |
-
request.visualize
|
| 207 |
)
|
| 208 |
metrics.sample()
|
| 209 |
|
|
@@ -213,8 +212,8 @@ async def create_plan(request: SearchRequest):
|
|
| 213 |
total_cost=plan_result.total_cost,
|
| 214 |
total_nodes_expanded=plan_result.total_nodes_expanded,
|
| 215 |
runtime_ms=metrics.runtime_ms,
|
| 216 |
-
|
| 217 |
-
cpu_percent=metrics.cpu_percent
|
| 218 |
)
|
| 219 |
except Exception as e:
|
| 220 |
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -227,7 +226,7 @@ async def compare_algorithms(request: CompareRequest):
|
|
| 227 |
state = parse_full_state(request.initial_state, request.traffic)
|
| 228 |
|
| 229 |
results: List[ComparisonResult] = []
|
| 230 |
-
optimal_cost = float(
|
| 231 |
|
| 232 |
# Run each algorithm
|
| 233 |
for algo_info in ALGORITHMS:
|
|
@@ -238,7 +237,7 @@ async def compare_algorithms(request: CompareRequest):
|
|
| 238 |
state.destinations,
|
| 239 |
state.tunnels,
|
| 240 |
algo_info.code,
|
| 241 |
-
visualize=False
|
| 242 |
)
|
| 243 |
metrics.sample()
|
| 244 |
|
|
@@ -246,25 +245,24 @@ async def compare_algorithms(request: CompareRequest):
|
|
| 246 |
if algo_info.code in ["UC", "AS1", "AS2"]:
|
| 247 |
optimal_cost = min(optimal_cost, plan_result.total_cost)
|
| 248 |
|
| 249 |
-
results.append(
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
| 260 |
|
| 261 |
# Mark optimal solutions
|
| 262 |
for result in results:
|
| 263 |
-
result.is_optimal =
|
| 264 |
|
| 265 |
-
return CompareResponse(
|
| 266 |
-
comparisons=results,
|
| 267 |
-
optimal_cost=optimal_cost
|
| 268 |
-
)
|
| 269 |
except Exception as e:
|
| 270 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 1 |
"""API routes for the delivery search application."""
|
| 2 |
+
|
| 3 |
from fastapi import APIRouter, HTTPException
|
| 4 |
from typing import List
|
| 5 |
|
|
|
|
| 34 |
AlgorithmInfo(
|
| 35 |
code="BF",
|
| 36 |
name="Breadth-First Search",
|
| 37 |
+
description="Explores all nodes at current depth before moving deeper. Finds shortest path in terms of steps.",
|
| 38 |
),
|
| 39 |
AlgorithmInfo(
|
| 40 |
code="DF",
|
| 41 |
name="Depth-First Search",
|
| 42 |
+
description="Explores as far as possible along each branch. Memory efficient but may not find optimal path.",
|
| 43 |
),
|
| 44 |
AlgorithmInfo(
|
| 45 |
code="ID",
|
| 46 |
name="Iterative Deepening",
|
| 47 |
+
description="Combines BFS completeness with DFS space efficiency. Good for unknown depth goals.",
|
| 48 |
),
|
| 49 |
AlgorithmInfo(
|
| 50 |
code="UC",
|
| 51 |
name="Uniform Cost Search",
|
| 52 |
+
description="Expands lowest-cost node first. Always finds the optimal (minimum cost) solution.",
|
| 53 |
),
|
| 54 |
AlgorithmInfo(
|
| 55 |
code="GR1",
|
| 56 |
name="Greedy (Manhattan)",
|
| 57 |
+
description="Uses Manhattan distance heuristic. Fast but may not find optimal path.",
|
| 58 |
),
|
| 59 |
AlgorithmInfo(
|
| 60 |
code="GR2",
|
| 61 |
name="Greedy (Euclidean)",
|
| 62 |
+
description="Uses Euclidean distance heuristic. Fast but may not find optimal path.",
|
| 63 |
),
|
| 64 |
AlgorithmInfo(
|
| 65 |
code="AS1",
|
| 66 |
name="A* (Manhattan)",
|
| 67 |
+
description="A* with Manhattan distance. Optimal and complete with admissible heuristic.",
|
| 68 |
),
|
| 69 |
AlgorithmInfo(
|
| 70 |
code="AS2",
|
| 71 |
name="A* (Tunnel-Aware)",
|
| 72 |
+
description="A* considering tunnel shortcuts. More informed for grids with tunnels.",
|
| 73 |
),
|
| 74 |
]
|
| 75 |
|
|
|
|
| 96 |
num_stores=config.num_stores,
|
| 97 |
num_destinations=config.num_destinations,
|
| 98 |
num_tunnels=config.num_tunnels,
|
| 99 |
+
obstacle_density=config.obstacle_density,
|
| 100 |
)
|
| 101 |
|
| 102 |
# Convert to GridData for frontend
|
|
|
|
| 108 |
for s in state.stores
|
| 109 |
],
|
| 110 |
destinations=[
|
| 111 |
+
DestinationData(
|
| 112 |
+
id=d.id, position=Position(x=d.position[0], y=d.position[1])
|
| 113 |
+
)
|
| 114 |
for d in state.destinations
|
| 115 |
],
|
| 116 |
tunnels=[
|
| 117 |
TunnelData(
|
| 118 |
entrance1=Position(x=t.entrance1[0], y=t.entrance1[1]),
|
| 119 |
entrance2=Position(x=t.entrance2[0], y=t.entrance2[1]),
|
| 120 |
+
cost=t.cost,
|
| 121 |
)
|
| 122 |
for t in state.tunnels
|
| 123 |
],
|
|
|
|
| 125 |
SegmentData(
|
| 126 |
src=Position(x=seg.src[0], y=seg.src[1]),
|
| 127 |
dst=Position(x=seg.dst[0], y=seg.dst[1]),
|
| 128 |
+
traffic=seg.traffic,
|
| 129 |
)
|
| 130 |
for seg in state.grid.segments.values()
|
| 131 |
+
],
|
| 132 |
)
|
| 133 |
|
| 134 |
return GenerateResponse(
|
| 135 |
+
initial_state=initial_state, traffic=traffic, parsed=parsed
|
|
|
|
|
|
|
| 136 |
)
|
| 137 |
except Exception as e:
|
| 138 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 149 |
grid = Grid(width=request.grid_width, height=request.grid_height)
|
| 150 |
for seg in request.segments:
|
| 151 |
grid.add_segment(
|
| 152 |
+
(seg.src.x, seg.src.y), (seg.dst.x, seg.dst.y), seg.traffic
|
|
|
|
|
|
|
| 153 |
)
|
| 154 |
|
| 155 |
# Build tunnels
|
| 156 |
tunnels = [
|
| 157 |
Tunnel(
|
| 158 |
entrance1=(t.entrance1.x, t.entrance1.y),
|
| 159 |
+
entrance2=(t.entrance2.x, t.entrance2.y),
|
| 160 |
)
|
| 161 |
for t in request.tunnels
|
| 162 |
]
|
|
|
|
| 169 |
(request.goal.x, request.goal.y),
|
| 170 |
tunnels,
|
| 171 |
request.strategy.value,
|
| 172 |
+
visualize=True,
|
| 173 |
)
|
| 174 |
metrics.sample()
|
| 175 |
|
|
|
|
| 178 |
cost=result.cost,
|
| 179 |
nodes_expanded=result.nodes_expanded,
|
| 180 |
runtime_ms=metrics.runtime_ms,
|
| 181 |
+
memory_kb=max(0, metrics.memory_kb),
|
| 182 |
cpu_percent=metrics.cpu_percent,
|
| 183 |
path=[Position(x=p[0], y=p[1]) for p in result.path],
|
| 184 |
+
steps=[s.to_dict() for s in steps] if steps else None,
|
| 185 |
)
|
| 186 |
except Exception as e:
|
| 187 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 202 |
state.destinations,
|
| 203 |
state.tunnels,
|
| 204 |
request.strategy.value,
|
| 205 |
+
request.visualize,
|
| 206 |
)
|
| 207 |
metrics.sample()
|
| 208 |
|
|
|
|
| 212 |
total_cost=plan_result.total_cost,
|
| 213 |
total_nodes_expanded=plan_result.total_nodes_expanded,
|
| 214 |
runtime_ms=metrics.runtime_ms,
|
| 215 |
+
memory_kb=max(0, metrics.memory_kb),
|
| 216 |
+
cpu_percent=metrics.cpu_percent,
|
| 217 |
)
|
| 218 |
except Exception as e:
|
| 219 |
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
| 226 |
state = parse_full_state(request.initial_state, request.traffic)
|
| 227 |
|
| 228 |
results: List[ComparisonResult] = []
|
| 229 |
+
optimal_cost = float("inf")
|
| 230 |
|
| 231 |
# Run each algorithm
|
| 232 |
for algo_info in ALGORITHMS:
|
|
|
|
| 237 |
state.destinations,
|
| 238 |
state.tunnels,
|
| 239 |
algo_info.code,
|
| 240 |
+
visualize=False,
|
| 241 |
)
|
| 242 |
metrics.sample()
|
| 243 |
|
|
|
|
| 245 |
if algo_info.code in ["UC", "AS1", "AS2"]:
|
| 246 |
optimal_cost = min(optimal_cost, plan_result.total_cost)
|
| 247 |
|
| 248 |
+
results.append(
|
| 249 |
+
ComparisonResult(
|
| 250 |
+
algorithm=algo_info.code,
|
| 251 |
+
name=algo_info.name,
|
| 252 |
+
plan=plan_result.to_string(),
|
| 253 |
+
cost=plan_result.total_cost,
|
| 254 |
+
nodes_expanded=plan_result.total_nodes_expanded,
|
| 255 |
+
runtime_ms=metrics.runtime_ms,
|
| 256 |
+
memory_kb=max(0, metrics.memory_kb),
|
| 257 |
+
cpu_percent=metrics.cpu_percent,
|
| 258 |
+
is_optimal=False, # Will be set below
|
| 259 |
+
)
|
| 260 |
+
)
|
| 261 |
|
| 262 |
# Mark optimal solutions
|
| 263 |
for result in results:
|
| 264 |
+
result.is_optimal = result.cost == optimal_cost
|
| 265 |
|
| 266 |
+
return CompareResponse(comparisons=results, optimal_cost=optimal_cost)
|
|
|
|
|
|
|
|
|
|
| 267 |
except Exception as e:
|
| 268 |
raise HTTPException(status_code=500, detail=str(e))
|
backend/app/core/__init__.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"""Core search module."""
|
|
|
|
| 2 |
from .node import SearchNode
|
| 3 |
from .frontier import Frontier, QueueFrontier, StackFrontier, PriorityQueueFrontier
|
| 4 |
-
from .generic_search import GenericSearch, graph_search
|
| 5 |
from .delivery_search import DeliverySearch
|
| 6 |
from .delivery_planner import DeliveryPlanner
|
| 7 |
|
|
@@ -13,7 +14,6 @@ __all__ = [
|
|
| 13 |
"PriorityQueueFrontier",
|
| 14 |
"GenericSearch",
|
| 15 |
"graph_search",
|
| 16 |
-
"graph_search_generator",
|
| 17 |
"DeliverySearch",
|
| 18 |
"DeliveryPlanner",
|
| 19 |
]
|
|
|
|
| 1 |
"""Core search module."""
|
| 2 |
+
|
| 3 |
from .node import SearchNode
|
| 4 |
from .frontier import Frontier, QueueFrontier, StackFrontier, PriorityQueueFrontier
|
| 5 |
+
from .generic_search import GenericSearch, graph_search
|
| 6 |
from .delivery_search import DeliverySearch
|
| 7 |
from .delivery_planner import DeliveryPlanner
|
| 8 |
|
|
|
|
| 14 |
"PriorityQueueFrontier",
|
| 15 |
"GenericSearch",
|
| 16 |
"graph_search",
|
|
|
|
| 17 |
"DeliverySearch",
|
| 18 |
"DeliveryPlanner",
|
| 19 |
]
|
backend/app/core/delivery_planner.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""DeliveryPlanner - Plans which trucks deliver which packages."""
|
|
|
|
| 2 |
from typing import List, Dict, Tuple, Optional
|
| 3 |
from .delivery_search import DeliverySearch
|
| 4 |
from ..models.grid import Grid
|
|
@@ -18,7 +19,7 @@ class DeliveryPlanner:
|
|
| 18 |
grid: Grid,
|
| 19 |
stores: List[Store],
|
| 20 |
destinations: List[Destination],
|
| 21 |
-
tunnels: Optional[List[Tunnel]] = None
|
| 22 |
):
|
| 23 |
"""
|
| 24 |
Initialize the delivery planner.
|
|
@@ -35,9 +36,7 @@ class DeliveryPlanner:
|
|
| 35 |
self.tunnels = tunnels or []
|
| 36 |
|
| 37 |
def plan(
|
| 38 |
-
self,
|
| 39 |
-
strategy: str,
|
| 40 |
-
visualize: bool = False
|
| 41 |
) -> Tuple[PlanResult, Optional[Dict[int, List[SearchStep]]]]:
|
| 42 |
"""
|
| 43 |
Create delivery plan assigning destinations to stores.
|
|
@@ -64,7 +63,7 @@ class DeliveryPlanner:
|
|
| 64 |
best_store: Optional[Store] = None
|
| 65 |
best_result: Optional[PathResult] = None
|
| 66 |
best_steps: Optional[List[SearchStep]] = None
|
| 67 |
-
best_cost = float(
|
| 68 |
|
| 69 |
# Try each store
|
| 70 |
for store in self.stores:
|
|
@@ -74,7 +73,7 @@ class DeliveryPlanner:
|
|
| 74 |
dest.position,
|
| 75 |
self.tunnels,
|
| 76 |
strategy,
|
| 77 |
-
visualize
|
| 78 |
)
|
| 79 |
|
| 80 |
# Track nodes expanded
|
|
@@ -92,7 +91,7 @@ class DeliveryPlanner:
|
|
| 92 |
assignment = DeliveryAssignment(
|
| 93 |
store_id=best_store.id,
|
| 94 |
destination_id=dest.id,
|
| 95 |
-
path_result=best_result
|
| 96 |
)
|
| 97 |
assignments.append(assignment)
|
| 98 |
total_cost += best_result.cost
|
|
@@ -100,75 +99,14 @@ class DeliveryPlanner:
|
|
| 100 |
if visualize and best_steps:
|
| 101 |
all_steps[dest.id] = best_steps
|
| 102 |
|
| 103 |
-
return
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
store: Store,
|
| 112 |
-
strategy: str,
|
| 113 |
-
visualize: bool = False
|
| 114 |
-
) -> List[Tuple[Destination, PathResult, Optional[List[SearchStep]]]]:
|
| 115 |
-
"""
|
| 116 |
-
Plan all deliveries from a single store.
|
| 117 |
-
|
| 118 |
-
This variant finds paths from one store to all destinations,
|
| 119 |
-
useful for comparing which destinations are closest.
|
| 120 |
-
|
| 121 |
-
Args:
|
| 122 |
-
store: The store to deliver from
|
| 123 |
-
strategy: Search strategy to use
|
| 124 |
-
visualize: If True, collect visualization steps
|
| 125 |
-
|
| 126 |
-
Returns:
|
| 127 |
-
List of (destination, path_result, steps) tuples
|
| 128 |
-
"""
|
| 129 |
-
results = []
|
| 130 |
-
|
| 131 |
-
for dest in self.destinations:
|
| 132 |
-
result, steps = DeliverySearch.path(
|
| 133 |
-
self.grid,
|
| 134 |
-
store.position,
|
| 135 |
-
dest.position,
|
| 136 |
-
self.tunnels,
|
| 137 |
-
strategy,
|
| 138 |
-
visualize
|
| 139 |
-
)
|
| 140 |
-
results.append((dest, result, steps))
|
| 141 |
-
|
| 142 |
-
# Sort by cost (closest first)
|
| 143 |
-
results.sort(key=lambda x: x[1].cost)
|
| 144 |
-
return results
|
| 145 |
-
|
| 146 |
-
def plan_sequential(
|
| 147 |
-
self,
|
| 148 |
-
strategy: str,
|
| 149 |
-
visualize: bool = False
|
| 150 |
-
) -> Tuple[PlanResult, Optional[Dict]]:
|
| 151 |
-
"""
|
| 152 |
-
Plan deliveries where trucks return to store after each delivery.
|
| 153 |
-
|
| 154 |
-
For each destination:
|
| 155 |
-
1. Find best store (minimum round-trip or just delivery cost)
|
| 156 |
-
2. Assign to that store
|
| 157 |
-
|
| 158 |
-
This is the simplified version as per project spec where
|
| 159 |
-
"once a delivery has been made, the truck immediately returns
|
| 160 |
-
to the store and can now make a new delivery."
|
| 161 |
-
|
| 162 |
-
Args:
|
| 163 |
-
strategy: Search strategy to use
|
| 164 |
-
visualize: If True, collect visualization steps
|
| 165 |
-
|
| 166 |
-
Returns:
|
| 167 |
-
Tuple of (PlanResult, Optional visualization data)
|
| 168 |
-
"""
|
| 169 |
-
# For this simplified version, we use the same logic as plan()
|
| 170 |
-
# since each delivery is independent (truck returns to store)
|
| 171 |
-
return self.plan(strategy, visualize)
|
| 172 |
|
| 173 |
@staticmethod
|
| 174 |
def plan_from_state(
|
|
@@ -177,7 +115,7 @@ class DeliveryPlanner:
|
|
| 177 |
destinations: List[Destination],
|
| 178 |
tunnels: List[Tunnel],
|
| 179 |
strategy: str,
|
| 180 |
-
visualize: bool = False
|
| 181 |
) -> Tuple[PlanResult, Optional[Dict]]:
|
| 182 |
"""
|
| 183 |
Static method to create and run planner.
|
|
|
|
| 1 |
"""DeliveryPlanner - Plans which trucks deliver which packages."""
|
| 2 |
+
|
| 3 |
from typing import List, Dict, Tuple, Optional
|
| 4 |
from .delivery_search import DeliverySearch
|
| 5 |
from ..models.grid import Grid
|
|
|
|
| 19 |
grid: Grid,
|
| 20 |
stores: List[Store],
|
| 21 |
destinations: List[Destination],
|
| 22 |
+
tunnels: Optional[List[Tunnel]] = None,
|
| 23 |
):
|
| 24 |
"""
|
| 25 |
Initialize the delivery planner.
|
|
|
|
| 36 |
self.tunnels = tunnels or []
|
| 37 |
|
| 38 |
def plan(
|
| 39 |
+
self, strategy: str, visualize: bool = False
|
|
|
|
|
|
|
| 40 |
) -> Tuple[PlanResult, Optional[Dict[int, List[SearchStep]]]]:
|
| 41 |
"""
|
| 42 |
Create delivery plan assigning destinations to stores.
|
|
|
|
| 63 |
best_store: Optional[Store] = None
|
| 64 |
best_result: Optional[PathResult] = None
|
| 65 |
best_steps: Optional[List[SearchStep]] = None
|
| 66 |
+
best_cost = float("inf")
|
| 67 |
|
| 68 |
# Try each store
|
| 69 |
for store in self.stores:
|
|
|
|
| 73 |
dest.position,
|
| 74 |
self.tunnels,
|
| 75 |
strategy,
|
| 76 |
+
visualize,
|
| 77 |
)
|
| 78 |
|
| 79 |
# Track nodes expanded
|
|
|
|
| 91 |
assignment = DeliveryAssignment(
|
| 92 |
store_id=best_store.id,
|
| 93 |
destination_id=dest.id,
|
| 94 |
+
path_result=best_result,
|
| 95 |
)
|
| 96 |
assignments.append(assignment)
|
| 97 |
total_cost += best_result.cost
|
|
|
|
| 99 |
if visualize and best_steps:
|
| 100 |
all_steps[dest.id] = best_steps
|
| 101 |
|
| 102 |
+
return (
|
| 103 |
+
PlanResult(
|
| 104 |
+
assignments=assignments,
|
| 105 |
+
total_cost=total_cost,
|
| 106 |
+
total_nodes_expanded=total_nodes,
|
| 107 |
+
),
|
| 108 |
+
all_steps,
|
| 109 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
@staticmethod
|
| 112 |
def plan_from_state(
|
|
|
|
| 115 |
destinations: List[Destination],
|
| 116 |
tunnels: List[Tunnel],
|
| 117 |
strategy: str,
|
| 118 |
+
visualize: bool = False,
|
| 119 |
) -> Tuple[PlanResult, Optional[Dict]]:
|
| 120 |
"""
|
| 121 |
Static method to create and run planner.
|
backend/app/core/delivery_search.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""DeliverySearch - Search problem for package delivery."""
|
|
|
|
| 2 |
from typing import List, Tuple, Optional, Dict
|
| 3 |
from .generic_search import GenericSearch
|
| 4 |
from ..models.grid import Grid
|
|
@@ -20,7 +21,7 @@ class DeliverySearch(GenericSearch):
|
|
| 20 |
grid: Grid,
|
| 21 |
start: Tuple[int, int],
|
| 22 |
goal: Tuple[int, int],
|
| 23 |
-
tunnels: Optional[List[Tunnel]] = None
|
| 24 |
):
|
| 25 |
"""
|
| 26 |
Initialize the delivery search problem.
|
|
@@ -120,10 +121,7 @@ class DeliverySearch(GenericSearch):
|
|
| 120 |
raise ValueError(f"Unknown action: {action}")
|
| 121 |
|
| 122 |
def step_cost(
|
| 123 |
-
self,
|
| 124 |
-
state: Tuple[int, int],
|
| 125 |
-
action: str,
|
| 126 |
-
next_state: Tuple[int, int]
|
| 127 |
) -> float:
|
| 128 |
"""
|
| 129 |
Return the cost of taking an action.
|
|
@@ -167,7 +165,7 @@ class DeliverySearch(GenericSearch):
|
|
| 167 |
goal: Tuple[int, int],
|
| 168 |
tunnels: List[Tunnel],
|
| 169 |
strategy: str,
|
| 170 |
-
visualize: bool = False
|
| 171 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 172 |
"""
|
| 173 |
Find path from start to goal using specified strategy.
|
|
@@ -185,27 +183,3 @@ class DeliverySearch(GenericSearch):
|
|
| 185 |
"""
|
| 186 |
search = DeliverySearch(grid, start, goal, tunnels)
|
| 187 |
return search.solve(strategy, visualize)
|
| 188 |
-
|
| 189 |
-
@staticmethod
|
| 190 |
-
def path_string(
|
| 191 |
-
grid: Grid,
|
| 192 |
-
start: Tuple[int, int],
|
| 193 |
-
goal: Tuple[int, int],
|
| 194 |
-
tunnels: List[Tunnel],
|
| 195 |
-
strategy: str
|
| 196 |
-
) -> str:
|
| 197 |
-
"""
|
| 198 |
-
Find path and return formatted string.
|
| 199 |
-
|
| 200 |
-
Args:
|
| 201 |
-
grid: The city grid
|
| 202 |
-
start: Starting position
|
| 203 |
-
goal: Goal position
|
| 204 |
-
tunnels: Available tunnels
|
| 205 |
-
strategy: Search strategy
|
| 206 |
-
|
| 207 |
-
Returns:
|
| 208 |
-
String in format "plan;cost;nodesExpanded"
|
| 209 |
-
"""
|
| 210 |
-
result, _ = DeliverySearch.path(grid, start, goal, tunnels, strategy)
|
| 211 |
-
return result.to_string()
|
|
|
|
| 1 |
"""DeliverySearch - Search problem for package delivery."""
|
| 2 |
+
|
| 3 |
from typing import List, Tuple, Optional, Dict
|
| 4 |
from .generic_search import GenericSearch
|
| 5 |
from ..models.grid import Grid
|
|
|
|
| 21 |
grid: Grid,
|
| 22 |
start: Tuple[int, int],
|
| 23 |
goal: Tuple[int, int],
|
| 24 |
+
tunnels: Optional[List[Tunnel]] = None,
|
| 25 |
):
|
| 26 |
"""
|
| 27 |
Initialize the delivery search problem.
|
|
|
|
| 121 |
raise ValueError(f"Unknown action: {action}")
|
| 122 |
|
| 123 |
def step_cost(
|
| 124 |
+
self, state: Tuple[int, int], action: str, next_state: Tuple[int, int]
|
|
|
|
|
|
|
|
|
|
| 125 |
) -> float:
|
| 126 |
"""
|
| 127 |
Return the cost of taking an action.
|
|
|
|
| 165 |
goal: Tuple[int, int],
|
| 166 |
tunnels: List[Tunnel],
|
| 167 |
strategy: str,
|
| 168 |
+
visualize: bool = False,
|
| 169 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 170 |
"""
|
| 171 |
Find path from start to goal using specified strategy.
|
|
|
|
| 183 |
"""
|
| 184 |
search = DeliverySearch(grid, start, goal, tunnels)
|
| 185 |
return search.solve(strategy, visualize)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/core/frontier.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Frontier data structures for search algorithms."""
|
|
|
|
| 2 |
from abc import ABC, abstractmethod
|
| 3 |
from collections import deque
|
| 4 |
import heapq
|
|
|
|
| 1 |
"""Frontier data structures for search algorithms."""
|
| 2 |
+
|
| 3 |
from abc import ABC, abstractmethod
|
| 4 |
from collections import deque
|
| 5 |
import heapq
|
backend/app/core/generic_search.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Generic search problem abstract base class."""
|
|
|
|
| 2 |
from abc import ABC, abstractmethod
|
| 3 |
-
from typing import List, Tuple, Optional
|
| 4 |
from .node import SearchNode
|
| 5 |
from .frontier import Frontier
|
| 6 |
from ..models.state import PathResult, SearchStep
|
|
@@ -53,9 +54,7 @@ class GenericSearch(ABC):
|
|
| 53 |
return 0.0
|
| 54 |
|
| 55 |
def solve(
|
| 56 |
-
self,
|
| 57 |
-
strategy: str,
|
| 58 |
-
visualize: bool = False
|
| 59 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 60 |
"""
|
| 61 |
Solve the search problem using the specified strategy.
|
|
@@ -86,62 +85,16 @@ class GenericSearch(ABC):
|
|
| 86 |
|
| 87 |
# Map strategy codes to search functions
|
| 88 |
strategy_map = {
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
if strategy not in strategy_map:
|
| 100 |
-
raise ValueError(f"Unknown strategy: {strategy}")
|
| 101 |
-
|
| 102 |
-
return strategy_map[strategy]()
|
| 103 |
-
|
| 104 |
-
def solve_with_steps(
|
| 105 |
-
self, strategy: str
|
| 106 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 107 |
-
"""
|
| 108 |
-
Generator version of solve that yields steps for real-time visualization.
|
| 109 |
-
|
| 110 |
-
Args:
|
| 111 |
-
strategy: Search strategy code
|
| 112 |
-
|
| 113 |
-
Yields:
|
| 114 |
-
SearchStep objects during search
|
| 115 |
-
|
| 116 |
-
Returns:
|
| 117 |
-
Final PathResult
|
| 118 |
-
"""
|
| 119 |
-
from ..algorithms import (
|
| 120 |
-
bfs_search_generator,
|
| 121 |
-
dfs_search_generator,
|
| 122 |
-
ids_search_generator,
|
| 123 |
-
ucs_search_generator,
|
| 124 |
-
greedy_search_generator,
|
| 125 |
-
astar_search_generator,
|
| 126 |
-
)
|
| 127 |
-
from ..heuristics import (
|
| 128 |
-
manhattan_heuristic,
|
| 129 |
-
euclidean_heuristic,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
# Wrap instance heuristic to match expected signature (state, goal) -> float
|
| 133 |
-
def tunnel_aware_wrapper(state, goal):
|
| 134 |
-
return self.heuristic(state)
|
| 135 |
-
|
| 136 |
-
strategy_map = {
|
| 137 |
-
'BF': lambda: bfs_search_generator(self),
|
| 138 |
-
'DF': lambda: dfs_search_generator(self),
|
| 139 |
-
'ID': lambda: ids_search_generator(self),
|
| 140 |
-
'UC': lambda: ucs_search_generator(self),
|
| 141 |
-
'GR1': lambda: greedy_search_generator(self, manhattan_heuristic),
|
| 142 |
-
'GR2': lambda: greedy_search_generator(self, euclidean_heuristic),
|
| 143 |
-
'AS1': lambda: astar_search_generator(self, manhattan_heuristic),
|
| 144 |
-
'AS2': lambda: astar_search_generator(self, tunnel_aware_wrapper),
|
| 145 |
}
|
| 146 |
|
| 147 |
if strategy not in strategy_map:
|
|
@@ -151,9 +104,7 @@ class GenericSearch(ABC):
|
|
| 151 |
|
| 152 |
|
| 153 |
def graph_search(
|
| 154 |
-
problem: GenericSearch,
|
| 155 |
-
frontier: Frontier,
|
| 156 |
-
visualize: bool = False
|
| 157 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 158 |
"""
|
| 159 |
Generic graph search algorithm.
|
|
@@ -180,24 +131,29 @@ def graph_search(
|
|
| 180 |
|
| 181 |
# Record step for visualization
|
| 182 |
if visualize:
|
| 183 |
-
steps.append(
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
| 192 |
|
| 193 |
# Goal test
|
| 194 |
if problem.goal_test(node.state):
|
| 195 |
-
return
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
# Skip if already explored
|
| 203 |
if node.state in explored:
|
|
@@ -217,87 +173,12 @@ def graph_search(
|
|
| 217 |
parent=node,
|
| 218 |
action=action,
|
| 219 |
path_cost=node.path_cost + step_cost,
|
| 220 |
-
depth=node.depth + 1
|
| 221 |
)
|
| 222 |
frontier.push(child)
|
| 223 |
|
| 224 |
# No solution found
|
| 225 |
-
return
|
| 226 |
-
plan="",
|
| 227 |
-
|
| 228 |
-
nodes_expanded=nodes_expanded,
|
| 229 |
-
path=[]
|
| 230 |
-
), steps
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
def graph_search_generator(
|
| 234 |
-
problem: GenericSearch,
|
| 235 |
-
frontier: Frontier
|
| 236 |
-
) -> Generator[SearchStep, None, PathResult]:
|
| 237 |
-
"""
|
| 238 |
-
Generator version of graph search that yields steps during execution.
|
| 239 |
-
|
| 240 |
-
Args:
|
| 241 |
-
problem: The search problem to solve
|
| 242 |
-
frontier: The frontier data structure
|
| 243 |
-
|
| 244 |
-
Yields:
|
| 245 |
-
SearchStep objects
|
| 246 |
-
|
| 247 |
-
Returns:
|
| 248 |
-
Final PathResult
|
| 249 |
-
"""
|
| 250 |
-
start = problem.initial_state()
|
| 251 |
-
start_node = SearchNode(state=start, path_cost=0, depth=0)
|
| 252 |
-
frontier.push(start_node)
|
| 253 |
-
explored: set = set()
|
| 254 |
-
nodes_expanded = 0
|
| 255 |
-
|
| 256 |
-
while not frontier.is_empty():
|
| 257 |
-
node = frontier.pop()
|
| 258 |
-
|
| 259 |
-
# Yield current step
|
| 260 |
-
yield SearchStep(
|
| 261 |
-
step_number=nodes_expanded,
|
| 262 |
-
current_node=node.state,
|
| 263 |
-
action=node.action,
|
| 264 |
-
frontier=frontier.get_states(),
|
| 265 |
-
explored=list(explored),
|
| 266 |
-
current_path=node.get_path(),
|
| 267 |
-
path_cost=node.path_cost
|
| 268 |
-
)
|
| 269 |
-
|
| 270 |
-
# Goal test
|
| 271 |
-
if problem.goal_test(node.state):
|
| 272 |
-
return PathResult(
|
| 273 |
-
plan=node.get_solution(),
|
| 274 |
-
cost=node.path_cost,
|
| 275 |
-
nodes_expanded=nodes_expanded,
|
| 276 |
-
path=node.get_path()
|
| 277 |
-
)
|
| 278 |
-
|
| 279 |
-
if node.state in explored:
|
| 280 |
-
continue
|
| 281 |
-
|
| 282 |
-
explored.add(node.state)
|
| 283 |
-
nodes_expanded += 1
|
| 284 |
-
|
| 285 |
-
for action in problem.actions(node.state):
|
| 286 |
-
child_state = problem.result(node.state, action)
|
| 287 |
-
if child_state not in explored and not frontier.contains_state(child_state):
|
| 288 |
-
step_cost = problem.step_cost(node.state, action, child_state)
|
| 289 |
-
child = SearchNode(
|
| 290 |
-
state=child_state,
|
| 291 |
-
parent=node,
|
| 292 |
-
action=action,
|
| 293 |
-
path_cost=node.path_cost + step_cost,
|
| 294 |
-
depth=node.depth + 1
|
| 295 |
-
)
|
| 296 |
-
frontier.push(child)
|
| 297 |
-
|
| 298 |
-
return PathResult(
|
| 299 |
-
plan="",
|
| 300 |
-
cost=float('inf'),
|
| 301 |
-
nodes_expanded=nodes_expanded,
|
| 302 |
-
path=[]
|
| 303 |
)
|
|
|
|
| 1 |
"""Generic search problem abstract base class."""
|
| 2 |
+
|
| 3 |
from abc import ABC, abstractmethod
|
| 4 |
+
from typing import List, Tuple, Optional
|
| 5 |
from .node import SearchNode
|
| 6 |
from .frontier import Frontier
|
| 7 |
from ..models.state import PathResult, SearchStep
|
|
|
|
| 54 |
return 0.0
|
| 55 |
|
| 56 |
def solve(
|
| 57 |
+
self, strategy: str, visualize: bool = False
|
|
|
|
|
|
|
| 58 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 59 |
"""
|
| 60 |
Solve the search problem using the specified strategy.
|
|
|
|
| 85 |
|
| 86 |
# Map strategy codes to search functions
|
| 87 |
strategy_map = {
|
| 88 |
+
"BF": lambda: bfs_search(self, visualize),
|
| 89 |
+
"DF": lambda: dfs_search(self, visualize),
|
| 90 |
+
"ID": lambda: ids_search(self, visualize),
|
| 91 |
+
"UC": lambda: ucs_search(self, visualize),
|
| 92 |
+
"GR1": lambda: greedy_search(self, manhattan_heuristic, visualize),
|
| 93 |
+
"GR2": lambda: greedy_search(self, euclidean_heuristic, visualize),
|
| 94 |
+
"AS1": lambda: astar_search(self, manhattan_heuristic, visualize),
|
| 95 |
+
"AS2": lambda: astar_search(
|
| 96 |
+
self, tunnel_aware_wrapper, visualize
|
| 97 |
+
), # Tunnel-aware
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
|
| 100 |
if strategy not in strategy_map:
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
def graph_search(
|
| 107 |
+
problem: GenericSearch, frontier: Frontier, visualize: bool = False
|
|
|
|
|
|
|
| 108 |
) -> Tuple[PathResult, Optional[List[SearchStep]]]:
|
| 109 |
"""
|
| 110 |
Generic graph search algorithm.
|
|
|
|
| 131 |
|
| 132 |
# Record step for visualization
|
| 133 |
if visualize:
|
| 134 |
+
steps.append(
|
| 135 |
+
SearchStep(
|
| 136 |
+
step_number=nodes_expanded,
|
| 137 |
+
current_node=node.state,
|
| 138 |
+
action=node.action,
|
| 139 |
+
frontier=frontier.get_states(),
|
| 140 |
+
explored=list(explored),
|
| 141 |
+
current_path=node.get_path(),
|
| 142 |
+
path_cost=node.path_cost,
|
| 143 |
+
)
|
| 144 |
+
)
|
| 145 |
|
| 146 |
# Goal test
|
| 147 |
if problem.goal_test(node.state):
|
| 148 |
+
return (
|
| 149 |
+
PathResult(
|
| 150 |
+
plan=node.get_solution(),
|
| 151 |
+
cost=node.path_cost,
|
| 152 |
+
nodes_expanded=nodes_expanded,
|
| 153 |
+
path=node.get_path(),
|
| 154 |
+
),
|
| 155 |
+
steps,
|
| 156 |
+
)
|
| 157 |
|
| 158 |
# Skip if already explored
|
| 159 |
if node.state in explored:
|
|
|
|
| 173 |
parent=node,
|
| 174 |
action=action,
|
| 175 |
path_cost=node.path_cost + step_cost,
|
| 176 |
+
depth=node.depth + 1,
|
| 177 |
)
|
| 178 |
frontier.push(child)
|
| 179 |
|
| 180 |
# No solution found
|
| 181 |
+
return (
|
| 182 |
+
PathResult(plan="", cost=float("inf"), nodes_expanded=nodes_expanded, path=[]),
|
| 183 |
+
steps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
)
|
backend/app/core/node.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""SearchNode class for the search tree."""
|
|
|
|
| 2 |
from dataclasses import dataclass, field
|
| 3 |
from typing import Optional, List, Tuple, Any
|
| 4 |
|
|
@@ -15,15 +16,16 @@ class SearchNode:
|
|
| 15 |
path_cost: g(n) - cost from start to this node
|
| 16 |
depth: Depth in search tree
|
| 17 |
"""
|
|
|
|
| 18 |
state: Tuple[int, int]
|
| 19 |
-
parent: Optional[
|
| 20 |
action: Optional[str] = None
|
| 21 |
path_cost: float = 0.0
|
| 22 |
depth: int = 0
|
| 23 |
# For priority queue - lower is better
|
| 24 |
priority: float = field(default=0.0, compare=False)
|
| 25 |
|
| 26 |
-
def __lt__(self, other:
|
| 27 |
"""Compare nodes by priority for priority queue."""
|
| 28 |
return self.priority < other.priority
|
| 29 |
|
|
@@ -78,12 +80,8 @@ class SearchNode:
|
|
| 78 |
return ",".join(actions) if actions else ""
|
| 79 |
|
| 80 |
def expand(
|
| 81 |
-
self,
|
| 82 |
-
|
| 83 |
-
result_func,
|
| 84 |
-
cost_func,
|
| 85 |
-
heuristic_func=None
|
| 86 |
-
) -> List['SearchNode']:
|
| 87 |
"""
|
| 88 |
Expand this node by generating all child nodes.
|
| 89 |
|
|
@@ -105,7 +103,7 @@ class SearchNode:
|
|
| 105 |
parent=self,
|
| 106 |
action=action,
|
| 107 |
path_cost=self.path_cost + step_cost,
|
| 108 |
-
depth=self.depth + 1
|
| 109 |
)
|
| 110 |
# Set priority if heuristic is provided (for A*)
|
| 111 |
if heuristic_func is not None:
|
|
@@ -116,4 +114,6 @@ class SearchNode:
|
|
| 116 |
return children
|
| 117 |
|
| 118 |
def __repr__(self) -> str:
|
| 119 |
-
return
|
|
|
|
|
|
|
|
|
| 1 |
"""SearchNode class for the search tree."""
|
| 2 |
+
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import Optional, List, Tuple, Any
|
| 5 |
|
|
|
|
| 16 |
path_cost: g(n) - cost from start to this node
|
| 17 |
depth: Depth in search tree
|
| 18 |
"""
|
| 19 |
+
|
| 20 |
state: Tuple[int, int]
|
| 21 |
+
parent: Optional["SearchNode"] = None
|
| 22 |
action: Optional[str] = None
|
| 23 |
path_cost: float = 0.0
|
| 24 |
depth: int = 0
|
| 25 |
# For priority queue - lower is better
|
| 26 |
priority: float = field(default=0.0, compare=False)
|
| 27 |
|
| 28 |
+
def __lt__(self, other: "SearchNode") -> bool:
|
| 29 |
"""Compare nodes by priority for priority queue."""
|
| 30 |
return self.priority < other.priority
|
| 31 |
|
|
|
|
| 80 |
return ",".join(actions) if actions else ""
|
| 81 |
|
| 82 |
def expand(
|
| 83 |
+
self, actions_func, result_func, cost_func, heuristic_func=None
|
| 84 |
+
) -> List["SearchNode"]:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
"""
|
| 86 |
Expand this node by generating all child nodes.
|
| 87 |
|
|
|
|
| 103 |
parent=self,
|
| 104 |
action=action,
|
| 105 |
path_cost=self.path_cost + step_cost,
|
| 106 |
+
depth=self.depth + 1,
|
| 107 |
)
|
| 108 |
# Set priority if heuristic is provided (for A*)
|
| 109 |
if heuristic_func is not None:
|
|
|
|
| 114 |
return children
|
| 115 |
|
| 116 |
def __repr__(self) -> str:
|
| 117 |
+
return (
|
| 118 |
+
f"SearchNode(state={self.state}, depth={self.depth}, cost={self.path_cost})"
|
| 119 |
+
)
|
backend/app/heuristics/__init__.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
| 1 |
"""Heuristics package for informed search algorithms."""
|
|
|
|
| 2 |
from .manhattan import manhattan_heuristic
|
| 3 |
from .euclidean import euclidean_heuristic
|
| 4 |
-
from .traffic_weighted import
|
|
|
|
|
|
|
|
|
|
| 5 |
from .tunnel_aware import tunnel_aware_heuristic, create_tunnel_aware_heuristic
|
| 6 |
|
| 7 |
__all__ = [
|
|
|
|
| 1 |
"""Heuristics package for informed search algorithms."""
|
| 2 |
+
|
| 3 |
from .manhattan import manhattan_heuristic
|
| 4 |
from .euclidean import euclidean_heuristic
|
| 5 |
+
from .traffic_weighted import (
|
| 6 |
+
traffic_weighted_heuristic,
|
| 7 |
+
create_traffic_weighted_heuristic,
|
| 8 |
+
)
|
| 9 |
from .tunnel_aware import tunnel_aware_heuristic, create_tunnel_aware_heuristic
|
| 10 |
|
| 11 |
__all__ = [
|
backend/app/heuristics/euclidean.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Euclidean distance heuristic."""
|
|
|
|
| 2 |
import math
|
| 3 |
from typing import Tuple
|
| 4 |
|
|
|
|
| 1 |
"""Euclidean distance heuristic."""
|
| 2 |
+
|
| 3 |
import math
|
| 4 |
from typing import Tuple
|
| 5 |
|
backend/app/heuristics/manhattan.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Manhattan distance heuristic."""
|
|
|
|
| 2 |
from typing import Tuple
|
| 3 |
|
| 4 |
|
|
|
|
| 1 |
"""Manhattan distance heuristic."""
|
| 2 |
+
|
| 3 |
from typing import Tuple
|
| 4 |
|
| 5 |
|
backend/app/heuristics/traffic_weighted.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
"""Traffic-weighted Manhattan heuristic."""
|
|
|
|
| 2 |
from typing import Tuple
|
| 3 |
|
| 4 |
|
| 5 |
def traffic_weighted_heuristic(
|
| 6 |
-
state: Tuple[int, int],
|
| 7 |
-
goal: Tuple[int, int],
|
| 8 |
-
min_traffic: float = 1.0
|
| 9 |
) -> float:
|
| 10 |
"""
|
| 11 |
Traffic-weighted Manhattan distance heuristic.
|
|
@@ -38,6 +37,8 @@ def create_traffic_weighted_heuristic(min_traffic: float = 1.0):
|
|
| 38 |
Returns:
|
| 39 |
Heuristic function
|
| 40 |
"""
|
|
|
|
| 41 |
def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
|
| 42 |
return traffic_weighted_heuristic(state, goal, min_traffic)
|
|
|
|
| 43 |
return heuristic
|
|
|
|
| 1 |
"""Traffic-weighted Manhattan heuristic."""
|
| 2 |
+
|
| 3 |
from typing import Tuple
|
| 4 |
|
| 5 |
|
| 6 |
def traffic_weighted_heuristic(
|
| 7 |
+
state: Tuple[int, int], goal: Tuple[int, int], min_traffic: float = 1.0
|
|
|
|
|
|
|
| 8 |
) -> float:
|
| 9 |
"""
|
| 10 |
Traffic-weighted Manhattan distance heuristic.
|
|
|
|
| 37 |
Returns:
|
| 38 |
Heuristic function
|
| 39 |
"""
|
| 40 |
+
|
| 41 |
def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
|
| 42 |
return traffic_weighted_heuristic(state, goal, min_traffic)
|
| 43 |
+
|
| 44 |
return heuristic
|
backend/app/heuristics/tunnel_aware.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
"""Tunnel-aware Manhattan heuristic."""
|
|
|
|
| 2 |
from typing import Tuple, List, Optional
|
| 3 |
from .manhattan import manhattan_heuristic
|
| 4 |
|
| 5 |
|
| 6 |
def tunnel_aware_heuristic(
|
| 7 |
-
state: Tuple[int, int],
|
| 8 |
-
goal: Tuple[int, int],
|
| 9 |
-
tunnels: Optional[List] = None
|
| 10 |
) -> float:
|
| 11 |
"""
|
| 12 |
Tunnel-aware Manhattan distance heuristic.
|
|
@@ -45,16 +44,16 @@ def tunnel_aware_heuristic(
|
|
| 45 |
|
| 46 |
# Path: state -> entrance1 -> (tunnel) -> entrance2 -> goal
|
| 47 |
via_tunnel_1 = (
|
| 48 |
-
manhattan_heuristic(state, entrance1)
|
| 49 |
-
tunnel_cost
|
| 50 |
-
manhattan_heuristic(entrance2, goal)
|
| 51 |
)
|
| 52 |
|
| 53 |
# Path: state -> entrance2 -> (tunnel) -> entrance1 -> goal
|
| 54 |
via_tunnel_2 = (
|
| 55 |
-
manhattan_heuristic(state, entrance2)
|
| 56 |
-
tunnel_cost
|
| 57 |
-
manhattan_heuristic(entrance1, goal)
|
| 58 |
)
|
| 59 |
|
| 60 |
best = min(best, via_tunnel_1, via_tunnel_2)
|
|
@@ -72,6 +71,8 @@ def create_tunnel_aware_heuristic(tunnels: List):
|
|
| 72 |
Returns:
|
| 73 |
Heuristic function
|
| 74 |
"""
|
|
|
|
| 75 |
def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
|
| 76 |
return tunnel_aware_heuristic(state, goal, tunnels)
|
|
|
|
| 77 |
return heuristic
|
|
|
|
| 1 |
"""Tunnel-aware Manhattan heuristic."""
|
| 2 |
+
|
| 3 |
from typing import Tuple, List, Optional
|
| 4 |
from .manhattan import manhattan_heuristic
|
| 5 |
|
| 6 |
|
| 7 |
def tunnel_aware_heuristic(
|
| 8 |
+
state: Tuple[int, int], goal: Tuple[int, int], tunnels: Optional[List] = None
|
|
|
|
|
|
|
| 9 |
) -> float:
|
| 10 |
"""
|
| 11 |
Tunnel-aware Manhattan distance heuristic.
|
|
|
|
| 44 |
|
| 45 |
# Path: state -> entrance1 -> (tunnel) -> entrance2 -> goal
|
| 46 |
via_tunnel_1 = (
|
| 47 |
+
manhattan_heuristic(state, entrance1)
|
| 48 |
+
+ tunnel_cost
|
| 49 |
+
+ manhattan_heuristic(entrance2, goal)
|
| 50 |
)
|
| 51 |
|
| 52 |
# Path: state -> entrance2 -> (tunnel) -> entrance1 -> goal
|
| 53 |
via_tunnel_2 = (
|
| 54 |
+
manhattan_heuristic(state, entrance2)
|
| 55 |
+
+ tunnel_cost
|
| 56 |
+
+ manhattan_heuristic(entrance1, goal)
|
| 57 |
)
|
| 58 |
|
| 59 |
best = min(best, via_tunnel_1, via_tunnel_2)
|
|
|
|
| 71 |
Returns:
|
| 72 |
Heuristic function
|
| 73 |
"""
|
| 74 |
+
|
| 75 |
def heuristic(state: Tuple[int, int], goal: Tuple[int, int]) -> float:
|
| 76 |
return tunnel_aware_heuristic(state, goal, tunnels)
|
| 77 |
+
|
| 78 |
return heuristic
|
backend/app/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""FastAPI application entry point."""
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
from fastapi import FastAPI
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -31,7 +32,9 @@ app.include_router(router)
|
|
| 31 |
static_dir = Path("/app/frontend/dist")
|
| 32 |
if static_dir.exists():
|
| 33 |
# Mount assets directory
|
| 34 |
-
app.mount(
|
|
|
|
|
|
|
| 35 |
|
| 36 |
# Serve index.html for root and all non-API routes
|
| 37 |
@app.get("/")
|
|
@@ -51,7 +54,9 @@ if static_dir.exists():
|
|
| 51 |
|
| 52 |
# Otherwise serve index.html (SPA routing)
|
| 53 |
return FileResponse(str(static_dir / "index.html"))
|
|
|
|
| 54 |
else:
|
|
|
|
| 55 |
@app.get("/")
|
| 56 |
async def root():
|
| 57 |
"""Root endpoint."""
|
|
@@ -65,7 +70,7 @@ else:
|
|
| 65 |
"path": "/api/search/path",
|
| 66 |
"plan": "/api/search/plan",
|
| 67 |
"compare": "/api/search/compare",
|
| 68 |
-
}
|
| 69 |
}
|
| 70 |
|
| 71 |
|
|
|
|
| 1 |
"""FastAPI application entry point."""
|
| 2 |
+
|
| 3 |
from pathlib import Path
|
| 4 |
from fastapi import FastAPI
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 32 |
static_dir = Path("/app/frontend/dist")
|
| 33 |
if static_dir.exists():
|
| 34 |
# Mount assets directory
|
| 35 |
+
app.mount(
|
| 36 |
+
"/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets"
|
| 37 |
+
)
|
| 38 |
|
| 39 |
# Serve index.html for root and all non-API routes
|
| 40 |
@app.get("/")
|
|
|
|
| 54 |
|
| 55 |
# Otherwise serve index.html (SPA routing)
|
| 56 |
return FileResponse(str(static_dir / "index.html"))
|
| 57 |
+
|
| 58 |
else:
|
| 59 |
+
|
| 60 |
@app.get("/")
|
| 61 |
async def root():
|
| 62 |
"""Root endpoint."""
|
|
|
|
| 70 |
"path": "/api/search/path",
|
| 71 |
"plan": "/api/search/plan",
|
| 72 |
"compare": "/api/search/compare",
|
| 73 |
+
},
|
| 74 |
}
|
| 75 |
|
| 76 |
|
backend/app/models/__init__.py
CHANGED
|
@@ -1,7 +1,15 @@
|
|
| 1 |
"""Models package - exports all model classes."""
|
|
|
|
| 2 |
from .grid import Grid, Segment
|
| 3 |
-
from .entities import Store, Destination, Tunnel
|
| 4 |
-
from .state import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from .requests import (
|
| 6 |
Algorithm,
|
| 7 |
Position,
|
|
@@ -32,7 +40,6 @@ __all__ = [
|
|
| 32 |
"Store",
|
| 33 |
"Destination",
|
| 34 |
"Tunnel",
|
| 35 |
-
"Truck",
|
| 36 |
# State models
|
| 37 |
"SearchState",
|
| 38 |
"PathResult",
|
|
|
|
| 1 |
"""Models package - exports all model classes."""
|
| 2 |
+
|
| 3 |
from .grid import Grid, Segment
|
| 4 |
+
from .entities import Store, Destination, Tunnel
|
| 5 |
+
from .state import (
|
| 6 |
+
SearchState,
|
| 7 |
+
PathResult,
|
| 8 |
+
DeliveryAssignment,
|
| 9 |
+
PlanResult,
|
| 10 |
+
SearchStep,
|
| 11 |
+
SearchMetrics,
|
| 12 |
+
)
|
| 13 |
from .requests import (
|
| 14 |
Algorithm,
|
| 15 |
Position,
|
|
|
|
| 40 |
"Store",
|
| 41 |
"Destination",
|
| 42 |
"Tunnel",
|
|
|
|
| 43 |
# State models
|
| 44 |
"SearchState",
|
| 45 |
"PathResult",
|
backend/app/models/entities.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Entity models for stores, destinations, tunnels, and trucks."""
|
|
|
|
| 2 |
from dataclasses import dataclass
|
| 3 |
from typing import Tuple
|
| 4 |
|
|
@@ -6,39 +7,44 @@ from typing import Tuple
|
|
| 6 |
@dataclass
|
| 7 |
class Store:
|
| 8 |
"""Represents a storage location / starting point for trucks."""
|
|
|
|
| 9 |
id: int
|
| 10 |
position: Tuple[int, int]
|
| 11 |
|
| 12 |
def to_dict(self) -> dict:
|
| 13 |
return {
|
| 14 |
"id": self.id,
|
| 15 |
-
"position": {"x": self.position[0], "y": self.position[1]}
|
| 16 |
}
|
| 17 |
|
| 18 |
|
| 19 |
@dataclass
|
| 20 |
class Destination:
|
| 21 |
"""Represents a customer destination for package delivery."""
|
|
|
|
| 22 |
id: int
|
| 23 |
position: Tuple[int, int]
|
| 24 |
|
| 25 |
def to_dict(self) -> dict:
|
| 26 |
return {
|
| 27 |
"id": self.id,
|
| 28 |
-
"position": {"x": self.position[0], "y": self.position[1]}
|
| 29 |
}
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass
|
| 33 |
class Tunnel:
|
| 34 |
"""Represents an underground tunnel connecting two points."""
|
|
|
|
| 35 |
entrance1: Tuple[int, int]
|
| 36 |
entrance2: Tuple[int, int]
|
| 37 |
|
| 38 |
@property
|
| 39 |
def cost(self) -> int:
|
| 40 |
"""Tunnel cost is Manhattan distance between entrances."""
|
| 41 |
-
return abs(self.entrance1[0] - self.entrance2[0]) + abs(
|
|
|
|
|
|
|
| 42 |
|
| 43 |
def get_other_entrance(self, entrance: Tuple[int, int]) -> Tuple[int, int]:
|
| 44 |
"""Get the other entrance of the tunnel."""
|
|
@@ -56,20 +62,5 @@ class Tunnel:
|
|
| 56 |
return {
|
| 57 |
"entrance1": {"x": self.entrance1[0], "y": self.entrance1[1]},
|
| 58 |
"entrance2": {"x": self.entrance2[0], "y": self.entrance2[1]},
|
| 59 |
-
"cost": self.cost
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
@dataclass
|
| 64 |
-
class Truck:
|
| 65 |
-
"""Represents a delivery truck."""
|
| 66 |
-
id: int
|
| 67 |
-
store_id: int
|
| 68 |
-
current_position: Tuple[int, int]
|
| 69 |
-
|
| 70 |
-
def to_dict(self) -> dict:
|
| 71 |
-
return {
|
| 72 |
-
"id": self.id,
|
| 73 |
-
"store_id": self.store_id,
|
| 74 |
-
"position": {"x": self.current_position[0], "y": self.current_position[1]}
|
| 75 |
}
|
|
|
|
| 1 |
"""Entity models for stores, destinations, tunnels, and trucks."""
|
| 2 |
+
|
| 3 |
from dataclasses import dataclass
|
| 4 |
from typing import Tuple
|
| 5 |
|
|
|
|
| 7 |
@dataclass
|
| 8 |
class Store:
|
| 9 |
"""Represents a storage location / starting point for trucks."""
|
| 10 |
+
|
| 11 |
id: int
|
| 12 |
position: Tuple[int, int]
|
| 13 |
|
| 14 |
def to_dict(self) -> dict:
|
| 15 |
return {
|
| 16 |
"id": self.id,
|
| 17 |
+
"position": {"x": self.position[0], "y": self.position[1]},
|
| 18 |
}
|
| 19 |
|
| 20 |
|
| 21 |
@dataclass
|
| 22 |
class Destination:
|
| 23 |
"""Represents a customer destination for package delivery."""
|
| 24 |
+
|
| 25 |
id: int
|
| 26 |
position: Tuple[int, int]
|
| 27 |
|
| 28 |
def to_dict(self) -> dict:
|
| 29 |
return {
|
| 30 |
"id": self.id,
|
| 31 |
+
"position": {"x": self.position[0], "y": self.position[1]},
|
| 32 |
}
|
| 33 |
|
| 34 |
|
| 35 |
@dataclass
|
| 36 |
class Tunnel:
|
| 37 |
"""Represents an underground tunnel connecting two points."""
|
| 38 |
+
|
| 39 |
entrance1: Tuple[int, int]
|
| 40 |
entrance2: Tuple[int, int]
|
| 41 |
|
| 42 |
@property
|
| 43 |
def cost(self) -> int:
|
| 44 |
"""Tunnel cost is Manhattan distance between entrances."""
|
| 45 |
+
return abs(self.entrance1[0] - self.entrance2[0]) + abs(
|
| 46 |
+
self.entrance1[1] - self.entrance2[1]
|
| 47 |
+
)
|
| 48 |
|
| 49 |
def get_other_entrance(self, entrance: Tuple[int, int]) -> Tuple[int, int]:
|
| 50 |
"""Get the other entrance of the tunnel."""
|
|
|
|
| 62 |
return {
|
| 63 |
"entrance1": {"x": self.entrance1[0], "y": self.entrance1[1]},
|
| 64 |
"entrance2": {"x": self.entrance2[0], "y": self.entrance2[1]},
|
| 65 |
+
"cost": self.cost,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
backend/app/models/grid.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Grid and Segment models for the delivery search problem."""
|
|
|
|
| 2 |
from dataclasses import dataclass, field
|
| 3 |
from typing import Dict, Tuple, Optional
|
| 4 |
|
|
@@ -6,6 +7,7 @@ from typing import Dict, Tuple, Optional
|
|
| 6 |
@dataclass
|
| 7 |
class Segment:
|
| 8 |
"""Represents a road segment between two adjacent grid points."""
|
|
|
|
| 9 |
src: Tuple[int, int]
|
| 10 |
dst: Tuple[int, int]
|
| 11 |
traffic: int # 0 = blocked, 1-4 = traffic level
|
|
@@ -27,11 +29,16 @@ class Segment:
|
|
| 27 |
@dataclass
|
| 28 |
class Grid:
|
| 29 |
"""Represents the city grid with all road segments."""
|
|
|
|
| 30 |
width: int
|
| 31 |
height: int
|
| 32 |
-
segments: Dict[Tuple[Tuple[int, int], Tuple[int, int]], Segment] = field(
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
def get_segment(
|
|
|
|
|
|
|
| 35 |
"""Get segment between two points (order doesn't matter)."""
|
| 36 |
key = (src, dst) if src < dst else (dst, src)
|
| 37 |
return self.segments.get(key)
|
|
@@ -77,8 +84,8 @@ class Grid:
|
|
| 77 |
{
|
| 78 |
"src": {"x": seg.src[0], "y": seg.src[1]},
|
| 79 |
"dst": {"x": seg.dst[0], "y": seg.dst[1]},
|
| 80 |
-
"traffic": seg.traffic
|
| 81 |
}
|
| 82 |
for seg in self.segments.values()
|
| 83 |
-
]
|
| 84 |
}
|
|
|
|
| 1 |
"""Grid and Segment models for the delivery search problem."""
|
| 2 |
+
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import Dict, Tuple, Optional
|
| 5 |
|
|
|
|
| 7 |
@dataclass
|
| 8 |
class Segment:
|
| 9 |
"""Represents a road segment between two adjacent grid points."""
|
| 10 |
+
|
| 11 |
src: Tuple[int, int]
|
| 12 |
dst: Tuple[int, int]
|
| 13 |
traffic: int # 0 = blocked, 1-4 = traffic level
|
|
|
|
| 29 |
@dataclass
|
| 30 |
class Grid:
|
| 31 |
"""Represents the city grid with all road segments."""
|
| 32 |
+
|
| 33 |
width: int
|
| 34 |
height: int
|
| 35 |
+
segments: Dict[Tuple[Tuple[int, int], Tuple[int, int]], Segment] = field(
|
| 36 |
+
default_factory=dict
|
| 37 |
+
)
|
| 38 |
|
| 39 |
+
def get_segment(
|
| 40 |
+
self, src: Tuple[int, int], dst: Tuple[int, int]
|
| 41 |
+
) -> Optional[Segment]:
|
| 42 |
"""Get segment between two points (order doesn't matter)."""
|
| 43 |
key = (src, dst) if src < dst else (dst, src)
|
| 44 |
return self.segments.get(key)
|
|
|
|
| 84 |
{
|
| 85 |
"src": {"x": seg.src[0], "y": seg.src[1]},
|
| 86 |
"dst": {"x": seg.dst[0], "y": seg.dst[1]},
|
| 87 |
+
"traffic": seg.traffic,
|
| 88 |
}
|
| 89 |
for seg in self.segments.values()
|
| 90 |
+
],
|
| 91 |
}
|
backend/app/models/requests.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Pydantic models for API requests and responses."""
|
|
|
|
| 2 |
from pydantic import BaseModel, Field
|
| 3 |
from typing import Optional, List, Tuple
|
| 4 |
from enum import Enum
|
|
@@ -6,10 +7,11 @@ from enum import Enum
|
|
| 6 |
|
| 7 |
class Algorithm(str, Enum):
|
| 8 |
"""Available search algorithms."""
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
GR1 = "GR1" # Greedy with Manhattan heuristic
|
| 14 |
GR2 = "GR2" # Greedy with Euclidean heuristic
|
| 15 |
AS1 = "AS1" # A* with Manhattan heuristic
|
|
@@ -18,6 +20,7 @@ class Algorithm(str, Enum):
|
|
| 18 |
|
| 19 |
class Position(BaseModel):
|
| 20 |
"""A position on the grid."""
|
|
|
|
| 21 |
x: int
|
| 22 |
y: int
|
| 23 |
|
|
@@ -27,6 +30,7 @@ class Position(BaseModel):
|
|
| 27 |
|
| 28 |
class SegmentData(BaseModel):
|
| 29 |
"""Segment data for API."""
|
|
|
|
| 30 |
src: Position
|
| 31 |
dst: Position
|
| 32 |
traffic: int = Field(ge=0, le=4)
|
|
@@ -34,18 +38,21 @@ class SegmentData(BaseModel):
|
|
| 34 |
|
| 35 |
class StoreData(BaseModel):
|
| 36 |
"""Store data for API."""
|
|
|
|
| 37 |
id: int
|
| 38 |
position: Position
|
| 39 |
|
| 40 |
|
| 41 |
class DestinationData(BaseModel):
|
| 42 |
"""Destination data for API."""
|
|
|
|
| 43 |
id: int
|
| 44 |
position: Position
|
| 45 |
|
| 46 |
|
| 47 |
class TunnelData(BaseModel):
|
| 48 |
"""Tunnel data for API."""
|
|
|
|
| 49 |
entrance1: Position
|
| 50 |
entrance2: Position
|
| 51 |
cost: Optional[int] = None
|
|
@@ -53,8 +60,10 @@ class TunnelData(BaseModel):
|
|
| 53 |
|
| 54 |
# Request Models
|
| 55 |
|
|
|
|
| 56 |
class GridConfig(BaseModel):
|
| 57 |
"""Configuration for grid generation."""
|
|
|
|
| 58 |
width: Optional[int] = Field(None, ge=5, le=50)
|
| 59 |
height: Optional[int] = Field(None, ge=5, le=50)
|
| 60 |
num_stores: Optional[int] = Field(None, ge=1, le=3)
|
|
@@ -65,6 +74,7 @@ class GridConfig(BaseModel):
|
|
| 65 |
|
| 66 |
class SearchRequest(BaseModel):
|
| 67 |
"""Request for running a search/plan."""
|
|
|
|
| 68 |
initial_state: str
|
| 69 |
traffic: str
|
| 70 |
strategy: Algorithm
|
|
@@ -73,6 +83,7 @@ class SearchRequest(BaseModel):
|
|
| 73 |
|
| 74 |
class PathRequest(BaseModel):
|
| 75 |
"""Request for finding a single path."""
|
|
|
|
| 76 |
grid_width: int
|
| 77 |
grid_height: int
|
| 78 |
start: Position
|
|
@@ -84,14 +95,17 @@ class PathRequest(BaseModel):
|
|
| 84 |
|
| 85 |
class CompareRequest(BaseModel):
|
| 86 |
"""Request for comparing all algorithms."""
|
|
|
|
| 87 |
initial_state: str
|
| 88 |
traffic: str
|
| 89 |
|
| 90 |
|
| 91 |
# Response Models
|
| 92 |
|
|
|
|
| 93 |
class PathData(BaseModel):
|
| 94 |
"""Path result data."""
|
|
|
|
| 95 |
plan: str
|
| 96 |
cost: float
|
| 97 |
nodes_expanded: int
|
|
@@ -100,6 +114,7 @@ class PathData(BaseModel):
|
|
| 100 |
|
| 101 |
class GridData(BaseModel):
|
| 102 |
"""Complete grid state data."""
|
|
|
|
| 103 |
width: int
|
| 104 |
height: int
|
| 105 |
stores: List[StoreData]
|
|
@@ -110,6 +125,7 @@ class GridData(BaseModel):
|
|
| 110 |
|
| 111 |
class GenerateResponse(BaseModel):
|
| 112 |
"""Response from grid generation."""
|
|
|
|
| 113 |
initial_state: str
|
| 114 |
traffic: str
|
| 115 |
parsed: GridData
|
|
@@ -117,11 +133,12 @@ class GenerateResponse(BaseModel):
|
|
| 117 |
|
| 118 |
class SearchResponse(BaseModel):
|
| 119 |
"""Response from search/plan execution."""
|
|
|
|
| 120 |
plan: str
|
| 121 |
cost: float
|
| 122 |
nodes_expanded: int
|
| 123 |
runtime_ms: float
|
| 124 |
-
|
| 125 |
cpu_percent: float
|
| 126 |
path: List[Position]
|
| 127 |
steps: Optional[List[dict]] = None
|
|
@@ -129,36 +146,40 @@ class SearchResponse(BaseModel):
|
|
| 129 |
|
| 130 |
class PlanResponse(BaseModel):
|
| 131 |
"""Response from delivery planning."""
|
|
|
|
| 132 |
output: str
|
| 133 |
assignments: List[dict]
|
| 134 |
total_cost: float
|
| 135 |
total_nodes_expanded: int
|
| 136 |
runtime_ms: float
|
| 137 |
-
|
| 138 |
cpu_percent: float
|
| 139 |
|
| 140 |
|
| 141 |
class ComparisonResult(BaseModel):
|
| 142 |
"""Result of comparing a single algorithm."""
|
|
|
|
| 143 |
algorithm: str
|
| 144 |
name: str
|
| 145 |
plan: str
|
| 146 |
cost: float
|
| 147 |
nodes_expanded: int
|
| 148 |
runtime_ms: float
|
| 149 |
-
|
| 150 |
cpu_percent: float
|
| 151 |
is_optimal: bool = False
|
| 152 |
|
| 153 |
|
| 154 |
class CompareResponse(BaseModel):
|
| 155 |
"""Response from algorithm comparison."""
|
|
|
|
| 156 |
comparisons: List[ComparisonResult]
|
| 157 |
optimal_cost: float
|
| 158 |
|
| 159 |
|
| 160 |
class AlgorithmInfo(BaseModel):
|
| 161 |
"""Information about an algorithm."""
|
|
|
|
| 162 |
code: str
|
| 163 |
name: str
|
| 164 |
description: str
|
|
@@ -166,4 +187,5 @@ class AlgorithmInfo(BaseModel):
|
|
| 166 |
|
| 167 |
class AlgorithmsResponse(BaseModel):
|
| 168 |
"""List of available algorithms."""
|
|
|
|
| 169 |
algorithms: List[AlgorithmInfo]
|
|
|
|
| 1 |
"""Pydantic models for API requests and responses."""
|
| 2 |
+
|
| 3 |
from pydantic import BaseModel, Field
|
| 4 |
from typing import Optional, List, Tuple
|
| 5 |
from enum import Enum
|
|
|
|
| 7 |
|
| 8 |
class Algorithm(str, Enum):
|
| 9 |
"""Available search algorithms."""
|
| 10 |
+
|
| 11 |
+
BF = "BF" # Breadth-first search
|
| 12 |
+
DF = "DF" # Depth-first search
|
| 13 |
+
ID = "ID" # Iterative deepening
|
| 14 |
+
UC = "UC" # Uniform cost search
|
| 15 |
GR1 = "GR1" # Greedy with Manhattan heuristic
|
| 16 |
GR2 = "GR2" # Greedy with Euclidean heuristic
|
| 17 |
AS1 = "AS1" # A* with Manhattan heuristic
|
|
|
|
| 20 |
|
| 21 |
class Position(BaseModel):
|
| 22 |
"""A position on the grid."""
|
| 23 |
+
|
| 24 |
x: int
|
| 25 |
y: int
|
| 26 |
|
|
|
|
| 30 |
|
| 31 |
class SegmentData(BaseModel):
|
| 32 |
"""Segment data for API."""
|
| 33 |
+
|
| 34 |
src: Position
|
| 35 |
dst: Position
|
| 36 |
traffic: int = Field(ge=0, le=4)
|
|
|
|
| 38 |
|
| 39 |
class StoreData(BaseModel):
|
| 40 |
"""Store data for API."""
|
| 41 |
+
|
| 42 |
id: int
|
| 43 |
position: Position
|
| 44 |
|
| 45 |
|
| 46 |
class DestinationData(BaseModel):
|
| 47 |
"""Destination data for API."""
|
| 48 |
+
|
| 49 |
id: int
|
| 50 |
position: Position
|
| 51 |
|
| 52 |
|
| 53 |
class TunnelData(BaseModel):
|
| 54 |
"""Tunnel data for API."""
|
| 55 |
+
|
| 56 |
entrance1: Position
|
| 57 |
entrance2: Position
|
| 58 |
cost: Optional[int] = None
|
|
|
|
| 60 |
|
| 61 |
# Request Models
|
| 62 |
|
| 63 |
+
|
| 64 |
class GridConfig(BaseModel):
|
| 65 |
"""Configuration for grid generation."""
|
| 66 |
+
|
| 67 |
width: Optional[int] = Field(None, ge=5, le=50)
|
| 68 |
height: Optional[int] = Field(None, ge=5, le=50)
|
| 69 |
num_stores: Optional[int] = Field(None, ge=1, le=3)
|
|
|
|
| 74 |
|
| 75 |
class SearchRequest(BaseModel):
|
| 76 |
"""Request for running a search/plan."""
|
| 77 |
+
|
| 78 |
initial_state: str
|
| 79 |
traffic: str
|
| 80 |
strategy: Algorithm
|
|
|
|
| 83 |
|
| 84 |
class PathRequest(BaseModel):
|
| 85 |
"""Request for finding a single path."""
|
| 86 |
+
|
| 87 |
grid_width: int
|
| 88 |
grid_height: int
|
| 89 |
start: Position
|
|
|
|
| 95 |
|
| 96 |
class CompareRequest(BaseModel):
|
| 97 |
"""Request for comparing all algorithms."""
|
| 98 |
+
|
| 99 |
initial_state: str
|
| 100 |
traffic: str
|
| 101 |
|
| 102 |
|
| 103 |
# Response Models
|
| 104 |
|
| 105 |
+
|
| 106 |
class PathData(BaseModel):
|
| 107 |
"""Path result data."""
|
| 108 |
+
|
| 109 |
plan: str
|
| 110 |
cost: float
|
| 111 |
nodes_expanded: int
|
|
|
|
| 114 |
|
| 115 |
class GridData(BaseModel):
|
| 116 |
"""Complete grid state data."""
|
| 117 |
+
|
| 118 |
width: int
|
| 119 |
height: int
|
| 120 |
stores: List[StoreData]
|
|
|
|
| 125 |
|
| 126 |
class GenerateResponse(BaseModel):
|
| 127 |
"""Response from grid generation."""
|
| 128 |
+
|
| 129 |
initial_state: str
|
| 130 |
traffic: str
|
| 131 |
parsed: GridData
|
|
|
|
| 133 |
|
| 134 |
class SearchResponse(BaseModel):
|
| 135 |
"""Response from search/plan execution."""
|
| 136 |
+
|
| 137 |
plan: str
|
| 138 |
cost: float
|
| 139 |
nodes_expanded: int
|
| 140 |
runtime_ms: float
|
| 141 |
+
memory_kb: float
|
| 142 |
cpu_percent: float
|
| 143 |
path: List[Position]
|
| 144 |
steps: Optional[List[dict]] = None
|
|
|
|
| 146 |
|
| 147 |
class PlanResponse(BaseModel):
|
| 148 |
"""Response from delivery planning."""
|
| 149 |
+
|
| 150 |
output: str
|
| 151 |
assignments: List[dict]
|
| 152 |
total_cost: float
|
| 153 |
total_nodes_expanded: int
|
| 154 |
runtime_ms: float
|
| 155 |
+
memory_kb: float
|
| 156 |
cpu_percent: float
|
| 157 |
|
| 158 |
|
| 159 |
class ComparisonResult(BaseModel):
|
| 160 |
"""Result of comparing a single algorithm."""
|
| 161 |
+
|
| 162 |
algorithm: str
|
| 163 |
name: str
|
| 164 |
plan: str
|
| 165 |
cost: float
|
| 166 |
nodes_expanded: int
|
| 167 |
runtime_ms: float
|
| 168 |
+
memory_kb: float
|
| 169 |
cpu_percent: float
|
| 170 |
is_optimal: bool = False
|
| 171 |
|
| 172 |
|
| 173 |
class CompareResponse(BaseModel):
|
| 174 |
"""Response from algorithm comparison."""
|
| 175 |
+
|
| 176 |
comparisons: List[ComparisonResult]
|
| 177 |
optimal_cost: float
|
| 178 |
|
| 179 |
|
| 180 |
class AlgorithmInfo(BaseModel):
|
| 181 |
"""Information about an algorithm."""
|
| 182 |
+
|
| 183 |
code: str
|
| 184 |
name: str
|
| 185 |
description: str
|
|
|
|
| 187 |
|
| 188 |
class AlgorithmsResponse(BaseModel):
|
| 189 |
"""List of available algorithms."""
|
| 190 |
+
|
| 191 |
algorithms: List[AlgorithmInfo]
|
backend/app/models/state.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""State models for search and planning results."""
|
|
|
|
| 2 |
from dataclasses import dataclass, field
|
| 3 |
from typing import List, Optional, Tuple
|
| 4 |
from .grid import Grid
|
|
@@ -8,6 +9,7 @@ from .entities import Store, Destination, Tunnel
|
|
| 8 |
@dataclass
|
| 9 |
class SearchState:
|
| 10 |
"""Represents the complete state for a delivery search problem."""
|
|
|
|
| 11 |
grid: Grid
|
| 12 |
stores: List[Store]
|
| 13 |
destinations: List[Destination]
|
|
@@ -25,17 +27,20 @@ class SearchState:
|
|
| 25 |
"grid": self.grid.to_dict(),
|
| 26 |
"stores": [s.to_dict() for s in self.stores],
|
| 27 |
"destinations": [d.to_dict() for d in self.destinations],
|
| 28 |
-
"tunnels": [t.to_dict() for t in self.tunnels]
|
| 29 |
}
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass
|
| 33 |
class PathResult:
|
| 34 |
"""Result of finding a path from start to goal."""
|
|
|
|
| 35 |
plan: str # Comma-separated actions: "up,down,left,right,tunnel"
|
| 36 |
cost: float # Total traffic cost
|
| 37 |
nodes_expanded: int # Number of nodes expanded during search
|
| 38 |
-
path: List[Tuple[int, int]] = field(
|
|
|
|
|
|
|
| 39 |
|
| 40 |
def to_string(self) -> str:
|
| 41 |
"""Format as required: plan;cost;nodesExpanded"""
|
|
@@ -46,13 +51,14 @@ class PathResult:
|
|
| 46 |
"plan": self.plan,
|
| 47 |
"cost": self.cost,
|
| 48 |
"nodes_expanded": self.nodes_expanded,
|
| 49 |
-
"path": [{"x": p[0], "y": p[1]} for p in self.path]
|
| 50 |
}
|
| 51 |
|
| 52 |
|
| 53 |
@dataclass
|
| 54 |
class DeliveryAssignment:
|
| 55 |
"""Assignment of a destination to a store/truck."""
|
|
|
|
| 56 |
store_id: int
|
| 57 |
destination_id: int
|
| 58 |
path_result: PathResult
|
|
@@ -61,13 +67,14 @@ class DeliveryAssignment:
|
|
| 61 |
return {
|
| 62 |
"store_id": self.store_id,
|
| 63 |
"destination_id": self.destination_id,
|
| 64 |
-
"path": self.path_result.to_dict()
|
| 65 |
}
|
| 66 |
|
| 67 |
|
| 68 |
@dataclass
|
| 69 |
class PlanResult:
|
| 70 |
"""Result of the complete delivery planning."""
|
|
|
|
| 71 |
assignments: List[DeliveryAssignment]
|
| 72 |
total_cost: float
|
| 73 |
total_nodes_expanded: int
|
|
@@ -85,13 +92,14 @@ class PlanResult:
|
|
| 85 |
return {
|
| 86 |
"assignments": [a.to_dict() for a in self.assignments],
|
| 87 |
"total_cost": self.total_cost,
|
| 88 |
-
"total_nodes_expanded": self.total_nodes_expanded
|
| 89 |
}
|
| 90 |
|
| 91 |
|
| 92 |
@dataclass
|
| 93 |
class SearchStep:
|
| 94 |
"""Represents a single step in the search process for visualization."""
|
|
|
|
| 95 |
step_number: int
|
| 96 |
current_node: Tuple[int, int]
|
| 97 |
action: Optional[str]
|
|
@@ -108,15 +116,16 @@ class SearchStep:
|
|
| 108 |
"frontier": [{"x": p[0], "y": p[1]} for p in self.frontier],
|
| 109 |
"explored": [{"x": p[0], "y": p[1]} for p in self.explored],
|
| 110 |
"currentPath": [{"x": p[0], "y": p[1]} for p in self.current_path],
|
| 111 |
-
"pathCost": self.path_cost
|
| 112 |
}
|
| 113 |
|
| 114 |
|
| 115 |
@dataclass
|
| 116 |
class SearchMetrics:
|
| 117 |
"""Performance metrics for a search execution."""
|
|
|
|
| 118 |
runtime_ms: float
|
| 119 |
-
|
| 120 |
cpu_percent: float
|
| 121 |
nodes_expanded: int
|
| 122 |
path_cost: float
|
|
@@ -125,9 +134,9 @@ class SearchMetrics:
|
|
| 125 |
def to_dict(self) -> dict:
|
| 126 |
return {
|
| 127 |
"runtime_ms": self.runtime_ms,
|
| 128 |
-
"
|
| 129 |
"cpu_percent": self.cpu_percent,
|
| 130 |
"nodes_expanded": self.nodes_expanded,
|
| 131 |
"path_cost": self.path_cost,
|
| 132 |
-
"path_length": self.path_length
|
| 133 |
}
|
|
|
|
| 1 |
"""State models for search and planning results."""
|
| 2 |
+
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import List, Optional, Tuple
|
| 5 |
from .grid import Grid
|
|
|
|
| 9 |
@dataclass
|
| 10 |
class SearchState:
|
| 11 |
"""Represents the complete state for a delivery search problem."""
|
| 12 |
+
|
| 13 |
grid: Grid
|
| 14 |
stores: List[Store]
|
| 15 |
destinations: List[Destination]
|
|
|
|
| 27 |
"grid": self.grid.to_dict(),
|
| 28 |
"stores": [s.to_dict() for s in self.stores],
|
| 29 |
"destinations": [d.to_dict() for d in self.destinations],
|
| 30 |
+
"tunnels": [t.to_dict() for t in self.tunnels],
|
| 31 |
}
|
| 32 |
|
| 33 |
|
| 34 |
@dataclass
|
| 35 |
class PathResult:
|
| 36 |
"""Result of finding a path from start to goal."""
|
| 37 |
+
|
| 38 |
plan: str # Comma-separated actions: "up,down,left,right,tunnel"
|
| 39 |
cost: float # Total traffic cost
|
| 40 |
nodes_expanded: int # Number of nodes expanded during search
|
| 41 |
+
path: List[Tuple[int, int]] = field(
|
| 42 |
+
default_factory=list
|
| 43 |
+
) # Actual positions in path
|
| 44 |
|
| 45 |
def to_string(self) -> str:
|
| 46 |
"""Format as required: plan;cost;nodesExpanded"""
|
|
|
|
| 51 |
"plan": self.plan,
|
| 52 |
"cost": self.cost,
|
| 53 |
"nodes_expanded": self.nodes_expanded,
|
| 54 |
+
"path": [{"x": p[0], "y": p[1]} for p in self.path],
|
| 55 |
}
|
| 56 |
|
| 57 |
|
| 58 |
@dataclass
|
| 59 |
class DeliveryAssignment:
|
| 60 |
"""Assignment of a destination to a store/truck."""
|
| 61 |
+
|
| 62 |
store_id: int
|
| 63 |
destination_id: int
|
| 64 |
path_result: PathResult
|
|
|
|
| 67 |
return {
|
| 68 |
"store_id": self.store_id,
|
| 69 |
"destination_id": self.destination_id,
|
| 70 |
+
"path": self.path_result.to_dict(),
|
| 71 |
}
|
| 72 |
|
| 73 |
|
| 74 |
@dataclass
|
| 75 |
class PlanResult:
|
| 76 |
"""Result of the complete delivery planning."""
|
| 77 |
+
|
| 78 |
assignments: List[DeliveryAssignment]
|
| 79 |
total_cost: float
|
| 80 |
total_nodes_expanded: int
|
|
|
|
| 92 |
return {
|
| 93 |
"assignments": [a.to_dict() for a in self.assignments],
|
| 94 |
"total_cost": self.total_cost,
|
| 95 |
+
"total_nodes_expanded": self.total_nodes_expanded,
|
| 96 |
}
|
| 97 |
|
| 98 |
|
| 99 |
@dataclass
|
| 100 |
class SearchStep:
|
| 101 |
"""Represents a single step in the search process for visualization."""
|
| 102 |
+
|
| 103 |
step_number: int
|
| 104 |
current_node: Tuple[int, int]
|
| 105 |
action: Optional[str]
|
|
|
|
| 116 |
"frontier": [{"x": p[0], "y": p[1]} for p in self.frontier],
|
| 117 |
"explored": [{"x": p[0], "y": p[1]} for p in self.explored],
|
| 118 |
"currentPath": [{"x": p[0], "y": p[1]} for p in self.current_path],
|
| 119 |
+
"pathCost": self.path_cost,
|
| 120 |
}
|
| 121 |
|
| 122 |
|
| 123 |
@dataclass
|
| 124 |
class SearchMetrics:
|
| 125 |
"""Performance metrics for a search execution."""
|
| 126 |
+
|
| 127 |
runtime_ms: float
|
| 128 |
+
memory_kb: float
|
| 129 |
cpu_percent: float
|
| 130 |
nodes_expanded: int
|
| 131 |
path_cost: float
|
|
|
|
| 134 |
def to_dict(self) -> dict:
|
| 135 |
return {
|
| 136 |
"runtime_ms": self.runtime_ms,
|
| 137 |
+
"memory_kb": self.memory_kb,
|
| 138 |
"cpu_percent": self.cpu_percent,
|
| 139 |
"nodes_expanded": self.nodes_expanded,
|
| 140 |
"path_cost": self.path_cost,
|
| 141 |
+
"path_length": self.path_length,
|
| 142 |
}
|
backend/app/services/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Services package."""
|
|
|
|
| 2 |
from .parser import (
|
| 3 |
parse_initial_state,
|
| 4 |
parse_traffic,
|
|
|
|
| 1 |
"""Services package."""
|
| 2 |
+
|
| 3 |
from .parser import (
|
| 4 |
parse_initial_state,
|
| 5 |
parse_traffic,
|
backend/app/services/grid_generator.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Grid generator service for random grid creation."""
|
|
|
|
| 2 |
import random
|
| 3 |
from typing import Tuple, List, Set, Optional
|
| 4 |
from ..models.grid import Grid
|
|
@@ -14,7 +15,7 @@ def gen_grid(
|
|
| 14 |
num_destinations: Optional[int] = None,
|
| 15 |
num_tunnels: Optional[int] = None,
|
| 16 |
obstacle_density: float = 0.1,
|
| 17 |
-
seed: Optional[int] = None
|
| 18 |
) -> Tuple[str, str, SearchState]:
|
| 19 |
"""
|
| 20 |
Randomly generate a valid grid configuration.
|
|
@@ -38,7 +39,9 @@ def gen_grid(
|
|
| 38 |
width = width or random.randint(5, 15)
|
| 39 |
height = height or random.randint(5, 15)
|
| 40 |
num_stores = num_stores or random.randint(1, 3)
|
| 41 |
-
num_destinations = num_destinations or random.randint(
|
|
|
|
|
|
|
| 42 |
num_tunnels = num_tunnels or random.randint(0, min(5, width * height // 10))
|
| 43 |
|
| 44 |
# Validate constraints
|
|
@@ -61,7 +64,9 @@ def gen_grid(
|
|
| 61 |
grid = _generate_traffic(width, height, obstacle_density, stores, destinations)
|
| 62 |
|
| 63 |
# Create search state
|
| 64 |
-
state = SearchState(
|
|
|
|
|
|
|
| 65 |
|
| 66 |
# Format strings
|
| 67 |
initial_state = format_initial_state(width, height, stores, destinations, tunnels)
|
|
@@ -71,10 +76,7 @@ def gen_grid(
|
|
| 71 |
|
| 72 |
|
| 73 |
def _generate_stores(
|
| 74 |
-
width: int,
|
| 75 |
-
height: int,
|
| 76 |
-
num_stores: int,
|
| 77 |
-
occupied: Set[Tuple[int, int]]
|
| 78 |
) -> List[Store]:
|
| 79 |
"""Generate store positions at corners/edges."""
|
| 80 |
stores = []
|
|
@@ -112,10 +114,7 @@ def _generate_stores(
|
|
| 112 |
|
| 113 |
|
| 114 |
def _generate_destinations(
|
| 115 |
-
width: int,
|
| 116 |
-
height: int,
|
| 117 |
-
num_destinations: int,
|
| 118 |
-
occupied: Set[Tuple[int, int]]
|
| 119 |
) -> List[Destination]:
|
| 120 |
"""Generate random destination positions."""
|
| 121 |
destinations = []
|
|
@@ -137,10 +136,7 @@ def _generate_destinations(
|
|
| 137 |
|
| 138 |
|
| 139 |
def _generate_tunnels(
|
| 140 |
-
width: int,
|
| 141 |
-
height: int,
|
| 142 |
-
num_tunnels: int,
|
| 143 |
-
occupied: Set[Tuple[int, int]]
|
| 144 |
) -> List[Tunnel]:
|
| 145 |
"""Generate random tunnel pairs."""
|
| 146 |
tunnels = []
|
|
@@ -154,18 +150,35 @@ def _generate_tunnels(
|
|
| 154 |
|
| 155 |
random.shuffle(available)
|
| 156 |
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
dist = abs(entrance1[0] - entrance2[0]) + abs(entrance1[1] - entrance2[1])
|
| 164 |
if dist >= 3: # Only create if Manhattan distance >= 3
|
| 165 |
tunnels.append(Tunnel(entrance1=entrance1, entrance2=entrance2))
|
| 166 |
occupied.add(entrance1)
|
| 167 |
occupied.add(entrance2)
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
return tunnels
|
| 170 |
|
| 171 |
|
|
@@ -174,7 +187,7 @@ def _generate_traffic(
|
|
| 174 |
height: int,
|
| 175 |
obstacle_density: float,
|
| 176 |
stores: List[Store],
|
| 177 |
-
destinations: List[Destination]
|
| 178 |
) -> Grid:
|
| 179 |
"""
|
| 180 |
Generate traffic levels for all segments.
|
|
@@ -212,9 +225,7 @@ def _generate_traffic(
|
|
| 212 |
|
| 213 |
|
| 214 |
def _ensure_connectivity(
|
| 215 |
-
grid: Grid,
|
| 216 |
-
stores: List[Store],
|
| 217 |
-
destinations: List[Destination]
|
| 218 |
) -> None:
|
| 219 |
"""
|
| 220 |
Ensure the grid is connected between stores and destinations.
|
|
@@ -222,7 +233,9 @@ def _ensure_connectivity(
|
|
| 222 |
Uses BFS to check connectivity and unblocks segments if needed.
|
| 223 |
"""
|
| 224 |
# Get all important positions
|
| 225 |
-
important_positions = [s.position for s in stores] + [
|
|
|
|
|
|
|
| 226 |
|
| 227 |
if len(important_positions) < 2:
|
| 228 |
return
|
|
@@ -253,7 +266,7 @@ def _create_path_to(
|
|
| 253 |
grid: Grid,
|
| 254 |
start: Tuple[int, int],
|
| 255 |
goal: Tuple[int, int],
|
| 256 |
-
visited: Set[Tuple[int, int]]
|
| 257 |
) -> None:
|
| 258 |
"""Create a path from visited area to goal by unblocking segments."""
|
| 259 |
# Simple approach: find closest visited cell to goal and unblock path
|
|
|
|
| 1 |
"""Grid generator service for random grid creation."""
|
| 2 |
+
|
| 3 |
import random
|
| 4 |
from typing import Tuple, List, Set, Optional
|
| 5 |
from ..models.grid import Grid
|
|
|
|
| 15 |
num_destinations: Optional[int] = None,
|
| 16 |
num_tunnels: Optional[int] = None,
|
| 17 |
obstacle_density: float = 0.1,
|
| 18 |
+
seed: Optional[int] = None,
|
| 19 |
) -> Tuple[str, str, SearchState]:
|
| 20 |
"""
|
| 21 |
Randomly generate a valid grid configuration.
|
|
|
|
| 39 |
width = width or random.randint(5, 15)
|
| 40 |
height = height or random.randint(5, 15)
|
| 41 |
num_stores = num_stores or random.randint(1, 3)
|
| 42 |
+
num_destinations = num_destinations or random.randint(
|
| 43 |
+
1, min(10, width * height // 4)
|
| 44 |
+
)
|
| 45 |
num_tunnels = num_tunnels or random.randint(0, min(5, width * height // 10))
|
| 46 |
|
| 47 |
# Validate constraints
|
|
|
|
| 64 |
grid = _generate_traffic(width, height, obstacle_density, stores, destinations)
|
| 65 |
|
| 66 |
# Create search state
|
| 67 |
+
state = SearchState(
|
| 68 |
+
grid=grid, stores=stores, destinations=destinations, tunnels=tunnels
|
| 69 |
+
)
|
| 70 |
|
| 71 |
# Format strings
|
| 72 |
initial_state = format_initial_state(width, height, stores, destinations, tunnels)
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
def _generate_stores(
|
| 79 |
+
width: int, height: int, num_stores: int, occupied: Set[Tuple[int, int]]
|
|
|
|
|
|
|
|
|
|
| 80 |
) -> List[Store]:
|
| 81 |
"""Generate store positions at corners/edges."""
|
| 82 |
stores = []
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
def _generate_destinations(
|
| 117 |
+
width: int, height: int, num_destinations: int, occupied: Set[Tuple[int, int]]
|
|
|
|
|
|
|
|
|
|
| 118 |
) -> List[Destination]:
|
| 119 |
"""Generate random destination positions."""
|
| 120 |
destinations = []
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
def _generate_tunnels(
|
| 139 |
+
width: int, height: int, num_tunnels: int, occupied: Set[Tuple[int, int]]
|
|
|
|
|
|
|
|
|
|
| 140 |
) -> List[Tunnel]:
|
| 141 |
"""Generate random tunnel pairs."""
|
| 142 |
tunnels = []
|
|
|
|
| 150 |
|
| 151 |
random.shuffle(available)
|
| 152 |
|
| 153 |
+
i = 0
|
| 154 |
+
attempts = 0
|
| 155 |
+
max_attempts = len(available) * 2
|
| 156 |
+
|
| 157 |
+
while i < num_tunnels and len(available) >= 2 and attempts < max_attempts:
|
| 158 |
+
attempts += 1
|
| 159 |
+
idx1 = random.randint(0, len(available) - 1)
|
| 160 |
+
entrance1 = available[idx1]
|
| 161 |
|
| 162 |
+
remaining = [pos for j, pos in enumerate(available) if j != idx1]
|
| 163 |
+
if not remaining:
|
| 164 |
+
break
|
| 165 |
+
|
| 166 |
+
idx2 = random.randint(0, len(remaining) - 1)
|
| 167 |
+
entrance2 = remaining[idx2]
|
| 168 |
+
|
| 169 |
+
# Ensure tunnels are useful
|
| 170 |
dist = abs(entrance1[0] - entrance2[0]) + abs(entrance1[1] - entrance2[1])
|
| 171 |
if dist >= 3: # Only create if Manhattan distance >= 3
|
| 172 |
tunnels.append(Tunnel(entrance1=entrance1, entrance2=entrance2))
|
| 173 |
occupied.add(entrance1)
|
| 174 |
occupied.add(entrance2)
|
| 175 |
|
| 176 |
+
# Remove used positions from available
|
| 177 |
+
available.remove(entrance1)
|
| 178 |
+
available.remove(entrance2)
|
| 179 |
+
|
| 180 |
+
i += 1
|
| 181 |
+
|
| 182 |
return tunnels
|
| 183 |
|
| 184 |
|
|
|
|
| 187 |
height: int,
|
| 188 |
obstacle_density: float,
|
| 189 |
stores: List[Store],
|
| 190 |
+
destinations: List[Destination],
|
| 191 |
) -> Grid:
|
| 192 |
"""
|
| 193 |
Generate traffic levels for all segments.
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
def _ensure_connectivity(
|
| 228 |
+
grid: Grid, stores: List[Store], destinations: List[Destination]
|
|
|
|
|
|
|
| 229 |
) -> None:
|
| 230 |
"""
|
| 231 |
Ensure the grid is connected between stores and destinations.
|
|
|
|
| 233 |
Uses BFS to check connectivity and unblocks segments if needed.
|
| 234 |
"""
|
| 235 |
# Get all important positions
|
| 236 |
+
important_positions = [s.position for s in stores] + [
|
| 237 |
+
d.position for d in destinations
|
| 238 |
+
]
|
| 239 |
|
| 240 |
if len(important_positions) < 2:
|
| 241 |
return
|
|
|
|
| 266 |
grid: Grid,
|
| 267 |
start: Tuple[int, int],
|
| 268 |
goal: Tuple[int, int],
|
| 269 |
+
visited: Set[Tuple[int, int]],
|
| 270 |
) -> None:
|
| 271 |
"""Create a path from visited area to goal by unblocking segments."""
|
| 272 |
# Simple approach: find closest visited cell to goal and unblock path
|
backend/app/services/metrics.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Performance metrics collection service."""
|
|
|
|
| 2 |
import time
|
| 3 |
import psutil
|
| 4 |
from contextlib import contextmanager
|
|
@@ -17,6 +18,7 @@ class MetricsCollector:
|
|
| 17 |
self.start_memory: int = 0
|
| 18 |
self.end_memory: int = 0
|
| 19 |
self.peak_memory: int = 0
|
|
|
|
| 20 |
self.cpu_samples: list = []
|
| 21 |
self._process = psutil.Process()
|
| 22 |
|
|
@@ -25,6 +27,7 @@ class MetricsCollector:
|
|
| 25 |
self.start_time = time.perf_counter()
|
| 26 |
self.start_memory = self._process.memory_info().rss
|
| 27 |
self.peak_memory = self.start_memory
|
|
|
|
| 28 |
self.cpu_samples = []
|
| 29 |
# Initial CPU sample
|
| 30 |
self._process.cpu_percent()
|
|
@@ -32,6 +35,7 @@ class MetricsCollector:
|
|
| 32 |
def sample(self) -> None:
|
| 33 |
"""Take a sample of current metrics."""
|
| 34 |
current_memory = self._process.memory_info().rss
|
|
|
|
| 35 |
self.peak_memory = max(self.peak_memory, current_memory)
|
| 36 |
self.cpu_samples.append(self._process.cpu_percent())
|
| 37 |
|
|
@@ -39,6 +43,8 @@ class MetricsCollector:
|
|
| 39 |
"""Stop collecting metrics."""
|
| 40 |
self.end_time = time.perf_counter()
|
| 41 |
self.end_memory = self._process.memory_info().rss
|
|
|
|
|
|
|
| 42 |
# Final CPU sample
|
| 43 |
self.cpu_samples.append(self._process.cpu_percent())
|
| 44 |
|
|
@@ -48,9 +54,13 @@ class MetricsCollector:
|
|
| 48 |
return (self.end_time - self.start_time) * 1000
|
| 49 |
|
| 50 |
@property
|
| 51 |
-
def
|
| 52 |
-
"""Get
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
@property
|
| 56 |
def cpu_percent(self) -> float:
|
|
@@ -59,15 +69,17 @@ class MetricsCollector:
|
|
| 59 |
return 0.0
|
| 60 |
return sum(self.cpu_samples) / len(self.cpu_samples)
|
| 61 |
|
| 62 |
-
def to_metrics(
|
|
|
|
|
|
|
| 63 |
"""Convert to SearchMetrics object."""
|
| 64 |
return SearchMetrics(
|
| 65 |
runtime_ms=self.runtime_ms,
|
| 66 |
-
|
| 67 |
cpu_percent=self.cpu_percent,
|
| 68 |
nodes_expanded=nodes_expanded,
|
| 69 |
path_cost=path_cost,
|
| 70 |
-
path_length=path_length
|
| 71 |
)
|
| 72 |
|
| 73 |
|
|
@@ -90,9 +102,7 @@ def measure_performance() -> Generator[MetricsCollector, None, None]:
|
|
| 90 |
|
| 91 |
|
| 92 |
def run_with_metrics(
|
| 93 |
-
func: Callable[..., Any],
|
| 94 |
-
*args,
|
| 95 |
-
**kwargs
|
| 96 |
) -> Tuple[Any, MetricsCollector]:
|
| 97 |
"""
|
| 98 |
Run a function and collect performance metrics.
|
|
|
|
| 1 |
"""Performance metrics collection service."""
|
| 2 |
+
|
| 3 |
import time
|
| 4 |
import psutil
|
| 5 |
from contextlib import contextmanager
|
|
|
|
| 18 |
self.start_memory: int = 0
|
| 19 |
self.end_memory: int = 0
|
| 20 |
self.peak_memory: int = 0
|
| 21 |
+
self.memory_samples: list = []
|
| 22 |
self.cpu_samples: list = []
|
| 23 |
self._process = psutil.Process()
|
| 24 |
|
|
|
|
| 27 |
self.start_time = time.perf_counter()
|
| 28 |
self.start_memory = self._process.memory_info().rss
|
| 29 |
self.peak_memory = self.start_memory
|
| 30 |
+
self.memory_samples = [self.start_memory]
|
| 31 |
self.cpu_samples = []
|
| 32 |
# Initial CPU sample
|
| 33 |
self._process.cpu_percent()
|
|
|
|
| 35 |
def sample(self) -> None:
|
| 36 |
"""Take a sample of current metrics."""
|
| 37 |
current_memory = self._process.memory_info().rss
|
| 38 |
+
self.memory_samples.append(current_memory)
|
| 39 |
self.peak_memory = max(self.peak_memory, current_memory)
|
| 40 |
self.cpu_samples.append(self._process.cpu_percent())
|
| 41 |
|
|
|
|
| 43 |
"""Stop collecting metrics."""
|
| 44 |
self.end_time = time.perf_counter()
|
| 45 |
self.end_memory = self._process.memory_info().rss
|
| 46 |
+
self.memory_samples.append(self.end_memory)
|
| 47 |
+
self.peak_memory = max(self.peak_memory, self.end_memory)
|
| 48 |
# Final CPU sample
|
| 49 |
self.cpu_samples.append(self._process.cpu_percent())
|
| 50 |
|
|
|
|
| 54 |
return (self.end_time - self.start_time) * 1000
|
| 55 |
|
| 56 |
@property
|
| 57 |
+
def memory_kb(self) -> float:
|
| 58 |
+
"""Get memory usage in KB (peak minus baseline)."""
|
| 59 |
+
if len(self.memory_samples) > 1:
|
| 60 |
+
# Use max sample minus start for more accurate peak measurement
|
| 61 |
+
max_sample = max(self.memory_samples)
|
| 62 |
+
return (max_sample - self.start_memory) / 1024
|
| 63 |
+
return (self.peak_memory - self.start_memory) / 1024
|
| 64 |
|
| 65 |
@property
|
| 66 |
def cpu_percent(self) -> float:
|
|
|
|
| 69 |
return 0.0
|
| 70 |
return sum(self.cpu_samples) / len(self.cpu_samples)
|
| 71 |
|
| 72 |
+
def to_metrics(
|
| 73 |
+
self, nodes_expanded: int, path_cost: float, path_length: int
|
| 74 |
+
) -> SearchMetrics:
|
| 75 |
"""Convert to SearchMetrics object."""
|
| 76 |
return SearchMetrics(
|
| 77 |
runtime_ms=self.runtime_ms,
|
| 78 |
+
memory_kb=max(0, self.memory_kb), # Ensure non-negative
|
| 79 |
cpu_percent=self.cpu_percent,
|
| 80 |
nodes_expanded=nodes_expanded,
|
| 81 |
path_cost=path_cost,
|
| 82 |
+
path_length=path_length,
|
| 83 |
)
|
| 84 |
|
| 85 |
|
|
|
|
| 102 |
|
| 103 |
|
| 104 |
def run_with_metrics(
|
| 105 |
+
func: Callable[..., Any], *args, **kwargs
|
|
|
|
|
|
|
| 106 |
) -> Tuple[Any, MetricsCollector]:
|
| 107 |
"""
|
| 108 |
Run a function and collect performance metrics.
|
backend/app/services/parser.py
CHANGED
|
@@ -1,17 +1,21 @@
|
|
| 1 |
"""Parser service for initial state and traffic strings."""
|
|
|
|
| 2 |
from typing import Tuple, List
|
| 3 |
from ..models.grid import Grid
|
| 4 |
from ..models.entities import Store, Destination, Tunnel
|
| 5 |
from ..models.state import SearchState
|
| 6 |
|
| 7 |
|
| 8 |
-
def parse_initial_state(
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
Parse the initial state string.
|
| 11 |
|
| 12 |
Format:
|
| 13 |
m;n;P;S;CustomerX_1,CustomerY_1,CustomerX_2,CustomerY_2,...;
|
| 14 |
-
TunnelX_1,TunnelY_1,TunnelX_1',TunnelY_1',TunnelX_2,TunnelY_2,TunnelX_2',TunnelY_2'
|
|
|
|
| 15 |
|
| 16 |
Args:
|
| 17 |
initial_state: The initial state string
|
|
@@ -19,7 +23,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
|
|
| 19 |
Returns:
|
| 20 |
Tuple of (width, height, stores, destinations, tunnels)
|
| 21 |
"""
|
| 22 |
-
parts = initial_state.strip().split(
|
| 23 |
|
| 24 |
# Grid dimensions
|
| 25 |
width = int(parts[0]) # m
|
|
@@ -32,7 +36,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
|
|
| 32 |
# Parse customer locations
|
| 33 |
destinations: List[Destination] = []
|
| 34 |
if len(parts) > 4 and parts[4]:
|
| 35 |
-
customer_coords = parts[4].split(
|
| 36 |
for i in range(0, len(customer_coords), 2):
|
| 37 |
if i + 1 < len(customer_coords):
|
| 38 |
x = int(customer_coords[i])
|
|
@@ -43,7 +47,7 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
|
|
| 43 |
# Parse tunnel locations
|
| 44 |
tunnels: List[Tunnel] = []
|
| 45 |
if len(parts) > 5 and parts[5]:
|
| 46 |
-
tunnel_coords = parts[5].split(
|
| 47 |
for i in range(0, len(tunnel_coords), 4):
|
| 48 |
if i + 3 < len(tunnel_coords):
|
| 49 |
x1 = int(tunnel_coords[i])
|
|
@@ -52,64 +56,20 @@ def parse_initial_state(initial_state: str) -> Tuple[int, int, List[Store], List
|
|
| 52 |
y2 = int(tunnel_coords[i + 3])
|
| 53 |
tunnels.append(Tunnel(entrance1=(x1, y1), entrance2=(x2, y2)))
|
| 54 |
|
| 55 |
-
#
|
| 56 |
-
# For now, place stores at corners/edges
|
| 57 |
stores: List[Store] = []
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
return width, height, stores, destinations, tunnels
|
| 63 |
|
| 64 |
|
| 65 |
-
def _generate_store_positions(
|
| 66 |
-
width: int,
|
| 67 |
-
height: int,
|
| 68 |
-
num_stores: int,
|
| 69 |
-
destinations: List[Destination],
|
| 70 |
-
tunnels: List[Tunnel]
|
| 71 |
-
) -> List[Tuple[int, int]]:
|
| 72 |
-
"""
|
| 73 |
-
Generate store positions avoiding conflicts.
|
| 74 |
-
|
| 75 |
-
Places stores at corners and edges of the grid.
|
| 76 |
-
"""
|
| 77 |
-
occupied = set()
|
| 78 |
-
for dest in destinations:
|
| 79 |
-
occupied.add(dest.position)
|
| 80 |
-
for tunnel in tunnels:
|
| 81 |
-
occupied.add(tunnel.entrance1)
|
| 82 |
-
occupied.add(tunnel.entrance2)
|
| 83 |
-
|
| 84 |
-
# Preferred positions (corners first, then edges)
|
| 85 |
-
preferred = [
|
| 86 |
-
(0, 0),
|
| 87 |
-
(width - 1, 0),
|
| 88 |
-
(0, height - 1),
|
| 89 |
-
(width - 1, height - 1),
|
| 90 |
-
(width // 2, 0),
|
| 91 |
-
(0, height // 2),
|
| 92 |
-
(width - 1, height // 2),
|
| 93 |
-
(width // 2, height - 1),
|
| 94 |
-
]
|
| 95 |
-
|
| 96 |
-
positions = []
|
| 97 |
-
for pos in preferred:
|
| 98 |
-
if pos not in occupied and len(positions) < num_stores:
|
| 99 |
-
positions.append(pos)
|
| 100 |
-
occupied.add(pos)
|
| 101 |
-
|
| 102 |
-
# If still need more positions, find any valid position
|
| 103 |
-
if len(positions) < num_stores:
|
| 104 |
-
for x in range(width):
|
| 105 |
-
for y in range(height):
|
| 106 |
-
if (x, y) not in occupied and len(positions) < num_stores:
|
| 107 |
-
positions.append((x, y))
|
| 108 |
-
occupied.add((x, y))
|
| 109 |
-
|
| 110 |
-
return positions
|
| 111 |
-
|
| 112 |
-
|
| 113 |
def parse_traffic(traffic_str: str, width: int, height: int) -> Grid:
|
| 114 |
"""
|
| 115 |
Parse the traffic string and create a Grid.
|
|
@@ -132,11 +92,11 @@ def parse_traffic(traffic_str: str, width: int, height: int) -> Grid:
|
|
| 132 |
_initialize_default_traffic(grid)
|
| 133 |
return grid
|
| 134 |
|
| 135 |
-
segments = traffic_str.strip().split(
|
| 136 |
for segment in segments:
|
| 137 |
if not segment:
|
| 138 |
continue
|
| 139 |
-
parts = segment.split(
|
| 140 |
if len(parts) >= 5:
|
| 141 |
src_x = int(parts[0])
|
| 142 |
src_y = int(parts[1])
|
|
@@ -179,10 +139,7 @@ def parse_full_state(initial_state: str, traffic_str: str) -> SearchState:
|
|
| 179 |
grid = parse_traffic(traffic_str, width, height)
|
| 180 |
|
| 181 |
return SearchState(
|
| 182 |
-
grid=grid,
|
| 183 |
-
stores=stores,
|
| 184 |
-
destinations=destinations,
|
| 185 |
-
tunnels=tunnels
|
| 186 |
)
|
| 187 |
|
| 188 |
|
|
@@ -191,7 +148,7 @@ def format_initial_state(
|
|
| 191 |
height: int,
|
| 192 |
stores: List[Store],
|
| 193 |
destinations: List[Destination],
|
| 194 |
-
tunnels: List[Tunnel]
|
| 195 |
) -> str:
|
| 196 |
"""
|
| 197 |
Format state back into initial state string.
|
|
@@ -217,18 +174,28 @@ def format_initial_state(
|
|
| 217 |
customer_coords = []
|
| 218 |
for dest in destinations:
|
| 219 |
customer_coords.extend([str(dest.position[0]), str(dest.position[1])])
|
| 220 |
-
parts.append(
|
| 221 |
|
| 222 |
# Tunnel coordinates
|
| 223 |
tunnel_coords = []
|
| 224 |
for tunnel in tunnels:
|
| 225 |
-
tunnel_coords.extend(
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
|
| 234 |
def format_traffic(grid: Grid) -> str:
|
|
@@ -243,7 +210,5 @@ def format_traffic(grid: Grid) -> str:
|
|
| 243 |
"""
|
| 244 |
segments = []
|
| 245 |
for (src, dst), segment in grid.segments.items():
|
| 246 |
-
segments.append(
|
| 247 |
-
|
| 248 |
-
)
|
| 249 |
-
return ';'.join(segments)
|
|
|
|
| 1 |
"""Parser service for initial state and traffic strings."""
|
| 2 |
+
|
| 3 |
from typing import Tuple, List
|
| 4 |
from ..models.grid import Grid
|
| 5 |
from ..models.entities import Store, Destination, Tunnel
|
| 6 |
from ..models.state import SearchState
|
| 7 |
|
| 8 |
|
| 9 |
+
def parse_initial_state(
|
| 10 |
+
initial_state: str,
|
| 11 |
+
) -> Tuple[int, int, List[Store], List[Destination], List[Tunnel]]:
|
| 12 |
"""
|
| 13 |
Parse the initial state string.
|
| 14 |
|
| 15 |
Format:
|
| 16 |
m;n;P;S;CustomerX_1,CustomerY_1,CustomerX_2,CustomerY_2,...;
|
| 17 |
+
TunnelX_1,TunnelY_1,TunnelX_1',TunnelY_1',TunnelX_2,TunnelY_2,TunnelX_2',TunnelY_2',...;
|
| 18 |
+
StoreX_1,StoreY_1,StoreX_2,StoreY_2,...
|
| 19 |
|
| 20 |
Args:
|
| 21 |
initial_state: The initial state string
|
|
|
|
| 23 |
Returns:
|
| 24 |
Tuple of (width, height, stores, destinations, tunnels)
|
| 25 |
"""
|
| 26 |
+
parts = initial_state.strip().split(";")
|
| 27 |
|
| 28 |
# Grid dimensions
|
| 29 |
width = int(parts[0]) # m
|
|
|
|
| 36 |
# Parse customer locations
|
| 37 |
destinations: List[Destination] = []
|
| 38 |
if len(parts) > 4 and parts[4]:
|
| 39 |
+
customer_coords = parts[4].split(",")
|
| 40 |
for i in range(0, len(customer_coords), 2):
|
| 41 |
if i + 1 < len(customer_coords):
|
| 42 |
x = int(customer_coords[i])
|
|
|
|
| 47 |
# Parse tunnel locations
|
| 48 |
tunnels: List[Tunnel] = []
|
| 49 |
if len(parts) > 5 and parts[5]:
|
| 50 |
+
tunnel_coords = parts[5].split(",")
|
| 51 |
for i in range(0, len(tunnel_coords), 4):
|
| 52 |
if i + 3 < len(tunnel_coords):
|
| 53 |
x1 = int(tunnel_coords[i])
|
|
|
|
| 56 |
y2 = int(tunnel_coords[i + 3])
|
| 57 |
tunnels.append(Tunnel(entrance1=(x1, y1), entrance2=(x2, y2)))
|
| 58 |
|
| 59 |
+
# Parse store locations
|
|
|
|
| 60 |
stores: List[Store] = []
|
| 61 |
+
if len(parts) > 6 and parts[6]:
|
| 62 |
+
store_coords = parts[6].split(",")
|
| 63 |
+
for i in range(0, len(store_coords), 2):
|
| 64 |
+
if i + 1 < len(store_coords):
|
| 65 |
+
x = int(store_coords[i])
|
| 66 |
+
y = int(store_coords[i + 1])
|
| 67 |
+
store_id = len(stores) + 1
|
| 68 |
+
stores.append(Store(id=store_id, position=(x, y)))
|
| 69 |
|
| 70 |
return width, height, stores, destinations, tunnels
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
def parse_traffic(traffic_str: str, width: int, height: int) -> Grid:
|
| 74 |
"""
|
| 75 |
Parse the traffic string and create a Grid.
|
|
|
|
| 92 |
_initialize_default_traffic(grid)
|
| 93 |
return grid
|
| 94 |
|
| 95 |
+
segments = traffic_str.strip().split(";")
|
| 96 |
for segment in segments:
|
| 97 |
if not segment:
|
| 98 |
continue
|
| 99 |
+
parts = segment.split(",")
|
| 100 |
if len(parts) >= 5:
|
| 101 |
src_x = int(parts[0])
|
| 102 |
src_y = int(parts[1])
|
|
|
|
| 139 |
grid = parse_traffic(traffic_str, width, height)
|
| 140 |
|
| 141 |
return SearchState(
|
| 142 |
+
grid=grid, stores=stores, destinations=destinations, tunnels=tunnels
|
|
|
|
|
|
|
|
|
|
| 143 |
)
|
| 144 |
|
| 145 |
|
|
|
|
| 148 |
height: int,
|
| 149 |
stores: List[Store],
|
| 150 |
destinations: List[Destination],
|
| 151 |
+
tunnels: List[Tunnel],
|
| 152 |
) -> str:
|
| 153 |
"""
|
| 154 |
Format state back into initial state string.
|
|
|
|
| 174 |
customer_coords = []
|
| 175 |
for dest in destinations:
|
| 176 |
customer_coords.extend([str(dest.position[0]), str(dest.position[1])])
|
| 177 |
+
parts.append(",".join(customer_coords))
|
| 178 |
|
| 179 |
# Tunnel coordinates
|
| 180 |
tunnel_coords = []
|
| 181 |
for tunnel in tunnels:
|
| 182 |
+
tunnel_coords.extend(
|
| 183 |
+
[
|
| 184 |
+
str(tunnel.entrance1[0]),
|
| 185 |
+
str(tunnel.entrance1[1]),
|
| 186 |
+
str(tunnel.entrance2[0]),
|
| 187 |
+
str(tunnel.entrance2[1]),
|
| 188 |
+
]
|
| 189 |
+
)
|
| 190 |
+
parts.append(",".join(tunnel_coords))
|
| 191 |
|
| 192 |
+
# Store coordinates
|
| 193 |
+
store_coords = []
|
| 194 |
+
for store in stores:
|
| 195 |
+
store_coords.extend([str(store.position[0]), str(store.position[1])])
|
| 196 |
+
parts.append(",".join(store_coords))
|
| 197 |
+
|
| 198 |
+
return ";".join(parts)
|
| 199 |
|
| 200 |
|
| 201 |
def format_traffic(grid: Grid) -> str:
|
|
|
|
| 210 |
"""
|
| 211 |
segments = []
|
| 212 |
for (src, dst), segment in grid.segments.items():
|
| 213 |
+
segments.append(f"{src[0]},{src[1]},{dst[0]},{dst[1]},{segment.traffic}")
|
| 214 |
+
return ";".join(segments)
|
|
|
|
|
|
backend/pyproject.toml
CHANGED
|
@@ -5,6 +5,7 @@ description = "Add your description here"
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.10"
|
| 7 |
dependencies = [
|
|
|
|
| 8 |
"fastapi>=0.122.0",
|
| 9 |
"httpx>=0.28.1",
|
| 10 |
"psutil>=7.1.3",
|
|
@@ -16,3 +17,10 @@ dependencies = [
|
|
| 16 |
"uvicorn>=0.38.0",
|
| 17 |
"websockets>=15.0.1",
|
| 18 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.10"
|
| 7 |
dependencies = [
|
| 8 |
+
"black>=25.11.0",
|
| 9 |
"fastapi>=0.122.0",
|
| 10 |
"httpx>=0.28.1",
|
| 11 |
"psutil>=7.1.3",
|
|
|
|
| 17 |
"uvicorn>=0.38.0",
|
| 18 |
"websockets>=15.0.1",
|
| 19 |
]
|
| 20 |
+
|
| 21 |
+
[tool.pytest.ini_options]
|
| 22 |
+
testpaths = ["tests"]
|
| 23 |
+
python_files = ["test_*.py"]
|
| 24 |
+
python_classes = ["Test*"]
|
| 25 |
+
python_functions = ["test_*"]
|
| 26 |
+
addopts = "-v --tb=short"
|
backend/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Test package for the delivery search backend."""
|
backend/tests/conftest.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration and fixtures for backend tests."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from app.models.grid import Grid, Segment
|
| 5 |
+
from app.models.entities import Store, Destination, Tunnel
|
| 6 |
+
from app.models.state import SearchState
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def simple_grid():
|
| 11 |
+
"""Create a simple 3x3 grid with all segments having traffic level 1."""
|
| 12 |
+
grid = Grid(width=3, height=3)
|
| 13 |
+
# Horizontal segments
|
| 14 |
+
for x in range(2):
|
| 15 |
+
for y in range(3):
|
| 16 |
+
grid.add_segment((x, y), (x + 1, y), 1)
|
| 17 |
+
# Vertical segments
|
| 18 |
+
for x in range(3):
|
| 19 |
+
for y in range(2):
|
| 20 |
+
grid.add_segment((x, y), (x, y + 1), 1)
|
| 21 |
+
return grid
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture
|
| 25 |
+
def grid_with_blocked():
|
| 26 |
+
"""Create a 3x3 grid with some blocked segments."""
|
| 27 |
+
grid = Grid(width=3, height=3)
|
| 28 |
+
# Add all segments with traffic level 1
|
| 29 |
+
for x in range(2):
|
| 30 |
+
for y in range(3):
|
| 31 |
+
grid.add_segment((x, y), (x + 1, y), 1)
|
| 32 |
+
for x in range(3):
|
| 33 |
+
for y in range(2):
|
| 34 |
+
grid.add_segment((x, y), (x, y + 1), 1)
|
| 35 |
+
# Block segment from (1,1) to (2,1)
|
| 36 |
+
grid.add_segment((1, 1), (2, 1), 0)
|
| 37 |
+
return grid
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def grid_with_varied_traffic():
|
| 42 |
+
"""Create a 3x3 grid with varied traffic levels."""
|
| 43 |
+
grid = Grid(width=3, height=3)
|
| 44 |
+
# Horizontal segments with different traffic
|
| 45 |
+
grid.add_segment((0, 0), (1, 0), 1)
|
| 46 |
+
grid.add_segment((1, 0), (2, 0), 2)
|
| 47 |
+
grid.add_segment((0, 1), (1, 1), 3)
|
| 48 |
+
grid.add_segment((1, 1), (2, 1), 4)
|
| 49 |
+
grid.add_segment((0, 2), (1, 2), 1)
|
| 50 |
+
grid.add_segment((1, 2), (2, 2), 1)
|
| 51 |
+
# Vertical segments
|
| 52 |
+
grid.add_segment((0, 0), (0, 1), 2)
|
| 53 |
+
grid.add_segment((0, 1), (0, 2), 1)
|
| 54 |
+
grid.add_segment((1, 0), (1, 1), 1)
|
| 55 |
+
grid.add_segment((1, 1), (1, 2), 2)
|
| 56 |
+
grid.add_segment((2, 0), (2, 1), 1)
|
| 57 |
+
grid.add_segment((2, 1), (2, 2), 1)
|
| 58 |
+
return grid
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@pytest.fixture
|
| 62 |
+
def sample_stores():
|
| 63 |
+
"""Create sample stores."""
|
| 64 |
+
return [
|
| 65 |
+
Store(id=1, position=(0, 0)),
|
| 66 |
+
Store(id=2, position=(2, 2)),
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@pytest.fixture
|
| 71 |
+
def sample_destinations():
|
| 72 |
+
"""Create sample destinations."""
|
| 73 |
+
return [
|
| 74 |
+
Destination(id=1, position=(2, 0)),
|
| 75 |
+
Destination(id=2, position=(0, 2)),
|
| 76 |
+
Destination(id=3, position=(1, 1)),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.fixture
|
| 81 |
+
def sample_tunnels():
|
| 82 |
+
"""Create sample tunnels."""
|
| 83 |
+
return [
|
| 84 |
+
Tunnel(entrance1=(0, 0), entrance2=(2, 2)),
|
| 85 |
+
Tunnel(entrance1=(0, 2), entrance2=(2, 0)),
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.fixture
|
| 90 |
+
def sample_search_state(simple_grid, sample_stores, sample_destinations, sample_tunnels):
|
| 91 |
+
"""Create a complete search state."""
|
| 92 |
+
return SearchState(
|
| 93 |
+
grid=simple_grid,
|
| 94 |
+
stores=sample_stores,
|
| 95 |
+
destinations=sample_destinations,
|
| 96 |
+
tunnels=sample_tunnels,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@pytest.fixture
|
| 101 |
+
def larger_grid():
|
| 102 |
+
"""Create a 5x5 grid for more complex tests."""
|
| 103 |
+
grid = Grid(width=5, height=5)
|
| 104 |
+
# Add all segments with traffic level 1
|
| 105 |
+
for x in range(4):
|
| 106 |
+
for y in range(5):
|
| 107 |
+
grid.add_segment((x, y), (x + 1, y), 1)
|
| 108 |
+
for x in range(5):
|
| 109 |
+
for y in range(4):
|
| 110 |
+
grid.add_segment((x, y), (x, y + 1), 1)
|
| 111 |
+
return grid
|
backend/tests/test_algorithms.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for search algorithms (BFS, DFS, UCS, A*, Greedy, IDS)."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from app.algorithms.bfs import bfs_search
|
| 5 |
+
from app.algorithms.dfs import dfs_search
|
| 6 |
+
from app.algorithms.ucs import ucs_search
|
| 7 |
+
from app.algorithms.astar import astar_search
|
| 8 |
+
from app.algorithms.greedy import greedy_search
|
| 9 |
+
from app.algorithms.ids import ids_search
|
| 10 |
+
from app.heuristics import manhattan_heuristic, euclidean_heuristic
|
| 11 |
+
from app.core.delivery_search import DeliverySearch
|
| 12 |
+
from app.models.grid import Grid
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestBFS:
|
| 16 |
+
"""Tests for Breadth-First Search."""
|
| 17 |
+
|
| 18 |
+
def test_bfs_finds_path(self, simple_grid):
|
| 19 |
+
"""Test BFS finds a path."""
|
| 20 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 21 |
+
result, steps = bfs_search(search, visualize=False)
|
| 22 |
+
|
| 23 |
+
assert result.plan != ""
|
| 24 |
+
assert result.cost < float("inf")
|
| 25 |
+
assert len(result.path) > 0
|
| 26 |
+
assert result.path[0] == (0, 0)
|
| 27 |
+
assert result.path[-1] == (2, 2)
|
| 28 |
+
|
| 29 |
+
def test_bfs_shortest_path(self, simple_grid):
|
| 30 |
+
"""Test BFS finds shortest path by steps."""
|
| 31 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 32 |
+
result, _ = bfs_search(search, visualize=False)
|
| 33 |
+
|
| 34 |
+
# BFS should find path with minimum steps
|
| 35 |
+
# From (0,0) to (2,2) needs at least 4 steps
|
| 36 |
+
assert len(result.path) == 5 # 5 nodes including start and end
|
| 37 |
+
|
| 38 |
+
def test_bfs_no_path(self):
|
| 39 |
+
"""Test BFS when no path exists."""
|
| 40 |
+
grid = Grid(width=3, height=3)
|
| 41 |
+
# Create disconnected grid - only add vertical segment
|
| 42 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 43 |
+
|
| 44 |
+
search = DeliverySearch(grid, (0, 0), (2, 2), [])
|
| 45 |
+
result, _ = bfs_search(search, visualize=False)
|
| 46 |
+
|
| 47 |
+
assert result.plan == ""
|
| 48 |
+
assert result.cost == float("inf")
|
| 49 |
+
assert len(result.path) == 0
|
| 50 |
+
|
| 51 |
+
def test_bfs_same_start_goal(self, simple_grid):
|
| 52 |
+
"""Test BFS when start equals goal."""
|
| 53 |
+
search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
|
| 54 |
+
result, _ = bfs_search(search, visualize=False)
|
| 55 |
+
|
| 56 |
+
assert result.cost == 0
|
| 57 |
+
assert len(result.path) == 1
|
| 58 |
+
assert result.path[0] == (1, 1)
|
| 59 |
+
|
| 60 |
+
def test_bfs_visualization(self, simple_grid):
|
| 61 |
+
"""Test BFS with visualization enabled."""
|
| 62 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 63 |
+
result, steps = bfs_search(search, visualize=True)
|
| 64 |
+
|
| 65 |
+
assert steps is not None
|
| 66 |
+
assert len(steps) > 0
|
| 67 |
+
# First step should have start node
|
| 68 |
+
assert steps[0].current_node == (0, 0)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TestDFS:
|
| 72 |
+
"""Tests for Depth-First Search."""
|
| 73 |
+
|
| 74 |
+
def test_dfs_finds_path(self, simple_grid):
|
| 75 |
+
"""Test DFS finds a path."""
|
| 76 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 77 |
+
result, steps = dfs_search(search, visualize=False)
|
| 78 |
+
|
| 79 |
+
assert result.plan != ""
|
| 80 |
+
assert result.cost < float("inf")
|
| 81 |
+
assert len(result.path) > 0
|
| 82 |
+
assert result.path[0] == (0, 0)
|
| 83 |
+
assert result.path[-1] == (2, 2)
|
| 84 |
+
|
| 85 |
+
def test_dfs_no_path(self):
|
| 86 |
+
"""Test DFS when no path exists."""
|
| 87 |
+
grid = Grid(width=3, height=3)
|
| 88 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 89 |
+
|
| 90 |
+
search = DeliverySearch(grid, (0, 0), (2, 2), [])
|
| 91 |
+
result, _ = dfs_search(search, visualize=False)
|
| 92 |
+
|
| 93 |
+
assert result.plan == ""
|
| 94 |
+
assert result.cost == float("inf")
|
| 95 |
+
|
| 96 |
+
def test_dfs_same_start_goal(self, simple_grid):
|
| 97 |
+
"""Test DFS when start equals goal."""
|
| 98 |
+
search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
|
| 99 |
+
result, _ = dfs_search(search, visualize=False)
|
| 100 |
+
|
| 101 |
+
assert result.cost == 0
|
| 102 |
+
assert len(result.path) == 1
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class TestUCS:
|
| 106 |
+
"""Tests for Uniform Cost Search."""
|
| 107 |
+
|
| 108 |
+
def test_ucs_finds_optimal_path(self, grid_with_varied_traffic):
|
| 109 |
+
"""Test UCS finds optimal (minimum cost) path."""
|
| 110 |
+
search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
|
| 111 |
+
result, _ = ucs_search(search, visualize=False)
|
| 112 |
+
|
| 113 |
+
assert result.plan != ""
|
| 114 |
+
assert result.cost < float("inf")
|
| 115 |
+
|
| 116 |
+
def test_ucs_prefers_low_traffic(self, grid_with_varied_traffic):
|
| 117 |
+
"""Test UCS prefers lower traffic segments."""
|
| 118 |
+
search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
|
| 119 |
+
ucs_result, _ = ucs_search(search, visualize=False)
|
| 120 |
+
bfs_result, _ = bfs_search(search, visualize=False)
|
| 121 |
+
|
| 122 |
+
# UCS should find equal or better cost than BFS
|
| 123 |
+
assert ucs_result.cost <= bfs_result.cost
|
| 124 |
+
|
| 125 |
+
def test_ucs_no_path(self):
|
| 126 |
+
"""Test UCS when no path exists."""
|
| 127 |
+
grid = Grid(width=3, height=3)
|
| 128 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 129 |
+
|
| 130 |
+
search = DeliverySearch(grid, (0, 0), (2, 2), [])
|
| 131 |
+
result, _ = ucs_search(search, visualize=False)
|
| 132 |
+
|
| 133 |
+
assert result.cost == float("inf")
|
| 134 |
+
|
| 135 |
+
def test_ucs_same_start_goal(self, simple_grid):
|
| 136 |
+
"""Test UCS when start equals goal."""
|
| 137 |
+
search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
|
| 138 |
+
result, _ = ucs_search(search, visualize=False)
|
| 139 |
+
|
| 140 |
+
assert result.cost == 0
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class TestAStar:
|
| 144 |
+
"""Tests for A* Search."""
|
| 145 |
+
|
| 146 |
+
def test_astar_manhattan_finds_path(self, simple_grid):
|
| 147 |
+
"""Test A* with Manhattan heuristic finds a path."""
|
| 148 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 149 |
+
result, _ = astar_search(search, manhattan_heuristic, visualize=False)
|
| 150 |
+
|
| 151 |
+
assert result.plan != ""
|
| 152 |
+
assert result.cost < float("inf")
|
| 153 |
+
assert result.path[0] == (0, 0)
|
| 154 |
+
assert result.path[-1] == (2, 2)
|
| 155 |
+
|
| 156 |
+
def test_astar_euclidean_finds_path(self, simple_grid):
|
| 157 |
+
"""Test A* with Euclidean heuristic finds a path."""
|
| 158 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 159 |
+
result, _ = astar_search(search, euclidean_heuristic, visualize=False)
|
| 160 |
+
|
| 161 |
+
assert result.plan != ""
|
| 162 |
+
assert result.cost < float("inf")
|
| 163 |
+
|
| 164 |
+
def test_astar_optimal(self, grid_with_varied_traffic):
|
| 165 |
+
"""Test A* finds optimal path (same as UCS with admissible heuristic)."""
|
| 166 |
+
search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
|
| 167 |
+
astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
|
| 168 |
+
ucs_result, _ = ucs_search(search, visualize=False)
|
| 169 |
+
|
| 170 |
+
# A* with admissible heuristic should find same cost as UCS
|
| 171 |
+
assert astar_result.cost == ucs_result.cost
|
| 172 |
+
|
| 173 |
+
def test_astar_fewer_nodes_than_ucs(self, larger_grid):
|
| 174 |
+
"""Test A* expands fewer nodes than UCS."""
|
| 175 |
+
search = DeliverySearch(larger_grid, (0, 0), (4, 4), [])
|
| 176 |
+
astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
|
| 177 |
+
ucs_result, _ = ucs_search(search, visualize=False)
|
| 178 |
+
|
| 179 |
+
# A* should expand fewer or equal nodes
|
| 180 |
+
assert astar_result.nodes_expanded <= ucs_result.nodes_expanded
|
| 181 |
+
|
| 182 |
+
def test_astar_no_path(self):
|
| 183 |
+
"""Test A* when no path exists."""
|
| 184 |
+
grid = Grid(width=3, height=3)
|
| 185 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 186 |
+
|
| 187 |
+
search = DeliverySearch(grid, (0, 0), (2, 2), [])
|
| 188 |
+
result, _ = astar_search(search, manhattan_heuristic, visualize=False)
|
| 189 |
+
|
| 190 |
+
assert result.cost == float("inf")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class TestGreedy:
|
| 194 |
+
"""Tests for Greedy Best-First Search."""
|
| 195 |
+
|
| 196 |
+
def test_greedy_manhattan_finds_path(self, simple_grid):
|
| 197 |
+
"""Test Greedy with Manhattan heuristic finds a path."""
|
| 198 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 199 |
+
result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
|
| 200 |
+
|
| 201 |
+
assert result.plan != ""
|
| 202 |
+
assert result.cost < float("inf")
|
| 203 |
+
|
| 204 |
+
def test_greedy_euclidean_finds_path(self, simple_grid):
|
| 205 |
+
"""Test Greedy with Euclidean heuristic finds a path."""
|
| 206 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 207 |
+
result, _ = greedy_search(search, euclidean_heuristic, visualize=False)
|
| 208 |
+
|
| 209 |
+
assert result.plan != ""
|
| 210 |
+
assert result.cost < float("inf")
|
| 211 |
+
|
| 212 |
+
def test_greedy_fast_but_suboptimal(self, grid_with_varied_traffic):
|
| 213 |
+
"""Test Greedy is fast but may not find optimal path."""
|
| 214 |
+
search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
|
| 215 |
+
greedy_result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
|
| 216 |
+
ucs_result, _ = ucs_search(search, visualize=False)
|
| 217 |
+
|
| 218 |
+
# Greedy may find suboptimal path
|
| 219 |
+
assert greedy_result.cost >= ucs_result.cost
|
| 220 |
+
|
| 221 |
+
def test_greedy_no_path(self):
|
| 222 |
+
"""Test Greedy when no path exists."""
|
| 223 |
+
grid = Grid(width=3, height=3)
|
| 224 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 225 |
+
|
| 226 |
+
search = DeliverySearch(grid, (0, 0), (2, 2), [])
|
| 227 |
+
result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
|
| 228 |
+
|
| 229 |
+
assert result.cost == float("inf")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class TestIDS:
|
| 233 |
+
"""Tests for Iterative Deepening Search."""
|
| 234 |
+
|
| 235 |
+
def test_ids_finds_path(self, simple_grid):
|
| 236 |
+
"""Test IDS finds a path."""
|
| 237 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 238 |
+
result, _ = ids_search(search, visualize=False)
|
| 239 |
+
|
| 240 |
+
assert result.plan != ""
|
| 241 |
+
assert result.cost < float("inf")
|
| 242 |
+
assert result.path[0] == (0, 0)
|
| 243 |
+
assert result.path[-1] == (2, 2)
|
| 244 |
+
|
| 245 |
+
def test_ids_optimal_steps(self, simple_grid):
|
| 246 |
+
"""Test IDS finds path with optimal number of steps."""
|
| 247 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 248 |
+
ids_result, _ = ids_search(search, visualize=False)
|
| 249 |
+
bfs_result, _ = bfs_search(search, visualize=False)
|
| 250 |
+
|
| 251 |
+
# IDS should find same path length as BFS
|
| 252 |
+
assert len(ids_result.path) == len(bfs_result.path)
|
| 253 |
+
|
| 254 |
+
def test_ids_no_path(self):
|
| 255 |
+
"""Test IDS when no path exists."""
|
| 256 |
+
grid = Grid(width=3, height=3)
|
| 257 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 258 |
+
|
| 259 |
+
search = DeliverySearch(grid, (0, 0), (2, 2), [])
|
| 260 |
+
result, _ = ids_search(search, visualize=False)
|
| 261 |
+
|
| 262 |
+
assert result.cost == float("inf")
|
| 263 |
+
|
| 264 |
+
def test_ids_same_start_goal(self, simple_grid):
|
| 265 |
+
"""Test IDS when start equals goal."""
|
| 266 |
+
search = DeliverySearch(simple_grid, (1, 1), (1, 1), [])
|
| 267 |
+
result, _ = ids_search(search, visualize=False)
|
| 268 |
+
|
| 269 |
+
assert result.cost == 0
|
| 270 |
+
assert len(result.path) == 1
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class TestAlgorithmComparison:
|
| 274 |
+
"""Tests comparing different algorithms."""
|
| 275 |
+
|
| 276 |
+
def test_all_algorithms_find_same_goal(self, simple_grid):
|
| 277 |
+
"""Test all algorithms reach the same goal."""
|
| 278 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 279 |
+
|
| 280 |
+
bfs_result, _ = bfs_search(search, visualize=False)
|
| 281 |
+
dfs_result, _ = dfs_search(search, visualize=False)
|
| 282 |
+
ucs_result, _ = ucs_search(search, visualize=False)
|
| 283 |
+
astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
|
| 284 |
+
greedy_result, _ = greedy_search(search, manhattan_heuristic, visualize=False)
|
| 285 |
+
ids_result, _ = ids_search(search, visualize=False)
|
| 286 |
+
|
| 287 |
+
# All should find a path ending at goal
|
| 288 |
+
assert bfs_result.path[-1] == (2, 2)
|
| 289 |
+
assert dfs_result.path[-1] == (2, 2)
|
| 290 |
+
assert ucs_result.path[-1] == (2, 2)
|
| 291 |
+
assert astar_result.path[-1] == (2, 2)
|
| 292 |
+
assert greedy_result.path[-1] == (2, 2)
|
| 293 |
+
assert ids_result.path[-1] == (2, 2)
|
| 294 |
+
|
| 295 |
+
def test_optimal_algorithms_same_cost(self, grid_with_varied_traffic):
|
| 296 |
+
"""Test UCS and A* find same optimal cost."""
|
| 297 |
+
search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
|
| 298 |
+
|
| 299 |
+
ucs_result, _ = ucs_search(search, visualize=False)
|
| 300 |
+
astar_result, _ = astar_search(search, manhattan_heuristic, visualize=False)
|
| 301 |
+
|
| 302 |
+
assert ucs_result.cost == astar_result.cost
|
| 303 |
+
|
| 304 |
+
def test_visualization_consistency(self, simple_grid):
|
| 305 |
+
"""Test visualization steps are consistent across algorithms."""
|
| 306 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 307 |
+
|
| 308 |
+
_, bfs_steps = bfs_search(search, visualize=True)
|
| 309 |
+
_, ucs_steps = ucs_search(search, visualize=True)
|
| 310 |
+
|
| 311 |
+
# Both should have visualization steps
|
| 312 |
+
assert bfs_steps is not None
|
| 313 |
+
assert ucs_steps is not None
|
| 314 |
+
assert len(bfs_steps) > 0
|
| 315 |
+
assert len(ucs_steps) > 0
|
backend/tests/test_api.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for API routes."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from fastapi.testclient import TestClient
|
| 5 |
+
from app.main import app
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.fixture
|
| 9 |
+
def client():
|
| 10 |
+
"""Create test client."""
|
| 11 |
+
return TestClient(app)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def sample_initial_state():
|
| 16 |
+
"""Sample initial state string."""
|
| 17 |
+
return "5;5;2;1;2,2,4,4;;0,0"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def sample_traffic():
|
| 22 |
+
"""Sample traffic string for 5x5 grid."""
|
| 23 |
+
segments = []
|
| 24 |
+
# Horizontal segments
|
| 25 |
+
for x in range(4):
|
| 26 |
+
for y in range(5):
|
| 27 |
+
segments.append(f"{x},{y},{x+1},{y},1")
|
| 28 |
+
# Vertical segments
|
| 29 |
+
for x in range(5):
|
| 30 |
+
for y in range(4):
|
| 31 |
+
segments.append(f"{x},{y},{x},{y+1},1")
|
| 32 |
+
return ";".join(segments)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TestHealthEndpoint:
|
| 36 |
+
"""Tests for health check endpoint."""
|
| 37 |
+
|
| 38 |
+
def test_health_check(self, client):
|
| 39 |
+
"""Test health check returns ok."""
|
| 40 |
+
response = client.get("/api/health")
|
| 41 |
+
assert response.status_code == 200
|
| 42 |
+
assert response.json()["status"] == "ok"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class TestAlgorithmsEndpoint:
|
| 46 |
+
"""Tests for algorithms endpoint."""
|
| 47 |
+
|
| 48 |
+
def test_list_algorithms(self, client):
|
| 49 |
+
"""Test listing available algorithms."""
|
| 50 |
+
response = client.get("/api/algorithms")
|
| 51 |
+
assert response.status_code == 200
|
| 52 |
+
|
| 53 |
+
data = response.json()
|
| 54 |
+
assert "algorithms" in data
|
| 55 |
+
assert len(data["algorithms"]) == 8 # 8 algorithms
|
| 56 |
+
|
| 57 |
+
# Check algorithm structure
|
| 58 |
+
algo = data["algorithms"][0]
|
| 59 |
+
assert "code" in algo
|
| 60 |
+
assert "name" in algo
|
| 61 |
+
assert "description" in algo
|
| 62 |
+
|
| 63 |
+
def test_algorithms_codes(self, client):
|
| 64 |
+
"""Test all expected algorithm codes are present."""
|
| 65 |
+
response = client.get("/api/algorithms")
|
| 66 |
+
data = response.json()
|
| 67 |
+
|
| 68 |
+
codes = [a["code"] for a in data["algorithms"]]
|
| 69 |
+
expected = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
|
| 70 |
+
assert set(codes) == set(expected)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class TestGenerateGridEndpoint:
|
| 74 |
+
"""Tests for grid generation endpoint."""
|
| 75 |
+
|
| 76 |
+
def test_generate_default_grid(self, client):
|
| 77 |
+
"""Test generating grid with default config."""
|
| 78 |
+
response = client.post("/api/grid/generate", json={})
|
| 79 |
+
assert response.status_code == 200
|
| 80 |
+
|
| 81 |
+
data = response.json()
|
| 82 |
+
assert "initial_state" in data
|
| 83 |
+
assert "traffic" in data
|
| 84 |
+
assert "parsed" in data
|
| 85 |
+
|
| 86 |
+
def test_generate_custom_grid(self, client):
|
| 87 |
+
"""Test generating grid with custom config."""
|
| 88 |
+
response = client.post(
|
| 89 |
+
"/api/grid/generate",
|
| 90 |
+
json={
|
| 91 |
+
"width": 8,
|
| 92 |
+
"height": 8,
|
| 93 |
+
"num_stores": 2,
|
| 94 |
+
"num_destinations": 3,
|
| 95 |
+
"num_tunnels": 1,
|
| 96 |
+
"obstacle_density": 0.1,
|
| 97 |
+
},
|
| 98 |
+
)
|
| 99 |
+
assert response.status_code == 200
|
| 100 |
+
|
| 101 |
+
data = response.json()
|
| 102 |
+
parsed = data["parsed"]
|
| 103 |
+
assert parsed["width"] == 8
|
| 104 |
+
assert parsed["height"] == 8
|
| 105 |
+
assert len(parsed["stores"]) == 2
|
| 106 |
+
assert len(parsed["destinations"]) == 3
|
| 107 |
+
|
| 108 |
+
def test_generate_grid_parsed_structure(self, client):
|
| 109 |
+
"""Test parsed grid structure."""
|
| 110 |
+
response = client.post("/api/grid/generate", json={"width": 5, "height": 5})
|
| 111 |
+
data = response.json()
|
| 112 |
+
parsed = data["parsed"]
|
| 113 |
+
|
| 114 |
+
assert "width" in parsed
|
| 115 |
+
assert "height" in parsed
|
| 116 |
+
assert "stores" in parsed
|
| 117 |
+
assert "destinations" in parsed
|
| 118 |
+
assert "tunnels" in parsed
|
| 119 |
+
assert "segments" in parsed
|
| 120 |
+
|
| 121 |
+
def test_generate_grid_stores_structure(self, client):
|
| 122 |
+
"""Test store structure in response."""
|
| 123 |
+
response = client.post(
|
| 124 |
+
"/api/grid/generate",
|
| 125 |
+
json={"width": 5, "height": 5, "num_stores": 1},
|
| 126 |
+
)
|
| 127 |
+
data = response.json()
|
| 128 |
+
store = data["parsed"]["stores"][0]
|
| 129 |
+
|
| 130 |
+
assert "id" in store
|
| 131 |
+
assert "position" in store
|
| 132 |
+
assert "x" in store["position"]
|
| 133 |
+
assert "y" in store["position"]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class TestPathEndpoint:
|
| 137 |
+
"""Tests for path finding endpoint."""
|
| 138 |
+
|
| 139 |
+
def test_find_path(self, client):
|
| 140 |
+
"""Test finding path between two points."""
|
| 141 |
+
# First generate a grid
|
| 142 |
+
gen_response = client.post(
|
| 143 |
+
"/api/grid/generate",
|
| 144 |
+
json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1},
|
| 145 |
+
)
|
| 146 |
+
grid_data = gen_response.json()["parsed"]
|
| 147 |
+
|
| 148 |
+
# Find path from store to destination
|
| 149 |
+
store = grid_data["stores"][0]
|
| 150 |
+
dest = grid_data["destinations"][0]
|
| 151 |
+
|
| 152 |
+
response = client.post(
|
| 153 |
+
"/api/search/path",
|
| 154 |
+
json={
|
| 155 |
+
"grid_width": 5,
|
| 156 |
+
"grid_height": 5,
|
| 157 |
+
"start": store["position"],
|
| 158 |
+
"goal": dest["position"],
|
| 159 |
+
"segments": grid_data["segments"],
|
| 160 |
+
"tunnels": grid_data["tunnels"],
|
| 161 |
+
"strategy": "BF",
|
| 162 |
+
},
|
| 163 |
+
)
|
| 164 |
+
assert response.status_code == 200
|
| 165 |
+
|
| 166 |
+
data = response.json()
|
| 167 |
+
assert "plan" in data
|
| 168 |
+
assert "cost" in data
|
| 169 |
+
assert "nodes_expanded" in data
|
| 170 |
+
assert "path" in data
|
| 171 |
+
assert "runtime_ms" in data
|
| 172 |
+
assert "memory_kb" in data
|
| 173 |
+
|
| 174 |
+
def test_find_path_all_strategies(self, client):
|
| 175 |
+
"""Test path finding with all strategies."""
|
| 176 |
+
gen_response = client.post(
|
| 177 |
+
"/api/grid/generate",
|
| 178 |
+
json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1},
|
| 179 |
+
)
|
| 180 |
+
grid_data = gen_response.json()["parsed"]
|
| 181 |
+
store = grid_data["stores"][0]
|
| 182 |
+
dest = grid_data["destinations"][0]
|
| 183 |
+
|
| 184 |
+
strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
|
| 185 |
+
for strategy in strategies:
|
| 186 |
+
response = client.post(
|
| 187 |
+
"/api/search/path",
|
| 188 |
+
json={
|
| 189 |
+
"grid_width": 5,
|
| 190 |
+
"grid_height": 5,
|
| 191 |
+
"start": store["position"],
|
| 192 |
+
"goal": dest["position"],
|
| 193 |
+
"segments": grid_data["segments"],
|
| 194 |
+
"tunnels": grid_data["tunnels"],
|
| 195 |
+
"strategy": strategy,
|
| 196 |
+
},
|
| 197 |
+
)
|
| 198 |
+
assert response.status_code == 200, f"Strategy {strategy} failed"
|
| 199 |
+
|
| 200 |
+
def test_find_path_with_visualization(self, client):
|
| 201 |
+
"""Test path finding returns visualization steps."""
|
| 202 |
+
gen_response = client.post(
|
| 203 |
+
"/api/grid/generate",
|
| 204 |
+
json={"width": 5, "height": 5, "num_stores": 1, "num_destinations": 1},
|
| 205 |
+
)
|
| 206 |
+
grid_data = gen_response.json()["parsed"]
|
| 207 |
+
store = grid_data["stores"][0]
|
| 208 |
+
dest = grid_data["destinations"][0]
|
| 209 |
+
|
| 210 |
+
response = client.post(
|
| 211 |
+
"/api/search/path",
|
| 212 |
+
json={
|
| 213 |
+
"grid_width": 5,
|
| 214 |
+
"grid_height": 5,
|
| 215 |
+
"start": store["position"],
|
| 216 |
+
"goal": dest["position"],
|
| 217 |
+
"segments": grid_data["segments"],
|
| 218 |
+
"tunnels": grid_data["tunnels"],
|
| 219 |
+
"strategy": "BF",
|
| 220 |
+
},
|
| 221 |
+
)
|
| 222 |
+
data = response.json()
|
| 223 |
+
|
| 224 |
+
# Steps should be included
|
| 225 |
+
assert "steps" in data
|
| 226 |
+
assert data["steps"] is not None
|
| 227 |
+
assert len(data["steps"]) > 0
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class TestPlanEndpoint:
|
| 231 |
+
"""Tests for delivery planning endpoint."""
|
| 232 |
+
|
| 233 |
+
def test_create_plan(self, client, sample_initial_state, sample_traffic):
|
| 234 |
+
"""Test creating delivery plan."""
|
| 235 |
+
response = client.post(
|
| 236 |
+
"/api/search/plan",
|
| 237 |
+
json={
|
| 238 |
+
"initial_state": sample_initial_state,
|
| 239 |
+
"traffic": sample_traffic,
|
| 240 |
+
"strategy": "BF",
|
| 241 |
+
"visualize": False,
|
| 242 |
+
},
|
| 243 |
+
)
|
| 244 |
+
assert response.status_code == 200
|
| 245 |
+
|
| 246 |
+
data = response.json()
|
| 247 |
+
assert "output" in data
|
| 248 |
+
assert "assignments" in data
|
| 249 |
+
assert "total_cost" in data
|
| 250 |
+
assert "total_nodes_expanded" in data
|
| 251 |
+
assert "runtime_ms" in data
|
| 252 |
+
assert "memory_kb" in data
|
| 253 |
+
|
| 254 |
+
def test_create_plan_all_strategies(self, client, sample_initial_state, sample_traffic):
|
| 255 |
+
"""Test planning with all strategies."""
|
| 256 |
+
strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
|
| 257 |
+
for strategy in strategies:
|
| 258 |
+
response = client.post(
|
| 259 |
+
"/api/search/plan",
|
| 260 |
+
json={
|
| 261 |
+
"initial_state": sample_initial_state,
|
| 262 |
+
"traffic": sample_traffic,
|
| 263 |
+
"strategy": strategy,
|
| 264 |
+
"visualize": False,
|
| 265 |
+
},
|
| 266 |
+
)
|
| 267 |
+
assert response.status_code == 200, f"Strategy {strategy} failed"
|
| 268 |
+
|
| 269 |
+
def test_plan_assignments_structure(self, client, sample_initial_state, sample_traffic):
|
| 270 |
+
"""Test plan assignments structure."""
|
| 271 |
+
response = client.post(
|
| 272 |
+
"/api/search/plan",
|
| 273 |
+
json={
|
| 274 |
+
"initial_state": sample_initial_state,
|
| 275 |
+
"traffic": sample_traffic,
|
| 276 |
+
"strategy": "BF",
|
| 277 |
+
"visualize": False,
|
| 278 |
+
},
|
| 279 |
+
)
|
| 280 |
+
data = response.json()
|
| 281 |
+
|
| 282 |
+
# Should have 2 assignments (2 destinations in sample)
|
| 283 |
+
assert len(data["assignments"]) == 2
|
| 284 |
+
|
| 285 |
+
assignment = data["assignments"][0]
|
| 286 |
+
assert "store_id" in assignment
|
| 287 |
+
assert "destination_id" in assignment
|
| 288 |
+
assert "path" in assignment
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class TestCompareEndpoint:
|
| 292 |
+
"""Tests for algorithm comparison endpoint."""
|
| 293 |
+
|
| 294 |
+
def test_compare_algorithms(self, client, sample_initial_state, sample_traffic):
|
| 295 |
+
"""Test comparing all algorithms."""
|
| 296 |
+
response = client.post(
|
| 297 |
+
"/api/search/compare",
|
| 298 |
+
json={
|
| 299 |
+
"initial_state": sample_initial_state,
|
| 300 |
+
"traffic": sample_traffic,
|
| 301 |
+
},
|
| 302 |
+
)
|
| 303 |
+
assert response.status_code == 200
|
| 304 |
+
|
| 305 |
+
data = response.json()
|
| 306 |
+
assert "comparisons" in data
|
| 307 |
+
assert "optimal_cost" in data
|
| 308 |
+
assert len(data["comparisons"]) == 8 # 8 algorithms
|
| 309 |
+
|
| 310 |
+
def test_compare_result_structure(self, client, sample_initial_state, sample_traffic):
|
| 311 |
+
"""Test comparison result structure."""
|
| 312 |
+
response = client.post(
|
| 313 |
+
"/api/search/compare",
|
| 314 |
+
json={
|
| 315 |
+
"initial_state": sample_initial_state,
|
| 316 |
+
"traffic": sample_traffic,
|
| 317 |
+
},
|
| 318 |
+
)
|
| 319 |
+
data = response.json()
|
| 320 |
+
result = data["comparisons"][0]
|
| 321 |
+
|
| 322 |
+
assert "algorithm" in result
|
| 323 |
+
assert "name" in result
|
| 324 |
+
assert "plan" in result
|
| 325 |
+
assert "cost" in result
|
| 326 |
+
assert "nodes_expanded" in result
|
| 327 |
+
assert "runtime_ms" in result
|
| 328 |
+
assert "memory_kb" in result
|
| 329 |
+
assert "is_optimal" in result
|
| 330 |
+
|
| 331 |
+
def test_compare_marks_optimal(self, client, sample_initial_state, sample_traffic):
|
| 332 |
+
"""Test that optimal solutions are marked."""
|
| 333 |
+
response = client.post(
|
| 334 |
+
"/api/search/compare",
|
| 335 |
+
json={
|
| 336 |
+
"initial_state": sample_initial_state,
|
| 337 |
+
"traffic": sample_traffic,
|
| 338 |
+
},
|
| 339 |
+
)
|
| 340 |
+
data = response.json()
|
| 341 |
+
|
| 342 |
+
# At least one should be marked optimal
|
| 343 |
+
optimal_count = sum(1 for r in data["comparisons"] if r["is_optimal"])
|
| 344 |
+
assert optimal_count >= 1
|
| 345 |
+
|
| 346 |
+
# Optimal results should have cost equal to optimal_cost
|
| 347 |
+
for result in data["comparisons"]:
|
| 348 |
+
if result["is_optimal"]:
|
| 349 |
+
assert result["cost"] == data["optimal_cost"]
|
| 350 |
+
|
| 351 |
+
def test_compare_ucs_astar_optimal(self, client, sample_initial_state, sample_traffic):
|
| 352 |
+
"""Test UCS and A* find optimal solutions."""
|
| 353 |
+
response = client.post(
|
| 354 |
+
"/api/search/compare",
|
| 355 |
+
json={
|
| 356 |
+
"initial_state": sample_initial_state,
|
| 357 |
+
"traffic": sample_traffic,
|
| 358 |
+
},
|
| 359 |
+
)
|
| 360 |
+
data = response.json()
|
| 361 |
+
|
| 362 |
+
ucs = next(r for r in data["comparisons"] if r["algorithm"] == "UC")
|
| 363 |
+
astar = next(r for r in data["comparisons"] if r["algorithm"] == "AS1")
|
| 364 |
+
|
| 365 |
+
# Both should find optimal cost
|
| 366 |
+
assert ucs["cost"] == data["optimal_cost"]
|
| 367 |
+
assert astar["cost"] == data["optimal_cost"]
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
class TestErrorHandling:
|
| 371 |
+
"""Tests for API error handling."""
|
| 372 |
+
|
| 373 |
+
def test_invalid_strategy(self, client, sample_initial_state, sample_traffic):
|
| 374 |
+
"""Test error on invalid strategy."""
|
| 375 |
+
response = client.post(
|
| 376 |
+
"/api/search/plan",
|
| 377 |
+
json={
|
| 378 |
+
"initial_state": sample_initial_state,
|
| 379 |
+
"traffic": sample_traffic,
|
| 380 |
+
"strategy": "INVALID",
|
| 381 |
+
"visualize": False,
|
| 382 |
+
},
|
| 383 |
+
)
|
| 384 |
+
# Should return validation error or internal error
|
| 385 |
+
assert response.status_code in [400, 422, 500]
|
| 386 |
+
|
| 387 |
+
def test_invalid_grid_dimensions(self, client):
|
| 388 |
+
"""Test error on invalid grid dimensions."""
|
| 389 |
+
response = client.post(
|
| 390 |
+
"/api/grid/generate",
|
| 391 |
+
json={"width": -1, "height": 5},
|
| 392 |
+
)
|
| 393 |
+
# Should return validation error
|
| 394 |
+
assert response.status_code in [400, 422, 500]
|
backend/tests/test_core.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for core modules (delivery_search, delivery_planner)."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from app.core.delivery_search import DeliverySearch
|
| 5 |
+
from app.core.delivery_planner import DeliveryPlanner
|
| 6 |
+
from app.core.node import SearchNode
|
| 7 |
+
from app.core.frontier import QueueFrontier, StackFrontier, PriorityQueueFrontier
|
| 8 |
+
from app.models.grid import Grid
|
| 9 |
+
from app.models.entities import Store, Destination, Tunnel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class TestSearchNode:
|
| 13 |
+
"""Tests for SearchNode."""
|
| 14 |
+
|
| 15 |
+
def test_node_creation(self):
|
| 16 |
+
"""Test basic node creation."""
|
| 17 |
+
node = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 18 |
+
assert node.state == (0, 0)
|
| 19 |
+
assert node.path_cost == 0
|
| 20 |
+
assert node.depth == 0
|
| 21 |
+
assert node.parent is None
|
| 22 |
+
assert node.action is None
|
| 23 |
+
|
| 24 |
+
def test_node_with_parent(self):
|
| 25 |
+
"""Test node with parent."""
|
| 26 |
+
parent = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 27 |
+
child = SearchNode(
|
| 28 |
+
state=(1, 0),
|
| 29 |
+
parent=parent,
|
| 30 |
+
action="right",
|
| 31 |
+
path_cost=1,
|
| 32 |
+
depth=1,
|
| 33 |
+
)
|
| 34 |
+
assert child.parent == parent
|
| 35 |
+
assert child.action == "right"
|
| 36 |
+
assert child.depth == 1
|
| 37 |
+
|
| 38 |
+
def test_get_path(self):
|
| 39 |
+
"""Test path reconstruction."""
|
| 40 |
+
root = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 41 |
+
child1 = SearchNode(state=(1, 0), parent=root, action="right", path_cost=1, depth=1)
|
| 42 |
+
child2 = SearchNode(state=(2, 0), parent=child1, action="right", path_cost=2, depth=2)
|
| 43 |
+
|
| 44 |
+
path = child2.get_path()
|
| 45 |
+
assert path == [(0, 0), (1, 0), (2, 0)]
|
| 46 |
+
|
| 47 |
+
def test_get_solution(self):
|
| 48 |
+
"""Test solution string generation."""
|
| 49 |
+
root = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 50 |
+
child1 = SearchNode(state=(1, 0), parent=root, action="right", path_cost=1, depth=1)
|
| 51 |
+
child2 = SearchNode(state=(1, 1), parent=child1, action="up", path_cost=2, depth=2)
|
| 52 |
+
|
| 53 |
+
solution = child2.get_solution()
|
| 54 |
+
assert solution == "right,up"
|
| 55 |
+
|
| 56 |
+
def test_get_solution_single_node(self):
|
| 57 |
+
"""Test solution for single node (start = goal)."""
|
| 58 |
+
root = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 59 |
+
assert root.get_solution() == ""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class TestFrontiers:
|
| 63 |
+
"""Tests for frontier data structures."""
|
| 64 |
+
|
| 65 |
+
def test_queue_frontier_fifo(self):
|
| 66 |
+
"""Test queue frontier is FIFO."""
|
| 67 |
+
frontier = QueueFrontier()
|
| 68 |
+
node1 = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 69 |
+
node2 = SearchNode(state=(1, 0), path_cost=1, depth=1)
|
| 70 |
+
node3 = SearchNode(state=(2, 0), path_cost=2, depth=2)
|
| 71 |
+
|
| 72 |
+
frontier.push(node1)
|
| 73 |
+
frontier.push(node2)
|
| 74 |
+
frontier.push(node3)
|
| 75 |
+
|
| 76 |
+
assert frontier.pop().state == (0, 0)
|
| 77 |
+
assert frontier.pop().state == (1, 0)
|
| 78 |
+
assert frontier.pop().state == (2, 0)
|
| 79 |
+
|
| 80 |
+
def test_stack_frontier_lifo(self):
|
| 81 |
+
"""Test stack frontier is LIFO."""
|
| 82 |
+
frontier = StackFrontier()
|
| 83 |
+
node1 = SearchNode(state=(0, 0), path_cost=0, depth=0)
|
| 84 |
+
node2 = SearchNode(state=(1, 0), path_cost=1, depth=1)
|
| 85 |
+
node3 = SearchNode(state=(2, 0), path_cost=2, depth=2)
|
| 86 |
+
|
| 87 |
+
frontier.push(node1)
|
| 88 |
+
frontier.push(node2)
|
| 89 |
+
frontier.push(node3)
|
| 90 |
+
|
| 91 |
+
assert frontier.pop().state == (2, 0)
|
| 92 |
+
assert frontier.pop().state == (1, 0)
|
| 93 |
+
assert frontier.pop().state == (0, 0)
|
| 94 |
+
|
| 95 |
+
def test_priority_queue_frontier(self):
|
| 96 |
+
"""Test priority queue frontier orders by priority."""
|
| 97 |
+
frontier = PriorityQueueFrontier()
|
| 98 |
+
# Priority is stored in node.priority attribute
|
| 99 |
+
node1 = SearchNode(state=(0, 0), path_cost=5, depth=0)
|
| 100 |
+
node1.priority = 5
|
| 101 |
+
node2 = SearchNode(state=(1, 0), path_cost=1, depth=1)
|
| 102 |
+
node2.priority = 1
|
| 103 |
+
node3 = SearchNode(state=(2, 0), path_cost=3, depth=2)
|
| 104 |
+
node3.priority = 3
|
| 105 |
+
|
| 106 |
+
frontier.push(node1)
|
| 107 |
+
frontier.push(node2)
|
| 108 |
+
frontier.push(node3)
|
| 109 |
+
|
| 110 |
+
assert frontier.pop().state == (1, 0) # Lowest priority first
|
| 111 |
+
assert frontier.pop().state == (2, 0)
|
| 112 |
+
assert frontier.pop().state == (0, 0)
|
| 113 |
+
|
| 114 |
+
def test_frontier_is_empty(self):
|
| 115 |
+
"""Test frontier empty check."""
|
| 116 |
+
frontier = QueueFrontier()
|
| 117 |
+
assert frontier.is_empty() is True
|
| 118 |
+
|
| 119 |
+
frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0))
|
| 120 |
+
assert frontier.is_empty() is False
|
| 121 |
+
|
| 122 |
+
frontier.pop()
|
| 123 |
+
assert frontier.is_empty() is True
|
| 124 |
+
|
| 125 |
+
def test_frontier_contains_state(self):
|
| 126 |
+
"""Test frontier state containment check."""
|
| 127 |
+
frontier = QueueFrontier()
|
| 128 |
+
frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0))
|
| 129 |
+
frontier.push(SearchNode(state=(1, 0), path_cost=1, depth=1))
|
| 130 |
+
|
| 131 |
+
assert frontier.contains_state((0, 0)) is True
|
| 132 |
+
assert frontier.contains_state((1, 0)) is True
|
| 133 |
+
assert frontier.contains_state((2, 0)) is False
|
| 134 |
+
|
| 135 |
+
def test_frontier_get_states(self):
|
| 136 |
+
"""Test getting all states in frontier."""
|
| 137 |
+
frontier = QueueFrontier()
|
| 138 |
+
frontier.push(SearchNode(state=(0, 0), path_cost=0, depth=0))
|
| 139 |
+
frontier.push(SearchNode(state=(1, 0), path_cost=1, depth=1))
|
| 140 |
+
|
| 141 |
+
states = frontier.get_states()
|
| 142 |
+
assert (0, 0) in states
|
| 143 |
+
assert (1, 0) in states
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class TestDeliverySearch:
|
| 147 |
+
"""Tests for DeliverySearch."""
|
| 148 |
+
|
| 149 |
+
def test_initial_state(self, simple_grid):
|
| 150 |
+
"""Test initial state is set correctly."""
|
| 151 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 152 |
+
assert search.initial_state() == (0, 0)
|
| 153 |
+
|
| 154 |
+
def test_goal_test(self, simple_grid):
|
| 155 |
+
"""Test goal test function."""
|
| 156 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 157 |
+
assert search.goal_test((2, 2)) is True
|
| 158 |
+
assert search.goal_test((0, 0)) is False
|
| 159 |
+
assert search.goal_test((1, 1)) is False
|
| 160 |
+
|
| 161 |
+
def test_actions(self, simple_grid):
|
| 162 |
+
"""Test available actions."""
|
| 163 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 164 |
+
|
| 165 |
+
# Corner has 2 actions
|
| 166 |
+
actions = search.actions((0, 0))
|
| 167 |
+
assert "up" in actions
|
| 168 |
+
assert "right" in actions
|
| 169 |
+
assert "down" not in actions
|
| 170 |
+
assert "left" not in actions
|
| 171 |
+
|
| 172 |
+
# Center has 4 actions
|
| 173 |
+
actions = search.actions((1, 1))
|
| 174 |
+
assert len(actions) == 4
|
| 175 |
+
|
| 176 |
+
def test_actions_with_tunnel(self, simple_grid):
|
| 177 |
+
"""Test tunnel action availability."""
|
| 178 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2))
|
| 179 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel])
|
| 180 |
+
|
| 181 |
+
actions = search.actions((0, 0))
|
| 182 |
+
assert "tunnel" in actions
|
| 183 |
+
|
| 184 |
+
actions = search.actions((1, 1))
|
| 185 |
+
assert "tunnel" not in actions
|
| 186 |
+
|
| 187 |
+
def test_result_movement(self, simple_grid):
|
| 188 |
+
"""Test result of movement actions."""
|
| 189 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 190 |
+
|
| 191 |
+
assert search.result((1, 1), "up") == (1, 2)
|
| 192 |
+
assert search.result((1, 1), "down") == (1, 0)
|
| 193 |
+
assert search.result((1, 1), "left") == (0, 1)
|
| 194 |
+
assert search.result((1, 1), "right") == (2, 1)
|
| 195 |
+
|
| 196 |
+
def test_result_tunnel(self, simple_grid):
|
| 197 |
+
"""Test result of tunnel action."""
|
| 198 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2))
|
| 199 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel])
|
| 200 |
+
|
| 201 |
+
assert search.result((0, 0), "tunnel") == (2, 2)
|
| 202 |
+
assert search.result((2, 2), "tunnel") == (0, 0)
|
| 203 |
+
|
| 204 |
+
def test_step_cost_movement(self, grid_with_varied_traffic):
|
| 205 |
+
"""Test step cost for movement."""
|
| 206 |
+
search = DeliverySearch(grid_with_varied_traffic, (0, 0), (2, 2), [])
|
| 207 |
+
|
| 208 |
+
# Cost should equal traffic level
|
| 209 |
+
cost = search.step_cost((0, 0), "right", (1, 0))
|
| 210 |
+
assert cost == grid_with_varied_traffic.get_traffic((0, 0), (1, 0))
|
| 211 |
+
|
| 212 |
+
def test_step_cost_tunnel(self, simple_grid):
|
| 213 |
+
"""Test step cost for tunnel (Manhattan distance)."""
|
| 214 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(2, 2))
|
| 215 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [tunnel])
|
| 216 |
+
|
| 217 |
+
cost = search.step_cost((0, 0), "tunnel", (2, 2))
|
| 218 |
+
assert cost == tunnel.cost # Manhattan distance = 4
|
| 219 |
+
|
| 220 |
+
def test_solve_all_strategies(self, simple_grid):
|
| 221 |
+
"""Test solve method with all strategies."""
|
| 222 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 223 |
+
|
| 224 |
+
strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
|
| 225 |
+
for strategy in strategies:
|
| 226 |
+
result, _ = search.solve(strategy, visualize=False)
|
| 227 |
+
assert result.path[-1] == (2, 2), f"Strategy {strategy} failed"
|
| 228 |
+
|
| 229 |
+
def test_solve_invalid_strategy(self, simple_grid):
|
| 230 |
+
"""Test solve with invalid strategy."""
|
| 231 |
+
search = DeliverySearch(simple_grid, (0, 0), (2, 2), [])
|
| 232 |
+
|
| 233 |
+
with pytest.raises(ValueError):
|
| 234 |
+
search.solve("INVALID", visualize=False)
|
| 235 |
+
|
| 236 |
+
def test_path_static_method(self, simple_grid):
|
| 237 |
+
"""Test static path method."""
|
| 238 |
+
result, steps = DeliverySearch.path(
|
| 239 |
+
simple_grid,
|
| 240 |
+
(0, 0),
|
| 241 |
+
(2, 2),
|
| 242 |
+
[],
|
| 243 |
+
"BF",
|
| 244 |
+
visualize=False,
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
assert result.path[0] == (0, 0)
|
| 248 |
+
assert result.path[-1] == (2, 2)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
class TestDeliveryPlanner:
|
| 252 |
+
"""Tests for DeliveryPlanner."""
|
| 253 |
+
|
| 254 |
+
def test_planner_single_destination(self, simple_grid):
|
| 255 |
+
"""Test planner with single destination."""
|
| 256 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 257 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 258 |
+
|
| 259 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, [])
|
| 260 |
+
result, _ = planner.plan("BF", visualize=False)
|
| 261 |
+
|
| 262 |
+
assert len(result.assignments) == 1
|
| 263 |
+
assert result.assignments[0].store_id == 1
|
| 264 |
+
assert result.assignments[0].destination_id == 1
|
| 265 |
+
assert result.total_cost > 0
|
| 266 |
+
|
| 267 |
+
def test_planner_multiple_destinations(self, simple_grid):
|
| 268 |
+
"""Test planner with multiple destinations."""
|
| 269 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 270 |
+
destinations = [
|
| 271 |
+
Destination(id=1, position=(2, 0)),
|
| 272 |
+
Destination(id=2, position=(0, 2)),
|
| 273 |
+
Destination(id=3, position=(2, 2)),
|
| 274 |
+
]
|
| 275 |
+
|
| 276 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, [])
|
| 277 |
+
result, _ = planner.plan("BF", visualize=False)
|
| 278 |
+
|
| 279 |
+
assert len(result.assignments) == 3
|
| 280 |
+
assert result.total_cost > 0
|
| 281 |
+
|
| 282 |
+
def test_planner_multiple_stores(self, simple_grid):
|
| 283 |
+
"""Test planner assigns to nearest store."""
|
| 284 |
+
stores = [
|
| 285 |
+
Store(id=1, position=(0, 0)),
|
| 286 |
+
Store(id=2, position=(2, 2)),
|
| 287 |
+
]
|
| 288 |
+
destinations = [Destination(id=1, position=(2, 0))]
|
| 289 |
+
|
| 290 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, [])
|
| 291 |
+
result, _ = planner.plan("UC", visualize=False)
|
| 292 |
+
|
| 293 |
+
# Should assign to store 1 (closer to destination (2,0))
|
| 294 |
+
assert len(result.assignments) == 1
|
| 295 |
+
|
| 296 |
+
def test_planner_with_tunnels(self, simple_grid):
|
| 297 |
+
"""Test planner uses tunnels."""
|
| 298 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 299 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 300 |
+
tunnels = [Tunnel(entrance1=(0, 0), entrance2=(2, 2))]
|
| 301 |
+
|
| 302 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, tunnels)
|
| 303 |
+
result, _ = planner.plan("UC", visualize=False)
|
| 304 |
+
|
| 305 |
+
# Tunnel cost is 4 (Manhattan distance)
|
| 306 |
+
# Without tunnel, minimum cost would be 4 (each segment has traffic 1)
|
| 307 |
+
assert result.total_cost == 4
|
| 308 |
+
|
| 309 |
+
def test_planner_from_state(self, simple_grid):
|
| 310 |
+
"""Test static plan_from_state method."""
|
| 311 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 312 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 313 |
+
|
| 314 |
+
result, _ = DeliveryPlanner.plan_from_state(
|
| 315 |
+
simple_grid,
|
| 316 |
+
stores,
|
| 317 |
+
destinations,
|
| 318 |
+
[],
|
| 319 |
+
"BF",
|
| 320 |
+
visualize=False,
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
assert len(result.assignments) == 1
|
| 324 |
+
assert result.total_cost > 0
|
| 325 |
+
|
| 326 |
+
def test_planner_total_nodes(self, simple_grid):
|
| 327 |
+
"""Test total nodes expanded tracking."""
|
| 328 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 329 |
+
destinations = [
|
| 330 |
+
Destination(id=1, position=(2, 0)),
|
| 331 |
+
Destination(id=2, position=(0, 2)),
|
| 332 |
+
]
|
| 333 |
+
|
| 334 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, [])
|
| 335 |
+
result, _ = planner.plan("BF", visualize=False)
|
| 336 |
+
|
| 337 |
+
assert result.total_nodes_expanded > 0
|
| 338 |
+
|
| 339 |
+
def test_planner_visualization(self, simple_grid):
|
| 340 |
+
"""Test planner with visualization."""
|
| 341 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 342 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 343 |
+
|
| 344 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, [])
|
| 345 |
+
result, viz_data = planner.plan("BF", visualize=True)
|
| 346 |
+
|
| 347 |
+
assert viz_data is not None
|
| 348 |
+
assert 1 in viz_data # Destination ID 1 should have steps
|
| 349 |
+
|
| 350 |
+
def test_planner_result_to_string(self, simple_grid):
|
| 351 |
+
"""Test plan result string formatting."""
|
| 352 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 353 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 354 |
+
|
| 355 |
+
planner = DeliveryPlanner(simple_grid, stores, destinations, [])
|
| 356 |
+
result, _ = planner.plan("BF", visualize=False)
|
| 357 |
+
|
| 358 |
+
output = result.to_string()
|
| 359 |
+
assert isinstance(output, str)
|
| 360 |
+
assert len(output) > 0
|
| 361 |
+
|
| 362 |
+
def test_planner_all_strategies(self, simple_grid):
|
| 363 |
+
"""Test planner works with all strategies."""
|
| 364 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 365 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 366 |
+
|
| 367 |
+
strategies = ["BF", "DF", "ID", "UC", "GR1", "GR2", "AS1", "AS2"]
|
| 368 |
+
for strategy in strategies:
|
| 369 |
+
result, _ = DeliveryPlanner.plan_from_state(
|
| 370 |
+
simple_grid, stores, destinations, [], strategy, False
|
| 371 |
+
)
|
| 372 |
+
assert len(result.assignments) == 1, f"Strategy {strategy} failed"
|
backend/tests/test_models.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for models (Grid, entities, state)."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from app.models.grid import Grid, Segment
|
| 5 |
+
from app.models.entities import Store, Destination, Tunnel
|
| 6 |
+
from app.models.state import SearchState, PathResult, SearchStep
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestSegment:
|
| 10 |
+
"""Tests for Segment model."""
|
| 11 |
+
|
| 12 |
+
def test_segment_creation(self):
|
| 13 |
+
"""Test basic segment creation."""
|
| 14 |
+
segment = Segment(src=(0, 0), dst=(1, 0), traffic=2)
|
| 15 |
+
assert segment.src == (0, 0)
|
| 16 |
+
assert segment.dst == (1, 0)
|
| 17 |
+
assert segment.traffic == 2
|
| 18 |
+
|
| 19 |
+
def test_segment_normalization(self):
|
| 20 |
+
"""Test that segment normalizes direction (src < dst)."""
|
| 21 |
+
segment = Segment(src=(1, 0), dst=(0, 0), traffic=1)
|
| 22 |
+
assert segment.src == (0, 0)
|
| 23 |
+
assert segment.dst == (1, 0)
|
| 24 |
+
|
| 25 |
+
def test_segment_is_blocked(self):
|
| 26 |
+
"""Test blocked segment detection."""
|
| 27 |
+
blocked = Segment(src=(0, 0), dst=(1, 0), traffic=0)
|
| 28 |
+
passable = Segment(src=(0, 0), dst=(1, 0), traffic=1)
|
| 29 |
+
assert blocked.is_blocked is True
|
| 30 |
+
assert passable.is_blocked is False
|
| 31 |
+
|
| 32 |
+
def test_segment_get_key(self):
|
| 33 |
+
"""Test segment key generation."""
|
| 34 |
+
segment = Segment(src=(0, 0), dst=(1, 0), traffic=1)
|
| 35 |
+
assert segment.get_key() == ((0, 0), (1, 0))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TestGrid:
|
| 39 |
+
"""Tests for Grid model."""
|
| 40 |
+
|
| 41 |
+
def test_grid_creation(self):
|
| 42 |
+
"""Test basic grid creation."""
|
| 43 |
+
grid = Grid(width=5, height=5)
|
| 44 |
+
assert grid.width == 5
|
| 45 |
+
assert grid.height == 5
|
| 46 |
+
assert len(grid.segments) == 0
|
| 47 |
+
|
| 48 |
+
def test_add_segment(self):
|
| 49 |
+
"""Test adding segments to grid."""
|
| 50 |
+
grid = Grid(width=3, height=3)
|
| 51 |
+
grid.add_segment((0, 0), (1, 0), 2)
|
| 52 |
+
assert len(grid.segments) == 1
|
| 53 |
+
segment = grid.get_segment((0, 0), (1, 0))
|
| 54 |
+
assert segment is not None
|
| 55 |
+
assert segment.traffic == 2
|
| 56 |
+
|
| 57 |
+
def test_add_segment_reversed(self):
|
| 58 |
+
"""Test adding segment with reversed coordinates."""
|
| 59 |
+
grid = Grid(width=3, height=3)
|
| 60 |
+
grid.add_segment((1, 0), (0, 0), 2)
|
| 61 |
+
# Should still be accessible both ways
|
| 62 |
+
segment = grid.get_segment((0, 0), (1, 0))
|
| 63 |
+
assert segment is not None
|
| 64 |
+
assert segment.traffic == 2
|
| 65 |
+
|
| 66 |
+
def test_get_traffic(self):
|
| 67 |
+
"""Test getting traffic level."""
|
| 68 |
+
grid = Grid(width=3, height=3)
|
| 69 |
+
grid.add_segment((0, 0), (1, 0), 3)
|
| 70 |
+
assert grid.get_traffic((0, 0), (1, 0)) == 3
|
| 71 |
+
assert grid.get_traffic((1, 0), (0, 0)) == 3 # Reversed
|
| 72 |
+
assert grid.get_traffic((0, 0), (0, 1)) == 0 # Non-existent
|
| 73 |
+
|
| 74 |
+
def test_is_blocked(self):
|
| 75 |
+
"""Test blocked segment detection."""
|
| 76 |
+
grid = Grid(width=3, height=3)
|
| 77 |
+
grid.add_segment((0, 0), (1, 0), 0)
|
| 78 |
+
grid.add_segment((0, 0), (0, 1), 1)
|
| 79 |
+
assert grid.is_blocked((0, 0), (1, 0)) is True
|
| 80 |
+
assert grid.is_blocked((0, 0), (0, 1)) is False
|
| 81 |
+
assert grid.is_blocked((1, 1), (2, 1)) is True # Non-existent = blocked
|
| 82 |
+
|
| 83 |
+
def test_is_valid_position(self):
|
| 84 |
+
"""Test position validation."""
|
| 85 |
+
grid = Grid(width=3, height=3)
|
| 86 |
+
assert grid.is_valid_position((0, 0)) is True
|
| 87 |
+
assert grid.is_valid_position((2, 2)) is True
|
| 88 |
+
assert grid.is_valid_position((3, 0)) is False
|
| 89 |
+
assert grid.is_valid_position((0, 3)) is False
|
| 90 |
+
assert grid.is_valid_position((-1, 0)) is False
|
| 91 |
+
|
| 92 |
+
def test_get_neighbors(self, simple_grid):
|
| 93 |
+
"""Test getting neighbors."""
|
| 94 |
+
# Corner (0,0) has 2 neighbors
|
| 95 |
+
neighbors = simple_grid.get_neighbors((0, 0))
|
| 96 |
+
assert len(neighbors) == 2
|
| 97 |
+
assert (1, 0) in neighbors
|
| 98 |
+
assert (0, 1) in neighbors
|
| 99 |
+
|
| 100 |
+
# Center (1,1) has 4 neighbors
|
| 101 |
+
neighbors = simple_grid.get_neighbors((1, 1))
|
| 102 |
+
assert len(neighbors) == 4
|
| 103 |
+
|
| 104 |
+
def test_get_neighbors_with_blocked(self, grid_with_blocked):
|
| 105 |
+
"""Test neighbors exclude blocked paths."""
|
| 106 |
+
# (1,1) normally has 4 neighbors but one path is blocked
|
| 107 |
+
neighbors = grid_with_blocked.get_neighbors((1, 1))
|
| 108 |
+
assert (2, 1) not in neighbors # Blocked
|
| 109 |
+
assert (0, 1) in neighbors
|
| 110 |
+
assert (1, 0) in neighbors
|
| 111 |
+
assert (1, 2) in neighbors
|
| 112 |
+
|
| 113 |
+
def test_to_dict(self, simple_grid):
|
| 114 |
+
"""Test grid serialization."""
|
| 115 |
+
result = simple_grid.to_dict()
|
| 116 |
+
assert result["width"] == 3
|
| 117 |
+
assert result["height"] == 3
|
| 118 |
+
assert "segments" in result
|
| 119 |
+
assert len(result["segments"]) > 0
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestStore:
|
| 123 |
+
"""Tests for Store entity."""
|
| 124 |
+
|
| 125 |
+
def test_store_creation(self):
|
| 126 |
+
"""Test store creation."""
|
| 127 |
+
store = Store(id=1, position=(5, 3))
|
| 128 |
+
assert store.id == 1
|
| 129 |
+
assert store.position == (5, 3)
|
| 130 |
+
|
| 131 |
+
def test_store_to_dict(self):
|
| 132 |
+
"""Test store serialization."""
|
| 133 |
+
store = Store(id=2, position=(1, 2))
|
| 134 |
+
result = store.to_dict()
|
| 135 |
+
assert result["id"] == 2
|
| 136 |
+
assert result["position"]["x"] == 1
|
| 137 |
+
assert result["position"]["y"] == 2
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class TestDestination:
|
| 141 |
+
"""Tests for Destination entity."""
|
| 142 |
+
|
| 143 |
+
def test_destination_creation(self):
|
| 144 |
+
"""Test destination creation."""
|
| 145 |
+
dest = Destination(id=1, position=(3, 4))
|
| 146 |
+
assert dest.id == 1
|
| 147 |
+
assert dest.position == (3, 4)
|
| 148 |
+
|
| 149 |
+
def test_destination_to_dict(self):
|
| 150 |
+
"""Test destination serialization."""
|
| 151 |
+
dest = Destination(id=3, position=(2, 5))
|
| 152 |
+
result = dest.to_dict()
|
| 153 |
+
assert result["id"] == 3
|
| 154 |
+
assert result["position"]["x"] == 2
|
| 155 |
+
assert result["position"]["y"] == 5
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class TestTunnel:
|
| 159 |
+
"""Tests for Tunnel entity."""
|
| 160 |
+
|
| 161 |
+
def test_tunnel_creation(self):
|
| 162 |
+
"""Test tunnel creation."""
|
| 163 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
|
| 164 |
+
assert tunnel.entrance1 == (0, 0)
|
| 165 |
+
assert tunnel.entrance2 == (5, 5)
|
| 166 |
+
|
| 167 |
+
def test_tunnel_cost(self):
|
| 168 |
+
"""Test tunnel cost calculation (Manhattan distance)."""
|
| 169 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(3, 4))
|
| 170 |
+
assert tunnel.cost == 7 # |3-0| + |4-0| = 7
|
| 171 |
+
|
| 172 |
+
def test_tunnel_cost_same_row(self):
|
| 173 |
+
"""Test tunnel cost on same row."""
|
| 174 |
+
tunnel = Tunnel(entrance1=(0, 5), entrance2=(10, 5))
|
| 175 |
+
assert tunnel.cost == 10
|
| 176 |
+
|
| 177 |
+
def test_tunnel_cost_same_column(self):
|
| 178 |
+
"""Test tunnel cost on same column."""
|
| 179 |
+
tunnel = Tunnel(entrance1=(3, 0), entrance2=(3, 7))
|
| 180 |
+
assert tunnel.cost == 7
|
| 181 |
+
|
| 182 |
+
def test_get_other_entrance(self):
|
| 183 |
+
"""Test getting other entrance."""
|
| 184 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
|
| 185 |
+
assert tunnel.get_other_entrance((0, 0)) == (5, 5)
|
| 186 |
+
assert tunnel.get_other_entrance((5, 5)) == (0, 0)
|
| 187 |
+
|
| 188 |
+
def test_get_other_entrance_invalid(self):
|
| 189 |
+
"""Test error on invalid entrance."""
|
| 190 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
|
| 191 |
+
with pytest.raises(ValueError):
|
| 192 |
+
tunnel.get_other_entrance((1, 1))
|
| 193 |
+
|
| 194 |
+
def test_has_entrance_at(self):
|
| 195 |
+
"""Test entrance detection."""
|
| 196 |
+
tunnel = Tunnel(entrance1=(0, 0), entrance2=(5, 5))
|
| 197 |
+
assert tunnel.has_entrance_at((0, 0)) is True
|
| 198 |
+
assert tunnel.has_entrance_at((5, 5)) is True
|
| 199 |
+
assert tunnel.has_entrance_at((1, 1)) is False
|
| 200 |
+
|
| 201 |
+
def test_tunnel_to_dict(self):
|
| 202 |
+
"""Test tunnel serialization."""
|
| 203 |
+
tunnel = Tunnel(entrance1=(1, 2), entrance2=(4, 6))
|
| 204 |
+
result = tunnel.to_dict()
|
| 205 |
+
assert result["entrance1"]["x"] == 1
|
| 206 |
+
assert result["entrance1"]["y"] == 2
|
| 207 |
+
assert result["entrance2"]["x"] == 4
|
| 208 |
+
assert result["entrance2"]["y"] == 6
|
| 209 |
+
assert result["cost"] == 7
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class TestSearchState:
|
| 213 |
+
"""Tests for SearchState model."""
|
| 214 |
+
|
| 215 |
+
def test_search_state_creation(self, simple_grid, sample_stores, sample_destinations, sample_tunnels):
|
| 216 |
+
"""Test search state creation."""
|
| 217 |
+
state = SearchState(
|
| 218 |
+
grid=simple_grid,
|
| 219 |
+
stores=sample_stores,
|
| 220 |
+
destinations=sample_destinations,
|
| 221 |
+
tunnels=sample_tunnels,
|
| 222 |
+
)
|
| 223 |
+
assert state.grid == simple_grid
|
| 224 |
+
assert len(state.stores) == 2
|
| 225 |
+
assert len(state.destinations) == 3
|
| 226 |
+
assert len(state.tunnels) == 2
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class TestPathResult:
|
| 230 |
+
"""Tests for PathResult model."""
|
| 231 |
+
|
| 232 |
+
def test_path_result_creation(self):
|
| 233 |
+
"""Test path result creation."""
|
| 234 |
+
result = PathResult(
|
| 235 |
+
plan="right,right,up",
|
| 236 |
+
cost=5.0,
|
| 237 |
+
nodes_expanded=10,
|
| 238 |
+
path=[(0, 0), (1, 0), (2, 0), (2, 1)],
|
| 239 |
+
)
|
| 240 |
+
assert result.plan == "right,right,up"
|
| 241 |
+
assert result.cost == 5.0
|
| 242 |
+
assert result.nodes_expanded == 10
|
| 243 |
+
assert len(result.path) == 4
|
| 244 |
+
|
| 245 |
+
def test_path_result_no_solution(self):
|
| 246 |
+
"""Test path result when no solution exists."""
|
| 247 |
+
result = PathResult(
|
| 248 |
+
plan="",
|
| 249 |
+
cost=float("inf"),
|
| 250 |
+
nodes_expanded=50,
|
| 251 |
+
path=[],
|
| 252 |
+
)
|
| 253 |
+
assert result.plan == ""
|
| 254 |
+
assert result.cost == float("inf")
|
| 255 |
+
assert len(result.path) == 0
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class TestSearchStep:
|
| 259 |
+
"""Tests for SearchStep model."""
|
| 260 |
+
|
| 261 |
+
def test_search_step_creation(self):
|
| 262 |
+
"""Test search step creation."""
|
| 263 |
+
step = SearchStep(
|
| 264 |
+
step_number=5,
|
| 265 |
+
current_node=(2, 3),
|
| 266 |
+
action="right",
|
| 267 |
+
frontier=[(3, 3), (2, 4)],
|
| 268 |
+
explored=[(0, 0), (1, 0), (2, 0)],
|
| 269 |
+
current_path=[(0, 0), (1, 0), (2, 0), (2, 1), (2, 2), (2, 3)],
|
| 270 |
+
path_cost=6.0,
|
| 271 |
+
)
|
| 272 |
+
assert step.step_number == 5
|
| 273 |
+
assert step.current_node == (2, 3)
|
| 274 |
+
assert step.action == "right"
|
| 275 |
+
assert len(step.frontier) == 2
|
| 276 |
+
assert len(step.explored) == 3
|
| 277 |
+
|
| 278 |
+
def test_search_step_to_dict(self):
|
| 279 |
+
"""Test search step serialization."""
|
| 280 |
+
step = SearchStep(
|
| 281 |
+
step_number=0,
|
| 282 |
+
current_node=(0, 0),
|
| 283 |
+
action=None,
|
| 284 |
+
frontier=[(1, 0)],
|
| 285 |
+
explored=[],
|
| 286 |
+
current_path=[(0, 0)],
|
| 287 |
+
path_cost=0.0,
|
| 288 |
+
)
|
| 289 |
+
result = step.to_dict()
|
| 290 |
+
assert result["stepNumber"] == 0
|
| 291 |
+
assert result["currentNode"]["x"] == 0
|
| 292 |
+
assert result["currentNode"]["y"] == 0
|
| 293 |
+
assert result["action"] is None
|
backend/tests/test_services.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for services (parser, grid_generator, metrics)."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from app.services.parser import (
|
| 5 |
+
parse_initial_state,
|
| 6 |
+
parse_traffic,
|
| 7 |
+
parse_full_state,
|
| 8 |
+
format_initial_state,
|
| 9 |
+
format_traffic,
|
| 10 |
+
)
|
| 11 |
+
from app.services.grid_generator import gen_grid
|
| 12 |
+
from app.services.metrics import MetricsCollector, measure_performance
|
| 13 |
+
from app.models.entities import Store, Destination, Tunnel
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestParseInitialState:
|
| 17 |
+
"""Tests for parse_initial_state function."""
|
| 18 |
+
|
| 19 |
+
def test_parse_basic(self):
|
| 20 |
+
"""Test parsing basic initial state."""
|
| 21 |
+
initial_state = "5;5;2;1;1,2,3,4;;0,0"
|
| 22 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 23 |
+
|
| 24 |
+
assert width == 5
|
| 25 |
+
assert height == 5
|
| 26 |
+
assert len(stores) == 1
|
| 27 |
+
assert len(destinations) == 2
|
| 28 |
+
assert len(tunnels) == 0
|
| 29 |
+
|
| 30 |
+
def test_parse_with_tunnels(self):
|
| 31 |
+
"""Test parsing initial state with tunnels."""
|
| 32 |
+
initial_state = "10;10;3;2;1,1,5,5,8,8;0,0,9,9,2,2,7,7;0,0,9,9"
|
| 33 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 34 |
+
|
| 35 |
+
assert width == 10
|
| 36 |
+
assert height == 10
|
| 37 |
+
assert len(stores) == 2
|
| 38 |
+
assert len(destinations) == 3
|
| 39 |
+
assert len(tunnels) == 2
|
| 40 |
+
|
| 41 |
+
# Check tunnel entrances
|
| 42 |
+
assert tunnels[0].entrance1 == (0, 0)
|
| 43 |
+
assert tunnels[0].entrance2 == (9, 9)
|
| 44 |
+
assert tunnels[1].entrance1 == (2, 2)
|
| 45 |
+
assert tunnels[1].entrance2 == (7, 7)
|
| 46 |
+
|
| 47 |
+
def test_parse_store_positions(self):
|
| 48 |
+
"""Test parsing store positions."""
|
| 49 |
+
initial_state = "5;5;1;2;2,2;;0,0,4,4"
|
| 50 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 51 |
+
|
| 52 |
+
assert len(stores) == 2
|
| 53 |
+
assert stores[0].position == (0, 0)
|
| 54 |
+
assert stores[1].position == (4, 4)
|
| 55 |
+
|
| 56 |
+
def test_parse_destination_positions(self):
|
| 57 |
+
"""Test parsing destination positions."""
|
| 58 |
+
initial_state = "5;5;3;1;1,1,2,2,3,3;;0,0"
|
| 59 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 60 |
+
|
| 61 |
+
assert len(destinations) == 3
|
| 62 |
+
assert destinations[0].position == (1, 1)
|
| 63 |
+
assert destinations[1].position == (2, 2)
|
| 64 |
+
assert destinations[2].position == (3, 3)
|
| 65 |
+
|
| 66 |
+
def test_parse_empty_tunnels(self):
|
| 67 |
+
"""Test parsing with no tunnels."""
|
| 68 |
+
initial_state = "3;3;1;1;1,1;;0,0"
|
| 69 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 70 |
+
|
| 71 |
+
assert len(tunnels) == 0
|
| 72 |
+
|
| 73 |
+
def test_parse_empty_destinations(self):
|
| 74 |
+
"""Test parsing with no destinations."""
|
| 75 |
+
initial_state = "3;3;0;1;;;0,0"
|
| 76 |
+
width, height, stores, destinations, tunnels = parse_initial_state(initial_state)
|
| 77 |
+
|
| 78 |
+
assert len(destinations) == 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class TestParseTraffic:
|
| 82 |
+
"""Tests for parse_traffic function."""
|
| 83 |
+
|
| 84 |
+
def test_parse_basic_traffic(self):
|
| 85 |
+
"""Test parsing basic traffic string."""
|
| 86 |
+
traffic_str = "0,0,1,0,2;0,0,0,1,3;1,0,1,1,1"
|
| 87 |
+
grid = parse_traffic(traffic_str, 3, 3)
|
| 88 |
+
|
| 89 |
+
assert grid.width == 3
|
| 90 |
+
assert grid.height == 3
|
| 91 |
+
assert grid.get_traffic((0, 0), (1, 0)) == 2
|
| 92 |
+
assert grid.get_traffic((0, 0), (0, 1)) == 3
|
| 93 |
+
assert grid.get_traffic((1, 0), (1, 1)) == 1
|
| 94 |
+
|
| 95 |
+
def test_parse_blocked_segment(self):
|
| 96 |
+
"""Test parsing blocked segment (traffic=0)."""
|
| 97 |
+
traffic_str = "0,0,1,0,0;0,0,0,1,1"
|
| 98 |
+
grid = parse_traffic(traffic_str, 2, 2)
|
| 99 |
+
|
| 100 |
+
assert grid.is_blocked((0, 0), (1, 0)) is True
|
| 101 |
+
assert grid.is_blocked((0, 0), (0, 1)) is False
|
| 102 |
+
|
| 103 |
+
def test_parse_empty_traffic(self):
|
| 104 |
+
"""Test parsing empty traffic string - should create default traffic."""
|
| 105 |
+
grid = parse_traffic("", 3, 3)
|
| 106 |
+
|
| 107 |
+
assert grid.width == 3
|
| 108 |
+
assert grid.height == 3
|
| 109 |
+
# Should have default traffic level 1
|
| 110 |
+
assert grid.get_traffic((0, 0), (1, 0)) == 1
|
| 111 |
+
assert grid.get_traffic((0, 0), (0, 1)) == 1
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class TestParseFullState:
|
| 115 |
+
"""Tests for parse_full_state function."""
|
| 116 |
+
|
| 117 |
+
def test_parse_full_state(self):
|
| 118 |
+
"""Test parsing complete state."""
|
| 119 |
+
initial_state = "5;5;2;1;1,1,3,3;;0,0"
|
| 120 |
+
traffic_str = "0,0,1,0,2;0,0,0,1,1"
|
| 121 |
+
|
| 122 |
+
state = parse_full_state(initial_state, traffic_str)
|
| 123 |
+
|
| 124 |
+
assert state.grid.width == 5
|
| 125 |
+
assert state.grid.height == 5
|
| 126 |
+
assert len(state.stores) == 1
|
| 127 |
+
assert len(state.destinations) == 2
|
| 128 |
+
assert len(state.tunnels) == 0
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class TestFormatInitialState:
|
| 132 |
+
"""Tests for format_initial_state function."""
|
| 133 |
+
|
| 134 |
+
def test_format_basic(self):
|
| 135 |
+
"""Test formatting basic state."""
|
| 136 |
+
stores = [Store(id=1, position=(0, 0))]
|
| 137 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 138 |
+
tunnels = []
|
| 139 |
+
|
| 140 |
+
result = format_initial_state(5, 5, stores, destinations, tunnels)
|
| 141 |
+
|
| 142 |
+
assert result == "5;5;1;1;2,2;;0,0"
|
| 143 |
+
|
| 144 |
+
def test_format_with_tunnels(self):
|
| 145 |
+
"""Test formatting state with tunnels."""
|
| 146 |
+
stores = [Store(id=1, position=(0, 0)), Store(id=2, position=(4, 4))]
|
| 147 |
+
destinations = [Destination(id=1, position=(2, 2))]
|
| 148 |
+
tunnels = [Tunnel(entrance1=(1, 1), entrance2=(3, 3))]
|
| 149 |
+
|
| 150 |
+
result = format_initial_state(5, 5, stores, destinations, tunnels)
|
| 151 |
+
|
| 152 |
+
assert result == "5;5;1;2;2,2;1,1,3,3;0,0,4,4"
|
| 153 |
+
|
| 154 |
+
def test_format_roundtrip(self):
|
| 155 |
+
"""Test that format and parse are inverses."""
|
| 156 |
+
stores = [Store(id=1, position=(0, 0)), Store(id=2, position=(4, 4))]
|
| 157 |
+
destinations = [
|
| 158 |
+
Destination(id=1, position=(1, 1)),
|
| 159 |
+
Destination(id=2, position=(3, 3)),
|
| 160 |
+
]
|
| 161 |
+
tunnels = [Tunnel(entrance1=(0, 4), entrance2=(4, 0))]
|
| 162 |
+
|
| 163 |
+
formatted = format_initial_state(5, 5, stores, destinations, tunnels)
|
| 164 |
+
width, height, parsed_stores, parsed_dests, parsed_tunnels = parse_initial_state(formatted)
|
| 165 |
+
|
| 166 |
+
assert width == 5
|
| 167 |
+
assert height == 5
|
| 168 |
+
assert len(parsed_stores) == 2
|
| 169 |
+
assert len(parsed_dests) == 2
|
| 170 |
+
assert len(parsed_tunnels) == 1
|
| 171 |
+
assert parsed_stores[0].position == (0, 0)
|
| 172 |
+
assert parsed_stores[1].position == (4, 4)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class TestFormatTraffic:
|
| 176 |
+
"""Tests for format_traffic function."""
|
| 177 |
+
|
| 178 |
+
def test_format_traffic(self, simple_grid):
|
| 179 |
+
"""Test formatting traffic."""
|
| 180 |
+
result = format_traffic(simple_grid)
|
| 181 |
+
|
| 182 |
+
# Should contain semicolon-separated segments
|
| 183 |
+
assert ";" in result or len(simple_grid.segments) <= 1
|
| 184 |
+
|
| 185 |
+
# Parse it back and verify
|
| 186 |
+
parsed_grid = parse_traffic(result, 3, 3)
|
| 187 |
+
assert parsed_grid.width == 3
|
| 188 |
+
assert parsed_grid.height == 3
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class TestGridGenerator:
|
| 192 |
+
"""Tests for grid generator."""
|
| 193 |
+
|
| 194 |
+
def test_gen_grid_basic(self):
|
| 195 |
+
"""Test basic grid generation."""
|
| 196 |
+
initial_state, traffic, state = gen_grid(
|
| 197 |
+
width=5,
|
| 198 |
+
height=5,
|
| 199 |
+
num_stores=1,
|
| 200 |
+
num_destinations=2,
|
| 201 |
+
obstacle_density=0.0,
|
| 202 |
+
seed=42,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
assert state.grid.width == 5
|
| 206 |
+
assert state.grid.height == 5
|
| 207 |
+
assert len(state.stores) == 1
|
| 208 |
+
assert len(state.destinations) == 2
|
| 209 |
+
# Tunnels may be generated randomly since 0 is treated as falsy
|
| 210 |
+
|
| 211 |
+
def test_gen_grid_with_tunnels(self):
|
| 212 |
+
"""Test grid generation with tunnels."""
|
| 213 |
+
initial_state, traffic, state = gen_grid(
|
| 214 |
+
width=10,
|
| 215 |
+
height=10,
|
| 216 |
+
num_stores=2,
|
| 217 |
+
num_destinations=3,
|
| 218 |
+
num_tunnels=2,
|
| 219 |
+
obstacle_density=0.1,
|
| 220 |
+
seed=42,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
assert len(state.stores) == 2
|
| 224 |
+
assert len(state.destinations) == 3
|
| 225 |
+
# Tunnels might be fewer if generation fails to find valid positions
|
| 226 |
+
assert len(state.tunnels) <= 2
|
| 227 |
+
|
| 228 |
+
def test_gen_grid_reproducible(self):
|
| 229 |
+
"""Test that same seed produces same grid."""
|
| 230 |
+
result1 = gen_grid(width=5, height=5, seed=12345)
|
| 231 |
+
result2 = gen_grid(width=5, height=5, seed=12345)
|
| 232 |
+
|
| 233 |
+
assert result1[0] == result2[0] # Same initial_state
|
| 234 |
+
assert result1[1] == result2[1] # Same traffic
|
| 235 |
+
|
| 236 |
+
def test_gen_grid_stores_limited(self):
|
| 237 |
+
"""Test that stores are limited to max 3."""
|
| 238 |
+
_, _, state = gen_grid(
|
| 239 |
+
width=10,
|
| 240 |
+
height=10,
|
| 241 |
+
num_stores=10, # Request 10, should get max 3
|
| 242 |
+
num_destinations=1,
|
| 243 |
+
seed=42,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
assert len(state.stores) <= 3
|
| 247 |
+
|
| 248 |
+
def test_gen_grid_destinations_limited(self):
|
| 249 |
+
"""Test that destinations are limited to max 10."""
|
| 250 |
+
_, _, state = gen_grid(
|
| 251 |
+
width=10,
|
| 252 |
+
height=10,
|
| 253 |
+
num_stores=1,
|
| 254 |
+
num_destinations=20, # Request 20, should get max 10
|
| 255 |
+
seed=42,
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
assert len(state.destinations) <= 10
|
| 259 |
+
|
| 260 |
+
def test_gen_grid_connectivity(self):
|
| 261 |
+
"""Test that generated grid has connected paths."""
|
| 262 |
+
_, _, state = gen_grid(
|
| 263 |
+
width=5,
|
| 264 |
+
height=5,
|
| 265 |
+
num_stores=1,
|
| 266 |
+
num_destinations=1,
|
| 267 |
+
obstacle_density=0.2,
|
| 268 |
+
seed=42,
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
# Should be able to reach destination from store
|
| 272 |
+
# This is ensured by _ensure_connectivity in grid_generator
|
| 273 |
+
store_pos = state.stores[0].position
|
| 274 |
+
dest_pos = state.destinations[0].position
|
| 275 |
+
|
| 276 |
+
# BFS to check connectivity
|
| 277 |
+
visited = {store_pos}
|
| 278 |
+
queue = [store_pos]
|
| 279 |
+
while queue:
|
| 280 |
+
current = queue.pop(0)
|
| 281 |
+
if current == dest_pos:
|
| 282 |
+
break
|
| 283 |
+
for neighbor in state.grid.get_neighbors(current):
|
| 284 |
+
if neighbor not in visited:
|
| 285 |
+
visited.add(neighbor)
|
| 286 |
+
queue.append(neighbor)
|
| 287 |
+
|
| 288 |
+
assert dest_pos in visited, "Destination should be reachable from store"
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class TestMetricsCollector:
|
| 292 |
+
"""Tests for MetricsCollector."""
|
| 293 |
+
|
| 294 |
+
def test_metrics_collector_basic(self):
|
| 295 |
+
"""Test basic metrics collection."""
|
| 296 |
+
collector = MetricsCollector()
|
| 297 |
+
collector.start()
|
| 298 |
+
|
| 299 |
+
# Do some work
|
| 300 |
+
_ = [i**2 for i in range(1000)]
|
| 301 |
+
|
| 302 |
+
collector.sample()
|
| 303 |
+
collector.stop()
|
| 304 |
+
|
| 305 |
+
assert collector.runtime_ms > 0
|
| 306 |
+
assert collector.memory_kb >= 0
|
| 307 |
+
assert collector.cpu_percent >= 0
|
| 308 |
+
|
| 309 |
+
def test_metrics_collector_multiple_samples(self):
|
| 310 |
+
"""Test multiple samples."""
|
| 311 |
+
collector = MetricsCollector()
|
| 312 |
+
collector.start()
|
| 313 |
+
|
| 314 |
+
for _ in range(3):
|
| 315 |
+
collector.sample()
|
| 316 |
+
|
| 317 |
+
collector.stop()
|
| 318 |
+
|
| 319 |
+
assert len(collector.memory_samples) >= 3
|
| 320 |
+
assert len(collector.cpu_samples) >= 3
|
| 321 |
+
|
| 322 |
+
def test_measure_performance_context_manager(self):
|
| 323 |
+
"""Test measure_performance context manager."""
|
| 324 |
+
with measure_performance() as metrics:
|
| 325 |
+
# Do some work
|
| 326 |
+
_ = sum(range(10000))
|
| 327 |
+
metrics.sample()
|
| 328 |
+
|
| 329 |
+
assert metrics.runtime_ms > 0
|
backend/uv.lock
CHANGED
|
@@ -39,6 +39,7 @@ name = "backend"
|
|
| 39 |
version = "0.1.0"
|
| 40 |
source = { virtual = "." }
|
| 41 |
dependencies = [
|
|
|
|
| 42 |
{ name = "fastapi" },
|
| 43 |
{ name = "httpx" },
|
| 44 |
{ name = "psutil" },
|
|
@@ -53,6 +54,7 @@ dependencies = [
|
|
| 53 |
|
| 54 |
[package.metadata]
|
| 55 |
requires-dist = [
|
|
|
|
| 56 |
{ name = "fastapi", specifier = ">=0.122.0" },
|
| 57 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 58 |
{ name = "psutil", specifier = ">=7.1.3" },
|
|
@@ -74,6 +76,45 @@ wheels = [
|
|
| 74 |
{ 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" },
|
| 75 |
]
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
[[package]]
|
| 78 |
name = "certifi"
|
| 79 |
version = "2025.11.12"
|
|
@@ -186,6 +227,15 @@ wheels = [
|
|
| 186 |
{ 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" },
|
| 187 |
]
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
[[package]]
|
| 190 |
name = "packaging"
|
| 191 |
version = "25.0"
|
|
@@ -195,6 +245,24 @@ wheels = [
|
|
| 195 |
{ 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" },
|
| 196 |
]
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
[[package]]
|
| 199 |
name = "pluggy"
|
| 200 |
version = "1.6.0"
|
|
@@ -427,6 +495,15 @@ wheels = [
|
|
| 427 |
{ 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" },
|
| 428 |
]
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
[[package]]
|
| 431 |
name = "starlette"
|
| 432 |
version = "0.50.0"
|
|
|
|
| 39 |
version = "0.1.0"
|
| 40 |
source = { virtual = "." }
|
| 41 |
dependencies = [
|
| 42 |
+
{ name = "black" },
|
| 43 |
{ name = "fastapi" },
|
| 44 |
{ name = "httpx" },
|
| 45 |
{ name = "psutil" },
|
|
|
|
| 54 |
|
| 55 |
[package.metadata]
|
| 56 |
requires-dist = [
|
| 57 |
+
{ name = "black", specifier = ">=25.11.0" },
|
| 58 |
{ name = "fastapi", specifier = ">=0.122.0" },
|
| 59 |
{ name = "httpx", specifier = ">=0.28.1" },
|
| 60 |
{ name = "psutil", specifier = ">=7.1.3" },
|
|
|
|
| 76 |
{ 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" },
|
| 77 |
]
|
| 78 |
|
| 79 |
+
[[package]]
|
| 80 |
+
name = "black"
|
| 81 |
+
version = "25.11.0"
|
| 82 |
+
source = { registry = "https://pypi.org/simple" }
|
| 83 |
+
dependencies = [
|
| 84 |
+
{ name = "click" },
|
| 85 |
+
{ name = "mypy-extensions" },
|
| 86 |
+
{ name = "packaging" },
|
| 87 |
+
{ name = "pathspec" },
|
| 88 |
+
{ name = "platformdirs" },
|
| 89 |
+
{ name = "pytokens" },
|
| 90 |
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
| 91 |
+
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
| 92 |
+
]
|
| 93 |
+
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" }
|
| 94 |
+
wheels = [
|
| 95 |
+
{ 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" },
|
| 96 |
+
{ 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" },
|
| 97 |
+
{ 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" },
|
| 98 |
+
{ 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" },
|
| 99 |
+
{ 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" },
|
| 100 |
+
{ 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" },
|
| 101 |
+
{ 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" },
|
| 102 |
+
{ 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" },
|
| 103 |
+
{ 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" },
|
| 104 |
+
{ 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" },
|
| 105 |
+
{ 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" },
|
| 106 |
+
{ 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" },
|
| 107 |
+
{ 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" },
|
| 108 |
+
{ 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" },
|
| 109 |
+
{ 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" },
|
| 110 |
+
{ 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" },
|
| 111 |
+
{ 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" },
|
| 112 |
+
{ 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" },
|
| 113 |
+
{ 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" },
|
| 114 |
+
{ 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" },
|
| 115 |
+
{ 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" },
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
[[package]]
|
| 119 |
name = "certifi"
|
| 120 |
version = "2025.11.12"
|
|
|
|
| 227 |
{ 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" },
|
| 228 |
]
|
| 229 |
|
| 230 |
+
[[package]]
|
| 231 |
+
name = "mypy-extensions"
|
| 232 |
+
version = "1.1.0"
|
| 233 |
+
source = { registry = "https://pypi.org/simple" }
|
| 234 |
+
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" }
|
| 235 |
+
wheels = [
|
| 236 |
+
{ 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" },
|
| 237 |
+
]
|
| 238 |
+
|
| 239 |
[[package]]
|
| 240 |
name = "packaging"
|
| 241 |
version = "25.0"
|
|
|
|
| 245 |
{ 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" },
|
| 246 |
]
|
| 247 |
|
| 248 |
+
[[package]]
|
| 249 |
+
name = "pathspec"
|
| 250 |
+
version = "0.12.1"
|
| 251 |
+
source = { registry = "https://pypi.org/simple" }
|
| 252 |
+
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" }
|
| 253 |
+
wheels = [
|
| 254 |
+
{ 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" },
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
[[package]]
|
| 258 |
+
name = "platformdirs"
|
| 259 |
+
version = "4.5.1"
|
| 260 |
+
source = { registry = "https://pypi.org/simple" }
|
| 261 |
+
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" }
|
| 262 |
+
wheels = [
|
| 263 |
+
{ 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" },
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
[[package]]
|
| 267 |
name = "pluggy"
|
| 268 |
version = "1.6.0"
|
|
|
|
| 495 |
{ 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" },
|
| 496 |
]
|
| 497 |
|
| 498 |
+
[[package]]
|
| 499 |
+
name = "pytokens"
|
| 500 |
+
version = "0.3.0"
|
| 501 |
+
source = { registry = "https://pypi.org/simple" }
|
| 502 |
+
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" }
|
| 503 |
+
wheels = [
|
| 504 |
+
{ 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" },
|
| 505 |
+
]
|
| 506 |
+
|
| 507 |
[[package]]
|
| 508 |
name = "starlette"
|
| 509 |
version = "0.50.0"
|
frontend/index.html
CHANGED
|
@@ -2,9 +2,9 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="root"></div>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/graph.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>AI Project</title>
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="root"></div>
|
frontend/public/graph.svg
ADDED
|
|
frontend/public/vite.svg
DELETED
frontend/src/App.tsx
CHANGED
|
@@ -115,7 +115,7 @@ function App() {
|
|
| 115 |
className="gap-2"
|
| 116 |
>
|
| 117 |
<Play className="w-4 h-4" />
|
| 118 |
-
{isLoading ? 'Running...' : 'Run Search'}
|
| 119 |
</Button>
|
| 120 |
<Button
|
| 121 |
onClick={handleRunPlan}
|
|
|
|
| 115 |
className="gap-2"
|
| 116 |
>
|
| 117 |
<Play className="w-4 h-4" />
|
| 118 |
+
{isLoading ? 'Running...' : 'Run Search for S1'}
|
| 119 |
</Button>
|
| 120 |
<Button
|
| 121 |
onClick={handleRunPlan}
|
frontend/src/api/client.ts
CHANGED
|
@@ -87,7 +87,7 @@ export async function findPath(
|
|
| 87 |
cost: response.data.cost,
|
| 88 |
nodesExpanded: response.data.nodes_expanded,
|
| 89 |
runtimeMs: response.data.runtime_ms,
|
| 90 |
-
|
| 91 |
cpuPercent: response.data.cpu_percent,
|
| 92 |
path: response.data.path,
|
| 93 |
steps: response.data.steps,
|
|
@@ -113,7 +113,7 @@ export async function createPlan(
|
|
| 113 |
totalCost: response.data.total_cost,
|
| 114 |
totalNodesExpanded: response.data.total_nodes_expanded,
|
| 115 |
runtimeMs: response.data.runtime_ms,
|
| 116 |
-
|
| 117 |
cpuPercent: response.data.cpu_percent,
|
| 118 |
};
|
| 119 |
}
|
|
@@ -135,7 +135,7 @@ export async function compareAlgorithms(
|
|
| 135 |
cost: c.cost,
|
| 136 |
nodesExpanded: c.nodes_expanded,
|
| 137 |
runtimeMs: c.runtime_ms,
|
| 138 |
-
|
| 139 |
cpuPercent: c.cpu_percent,
|
| 140 |
isOptimal: c.is_optimal,
|
| 141 |
})),
|
|
|
|
| 87 |
cost: response.data.cost,
|
| 88 |
nodesExpanded: response.data.nodes_expanded,
|
| 89 |
runtimeMs: response.data.runtime_ms,
|
| 90 |
+
memoryKb: response.data.memory_kb,
|
| 91 |
cpuPercent: response.data.cpu_percent,
|
| 92 |
path: response.data.path,
|
| 93 |
steps: response.data.steps,
|
|
|
|
| 113 |
totalCost: response.data.total_cost,
|
| 114 |
totalNodesExpanded: response.data.total_nodes_expanded,
|
| 115 |
runtimeMs: response.data.runtime_ms,
|
| 116 |
+
memoryKb: response.data.memory_kb,
|
| 117 |
cpuPercent: response.data.cpu_percent,
|
| 118 |
};
|
| 119 |
}
|
|
|
|
| 135 |
cost: c.cost,
|
| 136 |
nodesExpanded: c.nodes_expanded,
|
| 137 |
runtimeMs: c.runtime_ms,
|
| 138 |
+
memoryKb: c.memory_kb,
|
| 139 |
cpuPercent: c.cpu_percent,
|
| 140 |
isOptimal: c.is_optimal,
|
| 141 |
})),
|
frontend/src/components/Grid/Grid.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useGridStore } from '../../store/gridStore';
|
| 3 |
-
import {
|
|
|
|
| 4 |
import type { SearchStep } from '../../types';
|
| 5 |
|
| 6 |
const CELL_SIZE = 90;
|
|
@@ -46,7 +47,7 @@ const COLORS = {
|
|
| 46 |
};
|
| 47 |
|
| 48 |
export const Grid: React.FC = () => {
|
| 49 |
-
const { grid, steps, currentStep, searchResult } = useGridStore();
|
| 50 |
|
| 51 |
const stepData = useMemo(() => {
|
| 52 |
const step: SearchStep | null = steps[currentStep] || null;
|
|
@@ -59,12 +60,41 @@ export const Grid: React.FC = () => {
|
|
| 59 |
};
|
| 60 |
}, [steps, currentStep, searchResult]);
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
if (!grid) {
|
| 63 |
return (
|
| 64 |
<div className="flex items-center justify-center h-full" style={{ backgroundColor: '#0a0a0b' }}>
|
| 65 |
<div className="text-center">
|
| 66 |
<div className="w-20 h-20 rounded-2xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mx-auto mb-6">
|
| 67 |
-
<
|
| 68 |
</div>
|
| 69 |
<p className="text-zinc-400 text-sm font-medium">No Grid Generated</p>
|
| 70 |
<p className="text-zinc-600 text-xs mt-2">Configure and generate a grid from the sidebar</p>
|
|
@@ -77,14 +107,19 @@ export const Grid: React.FC = () => {
|
|
| 77 |
const height = grid.height * CELL_SIZE + PADDING * 2;
|
| 78 |
|
| 79 |
const { step, exploredSet, frontierSet, pathSet, finalPathSet } = stepData;
|
|
|
|
| 80 |
|
| 81 |
-
const getNodeState = (x: number, y: number): string => {
|
| 82 |
const key = `${x},${y}`;
|
| 83 |
-
if (step?.currentNode.x === x && step?.currentNode.y === y) return 'current';
|
| 84 |
-
if (pathSet.has(key) || (!step && finalPathSet.has(key))) return 'path';
|
| 85 |
-
if (frontierSet.has(key)) return 'frontier';
|
| 86 |
-
if (exploredSet.has(key)) return 'explored';
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
};
|
| 89 |
|
| 90 |
const getEdgeStyle = (traffic: number) => {
|
|
@@ -340,12 +375,34 @@ export const Grid: React.FC = () => {
|
|
| 340 |
);
|
| 341 |
})}
|
| 342 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
{/* Nodes */}
|
| 344 |
{Array.from({ length: grid.width }).map((_, x) =>
|
| 345 |
Array.from({ length: grid.height }).map((_, y) => {
|
| 346 |
const cx = toSvgX(x);
|
| 347 |
const cy = toSvgY(y);
|
| 348 |
-
const
|
|
|
|
| 349 |
|
| 350 |
let fill = COLORS.nodeDefault;
|
| 351 |
let stroke = COLORS.nodeDefaultStroke;
|
|
@@ -376,6 +433,11 @@ export const Grid: React.FC = () => {
|
|
| 376 |
fill = COLORS.explored;
|
| 377 |
stroke = COLORS.exploredLight;
|
| 378 |
break;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
return (
|
|
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useGridStore } from '../../store/gridStore';
|
| 3 |
+
import type { ColoredPath } from '../../store/gridStore';
|
| 4 |
+
import { Map as MapIcon } from 'lucide-react';
|
| 5 |
import type { SearchStep } from '../../types';
|
| 6 |
|
| 7 |
const CELL_SIZE = 90;
|
|
|
|
| 47 |
};
|
| 48 |
|
| 49 |
export const Grid: React.FC = () => {
|
| 50 |
+
const { grid, steps, currentStep, searchResult, showPlanPaths, planPaths } = useGridStore();
|
| 51 |
|
| 52 |
const stepData = useMemo(() => {
|
| 53 |
const step: SearchStep | null = steps[currentStep] || null;
|
|
|
|
| 60 |
};
|
| 61 |
}, [steps, currentStep, searchResult]);
|
| 62 |
|
| 63 |
+
// Build a map of position -> color for plan paths visualization
|
| 64 |
+
const planPathData = useMemo(() => {
|
| 65 |
+
if (!showPlanPaths || planPaths.length === 0) {
|
| 66 |
+
return { pathColorMap: new Map<string, string>(), pathEdges: [] as { from: string; to: string; color: string }[] };
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const pathColorMap = new Map<string, string>();
|
| 70 |
+
const pathEdges: { from: string; to: string; color: string }[] = [];
|
| 71 |
+
|
| 72 |
+
planPaths.forEach((coloredPath: ColoredPath) => {
|
| 73 |
+
const path = coloredPath.path;
|
| 74 |
+
for (let i = 0; i < path.length; i++) {
|
| 75 |
+
const key = `${path[i].x},${path[i].y}`;
|
| 76 |
+
// First path to claim a position gets to color it
|
| 77 |
+
if (!pathColorMap.has(key)) {
|
| 78 |
+
pathColorMap.set(key, coloredPath.color);
|
| 79 |
+
}
|
| 80 |
+
// Build edges
|
| 81 |
+
if (i < path.length - 1) {
|
| 82 |
+
const fromKey = `${path[i].x},${path[i].y}`;
|
| 83 |
+
const toKey = `${path[i + 1].x},${path[i + 1].y}`;
|
| 84 |
+
pathEdges.push({ from: fromKey, to: toKey, color: coloredPath.color });
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
return { pathColorMap, pathEdges };
|
| 90 |
+
}, [showPlanPaths, planPaths]);
|
| 91 |
+
|
| 92 |
if (!grid) {
|
| 93 |
return (
|
| 94 |
<div className="flex items-center justify-center h-full" style={{ backgroundColor: '#0a0a0b' }}>
|
| 95 |
<div className="text-center">
|
| 96 |
<div className="w-20 h-20 rounded-2xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mx-auto mb-6">
|
| 97 |
+
<MapIcon className="w-10 h-10 text-zinc-700" />
|
| 98 |
</div>
|
| 99 |
<p className="text-zinc-400 text-sm font-medium">No Grid Generated</p>
|
| 100 |
<p className="text-zinc-600 text-xs mt-2">Configure and generate a grid from the sidebar</p>
|
|
|
|
| 107 |
const height = grid.height * CELL_SIZE + PADDING * 2;
|
| 108 |
|
| 109 |
const { step, exploredSet, frontierSet, pathSet, finalPathSet } = stepData;
|
| 110 |
+
const { pathColorMap, pathEdges } = planPathData;
|
| 111 |
|
| 112 |
+
const getNodeState = (x: number, y: number): { state: string; color?: string } => {
|
| 113 |
const key = `${x},${y}`;
|
| 114 |
+
if (step?.currentNode.x === x && step?.currentNode.y === y) return { state: 'current' };
|
| 115 |
+
if (pathSet.has(key) || (!step && finalPathSet.has(key))) return { state: 'path' };
|
| 116 |
+
if (frontierSet.has(key)) return { state: 'frontier' };
|
| 117 |
+
if (exploredSet.has(key)) return { state: 'explored' };
|
| 118 |
+
// Check if this node is part of a plan path
|
| 119 |
+
if (showPlanPaths && pathColorMap.has(key)) {
|
| 120 |
+
return { state: 'planPath', color: pathColorMap.get(key) };
|
| 121 |
+
}
|
| 122 |
+
return { state: 'default' };
|
| 123 |
};
|
| 124 |
|
| 125 |
const getEdgeStyle = (traffic: number) => {
|
|
|
|
| 375 |
);
|
| 376 |
})}
|
| 377 |
|
| 378 |
+
{/* Plan Path Edges (rendered before nodes) */}
|
| 379 |
+
{showPlanPaths && pathEdges.map((edge, i) => {
|
| 380 |
+
const [fromX, fromY] = edge.from.split(',').map(Number);
|
| 381 |
+
const [toX, toY] = edge.to.split(',').map(Number);
|
| 382 |
+
const x1 = toSvgX(fromX);
|
| 383 |
+
const y1 = toSvgY(fromY);
|
| 384 |
+
const x2 = toSvgX(toX);
|
| 385 |
+
const y2 = toSvgY(toY);
|
| 386 |
+
|
| 387 |
+
return (
|
| 388 |
+
<line
|
| 389 |
+
key={`plan-edge-${i}`}
|
| 390 |
+
x1={x1} y1={y1} x2={x2} y2={y2}
|
| 391 |
+
stroke={edge.color}
|
| 392 |
+
strokeWidth={5}
|
| 393 |
+
strokeLinecap="round"
|
| 394 |
+
opacity={0.8}
|
| 395 |
+
/>
|
| 396 |
+
);
|
| 397 |
+
})}
|
| 398 |
+
|
| 399 |
{/* Nodes */}
|
| 400 |
{Array.from({ length: grid.width }).map((_, x) =>
|
| 401 |
Array.from({ length: grid.height }).map((_, y) => {
|
| 402 |
const cx = toSvgX(x);
|
| 403 |
const cy = toSvgY(y);
|
| 404 |
+
const nodeState = getNodeState(x, y);
|
| 405 |
+
const state = nodeState.state;
|
| 406 |
|
| 407 |
let fill = COLORS.nodeDefault;
|
| 408 |
let stroke = COLORS.nodeDefaultStroke;
|
|
|
|
| 433 |
fill = COLORS.explored;
|
| 434 |
stroke = COLORS.exploredLight;
|
| 435 |
break;
|
| 436 |
+
case 'planPath':
|
| 437 |
+
fill = nodeState.color || COLORS.path;
|
| 438 |
+
stroke = nodeState.color || COLORS.pathLight;
|
| 439 |
+
strokeWidth = 2;
|
| 440 |
+
break;
|
| 441 |
}
|
| 442 |
|
| 443 |
return (
|
frontend/src/components/Info/GroupInfo.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
-
import { BookOpen, Users, Code, FileText, GraduationCap, Calendar
|
| 3 |
|
| 4 |
export const GroupInfo: React.FC = () => {
|
| 5 |
return (
|
|
@@ -16,7 +16,7 @@ export const GroupInfo: React.FC = () => {
|
|
| 16 |
</h1>
|
| 17 |
<div className="flex items-center justify-center gap-2 text-xs text-zinc-500 mt-6">
|
| 18 |
<Calendar className="w-3.5 h-3.5" />
|
| 19 |
-
<span>Academic Year
|
| 20 |
</div>
|
| 21 |
</header>
|
| 22 |
|
|
@@ -24,14 +24,14 @@ export const GroupInfo: React.FC = () => {
|
|
| 24 |
<section className="mb-10">
|
| 25 |
<div className="flex items-center gap-2 mb-4">
|
| 26 |
<Users className="w-4 h-4 text-zinc-500" />
|
| 27 |
-
<h2 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">
|
| 28 |
</div>
|
| 29 |
<div className="grid grid-cols-2 gap-4">
|
| 30 |
{[
|
| 31 |
-
{ name: 'Kacem Mathlouthi', role: '
|
| 32 |
-
{ name: 'Mohamed Amine Houas', role: '
|
| 33 |
-
{ name: 'Oussema Kraiem', role: '
|
| 34 |
-
{ name: 'Alaeddine El Zaouali', role: '
|
| 35 |
].map((author) => (
|
| 36 |
<div key={author.name} className="bg-zinc-900/50 border border-zinc-800 rounded-lg p-4">
|
| 37 |
<p className="text-zinc-200 font-medium">{author.name}</p>
|
|
@@ -83,7 +83,7 @@ export const GroupInfo: React.FC = () => {
|
|
| 83 |
<div className="space-y-3">
|
| 84 |
<div>
|
| 85 |
<p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Visualization</p>
|
| 86 |
-
<p className="text-zinc-300">SVG +
|
| 87 |
</div>
|
| 88 |
<div>
|
| 89 |
<p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Styling</p>
|
|
@@ -91,7 +91,7 @@ export const GroupInfo: React.FC = () => {
|
|
| 91 |
</div>
|
| 92 |
<div>
|
| 93 |
<p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Communication</p>
|
| 94 |
-
<p className="text-zinc-300">REST API
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
</div>
|
|
@@ -160,40 +160,8 @@ export const GroupInfo: React.FC = () => {
|
|
| 160 |
</div>
|
| 161 |
</section>
|
| 162 |
|
| 163 |
-
{/* References */}
|
| 164 |
-
<section className="mb-10">
|
| 165 |
-
<div className="flex items-center gap-2 mb-4">
|
| 166 |
-
<BookOpen className="w-4 h-4 text-zinc-500" />
|
| 167 |
-
<h2 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">References</h2>
|
| 168 |
-
</div>
|
| 169 |
-
<div className="space-y-3 text-sm">
|
| 170 |
-
<div className="flex gap-3">
|
| 171 |
-
<span className="text-zinc-600 font-mono">[1]</span>
|
| 172 |
-
<p className="text-zinc-400">
|
| 173 |
-
Russell, S., & Norvig, P. (2020). <span className="italic">Artificial Intelligence: A Modern Approach</span> (4th ed.). Pearson.
|
| 174 |
-
</p>
|
| 175 |
-
</div>
|
| 176 |
-
<div className="flex gap-3">
|
| 177 |
-
<span className="text-zinc-600 font-mono">[2]</span>
|
| 178 |
-
<p className="text-zinc-400">
|
| 179 |
-
Hart, P. E., Nilsson, N. J., & Raphael, B. (1968). A Formal Basis for the Heuristic Determination of Minimum Cost Paths. <span className="italic">IEEE Transactions on Systems Science and Cybernetics</span>, 4(2), 100-107.
|
| 180 |
-
</p>
|
| 181 |
-
</div>
|
| 182 |
-
<div className="flex gap-3">
|
| 183 |
-
<span className="text-zinc-600 font-mono">[3]</span>
|
| 184 |
-
<p className="text-zinc-400">
|
| 185 |
-
Korf, R. E. (1985). Depth-first iterative-deepening: An optimal admissible tree search. <span className="italic">Artificial Intelligence</span>, 27(1), 97-109.
|
| 186 |
-
</p>
|
| 187 |
-
</div>
|
| 188 |
-
</div>
|
| 189 |
-
</section>
|
| 190 |
-
|
| 191 |
{/* Footer */}
|
| 192 |
<footer className="pt-8 border-t border-zinc-800 text-center">
|
| 193 |
-
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600">
|
| 194 |
-
<Building className="w-3.5 h-3.5" />
|
| 195 |
-
<span>Department of Computer Science</span>
|
| 196 |
-
</div>
|
| 197 |
<p className="text-xs text-zinc-700 mt-2">
|
| 198 |
This project was developed as part of the Artificial Intelligence curriculum
|
| 199 |
</p>
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
+
import { BookOpen, Users, Code, FileText, GraduationCap, Calendar } from 'lucide-react';
|
| 3 |
|
| 4 |
export const GroupInfo: React.FC = () => {
|
| 5 |
return (
|
|
|
|
| 16 |
</h1>
|
| 17 |
<div className="flex items-center justify-center gap-2 text-xs text-zinc-500 mt-6">
|
| 18 |
<Calendar className="w-3.5 h-3.5" />
|
| 19 |
+
<span>Academic Year 2025-2026</span>
|
| 20 |
</div>
|
| 21 |
</header>
|
| 22 |
|
|
|
|
| 24 |
<section className="mb-10">
|
| 25 |
<div className="flex items-center gap-2 mb-4">
|
| 26 |
<Users className="w-4 h-4 text-zinc-500" />
|
| 27 |
+
<h2 className="text-sm font-semibold text-zinc-300 uppercase tracking-wider">Team Members</h2>
|
| 28 |
</div>
|
| 29 |
<div className="grid grid-cols-2 gap-4">
|
| 30 |
{[
|
| 31 |
+
{ name: 'Kacem Mathlouthi', role: 'GL 4/2' },
|
| 32 |
+
{ name: 'Mohamed Amine Houas', role: 'GL 4/1' },
|
| 33 |
+
{ name: 'Oussema Kraiem', role: 'GL 4/2' },
|
| 34 |
+
{ name: 'Alaeddine El Zaouali', role: 'GL 4/2' },
|
| 35 |
].map((author) => (
|
| 36 |
<div key={author.name} className="bg-zinc-900/50 border border-zinc-800 rounded-lg p-4">
|
| 37 |
<p className="text-zinc-200 font-medium">{author.name}</p>
|
|
|
|
| 83 |
<div className="space-y-3">
|
| 84 |
<div>
|
| 85 |
<p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Visualization</p>
|
| 86 |
+
<p className="text-zinc-300">SVG + ShadCN</p>
|
| 87 |
</div>
|
| 88 |
<div>
|
| 89 |
<p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Styling</p>
|
|
|
|
| 91 |
</div>
|
| 92 |
<div>
|
| 93 |
<p className="text-zinc-500 text-xs uppercase tracking-wider mb-1">Communication</p>
|
| 94 |
+
<p className="text-zinc-300">REST API</p>
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
</div>
|
|
|
|
| 160 |
</div>
|
| 161 |
</section>
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
{/* Footer */}
|
| 164 |
<footer className="pt-8 border-t border-zinc-800 text-center">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
<p className="text-xs text-zinc-700 mt-2">
|
| 166 |
This project was developed as part of the Artificial Intelligence curriculum
|
| 167 |
</p>
|
frontend/src/components/Stats/ComparisonDashboard.tsx
CHANGED
|
@@ -42,7 +42,7 @@ export const ComparisonDashboard: React.FC = () => {
|
|
| 42 |
nodesExpanded: r.nodesExpanded,
|
| 43 |
runtime: r.runtimeMs,
|
| 44 |
cost: r.cost === Infinity ? 0 : r.cost,
|
| 45 |
-
memory: r.
|
| 46 |
isOptimal: r.isOptimal,
|
| 47 |
}));
|
| 48 |
|
|
@@ -306,7 +306,7 @@ export const ComparisonDashboard: React.FC = () => {
|
|
| 306 |
{result.runtimeMs.toFixed(2)}ms
|
| 307 |
</td>
|
| 308 |
<td className="px-4 py-3 text-right font-mono text-zinc-400">
|
| 309 |
-
{result.
|
| 310 |
</td>
|
| 311 |
<td className="px-4 py-3 text-center">
|
| 312 |
{result.isOptimal && (
|
|
|
|
| 42 |
nodesExpanded: r.nodesExpanded,
|
| 43 |
runtime: r.runtimeMs,
|
| 44 |
cost: r.cost === Infinity ? 0 : r.cost,
|
| 45 |
+
memory: r.memoryKb,
|
| 46 |
isOptimal: r.isOptimal,
|
| 47 |
}));
|
| 48 |
|
|
|
|
| 306 |
{result.runtimeMs.toFixed(2)}ms
|
| 307 |
</td>
|
| 308 |
<td className="px-4 py-3 text-right font-mono text-zinc-400">
|
| 309 |
+
{result.memoryKb.toFixed(2)}KB
|
| 310 |
</td>
|
| 311 |
<td className="px-4 py-3 text-center">
|
| 312 |
{result.isOptimal && (
|
frontend/src/components/Stats/MetricsPanel.tsx
CHANGED
|
@@ -30,7 +30,7 @@ export const MetricsPanel: React.FC = () => {
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
label: 'Memory',
|
| 33 |
-
value: `${result.
|
| 34 |
icon: HardDrive,
|
| 35 |
},
|
| 36 |
];
|
|
|
|
| 30 |
},
|
| 31 |
{
|
| 32 |
label: 'Memory',
|
| 33 |
+
value: `${result.memoryKb.toFixed(2)}KB`,
|
| 34 |
icon: HardDrive,
|
| 35 |
},
|
| 36 |
];
|
frontend/src/components/Stats/PlanResultsModal.tsx
CHANGED
|
@@ -12,15 +12,31 @@ import {
|
|
| 12 |
HardDrive,
|
| 13 |
ArrowRight,
|
| 14 |
CheckCircle,
|
|
|
|
|
|
|
| 15 |
} from 'lucide-react';
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
interface PlanResultsModalProps {
|
| 18 |
isOpen: boolean;
|
| 19 |
onClose: () => void;
|
| 20 |
}
|
| 21 |
|
| 22 |
export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onClose }) => {
|
| 23 |
-
const { planResult, selectedAlgorithm } = useGridStore();
|
| 24 |
|
| 25 |
if (!isOpen || !planResult) return null;
|
| 26 |
|
|
@@ -94,7 +110,7 @@ export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onCl
|
|
| 94 |
Memory
|
| 95 |
</div>
|
| 96 |
<p className="text-lg font-semibold text-zinc-200 font-mono">
|
| 97 |
-
{planResult.
|
| 98 |
</p>
|
| 99 |
</CardContent>
|
| 100 |
</Card>
|
|
@@ -124,42 +140,60 @@ export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onCl
|
|
| 124 |
{planResult.assignments.length === 0 ? (
|
| 125 |
<p className="text-zinc-500 text-sm">No assignments made</p>
|
| 126 |
) : (
|
| 127 |
-
planResult.assignments.map((assignment, index) =>
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
<
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
</div>
|
| 143 |
-
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
| 144 |
</div>
|
| 145 |
-
<div className="
|
| 146 |
-
<span
|
| 147 |
-
<span
|
| 148 |
</div>
|
| 149 |
</div>
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
<span className="font-mono text-zinc-400">{assignment.path.plan || 'Direct'}</span>
|
| 153 |
-
</div>
|
| 154 |
-
</div>
|
| 155 |
-
))
|
| 156 |
)}
|
| 157 |
</CardContent>
|
| 158 |
</Card>
|
| 159 |
</div>
|
| 160 |
|
| 161 |
{/* Footer */}
|
| 162 |
-
<div className="px-6 py-3 border-t border-zinc-800 bg-zinc-800/20 flex justify-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
<Button variant="secondary" size="sm" onClick={onClose}>
|
| 164 |
Close
|
| 165 |
</Button>
|
|
|
|
| 12 |
HardDrive,
|
| 13 |
ArrowRight,
|
| 14 |
CheckCircle,
|
| 15 |
+
Eye,
|
| 16 |
+
EyeOff,
|
| 17 |
} from 'lucide-react';
|
| 18 |
|
| 19 |
+
// Colors matching the store
|
| 20 |
+
const PATH_COLORS = [
|
| 21 |
+
'#f97316', // orange
|
| 22 |
+
'#06b6d4', // cyan
|
| 23 |
+
'#ec4899', // pink
|
| 24 |
+
'#84cc16', // lime
|
| 25 |
+
'#a855f7', // purple
|
| 26 |
+
'#14b8a6', // teal
|
| 27 |
+
'#f43f5e', // rose
|
| 28 |
+
'#eab308', // yellow
|
| 29 |
+
'#6366f1', // indigo
|
| 30 |
+
'#22c55e', // green
|
| 31 |
+
];
|
| 32 |
+
|
| 33 |
interface PlanResultsModalProps {
|
| 34 |
isOpen: boolean;
|
| 35 |
onClose: () => void;
|
| 36 |
}
|
| 37 |
|
| 38 |
export const PlanResultsModal: React.FC<PlanResultsModalProps> = ({ isOpen, onClose }) => {
|
| 39 |
+
const { planResult, selectedAlgorithm, showPlanPaths, setShowPlanPaths } = useGridStore();
|
| 40 |
|
| 41 |
if (!isOpen || !planResult) return null;
|
| 42 |
|
|
|
|
| 110 |
Memory
|
| 111 |
</div>
|
| 112 |
<p className="text-lg font-semibold text-zinc-200 font-mono">
|
| 113 |
+
{planResult.memoryKb.toFixed(2)}KB
|
| 114 |
</p>
|
| 115 |
</CardContent>
|
| 116 |
</Card>
|
|
|
|
| 140 |
{planResult.assignments.length === 0 ? (
|
| 141 |
<p className="text-zinc-500 text-sm">No assignments made</p>
|
| 142 |
) : (
|
| 143 |
+
planResult.assignments.map((assignment, index) => {
|
| 144 |
+
const pathColor = PATH_COLORS[index % PATH_COLORS.length];
|
| 145 |
+
return (
|
| 146 |
+
<div
|
| 147 |
+
key={index}
|
| 148 |
+
className="bg-zinc-800/50 rounded-lg p-3 border border-zinc-800"
|
| 149 |
+
style={{ borderLeftWidth: 4, borderLeftColor: pathColor }}
|
| 150 |
+
>
|
| 151 |
+
<div className="flex items-center justify-between mb-2">
|
| 152 |
+
<div className="flex items-center gap-3">
|
| 153 |
+
{/* Color indicator */}
|
| 154 |
+
<div
|
| 155 |
+
className="w-3 h-3 rounded-full"
|
| 156 |
+
style={{ backgroundColor: pathColor }}
|
| 157 |
+
/>
|
| 158 |
+
<div className="flex items-center gap-2">
|
| 159 |
+
<div className="w-6 h-6 rounded bg-blue-500/20 border border-blue-500/30 flex items-center justify-center">
|
| 160 |
+
<span className="text-xs font-mono text-blue-400">S{assignment.store_id}</span>
|
| 161 |
+
</div>
|
| 162 |
+
<ArrowRight className="w-4 h-4 text-zinc-600" />
|
| 163 |
+
<div className="w-6 h-6 rounded-full bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center">
|
| 164 |
+
<span className="text-xs font-mono text-emerald-400">D{assignment.destination_id}</span>
|
| 165 |
+
</div>
|
| 166 |
</div>
|
| 167 |
+
<CheckCircle className="w-4 h-4 text-emerald-500" />
|
| 168 |
+
</div>
|
| 169 |
+
<div className="flex items-center gap-4 text-xs text-zinc-500">
|
| 170 |
+
<span>Cost: <span className="text-zinc-300 font-mono">{assignment.path.cost}</span></span>
|
| 171 |
+
<span>Nodes: <span className="text-zinc-300 font-mono">{assignment.path.nodes_expanded}</span></span>
|
| 172 |
</div>
|
|
|
|
| 173 |
</div>
|
| 174 |
+
<div className="text-xs text-zinc-500">
|
| 175 |
+
<span className="text-zinc-600">Path: </span>
|
| 176 |
+
<span className="font-mono text-zinc-400">{assignment.path.plan || 'Direct'}</span>
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
+
);
|
| 180 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
)}
|
| 182 |
</CardContent>
|
| 183 |
</Card>
|
| 184 |
</div>
|
| 185 |
|
| 186 |
{/* Footer */}
|
| 187 |
+
<div className="px-6 py-3 border-t border-zinc-800 bg-zinc-800/20 flex justify-between">
|
| 188 |
+
<Button
|
| 189 |
+
variant={showPlanPaths ? 'primary' : 'outline'}
|
| 190 |
+
size="sm"
|
| 191 |
+
onClick={() => setShowPlanPaths(!showPlanPaths)}
|
| 192 |
+
className="gap-2"
|
| 193 |
+
>
|
| 194 |
+
{showPlanPaths ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
| 195 |
+
{showPlanPaths ? 'Hide Paths on Grid' : 'Show Paths on Grid'}
|
| 196 |
+
</Button>
|
| 197 |
<Button variant="secondary" size="sm" onClick={onClose}>
|
| 198 |
Close
|
| 199 |
</Button>
|
frontend/src/store/gridStore.ts
CHANGED
|
@@ -7,9 +7,18 @@ import type {
|
|
| 7 |
Algorithm,
|
| 8 |
SearchStep,
|
| 9 |
GridConfig,
|
|
|
|
| 10 |
} from '../types';
|
| 11 |
import { generateGrid, createPlan, compareAlgorithms, findPath } from '../api/client';
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
interface GridStore {
|
| 14 |
// Grid state
|
| 15 |
grid: GridState | null;
|
|
@@ -27,6 +36,10 @@ interface GridStore {
|
|
| 27 |
isPlaying: boolean;
|
| 28 |
playbackSpeed: number; // ms per step
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
// UI state
|
| 31 |
selectedAlgorithm: Algorithm;
|
| 32 |
isLoading: boolean;
|
|
@@ -46,8 +59,24 @@ interface GridStore {
|
|
| 46 |
reset: () => void;
|
| 47 |
nextStep: () => void;
|
| 48 |
prevStep: () => void;
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
export const useGridStore = create<GridStore>((set, get) => ({
|
| 52 |
// Initial state
|
| 53 |
grid: null,
|
|
@@ -60,6 +89,8 @@ export const useGridStore = create<GridStore>((set, get) => ({
|
|
| 60 |
steps: [],
|
| 61 |
isPlaying: false,
|
| 62 |
playbackSpeed: 100,
|
|
|
|
|
|
|
| 63 |
selectedAlgorithm: 'BF',
|
| 64 |
isLoading: false,
|
| 65 |
error: null,
|
|
@@ -76,6 +107,8 @@ export const useGridStore = create<GridStore>((set, get) => ({
|
|
| 76 |
currentStep: 0,
|
| 77 |
steps: [],
|
| 78 |
isPlaying: false,
|
|
|
|
|
|
|
| 79 |
error: null,
|
| 80 |
});
|
| 81 |
},
|
|
@@ -94,6 +127,8 @@ export const useGridStore = create<GridStore>((set, get) => ({
|
|
| 94 |
currentStep: 0,
|
| 95 |
steps: [],
|
| 96 |
isPlaying: false,
|
|
|
|
|
|
|
| 97 |
isLoading: false,
|
| 98 |
});
|
| 99 |
} catch (error) {
|
|
@@ -108,7 +143,7 @@ export const useGridStore = create<GridStore>((set, get) => ({
|
|
| 108 |
const { grid, selectedAlgorithm } = get();
|
| 109 |
if (!grid) return;
|
| 110 |
|
| 111 |
-
set({ isLoading: true, error: null });
|
| 112 |
try {
|
| 113 |
const result = await findPath(
|
| 114 |
grid.width,
|
|
@@ -141,8 +176,19 @@ export const useGridStore = create<GridStore>((set, get) => ({
|
|
| 141 |
set({ isLoading: true, error: null });
|
| 142 |
try {
|
| 143 |
const result = await createPlan(initialState, traffic, selectedAlgorithm, visualize);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
set({
|
| 145 |
planResult: result,
|
|
|
|
|
|
|
| 146 |
isLoading: false,
|
| 147 |
});
|
| 148 |
} catch (error) {
|
|
@@ -215,4 +261,12 @@ export const useGridStore = create<GridStore>((set, get) => ({
|
|
| 215 |
set({ currentStep: currentStep - 1 });
|
| 216 |
}
|
| 217 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
}));
|
|
|
|
| 7 |
Algorithm,
|
| 8 |
SearchStep,
|
| 9 |
GridConfig,
|
| 10 |
+
Position,
|
| 11 |
} from '../types';
|
| 12 |
import { generateGrid, createPlan, compareAlgorithms, findPath } from '../api/client';
|
| 13 |
|
| 14 |
+
// Path with color for visualization
|
| 15 |
+
export interface ColoredPath {
|
| 16 |
+
storeId: number;
|
| 17 |
+
destinationId: number;
|
| 18 |
+
path: Position[];
|
| 19 |
+
color: string;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
interface GridStore {
|
| 23 |
// Grid state
|
| 24 |
grid: GridState | null;
|
|
|
|
| 36 |
isPlaying: boolean;
|
| 37 |
playbackSpeed: number; // ms per step
|
| 38 |
|
| 39 |
+
// Plan visualization
|
| 40 |
+
showPlanPaths: boolean;
|
| 41 |
+
planPaths: ColoredPath[];
|
| 42 |
+
|
| 43 |
// UI state
|
| 44 |
selectedAlgorithm: Algorithm;
|
| 45 |
isLoading: boolean;
|
|
|
|
| 59 |
reset: () => void;
|
| 60 |
nextStep: () => void;
|
| 61 |
prevStep: () => void;
|
| 62 |
+
setShowPlanPaths: (show: boolean) => void;
|
| 63 |
+
clearPlanPaths: () => void;
|
| 64 |
}
|
| 65 |
|
| 66 |
+
// Colors for different delivery paths
|
| 67 |
+
const PATH_COLORS = [
|
| 68 |
+
'#f97316', // orange
|
| 69 |
+
'#06b6d4', // cyan
|
| 70 |
+
'#ec4899', // pink
|
| 71 |
+
'#84cc16', // lime
|
| 72 |
+
'#a855f7', // purple
|
| 73 |
+
'#14b8a6', // teal
|
| 74 |
+
'#f43f5e', // rose
|
| 75 |
+
'#eab308', // yellow
|
| 76 |
+
'#6366f1', // indigo
|
| 77 |
+
'#22c55e', // green
|
| 78 |
+
];
|
| 79 |
+
|
| 80 |
export const useGridStore = create<GridStore>((set, get) => ({
|
| 81 |
// Initial state
|
| 82 |
grid: null,
|
|
|
|
| 89 |
steps: [],
|
| 90 |
isPlaying: false,
|
| 91 |
playbackSpeed: 100,
|
| 92 |
+
showPlanPaths: false,
|
| 93 |
+
planPaths: [],
|
| 94 |
selectedAlgorithm: 'BF',
|
| 95 |
isLoading: false,
|
| 96 |
error: null,
|
|
|
|
| 107 |
currentStep: 0,
|
| 108 |
steps: [],
|
| 109 |
isPlaying: false,
|
| 110 |
+
showPlanPaths: false,
|
| 111 |
+
planPaths: [],
|
| 112 |
error: null,
|
| 113 |
});
|
| 114 |
},
|
|
|
|
| 127 |
currentStep: 0,
|
| 128 |
steps: [],
|
| 129 |
isPlaying: false,
|
| 130 |
+
showPlanPaths: false,
|
| 131 |
+
planPaths: [],
|
| 132 |
isLoading: false,
|
| 133 |
});
|
| 134 |
} catch (error) {
|
|
|
|
| 143 |
const { grid, selectedAlgorithm } = get();
|
| 144 |
if (!grid) return;
|
| 145 |
|
| 146 |
+
set({ isLoading: true, error: null, showPlanPaths: false, planPaths: [] });
|
| 147 |
try {
|
| 148 |
const result = await findPath(
|
| 149 |
grid.width,
|
|
|
|
| 176 |
set({ isLoading: true, error: null });
|
| 177 |
try {
|
| 178 |
const result = await createPlan(initialState, traffic, selectedAlgorithm, visualize);
|
| 179 |
+
|
| 180 |
+
// Extract paths for visualization with different colors
|
| 181 |
+
const coloredPaths: ColoredPath[] = result.assignments.map((assignment, index) => ({
|
| 182 |
+
storeId: assignment.store_id,
|
| 183 |
+
destinationId: assignment.destination_id,
|
| 184 |
+
path: assignment.path.path,
|
| 185 |
+
color: PATH_COLORS[index % PATH_COLORS.length],
|
| 186 |
+
}));
|
| 187 |
+
|
| 188 |
set({
|
| 189 |
planResult: result,
|
| 190 |
+
planPaths: coloredPaths,
|
| 191 |
+
showPlanPaths: true,
|
| 192 |
isLoading: false,
|
| 193 |
});
|
| 194 |
} catch (error) {
|
|
|
|
| 261 |
set({ currentStep: currentStep - 1 });
|
| 262 |
}
|
| 263 |
},
|
| 264 |
+
|
| 265 |
+
setShowPlanPaths: (show) => {
|
| 266 |
+
set({ showPlanPaths: show });
|
| 267 |
+
},
|
| 268 |
+
|
| 269 |
+
clearPlanPaths: () => {
|
| 270 |
+
set({ showPlanPaths: false, planPaths: [] });
|
| 271 |
+
},
|
| 272 |
}));
|