ai-lab-one/world.py

397 lines
13 KiB
Python

from emis_funky_funktions import *
from enum import Enum, unique
from math import isqrt
from typing import List, NamedTuple, Tuple
@unique
class Terrain(int, Enum):
"""
Various types of terrain understandable by the routing algorithm.
Each element of the terrain is associated with an integer value indictating the cost
that that terrain has on movement speed. Units are (approximately) measured in
seconds / kilometer.
For reference, a typical jogging speed is roughly 500 s/km, and a brisk walking
speed is about 700 s/km.
"""
OPEN_LAND = 510
ROUGH_MEADOW = 700
EASY_FOREST = 530
MEDIUM_FOREST = 666
WALK_FOREST = 800
BRUSH = 24000
WET = 6000
ROAD = 500
FOOTPATH = 505
OOB = 2 ** 62
class Point(NamedTuple):
x: int
y: int
def to_linear(self, array_width: int) -> int:
"""
Converts a point to an index in a linear 2D array
In all cases, this is equivalent to `point.x + array_width * point.y`
Arguments:
array_width: The width of the array being indexed
>>> my_array = [
... 0, 1, 2,
... 3, 4, 5,
... 6, 7, 8
... ]
>>> my_array[Point(1, 1).to_linear(3)]
4
"""
return self.x + array_width * self.y
def __repr__(self):
return f'({self.x}, {self.y})'
@dataclass(frozen=True)
class OobError:
"""
The distance cannot be computed because one point is out of bounds
This means off the map, not the OOB terrain type.
"""
p: Point
@dataclass
class World:
tiles: Sequence[Tuple[Terrain, int]]
"""
The tiles that make up the world
Each tile should be a tuple containing the terrain type at that location, along with
the elevation in millimeters at that point.
"""
width: int
"The number of pixels wide the map is"
lon_scale: int
"Real world distance represented by each longitudinal 'pixel', in millimeters"
lat_scale: int
"Real world distance represented by each latitudinal 'pixel', in millimeters"
_diag: int
"Real world distance represented by a diagonal movement, in millimeters"
def __init__(self,
tiles: Sequence[Tuple[Terrain, int]],
width: int = 395,
lon_scale: int = 10_290,
lat_scale: int = 7_550
):
self.tiles = tiles
self.width = width
self.lon_scale = lon_scale
self.lat_scale = lat_scale
self._diag = isqrt(lon_scale * lon_scale + lat_scale * lat_scale)
def _adjacency(self, p: Point) -> List[Tuple[Point, int]]:
"""
Return a series of adjacent points, in any of the eight compass directions
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.
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._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)]
"""
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 )
]
def __getitem__(self, loc: Point) -> Tuple[Terrain, int]:
"""
Look up terrain cost and elevation by point
"""
return self.tiles[loc.to_linear(self.width)]
def elevation_difference(self, p1: Point, p2: Point):
"""
Compute the elevation difference between two points
Points do not need to be adjacent!
>>> world = World([(Terrain.BRUSH, 1_000), (Terrain.BRUSH, 1_100)])
>>> world.elevation_difference(Point(0, 0), Point(1, 0))
100
"""
return abs(self[p1][1] - self[p2][1])
def neighbors(self, loc: Point) -> List[Tuple[Point, int]]:
"""
Produce a list of valid neighbors surrounding a given point.
Neighbors in any of the eight cardinal directions will be considered, but only
neighbors which are in bounds and correspond to a point on the map will be
returned.
In addition to each neighboring point, a time cost associated with moving from the
center of this point to the center of the neighbor is included. This time cost is
measured in microseconds, and factors in three-dimensional distance as well as the
movement speed allowed by the terrain.
Example:
In this example, we construct a simple world of only four tiles, and attempt
to list the neighbors of the upper left tile. Three other tiles can be
considered. Of these, the upper right tile is out of bounds terrain, leaving
only the bottom two tiles.
Each of these tiles is returned. Notice, however, that the cost of moving to
the bottom right tile is slightly larger than one might expect. This is
because diagonal travel is more expensive than latitudinal or longitudinal
travel.
>>> world = World(
... [ (Terrain.OPEN_LAND, 1_000), (Terrain.OOB, 950)
... , (Terrain.ROUGH_MEADOW, 1_080), (Terrain.EASY_FOREST, 1_010)
... ],
... width = 2, # Our simple world is only two tiles wide
... lon_scale = 500, # Pick an easy number for example
... lat_scale = 400 # and remember that these are in millimeters!
... )
>>> world.neighbors(Point(0, 0))
[((0, 1), 246235), ((1, 1), 332800)]
Let's look at how the time cost for the point at (1, 1) is computed.
First, we find the distance as the crow flies between the center of the two
points. This is equal to the square root of the latitudinal distance squared
plus the longitudinal distance squared.
>>> isqrt(500 ** 2 + 400 ** 2)
640
So now we know that the distance between the centers of these two points is
640 millimeters as the crow flies.
Next, we factor in elevation. The elevation difference between (0, 0) and
(1, 1) is just `10` millimeters. This should make an inconsiquential
difference, but we compute it nonetheless.
>>> isqrt(10 ** 2 + 640 ** 2)
640
Sure enough, the three-dimensional distance between these two points is still
640 millimeters.
Now, we need to know how difficult it will be to move over the terrain. Half
of the time, we expect to be moving over `OPEN_LAND`, which has a movement
speed of 510 microseconds / millimeter. Once we cross the border between the
two tiles at the half-way point, we'll be travelling through `EASY_FOREST`,
which has a movement speed of 530 microseconds / millimeter, just slightly
slower.
Since we'll our trip is perfectly balanced between these two terrain types, we
can compute the average movement speed of the trip to be the average of these
two values.
>>> (510 + 530) // 2
520
So now we know we'll be travelling at an average of 520 microseconds /
millimeter.
Now that we know both the distance we need to travel and the speed we'll be
travelling at, we can multiply them together to get our actual travel time.
>>> 640 * 520
332800
And there it is! The travel time returned by the `.neighbors` function!
"""
return [
(
adj_point,
(
# 2 * Movement speed (seconds / km = microseconds / millimeter)
(
self[loc][0] +
self[adj_point][0]
)
# * Distance travelled (millimeters) = 2 * Time cost (microseconds)
* isqrt(
# Crow Distance Squared
(crow_distance * crow_distance)
# + Elevation Change Squared = 3D distance squared
+ self.elevation_difference(loc, adj_point) ** 2
)
# / 2 = Time cost (microseconds)
// 2
)
)
for (adj_point, crow_distance) in self._adjacency(loc)
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
]
def heuristic(self, a: Point, b: Point) -> int:
"""
Estimate the time it will take to travel between two points
The following assumptions are made to speed up the process, at the cost of
accuracy:
- All tiles have the exact same movement speed (500 milliseconds / meter)
- All tiles are in-bounds
- All tiles are at the same elevation
This does NOT assume that we can travel at angles other than multiples of
45 degrees, however, as this would not be beneficial to the computation nor the
accuracy.
Because this algorithm ignores both terrain types and elevations, it does not
access world data at all, and the results of the computation depend exclusively on
the start point, the finish point, and the longitudinal/latitudinal scales.
>>> world = World([], lon_scale = 500, lat_scale = 400)
>>> world.heuristic(Point(0, 0), Point(3, 5))
1360000
For a slower algorithm that also includes elevation, see `calculate_distance()`.
"""
# Taxicab distance in each direction
lon_tiles_raw = abs(a.x - b.x)
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)
# Total distance necessary, not counting elevation
total_flat_distance = (
lon_moves_real * self.lon_scale +
lat_moves_real * self.lat_scale +
diag_moves_real * self._diag
)
# TODO: Test whether adding in elevation is beneficial
estimated_speed = 500 # milliseconds / meter = microseconds / millimeters
return estimated_speed * total_flat_distance
def calculate_distance(self, a: Point, b:Point) -> Result[int, OobError]:
"""
Calculate the distance between the centers of two tiles, incl elevation difference
Looks up the elevation at both points **a** and **b**, converting them into points
in 3D space, then computes the integer distance between
For a faster algorithm that does not include elevation, see `heuristic()`.
>>> world = World( # We instantiate a simple, small world
... [ (Terrain.OPEN_LAND, 1_000), (Terrain.BRUSH, 850), (Terrain.WET, 500)
... , (Terrain.ROUGH_MEADOW, 950), (Terrain.EASY_FOREST, 750), (Terrain.WET, 500)
... ],
... width = 3, # Our simple world is only two tiles wide
... lon_scale = 600, # Pick an easy number for example
... lat_scale = 500
... )
>>> world.calculate_distance(Point(0, 0), Point(2, 1))
Ok(1392)
>>> world.calculate_distance(Point(0, 0), Point(2, 2))
Err(OobError(p=(2, 2)))
Notice that this is distance as-the-crow-flies between two points in 3D space. If
you're looking for actual distance, you must either use two adjacent points, or
first pathfind between those two points and calculate the distance that way.
"""
lon_dist = abs(a.x - b.x) * self.lon_scale
lat_dist = abs(a.y - b.y) * self.lat_scale
try:
a_elev = self[a][1]
except IndexError:
return Err(OobError(a))
try:
b_elev = self[b][1]
except IndexError:
return Err(OobError(b))
elev_dist = abs(a_elev - b_elev)
return Ok(isqrt(lon_dist * lon_dist + lat_dist * lat_dist + elev_dist * elev_dist))
@tco_rec
def calculate_path_length(self, points: Sequence[Point], addend: int = 0) -> Result[int, OobError]:
"""
Calculate the length of a path to the greatest degree of accuracy possible
Points are expected to be a sequence of at least two adjacent points. The
returned distance will be the distance travelling between the centers of the tiles
of each tile in sequence, including elevation changes.
Asequential points will not result in an error, but will result in a drop in
accuracy. Diagonal moves to adjacent tiles, however, are valid and allowed.
Any points which fall outside the bounds of the map will result in an `OobError`
indicating the first point which was out of bounds.
On a successful run, the returned units will be in the units the world was
instantiated with, typically millimeters.
>>> world = World( # We instantiate a simple, small world
... [ (Terrain.OPEN_LAND, 1_000), (Terrain.BRUSH, 850), (Terrain.WET, 500)
... , (Terrain.ROUGH_MEADOW, 950), (Terrain.EASY_FOREST, 750), (Terrain.WET, 500)
... ],
... width = 3, # Our simple world is only two tiles wide
... lon_scale = 600, # Pick an easy number for example
... lat_scale = 500
... )
>>> world.calculate_path_length([Point(0,0), Point(1, 0), Point(2,1)])
Ok(1473)
Calling this method with only two points is equivalent to calling
`calculate_distance()`.
>>> world.calculate_path_length([Point(0, 0), Point(2, 1)])
Ok(1392)
>>> world.calculate_distance(Point(0, 0), Point(2, 1))
Ok(1392)
"""
match points:
case [a, b, *rest]:
match self.calculate_distance(a, b):
case Ok(this_dist):
return Recur(self, (b, *rest), addend + this_dist)
case err:
return Return(Ok(err))
case _: # single or empty
return Return(Ok(addend))
if __name__ == '__main__':
import doctest
doctest.testmod()