From 6c796ac8c18794b80bb9d16b6784eb6fe1efdd1f Mon Sep 17 00:00:00 2001 From: Alexey Zakharenkov Date: Thu, 23 Nov 2023 10:07:54 +0300 Subject: [PATCH] Check if stations are missing/differ in backward direction of some route --- subway_structure.py | 320 +++++++-- tests/assets/route_masters.osm | 527 ++++++++++++++ tests/assets/twin_routes.osm | 578 ++++++++++++++++ tests/assets/twin_routes_with_divergence.osm | 680 +++++++++++++++++++ tests/sample_data_for_error_messages.py | 76 ++- tests/sample_data_for_outputs.py | 10 +- tests/sample_data_for_twin_routes.py | 78 +++ tests/test_error_messages.py | 15 +- tests/test_route_master.py | 26 + 9 files changed, 2215 insertions(+), 95 deletions(-) create mode 100644 tests/assets/route_masters.osm create mode 100644 tests/assets/twin_routes.osm create mode 100644 tests/assets/twin_routes_with_divergence.osm create mode 100644 tests/sample_data_for_twin_routes.py create mode 100644 tests/test_route_master.py diff --git a/subway_structure.py b/subway_structure.py index bb38f85..e79d213 100644 --- a/subway_structure.py +++ b/subway_structure.py @@ -12,27 +12,26 @@ ALLOWED_STATIONS_MISMATCH = 0.02 # part of total station count ALLOWED_TRANSFERS_MISMATCH = 0.07 # part of total interchanges count ALLOWED_ANGLE_BETWEEN_STOPS = 45 # in degrees DISALLOWED_ANGLE_BETWEEN_STOPS = 20 # in degrees +SUGGEST_TRANSFER_MIN_DISTANCE = 100 # in meters # If an object was moved not too far compared to previous script run, # it is likely the same object DISPLACEMENT_TOLERANCE = 300 # in meters -MODES_RAPID = set(("subway", "light_rail", "monorail", "train")) -MODES_OVERGROUND = set(("tram", "bus", "trolleybus", "aerialway", "ferry")) -DEFAULT_MODES_RAPID = set(("subway", "light_rail")) -DEFAULT_MODES_OVERGROUND = set(("tram",)) # TODO: bus and trolleybus? +MODES_RAPID = {"subway", "light_rail", "monorail", "train"} +MODES_OVERGROUND = {"tram", "bus", "trolleybus", "aerialway", "ferry"} +DEFAULT_MODES_RAPID = {"subway", "light_rail"} +DEFAULT_MODES_OVERGROUND = {"tram"} # TODO: bus and trolleybus? ALL_MODES = MODES_RAPID | MODES_OVERGROUND -RAILWAY_TYPES = set( - ( - "rail", - "light_rail", - "subway", - "narrow_gauge", - "funicular", - "monorail", - "tram", - ) -) +RAILWAY_TYPES = { + "rail", + "light_rail", + "subway", + "narrow_gauge", + "funicular", + "monorail", + "tram", +} CONSTRUCTION_KEYS = ( "construction", "proposed", @@ -49,7 +48,7 @@ START_END_TIMES_RE = re.compile(r".*?(\d{2}):(\d{2})-(\d{2}):(\d{2}).*") def get_start_end_times(opening_hours): """Very simplified method to parse OSM opening_hours tag. We simply take the first HH:MM-HH:MM substring which is the most probable - opening hours interval for the most of weekdays. + opening hours interval for the most of the weekdays. """ start_time, end_time = None, None m = START_END_TIMES_RE.match(opening_hours) @@ -102,9 +101,9 @@ def el_center(el): if not el: return None if "lat" in el: - return (el["lon"], el["lat"]) + return el["lon"], el["lat"] elif "center" in el: - return (el["center"]["lon"], el["center"]["lat"]) + return el["center"]["lon"], el["center"]["lat"] return None @@ -485,7 +484,7 @@ class StopArea: self.center[i] /= len(self.stops) + len(self.platforms) def get_elements(self): - result = set([self.id, self.station.id]) + result = {self.id, self.station.id} result.update(self.entrances) result.update(self.exits) result.update(self.stops) @@ -1156,6 +1155,12 @@ class Route: return tracks + def are_tracks_complete(self) -> bool: + return ( + self.first_stop_on_rails_index == 0 + and self.last_stop_on_rails_index == len(self) - 1 + ) + def get_tracks_geometry(self): tracks = self.get_extended_tracks() tracks = self.get_truncated_tracks(tracks) @@ -1350,6 +1355,36 @@ class Route: ] return True + def get_end_transfers(self) -> tuple[str, str]: + """Using transfer ids because a train can arrive at different + stations within a transfer. But disregard transfer that may give + an impression of a circular route (for example, + Simonis / Elisabeth station and route 2 in Brussels). + """ + return ( + (self[0].stoparea.id, self[-1].stoparea.id) + if ( + self[0].stoparea.transfer is not None + and self[0].stoparea.transfer == self[-1].stoparea.transfer + ) + else ( + self[0].stoparea.transfer or self[0].stoparea.id, + self[-1].stoparea.transfer or self[-1].stoparea.id, + ) + ) + + def get_transfers_sequence(self) -> list[str]: + """Return a list of stoparea or transfer (if not None) ids.""" + transfer_seq = [ + stop.stoparea.transfer or stop.stoparea.id for stop in self + ] + if ( + self[0].stoparea.transfer is not None + and self[0].stoparea.transfer == self[-1].stoparea.transfer + ): + transfer_seq[0], transfer_seq[-1] = self.get_end_transfers() + return transfer_seq + def __len__(self): return len(self.stops) @@ -1479,13 +1514,75 @@ class RouteMaster: else: self.interval = min(self.interval, route.interval) + # Choose minimal id for determinancy if not self.has_master and (not self.id or self.id > route.id): self.id = route.id self.routes.append(route) - if not self.best or len(route.stops) > len(self.best.stops): + if ( + not self.best + or len(route.stops) > len(self.best.stops) + or ( + # Choose route with minimal id for determinancy + len(route.stops) == len(self.best.stops) + and route.element["id"] < self.best.element["id"] + ) + ): self.best = route + def get_meaningful_routes(self) -> list[Route]: + return [route for route in self if len(route) >= 2] + + def find_twin_routes(self) -> dict[Route, Route]: + """Two routes are twins if they have the same end stations + and opposite directions, and the number of stations is + the same or almost the same. We'll then find stops that are present + in one direction and is missing in another direction - to warn. + """ + + twin_routes = {} # route => "twin" route + + for route in self.get_meaningful_routes(): + if route.is_circular: + continue # Difficult to calculate. TODO(?) in the future + if route in twin_routes: + continue + if len(route) < 2: + continue + + route_transfer_ids = set(route.get_transfers_sequence()) + ends = route.get_end_transfers() + ends_reversed = ends[::-1] + + twin_candidates = [ + r + for r in self + if not r.is_circular + and r not in twin_routes + and r.get_end_transfers() == ends_reversed + # If absolute or relative difference in station count is large, + # possibly it's an express version of a route - skip it. + and ( + abs(len(r) - len(route)) <= 2 + or abs(len(r) - len(route)) / max(len(r), len(route)) + <= 0.2 + ) + ] + + if not twin_candidates: + continue + + twin_route = min( + twin_candidates, + key=lambda r: len( + route_transfer_ids ^ set(r.get_transfers_sequence()) + ), + ) + twin_routes[route] = twin_route + twin_routes[twin_route] = route + + return twin_routes + def stop_areas(self): """Returns a list of all stations on all route variants.""" seen_ids = set() @@ -1521,6 +1618,7 @@ class City: self.errors = [] self.warnings = [] self.notices = [] + self.id = None self.try_fill_int_attribute(city_data, "id") self.name = city_data["name"] self.country = city_data["country"] @@ -1555,7 +1653,7 @@ class City: else: self.modes = DEFAULT_MODES_RAPID else: - self.modes = set([x.strip() for x in networks[0].split(",")]) + self.modes = {x.strip() for x in networks[0].split(",")} # Reversing bbox so it is (xmin, ymin, xmax, ymax) bbox = city_data["bbox"].split(",") @@ -1627,7 +1725,7 @@ class City: self.warnings.append(msg) def error(self, message, el=None): - """Error if a critical problem that invalidates the city""" + """Error is a critical problem that invalidates the city.""" msg = City.log_message(message, el) self.errors.append(msg) @@ -1914,37 +2012,18 @@ class City: f"relations: {format_elid_list(not_in_sa)}" ) - def check_return_routes(self, rmaster): - variants = {} - have_return = set() - for variant in rmaster: - if len(variant) < 2: - continue - # Using transfer ids because a train can arrive at different - # stations within a transfer. But disregard transfer that may give - # an impression of a circular route (for example, - # Simonis / Elisabeth station and route 2 in Brussels) - if variant[0].stoparea.transfer == variant[-1].stoparea.transfer: - t = (variant[0].stoparea.id, variant[-1].stoparea.id) - else: - t = ( - variant[0].stoparea.transfer or variant[0].stoparea.id, - variant[-1].stoparea.transfer or variant[-1].stoparea.id, - ) - if t in variants: - continue - variants[t] = variant.element - tr = (t[1], t[0]) - if tr in variants: - have_return.add(t) - have_return.add(tr) + def check_return_routes(self, rmaster: RouteMaster) -> None: + """Check if a route has return direction, and if twin routes + miss stations. + """ + meaningful_routes = rmaster.get_meaningful_routes() - if len(variants) == 0: + if len(meaningful_routes) == 0: self.error( - "An empty route master {}. Please set construction:route " - "if it is under construction".format(rmaster.id) + f"An empty route master {rmaster.id}. " + "Please set construction:route if it is under construction" ) - elif len(variants) == 1: + elif len(meaningful_routes) == 1: log_function = ( self.error if not rmaster.best.is_circular else self.notice ) @@ -1954,9 +2033,144 @@ class City: rmaster.best.element, ) else: - for t, rel in variants.items(): - if t not in have_return: - self.notice("Route does not have a return direction", rel) + all_ends = { + route.get_end_transfers(): route for route in meaningful_routes + } + for route in meaningful_routes: + ends = route.get_end_transfers() + if ends[::-1] not in all_ends: + self.notice( + "Route does not have a return direction", route.element + ) + + twin_routes = rmaster.find_twin_routes() + for route1, route2 in twin_routes.items(): + if route1.id > route2.id: + continue # to process a pair of routes only once + # and to ensure the order of routes in the pair + self.alert_twin_routes_differ(route1, route2) + + def alert_twin_routes_differ(self, route1: Route, route2: Route) -> None: + """Arguments are that route1.id < route2.id""" + ( + stops_missing_from_route1, + stops_missing_from_route2, + stops_that_dont_match, + ) = self.calculate_twin_routes_diff(route1, route2) + + for st in stops_missing_from_route1: + if ( + not route1.are_tracks_complete() + or ( + projected_point := project_on_line( + st.stoparea.center, route1.tracks + )["projected_point"] + ) + is not None + and distance(st.stoparea.center, projected_point) + <= MAX_DISTANCE_STOP_TO_LINE + ): + self.notice( + f"Stop {st.stoparea.station.name} {st.stop} is included " + f"into the {route2.id} but not included into {route1.id}", + route1.element, + ) + + for st in stops_missing_from_route2: + if ( + not route2.are_tracks_complete() + or ( + projected_point := project_on_line( + st.stoparea.center, route2.tracks + )["projected_point"] + ) + is not None + and distance(st.stoparea.center, projected_point) + <= MAX_DISTANCE_STOP_TO_LINE + ): + self.notice( + f"Stop {st.stoparea.station.name} {st.stop} is included " + f"into the {route1.id} but not included into {route2.id}", + route2.element, + ) + + for st1, st2 in stops_that_dont_match: + if ( + st1.stoparea.station == st2.stoparea.station + or distance(st1.stop, st2.stop) < SUGGEST_TRANSFER_MIN_DISTANCE + ): + self.notice( + "Should there be one stoparea or a transfer between " + f"{st1.stoparea.station.name} {st1.stop} and " + f"{st2.stoparea.station.name} {st2.stop}?", + route1.element, + ) + + @staticmethod + def calculate_twin_routes_diff(route1: Route, route2: Route) -> tuple: + """Wagner–Fischer algorithm for stops diff in two twin routes.""" + + stops1 = route1.stops + stops2 = route2.stops[::-1] + + def stops_match(stop1: RouteStop, stop2: RouteStop) -> bool: + return ( + stop1.stoparea == stop2.stoparea + or stop1.stoparea.transfer is not None + and stop1.stoparea.transfer == stop2.stoparea.transfer + ) + + d = [[0] * (len(stops2) + 1) for _ in range(len(stops1) + 1)] + d[0] = list(range(len(stops2) + 1)) + for i in range(len(stops1) + 1): + d[i][0] = i + + for i in range(1, len(stops1) + 1): + for j in range(1, len(stops2) + 1): + d[i][j] = ( + d[i - 1][j - 1] + if stops_match(stops1[i - 1], stops2[j - 1]) + else min((d[i - 1][j], d[i][j - 1], d[i - 1][j - 1])) + 1 + ) + + stops_missing_from_route1: list[RouteStop] = [] + stops_missing_from_route2: list[RouteStop] = [] + stops_that_dont_match: list[tuple[RouteStop, RouteStop]] = [] + + i = len(stops1) + j = len(stops2) + while not (i == 0 and j == 0): + action = None + if i > 0 and j > 0: + match = stops_match(stops1[i - 1], stops2[j - 1]) + if match and d[i - 1][j - 1] == d[i][j]: + action = "no" + elif not match and d[i - 1][j - 1] + 1 == d[i][j]: + action = "change" + if not action and i > 0 and d[i - 1][j] + 1 == d[i][j]: + action = "add_2" + if not action and j > 0 and d[i][j - 1] + 1 == d[i][j]: + action = "add_1" + + match action: + case "add_1": + stops_missing_from_route1.append(stops2[j - 1]) + j -= 1 + case "add_2": + stops_missing_from_route2.append(stops1[i - 1]) + i -= 1 + case _: + if action == "change": + stops_that_dont_match.append( + (stops1[i - 1], stops2[j - 1]) + ) + i -= 1 + j -= 1 + return ( + stops_missing_from_route1, + stops_missing_from_route2, + stops_that_dont_match, + ) def validate_lines(self): self.found_light_lines = len( diff --git a/tests/assets/route_masters.osm b/tests/assets/route_masters.osm new file mode 100644 index 0000000..0635a2b --- /dev/null +++ b/tests/assets/route_masters.osm @@ -0,0 +1,527 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/assets/twin_routes.osm b/tests/assets/twin_routes.osm new file mode 100644 index 0000000..e2e7f42 --- /dev/null +++ b/tests/assets/twin_routes.osm @@ -0,0 +1,578 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/assets/twin_routes_with_divergence.osm b/tests/assets/twin_routes_with_divergence.osm new file mode 100644 index 0000000..057cca3 --- /dev/null +++ b/tests/assets/twin_routes_with_divergence.osm @@ -0,0 +1,680 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/sample_data_for_error_messages.py b/tests/sample_data_for_error_messages.py index 9bea1c7..245cfbb 100644 --- a/tests/sample_data_for_error_messages.py +++ b/tests/sample_data_for_error_messages.py @@ -42,11 +42,11 @@ metro_samples = [ "cities_info": [ { "num_stations": 2, + "num_lines": 1, + "num_light_lines": 0, + "num_interchanges": 0, }, ], - "num_lines": 1, - "num_light_lines": 0, - "num_interchanges": 0, "errors": [], "warnings": [], "notices": [], @@ -110,14 +110,9 @@ metro_samples = [ "num_stations": 4, }, ], - "num_lines": 1, - "num_light_lines": 0, - "num_interchanges": 0, "errors": [ - 'Angle between stops around "Station 3" (2.0, 0.0) ' - 'is too narrow, 0 degrees (relation 1, "Forward")', - 'Angle between stops around "Station 2" (1.0, 0.0) ' - 'is too narrow, 0 degrees (relation 1, "Forward")', + 'Angle between stops around "Station 3" (2.0, 0.0) is too narrow, 0 degrees (relation 1, "Forward")', # noqa: E501 + 'Angle between stops around "Station 2" (1.0, 0.0) is too narrow, 0 degrees (relation 1, "Forward")', # noqa: E501 ], "warnings": [], "notices": [], @@ -175,14 +170,9 @@ metro_samples = [ "num_stations": 3, }, ], - "num_lines": 1, - "num_light_lines": 0, - "num_interchanges": 0, "errors": [ - 'Angle between stops around "Station 2" (1.0, 0.0) ' - 'is too narrow, 11 degrees (relation 1, "Forward")', - 'Angle between stops around "Station 2" (1.0, 0.0) ' - 'is too narrow, 11 degrees (relation 2, "Backward")', + 'Angle between stops around "Station 2" (1.0, 0.0) is too narrow, 11 degrees (relation 1, "Forward")', # noqa: E501 + 'Angle between stops around "Station 2" (1.0, 0.0) is too narrow, 11 degrees (relation 2, "Backward")', # noqa: E501 ], "warnings": [], "notices": [], @@ -240,16 +230,11 @@ metro_samples = [ "num_stations": 3, }, ], - "num_lines": 1, - "num_light_lines": 0, - "num_interchanges": 0, "errors": [], "warnings": [], "notices": [ - 'Angle between stops around "Station 2" (1.0, 0.0) ' - 'is too narrow, 27 degrees (relation 1, "Forward")', - 'Angle between stops around "Station 2" (1.0, 0.0) ' - 'is too narrow, 27 degrees (relation 2, "Backward")', + 'Angle between stops around "Station 2" (1.0, 0.0) is too narrow, 27 degrees (relation 1, "Forward")', # noqa: E501 + 'Angle between stops around "Station 2" (1.0, 0.0) is too narrow, 27 degrees (relation 2, "Backward")', # noqa: E501 ], }, { @@ -326,16 +311,45 @@ metro_samples = [ "num_stations": 4, }, ], - "num_lines": 1, - "num_light_lines": 0, - "num_interchanges": 0, "errors": [ - 'Stops on tracks are unordered near "Station 2" (1.0, 0.0) ' - '(relation 1, "Forward")', - 'Stops on tracks are unordered near "Station 3" (0.0, 0.5) ' - '(relation 2, "Backward")', + 'Stops on tracks are unordered near "Station 2" (1.0, 0.0) (relation 1, "Forward")', # noqa: E501 + 'Stops on tracks are unordered near "Station 3" (0.0, 0.5) (relation 2, "Backward")', # noqa: E501 ], "warnings": [], "notices": [], }, + { + "name": ( + "Many different route masters, both on naked stations and " + "stop_positions/stop_areas/transfers, both linear and circular" + ), + "xml_file": "assets/route_masters.osm", + "cities_info": [ + { + "num_stations": (3 + 3 + 3 + 5 + 3 + 3 + 4) + + (3 + 3 + 3 + 3 + 3 + 3 + 4), + "num_lines": 7 + 7, + "num_interchanges": 0 + 1, + }, + ], + "errors": [ + 'Only one route in route_master. Please check if it needs a return route (relation 162, "03: 1-2-3")' # noqa: E501 + ], + "warnings": [], + "notices": [ + 'Route does not have a return direction (relation 155, "02: 1-2-3")', # noqa: E501 + 'Route does not have a return direction (relation 158, "02: 1-3 (2)")', # noqa: E501 + 'Only one route in route_master. Please check if it needs a return route (relation 159, "C: 1-3-5-1")', # noqa: E501 + 'Route does not have a return direction (relation 163, "04: 1-2-3")', # noqa: E501 + 'Route does not have a return direction (relation 164, "04: 2-1")', # noqa: E501 + 'Stop Station 2 (1.0, 0.0) is included into the r203 but not included into r204 (relation 204, "2: 3-1")', # noqa: E501 + 'Route does not have a return direction (relation 205, "3: 1-2-3")', # noqa: E501 + 'Route does not have a return direction (relation 206, "3: 1-2-3")', # noqa: E501 + 'Route does not have a return direction (relation 207, "4: 4-3-2-1")', # noqa: E501 + 'Route does not have a return direction (relation 208, "4: 1-2-3-4")', # noqa: E501 + 'Route does not have a return direction (relation 209, "5: 1-2-3")', # noqa: E501 + 'Route does not have a return direction (relation 210, "5: 2-1")', # noqa: E501 + 'Only one route in route_master. Please check if it needs a return route (relation 213, "C3: 1-2-3-8-1")', # noqa: E501 + ], + }, ] diff --git a/tests/sample_data_for_outputs.py b/tests/sample_data_for_outputs.py index 3c2a590..5419353 100644 --- a/tests/sample_data_for_outputs.py +++ b/tests/sample_data_for_outputs.py @@ -6,25 +6,17 @@ metro_samples = [ { "id": 1, "name": "Intersecting 2 metro lines", - "country": "World", - "continent": "Africa", - "num_stations": 6, + "num_stations": 4 + 2, "num_lines": 2, - "num_light_lines": 0, "num_interchanges": 1, - "bbox": "-179, -89, 179, 89", "networks": "network-1", }, { "id": 2, "name": "One light rail line", - "country": "World", - "continent": "Africa", "num_stations": 2, "num_lines": 0, "num_light_lines": 1, - "num_interchanges": 0, - "bbox": "-179, -89, 179, 89", "networks": "network-2", }, ], diff --git a/tests/sample_data_for_twin_routes.py b/tests/sample_data_for_twin_routes.py new file mode 100644 index 0000000..58b9e17 --- /dev/null +++ b/tests/sample_data_for_twin_routes.py @@ -0,0 +1,78 @@ +metro_samples = [ + { + "name": ( + "Many different routes, both on naked stations and stop_positions/stop_areas/transfers, both linear and circular" # noqa: E501 + ), + "xml_file": "assets/twin_routes.osm", + "cities_info": [ + { + "num_stations": (3 + 4 + 5 + 5) + (3 + 6 + 7 + 5 + 6 + 7 + 7), + "num_lines": 4 + 7, + "num_interchanges": 0 + 2, + }, + ], + "twin_routes": { # route master => twin routes + "r10021": {"r151": "r153", "r153": "r151"}, + "r10022": {}, + "r10023": {}, + "C": {}, + "r10001": {"r201": "r202", "r202": "r201"}, + "r10002": {}, + "r10003": {"r205": "r206", "r206": "r205"}, + "r10004": {}, + "r10005": {}, + "r10006": {}, + "C3": {}, + }, + "errors": [], + "warnings": [], + "notices": [ + 'Route does not have a return direction (relation 154, "02: 4-3")', + 'Route does not have a return direction (relation 155, "02: 1-3")', + 'Route does not have a return direction (relation 156, "02: 2-4")', + 'Route does not have a return direction (relation 157, "02: 4-1")', + 'Route does not have a return direction (relation 158, "02: 1-3 (2)")', # noqa: E501 + 'Only one route in route_master. Please check if it needs a return route (relation 159, "C: 1-2-3-4-5-1")', # noqa: E501 + 'Stop Station 4 (3.0, 0.0) is included into the r205 but not included into r206 (relation 206, "3: 7-6-5-3-2-1")', # noqa: E501 + 'Route does not have a return direction (relation 207, "4: 4-3-2-1")', # noqa: E501 + 'Route does not have a return direction (relation 208, "4: 1-2-3-4")', # noqa: E501 + 'Route does not have a return direction (relation 209, "5: 1-2-3-5-6-7")', # noqa: E501 + 'Route does not have a return direction (relation 210, "5: 6-5-3-2-1")', # noqa: E501 + 'Only one route in route_master. Please check if it needs a return route (relation 213, "C3: 1-2-3-5-6-7-8-1")', # noqa: E501 + ], + }, + { + "name": "Twin routes diverging for some extent", + "xml_file": "assets/twin_routes_with_divergence.osm", + "cities_info": [ + { + "num_stations": (22 + 22 + 21 + 21) * 2, + "num_lines": 4 * 2, + "num_interchanges": 0, + }, + ], + "twin_routes": { # route master => twin routes + "r1101": {"r101": "r102", "r102": "r101"}, + "r1102": {"r103": "r104", "r104": "r103"}, + "r1103": {"r105": "r106", "r106": "r105"}, + "r1104": {"r107": "r108", "r108": "r107"}, + "r1201": {"r201": "r202", "r202": "r201"}, + "r1202": {"r203": "r204", "r204": "r203"}, + "r1203": {"r205": "r206", "r206": "r205"}, + "r1204": {"r207": "r208", "r208": "r207"}, + }, + "errors": [], + "warnings": [], + "notices": [ + 'Should there be one stoparea or a transfer between Station 11 (0.1, 0.0) and Station 11(1) (0.1, 0.0003)? (relation 101, "1: 1-...-9-10-11-...-20")', # noqa: E501 + 'Should there be one stoparea or a transfer between Station 10 (0.09, 0.0) and Station 10(1) (0.09, 0.0003)? (relation 101, "1: 1-...-9-10-11-...-20")', # noqa: E501 + 'Stop Station 10 (0.09, 0.0) is included into the r105 but not included into r106 (relation 106, "3: 20-...-12-11(1)-9-...-1")', # noqa: E501 + 'Should there be one stoparea or a transfer between Station 11 (0.1, 0.0) and Station 11(1) (0.1, 0.0003)? (relation 105, "3: 1-...-9-10-11-...-20")', # noqa: E501 + 'Stop Station 10 (0.09, 0.0) is included into the r107 but not included into r108 (relation 108, "4: 20-...12-11(2)-9-...-1")', # noqa: E501 + 'Should there be one stoparea or a transfer between Station 11 (0.1, 0.0) and Station 11(1) (0.1, 0.0003)? (relation 201, "11: 1-...-9-10-11-...-20")', # noqa: E501 + 'Should there be one stoparea or a transfer between Station 10 (0.09, 0.0) and Station 10(1) (0.09, 0.0003)? (relation 201, "11: 1-...-9-10-11-...-20")', # noqa: E501 + 'Stop Station 10 (0.09, 0.0) is included into the r205 but not included into r206 (relation 206, "13: 20-...-12-11(1)-9-...-1")', # noqa: E501 + 'Should there be one stoparea or a transfer between Station 11 (0.1, 0.0) and Station 11(1) (0.1, 0.0003)? (relation 205, "13: 1-...-9-10-11-...-20")', # noqa: E501 + ], + }, +] diff --git a/tests/test_error_messages.py b/tests/test_error_messages.py index aee6f48..c833001 100644 --- a/tests/test_error_messages.py +++ b/tests/test_error_messages.py @@ -1,4 +1,11 @@ -from tests.sample_data_for_error_messages import metro_samples +import itertools + +from tests.sample_data_for_error_messages import ( + metro_samples as metro_samples_error, +) +from tests.sample_data_for_twin_routes import ( + metro_samples as metro_samples_route_masters, +) from tests.util import TestCase @@ -20,6 +27,10 @@ class TestValidationMessages(TestCase): ) def test_validation_messages(self) -> None: - for sample in metro_samples: + for sample in itertools.chain( + metro_samples_error, metro_samples_route_masters + ): + if "errors" not in sample: + continue with self.subTest(msg=sample["name"]): self._test_validation_messages_for_network(sample) diff --git a/tests/test_route_master.py b/tests/test_route_master.py new file mode 100644 index 0000000..1bab617 --- /dev/null +++ b/tests/test_route_master.py @@ -0,0 +1,26 @@ +from tests.util import TestCase + +from tests.sample_data_for_twin_routes import metro_samples + + +class TestRouteMaster(TestCase): + def _test_find_twin_routes_for_network(self, metro_sample: dict) -> None: + cities, transfers = self.prepare_cities(metro_sample) + city = cities[0] + + self.assertTrue(city.is_good) + + for route_master_id, expected_twin_ids in metro_sample[ + "twin_routes" + ].items(): + route_master = city.routes[route_master_id] + calculated_twins = route_master.find_twin_routes() + calculated_twin_ids = { + r1.id: r2.id for r1, r2 in calculated_twins.items() + } + self.assertDictEqual(expected_twin_ids, calculated_twin_ids) + + def test_find_twin_routes(self) -> None: + for sample in metro_samples: + with self.subTest(msg=sample["name"]): + self._test_find_twin_routes_for_network(sample)