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