Skip to content

Commit 17d3828

Browse files
authored
Merge pull request #3911 from hove-io/handimap_connector
Init Handimap connector
2 parents 815b05e + d7c5001 commit 17d3828

File tree

6 files changed

+846
-2
lines changed

6 files changed

+846
-2
lines changed

source/jormungandr/jormungandr/exceptions.py

+7
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ def __init__(self, msg):
134134
self.code = 500
135135

136136

137+
class HandimapTechnicalError(HTTPException):
138+
def __init__(self, msg):
139+
super(HandimapTechnicalError, self).__init__()
140+
self.data = format_error("technical_error", msg)
141+
self.code = 500
142+
143+
137144
class ConfigException(Exception):
138145
def __init__(self, arg):
139146
super(ConfigException, self).__init__(arg)

source/jormungandr/jormungandr/interfaces/v1/Journeys.py

+14
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,20 @@ def __init__(self):
495495
hidden=True,
496496
help="Here, Active or not the realtime traffic information (True/False)",
497497
)
498+
parser_get.add_argument(
499+
"_handimap_language",
500+
type=OptionValue(
501+
[
502+
'english',
503+
'french',
504+
]
505+
),
506+
hidden=True,
507+
help='Handimap, select a specific language for guidance instruction.\n'
508+
'list available:\n'
509+
'- english = english\n'
510+
'- french = french\n',
511+
)
498512
parser_get.add_argument(
499513
"_here_language",
500514
type=OptionValue(

source/jormungandr/jormungandr/street_network/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@
3939
from jormungandr.street_network.taxi import Taxi
4040
from jormungandr.street_network.with_parking import WithParking
4141
from jormungandr.street_network.car_with_park import CarWithPark
42+
from jormungandr.street_network.handimap import Handimap
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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

Comments
 (0)