diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000..e9f30ad --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,34 @@ +name: Madison Metro Sim - CI Tests + +on: [ push ] + +jobs: + pytest: + name: Run tests + + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ macos-latest, windows-latest ] + + continue-on-error: true + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v2 + with: + python-version: '3.8' + + - name: Install required packages + run: | + pip install -r requirements.txt + + - name: Install pytest + run: | + pip install pytest + + - name: Run tests + run: | + pytest diff --git a/.github/workflows/cqa.yml b/.github/workflows/cqa.yml new file mode 100644 index 0000000..ae4dafc --- /dev/null +++ b/.github/workflows/cqa.yml @@ -0,0 +1,38 @@ +name: Madison Metro Sim - CQA + +on: [ push ] + +jobs: + cqa: + name: CQA + + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ macos-latest, windows-latest ] + + continue-on-error: true + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v2 + with: + python-version: '3.8' + + - name: Install required packages + run: | + pip install -r requirements.txt + + - name: Install CQA checkers + run: | + pip install pylint pydocstyle + + - name: pylint checks + run: | + pylint msnmetrosim + + - name: pydocstyle checks + run: | + pydocstyle msnmetrosim --count diff --git a/.gitignore b/.gitignore index e7fcece..945b031 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ venv/ # Generated map file (by folium) map.html + +# pytest cache file +.pytest_cache/ + +# Compiled files +__pycache__ +*.pyc diff --git a/.pydocstyle b/.pydocstyle new file mode 100644 index 0000000..38ee1b9 --- /dev/null +++ b/.pydocstyle @@ -0,0 +1,30 @@ +[pydocstyle] +ignore = + # Public method missing docstring - `pylint` will check if there's really missing the docstring + D102, + # Magic method missing docstring - no need for it + D105, + # __init__ missing docstring - optional. add details to class docstring + D107, + # Blank line required before docstring - mutually exclusive to D204 + D203, + # Multi-line docstring summary should start at the first line - mutually exclusive to D213 + D212, + # Section underline is over-indented + D215, + # First line should be in imperative mood + D401, + # First word of the docstring should not be This + D404, + # Section name should end with a newline + D406, + # Missing dashed underline after section + D407, + # Section underline should be in the line following the section’s name + D408, + # Section underline should match the length of its name + D409, + # No blank lines allowed between a section header and its content + D412, + # Missing blank line after last section + D413 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..1faca9a --- /dev/null +++ b/.pylintrc @@ -0,0 +1,3 @@ +[FORMAT] + +max-line-length=119 diff --git a/README.md b/README.md index 60c7716..0459335 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,29 @@ Madison Metro Simulator. ------ -2020 Fall CS 638 Project. +### Introduction + +This is a project of UW Madison 2020 Fall CS 638 class. + +The intended group of users of this program is its developers and technical users. + +- This specification may be changed in the future + + +### Usage + +1. Generate a map. + + ```python + py main.py + ``` + +2. Open the generated `map.html` file. ------ -- Intended for technical users. (confirmed with Tyler) +If you are first time running this program, execute the below first -- Does not necessarily need to use Python if there's any more efficient language / framework. +```bash +pip install -r requirements-dev.txt +``` diff --git a/main.py b/main.py index 6a13f5c..9d97b7e 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,17 @@ -import csv +import time -import folium +from msnmetrosim.views import generate_92_wkd_routes_and_stops def main(): - m = folium.Map(location=[43.080171, -89.380797]) + print("Generating map object...") + folium_map = generate_92_wkd_routes_and_stops() - # Read csv - - with open("data/mmt_gtfs/stops.csv", "r") as stops: - csv_reader = csv.reader(stops, delimiter=",") - next(csv_reader, None) # Dump header - - for row in csv_reader: - lat = float(row[4]) - lon = float(row[5]) - - name = f"#{row[0]} - {row[2]}" - - folium.Circle((lat, lon), 0.5, popup=name).add_to(m) - - m.save("map.html") + print("Exporting HTML...") + folium_map.save("map.html") if __name__ == '__main__': + _start = time.time() main() + print(f"Time spent: {(time.time() - _start) * 1000:.3f} ms") diff --git a/msnmetrosim/__init__.py b/msnmetrosim/__init__.py new file mode 100644 index 0000000..e4e569a --- /dev/null +++ b/msnmetrosim/__init__.py @@ -0,0 +1 @@ +"""Main code of the Madison Metro Simulator.""" diff --git a/msnmetrosim/controllers/__init__.py b/msnmetrosim/controllers/__init__.py new file mode 100644 index 0000000..c4d3ed0 --- /dev/null +++ b/msnmetrosim/controllers/__init__.py @@ -0,0 +1,5 @@ +"""Various data controllers. Loaded and parsed data will be stored into these controllers for use.""" +from .route import MMTRouteDataController +from .shape import MMTShapeDataController, ShapeIdNotFoundError +from .stop import MMTStopDataController +from .trip import MMTTripDataController diff --git a/msnmetrosim/controllers/route.py b/msnmetrosim/controllers/route.py new file mode 100644 index 0000000..85fff23 --- /dev/null +++ b/msnmetrosim/controllers/route.py @@ -0,0 +1,80 @@ +""" +Controller of the MMT GTFS route data. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +import csv +from typing import List, Dict + +from msnmetrosim.models import MMTRoute + +__all__ = ("MMTRouteDataController", "RouteIdNotFoundError") + + +class RouteIdNotFoundError(KeyError): + """Raised if the given route ID is not found in the loaded data.""" + + def __init__(self, route_id: int): + super().__init__(f"Data of route ID <{route_id}> not found") + + +class MMTRouteDataController: + """MMT route data controller.""" + + def _init_dict_by_rid(self, route: MMTRoute): + # This assumes that `route_id` in the original data is unique + self._dict_by_rid[route.route_id] = route + + def __init__(self, routes: List[MMTRoute]): + self._dict_by_rid: Dict[int, MMTRoute] = {} + + # Create a dict with route ID as key and route data entry as value + for route in routes: + self._init_dict_by_rid(route) + + def get_route_by_route_id(self, route_id: int) -> MMTRoute: + """ + Get a :class:`MMTRoute` by ``route_id``. + + :raise ServiceIdNotFoundError: if `route_id` is not in the loaded data + """ + if route_id not in self._dict_by_rid: + raise RouteIdNotFoundError(route_id) + + return self._dict_by_rid[route_id] + + @staticmethod + def load_from_file(file_path: str): + """ + Load the route data from route data file. + + This file should be a csv with the following schema: + + ( + route_id, + service_id, + agency_id, + route_short_name, + route_long_name, + route_service_name, + route_desc, + route_type, + route_url, + route_color, + route_text_color, + bikes_allowed + ) + + This file could be found in the MMT GTFS dataset with the name ``routes.csv``. + """ + routes = [] + + with open(file_path, "r") as routes_file: + csv_reader = csv.reader(routes_file, delimiter=",") + next(csv_reader, None) # Dump header + + for row in csv_reader: + routes.append(MMTRoute.parse_from_row(row)) + + return MMTRouteDataController(routes) diff --git a/msnmetrosim/controllers/shape.py b/msnmetrosim/controllers/shape.py new file mode 100644 index 0000000..a594201 --- /dev/null +++ b/msnmetrosim/controllers/shape.py @@ -0,0 +1,80 @@ +""" +Controller of the MMT GTFS shape data. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +import csv +from typing import List, Dict, Tuple + +from msnmetrosim.models import MMTShape + +__all__ = ("MMTShapeDataController", "ShapeIdNotFoundError") + + +class ShapeIdNotFoundError(KeyError): + """Raised if the given shape ID is not found in the loaded data.""" + + def __init__(self, shape_id: int): + super().__init__(f"Data of shape ID <{shape_id}> not found") + + +class MMTShapeDataController: + """MMT shape data controller.""" + + def _init_dict_by_id(self, shape: MMTShape): + sid = shape.shape_id + + if sid not in self._dict_by_id: + self._dict_by_id[sid] = [] + + self._dict_by_id[sid].append(shape) + + def __init__(self, shapes: List[MMTShape]): + """ + Initializes the shape data controller. + + Data in ``shapes`` should be pre-processed as following, or the functions may misbehave. + + GROUP BY shape_id, shape_pt_sequence ASC + + :param shapes: list of shape data + """ + self._dict_by_id: Dict[int, List[MMTShape]] = {} + + # Create a dict with ID as key and shape data entry as value + for shape in shapes: + self._init_dict_by_id(shape) + + def get_shape_coords_by_id(self, shape_id: int) -> List[Tuple[float, float]]: + """ + Get a list of :class:`MMTShapes` by ``shape_id``. + + :raise ShapeIdNotFoundError: if `shape_id` is not in the loaded data + """ + if shape_id not in self._dict_by_id: + raise ShapeIdNotFoundError(shape_id) + + return [shape.coordinate for shape in self._dict_by_id[shape_id]] + + @staticmethod + def load_from_file(file_path: str): + """ + Load the shape data from shape data file. + + This file should be a csv with the following schema: + + (shape_id, shape_code, shape_pt_lat, shape_pt_lon, shape_pt_sequence, shape_dist_traveled) + + This file could be found in the MMT GTFS dataset with the name ``shapes.csv``. + """ + shapes = [] + + with open(file_path, "r") as shapes_file: + csv_reader = csv.reader(shapes_file, delimiter=",") + next(csv_reader, None) # Dump header + + for row in csv_reader: + shapes.append(MMTShape.parse_from_row(row)) + + return MMTShapeDataController(shapes) diff --git a/msnmetrosim/controllers/stop.py b/msnmetrosim/controllers/stop.py new file mode 100644 index 0000000..7bf2f2c --- /dev/null +++ b/msnmetrosim/controllers/stop.py @@ -0,0 +1,71 @@ +""" +Controller of the MMT GTFS stop data. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +import csv +from typing import List, Dict, Generator + +from msnmetrosim.models import MMTStop + +__all__ = ("MMTStopDataController",) + + +class MMTStopDataController: + """MMT stop data controller.""" + + def _init_dict_by_id(self, stop: MMTStop): + # Assuming ``stop_id`` is unique + self._dict_by_id[stop.stop_id] = stop + + def __init__(self, stops: List[MMTStop]): + self._dict_by_id: Dict[int, MMTStop] = {} + + # Create a dict with ID as key and stop data entry as value + for stop in stops: + self._init_dict_by_id(stop) + + def get_all_stops(self) -> Generator[MMTStop, None, None]: + """Get all the stops in the loaded data.""" + for stop in self._dict_by_id.values(): + yield stop + + @staticmethod + def load_from_file(file_path: str): + """ + Load the stop data from stop data file. + + This file should be a csv with the following schema: + + ( + stop_id, + stop_code, + stop_name, + stop_desc, + stop_lat, + stop_lon, + agency_id, + jurisdiction_id, + location_type, + parent_station, + relative_position, + cardinal_direction, + wheelchair_boarding, + primary_street, + address_range, + cross_location + ) + + This file could be found in the MMT GTFS dataset with the name ``stops.csv``. + """ + stops = [] + + with open(file_path, "r") as stops_file: + csv_reader = csv.reader(stops_file, delimiter=",") + next(csv_reader, None) # Dump header + + for row in csv_reader: + stops.append(MMTStop.parse_from_row(row)) + + return MMTStopDataController(stops) diff --git a/msnmetrosim/controllers/trip.py b/msnmetrosim/controllers/trip.py new file mode 100644 index 0000000..74eda94 --- /dev/null +++ b/msnmetrosim/controllers/trip.py @@ -0,0 +1,122 @@ +""" +Controller of the MMT GTFS trip data. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +import csv +from typing import List, Dict + +from msnmetrosim.models import MMTTrip +from msnmetrosim.utils import temporary_func + +__all__ = ("MMTTripDataController", "ServiceIdNotFoundError") + + +class ServiceIdNotFoundError(KeyError): + """Raised if the given service ID is not found in the loaded data.""" + + def __init__(self, service_id: str): + super().__init__(f"Data of service ID <{service_id}> not found") + + +class MMTTripDataController: + """MMT trip data controller.""" + + def _init_dict_by_serv_id(self, trip: MMTTrip): + sid = trip.service_id + + if sid not in self._dict_by_serv_id: + self._dict_by_serv_id[sid] = [] + + self._dict_by_serv_id[sid].append(trip) + + def __init__(self, trips: List[MMTTrip]): + self._core: List[MMTTrip] = trips + self._dict_by_serv_id: Dict[str, List[MMTTrip]] = {} + + # Create a dict with service ID as key and trip data entry as value + for trip in trips: + self._init_dict_by_serv_id(trip) + + @temporary_func + def get_shapes_available_in_service(self, service_id: str) -> Dict[int, MMTTrip]: + """ + Get all shapes that will be ran at least once in a service plan. + + The key of the return is ``shape_id``; + the value of the return is the last trip of the day ran in ``shape_id``. + + ------ + + This is temporary and should be removed later because: + + - This includes routes / trips that will run for the last schedule of a day + + - Does not have the ability to reflect on the route redundancy + + :raises ServiceIdNotFoundError: `service_id` not found in the loaded data + """ + if service_id not in self._dict_by_serv_id: + raise ServiceIdNotFoundError(service_id) + + ret = {} + + for trip in self._dict_by_serv_id[service_id]: + ret[trip.shape_id] = trip + + return ret + + def get_all_service_ids(self) -> Dict[str, int]: + """ + Get all available service IDs and the corresponding count of trips. + + This function is slightly expensive because it iterate through all the trips for each call of this function. + """ + ret = {} + + for trip in self._core: + sid = trip.service_id + if sid not in ret: + ret[sid] = 0 + + ret[sid] += 1 + + return ret + + @staticmethod + def load_from_file(file_path: str): + """ + Load the trip data from trip data file. + + This file should be a csv with the following schema: + + ( + route_id, + route_short_name, + service_id, + trip_id, + trip_headsign, + direction_id, + direction_name, + block_id, + shape_id, + shape_code, + trip_type, + trip_sort, + wheelchair_accessible, + bikes_allowed + ) + + This file could be found in the MMT GTFS dataset with the name ``trips.csv``. + """ + trips = [] + + with open(file_path, "r") as trips_file: + csv_reader = csv.reader(trips_file, delimiter=",") + next(csv_reader, None) # Dump header + + for row in csv_reader: + trips.append(MMTTrip.parse_from_row(row)) + + return MMTTripDataController(trips) diff --git a/msnmetrosim/models/__init__.py b/msnmetrosim/models/__init__.py new file mode 100644 index 0000000..e45f826 --- /dev/null +++ b/msnmetrosim/models/__init__.py @@ -0,0 +1,5 @@ +"""Various models representing the data entry.""" +from .route import MMTRoute +from .shape import MMTShape +from .stop import MMTStop +from .trip import MMTTrip, MMTTripType diff --git a/msnmetrosim/models/base/__init__.py b/msnmetrosim/models/base/__init__.py new file mode 100644 index 0000000..33fb252 --- /dev/null +++ b/msnmetrosim/models/base/__init__.py @@ -0,0 +1,2 @@ +"""Base classes for data entry classes.""" +from .coord import Locational diff --git a/msnmetrosim/models/base/coord.py b/msnmetrosim/models/base/coord.py new file mode 100644 index 0000000..df0533d --- /dev/null +++ b/msnmetrosim/models/base/coord.py @@ -0,0 +1,19 @@ +"""Base dataclass for object that contains coordinates.""" +from abc import ABC +from dataclasses import dataclass +from typing import Tuple + +__all__ = ("Locational",) + + +@dataclass +class Locational(ABC): + """Interface for the data entry which contains coordinates.""" + + lat: float + lon: float + + @property + def coordinate(self) -> Tuple[float, float]: + """Coordinate in ``tuple`` of the stop.""" + return self.lat, self.lon diff --git a/msnmetrosim/models/route.py b/msnmetrosim/models/route.py new file mode 100644 index 0000000..63e2451 --- /dev/null +++ b/msnmetrosim/models/route.py @@ -0,0 +1,54 @@ +""" +Single entry of route data (routes.csv) in MMT GTFS dataset. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +from dataclasses import dataclass +from typing import List + +__all__ = ("MMTRoute",) + + +@dataclass +class MMTRoute: + """ + MMT GTFS route entry. + + .. note:: + ``route_short_name`` is the router number in daily use. + + ``service_id`` is the service plan ID. + + - This only contains the batch number (for example, ``92`` or ``93``). + + - ``service_id`` of :class:`MMTTrip` is slightly different from this. + The ``service_id`` there includes codes, which corresponds to the operational dates and plans recorded in + ``calendar.csv`` and ``calendar_dates.csv``. + + Original ``route_color`` and ``route_text_color`` are HEX colors without "#". + After parsing, these will be prefixed with "#". + """ + + route_id: int + + service_id: int + + route_short_name: str + + route_color: str + route_text_color: str + + @staticmethod + def parse_from_row(row: List[str]): + """Parse a single entry into :class:`MMTRoute` from a csv row.""" + route_id = int(row[0]) + + service_id = int(row[1]) + + route_short_name = row[3] + + route_color = f"#{row[9]}" + route_text_color = f"#{row[10]}" + + return MMTRoute(route_id, service_id, route_short_name, route_color, route_text_color) diff --git a/msnmetrosim/models/shape.py b/msnmetrosim/models/shape.py new file mode 100644 index 0000000..b1a22e8 --- /dev/null +++ b/msnmetrosim/models/shape.py @@ -0,0 +1,46 @@ +""" +Single entry of shape data (shapes.csv) in MMT GTFS dataset. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +from dataclasses import dataclass +from typing import List + +from .base import Locational + +__all__ = ("MMTShape",) + + +@dataclass +class MMTShape(Locational): + """ + MMT GTFS shape entry. + + .. note:: + ``shape_id`` is a unique ID whereas ``shape_code`` acts the route name. + + There will be some cases that the two different ``shape_id`` are sharing the same ``shape_code``. + """ + + lat: float + lon: float + + shape_id: int + shape_code: str + + seq_num: int + dist_traveled: float + + @staticmethod + def parse_from_row(row: List[str]): + """Parse a single entry into :class:`MMTShape` from a csv row.""" + shape_id = int(row[0]) + shape_code = row[1] + + lat, lon = float(row[2]), float(row[3]) + + seq_num = int(row[4]) + dist_traveled = float(row[5]) + + return MMTShape(lat, lon, shape_id, shape_code, seq_num, dist_traveled) diff --git a/msnmetrosim/models/stop.py b/msnmetrosim/models/stop.py new file mode 100644 index 0000000..934d7de --- /dev/null +++ b/msnmetrosim/models/stop.py @@ -0,0 +1,58 @@ +""" +Single entry of stop data (stops.csv) in MMT GTFS dataset. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +from dataclasses import dataclass +from typing import List + +from .base import Locational + +__all__ = ("MMTStop",) + + +@dataclass +class MMTStop(Locational): + """ + MMT GTFS stop entry. + + .. note:: + ``stop_code`` mostly is just a padded ``stop_id``. + + For example, if ``stop_id`` is ``12``, then ``stop_code`` is ``0012``. + + There are exceptional cases for this. + + If the stop is a transfer point, ``stop_code`` will be the abbreviation instead. + + For example, the ``stop_code`` of *South Transfer Point* is ``SoTP`` instead of ``4000``, + which is its ``stop_id``. + """ + + lat: float + lon: float + + stop_id: int + stop_code: str + stop_name: str + + @property + def name(self) -> str: + """ + Formatted name of the stop. + + The return will be (). + """ + return f"{self.stop_name} ({self.stop_code})" + + @staticmethod + def parse_from_row(row: List[str]): + """Parse a single entry into :class:`MMTStop` from a csv row.""" + stop_id = int(row[0]) + stop_code = row[1] + stop_name = row[2] + + lat, lon = float(row[4]), float(row[5]) + + return MMTStop(lat, lon, stop_id, stop_code, stop_name) diff --git a/msnmetrosim/models/trip.py b/msnmetrosim/models/trip.py new file mode 100644 index 0000000..c61641e --- /dev/null +++ b/msnmetrosim/models/trip.py @@ -0,0 +1,146 @@ +""" +Single entry of trip data (trips.csv) in MMT GTFS dataset. + +The complete MMT GTFS dataset can be downloaded here: +http://transitdata.cityofmadison.com/GTFS/mmt_gtfs.zip +""" +from dataclasses import dataclass +from datetime import time +from enum import Enum, auto +from typing import List + +from msnmetrosim.utils import time_from_seconds + +__all__ = ("MMTTrip", "MMTTripType") + + +class MMTTripType(Enum): + """ + Enum to represent MMT trip type. + + .. note:: + Documentations except ``MMTTripType.MMTTripType`` are + copied from ``extended_data_dictionary.txt`` of MMT GTFS dataset. + """ + + ALL_DATES = auto() + """Trip generally operates on all service dates - including weekday/weekend/holiday service dates.""" + + NO_HOLIDAY = auto() + """Trip generally operates on all service dates - except Holiday service dates.""" + + WEEKEND_HOLIDAY = auto() + """Trip generally operates on Weekend and Holiday service dates only.""" + + WEEKDAY = auto() + """Trip generally operates on Weekday service dates only.""" + + FRIDAY = auto() + """Trip only operates on Friday Standard service dates.""" + + RECESS = auto() + """Trip only operates on Recess service dates.""" + + UNKNOWN = auto() + """Sentinel value for empty code.""" + + @staticmethod + def parse_from_data(code: str): + """Parse the trip type from the original data.""" + # pylint: disable=too-many-return-statements + + if not code: + return MMTTripType.UNKNOWN + + if code == "D": + return MMTTripType.ALL_DATES + + if code == "H": + return MMTTripType.NO_HOLIDAY + + if code == "S": + return MMTTripType.WEEKEND_HOLIDAY + + if code == "W": + return MMTTripType.WEEKDAY + + if code == "F": + return MMTTripType.FRIDAY + + if code == "R": + return MMTTripType.RECESS + + return ValueError(f"Unknown trip type code: {code}") + + +@dataclass +class MMTTrip: + """ + MMT GTFS trip entry. + + .. note:: + ``route_id`` and ``route_short_name`` mimics the data in ``routes.csv``. + + - Also will be as same as the field name of :class:`MMTRoute`. + + ``service_id`` is the service plan ID **PLUS** the service days code. + + - The ``service_id`` here corresponds to the data in ``calendar.csv`` and ``calendar_dates.csv``. + + ``shape_id`` and ``shape_code`` mimics the data in ``shapes.csv``. + + - Also will be as same as the field name of :class:`MMTShape`. + + ``trip_sort`` in the original data file / ``trip_departure`` after parse is the scheduled departure time. + + - ``trip_sort`` scheduled departure time counting from 12 AM in seconds + + - ``trip_departure`` is the parsed ``trip_sort`` in :class:`datetime.time`. + + Currently not sure about the meaning of ``block_id``. Awaiting investigation. + """ + + # pylint: disable=too-many-instance-attributes + + route_id: int + route_short_name: str + + service_id: str + + trip_id: id + trip_headsign: str + + trip_direction_id: int + trip_direction_name: str + + block_id: int + shape_id: int + shape_code: str + + trip_type: MMTTripType + trip_departure: time + + @staticmethod + def parse_from_row(row: List[str]): + """Parse a single entry into :class:`MMTTrip` from a csv row.""" + route_id = int(row[0]) + route_short_name = row[1] + + service_id = row[2] + + trip_id = int(row[3]) + trip_headsign = row[4] + + trip_direction_id = int(row[5]) + trip_direction_name = row[6] + + block_id = int(row[7]) + shape_id = int(row[8]) + shape_code = row[9] + + trip_type = MMTTripType.parse_from_data(row[10]) + trip_departure = time_from_seconds(int(row[11])) + + return MMTTrip(route_id, route_short_name, service_id, trip_id, trip_headsign, + trip_direction_id, trip_direction_name, block_id, shape_id, shape_code, + trip_type, trip_departure) diff --git a/msnmetrosim/static.py b/msnmetrosim/static.py new file mode 100644 index 0000000..806aba0 --- /dev/null +++ b/msnmetrosim/static.py @@ -0,0 +1,9 @@ +"""Some static variables. For example: configurations, madison center coordinate, etc.""" + +# Map configurations + +MAP_MADISON_CENTER_COORD = (43.080171, -89.380797) + +MAP_TILE = "CartoDB dark_matter" + +MAP_ZOOM_START = 14 diff --git a/msnmetrosim/utils/__init__.py b/msnmetrosim/utils/__init__.py new file mode 100644 index 0000000..e1e477e --- /dev/null +++ b/msnmetrosim/utils/__init__.py @@ -0,0 +1,4 @@ +"""Various utils and helpers.""" +from .colorgen import get_color +from .deco_warning import temporary_func +from .dt_convert import time_from_seconds diff --git a/msnmetrosim/utils/colorgen.py b/msnmetrosim/utils/colorgen.py new file mode 100644 index 0000000..a97d610 --- /dev/null +++ b/msnmetrosim/utils/colorgen.py @@ -0,0 +1,26 @@ +"""Randomly generate a color and return it.""" + +__all__ = ("get_color",) + +# Based on https://www.oberlo.com/blog/color-combinations-cheat-sheet +_palette = [ + "#E1A730", "#2879C0", "#AB3910", "#2B5615", "#F8CF2C", "#DB55D4", "#9B948A", + "#FF0BAC", "#490034", "#F6B405", "#FDBCFD", "#284E60", "#F99B45", "#63AAC0", + "#D95980", "#425F06", "#0352A0", "#EF3340", "#A16AE8", "#B19FF9", "#0E3506", + "#FEA303" +] # pylint: disable=invalid-name +_counter: int = -1 # pylint: disable=invalid-name + + +def get_color(): + """ + Get a color from palette in HEX. The color picking process is sequential. + + Currently not used in any place, but may be used in the future. + """ + global _palette, _counter # pylint: disable=global-statement + + _counter += 1 + _counter %= len(_palette) + + return _palette[_counter] diff --git a/msnmetrosim/utils/deco_warning.py b/msnmetrosim/utils/deco_warning.py new file mode 100644 index 0000000..b25eaa8 --- /dev/null +++ b/msnmetrosim/utils/deco_warning.py @@ -0,0 +1,26 @@ +"""Decorators to yield warnings under various circumstances.""" +import functools +import warnings + +__all__ = ("temporary_func",) + + +def temporary_func(func): + """ + Decorator to yield warnings indicating that the function is for temporary use, and should be removed later. + + Example:: + + >>> @temporary_func + >>> def temp_func(): + >>> pass + """ + + @functools.wraps(func) + def new_func(*args, **kwargs): + warnings.warn(f"Temporary function called: {func}.", + category=DeprecationWarning, + stacklevel=2) + return func(*args, **kwargs) + + return new_func diff --git a/msnmetrosim/utils/dt_convert.py b/msnmetrosim/utils/dt_convert.py new file mode 100644 index 0000000..9e47144 --- /dev/null +++ b/msnmetrosim/utils/dt_convert.py @@ -0,0 +1,16 @@ +"""Conversion functions related to datetime.""" +from datetime import time + +__all__ = ("time_from_seconds",) + + +def time_from_seconds(seconds: int) -> time: + """Convert ``seconds`` to :class:`time`.""" + # ``seconds`` passed in may be > 86400 (a day), which causes ``hours`` to be invalid. + # One example is that ``trips.txt:trip_sort`` contains value > 86400. + seconds %= 86400 + + # Not directly modifying ``seconds`` for easier debugging (check the original input) + hours, secs = divmod(seconds, 3600) + mins, secs = divmod(secs, 60) + return time(hours, mins, secs) diff --git a/msnmetrosim/views/__init__.py b/msnmetrosim/views/__init__.py new file mode 100644 index 0000000..0856556 --- /dev/null +++ b/msnmetrosim/views/__init__.py @@ -0,0 +1,2 @@ +"""Views of the data. Maps to render should be generated from this module.""" +from .mapgen import generate_clean_map, generate_92_wkd_routes_and_stops diff --git a/msnmetrosim/views/mapgen.py b/msnmetrosim/views/mapgen.py new file mode 100644 index 0000000..e9e5e7d --- /dev/null +++ b/msnmetrosim/views/mapgen.py @@ -0,0 +1,87 @@ +"""Functions to generate folium map object.""" +from typing import Tuple + +from folium import Map as FoliumMap, Icon, Marker, PolyLine +from folium.plugins import MarkerCluster + +from msnmetrosim.controllers import ( + MMTRouteDataController, MMTShapeDataController, MMTStopDataController, MMTTripDataController +) +from msnmetrosim.static import MAP_MADISON_CENTER_COORD, MAP_TILE, MAP_ZOOM_START +from msnmetrosim.utils import temporary_func + +__all__ = ("generate_clean_map", "generate_92_wkd_routes_and_stops") + +# Preloading the data - be aware that these should be removed if controllers will perform manipulations +_routes = MMTRouteDataController.load_from_file("data/mmt_gtfs/routes.csv") +_shapes = MMTShapeDataController.load_from_file("data/mmt_gtfs/shapes.csv") +_stops = MMTStopDataController.load_from_file("data/mmt_gtfs/stops.csv") +_trips = MMTTripDataController.load_from_file("data/mmt_gtfs/trips.csv") + + +def plot_stops(folium_map: FoliumMap, clustered: bool = True): + """ + Plot all the stops onto ``folium_map``. + + ``clustered`` determines if the stop will be clustered/expanded upon zoom. + + Could use customized color in the future for better rendering effect. + """ + if clustered: + parent = MarkerCluster().add_to(folium_map) + else: + parent = folium_map + + for stop in _stops.get_all_stops(): + Marker( + stop.coordinate, + popup=stop.name, + icon=Icon(color="green", icon_color="white", icon="bus", angle=0, + prefix="fa") + ).add_to(parent) + + +def plot_shape(folium_map: FoliumMap, shape_id: int, shape_popup: str, shape_color: str): + """ + Plot the shape of ``shape_id`` onto ``folium_map``. + + ``shape_color`` can be any strings that represents color in CSS. + """ + shape_coords = _shapes.get_shape_coords_by_id(shape_id) + PolyLine(shape_coords, color=shape_color, popup=shape_popup).add_to(folium_map) + + +@temporary_func +def plot_92_wkd_routes(folium_map: FoliumMap): + """Plot all the routes (shapes) available under service ID ``92_WKD`` (Batch #92, weekday plan, presumably).""" + shapes = _trips.get_shapes_available_in_service("92_WKD") + + for shape_id, last_trip in shapes.items(): + shape_popup = f"{last_trip.route_short_name} - {last_trip.trip_headsign}" + shape_color = _routes.get_route_by_route_id(last_trip.route_id).route_color + + plot_shape(folium_map, shape_id, shape_popup, shape_color) + + +def generate_clean_map(center_coord: Tuple[float, float] = None, + tile: str = None, + zoom_start: int = None) -> FoliumMap: + """ + Generate a clean map. + + Default configuration will be applied for each value if not specified. + """ + return FoliumMap(location=center_coord if center_coord else MAP_MADISON_CENTER_COORD, + tiles=tile if tile else MAP_TILE, + zoom_start=zoom_start if zoom_start else MAP_ZOOM_START) + + +@temporary_func +def generate_92_wkd_routes_and_stops() -> FoliumMap: + """Generate a map with 92_WKD routes and all stops plotted on the map.""" + folium_map = generate_clean_map() + + plot_stops(folium_map) + plot_92_wkd_routes(folium_map) + + return folium_map diff --git a/requirements-dev.txt b/requirements-dev.txt index c4156f1..b76e9e4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,12 @@ # Jupyter notebook jupyter +# CQA check +pydocstyle +pylint + +# CI +pytest + # Other necessary requirements -r requirements.txt diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_system/__init__.py b/tests/test_system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_system/test_main.py b/tests/test_system/test_main.py new file mode 100644 index 0000000..355cf1d --- /dev/null +++ b/tests/test_system/test_main.py @@ -0,0 +1,17 @@ +import os + +from msnmetrosim.views import generate_clean_map, generate_92_wkd_routes_and_stops + + +def test_gen_clean_map(): + generate_clean_map().save("temp.html") + + # cleanup + os.unlink("temp.html") + + +def test_gen_92_wkd_map(): + generate_92_wkd_routes_and_stops().save("temp.html") + + # cleanup + os.unlink("temp.html")