|
|
import sys |
|
|
import time |
|
|
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, |
|
|
QPushButton, QComboBox, QCheckBox, QLabel, QWidget, |
|
|
QTextEdit, QSplitter, QFrame) |
|
|
from PyQt5.QtCore import QTimer, Qt |
|
|
from PyQt5.QtGui import QColor, QPainter, QBrush, QPen |
|
|
|
|
|
from environment import Environment, CellType, Direction |
|
|
from search_algorithms import SearchAlgorithms |
|
|
|
|
|
class GridWidget(QWidget): |
|
|
def __init__(self, rows, cols, cell_size, environment): |
|
|
super().__init__() |
|
|
self.rows = rows |
|
|
self.cols = cols |
|
|
self.cell_size = cell_size |
|
|
self.env = environment |
|
|
self.current_path = [] |
|
|
self.current_direction = None |
|
|
|
|
|
self.setFixedSize(cols * cell_size, rows * cell_size) |
|
|
|
|
|
def paintEvent(self, event): |
|
|
painter = QPainter(self) |
|
|
painter.setRenderHint(QPainter.Antialiasing) |
|
|
|
|
|
|
|
|
for row in range(self.rows): |
|
|
for col in range(self.cols): |
|
|
x = col * self.cell_size |
|
|
y = row * self.cell_size |
|
|
|
|
|
|
|
|
cell_type = self.env.grid[row][col] |
|
|
if cell_type == CellType.CLEAN: |
|
|
color = QColor(255, 255, 0) |
|
|
elif cell_type == CellType.DIRTY: |
|
|
color = QColor(255, 0, 0) |
|
|
elif cell_type == CellType.OBSTACLE: |
|
|
color = QColor(0, 0, 255) |
|
|
elif cell_type == CellType.EXPLORED: |
|
|
color = QColor(0, 255, 0) |
|
|
|
|
|
|
|
|
painter.fillRect(x, y, self.cell_size, self.cell_size, color) |
|
|
painter.setPen(Qt.black) |
|
|
painter.drawRect(x, y, self.cell_size, self.cell_size) |
|
|
|
|
|
|
|
|
if self.current_path: |
|
|
painter.setPen(QPen(QColor(255, 165, 0), 3)) |
|
|
painter.setBrush(QBrush(QColor(255, 165, 0))) |
|
|
|
|
|
|
|
|
for i in range(len(self.current_path) - 1): |
|
|
row1, col1 = self.current_path[i] |
|
|
row2, col2 = self.current_path[i + 1] |
|
|
|
|
|
x1 = col1 * self.cell_size + self.cell_size // 2 |
|
|
y1 = row1 * self.cell_size + self.cell_size // 2 |
|
|
x2 = col2 * self.cell_size + self.cell_size // 2 |
|
|
y2 = row2 * self.cell_size + self.cell_size // 2 |
|
|
|
|
|
painter.drawLine(x1, y1, x2, y2) |
|
|
|
|
|
|
|
|
for i, (row, col) in enumerate(self.current_path): |
|
|
x = col * self.cell_size + self.cell_size // 2 |
|
|
y = row * self.cell_size + self.cell_size // 2 |
|
|
|
|
|
|
|
|
if i == 0: |
|
|
painter.setBrush(QBrush(QColor(0, 255, 0))) |
|
|
radius = 6 |
|
|
elif i == len(self.current_path) - 1: |
|
|
painter.setBrush(QBrush(QColor(255, 0, 0))) |
|
|
radius = 6 |
|
|
else: |
|
|
painter.setBrush(QBrush(QColor(255, 165, 0))) |
|
|
radius = 4 |
|
|
|
|
|
painter.drawEllipse(x - radius, y - radius, radius * 2, radius * 2) |
|
|
|
|
|
|
|
|
if self.env.vacuum_pos: |
|
|
row, col = self.env.vacuum_pos |
|
|
x = col * self.cell_size |
|
|
y = row * self.cell_size |
|
|
|
|
|
|
|
|
painter.setPen(QPen(Qt.black, 2)) |
|
|
painter.setBrush(QBrush(Qt.white)) |
|
|
painter.drawEllipse(x + 5, y + 5, self.cell_size - 10, self.cell_size - 10) |
|
|
|
|
|
|
|
|
if self.current_direction is not None: |
|
|
center_x = x + self.cell_size // 2 |
|
|
center_y = y + self.cell_size // 2 |
|
|
|
|
|
|
|
|
direction_pen = QPen(Qt.black, 3) |
|
|
painter.setPen(direction_pen) |
|
|
|
|
|
|
|
|
if self.current_direction == Direction.UP: |
|
|
painter.drawLine(center_x, center_y + 5, center_x, y + 5) |
|
|
elif self.current_direction == Direction.RIGHT: |
|
|
painter.drawLine(center_x - 5, center_y, x + self.cell_size - 5, center_y) |
|
|
elif self.current_direction == Direction.DOWN: |
|
|
painter.drawLine(center_x, center_y - 5, center_x, y + self.cell_size - 5) |
|
|
elif self.current_direction == Direction.LEFT: |
|
|
painter.drawLine(center_x + 5, center_y, x + 5, center_y) |
|
|
|
|
|
def update_path(self, path, direction): |
|
|
self.current_path = path |
|
|
self.current_direction = direction |
|
|
self.update() |
|
|
|
|
|
class VacuumSimulation(QMainWindow): |
|
|
def __init__(self, rows=15, cols=15): |
|
|
super().__init__() |
|
|
self.rows = rows |
|
|
self.cols = cols |
|
|
self.cell_size = 30 |
|
|
self.env = Environment(rows, cols) |
|
|
self.search = SearchAlgorithms(self.env) |
|
|
|
|
|
|
|
|
self.current_path = [] |
|
|
self.explored_cells = set() |
|
|
self.steps_taken = 0 |
|
|
self.total_cost = 0 |
|
|
self.current_direction = None |
|
|
self.is_running = False |
|
|
self.timer = QTimer() |
|
|
self.timer.timeout.connect(self.next_step) |
|
|
|
|
|
|
|
|
self.total_nodes_expanded = 0 |
|
|
self.total_computation_time = 0 |
|
|
self.algorithm_stats = { |
|
|
"BFS": {"runs": 0, "total_nodes": 0, "total_time": 0}, |
|
|
"A* Manhattan": {"runs": 0, "total_nodes": 0, "total_time": 0}, |
|
|
"A* Euclidean": {"runs": 0, "total_nodes": 0, "total_time": 0}, |
|
|
"A* Chebyshev": {"runs": 0, "total_nodes": 0, "total_time": 0} |
|
|
} |
|
|
|
|
|
self.init_ui() |
|
|
self.update_display() |
|
|
|
|
|
def init_ui(self): |
|
|
self.setWindowTitle("Vacuum Cleaner Search Simulation - Algorithm Comparison") |
|
|
|
|
|
|
|
|
grid_width = self.cols * self.cell_size |
|
|
grid_height = self.rows * self.cell_size |
|
|
self.setMinimumSize(grid_width + 400, grid_height + 200) |
|
|
|
|
|
|
|
|
central_widget = QWidget() |
|
|
self.setCentralWidget(central_widget) |
|
|
|
|
|
|
|
|
main_layout = QHBoxLayout() |
|
|
central_widget.setLayout(main_layout) |
|
|
|
|
|
|
|
|
left_widget = QWidget() |
|
|
left_layout = QVBoxLayout() |
|
|
left_widget.setLayout(left_layout) |
|
|
|
|
|
|
|
|
right_widget = QWidget() |
|
|
right_widget.setMaximumWidth(350) |
|
|
right_layout = QVBoxLayout() |
|
|
right_widget.setLayout(right_layout) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
metrics_layout = QHBoxLayout() |
|
|
|
|
|
|
|
|
left_metrics = QVBoxLayout() |
|
|
self.steps_label = QLabel("Steps: 0") |
|
|
self.cost_label = QLabel("Total Cost: 0.0") |
|
|
self.turn_cost_label = QLabel("Turn Cost: 0.0") |
|
|
self.dirty_label = QLabel("Dirty Cells: 0") |
|
|
|
|
|
left_metrics.addWidget(self.steps_label) |
|
|
left_metrics.addWidget(self.cost_label) |
|
|
left_metrics.addWidget(self.turn_cost_label) |
|
|
left_metrics.addWidget(self.dirty_label) |
|
|
|
|
|
|
|
|
right_metrics = QVBoxLayout() |
|
|
self.nodes_explored_label = QLabel("Nodes Explored: 0") |
|
|
self.nodes_expanded_label = QLabel("Nodes Expanded: 0") |
|
|
self.comp_time_label = QLabel("Comp Time: 0.000s") |
|
|
self.algorithm_label = QLabel("Algorithm: None") |
|
|
|
|
|
right_metrics.addWidget(self.nodes_explored_label) |
|
|
right_metrics.addWidget(self.nodes_expanded_label) |
|
|
right_metrics.addWidget(self.comp_time_label) |
|
|
right_metrics.addWidget(self.algorithm_label) |
|
|
|
|
|
metrics_layout.addLayout(left_metrics) |
|
|
metrics_layout.addLayout(right_metrics) |
|
|
metrics_layout.addStretch() |
|
|
|
|
|
left_layout.addLayout(metrics_layout) |
|
|
|
|
|
|
|
|
control_layout = QHBoxLayout() |
|
|
|
|
|
|
|
|
self.reset_button = QPushButton("Reset") |
|
|
self.reset_button.clicked.connect(self.reset_simulation) |
|
|
control_layout.addWidget(self.reset_button) |
|
|
|
|
|
|
|
|
self.next_button = QPushButton("Next") |
|
|
self.next_button.clicked.connect(self.next_step) |
|
|
control_layout.addWidget(self.next_button) |
|
|
|
|
|
|
|
|
self.run_button = QPushButton("Run") |
|
|
self.run_button.clicked.connect(self.toggle_run) |
|
|
control_layout.addWidget(self.run_button) |
|
|
|
|
|
control_layout.addStretch() |
|
|
left_layout.addLayout(control_layout) |
|
|
|
|
|
|
|
|
search_layout = QHBoxLayout() |
|
|
|
|
|
|
|
|
self.turn_cost_checkbox = QCheckBox("Turn Cost (0.5 per 90° turn)") |
|
|
self.turn_cost_checkbox.stateChanged.connect(self.toggle_turn_cost) |
|
|
search_layout.addWidget(self.turn_cost_checkbox) |
|
|
|
|
|
|
|
|
search_layout.addWidget(QLabel("Search:")) |
|
|
self.search_combo = QComboBox() |
|
|
self.search_combo.addItems(["BFS", "A* Manhattan", "A* Euclidean", "A* Chebyshev"]) |
|
|
search_layout.addWidget(self.search_combo) |
|
|
|
|
|
search_layout.addStretch() |
|
|
left_layout.addLayout(search_layout) |
|
|
|
|
|
|
|
|
self.grid_widget = GridWidget(self.rows, self.cols, self.cell_size, self.env) |
|
|
left_layout.addWidget(self.grid_widget) |
|
|
|
|
|
|
|
|
legend_layout = QHBoxLayout() |
|
|
|
|
|
def create_legend_item(color, text): |
|
|
item_widget = QWidget() |
|
|
item_layout = QHBoxLayout() |
|
|
item_widget.setLayout(item_layout) |
|
|
|
|
|
color_label = QLabel() |
|
|
color_label.setFixedSize(16, 16) |
|
|
color_label.setStyleSheet(f"background-color: {color}; border: 1px solid black") |
|
|
item_layout.addWidget(color_label) |
|
|
|
|
|
text_label = QLabel(text) |
|
|
text_label.setStyleSheet("font-size: 10px;") |
|
|
item_layout.addWidget(text_label) |
|
|
item_layout.setContentsMargins(2, 0, 5, 0) |
|
|
|
|
|
return item_widget |
|
|
|
|
|
legend_layout.addWidget(create_legend_item("yellow", "Clean")) |
|
|
legend_layout.addWidget(create_legend_item("red", "Dirty")) |
|
|
legend_layout.addWidget(create_legend_item("blue", "Obstacle")) |
|
|
legend_layout.addWidget(create_legend_item("green", "Explored")) |
|
|
legend_layout.addWidget(create_legend_item("orange", "Path")) |
|
|
legend_layout.addStretch() |
|
|
|
|
|
left_layout.addLayout(legend_layout) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
info_label = QLabel("Algorithm Information:") |
|
|
info_label.setStyleSheet("font-weight: bold; font-size: 12px; margin-bottom: 5px;") |
|
|
right_layout.addWidget(info_label) |
|
|
|
|
|
info_text = QTextEdit() |
|
|
info_text.setMaximumHeight(120) |
|
|
info_text.setReadOnly(True) |
|
|
info_text.setHtml(""" |
|
|
<b>Search Algorithms:</b><br> |
|
|
• <b>BFS</b>: Explores all directions equally, finds shortest path<br> |
|
|
• <b>A* Manhattan</b>: Uses city-block distance heuristic<br> |
|
|
• <b>A* Euclidean</b>: Uses straight-line distance heuristic<br> |
|
|
• <b>A* Chebyshev</b>: Uses chessboard distance heuristic<br><br> |
|
|
<b>Turn Cost</b>: When enabled, 90° turns cost +0.5 |
|
|
""") |
|
|
right_layout.addWidget(info_text) |
|
|
|
|
|
|
|
|
stats_label = QLabel("Algorithm Performance:") |
|
|
stats_label.setStyleSheet("font-weight: bold; font-size: 12px; margin-top: 10px; margin-bottom: 5px;") |
|
|
right_layout.addWidget(stats_label) |
|
|
|
|
|
self.stats_text = QTextEdit() |
|
|
self.stats_text.setReadOnly(True) |
|
|
self.stats_text.setStyleSheet("font-family: monospace; font-size: 10px;") |
|
|
right_layout.addWidget(self.stats_text) |
|
|
|
|
|
|
|
|
analysis_label = QLabel("Current Run Analysis:") |
|
|
analysis_label.setStyleSheet("font-weight: bold; font-size: 12px; margin-top: 10px; margin-bottom: 5px;") |
|
|
right_layout.addWidget(analysis_label) |
|
|
|
|
|
self.analysis_text = QTextEdit() |
|
|
self.analysis_text.setMaximumHeight(100) |
|
|
self.analysis_text.setReadOnly(True) |
|
|
self.analysis_text.setStyleSheet("font-family: monospace; font-size: 10px;") |
|
|
right_layout.addWidget(self.analysis_text) |
|
|
|
|
|
|
|
|
right_layout.addStretch() |
|
|
|
|
|
|
|
|
main_layout.addWidget(left_widget) |
|
|
main_layout.addWidget(right_widget) |
|
|
|
|
|
|
|
|
main_layout.setStretchFactor(left_widget, 1) |
|
|
main_layout.setStretchFactor(right_widget, 0) |
|
|
|
|
|
self.update_stats_display() |
|
|
|
|
|
def update_stats_display(self): |
|
|
stats_text = "<pre>" |
|
|
for algo, stats in self.algorithm_stats.items(): |
|
|
if stats["runs"] > 0: |
|
|
avg_nodes = stats["total_nodes"] / stats["runs"] |
|
|
avg_time = stats["total_time"] / stats["runs"] |
|
|
stats_text += f"{algo:<15} {stats['runs']:>3} runs\n" |
|
|
stats_text += f" Avg Nodes: {avg_nodes:>6.1f}\n" |
|
|
stats_text += f" Avg Time: {avg_time:>7.4f}s\n" |
|
|
else: |
|
|
stats_text += f"{algo:<15} No runs yet\n" |
|
|
stats_text += "</pre>" |
|
|
|
|
|
|
|
|
chebyshev_stats = self.algorithm_stats["A* Chebyshev"] |
|
|
euclidean_stats = self.algorithm_stats["A* Euclidean"] |
|
|
|
|
|
analysis_text = "<pre>" |
|
|
if chebyshev_stats["runs"] > 0 and euclidean_stats["runs"] > 0: |
|
|
chebyshev_avg_nodes = chebyshev_stats["total_nodes"] / chebyshev_stats["runs"] |
|
|
euclidean_avg_nodes = euclidean_stats["total_nodes"] / euclidean_stats["runs"] |
|
|
chebyshev_avg_time = chebyshev_stats["total_time"] / chebyshev_stats["runs"] |
|
|
euclidean_avg_time = euclidean_stats["total_time"] / euclidean_stats["runs"] |
|
|
|
|
|
if euclidean_avg_nodes > 0: |
|
|
node_ratio = chebyshev_avg_nodes / euclidean_avg_nodes |
|
|
analysis_text += f"Node Exploration:\n" |
|
|
analysis_text += f" Chebyshev explores {node_ratio:.1f}x\n" |
|
|
analysis_text += f" more nodes than Euclidean\n\n" |
|
|
|
|
|
if euclidean_avg_time > 0: |
|
|
time_ratio = chebyshev_avg_time / euclidean_avg_time |
|
|
analysis_text += f"Computation Time:\n" |
|
|
analysis_text += f" Chebyshev is {time_ratio:.1f}x\n" |
|
|
analysis_text += f" slower than Euclidean" |
|
|
else: |
|
|
analysis_text += "Run simulations to see\nalgorithm comparisons" |
|
|
analysis_text += "</pre>" |
|
|
|
|
|
self.stats_text.setHtml(stats_text) |
|
|
self.analysis_text.setHtml(analysis_text) |
|
|
|
|
|
def update_display(self): |
|
|
self.steps_label.setText(f"Steps: {self.steps_taken}") |
|
|
self.cost_label.setText(f"Total Cost: {self.total_cost:.2f}") |
|
|
|
|
|
|
|
|
turn_cost = max(0, self.total_cost - self.steps_taken) |
|
|
self.turn_cost_label.setText(f"Turn Cost: {turn_cost:.2f}") |
|
|
|
|
|
self.dirty_label.setText(f"Dirty Cells: {self.env.get_dirty_count()}") |
|
|
self.nodes_explored_label.setText(f"Nodes Explored: {len(self.explored_cells)}") |
|
|
self.nodes_expanded_label.setText(f"Nodes Expanded: {self.total_nodes_expanded}") |
|
|
self.comp_time_label.setText(f"Comp Time: {self.total_computation_time:.3f}s") |
|
|
|
|
|
current_algorithm = self.search_combo.currentText() |
|
|
self.algorithm_label.setText(f"Algorithm: {current_algorithm}") |
|
|
|
|
|
self.grid_widget.update_path(self.current_path, self.current_direction) |
|
|
|
|
|
def reset_simulation(self): |
|
|
self.env.reset() |
|
|
self.current_path = [] |
|
|
self.explored_cells = set() |
|
|
self.steps_taken = 0 |
|
|
self.total_cost = 0 |
|
|
self.current_direction = None |
|
|
self.total_nodes_expanded = 0 |
|
|
self.total_computation_time = 0 |
|
|
self.is_running = False |
|
|
self.timer.stop() |
|
|
self.run_button.setText("Run") |
|
|
self.update_display() |
|
|
|
|
|
def next_step(self): |
|
|
if self.env.is_clean(): |
|
|
self.is_running = False |
|
|
self.timer.stop() |
|
|
self.run_button.setText("Run") |
|
|
return |
|
|
|
|
|
|
|
|
if not self.current_path: |
|
|
self.find_path() |
|
|
|
|
|
if not self.current_path: |
|
|
self.is_running = False |
|
|
self.timer.stop() |
|
|
self.run_button.setText("Run") |
|
|
return |
|
|
|
|
|
|
|
|
if self.current_path: |
|
|
|
|
|
next_pos = self.current_path.pop(0) |
|
|
current_pos = self.env.vacuum_pos |
|
|
|
|
|
|
|
|
move_cost = 1 |
|
|
|
|
|
|
|
|
new_direction = Direction.from_movement(current_pos, next_pos) |
|
|
|
|
|
if self.turn_cost_checkbox.isChecked() and self.current_direction is not None: |
|
|
|
|
|
if self.current_direction != new_direction: |
|
|
turn_cost = 0.5 |
|
|
move_cost += turn_cost |
|
|
|
|
|
|
|
|
self.current_direction = new_direction |
|
|
|
|
|
|
|
|
self.env.vacuum_pos = next_pos |
|
|
self.steps_taken += 1 |
|
|
self.total_cost += move_cost |
|
|
|
|
|
|
|
|
if self.env.clean_cell(next_pos[0], next_pos[1]): |
|
|
|
|
|
self.current_path = [] |
|
|
|
|
|
|
|
|
self.env.mark_explored(next_pos[0], next_pos[1]) |
|
|
|
|
|
self.update_display() |
|
|
|
|
|
def find_path(self): |
|
|
|
|
|
search_type = self.search_combo.currentText() |
|
|
|
|
|
if search_type == "BFS": |
|
|
algorithm = "bfs" |
|
|
heuristic = "manhattan" |
|
|
else: |
|
|
algorithm = "a_star" |
|
|
if search_type == "A* Manhattan": |
|
|
heuristic = "manhattan" |
|
|
elif search_type == "A* Euclidean": |
|
|
heuristic = "euclidean" |
|
|
else: |
|
|
heuristic = "chebyshev" |
|
|
|
|
|
|
|
|
self.search.turn_cost_enabled = self.turn_cost_checkbox.isChecked() |
|
|
|
|
|
|
|
|
path, explored, nodes_expanded, computation_time = self.search.find_path_to_nearest_dirty( |
|
|
self.env.vacuum_pos, algorithm, heuristic) |
|
|
|
|
|
|
|
|
self.total_nodes_expanded += nodes_expanded |
|
|
self.total_computation_time += computation_time |
|
|
|
|
|
|
|
|
self.algorithm_stats[search_type]["runs"] += 1 |
|
|
self.algorithm_stats[search_type]["total_nodes"] += nodes_expanded |
|
|
self.algorithm_stats[search_type]["total_time"] += computation_time |
|
|
|
|
|
if path: |
|
|
|
|
|
self.current_path = path[1:] |
|
|
self.explored_cells.update(explored) |
|
|
|
|
|
|
|
|
for row, col in explored: |
|
|
if (row, col) != self.env.vacuum_pos and self.env.grid[row][col] == CellType.CLEAN: |
|
|
self.env.grid[row][col] = CellType.EXPLORED |
|
|
|
|
|
self.update_stats_display() |
|
|
|
|
|
def toggle_run(self): |
|
|
self.is_running = not self.is_running |
|
|
|
|
|
if self.is_running: |
|
|
self.run_button.setText("Pause") |
|
|
self.timer.start(500) |
|
|
else: |
|
|
self.run_button.setText("Run") |
|
|
self.timer.stop() |
|
|
|
|
|
def toggle_turn_cost(self): |
|
|
|
|
|
if self.current_path: |
|
|
self.current_path = [] |