ai-lab-one/read_in.py

270 lines
8.5 KiB
Python

from emis_funky_funktions import *
from world import Point, Terrain, World
from PIL import Image
from itertools import chain
from typing import List, NamedTuple, Tuple
def meters_to_millimeters(meters: float) -> int:
"""
Convert meters (floating point) to millimeters (int)
>>> meters_to_millimeters(1.9333473e+02)
193334
>>> meters_to_millimeters(1.0)
1000
"""
return int(1000 * meters)
def read_single_elevation(dat: str) -> Result[int, str]:
"""
Read in a single elevation
Expects a single, stringified floating point value, in meters, without a unit, e.g.
"2.057e+02". If there is a parsing error, returns an error containing the input
string. Otherwise, returns the elevation in millimeters.
>>> read_single_elevation("1.9333473e+02")
Ok(193334)
>>> read_single_elevation("1.0")
Ok(1000)
>>> read_single_elevation("10.0m")
Err('10.0m')
"""
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]]:
"""
Reads a line of elevations
Each elevation should be in the format specified by `read_single_elevation`. Every
element of the line is parsed and combined into an iterator, which is returned. If
any parsing errors are encountered, the first error is returned. It takes the form of
a tuple containing the column of the error (in terms of whitespace-deliniated words,
not characters) and the string which could not be parsed.
Recall from the definition of `read_single_elevation()` that the return value is an
integer number of millimeters.
As per the problem specification, the last five columns of the line are not read or
parsed. Parsing errors in these columns therefor cannot be encountered, and any data
in these columns will not be returned.
>>> map_res(list, read_elevation_line("1.9e2 18e1 181 179.1 ignored 170.1 1.8e2 18i+2 20e1"))
Ok([190000, 180000, 181000, 179100])
>>> map_res(list, read_elevation_line("1.9e2 18e 181 179.1 ignored 170.1 1.8e2 18i+2 20e1"))
Err((2, '18e'))
"""
return sequence([
map_err(
lambda err_text: (col_no + 1, err_text),
read_single_elevation(elem)
)
# As per spec, we drop the last five entries of the file.
for (col_no, elem) in enumerate(line.split()[:-5])
])
class UnparsableElevation(NamedTuple):
"An elevation entry failed to be parsed as a floating point"
line_no: int
"The line on which the error occurred"
col_no: int
"The column (1-indexed number of whitespace deliniated words) on which the error occurred"
invalid_entry: str
"The text which failed to parse as a floating point value"
def read_elevations(lines: Iterable[str]) -> Result[Iterator[int], UnparsableElevation]:
"""
Read an entire elevation file into a list
This parses each line in of the file using `read_elevation_line()`. Errors
encountered are reported using an `UnparsableElevation`. The resulting list is
single-dimensional, but can be indexed arbitrarily, meaning for an input file with a
constant number of columns per line, this data can be accessed as if it were a 2d
square array.
However, keep in mind that, as with `read_elevation_line()`, the last five columns of
each line are dropped/ignored, and are therefor not present in the returned data.
>>> map_res(list, read_elevations('''
... 1 2 3 4 5 6 7 8 9e 10
... 11 2.0 3e1 4 5 6 7 bwa 9 10
... 11 2.0 3e0 4 5 ign err 8 9 10
... '''.strip().split('\\n')))
Ok([1000, 2000, 3000, 4000, 5000, 11000, 2000, 30000, 4000, 5000, 11000, 2000, 3000, 4000, 5000])
>>> map_res(list, read_elevations('''
... 1 2 3 4 5 6 7 8 9e 10
... 11 ERR 3e1 4 5 6 7 bwa 9 10
... 11 2.0 3e0 4 5 ign err 8 9 10
... '''.strip().split('\\n')))
Err(UnparsableElevation(line_no=2, col_no=2, invalid_entry='ERR'))
"""
return map_res(
chain.from_iterable,
sequence([
map_err(
lambda err: UnparsableElevation(line_no + 1, *err),
read_elevation_line(line)
)
for (line_no, line) in enumerate(lines)
])
)
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"
x: int
"The x coordinate of the unidentifided color"
y: int
"The y coordinate of the unidentifided color"
def load_gb_pixels_as_map(pixels: Iterable[Tuple[int, int]]) -> Result[Sequence[Terrain], UnrecognizedColor]:
"""
Interpret the green and blue bands of an image as pixels on a map
This assumes that the map follows the specification laid out at
https://cs.rit.edu/~jro/courses/intelSys/labs/orienteering/
Namely, the map should be exactly 395 pixels wide, and should follow the color
conventions laid out in the Terrain Type table.
This function is only interested in the green and blue channels of the image. Rather
than pass a full pixel, it is only necessary to pass the green and blue color values.
If an error is encountered, the returned `Err` type will contain the x and y
coordinates of the pixel which could not be identified.
"""
return sequence([
try_(lambda _: UnrecognizedColor(pix_no % 395, pix_no // 395), gb_to_terrain_type.get, gb)
for (pix_no, gb) in enumerate(pixels)
])
def load_image(path: str) -> Result[Sequence[Terrain], UnrecognizedColor]:
"Run `load_gb_pixels_as_map` on an image read from a path"
with Image.open(path) as im:
return load_gb_pixels_as_map(zip(im.getdata(1), im.getdata(2)))
def load_elevations(path: str) -> Result[Iterator[int], UnparsableElevation]:
"Run `read_elevations` on a text file read from a path"
with open(path) as file:
return read_elevations(file)
def load_world_from_paths(map_path: str, elevation_path: str) -> Result[World, UnparsableElevation | UnrecognizedColor]:
"""
Read world information (terrain and elevation) from file paths.
See `read_elevations` and `load_gb_pixels_as_map` for more information on the format
that this data should be in. Note that for this method, you do not need to worry
about the channels present in the terrain map, just that it align with the colors and
size of the specified format.
"""
return bind_res(
lambda terrain_data:
map_res(
lambda elevation_data: World((*zip(terrain_data, elevation_data),)),
load_elevations(elevation_path)
),
load_image(map_path)
)
def parse_point(s: str) -> Result[Point, str]:
"""
Parses a point from a string
The point is expected to be a string containing two integer values seperated by
whitespace.
Any errors in parsing the string will result in an `Err` being returned, containing
the original string.
>>> parse_point('230 327')
Ok((230, 327))
>>> parse_point('230327')
Err('230327')
>>> parse_point('23O 327')
Err('23O 327')
"""
match s.strip().split():
case [p1, p2]:
try:
return Ok(Point(int(p1), int(p2)))
except ValueError:
return Err(s)
case _:
return Err(s)
def parse_points(lines: Iterable[str]) -> Result[Sequence[Point], str]:
"""
Parse a series of points
Each point should be in the format specified by `parse_point()`, and each should be on
it's own "line" (aka, item in an iterable). Blank lines are ignored and dropped. If
any one of the lines in the input string fail to parse, the text of the first failed
line will be returned in an `Err`.
>>> parse_points('''
... 230 327
... 276 279
...
... 303 240
... '''.split('\\n'))
Ok([(230, 327), (276, 279), (303, 240)])
>>> parse_points('''
... 230 327
... 276 2BAD
... 3BAD 240
... '''.split('\\n'))
Err('276 2BAD')
"""
return sequence([
parse_point(line)
for line in lines
if len(line) and not line.isspace()
])
def load_points(path: str) -> Result[Sequence[Point], str]:
"""
Load a file full of points by passing the lines to `parse_points()`
See `parse_points()` for format specifications
"""
with open(path) as f:
return parse_points(f)
if __name__ == '__main__':
import doctest
doctest.testmod()