Compare commits
10 Commits
92c545fab1
...
5b1f6ce1ba
Author | SHA1 | Date |
---|---|---|
Emi Simpson | 5b1f6ce1ba | |
Emi Simpson | e504026cfe | |
Emi Simpson | f05e1ef3c6 | |
Emi Simpson | 91ebe8d055 | |
Emi Simpson | 4680fb6090 | |
Emi Simpson | 5fac1d9c25 | |
Emi Simpson | 66bd8bbec8 | |
Emi Simpson | c71ea2616e | |
Emi Simpson | d7dab8e4b0 | |
Emi Simpson | cdcbee5fdf |
|
@ -0,0 +1,91 @@
|
|||
\documentclass{article}
|
||||
\date{2022-09-29}
|
||||
|
||||
%\usepackage{amsmath}
|
||||
%\usepackage{amssymb}
|
||||
\usepackage{fancyhdr}
|
||||
|
||||
\usepackage[hmargin=1in,vmargin=1in]{geometry}
|
||||
\pagestyle{fancy}
|
||||
\lhead{Emi Simpson}
|
||||
\chead{Introduction to AI - Lab 1 - Summer Orienteering}
|
||||
\rhead{\thepage}
|
||||
\fancyfoot{}
|
||||
|
||||
\newcommand{\f}[1]{\textnormal{#1}}
|
||||
\newcommand{\st}{\textnormal{ s.t. }}
|
||||
\newcommand{\bb}[1]{\mathbb{#1}}
|
||||
\newcommand{\var}[1]{\textnormal{#1}}
|
||||
\newcommand{\bld}[1]{\textbf{#1}}
|
||||
\newcommand{\flr}[1]{\left\lfloor#1\right\rfloor}
|
||||
\newcommand{\paren}[1]{\left(#1\right)}
|
||||
\newcommand{\sqb}[1]{\left[#1\right]}
|
||||
\newcommand{\ben}{\begin{enumerate}}
|
||||
\newcommand{\een}{\end{enumerate}}
|
||||
|
||||
\begin{document}
|
||||
\section{Writeup}
|
||||
|
||||
My code is divided into a fed discrete modules, each of which detailed below.
|
||||
|
||||
\subsection{\texttt{read\_in} and \texttt{write\_out}}
|
||||
|
||||
These two modules are dedicated to transforming data to and from the specified format.
|
||||
For improving testability, as many functions as possible work on data itself. Other
|
||||
than that, it's pretty mundane.
|
||||
|
||||
Of note, I've chosen to represent distances and elevations as integer values rather
|
||||
than floating point. To ensure that no significant data is lost, we measure distances
|
||||
in millimeters and speeds is seconds per kilometer (or equivalently microseconds per
|
||||
meter). This was mostly done out of my strong distaste for floating point numbers,
|
||||
but seems to have benefitted performance slightly.
|
||||
|
||||
\subsection{\texttt{a\_star}}
|
||||
|
||||
This is where the actual routing algorithm lies, although not the heuristic nor the
|
||||
neighbor finding algorithm. The A* algorithm I've implement is pretty generic and
|
||||
works on any search space for which a neighbor function and a heuristic can be
|
||||
defined.
|
||||
|
||||
It may be interesting to you that I've combined the cost and neighbor function. This
|
||||
mostly to make the code a little simpler. However, the core mechanics of the
|
||||
algorithm work nonetheless.
|
||||
|
||||
I've also written the function \texttt{pathfind\_multi}, which is able to find a path
|
||||
which passes through several different checkpoints. This is the function which is
|
||||
ultimately called to perform pathfinding.
|
||||
|
||||
This function simply repeatedly calls the normal \texttt{pathfind} function for each
|
||||
pair of adjacent goals.
|
||||
|
||||
\subsection{\texttt{world}}
|
||||
|
||||
Perhaps the most interesting part of the code, this module is what defines the search
|
||||
space, heuristic function, and neighbor function. Each node is simply represented by
|
||||
a tuple containing an X and a Y coordinate. When the \texttt{neighbor} function is
|
||||
called, these coordinates are looked up. Each adjacent node is evaluated, and a cost
|
||||
(in seconds) is produced for moving to any valid node, and the results are returned.
|
||||
More details about the specifics can be found in the detailed doccomment written for
|
||||
the \texttt{neighbor} function.
|
||||
|
||||
This section also contains a heuristic function, which is considerably simpler. By
|
||||
looking only at the coordinates of the function, we can compute the minimum distance
|
||||
needed to be travelled between the two points. On a flat surface, this computation is
|
||||
exact. For performance, we leave out height from the computation. However, by
|
||||
assuming that all terrain is flat, we have not affected the admissibility of the
|
||||
heuristic. Real conditions will be equivalent or worse than the predicted measure.
|
||||
|
||||
The last step in computing the heuristic is simply to convert between distance (in
|
||||
millimeters) and time (in microseconds). To do this, we just use the fastest speed
|
||||
possible, as to keep admissibility. In our implementation, this happens to be 500
|
||||
microseconds per millimeter, the speed we have selected for \texttt{ROAD} terrain, and
|
||||
also a reasonable human jogging speed.
|
||||
|
||||
\subsection{Conclusion}
|
||||
|
||||
If this writeup has left you with any unanswered questions, I encourage you to look
|
||||
into the docstrings I've left in my code. Almost all functions defined for this
|
||||
project should be documented, and most have examples that you can run using
|
||||
doctesting.
|
||||
|
||||
\end{document}
|
17
a_star.py
17
a_star.py
|
@ -77,25 +77,23 @@ def pathfind(
|
|||
(2, 3), (3, 3), (3, 4), (4, 4), (5, 4), (6, 4),
|
||||
(6, 3), (6, 2), (7, 2), (7, 1), (7, 0)), 19))
|
||||
"""
|
||||
@tco_rec
|
||||
def pathfind_inner(
|
||||
frontier: List[_FrontierNode[S]],
|
||||
visited: Set[S]
|
||||
) -> Return[Option[Tuple[Tuple[S, ...], int]]] | Recur[[List[_FrontierNode[S]], Set[S]]]:
|
||||
frontier, visited = ([_FrontierNode(0, 0, start_state, tuple())], set())
|
||||
while True:
|
||||
# Don't look at this in mypy
|
||||
# The types check out but mypy is REALLY bad at unifying types
|
||||
match try_(ident, heappop, frontier):
|
||||
case Err(_):
|
||||
return Return(None)
|
||||
return None
|
||||
case Ok(current) if current.node in visited:
|
||||
return Recur(frontier, visited)
|
||||
pass #RECUR
|
||||
case Ok(current):
|
||||
new_path = (*current.path, current.node)
|
||||
if goal(current.node):
|
||||
return Return(Some((new_path, current.total_cost)))
|
||||
return Some((new_path, current.total_cost))
|
||||
else:
|
||||
visited.add(current.node)
|
||||
return Recur(
|
||||
|
||||
frontier, visited = ( #RECUR
|
||||
_heappush_all(
|
||||
frontier,
|
||||
[
|
||||
|
@ -111,7 +109,6 @@ def pathfind(
|
|||
),
|
||||
visited
|
||||
)
|
||||
return pathfind_inner([_FrontierNode(0, 0, start_state, tuple())], set())
|
||||
|
||||
@tco_rec
|
||||
def pathfind_multi(
|
||||
|
|
|
@ -530,21 +530,24 @@ def bind_opt(f: Callable[[A], Option[B]], o: Option[A]) -> Option[B]:
|
|||
return f(val)
|
||||
case none:
|
||||
return none
|
||||
def note(e: B, o: Option[A]) -> 'Result[A, B]':
|
||||
def note(e: Callable[[], B], o: Option[A]) -> 'Result[A, B]':
|
||||
"""
|
||||
Convert an `Option` to a `Result` by attaching an error to the `None` variants
|
||||
|
||||
>>> note('woops!', Some(1))
|
||||
`e` should be a zero-argument function which produces the desired error value. It
|
||||
will be called if and only if `o` is `None`.
|
||||
|
||||
>>> note(lambda: 'woops!', Some(1))
|
||||
Ok(1)
|
||||
|
||||
>>> note('woops!', None)
|
||||
>>> note(lambda: 'woops!', None)
|
||||
Err('woops!')
|
||||
"""
|
||||
match o:
|
||||
case Some(val):
|
||||
return Ok(val)
|
||||
case None:
|
||||
return Err(e)
|
||||
return Err(e())
|
||||
|
||||
def unwrap_opt(r: Option[A]) -> A:
|
||||
"""
|
||||
|
@ -729,6 +732,44 @@ def sequence(s: Sequence[Result[A, B]]) -> Result[Sequence[A], B]:
|
|||
assert isinstance(o, Err)
|
||||
return o
|
||||
|
||||
def trace(x: A) -> A:
|
||||
"""
|
||||
Print a value in passing
|
||||
|
||||
Equivalent to the identity function **except** for the fact that it prints the value
|
||||
to the screen before returning. The value is printed with the prefix "TRACE:" to make
|
||||
it easy to see what printed.
|
||||
|
||||
>>> trace(1 + 2) * 4
|
||||
TRACE: 3
|
||||
12
|
||||
"""
|
||||
print(f'TRACE:', x)
|
||||
return x
|
||||
|
||||
def profile(f: Callable[P, A]) -> Callable[P, A]:
|
||||
"""
|
||||
Wraps a function and check how long it takes to execute
|
||||
|
||||
Returns a function which is identical to the input, but when called, attempts to
|
||||
record how long it takes to execute the function, and prints that information to the
|
||||
screen.
|
||||
|
||||
>>> from time import sleep
|
||||
>>> profile(ident)(1) #doctest: +ELLIPSIS
|
||||
TIME OF ident(): ...ms
|
||||
1
|
||||
"""
|
||||
from time import perf_counter
|
||||
@wraps(f)
|
||||
def profiled(*args: P.args, **kwargs: P.kwargs) -> A:
|
||||
start_time = perf_counter()
|
||||
o = f(*args, **kwargs)
|
||||
stop_time = perf_counter()
|
||||
print(f'TIME OF {f.__name__}(): {1000 * (stop_time - start_time):.2f}ms')
|
||||
return o
|
||||
return profiled
|
||||
|
||||
if __name__ == '__main__':
|
||||
import doctest
|
||||
doctest.testmod()
|
13
main.py
13
main.py
|
@ -4,6 +4,7 @@ from emis_funky_funktions import *
|
|||
|
||||
from sys import argv
|
||||
from operator import eq
|
||||
from time import perf_counter_ns
|
||||
|
||||
from a_star import pathfind_multi
|
||||
from read_in import load_points, load_world_from_paths
|
||||
|
@ -11,18 +12,24 @@ from world import Point, World
|
|||
from write_out import save_map
|
||||
|
||||
def main(terrain_path: str, elevation_path: str, checkpoints_path: str, image_output: str):
|
||||
start_time = perf_counter_ns()
|
||||
world = unwrap_r(load_world_from_paths(terrain_path, elevation_path))
|
||||
checkpoints = unwrap_r(load_points(checkpoints_path))
|
||||
print('All files loaded, begining search...')
|
||||
print('All files loaded, begining search...', end = ' ')
|
||||
loaded_time = perf_counter_ns()
|
||||
maybe_path = pathfind_multi(
|
||||
world.neighbors,
|
||||
world.heuristic,
|
||||
checkpoints
|
||||
)
|
||||
finish_time = perf_counter_ns()
|
||||
path, cost = unwrap_opt(maybe_path)
|
||||
print(f'Path found!\n\nEstimated time: {cost//60_000_000} minutes')
|
||||
print(f'Path found!\n')
|
||||
print(f'Loading resources completed in {(loaded_time - start_time) // 1_000_000}ms')
|
||||
print(f'Search completed in {(finish_time - loaded_time) // 1_000_000}ms\n')
|
||||
print(f'Estimated travel time: {cost//60_000_000} minutes')
|
||||
path_length = unwrap_r(world.calculate_path_length(path))
|
||||
print(f'Estimated length: {path_length/1_000_000:.1f} kilometers')
|
||||
print(f'Total path length: {path_length/1_000_000:.1f} kilometers')
|
||||
save_map(image_output, world, path)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
64
read_in.py
64
read_in.py
|
@ -36,10 +36,11 @@ def read_single_elevation(dat: str) -> Result[int, str]:
|
|||
>>> read_single_elevation("10.0m")
|
||||
Err('10.0m')
|
||||
"""
|
||||
return map_res(
|
||||
meters_to_millimeters, # fuck floating points
|
||||
try_(replace(dat), float, dat)
|
||||
)
|
||||
try:
|
||||
# fuck floating points
|
||||
return Ok(meters_to_millimeters(float(dat)))
|
||||
except ValueError:
|
||||
return Err(dat)
|
||||
|
||||
def read_elevation_line(line: str) -> Result[Sequence[int], Tuple[int, str]]:
|
||||
"""
|
||||
|
@ -123,41 +124,21 @@ def read_elevations(lines: Iterable[str]) -> Result[Iterator[int], UnparsableEle
|
|||
])
|
||||
)
|
||||
|
||||
def gb_to_terrain_type(green_blue: Tuple[int, int]) -> Option[Terrain]:
|
||||
"""
|
||||
Maps the green and blue components of a color to a terrain type.
|
||||
|
||||
If no terrain type exists with that color, then `None` is returned.
|
||||
|
||||
>>> gb_to_terrain_type((192, 0))
|
||||
Some(<Terrain.ROUGH_MEADOW: 700>)
|
||||
|
||||
>>> gb_to_terrain_type((192, 1)) is None
|
||||
True
|
||||
"""
|
||||
match green_blue:
|
||||
case (148, 18):
|
||||
return Some(Terrain.OPEN_LAND)
|
||||
case (192, 0):
|
||||
return Some(Terrain.ROUGH_MEADOW)
|
||||
case (255, 255):
|
||||
return Some(Terrain.EASY_FOREST)
|
||||
case (208, 60):
|
||||
return Some(Terrain.MEDIUM_FOREST)
|
||||
case (136, 40):
|
||||
return Some(Terrain.WALK_FOREST)
|
||||
case (73, 24):
|
||||
return Some(Terrain.BRUSH)
|
||||
case (0, 255):
|
||||
return Some(Terrain.WET)
|
||||
case (51, 3):
|
||||
return Some(Terrain.ROAD)
|
||||
case (0, 0):
|
||||
return Some(Terrain.FOOTPATH)
|
||||
case (0, 101):
|
||||
return Some(Terrain.OOB)
|
||||
case _:
|
||||
return None
|
||||
gb_to_terrain_type = {
|
||||
(148, 18): Terrain.OPEN_LAND,
|
||||
(192, 0): Terrain.ROUGH_MEADOW,
|
||||
(255, 255): Terrain.EASY_FOREST,
|
||||
(208, 60): Terrain.MEDIUM_FOREST,
|
||||
(136, 40): Terrain.WALK_FOREST,
|
||||
(73, 24): Terrain.BRUSH,
|
||||
(0, 255): Terrain.WET,
|
||||
(51, 3): Terrain.ROAD,
|
||||
(0, 0): Terrain.FOOTPATH,
|
||||
(0, 101): Terrain.OOB
|
||||
}
|
||||
"""
|
||||
Maps the green and blue components of a color to a terrain type.
|
||||
"""
|
||||
|
||||
class UnrecognizedColor(NamedTuple):
|
||||
"A color in a provided map was not in the list of recognized colors"
|
||||
|
@ -185,10 +166,7 @@ def load_gb_pixels_as_map(pixels: Iterable[Tuple[int, int]]) -> Result[Sequence[
|
|||
coordinates of the pixel which could not be identified.
|
||||
"""
|
||||
return sequence([
|
||||
note(
|
||||
UnrecognizedColor(pix_no % 395, pix_no // 395),
|
||||
gb_to_terrain_type(gb)
|
||||
)
|
||||
try_(lambda _: UnrecognizedColor(pix_no % 395, pix_no // 395), gb_to_terrain_type.get, gb)
|
||||
for (pix_no, gb) in enumerate(pixels)
|
||||
])
|
||||
|
||||
|
|
79
world.py
79
world.py
|
@ -83,6 +83,13 @@ class World:
|
|||
_diag: int
|
||||
"Real world distance represented by a diagonal movement, in millimeters"
|
||||
|
||||
_lon_scale2: int
|
||||
"Longitudinal scale squared"
|
||||
_lat_scale2: int
|
||||
"Latitudinal scale squared"
|
||||
_diag2: int
|
||||
"Diagonal scale squared"
|
||||
|
||||
def __init__(self,
|
||||
tiles: Sequence[Tuple[Terrain, int]],
|
||||
width: int = 395,
|
||||
|
@ -93,7 +100,11 @@ class World:
|
|||
self.width = width
|
||||
self.lon_scale = lon_scale
|
||||
self.lat_scale = lat_scale
|
||||
self._diag = isqrt(lon_scale * lon_scale + lat_scale * lat_scale)
|
||||
|
||||
self._lon_scale2 = lon_scale * lon_scale
|
||||
self._lat_scale2 = lat_scale * lat_scale
|
||||
self._diag2 = self._lon_scale2 + self._lat_scale2
|
||||
self._diag = isqrt(self._diag2)
|
||||
|
||||
def _adjacency(self, p: Point) -> List[Tuple[Point, int]]:
|
||||
"""
|
||||
|
@ -101,38 +112,38 @@ class World:
|
|||
|
||||
In addition to each point, a number is returned representing the distance as the
|
||||
crow flies between the center of the original point and the center of the adjacent
|
||||
point, in millimeters.
|
||||
point, in millimeters **AND SQUARED**.
|
||||
|
||||
This does not take into account the presence, value, or elevation of tiles
|
||||
represented on these points.
|
||||
|
||||
>>> world = World([], lon_scale=10_290, lat_scale=7_550)
|
||||
>>> world = World([], lon_scale=3, lat_scale=4)
|
||||
>>> world._adjacency(Point(13, 12)) #doctest: +NORMALIZE_WHITESPACE
|
||||
[((12, 11), 12762),
|
||||
((13, 11), 7550),
|
||||
((14, 11), 12762),
|
||||
((12, 12), 10290),
|
||||
((14, 12), 10290),
|
||||
((12, 13), 12762),
|
||||
((13, 13), 7550),
|
||||
((14, 13), 12762)]
|
||||
[((12, 11), 25),
|
||||
((13, 11), 16),
|
||||
((14, 11), 25),
|
||||
((12, 12), 9),
|
||||
((14, 12), 9),
|
||||
((12, 13), 25),
|
||||
((13, 13), 16),
|
||||
((14, 13), 25)]
|
||||
"""
|
||||
return [
|
||||
(Point(p.x - 1, p.y - 1), self._diag ),
|
||||
(Point(p.x , p.y - 1), self.lat_scale),
|
||||
(Point(p.x + 1, p.y - 1), self._diag ),
|
||||
(Point(p.x - 1, p.y ), self.lon_scale),
|
||||
(Point(p.x + 1, p.y ), self.lon_scale),
|
||||
(Point(p.x - 1, p.y + 1), self._diag ),
|
||||
(Point(p.x , p.y + 1), self.lat_scale),
|
||||
(Point(p.x + 1, p.y + 1), self._diag )
|
||||
(Point(p.x - 1, p.y - 1), self._diag2 ),
|
||||
(Point(p.x , p.y - 1), self._lat_scale2),
|
||||
(Point(p.x + 1, p.y - 1), self._diag2 ),
|
||||
(Point(p.x - 1, p.y ), self._lon_scale2),
|
||||
(Point(p.x + 1, p.y ), self._lon_scale2),
|
||||
(Point(p.x - 1, p.y + 1), self._diag2 ),
|
||||
(Point(p.x , p.y + 1), self._lat_scale2),
|
||||
(Point(p.x + 1, p.y + 1), self._diag2 )
|
||||
]
|
||||
|
||||
def __getitem__(self, loc: Point) -> Tuple[Terrain, int]:
|
||||
"""
|
||||
Look up terrain cost and elevation by point
|
||||
"""
|
||||
return self.tiles[loc.to_linear(self.width)]
|
||||
return self.tiles[loc.x + self.width * loc.y]
|
||||
|
||||
def elevation_difference(self, p1: Point, p2: Point):
|
||||
"""
|
||||
|
@ -144,7 +155,7 @@ class World:
|
|||
>>> world.elevation_difference(Point(0, 0), Point(1, 0))
|
||||
100
|
||||
"""
|
||||
return abs(self[p1][1] - self[p2][1])
|
||||
return self[p2][1] - self[p1][1]
|
||||
|
||||
def neighbors(self, loc: Point) -> List[Tuple[Point, int]]:
|
||||
"""
|
||||
|
@ -228,34 +239,36 @@ class World:
|
|||
|
||||
And there it is! The travel time returned by the `.neighbors` function!
|
||||
"""
|
||||
loc_terrain, loc_elevation = self[loc]
|
||||
return [
|
||||
(
|
||||
adj_point,
|
||||
(
|
||||
# 2 * Movement speed (seconds / km = microseconds / millimeter)
|
||||
(
|
||||
self[loc][0] +
|
||||
self[adj_point][0]
|
||||
loc_terrain +
|
||||
adj_terrain
|
||||
)
|
||||
|
||||
# * Distance travelled (millimeters) = 2 * Time cost (microseconds)
|
||||
* isqrt(
|
||||
# Crow Distance Squared
|
||||
(crow_distance * crow_distance)
|
||||
crow_distance2
|
||||
# + Elevation Change Squared = 3D distance squared
|
||||
+ self.elevation_difference(loc, adj_point) ** 2
|
||||
+ (loc_elevation - adj_elevation) ** 2
|
||||
)
|
||||
|
||||
# / 2 = Time cost (microseconds)
|
||||
// 2
|
||||
)
|
||||
)
|
||||
for (adj_point, crow_distance) in self._adjacency(loc)
|
||||
for (adj_point, crow_distance2) in self._adjacency(loc)
|
||||
for (adj_terrain, adj_elevation) in (self[adj_point],)
|
||||
if adj_point.x >= 0
|
||||
and adj_point.y >= 0
|
||||
and adj_point.x < self.width
|
||||
and adj_point.y < len(self.tiles) // self.width
|
||||
and self[adj_point][0] != Terrain.OOB
|
||||
and adj_terrain < 4294967296
|
||||
]
|
||||
|
||||
def heuristic(self, a: Point, b: Point) -> int:
|
||||
|
@ -288,9 +301,15 @@ class World:
|
|||
lat_tiles_raw = abs(a.y - b.y)
|
||||
|
||||
# The number of moves necessary, allowing diagonal moves
|
||||
lon_moves_real = max(0, lon_tiles_raw - lat_tiles_raw)
|
||||
lat_moves_real = max(0, lat_tiles_raw - lon_tiles_raw)
|
||||
diag_moves_real = min(lat_tiles_raw, lon_tiles_raw)
|
||||
vh_moves = lon_tiles_raw - lat_tiles_raw
|
||||
if vh_moves > 0:
|
||||
lon_moves_real = vh_moves
|
||||
lat_moves_real = 0
|
||||
diag_moves_real = lat_tiles_raw
|
||||
else:
|
||||
lat_moves_real = -vh_moves
|
||||
lon_moves_real = 0
|
||||
diag_moves_real = lon_tiles_raw
|
||||
|
||||
# Total distance necessary, not counting elevation
|
||||
total_flat_distance = (
|
||||
|
|
Loading…
Reference in New Issue