aio_overpass.pt

Classes and queries specialized on public transportation routes.

  1"""Classes and queries specialized on public transportation routes."""
  2
  3from collections import Counter
  4from collections.abc import Generator
  5from dataclasses import dataclass
  6from enum import Enum, auto
  7from typing import Any, Final, TypeGuard
  8
  9from aio_overpass._dist import fast_distance
 10from aio_overpass.element import Bbox, Element, Node, Relation, Relationship, Way, collect_elements
 11from aio_overpass.ql import one_of_filter, poly_clause
 12from aio_overpass.query import Query
 13from aio_overpass.spatial import GeoJsonDict, Spatial
 14
 15import shapely.ops
 16from shapely.geometry import GeometryCollection, Point, Polygon
 17
 18
 19__docformat__ = "google"
 20__all__ = (
 21    "collect_routes",
 22    "RouteQuery",
 23    "SingleRouteQuery",
 24    "RoutesWithinQuery",
 25    "Route",
 26    "Vehicle",
 27    "Stop",
 28    "Connection",
 29    "RouteScheme",
 30)
 31
 32
 33class RouteQuery(Query):
 34    """
 35    Base class for queries that produce ``Route`` and ``RouteSegment`` objects.
 36
 37    Be aware that to build full ``RouteSegment`` objects with tags and geometry, this query loads:
 38     - every route member
 39     - every stop area any stop on the route is related to
 40     - every stop in one of those stop areas
 41
 42    Args:
 43        input_code: A query that puts the desired routes into the ``.routes`` set,
 44                     and optionally, its route masters into the ``.masters`` set
 45                     (for example by recursing up from ``.routes``).
 46    """
 47
 48    __slots__ = ()
 49
 50    def __init__(
 51        self,
 52        input_code: str,
 53        **kwargs: Any,  # noqa: ANN401
 54    ) -> None:
 55        input_code = f"""
 56            {input_code}
 57            .routes >> -> .route_members;
 58            way.route_members -> .route_ways;
 59
 60            (
 61                node.route_members[highway=bus_stop];
 62                node.route_members[public_transport];
 63                way .route_members[public_transport];
 64                rel .route_members[public_transport];
 65            ) -> .route_pt_members;
 66
 67            .route_pt_members <;
 68            rel._[public_transport=stop_area]->.stop_areas;
 69            node(r.stop_areas:"stop")[public_transport=stop_position]->.stop_area_stops;
 70
 71            .masters out;
 72            .routes out geom;
 73            .route_ways out tags;
 74            .route_pt_members out geom;
 75            .stop_areas out;
 76            .stop_area_stops out;
 77        """
 78
 79        super().__init__(input_code, **kwargs)
 80
 81
 82class SingleRouteQuery(RouteQuery):
 83    """
 84    A query that produces a single ``Route`` object.
 85
 86    Args:
 87        relation_id: the desired route's relation ID
 88    """
 89
 90    __slots__ = ("relation_id",)
 91
 92    def __init__(
 93        self,
 94        relation_id: int,
 95        **kwargs: Any,  # noqa: ANN401
 96    ) -> None:
 97        self.relation_id = relation_id
 98
 99        input_code = f"""
100            rel({self.relation_id});
101            rel._[type=route]->.routes;
102            .routes <<;
103            rel._[type=route_master]->.masters;
104        """
105
106        super().__init__(input_code, **kwargs)
107
108
109class RoutesWithinQuery(RouteQuery):
110    """
111    A query that produces ``Route`` objects for any route within the exterior of a polygon.
112
113    Args:
114        polygon: Any route that has at least one member element within this shape
115                 will be in the result set of this query. Note that the route members
116                 are not limited to this polygon - the majority of a route may in fact
117                 be outside of it. This shape should be simplified, since a larger number
118                 of coordinates on the exterior will slow down the query.
119        vehicles: A list of transportation modes to filter by, or an empty list to include
120                  routes of any type. A non-empty list will filter routes by the ``route``
121                  key.
122    """
123
124    __slots__ = (
125        "polygon",
126        "vehicles",
127    )
128
129    def __init__(
130        self,
131        polygon: Polygon,
132        vehicles: list["Vehicle"] | None = None,
133        **kwargs: Any,  # noqa: ANN401
134    ) -> None:
135        if not vehicles:
136            vehicles = list(Vehicle)
137
138        self.polygon = polygon
139        self.vehicles = vehicles
140
141        region_clause = poly_clause(self.polygon)
142        route_filter = one_of_filter("route", *(v.name.lower() for v in vehicles))
143        input_code = f"""
144            rel{region_clause}{route_filter}[type=route]->.routes;
145            rel{region_clause}{route_filter}[type=route_master]->.masters;
146        """
147
148        super().__init__(input_code, **kwargs)
149
150
151class _RouteRole(Enum):
152    """
153    A role in a route relation that is relevant to public transportation.
154
155    References:
156        - https://taginfo.openstreetmap.org/relations/route#roles
157    """
158
159    STOP = auto()
160    PLATFORM = auto()
161    NONE = auto()
162
163
164class Connection(Enum):
165    """
166    Indicates whether you can enter, exit, or do both at a stop on a route.
167
168    References:
169        - https://taginfo.openstreetmap.org/relations/route#roles
170    """
171
172    ENTRY_AND_EXIT = auto()
173    ENTRY_ONLY = auto()
174    EXIT_ONLY = auto()
175
176    @property
177    def entry_possible(self) -> bool:
178        """``True`` if you can enter at this stop on the route."""
179        return self != Connection.EXIT_ONLY
180
181    @property
182    def exit_possible(self) -> bool:
183        """``True`` if you can exit at this stop on the route."""
184        return self != Connection.ENTRY_ONLY
185
186    def __repr__(self) -> str:
187        return f"{type(self).__name__}.{self.name}"
188
189
190@dataclass(kw_only=True, slots=True)
191class Stop(Spatial):
192    """
193    A stop on a public transportation route.
194
195    Typically, a stop is modelled as two members in a relation: a stop_position node with the
196    'stop' role, and a platform with the 'platform' role. These members may be grouped in
197    a common stop_area.
198
199    Attributes:
200        idx: stop index on the route
201        platform: the platform node, way or relation associated with this stop, if any
202        stop_position: the stop position node associated with this stop, if any
203        stop_coords: a point that, compared to ``stop_position``, is guaranteed to be on the
204                     track of the route whenever it is set. Only set if you are collecting
205                     ``RouteSegments``.
206    """
207
208    idx: int
209    platform: Relationship | None
210    stop_position: Relationship | None
211    stop_coords: Node | Point | None
212
213    @property
214    def name(self) -> str | None:
215        """
216        This stop's name.
217
218        If platform and stop position names are the same, that name will be returned.
219        Otherwise, the most common name out of platform, stop position, and all related stop areas
220        will be returned.
221        """
222        stop_pos_name = self.stop_position.member.tag("name") if self.stop_position else None
223        platform_name = self.platform.member.tag("name") if self.platform else None
224
225        if stop_pos_name == platform_name and stop_pos_name is not None:
226            return stop_pos_name
227
228        names = [stop_pos_name, platform_name, *(rel.tag("name") for rel in self.stop_areas)]
229        names = [name for name in names if name]
230
231        if not names:
232            return None
233
234        counter = Counter(names)
235        ((most_common, _),) = counter.most_common(1)
236        return most_common
237
238    @property
239    def connection(self) -> Connection:
240        """Indicates whether you can enter, exit, or do both at this stop."""
241        options = [
242            _connection(relship) for relship in (self.stop_position, self.platform) if relship
243        ]
244        return next(
245            (opt for opt in options if opt != Connection.ENTRY_AND_EXIT), Connection.ENTRY_AND_EXIT
246        )
247
248    @property
249    def stop_areas(self) -> set[Relation]:
250        """Any stop area related to this stop."""
251        return {
252            relship_to_stop_area.relation
253            for relship_to_route in (self.platform, self.stop_position)
254            if relship_to_route
255            for relship_to_stop_area in relship_to_route.member.relations
256            if relship_to_stop_area.relation.tag("public_transport") == "stop_area"
257        }
258
259    @property
260    def geojson(self) -> GeoJsonDict:
261        """A mapping of this object, using the GeoJSON format."""
262        # TODO: Stop geojson
263        raise NotImplementedError
264
265    @property
266    def _stop_point(self) -> Point | None:
267        """This is set if we have a point that is on the track of the route."""
268        if isinstance(self.stop_coords, Node):
269            return self.stop_coords.geometry
270        if isinstance(self.stop_coords, Point):
271            return self.stop_coords
272        return None
273
274    @property
275    def _geometry(self) -> GeometryCollection:
276        """Collection of ``self.platform``, ``self.stop_position`` and  ``self.stop_coords``."""
277        geoms = []
278
279        # geometry can be None if the platform is a relation
280        if self.platform and self.platform.member.base_geometry:
281            geoms.append(self.platform.member.base_geometry)
282
283        if self.stop_position:
284            assert isinstance(self.stop_position.member, Node)
285            geoms.append(self.stop_position.member.geometry)
286
287        if isinstance(self.stop_coords, Point) and self.stop_coords not in geoms:
288            geoms.append(self.stop_coords)
289
290        return GeometryCollection(geoms)
291
292    def __repr__(self) -> str:
293        if self.stop_position:
294            elem = f"stop_position={self.stop_position.member}"
295        elif self.platform:
296            elem = f"platform={self.platform.member}"
297        else:
298            raise AssertionError
299
300        return f"{type(self).__name__}({elem}, name='{self.name}')"
301
302
303class Vehicle(Enum):
304    """
305    Most common modes of public transportation.
306
307    References:
308        - https://wiki.openstreetmap.org/wiki/Relation:route#Public_transport_routes
309    """
310
311    # motor vehicles
312    BUS = auto()
313    TROLLEYBUS = auto()
314    MINIBUS = auto()
315    SHARE_TAXI = auto()
316
317    # railway vehicles
318    TRAIN = auto()
319    LIGHT_RAIL = auto()
320    SUBWAY = auto()
321    TRAM = auto()
322
323    # boats
324    FERRY = auto()
325
326    def __repr__(self) -> str:
327        return f"{type(self).__name__}.{self.name}"
328
329
330class RouteScheme(Enum):
331    """
332    Tagging schemes for public transportation routes.
333
334    References:
335        - https://wiki.openstreetmap.org/wiki/Public_transport#Different_tagging_schemas
336        - https://wiki.openstreetmap.org/wiki/Key:public_transport:version
337    """
338
339    EXPLICIT_V1 = auto()
340    EXPLICIT_V2 = auto()
341
342    ASSUME_V1 = auto()
343    ASSUME_V2 = auto()
344
345    OTHER = auto()
346
347    @property
348    def version_number(self) -> int | None:
349        """Public transport tagging scheme."""
350        match self:
351            case RouteScheme.EXPLICIT_V1 | RouteScheme.ASSUME_V1:
352                return 1
353            case RouteScheme.EXPLICIT_V2 | RouteScheme.ASSUME_V2:
354                return 2
355            case RouteScheme.OTHER:
356                return None
357            case _:
358                raise AssertionError
359
360    def __repr__(self) -> str:
361        return f"{type(self).__name__}.{self.name}"
362
363
364@dataclass(kw_only=True, slots=True)
365class Route(Spatial):
366    """
367    A public transportation service route, e.g. a bus line.
368
369    Instances of this class are meant to represent OSM routes using the
370    'Public Transport Version 2' (PTv2) scheme. Compared to PTv1, this means f.e. that routes
371    with multiple directions (forwards, backwards) are represented by multiple route elements.
372
373    Attributes:
374        relation: the underlying relation that describes the path taken by a route,
375                  and places where people can embark and disembark from the transit service
376        scheme: The tagging scheme that was either assumed, or explicitly set on the route relation.
377        stops: a sorted list of stops on this route as they would appear on its timetable,
378               which was derived from the ``relation``
379
380    References:
381        - https://wiki.openstreetmap.org/wiki/Public_transport
382        - https://wiki.openstreetmap.org/wiki/Relation:route#Public_transport_routes
383    """
384
385    relation: Relation
386    scheme: RouteScheme
387    stops: list[Stop]
388
389    @property
390    def id(self) -> int:
391        """Route relation ID."""
392        return self.relation.id
393
394    @property
395    def tags(self) -> dict[str, str]:
396        """
397        Tags of the route relation.
398
399        Some tags can be inherited from a route master, if not set already.
400        """
401        from_relation = self.relation.tags or {}
402        from_master = {}
403
404        masters = self.masters
405        master = masters[0] if len(masters) == 1 else None
406
407        if master and master.tags is not None:
408            from_master = {k: v for k, v in master.tags.items() if k in _TAGS_FROM_ROUTE_MASTER}
409
410        return from_master | from_relation
411
412    def tag(self, key: str, default: str | None = None) -> str | None:
413        """
414        Get the tag value for the given key.
415
416        Some tags can be inherited from a route master, if not set already.
417        """
418        value = self.relation.tag(key, default)
419        if value is not default:
420            return value
421
422        if key not in _TAGS_FROM_ROUTE_MASTER:
423            return default
424
425        masters = self.masters
426        master = masters[0] if len(masters) == 1 else None
427
428        if not master:
429            return default
430
431        return master.tag(key, default)
432
433    @property
434    def ways(self) -> list[Way]:
435        """
436        The ways making up the path of the route.
437
438        Ways may be ordered, and appear multiple times if a way is travelled on more than once.
439        All the ways may be contiguous, but gaps are not uncommon.
440        """
441        return [
442            relship.member
443            for relship in self.relation.members
444            if isinstance(relship.member, Way) and _role(relship) == _RouteRole.NONE
445        ]
446
447    @property
448    def masters(self) -> list[Relation]:
449        """
450        Route master relations this route is a part of.
451
452        By convention, this should be a single relation at most.
453
454        References:
455            - https://wiki.openstreetmap.org/wiki/Relation:route_master
456        """
457        return [
458            relship.relation
459            for relship in self.relation.relations
460            if relship.relation.tag("type") == "route_master"
461        ]
462
463    @property
464    def name_from(self) -> str | None:
465        """
466        Name of the start station.
467
468        This is either the value of the relation's ``from`` key, a name derived
469        from the route's first stop (see ``Stop.name``), or ``None`` if neither
470        is available.
471        """
472        return self.relation.tag(
473            key="from",
474            default=self.stops[0].name if self.stops else None,
475        )
476
477    @property
478    def name_to(self) -> str | None:
479        """
480        Name of the end station.
481
482        This is either the value of the relation's ``to`` key, a name derived
483        from the route's last stop (see ``Stop.name``), or ``None`` if neither
484        is available.
485        """
486        return self.relation.tag(
487            key="to",
488            default=self.stops[-1].name if len(self.stops) > 1 else None,
489        )
490
491    @property
492    def name_via(self) -> str | None:
493        """
494        A name of an important station along the route.
495
496        This is either the value of the relation's ``via`` key, or ``None`` if it is not set.
497        """
498        return self.relation.tag("via")
499
500    @property
501    def name(self) -> str | None:
502        """
503        The name of the route.
504
505        This is either the value relation's ``name`` key, ``{from_} => {via} => {to}`` if at
506        least ``from`` and ``to`` are set, or ``None`` otherwise.
507        """
508        name_tag = self.tag("name")
509        if name_tag:
510            return name_tag
511
512        from_ = self.name_from
513        via = self.name_via
514        to = self.name_to
515
516        if from_ and to and via:
517            return f"{from_} => {via} => {to}"
518
519        if from_ and to:
520            return f"{from_} => {to}"
521
522        return None
523
524    @property
525    def vehicle(self) -> Vehicle:
526        """
527        The mode of transportation used on this route.
528
529        This value corresponds with the value of the relation's ``route`` key.
530        """
531        if not self.relation.tags or "route" not in self.relation.tags:
532            raise AssertionError
533        return Vehicle[self.relation.tags["route"].upper()]
534
535    @property
536    def bounds(self) -> Bbox | None:
537        """The bounding box around all stops of this route."""
538        geom = GeometryCollection([stop._geometry for stop in self.stops if stop._geometry])
539        return geom.bounds or None
540
541    @property
542    def geojson(self) -> GeoJsonDict:
543        """A mapping of this object, using the GeoJSON format."""
544        # TODO: Route geojson
545        raise NotImplementedError
546
547    def __repr__(self) -> str:
548        return f"{type(self).__name__}(id={self.relation.id}, name='{self.name}')"
549
550
551_TAGS_FROM_ROUTE_MASTER: Final[set[str]] = {
552    "colour",
553    "interval",
554    "name",
555    "network",
556    "opening_hours",
557    "operator",
558    "ref",
559    "school",
560    "tourism",
561    "wheelchair",
562}
563"""
564Tags that, by convention, can be applied to route masters instead of their routes,
565in order to avoid duplication.
566
567References:
568    - https://wiki.openstreetmap.org/wiki/Relation:route_master
569"""
570
571
572def collect_routes(query: RouteQuery, perimeter: Polygon | None = None) -> list[Route]:
573    # TODO: the way 'perimeter' works might be confusing
574    """
575    Consumes the result set of a query and produces ``Route`` objects.
576
577    The order of elements is *not* retained.
578
579    The result set in the input query will be empty after calling this function.
580
581    Args:
582        query: The query that produced a result set of route relations.
583        perimeter: If set, ``stops`` at the end will be truncated if they are outside of this
584                   polygon. This means stops outside the polygon will still be in the list as
585                   long as there is at least one following stop that *is* inside the list.
586                   Any relation members in ``members`` will not be filtered.
587
588    Raises:
589        ValueError: if the input query has no result set
590
591    Returns:
592        all routes in the result set of the input query
593    """
594    elements = collect_elements(query)
595    route_rels = [elem for elem in elements if _is_tagged_route_relation(elem)]
596
597    routes = []
598
599    for route_rel in route_rels:
600        # Group route relation members per stop:
601        # f.e. a platform, and a stop position at the same station.
602        stops = list(_stops(route_rel))
603
604        # Filter stops by perimeter: cut off at the last stop that is in the perimeter.
605        if perimeter:
606            while stops and not perimeter.contains(stops[-1]._stop_point):
607                del stops[-1]
608
609        route = Route(
610            relation=route_rel,
611            scheme=_scheme(route_rel),
612            stops=stops,
613        )
614
615        routes.append(route)
616
617    return routes
618
619
620def _is_tagged_route_relation(elem: Element) -> TypeGuard[Relation]:
621    return isinstance(elem, Relation) and elem.tag("type") == "route"
622
623
624def _scheme(route: Relation) -> RouteScheme:
625    """Try to identify a route's tagging scheme."""
626    tagged_version = route.tag("public_transport:version")
627
628    match tagged_version:
629        case "1":
630            return RouteScheme.EXPLICIT_V1
631        case "2":
632            return RouteScheme.EXPLICIT_V2
633        case _ if tagged_version:
634            return RouteScheme.OTHER
635
636    # any directed and/or numbered tags like "forward:stop:1" indicate PTv1
637    directions = {
638        "forward:",
639        "forward_",
640        "backward:",
641        "backward_",
642    }
643    assume_v1 = any(
644        (
645            role and (role.startswith(prefix) or role[-1].isnumeric())
646            for role, _ in route
647            for prefix in directions
648        )
649    )
650
651    return RouteScheme.ASSUME_V1 if assume_v1 else RouteScheme.ASSUME_V2
652
653
654def _stops(route_relation: Relation) -> Generator[Stop, None, None]:
655    """
656    Group route relation members so that each member belongs to the same stop along the route.
657
658    Typically, a stop is represented by a stop position or platform, or both.
659    """
660    idx = 0
661
662    def to_stop(*selected: Relationship) -> Stop:
663        nonlocal idx
664        stop_idx = idx
665        idx += 1
666        return Stop(
667            idx=stop_idx,
668            platform=next(
669                (relship for relship in selected if _role(relship) == _RouteRole.PLATFORM), None
670            ),
671            stop_position=next(
672                (relship for relship in selected if _role(relship) == _RouteRole.STOP), None
673            ),
674            stop_coords=None,  # set later
675        )
676
677    # Consider all route relation members that are tagged or given a role that makes them
678    # relevant for public transportation.
679    route_members = [
680        relship for relship in route_relation.members if _role(relship) is not _RouteRole.NONE
681    ]
682
683    # no more than two elements per group (best case: roles "stop" & "platform")
684    prev: Relationship | None = None
685    for next_ in route_members:
686        if not prev:  # case 1: no two members to compare yet
687            prev = next_
688            continue
689
690        if not _at_same_stop(prev, next_):  # case 2: members are not part of same station
691            yield to_stop(prev)
692            prev = next_
693            continue
694
695        yield to_stop(prev, next_)  # case 3: members are part of same station
696        prev = None
697
698    if prev:
699        yield to_stop(prev)
700
701
702def _connection(relship: Relationship) -> Connection:
703    """Returns the connection at the route member, according to its role."""
704    if relship.role:
705        if relship.role.endswith("_entry_only"):
706            return Connection.ENTRY_ONLY
707
708        if relship.role.endswith("_exit_only"):
709            return Connection.EXIT_ONLY
710
711    return Connection.ENTRY_AND_EXIT
712
713
714def _role(relship: Relationship) -> _RouteRole:
715    """
716    Returns the route member's tagged role, or a fitting fallback.
717
718    If a member is tagged as platform or stop_position, use the role as is.
719    Otherwise, assign the roles that fit the member. This means that if there's
720    a platform in the route relation, we'll recognize it as such, even if it has
721    not been given the platform role (perhaps due to oversight by a contributor).
722    """
723    if relship.role:
724        if relship.role.startswith("platform") or relship.role == "hail_and_ride":
725            return _RouteRole.PLATFORM
726
727        if relship.role.startswith("stop"):
728            return _RouteRole.STOP
729
730    if (
731        relship.member.tag("public_transport") == "platform"
732        or relship.member.tag("highway") == "bus_stop"
733    ):
734        return _RouteRole.PLATFORM
735
736    # The correct tag for this is [public_transport=stop_position], but we'll accept
737    # values like [public_transport=station] as well, as long as the member is a node.
738    # The assumption is that any node that is not representing a platform is *probably*
739    # supposed to represent the stop position.
740    if relship.member.type == "node" and relship.member.tag("public_transport"):
741        return _RouteRole.STOP
742
743    return _RouteRole.NONE
744
745
746def _share_stop_area(a: Relationship, b: Relationship) -> bool:
747    """``True`` if the given route members share least one common stop area."""
748    a_areas = {
749        relship.relation.id
750        for relship in a.member.relations
751        if relship.relation.tag("public_transport") == "stop_area"
752    }
753    b_areas = {
754        relship.relation.id
755        for relship in b.member.relations
756        if relship.relation.tag("public_transport") == "stop_area"
757    }
758    return not a_areas.isdisjoint(b_areas)
759
760
761def _connection_compatible(a: Connection, b: Connection) -> bool:
762    """
763    Check whether two connections are compatible.
764
765    Returns:
766        ``False`` if one of the connections is entry-only, while the other is exit-only,
767        ``True`` otherwise.
768    """
769    return Connection.ENTRY_AND_EXIT in (a, b) or a == b
770
771
772def _at_same_stop(a: Relationship, b: Relationship) -> bool:
773    """
774    Check if two members of a route belong to the same stop in the timetable.
775
776    Looking at the route on a map makes it trivial to group elements as part of the same station,
777    but automating this requires some heuristics: we will look at members' type, role, name, and
778    their proximity to each other.
779
780    If members grouped by this function are far apart, they might be mistagged, i.e. there was a
781    mix-up with either the stop position, platform or order (f.e. having "forward" & "backward"
782    stops listed consecutively in the route, instead of their correct timetable order).
783    """
784    # require not both exit_only & enter_only at same stop
785    if not _connection_compatible(_connection(a), _connection(b)):
786        return False
787
788    # require no duplicate role at same stop
789    if _role(a) == _role(b) and _role(a) is not _RouteRole.NONE:
790        return False
791
792    # same name, assume same stop
793    if a.member.tag("name") and a.member.tag("name") == b.member.tag("name"):
794        return True
795
796    # same stop area, assume same stop
797    if _share_stop_area(a, b):
798        return True
799
800    # assume same stop if close together
801    if not a.member.base_geometry or not b.member.base_geometry:
802        return False
803
804    # euclidean nearest
805    pt_a, pt_b = shapely.ops.nearest_points(a.member.base_geometry, b.member.base_geometry)
806    distance = fast_distance(*pt_a.coords[0], *pt_b.coords[0])
807    return distance <= _MAX_DISTANCE_TO_TRACK
808
809
810_MAX_DISTANCE_TO_TRACK: Final[float] = 30.0  # meters
811"""
812An expectation of the maximum distance between a stop position and its platform.
813
814The two should typically be pretty close to each other. Effectively, this value
815should be lower than any sensible (beeline) distance between two consecutive
816stops on the same route. It should not be used as a hard constraint.
817"""
def collect_routes( query: RouteQuery, perimeter: shapely.geometry.polygon.Polygon | None = None) -> list[Route]:
573def collect_routes(query: RouteQuery, perimeter: Polygon | None = None) -> list[Route]:
574    # TODO: the way 'perimeter' works might be confusing
575    """
576    Consumes the result set of a query and produces ``Route`` objects.
577
578    The order of elements is *not* retained.
579
580    The result set in the input query will be empty after calling this function.
581
582    Args:
583        query: The query that produced a result set of route relations.
584        perimeter: If set, ``stops`` at the end will be truncated if they are outside of this
585                   polygon. This means stops outside the polygon will still be in the list as
586                   long as there is at least one following stop that *is* inside the list.
587                   Any relation members in ``members`` will not be filtered.
588
589    Raises:
590        ValueError: if the input query has no result set
591
592    Returns:
593        all routes in the result set of the input query
594    """
595    elements = collect_elements(query)
596    route_rels = [elem for elem in elements if _is_tagged_route_relation(elem)]
597
598    routes = []
599
600    for route_rel in route_rels:
601        # Group route relation members per stop:
602        # f.e. a platform, and a stop position at the same station.
603        stops = list(_stops(route_rel))
604
605        # Filter stops by perimeter: cut off at the last stop that is in the perimeter.
606        if perimeter:
607            while stops and not perimeter.contains(stops[-1]._stop_point):
608                del stops[-1]
609
610        route = Route(
611            relation=route_rel,
612            scheme=_scheme(route_rel),
613            stops=stops,
614        )
615
616        routes.append(route)
617
618    return routes

