Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.17% covered (success)
96.17%
226 / 235
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SearchTransportConnectionEndpoint
96.17% covered (success)
96.17%
226 / 235
76.92% covered (warning)
76.92%
10 / 13
57
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
 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\CoordinateUtilsTrait;
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    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}