asteracer/asteracer-python-search/__main__.py
2025-04-24 00:42:48 +02:00

569 lines
19 KiB
Python

"""Tom's solution to Asteracer. Moves on the vertices of the asteroid graph by simulating all angles."""
import os
from typing import Optional
import drawsvg as draw
import networkx as nx
import numpy as np
from asteracer import *
directional_instructions_cache = {}
Vertex = tuple[int, int]
def load_asteroid_graph(path: str):
with open(path) as file:
lines = [line.strip() for line in file.readlines()]
# Filter out comments and empty lines
contents = [line for line in lines if line and not line.startswith('#')]
iter_lines = iter(contents)
first_line = list(map(int, next(iter_lines).split()))
n_racer, n_asteroid, n_goal, m = first_line
vertices = []
edges = []
vertex_objects = []
# Load vertices
for i in range(n_racer + n_asteroid + n_goal):
line = list(map(int, next(iter_lines).split()))
vertices.append((line[0], line[1]))
if i < n_racer:
vertex_objects.append(('S', i))
elif i < (n_racer + n_asteroid):
vertex_objects.append(('A', line[2]))
else:
vertex_objects.append(('G', line[2]))
# Load edges
for _ in range(m):
line = list(map(int, next(iter_lines).split()))
edges.append((line[0], line[1]))
return vertices, edges, vertex_objects
def _yield_points_at_distance(x: float, y: float, r: float, n: int):
"""Generate n points uniformly at distance r from the coordinates (x, y)."""
for i in range(n):
t = (i / n) * np.pi * 2
yield x + np.cos(t) * r, y + np.sin(t) * r
def _get_directional_instructions(
n: int = 1000,
sort_by_angle_to: tuple[float, float] = None,
limit_angle=None
) -> list[Instruction]:
"""Generate normalized instructions in at most n different angles with maximum velocity."""
instructions = []
if n in directional_instructions_cache:
instructions = directional_instructions_cache[n]
else:
for point in _yield_points_at_distance(0, 0, 127, n):
i1 = Instruction(*point)
if i1 in instructions:
continue
for i2 in instructions:
if np.sign(i1.vx) == np.sign(i2.vx) and abs(i1.vx) <= abs(i2.vx) \
and np.sign(i1.vy) == np.sign(i2.vy) and abs(i1.vy) <= abs(i2.vy):
break
else:
instructions.append(i1)
directional_instructions_cache[n] = instructions
def instr_vector_angle(i_from: Instruction, v_to: tuple[float, float]) -> float:
"""The angle between an instruction and a vector."""
i_vec = np.array([i_from.vx, i_from.vy])
o_vec = np.array(v_to)
return (1 - np.dot(i_vec, o_vec) / (np.linalg.norm(i_vec) * np.linalg.norm(o_vec))) / 2
# possibly sort by an angle to a given instruction
if sort_by_angle_to:
instructions = sorted(instructions, key=lambda x: instr_vector_angle(x, sort_by_angle_to))
if limit_angle:
return [i for i in instructions if abs(instr_vector_angle(i, sort_by_angle_to)) <= limit_angle]
else:
return instructions
else:
return instructions
def instructions_to_position(
simulation: Simulation,
position: Vertex,
try_ticks=7,
max_distance=1.25,
) -> Optional[list[Instruction]]:
"""Return a sequence of instructions that brings us the closest to the provided coordinates.
Returns None if there is no such set that brings us at most max_distance * racer_radius away."""
x, y = position
best_distance = float('inf')
best_instructions = None
def is_safe(simulation: Simulation) -> bool:
"""Returns True if the racer has a sequence of instructions to not crash at this stage of the simulation."""
simulation.push()
for instruction in _get_directional_instructions(10):
simulation.apply()
for _ in range(try_ticks):
result = simulation.tick(instruction)
if result & TickFlag.COLLIDED:
break
else:
simulation.pop()
return True
simulation.pop()
return False
simulation.push()
for inst in _get_directional_instructions(
sort_by_angle_to=(x - int(simulation.racer.x), y - int(simulation.racer.y)),
limit_angle=0.5
):
simulation.apply()
started_improving = False
prev_dist = 0
i = 0
while True:
result = simulation.tick(inst)
if result & TickFlag.COLLIDED:
break
distance = euclidean_distance(simulation.racer.x, simulation.racer.y, x, y)
# if we're closer than before, we started improving
if distance < prev_dist:
started_improving = True
# if we've started improving but are now getting worse, terminate this instruction
if started_improving and distance > prev_dist:
break
prev_dist = distance
# if we improved and are safe, save results
if distance < best_distance and is_safe(simulation):
best_distance = distance
best_instructions = [inst] * i
i += 1
simulation.pop()
if best_distance > max_distance * simulation.racer.radius:
return None
return best_instructions
def get_preview(simulation: Simulation, size=1000):
"""Generate preview of the simulation as an SVG."""
d = draw.Drawing(size, size, origin='center')
# background
d.append(
draw.Rectangle(
-size / 2, -size / 2,
size, size,
fill="White"
)
)
def draw_circles(circles: list[Asteroid | Goal | Racer], color: str):
"""Draw a circle with a specific color on the SVG."""
s = simulation.bounding_box.width() / size
for i, circle in enumerate(circles):
d.append(draw.Circle(circle.x / s, circle.y / s, circle.radius / s, fill=color, stroke=color))
draw_circles(simulation.asteroids, "Black")
draw_circles([g for i, g in enumerate(simulation.goals) if simulation.reached_goals[i]], "LightGreen")
draw_circles([g for i, g in enumerate(simulation.goals) if not simulation.reached_goals[i]], "Red")
draw_circles([simulation.racer], "Gray")
return d
def get_solution_preview(
simulation: Simulation,
simulation_path: Optional[list[Vertex]],
color: str = "Green"
) -> draw.Drawing:
"""Return an SVG object with the solution graph, possibly with the simulation too."""
d = get_preview(simulation)
s = simulation.bounding_box.width() / d.width
def draw_path(path, color, stroke_width, stroke_opacity) -> draw.Path:
p = draw.Path(stroke_width=stroke_width, stroke=color, opacity=stroke_opacity, fill_opacity=0)
p.M(path[0][0] / s, path[0][1] / s)
for i in range(1, len(path)):
p.L(path[i][0] / s, path[i][1] / s)
d.append(p)
if simulation_path:
d.append(draw_path(simulation_path, color, simulation.racer.radius / (s * 2), 1))
return d
def get_checkpoints_path(
simulation, vertices, edges, goal_vertices,
longest_edges,
):
"""Get a path that the rocket should follow to obtain all goals."""
def astar_heuristic(p1, p2):
return np.linalg.norm(np.array(p1) - np.array(p2))
def perturb_shortest_path(G, best_path, penalty_factor=2.0):
""" Perturbs the shortest path by increasing the weight of a random edge. """
if len(best_path) < 2:
return best_path # No modification possible
# Randomly choose an edge along the best path
idx = random.randint(2, len(best_path) - 3)
u, v = best_path[idx], best_path[idx + 1]
if G.has_edge(u, v):
original_weight = G[u][v].get("weight", 1.0)
G[u][v]["weight"] = original_weight * penalty_factor
if G.has_edge(v, u): # If the graph is undirected
G[v][u]["weight"] = original_weight * penalty_factor
perturbed_path = nx.astar_path(G, best_path[0], best_path[-1], heuristic=astar_heuristic)
return perturbed_path
# convert vertices, edges to nx.Graph
G = nx.Graph()
for v in vertices:
G.add_node(v)
for u, v in edges:
G.add_edge(u, v, weight=np.linalg.norm(np.array(u) - np.array(v)))
# for more than 1 goal, we have to solve TSP (at least approximately)
if len(simulation.goals) > 1:
G_tsp = nx.DiGraph()
tsp_vertices = [(simulation.racer.x, simulation.racer.y)]
for g in simulation.goals:
tsp_vertices.append((g.x, g.y))
G_tsp.add_node(tsp_vertices[-1])
count = 0
for i, u in enumerate(tsp_vertices):
for v in tsp_vertices[i + 1:]:
print(f"TSP: building graph {count + 1}/{len(tsp_vertices) * (len(tsp_vertices) - 1) // 2}")
w = nx.astar_path_length(G, u, v, heuristic=astar_heuristic)
G_tsp.add_edge(u, v, weight=w)
G_tsp.add_edge(v, u, weight=w)
count += 1
print(f"TSP: graph built")
# nx doesn't play nice with infinite values, but this might as well be
INF = 100000000000
# we solve TSP by adding a hack vertex to force us to try all possible ending vertices
# this means that we have to solve #number_of_vertices instances of TSP
G_tsp.add_node("hack")
G_tsp.add_edge(vertices[0], "hack", weight=0)
G_tsp.add_edge("hack", vertices[0], weight=INF)
best_cost = float('inf')
best_path = None
for i, g in enumerate(simulation.goals):
print(f"TSP: solving {i + 1}/{len(simulation.goals)}")
for g_hack in simulation.goals:
v = (g_hack.x, g_hack.y)
G_tsp.add_edge("hack", v, weight=0 if g is g_hack else INF)
G_tsp.add_edge(v, "hack", weight=INF)
path = nx.approximation.simulated_annealing_tsp(G_tsp, init_cycle="greedy")
path.pop()
cost = 0
for j in range(len(path)):
cost += G_tsp[path[j - 1]][path[j]]["weight"]
for g_hack in simulation.goals:
v = (g_hack.x, g_hack.y)
G_tsp.remove_edge("hack", v)
G_tsp.remove_edge(v, "hack")
path = list(reversed(path))
j = path.index("hack")
path.remove("hack")
path = path[j:] + path[:j]
assert len(path) == len(simulation.goals) + 1, "Not all goals visited!"
if cost < best_cost:
best_path = path
best_cost = cost
print(f"TSP: new optimum of length {best_cost}")
path = [(int(simulation.racer.x), int(simulation.racer.y))]
for i in range(len(best_path) - 1):
path += nx.astar_path(G, best_path[i], best_path[i + 1], heuristic=astar_heuristic)[1:]
# last vertex is a center of some goal
path = path[:-1]
# shorten the path by cutting out vertices
while True:
# try to remove vertices for goals
for i in range(len(path) - 2):
if not G.has_edge(path[i], path[i + 2]):
continue
# if it's not a part of a goal, we can remove for free
if path[i + 1] not in goal_vertices:
path.pop(i + 1)
break
# if it is and is not the only one, we can remove it too
goal_vertices_count = 0
for v in path:
if v in goal_vertices and goal_vertices[v] == goal_vertices[path[i + 1]]:
goal_vertices_count += 1
if goal_vertices_count != 1:
path.pop(i + 1)
break
else:
break
# rotate goal vertices (we can take an arbitrary one to get the goal)
while True:
improved = False
for i in range(len(path) - 2):
u = path[i + 1]
if u not in goal_vertices:
continue
for v in vertices:
if v not in goal_vertices:
continue
if goal_vertices[u] != goal_vertices[v]:
continue
if not G.has_edge(path[i], v) or not G.has_edge(v, path[i + 2]):
continue
u_dist = G[path[i]][u]["weight"] + G[u][path[i + 2]]["weight"]
v_dist = G[path[i]][v]["weight"] + G[v][path[i + 2]]["weight"]
if u_dist > v_dist:
path = path[:i + 1] + [v] + path[i + 2:]
improved = True
break
if not improved:
break
# TODO: remove goal vertices entirely if the edge exists and intersects it
# TODO: for each goal, attempt to remove vertex and connect astar to next and to previous
print("Solved with TSP.")
else:
path = nx.astar_path(
G,
(simulation.racer.x, simulation.racer.y),
(simulation.goals[0].x, simulation.goals[0].y),
heuristic=astar_heuristic,
)
path = perturb_shortest_path(G, path, penalty_factor=1000)
print("Solved with A*.")
# split long edges
while True:
split = False
i = 0
while i < len(path) - 1:
if euclidean_distance(*path[i], *path[i + 1]) > longest_edges:
x = path[i][0] + path[i + 1][0]
y = path[i][1] + path[i + 1][1]
path.insert(i + 1, (x / 2, y / 2))
split = True
i += 1
if not split:
break
return path
def finalize_instructions(simulation, instructions):
"""Finalize the instructions (cut useless ones + extend if the simulation isn't finished)."""
simulation.restart()
for i, instruction in enumerate(instructions):
simulation.tick(instruction)
# there might be some redundant instructions at the very end
if simulation.finished():
break
instructions = instructions[:i + 1]
# if we ended just before the goal, repeat last instruction
simulation.simulate(instructions)
i = 0
while not simulation.finished():
simulation.tick(instruction)
instructions.append(instruction)
i += 1
if i > 1000:
break
return instructions
def get_solution_path(simulation, instructions):
"""Generate the path the racer took using the instructions."""
simulation.restart()
solution_path = [(simulation.racer.x, simulation.racer.y)]
for i, instruction in enumerate(instructions):
simulation.tick(instruction)
solution_path.append((simulation.racer.x, simulation.racer.y))
return solution_path
def solve(simulation, path):
"""Solve the simulation by following the path."""
steps_instructions = []
randomize_step_counter = 0
i = 1
while i < len(path):
v = path[i]
for goal in simulation.goals:
if euclidean_distance(goal.x, goal.y, v[0], v[1]) < goal.radius + simulation.racer.radius:
is_goal_position = True
break
else:
is_goal_position = False
print(f"Simulating {i}/{len(path) - 1}{'' if not is_goal_position else ' to goal'}", end=": ", flush=True)
if randomize_step_counter:
print("randomized, ", end="")
# only move towards the goal
if is_goal_position:
p2g_vector = np.array([goal.x, goal.y]) - np.array(v)
v = (p2g_vector / np.linalg.norm(p2g_vector)) * np.random.normal(0, simulation.racer.radius, 1)
else:
v = np.array(v) + np.random.normal(0, simulation.racer.radius, 2)
new_instructions = instructions_to_position(simulation, v, max_distance=2 if not is_goal_position else 0.75)
if new_instructions is None:
if len(steps_instructions) != 0:
randomize_step_counter += 1
i -= randomize_step_counter
for _ in range(randomize_step_counter):
steps_instructions.pop()
print(f"(- backtrack {randomize_step_counter}x)")
if len(steps_instructions) != 0:
simulation.simulate(list(np.hstack(steps_instructions)))
else:
simulation.restart()
continue
else:
randomize_step_counter = max(randomize_step_counter - 1, 0)
steps_instructions.append(new_instructions)
simulation.simulate(list(np.hstack(steps_instructions)))
print(f"(+ {len(new_instructions)})")
i += 1
return finalize_instructions(simulation, list(np.hstack(steps_instructions)))
if __name__ == "__main__":
iterations = 5000
for task in ["sprint"]:
print(f"Solving {task} ({iterations} attempts):")
os.makedirs(task, exist_ok=True)
simulation = Simulation.load(f"../mapy/{task}.txt")
print("Simulation loaded.")
vertices, edges, object_types = load_asteroid_graph(f"../grafy/{task}.txt")
edges = [(vertices[u], vertices[v]) for u, v in edges]
print("Graph loaded.")
average_asteroid_radius = sum([asteroid.radius for asteroid in simulation.asteroids]) / len(simulation.asteroids)
goal_vertices = {
vertices[i]: (simulation.goals[j].x, simulation.goals[j].y)
for (i, (c, j)) in enumerate(object_types)
if c == "G"
}
best_instructions = None
best_instructions_length = float('inf')
i = 0
while i < iterations:
simulation.restart()
path = get_checkpoints_path(
simulation,
vertices, edges, goal_vertices,
longest_edges=average_asteroid_radius * np.random.uniform(0.5, 5),
)
instructions = solve(simulation, path)
i += 1
if len(instructions) < best_instructions_length:
best_instructions = instructions
best_instructions_length = len(instructions)
print(f"Solved with {len(instructions)} instructions.")
solution_path = get_solution_path(simulation, best_instructions)
d = get_solution_preview(simulation, solution_path, "Green")
d.save_svg(f"{task}/solution.svg")
save_instructions(f"{task}/solution.txt", best_instructions)
print(f"Best solution: {len(best_instructions)} instructions.")