Consumes the result set of a query and produces Route objects.

The order of elements is not retained.

The result set in the input query will be empty after calling this function.

Arguments:
  • query: The query that produced a result set of route relations.
  • perimeter: If set, stops at the end will be truncated if they are outside of this polygon. This means stops outside the polygon will still be in the list as long as there is at least one following stop that is inside the list. Any relation members in members will not be filtered.
Raises:
  • ValueError: if the input query has no result set
Returns:

all routes in the result set of the input query

class RouteQuery(aio_overpass.query.Query):
34class RouteQuery(Query):
35    """
36    Base class for queries that produce ``Route`` and ``RouteSegment`` objects.
37
38    Be aware that to build full ``RouteSegment`` objects with tags and geometry, this query loads:
39     - every route member
40     - every stop area any stop on the route is related to
41     - every stop in one of those stop areas
42
43    Args:
44        input_code: A query that puts the desired routes into the ``.routes`` set,
45                     and optionally, its route masters into the ``.masters`` set
46                     (for example by recursing up from ``.routes``).
47    """
48
49    __slots__ = ()
50
51    def __init__(
52        self,
53        input_code: str,
54        **kwargs: Any,  # noqa: ANN401
55    ) -> None:
56        input_code = f"""
57            {input_code}
58            .routes >> -> .route_members;
59            way.route_members -> .route_ways;
60
61            (
62                node.route_members[highway=bus_stop];
63                node.route_members[public_transport];
64                way .route_members[public_transport];
65                rel .route_members[public_transport];
66            ) -> .route_pt_members;
67
68            .route_pt_members <;
69            rel._[public_transport=stop_area]->.stop_areas;
70            node(r.stop_areas:"stop")[public_transport=stop_position]->.stop_area_stops;
71
72            .masters out;
73            .routes out geom;
74            .route_ways out tags;
75            .route_pt_members out geom;
76            .stop_areas out;
77            .stop_area_stops out;
78        """
79
80        super().__init__(input_code, **kwargs)

