Compare commits

...

10 Commits

6 changed files with 223 additions and 90 deletions

91
Writeup.tex Normal file
View File

@ -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}

View File

@ -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(

View File

@ -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
View File

@ -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__':

View File

@ -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)
])

View File

@ -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 = (