From 5b1c9cb80e7b2ee93c3774c4b0417234e64bb7c7 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Fri, 3 May 2024 12:49:45 +0200 Subject: [PATCH 01/17] first commit --- .gitignore | 169 ++++++++ .vscode/launch.json | 39 ++ amarillo-gtfs-generator/__init__.py | 1 + amarillo-gtfs-generator/gtfs.py | 137 ++++++ amarillo-gtfs-generator/gtfs_constants.py | 14 + amarillo-gtfs-generator/gtfs_export.py | 230 ++++++++++ amarillo-gtfs-generator/gtfs_generator.py | 220 ++++++++++ amarillo-gtfs-generator/gtfsrt/__init__.py | 0 .../gtfsrt/gtfs_realtime_pb2.py | 80 ++++ .../gtfsrt/realtime_extension_pb2.py | 33 ++ amarillo-gtfs-generator/models/Carpool.py | 407 ++++++++++++++++++ amarillo-gtfs-generator/models/__init__.py | 0 amarillo-gtfs-generator/models/gtfs.py | 30 ++ amarillo-gtfs-generator/router.py | 68 +++ amarillo-gtfs-generator/tests/__init__.py | 0 amarillo-gtfs-generator/tests/test_gtfs.py | 142 ++++++ pyproject.toml | 7 + 17 files changed, 1577 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 amarillo-gtfs-generator/__init__.py create mode 100644 amarillo-gtfs-generator/gtfs.py create mode 100644 amarillo-gtfs-generator/gtfs_constants.py create mode 100644 amarillo-gtfs-generator/gtfs_export.py create mode 100644 amarillo-gtfs-generator/gtfs_generator.py create mode 100644 amarillo-gtfs-generator/gtfsrt/__init__.py create mode 100644 amarillo-gtfs-generator/gtfsrt/gtfs_realtime_pb2.py create mode 100644 amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py create mode 100644 amarillo-gtfs-generator/models/Carpool.py create mode 100644 amarillo-gtfs-generator/models/__init__.py create mode 100644 amarillo-gtfs-generator/models/gtfs.py create mode 100644 amarillo-gtfs-generator/router.py create mode 100644 amarillo-gtfs-generator/tests/__init__.py create mode 100644 amarillo-gtfs-generator/tests/test_gtfs.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c594f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +data/ +secrets +logging.conf +config +static/** +templates/** +conf/** diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e67162b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,39 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + // { + // "name": "Debug Tests", + // "type": "debugpy", + // "request": "launch", + // "purpose": ["debug-test"], + // "module": "pytest", + // "console": "integratedTerminal", + // "justMyCode": true, + // "env": { + // "_PYTEST_RAISE": "1" + // }, + // }, + { + "name": "Python: FastAPI", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "amarillo-gtfs-generator.gtfs_generator:app", + "--workers=1", + "--port=8002" + ], + // "preLaunchTask": "enhance", + "jinja": true, + "justMyCode": false, + "env": { + "admin_token": "supersecret", + "ride2go_token": "supersecret2" + } + } + ] +} \ No newline at end of file diff --git a/amarillo-gtfs-generator/__init__.py b/amarillo-gtfs-generator/__init__.py new file mode 100644 index 0000000..3aa6b3b --- /dev/null +++ b/amarillo-gtfs-generator/__init__.py @@ -0,0 +1 @@ +from .gtfs_generator import setup \ No newline at end of file diff --git a/amarillo-gtfs-generator/gtfs.py b/amarillo-gtfs-generator/gtfs.py new file mode 100644 index 0000000..368d924 --- /dev/null +++ b/amarillo-gtfs-generator/gtfs.py @@ -0,0 +1,137 @@ +import amarillo.plugins.gtfs_export.gtfsrt.gtfs_realtime_pb2 as gtfs_realtime_pb2 +import amarillo.plugins.gtfs_export.gtfsrt.realtime_extension_pb2 as mfdzrte +from amarillo.plugins.gtfs_export.gtfs_constants import * +from google.protobuf.json_format import MessageToDict +from google.protobuf.json_format import ParseDict +from datetime import datetime, timedelta +import json +import re +import time + +class GtfsRtProducer(): + + def __init__(self, trip_store): + self.trip_store = trip_store + + def generate_feed(self, time, format='protobuf', bbox=None): + # See https://developers.google.com/transit/gtfs-realtime/reference + # https://github.com/mfdz/carpool-gtfs-rt/blob/master/src/main/java/de/mfdz/resource/CarpoolResource.java + gtfsrt_dict = { + 'header': { + 'gtfsRealtimeVersion': '1.0', + 'timestamp': int(time) + }, + 'entity': self._get_trip_updates(bbox) + } + feed = gtfs_realtime_pb2.FeedMessage() + ParseDict(gtfsrt_dict, feed) + + if "message" == format.lower(): + return feed + elif "json" == format.lower(): + return MessageToDict(feed) + else: + return feed.SerializeToString() + + def export_feed(self, timestamp, file_path, bbox=None): + """ + Exports gtfs-rt feed as .json and .pbf file to file_path + """ + feed = self.generate_feed(timestamp, "message", bbox) + with open(f"{file_path}.pbf", "wb") as f: + f.write(feed.SerializeToString()) + with open(f"{file_path}.json", "w") as f: + json.dump(MessageToDict(feed), f) + + def _get_trip_updates(self, bbox = None): + trips = [] + trips.extend(self._get_added(bbox)) + trips.extend(self._get_deleted(bbox)) + trip_updates = [] + for num, trip in enumerate(trips): + trip_updates.append( { + 'id': f'carpool-update-{num}', + 'tripUpdate': trip + } + ) + return trip_updates + + def _get_deleted(self, bbox = None): + return self._get_updates( + self.trip_store.recently_deleted_trips(), + self._as_delete_updates, + bbox) + + def _get_added(self, bbox = None): + return self._get_updates( + self.trip_store.recently_added_trips(), + self._as_added_updates, + bbox) + + def _get_updates(self, trips, update_func, bbox = None): + updates = [] + today = datetime.today() + for t in trips: + if bbox == None or t.intersects(bbox): + updates.extend(update_func(t, today)) + return updates + + def _as_delete_updates(self, trip, fromdate): + return [{ + 'trip': { + 'tripId': trip.trip_id, + 'startTime': trip.start_time_str(), + 'startDate': trip_date, + 'scheduleRelationship': 'CANCELED', + 'routeId': trip.trip_id + } + } for trip_date in trip.next_trip_dates(fromdate)] + + def _to_seconds(self, fromdate, stop_time): + startdate = datetime.strptime(fromdate, '%Y%m%d') + m = re.search(r'(\d+):(\d+):(\d+)', stop_time) + delta = timedelta( + hours=int(m.group(1)), + minutes=int(m.group(2)), + seconds=int(m.group(3))) + return time.mktime((startdate + delta).timetuple()) + + def _to_stop_times(self, trip, fromdate): + return [{ + 'stopSequence': stoptime.stop_sequence, + 'arrival': { + 'time': self._to_seconds(fromdate, stoptime.arrival_time), + 'uncertainty': MFDZ_DEFAULT_UNCERTAINITY + }, + 'departure': { + 'time': self._to_seconds(fromdate, stoptime.departure_time), + 'uncertainty': MFDZ_DEFAULT_UNCERTAINITY + }, + 'stopId': stoptime.stop_id, + 'scheduleRelationship': 'SCHEDULED', + 'stop_time_properties': { + '[transit_realtime.stop_time_properties]': { + 'dropoffType': 'COORDINATE_WITH_DRIVER' if stoptime.drop_off_type == STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER else 'NONE', + 'pickupType': 'COORDINATE_WITH_DRIVER' if stoptime.pickup_type == STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER else 'NONE' + } + } + } + for stoptime in trip.stop_times] + + def _as_added_updates(self, trip, fromdate): + return [{ + 'trip': { + 'tripId': trip.trip_id, + 'startTime': trip.start_time_str(), + 'startDate': trip_date, + 'scheduleRelationship': 'ADDED', + 'routeId': trip.trip_id, + '[transit_realtime.trip_descriptor]': { + 'routeUrl' : trip.url, + 'agencyId' : trip.agency, + 'route_long_name' : trip.route_long_name(), + 'route_type': RIDESHARING_ROUTE_TYPE + } + }, + 'stopTimeUpdate': self._to_stop_times(trip, trip_date) + } for trip_date in trip.next_trip_dates(fromdate)] diff --git a/amarillo-gtfs-generator/gtfs_constants.py b/amarillo-gtfs-generator/gtfs_constants.py new file mode 100644 index 0000000..1e8f3af --- /dev/null +++ b/amarillo-gtfs-generator/gtfs_constants.py @@ -0,0 +1,14 @@ +# Constants + +NO_BIKES_ALLOWED = 2 +RIDESHARING_ROUTE_TYPE = 1551 +CALENDAR_DATES_EXCEPTION_TYPE_ADDED = 1 +CALENDAR_DATES_EXCEPTION_TYPE_REMOVED = 2 +STOP_TIMES_STOP_TYPE_REGULARLY = 0 +STOP_TIMES_STOP_TYPE_NONE = 1 +STOP_TIMES_STOP_TYPE_PHONE_AGENCY = 2 +STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER = 3 +STOP_TIMES_TIMEPOINT_APPROXIMATE = 0 +STOP_TIMES_TIMEPOINT_EXACT = 1 + +MFDZ_DEFAULT_UNCERTAINITY = 600 \ No newline at end of file diff --git a/amarillo-gtfs-generator/gtfs_export.py b/amarillo-gtfs-generator/gtfs_export.py new file mode 100644 index 0000000..1626fcd --- /dev/null +++ b/amarillo-gtfs-generator/gtfs_export.py @@ -0,0 +1,230 @@ + +from collections.abc import Iterable +from datetime import datetime, timedelta +from zipfile import ZipFile +import csv +import gettext +import logging +import re + +from amarillo.utils.utils import assert_folder_exists +from amarillo.plugins.gtfs_export.models.gtfs import GtfsTimeDelta, GtfsFeedInfo, GtfsAgency, GtfsRoute, GtfsStop, GtfsStopTime, GtfsTrip, GtfsCalendar, GtfsCalendarDate, GtfsShape +from amarillo.plugins.enhancer.services.stops import is_carpooling_stop +from amarillo.plugins.gtfs_export.gtfs_constants import * +from .models.Carpool import Agency + + +logger = logging.getLogger(__name__) + +class GtfsExport: + + stops_counter = 0 + trips_counter = 0 + routes_counter = 0 + + stored_stops = {} + + def __init__(self, agencies: dict[str, Agency], feed_info, ridestore, stopstore, bbox = None): + self.stops = {} + self.routes = [] + self.calendar_dates = [] + self.calendar = [] + self.trips = [] + self.stop_times = [] + self.calendar = [] + self.shapes = [] + self.agencies = [GtfsAgency(a.id, a.name, a.url, a.timezone, a.lang, a.email) for a in agencies.values()] + self.feed_info = feed_info + self.localized_to = " nach " + self.localized_short_name = "Mitfahrgelegenheit" + self.stopstore = stopstore + self.ridestore = ridestore + self.bbox = bbox + + def export(self, gtfszip_filename, gtfsfolder): + assert_folder_exists(gtfsfolder) + self._prepare_gtfs_feed(self.ridestore, self.stopstore) + self._write_csvfile(gtfsfolder, 'agency.txt', self.agencies) + self._write_csvfile(gtfsfolder, 'feed_info.txt', self.feed_info) + self._write_csvfile(gtfsfolder, 'routes.txt', self.routes) + self._write_csvfile(gtfsfolder, 'trips.txt', self.trips) + self._write_csvfile(gtfsfolder, 'calendar.txt', self.calendar) + self._write_csvfile(gtfsfolder, 'calendar_dates.txt', self.calendar_dates) + self._write_csvfile(gtfsfolder, 'stops.txt', self.stops.values()) + self._write_csvfile(gtfsfolder, 'stop_times.txt', self.stop_times) + self._write_csvfile(gtfsfolder, 'shapes.txt', self.shapes) + self._zip_files(gtfszip_filename, gtfsfolder) + + def _zip_files(self, gtfszip_filename, gtfsfolder): + gtfsfiles = ['agency.txt', 'feed_info.txt', 'routes.txt', 'trips.txt', + 'calendar.txt', 'calendar_dates.txt', 'stops.txt', 'stop_times.txt', 'shapes.txt'] + with ZipFile(gtfszip_filename, 'w') as gtfszip: + for gtfsfile in gtfsfiles: + gtfszip.write(gtfsfolder+'/'+gtfsfile, gtfsfile) + + def _prepare_gtfs_feed(self, ridestore, stopstore): + """ + Prepares all gtfs objects in memory before they are written + to their respective streams. + + For all wellknown stops a GTFS stop is created and + afterwards all ride offers are transformed into their + gtfs equivalents. + """ + for stopSet in stopstore.stopsDataFrames: + for stop in stopSet["stops"].itertuples(): + self._load_stored_stop(stop) + cloned_trips = dict(ridestore.trips) + for _, trip in cloned_trips.items(): + if self.bbox is None or trip.intersects(self.bbox): + self._convert_trip(trip) + + def _convert_trip(self, trip): + self.routes_counter += 1 + self.routes.append(self._create_route(trip)) + self.calendar.append(self._create_calendar(trip)) + if not trip.runs_regularly: + self.calendar_dates.append(self._create_calendar_date(trip)) + self.trips.append(self._create_trip(trip, self.routes_counter)) + self._append_stops_and_stop_times(trip) + self._append_shapes(trip, self.routes_counter) + + def _trip_headsign(self, destination): + destination = destination.replace('(Deutschland)', '') + destination = destination.replace(', Deutschland', '') + appendix = '' + if 'Schweiz' in destination or 'Switzerland' in destination: + appendix = ', Schweiz' + destination = destination.replace('(Schweiz)', '') + destination = destination.replace(', Schweiz', '') + destination = destination.replace('(Switzerland)', '') + + try: + matches = re.match(r"(.*,)? ?(\d{4,5})? ?(.*)", destination) + + match = matches.group(3).strip() if matches != None else destination.strip() + if match[-1]==')' and not '(' in match: + match = match[0:-1] + + return match + appendix + except Exception as ex: + logger.error("error for "+destination ) + logger.exception(ex) + return destination + + def _create_route(self, trip): + return GtfsRoute(trip.agency, trip.trip_id, trip.route_long_name(), RIDESHARING_ROUTE_TYPE, trip.url, "", trip.route_color, trip.route_text_color) + + def _create_calendar(self, trip): + # TODO currently, calendar is not provided by Fahrgemeinschaft.de interface. + # We could apply some heuristics like requesting multiple days and extrapolate + # if multiple trips are found, but better would be to have these provided by the + # offical interface. Then validity periods should be provided as well (not + # sure if these are available) + # For fahrgemeinschaft.de, regurlar trips are recognizable via their url + # which contains "regelmaessig". However, we don't know on which days of the week, + # nor until when. As a first guess, if datetime is a mo-fr, we assume each workday, + # if it's sa/su, only this... + + feed_start_date = datetime.today() + stop_date = self._convert_stop_date(feed_start_date) + return GtfsCalendar(trip.trip_id, stop_date, self._convert_stop_date(feed_start_date + timedelta(days=31)), *(trip.weekdays)) + + def _create_calendar_date(self, trip): + return GtfsCalendarDate(trip.trip_id, self._convert_stop_date(trip.start), CALENDAR_DATES_EXCEPTION_TYPE_ADDED) + + def _create_trip(self, trip, shape_id): + return GtfsTrip(trip.trip_id, trip.trip_id, trip.trip_id, shape_id, trip.trip_headsign, NO_BIKES_ALLOWED) + + def _convert_stop(self, stop): + """ + Converts a stop represented as pandas row to a gtfs stop. + Expected attributes of stop: id, stop_name, x, y (in wgs84) + """ + if stop.id: + id = stop.id + else: + self.stops_counter += 1 + id = "tmp-{}".format(self.stops_counter) + + stop_name = "k.A." if stop.stop_name is None else stop.stop_name + return GtfsStop(id, stop.y, stop.x, stop_name) + + def _append_stops_and_stop_times(self, trip): + # Assumptions: + # arrival_time = departure_time + # pickup_type, drop_off_type for origin: = coordinate/none + # pickup_type, drop_off_type for destination: = none/coordinate + # timepoint = approximate for origin and destination (not sure what consequences this might have for trip planners) + for stop_time in trip.stop_times: + # retrieve stop from stored_stops and mark it to be exported + wkn_stop = self.stored_stops.get(stop_time.stop_id) + if not wkn_stop: + logger.warning("No stop found in stop_store for %s. Will skip stop_time %s of trip %s", stop_time.stop_id, stop_time.stop_sequence, trip.trip_id) + else: + self.stops[stop_time.stop_id] = wkn_stop + # Append stop_time + self.stop_times.append(stop_time) + + def _append_shapes(self, trip, shape_id): + counter = 0 + for point in trip.path.coordinates: + counter += 1 + self.shapes.append(GtfsShape(shape_id, point[0], point[1], counter)) + + def _stop_hash(self, stop): + return "{}#{}#{}".format(stop.stop_name,stop.x,stop.y) + + def _should_always_export(self, stop): + """ + Returns true, if the given stop shall be exported to GTFS, + regardless, if it's part of a trip or not. + + This is necessary, as potential stops are required + to be part of the GTFS to be referenced later on + by dynamicly added trips. + """ + if self.bbox: + return (self.bbox[0] <= stop.stop_lon <= self.bbox[2] and + self.bbox[1] <= stop.stop_lat <= self.bbox[3]) + else: + return is_carpooling_stop(stop.stop_id, stop.stop_name) + + def _load_stored_stop(self, stop): + gtfsstop = self._convert_stop(stop) + stop_hash = self._stop_hash(stop) + self.stored_stops[gtfsstop.stop_id] = gtfsstop + if self._should_always_export(gtfsstop): + self.stops[gtfsstop.stop_id] = gtfsstop + + def _get_stop_by_hash(self, stop_hash): + return self.stops.get(stop_hash, self.stored_stops.get(stop_hash)) + + def _get_or_create_stop(self, stop): + stop_hash = self._stop_hash(stop) + gtfsstop = self.stops.get(stop_hash) + if gtfsstop is None: + gtfsstop = self.stored_stops.get(stop_hash, self._convert_stop(stop)) + self.stops[stop_hash] = gtfsstop + return gtfsstop + + def _convert_stop_date(self, date_time): + return date_time.strftime("%Y%m%d") + + def _write_csvfile(self, gtfsfolder, filename, content): + with open(gtfsfolder+"/"+filename, 'w', newline="\n", encoding="utf-8") as csvfile: + self._write_csv(csvfile, content) + + def _write_csv(self, csvfile, content): + if hasattr(content, '_fields'): + writer = csv.DictWriter(csvfile, content._fields) + writer.writeheader() + writer.writerow(content._asdict()) + else: + if content: + writer = csv.DictWriter(csvfile, next(iter(content))._fields) + writer.writeheader() + for record in content: + writer.writerow(record._asdict()) + + \ No newline at end of file diff --git a/amarillo-gtfs-generator/gtfs_generator.py b/amarillo-gtfs-generator/gtfs_generator.py new file mode 100644 index 0000000..c6eb2d1 --- /dev/null +++ b/amarillo-gtfs-generator/gtfs_generator.py @@ -0,0 +1,220 @@ +from fastapi import FastAPI, Body, status +from fastapi.responses import FileResponse + +from .gtfs_export import GtfsExport, GtfsFeedInfo, GtfsAgency +from .gtfs import GtfsRtProducer +from amarillo.utils.container import container +# from amarillo.plugins.gtfs_export.router import router +from amarillo.plugins.enhancer.configuration import configure_enhancer_services +from glob import glob +import json +import schedule +import threading +import time +import logging +from .models.Carpool import Carpool, Region +from .router import _assert_region_exists +from amarillo.plugins.enhancer.services import stops +from amarillo.plugins.enhancer.services.trips import TripStore, Trip +from amarillo.plugins.enhancer.services.carpools import CarpoolService +from amarillo.services.agencies import AgencyService +from amarillo.services.regions import RegionService + +logger = logging.getLogger(__name__) + +def init(): + container['agencies'] = AgencyService() + logger.info("Loaded %d agencies", len(container['agencies'].agencies)) + + container['regions'] = RegionService() + logger.info("Loaded %d regions", len(container['regions'].regions)) + + + logger.info("Load stops...") + with open('data/stop_sources.json') as stop_sources_file: + stop_sources = json.load(stop_sources_file) + stop_store = stops.StopsStore(stop_sources) + + stop_store.load_stop_sources() + # TODO: do we need container? + container['stops_store'] = stop_store + container['trips_store'] = TripStore(stop_store) + + # TODO: do we need the carpool service at all? + container['carpools'] = CarpoolService(container['trips_store']) + + logger.info("Restore carpools...") + + for agency_id in container['agencies'].agencies: + for carpool_file_name in glob(f'data/carpool/{agency_id}/*.json'): + try: + with open(carpool_file_name) as carpool_file: + carpool = Carpool(**(json.load(carpool_file))) + #TODO: convert to trip and add to tripstore directly + container['carpools'].put(carpool.agency, carpool.id, carpool) + except Exception as e: + logger.warning("Issue during restore of carpool %s: %s", carpool_file_name, repr(e)) + +def run_schedule(): + + while 1: + try: + schedule.run_pending() + except Exception as e: + logger.exception(e) + time.sleep(1) + +def midnight(): + container['stops_store'].load_stop_sources() + # container['trips_store'].unflag_unrecent_updates() + # container['carpools'].purge_outdated_offers() + + generate_gtfs() + +#TODO: generate for a specific region only +#TODO: what happens when there are no trips? +def generate_gtfs(): + logger.info("Generate GTFS") + + for region in container['regions'].regions.values(): + # TODO make feed producer infos configurable + feed_info = GtfsFeedInfo('mfdz', 'MITFAHR|DE|ZENTRALE', 'http://www.mitfahrdezentrale.de', 'de', 1) + exporter = GtfsExport( + container['agencies'].agencies, + feed_info, + container['trips_store'], # TODO: read carpools from disk and convert them to trips + container['stops_store'], + region.bbox) + exporter.export(f"data/gtfs/amarillo.{region.id}.gtfs.zip", "data/tmp/") + +def generate_gtfs_rt(): + logger.info("Generate GTFS-RT") + producer = GtfsRtProducer(container['trips_store']) + for region in container['regions'].regions.values(): + rt = producer.export_feed(time.time(), f"data/gtfs/amarillo.{region.id}.gtfsrt", bbox=region.bbox) + +def start_schedule(): + schedule.every().day.at("00:00").do(midnight) + schedule.every(60).seconds.do(generate_gtfs_rt) + # Create all feeds once at startup + schedule.run_all() + job_thread = threading.Thread(target=run_schedule, daemon=True) + job_thread.start() + +def setup(app : FastAPI): + # TODO: Create all feeds once at startup + # configure_enhancer_services() + # app.include_router(router) + # start_schedule() + pass + +logging.config.fileConfig('logging.conf', disable_existing_loggers=False) +logger = logging.getLogger("enhancer") + +#TODO: clean up metadata +app = FastAPI(title="Amarillo GTFS Generator", + description="This service allows carpool agencies to publish " + "their trip offers, so routing services may suggest " + "them as trip options. For carpool offers, only the " + "minimum required information (origin/destination, " + "optionally intermediate stops, departure time and a " + "deep link for booking/contacting the driver) needs to " + "be published, booking/contact exchange is to be " + "handled by the publishing agency.", + version="0.0.1", + # TODO 404 + terms_of_service="http://mfdz.de/carpool-hub-terms/", + contact={ + # "name": "unused", + # "url": "http://unused", + "email": "info@mfdz.de", + }, + license_info={ + "name": "AGPL-3.0 License", + "url": "https://www.gnu.org/licenses/agpl-3.0.de.html", + }, + openapi_tags=[ + { + "name": "carpool", + # "description": "Find out more about Amarillo - the carpooling intermediary", + "externalDocs": { + "description": "Find out more about Amarillo - the carpooling intermediary", + "url": "https://github.com/mfdz/amarillo", + }, + }], + servers=[ + { + "description": "MobiData BW Amarillo service", + "url": "https://amarillo.mobidata-bw.de" + }, + { + "description": "DABB bbnavi Amarillo service", + "url": "https://amarillo.bbnavi.de" + }, + { + "description": "Demo server by MFDZ", + "url": "https://amarillo.mfdz.de" + }, + { + "description": "Dev server for development", + "url": "https://amarillo-dev.mfdz.de" + }, + { + "description": "Server for Mitanand project", + "url": "https://mitanand.mfdz.de" + }, + { + "description": "Localhost for development", + "url": "http://localhost:8000" + } + ], + redoc_url=None + ) + +init() + +@app.post("/", + operation_id="enhancecarpool", + summary="Add a new or update existing carpool", + description="Carpool object to be enhanced", + responses={ + status.HTTP_404_NOT_FOUND: { + "description": "Agency does not exist"}, + + }) +#TODO: add examples +async def post_carpool(carpool: Carpool = Body(...)): + + logger.info(f"POST trip {carpool.agency}:{carpool.id}.") + + trips_store: TripStore = container['trips_store'] + trip = trips_store._load_as_trip(carpool) + +#TODO: carpool deleted endpoint + +#TODO: gtfs, gtfs-rt endpoints + +@app.get("/region/{region_id}/gtfs", + summary="Return GTFS Feed for this region", + response_description="GTFS-Feed (zip-file)", + response_class=FileResponse, + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Region not found"}, + } + ) +async def get_file(region_id: str): + _assert_region_exists(region_id) + generate_gtfs() + # verify_permission("gtfs", requesting_user) + return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfs.zip') + +#TODO: sync endpoint that calls midnight + +@app.post("/sync", + operation_id="sync") +#TODO: add examples +async def post_sync(): + + logger.info(f"Sync") + + midnight() \ No newline at end of file diff --git a/amarillo-gtfs-generator/gtfsrt/__init__.py b/amarillo-gtfs-generator/gtfsrt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amarillo-gtfs-generator/gtfsrt/gtfs_realtime_pb2.py b/amarillo-gtfs-generator/gtfsrt/gtfs_realtime_pb2.py new file mode 100644 index 0000000..4e10463 --- /dev/null +++ b/amarillo-gtfs-generator/gtfsrt/gtfs_realtime_pb2.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: gtfs-realtime.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13gtfs-realtime.proto\x12\x10transit_realtime\"y\n\x0b\x46\x65\x65\x64Message\x12,\n\x06header\x18\x01 \x02(\x0b\x32\x1c.transit_realtime.FeedHeader\x12,\n\x06\x65ntity\x18\x02 \x03(\x0b\x32\x1c.transit_realtime.FeedEntity*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\xd7\x01\n\nFeedHeader\x12\x1d\n\x15gtfs_realtime_version\x18\x01 \x02(\t\x12Q\n\x0eincrementality\x18\x02 \x01(\x0e\x32+.transit_realtime.FeedHeader.Incrementality:\x0c\x46ULL_DATASET\x12\x11\n\ttimestamp\x18\x03 \x01(\x04\"4\n\x0eIncrementality\x12\x10\n\x0c\x46ULL_DATASET\x10\x00\x12\x10\n\x0c\x44IFFERENTIAL\x10\x01*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\xd2\x01\n\nFeedEntity\x12\n\n\x02id\x18\x01 \x02(\t\x12\x19\n\nis_deleted\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x31\n\x0btrip_update\x18\x03 \x01(\x0b\x32\x1c.transit_realtime.TripUpdate\x12\x32\n\x07vehicle\x18\x04 \x01(\x0b\x32!.transit_realtime.VehiclePosition\x12&\n\x05\x61lert\x18\x05 \x01(\x0b\x32\x17.transit_realtime.Alert*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\x82\x08\n\nTripUpdate\x12.\n\x04trip\x18\x01 \x02(\x0b\x32 .transit_realtime.TripDescriptor\x12\x34\n\x07vehicle\x18\x03 \x01(\x0b\x32#.transit_realtime.VehicleDescriptor\x12\x45\n\x10stop_time_update\x18\x02 \x03(\x0b\x32+.transit_realtime.TripUpdate.StopTimeUpdate\x12\x11\n\ttimestamp\x18\x04 \x01(\x04\x12\r\n\x05\x64\x65lay\x18\x05 \x01(\x05\x12\x44\n\x0ftrip_properties\x18\x06 \x01(\x0b\x32+.transit_realtime.TripUpdate.TripProperties\x1aQ\n\rStopTimeEvent\x12\r\n\x05\x64\x65lay\x18\x01 \x01(\x05\x12\x0c\n\x04time\x18\x02 \x01(\x03\x12\x13\n\x0buncertainty\x18\x03 \x01(\x05*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\x1a\xa0\x04\n\x0eStopTimeUpdate\x12\x15\n\rstop_sequence\x18\x01 \x01(\r\x12\x0f\n\x07stop_id\x18\x04 \x01(\t\x12;\n\x07\x61rrival\x18\x02 \x01(\x0b\x32*.transit_realtime.TripUpdate.StopTimeEvent\x12=\n\tdeparture\x18\x03 \x01(\x0b\x32*.transit_realtime.TripUpdate.StopTimeEvent\x12j\n\x15schedule_relationship\x18\x05 \x01(\x0e\x32@.transit_realtime.TripUpdate.StopTimeUpdate.ScheduleRelationship:\tSCHEDULED\x12\\\n\x14stop_time_properties\x18\x06 \x01(\x0b\x32>.transit_realtime.TripUpdate.StopTimeUpdate.StopTimeProperties\x1a>\n\x12StopTimeProperties\x12\x18\n\x10\x61ssigned_stop_id\x18\x01 \x01(\t*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"P\n\x14ScheduleRelationship\x12\r\n\tSCHEDULED\x10\x00\x12\x0b\n\x07SKIPPED\x10\x01\x12\x0b\n\x07NO_DATA\x10\x02\x12\x0f\n\x0bUNSCHEDULED\x10\x03*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\x1aY\n\x0eTripProperties\x12\x0f\n\x07trip_id\x18\x01 \x01(\t\x12\x12\n\nstart_date\x18\x02 \x01(\t\x12\x12\n\nstart_time\x18\x03 \x01(\t*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\xdf\t\n\x0fVehiclePosition\x12.\n\x04trip\x18\x01 \x01(\x0b\x32 .transit_realtime.TripDescriptor\x12\x34\n\x07vehicle\x18\x08 \x01(\x0b\x32#.transit_realtime.VehicleDescriptor\x12,\n\x08position\x18\x02 \x01(\x0b\x32\x1a.transit_realtime.Position\x12\x1d\n\x15\x63urrent_stop_sequence\x18\x03 \x01(\r\x12\x0f\n\x07stop_id\x18\x07 \x01(\t\x12Z\n\x0e\x63urrent_status\x18\x04 \x01(\x0e\x32\x33.transit_realtime.VehiclePosition.VehicleStopStatus:\rIN_TRANSIT_TO\x12\x11\n\ttimestamp\x18\x05 \x01(\x04\x12K\n\x10\x63ongestion_level\x18\x06 \x01(\x0e\x32\x31.transit_realtime.VehiclePosition.CongestionLevel\x12K\n\x10occupancy_status\x18\t \x01(\x0e\x32\x31.transit_realtime.VehiclePosition.OccupancyStatus\x12\x1c\n\x14occupancy_percentage\x18\n \x01(\r\x12Q\n\x16multi_carriage_details\x18\x0b \x03(\x0b\x32\x31.transit_realtime.VehiclePosition.CarriageDetails\x1a\xd9\x01\n\x0f\x43\x61rriageDetails\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12^\n\x10occupancy_status\x18\x03 \x01(\x0e\x32\x31.transit_realtime.VehiclePosition.OccupancyStatus:\x11NO_DATA_AVAILABLE\x12 \n\x14occupancy_percentage\x18\x04 \x01(\x05:\x02-1\x12\x19\n\x11\x63\x61rriage_sequence\x18\x05 \x01(\r*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"G\n\x11VehicleStopStatus\x12\x0f\n\x0bINCOMING_AT\x10\x00\x12\x0e\n\nSTOPPED_AT\x10\x01\x12\x11\n\rIN_TRANSIT_TO\x10\x02\"}\n\x0f\x43ongestionLevel\x12\x1c\n\x18UNKNOWN_CONGESTION_LEVEL\x10\x00\x12\x14\n\x10RUNNING_SMOOTHLY\x10\x01\x12\x0f\n\x0bSTOP_AND_GO\x10\x02\x12\x0e\n\nCONGESTION\x10\x03\x12\x15\n\x11SEVERE_CONGESTION\x10\x04\"\xd9\x01\n\x0fOccupancyStatus\x12\t\n\x05\x45MPTY\x10\x00\x12\x18\n\x14MANY_SEATS_AVAILABLE\x10\x01\x12\x17\n\x13\x46\x45W_SEATS_AVAILABLE\x10\x02\x12\x16\n\x12STANDING_ROOM_ONLY\x10\x03\x12\x1e\n\x1a\x43RUSHED_STANDING_ROOM_ONLY\x10\x04\x12\x08\n\x04\x46ULL\x10\x05\x12\x1c\n\x18NOT_ACCEPTING_PASSENGERS\x10\x06\x12\x15\n\x11NO_DATA_AVAILABLE\x10\x07\x12\x11\n\rNOT_BOARDABLE\x10\x08*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\x80\t\n\x05\x41lert\x12\x32\n\ractive_period\x18\x01 \x03(\x0b\x32\x1b.transit_realtime.TimeRange\x12\x39\n\x0finformed_entity\x18\x05 \x03(\x0b\x32 .transit_realtime.EntitySelector\x12;\n\x05\x63\x61use\x18\x06 \x01(\x0e\x32\x1d.transit_realtime.Alert.Cause:\rUNKNOWN_CAUSE\x12>\n\x06\x65\x66\x66\x65\x63t\x18\x07 \x01(\x0e\x32\x1e.transit_realtime.Alert.Effect:\x0eUNKNOWN_EFFECT\x12/\n\x03url\x18\x08 \x01(\x0b\x32\".transit_realtime.TranslatedString\x12\x37\n\x0bheader_text\x18\n \x01(\x0b\x32\".transit_realtime.TranslatedString\x12<\n\x10\x64\x65scription_text\x18\x0b \x01(\x0b\x32\".transit_realtime.TranslatedString\x12;\n\x0ftts_header_text\x18\x0c \x01(\x0b\x32\".transit_realtime.TranslatedString\x12@\n\x14tts_description_text\x18\r \x01(\x0b\x32\".transit_realtime.TranslatedString\x12O\n\x0eseverity_level\x18\x0e \x01(\x0e\x32%.transit_realtime.Alert.SeverityLevel:\x10UNKNOWN_SEVERITY\"\xd8\x01\n\x05\x43\x61use\x12\x11\n\rUNKNOWN_CAUSE\x10\x01\x12\x0f\n\x0bOTHER_CAUSE\x10\x02\x12\x15\n\x11TECHNICAL_PROBLEM\x10\x03\x12\n\n\x06STRIKE\x10\x04\x12\x11\n\rDEMONSTRATION\x10\x05\x12\x0c\n\x08\x41\x43\x43IDENT\x10\x06\x12\x0b\n\x07HOLIDAY\x10\x07\x12\x0b\n\x07WEATHER\x10\x08\x12\x0f\n\x0bMAINTENANCE\x10\t\x12\x10\n\x0c\x43ONSTRUCTION\x10\n\x12\x13\n\x0fPOLICE_ACTIVITY\x10\x0b\x12\x15\n\x11MEDICAL_EMERGENCY\x10\x0c\"\xdd\x01\n\x06\x45\x66\x66\x65\x63t\x12\x0e\n\nNO_SERVICE\x10\x01\x12\x13\n\x0fREDUCED_SERVICE\x10\x02\x12\x16\n\x12SIGNIFICANT_DELAYS\x10\x03\x12\n\n\x06\x44\x45TOUR\x10\x04\x12\x16\n\x12\x41\x44\x44ITIONAL_SERVICE\x10\x05\x12\x14\n\x10MODIFIED_SERVICE\x10\x06\x12\x10\n\x0cOTHER_EFFECT\x10\x07\x12\x12\n\x0eUNKNOWN_EFFECT\x10\x08\x12\x0e\n\nSTOP_MOVED\x10\t\x12\r\n\tNO_EFFECT\x10\n\x12\x17\n\x13\x41\x43\x43\x45SSIBILITY_ISSUE\x10\x0b\"H\n\rSeverityLevel\x12\x14\n\x10UNKNOWN_SEVERITY\x10\x01\x12\x08\n\x04INFO\x10\x02\x12\x0b\n\x07WARNING\x10\x03\x12\n\n\x06SEVERE\x10\x04*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"7\n\tTimeRange\x12\r\n\x05start\x18\x01 \x01(\x04\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x04*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"q\n\x08Position\x12\x10\n\x08latitude\x18\x01 \x02(\x02\x12\x11\n\tlongitude\x18\x02 \x02(\x02\x12\x0f\n\x07\x62\x65\x61ring\x18\x03 \x01(\x02\x12\x10\n\x08odometer\x18\x04 \x01(\x01\x12\r\n\x05speed\x18\x05 \x01(\x02*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\xcd\x02\n\x0eTripDescriptor\x12\x0f\n\x07trip_id\x18\x01 \x01(\t\x12\x10\n\x08route_id\x18\x05 \x01(\t\x12\x14\n\x0c\x64irection_id\x18\x06 \x01(\r\x12\x12\n\nstart_time\x18\x02 \x01(\t\x12\x12\n\nstart_date\x18\x03 \x01(\t\x12T\n\x15schedule_relationship\x18\x04 \x01(\x0e\x32\x35.transit_realtime.TripDescriptor.ScheduleRelationship\"t\n\x14ScheduleRelationship\x12\r\n\tSCHEDULED\x10\x00\x12\t\n\x05\x41\x44\x44\x45\x44\x10\x01\x12\x0f\n\x0bUNSCHEDULED\x10\x02\x12\x0c\n\x08\x43\x41NCELED\x10\x03\x12\x13\n\x0bREPLACEMENT\x10\x05\x1a\x02\x08\x01\x12\x0e\n\nDUPLICATED\x10\x06*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"U\n\x11VehicleDescriptor\x12\n\n\x02id\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x15\n\rlicense_plate\x18\x03 \x01(\t*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\xb0\x01\n\x0e\x45ntitySelector\x12\x11\n\tagency_id\x18\x01 \x01(\t\x12\x10\n\x08route_id\x18\x02 \x01(\t\x12\x12\n\nroute_type\x18\x03 \x01(\x05\x12.\n\x04trip\x18\x04 \x01(\x0b\x32 .transit_realtime.TripDescriptor\x12\x0f\n\x07stop_id\x18\x05 \x01(\t\x12\x14\n\x0c\x64irection_id\x18\x06 \x01(\r*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N\"\xa6\x01\n\x10TranslatedString\x12\x43\n\x0btranslation\x18\x01 \x03(\x0b\x32..transit_realtime.TranslatedString.Translation\x1a=\n\x0bTranslation\x12\x0c\n\x04text\x18\x01 \x02(\t\x12\x10\n\x08language\x18\x02 \x01(\t*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90N*\x06\x08\xe8\x07\x10\xd0\x0f*\x06\x08\xa8\x46\x10\x90NB\x1d\n\x1b\x63om.google.transit.realtime') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'gtfs_realtime_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\033com.google.transit.realtime' + _TRIPDESCRIPTOR_SCHEDULERELATIONSHIP.values_by_name["REPLACEMENT"]._options = None + _TRIPDESCRIPTOR_SCHEDULERELATIONSHIP.values_by_name["REPLACEMENT"]._serialized_options = b'\010\001' + _FEEDMESSAGE._serialized_start=41 + _FEEDMESSAGE._serialized_end=162 + _FEEDHEADER._serialized_start=165 + _FEEDHEADER._serialized_end=380 + _FEEDHEADER_INCREMENTALITY._serialized_start=312 + _FEEDHEADER_INCREMENTALITY._serialized_end=364 + _FEEDENTITY._serialized_start=383 + _FEEDENTITY._serialized_end=593 + _TRIPUPDATE._serialized_start=596 + _TRIPUPDATE._serialized_end=1622 + _TRIPUPDATE_STOPTIMEEVENT._serialized_start=887 + _TRIPUPDATE_STOPTIMEEVENT._serialized_end=968 + _TRIPUPDATE_STOPTIMEUPDATE._serialized_start=971 + _TRIPUPDATE_STOPTIMEUPDATE._serialized_end=1515 + _TRIPUPDATE_STOPTIMEUPDATE_STOPTIMEPROPERTIES._serialized_start=1355 + _TRIPUPDATE_STOPTIMEUPDATE_STOPTIMEPROPERTIES._serialized_end=1417 + _TRIPUPDATE_STOPTIMEUPDATE_SCHEDULERELATIONSHIP._serialized_start=1419 + _TRIPUPDATE_STOPTIMEUPDATE_SCHEDULERELATIONSHIP._serialized_end=1499 + _TRIPUPDATE_TRIPPROPERTIES._serialized_start=1517 + _TRIPUPDATE_TRIPPROPERTIES._serialized_end=1606 + _VEHICLEPOSITION._serialized_start=1625 + _VEHICLEPOSITION._serialized_end=2872 + _VEHICLEPOSITION_CARRIAGEDETAILS._serialized_start=2219 + _VEHICLEPOSITION_CARRIAGEDETAILS._serialized_end=2436 + _VEHICLEPOSITION_VEHICLESTOPSTATUS._serialized_start=2438 + _VEHICLEPOSITION_VEHICLESTOPSTATUS._serialized_end=2509 + _VEHICLEPOSITION_CONGESTIONLEVEL._serialized_start=2511 + _VEHICLEPOSITION_CONGESTIONLEVEL._serialized_end=2636 + _VEHICLEPOSITION_OCCUPANCYSTATUS._serialized_start=2639 + _VEHICLEPOSITION_OCCUPANCYSTATUS._serialized_end=2856 + _ALERT._serialized_start=2875 + _ALERT._serialized_end=4027 + _ALERT_CAUSE._serialized_start=3497 + _ALERT_CAUSE._serialized_end=3713 + _ALERT_EFFECT._serialized_start=3716 + _ALERT_EFFECT._serialized_end=3937 + _ALERT_SEVERITYLEVEL._serialized_start=3939 + _ALERT_SEVERITYLEVEL._serialized_end=4011 + _TIMERANGE._serialized_start=4029 + _TIMERANGE._serialized_end=4084 + _POSITION._serialized_start=4086 + _POSITION._serialized_end=4199 + _TRIPDESCRIPTOR._serialized_start=4202 + _TRIPDESCRIPTOR._serialized_end=4535 + _TRIPDESCRIPTOR_SCHEDULERELATIONSHIP._serialized_start=4403 + _TRIPDESCRIPTOR_SCHEDULERELATIONSHIP._serialized_end=4519 + _VEHICLEDESCRIPTOR._serialized_start=4537 + _VEHICLEDESCRIPTOR._serialized_end=4622 + _ENTITYSELECTOR._serialized_start=4625 + _ENTITYSELECTOR._serialized_end=4801 + _TRANSLATEDSTRING._serialized_start=4804 + _TRANSLATEDSTRING._serialized_end=4970 + _TRANSLATEDSTRING_TRANSLATION._serialized_start=4893 + _TRANSLATEDSTRING_TRANSLATION._serialized_end=4954 +# @@protoc_insertion_point(module_scope) diff --git a/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py b/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py new file mode 100644 index 0000000..c2bbd7b --- /dev/null +++ b/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: realtime_extension.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import amarillo.plugins.enhancer.services.gtfsrt.gtfs_realtime_pb2 as gtfs__realtime__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18realtime_extension.proto\x12\x10transit_realtime\x1a\x13gtfs-realtime.proto\"p\n\x1bMfdzTripDescriptorExtension\x12\x11\n\troute_url\x18\x01 \x01(\t\x12\x11\n\tagency_id\x18\x02 \x01(\t\x12\x17\n\x0froute_long_name\x18\x03 \x01(\t\x12\x12\n\nroute_type\x18\x04 \x01(\r\"\xb0\x02\n\x1fMfdzStopTimePropertiesExtension\x12X\n\x0bpickup_type\x18\x01 \x01(\x0e\x32\x43.transit_realtime.MfdzStopTimePropertiesExtension.DropOffPickupType\x12Y\n\x0c\x64ropoff_type\x18\x02 \x01(\x0e\x32\x43.transit_realtime.MfdzStopTimePropertiesExtension.DropOffPickupType\"X\n\x11\x44ropOffPickupType\x12\x0b\n\x07REGULAR\x10\x00\x12\x08\n\x04NONE\x10\x01\x12\x10\n\x0cPHONE_AGENCY\x10\x02\x12\x1a\n\x16\x43OORDINATE_WITH_DRIVER\x10\x03:i\n\x0ftrip_descriptor\x12 .transit_realtime.TripDescriptor\x18\xf5\x07 \x01(\x0b\x32-.transit_realtime.MfdzTripDescriptorExtension:\x90\x01\n\x14stop_time_properties\x12>.transit_realtime.TripUpdate.StopTimeUpdate.StopTimeProperties\x18\xf5\x07 \x01(\x0b\x32\x31.transit_realtime.MfdzStopTimePropertiesExtensionB\t\n\x07\x64\x65.mfdz') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'realtime_extension_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + gtfs__realtime__pb2.TripDescriptor.RegisterExtension(trip_descriptor) + gtfs__realtime__pb2.TripUpdate.StopTimeUpdate.StopTimeProperties.RegisterExtension(stop_time_properties) + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\007de.mfdz' + _MFDZTRIPDESCRIPTOREXTENSION._serialized_start=67 + _MFDZTRIPDESCRIPTOREXTENSION._serialized_end=179 + _MFDZSTOPTIMEPROPERTIESEXTENSION._serialized_start=182 + _MFDZSTOPTIMEPROPERTIESEXTENSION._serialized_end=486 + _MFDZSTOPTIMEPROPERTIESEXTENSION_DROPOFFPICKUPTYPE._serialized_start=398 + _MFDZSTOPTIMEPROPERTIESEXTENSION_DROPOFFPICKUPTYPE._serialized_end=486 +# @@protoc_insertion_point(module_scope) diff --git a/amarillo-gtfs-generator/models/Carpool.py b/amarillo-gtfs-generator/models/Carpool.py new file mode 100644 index 0000000..a0e1d59 --- /dev/null +++ b/amarillo-gtfs-generator/models/Carpool.py @@ -0,0 +1,407 @@ +from datetime import time, date, datetime +from pydantic import ConfigDict, BaseModel, Field, HttpUrl, EmailStr +from typing import List, Union, Set, Optional, Tuple +from datetime import time +from pydantic import BaseModel, Field +from geojson_pydantic.geometries import LineString +from enum import Enum, IntEnum + +NumType = Union[float, int] + +MAX_STOPS_PER_TRIP = 100 + +class Weekday(str, Enum): + monday = "monday" + tuesday = "tuesday" + wednesday = "wednesday" + thursday = "thursday" + friday = "friday" + saturday = "saturday" + sunday = "sunday" + +class PickupDropoffType(str, Enum): + pickup_and_dropoff = "pickup_and_dropoff" + only_pickup = "only_pickup" + only_dropoff = "only_dropoff" + +class YesNoEnum(IntEnum): + yes = 1 + no = 2 + +class LuggageSize(IntEnum): + small = 1 + medium = 2 + large = 3 + +class StopTime(BaseModel): + id: Optional[str] = Field( + None, + description="Optional Stop ID. If given, it should conform to the " + "IFOPT specification. For official transit stops, " + "it should be their official IFOPT. In Germany, this is " + "the DHID which is available via the 'zentrales " + "Haltestellenverzeichnis (zHV)', published by DELFI e.V. " + "Note, that currently carpooling location.", + pattern=r"^([a-zA-Z]{2,6}):\d+:\d+(:\d*(:\w+)?)?$|^osm:[nwr]\d+$", + examples=["de:12073:900340137::2"]) + + name: str = Field( + description="Name of the location. Use a name that people will " + "understand in the local and tourist vernacular.", + min_length=1, + max_length=256, + examples=["Angermünde, Breitscheidstr."]) + + departureTime: Optional[str] = Field( + None, + description="Departure time from a specific stop for a specific " + "carpool trip. For times occurring after midnight on the " + "service day, the time is given as a value greater than " + "24:00:00 in HH:MM:SS local time for the day on which the " + "trip schedule begins. If there are not separate times for " + "arrival and departure at a stop, the same value for arrivalTime " + "and departureTime. Note, that arrivalTime/departureTime of " + "the stops are not mandatory, and might then be estimated by " + "this service.", + pattern=r"^[0-9][0-9]:[0-5][0-9](:[0-5][0-9])?$", + examples=["17:00"] + ) + + arrivalTime: Optional[str] = Field( + None, + description="Arrival time at a specific stop for a specific trip on a " + "carpool route. If there are not separate times for arrival " + "and departure at a stop, enter the same value for arrivalTime " + "and departureTime. For times occurring after midnight on the " + "service day, the time as a value greater than 24:00:00 in " + "HH:MM:SS local time for the day on which the trip schedule " + "begins. Note, that arrivalTime/departureTime of the stops " + "are not mandatory, and might then be estimated by this " + "service.", + pattern=r"^[0-9][0-9]:[0-5][0-9](:[0-5][0-9])?$", + examples=["18:00"]) + + lat: float = Field( + description="Latitude of the location. Should describe the location " + "where a passenger may mount/dismount the vehicle.", + ge=-90, + lt=90, + examples=["53.0137311391"]) + + lon: float = Field( + description="Longitude of the location. Should describe the location " + "where a passenger may mount/dismount the vehicle.", + ge=-180, + lt=180, + examples=["13.9934706687"]) + + pickup_dropoff: Optional[PickupDropoffType] = Field( + None, description="If passengers may be picked up, dropped off or both at this stop. " + "If not specified, this service may assign this according to some custom rules. " + "E.g. Amarillo may allow pickup only for the first third of the distance travelled, " + "and dropoff only for the last third." , + examples=["only_pickup"] + ) + model_config = ConfigDict(json_schema_extra={ + "example": "{'id': 'de:12073:900340137::2', 'name': " + "'Angermünde, Breitscheidstr.', 'lat': 53.0137311391, " + "'lon': 13.9934706687}" + }) + +class Region(BaseModel): + id: str = Field( + description="ID of the region.", + min_length=1, + max_length=20, + pattern='^[a-zA-Z0-9]+$', + examples=["bb"]) + + bbox: Tuple[NumType, NumType, NumType, NumType] = Field( + description="Bounding box of the region. Format is [minLon, minLat, maxLon, maxLat]", + examples=[[10.5,49.2,11.3,51.3]]) + +class RidesharingInfo(BaseModel): + number_free_seats: int = Field( + description="Number of free seats", + ge=0, + examples=[3]) + + same_gender: Optional[YesNoEnum] = Field( + None, + description="Trip only for same gender:" + "1: Yes" + "2: No", + examples=[1]) + luggage_size: Optional[LuggageSize] = Field( + None, + description="Size of the luggage:" + "1: small" + "2: medium" + "3: large", + examples=[3]) + animal_car: Optional[YesNoEnum] = Field( + None, + description="Animals in Car allowed:" + "1: Yes" + "2: No", + examples=[2]) + + car_model: Optional[str] = Field( + None, + description="Car model", + min_length=1, + max_length=48, + examples=["Golf"]) + car_brand: Optional[str] = Field( + None, + description="Car brand", + min_length=1, + max_length=48, + examples=["VW"]) + + creation_date: datetime = Field( + description="Date when trip was created", + examples=["2022-02-13T20:20:39+00:00"]) + + smoking: Optional[YesNoEnum] = Field( + None, + description="Smoking allowed:" + "1: Yes" + "2: No", + examples=[2]) + + payment_method: Optional[str] = Field( + None, + description="Method of payment", + min_length=1, + max_length=48) + +class Driver(BaseModel): + driver_id: Optional[str] = Field( + None, + description="Identifies the driver.", + min_length=1, + max_length=256, + pattern='^[a-zA-Z0-9_-]+$', + examples=["789"]) + profile_picture: Optional[HttpUrl] = Field( + None, + description="URL that contains the profile picture", + examples=["https://mfdz.de/driver/789/picture"]) + rating: Optional[int] = Field( + None, + description="Rating of the driver from 1 to 5." + "0 no rating yet", + ge=0, + le=5, + examples=[5]) + +class Agency(BaseModel): + id: str = Field( + description="ID of the agency.", + min_length=1, + max_length=20, + pattern='^[a-zA-Z0-9]+$', + examples=["mfdz"]) + + name: str = Field( + description="Name", + min_length=1, + max_length=48, + pattern=r'^[\w -\.\|]+$', + examples=["MITFAHR|DE|ZENTRALE"]) + + url: HttpUrl = Field( + description="URL of the carpool agency.", + examples=["https://mfdz.de/"]) + + timezone: str = Field( + description="Timezone where the carpool agency is located.", + min_length=1, + max_length=48, + pattern=r'^[\w/]+$', + examples=["Europe/Berlin"]) + + lang: str = Field( + description="Primary language used by this carpool agency.", + min_length=1, + max_length=2, + pattern=r'^[a-zA-Z_]+$', + examples=["de"]) + + email: EmailStr = Field( + description="""Email address actively monitored by the agency’s + customer service department. This email address should be a direct + contact point where carpool riders can reach a customer service + representative at the agency.""", + examples=["info@mfdz.de"]) + + terms_url: Optional[HttpUrl] = Field( + None, description="""A fully qualified URL pointing to the terms of service + (also often called "terms of use" or "terms and conditions") + for the service.""", + examples=["https://mfdz.de/nutzungsbedingungen"]) + + privacy_url: Optional[HttpUrl] = Field( + None, description="""A fully qualified URL pointing to the privacy policy for the service.""", + examples=["https://mfdz.de/datenschutz"]) + model_config = ConfigDict(json_schema_extra={ + "title": "Agency", + "description": "Carpool agency.", + "example": + #""" + { + "id": "mfdz", + "name": "MITFAHR|DE|ZENTRALE", + "url": "http://mfdz.de", + "timezone": "Europe/Berlin", + "lang": "de", + "email": "info@mfdz.de", + "terms_url": "https://mfdz.de/nutzungsbedingungen", + "privacy_url": "https://mfdz.de/datenschutz", + } + #""" + }) + +class Carpool(BaseModel): + id: str = Field( + description="ID of the carpool. Should be supplied and managed by the " + "carpooling platform which originally published this " + "offer.", + min_length=1, + max_length=256, + pattern='^[a-zA-Z0-9_-]+$', + examples=["103361"]) + + agency: str = Field( + description="Short one string name of the agency, used as a namespace " + "for ids.", + min_length=1, + max_length=20, + pattern='^[a-zA-Z0-9]+$', + examples=["mfdz"]) + + driver: Optional[Driver] = Field( + None, + description="Driver data", + examples=[""" + { + "driver_id": "123", + "profile_picture": "https://mfdz.de/driver/789/picture", + "rating": 5 + } + """]) + + deeplink: HttpUrl = Field( + description="Link to an information page providing detail information " + "for this offer, and, especially, an option to book the " + "trip/contact the driver.", + examples=["https://mfdz.de/trip/103361"]) + + stops: List[StopTime] = Field( + ..., + min_length=2, + max_length=MAX_STOPS_PER_TRIP, + description="Stops which this carpool passes by and offers to pick " + "up/drop off passengers. This list must at minimum " + "include two stops, the origin and destination of this " + "carpool trip. Note that for privacy reasons, the stops " + "usually should be official locations, like meeting " + "points, carpool parkings, ridesharing benches or " + "similar.", + examples=["""[ + { + "id": "03", + "name": "drei", + "lat": 45, + "lon": 9 + }, + { + "id": "03b", + "name": "drei b", + "lat": 45, + "lon": 9 + } + ]"""]) + + # TODO can be removed, as first stop has departureTime as well + departureTime: time = Field( + description="Time when the carpool leaves at the first stop. Note, " + "that this API currently does not support flexible time " + "windows for departure, though drivers might be flexible." + "For recurring trips, the weekdays this trip will run. ", + examples=["17:00"]) + + # TODO think about using googlecal Format + departureDate: Union[date, Set[Weekday]] = Field( + description="Date when the trip will start, in case it is a one-time " + "trip. For recurring trips, specify weekdays. " + "Note, that when for different weekdays different " + "departureTimes apply, multiple carpool offers should be " + "published.", + examples=['A single date 2022-04-04 or a list of weekdays ["saturday", ' + '"sunday"]']) + route_color: Optional[str] = Field( + None, + pattern='^([0-9A-Fa-f]{6})$', + description="Route color designation that matches public facing material. " + "The color difference between route_color and route_text_color " + "should provide sufficient contrast when viewed on a black and " + "white screen.", + examples=["0039A6"]) + route_text_color: Optional[str] = Field( + None, + pattern='^([0-9A-Fa-f]{6})$', + description="Legible color to use for text drawn against a background of " + "route_color. The color difference between route_color and " + "route_text_color should provide sufficient contrast when " + "viewed on a black and white screen.", + examples=["D4D2D2"]) + path: Optional[LineString] = Field( + None, description="Optional route geometry as json LineString.") + + lastUpdated: Optional[datetime] = Field( + None, + description="LastUpdated should reflect the last time, the user " + "providing this offer, made an update or confirmed, " + "the offer is still valid. Note that this service might " + "purge outdated offers (e.g. older than 180 days). If not " + "passed, the service may assume 'now'", + examples=["2022-02-13T20:20:39+00:00"]) + additional_ridesharing_info: Optional[RidesharingInfo] = Field( + None, + description="Extension of GRFS to the GTFS standard", + examples=[""" + { + "number_free_seats": 2, + "creation_date": "2022-02-13T20:20:39+00:00", + "same_gender": 2, + "smoking": 1, + "luggage_size": 3 + } + """]) + model_config = ConfigDict(json_schema_extra={ + "title": "Carpool", + # description ... + "example": + """ + { + "id": "1234", + "agency": "mfdz", + "deeplink": "http://mfdz.de", + "stops": [ + { + "id": "de:12073:900340137::2", "name": "ABC", + "lat": 45, "lon": 9 + }, + { + "id": "de:12073:900340137::3", "name": "XYZ", + "lat": 45, "lon": 9 + } + ], + "departureTime": "12:34", + "departureDate": "2022-03-30", + "lastUpdated": "2022-03-30T12:34:00+00:00" + } + """ + }) diff --git a/amarillo-gtfs-generator/models/__init__.py b/amarillo-gtfs-generator/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amarillo-gtfs-generator/models/gtfs.py b/amarillo-gtfs-generator/models/gtfs.py new file mode 100644 index 0000000..958b58b --- /dev/null +++ b/amarillo-gtfs-generator/models/gtfs.py @@ -0,0 +1,30 @@ +# TODO: move to enhancer +from collections import namedtuple +from datetime import timedelta + +GtfsFeedInfo = namedtuple('GtfsFeedInfo', 'feed_id feed_publisher_name feed_publisher_url feed_lang feed_version') +GtfsAgency = namedtuple('GtfsAgency', 'agency_id agency_name agency_url agency_timezone agency_lang agency_email') +GtfsRoute = namedtuple('GtfsRoute', 'agency_id route_id route_long_name route_type route_url route_short_name route_color route_text_color') +GtfsStop = namedtuple('GtfsStop', 'stop_id stop_lat stop_lon stop_name') +GtfsStopTime = namedtuple('GtfsStopTime', 'trip_id departure_time arrival_time stop_id stop_sequence pickup_type drop_off_type timepoint') +GtfsTrip = namedtuple('GtfsTrip', 'route_id trip_id service_id shape_id trip_headsign bikes_allowed') +GtfsCalendar = namedtuple('GtfsCalendar', 'service_id start_date end_date monday tuesday wednesday thursday friday saturday sunday') +GtfsCalendarDate = namedtuple('GtfsCalendarDate', 'service_id date exception_type') +GtfsShape = namedtuple('GtfsShape','shape_id shape_pt_lon shape_pt_lat shape_pt_sequence') + +# TODO Move to utils +class GtfsTimeDelta(timedelta): + def __str__(self): + seconds = self.total_seconds() + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + seconds = seconds % 60 + str = '{:02d}:{:02d}:{:02d}'.format(int(hours), int(minutes), int(seconds)) + return (str) + + def __add__(self, other): + if isinstance(other, timedelta): + return self.__class__(self.days + other.days, + self.seconds + other.seconds, + self.microseconds + other.microseconds) + return NotImplemented \ No newline at end of file diff --git a/amarillo-gtfs-generator/router.py b/amarillo-gtfs-generator/router.py new file mode 100644 index 0000000..df40d42 --- /dev/null +++ b/amarillo-gtfs-generator/router.py @@ -0,0 +1,68 @@ +import logging + +from fastapi import APIRouter, HTTPException, status, Depends + +from amarillo.models.Carpool import Region +from amarillo.services.regions import RegionService +# from amarillo.services.oauth2 import get_current_user, verify_permission +from amarillo.models.User import User +from amarillo.utils.container import container +from fastapi.responses import FileResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# @router.post("/export") +# async def trigger_export(requesting_user: User = Depends(get_current_user)): +# verify_permission("generate-gtfs", requesting_user) +# #import is here to avoid circular import +# from amarillo.plugins.gtfs_export.gtfs_generator import generate_gtfs +# generate_gtfs() + +#TODO: move to amarillo/utils? +def _assert_region_exists(region_id: str) -> Region: + regions: RegionService = container['regions'] + region = regions.get_region(region_id) + region_exists = region is not None + + if not region_exists: + message = f"Region with id {region_id} does not exist." + logger.error(message) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message) + + return region + + +# @router.get("/region/{region_id}/gtfs", +# summary="Return GTFS Feed for this region", +# response_description="GTFS-Feed (zip-file)", +# response_class=FileResponse, +# responses={ +# status.HTTP_404_NOT_FOUND: {"description": "Region not found"}, +# } +# ) +# async def get_file(region_id: str, requesting_user: User = Depends(get_current_user)): +# verify_permission("gtfs", requesting_user) +# _assert_region_exists(region_id) +# return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfs.zip') + +# @router.get("/region/{region_id}/gtfs-rt", +# summary="Return GTFS-RT Feed for this region", +# response_description="GTFS-RT-Feed", +# response_class=FileResponse, +# responses={ +# status.HTTP_404_NOT_FOUND: {"description": "Region not found"}, +# status.HTTP_400_BAD_REQUEST: {"description": "Bad request, e.g. because format is not supported, i.e. neither protobuf nor json."} +# } +# ) +# async def get_file(region_id: str, format: str = 'protobuf', requesting_user: User = Depends(get_current_user)): +# verify_permission("gtfs", requesting_user) +# _assert_region_exists(region_id) +# if format == 'json': +# return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfsrt.json') +# elif format == 'protobuf': +# return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfsrt.pbf') +# else: +# message = "Specified format is not supported, i.e. neither protobuf nor json." +# raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) \ No newline at end of file diff --git a/amarillo-gtfs-generator/tests/__init__.py b/amarillo-gtfs-generator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amarillo-gtfs-generator/tests/test_gtfs.py b/amarillo-gtfs-generator/tests/test_gtfs.py new file mode 100644 index 0000000..3fbe97c --- /dev/null +++ b/amarillo-gtfs-generator/tests/test_gtfs.py @@ -0,0 +1,142 @@ +from amarillo.tests.sampledata import carpool_1234, data1, carpool_repeating_json, stop_issue +from amarillo.plugins.enhancer.services.gtfs_export import GtfsExport +from amarillo.plugins.enhancer.services.gtfs import GtfsRtProducer +from amarillo.plugins.enhancer.services.stops import StopsStore +from amarillo.plugins.enhancer.services.trips import TripStore +from amarillo.models.Carpool import Carpool +from datetime import datetime +import time +import pytest + + +def test_gtfs_generation(): + cp = Carpool(**data1) + stops_store = StopsStore() + trips_store = TripStore(stops_store) + trips_store.put_carpool(cp) + + exporter = GtfsExport(None, None, trips_store, stops_store) + exporter.export('target/tests/test_gtfs_generation/test.gtfs.zip', "target/tests/test_gtfs_generation") + +def test_correct_stops(): + cp = Carpool(**stop_issue) + stops_store = StopsStore([{"url": "https://datahub.bbnavi.de/export/rideshare_points.geojson", "vicinity": 250}]) + stops_store.load_stop_sources() + trips_store = TripStore(stops_store) + trips_store.put_carpool(cp) + assert len(trips_store.trips) == 1 + + +class TestTripConverter: + + def setup_method(self, method): + self.stops_store = StopsStore([{"url": "https://datahub.bbnavi.de/export/rideshare_points.geojson", "vicinity": 50}]) + self.trips_store = TripStore(self.stops_store) + + def test_as_one_time_trip_as_delete_update(self): + cp = Carpool(**data1) + self.trips_store.put_carpool(cp) + trip = next(iter(self.trips_store.trips.values())) + + converter = GtfsRtProducer(self.trips_store) + json = converter._as_delete_updates(trip, datetime(2022,4,11)) + + assert json == [{ + 'trip': { + 'tripId': 'mfdz:Eins', + 'startTime': '23:59:00', + 'startDate': '20220530', + 'scheduleRelationship': 'CANCELED', + 'routeId': 'mfdz:Eins' + } + }] + + def test_as_one_time_trip_as_added_update(self): + cp = Carpool(**data1) + self.trips_store.put_carpool(cp) + trip = next(iter(self.trips_store.trips.values())) + + converter = GtfsRtProducer(self.trips_store) + json = converter._as_added_updates(trip, datetime(2022,4,11)) + assert json == [{ + 'trip': { + 'tripId': 'mfdz:Eins', + 'startTime': '23:59:00', + 'startDate': '20220530', + 'scheduleRelationship': 'ADDED', + 'routeId': 'mfdz:Eins', + '[transit_realtime.trip_descriptor]': { + 'routeUrl' : 'https://mfdz.de/trip/123', + 'agencyId' : 'mfdz', + 'route_long_name' : 'abc nach xyz', + 'route_type': 1551 + } + }, + 'stopTimeUpdate': [{ + 'stopSequence': 1, + 'arrival': { + 'time': time.mktime(datetime(2022,5,30,23,59,0).timetuple()), + 'uncertainty': 600 + }, + 'departure': { + 'time': time.mktime(datetime(2022,5,30,23,59,0).timetuple()), + 'uncertainty': 600 + }, + 'stopId': 'mfdz:12073:001', + 'scheduleRelationship': 'SCHEDULED', + 'stop_time_properties': { + '[transit_realtime.stop_time_properties]': { + 'dropoffType': 'NONE', + 'pickupType': 'COORDINATE_WITH_DRIVER' + } + } + }, + { + 'stopSequence': 2, + 'arrival': { + 'time': time.mktime(datetime(2022,5,31,0,16,45,0).timetuple()), + 'uncertainty': 600 + }, + 'departure': { + 'time': time.mktime(datetime(2022,5,31,0,16,45,0).timetuple()), + 'uncertainty': 600 + }, + + 'stopId': 'de:12073:900340137::3', + 'scheduleRelationship': 'SCHEDULED', + 'stop_time_properties': { + '[transit_realtime.stop_time_properties]': { + 'dropoffType': 'COORDINATE_WITH_DRIVER', + 'pickupType': 'NONE' + } + } + }] + }] + + def test_as_periodic_trip_as_delete_update(self): + cp = Carpool(**carpool_repeating_json) + self.trips_store.put_carpool(cp) + trip = next(iter(self.trips_store.trips.values())) + + converter = GtfsRtProducer(self.trips_store) + json = converter._as_delete_updates(trip, datetime(2022,4,11)) + + assert json == [{ + 'trip': { + 'tripId': 'mfdz:Zwei', + 'startTime': '15:00:00', + 'startDate': '20220411', + 'scheduleRelationship': 'CANCELED', + 'routeId': 'mfdz:Zwei' + } + }, + { + 'trip': { + 'tripId': 'mfdz:Zwei', + 'startTime': '15:00:00', + 'startDate': '20220418', + 'scheduleRelationship': 'CANCELED', + 'routeId': 'mfdz:Zwei' + } + } + ] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ede49f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "amarillo-gtfs-generator" +version = "0.0.1" +dependencies = [] + +[tool.setuptools.packages] +find = {} \ No newline at end of file From 2e6420b78f5820bfa4c2188fcc050e2284714ad9 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Tue, 7 May 2024 12:22:47 +0200 Subject: [PATCH 02/17] File system events --- amarillo-gtfs-generator/gtfs.py | 6 +- amarillo-gtfs-generator/gtfs_export.py | 4 +- amarillo-gtfs-generator/gtfs_generator.py | 93 ++++- amarillo-gtfs-generator/services/__init__.py | 0 amarillo-gtfs-generator/services/carpools.py | 60 +++ amarillo-gtfs-generator/services/trips.py | 377 +++++++++++++++++++ 6 files changed, 516 insertions(+), 24 deletions(-) create mode 100644 amarillo-gtfs-generator/services/__init__.py create mode 100644 amarillo-gtfs-generator/services/carpools.py create mode 100644 amarillo-gtfs-generator/services/trips.py diff --git a/amarillo-gtfs-generator/gtfs.py b/amarillo-gtfs-generator/gtfs.py index 368d924..6b11c5a 100644 --- a/amarillo-gtfs-generator/gtfs.py +++ b/amarillo-gtfs-generator/gtfs.py @@ -1,6 +1,6 @@ -import amarillo.plugins.gtfs_export.gtfsrt.gtfs_realtime_pb2 as gtfs_realtime_pb2 -import amarillo.plugins.gtfs_export.gtfsrt.realtime_extension_pb2 as mfdzrte -from amarillo.plugins.gtfs_export.gtfs_constants import * +from .gtfsrt import gtfs_realtime_pb2 as gtfs_realtime_pb2 +from gtfsrt import realtime_extension_pb2 as mfdzrte +from .gtfs_constants import * from google.protobuf.json_format import MessageToDict from google.protobuf.json_format import ParseDict from datetime import datetime, timedelta diff --git a/amarillo-gtfs-generator/gtfs_export.py b/amarillo-gtfs-generator/gtfs_export.py index 1626fcd..592b561 100644 --- a/amarillo-gtfs-generator/gtfs_export.py +++ b/amarillo-gtfs-generator/gtfs_export.py @@ -8,9 +8,9 @@ import logging import re from amarillo.utils.utils import assert_folder_exists -from amarillo.plugins.gtfs_export.models.gtfs import GtfsTimeDelta, GtfsFeedInfo, GtfsAgency, GtfsRoute, GtfsStop, GtfsStopTime, GtfsTrip, GtfsCalendar, GtfsCalendarDate, GtfsShape +from .models.gtfs import GtfsTimeDelta, GtfsFeedInfo, GtfsAgency, GtfsRoute, GtfsStop, GtfsStopTime, GtfsTrip, GtfsCalendar, GtfsCalendarDate, GtfsShape from amarillo.plugins.enhancer.services.stops import is_carpooling_stop -from amarillo.plugins.gtfs_export.gtfs_constants import * +from .gtfs_constants import * from .models.Carpool import Agency diff --git a/amarillo-gtfs-generator/gtfs_generator.py b/amarillo-gtfs-generator/gtfs_generator.py index c6eb2d1..4d6ed1b 100644 --- a/amarillo-gtfs-generator/gtfs_generator.py +++ b/amarillo-gtfs-generator/gtfs_generator.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Body, status +from fastapi import FastAPI, Body, HTTPException, status from fastapi.responses import FileResponse from .gtfs_export import GtfsExport, GtfsFeedInfo, GtfsAgency @@ -12,16 +12,46 @@ import schedule import threading import time import logging +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler from .models.Carpool import Carpool, Region from .router import _assert_region_exists -from amarillo.plugins.enhancer.services import stops -from amarillo.plugins.enhancer.services.trips import TripStore, Trip -from amarillo.plugins.enhancer.services.carpools import CarpoolService +from amarillo.plugins.enhancer.services import stops #TODO: make stop service its own package?? +from .services.trips import TripStore, Trip +from .services.carpools import CarpoolService from amarillo.services.agencies import AgencyService from amarillo.services.regions import RegionService +from amarillo.utils.utils import agency_carpool_ids_from_filename + logger = logging.getLogger(__name__) +class EventHandler(FileSystemEventHandler): + + def on_closed(self, event): + + logger.info("CLOSE_WRITE: Created %s", event.src_path) + try: + with open(event.src_path, 'r', encoding='utf-8') as f: + dict = json.load(f) + carpool = Carpool(**dict) + + container['carpools'].put(carpool.agency, carpool.id, carpool) + except FileNotFoundError as e: + logger.error("Carpool could not be added, as already deleted (%s)", event.src_path) + except: + logger.exception("Eventhandler on_closed encountered exception") + + def on_deleted(self, event): + try: + logger.info("DELETE: Removing %s", event.src_path) + (agency_id, carpool_id) = agency_carpool_ids_from_filename(event.src_path) + container['carpools'].delete(agency_id, carpool_id) + except: + logger.exception("Eventhandler on_deleted encountered exception") + + + def init(): container['agencies'] = AgencyService() logger.info("Loaded %d agencies", len(container['agencies'].agencies)) @@ -40,7 +70,7 @@ def init(): container['stops_store'] = stop_store container['trips_store'] = TripStore(stop_store) - # TODO: do we need the carpool service at all? + # TODO: the carpool service may be obsolete container['carpools'] = CarpoolService(container['trips_store']) logger.info("Restore carpools...") @@ -55,6 +85,11 @@ def init(): except Exception as e: logger.warning("Issue during restore of carpool %s: %s", carpool_file_name, repr(e)) + observer = Observer() # Watch Manager + + observer.schedule(EventHandler(), 'data/enhanced', recursive=True) + observer.start() + def run_schedule(): while 1: @@ -82,7 +117,7 @@ def generate_gtfs(): exporter = GtfsExport( container['agencies'].agencies, feed_info, - container['trips_store'], # TODO: read carpools from disk and convert them to trips + container['trips_store'], container['stops_store'], region.bbox) exporter.export(f"data/gtfs/amarillo.{region.id}.gtfs.zip", "data/tmp/") @@ -109,7 +144,7 @@ def setup(app : FastAPI): pass logging.config.fileConfig('logging.conf', disable_existing_loggers=False) -logger = logging.getLogger("enhancer") +logger = logging.getLogger("gtfs-generator") #TODO: clean up metadata app = FastAPI(title="Amarillo GTFS Generator", @@ -173,22 +208,22 @@ app = FastAPI(title="Amarillo GTFS Generator", init() -@app.post("/", - operation_id="enhancecarpool", - summary="Add a new or update existing carpool", - description="Carpool object to be enhanced", - responses={ - status.HTTP_404_NOT_FOUND: { - "description": "Agency does not exist"}, +# @app.post("/", +# operation_id="enhancecarpool", +# summary="Add a new or update existing carpool", +# description="Carpool object to be enhanced", +# responses={ +# status.HTTP_404_NOT_FOUND: { +# "description": "Agency does not exist"}, - }) +# }) #TODO: add examples -async def post_carpool(carpool: Carpool = Body(...)): +# async def post_carpool(carpool: Carpool = Body(...)): - logger.info(f"POST trip {carpool.agency}:{carpool.id}.") +# logger.info(f"POST trip {carpool.agency}:{carpool.id}.") - trips_store: TripStore = container['trips_store'] - trip = trips_store._load_as_trip(carpool) +# trips_store: TripStore = container['trips_store'] +# trip = trips_store._load_as_trip(carpool) #TODO: carpool deleted endpoint @@ -208,6 +243,26 @@ async def get_file(region_id: str): # verify_permission("gtfs", requesting_user) return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfs.zip') +@app.get("/region/{region_id}/grfs-rt/", + summary="Return GRFS-RT Feed for this region", + response_description="GRFS-RT-Feed", + response_class=FileResponse, + responses={ + status.HTTP_404_NOT_FOUND: {"description": "Region not found"}, + status.HTTP_400_BAD_REQUEST: {"description": "Bad request, e.g. because format is not supported, i.e. neither protobuf nor json."} + } + ) +async def get_file(region_id: str, format: str = 'protobuf'): + generate_gtfs_rt() + _assert_region_exists(region_id) + if format == 'json': + return FileResponse(f'data/grfs/amarillo.{region_id}.gtfsrt.json') + elif format == 'protobuf': + return FileResponse(f'data/grfs/amarillo.{region_id}.gtfsrt.pbf') + else: + message = "Specified format is not supported, i.e. neither protobuf nor json." + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) + #TODO: sync endpoint that calls midnight @app.post("/sync", diff --git a/amarillo-gtfs-generator/services/__init__.py b/amarillo-gtfs-generator/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amarillo-gtfs-generator/services/carpools.py b/amarillo-gtfs-generator/services/carpools.py new file mode 100644 index 0000000..e62d7fd --- /dev/null +++ b/amarillo-gtfs-generator/services/carpools.py @@ -0,0 +1,60 @@ +import json +import logging +from datetime import datetime +from typing import Dict +from amarillo.models.Carpool import Carpool +from amarillo.utils.utils import yesterday, is_older_than_days + +logger = logging.getLogger(__name__) + +class CarpoolService(): + MAX_OFFER_AGE_IN_DAYS = 180 + + def __init__(self, trip_store): + + self.trip_store = trip_store + self.carpools: Dict[str, Carpool] = {} + + def is_outdated(self, carpool): + """ + A carpool offer is outdated, if + * it's completly in the past (if it's a single date offer). + As we know the start time but not latest arrival, we deem + offers starting the day before yesterday as outdated + * it's last update occured before MAX_OFFER_AGE_IN_DAYS + """ + runs_once = not isinstance(carpool.departureDate, set) + return (is_older_than_days(carpool.lastUpdated.date(), self.MAX_OFFER_AGE_IN_DAYS) or + (runs_once and carpool.departureDate < yesterday())) + + def purge_outdated_offers(self): + """ + Iterates over all carpools and deletes those which are outdated + """ + for key in list(self.carpools.keys()): + cp = self.carpools.get(key) + if cp and self.is_outdated(cp): + logger.info("Purge outdated offer %s", key) + self.delete(cp.agency, cp.id) + + def get(self, agency_id: str, carpool_id: str): + return self.carpools.get(f"{agency_id}:{carpool_id}") + + def get_all_ids(self): + return list(self.carpools) + + def put(self, agency_id: str, carpool_id: str, carpool): + self.carpools[f"{agency_id}:{carpool_id}"] = carpool + # Outdated trips (which might have been in the store) + # will be deleted + if self.is_outdated(carpool): + logger.info('Deleting outdated carpool %s:%s', agency_id, carpool_id) + self.delete(agency_id, carpool_id) + else: + self.trip_store.put_carpool(carpool) + + def delete(self, agency_id: str, carpool_id: str): + id = f"{agency_id}:{carpool_id}" + if id in self.carpools: + del self.carpools[id] + self.trip_store.delete_carpool(agency_id, carpool_id) diff --git a/amarillo-gtfs-generator/services/trips.py b/amarillo-gtfs-generator/services/trips.py new file mode 100644 index 0000000..6faa749 --- /dev/null +++ b/amarillo-gtfs-generator/services/trips.py @@ -0,0 +1,377 @@ +from amarillo.plugins.enhancer.models.gtfs import GtfsTimeDelta, GtfsStopTime +from amarillo.models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, PickupDropoffType, Driver, RidesharingInfo +from amarillo.services.config import config +from amarillo.plugins.enhancer.services.gtfs_constants import * +from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException +from amarillo.plugins.enhancer.services.stops import is_carpooling_stop +from amarillo.utils.utils import assert_folder_exists, is_older_than_days, yesterday, geodesic_distance_in_m +from shapely.geometry import Point, LineString, box +from geojson_pydantic.geometries import LineString as GeoJSONLineString +from datetime import datetime, timedelta +import numpy as np +import os +import json +import logging + +logger = logging.getLogger(__name__) + +class Trip: + + def __init__(self, trip_id, route_name, headsign, url, calendar, departureTime, path, agency, lastUpdated, stop_times, driver: Driver, additional_ridesharing_info: RidesharingInfo, bbox): + if isinstance(calendar, set): + self.runs_regularly = True + self.weekdays = [ + 1 if Weekday.monday in calendar else 0, + 1 if Weekday.tuesday in calendar else 0, + 1 if Weekday.wednesday in calendar else 0, + 1 if Weekday.thursday in calendar else 0, + 1 if Weekday.friday in calendar else 0, + 1 if Weekday.saturday in calendar else 0, + 1 if Weekday.sunday in calendar else 0, + ] + start_in_day = self._total_seconds(departureTime) + else: + self.start = datetime.combine(calendar, departureTime) + self.runs_regularly = False + self.weekdays = [0,0,0,0,0,0,0] + + self.start_time = departureTime + self.path = path + self.trip_id = trip_id + self.url = url + self.agency = agency + self.stops = [] + self.lastUpdated = lastUpdated + self.stop_times = stop_times + self.driver = driver + self.additional_ridesharing_info = additional_ridesharing_info + self.bbox = bbox + self.route_name = route_name + self.trip_headsign = headsign + + def path_as_line_string(self): + return self.path + + def _total_seconds(self, instant): + return instant.hour * 3600 + instant.minute * 60 + instant.second + + def start_time_str(self): + return self.start_time.strftime("%H:%M:%S") + + def next_trip_dates(self, start_date, day_count=14): + if self.runs_regularly: + for single_date in (start_date + timedelta(n) for n in range(day_count)): + if self.weekdays[single_date.weekday()]==1: + yield single_date.strftime("%Y%m%d") + else: + yield self.start.strftime("%Y%m%d") + + def route_long_name(self): + return self.route_name + + def intersects(self, bbox): + return self.bbox.intersects(box(*bbox)) + + +class TripStore(): + """ + TripStore maintains the currently valid trips. A trip is a + carpool offer enhanced with all stops this + + Attributes: + trips Dict of currently valid trips. + deleted_trips Dict of recently deleted trips. + """ + + def __init__(self, stops_store): + self.transformer = TripTransformer(stops_store) + self.stops_store = stops_store + self.trips = {} + self.deleted_trips = {} + self.recent_trips = {} + + + def put_carpool(self, carpool: Carpool): + """ + Adds carpool to the TripStore. + """ + id = "{}:{}".format(carpool.agency, carpool.id) + filename = f'data/enhanced/{carpool.agency}/{carpool.id}.json' + try: + existing_carpool = self._load_carpool_if_exists(carpool.agency, carpool.id) + if existing_carpool and existing_carpool.lastUpdated == carpool.lastUpdated: + enhanced_carpool = existing_carpool + else: + if len(carpool.stops) < 2 or self.distance_in_m(carpool) < 1000: + logger.warning("Failed to add carpool %s:%s to TripStore, distance too low", carpool.agency, carpool.id) + self.handle_failed_carpool_enhancement(carpool) + return + enhanced_carpool = self.transformer.enhance_carpool(carpool) + # TODO should only store enhanced_carpool, if it has 2 or more stops + assert_folder_exists(f'data/enhanced/{carpool.agency}/') + with open(filename, 'w', encoding='utf-8') as f: + f.write(enhanced_carpool.json()) + logger.info("Added enhanced carpool %s:%s", carpool.agency, carpool.id) + + return self._load_as_trip(enhanced_carpool) + except RoutingException as err: + logger.warning("Failed to add carpool %s:%s to TripStore due to RoutingException %s", carpool.agency, carpool.id, getattr(err, 'message', repr(err))) + self.handle_failed_carpool_enhancement(carpool) + except Exception as err: + logger.error("Failed to add carpool %s:%s to TripStore.", carpool.agency, carpool.id, exc_info=True) + self.handle_failed_carpool_enhancement(carpool) + + def handle_failed_carpool_enhancement(sellf, carpool: Carpool): + assert_folder_exists(f'data/failed/{carpool.agency}/') + with open(f'data/failed/{carpool.agency}/{carpool.id}.json', 'w', encoding='utf-8') as f: + f.write(carpool.json()) + + def distance_in_m(self, carpool): + if len(carpool.stops) < 2: + return 0 + s1 = carpool.stops[0] + s2 = carpool.stops[-1] + return geodesic_distance_in_m((s1.lon, s1.lat),(s2.lon, s2.lat)) + + def recently_added_trips(self): + return list(self.recent_trips.values()) + + def recently_deleted_trips(self): + return list(self.deleted_trips.values()) + + def _load_carpool_if_exists(self, agency_id: str, carpool_id: str): + if carpool_exists(agency_id, carpool_id, 'data/enhanced'): + try: + return load_carpool(agency_id, carpool_id, 'data/enhanced') + except Exception as e: + # An error on restore could be caused by model changes, + # in such a case, it need's to be recreated + logger.warning("Could not restore enhanced trip %s:%s, reason: %s", agency_id, carpool_id, repr(e)) + + return None + + def _load_as_trip(self, carpool: Carpool): + trip = self.transformer.transform_to_trip(carpool) + id = trip.trip_id + self.trips[id] = trip + if not is_older_than_days(carpool.lastUpdated, 1): + self.recent_trips[id] = trip + logger.debug("Added trip %s", id) + + return trip + + def delete_carpool(self, agency_id: str, carpool_id: str): + """ + Deletes carpool from the TripStore. + """ + agencyScopedCarpoolId = f"{agency_id}:{carpool_id}" + trip_to_be_deleted = self.trips.get(agencyScopedCarpoolId) + if trip_to_be_deleted: + self.deleted_trips[agencyScopedCarpoolId] = trip_to_be_deleted + del self.trips[agencyScopedCarpoolId] + + if self.recent_trips.get(agencyScopedCarpoolId): + del self.recent_trips[agencyScopedCarpoolId] + + if carpool_exists(agency_id, carpool_id): + remove_carpool_file(agency_id, carpool_id) + + logger.debug("Deleted trip %s", id) + + def unflag_unrecent_updates(self): + """ + Trips that were last updated before yesterday, are not recent + any longer. As no updates need to be sent for them any longer, + they will be removed from recent recent_trips and deleted_trips. + """ + for key in list(self.recent_trips): + t = self.recent_trips.get(key) + if t and t.lastUpdated.date() < yesterday(): + del self.recent_trips[key] + + for key in list(self.deleted_trips): + t = self.deleted_trips.get(key) + if t and t.lastUpdated.date() < yesterday(): + del self.deleted_trips[key] + + +class TripTransformer: + REPLACE_CARPOOL_STOPS_BY_CLOSEST_TRANSIT_STOPS = True + REPLACEMENT_STOPS_SERACH_RADIUS_IN_M = 1000 + SIMPLIFY_TOLERANCE = 0.0001 + + router = RoutingService(config.graphhopper_base_url) + + def __init__(self, stops_store): + self.stops_store = stops_store + + def transform_to_trip(self, carpool : Carpool): + stop_times = self._convert_stop_times(carpool) + route_name = carpool.stops[0].name + " nach " + carpool.stops[-1].name + headsign= carpool.stops[-1].name + trip_id = self._trip_id(carpool) + path = carpool.path + bbox = box( + min([pt[0] for pt in path.coordinates]), + min([pt[1] for pt in path.coordinates]), + max([pt[0] for pt in path.coordinates]), + max([pt[1] for pt in path.coordinates])) + + trip = Trip(trip_id, route_name, headsign, str(carpool.deeplink), carpool.departureDate, carpool.departureTime, carpool.path, carpool.agency, carpool.lastUpdated, stop_times, carpool.driver, carpool.additional_ridesharing_info, bbox) + + return trip + + def _trip_id(self, carpool): + return f"{carpool.agency}:{carpool.id}" + + def _replace_stops_by_transit_stops(self, carpool, max_search_distance): + new_stops = [] + for carpool_stop in carpool.stops: + new_stops.append(self.stops_store.find_closest_stop(carpool_stop, max_search_distance)) + return new_stops + + def enhance_carpool(self, carpool): + if self.REPLACE_CARPOOL_STOPS_BY_CLOSEST_TRANSIT_STOPS: + carpool.stops = self._replace_stops_by_transit_stops(carpool, self.REPLACEMENT_STOPS_SERACH_RADIUS_IN_M) + + path = self._path_for_ride(carpool) + lineString_shapely_wgs84 = LineString(coordinates = path["points"]["coordinates"]).simplify(0.0001) + lineString_wgs84 = GeoJSONLineString(type="LineString", coordinates=list(lineString_shapely_wgs84.coords)) + virtual_stops = self.stops_store.find_additional_stops_around(lineString_wgs84, carpool.stops) + if not virtual_stops.empty: + virtual_stops["time"] = self._estimate_times(path, virtual_stops['distance']) + logger.debug("Virtual stops found: {}".format(virtual_stops)) + if len(virtual_stops) > MAX_STOPS_PER_TRIP: + # in case we found more than MAX_STOPS_PER_TRIP, we retain first and last + # half of MAX_STOPS_PER_TRIP + virtual_stops = virtual_stops.iloc[np.r_[0:int(MAX_STOPS_PER_TRIP/2), int(MAX_STOPS_PER_TRIP/2):]] + + trip_id = f"{carpool.agency}:{carpool.id}" + stop_times = self._stops_and_stop_times(carpool.departureTime, trip_id, virtual_stops) + + enhanced_carpool = carpool.copy() + enhanced_carpool.stops = stop_times + enhanced_carpool.path = lineString_wgs84 + return enhanced_carpool + + def _convert_stop_times(self, carpool): + + stop_times = [GtfsStopTime( + self._trip_id(carpool), + stop.arrivalTime, + stop.departureTime, + stop.id, + seq_nr+1, + STOP_TIMES_STOP_TYPE_NONE if stop.pickup_dropoff == PickupDropoffType.only_dropoff else STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER, + STOP_TIMES_STOP_TYPE_NONE if stop.pickup_dropoff == PickupDropoffType.only_pickup else STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER, + STOP_TIMES_TIMEPOINT_APPROXIMATE) + for seq_nr, stop in enumerate(carpool.stops)] + return stop_times + + def _path_for_ride(self, carpool): + points = self._stop_coords(carpool.stops) + return self.router.path_for_stops(points) + + def _stop_coords(self, stops): + # Retrieve coordinates of all officially announced stops (start, intermediate, target) + return [Point(stop.lon, stop.lat) for stop in stops] + + def _estimate_times(self, path, distances_from_start): + cumulated_distance = 0 + cumulated_time = 0 + stop_times = [] + instructions = path["instructions"] + + cnt = 0 + instr_distance = instructions[cnt]["distance"] + instr_time = instructions[cnt]["time"] + + for distance in distances_from_start: + while cnt < len(instructions) and cumulated_distance + instructions[cnt]["distance"] < distance: + cumulated_distance = cumulated_distance + instructions[cnt]["distance"] + cumulated_time = cumulated_time + instructions[cnt]["time"] + cnt = cnt + 1 + + if cnt < len(instructions): + if instructions[cnt]["distance"] ==0: + raise RoutingException("Origin and destinaction too close") + percent_dist = (distance - cumulated_distance) / instructions[cnt]["distance"] + stop_time = cumulated_time + percent_dist * instructions[cnt]["time"] + stop_times.append(stop_time) + else: + logger.debug("distance {} exceeds total length {}, using max arrival time {}".format(distance, cumulated_distance, cumulated_time)) + stop_times.append(cumulated_time) + return stop_times + + def _stops_and_stop_times(self, start_time, trip_id, stops_frame): + # Assumptions: + # arrival_time = departure_time + # pickup_type, drop_off_type for origin: = coordinate/none + # pickup_type, drop_off_type for destination: = none/coordinate + # timepoint = approximate for origin and destination (not sure what consequences this might have for trip planners) + number_of_stops = len(stops_frame.index) + total_distance = stops_frame.iloc[number_of_stops-1]["distance"] + + first_stop_time = GtfsTimeDelta(hours = start_time.hour, minutes = start_time.minute, seconds = start_time.second) + stop_times = [] + seq_nr = 0 + for i in range(0, number_of_stops): + current_stop = stops_frame.iloc[i] + + if not current_stop.id: + continue + elif i == 0: + if (stops_frame.iloc[1].time-current_stop.time) < 1000: + # skip custom stop if there is an official stop very close by + logger.debug("Skipped stop %s", current_stop.id) + continue + else: + if (current_stop.time-stops_frame.iloc[i-1].time) < 5000 and not i==1 and not is_carpooling_stop(current_stop.id, current_stop.stop_name): + # skip latter stop if it's very close (<5 seconds drive) by the preceding + logger.debug("Skipped stop %s", current_stop.id) + continue + trip_time = timedelta(milliseconds=int(current_stop.time)) + is_dropoff = self._is_dropoff_stop(current_stop, total_distance) + is_pickup = self._is_pickup_stop(current_stop, total_distance) + # TODO would be nice if possible to publish a minimum shared distance + pickup_type = STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER if is_pickup else STOP_TIMES_STOP_TYPE_NONE + dropoff_type = STOP_TIMES_STOP_TYPE_COORDINATE_DRIVER if is_dropoff else STOP_TIMES_STOP_TYPE_NONE + + if is_pickup and not is_dropoff: + pickup_dropoff = PickupDropoffType.only_pickup + elif not is_pickup and is_dropoff: + pickup_dropoff = PickupDropoffType.only_dropoff + else: + pickup_dropoff = PickupDropoffType.pickup_and_dropoff + + next_stop_time = first_stop_time + trip_time + seq_nr += 1 + stop_times.append(StopTime(**{ + 'arrivalTime': str(next_stop_time), + 'departureTime': str(next_stop_time), + 'id': current_stop.id, + 'pickup_dropoff': pickup_dropoff, + 'name': str(current_stop.stop_name), + 'lat': current_stop.y, + 'lon': current_stop.x + })) + + return stop_times + + def _is_dropoff_stop(self, current_stop, total_distance): + return current_stop["distance"] >= 0.5 * total_distance + + def _is_pickup_stop(self, current_stop, total_distance): + return current_stop["distance"] < 0.5 * total_distance + +def load_carpool(agency_id: str, carpool_id: str, folder: str ='data/enhanced') -> Carpool: + with open(f'{folder}/{agency_id}/{carpool_id}.json', 'r', encoding='utf-8') as f: + dict = json.load(f) + carpool = Carpool(**dict) + return carpool + +def carpool_exists(agency_id: str, carpool_id: str, folder: str ='data/enhanced'): + return os.path.exists(f"{folder}/{agency_id}/{carpool_id}.json") + +def remove_carpool_file(agency_id: str, carpool_id: str, folder: str ='data/enhanced'): + return os.remove(f"{folder}/{agency_id}/{carpool_id}.json") From 16c5f4f1631ed943505699409585e3d8a9b9ffaf Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Tue, 7 May 2024 13:10:17 +0200 Subject: [PATCH 03/17] Fixed imports --- amarillo-gtfs-generator/__init__.py | 1 - amarillo-gtfs-generator/gtfs.py | 2 +- amarillo-gtfs-generator/gtfs_generator.py | 2 +- amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py | 2 +- amarillo-gtfs-generator/router.py | 2 +- amarillo-gtfs-generator/services/trips.py | 6 +++--- 6 files changed, 7 insertions(+), 8 deletions(-) diff --git a/amarillo-gtfs-generator/__init__.py b/amarillo-gtfs-generator/__init__.py index 3aa6b3b..e69de29 100644 --- a/amarillo-gtfs-generator/__init__.py +++ b/amarillo-gtfs-generator/__init__.py @@ -1 +0,0 @@ -from .gtfs_generator import setup \ No newline at end of file diff --git a/amarillo-gtfs-generator/gtfs.py b/amarillo-gtfs-generator/gtfs.py index 6b11c5a..ce0c722 100644 --- a/amarillo-gtfs-generator/gtfs.py +++ b/amarillo-gtfs-generator/gtfs.py @@ -1,5 +1,5 @@ from .gtfsrt import gtfs_realtime_pb2 as gtfs_realtime_pb2 -from gtfsrt import realtime_extension_pb2 as mfdzrte +from .gtfsrt import realtime_extension_pb2 as mfdzrte from .gtfs_constants import * from google.protobuf.json_format import MessageToDict from google.protobuf.json_format import ParseDict diff --git a/amarillo-gtfs-generator/gtfs_generator.py b/amarillo-gtfs-generator/gtfs_generator.py index 4d6ed1b..41a85c9 100644 --- a/amarillo-gtfs-generator/gtfs_generator.py +++ b/amarillo-gtfs-generator/gtfs_generator.py @@ -5,7 +5,7 @@ from .gtfs_export import GtfsExport, GtfsFeedInfo, GtfsAgency from .gtfs import GtfsRtProducer from amarillo.utils.container import container # from amarillo.plugins.gtfs_export.router import router -from amarillo.plugins.enhancer.configuration import configure_enhancer_services +# from amarillo.plugins.enhancer.configuration import configure_enhancer_services from glob import glob import json import schedule diff --git a/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py b/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py index c2bbd7b..f4419c0 100644 --- a/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py +++ b/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py @@ -11,7 +11,7 @@ from google.protobuf import symbol_database as _symbol_database _sym_db = _symbol_database.Default() -import amarillo.plugins.enhancer.services.gtfsrt.gtfs_realtime_pb2 as gtfs__realtime__pb2 +from . import gtfs_realtime_pb2 as gtfs__realtime__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18realtime_extension.proto\x12\x10transit_realtime\x1a\x13gtfs-realtime.proto\"p\n\x1bMfdzTripDescriptorExtension\x12\x11\n\troute_url\x18\x01 \x01(\t\x12\x11\n\tagency_id\x18\x02 \x01(\t\x12\x17\n\x0froute_long_name\x18\x03 \x01(\t\x12\x12\n\nroute_type\x18\x04 \x01(\r\"\xb0\x02\n\x1fMfdzStopTimePropertiesExtension\x12X\n\x0bpickup_type\x18\x01 \x01(\x0e\x32\x43.transit_realtime.MfdzStopTimePropertiesExtension.DropOffPickupType\x12Y\n\x0c\x64ropoff_type\x18\x02 \x01(\x0e\x32\x43.transit_realtime.MfdzStopTimePropertiesExtension.DropOffPickupType\"X\n\x11\x44ropOffPickupType\x12\x0b\n\x07REGULAR\x10\x00\x12\x08\n\x04NONE\x10\x01\x12\x10\n\x0cPHONE_AGENCY\x10\x02\x12\x1a\n\x16\x43OORDINATE_WITH_DRIVER\x10\x03:i\n\x0ftrip_descriptor\x12 .transit_realtime.TripDescriptor\x18\xf5\x07 \x01(\x0b\x32-.transit_realtime.MfdzTripDescriptorExtension:\x90\x01\n\x14stop_time_properties\x12>.transit_realtime.TripUpdate.StopTimeUpdate.StopTimeProperties\x18\xf5\x07 \x01(\x0b\x32\x31.transit_realtime.MfdzStopTimePropertiesExtensionB\t\n\x07\x64\x65.mfdz') diff --git a/amarillo-gtfs-generator/router.py b/amarillo-gtfs-generator/router.py index df40d42..2a35901 100644 --- a/amarillo-gtfs-generator/router.py +++ b/amarillo-gtfs-generator/router.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, HTTPException, status, Depends from amarillo.models.Carpool import Region from amarillo.services.regions import RegionService # from amarillo.services.oauth2 import get_current_user, verify_permission -from amarillo.models.User import User +# from amarillo.models.User import User from amarillo.utils.container import container from fastapi.responses import FileResponse diff --git a/amarillo-gtfs-generator/services/trips.py b/amarillo-gtfs-generator/services/trips.py index 6faa749..56879af 100644 --- a/amarillo-gtfs-generator/services/trips.py +++ b/amarillo-gtfs-generator/services/trips.py @@ -1,7 +1,7 @@ -from amarillo.plugins.enhancer.models.gtfs import GtfsTimeDelta, GtfsStopTime -from amarillo.models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, PickupDropoffType, Driver, RidesharingInfo +from ..models.gtfs import GtfsTimeDelta, GtfsStopTime +from ..models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, PickupDropoffType, Driver, RidesharingInfo from amarillo.services.config import config -from amarillo.plugins.enhancer.services.gtfs_constants import * +from ..gtfs_constants import * from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException from amarillo.plugins.enhancer.services.stops import is_carpooling_stop from amarillo.utils.utils import assert_folder_exists, is_older_than_days, yesterday, geodesic_distance_in_m From d610699f9f68423bcc6aaeb2bf0396f2cbba121f Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Tue, 7 May 2024 13:15:58 +0200 Subject: [PATCH 04/17] Stops service --- amarillo-gtfs-generator/gtfs_export.py | 2 +- amarillo-gtfs-generator/gtfs_generator.py | 2 +- amarillo-gtfs-generator/services/stops.py | 182 ++++++++++++++++++++++ amarillo-gtfs-generator/services/trips.py | 2 +- 4 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 amarillo-gtfs-generator/services/stops.py diff --git a/amarillo-gtfs-generator/gtfs_export.py b/amarillo-gtfs-generator/gtfs_export.py index 592b561..c1b1952 100644 --- a/amarillo-gtfs-generator/gtfs_export.py +++ b/amarillo-gtfs-generator/gtfs_export.py @@ -9,7 +9,7 @@ import re from amarillo.utils.utils import assert_folder_exists from .models.gtfs import GtfsTimeDelta, GtfsFeedInfo, GtfsAgency, GtfsRoute, GtfsStop, GtfsStopTime, GtfsTrip, GtfsCalendar, GtfsCalendarDate, GtfsShape -from amarillo.plugins.enhancer.services.stops import is_carpooling_stop +from .services.stops import is_carpooling_stop from .gtfs_constants import * from .models.Carpool import Agency diff --git a/amarillo-gtfs-generator/gtfs_generator.py b/amarillo-gtfs-generator/gtfs_generator.py index 41a85c9..de35a5e 100644 --- a/amarillo-gtfs-generator/gtfs_generator.py +++ b/amarillo-gtfs-generator/gtfs_generator.py @@ -16,7 +16,7 @@ from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from .models.Carpool import Carpool, Region from .router import _assert_region_exists -from amarillo.plugins.enhancer.services import stops #TODO: make stop service its own package?? +from .services import stops #TODO: make stop service its own package?? from .services.trips import TripStore, Trip from .services.carpools import CarpoolService from amarillo.services.agencies import AgencyService diff --git a/amarillo-gtfs-generator/services/stops.py b/amarillo-gtfs-generator/services/stops.py new file mode 100644 index 0000000..1d3a1bd --- /dev/null +++ b/amarillo-gtfs-generator/services/stops.py @@ -0,0 +1,182 @@ +import csv +import geopandas as gpd +import pandas as pd +from amarillo.models.Carpool import StopTime +from contextlib import closing +from shapely.geometry import Point, LineString +from shapely.ops import transform +from pyproj import Proj, Transformer +import re +import requests +from io import TextIOWrapper +import codecs +import logging + +logger = logging.getLogger(__name__) + +class StopsStore(): + + def __init__(self, stop_sources = [], internal_projection = "EPSG:32632"): + self.internal_projection = internal_projection + self.projection = Transformer.from_crs("EPSG:4326", internal_projection, always_xy=True).transform + self.stopsDataFrames = [] + self.stop_sources = stop_sources + + + def load_stop_sources(self): + """Imports stops from stop_sources and registers them with + the distance they are still associated with a trip. + E.g. bus stops should be registered with a distance of e.g. 30m, + while larger carpool parkings might be registered with e.g. 500m. + + Subsequent calls of load_stop_sources will reload all stop_sources + but replace the current stops only if all stops could be loaded successfully. + """ + stopsDataFrames = [] + error_occured = False + + for stops_source in self.stop_sources: + try: + stopsDataFrame =self._load_stops(stops_source["url"]) + stopsDataFrames.append({'distanceInMeter': stops_source["vicinity"], + 'stops': stopsDataFrame}) + except Exception as err: + error_occured = True + logger.error("Failed to load stops from %s to StopsStore.", stops_source["url"], exc_info=True) + + if not error_occured: + self.stopsDataFrames = stopsDataFrames + + def find_additional_stops_around(self, line, stops = None): + """Returns a GeoDataFrame with all stops in vicinity of the + given line, sorted by distance from origin of the line. + Note: for internal projection/distance calculations, the + lat/lon geometries of line and stops are converted to + """ + stops_frames = [] + if stops: + stops_frames.append(self._convert_to_dataframe(stops)) + transformedLine = transform(self.projection, LineString(line.coordinates)) + for stops_to_match in self.stopsDataFrames: + stops_frames.append(self._find_stops_around_transformed(stops_to_match['stops'], transformedLine, stops_to_match['distanceInMeter'])) + stops = gpd.GeoDataFrame( pd.concat(stops_frames, ignore_index=True, sort=True)) + if not stops.empty: + self._sort_by_distance(stops, transformedLine) + return stops + + def find_closest_stop(self, carpool_stop, max_search_distance): + transformedCoord = Point(self.projection(carpool_stop.lon, carpool_stop.lat)) + best_dist = max_search_distance + 1 + best_stop = None + for stops_with_dist in self.stopsDataFrames: + stops = stops_with_dist['stops'] + s, d = stops.sindex.nearest(transformedCoord, return_all= True, return_distance=True, max_distance=max_search_distance) + if len(d) > 0 and d[0] < best_dist: + best_dist = d[0] + row = s[1][0] + best_stop = StopTime(name=stops.at[row, 'stop_name'], lat=stops.at[row, 'y'], lon=stops.at[row, 'x']) + + return best_stop if best_stop else carpool_stop + + def _normalize_stop_name(self, stop_name): + default_name = 'P+R-Parkplatz' + if stop_name in ('', 'Park&Ride'): + return default_name + normalized_stop_name = re.sub(r"P(ark)?\s?[\+&]\s?R(ail|ide)?",'P+R', stop_name) + + return normalized_stop_name + + def _load_stops(self, source : str): + """Loads stops from given source and registers them with + the distance they are still associated with a trip. + E.g. bus stops should be registered with a distance of e.g. 30m, + while larger carpool parkings might be registered with e.g. 500m + """ + logger.info("Load stops from %s", source) + if source.startswith('http'): + if source.endswith('json'): + with requests.get(source) as json_source: + stopsDataFrame = self._load_stops_geojson(json_source.json()) + else: + with requests.get(source) as csv_source: + stopsDataFrame = self._load_stops_csv(codecs.iterdecode(csv_source.iter_lines(), 'utf-8')) + else: + with open(source, encoding='utf-8') as csv_source: + stopsDataFrame = self._load_stops_csv(csv_source) + + return stopsDataFrame + + def _load_stops_csv(self, csv_source): + id = [] + lat = [] + lon = [] + stop_name = [] + reader = csv.DictReader(csv_source, delimiter=';') + columns = ['stop_id', 'stop_lat', 'stop_lon', 'stop_name'] + lists = [id, lat, lon, stop_name] + for row in reader: + for col, lst in zip(columns, lists): + if col == "stop_lat" or col == "stop_lon": + lst.append(float(row[col].replace(",","."))) + elif col == "stop_name": + row_stop_name = self._normalize_stop_name(row[col]) + lst.append(row_stop_name) + else: + lst.append(row[col]) + + return self._as_dataframe(id, lat, lon, stop_name) + + def _load_stops_geojson(self, geojson_source): + id = [] + lat = [] + lon = [] + stop_name = [] + columns = ['stop_id', 'stop_lat', 'stop_lon', 'stop_name'] + lists = [id, lat, lon, stop_name] + for row in geojson_source['features']: + coord = row['geometry']['coordinates'] + if not coord or not row['properties'].get('name'): + logger.error('Stop feature {} has null coord or name'.format(row['id'])) + continue + for col, lst in zip(columns, lists): + if col == "stop_lat": + lst.append(coord[1]) + elif col == "stop_lon": + lst.append(coord[0]) + elif col == "stop_name": + row_stop_name = self._normalize_stop_name(row['properties']['name']) + lst.append(row_stop_name) + elif col == "stop_id": + lst.append(row['id']) + + return self._as_dataframe(id, lat, lon, stop_name) + + def _as_dataframe(self, id, lat, lon, stop_name): + + df = gpd.GeoDataFrame(data={'x':lon, 'y':lat, 'stop_name':stop_name, 'id':id}) + stopsGeoDataFrame = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.x, df.y, crs='EPSG:4326')) + stopsGeoDataFrame.to_crs(crs=self.internal_projection, inplace=True) + return stopsGeoDataFrame + + def _find_stops_around_transformed(self, stopsDataFrame, transformedLine, distance): + bufferedLine = transformedLine.buffer(distance) + sindex = stopsDataFrame.sindex + possible_matches_index = list(sindex.intersection(bufferedLine.bounds)) + possible_matches = stopsDataFrame.iloc[possible_matches_index] + exact_matches = possible_matches[possible_matches.intersects(bufferedLine)] + + return exact_matches + + def _convert_to_dataframe(self, stops): + return gpd.GeoDataFrame([[stop.name, stop.lon, stop.lat, + stop.id, Point(self.projection(stop.lon, stop.lat))] for stop in stops], columns = ['stop_name','x','y','id','geometry'], crs=self.internal_projection) + + def _sort_by_distance(self, stops, transformedLine): + stops['distance']=stops.apply(lambda row: transformedLine.project(row['geometry']), axis=1) + stops.sort_values('distance', inplace=True) + +def is_carpooling_stop(stop_id, name): + stop_name = name.lower() + # mfdz: or bbnavi: prefixed stops are custom stops which are explicitly meant to be carpooling stops + return stop_id.startswith('mfdz:') or stop_id.startswith('bbnavi:') or 'mitfahr' in stop_name or 'p&m' in stop_name + diff --git a/amarillo-gtfs-generator/services/trips.py b/amarillo-gtfs-generator/services/trips.py index 56879af..843e2c6 100644 --- a/amarillo-gtfs-generator/services/trips.py +++ b/amarillo-gtfs-generator/services/trips.py @@ -3,7 +3,7 @@ from ..models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, Pic from amarillo.services.config import config from ..gtfs_constants import * from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException -from amarillo.plugins.enhancer.services.stops import is_carpooling_stop +from ..services.stops import is_carpooling_stop from amarillo.utils.utils import assert_folder_exists, is_older_than_days, yesterday, geodesic_distance_in_m from shapely.geometry import Point, LineString, box from geojson_pydantic.geometries import LineString as GeoJSONLineString From aaa9aa4e09c60377b9b4409bfdc12e39a5c08c80 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Thu, 9 May 2024 16:23:51 +0200 Subject: [PATCH 05/17] Agencies, regions --- amarillo-gtfs-generator/gtfs_generator.py | 85 +++++++++++--------- amarillo-gtfs-generator/services/agencies.py | 24 ++++++ amarillo-gtfs-generator/services/regions.py | 21 +++++ 3 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 amarillo-gtfs-generator/services/agencies.py create mode 100644 amarillo-gtfs-generator/services/regions.py diff --git a/amarillo-gtfs-generator/gtfs_generator.py b/amarillo-gtfs-generator/gtfs_generator.py index de35a5e..8a90fee 100644 --- a/amarillo-gtfs-generator/gtfs_generator.py +++ b/amarillo-gtfs-generator/gtfs_generator.py @@ -12,6 +12,7 @@ import schedule import threading import time import logging +import os from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from .models.Carpool import Carpool, Region @@ -19,8 +20,8 @@ from .router import _assert_region_exists from .services import stops #TODO: make stop service its own package?? from .services.trips import TripStore, Trip from .services.carpools import CarpoolService -from amarillo.services.agencies import AgencyService -from amarillo.services.regions import RegionService +from .services.agencies import AgencyService +from .services.regions import RegionService from amarillo.utils.utils import agency_carpool_ids_from_filename @@ -53,6 +54,11 @@ class EventHandler(FileSystemEventHandler): def init(): + logger.info(f"Current working directory is {os.path.abspath(os.getcwd())}") + if not os.path.isdir('data/agency'): + logger.error(f'{os.path.abspath("data/agency")} directory does not exist') + + container['agencies'] = AgencyService() logger.info("Loaded %d agencies", len(container['agencies'].agencies)) @@ -76,12 +82,13 @@ def init(): logger.info("Restore carpools...") for agency_id in container['agencies'].agencies: - for carpool_file_name in glob(f'data/carpool/{agency_id}/*.json'): + for carpool_file_name in glob(f'data/enhanced/{agency_id}/*.json'): try: with open(carpool_file_name) as carpool_file: carpool = Carpool(**(json.load(carpool_file))) #TODO: convert to trip and add to tripstore directly container['carpools'].put(carpool.agency, carpool.id, carpool) + logger.info(f"Restored carpool {carpool_file_name}") except Exception as e: logger.warning("Issue during restore of carpool %s: %s", carpool_file_name, repr(e)) @@ -90,21 +97,21 @@ def init(): observer.schedule(EventHandler(), 'data/enhanced', recursive=True) observer.start() -def run_schedule(): +# def run_schedule(): - while 1: - try: - schedule.run_pending() - except Exception as e: - logger.exception(e) - time.sleep(1) +# while 1: +# try: +# schedule.run_pending() +# except Exception as e: +# logger.exception(e) +# time.sleep(1) -def midnight(): - container['stops_store'].load_stop_sources() - # container['trips_store'].unflag_unrecent_updates() - # container['carpools'].purge_outdated_offers() +# def midnight(): +# container['stops_store'].load_stop_sources() +# # container['trips_store'].unflag_unrecent_updates() +# # container['carpools'].purge_outdated_offers() - generate_gtfs() +# generate_gtfs() #TODO: generate for a specific region only #TODO: what happens when there are no trips? @@ -128,20 +135,20 @@ def generate_gtfs_rt(): for region in container['regions'].regions.values(): rt = producer.export_feed(time.time(), f"data/gtfs/amarillo.{region.id}.gtfsrt", bbox=region.bbox) -def start_schedule(): - schedule.every().day.at("00:00").do(midnight) - schedule.every(60).seconds.do(generate_gtfs_rt) - # Create all feeds once at startup - schedule.run_all() - job_thread = threading.Thread(target=run_schedule, daemon=True) - job_thread.start() +# def start_schedule(): +# # schedule.every().day.at("00:00").do(midnight) +# schedule.every(60).seconds.do(generate_gtfs_rt) +# # Create all feeds once at startup +# schedule.run_all() +# job_thread = threading.Thread(target=run_schedule, daemon=True) +# job_thread.start() -def setup(app : FastAPI): - # TODO: Create all feeds once at startup - # configure_enhancer_services() - # app.include_router(router) - # start_schedule() - pass +# def setup(app : FastAPI): +# # TODO: Create all feeds once at startup +# # configure_enhancer_services() +# # app.include_router(router) +# # start_schedule() +# pass logging.config.fileConfig('logging.conf', disable_existing_loggers=False) logger = logging.getLogger("gtfs-generator") @@ -243,9 +250,9 @@ async def get_file(region_id: str): # verify_permission("gtfs", requesting_user) return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfs.zip') -@app.get("/region/{region_id}/grfs-rt/", - summary="Return GRFS-RT Feed for this region", - response_description="GRFS-RT-Feed", +@app.get("/region/{region_id}/gtfs-rt/", + summary="Return GTFS-RT Feed for this region", + response_description="GTFS-RT-Feed", response_class=FileResponse, responses={ status.HTTP_404_NOT_FOUND: {"description": "Region not found"}, @@ -256,20 +263,20 @@ async def get_file(region_id: str, format: str = 'protobuf'): generate_gtfs_rt() _assert_region_exists(region_id) if format == 'json': - return FileResponse(f'data/grfs/amarillo.{region_id}.gtfsrt.json') + return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfsrt.json') elif format == 'protobuf': - return FileResponse(f'data/grfs/amarillo.{region_id}.gtfsrt.pbf') + return FileResponse(f'data/gtfs/amarillo.{region_id}.gtfsrt.pbf') else: message = "Specified format is not supported, i.e. neither protobuf nor json." raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) #TODO: sync endpoint that calls midnight -@app.post("/sync", - operation_id="sync") -#TODO: add examples -async def post_sync(): +# @app.post("/sync", +# operation_id="sync") +# #TODO: add examples +# async def post_sync(): - logger.info(f"Sync") +# logger.info(f"Sync") - midnight() \ No newline at end of file +# midnight() \ No newline at end of file diff --git a/amarillo-gtfs-generator/services/agencies.py b/amarillo-gtfs-generator/services/agencies.py new file mode 100644 index 0000000..e7450aa --- /dev/null +++ b/amarillo-gtfs-generator/services/agencies.py @@ -0,0 +1,24 @@ +import json +from glob import glob +from typing import Dict + +from amarillo.models.Carpool import Agency + +# TODO FG HB this service should also listen to pyinotify +# because the (updated) agencies are needed in the enhancer +# as well. + +class AgencyService: + + def __init__(self): + self.agencies: Dict[str, Agency] = {} + for agency_file_name in glob('data/agency/*.json'): + with open(agency_file_name) as agency_file: + dict = json.load(agency_file) + agency = Agency(**dict) + agency_id = agency.id + self.agencies[agency_id] = agency + + def get_agency(self, agency_id: str) -> Agency: + agency = self.agencies.get(agency_id) + return agency diff --git a/amarillo-gtfs-generator/services/regions.py b/amarillo-gtfs-generator/services/regions.py new file mode 100644 index 0000000..425b3ac --- /dev/null +++ b/amarillo-gtfs-generator/services/regions.py @@ -0,0 +1,21 @@ +import json +from glob import glob +from typing import Dict + +from amarillo.models.Carpool import Region + + +class RegionService: + + def __init__(self): + self.regions: Dict[str, Region] = {} + for region_file_name in glob('data/region/*.json'): + with open(region_file_name) as region_file: + dict = json.load(region_file) + region = Region(**dict) + region_id = region.id + self.regions[region_id] = region + + def get_region(self, region_id: str) -> Region: + region = self.regions.get(region_id) + return region From af316b794349082381c5e7807cf08a3dffe67348 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Thu, 9 May 2024 16:24:10 +0200 Subject: [PATCH 06/17] Updated trips.py --- amarillo-gtfs-generator/services/trips.py | 62 ++++++++++++----------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/amarillo-gtfs-generator/services/trips.py b/amarillo-gtfs-generator/services/trips.py index 843e2c6..edf0a0a 100644 --- a/amarillo-gtfs-generator/services/trips.py +++ b/amarillo-gtfs-generator/services/trips.py @@ -2,7 +2,7 @@ from ..models.gtfs import GtfsTimeDelta, GtfsStopTime from ..models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, PickupDropoffType, Driver, RidesharingInfo from amarillo.services.config import config from ..gtfs_constants import * -from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException +# from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException from ..services.stops import is_carpooling_stop from amarillo.utils.utils import assert_folder_exists, is_older_than_days, yesterday, geodesic_distance_in_m from shapely.geometry import Point, LineString, box @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) class Trip: - def __init__(self, trip_id, route_name, headsign, url, calendar, departureTime, path, agency, lastUpdated, stop_times, driver: Driver, additional_ridesharing_info: RidesharingInfo, bbox): + def __init__(self, trip_id, route_name, headsign, url, calendar, departureTime, path, agency, lastUpdated, stop_times, driver: Driver, additional_ridesharing_info: RidesharingInfo, route_color, route_text_color, bbox): if isinstance(calendar, set): self.runs_regularly = True self.weekdays = [ @@ -45,6 +45,8 @@ class Trip: self.stop_times = stop_times self.driver = driver self.additional_ridesharing_info = additional_ridesharing_info + self.route_color = route_color + self.route_text_color = route_text_color self.bbox = bbox self.route_name = route_name self.trip_headsign = headsign @@ -95,31 +97,32 @@ class TripStore(): """ Adds carpool to the TripStore. """ - id = "{}:{}".format(carpool.agency, carpool.id) - filename = f'data/enhanced/{carpool.agency}/{carpool.id}.json' - try: - existing_carpool = self._load_carpool_if_exists(carpool.agency, carpool.id) - if existing_carpool and existing_carpool.lastUpdated == carpool.lastUpdated: - enhanced_carpool = existing_carpool - else: - if len(carpool.stops) < 2 or self.distance_in_m(carpool) < 1000: - logger.warning("Failed to add carpool %s:%s to TripStore, distance too low", carpool.agency, carpool.id) - self.handle_failed_carpool_enhancement(carpool) - return - enhanced_carpool = self.transformer.enhance_carpool(carpool) - # TODO should only store enhanced_carpool, if it has 2 or more stops - assert_folder_exists(f'data/enhanced/{carpool.agency}/') - with open(filename, 'w', encoding='utf-8') as f: - f.write(enhanced_carpool.json()) - logger.info("Added enhanced carpool %s:%s", carpool.agency, carpool.id) + return self._load_as_trip(carpool) + # id = "{}:{}".format(carpool.agency, carpool.id) + # filename = f'data/enhanced/{carpool.agency}/{carpool.id}.json' + # try: + # existing_carpool = self._load_carpool_if_exists(carpool.agency, carpool.id) + # if existing_carpool and existing_carpool.lastUpdated == carpool.lastUpdated: + # enhanced_carpool = existing_carpool + # else: + # if len(carpool.stops) < 2 or self.distance_in_m(carpool) < 1000: + # logger.warning("Failed to add carpool %s:%s to TripStore, distance too low", carpool.agency, carpool.id) + # self.handle_failed_carpool_enhancement(carpool) + # return + # enhanced_carpool = self.transformer.enhance_carpool(carpool) + # # TODO should only store enhanced_carpool, if it has 2 or more stops + # assert_folder_exists(f'data/enhanced/{carpool.agency}/') + # with open(filename, 'w', encoding='utf-8') as f: + # f.write(enhanced_carpool.json()) + # logger.info("Added enhanced carpool %s:%s", carpool.agency, carpool.id) - return self._load_as_trip(enhanced_carpool) - except RoutingException as err: - logger.warning("Failed to add carpool %s:%s to TripStore due to RoutingException %s", carpool.agency, carpool.id, getattr(err, 'message', repr(err))) - self.handle_failed_carpool_enhancement(carpool) - except Exception as err: - logger.error("Failed to add carpool %s:%s to TripStore.", carpool.agency, carpool.id, exc_info=True) - self.handle_failed_carpool_enhancement(carpool) + # return self._load_as_trip(enhanced_carpool) + # except RoutingException as err: + # logger.warning("Failed to add carpool %s:%s to TripStore due to RoutingException %s", carpool.agency, carpool.id, getattr(err, 'message', repr(err))) + # self.handle_failed_carpool_enhancement(carpool) + # except Exception as err: + # logger.error("Failed to add carpool %s:%s to TripStore.", carpool.agency, carpool.id, exc_info=True) + # self.handle_failed_carpool_enhancement(carpool) def handle_failed_carpool_enhancement(sellf, carpool: Carpool): assert_folder_exists(f'data/failed/{carpool.agency}/') @@ -200,7 +203,7 @@ class TripTransformer: REPLACEMENT_STOPS_SERACH_RADIUS_IN_M = 1000 SIMPLIFY_TOLERANCE = 0.0001 - router = RoutingService(config.graphhopper_base_url) + # router = RoutingService(config.graphhopper_base_url) def __init__(self, stops_store): self.stops_store = stops_store @@ -217,7 +220,7 @@ class TripTransformer: max([pt[0] for pt in path.coordinates]), max([pt[1] for pt in path.coordinates])) - trip = Trip(trip_id, route_name, headsign, str(carpool.deeplink), carpool.departureDate, carpool.departureTime, carpool.path, carpool.agency, carpool.lastUpdated, stop_times, carpool.driver, carpool.additional_ridesharing_info, bbox) + trip = Trip(trip_id, route_name, headsign, str(carpool.deeplink), carpool.departureDate, carpool.departureTime, carpool.path, carpool.agency, carpool.lastUpdated, stop_times, carpool.driver, carpool.additional_ridesharing_info, carpool.route_color, carpool.route_text_color, bbox) return trip @@ -294,7 +297,8 @@ class TripTransformer: if cnt < len(instructions): if instructions[cnt]["distance"] ==0: - raise RoutingException("Origin and destinaction too close") + raise Exception("Origin and destinaction too close") + # raise RoutingException("Origin and destinaction too close") percent_dist = (distance - cumulated_distance) / instructions[cnt]["distance"] stop_time = cumulated_time + percent_dist * instructions[cnt]["time"] stop_times.append(stop_time) From fe6bc978db42bb7362d0c3f7ec2fdc9432ab0ac9 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Thu, 9 May 2024 16:24:18 +0200 Subject: [PATCH 07/17] Added Dockerfile --- .gitignore | 1 + Dockerfile | 23 +++++++++++++++++++++++ requirements.txt | 3 +++ 3 files changed, 27 insertions(+) create mode 100644 Dockerfile create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index 9c594f2..1835017 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,4 @@ config static/** templates/** conf/** +data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a21f57b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM tiangolo/uvicorn-gunicorn:python3.10-slim + +LABEL maintainer="info@mfdz.de" + +WORKDIR /app + +EXPOSE 80 + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt + +COPY ./amarillo-gtfs-generator /app/amarillo-gtfs-generator +COPY ./config /app/config +COPY ./logging.conf /app + +ENV ADMIN_TOKEN="" +ENV MODULE_NAME=amarillo-gtfs-generator.gtfs_generator +ENV MAX_WORKERS=1 + +# This image inherits uvicorn-gunicorn's CMD. If you'd like to start uvicorn, use this instead +# CMD ["uvicorn", "amarillo.main:app", "--host", "0.0.0.0", "--port", "8000"] + +#`docker run -it --rm --name amarillo-gtfs-generator -p 8002:80 -e TZ=Europe/Berlin -v $(pwd)/data:/app/data amarillo-gtfs-generator \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f1d8759 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +watchdog +amarillo +amarillo-enhancer \ No newline at end of file From c1f72fc19d1add2fc3eea022368f16ac30282cf3 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Wed, 12 Jun 2024 14:10:14 +0200 Subject: [PATCH 08/17] Added Jenkinsfile --- Jenkinsfile | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 Jenkinsfile diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..f21a19f --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,79 @@ +pipeline { + agent { label 'builtin' } + environment { + GITEA_CREDS = credentials('AMARILLO-JENKINS-GITEA-USER') + PYPI_CREDS = credentials('AMARILLO-JENKINS-PYPI-USER') + TWINE_REPO_URL = "https://git.gerhardt.io/api/packages/amarillo/pypi" + DOCKER_REGISTRY_URL = 'https://git.gerhardt.io' + OWNER = 'amarillo' + IMAGE_NAME = 'amarillo-gtfs-generator' + DISTRIBUTION = '0.1' + TAG = "${DISTRIBUTION}.${BUILD_NUMBER}" + } + stages { + stage('Create virtual environment') { + steps { + echo 'Creating virtual environment' + sh '''python3 -m venv .venv + . .venv/bin/activate''' + } + } + stage('Installing requirements') { + steps { + echo 'Installing packages' + sh 'python3 -m pip install -r requirements.txt' + sh 'python3 -m pip install --upgrade build' + sh 'python3 -m pip install --upgrade twine' + } + } + stage('Build') { + steps { + echo 'Cleaning up dist directory' + dir("dist") { + deleteDir() + } + echo 'Building package' + sh 'python3 -m build' + } + } + stage('Publish package to GI') { + steps { + sh 'python3 -m twine upload --skip-existing --verbose --repository-url $TWINE_REPO_URL --username $GITEA_CREDS_USR --password $GITEA_CREDS_PSW ./dist/*' + } + } + stage('Publish package to PyPI') { + when { + branch 'main' + } + steps { + sh 'python3 -m twine upload --verbose --username $PYPI_CREDS_USR --password $PYPI_CREDS_PSW ./dist/*' + } + } + stage('Build docker image') { + when { + branch 'main' + } + steps { + echo 'Building image' + script { + docker.build("${OWNER}/${IMAGE_NAME}:${TAG}") + } + } + } + stage('Push image to container registry') { + when { + branch 'main' + } + steps { + echo 'Pushing image to registry' + script { + docker.withRegistry(DOCKER_REGISTRY_URL, 'AMARILLO-JENKINS-GITEA-USER'){ + def image = docker.image("${OWNER}/${IMAGE_NAME}:${TAG}") + image.push() + image.push('latest') + } + } + } + } + } +} \ No newline at end of file From bbcb1e77507ae5496e1e7653defabafd00799dcf Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Wed, 12 Jun 2024 14:22:55 +0200 Subject: [PATCH 09/17] Added logging.conf --- .gitignore | 1 - logging.conf | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 logging.conf diff --git a/.gitignore b/.gitignore index 1835017..9354efb 100644 --- a/.gitignore +++ b/.gitignore @@ -162,7 +162,6 @@ cython_debug/ data/ secrets -logging.conf config static/** templates/** diff --git a/logging.conf b/logging.conf new file mode 100644 index 0000000..429da8e --- /dev/null +++ b/logging.conf @@ -0,0 +1,22 @@ +[loggers] +keys=root + +[handlers] +keys=consoleHandler + +[formatters] +keys=simpleFormatter + +[logger_root] +level=INFO +handlers=consoleHandler +propagate=yes + +[handler_consoleHandler] +class=StreamHandler +level=DEBUG +formatter=simpleFormatter +args=(sys.stdout,) + +[formatter_simpleFormatter] +format=%(asctime)s - %(name)s - %(levelname)s - %(message)s \ No newline at end of file From 970822b2e91e2675e86684419b5336a96daab283 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Wed, 12 Jun 2024 14:38:24 +0200 Subject: [PATCH 10/17] Removed config and enhancer requirements --- Dockerfile | 2 -- amarillo-gtfs-generator/services/trips.py | 2 +- requirements.txt | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index a21f57b..39e9958 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,8 @@ COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt COPY ./amarillo-gtfs-generator /app/amarillo-gtfs-generator -COPY ./config /app/config COPY ./logging.conf /app -ENV ADMIN_TOKEN="" ENV MODULE_NAME=amarillo-gtfs-generator.gtfs_generator ENV MAX_WORKERS=1 diff --git a/amarillo-gtfs-generator/services/trips.py b/amarillo-gtfs-generator/services/trips.py index edf0a0a..8c020a9 100644 --- a/amarillo-gtfs-generator/services/trips.py +++ b/amarillo-gtfs-generator/services/trips.py @@ -1,6 +1,6 @@ from ..models.gtfs import GtfsTimeDelta, GtfsStopTime from ..models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, PickupDropoffType, Driver, RidesharingInfo -from amarillo.services.config import config +# from amarillo.services.config import config from ..gtfs_constants import * # from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException from ..services.stops import is_carpooling_stop diff --git a/requirements.txt b/requirements.txt index f1d8759..50815b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ watchdog -amarillo -amarillo-enhancer \ No newline at end of file +amarillo \ No newline at end of file From 0b44607c44efeaceb7b63cfcffef564d33a6a804 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Wed, 12 Jun 2024 14:41:10 +0200 Subject: [PATCH 11/17] Increment version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ede49f..b2ef1b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "amarillo-gtfs-generator" -version = "0.0.1" +version = "0.0.2" dependencies = [] [tool.setuptools.packages] From e307b3c1cd8c8f7be5c67aa8dc9856e33a083180 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Fri, 14 Jun 2024 14:22:42 +0200 Subject: [PATCH 12/17] Added schedule dependency --- pyproject.toml | 6 +++++- requirements.txt | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b2ef1b5..aa7faed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,11 @@ [project] name = "amarillo-gtfs-generator" version = "0.0.2" -dependencies = [] +dependencies = [ + "amarillo", + "schedule", + "watchdog", +] [tool.setuptools.packages] find = {} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 50815b6..757379f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -watchdog -amarillo \ No newline at end of file +amarillo +schedule +watchdog \ No newline at end of file From 41b963d50c03714cf871f350703e16f2de8ad32b Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Fri, 28 Jun 2024 13:51:28 +0200 Subject: [PATCH 13/17] Run as amarillo user --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 39e9958..2744f55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,9 @@ COPY ./logging.conf /app ENV MODULE_NAME=amarillo-gtfs-generator.gtfs_generator ENV MAX_WORKERS=1 +RUN useradd amarillo +USER amarillo + # This image inherits uvicorn-gunicorn's CMD. If you'd like to start uvicorn, use this instead # CMD ["uvicorn", "amarillo.main:app", "--host", "0.0.0.0", "--port", "8000"] From 1b1ac3c862601db4ec7179ecfae14e2b69663773 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Tue, 9 Jul 2024 12:12:13 +0200 Subject: [PATCH 14/17] Stops package, use underscores in folder name --- .vscode/launch.json | 7 +- Dockerfile | 4 +- amarillo-gtfs-generator/services/stops.py | 182 ------------------ .../__init__.py | 0 .../gtfs.py | 0 .../gtfs_constants.py | 0 .../gtfs_export.py | 2 +- .../gtfs_generator.py | 59 +++--- .../gtfsrt/__init__.py | 0 .../gtfsrt/gtfs_realtime_pb2.py | 0 .../gtfsrt/realtime_extension_pb2.py | 0 .../models/Carpool.py | 0 .../models/__init__.py | 0 .../models/gtfs.py | 0 .../router.py | 0 .../services/__init__.py | 0 .../services/agencies.py | 0 .../services/carpools.py | 0 .../services/regions.py | 0 .../services/trips.py | 2 +- .../tests/__init__.py | 0 .../tests/test_gtfs.py | 8 +- 22 files changed, 38 insertions(+), 226 deletions(-) delete mode 100644 amarillo-gtfs-generator/services/stops.py rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/__init__.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfs.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfs_constants.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfs_export.py (99%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfs_generator.py (90%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfsrt/__init__.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfsrt/gtfs_realtime_pb2.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/gtfsrt/realtime_extension_pb2.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/models/Carpool.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/models/__init__.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/models/gtfs.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/router.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/services/__init__.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/services/agencies.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/services/carpools.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/services/regions.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/services/trips.py (99%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/tests/__init__.py (100%) rename {amarillo-gtfs-generator => amarillo_gtfs_generator}/tests/test_gtfs.py (94%) diff --git a/.vscode/launch.json b/.vscode/launch.json index e67162b..19f51ea 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,17 +23,14 @@ "request": "launch", "module": "uvicorn", "args": [ - "amarillo-gtfs-generator.gtfs_generator:app", + "amarillo_gtfs_generator.gtfs_generator:app", "--workers=1", "--port=8002" ], // "preLaunchTask": "enhance", "jinja": true, "justMyCode": false, - "env": { - "admin_token": "supersecret", - "ride2go_token": "supersecret2" - } + "env": {} } ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2744f55..367a6b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,10 @@ EXPOSE 80 COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt -COPY ./amarillo-gtfs-generator /app/amarillo-gtfs-generator +COPY ./amarillo_gtfs_generator /app/amarillo_gtfs_generator COPY ./logging.conf /app -ENV MODULE_NAME=amarillo-gtfs-generator.gtfs_generator +ENV MODULE_NAME=amarillo_gtfs_generator.gtfs_generator ENV MAX_WORKERS=1 RUN useradd amarillo diff --git a/amarillo-gtfs-generator/services/stops.py b/amarillo-gtfs-generator/services/stops.py deleted file mode 100644 index 1d3a1bd..0000000 --- a/amarillo-gtfs-generator/services/stops.py +++ /dev/null @@ -1,182 +0,0 @@ -import csv -import geopandas as gpd -import pandas as pd -from amarillo.models.Carpool import StopTime -from contextlib import closing -from shapely.geometry import Point, LineString -from shapely.ops import transform -from pyproj import Proj, Transformer -import re -import requests -from io import TextIOWrapper -import codecs -import logging - -logger = logging.getLogger(__name__) - -class StopsStore(): - - def __init__(self, stop_sources = [], internal_projection = "EPSG:32632"): - self.internal_projection = internal_projection - self.projection = Transformer.from_crs("EPSG:4326", internal_projection, always_xy=True).transform - self.stopsDataFrames = [] - self.stop_sources = stop_sources - - - def load_stop_sources(self): - """Imports stops from stop_sources and registers them with - the distance they are still associated with a trip. - E.g. bus stops should be registered with a distance of e.g. 30m, - while larger carpool parkings might be registered with e.g. 500m. - - Subsequent calls of load_stop_sources will reload all stop_sources - but replace the current stops only if all stops could be loaded successfully. - """ - stopsDataFrames = [] - error_occured = False - - for stops_source in self.stop_sources: - try: - stopsDataFrame =self._load_stops(stops_source["url"]) - stopsDataFrames.append({'distanceInMeter': stops_source["vicinity"], - 'stops': stopsDataFrame}) - except Exception as err: - error_occured = True - logger.error("Failed to load stops from %s to StopsStore.", stops_source["url"], exc_info=True) - - if not error_occured: - self.stopsDataFrames = stopsDataFrames - - def find_additional_stops_around(self, line, stops = None): - """Returns a GeoDataFrame with all stops in vicinity of the - given line, sorted by distance from origin of the line. - Note: for internal projection/distance calculations, the - lat/lon geometries of line and stops are converted to - """ - stops_frames = [] - if stops: - stops_frames.append(self._convert_to_dataframe(stops)) - transformedLine = transform(self.projection, LineString(line.coordinates)) - for stops_to_match in self.stopsDataFrames: - stops_frames.append(self._find_stops_around_transformed(stops_to_match['stops'], transformedLine, stops_to_match['distanceInMeter'])) - stops = gpd.GeoDataFrame( pd.concat(stops_frames, ignore_index=True, sort=True)) - if not stops.empty: - self._sort_by_distance(stops, transformedLine) - return stops - - def find_closest_stop(self, carpool_stop, max_search_distance): - transformedCoord = Point(self.projection(carpool_stop.lon, carpool_stop.lat)) - best_dist = max_search_distance + 1 - best_stop = None - for stops_with_dist in self.stopsDataFrames: - stops = stops_with_dist['stops'] - s, d = stops.sindex.nearest(transformedCoord, return_all= True, return_distance=True, max_distance=max_search_distance) - if len(d) > 0 and d[0] < best_dist: - best_dist = d[0] - row = s[1][0] - best_stop = StopTime(name=stops.at[row, 'stop_name'], lat=stops.at[row, 'y'], lon=stops.at[row, 'x']) - - return best_stop if best_stop else carpool_stop - - def _normalize_stop_name(self, stop_name): - default_name = 'P+R-Parkplatz' - if stop_name in ('', 'Park&Ride'): - return default_name - normalized_stop_name = re.sub(r"P(ark)?\s?[\+&]\s?R(ail|ide)?",'P+R', stop_name) - - return normalized_stop_name - - def _load_stops(self, source : str): - """Loads stops from given source and registers them with - the distance they are still associated with a trip. - E.g. bus stops should be registered with a distance of e.g. 30m, - while larger carpool parkings might be registered with e.g. 500m - """ - logger.info("Load stops from %s", source) - if source.startswith('http'): - if source.endswith('json'): - with requests.get(source) as json_source: - stopsDataFrame = self._load_stops_geojson(json_source.json()) - else: - with requests.get(source) as csv_source: - stopsDataFrame = self._load_stops_csv(codecs.iterdecode(csv_source.iter_lines(), 'utf-8')) - else: - with open(source, encoding='utf-8') as csv_source: - stopsDataFrame = self._load_stops_csv(csv_source) - - return stopsDataFrame - - def _load_stops_csv(self, csv_source): - id = [] - lat = [] - lon = [] - stop_name = [] - reader = csv.DictReader(csv_source, delimiter=';') - columns = ['stop_id', 'stop_lat', 'stop_lon', 'stop_name'] - lists = [id, lat, lon, stop_name] - for row in reader: - for col, lst in zip(columns, lists): - if col == "stop_lat" or col == "stop_lon": - lst.append(float(row[col].replace(",","."))) - elif col == "stop_name": - row_stop_name = self._normalize_stop_name(row[col]) - lst.append(row_stop_name) - else: - lst.append(row[col]) - - return self._as_dataframe(id, lat, lon, stop_name) - - def _load_stops_geojson(self, geojson_source): - id = [] - lat = [] - lon = [] - stop_name = [] - columns = ['stop_id', 'stop_lat', 'stop_lon', 'stop_name'] - lists = [id, lat, lon, stop_name] - for row in geojson_source['features']: - coord = row['geometry']['coordinates'] - if not coord or not row['properties'].get('name'): - logger.error('Stop feature {} has null coord or name'.format(row['id'])) - continue - for col, lst in zip(columns, lists): - if col == "stop_lat": - lst.append(coord[1]) - elif col == "stop_lon": - lst.append(coord[0]) - elif col == "stop_name": - row_stop_name = self._normalize_stop_name(row['properties']['name']) - lst.append(row_stop_name) - elif col == "stop_id": - lst.append(row['id']) - - return self._as_dataframe(id, lat, lon, stop_name) - - def _as_dataframe(self, id, lat, lon, stop_name): - - df = gpd.GeoDataFrame(data={'x':lon, 'y':lat, 'stop_name':stop_name, 'id':id}) - stopsGeoDataFrame = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.x, df.y, crs='EPSG:4326')) - stopsGeoDataFrame.to_crs(crs=self.internal_projection, inplace=True) - return stopsGeoDataFrame - - def _find_stops_around_transformed(self, stopsDataFrame, transformedLine, distance): - bufferedLine = transformedLine.buffer(distance) - sindex = stopsDataFrame.sindex - possible_matches_index = list(sindex.intersection(bufferedLine.bounds)) - possible_matches = stopsDataFrame.iloc[possible_matches_index] - exact_matches = possible_matches[possible_matches.intersects(bufferedLine)] - - return exact_matches - - def _convert_to_dataframe(self, stops): - return gpd.GeoDataFrame([[stop.name, stop.lon, stop.lat, - stop.id, Point(self.projection(stop.lon, stop.lat))] for stop in stops], columns = ['stop_name','x','y','id','geometry'], crs=self.internal_projection) - - def _sort_by_distance(self, stops, transformedLine): - stops['distance']=stops.apply(lambda row: transformedLine.project(row['geometry']), axis=1) - stops.sort_values('distance', inplace=True) - -def is_carpooling_stop(stop_id, name): - stop_name = name.lower() - # mfdz: or bbnavi: prefixed stops are custom stops which are explicitly meant to be carpooling stops - return stop_id.startswith('mfdz:') or stop_id.startswith('bbnavi:') or 'mitfahr' in stop_name or 'p&m' in stop_name - diff --git a/amarillo-gtfs-generator/__init__.py b/amarillo_gtfs_generator/__init__.py similarity index 100% rename from amarillo-gtfs-generator/__init__.py rename to amarillo_gtfs_generator/__init__.py diff --git a/amarillo-gtfs-generator/gtfs.py b/amarillo_gtfs_generator/gtfs.py similarity index 100% rename from amarillo-gtfs-generator/gtfs.py rename to amarillo_gtfs_generator/gtfs.py diff --git a/amarillo-gtfs-generator/gtfs_constants.py b/amarillo_gtfs_generator/gtfs_constants.py similarity index 100% rename from amarillo-gtfs-generator/gtfs_constants.py rename to amarillo_gtfs_generator/gtfs_constants.py diff --git a/amarillo-gtfs-generator/gtfs_export.py b/amarillo_gtfs_generator/gtfs_export.py similarity index 99% rename from amarillo-gtfs-generator/gtfs_export.py rename to amarillo_gtfs_generator/gtfs_export.py index c1b1952..6570f92 100644 --- a/amarillo-gtfs-generator/gtfs_export.py +++ b/amarillo_gtfs_generator/gtfs_export.py @@ -9,7 +9,7 @@ import re from amarillo.utils.utils import assert_folder_exists from .models.gtfs import GtfsTimeDelta, GtfsFeedInfo, GtfsAgency, GtfsRoute, GtfsStop, GtfsStopTime, GtfsTrip, GtfsCalendar, GtfsCalendarDate, GtfsShape -from .services.stops import is_carpooling_stop +from amarillo_stops.stops import is_carpooling_stop from .gtfs_constants import * from .models.Carpool import Agency diff --git a/amarillo-gtfs-generator/gtfs_generator.py b/amarillo_gtfs_generator/gtfs_generator.py similarity index 90% rename from amarillo-gtfs-generator/gtfs_generator.py rename to amarillo_gtfs_generator/gtfs_generator.py index 8a90fee..c868992 100644 --- a/amarillo-gtfs-generator/gtfs_generator.py +++ b/amarillo_gtfs_generator/gtfs_generator.py @@ -17,7 +17,7 @@ from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from .models.Carpool import Carpool, Region from .router import _assert_region_exists -from .services import stops #TODO: make stop service its own package?? +from amarillo_stops import stops from .services.trips import TripStore, Trip from .services.carpools import CarpoolService from .services.agencies import AgencyService @@ -96,22 +96,25 @@ def init(): observer.schedule(EventHandler(), 'data/enhanced', recursive=True) observer.start() + start_schedule() -# def run_schedule(): + generate_gtfs() -# while 1: -# try: -# schedule.run_pending() -# except Exception as e: -# logger.exception(e) -# time.sleep(1) -# def midnight(): -# container['stops_store'].load_stop_sources() -# # container['trips_store'].unflag_unrecent_updates() -# # container['carpools'].purge_outdated_offers() +def run_schedule(): + while 1: + try: + schedule.run_pending() + except Exception as e: + logger.exception(e) + time.sleep(1) -# generate_gtfs() +def midnight(): + container['stops_store'].load_stop_sources() + container['trips_store'].unflag_unrecent_updates() + container['carpools'].purge_outdated_offers() + + generate_gtfs() #TODO: generate for a specific region only #TODO: what happens when there are no trips? @@ -135,20 +138,14 @@ def generate_gtfs_rt(): for region in container['regions'].regions.values(): rt = producer.export_feed(time.time(), f"data/gtfs/amarillo.{region.id}.gtfsrt", bbox=region.bbox) -# def start_schedule(): -# # schedule.every().day.at("00:00").do(midnight) +def start_schedule(): + schedule.every().day.at("00:00").do(midnight) # schedule.every(60).seconds.do(generate_gtfs_rt) -# # Create all feeds once at startup -# schedule.run_all() -# job_thread = threading.Thread(target=run_schedule, daemon=True) -# job_thread.start() + # Create all feeds once at startup + # schedule.run_all() + job_thread = threading.Thread(target=run_schedule, daemon=True) + job_thread.start() -# def setup(app : FastAPI): -# # TODO: Create all feeds once at startup -# # configure_enhancer_services() -# # app.include_router(router) -# # start_schedule() -# pass logging.config.fileConfig('logging.conf', disable_existing_loggers=False) logger = logging.getLogger("gtfs-generator") @@ -272,11 +269,11 @@ async def get_file(region_id: str, format: str = 'protobuf'): #TODO: sync endpoint that calls midnight -# @app.post("/sync", -# operation_id="sync") -# #TODO: add examples -# async def post_sync(): +@app.post("/sync", + operation_id="sync") +#TODO: add examples +async def post_sync(): -# logger.info(f"Sync") + logger.info(f"Sync") -# midnight() \ No newline at end of file + midnight() \ No newline at end of file diff --git a/amarillo-gtfs-generator/gtfsrt/__init__.py b/amarillo_gtfs_generator/gtfsrt/__init__.py similarity index 100% rename from amarillo-gtfs-generator/gtfsrt/__init__.py rename to amarillo_gtfs_generator/gtfsrt/__init__.py diff --git a/amarillo-gtfs-generator/gtfsrt/gtfs_realtime_pb2.py b/amarillo_gtfs_generator/gtfsrt/gtfs_realtime_pb2.py similarity index 100% rename from amarillo-gtfs-generator/gtfsrt/gtfs_realtime_pb2.py rename to amarillo_gtfs_generator/gtfsrt/gtfs_realtime_pb2.py diff --git a/amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py b/amarillo_gtfs_generator/gtfsrt/realtime_extension_pb2.py similarity index 100% rename from amarillo-gtfs-generator/gtfsrt/realtime_extension_pb2.py rename to amarillo_gtfs_generator/gtfsrt/realtime_extension_pb2.py diff --git a/amarillo-gtfs-generator/models/Carpool.py b/amarillo_gtfs_generator/models/Carpool.py similarity index 100% rename from amarillo-gtfs-generator/models/Carpool.py rename to amarillo_gtfs_generator/models/Carpool.py diff --git a/amarillo-gtfs-generator/models/__init__.py b/amarillo_gtfs_generator/models/__init__.py similarity index 100% rename from amarillo-gtfs-generator/models/__init__.py rename to amarillo_gtfs_generator/models/__init__.py diff --git a/amarillo-gtfs-generator/models/gtfs.py b/amarillo_gtfs_generator/models/gtfs.py similarity index 100% rename from amarillo-gtfs-generator/models/gtfs.py rename to amarillo_gtfs_generator/models/gtfs.py diff --git a/amarillo-gtfs-generator/router.py b/amarillo_gtfs_generator/router.py similarity index 100% rename from amarillo-gtfs-generator/router.py rename to amarillo_gtfs_generator/router.py diff --git a/amarillo-gtfs-generator/services/__init__.py b/amarillo_gtfs_generator/services/__init__.py similarity index 100% rename from amarillo-gtfs-generator/services/__init__.py rename to amarillo_gtfs_generator/services/__init__.py diff --git a/amarillo-gtfs-generator/services/agencies.py b/amarillo_gtfs_generator/services/agencies.py similarity index 100% rename from amarillo-gtfs-generator/services/agencies.py rename to amarillo_gtfs_generator/services/agencies.py diff --git a/amarillo-gtfs-generator/services/carpools.py b/amarillo_gtfs_generator/services/carpools.py similarity index 100% rename from amarillo-gtfs-generator/services/carpools.py rename to amarillo_gtfs_generator/services/carpools.py diff --git a/amarillo-gtfs-generator/services/regions.py b/amarillo_gtfs_generator/services/regions.py similarity index 100% rename from amarillo-gtfs-generator/services/regions.py rename to amarillo_gtfs_generator/services/regions.py diff --git a/amarillo-gtfs-generator/services/trips.py b/amarillo_gtfs_generator/services/trips.py similarity index 99% rename from amarillo-gtfs-generator/services/trips.py rename to amarillo_gtfs_generator/services/trips.py index 8c020a9..21915ee 100644 --- a/amarillo-gtfs-generator/services/trips.py +++ b/amarillo_gtfs_generator/services/trips.py @@ -3,7 +3,7 @@ from ..models.Carpool import MAX_STOPS_PER_TRIP, Carpool, Weekday, StopTime, Pic # from amarillo.services.config import config from ..gtfs_constants import * # from amarillo.plugins.enhancer.services.routing import RoutingService, RoutingException -from ..services.stops import is_carpooling_stop +from amarillo_stops.stops import is_carpooling_stop from amarillo.utils.utils import assert_folder_exists, is_older_than_days, yesterday, geodesic_distance_in_m from shapely.geometry import Point, LineString, box from geojson_pydantic.geometries import LineString as GeoJSONLineString diff --git a/amarillo-gtfs-generator/tests/__init__.py b/amarillo_gtfs_generator/tests/__init__.py similarity index 100% rename from amarillo-gtfs-generator/tests/__init__.py rename to amarillo_gtfs_generator/tests/__init__.py diff --git a/amarillo-gtfs-generator/tests/test_gtfs.py b/amarillo_gtfs_generator/tests/test_gtfs.py similarity index 94% rename from amarillo-gtfs-generator/tests/test_gtfs.py rename to amarillo_gtfs_generator/tests/test_gtfs.py index 3fbe97c..0a1c3af 100644 --- a/amarillo-gtfs-generator/tests/test_gtfs.py +++ b/amarillo_gtfs_generator/tests/test_gtfs.py @@ -1,8 +1,8 @@ from amarillo.tests.sampledata import carpool_1234, data1, carpool_repeating_json, stop_issue -from amarillo.plugins.enhancer.services.gtfs_export import GtfsExport -from amarillo.plugins.enhancer.services.gtfs import GtfsRtProducer -from amarillo.plugins.enhancer.services.stops import StopsStore -from amarillo.plugins.enhancer.services.trips import TripStore +from amarillo_gtfs_generator.gtfs_export import GtfsExport +from amarillo_gtfs_generator.gtfs import GtfsRtProducer +from amarillo_stops.stops import StopsStore +from amarillo_gtfs_generator.services.trips import TripStore from amarillo.models.Carpool import Carpool from datetime import datetime import time From 95835599c5a87fd74cef8b5042bc9aeb5f6d7d55 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Thu, 11 Jul 2024 14:04:32 +0200 Subject: [PATCH 15/17] Expanded readme --- README.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 09254cb..0c93aac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,82 @@ # amarillo-gtfs-generator -Generate GTFS from carpools as standalone (Docker) service +Generate GTFS from carpools as standalone (Docker) service. + +This service complements the Amarillo application, creating GTFS and GTFS-RT data from the enhanced Amarillo carpool files. + +# Usage + +## 1. Configuration + +### Create `data/stop_sources.json` + +Example contents: +```json +[ + {"url": "https://datahub.bbnavi.de/export/rideshare_points.geojson", "vicinity": 50}, + {"url": "https://data.mfdz.de/mfdz/stops/stops_zhv.csv", "vicinity": 50}, + {"url": "https://data.mfdz.de/mfdz/stops/parkings_osm.csv", "vicinity": 500} +] +``` + +### Add region files `data/region` + +File name should be `{region_id}.json`. + +Example (`by.json`): +```json +{"id": "by", "bbox": [ 8.97, 47.28, 13.86, 50.56]} +``` +For each region a separate GTFS zip file will be created in `/data/gtfs`, only containing the trips that intersect the region's bounding box. + +### Add agency files `data/agency` + +File name should be `{agency_id}.json`. + +Example (`mfdz.json`): +```json +{ + "id": "mfdz", + "name": "MITFAHR|DE|ZENTRALE", + "url": "http://mfdz.de", + "timezone": "Europe/Berlin", + "lang": "de", + "email": "info@mfdz.de" +} +``` +The generator will use this data to populate agency.txt in the GTFS output. + +### Uvicorn configuration + +`amarillo-gtfs-generator` uses `uvicorn` to run. Uvicorn can be configured as normal by passing in arguments such as `--port 8002` to change the port number. + +## 2. Add carpool files to /data/enhanced + +The generator listens to file system events in the `/data/enhanced` folder to recognize newly added or deleted carpools. GTFS generation happens automatically on startup, at midnight on a schedule, and by sending a GET request to a `/region/{region_id}/gtfs` or `/region/{region_id}/gtfs-rt` endpoint. + +Amarillo will use its configured enhancer to create enhanced carpool files. They will get picked up by the generator and they will be included in the next batch of generated GTFS data. Changes to carpools will be reflected immediately in the GTFS-RT output. + + + +## 3. Install the gtfs-exporter plugin for Amarillo + +The [amarillo-gtfs-exporter plugin](https://github.com/mfdz/amarillo-gtfs-exporter) creates endpoints for `/region/{region_id}/gtfs` and `/region/{region_id}/gtfs-rt` on your Amarillo instance. These will serve the GTFS zip files from `data/gtfs`, or if they do not exist yet, they will call the configured generator and cache the results. + +# Run with uvicorn + +- Python 3.10 with pip +- python3-venv + +Create a virtual environment `python3 -m venv venv`. + +Activate the environment and install the dependencies `pip install -r requirements.txt`. + +Run `uvicorn amarillo_gtfs_generator.gtfs_generator:app`. + +In development, you can use `--reload`. + +# Run with docker +You can download a container image from the [MFDZ package registry](https://github.com/orgs/mfdz/packages?repo_name=amarillo-gtfs-generator). + +Example command: +```bash +docker run -it --rm --name amarillo-gtfs-generator -p 8002:80 -e TZ=Europe/Berlin -v $(pwd)/data:/app/data amarillo-gtfs-generator``` \ No newline at end of file From fe115bf839917001f18afe784b35732192850bb5 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Tue, 16 Jul 2024 14:58:19 +0200 Subject: [PATCH 16/17] Update readme --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0c93aac..4050913 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,10 @@ # amarillo-gtfs-generator Generate GTFS from carpools as standalone (Docker) service. -This service complements the Amarillo application, creating GTFS and GTFS-RT data from the enhanced Amarillo carpool files. +This service complements the Amarillo application, creating GTFS and GTFS-RT data from the enhanced Amarillo carpool files. +It is a non-public backend service called from the Amarillo FastAPI application. +You can run it as part of docker compose, or separately using the instructions below. + # Usage @@ -49,18 +52,18 @@ The generator will use this data to populate agency.txt in the GTFS output. `amarillo-gtfs-generator` uses `uvicorn` to run. Uvicorn can be configured as normal by passing in arguments such as `--port 8002` to change the port number. -## 2. Add carpool files to /data/enhanced +## 2. Install the gtfs-exporter plugin for Amarillo -The generator listens to file system events in the `/data/enhanced` folder to recognize newly added or deleted carpools. GTFS generation happens automatically on startup, at midnight on a schedule, and by sending a GET request to a `/region/{region_id}/gtfs` or `/region/{region_id}/gtfs-rt` endpoint. +This is a separate service and not used by Amarillo by default. You should use the [amarillo-gtfs-exporter plugin](https://github.com/mfdz/amarillo-gtfs-exporter) which creates endpoints for `/region/{region_id}/gtfs` and `/region/{region_id}/gtfs-rt` on your Amarillo instance. These will serve the GTFS zip files from `data/gtfs`, or if they do not exist yet, they will call the configured generator and cache the results. + +## 3. Add carpools to Amarillo + +Use Amarillo's `/carpool` endpoint to create new carpools. The generator listens to file system events in the `/data/enhanced` folder to recognize newly added or deleted carpools. It will also discover existing carpools on startup. GTFS generation happens automatically on startup, at midnight on a schedule, and by sending a GET request to a `/region/{region_id}/gtfs` or `/region/{region_id}/gtfs-rt` endpoint. Amarillo will use its configured enhancer to create enhanced carpool files. They will get picked up by the generator and they will be included in the next batch of generated GTFS data. Changes to carpools will be reflected immediately in the GTFS-RT output. -## 3. Install the gtfs-exporter plugin for Amarillo - -The [amarillo-gtfs-exporter plugin](https://github.com/mfdz/amarillo-gtfs-exporter) creates endpoints for `/region/{region_id}/gtfs` and `/region/{region_id}/gtfs-rt` on your Amarillo instance. These will serve the GTFS zip files from `data/gtfs`, or if they do not exist yet, they will call the configured generator and cache the results. - # Run with uvicorn - Python 3.10 with pip From ea1f5e915c90ca22ae906c33ed708ba2fbfb5734 Mon Sep 17 00:00:00 2001 From: Francia Csaba Date: Wed, 31 Jul 2024 11:34:32 +0200 Subject: [PATCH 17/17] Readme overview diagram --- README.md | 3 +++ docs-overview-diagram.png | Bin 0 -> 73109 bytes 2 files changed, 3 insertions(+) create mode 100644 docs-overview-diagram.png diff --git a/README.md b/README.md index 4050913..86de4a5 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ This service complements the Amarillo application, creating GTFS and GTFS-RT dat It is a non-public backend service called from the Amarillo FastAPI application. You can run it as part of docker compose, or separately using the instructions below. +# Overview + + ![Amarillo GTFS generation overview](/docs-overview-diagram.png) # Usage diff --git a/docs-overview-diagram.png b/docs-overview-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..2957bf941baddb0c5255f9bc34366e6c1d2e75bb GIT binary patch literal 73109 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 n|Gd2M|3A(K_