Base class for queries that produce Route and RouteSegment objects.

Be aware that to build full RouteSegment objects with tags and geometry, this query loads:

  • every route member
  • every stop area any stop on the route is related to
  • every stop in one of those stop areas
Arguments:
  • input_code: A query that puts the desired routes into the .routes set, and optionally, its route masters into the .masters set (for example by recursing up from .routes).
RouteQuery(input_code: str, **kwargs: Any)
51    def __init__(
52        self,
53        input_code: str,
54        **kwargs: Any,  # noqa: ANN401
55    ) -> None:
56        input_code = f"""
57            {input_code}
58            .routes >> -> .route_members;
59            way.route_members -> .route_ways;
60
61            (
62                node.route_members[highway=bus_stop];
63                node.route_members[public_transport];
64                way .route_members[public_transport];
65                rel .route_members[public_transport];
66            ) -> .route_pt_members;
67
68            .route_pt_members <;
69            rel._[public_transport=stop_area]->.stop_areas;
70            node(r.stop_areas:"stop")[public_transport=stop_position]->.stop_area_stops;
71
72            .masters out;
73            .routes out geom;
74            .route_ways out tags;
75            .route_pt_members out geom;
76            .stop_areas out;
77            .stop_area_stops out;
78        """
79
80        super().__init__(input_code, **kwargs)
class SingleRouteQuery(RouteQuery):
 83class SingleRouteQuery(RouteQuery):
 84    """
 85    A query that produces a single ``Route`` object.
 86
 87    Args:
 88        relation_id: the desired route's relation ID
 89    """
 90
 91    __slots__ = ("relation_id",)
 92
 93    def __init__(
 94        self,
 95        relation_id: int,
 96        **kwargs: Any,  # noqa: ANN401
 97    ) -> None:
 98        self.relation_id = relation_id
 99
