Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom cost routing #1

Merged
merged 24 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c091a44
added tests for custom cost routing logic, added fixtures to conftest…
roopehub Nov 9, 2023
ba0e6d3
modified DetailedItineraries related components to support returning …
roopehub Nov 9, 2023
3a3af7e
modified matrix computer to support results OsmId's
roopehub Nov 9, 2023
5f684be
created a subclass of TransportNetwork for enabling support for custo…
roopehub Nov 9, 2023
ce869fa
fixed files and tests for when using r5 without custom cost support, …
roopehub Nov 15, 2023
1b20970
added datas for the tests, improved the tests and conftestfile, added…
roopehub Nov 15, 2023
49b1fcc
added better checking of osmids to prevent crashing if absent
roopehub Nov 15, 2023
134cd52
improved and fixed the business logic, added support for lists for na…
roopehub Nov 15, 2023
d0377bf
apparently accidentally removed start_jvm from util init.py, added it…
roopehub Nov 15, 2023
fc31232
removed unused commented imports from util init
roopehub Nov 15, 2023
90036b9
refactored logic, fixed not to add osmIds from result to df if they a…
roopehub Nov 22, 2023
926e48b
refactored tests for better, added tests for base travel time cost an…
roopehub Nov 22, 2023
f522e43
just a bogus edit to try to trigger github to re-render the PR diff
christophfink Nov 23, 2023
b8d6762
remove bogus edit again
christophfink Nov 23, 2023
edc2613
Use our own R5 jar, upgrade R5 to v7.0 (#385)
christophfink Nov 28, 2023
0cba520
apparently accidentally removed start_jvm from util init.py, added it…
roopehub Nov 15, 2023
772c767
removed unused commented imports from util init
roopehub Nov 15, 2023
d0f8fa5
Use our own R5 jar, upgrade R5 to v7.0 (#385)
christophfink Nov 28, 2023
832800b
apparently accidentally removed start_jvm from util init.py, added it…
roopehub Nov 15, 2023
d1882b3
pr comment changes, renamed custom_datas and remove obsole type check…
roopehub Nov 29, 2023
db8e3b8
also did the name change in the tests
roopehub Nov 29, 2023
40b1d8f
added kmh and mph for car detailed itineraries, added test data for i…
roopehub Dec 19, 2023
e745c49
merged remote to local after merge to main
roopehub Dec 19, 2023
7de6602
Merge branch 'main' into custom-cost-routing
roopehub Dec 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/r5py/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
RegionalTask,
TransportMode,
TransportNetwork,
CustomCostTransportNetwork,
TravelTimeMatrixComputer,
)

Expand All @@ -18,5 +19,6 @@
"TransportMode",
"TransportNetwork",
"TravelTimeMatrixComputer",
"CustomCostTransportNetwork",
"__version__",
]
2 changes: 2 additions & 0 deletions src/r5py/r5/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .transport_mode import TransportMode
from .transport_network import TransportNetwork
from .travel_time_matrix_computer import TravelTimeMatrixComputer
from .custom_cost_transport_network import CustomCostTransportNetwork

__all__ = [
"BreakdownStat",
Expand All @@ -20,5 +21,6 @@
"StreetLayer",
"TransportMode",
"TransportNetwork",
"CustomCostTransportNetwork",
"TravelTimeMatrixComputer",
]
5 changes: 4 additions & 1 deletion src/r5py/r5/base_travel_time_matrix_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..util import check_od_data_set, Config
from .regional_task import RegionalTask
from .transport_network import TransportNetwork
from .custom_cost_transport_network import CustomCostTransportNetwork


