Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 237 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
SearchTransportConnectionEndpoint | |
0.00% |
0 / 237 |
|
0.00% |
0 / 14 |
3422 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
configure | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
runtimeSetup | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
setTransportApiFetcher | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
handle | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
156 | |||
getConnectionsFromOriginsToDestination | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
110 | |||
getMostPeripheralOriginStations | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
6 | |||
getCenterOfOriginStations | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
processMainConnection | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
getJoiningStationFromConnection | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
shouldUseConnection | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
getNormalizedSuggestion | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
getNormalizedConnection | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
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\CoordinateUtils; |
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 | 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 | } |