100        input_code = f"""
101            rel({self.relation_id});
102            rel._[type=route]->.routes;
103            .routes <<;
104            rel._[type=route_master]->.masters;
105        """
106
107        super().__init__(input_code, **kwargs)

A query that produces a single Route object.

Arguments:
  • relation_id: the desired route's relation ID
SingleRouteQuery(relation_id: int, **kwargs: Any)
 93    def __init__(
 94        self,
 95        relation_id: int,
 96        **kwargs: Any,  # noqa: ANN401
 97    ) -> None:
 98        self.relation_id = relation_id
 99
100        input_code = f"""
101            rel({self.relation_id});
102            rel._[type=route]->.routes;
103            .routes <<;
104            rel._[type=route_master]->.masters;
105        """
106
107        super().__init__(input_code, **kwargs)
relation_id
class RoutesWithinQuery(RouteQuery):
110class RoutesWithinQuery(RouteQuery):
111    """
112    A query that produces ``Route`` objects for any route within the exterior of a polygon.
113
114    Args:
115        polygon: Any route that has at least one member element within this shape
116                 will be in the result set of this query. Note that the route members
117                 are not limited to this polygon - the majority of a route may in fact
118                 be outside of it. This shape should be simplified, since a larger number
119                 of coordinates on the exterior will slow down the query.
120        vehicles: A list of transportation modes to filter by, or an empty list to include
121                  routes of any type. A non-empty list will filter routes by the ``route``
122                  key.
123    """
124
125    __slots__ = (
126        "polygon",
127        "vehicles",
128    )
129
130    def __init__(
131        self,
132        polygon: Polygon,
133        vehicles: list["Vehicle"] | None = None,
134        **kwargs: Any,  # noqa: ANN401
135    ) -> None:
136        if not vehicles:
137            vehicles = list(Vehicle)
138
139        self.polygon = polygon
140        self.vehicles = vehicles
141
142        region_clause = poly_clause(self.polygon)
143        route_filter = one_of_filter("route", *(v.name.lower() for v in vehicles))
144        input_code = f"""
145            rel{region_clause}{route_filter}[type=route]->.routes;
146            rel{region_clause}{route_filter}[type=route_master]->.masters;
147        """
148
149        super().__init__(input_code, **kwargs)

