From 7ca7c3cb79ba04c49af2741bfae33cf3f581fe8a Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 20:39:21 +0000 Subject: [PATCH 01/13] test --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efd8e5c..a32fcb7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This is a Python package for decoding OpenLR™ location references on target ma It implements [Chapter G – Decoder](https://www.openlr-association.com/fileadmin/user_upload/openlr-whitepaper_v1.5.pdf#page=97) in the OpenLR whitepaper, except "Step 1 – decode physical data". -Its purpose is to give insights into the map-matching process. +Its purpose is to give insights into the map-matching process. Test! ## Dependencies - Python ≥ 3.6 From c44d00613f18c925b9f7b6aeee78351ce5adada1 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 20:44:53 +0000 Subject: [PATCH 02/13] undo test --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a32fcb7..efd8e5c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This is a Python package for decoding OpenLR™ location references on target ma It implements [Chapter G – Decoder](https://www.openlr-association.com/fileadmin/user_upload/openlr-whitepaper_v1.5.pdf#page=97) in the OpenLR whitepaper, except "Step 1 – decode physical data". -Its purpose is to give insights into the map-matching process. Test! +Its purpose is to give insights into the map-matching process. ## Dependencies - Python ≥ 3.6 From 5d560091458701d3d5f54f9320ab512d724425d3 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 20:59:00 +0000 Subject: [PATCH 03/13] changes to decoding --- openlr_dereferencer/decoding/__init__.py | 17 ++-- .../decoding/candidate_functions.py | 95 +++++++++++-------- openlr_dereferencer/decoding/configuration.py | 40 ++++---- openlr_dereferencer/decoding/error.py | 5 +- openlr_dereferencer/decoding/line_decoding.py | 13 +-- openlr_dereferencer/decoding/line_location.py | 13 +-- openlr_dereferencer/decoding/path_math.py | 40 ++++---- .../decoding/point_locations.py | 37 +++++--- openlr_dereferencer/decoding/routes.py | 34 ++++--- 9 files changed, 172 insertions(+), 122 deletions(-) diff --git a/openlr_dereferencer/decoding/__init__.py b/openlr_dereferencer/decoding/__init__.py index f6578bf..e985806 100644 --- a/openlr_dereferencer/decoding/__init__.py +++ b/openlr_dereferencer/decoding/__init__.py @@ -22,20 +22,19 @@ ) from .configuration import Config, DEFAULT_CONFIG, load_config, save_config -LR = TypeVar("LocationReference", - LineLocationReference, - PointAlongLineLocationReference, - PoiWithAccessPointLocationReference, - GeoCoordinateLocationReference) +LR = TypeVar( + "LocationReference", + LineLocationReference, + PointAlongLineLocationReference, + PoiWithAccessPointLocationReference, + GeoCoordinateLocationReference, +) MapObjects = TypeVar("MapObjects", LineLocation, Coordinates, PointAlongLine) def decode( - reference: LR, - reader: MapReader, - observer: Optional[DecoderObserver] = None, - config: Config = DEFAULT_CONFIG + reference: LR, reader: MapReader, observer: Optional[DecoderObserver] = None, config: Config = DEFAULT_CONFIG ) -> MapObjects: """Translates an openLocationReference into a real location on your map. diff --git a/openlr_dereferencer/decoding/candidate_functions.py b/openlr_dereferencer/decoding/candidate_functions.py index eecd5ba..9c86f37 100644 --- a/openlr_dereferencer/decoding/candidate_functions.py +++ b/openlr_dereferencer/decoding/candidate_functions.py @@ -1,15 +1,16 @@ "Contains functions for candidate searching and map matching" - +import functools from itertools import product from logging import debug from typing import Optional, Iterable, List, Tuple +import time from openlr import FRC, LocationReferencePoint from ..maps import shortest_path, MapReader, Line, Node from ..maps.a_star import LRPathNotFoundError from ..observer import DecoderObserver from .candidate import Candidate from .scoring import score_lrp_candidate, angle_difference -from .error import LRDecodeError +from .error import LRDecodeError, LRTimeoutError from .path_math import coords, project, compute_bearing from .routes import Route from .configuration import Config @@ -23,8 +24,9 @@ def make_candidate( # we don't need to project on the point that is the degenerated line. if line.geometry.length == 0: return - point_on_line = project(line, coords(lrp)) + point_on_line = project(line, coords(lrp), config.equal_area) reloff = point_on_line.relative_offset + # In case the LRP is not the last LRP if not is_last_lrp: # Snap to the relevant end of the line, only if the node is not a simple connection node between two lines: @@ -52,47 +54,50 @@ def make_candidate( # Drop candidate if there is no partial line left if is_last_lrp and reloff <= 0.0 or not is_last_lrp and reloff >= 1.0: return - candidate = Candidate(line, reloff) - bearing = compute_bearing(lrp, candidate, is_last_lrp, config.bear_dist) + candidate = Candidate(line, reloff, config.equal_area) + bearing = compute_bearing(lrp, candidate, is_last_lrp, config.bear_dist, config.equal_area) bear_diff = angle_difference(bearing, lrp.bear) if abs(bear_diff) > config.max_bear_deviation: if observer is not None: observer.on_candidate_rejected( - lrp, candidate, + lrp, + candidate, f"Bearing difference = {bear_diff} greater than max. bearing deviation = {config.max_bear_deviation}", ) debug( - f"Not considering {candidate} because the bearing difference is {bear_diff} °.", - f"bear: {bearing}. lrp bear: {lrp.bear}", + f"Not considering {candidate} because the bearing difference is {bear_diff} °." + + f"bear: {bearing}. lrp bear: {lrp.bear}", ) return candidate.score = score_lrp_candidate(lrp, candidate, config, is_last_lrp) if candidate.score < config.min_score: if observer is not None: observer.on_candidate_rejected( - lrp, candidate, + lrp, + candidate, f"Candidate score = {candidate.score} lower than min. score = {config.min_score}", ) debug( - f"Not considering {candidate}", - f"Candidate score = {candidate.score} < min. score = {config.min_score}", + f"Not considering {candidate}" + f"Candidate score = {candidate.score} < min. score = {config.min_score}", ) return if observer is not None: observer.on_candidate_found( - lrp, candidate, + lrp, + candidate, ) return candidate def nominate_candidates( - lrp: LocationReferencePoint, reader: MapReader, config: Config, - observer: Optional[DecoderObserver], is_last_lrp: bool + lrp: LocationReferencePoint, + reader: MapReader, + config: Config, + observer: Optional[DecoderObserver], + is_last_lrp: bool, ) -> Iterable[Candidate]: "Yields candidate lines for the LRP along with their score." - debug( - f"Finding candidates for LRP {lrp} at {coords(lrp)} in radius {config.search_radius}" - ) + debug(f"Finding candidates for LRP {lrp} at {coords(lrp)} in radius {config.search_radius}") for line in reader.find_lines_close_to(coords(lrp), config.search_radius): candidate = make_candidate(lrp, line, config, observer, is_last_lrp) if candidate: @@ -100,7 +105,7 @@ def nominate_candidates( def get_candidate_route( - start: Candidate, dest: Candidate, lfrc: FRC, maxlen: float + start: Candidate, dest: Candidate, lfrc: FRC, maxlen: float, equal_area: bool ) -> Optional[Route]: """Returns the shortest path between two LRP candidates, excluding partial lines. @@ -126,18 +131,19 @@ def get_candidate_route( debug(f"Try to find path between {start, dest}") if start.line.line_id == dest.line.line_id: return Route(start, [], dest) - debug( - f"Finding path between nodes {start.line.end_node.node_id, dest.line.start_node.node_id}" - ) - linefilter = lambda line: line.frc <= lfrc + debug(f"Finding path between nodes {start.line.end_node.node_id, dest.line.start_node.node_id}") + + def linefilter(line, lfrc=lfrc): + return line.frc <= lfrc + try: path = shortest_path( - start.line.end_node, dest.line.start_node, linefilter, maxlen=maxlen + start.line.end_node, dest.line.start_node, linefilter, maxlen=maxlen, equal_area=equal_area ) debug(f"Returning {path}") return Route(start, path, dest) except LRPathNotFoundError: - debug(f"No path found between these nodes") + debug(f"No path found between these nodes: ({start.line.end_node.node_id}, {dest.line.start_node.node_id})") return None @@ -148,6 +154,7 @@ def match_tail( reader: MapReader, config: Config, observer: Optional[DecoderObserver], + start_time: Optional[float] = None, ) -> List[Route]: """Searches for the rest of the line location. @@ -169,6 +176,9 @@ def match_tail( The wanted behaviour, as configuration options observer: The optional decoder observer, which emits events and calls back. + elapsed_time: + Time in seconds since outer-most call to `match_tail()` was initiated. + Returns: If any candidate pair matches, the function calls itself for the rest of `tail` and @@ -177,8 +187,17 @@ def match_tail( Raises: LRDecodeError: If no candidate pair matches or a recursive call can not resolve a route. + LRTimeoutError: + If `elapsed_time` > `config.timeout` before a candidate pair is matched """ + if start_time is None: + start_time = time.time() + elapsed_time = time.time() - start_time + if elapsed_time > config.timeout: + raise LRTimeoutError("Decoding was unsuccessful: timed out trying to find a match.") + last_lrp = len(tail) == 1 + # The accepted distance to next point. This helps to save computations and filter bad paths minlen = (1 - config.max_dnp_deviation) * current.dnp - config.tolerated_dnp_dev maxlen = (1 + config.max_dnp_deviation) * current.dnp + config.tolerated_dnp_dev @@ -191,20 +210,17 @@ def match_tail( pairs = list(product(candidates, next_candidates)) # Sort by line scores pairs.sort(key=lambda pair: (pair[0].score + pair[1].score), reverse=True) - # For every pair of candidates, search for a path matching our requirements - for (c_from, c_to) in pairs: + for c_from, c_to in pairs: route = handleCandidatePair( - (current, next_lrp), (c_from, c_to), observer, lfrc, minlen, maxlen + (current, next_lrp), (c_from, c_to), observer, lfrc, minlen, maxlen, config.equal_area ) if route is None: continue if last_lrp: return [route] try: - return [route] + match_tail( - next_lrp, [c_to], tail[1:], reader, config, observer - ) + return [route] + match_tail(next_lrp, [c_to], tail[1:], reader, config, observer, start_time) except LRDecodeError: debug("Recursive call to resolve remaining path had no success") continue @@ -221,6 +237,7 @@ def handleCandidatePair( lowest_frc: FRC, minlen: float, maxlen: float, + equal_area: bool, ) -> Optional[Route]: """ Try to find an adequate route between two LRP candidates. @@ -245,7 +262,7 @@ def handleCandidatePair( """ current, next_lrp = lrps source, dest = candidates - route = get_candidate_route(source, dest, lowest_frc, maxlen) + route = get_candidate_route(source, dest, lowest_frc, maxlen, equal_area) if not route: debug("No path for candidate found") @@ -271,11 +288,13 @@ def handleCandidatePair( return route +@functools.lru_cache(maxsize=1000) def is_valid_node(node: Node): """ Checks if a node is a valid node. A valid node is a node that corresponds to a real-world junction """ - return not is_invalid_node(node) + v = not is_invalid_node(node) + return v def is_invalid_node(node: Node): @@ -284,21 +303,23 @@ def is_invalid_node(node: Node): """ # Get a list of all incoming lines to the node - incoming_lines = list(node.incoming_lines()) + incoming_line_nodes = list(node.incoming_line_nodes()) # Get a list of all outgoing lines from the node - outgoing_lines = list(node.outgoing_lines()) + outgoing_line_nodes = list(node.outgoing_line_nodes()) # Check the number of incoming and outgoing lines - if (len(incoming_lines) == 1 and len(outgoing_lines) == 1) or (len(incoming_lines) == 2 and len(outgoing_lines) == 2): + if (len(incoming_line_nodes) == 1 and len(outgoing_line_nodes) == 1) or ( + len(incoming_line_nodes) == 2 and len(outgoing_line_nodes) == 2 + ): # Get the unique nodes of all incoming and outgoing lines unique_nodes = set() - for line in incoming_lines: + for line in outgoing_line_nodes: unique_nodes.add(line.start_node) unique_nodes.add(line.end_node) - for line in outgoing_lines: + for line in outgoing_line_nodes: unique_nodes.add(line.start_node) unique_nodes.add(line.end_node) diff --git a/openlr_dereferencer/decoding/configuration.py b/openlr_dereferencer/decoding/configuration.py index bb17d18..36c2418 100644 --- a/openlr_dereferencer/decoding/configuration.py +++ b/openlr_dereferencer/decoding/configuration.py @@ -48,7 +48,7 @@ class Config(NamedTuple): min_score: float = 0.3 #: For every LFRCNP possibly present in an LRP, this defines #: what lowest FRC in a considered route is acceptable - tolerated_lfrc: Dict[FRC, FRC] = {frc: frc for frc in FRC} + tolerated_lfrc: Dict[FRC, FRC] = {frc: frc if frc < 4 else 6 for frc in FRC} #: Partial candidate line threshold, measured in meters #: #: To find candidates, the LRP coordinates are projected against any line in the local area. @@ -56,6 +56,7 @@ class Config(NamedTuple): #: beginning at the projection point is considered to be the candidate. Else, the candidate #: snaps onto the starting point (or ending point, when it is the last LRP) candidate_threshold: int = 20 + rel_candidate_threshold: float = 1.0 #: Defines a threshold for the bearing difference. Candidates differing too much from #: the LRP's bearing value are pre-filtered. max_bear_deviation: float = 45.0 @@ -74,6 +75,11 @@ class Config(NamedTuple): fow_standin_score: List[List[float]] = DEFAULT_FOW_STAND_IN_SCORE #: The bearing angle is computed along this distance on a given line. Given in meters. bear_dist: int = 20 + #: Input coordinates are provided in an equal-area projection (i.e. NOT WGS84 lat/lon) + equal_area: bool = False + equal_area_srid: int = 2163 + #: Timeout in seconds for single segment line decoding + timeout: int = 50000 DEFAULT_CONFIG = Config() @@ -101,22 +107,22 @@ def load_config(source: Union[str, TextIOBase, dict]) -> Config: raise TypeError("Surprising type") return Config._make( [ - opened_source['search_radius'], - opened_source['max_dnp_deviation'], - opened_source['tolerated_dnp_dev'], - opened_source['min_score'], - { - FRC(int(key)): FRC(value) - for (key, value) in opened_source['tolerated_lfrc'].items() - }, - opened_source['candidate_threshold'], - opened_source['max_bear_deviation'], - opened_source['fow_weight'], - opened_source['frc_weight'], - opened_source['geo_weight'], - opened_source['bear_weight'], - opened_source['fow_standin_score'], - opened_source['bear_dist'] + opened_source["search_radius"], + opened_source["max_dnp_deviation"], + opened_source["tolerated_dnp_dev"], + opened_source["min_score"], + {FRC(int(key)): FRC(value) for (key, value) in opened_source["tolerated_lfrc"].items()}, + opened_source["candidate_threshold"], + opened_source["rel_candidate_threshold"], + opened_source["max_bear_deviation"], + opened_source["fow_weight"], + opened_source["frc_weight"], + opened_source["geo_weight"], + opened_source["bear_weight"], + opened_source["fow_standin_score"], + opened_source["bear_dist"], + opened_source["equal_area"], + opened_source["timeout"], ] ) diff --git a/openlr_dereferencer/decoding/error.py b/openlr_dereferencer/decoding/error.py index 51d2205..295e03f 100644 --- a/openlr_dereferencer/decoding/error.py +++ b/openlr_dereferencer/decoding/error.py @@ -1,2 +1,5 @@ class LRDecodeError(Exception): - "An error that happens through decoding location references" \ No newline at end of file + "An error that happens through decoding location references" + +class LRTimeoutError(Exception): + "A timeout error that happens through decoding location references" \ No newline at end of file diff --git a/openlr_dereferencer/decoding/line_decoding.py b/openlr_dereferencer/decoding/line_decoding.py index dbb9a36..5f1ea19 100644 --- a/openlr_dereferencer/decoding/line_decoding.py +++ b/openlr_dereferencer/decoding/line_decoding.py @@ -11,23 +11,20 @@ def dereference_path( - lrps: List[LocationReferencePoint], - reader: MapReader, - config: Config, - observer: Optional[DecoderObserver] + lrps: List[LocationReferencePoint], reader: MapReader, config: Config, observer: Optional[DecoderObserver] ) -> List[Route]: "Decode the location reference path, without considering any offsets" first_lrp = lrps[0] first_candidates = list(nominate_candidates(first_lrp, reader, config, observer, False)) - linelocationpath = match_tail(first_lrp, first_candidates, lrps[1:], reader, config, observer) return linelocationpath -def decode_line(reference: LineLocationReference, reader: MapReader, config: Config, - observer: Optional[DecoderObserver]) -> LineLocation: +def decode_line( + reference: LineLocationReference, reader: MapReader, config: Config, observer: Optional[DecoderObserver] +) -> LineLocation: """Decodes an openLR line location reference Candidates are searched in a radius of `radius` meters around an LRP.""" parts = dereference_path(reference.points, reader, config, observer) - return build_line_location(parts, reference) + return build_line_location(parts, reference, config.equal_area) diff --git a/openlr_dereferencer/decoding/line_location.py b/openlr_dereferencer/decoding/line_location.py index 7d0061d..211fc3d 100644 --- a/openlr_dereferencer/decoding/line_location.py +++ b/openlr_dereferencer/decoding/line_location.py @@ -6,6 +6,7 @@ from .path_math import remove_offsets from .routes import Route, PointOnLine + class LineLocation: """A dereferenced line location. Create it from a combined Route which represents the line location path. The attribute `lines` is the list of involved `Line` elements. @@ -49,7 +50,7 @@ def get_lines(line_location_path: Iterable[Route]) -> List[Line]: return result -def combine_routes(line_location_path: Iterable[Route]) -> Route: +def combine_routes(line_location_path: Iterable[Route], equal_area: bool = False) -> Route: """Builds the whole location reference path Args: @@ -61,18 +62,18 @@ def combine_routes(line_location_path: Iterable[Route]) -> Route: The combined route """ path = get_lines(line_location_path) - start = PointOnLine(path.pop(0), line_location_path[0].start.relative_offset) + start = PointOnLine(path.pop(0), line_location_path[0].start.relative_offset, equal_area) if path: - end = PointOnLine(path.pop(), line_location_path[-1].end.relative_offset) + end = PointOnLine(path.pop(), line_location_path[-1].end.relative_offset, equal_area) else: - end = PointOnLine(start.line, line_location_path[-1].end.relative_offset) + end = PointOnLine(start.line, line_location_path[-1].end.relative_offset, equal_area) return Route(start, path, end) -def build_line_location(path: List[Route], reference: LineLocationReference) -> LineLocation: +def build_line_location(path: List[Route], reference: LineLocationReference, equal_area: bool = False) -> LineLocation: """Builds a LineLocation object from all location reference path parts and the offset values. The result will be a trimmed list of Line objects, with minimized offset values""" p_off = reference.poffs * path[0].length() n_off = reference.noffs * path[-1].length() - return LineLocation(remove_offsets(combine_routes(path), p_off, n_off)) + return LineLocation(remove_offsets(combine_routes(path, equal_area), p_off, n_off)) diff --git a/openlr_dereferencer/decoding/path_math.py b/openlr_dereferencer/decoding/path_math.py index a280b12..54fdced 100644 --- a/openlr_dereferencer/decoding/path_math.py +++ b/openlr_dereferencer/decoding/path_math.py @@ -9,7 +9,8 @@ from .error import LRDecodeError from .routes import Route, PointOnLine from ..maps import Line -from ..maps.wgs84 import interpolate, bearing, line_string_length +from ..maps import wgs84 +from ..maps import equal_area as ee def remove_offsets(path: Route, p_off: float, n_off: float) -> Route: @@ -21,16 +22,14 @@ def remove_offsets(path: Route, p_off: float, n_off: float) -> Route: debug(f"first line's offset is {path.absolute_start_offset}") remaining_poff = p_off + path.absolute_start_offset while remaining_poff >= lines[0].length: - debug(f"Remaining positive offset {remaining_poff} is greater than " - f"the first line. Removing it.") + debug(f"Remaining positive offset {remaining_poff} is greater than the first line. Removing it.") remaining_poff -= lines.pop(0).length if not lines: raise LRDecodeError("Offset is bigger than line location path") # Remove negative offset remaining_noff = n_off + path.absolute_end_offset while remaining_noff >= lines[-1].length: - debug(f"Remaining negative offset {remaining_noff} is greater than " - f"the last line. Removing it.") + debug(f"Remaining negative offset {remaining_noff} is greater than the last line. Removing it.") remaining_noff -= lines.pop().length if not lines: raise LRDecodeError("Offset is bigger than line location path") @@ -42,7 +41,7 @@ def remove_offsets(path: Route, p_off: float, n_off: float) -> Route: return Route( PointOnLine.from_abs_offset(start_line, remaining_poff), lines, - PointOnLine.from_abs_offset(end_line, end_line.length - remaining_noff) + PointOnLine.from_abs_offset(end_line, end_line.length - remaining_noff), ) @@ -51,7 +50,7 @@ def coords(lrp: LocationReferencePoint) -> Coordinates: return Coordinates(lrp.lon, lrp.lat) -def project(line: Line, coord: Coordinates) -> PointOnLine: +def project(line: Line, coord: Coordinates, equal_area: bool = False) -> PointOnLine: """Computes the nearest point to `coord` on the line Returns: The point on `line` where this nearest point resides""" @@ -59,12 +58,16 @@ def project(line: Line, coord: Coordinates) -> PointOnLine: to_projection_point = substring(line.geometry, 0.0, fraction, normalized=True) - meters_to_projection_point = line_string_length(to_projection_point) - geometry_length = line_string_length(line.geometry) + if not equal_area: + meters_to_projection_point = wgs84.line_string_length(to_projection_point) + geometry_length = wgs84.line_string_length(line.geometry) + else: + meters_to_projection_point = ee.line_string_length(to_projection_point) + geometry_length = ee.line_string_length(line.geometry) length_fraction = meters_to_projection_point / geometry_length - return PointOnLine(line, length_fraction) + return PointOnLine(line, length_fraction, equal_area) def linestring_coords(line: LineString) -> List[Coordinates]: @@ -73,10 +76,7 @@ def linestring_coords(line: LineString) -> List[Coordinates]: def compute_bearing( - lrp: LocationReferencePoint, - candidate: PointOnLine, - is_last_lrp: bool, - bear_dist: float + lrp: LocationReferencePoint, candidate: PointOnLine, is_last_lrp: bool, bear_dist: float, equal_area: bool = False ) -> float: "Returns the bearing angle of a partial line in degrees in the range 0.0 .. 360.0" line1, line2 = candidate.split() @@ -89,6 +89,12 @@ def compute_bearing( if line2 is None: return 0.0 coordinates = linestring_coords(line2) - bearing_point = interpolate(coordinates, bear_dist) - bear = bearing(coordinates[0], bearing_point) - return degrees(bear) % 360 + if not equal_area: + bearing_point = wgs84.interpolate(coordinates, bear_dist) + bear = wgs84.bearing(coordinates[0], bearing_point) + result = degrees(bear) % 360 + else: + bearing_point = ee.interpolate(coordinates, bear_dist) + bear = ee.bearing(coordinates[0], bearing_point) + result = (degrees(bear) + 360) % 360 + return result diff --git a/openlr_dereferencer/decoding/point_locations.py b/openlr_dereferencer/decoding/point_locations.py index 68ec29a..5dde5b8 100644 --- a/openlr_dereferencer/decoding/point_locations.py +++ b/openlr_dereferencer/decoding/point_locations.py @@ -11,7 +11,8 @@ from ..maps import MapReader, path_length from ..maps.abstract import Line from ..observer import DecoderObserver -from ..maps.wgs84 import interpolate +from ..maps import wgs84 +from ..maps import equal_area as ee from .line_decoding import dereference_path from .line_location import get_lines, Route, combine_routes from .configuration import Config @@ -27,10 +28,15 @@ class PointAlongLine(NamedTuple): positive_offset: float side: SideOfRoad orientation: Orientation + config: Config def coordinates(self) -> Coordinates: "Returns the geo coordinates of the point" - return interpolate(list(self.line.coordinates()), self.positive_offset) + if not self.config.equal_area: + coords = wgs84.interpolate(list(self.line.coordinates()), self.positive_offset) + else: + coords = ee.interpolate_ee(list(self.line.coordinates()), self.positive_offset) + return coords def point_along_linelocation(route: Route, length: float) -> Tuple[Line, float]: @@ -54,16 +60,13 @@ def point_along_linelocation(route: Route, length: float) -> Tuple[Line, float]: def decode_pointalongline( - reference: PointAlongLineLocationReference, - reader: MapReader, - config: Config, - observer: Optional[DecoderObserver] + reference: PointAlongLineLocationReference, reader: MapReader, config: Config, observer: Optional[DecoderObserver] ) -> PointAlongLine: "Decodes a point along line location reference into a PointAlongLine object" - path = combine_routes(dereference_path(reference.points, reader, config, observer)) + path = combine_routes(dereference_path(reference.points, reader, config, observer), config.equal_area) absolute_offset = path.length() * reference.poffs line_object, line_offset = point_along_linelocation(path, absolute_offset) - return PointAlongLine(line_object, line_offset, reference.sideOfRoad, reference.orientation) + return PointAlongLine(line_object, line_offset, reference.sideOfRoad, reference.orientation, config) class PoiWithAccessPoint(NamedTuple): @@ -73,20 +76,25 @@ class PoiWithAccessPoint(NamedTuple): side: SideOfRoad orientation: Orientation poi: Coordinates + config: Config def access_point_coordinates(self) -> Coordinates: "Returns the geo coordinates of the access point" - return interpolate(list(self.line.coordinates()), self.positive_offset) + if not self.config.equal_area: + result = wgs84.interpolate(list(self.line.coordinates()), self.positive_offset) + else: + result = ee.interpolate(list(self.line.coordinates()), self.positive_offset) + return result def decode_poi_with_accesspoint( - reference: PoiWithAccessPointLocationReference, - reader: MapReader, - config: Config, - observer: Optional[DecoderObserver] + reference: PoiWithAccessPointLocationReference, + reader: MapReader, + config: Config, + observer: Optional[DecoderObserver], ) -> PoiWithAccessPoint: "Decodes a poi with access point location reference into a PoiWithAccessPoint" - path = combine_routes(dereference_path(reference.points, reader, config, observer)) + path = combine_routes(dereference_path(reference.points, reader, config, observer), config.equal_area) absolute_offset = path_length(get_lines([path])) * reference.poffs line, line_offset = point_along_linelocation(path, absolute_offset) return PoiWithAccessPoint( @@ -95,4 +103,5 @@ def decode_poi_with_accesspoint( reference.sideOfRoad, reference.orientation, Coordinates(reference.lon, reference.lat), + config, ) diff --git a/openlr_dereferencer/decoding/routes.py b/openlr_dereferencer/decoding/routes.py index e97932a..67b991b 100644 --- a/openlr_dereferencer/decoding/routes.py +++ b/openlr_dereferencer/decoding/routes.py @@ -5,7 +5,7 @@ from shapely.ops import substring from openlr import Coordinates from ..maps.abstract import Line, path_length -from ..maps.wgs84 import interpolate, split_line, join_lines, line_string_length +from ..maps import wgs84, equal_area as ee class PointOnLine(NamedTuple): @@ -16,14 +16,22 @@ class PointOnLine(NamedTuple): #: Its value is member of the interval [0.0, 1.0]. #: A value of 0 references the starting point of the line. relative_offset: float + equal_area: bool = False def _geometry_length_from_start(self): - geometry_length = line_string_length(self.line.geometry) + if not self.equal_area: + geometry_length = wgs84.line_string_length(self.line.geometry) + else: + geometry_length = ee.line_string_length(self.line.geometry) return geometry_length * self.relative_offset def position(self) -> Coordinates: "Returns the actual geo position" - return interpolate(self.line.coordinates(), self._geometry_length_from_start()) + if not self.equal_area: + pos = wgs84.interpolate(self.line.coordinates(), self._geometry_length_from_start()) + else: + pos = ee.interpolate(self.line.coordinates(), self._geometry_length_from_start()) + return pos def distance_from_start(self) -> float: "Returns the distance in meters from the start of the line to the point" @@ -35,18 +43,22 @@ def distance_to_end(self) -> float: def split(self) -> Tuple[Optional[LineString], Optional[LineString]]: "Splits the Line element that this point is along and returns the parts" - return split_line(self.line.geometry, self._geometry_length_from_start()) + if not self.equal_area: + result = wgs84.split_line(self.line.geometry, self._geometry_length_from_start()) + else: + result = ee.split_line(self.line.geometry, self._geometry_length_from_start()) + return result @classmethod - def from_abs_offset(cls, line: Line, meters_into: float): + def from_abs_offset(cls, line: Line, meters_into: float, equal_area: bool = False): """Build a PointOnLine from an absolute offset value. Negative offsets are recognized and subtracted.""" if meters_into >= 0.0: - return cls(line, meters_into / line.length) + return cls(line, meters_into / line.length, equal_area) else: negative_meters_into = line.length + meters_into - return cls(line, negative_meters_into / line.length) + return cls(line, negative_meters_into / line.length, equal_area) class Route(NamedTuple): @@ -95,10 +107,7 @@ def shape(self) -> LineString: "Returns the shape of the route. The route is has to be continuous." if self.start.line.line_id == self.end.line.line_id: return substring( - self.start.line.geometry, - self.start.relative_offset, - self.end.relative_offset, - normalized=True + self.start.line.geometry, self.start.relative_offset, self.end.relative_offset, normalized=True ) result = [] @@ -109,8 +118,7 @@ def shape(self) -> LineString: result += [line.geometry for line in self.path_inbetween] if last is not None: result.append(last) - - return join_lines(result) + return wgs84.join_lines(result) def coordinates(self) -> List[Coordinates]: "Returns all Coordinates of this line location" From f4fcbc9231f45a9c2ef0bd2706a397aff51f1fd6 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 21:03:50 +0000 Subject: [PATCH 04/13] postgres map --- .../example_postgres_map/__init__.py | 112 +++++++++ .../example_postgres_map/init.sql | 20 ++ .../example_postgres_map/primitives.py | 228 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 openlr_dereferencer/example_postgres_map/__init__.py create mode 100644 openlr_dereferencer/example_postgres_map/init.sql create mode 100644 openlr_dereferencer/example_postgres_map/primitives.py diff --git a/openlr_dereferencer/example_postgres_map/__init__.py b/openlr_dereferencer/example_postgres_map/__init__.py new file mode 100644 index 0000000..708999d --- /dev/null +++ b/openlr_dereferencer/example_postgres_map/__init__.py @@ -0,0 +1,112 @@ +"""The example map format described in `map_format.md`, conforming to +the interface in openlr_dereferencer.maps""" + +from typing import Iterable +from openlr import Coordinates +from .primitives import Line, Node, ExampleMapError +from openlr_dereferencer.maps import MapReader +from stl_general import database as db + + +class PostgresMapReader(MapReader): + """ + This is a reader for the example map format described in `map_format.md`. + + Create an instance with: `ExampleMapReader('example.sqlite')`. + """ + + def __init__(self, db_nickname, db_schema, lines_tbl_name, nodes_tbl_name, srid=4326): + self.db_nickname = db_nickname + self.db_schema = db_schema + self.lines_tbl_name = lines_tbl_name + self.nodes_tbl_name = nodes_tbl_name + self.connection = None + self.srid = srid + + def __enter__(self): + assert self.db_nickname is not None + self.connection = db.connect_db(nickname=self.db_nickname, driver="psycopg2") + self.cursor = self.connection.cursor() + return self + + def __exit__(self, *exc_info): + # make sure the dbconnection gets closed + try: + close_it = self.connection.close + except AttributeError: + pass + else: + close_it() + + def get_line(self, line_id: int) -> Line: + # Just verify that this line ID exists. + self.cursor.execute( + f"SELECT line_id FROM {self.db_schema}.{self.lines_tbl_name} WHERE line_id=%s", + (line_id,), + ) + if self.cursor.fetchone() is None: + raise ExampleMapError(f"The line {line_id} does not exist") + return Line(self, line_id) + + def get_lines(self) -> Iterable[Line]: + self.cursor.execute(f"SELECT line_id FROM {self.db_schema}.{self.lines_tbl_name}") + for (line_id,) in self.cursor.fetchall(): + yield Line(self, line_id) + + def get_linecount(self) -> int: + self.cursor.execute(f"SELECT COUNT(*) FROM {self.db_schema}.{self.lines_tbl_name}") + (count,) = self.cursor.fetchone() + return count + + def get_node(self, node_id: int) -> Node: + self.cursor.execute( + f"SELECT node_id FROM {self.db_schema}.{self.nodes_tbl_name} WHERE node_id=%s", + (node_id,), + ) + (node_id,) = self.cursor.fetchone() + return Node(self, node_id) + + def get_nodes(self) -> Iterable[Node]: + self.cursor.execute(f"SELECT node_id FROM {self.db_schema}.{self.nodes_tbl_name}") + for (node_id,) in self.cursor.fetchall(): + yield Node(self, node_id) + + def get_nodecount(self) -> int: + self.cursor.execute(f"SELECT COUNT(*) FROM {self.db_schema}.{self.nodes_tbl_name}") + (count,) = self.cursor.fetchone() + return count + + def find_nodes_close_to(self, coord: Coordinates, dist: float) -> Iterable[Node]: + """Finds all nodes in a given radius, given in meters + Yields every node within this distance to `coord`.""" + lon, lat = coord.lon, coord.lat + stmt = f""" + SELECT + node_id + FROM {self.db_schema}.{self.nodes_tbl_name} + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(%s,%s), {self.srid}), + geometry, + %s + ); + """ + self.cursor.execute(stmt, (lon, lat, dist)) + for (node_id,) in self.cursor.fetchall(): + yield Node(self, node_id) + + def find_lines_close_to(self, coord: Coordinates, dist: float) -> Iterable[Line]: + "Yields all lines within `dist` meters around `coord`" + lon, lat = coord.lon, coord.lat + stmt = f""" + SELECT + line_id + FROM {self.db_schema}.{self.lines_tbl_name} + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(%s,%s), {self.srid}), + geometry, + %s + ); + """ + self.cursor.execute(stmt, (lon, lat, dist)) + for (line_id,) in self.cursor.fetchall(): + yield Line(self, line_id) diff --git a/openlr_dereferencer/example_postgres_map/init.sql b/openlr_dereferencer/example_postgres_map/init.sql new file mode 100644 index 0000000..cd04fe4 --- /dev/null +++ b/openlr_dereferencer/example_postgres_map/init.sql @@ -0,0 +1,20 @@ +CREATE TABLE openlr_nodes ( + node_id BIGINT PRIMARY KEY, + geometry geometry(Point, 4326) DEFAULT NULL::geometry +); + +CREATE TABLE openlr_lines ( + line_id BIGINT PRIMARY KEY, + way_id BIGINT, + startnode BIGINT NOT NULL, + endnode BIGINT NOT NULL, + frc integer NOT NULL, + fow integer NOT NULL, + geometry geometry(LineString, 4326) DEFAULT NULL::geometry, + bearing float8 +); + +CREATE INDEX idx_openlr_nodes_geometry ON openlr_nodes USING GIST (geometry gist_geometry_ops_2d); +CREATE INDEX idx_openlr_lines_geometry ON openlr_lines USING GIST (geometry gist_geometry_ops_2d); +CREATE INDEX idx_openlr_lines_startnode ON openlr_lines(startnode int8_ops); +CREATE INDEX idx_openlr_lines_endnode ON openlr_lines(endnode int8_ops); \ No newline at end of file diff --git a/openlr_dereferencer/example_postgres_map/primitives.py b/openlr_dereferencer/example_postgres_map/primitives.py new file mode 100644 index 0000000..c93b614 --- /dev/null +++ b/openlr_dereferencer/example_postgres_map/primitives.py @@ -0,0 +1,228 @@ +"Contains the Node and the Line class of the example format" +from collections import namedtuple +from itertools import chain +import functools +from typing import Iterable +from openlr import Coordinates, FRC, FOW +from shapely.geometry import LineString +from shapely import wkt +from openlr_dereferencer.maps import Line as AbstractLine, Node as AbstractNode + +LineNode = namedtuple("LineNode", ["start_node", "end_node"]) + + +class Line(AbstractLine): + "Line object implementation for the example format" + + def __init__(self, map_reader, line_id: int): + if not isinstance(line_id, int): + raise ExampleMapError(f"Line id '{line_id}' has confusing type {type(line_id)}") + self.map_reader = map_reader + self.db_schema = map_reader.db_schema + self.lines_tbl_name = map_reader.lines_tbl_name + self.nodes_tbl_name = map_reader.nodes_tbl_name + self.line_id_internal = line_id + self.srid = map_reader.srid + self._start_node = None + self._end_node = None + self._fow = None + self._frc = None + self._geometry = None + self._numpoints = None + self._length = None + + def __repr__(self): + return f"Line with id={self.line_id} of length {self.length}" + + @property + def line_id(self) -> int: + "Returns the line id" + return self.line_id_internal + + def get_and_store_database_info(self): + stmt = f""" + SELECT + startnode, + endnode, + fow, + frc, + ST_astext(geometry), + ST_NumPoints(geometry), + st_length(geometry) + FROM {self.db_schema}.{self.lines_tbl_name} + WHERE line_id = %s + """ + self.map_reader.cursor.execute(stmt, (self.line_id,)) + (startnode, endnode, fow, frc, geometry, num_points, length) = self.map_reader.cursor.fetchone() + self._start_node = Node(self.map_reader, startnode) + self._end_node = Node(self.map_reader, endnode) + self._fow = FOW(fow) + self._frc = FRC(frc) + self._geometry = wkt.loads(geometry) + self._numpoints = num_points + self._length = length + + @property + def start_node(self) -> "Node": + "Returns the node from which this line comes from" + if not self._start_node: + self.get_and_store_database_info() + return self._start_node + + @property + def end_node(self) -> "Node": + "Returns the node to which this line goes" + if not self._end_node: + self.get_and_store_database_info() + return self._end_node + + @property + def fow(self) -> FOW: + "Returns the form of way for this line" + if not self._fow: + self.get_and_store_database_info() + return self._fow + + @property + def frc(self) -> FRC: + "Returns the functional road class for this line" + if not self._frc: + self.get_and_store_database_info() + return self._frc + + @property + def length(self) -> float: + "Length of line in meters" + if not self._length: + self.get_and_store_database_info() + return self._length + + @property + def way_ids(self) -> int: + stmt = f"SELECT distinct(way_ids) FROM {self.db_schema}.{self.lines_tbl_name} WHERE line_id = %s" + self.map_reader.cursor.execute(stmt, (self.line_id,)) + (result,) = self.map_reader.cursor.fetchone() + return result + + @property + def geometry(self) -> LineString: + "Returns the line geometry" + # chg list comp to single call + if not self._geometry: + self.get_and_store_database_info() + return self._geometry + + def num_points(self) -> int: + "Returns how many points the path geometry contains" + if not self._numpoints: + self.get_and_store_database_info() + return self._numpoints + + def distance_to(self, coord) -> float: + "Returns the distance of this line to `coord` in meters" + stmt = f""" + SELECT + ST_Distance( + ST_SetSRID(ST_MakePoint(%s,%s), {self.srid}), + geometry + ) + FROM {self.db_schema}.{self.lines_tbl_name} + WHERE + line_id = %s; + """ + cur = self.map_reader.cursor + cur.execute(stmt, (coord.lon, coord.lat, self.line_id)) + (dist,) = cur.fetchone() + if dist is None: + return 0.0 + return dist + + def point_n(self, index) -> Coordinates: + "Returns the `n` th point in the path geometry, starting at 0" + stmt = f""" + SELECT ST_X(ST_PointN(geometry, %s)), ST_Y(ST_PointN(geometry, %s)) + FROM {self.db_schema}.{self.lines_tbl_name} + WHERE line_id = %s + """ + self.map_reader.cursor.execute(stmt, (index, index, self.line_id)) + (lon, lat) = self.map_reader.cursor.fetchone() + if lon is None or lat is None: + raise Exception(f"line {self.line_id} has no point {index}!") + return Coordinates(lon, lat) + + def near_nodes(self, distance): + "Yields every point within a certain distance, in meters." + stmt = f""" + SELECT + nodes.node_id + FROM {self.db_schema}.{self.nodes_tbl_name} nodes, {self.db_schema}.{self.lines_tbl_name} lines + WHERE + lines.line_id = %s AND + ST_DWithin( + nodes.geometry, + lines.geometry, + %s + ) + """ + self.map_reader.cursor.execute(stmt, (self.line_id, distance)) + for (point_id,) in self.map_reader.cursor.fetchall(): + yield self.map_reader.get_node(point_id) + + +class Node(AbstractNode): + "Node class implementation for example_sqlite_map" + + def __init__(self, map_reader, node_id: int): + if not isinstance(node_id, int): + raise ExampleMapError(f"Node id '{id}' has confusing type {type(node_id)}") + self.map_reader = map_reader + self.db_schema = map_reader.db_schema + self.lines_tbl_name = map_reader.lines_tbl_name + self.nodes_tbl_name = map_reader.nodes_tbl_name + self.node_id_internal = node_id + + @property + def node_id(self): + return self.node_id_internal + + @property + def coordinates(self) -> Coordinates: + stmt = f"SELECT ST_X(geometry), ST_Y(geometry) FROM {self.db_schema}.{self.nodes_tbl_name} WHERE node_id = %s" + self.map_reader.cursor.execute(stmt, (self.node_id,)) + geo = self.map_reader.cursor.fetchone() + return Coordinates(lon=geo[0], lat=geo[1]) + + @functools.cache + def outgoing_lines(self) -> Iterable[Line]: + stmt = f"SELECT line_id FROM {self.db_schema}.{self.lines_tbl_name} WHERE startnode = %s" + self.map_reader.cursor.execute(stmt, (self.node_id,)) + for (line_id,) in self.map_reader.cursor.fetchall(): + yield Line(self.map_reader, line_id) + + @functools.cache + def incoming_lines(self) -> Iterable[Line]: + stmt = f"SELECT line_id FROM {self.db_schema}.{self.lines_tbl_name} WHERE endnode = %s" + self.map_reader.cursor.execute(stmt, [self.node_id]) + for (line_id,) in self.map_reader.cursor.fetchall(): + yield Line(self.map_reader, line_id) + + @functools.cache + def incoming_line_nodes(self) -> Iterable[LineNode]: + stmt = f"SELECT startnode, endnode FROM {self.db_schema}.{self.lines_tbl_name} WHERE startnode = %s" + self.map_reader.cursor.execute(stmt, (self.node_id,)) + for (startnode, endnode) in self.map_reader.cursor.fetchall(): + yield LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) + + @functools.cache + def outgoing_line_nodes(self) -> Iterable[LineNode]: + stmt = f"SELECT startnode, endnode FROM {self.db_schema}.{self.lines_tbl_name} WHERE endnode = %s" + self.map_reader.cursor.execute(stmt, (self.node_id,)) + for (startnode, endnode) in self.map_reader.cursor.fetchall(): + yield LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) + + def connected_lines(self) -> Iterable[Line]: + return chain(self.incoming_lines(), self.outgoing_lines()) + + +class ExampleMapError(Exception): + "Some error reading the DB" From 174347672ebead54895335f18e6130157fe99310 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 21:04:11 +0000 Subject: [PATCH 05/13] changes to decoding --- openlr_dereferencer/decoding/scoring.py | 30 ++++++++++++------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/openlr_dereferencer/decoding/scoring.py b/openlr_dereferencer/decoding/scoring.py index 1df7511..d8747dc 100644 --- a/openlr_dereferencer/decoding/scoring.py +++ b/openlr_dereferencer/decoding/scoring.py @@ -7,7 +7,7 @@ from logging import debug from openlr import FRC, FOW, LocationReferencePoint -from ..maps.wgs84 import distance +from ..maps import wgs84, equal_area as ee from .path_math import coords, PointOnLine, compute_bearing from .configuration import Config @@ -17,12 +17,15 @@ def score_frc(wanted: FRC, actual: FRC) -> float: return 1.0 - abs(actual - wanted) / 7 -def score_geolocation(wanted: LocationReferencePoint, actual: PointOnLine, radius: float) -> float: +def score_geolocation(wanted: LocationReferencePoint, actual: PointOnLine, radius: float, equal_area: bool) -> float: """Scores the geolocation of a candidate. A distance of `radius` or more will result in a 0.0 score.""" debug(f"Candidate coords are {actual.position()}") - dist = distance(coords(wanted), actual.position()) + if not equal_area: + dist = wgs84.distance(coords(wanted), actual.position()) + else: + dist = ee.distance(coords(wanted), actual.position()) if dist < radius: return 1.0 - dist / radius return 0.0 @@ -45,13 +48,13 @@ def angle_sector(angle: float) -> int: def angle_sector_difference(angle1: float, angle2: float) -> int: - """"The distance of the two sectors containing the angles values, respectively + """The distance of the two sectors containing the angles values, respectively Args: angle1, angle2: the values are expected in degrees Returns: Value in the range [0,16] - """ + """ sector_diff = abs(angle_sector(angle1) - angle_sector(angle2)) "Differences should be between 0 and 16. Direction (clockwise or counter clockwise) between the two sectors should " @@ -99,32 +102,27 @@ def score_angle_difference(angle1: float, angle2: float) -> float: def score_bearing( - wanted: LocationReferencePoint, - actual: PointOnLine, - is_last_lrp: bool, - bear_dist: float + wanted: LocationReferencePoint, actual: PointOnLine, is_last_lrp: bool, bear_dist: float, equal_area: bool ) -> float: """Scores the difference between expected and actual bearing angle. A difference of 0° will result in a 1.0 score, while 180° will cause a score of 0.0.""" - bear = compute_bearing(wanted, actual, is_last_lrp, bear_dist) + bear = compute_bearing(wanted, actual, is_last_lrp, bear_dist, equal_area) return score_angle_sector_differences(wanted.bear, bear) def score_lrp_candidate( - wanted: LocationReferencePoint, - candidate: PointOnLine, config: Config, is_last_lrp: bool + wanted: LocationReferencePoint, candidate: PointOnLine, config: Config, is_last_lrp: bool ) -> float: """Scores the candidate (line) for the LRP. This is the average of fow, frc, geo and bearing score.""" debug(f"scoring {candidate} with config {config}") - geo_score = config.geo_weight * score_geolocation(wanted, candidate, config.search_radius) + geo_score = config.geo_weight * score_geolocation(wanted, candidate, config.search_radius, config.equal_area) fow_score = config.fow_weight * config.fow_standin_score[wanted.fow][candidate.line.fow] frc_score = config.frc_weight * score_frc(wanted.frc, candidate.line.frc) - bear_score = score_bearing(wanted, candidate, is_last_lrp, config.bear_dist) + bear_score = score_bearing(wanted, candidate, is_last_lrp, config.bear_dist, config.equal_area) bear_score *= config.bear_weight score = fow_score + frc_score + geo_score + bear_score - debug(f"Score: geo {geo_score} + fow {fow_score} + frc {frc_score} " - f"+ bear {bear_score} = {score}") + debug(f"Score: geo {geo_score} + fow {fow_score} + frc {frc_score} bear {bear_score} = {score}") return score From b95502b8691c198c2eab13474d9582366d0bb3f2 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 21:07:03 +0000 Subject: [PATCH 06/13] changes to maps --- openlr_dereferencer/maps/a_star/__init__.py | 23 ++-- openlr_dereferencer/maps/a_star/tools.py | 11 +- openlr_dereferencer/maps/equal_area.py | 114 ++++++++++++++++++++ 3 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 openlr_dereferencer/maps/equal_area.py diff --git a/openlr_dereferencer/maps/a_star/__init__.py b/openlr_dereferencer/maps/a_star/__init__.py index 7fff60f..cbdd4ff 100644 --- a/openlr_dereferencer/maps/a_star/__init__.py +++ b/openlr_dereferencer/maps/a_star/__init__.py @@ -11,12 +11,15 @@ class Score(NamedTuple): """The score of a single item in the search priority queue""" + f: float g: float + @total_ordering class PQItem(NamedTuple): """A single item in the search priority queue""" + score: Score node: Node line: Line @@ -27,10 +30,11 @@ def __lt__(self, other): def shortest_path( - start: Node, - end: Node, - linefilter: Callable[[Line], bool] = tautology, - maxlen: float = float("inf"), + start: Node, + end: Node, + linefilter: Callable[[Line], bool] = tautology, + maxlen: float = float("inf"), + equal_area: bool = False, ) -> List[Line]: """ Returns a shortest path on the map between two nodes, as list of lines. @@ -70,7 +74,7 @@ def shortest_path( """ # The initial queue item - initial = PQItem(Score(heuristic(start, end), 0), start, None, None) + initial = PQItem(Score(heuristic(start, end, equal_area), 0), start, None, None) # The queue open_set = [initial] @@ -112,17 +116,12 @@ def shortest_path( continue neighbor_g_score = current.score.g + line.length - neighbor_f_score = neighbor_g_score + heuristic(neighbor_node, end) + neighbor_f_score = neighbor_g_score + heuristic(neighbor_node, end, equal_area) if neighbor_f_score > maxlen: continue - neighbor = PQItem( - Score(neighbor_f_score, neighbor_g_score), - neighbor_node, - line, - current - ) + neighbor = PQItem(Score(neighbor_f_score, neighbor_g_score), neighbor_node, line, current) heappush(open_set, neighbor) diff --git a/openlr_dereferencer/maps/a_star/tools.py b/openlr_dereferencer/maps/a_star/tools.py index 63a6d67..4fa7ecc 100644 --- a/openlr_dereferencer/maps/a_star/tools.py +++ b/openlr_dereferencer/maps/a_star/tools.py @@ -2,7 +2,8 @@ from functools import lru_cache from ..abstract import Node -from ..wgs84 import distance +from .. import wgs84 +from .. import equal_area as ee class LRPathNotFoundError(Exception): @@ -10,11 +11,15 @@ class LRPathNotFoundError(Exception): @lru_cache(maxsize=2) -def heuristic(current: Node, target: Node) -> float: +def heuristic(current: Node, target: Node, equal_area: bool = False) -> float: """Estimated cost from current to target. We use geographical distance here as heuristic here.""" - return distance(current.coordinates, target.coordinates) + if not equal_area: + dist = wgs84.distance(current.coordinates, target.coordinates) + else: + dist = ee.distance(current.coordinates, target.coordinates) + return dist def tautology(_) -> bool: diff --git a/openlr_dereferencer/maps/equal_area.py b/openlr_dereferencer/maps/equal_area.py new file mode 100644 index 0000000..d99923a --- /dev/null +++ b/openlr_dereferencer/maps/equal_area.py @@ -0,0 +1,114 @@ +"Some geo coordinates related tools" +from math import radians, degrees, sin, cos, pi +from typing import Sequence, Tuple, Optional +from geographiclib.geodesic import Geodesic +from openlr import Coordinates +from shapely.geometry import LineString +from itertools import tee +import numpy as np + + +def distance(point_a: Coordinates, point_b: Coordinates) -> float: + "Returns the distance of two coordinates from an equal area projection, assuming units are in meters" + dist = np.sqrt((point_b.lat - point_a.lat) ** 2 + (point_b.lon - point_a.lon) ** 2) + return dist + + +def pairwise(iterable): + "s -> (s0,s1), (s1,s2), (s2, s3), ..." + first, second = tee(iterable) + next(second, None) + return zip(first, second) + + +def line_string_length(line_string: LineString) -> float: + """Returns the length of a line string in meters""" + + length = 0 + + for coord_a, coord_b in pairwise(line_string.coords): + l = np.sqrt((coord_b[0] - coord_a[0]) ** 2 + (coord_b[1] - coord_a[1]) ** 2) + length += l + + return length + + +def bearing(point_a: Coordinates, point_b: Coordinates) -> float: + """Returns the angle between self and other relative to true north + The result of this function is between -pi, pi, including them""" + + bear = np.arctan2(point_b.lon - point_a.lon, point_b.lat - point_a.lat) + return bear + + +def extrapolate(point: Coordinates, dist: float, angle: float) -> Coordinates: + """Creates a new point that is `dist` meters away in direction `angle` + NOTE: angle must be in radians bc it should be the output of bearing() + """ + x0, y0 = point.lon, point.lat + theta_rad = pi / 2 - angle + x1 = x0 + dist * cos(theta_rad) + y1 = y0 + dist * sin(theta_rad) + return Coordinates(x1, y1) + + +def interpolate(path: Sequence[Coordinates], distance_meters: float) -> Coordinates: + """Go `distance` meters along the `path` and return the resulting point + When the length of the path is too short, returns its last coordinate""" + remaining_distance = distance_meters + segments = [(path[i], path[i + 1]) for i in range(len(path) - 1)] + for point1, point2 in segments: + segment_length = distance(point1, point2) + if remaining_distance == 0.0: + return point1 + if remaining_distance < segment_length: + angle = bearing(point1, point2) + return extrapolate(point1, remaining_distance, angle) + remaining_distance -= segment_length + return segments[-1][1] + + +def split_line(line: LineString, meters_into: float) -> Tuple[Optional[LineString], Optional[LineString]]: + "Splits a line at `meters_into` meters and returns the two parts. A part is None if it would be a Point" + first_part = [] + second_part = [] + remaining_offset = meters_into + splitpoint = None + for point_from, point_to in pairwise(line.coords): + if splitpoint is None: + first_part.append(point_from) + (coord_from, coord_to) = (Coordinates(*point_from), Coordinates(*point_to)) + segment_length = distance(coord_from, coord_to) + if remaining_offset < segment_length: + splitpoint = interpolate([coord_from, coord_to], remaining_offset) + if splitpoint != coord_from: + first_part.append(splitpoint) + second_part = [splitpoint, point_to] + remaining_offset -= segment_length + else: + second_part.append(point_to) + if splitpoint is None: + return (line, None) + first_part = LineString(first_part) if len(first_part) > 1 else None + second_part = LineString(second_part) if len(second_part) > 1 else None + return (first_part, second_part) + + +def join_lines(lines: Sequence[LineString]) -> LineString: + coords = [] + last = None + + for l in lines: + cs = l.coords + first = cs[0] + + if last is None: + coords.append(first) + else: + if first != last: + raise ValueError("Lines are not connected") + + coords.extend(cs[1:]) + last = cs[-1] + + return LineString(coords) From 6c5a68bf6b67510d6de874782bf36a3a128a3280 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 21:07:45 +0000 Subject: [PATCH 07/13] changes to tests --- tests/test_decode.py | 125 +++++++++++++++++++++---------------------- 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/tests/test_decode.py b/tests/test_decode.py index 244da51..fbb8127 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -5,15 +5,23 @@ from io import StringIO from typing import List, Iterable, TypeVar, NamedTuple from shapely.geometry import LineString -from openlr import Coordinates, FRC, FOW, LineLocationReference, LocationReferencePoint,\ - PointAlongLineLocationReference, Orientation, SideOfRoad, PoiWithAccessPointLocationReference, \ - GeoCoordinateLocationReference +from openlr import ( + Coordinates, + FRC, + FOW, + LineLocationReference, + LocationReferencePoint, + PointAlongLineLocationReference, + Orientation, + SideOfRoad, + PoiWithAccessPointLocationReference, + GeoCoordinateLocationReference, +) from openlr_dereferencer import decode, Config from openlr_dereferencer.decoding import PointAlongLine, LineLocation, LRDecodeError, PoiWithAccessPoint from openlr_dereferencer.decoding.candidate_functions import nominate_candidates, make_candidate -from openlr_dereferencer.decoding.scoring import score_geolocation, score_frc, \ - score_bearing, score_angle_difference +from openlr_dereferencer.decoding.scoring import score_geolocation, score_frc, score_bearing, score_angle_difference from openlr_dereferencer.decoding.routes import PointOnLine, Route from openlr_dereferencer.decoding.path_math import remove_offsets from openlr_dereferencer.observer import SimpleObserver @@ -24,8 +32,10 @@ from openlr_dereferencer import load_config, save_config, DEFAULT_CONFIG -class DummyNode(): + +class DummyNode: "Fake Node class for unit testing" + def __init__(self, coord: Coordinates): self.coord = coord @@ -37,6 +47,7 @@ def coordinates(self) -> Coordinates: "Return the saved coordinates" return self.coord + class DummyLine(NamedTuple): "Fake Line class for unit testing" line_id: int @@ -62,47 +73,38 @@ def length(self) -> float: def geometry(self) -> LineString: return LineString([(c.lon, c.lat) for c in self.coordinates()]) + def get_test_linelocation_1(): "Return a prepared line location with 3 LRPs" # References node 0 / line 1 / lines 1, 3 - lrp1 = LocationReferencePoint(13.41, 52.525, - FRC.FRC0, FOW.SINGLE_CARRIAGEWAY, 90.0, - FRC.FRC2, 717.8) + lrp1 = LocationReferencePoint(13.41, 52.525, FRC.FRC0, FOW.SINGLE_CARRIAGEWAY, 90.0, FRC.FRC2, 717.8) # References node 3 / line 4 - lrp2 = LocationReferencePoint(13.4145, 52.529, - FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, 170, - FRC.FRC2, 456.6) + lrp2 = LocationReferencePoint(13.4145, 52.529, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, 170, FRC.FRC2, 456.6) # References node 4 / line 4 - lrp3 = LocationReferencePoint(13.416, 52.525, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, 320.0, None, None) + lrp3 = LocationReferencePoint(13.416, 52.525, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, 320.0, None, None) return LineLocationReference([lrp1, lrp2, lrp3], 0.0, 0.0) def get_test_linelocation_2(): "Return a undecodable line location with 2 LRPs" # References node 0 / line 1 / lines 1, 3 - lrp1 = LocationReferencePoint(13.41, 52.525, - FRC.FRC0, FOW.SINGLE_CARRIAGEWAY, 90.0, - FRC.FRC2, 0.0) + lrp1 = LocationReferencePoint(13.41, 52.525, FRC.FRC0, FOW.SINGLE_CARRIAGEWAY, 90.0, FRC.FRC2, 0.0) # References node 13 / ~ line 17 - lrp2 = LocationReferencePoint(13.429, 52.523, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, 270.0, None, None) + lrp2 = LocationReferencePoint(13.429, 52.523, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, 270.0, None, None) return LineLocationReference([lrp1, lrp2], 0.0, 0.0) def get_test_linelocation_3(): """Returns a line location that is within a line. - + This simulates that the start and end junction are missing on the target map.""" # References a point on line 1 - lrp1 = LocationReferencePoint(13.411, 52.525, - FRC.FRC1, FOW.SINGLE_CARRIAGEWAY, 90.0, - FRC.FRC1, 135) + lrp1 = LocationReferencePoint(13.411, 52.525, FRC.FRC1, FOW.SINGLE_CARRIAGEWAY, 90.0, FRC.FRC1, 135) # References another point on line 1 - lrp2 = LocationReferencePoint(13.413, 52.525, FRC.FRC1, - FOW.SINGLE_CARRIAGEWAY, -90.0, None, None) + lrp2 = LocationReferencePoint(13.413, 52.525, FRC.FRC1, FOW.SINGLE_CARRIAGEWAY, -90.0, None, None) return LineLocationReference([lrp1, lrp2], 0.0, 0.0) + def get_test_linelocation_4() -> LineLocationReference: "Test backtracking with a location that tries the decoder to get lost" # Seems to reference line 19 -> Decoder gets lost @@ -117,23 +119,22 @@ def get_test_linelocation_4() -> LineLocationReference: def get_test_pointalongline() -> PointAlongLineLocationReference: "Get a test Point Along Line location reference" path_ref = get_test_linelocation_1().points[-2:] - return PointAlongLineLocationReference(path_ref, 0.5, Orientation.WITH_LINE_DIRECTION, \ - SideOfRoad.RIGHT) + return PointAlongLineLocationReference(path_ref, 0.5, Orientation.WITH_LINE_DIRECTION, SideOfRoad.RIGHT) def get_test_invalid_pointalongline() -> PointAlongLineLocationReference: "Get a test Point Along Line location reference" path_ref = get_test_linelocation_1().points[-2:] - return PointAlongLineLocationReference(path_ref, 1500, Orientation.WITH_LINE_DIRECTION, \ - SideOfRoad.RIGHT) + return PointAlongLineLocationReference(path_ref, 1500, Orientation.WITH_LINE_DIRECTION, SideOfRoad.RIGHT) def get_test_poi() -> PoiWithAccessPointLocationReference: "Get a test POI with access point location reference" path_ref = get_test_linelocation_1().points[-2:] return PoiWithAccessPointLocationReference( - path_ref, 0.5, 13.414, 52.526, Orientation.WITH_LINE_DIRECTION, - SideOfRoad.RIGHT) + path_ref, 0.5, 13.414, 52.526, Orientation.WITH_LINE_DIRECTION, SideOfRoad.RIGHT + ) + T = TypeVar("T") @@ -149,12 +150,14 @@ def assertIterableAlmostEqual(self, iter_a: Iterable[T], iter_b: Iterable[T], de # Get the generators gen_a = iter(iter_a) gen_b = iter(iter_b) - for (a, b) in zip_longest(gen_a, gen_b): + for a, b in zip_longest(gen_a, gen_b): if abs(a - b) > delta: list_a = [a] + list(gen_a) list_b = [b] + list(gen_b) - msg = (f"Iterables are not almost equal within delta {delta}.\n" - f"Remaining a: {list_a}\nRemaining b: {list_b}") + msg = ( + f"Iterables are not almost equal within delta {delta}.\n" + f"Remaining a: {list_a}\nRemaining b: {list_b}" + ) raise self.failureException(msg) def setUp(self): @@ -175,7 +178,7 @@ def test_geoscore_1(self): node1 = DummyNode(Coordinates(0.0, 0.0)) node2 = DummyNode(Coordinates(0.0, 90.0)) pal = PointOnLine(DummyLine(None, node1, node2), 0.0) - score = score_geolocation(lrp, pal, 1.0) + score = score_geolocation(lrp, pal, 1.0, self.config.equal_area) self.assertEqual(score, 1.0) def test_geoscore_0(self): @@ -184,7 +187,7 @@ def test_geoscore_0(self): node1 = DummyNode(Coordinates(0.0, 0.0)) node2 = DummyNode(Coordinates(0.0, 90.0)) pal = PointOnLine(DummyLine(None, node1, node2), 1.0) - score = score_geolocation(lrp, pal, 1.0) + score = score_geolocation(lrp, pal, 1.0, self.config.equal_area) self.assertEqual(score, 0.0) def test_frcscore_0(self): @@ -218,10 +221,9 @@ def test_bearingscore_1(self): node2 = DummyNode(Coordinates(0.0, 90.0)) node3 = DummyNode(Coordinates(1.0, 0.0)) wanted_bearing = degrees(bearing(node1.coordinates, node2.coordinates)) - wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) + wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) line = DummyLine(1, node1, node3) - score = score_bearing(wanted, PointOnLine(line, 0.0), False, self.config.bear_dist) + score = score_bearing(wanted, PointOnLine(line, 0.0), False, self.config.bear_dist, self.config.equal_area) self.assertEqual(score, 0.5) def test_bearingscore_2(self): @@ -230,10 +232,9 @@ def test_bearingscore_2(self): node2 = DummyNode(Coordinates(0.0, 90.0)) node3 = DummyNode(Coordinates(-1.0, 0.0)) wanted_bearing = degrees(bearing(node1.coordinates, node2.coordinates)) - wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) + wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) line = DummyLine(1, node1, node3) - score = score_bearing(wanted, PointOnLine(line, 0.0), False, self.config.bear_dist) + score = score_bearing(wanted, PointOnLine(line, 0.0), False, self.config.bear_dist, self.config.equal_area) self.assertEqual(score, 0.5) def test_bearingscore_3(self): @@ -242,10 +243,9 @@ def test_bearingscore_3(self): node2 = DummyNode(Coordinates(0.0, 90.0)) node3 = DummyNode(Coordinates(1.0, 0.0)) wanted_bearing = degrees(bearing(node1.coordinates, node2.coordinates)) - wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) + wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) line = DummyLine(1, node1, node3) - score = score_bearing(wanted, PointOnLine(line, 1.0), True, self.config.bear_dist) + score = score_bearing(wanted, PointOnLine(line, 1.0), True, self.config.bear_dist, self.config.equal_area) self.assertAlmostEqual(score, 0.5) def test_bearingscore_4(self): @@ -254,10 +254,9 @@ def test_bearingscore_4(self): node2 = DummyNode(Coordinates(0.0, 90.0)) node3 = DummyNode(Coordinates(-1.0, 0.0)) wanted_bearing = degrees(bearing(node1.coordinates, node2.coordinates)) - wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) + wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) line = DummyLine(1, node1, node3) - score = score_bearing(wanted, PointOnLine(line, 1.0), True, self.config.bear_dist) + score = score_bearing(wanted, PointOnLine(line, 1.0), True, self.config.bear_dist, self.config.equal_area) self.assertAlmostEqual(score, 0.5) def test_bearingscore_5(self): @@ -265,12 +264,11 @@ def test_bearingscore_5(self): node1 = DummyNode(Coordinates(1.0, 0.0)) node2 = DummyNode(Coordinates(0.0, 0.0)) wanted_bearing = degrees(bearing(node1.coordinates, node2.coordinates)) - wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, - FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) + wanted = LocationReferencePoint(13.416, 52.525, FRC.FRC2, FOW.SINGLE_CARRIAGEWAY, wanted_bearing, None, None) line = DummyLine(1, node1, node2) - score = score_bearing(wanted, PointOnLine(line, 0.0), False, self.config.bear_dist) + score = score_bearing(wanted, PointOnLine(line, 0.0), False, self.config.bear_dist, self.config.equal_area) self.assertAlmostEqual(score, 1.0) - score = score_bearing(wanted, PointOnLine(line, 1.0), True, self.config.bear_dist) + score = score_bearing(wanted, PointOnLine(line, 1.0), True, self.config.bear_dist, self.config.equal_area) self.assertAlmostEqual(score, 0.0) def test_anglescore_1(self): @@ -303,9 +301,15 @@ def test_decode_3_lrps(self): self.assertTrue(isinstance(location, LineLocation)) lines = [l.line_id for l in location.lines] self.assertListEqual([1, 3, 4], lines) - for (a, b) in zip(location.coordinates(), - [Coordinates(13.41, 52.525), Coordinates(13.414, 52.525), - Coordinates(13.4145, 52.529), Coordinates(13.416, 52.525)]): + for a, b in zip( + location.coordinates(), + [ + Coordinates(13.41, 52.525), + Coordinates(13.414, 52.525), + Coordinates(13.4145, 52.529), + Coordinates(13.416, 52.525), + ], + ): self.assertAlmostEqual(a.lon, b.lon, delta=0.00001) self.assertAlmostEqual(a.lat, b.lat, delta=0.00001) @@ -365,18 +369,17 @@ def test_decode_invalid_poi(self): with self.assertRaises(LRDecodeError): decode(reference, self.reader) - def test_decode_midline(self): reference = get_test_linelocation_3() line_location = decode(reference, self.reader) coords = line_location.coordinates() self.assertEqual(len(coords), 2) - for ((lon1, lat1), (lon2, lat2)) in zip(coords, [(13.411, 52.525), (13.413, 52.525)]): + for (lon1, lat1), (lon2, lat2) in zip(coords, [(13.411, 52.525), (13.413, 52.525)]): self.assertAlmostEqual(lon1, lon2) self.assertAlmostEqual(lat1, lat2) def test_observer_decode_3_lrps(self): - "Add a simple observer for decoding a line location of 3 lrps " + "Add a simple observer for decoding a line location of 3 lrps" observer = SimpleObserver() reference = get_test_linelocation_1() decode(reference, self.reader, observer=observer) @@ -466,11 +469,7 @@ def test_remove_offsets(self): node1 = DummyNode(extrapolate(node0.coord, 20, 180.0)) node2 = DummyNode(extrapolate(node1.coord, 90, 90.0)) node3 = DummyNode(extrapolate(node2.coord, 20, 180.0)) - lines = [ - DummyLine(0, node0, node1), - DummyLine(1, node1, node2), - DummyLine(2, node2, node3) - ] + lines = [DummyLine(0, node0, node1), DummyLine(1, node1, node2), DummyLine(2, node2, node3)] route = Route(PointOnLine(lines[0], 0.5), [lines[1]], PointOnLine(lines[2], 0.5)) route = remove_offsets(route, 40, 40) self.assertListEqual(route.lines, [lines[1]]) From d11962bd24220cd2047cbbddfa015dabca7afbe8 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 21:36:58 +0000 Subject: [PATCH 08/13] cleanup --- openlr_dereferencer/decoding/configuration.py | 4 +--- .../example_postgres_map/__init__.py | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/openlr_dereferencer/decoding/configuration.py b/openlr_dereferencer/decoding/configuration.py index 36c2418..23d01f8 100644 --- a/openlr_dereferencer/decoding/configuration.py +++ b/openlr_dereferencer/decoding/configuration.py @@ -48,7 +48,7 @@ class Config(NamedTuple): min_score: float = 0.3 #: For every LFRCNP possibly present in an LRP, this defines #: what lowest FRC in a considered route is acceptable - tolerated_lfrc: Dict[FRC, FRC] = {frc: frc if frc < 4 else 6 for frc in FRC} + tolerated_lfrc: Dict[FRC, FRC] = {frc: frc for frc in FRC} #: Partial candidate line threshold, measured in meters #: #: To find candidates, the LRP coordinates are projected against any line in the local area. @@ -56,7 +56,6 @@ class Config(NamedTuple): #: beginning at the projection point is considered to be the candidate. Else, the candidate #: snaps onto the starting point (or ending point, when it is the last LRP) candidate_threshold: int = 20 - rel_candidate_threshold: float = 1.0 #: Defines a threshold for the bearing difference. Candidates differing too much from #: the LRP's bearing value are pre-filtered. max_bear_deviation: float = 45.0 @@ -113,7 +112,6 @@ def load_config(source: Union[str, TextIOBase, dict]) -> Config: opened_source["min_score"], {FRC(int(key)): FRC(value) for (key, value) in opened_source["tolerated_lfrc"].items()}, opened_source["candidate_threshold"], - opened_source["rel_candidate_threshold"], opened_source["max_bear_deviation"], opened_source["fow_weight"], opened_source["frc_weight"], diff --git a/openlr_dereferencer/example_postgres_map/__init__.py b/openlr_dereferencer/example_postgres_map/__init__.py index 708999d..bbcde2f 100644 --- a/openlr_dereferencer/example_postgres_map/__init__.py +++ b/openlr_dereferencer/example_postgres_map/__init__.py @@ -5,18 +5,25 @@ from openlr import Coordinates from .primitives import Line, Node, ExampleMapError from openlr_dereferencer.maps import MapReader -from stl_general import database as db +import psycopg2 class PostgresMapReader(MapReader): """ - This is a reader for the example map format described in `map_format.md`. - - Create an instance with: `ExampleMapReader('example.sqlite')`. + This is a reader for the example postgres map format described in `init.sql` """ - def __init__(self, db_nickname, db_schema, lines_tbl_name, nodes_tbl_name, srid=4326): - self.db_nickname = db_nickname + def __init__(self, conn_str, db_schema, lines_tbl_name, nodes_tbl_name, srid=4326): + """Initializes PostgresMapReader class + + Args: + conn_str (str): a libpq-style connection string (dsn) + db_schema (str): name of postgres schema where basemap tables live + lines_tbl_name (str): name of the basemap line table on postgres + nodes_tbl_name (str): name of the basemap nodes table on postgres + srid (int, optional): SRID of basemap node and line geometries. Defaults to 4326. + """ + self.conn_str = conn_str self.db_schema = db_schema self.lines_tbl_name = lines_tbl_name self.nodes_tbl_name = nodes_tbl_name @@ -24,8 +31,9 @@ def __init__(self, db_nickname, db_schema, lines_tbl_name, nodes_tbl_name, srid= self.srid = srid def __enter__(self): - assert self.db_nickname is not None - self.connection = db.connect_db(nickname=self.db_nickname, driver="psycopg2") + self.connection = psycopg2.connect( + self.conn_str, keepalives_idle=120, keepalives_interval=20, keepalives_count=100 + ) self.cursor = self.connection.cursor() return self From e2c9c7f2507b994eb403e96d91e38fa80f688ab3 Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 22:20:36 +0000 Subject: [PATCH 09/13] cleanup --- .../example_postgres_map/primitives.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/openlr_dereferencer/example_postgres_map/primitives.py b/openlr_dereferencer/example_postgres_map/primitives.py index c93b614..d00f8d0 100644 --- a/openlr_dereferencer/example_postgres_map/primitives.py +++ b/openlr_dereferencer/example_postgres_map/primitives.py @@ -97,13 +97,6 @@ def length(self) -> float: self.get_and_store_database_info() return self._length - @property - def way_ids(self) -> int: - stmt = f"SELECT distinct(way_ids) FROM {self.db_schema}.{self.lines_tbl_name} WHERE line_id = %s" - self.map_reader.cursor.execute(stmt, (self.line_id,)) - (result,) = self.map_reader.cursor.fetchone() - return result - @property def geometry(self) -> LineString: "Returns the line geometry" @@ -170,7 +163,7 @@ def near_nodes(self, distance): class Node(AbstractNode): - "Node class implementation for example_sqlite_map" + "Node class implementation for example_postgres_map" def __init__(self, map_reader, node_id: int): if not isinstance(node_id, int): @@ -210,14 +203,14 @@ def incoming_lines(self) -> Iterable[Line]: def incoming_line_nodes(self) -> Iterable[LineNode]: stmt = f"SELECT startnode, endnode FROM {self.db_schema}.{self.lines_tbl_name} WHERE startnode = %s" self.map_reader.cursor.execute(stmt, (self.node_id,)) - for (startnode, endnode) in self.map_reader.cursor.fetchall(): + for startnode, endnode in self.map_reader.cursor.fetchall(): yield LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) @functools.cache def outgoing_line_nodes(self) -> Iterable[LineNode]: stmt = f"SELECT startnode, endnode FROM {self.db_schema}.{self.lines_tbl_name} WHERE endnode = %s" self.map_reader.cursor.execute(stmt, (self.node_id,)) - for (startnode, endnode) in self.map_reader.cursor.fetchall(): + for startnode, endnode in self.map_reader.cursor.fetchall(): yield LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) def connected_lines(self) -> Iterable[Line]: From 24c72b84dddf2f8291ac5e6ea979a2aa0c0aeeaa Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 29 Nov 2023 22:21:20 +0000 Subject: [PATCH 10/13] add new line/node methods to abstract base classes --- openlr_dereferencer/maps/abstract.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openlr_dereferencer/maps/abstract.py b/openlr_dereferencer/maps/abstract.py index d784e8e..73aeeb0 100644 --- a/openlr_dereferencer/maps/abstract.py +++ b/openlr_dereferencer/maps/abstract.py @@ -45,6 +45,7 @@ from abc import ABC, abstractmethod +from collections import namedtuple from typing import Iterable, Hashable, Sequence from openlr import Coordinates, FOW, FRC from shapely.geometry import LineString, Point @@ -57,6 +58,7 @@ class GeometricObject(ABC): def geometry(self) -> BaseGeometry: "Returns the geometry of this object" + class Line(GeometricObject): "Abstract Line class, modelling a line coming from a map reader" @@ -64,9 +66,13 @@ class Line(GeometricObject): @abstractmethod def line_id(self) -> Hashable: """Returns the id of the line. - + A type is not specified here, but the ID has to be usable as key of a dictionary.""" + @abstractmethod + def get_and_store_database_info(self): + """Single call to db to fetch all attributes of line and store as class attributes""" + @property @abstractmethod def start_node(self) -> "Node": @@ -126,6 +132,14 @@ def outgoing_lines(self) -> Iterable[Line]: def incoming_lines(self) -> Iterable[Line]: "Yields all lines coming directly to this node." + @abstractmethod + def outgoing_line_nodes(self) -> Iterable[namedtuple]: + """Yields all tuples of start and end nodes for lines where end node is this node""" + + @abstractmethod + def incoming_line_nodes(self) -> Iterable[namedtuple]: + """Yields all tuples of start and end nodes for lines where start node is this node""" + @abstractmethod def connected_lines(self) -> Iterable[Line]: "Returns lines which touch this node" From 77e3b0ac4ff8a15c2ef42fc8f4242089ed4ac6fc Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Thu, 30 Nov 2023 11:03:53 -0800 Subject: [PATCH 11/13] Update configuration.py default ee srid Signed-off-by: Max Gardner --- openlr_dereferencer/decoding/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openlr_dereferencer/decoding/configuration.py b/openlr_dereferencer/decoding/configuration.py index 23d01f8..b3613b1 100644 --- a/openlr_dereferencer/decoding/configuration.py +++ b/openlr_dereferencer/decoding/configuration.py @@ -76,7 +76,7 @@ class Config(NamedTuple): bear_dist: int = 20 #: Input coordinates are provided in an equal-area projection (i.e. NOT WGS84 lat/lon) equal_area: bool = False - equal_area_srid: int = 2163 + equal_area_srid: int = 9311 #: Timeout in seconds for single segment line decoding timeout: int = 50000 From 18a663867393dcc50ca2892862d0b663f3935e8d Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Tue, 12 Dec 2023 00:48:46 +0000 Subject: [PATCH 12/13] bug fix, equal area for matched route --- openlr_dereferencer/decoding/line_location.py | 2 +- openlr_dereferencer/decoding/path_math.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openlr_dereferencer/decoding/line_location.py b/openlr_dereferencer/decoding/line_location.py index 211fc3d..d6a56d4 100644 --- a/openlr_dereferencer/decoding/line_location.py +++ b/openlr_dereferencer/decoding/line_location.py @@ -76,4 +76,4 @@ def build_line_location(path: List[Route], reference: LineLocationReference, equ The result will be a trimmed list of Line objects, with minimized offset values""" p_off = reference.poffs * path[0].length() n_off = reference.noffs * path[-1].length() - return LineLocation(remove_offsets(combine_routes(path, equal_area), p_off, n_off)) + return LineLocation(remove_offsets(combine_routes(path, equal_area), p_off, n_off, equal_area)) diff --git a/openlr_dereferencer/decoding/path_math.py b/openlr_dereferencer/decoding/path_math.py index 54fdced..d0c64df 100644 --- a/openlr_dereferencer/decoding/path_math.py +++ b/openlr_dereferencer/decoding/path_math.py @@ -13,7 +13,7 @@ from ..maps import equal_area as ee -def remove_offsets(path: Route, p_off: float, n_off: float) -> Route: +def remove_offsets(path: Route, p_off: float, n_off: float, equal_area: bool = False) -> Route: """Remove start+end offsets, measured in meters, from a route and return the result""" debug(f"Will consider positive offset = {p_off} m and negative offset {n_off} m.") lines = path.lines @@ -39,9 +39,9 @@ def remove_offsets(path: Route, p_off: float, n_off: float) -> Route: else: end_line = start_line return Route( - PointOnLine.from_abs_offset(start_line, remaining_poff), + PointOnLine.from_abs_offset(start_line, remaining_poff, equal_area), lines, - PointOnLine.from_abs_offset(end_line, end_line.length - remaining_noff), + PointOnLine.from_abs_offset(end_line, end_line.length - remaining_noff, equal_area), ) From 87e4fd2adf6d900284ba93ed07511b1316b6cadb Mon Sep 17 00:00:00 2001 From: Max Gardner Date: Wed, 13 Dec 2023 23:21:40 +0000 Subject: [PATCH 13/13] convert generators to lists in order to use caching --- .../example_postgres_map/primitives.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/openlr_dereferencer/example_postgres_map/primitives.py b/openlr_dereferencer/example_postgres_map/primitives.py index d00f8d0..bfe0b36 100644 --- a/openlr_dereferencer/example_postgres_map/primitives.py +++ b/openlr_dereferencer/example_postgres_map/primitives.py @@ -189,29 +189,31 @@ def coordinates(self) -> Coordinates: def outgoing_lines(self) -> Iterable[Line]: stmt = f"SELECT line_id FROM {self.db_schema}.{self.lines_tbl_name} WHERE startnode = %s" self.map_reader.cursor.execute(stmt, (self.node_id,)) - for (line_id,) in self.map_reader.cursor.fetchall(): - yield Line(self.map_reader, line_id) + return [Line(self.map_reader, line_id[0]) for line_id in self.map_reader.cursor.fetchall()] @functools.cache def incoming_lines(self) -> Iterable[Line]: stmt = f"SELECT line_id FROM {self.db_schema}.{self.lines_tbl_name} WHERE endnode = %s" self.map_reader.cursor.execute(stmt, [self.node_id]) - for (line_id,) in self.map_reader.cursor.fetchall(): - yield Line(self.map_reader, line_id) + return [Line(self.map_reader, line_id[0]) for line_id in self.map_reader.cursor.fetchall()] @functools.cache def incoming_line_nodes(self) -> Iterable[LineNode]: stmt = f"SELECT startnode, endnode FROM {self.db_schema}.{self.lines_tbl_name} WHERE startnode = %s" self.map_reader.cursor.execute(stmt, (self.node_id,)) - for startnode, endnode in self.map_reader.cursor.fetchall(): - yield LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) + return [ + LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) + for startnode, endnode in self.map_reader.cursor.fetchall() + ] @functools.cache def outgoing_line_nodes(self) -> Iterable[LineNode]: stmt = f"SELECT startnode, endnode FROM {self.db_schema}.{self.lines_tbl_name} WHERE endnode = %s" self.map_reader.cursor.execute(stmt, (self.node_id,)) - for startnode, endnode in self.map_reader.cursor.fetchall(): - yield LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) + return [ + LineNode(Node(self.map_reader, startnode), Node(self.map_reader, endnode)) + for startnode, endnode in self.map_reader.cursor.fetchall() + ] def connected_lines(self) -> Iterable[Line]: return chain(self.incoming_lines(), self.outgoing_lines())