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