A query that produces Route objects for any route within the exterior of a polygon.

Arguments:
  • polygon: Any route that has at least one member element within this shape will be in the result set of this query. Note that the route members are not limited to this polygon - the majority of a route may in fact be outside of it. This shape should be simplified, since a larger number of coordinates on the exterior will slow down the query.
  • vehicles: A list of transportation modes to filter by, or an empty list to include routes of any type. A non-empty list will filter routes by the route key.
RoutesWithinQuery( polygon: shapely.geometry.polygon.Polygon, vehicles: list[Vehicle] | None = None, **kwargs: Any)
130    def __init__(
131        self,
132        polygon: Polygon,
133        vehicles: list["Vehicle"] | None = None,
134        **kwargs: Any,  # noqa: ANN401
135    ) -> None:
136        if not vehicles:
137            vehicles = list(Vehicle)
138
139        self.polygon = polygon
140        self.vehicles = vehicles
141
142        region_clause = poly_clause(self.polygon)
143        route_filter = one_of_filter("route", *(v.name.lower() for v in vehicles))
144        input_code = f"""
145            rel{region_clause}{route_filter}[type=route]->.routes;
146            rel{region_clause}{route_filter}[type=route_master]->.masters;
147        """
148
149        super().__init__(input_code, **kwargs)
polygon
vehicles
@dataclass(kw_only=True, slots=True)
class Route(aio_overpass.spatial.Spatial):
365@dataclass(kw_only=True, slots=True)
366class Route(Spatial):
367    """
368    A public transportation service route, e.g. a bus line.
369
370    Instances of this class are meant to represent OSM routes using the
371    'Public Transport Version 2' (PTv2) scheme. Compared to PTv1, this means f.e. that routes
372    with multiple directions (forwards, backwards) are represented by multiple route elements.
373
374    Attributes:
375        relation: the underlying relation that describes the path taken by a route,
376                  and places where people can embark and disembark from the transit service
377        scheme: The tagging scheme that was either assumed, or explicitly set on the route relation.
378        stops: a sorted list of stops on this route as they would appear on its timetable,
379               which was derived from the ``relation``
380
381    References:
382        - https://wiki.openstreetmap.org/wiki/Public_transport
383        - https://wiki.openstreetmap.org/wiki/Relation:route#Public_transport_routes
384    """
385
386    relation: Relation
387    scheme: RouteScheme
388    stops: list[Stop]
389
390    @property
391    def id(self) -> int:
392        """Route relation ID."""
393        return self.relation.id
394
395    @property
396    def tags(self) -> dict[str, str]:
397        """
398        Tags of the route relation.
399
400        Some tags can be inherited from a route master, if not set already.
401        """
402        from_relation = self.relation.tags or {}
403        from_master = {}
404
405        masters = self.masters
406        master = masters[0] if len(masters) == 1 else None
407
408        if master and master.tags is not None:
409            from_master = {k: v for k, v in master.tags.items() if k in _TAGS_FROM_ROUTE_MASTER}
410
411        return from_master | from_relation
412
413    def tag(self, key: str, default: str | None = None) -> str | None:
414        """
415        Get the tag value for the given key.
416
417        Some tags can be inherited from a route master, if not set already.
418        """
419        value = self.relation.tag(key, default)
420        if value is not default:
421            return value
422
423        if key not in _TAGS_FROM_ROUTE_MASTER:
424            return default
425
426        masters = self.masters
427        master = masters[0] if len(masters) == 1 else None
428
429        if not master:
430            return default
431
432        return master.tag(key, default)
433
434    @property
435    def ways(self) -> list[Way]:
436        """
437        The ways making up the path of the route.
438
439        Ways may be ordered, and appear multiple times if a way is travelled on more than once.
440        All the ways may be contiguous, but gaps are not uncommon.
441        """
442        return [
443            relship.member
444            for relship in self.relation.members
445            if isinstance(relship.member, Way) and _role(relship) == _RouteRole.NONE
446        ]
447
448    @property
449    def masters(self) -> list[Relation]:
450        """
451        Route master relations this route is a part of.
452
453        By convention, this should be a single relation at most.
454
455        References:
456            - https://wiki.openstreetmap.org/wiki/Relation:route_master
457        """
458        return [
459            relship.relation
460            for relship in self.relation.relations
461            if relship.relation.tag("type") == "route_master"
462        ]
463
464    @property
465    def name_from(self) -> str | None:
466        """
467        Name of the start station.
468
469        This is either the value of the relation's ``from`` key, a name derived
470        from the route's first stop (see ``Stop.name``), or ``None`` if neither
471        is available.
472        """
473        return self.relation.tag(
474            key="from",
475            default=self.stops[0].name if self.stops else None,
476        )
477
478    @property
479    def name_to(self) -> str | None:
480        """
481        Name of the end station.
482
483        This is either the value of the relation's ``to`` key, a name derived
484        from the route's last stop (see ``Stop.name``), or ``None`` if neither
485        is available.
486        """
487        return self.relation.tag(
488            key="to",
489            default=self.stops[-1].name if len(self.stops) > 1 else None,
490        )
491
492    @property
493    def name_via(self) -> str | None:
494        """
495        A name of an important station along the route.
496
497        This is either the value of the relation's ``via`` key, or ``None`` if it is not set.
498        """
499        return self.relation.tag("via")
500
501    @property
502    def name(self) -> str | None:
503        """
504        The name of the route.
505
506        This is either the value relation's ``name`` key, ``{from_} => {via} => {to}`` if at
507        least ``from`` and ``to`` are set, or ``None`` otherwise.
508        """
509        name_tag = self.tag("name")
510        if name_tag:
511            return name_tag
512
513        from_ = self.name_from
514        via = self.name_via
515        to = self.name_to
516
517        if from_ and to and via:
518            return f"{from_} => {via} => {to}"
519
520        if from_ and to:
521            return f"{from_} => {to}"
522
523        return None
524
525    @property
526    def vehicle(self) -> Vehicle:
527        """
528        The mode of transportation used on this route.
529
530        This value corresponds with the value of the relation's ``route`` key.
531        """
532        if not self.relation.tags or "route" not in self.relation.tags:
533            raise AssertionError
534        return Vehicle[self.relation.tags["route"].upper()]
535
536    @property
537    def bounds(self) -> Bbox | None:
538        """The bounding box around all stops of this route."""
539        geom = GeometryCollection([stop._geometry for stop in self.stops if stop._geometry])
540        return geom.bounds or None
541
542    @property
543    def geojson(self) -> GeoJsonDict:
544        """A mapping of this object, using the GeoJSON format."""
545        # TODO: Route geojson
546        raise NotImplementedError
547
548    def __repr__(self) -> str:
549        return f"{type(self).__name__}(id={self.relation.id}, name='{self.name}')"

