diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b782d5b --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +init: + pip install -r requirements.txt + +test: + py.test tests diff --git a/docs/architecture/simulator/within-day_model.rst b/docs/architecture/simulator/within-day_model.rst index 6c24a6a..c7c96d5 100644 --- a/docs/architecture/simulator/within-day_model.rst +++ b/docs/architecture/simulator/within-day_model.rst @@ -4,11 +4,100 @@ Within-Day Model The within-day model simulates the movements of vehicles and individuals in the network, for a single day. It is an event-based model that triggers event according to the time of the day. -This page lists all events in MetroSim. - +Events +------ PVReachesNode --------------- +^^^^^^^^^^^^^ This event signals that a private vehicle reaches a node, at a given time. + +Expected Travel Times +--------------------- + +At each node of the network, car drivers choose the next edge that they will take. +The choice is made based on the expected utility. +For each downstream edge, the expected utility depends on + +- the past travel time of the car driver from the origin to the current node, +- the observed travel time on the downstream edge, +- the expected travel time from the target node to the destination, +- the observed road toll on the downstream edge, +- the expected road toll from the target node to the destination. + +This mean that MetroSim should know, for each pair of nodes :math:`(s, t)` and for each departure time :math:`t_d`, all feasible and non-Pareto-dominated pairs :math:`({tt}, \tau)` where :math:`{tt}` is the travel time from :math:`s` to :math:`t` and :math:`\tau` is the toll paid. +A pair :math:`({tt}, \tau)` is feasible if there is at least one path, from :math:`s` to :math:`t`, starting at time :math:`t_d` such that the travel time is :math:`{tt}` and the toll paid is :math:`\tau`. +A pair :math:`({tt}, \tau)` is Pareto dominated if there is another feasible pair :math:`({tt}', \tau')` such that :math:`{tt}\geq{tt}'` and :math:`\tau\geq\tau'`, where at least one inequality is strict. + +It can be very computationally and memory intensive to compute and store all the feasible and non-Pareto-dominated :math:`({tt}, \tau)` pairs, for all node pairs and for all departure times (or even for some departure-time intervals). +Therefore, each time a car driver wants to compute the expected travel utility from a source node :math:`s` to a target node :math:`t`, starting at time :math:`t_d`, MetroSim uses the following rules: + +1. If the pairs :math:`({tt}, \tau)` are known for both a starting time :math:`t_d' \in [t_d - M, t_d - m]` and for a starting time :math:`t_d'' \in [t_d + m, t_d + M]`, then compute expected travel times using linear interpolation. +2. If the pairs :math:`({tt}, \tau)` are known for a starting time :math:`t_d' \in [t_d - m, t_d + m]`, then use the expected travel times with starting time :math:`t_d'` as a proxy for the expected travel times with starting time :math:`t_d`. +3. If the pairs :math:`({tt}, \tau)` are known for a starting time :math:`t_d' \in [t_d - M, t_d - m]` only, then compute expected travel times with starting time :math:`t_d'' = t_d' + 2 \cdot M`, store the results and use a linear interpolation between :math:`t_d'` and :math:`t_d''`. +4. In all other cases, compute expected travel times with starting time :math:`t_d`, store the results and use them. + +The parameters :math:`m` and :math:`M` can be fixed by the user. +Reasonable values are :math:`m=30` seconds and :math:`M=10` minutes. + +If memory is getting low, we can remove all the expected travel-time results such that the starting time is earlier than the current time of the simulation minus :math:`M`. + +When computing the expected travel times from :math:`s` to :math:`t`, we can also get the expected travel times from :math:`s` to any node :math:`t'` on the fastest paths. + +Example +^^^^^^^ + +We consider the following road network, where all edges only go from West to East. +To keep it simple, free-flow travel-times are set to 1 minute for each edge and we assume that they are never congested. +The edge from node 2 to node 4 is the only edge with a road toll, set to 1 euro. +We set :math:`m=30` seconds and :math:`M=10` minutes. + ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| From / to | 1 | 2 | 3 | 4 | 5 | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 1 | X | (1, 0) | X | X | X | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 2 | X | X | (1, 0) | (1, 1) | X | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 3 | X | X | X | (1, 0) | X | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 4 | X | X | X | X | (1, 0) | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 5 | X | X | X | X | X | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ + +.. plot:: plots/network.py + + Example road network + +Assume that the first car entering the network goes from node 1 to node 5, starting at 08:00:00. +Following rule 4., we compute expected travel times with starting time 08:00:00. +Two paths are found: the path (1, 2, 3, 4, 5) with 4-minute travel-time and no toll and the path (1, 2, 4, 5) with 3-minute travel-time and a toll of 1 euro. +The car driver chooses the best path to take, given his preferences (value of time, schedule utility, etc.). +At the same time, we have also found two paths from node 1 to node 4, with their travel-time and toll values, starting at 08:00:00. +And we have found two paths from node 2 to node 5, with their travel-time and toll values, starting at 08:01:00. +All these results are stored in memory (only the travel times and tolls are stored, not the paths). + ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| From / to | 1 | 2 | 3 | 4 | 5 | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 1 | X | (1, 0) | (2, 0) at 08:00:00 | [(3, 0), (2, 1)] at 08:00:00 | [(4, 0), (3, 1)] at 08:00:00 | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 2 | X | X | (1, 0) | (1, 1) | [(3, 0), (2, 1)] at 08:01:00 | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 3 | X | X | X | (1, 0) | (2, 0) at 08:02:00 | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 4 | X | X | X | X | (1, 0) | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ +| 5 | X | X | X | X | X | ++-----------+---+--------+--------------------+------------------------------+-------------------------------+ + +Assume that the next car to move goes from node 1 to node 4, starting at time 08:00:45. +The only known results between node 1 and node 4 are for a starting time at 08:00:00. +Then, following rule 3., we compute the fastest paths for a starting time at 08:10:00. +We find the same two paths as previously. + +.. warning:: + + TODO: Enrich the algorithm to consider cordon tolls, mobility permits and class-specific policies. diff --git a/docs/conf.py b/docs/conf.py index 9272f3e..e48dae2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,9 +10,9 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- @@ -32,6 +32,8 @@ extensions = [ 'sphinxcontrib.plantuml', 'sphinx_rtd_theme', 'matplotlib.sphinxext.plot_directive', + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/index.rst b/docs/index.rst index b40bd6d..84de1de 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -35,6 +35,9 @@ It is intended at developers willing to improve or extend it. .. toctree:: :caption: References + /references/models/models + /references/initializer/initializer + /references/simulator/simulator /references/changelog .. toctree:: diff --git a/docs/plots/network.py b/docs/plots/network.py new file mode 100644 index 0000000..6242471 --- /dev/null +++ b/docs/plots/network.py @@ -0,0 +1,32 @@ +import matplotlib.pyplot as plt + +# Update matplotlib parameters. +params = {'text.usetex': True, + 'figure.dpi': 200, + 'font.size': 14, + 'font.serif': [], + 'font.sans-serif': [], + 'font.monospace': [], + 'axes.labelsize': 16, + 'axes.titlesize': 18, + 'axes.linewidth': .6, + 'legend.fontsize': 14, + 'xtick.labelsize': 12, + 'ytick.labelsize': 12, + 'font.family': 'serif'} +plt.rcParams.update(params) + +plt.figure(figsize=(8, 2)) + +plt.plot([0, 1, 3, 4], [1, 1, 1, 1], '-o', color='black') +plt.plot([1, 2, 3], [1, 0, 1], '-o', color='black') + +plt.annotate('1', (0, 1), (0, .9), va='top', ha='center') +plt.annotate('2', (1, 1), (1, .9), va='top', ha='center') +plt.annotate('3', (2, 0), (2, -.1), va='top', ha='center') +plt.annotate('4', (3, 1), (3, .9), va='top', ha='center') +plt.annotate('5', (4, 1), (4, .9), va='top', ha='center') + +plt.axis('off') +plt.tight_layout() +plt.show() diff --git a/docs/references/initializer/gtfs.rst b/docs/references/initializer/gtfs.rst new file mode 100644 index 0000000..f1ebdcd --- /dev/null +++ b/docs/references/initializer/gtfs.rst @@ -0,0 +1,5 @@ +GTFS +==== + +.. automodule:: metrosim.initializer.gtfs + :members: diff --git a/docs/references/initializer/initializer.rst b/docs/references/initializer/initializer.rst new file mode 100644 index 0000000..d44aeb7 --- /dev/null +++ b/docs/references/initializer/initializer.rst @@ -0,0 +1,5 @@ +Initializer +=========== + +.. toctree:: + gtfs diff --git a/docs/references/models/models.rst b/docs/references/models/models.rst new file mode 100644 index 0000000..eed459b --- /dev/null +++ b/docs/references/models/models.rst @@ -0,0 +1,5 @@ +Models +====== + +.. toctree:: + pt_network diff --git a/docs/references/models/pt_network.rst b/docs/references/models/pt_network.rst new file mode 100644 index 0000000..cc74080 --- /dev/null +++ b/docs/references/models/pt_network.rst @@ -0,0 +1,5 @@ +Public-Transit Network +====================== + +.. automodule:: metrosim.models.pt_network + :members: diff --git a/docs/references/simulator/manager.rst b/docs/references/simulator/manager.rst new file mode 100644 index 0000000..00dffef --- /dev/null +++ b/docs/references/simulator/manager.rst @@ -0,0 +1,6 @@ +Manager +======= + +.. autoclass:: metrosim.simulator.manager.MetroManager + :members: + diff --git a/docs/references/simulator/simulator.rst b/docs/references/simulator/simulator.rst new file mode 100644 index 0000000..dd5fdcb --- /dev/null +++ b/docs/references/simulator/simulator.rst @@ -0,0 +1,5 @@ +Simulator +========= + +.. toctree:: + manager diff --git a/metrosim/exceptions.py b/metrosim/exceptions.py new file mode 100644 index 0000000..f399ae3 --- /dev/null +++ b/metrosim/exceptions.py @@ -0,0 +1,18 @@ +"""This module contains the exceptions of MetroSim. +""" +# Copyright Lucas Javaudin - All Rights Reserved +# Unauthorized copying of this file, via any medium is strictly prohibited +# Proprietary and confidential +# Written by Lucas Javaudin , 2021 + + +class MetroIOError(Exception): + """Error reading or writing a file.""" + + +class MetroInputError(Exception): + """Bad format of input file.""" + + +class MetroUnsupportedError(Exception): + """Unsupported feature.""" diff --git a/metrosim/initializer/__init__.py b/metrosim/initializer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metrosim/initializer/gtfs.py b/metrosim/initializer/gtfs.py new file mode 100644 index 0000000..a0f55ba --- /dev/null +++ b/metrosim/initializer/gtfs.py @@ -0,0 +1,209 @@ +"""This module contains function to create MetroSim input data from GTFS files. +""" +# Copyright Lucas Javaudin - All Rights Reserved +# Unauthorized copying of this file, via any medium is strictly prohibited +# Proprietary and confidential +# Written by Lucas Javaudin , 2021 + +import zipfile +from zipfile import ZipFile +from io import TextIOWrapper +import csv +from datetime import datetime, timedelta + +from metrosim.models.pt_network import PTNetwork +from metrosim.exceptions import MetroIOError, MetroInputError + +INT_TO_WEEKDAY = {0: 'monday', 1: 'tuesday', 2: 'wednesday', 3: 'thursday', + 4: 'friday', 5: 'saturday', 6: 'sunday'} + + +def gtfs_to_ptnetwork(gtfs_path, at_date, start_time=timedelta(0), + end_time=timedelta(hours=30)): + """Create a PTNetwork instance from a GTFS file. + + The GTFS file must respect the `reference + `_ defined by Google, + although some files and fields are not used. + + The required files are `trips.txt`, `routes.txt`, `stop_times.txt` and + `stops.txt`. Also, either `calendar.txt` or `calendar_dates.txt` (or both) + must be in the GTFS file. + + The parameters can be used to filter a specific day and time period. By + default, the PTNetwork covers the entire day. + + The PTNetwork cannot cover more than one day. + + :param str gtfs_path: Path to the GTFS zipfile on the computer. + :param at_date: Only trips that run during this day are added to the + PTNetwork. + :type at_date: :class:`datetime.date` + :param start_time: Only trips that run after this time of the day are added + to the PTNetwork. It represents an amount of time after midnight (it can + be larger than 24 hours). + :type start_time: :class:`datetime.timedelta` + :param end_time: Only trips that run before this time of the day are added + to the PTNetwork. It represents an amount of time after midnight (it can + be larger than 24 hours). + :type end_time: :class:`datetime.timedelta` + :return: :class:`metrosim.models.pt_network.PTNetwork` + :rtype: PTNetwork + """ + if not isinstance(gtfs_path, str) or not zipfile.is_zipfile(gtfs_path): + raise MetroIOError('Invalid zip file') + + with ZipFile(gtfs_path) as gtfs_zip: + # Check that the GTFS file has all the required files. + required_files = ('trips.txt', 'routes.txt', 'stop_times.txt', + 'stops.txt') + for filename in required_files: + if filename not in gtfs_zip.namelist(): + msg = ( + 'The GTFS file must have the following required files: {}') + raise MetroInputError(msg.format(required_files)) + + # Find the active services at the desired date. + active_services = set() + if 'calendar.txt' in gtfs_zip.namelist(): + with gtfs_zip.open('calendar.txt') as calendar_csv: + reader = csv.DictReader(TextIOWrapper(calendar_csv, 'utf-8')) + services = _get_services_from_calendar(reader, at_date) + active_services = active_services.union(services) + if 'calendar_dates.txt' in gtfs_zip.namelist(): + with gtfs_zip.open('calendar_dates.txt') as calendar_dates_csv: + reader = csv.DictReader( + TextIOWrapper(calendar_dates_csv, 'utf-8')) + services, no_services = _get_services_from_calendar_dates( + reader, at_date) + active_services = active_services.union(services) + active_services = active_services.difference(no_services) + if not active_services: + # No service found in the GTFS at the desired date. + msg = 'The GTFS file does not cover the date: {}' + raise MetroInputError(msg.format(at_date)) + + pt_network = PTNetwork() + + # Read the PT stops. + with gtfs_zip.open('stops.txt') as stops_csv: + reader = csv.DictReader(TextIOWrapper(stops_csv, 'utf-8')) + pt_network._read_nodes(reader) + + # Find active trips and routes at the desired date. + with gtfs_zip.open('trips.txt') as trips_csv: + reader = csv.DictReader(TextIOWrapper(trips_csv, 'utf-8')) + trips, routes = _get_active_at_date(reader, active_services) + + # Add the valid routes to the PT network. + with gtfs_zip.open('routes.txt') as routes_csv: + reader = csv.DictReader(TextIOWrapper(routes_csv, 'utf-8')) + pt_network._add_valid_routes(reader, routes) + + # Add the valid trips to the PT network. + with gtfs_zip.open('trips.txt') as trips_csv: + reader = csv.DictReader(TextIOWrapper(trips_csv, 'utf-8')) + pt_network._add_valid_trips(reader, trips) + + # Add edges to the PT network from the stop_times. + with gtfs_zip.open('stop_times.txt') as stop_times_csv: + reader = csv.DictReader(TextIOWrapper(stop_times_csv, 'utf-8')) + pt_network._read_stop_times(reader, start_time, end_time) + + pt_network._remove_unused_platforms() + + if 'transfers.txt' in gtfs_zip.namelist(): + # Add edges to the PT network from transfers. + with gtfs_zip.open('transfers.txt') as transfers_times_csv: + reader = csv.DictReader( + TextIOWrapper(transfers_times_csv, 'utf-8')) + pt_network._read_transfers(reader) + + pt_network._add_generic_transfers() + pt_network._remove_unused_nodes() + + print('Succesfully created a PT network with {} nodes and {} edges'.format( + pt_network.number_of_nodes(), pt_network.number_of_edges())) + + return pt_network + + +def _get_services_from_calendar(reader, at_date): + """Returns the set of services enabled at a given date. + + The function reads the services from a csv.DictReader representing the + `calendar.txt` file of a GTFS. + + :param reader: DictReader of a CSV representing calendar.txt. + :param at_date: Date for which the active services are found. + :type at_date: :class:`datetime.date` + :return: Set with the ids of the services enables at the given date. + :rtype: set + """ + # Get the weeday of the desired date. + at_weekday = INT_TO_WEEKDAY[at_date.weekday()] + valid_services = set() + for row in reader: + start_date = datetime.strptime(row['start_date'], '%Y%m%d').date() + end_date = datetime.strptime(row['end_date'], '%Y%m%d').date() + if start_date > at_date or end_date < at_date: + # The service does not overlap with the desired date. + continue + if row[at_weekday] == '1': + # The service is active for the desired date. + valid_services.add(row['service_id']) + return valid_services + + +def _get_services_from_calendar_dates(reader, at_date): + """Returns the services explicitely enabled or disabled at a given date. + + The function reads the services from a csv.DictReader representing the + `calendar_dates.txt` file of a GTFS. + + :param reader: DictReader of a CSV representing calendar_dates.txt. + :param at_date: Date for which the added and removed services are found. + :type at_date: :class:`datetime.date` + :return: Tuple with a set of the ids of the services explicitely enabled + and a set of the ids of the services explicitely disabled, at the given + date. + :rtype: tuple + """ + valid_services = set() + invalid_services = set() + for row in reader: + row_date = datetime.strptime(row['date'], '%Y%m%d').date() + if row_date == at_date: + if row['exception_type'] == '1': + # exception_type = 1 indicates that service has been added for + # this date. + valid_services.add(row['service_id']) + elif row['exception_type'] == '2': + # exception_type = 2 indicates that service has been removed + # for this date. + invalid_services.add(row['service_id']) + return valid_services, invalid_services + + +def _get_active_at_date(reader, services): + """Returns the trips and routes that run given a set of services. + + The function reads the trips and routes from a csv.DictReader representing + the `trips.txt` file of a GTFS. + + Only the trips whose service is in the set of services given as argument + are returned. + + :param reader: DictReader of a CSV representing trips.txt. + :param set services: Set with the ids of active services. + :return: Tuple with a set of the ids of the trips and a set of the ids of + the routes that are active. + :rtype: tuple + """ + trips = set() + routes = set() + for row in reader: + if row['service_id'] in services: + trips.add(row['trip_id']) + routes.add(row['route_id']) + return trips, routes diff --git a/metrosim/models/__init__.py b/metrosim/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metrosim/models/mode_choice.py b/metrosim/models/mode_choice.py new file mode 100644 index 0000000..eb91c6a --- /dev/null +++ b/metrosim/models/mode_choice.py @@ -0,0 +1,27 @@ +from metrosim.models.modes import mode_from_dict + + +class _ModeChoice(object): + + def __init__(self, modes): + self.modes = modes + + def modes_from_dict(mode_dicts): + modes = list() + for mode_data in mode_dicts: + modes.append(mode_from_dict(mode_data)) + return modes + + +class DeterministicModeChoice(_ModeChoice): + + def from_dict(mode_dicts): + modes = _ModeChoice.modes_from_dict(mode_dicts) + return DeterministicModeChoice(modes) + + +class StochasticModeChoice(_ModeChoice): + + def from_dict(mode_dicts): + modes = _ModeChoice.modes_from_dict(mode_dicts) + return StochasticModeChoice(modes) diff --git a/metrosim/models/population.py b/metrosim/models/population.py new file mode 100644 index 0000000..4857b75 --- /dev/null +++ b/metrosim/models/population.py @@ -0,0 +1,130 @@ +"""This module contains the classes describing the population of a simulation. +""" +# Copyright Lucas Javaudin - All Rights Reserved +# Unauthorized copying of this file, via any medium is strictly prohibited +# Proprietary and confidential +# Written by Lucas Javaudin , 2021 + +import os +import json + +import numpy as np +import pandas as pd + +from metrosim.exceptions import ( + MetroIOError, MetroInputError, MetroUnsupportedError +) +from metrosim.models.schedule_utility import AlphaBetaGammaModel +from metrosim.models.mode_choice import ( + DeterministicModeChoice, StochasticModeChoice +) + + +class Agent(object): + + def __init__(self, agent_id, schedule_utility, mode_choice): + self.id = agent_id + self.schedule_utility = schedule_utility + self.mode_choice = mode_choice + + def from_dict(data): + if 'id' not in data: + raise MetroInputError("Each Agent must have a valid id") + + schedule_utility_data = data.get('schedule_utility', dict()) + schedule_utility = super()._load_schedule_utility( + schedule_utility_data) + + mode_choice_data = data.get('mode_choice', dict()) + mode_choice = super()._load_mode_choice(mode_choice_data) + + return Agent(data['id'], schedule_utility, mode_choice) + + def _load_schedule_utility(data): + utility_type = data.get('type', None) + parameters = data.get('parameters', dict()) + if utility_type == 'alpha-beta-gamma': + return AlphaBetaGammaModel.from_dict(parameters) + elif utility_type: + msg = "Unsupported schedule-utility type: {}" + raise MetroUnsupportedError(msg.format(utility_type)) + else: + raise MetroInputError("Each Agent must have a schedule utility") + + def _load_mode_choice(data): + choice_type = data.get('type', None) + modes = data.get('modes', list()) + if choice_type == 'deterministic': + return DeterministicModeChoice.from_dict(modes) + elif choice_type == 'stochastic': + return StochasticModeChoice.from_dict(modes) + elif choice_type: + msg = "Unsupported mode-choice type: {}" + raise MetroUnsupportedError(msg.format(choice_type)) + else: + raise MetroInputError("Each Agent must have a mode choice") + + def get_pre_day_choice(self): + # Compute the expected utility for each continuous mode. + best_utility = -np.inf + best_mode = None + for mode in self.cont_modes: + results = mode.get_exp_utility(self) + self.mode_results[mode] = results + if results['exp_utility'] > best_utility: + best_utility = results['exp_utility'] + best_mode = mode + + for mode in self.det_modes: + results = mode.get_exp_utility(self, threshold=best_utility) + self.mode_results[mode] = results + + +class RawPopulation(object): + + def __init__(self, segments, random_seed=None): + if not segments: + raise MetroInputError("A RawPopulation must have at least one " + "PopulationSegment") + self.segments = segments + self.random_seed = random_seed + + def from_file(filename): + if not isinstance(filename, str): + raise TypeError("filename must be a str, got a {}" + .format(type(filename))) + if not os.path.isfile(filename): + raise MetroIOError("File not found: {}".format(filename)) + + try: + with open(filename, 'r') as f: + data = json.load(f) + except json.decoder.JSONDecodeError: + raise MetroIOError("Invalid JSON file: {}".format(filename)) + + segments = list() + for segment in data.get('segments', []): + segments.append(PopulationSegment(**segment)) + + random_seed = data.get('random_seed', None) + + return RawPopulation(segments, random_seed) + + +class PopulationSegment(object): + + def __init__(self, id, od_matrix): + self.id = id + self.od_matrix = ODMatrix.from_file(od_matrix) + + +class ODMatrix(object): + + def from_file(filename): + if not isinstance(filename, str): + raise TypeError("filename must be a str, got a {}" + .format(type(filename))) + if not os.path.isfile(filename): + raise MetroIOError("File not found: {}".format(filename)) + + df = pd.read_csv(filename) diff --git a/metrosim/models/pt_network.py b/metrosim/models/pt_network.py new file mode 100644 index 0000000..ef950c6 --- /dev/null +++ b/metrosim/models/pt_network.py @@ -0,0 +1,330 @@ +"""This module contains the classes describing the public-transit network of a +simulation. +""" +# Copyright Lucas Javaudin - All Rights Reserved +# Unauthorized copying of this file, via any medium is strictly prohibited +# Proprietary and confidential +# Written by Lucas Javaudin , 2021 + +from enum import Enum +from datetime import timedelta +import re +from collections import defaultdict + +import networkx as nx + + +class PTNetwork(nx.MultiDiGraph): + """Class representing a public-transit network. + + The public-transit (PT) network is represented as a Multi-edges Directed + Graph. As such, it inherits all methods from :class:`nx.MultiDiGraph`. + + On initialization, all arguments are passed to :class:`nx.MultiDiGraph`. + + Compared to a :class:`nx.MultiDiGraph`, a PTNetwork has two additional dict + attributes: + + - `routes`: Used to stored data on the routes of the PTNetwork. + - `trips`: Used to stored data on the trips of the PTNetwork. + """ + + def __init__(self, *args, **kwargs): + self.routes = dict() + self.trips = dict() + super().__init__(*args, **kwargs) + + def _read_nodes(self, reader): + """Add nodes to the PT network from a csv.DictReader. + + The CSV file read is assumed to have the format of stops.txt from the + GTFS standard. In praticular, the file must have a columns `stop_id` + and `location_type`. + + :param reader: DictReader of a CSV representing stops.txt. + """ + for row in reader: + try: + lat = float(row.get('stop_lat')) + lon = float(row.get('stop_lon')) + except ValueError: + # Cannot parse latitude and longitude. + lat, lon = None, None + try: + location_type = PTNodeType(int(row.get('location_type', 0))) + except ValueError: + # Invalid location type, skip this node. + continue + self.add_node( + row['stop_id'], + name=row.get('stop_name', ''), + lat=lat, + lon=lon, + node_type=location_type, + parent_id=row.get('parent_station'), + ) + + def _read_stop_times(self, reader, from_time=timedelta(0), + to_time=timedelta(hours=30)): + """Add edges to the PT network from a csv.DictReader. + + These edges represent a public-transit trip from one stop to a + successive stop, with a specific departure and arrival time. + + The CSV file read is assumed to have the format of stop_times.txt from + the GTFS standard. In particular, the file must have columns `trip_id`, + `stop_id`, `arrival_time`, `departure_time` and `stop_sequence`. + + The parameters `from_time` and `to_time` can be used to constrain the + edges to a specific time period. + + :param reader: DictReader of a CSV representing stop_times.txt. + :param from_time: Consider only trips whose departure time is later + than this time. + :param to_time: Consider only trips whose arrival time is earlier than + this time. + :type from_time: datetime.timedelta + :type to_time: datetime.timedelta + """ + if not self.trips: + # No trip is active, nothing to do. + return + + # Initialize a dictionary to hold all the stop_times of a trip. + legs_dict = defaultdict(list) + + invalid_stop_types = 0 + for row in reader: + if row.get('trip_id') not in self.trips: + # Discard stop_time of invalid trips. + continue + if ( + row.get('pickup_type', '0') != '0' + or row.get('drop_off_type', '0') != '0' + or row.get('continuous_pickup', '1') != '1' + or row.get('continuous_drop_off', '1') != '1' + ): + # Not a regular pickup or drop off, unsupported. + invalid_stop_types += 1 + continue + legs_dict[row['trip_id']].append(row) + + if invalid_stop_types: + print('Warning: Discarded {} stop-times with invalid pickup or ' + 'dropoff'.format(invalid_stop_types)) + + for trip_id, stop_times in legs_dict.items(): + # Sort stop_times by increasing stop_sequence. + stop_times = sorted( + stop_times, key=lambda x: int(x['stop_sequence'])) + for prev_stop, next_stop in zip(stop_times[:-1], stop_times[1:]): + dep_time = _parse_time(prev_stop['departure_time']) + arr_time = _parse_time(next_stop['arrival_time']) + if dep_time < from_time or arr_time > to_time: + # This trip stop is not in the desired time window. + continue + route_id = self.trips[trip_id]['route_id'] + self.add_edge( + prev_stop['stop_id'], + next_stop['stop_id'], + dep_time=dep_time, + arr_time=arr_time, + travel_time=arr_time - dep_time, + trip_id=trip_id, + route_id=route_id, + type=self.routes[route_id]['type'], + ) + + def _add_valid_routes(self, reader, routes): + """Add route data to the PT network. + + From the GTFS reference, a route is a group of trips that are displayed + to riders as a single service. + + The CSV file read is assumed to have the format of routes.txt from the + GTFS standard. In particular, the file must have columns `route_id` and + `route_type`. + + The data stored are the name and the type of the route. The type of the + route must be a valid PTType. + + :param reader: DictReader of a CSV representing routes.txt. + :param routes: Set of route_id to include. + """ + invalid_route_types = 0 + for row in reader: + if row['route_id'] not in routes: + continue + try: + route_type = PTType(int(row['route_type'])) + except ValueError: + # This route type is not supported. + invalid_route_types += 1 + continue + # Get the name of the route. + route_name = ( + row.get('route_short_name', '') + or row.get('route_long_name', '') + ) + # Add the route data to the PTNetwork instance. + self.routes[row['route_id']] = dict( + type=route_type, + name=route_name, + ) + if invalid_route_types: + print('Warning: Discarded {} routes with an invalid ' + 'transportation mode.'.format(invalid_route_types)) + + def _add_valid_trips(self, reader, trips): + """Add trip data to the PT network. + + From the GTFS reference, a trip is a sequence of two or more stops that + occur during a specific time period. + + The CSV file read is assumed to have the format of trips.txt from the + GTFS standard. In particular, the file must have columns `trip_id` and + `route_id`. + + The data stored are the route_id, the name and the headsign of the + trip. + + Only trips whose route_id is in the `routes` attribute of the PTNetwork + are stored. + + :param reader: DictReader of a CSV representing trips.txt. + :param trips: Set of trip_id to include. + """ + for row in reader: + if row['trip_id'] not in trips: + continue + if row['route_id'] not in self.routes: + # Route must be added to the PTNetwork before adding trips. + continue + self.trips[row['trip_id']] = dict( + route_id=row['route_id'], + name=row.get('trip_short_name'), + headsign=row.get('trip_headsign'), + ) + + def _read_transfers(self, reader): + """Add edges to the PT network from a csv.DictReader. + + These edges represent a transfer from one platform (or stop) to + another. + + The CSV file read is assumed to have the format of transfers.txt from + the GTFS standard. In particular, the file must have columns + `from_stop_id`, `to_stop_id`, `transfer_type`, `min_transfer_time`. + + :param reader: DictReader of a CSV representing stop_times.txt. + """ + for row in reader: + if row.get('transfer_type', '3') == '3': + # Transfer is impossible or transfer_type is missing. + continue + if (row['from_stop_id'] not in self + or row['to_stop_id'] not in self): + # One of the node is not in the network. + continue + try: + transfer_seconds = int(row.get('min_transfer_time', 60)) + except ValueError: + # Default is a transfer of 1 minute. + transfer_seconds = 60 + self.add_edge( + row['from_stop_id'], + row['to_stop_id'], + travel_time=timedelta(seconds=transfer_seconds), + type=PTType.TRANSFER, + ) + + def _remove_unused_platforms(self): + """Remove from the PTNetwork all nodes of type PTNodeType.PLATFORM, + with no trip edge. + """ + nodes_to_remove = set() + for node_id, data in self.nodes(data=True): + if (data['node_type'] == PTNodeType.PLATFORM + and not self.pred[node_id] and not self.succ[node_id]): + # Platform has no trip, remove it. + nodes_to_remove.add(node_id) + self.remove_nodes_from(nodes_to_remove) + print('Warning: Remove {} platforms with no trip'.format( + len(nodes_to_remove))) + + def _add_generic_transfers(self, transfer_seconds=60): + """Add edges representing transfers from each node to their parent or + their children. + + :param transfer_seconds: Travel time of the transfers, in seconds. + """ + for node_id, data in self.nodes(data=True): + parent_id = data['parent_id'] + if not parent_id: + continue + # Add a TRANSFER edge from the node to its parent. + self.add_edge( + node_id, + parent_id, + travel_time=timedelta(seconds=transfer_seconds), + type=PTType.TRANSFER, + ) + # Add a TRANSFER edge from the parent to its child. + self.add_edge( + parent_id, + node_id, + travel_time=timedelta(seconds=transfer_seconds), + type=PTType.TRANSFER, + ) + + def _remove_unused_nodes(self): + """Remove all nodes with no edge.""" + nodes_to_remove = set() + for node_id in self.nodes: + if not self.pred[node_id] and not self.succ[node_id]: + nodes_to_remove.add(node_id) + self.remove_nodes_from(nodes_to_remove) + + +class PTType(Enum): + """Enum representing all public-transit modes supported. + + The enumeration is based on the GTFS reference of route_type in routes.txt, + with the addition of transfers type. + """ + TRAM = 0 + METRO = 1 + RAIL = 2 + # BUS = 3 + TRANSFER = 20 + + +class PTNodeType(Enum): + """Enum for the different types of nodes. + + The enumeration is based on the GTFS reference of location_type in + stops.txt. + """ + PLATFORM = 0 + STATION = 1 + ENTRANCE = 2 + GENERIC = 3 + BOARDING_AREA = 4 + + +def _parse_time(time_str): + """Returns a timedelta from a time string. + + :param time_str: String with a format "%H:%M:%S". + """ + p = '(?P[0-9]{2}):(?P[0-5][0-9]):(?P[0-5][0-9])' + m = re.match(p, time_str) + if m: + return timedelta( + hours=int(m.group('hour')), + minutes=int(m.group('minute')), + seconds=int(m.group('second')), + ) + else: + # Invalid time string. + return timedelta(0) diff --git a/metrosim/models/schedule_utility.py b/metrosim/models/schedule_utility.py new file mode 100644 index 0000000..d824dce --- /dev/null +++ b/metrosim/models/schedule_utility.py @@ -0,0 +1,32 @@ +import time + +from metrosim.exceptions import MetroInputError + + +class AlphaBetaGammaModel(object): + + mandatory_parameters = ('beta', 'gamma', 'tstar') + + def __init__(self, beta, gamma, tstar, delta=0, morning=True): + self.beta = beta + self.gamma = gamma + self.tstar = tstar + self.delta = delta + self.morning = morning + + def from_dict(data): + # Check that all mandatory parameters are in the data. + for parameter in super().mandatory_parameters: + if parameter not in data: + msg = ( + "Mandatory parameter for alpha-beta-gamma preferences: {}" + ) + raise MetroInputError(msg.format(parameter)) + # Convert tstar input to time.time instance using format '%H:%M:%S'. + try: + tstar = time.strptime(data['tstar'], format='%H:%M:%S') + except ValueError: + raise MetroInputError("Invalid time: {}".format(data['tstar'])) + return AlphaBetaGammaModel(beta=data['beta'], gamma=data['gamma'], + tstar=tstar, delta=data.get('delta', None), + morning=data.get('morning', None)) diff --git a/metrosim/simple_simulation/agents.json b/metrosim/simple_simulation/agents.json new file mode 100644 index 0000000..dce2868 --- /dev/null +++ b/metrosim/simple_simulation/agents.json @@ -0,0 +1,35 @@ +[ + { + "id": 1, + "schedule_utility": { + "type": "alpha-beta-gamma", + "parameters": { + "beta": 5, + "gamma": 20, + "delta": 0, + "tstar": "08:30:00", + "morning": true + } + }, + "mode_choice": { + "type": "deterministic", + "modes": [ + { + "type": "car", + "value_of_time": 10, + "departure_time": { + "type": "stochastic", + "parameters": { + "mu": 1, + "u": 0.5 + } + }, + "route_choice": { + "type": "deterministic", + "see_congestion": true + } + } + ] + } + } +] diff --git a/metrosim/simple_simulation/population.json b/metrosim/simple_simulation/population.json new file mode 100644 index 0000000..56b2fa3 --- /dev/null +++ b/metrosim/simple_simulation/population.json @@ -0,0 +1,9 @@ +{ + "random_seed": 42, + "segments": [ + { + "id": 1, + "od_matrix": "./od_matrix" + } + ] +} diff --git a/metrosim/simulator/__init__.py b/metrosim/simulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/metrosim/simulator/manager.py b/metrosim/simulator/manager.py new file mode 100644 index 0000000..16af266 --- /dev/null +++ b/metrosim/simulator/manager.py @@ -0,0 +1,42 @@ +# Copyright Lucas Javaudin - All Rights Reserved +# Unauthorized copying of this file, via any medium is strictly prohibited +# Proprietary and confidential +# Written by Lucas Javaudin , 2021 + + +class MetroManager(object): + + def __init__(self): + pass + + def run_pre_day_model(self): + """Returns events from the pre-day model. + + The pre-day model computes the decisions made by the agents before the + day start: mode choice, departure-time choice and (for public-transit + only) path choice. The decisions depend on the current state of the + network. + + :returns: one event for each agent, describing the mode and + departure-time chosen + :rtype: list + """ + events = list() + for agent in self.population.agents: + events.append(agent.get_pre_day_choice(self.network_state)) + return events + + def run_within_day_model(self, events): + """Returns the state of the network resulting from agent and vehicle + movements. + + The within-day model simulates the movements of agents and vehicles in + the network through an event-based model. + + :param events: initial set of events + :returns: state of the network + """ + while events: + event = events.pop() + self.network_state = event.execute(self.network_state) + return self.network_state diff --git a/test/simple_generate_agents_test.py b/test/simple_generate_agents_test.py new file mode 100644 index 0000000..d2e3626 --- /dev/null +++ b/test/simple_generate_agents_test.py @@ -0,0 +1,14 @@ +import os +import sys + +from metrosim import RawPopulation + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, BASE_DIR) + +# Load the raw population of the simple simulation example from its directory. +pop_file = os.path.join(BASE_DIR, 'metrosim/simple_simulation/population.json') +raw_pop = RawPopulation.from_file(pop_file) + +# Generate the agents from the raw population. +agents = raw_pop.generate() diff --git a/test/simple_simulation_test.py b/test/simple_simulation_test.py new file mode 100644 index 0000000..46cb992 --- /dev/null +++ b/test/simple_simulation_test.py @@ -0,0 +1,11 @@ +import os +import sys + +from metrosim import Simulation + +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, BASE_DIR) + +# Load the simple_simulation raw data from its directory. +simple_sim_dir = os.path.join(BASE_DIR, 'metrosim/simple_simulation/') +simple_sim = Simulation.from_directory(simple_sim_dir)