Skip to content

Commit

Permalink
Process route duration (average vehicle speed)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexey-zakharenkov committed Apr 5, 2024
1 parent 6a4c2a2 commit f5cf824
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 90 deletions.
7 changes: 7 additions & 0 deletions subways/osm_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,10 @@ def el_center(el: OsmElementT) -> LonLat | None:
elif "center" in el:
return el["center"]["lon"], el["center"]["lat"]
return None


def get_network(relation: OsmElementT) -> str | None:
for k in ("network:metro", "network", "operator"):
if k in relation["tags"]:
return relation["tags"][k]
return None
2 changes: 2 additions & 0 deletions subways/processors/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

DEFAULT_INTERVAL = 2.5 * 60 # seconds
KMPH_TO_MPS = 1 / 3.6 # km/h to m/s conversion multiplier
DEFAULT_AVE_VEHICLE_SPEED = 40 * KMPH_TO_MPS # m/s
SPEED_ON_TRANSFER = 3.5 * KMPH_TO_MPS # m/s
TRANSFER_PENALTY = 30 # seconds

Expand Down Expand Up @@ -52,6 +53,7 @@ def transit_to_dict(cities: list[City], transfers: TransfersT) -> dict:
"start_time": route.start_time,
"end_time": route.end_time,
"interval": route.interval,
"duration": route.duration,
"stops": [
{
"stoparea_id": route_stop.stoparea.id,
Expand Down
14 changes: 14 additions & 0 deletions subways/processors/gtfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
from zipfile import ZipFile

from ._common import (
DEFAULT_AVE_VEHICLE_SPEED,
DEFAULT_INTERVAL,
format_colour,
KMPH_TO_MPS,
SPEED_ON_TRANSFER,
TRANSFER_PENALTY,
transit_to_dict,
Expand Down Expand Up @@ -63,6 +65,7 @@
"trip_route_type",
"route_pattern_id",
"bikes_allowed",
"average_speed", # extension field (km/h)
],
"stops": [
"stop_id",
Expand Down Expand Up @@ -242,11 +245,22 @@ def transit_data_to_gtfs(data: dict) -> dict:

for itinerary in route_master["itineraries"]:
shape_id = itinerary["id"][1:] # truncate leading 'r'
average_speed = round(
(
DEFAULT_AVE_VEHICLE_SPEED
if not itinerary["duration"]
else itinerary["stops"][-1]["distance"]
/ itinerary["duration"]
)
/ KMPH_TO_MPS,
1,
) # km/h
trip = {
"trip_id": itinerary["id"],
"route_id": route_master["id"],
"service_id": "always",
"shape_id": shape_id,
"average_speed": average_speed,
}
gtfs_data["trips"].append(trip)

Expand Down
4 changes: 2 additions & 2 deletions subways/processors/mapsme.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from subways.structure.station import Station
from subways.types import IdT, LonLat, OsmElementT, TransfersT
from ._common import (
DEFAULT_AVE_VEHICLE_SPEED,
DEFAULT_INTERVAL,
format_colour,
KMPH_TO_MPS,
Expand All @@ -29,7 +30,6 @@
OSM_TYPES = {"n": (0, "node"), "w": (2, "way"), "r": (3, "relation")}
ENTRANCE_PENALTY = 60 # seconds
SPEED_TO_ENTRANCE = 5 * KMPH_TO_MPS # m/s
SPEED_ON_LINE = 40 * KMPH_TO_MPS # m/s

# (stoparea1_uid, stoparea2_uid) -> seconds; stoparea1_uid < stoparea2_uid
TransferTimesT: TypeAlias = dict[tuple[int, int], int]
Expand Down Expand Up @@ -258,7 +258,7 @@ def find_exits_for_platform(
itin.append(
[
uid(stop.stoparea.id),
round(stop.distance / SPEED_ON_LINE),
round(stop.distance / DEFAULT_AVE_VEHICLE_SPEED),
]
)
# Make exits from platform nodes,
Expand Down
26 changes: 11 additions & 15 deletions subways/structure/city.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
DEFAULT_MODES_OVERGROUND,
DEFAULT_MODES_RAPID,
)
from subways.osm_element import el_center, el_id
from subways.osm_element import el_center, el_id, get_network
from subways.structure.route import Route
from subways.structure.route_master import RouteMaster
from subways.structure.station import Station
Expand Down Expand Up @@ -287,11 +287,11 @@ def extract_routes(self) -> None:
if el["tags"].get("access") in ("no", "private"):
continue
route_id = el_id(el)
master = self.masters.get(route_id, None)
master_element = self.masters.get(route_id, None)
if self.networks:
network = Route.get_network(el)
if master:
master_network = Route.get_network(master)
network = get_network(el)
if master_element:
master_network = get_network(master_element)
else:
master_network = None
if (
Expand All @@ -300,23 +300,19 @@ def extract_routes(self) -> None:
):
continue

route = self.route_class(el, self, master)
route = self.route_class(el, self, master_element)
if not route.stops:
self.warn("Route has no stops", el)
continue
elif len(route.stops) == 1:
self.warn("Route has only one stop", el)
continue

k = el_id(master) if master else route.ref
if k not in self.routes:
self.routes[k] = RouteMaster(self, master)
self.routes[k].add(route)

# Sometimes adding a route to a newly initialized RouteMaster
# can fail
if len(self.routes[k]) == 0:
del self.routes[k]
master_id = el_id(master_element) or route.ref
route_master = self.routes.setdefault(
master_id, RouteMaster(self, master_element)
)
route_master.add(route)

# And while we're iterating over relations, find interchanges
if (
Expand Down
137 changes: 86 additions & 51 deletions subways/structure/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import re
import typing
from collections.abc import Callable, Iterator
from collections.abc import Callable, Collection, Iterator
from itertools import islice

from subways.consts import (
Expand All @@ -18,7 +18,7 @@
find_segment,
project_on_line,
)
from subways.osm_element import el_id, el_center
from subways.osm_element import el_id, el_center, get_network
from subways.structure.route_stop import RouteStop
from subways.structure.station import Station
from subways.structure.stop_area import StopArea
Expand All @@ -33,24 +33,29 @@
DISALLOWED_ANGLE_BETWEEN_STOPS = 20 # in degrees


def get_start_end_times(
def parse_time_range(
opening_hours: str,
) -> tuple[tuple[int, int], tuple[int, int]] | tuple[None, None]:
) -> tuple[tuple[int, int], tuple[int, int]] | None:
"""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 the weekdays.
"""
start_time, end_time = None, None
if opening_hours == "24/7":
return (0, 0), (24, 0)

m = START_END_TIMES_RE.match(opening_hours)
if m:
ints = tuple(map(int, m.groups()))
start_time = (ints[0], ints[1])
end_time = (ints[2], ints[3])
if not m:
return None
ints = tuple(map(int, m.groups()))
if ints[1] > 59 or ints[3] > 59:
return None
start_time = (ints[0], ints[1])
end_time = (ints[2], ints[3])
return start_time, end_time


def osm_interval_to_seconds(interval_str: str) -> int | None:
"""Convert to int an OSM value for 'interval'/'headway' tag
"""Convert to int an OSM value for 'interval'/'headway'/'duration' tag
which may be in these formats:
HH:MM:SS,
HH:MM,
Expand All @@ -71,7 +76,54 @@ def osm_interval_to_seconds(interval_str: str) -> int | None:
return None
except ValueError:
return None
return seconds + 60 * minutes + 60 * 60 * hours

if seconds < 0 or minutes < 0 or hours < 0:
return None
if semicolon_count > 0 and (seconds >= 60 or minutes >= 60):
return None

interval = seconds + 60 * minutes + 60 * 60 * hours
if interval == 0:
return None
return interval


def get_interval_in_seconds_from_tags(
tags: dict, keys: str | Collection[str]
) -> int | None:
"""Extract time interval value from tags for keys among "keys".
E.g., "interval" and "headway" means the same in OSM.
Examples:
interval=5 => 300
headway:peak=00:01:30 => 90
"""
if isinstance(keys, str):
keys = (keys,)

value = None
for key in keys:
if key in tags:
value = tags[key]
break
if value is None:
for key in keys:
if value:
break
for tag_name in tags:
if tag_name.startswith(key + ":"):
value = tags[tag_name]
break
if not value:
return None
return osm_interval_to_seconds(value)


def get_route_interval(tags: dict) -> int | None:
return get_interval_in_seconds_from_tags(tags, ("interval", "headway"))


def get_route_duration(tags: dict) -> int | None:
return get_interval_in_seconds_from_tags(tags, "duration")


class Route:
Expand All @@ -95,29 +147,6 @@ def is_route(el: OsmElementT, modes: set[str]) -> bool:
return False
return True

@staticmethod
def get_network(relation: OsmElementT) -> str | None:
for k in ("network:metro", "network", "operator"):
if k in relation["tags"]:
return relation["tags"][k]
return None

@staticmethod
def get_interval(tags: dict) -> int | None:
v = None
for k in ("interval", "headway"):
if k in tags:
v = tags[k]
break
else:
for kk in tags:
if kk.startswith(k + ":"):
v = tags[kk]
break
if not v:
return None
return osm_interval_to_seconds(v)

def stopareas(self) -> Iterator[StopArea]:
yielded_stopareas = set()
for route_stop in self:
Expand Down Expand Up @@ -146,6 +175,7 @@ def __init__(
self.infill = None
self.network = None
self.interval = None
self.duration = None
self.start_time = None
self.end_time = None
self.is_circular = False
Expand Down Expand Up @@ -319,46 +349,51 @@ def calculate_distances(self) -> None:

def process_tags(self, master: OsmElementT) -> None:
relation = self.element
tags = relation["tags"]
master_tags = {} if not master else master["tags"]
if "ref" not in relation["tags"] and "ref" not in master_tags:
if "ref" not in tags and "ref" not in master_tags:
self.city.notice("Missing ref on a route", relation)
self.ref = relation["tags"].get(
"ref", master_tags.get("ref", relation["tags"].get("name", None))
self.ref = tags.get(
"ref", master_tags.get("ref", tags.get("name", None))
)
self.name = relation["tags"].get("name", None)
self.mode = relation["tags"]["route"]
self.name = tags.get("name", None)
self.mode = tags["route"]
if (
"colour" not in relation["tags"]
"colour" not in tags
and "colour" not in master_tags
and self.mode != "tram"
):
self.city.notice("Missing colour on a route", relation)
try:
self.colour = normalize_colour(
relation["tags"].get("colour", master_tags.get("colour", None))
tags.get("colour", master_tags.get("colour", None))
)
except ValueError as e:
self.colour = None
self.city.warn(str(e), relation)
try:
self.infill = normalize_colour(
relation["tags"].get(
tags.get(
"colour:infill", master_tags.get("colour:infill", None)
)
)
except ValueError as e:
self.infill = None
self.city.warn(str(e), relation)
self.network = Route.get_network(relation)
self.interval = Route.get_interval(
relation["tags"]
) or Route.get_interval(master_tags)
self.start_time, self.end_time = get_start_end_times(
relation["tags"].get(
"opening_hours", master_tags.get("opening_hours", "")
)
self.network = get_network(relation)
self.interval = get_route_interval(tags) or get_route_interval(
master_tags
)
if relation["tags"].get("public_transport:version") == "1":
self.duration = get_route_duration(tags) or get_route_duration(
master_tags
)
parsed_time_range = parse_time_range(
tags.get("opening_hours", master_tags.get("opening_hours", ""))
)
if parsed_time_range:
self.start_time, self.end_time = parsed_time_range

if tags.get("public_transport:version") == "1":
self.city.warn(
"Public transport version is 1, which means the route "
"is an unsorted pile of objects",
Expand Down
Loading

0 comments on commit f5cf824

Please sign in to comment.