A public transportation service route, e.g. a bus line.

Instances of this class are meant to represent OSM routes using the 'Public Transport Version 2' (PTv2) scheme. Compared to PTv1, this means f.e. that routes with multiple directions (forwards, backwards) are represented by multiple route elements.

Attributes:
  • relation: the underlying relation that describes the path taken by a route, and places where people can embark and disembark from the transit service
  • scheme: The tagging scheme that was either assumed, or explicitly set on the route relation.
  • stops: a sorted list of stops on this route as they would appear on its timetable, which was derived from the relation
References:
Route( *, relation: aio_overpass.element.Relation, scheme: RouteScheme, stops: list[Stop])
scheme: RouteScheme
stops: list[Stop]
id: int
390    @property
391    def id(self) -> int:
392        """Route relation ID."""
393        return self.relation.id

Route relation ID.

tags: dict[str, str]
395    @property
396    def tags(self) -> dict[str, str]:
397        """
398        Tags of the route relation.
399
400        Some tags can be inherited from a route master, if not set already.
401        """
402        from_relation = self.relation.tags or {}
403        from_master = {}
404
405        masters = self.masters
406        master = masters[0] if len(masters) == 1 else None
407
408        if master and master.tags is not None:
409            from_master = {k: v for k, v in master.tags.items() if k in _TAGS_FROM_ROUTE_MASTER}
410
411        return from_master | from_relation

Tags of the route relation.

Some tags can be inherited from a route master, if not set already.

def tag(self, key: str, default: str | None = None) -> str | None:
413    def tag(self, key: str, default: str | None = None) -> str | None:
414        """
415        Get the tag value for the given key.
416
417        Some tags can be inherited from a route master, if not set already.
418        """
419        value = self.relation.tag(key, default)
420        if value is not default:
421            return value
422
423        if key not in _TAGS_FROM_ROUTE_MASTER:
424            return default
425
426        masters = self.masters
427        master = masters[0] if len(masters) == 1 else None
428
429        if not master:
430            return default
431
432        return master.tag(key, default)

Get the tag value for the given key.

Some tags can be inherited from a route master, if not set already.

ways: list[aio_overpass.element.Way]
434    @property
435    def ways(self) -> list[Way]:
436        """
437        The ways making up the path of the route.
438
439        Ways may be ordered, and appear multiple times if a way is travelled on more than once.
440        All the ways may be contiguous, but gaps are not uncommon.
441        """
442        return [
443            relship.member
444            for relship in self.relation.members
445            if isinstance(relship.member, Way) and _role(relship) == _RouteRole.NONE
446        ]

The ways making up the path of the route.

Ways may be ordered, and appear multiple times if a way is travelled on more than once. All the ways may be contiguous, but gaps are not uncommon.

masters: list[aio_overpass.element.Relation]
448    @property
449    def masters(self) -> list[Relation]:
450        """
451        Route master relations this route is a part of.
452
453        By convention, this should be a single relation at most.
454
455        References:
456            - https://wiki.openstreetmap.org/wiki/Relation:route_master
457        """
458        return [
459            relship.relation
460            for relship in self.relation.relations
461            if relship.relation.tag("type") == "route_master"
462        ]

Route master relations this route is a part of.

By convention, this should be a single relation at most.

