Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.17% |
226 / 235 |
|
76.92% |
10 / 13 |
CRAP | |
0.00% |
0 / 1 |
SearchTransportConnectionEndpoint | |
96.17% |
226 / 235 |
|
76.92% |
10 / 13 |
57 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
runtimeSetup | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
setTransportApiFetcher | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
handle | |
95.16% |
59 / 62 |
|
0.00% |
0 / 1 |
12 | |||
getConnectionsFromOriginsToDestination | |
97.56% |
40 / 41 |
|
0.00% |
0 / 1 |
10 | |||
getMostPeripheralOriginStations | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
2 | |||
getCenterOfOriginStations | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
processMainConnection | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
6 | |||
getJoiningStationFromConnection | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
shouldUseConnection | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
6 | |||
getNormalizedSuggestion | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
3 | |||
getNormalizedConnection | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
isOriginStation | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace Olz\Apps\Oev\Endpoints; |
4 | |
5 | use Olz\Api\OlzTypedEndpoint; |
6 | use Olz\Apps\Oev\Utils\CoordinateUtilsTrait; |
7 | use Olz\Apps\Oev\Utils\TransportConnection; |
8 | use Olz\Apps\Oev\Utils\TransportSuggestion; |
9 | use Olz\Fetchers\TransportApiFetcher; |
10 | use PhpTypeScriptApi\PhpStan\IsoDateTime; |
11 | |
12 | /** |
13 | * Search for a swiss public transport connection. |
14 | * |
15 | * for further information on the backend used, see |
16 | * https://transport.opendata.ch/docs.html#connections |
17 | * |
18 | * Note: `rating` must be between 0.0 and 1.0 |
19 | * |
20 | * @phpstan-type OlzTransportHalt array{ |
21 | * stationId: non-empty-string, |
22 | * stationName: non-empty-string, |
23 | * time: IsoDateTime, |
24 | * } |
25 | * @phpstan-type OlzTransportSection array{ |
26 | * departure: OlzTransportHalt, |
27 | * arrival: OlzTransportHalt, |
28 | * passList: array<OlzTransportHalt>, |
29 | * isWalk: bool, |
30 | * } |
31 | * @phpstan-type OlzTransportConnection array{ |
32 | * sections: array<OlzTransportSection> |
33 | * } |
34 | * @phpstan-type OlzOriginInfo array{ |
35 | * halt: OlzTransportHalt, |
36 | * isSkipped: bool, |
37 | * rating: float, |
38 | * } |
39 | * @phpstan-type OlzTransportSuggestion array{ |
40 | * mainConnection: OlzTransportConnection, |
41 | * sideConnections: array<array{ |
42 | * connection: OlzTransportConnection, |
43 | * joiningStationId: non-empty-string, |
44 | * }>, |
45 | * originInfo: array<OlzOriginInfo>, |
46 | * debug: string, |
47 | * } |
48 | * |
49 | * @extends OlzTypedEndpoint< |
50 | * array{destination: non-empty-string, arrival: IsoDateTime}, |
51 | * array{status: 'OK'|'ERROR', suggestions?: ?array<OlzTransportSuggestion>}, |
52 | * > |
53 | */ |
54 | class SearchTransportConnectionEndpoint extends OlzTypedEndpoint { |
55 | use CoordinateUtilsTrait; |
56 | |
57 | public const MIN_CHANGING_TIME = 1; // Minimum time to change at same station |
58 | |
59 | /** @var array<array{id: string, name: string, coordinate: array{type: string, x: float, y: float}, weight: float}> */ |
60 | protected array $originStations; |
61 | protected TransportApiFetcher $transportApiFetcher; |
62 | |
63 | /** @var array<string, bool> */ |
64 | protected array $is_origin_station_by_station_id = []; |
65 | |
66 | public function __construct() { |
67 | parent::__construct(); |
68 | $filename = __DIR__.'/../olz_transit_stations.json'; |
69 | $content = file_get_contents($filename) ?: '{}'; |
70 | $data = json_decode($content, true); |
71 | $this->originStations = $data; |
72 | } |
73 | |
74 | public function runtimeSetup(): void { |
75 | $this->setLogger($this->log()); |
76 | $transport_api_fetcher = new TransportApiFetcher(); |
77 | $this->setTransportApiFetcher($transport_api_fetcher); |
78 | } |
79 | |
80 | public function setTransportApiFetcher(TransportApiFetcher $transportApiFetcher): void { |
81 | $this->transportApiFetcher = $transportApiFetcher; |
82 | } |
83 | |
84 | protected function handle(mixed $input): mixed { |
85 | $this->checkPermission('any'); |
86 | |
87 | $destination = $input['destination']; |
88 | $arrival_datetime = $input['arrival']; |
89 | try { |
90 | $all_connections = |
91 | $this->getConnectionsFromOriginsToDestination( |
92 | $destination, |
93 | $arrival_datetime |
94 | ); |
95 | } catch (\Throwable $th) { |
96 | $this->log()->error($th); |
97 | return ['status' => 'ERROR', 'suggestions' => null]; |
98 | } |
99 | |
100 | $suggestions = []; |
101 | foreach ($all_connections as $main_connection) { |
102 | $suggestion = new TransportSuggestion(); |
103 | $suggestion->setMainConnection($main_connection); |
104 | |
105 | $result = $this->processMainConnection($main_connection); |
106 | // For each station on the main connection, the departure time: |
107 | $latest_joining_time_by_station_id = $result['latest_joining_time_by_station_id']; |
108 | // For each origin station, the departure time using the main connection: |
109 | $latest_departure_by_station_id = $result['latest_departure_by_station_id']; |
110 | |
111 | $suggestion->addDebug("Latest joining time by station id:"); |
112 | $suggestion->addDebug(json_encode($latest_joining_time_by_station_id, JSON_PRETTY_PRINT) ?: '{}'); |
113 | $suggestion->addDebug("Latest departure time by station id:"); |
114 | $suggestion->addDebug(json_encode($latest_departure_by_station_id, JSON_PRETTY_PRINT) ?: '{}'); |
115 | |
116 | foreach (array_reverse($all_connections) as $connection) { |
117 | $joining_station_id = $this->getJoiningStationFromConnection( |
118 | $connection, |
119 | $latest_joining_time_by_station_id, |
120 | $latest_departure_by_station_id |
121 | ); |
122 | |
123 | if ($joining_station_id !== null) { |
124 | $result = $this->shouldUseConnection( |
125 | $connection, |
126 | $joining_station_id, |
127 | $latest_departure_by_station_id |
128 | ); |
129 | $use_this_connection = $result['use_this_connection']; |
130 | if ($use_this_connection) { |
131 | $latest_departure_by_station_id = |
132 | $result['latest_departure_by_station_id']; |
133 | $side_connection = [ |
134 | 'connection' => $connection, |
135 | 'joiningStationId' => $joining_station_id, |
136 | ]; |
137 | $suggestion->addSideConnection($side_connection); |
138 | } |
139 | } |
140 | } |
141 | |
142 | $origin_info = $suggestion->getOriginInfo(); |
143 | $suggestion->setOriginInfo($origin_info); |
144 | foreach ($origin_info as $station_info) { |
145 | $station_name = $station_info['halt']['stationName']; |
146 | $rating = $station_info['rating']; |
147 | $suggestion->addDebug("Station info {$station_name} {$rating}"); |
148 | } |
149 | |
150 | $missing_stations_covered = []; |
151 | foreach ($latest_departure_by_station_id as $station_id => $latest_departure) { |
152 | if ($latest_departure === 0) { |
153 | $missing_stations_covered[] = $station_id; |
154 | } |
155 | } |
156 | if (count($missing_stations_covered) === 0) { |
157 | $normalized_suggestion = $this->getNormalizedSuggestion( |
158 | $suggestion, |
159 | $latest_departure_by_station_id |
160 | ); |
161 | $suggestions[] = $normalized_suggestion->getFieldValue(); |
162 | } else { |
163 | $missing_station_ids_covered = implode(', ', $missing_stations_covered); |
164 | $this->log()->info("Suggestion omitted: {$suggestion->getPrettyPrint()}\n\nMissing stations: {$missing_station_ids_covered}"); |
165 | } |
166 | } |
167 | |
168 | // @phpstan-ignore-next-line |
169 | return ['status' => 'OK', 'suggestions' => $suggestions]; |
170 | } |
171 | |
172 | /** @return array<TransportConnection> */ |
173 | protected function getConnectionsFromOriginsToDestination( |
174 | string $destination, |
175 | \DateTime $arrival_datetime, |
176 | ): array { |
177 | $most_peripheral_stations = $this->getMostPeripheralOriginStations(); |
178 | $arrival_date = $arrival_datetime->format('Y-m-d'); |
179 | $arrival_time = $arrival_datetime->format('H:i'); |
180 | |
181 | $connections_by_origin_station_id = []; |
182 | // For each station ID stores whether it has already been covered by |
183 | // another connection. |
184 | $is_covered_by_station_id = []; |
185 | foreach ($most_peripheral_stations as $station) { |
186 | $station_id = $station['id']; |
187 | if ($is_covered_by_station_id[$station_id] ?? false) { |
188 | continue; |
189 | } |
190 | $connection_response = $this->transportApiFetcher->fetchConnection([ |
191 | 'from' => $station_id, |
192 | 'to' => $destination, |
193 | 'date' => $arrival_date, |
194 | 'time' => $arrival_time, |
195 | 'isArrivalTime' => 1, |
196 | ]); |
197 | $api_connections = $connection_response['connections'] ?? null; |
198 | if ($api_connections === null) { |
199 | throw new \Exception('Request to transport API failed'); |
200 | } |
201 | foreach ($api_connections as $api_connection) { |
202 | $connection = TransportConnection::fromTransportApi($api_connection); |
203 | $halts = $connection->getFlatHalts(); |
204 | foreach ($halts as $halt) { |
205 | $halt_station_id = $halt->getStationId(); |
206 | $is_covered_by_station_id[$halt_station_id] = true; |
207 | $connections_from_halt = |
208 | $connections_by_origin_station_id[$halt_station_id] ?? []; |
209 | $relevant_connections_from_halt = []; |
210 | foreach ($connections_from_halt as $connection_from_halt) { |
211 | if (!$connection->isSuperConnectionOf($connection_from_halt)) { |
212 | $relevant_connections_from_halt[] = $connection_from_halt; |
213 | } |
214 | } |
215 | $connections_by_origin_station_id[$halt_station_id] = |
216 | $relevant_connections_from_halt; |
217 | } |
218 | $connections_from_station = $connections_by_origin_station_id[$station_id] ?? []; |
219 | $connections_from_station[] = $connection; |
220 | $connections_by_origin_station_id[$station_id] = $connections_from_station; |
221 | } |
222 | } |
223 | |
224 | $all_connections = []; |
225 | foreach ($connections_by_origin_station_id as $station_id => $connections) { |
226 | foreach ($connections as $connection) { |
227 | $all_connections[] = $connection; |
228 | } |
229 | } |
230 | |
231 | return $all_connections; |
232 | } |
233 | |
234 | /** @return array<array{id: string, name: string, coordinate: array{type: string, x: float, y: float}, weight: float}> */ |
235 | protected function getMostPeripheralOriginStations(): array { |
236 | $coord_utils = $this->coordinateUtils(); |
237 | $center_of_stations = $this->getCenterOfOriginStations(); |
238 | $most_peripheral_stations = array_slice($this->originStations, 0); |
239 | usort( |
240 | $most_peripheral_stations, |
241 | function ($station_a, $station_b) use ($coord_utils, $center_of_stations) { |
242 | $station_a_point = [ |
243 | 'x' => $station_a['coordinate']['x'], |
244 | 'y' => $station_a['coordinate']['y'], |
245 | ]; |
246 | $station_b_point = [ |
247 | 'x' => $station_b['coordinate']['x'], |
248 | 'y' => $station_b['coordinate']['y'], |
249 | ]; |
250 | $station_a_dist = $coord_utils->getDistance( |
251 | $station_a_point, |
252 | $center_of_stations |
253 | ); |
254 | $station_b_dist = $coord_utils->getDistance( |
255 | $station_b_point, |
256 | $center_of_stations |
257 | ); |
258 | return $station_a_dist < $station_b_dist ? 1 : -1; |
259 | } |
260 | ); |
261 | return $most_peripheral_stations; |
262 | } |
263 | |
264 | /** @return array{x: int|float, y: int|float} */ |
265 | protected function getCenterOfOriginStations(): array { |
266 | $coord_utils = $this->coordinateUtils(); |
267 | $station_points = array_map(function ($station) { |
268 | return [ |
269 | 'x' => $station['coordinate']['x'], |
270 | 'y' => $station['coordinate']['y'], |
271 | ]; |
272 | }, $this->originStations); |
273 | return $coord_utils->getCenter($station_points); |
274 | } |
275 | |
276 | /** @return array{latest_joining_time_by_station_id: array<string, int>, latest_departure_by_station_id: array<string, int>} */ |
277 | protected function processMainConnection(TransportConnection $main_connection): array { |
278 | $latest_departure_by_station_id = []; |
279 | |
280 | foreach ($this->originStations as $station) { |
281 | $latest_departure_by_station_id[$station['id']] = 0; |
282 | } |
283 | |
284 | $latest_joining_time_by_station_id = []; |
285 | |
286 | $sections = $main_connection->getSections(); |
287 | foreach ($sections as $section) { |
288 | $halts = $section->getHalts(); |
289 | foreach ($halts as $halt) { |
290 | $station_id = $halt->getStationId(); |
291 | $time = $halt->getTimeSeconds() ?? 0; |
292 | if (($latest_joining_time_by_station_id[$station_id] ?? 0) < $time) { |
293 | $latest_joining_time_by_station_id[$station_id] = $time; |
294 | } |
295 | if (isset($latest_departure_by_station_id[$station_id])) { |
296 | $latest_departure_by_station_id[$station_id] = $time; |
297 | } |
298 | } |
299 | } |
300 | |
301 | return [ |
302 | 'latest_joining_time_by_station_id' => $latest_joining_time_by_station_id, |
303 | 'latest_departure_by_station_id' => $latest_departure_by_station_id, |
304 | ]; |
305 | } |
306 | |
307 | /** |
308 | * @param array<int|string, int> $latest_joining_time_by_station_id |
309 | * @param array<string, int> $latest_departure_by_station_id |
310 | */ |
311 | protected function getJoiningStationFromConnection( |
312 | TransportConnection $connection, |
313 | array $latest_joining_time_by_station_id, |
314 | array $latest_departure_by_station_id, |
315 | ): ?string { |
316 | $joining_station_id = null; |
317 | $look_for_joining_station = true; |
318 | $halts = $connection->getFlatHalts(); |
319 | foreach ($halts as $halt) { |
320 | $station_id = $halt->getStationId(); |
321 | $time = $halt->getTimeSeconds(); |
322 | $latest_joining_at_station = |
323 | $latest_joining_time_by_station_id[$station_id] ?? $time; |
324 | $can_join_at_station = |
325 | $latest_joining_at_station > $time + self::MIN_CHANGING_TIME; |
326 | if ($can_join_at_station && $look_for_joining_station) { |
327 | $joining_station_id = $station_id; |
328 | $look_for_joining_station = false; |
329 | } |
330 | $is_unserved_origin_station = |
331 | ($latest_departure_by_station_id[$station_id] ?? null) === 0; |
332 | if ($is_unserved_origin_station) { |
333 | $look_for_joining_station = true; |
334 | } |
335 | } |
336 | return $joining_station_id; |
337 | } |
338 | |
339 | /** |
340 | * @param array<string, int> $latest_departure_by_station_id |
341 | * |
342 | * @return array{use_this_connection: bool, latest_departure_by_station_id: array<string, int>} |
343 | */ |
344 | protected function shouldUseConnection( |
345 | TransportConnection $connection, |
346 | string $joining_station_id, |
347 | array $latest_departure_by_station_id, |
348 | ): array { |
349 | $use_this_connection = false; |
350 | $is_before_joining = true; |
351 | $halts = $connection->getFlatHalts(); |
352 | foreach ($halts as $halt) { |
353 | $station_id = $halt->getStationId(); |
354 | $time = $halt->getTimeSeconds(); |
355 | if ($station_id === $joining_station_id) { |
356 | $is_before_joining = false; |
357 | } |
358 | $does_improve_travel_time = |
359 | ($latest_departure_by_station_id[$station_id] ?? $time) < $time; |
360 | if ($is_before_joining && $time !== null && $does_improve_travel_time) { |
361 | $latest_departure_by_station_id[$station_id] = $time; |
362 | $use_this_connection = true; |
363 | } |
364 | } |
365 | return [ |
366 | 'use_this_connection' => $use_this_connection, |
367 | 'latest_departure_by_station_id' => $latest_departure_by_station_id, |
368 | ]; |
369 | } |
370 | |
371 | /** @param array<string, int> $latest_departure_by_station_id */ |
372 | protected function getNormalizedSuggestion( |
373 | TransportSuggestion $suggestion, |
374 | array $latest_departure_by_station_id, |
375 | ): TransportSuggestion { |
376 | $normalized_suggestion = new TransportSuggestion(); |
377 | |
378 | $normalized_main_connection = $this->getNormalizedConnection( |
379 | $suggestion->getMainConnection(), |
380 | $latest_departure_by_station_id |
381 | ); |
382 | $normalized_suggestion->setMainConnection($normalized_main_connection); |
383 | |
384 | foreach ($suggestion->getSideConnections() as $side_connection) { |
385 | $normalized_connection = $this->getNormalizedConnection( |
386 | $side_connection['connection'], |
387 | $latest_departure_by_station_id |
388 | ); |
389 | $normalized_side_connection = [ |
390 | 'connection' => $normalized_connection, |
391 | 'joiningStationId' => $side_connection['joiningStationId'], |
392 | ]; |
393 | $normalized_suggestion->addSideConnection($normalized_side_connection); |
394 | } |
395 | |
396 | $origin_info = $normalized_suggestion->getOriginInfo(); |
397 | $normalized_suggestion->setOriginInfo($origin_info); |
398 | |
399 | foreach ($suggestion->getDebug() as $line) { |
400 | $normalized_suggestion->addDebug($line); |
401 | } |
402 | |
403 | return $normalized_suggestion; |
404 | } |
405 | |
406 | /** @param array<string, int> $latest_departure_by_station_id */ |
407 | protected function getNormalizedConnection( |
408 | TransportConnection $connection, |
409 | array $latest_departure_by_station_id, |
410 | ): TransportConnection { |
411 | $crop_from_halt = null; |
412 | foreach ($connection->getFlatHalts() as $halt) { |
413 | $station_id = $halt->getStationId(); |
414 | $latest_departure = $latest_departure_by_station_id[$station_id] ?? null; |
415 | if ($latest_departure === null) { |
416 | continue; |
417 | } |
418 | $halt_is_latest_departure = $halt->getTimeSeconds() >= $latest_departure; |
419 | if ($crop_from_halt === null && $halt_is_latest_departure) { |
420 | $crop_from_halt = $halt; |
421 | } |
422 | } |
423 | return $connection->getCropped($crop_from_halt, null); |
424 | } |
425 | |
426 | protected function isOriginStation(string $station_id): bool { |
427 | if (($this->is_origin_station_by_station_id ?? null) === null) { |
428 | $this->is_origin_station_by_station_id = []; |
429 | foreach ($this->originStations as $station) { |
430 | $this->is_origin_station_by_station_id[$station['id']] = true; |
431 | } |
432 | } |
433 | return $this->is_origin_station_by_station_id[$station_id] ?? false; |
434 | } |
435 | } |