__all__ = ["BaseTravelTimeMatrixComputer"]
Expand Down Expand Up @@ -71,7 +72,9 @@ def __init__(
``max_time_cycling``, ``max_time_driving``, ``speed_cycling``, ``speed_walking``,
``max_public_transport_rides``, ``max_bicycle_traffic_stress``
"""
if not isinstance(transport_network, TransportNetwork):
if not isinstance(transport_network, TransportNetwork) and not isinstance(
transport_network, CustomCostTransportNetwork
):
roopehub marked this conversation as resolved.
Show resolved Hide resolved
transport_network = TransportNetwork(*transport_network)
self.transport_network = transport_network

Expand Down
243 changes: 243 additions & 0 deletions src/r5py/r5/custom_cost_transport_network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#!/usr/bin/env python3

"""Subclass for TransportNetwork, enables custom cost routing."""
import com.conveyal.r5
from r5py.r5.transport_network import TransportNetwork
from r5py.util.custom_cost_conversions import (
convert_java_hashmap_to_python_dict,
convert_python_custom_costs_to_java_custom_costs,
)
from r5py.util.exceptions import CustomCostDataError
from r5py.util.jvm import start_jvm

__all__ = ["CustomCostTransportNetwork"]

start_jvm()


def r5_supports_custom_costs():
"""
Check if the R5 java has the GP2 (Green Paths 2) patch i.e. supports custom costs routing.

Returns:
--------
bool: True if using GP2 R5, False otherwise.
"""
try:
import com.conveyal.r5.rastercost.CustomCostField # noqa: F401

# the import was successful thus using GP2 R5
return True
except ImportError:
# the import was unsuccessful
return False


class CustomCostTransportNetwork(TransportNetwork):
"""Inherit from TransportNetwork, adds custom cost data routing functionality."""

def __init__(self, osm_pbf, names, sensitivities, custom_cost_datas):
roopehub marked this conversation as resolved.
Show resolved Hide resolved
"""
Initialise a transport network with custom costs.
Supports multiple custom costs. Must always have the same number of:
names, sensitivities, and custom_cost_datas.

Arguments
---------
osm_pbf : str
file path of an OpenStreetMap extract in PBF format
names : List[str]
name(s) of the custom cost(s)
sensitivities : List[float] | List[int]
sensitivities of the custom cost(s)
custom_cost_datas : List[Dict[str, float]]
custom cost data(s) to be used in routing.
str key is osmid, float value is custom costs per edge (way).
multiple custom cost data can be provided as a list of python dicts,
when multiple custom cost datas are provided, all of those custom cost datas will be combined
for each edge (way) during r5 custom cost routing.
"""
# crash if custom costs are NOT supported in the used version of R5
# either use TransportNetwork (without custom costs) or change to correct version of R5
if not r5_supports_custom_costs():
raise ImportError(
roopehub marked this conversation as resolved.
Show resolved Hide resolved
"""Custom costs are not supported in this version of R5.
Correct (Green Paths 2 / r5_gp2) R5 version can be found here:
https://github.com/DigitalGeographyLab/r5_gp2.
"""
)
self.validate_custom_cost_params(names, sensitivities, custom_cost_datas)
self.names = names
self.sensitivities = sensitivities
self.custom_cost_datas = custom_cost_datas
# GTFS is currently not supported for custom cost transport network
super().__init__(osm_pbf, gtfs=[])

def validate_custom_cost_params(self, names, sensitivities, custom_cost_datas):
"""Validate custom cost parameters."""
# parameters are lists and non-empty
params = {
"names": names,
"sensitivities": sensitivities,
"custom_cost_datas": custom_cost_datas,
}
for param_name, param_value in params.items():
if not isinstance(param_value, list):
raise CustomCostDataError(f"{param_name} must be a list")
if not param_value:
raise CustomCostDataError(f"{param_name} must not be empty")

# lists are of the same length
if len(set(map(len, params.values()))) != 1:
raise CustomCostDataError(
"names, sensitivities, and custom_cost_datas must be of the same length"
)

# check individual item types
for name, sensitivity, custom_cost_data in zip(
names, sensitivities, custom_cost_datas
):
if not isinstance(name, str):
raise CustomCostDataError("Names must be strings")
if not isinstance(sensitivity, (float, int)):
raise CustomCostDataError("Sensitivities must be floats or integers")
if not isinstance(custom_cost_data, dict) or not all(
isinstance(key, str) and isinstance(value, float)
for key, value in custom_cost_data.items()
):
raise CustomCostDataError(
"Custom_cost_datas must be dicts with string keys and float values"
)

def add_custom_cost_data_to_network(self, transport_network):
"""Custom hook for adding custom cost data to the transport network edges."""
vertex_store = com.conveyal.r5.streets.VertexStore(100_000)
roopehub marked this conversation as resolved.
Show resolved Hide resolved
edge_store = com.conveyal.r5.streets.EdgeStore(
vertex_store, transport_network.streetLayer, 200_000
roopehub marked this conversation as resolved.
Show resolved Hide resolved
)
transport_network.streetLayer.vertexStore = vertex_store
transport_network.streetLayer.edgeStore = edge_store
converted_custom_cost_data = convert_python_custom_costs_to_java_custom_costs(
self.names, self.sensitivities, self.custom_cost_datas
)
transport_network.streetLayer.edgeStore.costFields = converted_custom_cost_data
self._transport_network = transport_network
return transport_network

def _fetch_network_custom_cost_travel_time_product(
self, method_name, osmids=[], merged=False
):
"""
Retrieve custom cost travel time related product hashmap per osmid from the network edges.
Ensure that this method is executed post-routing.
Should not be called directly, use get_base_travel_times or get_additional_travel_times instead.

Arguments:
----------
method_name : str
name of the method to be called from the custom cost transport network
this can be either: getAdditionalTravelTimes or getTravelTimes
getAdditionalTravelTimes returns the additional travel times from the custom cost instances
getTravelTimes returns the base travel times from the custom cost instances
both methods return a Java HashMap with Osmid as key and travel time as value

note: in getTravelTimes the value is actual seconds but in getAdditionalTravelTimes
the value more of a cost than actual seconds

osmids : List[str | int] (optional)
list of osmids to get travel times for. If not provided, return all travel times.

merged : bool, default False
define if the base travel times should be merged into a single dict or not

Returns:
--------
travel_times_per_custom_cost: List[Tuple[str, Dict[str, int]]]
list of tuples of custom cost name and travel times per custom cost routing

"""
try:
cost_fields_list = list(
self._transport_network.streetLayer.edgeStore.costFields
)
travel_times_per_custom_cost = [
(
str(cost_field.getDisplayKey()),
convert_java_hashmap_to_python_dict(
getattr(cost_field, method_name)()
),
)
for cost_field in cost_fields_list
]

# if osmids provided, filter the osm dict value result to only include the osmids provided
if osmids:
travel_times_per_custom_cost = [
(
str(display_key),
{
str(osmid): osmid_dict[str(osmid)]
for osmid in osmids
if str(osmid) in osmid_dict.keys()
}
)
for display_key, osmid_dict in travel_times_per_custom_cost
]

# if merged flag is True, merge all results into a single dict
if merged:
merged_name = "merged_custom_costs:"
merged_travel_times = {}
for name, base_travel_times in travel_times_per_custom_cost:
merged_travel_times.update(base_travel_times)
merged_name+= f"_{name}"
# return all base travel times merged in a single dict in a list
return [(merged_name, merged_travel_times)]
# return times per custom cost routing
return travel_times_per_custom_cost
except:
raise CustomCostDataError(
"Failed to get base travel times from custom cost transport network."
)

# kept two similar getters for intuitive abstraction, naming and clarity for user

def get_base_travel_times(self, osmids=[], merged=False):
"""Get base travel times from edges used during routing from custom costs instances.

Arguments:
----------
osmids : List[str | int] (optional)
list of osmids to get base travel times for. If not provided, return all base travel times.
merged : bool, default False
define if the base travel times should be merged into a single dict or not

Returns:
--------
List[Tuple[str, Dict[str, int]]]
list of tuples of custom cost name and base travel times
each tuple represents one custom cost, if merged is True, only one tuple is returned
"""
return self._fetch_network_custom_cost_travel_time_product(
"getBaseTraveltimes", osmids, merged
)

def get_custom_cost_additional_travel_times(self, osmids=[], merged=False):
"""Get custom cost addition travel times from edges used during routing from custom costs instances.

Arguments:
----------
osmids : List[str] (optional)
list of osmids to get additional custom costs for. If not provided, return all base travel times.
merged : bool, default False
define if the base travel times should be merged into a single dict or not

Returns:
--------
List[Tuple[str, Dict[str, int]]]
list of tuples of custom cost name and additional travel timecosts
each tuple represents one custom cost, if merged is True, only one tuple is returned
"""
return self._fetch_network_custom_cost_travel_time_product(
"getcustomCostAdditionalTraveltimes", osmids, merged
)
7 changes: 7 additions & 0 deletions src/r5py/r5/direct_leg.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,17 @@ def __init__(self, transport_mode, street_segment):
distance = street_segment.distance / 1000.0 # millimetres!
travel_time = datetime.timedelta(seconds=street_segment.duration)
geometry = shapely.from_wkt(str(street_segment.geometry))
osm_ids = []

if hasattr(street_segment, "streetEdges"):
for edge_info in street_segment.streetEdges:
if hasattr(edge_info, "edgeOsmId"):
osm_ids.append(edge_info.edgeOsmId)

super().__init__(
transport_mode=transport_mode,
distance=distance,
travel_time=travel_time,
geometry=geometry,
osm_ids=osm_ids,
)
34 changes: 22 additions & 12 deletions src/r5py/r5/transport_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,27 +64,37 @@ def __init__(self, osm_pbf, gtfs=[]):
self.osm_file = osm_file # keep the mapdb open, close in destructor

transport_network.streetLayer = com.conveyal.r5.streets.StreetLayer()

# add custom cost datas for custom cost routing
if hasattr(self, "custom_cost_datas") and hasattr(
self, "add_custom_cost_data_to_network"
):
transport_network = self.add_custom_cost_data_to_network(transport_network)

transport_network.streetLayer.loadFromOsm(osm_file)
transport_network.streetLayer.parentNetwork = transport_network
transport_network.streetLayer.indexStreets()

transport_network.transitLayer = com.conveyal.r5.transit.TransitLayer()
for gtfs_file in gtfs:
gtfs_feed = com.conveyal.gtfs.GTFSFeed.readOnlyTempFileFromGtfs(gtfs_file)
transport_network.transitLayer.loadFromGtfs(gtfs_feed)
gtfs_feed.close()
transport_network.transitLayer.parentNetwork = transport_network
if gtfs:
for gtfs_file in gtfs:
gtfs_feed = com.conveyal.gtfs.GTFSFeed.readOnlyTempFileFromGtfs(
gtfs_file
)
transport_network.transitLayer.loadFromGtfs(gtfs_feed)
gtfs_feed.close()
transport_network.transitLayer.parentNetwork = transport_network

transport_network.streetLayer.associateStops(transport_network.transitLayer)
transport_network.streetLayer.buildEdgeLists()
transport_network.streetLayer.associateStops(transport_network.transitLayer)
transport_network.streetLayer.buildEdgeLists()

transport_network.transitLayer.rebuildTransientIndexes()
transport_network.transitLayer.rebuildTransientIndexes()

transfer_finder = com.conveyal.r5.transit.TransferFinder(transport_network)
transfer_finder.findTransfers()
transfer_finder.findParkRideTransfer()
transfer_finder = com.conveyal.r5.transit.TransferFinder(transport_network)
transfer_finder.findTransfers()
transfer_finder.findParkRideTransfer()

transport_network.transitLayer.buildDistanceTables(None)
transport_network.transitLayer.buildDistanceTables(None)

self._transport_network = transport_network

Expand Down
5 changes: 5 additions & 0 deletions src/r5py/r5/travel_time_matrix_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ def _parse_results(self, from_id, results):
if self.request.percentiles == [50]:
od_matrix = od_matrix.rename(columns={"travel_time_p50": "travel_time"})

# add OSM IDs if found in results
# osmIdsResults are generated when routing with custom_cost_transport_network
if hasattr(results, "osmIdResults") and results.osmIdResults is not None:
od_matrix[f"osm_ids"] = results.osmIdResults

# R5’s NULL value is MAX_INT32
od_matrix = self._fill_nulls(od_matrix)

Expand Down
Loading