References:
name_from: str | None
464    @property
465    def name_from(self) -> str | None:
466        """
467        Name of the start station.
468
469        This is either the value of the relation's ``from`` key, a name derived
470        from the route's first stop (see ``Stop.name``), or ``None`` if neither
471        is available.
472        """
473        return self.relation.tag(
474            key="from",
475            default=self.stops[0].name if self.stops else None,
476        )

Name of the start station.

This is either the value of the relation's from key, a name derived from the route's first stop (see Stop.name), or None if neither is available.

name_to: str | None
478    @property
479    def name_to(self) -> str | None:
480        """
481        Name of the end station.
482
483        This is either the value of the relation's ``to`` key, a name derived
484        from the route's last stop (see ``Stop.name``), or ``None`` if neither
485        is available.
486        """
487        return self.relation.tag(
488            key="to",
489            default=self.stops[-1].name if len(self.stops) > 1 else None,
490        )

Name of the end station.

This is either the value of the relation's to key, a name derived from the route's last stop (see Stop.name), or None if neither is available.

name_via: str | None
492    @property
493    def name_via(self) -> str | None:
494        """
495        A name of an important station along the route.
496
497        This is either the value of the relation's ``via`` key, or ``None`` if it is not set.
498        """
499        return self.relation.tag("via")

A name of an important station along the route.

This is either the value of the relation's via key, or None if it is not set.

name: str | None
501    @property
502    def name(self) -> str | None:
503        """
504        The name of the route.
505
506        This is either the value relation's ``name`` key, ``{from_} => {via} => {to}`` if at
507        least ``from`` and ``to`` are set, or ``None`` otherwise.
508        """
509        name_tag = self.tag("name")
510        if name_tag:
511            return name_tag
512
513        from_ = self.name_from
514        via = self.name_via
515        to = self.name_to
516
517        if from_ and to and via:
518            return f"{from_} => {via} => {to}"
519
520        if from_ and to:
521            return f"{from_} => {to}"
522
523        return None

The name of the route.

This is either the value relation's name key, {from_} => {via} => {to} if at least from and to are set, or None otherwise.

vehicle: Vehicle
525    @property
526    def vehicle(self) -> Vehicle:
527        """
528        The mode of transportation used on this route.
529
530        This value corresponds with the value of the relation's ``route`` key.
531        """
532        if not self.relation.tags or "route" not in self.relation.tags:
533            raise AssertionError
534        return Vehicle[self.relation.tags["route"].upper()]

The mode of transportation used on this route.

This value corresponds with the value of the relation's route key.

bounds: tuple[float, float, float, float] | None
536    @property
537    def bounds(self) -> Bbox | None:
538        """The bounding box around all stops of this route."""
539        geom = GeometryCollection([stop._geometry for stop in self.stops if stop._geometry])
540        return geom.bounds or None

The bounding box around all stops of this route.

geojson: dict[str, typing.Any]
542    @property
543    def geojson(self) -> GeoJsonDict:
544        """A mapping of this object, using the GeoJSON format."""
545        # TODO: Route geojson
546        raise NotImplementedError

A mapping of this object, using the GeoJSON format.

class Vehicle(enum.Enum):
304class Vehicle(Enum):
305    """
306    Most common modes of public transportation.
307
308    References:
309        - https://wiki.openstreetmap.org/wiki/Relation:route#Public_transport_routes
310    """
311
312    # motor vehicles
313    BUS = auto()
314    TROLLEYBUS = auto()
315    MINIBUS = auto()
316    SHARE_TAXI = auto()
317
318    # railway vehicles
319    TRAIN = auto()
320    LIGHT_RAIL = auto()
321    SUBWAY = auto()
322    TRAM = auto()
323
324    # boats
325    FERRY = auto()
326
327    def __repr__(self) -> str:
328        return f"{type(self).__name__}.{self.name}"

Most common modes of public transportation.

References:
BUS = Vehicle.BUS
TROLLEYBUS = Vehicle.TROLLEYBUS
MINIBUS = Vehicle.MINIBUS
SHARE_TAXI = Vehicle.SHARE_TAXI
TRAIN = Vehicle.TRAIN
LIGHT_RAIL = Vehicle.LIGHT_RAIL
SUBWAY = Vehicle.SUBWAY
TRAM = Vehicle.TRAM
FERRY = Vehicle.FERRY
@dataclass(kw_only=True, slots=True)
class Stop(aio_overpass.spatial.Spatial):
191@dataclass(kw_only=True, slots=True)
192class Stop(Spatial):
193    """
194    A stop on a public transportation route.
195
196    Typically, a stop is modelled as two members in a relation: a stop_position node with the
197    'stop' role, and a platform with the 'platform' role. These members may be grouped in
198    a common stop_area.
199
200    Attributes:
201        idx: stop index on the route
202        platform: the platform node, way or relation associated with this stop, if any
203        stop_position: the stop position node associated with this stop, if any
204        stop_coords: a point that, compared to ``stop_position``, is guaranteed to be on the
205                     track of the route whenever it is set. Only set if you are collecting
206                     ``RouteSegments``.
207    """
208
209    idx: int
210    platform: Relationship | None
211    stop_position: Relationship | None
212    stop_coords: Node | Point | None
213
214    @property
215    def name(self) -> str | None:
216        """
217        This stop's name.
218
219        If platform and stop position names are the same, that name will be returned.
220        Otherwise, the most common name out of platform, stop position, and all related stop areas
221        will be returned.
222        """
223        stop_pos_name = self.stop_position.member.tag("name") if self.stop_position else None
224        platform_name = self.platform.member.tag("name") if self.platform else None
225
226        if stop_pos_name == platform_name and stop_pos_name is not None:
227            return stop_pos_name
228
229        names = [stop_pos_name, platform_name, *(rel.tag("name") for rel in self.stop_areas)]
230        names = [name for name in names if name]
231
232        if not names:
233            return None
234
235        counter = Counter(names)
236        ((most_common, _),) = counter.most_common(1)
237        return most_common
238
239    @property
240    def connection(self) -> Connection:
241        """Indicates whether you can enter, exit, or do both at this stop."""
242        options = [
243            _connection(relship) for relship in (self.stop_position, self.platform) if relship
244        ]
245        return next(
246            (opt for opt in options if opt != Connection.ENTRY_AND_EXIT), Connection.ENTRY_AND_EXIT
247        )
248
249    @property
250    def stop_areas(self) -> set[Relation]:
251        """Any stop area related to this stop."""
252        return {
253            relship_to_stop_area.relation
254            for relship_to_route in (self.platform, self.stop_position)
255            if relship_to_route
256            for relship_to_stop_area in relship_to_route.member.relations
257            if relship_to_stop_area.relation.tag("public_transport") == "stop_area"
258        }
259
260    @property
261    def geojson(self) -> GeoJsonDict:
262        """A mapping of this object, using the GeoJSON format."""
263        # TODO: Stop geojson
264        raise NotImplementedError
265
266    @property
267    def _stop_point(self) -> Point | None:
268        """This is set if we have a point that is on the track of the route."""
269        if isinstance(self.stop_coords, Node):
270            return self.stop_coords.geometry
271        if isinstance(self.stop_coords, Point):
272            return self.stop_coords
273        return None
274
275    @property
276    def _geometry(self) -> GeometryCollection:
277        """Collection of ``self.platform``, ``self.stop_position`` and  ``self.stop_coords``."""
278        geoms = []
279
280        # geometry can be None if the platform is a relation
281        if self.platform and self.platform.member.base_geometry:
282            geoms.append(self.platform.member.base_geometry)
283
284        if self.stop_position:
285            assert isinstance(self.stop_position.member, Node)
286            geoms.append(self.stop_position.member.geometry)
287
288        if isinstance(self.stop_coords, Point) and self.stop_coords not in geoms:
289            geoms.append(self.stop_coords)
290
291        return GeometryCollection(geoms)
292
293    def __repr__(self) -> str:
294        if self.stop_position:
295            elem = f"stop_position={self.stop_position.member}"
296        elif self.platform:
297            elem = f"platform={self.platform.member}"
298        else:
299            raise AssertionError
300
301        return f"{type(self).__name__}({elem}, name='{self.name}')"

