Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 235 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
| SearchTransportConnectionEndpoint | |
0.00% |
0 / 235 |
|
0.00% |
0 / 13 |
3306 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| 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\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 | } |