|
| 1 | +# Copyright (c) 2001-2023, Hove and/or its affiliates. All rights reserved. |
| 2 | +# |
| 3 | +# This file is part of Navitia, |
| 4 | +# the software to build cool stuff with public transport. |
| 5 | +# |
| 6 | +# Hope you'll enjoy and contribute to this project, |
| 7 | +# powered by Hove (www.hove.com). |
| 8 | +# Help us simplify mobility and open public transport: |
| 9 | +# a non ending quest to the responsive locomotion way of traveling! |
| 10 | +# |
| 11 | +# LICENCE: This program is free software; you can redistribute it and/or modify |
| 12 | +# it under the terms of the GNU Affero General Public License as published by |
| 13 | +# the Free Software Foundation, either version 3 of the License, or |
| 14 | +# (at your option) any later version. |
| 15 | +# |
| 16 | +# This program is distributed in the hope that it will be useful, |
| 17 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 18 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 19 | +# GNU Affero General Public License for more details. |
| 20 | +# |
| 21 | +# You should have received a copy of the GNU Affero General Public License |
| 22 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 23 | +# |
| 24 | +# Stay tuned using |
| 25 | +# twitter @navitia |
| 26 | +# channel `#navitia` on riot https://riot.im/app/#/room/#navitia:matrix.org |
| 27 | +# https://groups.google.com/d/forum/navitia |
| 28 | +# www.navitia.io |
| 29 | + |
| 30 | +# Possible values implemented. Full languages within the doc: |
| 31 | +# https://valhalla.readthedocs.io/en/latest/api/turn-by-turn/api-reference/#supported-language-tags |
| 32 | +# Be careful, the syntax has to be exact |
| 33 | + |
| 34 | +from __future__ import absolute_import, print_function, unicode_literals, division |
| 35 | +import logging |
| 36 | +from enum import Enum |
| 37 | +import pybreaker |
| 38 | +import six |
| 39 | +import requests as requests |
| 40 | +import ujson |
| 41 | +import json |
| 42 | + |
| 43 | +from jormungandr import app |
| 44 | +from jormungandr.street_network.street_network import AbstractStreetNetworkService, StreetNetworkPathKey |
| 45 | +from jormungandr.ptref import FeedPublisher |
| 46 | +from jormungandr.exceptions import HandimapTechnicalError, InvalidArguments, UnableToParse |
| 47 | +from jormungandr.utils import get_pt_object_coord, mps_to_kmph, decode_polyline, kilometers_to_meters |
| 48 | +from navitiacommon import response_pb2 |
| 49 | + |
| 50 | +DEFAULT_HANDIMAP_FEED_PUBLISHER = { |
| 51 | + 'id': 'handimap', |
| 52 | + 'name': 'handimap', |
| 53 | + 'license': 'Private', |
| 54 | + 'url': 'https://www.handimap.fr', |
| 55 | +} |
| 56 | + |
| 57 | + |
| 58 | +class Languages(Enum): |
| 59 | + french = "fr-FR" |
| 60 | + english = "en-EN" |
| 61 | + |
| 62 | + |
| 63 | +class Handimap(AbstractStreetNetworkService): |
| 64 | + def __init__( |
| 65 | + self, |
| 66 | + instance, |
| 67 | + service_url, |
| 68 | + username="", |
| 69 | + password="", |
| 70 | + modes=None, |
| 71 | + id='handimap', |
| 72 | + timeout=10, |
| 73 | + feed_publisher=DEFAULT_HANDIMAP_FEED_PUBLISHER, |
| 74 | + **kwargs |
| 75 | + ): |
| 76 | + self.instance = instance |
| 77 | + self.sn_system_id = id |
| 78 | + if not service_url: |
| 79 | + raise ValueError('service_url {} is not a valid handimap url'.format(service_url)) |
| 80 | + self.service_url = service_url |
| 81 | + self.log = logging.LoggerAdapter( |
| 82 | + logging.getLogger(__name__), extra={'streetnetwork_id': six.text_type(id)} |
| 83 | + ) |
| 84 | + self.auth = (username, password) |
| 85 | + self.headers = {"Content-Type": "application/json", "Accept": "application/json"} |
| 86 | + self.timeout = timeout |
| 87 | + self.modes = modes if modes else ["walking"] |
| 88 | + self.language = self._get_language(kwargs.get('language', "french")) |
| 89 | + self.verify = kwargs.get('verify', True) |
| 90 | + |
| 91 | + self.breaker = pybreaker.CircuitBreaker( |
| 92 | + fail_max=kwargs.get( |
| 93 | + 'circuit_breaker_max_fail', app.config.get('CIRCUIT_BREAKER_MAX_HANDIMAP_FAIL', 4) |
| 94 | + ), |
| 95 | + reset_timeout=kwargs.get( |
| 96 | + 'circuit_breaker_reset_timeout', app.config.get('CIRCUIT_BREAKER_HANDIMAP_TIMEOUT_S', 60) |
| 97 | + ), |
| 98 | + ) |
| 99 | + self._feed_publisher = FeedPublisher(**feed_publisher) if feed_publisher else None |
| 100 | + self.check_handimap_modes(self.modes) |
| 101 | + |
| 102 | + def check_handimap_modes(self, modes): |
| 103 | + if len(modes) != 1 or "walking" not in modes: |
| 104 | + self.log.error('Handimap, mode(s) {} not implemented'.format(modes)) |
| 105 | + raise InvalidArguments('Handimap, mode(s) {} not implemented'.format(modes)) |
| 106 | + |
| 107 | + def status(self): |
| 108 | + return { |
| 109 | + 'id': six.text_type(self.sn_system_id), |
| 110 | + 'class': self.__class__.__name__, |
| 111 | + 'modes': self.modes, |
| 112 | + 'timeout': self.timeout, |
| 113 | + 'circuit_breaker': { |
| 114 | + 'current_state': self.breaker.current_state, |
| 115 | + 'fail_counter': self.breaker.fail_counter, |
| 116 | + 'reset_timeout': self.breaker.reset_timeout, |
| 117 | + }, |
| 118 | + } |
| 119 | + |
| 120 | + def _get_language(self, language): |
| 121 | + try: |
| 122 | + return Languages[language].value |
| 123 | + except KeyError: |
| 124 | + self.log.error( |
| 125 | + 'Handimap parameter language={} is not a valid parameter - language is set to french by default'.format( |
| 126 | + language |
| 127 | + ) |
| 128 | + ) |
| 129 | + return Languages.french.value |
| 130 | + |
| 131 | + def get_language_parameter(self, request): |
| 132 | + language = request.get('_handimap_language', None) |
| 133 | + return self.language if not language else self._get_language(language.lower()) |
| 134 | + |
| 135 | + @staticmethod |
| 136 | + def _make_request_arguments_walking_details(walking_speed, language): |
| 137 | + walking_speed_km = mps_to_kmph(walking_speed) |
| 138 | + return { |
| 139 | + "costing": "walking", |
| 140 | + "costing_options": {"walking": {"walking_speed": walking_speed_km}}, |
| 141 | + "directions_options": {"units": "kilometers", "language": language}, |
| 142 | + } |
| 143 | + |
| 144 | + @staticmethod |
| 145 | + def _format_coord(pt_object): |
| 146 | + coord = get_pt_object_coord(pt_object) |
| 147 | + return {"lat": coord.lat, "lon": coord.lon} |
| 148 | + |
| 149 | + @classmethod |
| 150 | + def _make_request_arguments_direct_path(cls, origin, destination, walking_speed, language): |
| 151 | + walking_details = cls._make_request_arguments_walking_details(walking_speed, language) |
| 152 | + params = { |
| 153 | + 'locations': [ |
| 154 | + cls._format_coord(origin), |
| 155 | + cls._format_coord(destination), |
| 156 | + ] |
| 157 | + } |
| 158 | + params.update(walking_details) |
| 159 | + return params |
| 160 | + |
| 161 | + def matrix_payload(self, origins, destinations, walking_speed, language): |
| 162 | + |
| 163 | + walking_details = self._make_request_arguments_walking_details(walking_speed, language) |
| 164 | + |
| 165 | + params = { |
| 166 | + "sources": [self._format_coord(o) for o in origins], |
| 167 | + "targets": [self._format_coord(d) for d in destinations], |
| 168 | + } |
| 169 | + params.update(walking_details) |
| 170 | + |
| 171 | + return params |
| 172 | + |
| 173 | + def post_matrix_request(self, origins, destinations, walking_speed, language): |
| 174 | + post_data = self.matrix_payload(origins, destinations, walking_speed, language) |
| 175 | + response = self._call_handimap( |
| 176 | + 'sources_to_targets', |
| 177 | + post_data, |
| 178 | + ) |
| 179 | + return self.check_response_and_get_json(response) |
| 180 | + |
| 181 | + def make_path_key(self, mode, orig_uri, dest_uri, streetnetwork_path_type, period_extremity): |
| 182 | + """ |
| 183 | + :param orig_uri, dest_uri, mode: matters obviously |
| 184 | + :param streetnetwork_path_type: whether it's a fallback at |
| 185 | + the beginning, the end of journey or a direct path without PT also matters especially for car (to know if we |
| 186 | + park before or after) |
| 187 | + :param period_extremity: is a PeriodExtremity (a datetime and it's meaning on the |
| 188 | + fallback period) |
| 189 | + Nota: period_extremity is not taken into consideration so far because we assume that a |
| 190 | + direct path from A to B remains the same even the departure time are different (no realtime) |
| 191 | + """ |
| 192 | + return StreetNetworkPathKey(mode, orig_uri, dest_uri, streetnetwork_path_type, None) |
| 193 | + |
| 194 | + def _create_matrix_response(self, json_response, origins, destinations, max_duration): |
| 195 | + sources_to_targets = json_response.get('sources_to_targets', []) |
| 196 | + sn_routing_matrix = response_pb2.StreetNetworkRoutingMatrix() |
| 197 | + row = sn_routing_matrix.rows.add() |
| 198 | + for i_o in range(len(origins)): |
| 199 | + for i_d in range(len(destinations)): |
| 200 | + sources_to_target = sources_to_targets[i_o][i_d] |
| 201 | + duration = int(round(sources_to_target["time"])) |
| 202 | + routing = row.routing_response.add() |
| 203 | + if duration <= max_duration: |
| 204 | + routing.duration = duration |
| 205 | + routing.routing_status = response_pb2.reached |
| 206 | + else: |
| 207 | + routing.duration = -1 |
| 208 | + routing.routing_status = response_pb2.unreached |
| 209 | + return sn_routing_matrix |
| 210 | + |
| 211 | + def check_content_response(self, json_respons, origins, destinations): |
| 212 | + len_origins = len(origins) |
| 213 | + len_destinations = len(destinations) |
| 214 | + sources_to_targets = json_respons.get("sources_to_targets", []) |
| 215 | + check_content = (len_destinations == len(resp) for resp in sources_to_targets) |
| 216 | + if len_origins != len(sources_to_targets) or not all(check_content): |
| 217 | + self.log.error('Handimap nb response != nb requested') |
| 218 | + raise UnableToParse('Handimap nb response != nb requested') |
| 219 | + |
| 220 | + def _get_street_network_routing_matrix( |
| 221 | + self, instance, origins, destinations, street_network_mode, max_duration, request, request_id, **kwargs |
| 222 | + ): |
| 223 | + walking_speed = request["walking_speed"] |
| 224 | + language = self.get_language_parameter(request) |
| 225 | + resp_json = self.post_matrix_request(origins, destinations, walking_speed, language) |
| 226 | + |
| 227 | + self.check_content_response(resp_json, origins, destinations) |
| 228 | + return self._create_matrix_response(resp_json, origins, destinations, max_duration) |
| 229 | + |
| 230 | + def _direct_path( |
| 231 | + self, |
| 232 | + instance, |
| 233 | + mode, |
| 234 | + pt_object_origin, |
| 235 | + pt_object_destination, |
| 236 | + fallback_extremity, |
| 237 | + request, |
| 238 | + direct_path_type, |
| 239 | + request_id, |
| 240 | + ): |
| 241 | + self.check_handimap_modes([mode]) |
| 242 | + walking_speed = request["walking_speed"] |
| 243 | + language = self.get_language_parameter(request) |
| 244 | + |
| 245 | + params = self._make_request_arguments_direct_path( |
| 246 | + pt_object_origin, pt_object_destination, walking_speed, language |
| 247 | + ) |
| 248 | + |
| 249 | + response = self._call_handimap('route', params) |
| 250 | + json_response = self.check_response_and_get_json(response) |
| 251 | + return self._get_response(json_response, pt_object_origin, pt_object_destination, fallback_extremity) |
| 252 | + |
| 253 | + @staticmethod |
| 254 | + def _get_response(json_response, pt_object_origin, pt_object_destination, fallback_extremity): |
| 255 | + ''' |
| 256 | + :param fallback_extremity: is a PeriodExtremity (a datetime and it's meaning on the fallback period) |
| 257 | + ''' |
| 258 | + resp = response_pb2.Response() |
| 259 | + resp.status_code = 200 |
| 260 | + resp.response_type = response_pb2.ITINERARY_FOUND |
| 261 | + handimap_trip = json_response["trip"] |
| 262 | + journey = resp.journeys.add() |
| 263 | + journey.duration = int(round(handimap_trip['summary']["time"])) |
| 264 | + datetime, represents_start_fallback = fallback_extremity |
| 265 | + if represents_start_fallback: |
| 266 | + journey.departure_date_time = datetime |
| 267 | + journey.arrival_date_time = datetime + journey.duration |
| 268 | + else: |
| 269 | + journey.departure_date_time = datetime - journey.duration |
| 270 | + journey.arrival_date_time = datetime |
| 271 | + journey.durations.total = journey.duration |
| 272 | + journey.durations.walking = journey.duration |
| 273 | + |
| 274 | + journey.distances.walking = kilometers_to_meters(handimap_trip['summary']["length"]) |
| 275 | + |
| 276 | + previous_section_endtime = journey.departure_date_time |
| 277 | + for index, handimap_leg in enumerate(handimap_trip['legs']): |
| 278 | + section = journey.sections.add() |
| 279 | + section.type = response_pb2.STREET_NETWORK |
| 280 | + section.duration = int(round(handimap_leg["summary"]['time'])) |
| 281 | + section.begin_date_time = previous_section_endtime |
| 282 | + section.end_date_time = section.begin_date_time + section.duration |
| 283 | + previous_section_endtime = section.end_date_time |
| 284 | + |
| 285 | + section.id = 'section_{}'.format(index) |
| 286 | + section.length = kilometers_to_meters(handimap_leg["summary"]['length']) |
| 287 | + |
| 288 | + section.street_network.duration = section.duration |
| 289 | + section.street_network.length = section.length |
| 290 | + section.street_network.mode = response_pb2.Walking |
| 291 | + for handimap_instruction in handimap_leg['maneuvers']: |
| 292 | + path_item = section.street_network.path_items.add() |
| 293 | + if handimap_instruction.get("street_names", []): |
| 294 | + path_item.name = handimap_instruction["street_names"][0] |
| 295 | + path_item.instruction = handimap_instruction["instruction"] |
| 296 | + path_item.length = kilometers_to_meters(handimap_instruction["length"]) |
| 297 | + path_item.duration = int(round(handimap_instruction["time"])) |
| 298 | + shape = decode_polyline(handimap_leg['shape']) |
| 299 | + for sh in shape: |
| 300 | + section.street_network.coordinates.add(lon=sh[0], lat=sh[1]) |
| 301 | + journey.sections[0].origin.CopyFrom(pt_object_origin) |
| 302 | + journey.sections[-1].destination.CopyFrom(pt_object_destination) |
| 303 | + |
| 304 | + return resp |
| 305 | + |
| 306 | + def _call_handimap(self, api, data): |
| 307 | + self.log.debug('Handimap routing service , call url : {}'.format(self.service_url)) |
| 308 | + try: |
| 309 | + return self.breaker.call( |
| 310 | + requests.post, |
| 311 | + '{url}/{api}'.format(url=self.service_url, api=api), |
| 312 | + data=json.dumps(data), |
| 313 | + timeout=self.timeout, |
| 314 | + auth=self.auth, |
| 315 | + headers=self.headers, |
| 316 | + verify=self.verify, |
| 317 | + ) |
| 318 | + except pybreaker.CircuitBreakerError as e: |
| 319 | + self.log.error('Handimap routing service unavailable (error: {})'.format(str(e))) |
| 320 | + self.record_external_failure('circuit breaker open') |
| 321 | + raise HandimapTechnicalError('Handimap routing service unavailable, Circuit breaker is open') |
| 322 | + except requests.Timeout as t: |
| 323 | + self.log.error('Handimap routing service unavailable (error: {})'.format(str(t))) |
| 324 | + self.record_external_failure('timeout') |
| 325 | + raise HandimapTechnicalError('Handimap routing service unavailable: Timeout') |
| 326 | + except Exception as e: |
| 327 | + self.log.exception('Handimap routing error: {}'.format(str(e))) |
| 328 | + self.record_external_failure(str(e)) |
| 329 | + raise HandimapTechnicalError('Handimap routing has encountered unknown error') |
| 330 | + |
| 331 | + def check_response_and_get_json(self, response): |
| 332 | + if response.status_code != 200: |
| 333 | + self.log.error('Handimap service unavailable, response code: {}'.format(response.status_code)) |
| 334 | + raise HandimapTechnicalError('Handimap service unavailable, impossible to query') |
| 335 | + try: |
| 336 | + return ujson.loads(response.text) |
| 337 | + except Exception as e: |
| 338 | + msg = 'Handimap unable to parse response, error: {}'.format(str(e)) |
| 339 | + self.log.error(msg) |
| 340 | + raise UnableToParse(msg) |
| 341 | + |
| 342 | + def feed_publisher(self): |
| 343 | + return self._feed_publisher |
0 commit comments