A stop on a public transportation route.

Typically, a stop is modelled as two members in a relation: a stop_position node with the 'stop' role, and a platform with the 'platform' role. These members may be grouped in a common stop_area.

Attributes:
  • idx: stop index on the route
  • platform: the platform node, way or relation associated with this stop, if any
  • stop_position: the stop position node associated with this stop, if any
  • stop_coords: a point that, compared to stop_position, is guaranteed to be on the track of the route whenever it is set. Only set if you are collecting RouteSegments.
Stop( *, idx: int, platform: aio_overpass.element.Relationship | None, stop_position: aio_overpass.element.Relationship | None, stop_coords: aio_overpass.element.Node | shapely.geometry.point.Point | None)
idx: int
stop_position: aio_overpass.element.Relationship | None
stop_coords: aio_overpass.element.Node | shapely.geometry.point.Point | None
name: str | None
214    @property
215    def name(self) -> str | None:
216        """
217        This stop's name.
218
219        If platform and stop position names are the same, that name will be returned.
220        Otherwise, the most common name out of platform, stop position, and all related stop areas
221        will be returned.
222        """
223        stop_pos_name = self.stop_position.member.tag("name") if self.stop_position else None
224        platform_name = self.platform.member.tag("name") if self.platform else None
225
226        if stop_pos_name == platform_name and stop_pos_name is not None:
227            return stop_pos_name
228
229        names = [stop_pos_name, platform_name, *(rel.tag("name") for rel in self.stop_areas)]
230        names = [name for name in names if name]
231
232        if not names:
233            return None
234
235        counter = Counter(names)
236        ((most_common, _),) = counter.most_common(1)
237        return most_common

This stop's name.

If platform and stop position names are the same, that name will be returned. Otherwise, the most common name out of platform, stop position, and all related stop areas will be returned.

connection: Connection
239    @property
240    def connection(self) -> Connection:
241        """Indicates whether you can enter, exit, or do both at this stop."""
242        options = [
243            _connection(relship) for relship in (self.stop_position, self.platform) if relship
244        ]
245        return next(
246            (opt for opt in options if opt != Connection.ENTRY_AND_EXIT), Connection.ENTRY_AND_EXIT
247        )

Indicates whether you can enter, exit, or do both at this stop.

stop_areas: set[aio_overpass.element.Relation]
249    @property
250    def stop_areas(self) -> set[Relation]:
251        """Any stop area related to this stop."""
252        return {
253            relship_to_stop_area.relation
254            for relship_to_route in (self.platform, self.stop_position)
255            if relship_to_route
256            for relship_to_stop_area in relship_to_route.member.relations
257            if relship_to_stop_area.relation.tag("public_transport") == "stop_area"
258        }

Any stop area related to this stop.

geojson: dict[str, typing.Any]
260    @property
261    def geojson(self) -> GeoJsonDict:
262        """A mapping of this object, using the GeoJSON format."""
263        # TODO: Stop geojson
264        raise NotImplementedError

A mapping of this object, using the GeoJSON format.

class Connection(enum.Enum):
165class Connection(Enum):
166    """
167    Indicates whether you can enter, exit, or do both at a stop on a route.
168
169    References:
170        - https://taginfo.openstreetmap.org/relations/route#roles
171    """
172
173    ENTRY_AND_EXIT = auto()
174    ENTRY_ONLY = auto()
175    EXIT_ONLY = auto()
176
177    @property
178    def entry_possible(self) -> bool:
179        """``True`` if you can enter at this stop on the route."""
180        return self != Connection.EXIT_ONLY
181
182    @property
183    def exit_possible(self) -> bool:
184        """``True`` if you can exit at this stop on the route."""
185        return self != Connection.ENTRY_ONLY
186
187    def __repr__(self) -> str:
188        return f"{type(self).__name__}.{self.name}"

Indicates whether you can enter, exit, or do both at a stop on a route.

References:
ENTRY_AND_EXIT = Connection.ENTRY_AND_EXIT
ENTRY_ONLY = Connection.ENTRY_ONLY
EXIT_ONLY = Connection.EXIT_ONLY
entry_possible: bool
177    @property
178    def entry_possible(self) -> bool:
179        """``True`` if you can enter at this stop on the route."""
180        return self != Connection.EXIT_ONLY

True if you can enter at this stop on the route.

exit_possible: bool
182    @property
183    def exit_possible(self) -> bool:
184        """``True`` if you can exit at this stop on the route."""
185        return self != Connection.ENTRY_ONLY

True if you can exit at this stop on the route.

class RouteScheme(enum.Enum):
331class RouteScheme(Enum):
332    """
333    Tagging schemes for public transportation routes.
334
335    References:
336        - https://wiki.openstreetmap.org/wiki/Public_transport#Different_tagging_schemas
337        - https://wiki.openstreetmap.org/wiki/Key:public_transport:version
338    """
339
340    EXPLICIT_V1 = auto()
341    EXPLICIT_V2 = auto()
342
343    ASSUME_V1 = auto()
344    ASSUME_V2 = auto()
345
346    OTHER = auto()
347
348    @property
349    def version_number(self) -> int | None:
350        """Public transport tagging scheme."""
351        match self:
352            case RouteScheme.EXPLICIT_V1 | RouteScheme.ASSUME_V1:
353                return 1
354            case RouteScheme.EXPLICIT_V2 | RouteScheme.ASSUME_V2:
355                return 2
356            case RouteScheme.OTHER:
357                return None
358            case _:
359                raise AssertionError
360
361    def __repr__(self) -> str:
362        return f"{type(self).__name__}.{self.name}"
EXPLICIT_V1 = RouteScheme.EXPLICIT_V1
EXPLICIT_V2 = RouteScheme.EXPLICIT_V2
ASSUME_V1 = RouteScheme.ASSUME_V1
ASSUME_V2 = RouteScheme.ASSUME_V2
version_number: int | None
348    @property
349    def version_number(self) -> int | None:
350        """Public transport tagging scheme."""
351        match self:
352            case RouteScheme.EXPLICIT_V1 | RouteScheme.ASSUME_V1:
353                return 1
354            case RouteScheme.EXPLICIT_V2 | RouteScheme.ASSUME_V2:
355                return 2
356            case RouteScheme.OTHER:
357                return None
358            case _:
359                raise AssertionError

Public transport tagging scheme.