diff --git a/.github/docs/images/readme-header.png b/.github/docs/images/readme-header.png new file mode 100644 index 0000000..f088b96 Binary files /dev/null and b/.github/docs/images/readme-header.png differ diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml new file mode 100644 index 0000000..0e9ed6f --- /dev/null +++ b/.github/workflows/ci-workflow.yml @@ -0,0 +1,39 @@ +name: Stand Status CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + schedule: + - cron: "0 12 1 * *" + +jobs: + build: + name: CI on PHP Version ${{ matrix.php_versions }} + runs-on: ubuntu-latest + + strategy: + matrix: + php_versions: ['7.2', '7.3', '7.4'] + + steps: + - name: Checkout Repo + uses: actions/checkout@v2 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php_versions }} + + - name: Verify PHP Version + run: php -v + + - name: Validate composer.json and composer.lock + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run PHPUNIT + run: vendor/bin/phpunit --coverage-text diff --git a/.gitignore b/.gitignore index 7579f74..326be2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ vendor composer.lock +.idea +build +.phpunit.result.cache +storage/data/*.csv \ No newline at end of file diff --git a/README.md b/README.md index 921670d..4b38bcb 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,47 @@ -# vatsim-stand-status +# VATSIM Stand Status ![Stand Status CI](https://github.com/atoff/vatsim-stand-status/workflows/Stand%20Status%20CI/badge.svg) + +![Stand Status Header Image](.github/docs/images/readme-header.png) + +## Table of Contents + +* [About](#about) +* [Installation](#installation) +* [Configuration](#configuration) +* [Usage](#usage) + + [Construct an Instance](#construct-an-instance) + + [Loading Stand Data](#loading-stand-data) + - [1. Loading from a CSV](#1-loading-from-a-csv) + - [2. Loading from an Array](#2-loading-from-an-array) + - [3. Loading from OpenStreetMap (OSM)](#3-loading-from-openstreetmap) + + [Parsing the Data](#parsing-the-data) +* [Data Types](#data-types) +* [Examples](#examples) + * [Constructing the library with a CSV data file](#constructing-the-library-with-a-csv-data-file) + * [Constructing the library with a stand data array](#constructing-the-library-with-a-stand-data-array) + * [Getting all the stands](#getting-stands) + * [Getting all occupied stands](#getting-all-occupied-stands) + * [Get all aircraft on the ground](#get-all-aircraft-on-the-ground) + + ## About -#### Description -vatsim-stand-status is a fairly lightweight library to allow correlation of aircraft on the VATSIM network with known airport stand coordinates. +###### Description +vatsim-stand-status is a tested, lightweight PHP library to allow the correlation between aircraft on the VATSIM flight simulation network and an airport stand. -Data is retrieved from the offical VATSIM network data sources through the use of [Skymeyer's Vatsimphp](https://github.com/skymeyer/Vatsimphp) libaray. +VATSIM network data is downloaded and parsed by [Skymeyer's Vatsimphp](https://github.com/skymeyer/Vatsimphp) library. -#### Requirements -* PHP 5.3.29 and above (For skymeyer/Vatsimphp) +###### Requirements +* PHP 7.2 and above -#### Author +###### Author This package was created by [Alex Toff](https://alextoff.uk) -#### License -vatsim-stand-status is licensed under the GNU General Public License v3.0, which can be found in the root of the package in the `LICENSE` file. +###### License +`vatsim-stand-status` is licensed under the GNU General Public License v3.0, which can be found in the root of the package in the `LICENSE` file. + + ## Installation @@ -24,170 +50,259 @@ The easiest way to install stand status is through the use of composer: $ composer require cobaltgrid/vatsim-stand-status ``` -## Configuration -You can configure various variables using the setters in the `StandStatus` class file. Once changing one (or many) of these values, you must run `$StandStatus->parseData()` so that the data is reloaded with the new settings. To be more efficient, set the `$parseData` argument in the constructor to false, set your new config settings, and then parse the data. Note: the setters here all return the class instance, so you can chain them together. +> New to Composer? [Here is a useful guide on getting started](https://www.codementor.io/@jadjoubran/php-tutorial-getting-started-with-composer-8sbn6fb6t) +## Configuration + +Stand Status's options are easily configurable through a series of setters on the base class. After changing the settings, make sure to run `$standStatus->parseData()` to (re)run the correlation algorithm. + +Note that these are fluent setters, so you can chain them together. + +Below are the available options. Prefix with get/set depending on what you want to change: + +* **maxStandDistance** + * This is the maximum an aircraft can be from a stand (in km) for that stand to be considered in the search for matching aircraft with stand + * Default: `0.07` km + +* **hideStandSidesWhenOccupied** + * If true, stand sides (such as 42L and 42R) will be hidden when an aircraft occupies the 'base' stand, or a side. e.g. if an aircraft is occupying stand 42, the stands 42L and 42R will be removed from the list of stands + * Default: `true` + +* **maxDistanceFromAirport** + * Aircraft filter. Aircraft that are within this distance from the defined center of the airport will be eligible for stand assignment. + * Default: `2` km + +* **maxAircraftAltitude** + * Aircraft filter. Aircraft below this altitude will be eligible for stand assignment. + * Default: `3000` ft + +* **maxAircraftGroundspeed** + * Aircraft filter. Aircraft below this ground speed will be eligible for stand assignment. + * Default: `10` kts + +* **standExtensions** + * An array that defines the characters to indicate side stands. + * Default: `['L', 'C', 'R', 'A', 'B', 'N', 'E', 'S', 'W']` + +* **standExtensionPattern** + * A string that describes the pattern for stand names. **MUST** contain `` and ``. Default will work for any stand following the format 42L, 42R, 155, etc. + * Default: `''` + +#### Example +```php + // Sets the maximum allowed altitude to 4000ft, and searches aircraft within 5km of defined centre + $standStatus->setMaxAircraftAltitude(4000)->setMaxStandDistance(5)->parseData(); ``` - private $maxStandDistance = 0.07; // In kilometeres -``` -* This is the maximum an aircraft can be from a stand (in km) for that stand to be considered in the search for matching aircraft with stand -* Getter: `$StandStatus->getMaxStandDistance()` -* Setter: `$StandStatus->setMaxStandDistance($distance)` -``` - private $hideStandSidesWhenOccupied = true; -``` -* If true, stand sides (such as 42L and 42R) will be hidden when an aircraft occupies the 'base' stand, or a side. I.E If the system determines the aircraft is occupying stand 42, the stands 42L and 42R will be removed from the list of stands -* Getter: `$StandStatus->getHideStandSidesWhenOccupied()` -* Setter: `$StandStatus->setHideStandSidesWhenOccupied($bool)` -``` - private $maxDistanceFromAirport = 2; // In kilometeres -``` -Note: There is no need to manually override this value here, as you can pass a value to the constructor instead. -* This is one of the first checks used to filter down the global VATSIM pilots into possible pilots. Aircraft that are within this distance from the defined center of the airport will be used in later filtering. -* Getter: `$StandStatus->getMaxDistanceFromAirport()` -* Setter: `$StandStatus->setMaxDistanceFromAirport($distance)` -``` - private $maxAircraftAltitude = 3000; // In feet + +## Usage + +There are 3 steps you need to take in order to get this library working: + +### Construct an Instance + +If you have installed via composer, include the autoloader: ``` -* This value is used in a check that ensures that possible aircraft are at or below this altitude. -* Getter: `$StandStatus->getMaxAircraftAltitude()` -* Setter: `$StandStatus->setMaxAircraftAltitude($altitude)` + require('./vendor/autoload.php'); + use CobaltGrid\VatsimStandStatus\StandStatus; ``` - private $maxAircraftGroundspeed = 10; // In knots + + +Then, an instance of the class must be made: ``` -* This value is used in a check that ensures that possible aircraft are at or below this ground speed. -* Getter: `$StandStatus->getMaxAircraftGroundspeed()` -* Setter: `$StandStatus->setMaxAircraftGroundspeed($speed)` +$StandStatus = new StandStatus( + $airportLatitude, + $airportLongitude, + $standCoordinateFormat = self::COORD_FORMAT_DECIMAL +); ``` - private $standExtensions = array("L", "C", "R", "A", "B"); + +##### Required +* `$airportLatitude` - The decimal-format version of the airport's latitude. e.g. 51.148056 +* `$airportLongitude` - The decimal-format version of the airport's longitude. e.g. -0.190278 +##### Optional +* `$standCoordinateFormat` - Sets the format of coordinates in the stand data file. Defaults to decimal. + * Use `StandStatus::COORD_FORMAT_DECIMAL` for decimal coordinates (-51.26012) + * Use `StandStatus::COORD_FORMAT_CAA` for CAA / Aerospace Coordinate format (510917.35N) + +Here is an example: +```php + use CobaltGrid\VatsimStandStatus\StandStatus; + + $StandStatus = new StandStatus( + 51.148056, + -0.190278, + StandStatus::COORD_FORMAT_CAA + ); ``` -* ___TLDR; Not implemented.___ These letters are possible extensions for 'base' stands. Many airports, such as Gatwick, have stands on the side of a main stand, usually used for aircraft that do not require the full width. For example, stand 53 comprises of 53, 53L and 53R. You can add more extensions here. Not implemented yet, however. -* Getter: `$StandStatus->getStandExtensions()` -* Setter: `$StandStatus->setStandExtensions($standArray)` +### Loading Stand Data -## Usage +After constructing the instance, you must load in the stand data for the airport. + +There are currently 3 ways of getting stand data into StandStatus. + +#### 1. Loading from a CSV + +Stand data can be read into the library via a CSV file, an example of which can be found in `tests/Fixtures/SampleData/egkkstands.csv`. -#### The CSV File -The CSV file currently has to follow an exact format. A couple of examples of these files can be found in examples/standData +You can load in a CSV file's data like so: +```php + $standStatus->loadStandDataFromCSV('path/to/data.csv'); +``` + +The first row can be used for headers if you so wish. + + The order of the data should be `ID, Latitude, Longitude`. -The first row is reserved for headers. They should be `id`, `latcoord` and `longcoord` (I have not tested using other headers) +* In the ID column is the stand name. This can be text, such as "42L", and doesn't just have to be a number. +* In the Latitude and Longitude columns should be the stand's latitude and longitude coordinate respectively. Current supported formats are: + * Decimal (Default) - e.g. 51.0100 by -1.12000 + * CAA / Aerospace - e.g. 510917.35N -* In the `id` column is the stand name. This can be text, such as "42L", and doesn't just have to be a number. -* In the `latcoord` column as current, you __MUST__ have the weird latitude format the the CAA uses on their stand data documents, as the program is currently hardcoded to convert these into the normal decimal coordinates. (e.g 510917.35N) -* In the `longcoord` column, just like the `latcoord` column, you __MUST__ have the weird latitude format the the CAA uses on their stand data documents (e.g 0000953.33W) -*** -If you would like to code a fix for this, feel free to submit a PR for it :) -*** +If your stand data file uses anything other than the default, you must specify this when constructing the instance (See above section) -In the end, you should have a CSV file that looks something like this: +In the end, you should have a CSV file that looks something like this (For a CAA / Aerospace format): -| id | latcoord | longcoord | +| id | latitude | longitude | | ------------- |:-------------:| :----: | -| 1 | 10917.35N | 0000953.33W | +| 1 | 510917.35N | 0000953.33W | | 2 | 510915.83N | 0000952.81W | | 3 | 510914.31N | 0000952.28W | -#### The construction - -The library is used by first (a) using the composer class autoloader and then 'using' the class `use CobaltGrid\VatsimStandStatus\StandStatus;` or (b) using require/include to import the class into the current file `require_once('...../src/StandStatus.php')` +#### 2. Loading from an Array -Then, an instance of the class must be made: -``` -$StandStatus = new StandStatus($airportICAO, $airportStandsFile, $airportLatCoordinate, $airportLongCoordinate, $parseData = true, $minAirportDistance = null); +Alternatively, you can load in stand data through an array that follows the format id, latitude, longitude: +```php + $standStatus->loadStandDataFromArray([ + ['1', '510917.35N', '0000953.33W'], + ['2', '510915.83N', '0000952.81W'], + ['3', '510914.31N', '0000952.28W'], + ... + ]); ``` -* `$airportICAO` - The 4 letter airport ICAO code. No real use as of yet. -* `$airportStandsFile` - The absolute path to the CSV file you should have created. -* `$airportLatCoordinate` - The decimal-format version of the airports latitude. E.G 51.148056 -* `$airportLongCoordinate` - The decimal-format version of the airports longitude. E.G -0.190278 -* `$parseData` - Boolean. Sets whether or not the data should be parsed immediately. Set to false when you plan on changing the default variables in "Configuration" -* `$maxAirportDistance ` - The maximum distance filtered aircraft can be from the airport coordinates in kilometers. -Here is an example: +Again, make sure you set the correct stand coordinate format in the constructor. -`$StandStatus = new StandStatus("EGKK", dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278, 3);` +#### 3. Loading from OpenStreetMap -Once this step has done, the data file is downloaded and processed, and stands with aircraft close enough to them, and that fit within the requirements, are marked as occupied by the associated aircraft. All of the aircraft's data is assigned to the stand, allowing you to access many variables from the network data: -``` -callsign:cid:realname:clienttype:frequency:latitude:longitude:altitude:groundspeed:planned_aircraft:planned_tascruise:planned_depairport:planned_altitude:planned_destairport:server:protrevision:rating:transponder:facilitytype:visualrange:planned_revision:planned_flighttype:planned_deptime:planned_actdeptime:planned_hrsenroute:planned_minenroute:planned_hrsfuel:planned_minfuel:planned_altairport:planned_remarks:planned_route:planned_depairport_lat:planned_depairport_lon:planned_destairport_lat:planned_destairport_lon:atis_message:time_last_atis_received:time_logon:heading:QNH_iHg:QNH_Mb: -``` -__Possible Stand Array Formats__ +This option leverages the powerful OpenStreetMap (OSM) [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) to attempt to find and download stand data that has been contributed on OSM. Unlike the other two methods, you don't have to do any data-digging at all, and just have to specify the ICAO code of the airport. -If a stand is occupied, you are able to fetch the details of the aircraft by accessing the stands "occupied" index (As stands are passed as an array). Here is an example for an occupied stand: +Some __important__ notes: +* OSM data is constantly being edited. There is never any guarantee the data is accurate, or that it will stay up to date with changes. You should check the airport your want to use this method for has data available by taking a look on the [OSM web editor](https://www.openstreetmap.org/edit). If it has no / limited data, change it yourself! Contribute to #opensource. +* OSM data is governed by the Open Data Commons Open Database License. The key take-away from this is that you ___must attribute OSM where you use the data___. Check out [their copyright site](https://www.openstreetmap.org/copyright) to find out more. +* The library will cache the data retrieved for an airport when the below method is first run to reduce unnecessary calls to the API. By default, this cache will last for 3 months. +* The library will search for the OSM data type `aeroways=parking_position` located inside the airfield boundary, using either the `ref` or `name` for the stand name. It will also use the latitude and longitude set in the constructor of the library, as well as the `maxDistanceFromAirport` setting to determine the search area bounding box. +* You must ensure the coordinate format set when initalising the instance is `StandStatus::COORD_FORMAT_DECIMAL` (the default) -| id | latcoord | longcoord | occupied | -| ------------- |:-------------:| :----: | :----: | -| 1 | 51.148056 | -0.190278 | array(...) | +To automatically download, cache and load the OpenStreetMap data, run: +```php + $standStatus->fetchAndLoadStandDataFromOSM($icao)->parseData(); -and a unoccupied stand: + // Example for Heathrow + $StandStatus = new StandStatus(51.4775, -0.461389); + $standStatus->fetchAndLoadStandDataFromOSM('EGLL')->parseData(); +``` -| id | latcoord | longcoord | -| ------------- |:-------------:| :----: | -| 1 | 51.148056 | -0.190278 | -#### The use +If the API call fails, or there is an error parsing the data return, exceptions will be thrown. Check the source code to see what you want to catch. -There are 3 main functions that you can use from the `$StandStatus` instance: +### Parsing the Data -___allStands($pageNo = null, $pageLimit = null)___ ->This function returns an array of ALL of the possible stands. All stands from the CSV are returned (with the exception of occupied stand's side stands if enabled), and each stand follows the data format shown under Possible Stand Array Formats. Returns an array of stands. +You must then tell the library to download and parse the VATSIM data to assign aircraft to stands. This is done like so: +```php + $standStatus->parseData(); +``` ->If $pageNo and $pageLimit are specified, only $pageLimit ammount of stands will be returned. Useful for pagination +You can then use the methods to retrieve a list of stands with assigned aircraft, etc. +## Data Types -Usage -``` -foreach ($stand as $StandStatus->allStands()) -{ - if (isset($stand['occupied'])) { - echo "Stand " . $stand['id'] . " is occupied by " . $stand['occupied']['callsign'] . "
"; - }else{ - echo "Stand " . $stand['id'] . " is not occupied
"; - } -} -// Output: -// Stand 1 is occupied by SHT1G -// Stand 2 is not occupied -``` +There are two main object types used: +* `Stand::class` + * Can call the stand's ID, latitude and longitude via properties (e.g. `$stand->latitude`) + * `$stand->occupier` Returns the occupier (a `Aircraft::class` object), or null if none + * `$stand->isOccupied()` Returns a boolean value for if the stand is occupied or not + * `$stand->getRoot()` Gets the root of the stand. e.g. Stand 42R's root is 42 + * `$stand->getExtension()` Gets the extension for the stand, if it has one. e.g. Stand 42R's extension is 'R' + +* `Aircraft::class` + * The following properties are available on the instance (e.g. `$aircraft->callsign`) + >callsign,cid,realname,clienttype,frequency,latitude,longitude,altitude,groundspeed,planned_aircraft,planned_tascruise,planned_depairport,planned_altitude,planned_destairport,server,protrevision,rating,transponder,facilitytype,visualrange,planned_revision,planned_flighttype,planned_deptime,planned_actdeptime,planned_hrsenroute,planned_minenroute,planned_hrsfuel,planned_minfuel,planned_altairport,planned_remarks,planned_route,planned_depairport_lat,planned_depairport_lon,planned_destairport_lat,planned_destairport_lon,atis_message,time_last_atis_received,time_logon,heading,QNH_iHg,QNH_Mb + * `$aircraft->onStand()` Returns a boolean value for if the aircraft is on a stand -___occupiedStands($pageNo = null, $pageLimit = null)___ ->This function returns an array of only the occupied stands (with the exception of occupied stand's side stands if enabled). Returns an array of stands. +## Examples ->If $pageNo and $pageLimit are specified, only $pageLimit ammount of stands will be returned. Useful for pagination +For an integrated usage example, see the Gatwick demo in `examples/egkkStands.php`. -Usage +##### Constructing the library with a CSV data file +```php + use CobaltGrid\VatsimStandStatus\StandStatus; + $standStatus = new StandStatus( + 51.148056, + -0.190278, + StandStatus::COORD_FORMAT_CAA); + $standStatus->loadStandDataFromCSV('path/to/data.csv')->parseData(); ``` -foreach ($stand as $StandStatus->occupiedStands()) -{ - echo "Stand " . $stand['id'] . " is occupied by " . $stand['occupied']['callsign'] . "
"; -} -// Output: -// Stand 1 is occupied by SHT1G + +##### Constructing the library with a stand data array +```php + $standStatus = new \CobaltGrid\VatsimStandStatus\StandStatus( + 51.148056, + -0.190278); + $standStatus->loadStandDataFromArray([ + ['1', 51.154819, -0.164813], + ['10', 51.15509, -0.16466], + ['101', 51.1568, -0.17706] + ])->parseData(); ``` -___allStandsPaginationArray($pageLimit = null)___ ->Kind of WIP. This function will return an array, sliced into pages of $pageLimit, ready for you to access each page. It currently only used the allStands() function. +##### Getting stands +```php + foreach ($standStatus->stands() as $stand){ + if ($stand->isOccupied()) { + echo "Stand {$stand->getName()} is occupied by {$stand->occupier->callsign}
"; + }else{ + echo "Stand {$stand->getName()} is not occupied
"; + } + } + + // Output: + // Stand 1 is occupied by SHT1G + // Stand 2 is not occupied +``` +> Note that the output of `stands()` will hide "side stands" if the `hideStandSidesWhenOccupied` setting is true. If you want to get all stands, including this hidden stands, use `allStands()`. -Usage -``` -$pages = $StandStatus->allStandsPaginationArray(10); +##### Getting all occupied stands -foreach ($stand as $pages[0]) -{ - echo "Stand " . $stand['id'] . "
"; -} -echo "Page 2
"; -foreach ($stand as $pages[1]) -{ - echo "Stand " . $stand['id'] . "
"; -} +```php + foreach ($standStatus->occupiedStands() as $stand){ + echo "Stand {$stand->getName()} is occupied by {$stand->occupier->callsign}
"; + } -// Output: -// Stand 1 -// ..... -// -// Page 2 -// Stand 10 + // Output: + // Stand 1 is occupied by SHT1G + // Stand 3L is occupied by DLH49Y +``` +> If you want an associative array, where the index is the stand name, use `->occupiedStands(true)`. Use can use this on all the methods that return an array of stands. + +>Similarly, you can also use `->unoccupiedStands()` to get an array of unoccupied stands +> +##### Get all aircraft on the ground + +```php + foreach ($standStatus->getAllAircraft() as $aircraft){ + if($aircraft->onStand()){ + $stand = $aircraft->getStand($standStatus->allStands()); + echo "{$aircraft->callsign} is on stand {$stand->getName()}
"; + }else{ + echo "{$aircraft->callsign} is not on stand
"; + } + } + + // Output: + // BAW53M is not on stand + // EZY48VY is on stand 554 ``` +> If you want an associative array, where the index is the stand name, use `->occupiedStands(true)` diff --git a/composer.json b/composer.json index d68976b..fde6780 100644 --- a/composer.json +++ b/composer.json @@ -1,23 +1,40 @@ { - "name": "cobaltgrid/vatsim-stand-status", - "description" : "A PHP library to access the status of aircraft stands on VATSIM", - "keywords" : ["vatsim", "stand", "stands", "flight simulator"], - "homepage" : "https://github.com/atoff/vatsim-stand-status", - "type" : "library", - "license" : "AGPL-3.0-or-later", - "authors" : [ - { - "name" : "Alex Toff", - "email" : "admin@cobaltgrid.com", - "homepage" : "https://www.cobaltgrid.com" - } - ], - "require": { - "skymeyer/vatsimphp": "^2.0" - }, - "autoload": { - "psr-4": { - "CobaltGrid\\VatsimStandStatus\\": "src/" - } + "name": "cobaltgrid/vatsim-stand-status", + "description": "A PHP library to access the status of aircraft stands on VATSIM", + "keywords": [ + "vatsim", + "stand", + "stands", + "flight simulator" + ], + "homepage": "https://github.com/atoff/vatsim-stand-status", + "type": "library", + "license": "AGPL-3.0-or-later", + "authors": [ + { + "name": "Alex Toff", + "email": "alex.toff@cobaltgrid.com", + "homepage": "https://cobaltgrid.com" } + ], + "require": { + "php": ">=7.2", + "skymeyer/vatsimphp": "^2.0", + "guzzlehttp/guzzle": "^6.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5", + "mockery/mockery": "^1.3", + "symfony/var-dumper": "^5.0" + }, + "autoload": { + "psr-4": { + "CobaltGrid\\VatsimStandStatus\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + } } diff --git a/examples/egkkStands.php b/examples/egkkStands.php index 8fc77a8..fbd2696 100644 --- a/examples/egkkStands.php +++ b/examples/egkkStands.php @@ -4,128 +4,142 @@ require_once '../vendor/autoload.php'; -$StandStatus = new StandStatus("EGKK", dirname(__FILE__) . "/standData/egkkstands.csv", 51.148056, -0.190278); - +$StandStatus = new StandStatus(51.148056, -0.190278, StandStatus::COORD_FORMAT_CAA); +$StandStatus->loadStandDataFromCSV(dirname(__FILE__) . "/../tests/Fixtures/SampleData/egkkstands.csv")->parseData(); ?> - - - - - - + + + + + + + + + + + + + -
- - - - - - - - occupiedStands() as $stand){ - ?> - - - - - -
StandOccupied
- - - - - - - - -allStands() as $stand) { - - ?> - - - - - - - -
StandLatLong
- -
-
    - aircraftSearchResults as $pilot){ - echo "
  • " . $pilot['callsign'] . "
  • " . $pilot['latitude'] . " BY " . $pilot['longitude']; - } - ?> -
+ + + + + +
+
+
+
Occupied Stands
+ + + + + + occupiedStands() as $stand) { + ?> + + + + + +
StandOccupied By
getName() ?>occupier->callsign ?>
+
+
+
+
+
+
+
+
All Stands
+ + + + + + + + + + + stands() as $stand) { ?> + + + + + + + + +
StandLatitudeLongitudeOccupier
getName() ?>latitude ?>longitude ?>isOccupied() ? $stand->occupier->callsign : null ?>
+
+
+
Aircraft On The Ground
+
+ getAllAircraft() as $pilot) { + if($pilot->onStand()){ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Stand {$pilot->getStandIndex()})
"; + }else{ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Not on stand)
"; + } + } + ?> +
+
+
+ + diff --git a/examples/egllStands.php b/examples/egllStands.php index 7f94247..c27612a 100644 --- a/examples/egllStands.php +++ b/examples/egllStands.php @@ -1,128 +1,152 @@ setMaxDistanceFromAirport(3)->fetchAndLoadStandDataFromOSM("EGLL")->parseData(); ?> - - - - - + + + - }); - occupiedStands() as $stand){ - ?> - var cityCircle = new google.maps.Circle({ - strokeColor: '#FF0000', - strokeOpacity: 0.8, - strokeWeight: 2, - fillColor: '#FF0000', - fillOpacity: 0.35, - map: map, - center: {lat: - , lng: - }, - radius: 40 - }); - var mapLabel = new MapLabel({ - text: "", - position: new google.maps.LatLng( - , - ), - map: map, - fontSize: 12, - strokeWeight: 2 - }); - - var cityCircle = new google.maps.Circle({ - strokeColor: '#FF0000', - strokeOpacity: 0.8, - strokeWeight: 2, - fillColor: '#FF0000', - fillOpacity: 0.35, - map: map, - center: {lat: 19.505434, lng:-98.919304}, - radius: 100000 - }); + + - }); - -
+ + + - - - - - - occupiedStands as $stand){ - ?> - - - - - + - ?> - - - - - - - -
StandOccupied
stands[$stand]['occupied']['callsign'] ?>
+ + +
+
+
+
Occupied Stands
+ + + + + + occupiedStands() as $stand) { + ?> + + + + + -
    - aircraftSearchResults as $pilot){ - echo "
  • " . $pilot['callsign'] . "
  • " . $pilot['latitude'] . " BY " . $pilot['longitude']; - } - ?> -
+ ?> +
StandOccupied By
getName() ?>occupier->callsign ?>
+
+
+
+
+
+
+
+
All Stands
+ Stand Data © OpenStreetMap Contributors + + + + + + + + + + + stands() as $stand) { ?> + + + + + + + + +
StandLatitudeLongitudeOccupier
getName() ?>latitude ?>longitude ?>isOccupied() ? $stand->occupier->callsign : null ?>
+
+
+
Aircraft On The Ground
+
+ getAllAircraft() as $pilot) { + if($pilot->onStand()){ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Stand {$pilot->getStandIndex()})
"; + }else{ + echo "
{$pilot->callsign} ({$pilot->latitude},{$pilot->longitude}) (Not on stand)
"; + } + } + ?> +
+
+
+ + diff --git a/examples/standData/egllstands.csv b/examples/standData/egllstands.csv deleted file mode 100644 index cb2df1f..0000000 --- a/examples/standData/egllstands.csv +++ /dev/null @@ -1,240 +0,0 @@ -id,latcoord,longcoord -122,512818.92N,0002647.53W -139,512818.14N,0002644.55W -141,512819.47N,0002642.43W -216,512813.31N,0002646.75W -217,512811.87N,0002646.74W -218,512809.72N,0002646.72W -218L,512809.04N,0002646.71W -218R,512810.43N,0002646.72W -219,512806.91N,0002646.69W -220,512805.54N,0002646.68W -221,512804.69N,0002655.73W -221L,512804.69N,0002656.77W -221R,512804.69N,0002654.54W -223,512804.68N,0002659.25W -224,512804.67N,0002701.58W -225,512804.66N,0002704.07W -226,512804.65N,0002706.53W -231,512826.94N,0002641.43W -232,512824.22N,0002641.41W -233,512821.58N,0002641.39W -233L,512822.32N,0002641.39W -233R,512821.04N,0002641.38W -236,512812.62N,0002641.31W -236L,512813.28N,0002641.32W -236R,512811.90N,0002641.31W -238,512809.74N,0002641.29W -239,512807.10N,0002641.27W -241,512807.21N,0002628.72W -242,512809.78N,0002628.74W -243,512812.66N,0002628.76W -243L,512811.99N,0002628.76W -243R,512813.38N,0002628.77W -244,512815.54N,0002628.79W -246,512818.74N,0002628.81W -246L,512818.07N,0002628.81W -246R,512819.46N,0002628.82W -247,512821.62N,0002628.84W -247L,512820.88N,0002628.83W -247R,512822.33N,0002628.84W -248,512824.26N,0002628.86W -249,512826.98N,0002628.88W -251,512807.64N,0002623.30W -252,512810.04N,0002623.32W -253,512812.68N,0002623.34W -253L,512813.34N,0002623.34W -253R,512811.95N,0002623.33W -254,512815.55N,0002623.36W -254L,512816.22N,0002623.36W -254R,512814.83N,0002623.35W -255,512819.07N,0002623.39W -255L,, -255R,, -256,512821.40N,0002623.41W -257,512823.79N,0002623.43W -258,512826.75N,0002623.45W -258L,512827.46N,0002623.46W -258R,512826.01N,0002623.44W -301,512804.68N,0002716.02W -303,512804.66N,0002720.63W -303L,512804.66N,0002721.82W -303R,512804.67N,0002719.63W -305,512804.65N,0002725.24W -305L,512804.64N,0002726.43W -305R,512804.65N,0002724.24W -307,512804.63N,0002729.85W -309,512807.81N,0002735.03W -311,512809.02N,0002736.35W -313,512807.06N,0002739.59W -316,512805.70N,0002750.57W -317,512807.74N,0002752.80W -318,512812.15N,0002744.72W -319,512809.77N,0002755.03W -320,512814.18N,0002746.95W -321,512811.81N,0002757.26W -322,512816.22N,0002749.18W -323,512813.32N,0002740.59W -325,512815.19N,0002742.65W -326,512823.20N,0002738.90W -327,512817.06N,0002744.70W -328,512825.06N,0002740.95W -329,512818.93N,0002746.74W -330,512826.92N,0002742.99W -331,512820.80N,0002748.79W -332,512829.17N,0002745.61W -334,512829.16N,0002750.34W -335,512821.96N,0002751.76W -336,512829.14N,0002754.38W -340,512827.05N,0002759.97W -340L,512827.88N,0002800.28W -340R,512825.70N,0002759.53W -342,512823.44N,0002759.51W -351,512824.88N,0002734.02W -353,512826.39N,0002736.96W -355,512828.08N,0002738.81W -357,, -357L,, -357R,, -363,512813.92N,0002759.54W -364,512818.25N,0002751.42W -365,512816.79N,0002759.71W -401,512728.14N,0002653.89W -402,512727.05N,0002657.03W -403,512725.90N,0002700.26W -405,512731.71N,0002706.24W -406,512733.86N,0002703.17W -407,512735.89N,0002700.27W -408,512737.78N,0002657.57W -409,512739.66N,0002654.88W -410,512741.54N,0002652.20W -410L,, -410R,, -411,512743.42N,0002649.50W -412,512745.25N,0002646.89W -414,512744.71N,0002635.41W -415,512742.97N,0002636.62W -416,512742.79N,0002636.74W -417,512741.78N,0002637.43W -419,512740.50N,0002638.32W -420,512739.32N,0002639.12W -421,512737.59N,0002640.31W -422,512736.69N,0002636.06W -423,512738.84N,0002634.57W -424,512741.00N,0002633.08W -425,512743.20N,0002631.57W -429,512744.06N,0002625.51W -430,512741.85N,0002624.32W -431,512741.87N,0002620.43W -432,512741.73N,0002616.54W -440,512737.75N,0002704.58W -441,512739.57N,0002701.98W -449,512730.12N,0002714.35W -450,512730.34N,0002714.34W -451,512732.54N,0002714.27W -452,512732.77N,0002714.26W -453,512735.19N,0002714.18W -454,512738.95N,0002715.12W -455,512740.95N,0002717.32W -456,512742.95N,0002719.51W -461,512728.28N,0002709.74W -601,512746.11N,0002730.69W -602,512746.09N,0002734.55W -603,512746.08N,0002738.41W -604,512746.08N,0002742.27W -605,512746.06N,0002746.13W -606,512746.05N,0002749.96W -607,512745.77N,0002753.93W -608,512745.76N,0002757.72W -609,512745.74N,0002801.50W -611,512741.43N,0002819.11W -612,512739.62N,0002819.09W -613,512737.35N,0002819.08W -614,512737.64N,0002823.73W -615,512740.78N,0002823.76W -616,512743.90N,0002823.79W -457,, -457L,, -457R,, -HAP,512744.27N,0002704.97W -501,512828.22N,0002922.34W -502,512827.70N,0002920.10W -503,512827.62N,0002917.87W -505,512827.62N,0002915.29W -506,512826.36N,0002907.32W -507,512824.97N,0002907.31W -508,512823.36N,0002907.29W -509,512821.75N,0002907.28W -511,512820.36N,0002907.27W -512,512818.75N,0002907.25W -513,512816.81N,0002907.23W -514,512816.56N,0002907.23W -515,512815.12N,0002907.22W -516,512814.17N,0002907.21W -517,512813.73N,0002907.21W -518,512811.77N,0002907.19W -519,512808.36N,0002908.64W -520,512808.36N,0002911.31W -521,512808.35N,0002913.75W -522,512808.34N,0002916.34W -523,512808.33N,0002918.93W -524,512806.72N,0002919.71W -525,512806.72N,0002917.49W -526,512806.74N,0002912.56W -527,512806.75N,0002910.34W -531,512825.69N,0002858.73W -532,512823.30N,0002858.70W -533,512820.91N,000288.68W -534,512818.51N,0002858.66W -535,512816.12N,0002858.64W -536,512813.72N,0002858.62W -537,512811.33N,0002858.60W -538,512808.93N,0002858.58W -539,512806.54N,0002858.56W -541,512806.48N,0002846.00W -542,512808.88N,0002846.03W -543,512811.27N,0002846.05W -544,512813.91N,0002846.07W -544L,512813.24N,0002846.06W -544R,512814.56N,0002846.08W -545,512816.79N,0002846.10W -545L,512816.11N,0002846.09W -545R,512817.46N,0002846.10W -546,512819.67N,0002846.12W -546L,512819.00N,0002846.12W -546R,512820.37N,0002846.13W -547,512823.10N,0002846.15W -547L,512822.42N,0002846.15W -547R,512823.84N,0002846.16W -548,512826.31N,0002846.18W -548L,512825.65N,0002846.17W -548R,512827.00N,0002846.19W -551,512806.60N,0002840.53W -552,512809.00N,0002840.55W -553,512811.39N,0002840.57W -554,512813.79N,0002840.59W -555,512816.42N,0002840.61W -556,512819.92N,0002840.64W -557,512823.35N,0002840.67W -558,512826.23N,0002840.70W -561,512826.30N,0002827.53W -562,512823.42N,0002827.50W -563,512820.05N,0002827.47W -564,512817.17N,0002827.45W -565,512813.97N,0002827.42W -566,512811.33N,0002827.40W -567,512808.94N,0002827.38W -568,512806.55N,0002827.36W -572,512821.85N,0002822.01W -573,512819.46N,0002821.99W -575,512813.65N,0002821.94W -576,512809.77N,0002821.90W -581,512804.54N,0002819.07W -582,512804.56N,0002815.58W -583,512804.57N,0002811.67W -590,512809.06N,0002809.62W -591,512811.49N,0002809.65W -592,512813.91N,0002809.67W -594,512819.59N,0002809.89W -595,512822.47N,0002809.92W -596,512825.35N,0002810.03W diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e2d82e7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Aircraft.php b/src/Aircraft.php new file mode 100644 index 0000000..8d6a345 --- /dev/null +++ b/src/Aircraft.php @@ -0,0 +1,70 @@ +vatsimData = $vatsimData; + } + + /** + * @param $name + * @return mixed + * @throws Exception + */ + public function __get($name) + { + if (isset($this->vatsimData[$name])) { + return $this->vatsimData[$name]; + } + + return null; + } + + /** + * @return string + */ + public function getStandIndex() + { + return $this->standIndex; + } + + /** + * @param array $stands Master list of stands + * @return Stand|null + */ + public function getStand($stands) + { + return $this->standIndex ? $stands[$this->standIndex] : null; + } + + /** + * Returns if the aircraft is on a stand or not + * + * @return bool + */ + public function onStand() + { + return !!$this->standIndex; + } + + /** + * @param string $standIndex + */ + public function setStandIndex($standIndex) + { + $this->standIndex = $standIndex; + } +} \ No newline at end of file diff --git a/src/Exceptions/CoordinateOutOfBoundsException.php b/src/Exceptions/CoordinateOutOfBoundsException.php new file mode 100644 index 0000000..dbcd678 --- /dev/null +++ b/src/Exceptions/CoordinateOutOfBoundsException.php @@ -0,0 +1,8 @@ +latitude, 0, 2); + $min = substr($this->latitude, 2, 2); + $sec = substr($this->latitude, 4, 5); + $negative = substr($this->latitude, -1) == 'S'; + return $this->convertDMSToDecimal($deg, $min, $sec, $negative); + } + + public function longitudeToDecimal() + { + $deg = substr($this->longitude, 0, 3); + $min = substr($this->longitude, 3, 2); + $sec = substr($this->longitude, 5, 5); + $negative = substr($this->longitude, -1) == 'W'; + return $this->convertDMSToDecimal($deg, $min, $sec, $negative); + } +} \ No newline at end of file diff --git a/src/Libraries/CoordinateConverter.php b/src/Libraries/CoordinateConverter.php new file mode 100644 index 0000000..57c2e55 --- /dev/null +++ b/src/Libraries/CoordinateConverter.php @@ -0,0 +1,51 @@ +latitude = $latitude; + $this->longitude = $longitude; + } + + /** + * Converts Degrees, minutes and seconds into a decimal format coordinate + * + * @param float $degrees + * @param float $minutes + * @param float $seconds + * @param bool|null $negative + * @return float|int + */ + protected function convertDMSToDecimal($degrees, $minutes, $seconds, $negative = null) + { + // Casts + $degrees = floatval($degrees); + $minutes = floatval($minutes); + $seconds = floatval($seconds); + + // Deduce sign if not given + if($negative == null){ + // Find sign from the sign of the degrees integer. If positive, assume East / North + if($degrees >= 0){ + $negative = false; + }else{ + $negative = true; + } + } + + // Converting DMS ( Degrees / minutes / seconds ) to decimal format + $float = abs($degrees) + ((($minutes * 60) + ($seconds)) / 3600); + return $negative ? -1 * $float : $float; + } + + abstract public function latitudeToDecimal(); + abstract public function longitudeToDecimal(); +} \ No newline at end of file diff --git a/src/Libraries/DecimalCoordinateHelper.php b/src/Libraries/DecimalCoordinateHelper.php new file mode 100644 index 0000000..53baf7f --- /dev/null +++ b/src/Libraries/DecimalCoordinateHelper.php @@ -0,0 +1,98 @@ += -90; + } + + /** + * Validates a given longitude coordinate to make sure it is realistic + * + * @param float $coordinate Coordinate is decimal format + * @return bool + */ + public static function validateLongitudeCoordinate($coordinate) + { + return $coordinate <= 180 && $coordinate >= -180; + } + + /** + * Return the distance in kilometres between two sets of coordinates using the haversine formula + * + * @param float $latitude1 Latitude in decimal format + * @param float $longitude1 Longitude in decimal format + * @param float $latitude2 Latitude in decimal format + * @param float $longitude2 Longitude in decimal format + * @return float|int + */ + public static function distanceBetweenCoordinates($latitude1, $longitude1, $latitude2, $longitude2) + { + $earth_radius = 6371; + + $latitude1 = floatval($latitude1); + $longitude1 = floatval($longitude1); + $latitude2 = floatval($latitude2); + $longitude2 = floatval($longitude2); + + $dLat = deg2rad($latitude2 - $latitude1); + $dLon = deg2rad($longitude2 - $longitude1); + + $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * sin($dLon / 2) * sin($dLon / 2); + $c = 2 * asin(sqrt($a)); + return $earth_radius * $c; + } + + /** + * Return the distance in kilometres between two sets of coordinates using the haversine formula + * + * @param float $latitude Latitude in decimal format + * @param float $longitude Longitude in decimal format + * @param float|int $distance Distance in km + * @param int $direction Clockwise direction in degrees, where north is 0 degrees, east is 90 degrees, etc. + * @return object Object with properties `latitude` and `longitude` accessible + */ + public static function distanceFromCoordinate($latitude, $longitude, $distance, $direction) + { + $earth_radius = 6371; + + $latitude = floatval($latitude) * pi()/180; + $longitude = floatval($longitude) * pi()/180; + $delta = $distance/$earth_radius; + $directionRad = $direction * pi()/180; + + $newLatitudeRad = asin(sin($latitude)*cos($delta) + cos($latitude)*sin($delta)*cos($directionRad)); + $newLongitudeRad = $longitude + atan2(sin($directionRad)*sin($delta)*cos($latitude), cos($delta) - sin($latitude)*sin($newLatitudeRad)); + + return (object) ['latitude' => $newLatitudeRad * 180/pi(), 'longitude' => $newLongitudeRad * 180/pi()]; + } +} \ No newline at end of file diff --git a/src/Libraries/OSMStandData.php b/src/Libraries/OSMStandData.php new file mode 100644 index 0000000..97ce237 --- /dev/null +++ b/src/Libraries/OSMStandData.php @@ -0,0 +1,210 @@ +airportICAO = $icao; + $this->cacheFolder = dirname(__FILE__) . '/../../storage/data'; + if ($client) $this->client = $client; else $this->client = new Client(); + } + + /** + * Fetches stand data from the Overpass API. + * + * If no results found, this function will return null. + * Otherwise, will return the absolute path to the created CSV. + * + * @param float $centerLatitude The defined airport center latitude + * @param float $centerLongitude The defined airport center longitude + * @param int $radius The radius in km from which to create the bounding box for the search + * @return string|null + */ + public function fetchStandData($centerLatitude, $centerLongitude, $radius = 6) + { + if ($this->cachedCSVPath()) { + return $this->cachedCSVPath(); + } + + // Download data from OSM API + $result = $this->client->get($this->overpassAPIUrl . $this->composeQuery($centerLatitude, $centerLongitude, $radius)); + $body = $result->getBody()->getContents(); + + // Parse the rows + $csv = str_getcsv($body, "\n"); + $stands = []; + // Check and remove duplicates + foreach ($csv as $index => $row) { + // Handle header row + if ($index == 0) { + $csv[$index] = "id,latitude,longitude,This data was extracted from the OpenStreetMap API and is licensed under the ODbL license. It's use must be attributed"; + continue; + } + $row = str_getcsv($row); + + // name tag from data - fallback name for stand id + $standName = $row[1]; + $featureType = $row[2]; + // Ref tag from data - primarily used as the stand id + $standRef = $row[0] ?? $standName; + + // Check if stand has already been processed (i.e. this is a duplicate) + if (($key = array_search($standRef, $stands)) !== false) { + + // If the stand name tag is null, is same as the ref tag, or is already in the stand array + if (!$standName || $standName == $standRef || in_array($standName, $stands)) { + // Allow nodes to take preference + if ($featureType == "node") { + // Remove the already registered stand from the registered list, and the CSV + unset($stands[$key]); + unset($csv[$key]); + } else { + // Remove this stand from the CSV + unset($csv[$index]); + continue; + } + }else{ + // We can use the stand name instead + $standRef = $standName; + } + } + + $csv[$index] = implode(',', [$standRef, $row[3], $row[4]]); + $stands[$index] = $standRef; + } + + $csv = implode("\n", $csv); + + // Store file + file_put_contents($this->getCacheFilePath(), $csv, LOCK_EX); + return $this->cachedCSVPath(); + } + + /** + * Deletes the cached file (if exists) + * + * @return bool Returns true if deleted, or false if not deleted / error. + */ + public function deleteCachedData() + { + return file_exists($this->getCacheFilePath()) ? unlink($this->getCacheFilePath()) : false; + } + + /** + * Composes the Overpass Query Language Query + * + * @param $centerLatitude + * @param $centerLongitude + * @param $radius + * @return string URL Encoded Query + */ + private function composeQuery($centerLatitude, $centerLongitude, $radius) + { + $diagonalRadius = $radius / cos(deg2rad(45)); + + // Set a hard limit for radius + if ($diagonalRadius > 20) { + $diagonalRadius = 20; + } + + // Generate bounding box following South, West, North, East format + $bottomLeft = DecimalCoordinateHelper::distanceFromCoordinate($centerLatitude, $centerLongitude, $diagonalRadius, 180 + 45); + $topRight = DecimalCoordinateHelper::distanceFromCoordinate($centerLatitude, $centerLongitude, $diagonalRadius, 45); + + return urlencode(" + [bbox:{$bottomLeft->latitude},{$bottomLeft->longitude},{$topRight->latitude},{$topRight->longitude}] + [out:csv(ref,name,::type,::lat,::lon;true;\",\")][timeout:{$this->timeout}]; + ( + nwr[aeroway=aerodrome][icao=\"{$this->airportICAO}\"]; + ); + map_to_area; + nwr[aeroway=parking_position][~\"^name|ref?$\"~\".\"](area); + out tags center; + "); + } + + /** + * Returns either the path to the cached CSV file, or null in the case of no / expired cache. + * + * @return string|null + */ + private function cachedCSVPath() + { + $fileExists = file_exists($this->getCacheFilePath()); + $fileUpdatedWithinCacheTTL = $fileExists && filemtime($this->getCacheFilePath()) > (time() - $this->cacheTTL); + + return $fileExists && $fileUpdatedWithinCacheTTL ? $this->getCacheFilePath() : null; + } + + /** + * Returns the path to the stand stand CSV file, assuming it exists + * + * @return string + */ + public function getCacheFilePath() + { + return $this->cacheFolder . "/OSM-{$this->airportICAO}-stand-data.csv"; + } + + /** + * Set the length of time to cache stand data files + * + * @param int $cacheTTL Time in seconds + * @return OSMStandData + */ + public function setCacheTTL($cacheTTL) + { + $this->cacheTTL = $cacheTTL; + return $this; + } + + /** + * Set the timeout length for retrieving data from Overpass API. If you are expecting a large data return, + * you could increase this to prevent timeouts. + * + * @param int $timeout + * @return OSMStandData + */ + public function setTimeout(int $timeout) + { + $this->timeout = $timeout; + return $this; + } + +} \ No newline at end of file diff --git a/src/Stand.php b/src/Stand.php new file mode 100644 index 0000000..2c3aa14 --- /dev/null +++ b/src/Stand.php @@ -0,0 +1,182 @@ +id = (string)$id; + if ($this->id == null) { + throw new InvalidStandException("An invalid stand ID/name was passed to the Stand constructor: '{$id}'"); + } + $this->latitude = $latitude; + $this->longitude = $longitude; + $this->occupier = $occupier; + $this->standExtensions = $standExtensions; + $this->standPattern = $standPattern; + } + + /** + * @param $name + * @return mixed + */ + public function __get($name) + { + if (isset($this->$name)) { + return $this->$name; + } + + return null; + } + + /** + * @param Aircraft $aircraft + */ + public function setOccupier(?Aircraft $aircraft) + { + $this->occupier = $aircraft; + } + + /** + * @return bool + */ + public function isOccupied() + { + return !!$this->occupier; + } + + public function isPartOfOccupiedGroup() + { + return $this->isOccupied() && $this->occupier->getStandIndex() !== $this->getKey(); + } + + /** + * @return string + */ + public function getKey() + { + return $this->id; + } + + /** + * @return string + */ + public function getName() + { + return $this->getKey(); + } + + /** + * Finds and returns the stand number without an extension + * + * @return string|null + */ + public function getRoot() + { + if(!$matches = $this->matchNameAgainstRegex()){ + return null; + } + + if(count($matches) < 3){ + // No extension + return $matches[1]; + } + + return $this->standRootComesFirst() ? $matches[1] : $matches[2]; + } + + /** + * Finds and returns the stand's extension (if exists) + * + * @return string|null + */ + public function getExtension() + { + if(!$matches = $this->matchNameAgainstRegex()){ + return null; + } + + if(count($matches) < 3){ + // No extension + return null; + } + + return !$this->standRootComesFirst() ? $matches[1] : $matches[2]; + } + + /** + * Resets the stand to remove any matched aircraft + */ + public function clearParsedData() + { + $this->occupier = null; + } + + /** + * Matches the name against the extension regex + * + * @return array|null + */ + private function matchNameAgainstRegex() + { + $gotMatch = preg_match($this->generateExtensionRegex(), $this->getName(), $matches); + if (!$gotMatch) { + return null; + } + return $matches; + } + + /** + * Generates the regex to capture a stand's extension + * + * @return string + */ + private function generateExtensionRegex() + { + // Compose regex + $extensions = "(" . implode('|', $this->standExtensions) . ")?"; + $pattern = '/^' . $this->standPattern . '$/'; + return str_replace(['', ''], ['([0-9]+)', $extensions], $pattern); + } + + /** + * Returns whether in the stand pattern, the root or extension comes first + * + * @return bool; + */ + private function standRootComesFirst() + { + return strpos($this->standPattern, '') < strpos($this->standPattern, ''); + } + +} \ No newline at end of file diff --git a/src/StandStatus.php b/src/StandStatus.php index a347c16..7bdaa74 100644 --- a/src/StandStatus.php +++ b/src/StandStatus.php @@ -1,549 +1,571 @@ '; // Use to determine where to insert the extensions, and to represent the stand number. Can only use one of each + private $standCoordinateFormat = self::COORD_FORMAT_DECIMAL; // Stand Data file coordinate type + + + /** + * StandStatus constructor. + * @param float $airportLatitude The decimal-format latitude of the airport + * @param float $airportLongitude The decimal-format longitude of the airport + * @param int $standCoordinateFormat The format of the coordinates in the stand data file. Defaults to decimal. + * @throws CoordinateOutOfBoundsException + */ + public function __construct( + $airportLatitude, + $airportLongitude, + $standCoordinateFormat = self::COORD_FORMAT_DECIMAL) { + $this->airportLatitude = $airportLatitude; + $this->airportLongitude = $airportLongitude; + if ($standCoordinateFormat) $this->standCoordinateFormat = $standCoordinateFormat; + DecimalCoordinateHelper::validateCoordinatePairOrFail($airportLatitude, $airportLongitude); + } - public $stands = []; - public $occupiedStands = []; - public $aircraftSearchResults = []; - - /* - Airport Stand Details - */ - - public $airportICAO; - public $airportName; - public $airportCoordinates; - - public $airportStandsFile; + /** + * Loads and parse's stand data from the stand data csv file + * + * @param string $filePath Path to the Stand Data CSV file + * @return StandStatus + * @throws CoordinateOutOfBoundsException + * @throws Exceptions\InvalidStandException + * @throws UnableToLoadStandDataFileException + * @throws UnableToParseStandDataException + */ + public function loadStandDataFromCSV(string $filePath) + { + $this->stands = []; - /* - Configuration - */ + $standDataStream = @fopen($filePath, "r"); - private $maxStandDistance = 0.07; // In kilometeres - private $hideStandSidesWhenOccupied = true; - private $maxDistanceFromAirport = 2; // In kilometeres - private $maxAircraftAltitude = 3000; // In feet - private $maxAircraftGroundspeed = 10; // In knots - private $standExtensions = array("L", "C", "R", "A", "B"); + if (!$standDataStream) { + throw new UnableToLoadStandDataFileException("Unable to load the stand data file located at path '{$filePath}'"); + } + while (($row = fgetcsv($standDataStream, 4096)) !== false) { + // Assume file data structure of id, latitude, longitude + $name = $row[0]; + $latitude = $row[1]; + $longitude = $row[2]; - public function __construct($airportICAO, $airportStandsFile, $airportLatCoordinate, $airportLongCoordinate, $parseData = true, $maxAirportDistance = null) - { - $this->airportICAO = $airportICAO; - $this->airportStandsFile = $airportStandsFile; - $this->airportCoordinates = array("lat" => $airportLatCoordinate, "long" => $airportLongCoordinate); - if ($maxAirportDistance != null) { - $this->maxDistanceFromAirport = $maxAirportDistance; + // Check if this is a header row + if (ctype_alpha($latitude)) { + continue; } - if ($this->loadStandsData()) { - if ($parseData) { - $this->parseData(); - } + switch ($this->standCoordinateFormat) { + case self::COORD_FORMAT_CAA: + $converter = new CAACoordinateConverter($latitude, $longitude); + $latitude = $converter->latitudeToDecimal(); + $longitude = $converter->longitudeToDecimal(); + break; } + DecimalCoordinateHelper::validateCoordinatePairOrFail($latitude, $longitude); + $this->addStand($name, $latitude, $longitude); } + fclose($standDataStream); + return $this; + } - public function allStands($pageNo = null, $pageLimit = null) - { - if ($pageLimit == null) { - return $this->stands; - } else { - if ($pageNo == null) { - // Assume first page - return array_slice($this->stands, 0, $pageLimit); - } else { - return array_slice($this->stands, ($pageNo * $pageLimit) - $pageLimit, $pageLimit); - } - - } - + /** + * Fetches parking position data from the OpenStreetMap's Overpass API. + * + * It should be noted that OSM will not always have all the data needed for every airport, and some of the data may + * be incorrect, out of date or ill-formatted. Check on the OpenStreetMap's editor to ensure your airport has coverage. + * + * OpenStreetMap data is licensed under the ODbL license. If you use this method, you MUST provide appropriate + * attribution to OSM. See https://www.openstreetmap.org/copyright for details. + * + * @param string $icao 4 letter ICAO code for the airport + * @param OSMStandData $osmDataLibrary Optional. Inject the OSMStandData library if you would like to change the default settings + * @return StandStatus + * @throws CoordinateOutOfBoundsException|InvalidStandException|InvalidStandException|InvalidCoordinateFormat|UnableToLoadStandDataFileException|UnableToParseStandDataException|InvalidICAOCodeException + */ + public function fetchAndLoadStandDataFromOSM($icao, OSMStandData $osmDataLibrary = null) + { + if ($this->standCoordinateFormat != self::COORD_FORMAT_DECIMAL) { + throw new InvalidCoordinateFormat('To use OSM map data, the stand coordinate format must be set to decimal!'); } - public function occupiedStands($pageNo = null, $pageLimit = null) - { - $occupiedStands = $this->occupiedStands; - foreach ($occupiedStands as $stand) { - $occupiedStands[$stand] = $this->stands[$stand]; // Fill in pilot data - } + if (!$osmDataLibrary) $osmDataLibrary = new OSMStandData($icao); + $csvPath = $osmDataLibrary->fetchStandData($this->airportLatitude, $this->airportLongitude, $this->maxDistanceFromAirport * 3); + return $this->loadStandDataFromCSV($csvPath); + } - if ($pageLimit == null) { - return $occupiedStands; - } else { - if ($pageNo == null) { - // Assume first page - return array_slice($occupiedStands, 0, $pageLimit); - } else { - return array_slice($occupiedStands, ($pageNo * $pageLimit) - $pageLimit, $pageLimit); - } + /** + * Loads in stand data from an array. + * + * @param array $standData Array of stands. Expects each stand to have the name/id at index 0, latitude at index 1, and longitude at index 2 + * @return StandStatus + * @throws InvalidStandException|UnableToParseStandDataException + */ + public function loadStandDataFromArray(array $standData) + { + $this->stands = []; - } + foreach ($standData as $stand) { + $this->addStand($stand[0], $stand[1], $stand[2]); } + return $this; + } - public function allStandsPaginationArray($pageLimit) - { - // Work out the ammount of pages - $noOfPages = ceil(count($this->stands) / $pageLimit); - $pageinationArray = []; - for ($i = 0; $i < $noOfPages; $i++) { - $pageinationArray[] = $this->allStands($i, $pageLimit); - } + /** + * Fetches VATSIM pilot data, and runs stand assignment algorithm + * + * @return StandStatus + * @throws NoStandDataException + */ + public function parseData() + { + // If no stands loaded, throw + if (count($this->stands) == 0) { + throw new NoStandDataException(); + } - return $pageinationArray; + // Flush any stand caches + $this->occupiedStandsCache = null; + $this->unoccupiedStandsCache = null; + // Fetch pilot data + $vatsimData = new VatsimData(); + $pilots = $this->getVATSIMPilots($vatsimData); + // Clear existing matches + foreach ($this->stands as &$stand) { + $stand->clearParsedData(); } - function parseData() - { - if ($this->getAircraftWithinParameters()) { - $this->checkIfAircraftAreOnStand(); - } - return $this; + // If we have pilots, filter and run assignment program + if ($pilots && $this->getAircraftWithinParameters($pilots)) { + $this->checkIfAircraftAreOnStand(); } + return $this; + } - // Load the stand data - function loadStandsData() - { - $array = $fields = []; - $i = 0; - $handle = @fopen($this->airportStandsFile, "r"); - if ($handle) { - while (($row = fgetcsv($handle, 4096)) !== false) { - if (empty($fields)) { - $fields = $row; - continue; - } - $y = 0; - foreach ($row as $k => $value) { - if ($y == 1) { // Convert LAT coordinate - $array[$row[0]][$fields[$k]] = $this->convertCAALatCoord($value); - } else if ($y == 2) { // Convert LONG coordinate - $array[$row[0]][$fields[$k]] = $this->convertCAALongCoord($value); - } else { - $array[$row[0]][$fields[$k]] = $value; - } - $y++; - } - $i++; - } - if (!feof($handle)) { - echo "Error: unexpected fgets() fail\n"; - return false; - } - fclose($handle); - } else { - return false; - } - $this->stands = $array; - return true; + /* + * Useful functions + */ + + /** + * Returns a list of all the stands (minus hidden side stands if enabled) + * + * @param bool $assoc + * @return Stand[] + */ + public function stands($assoc = false) + { + if (!$this->hideStandSidesWhenOccupied) { + return $this->allStands($assoc); } + return array_filter($this->allStands($assoc), function (Stand $stand) { + return !$stand->isPartOfOccupiedGroup(); + }); + } - function getAircraftWithinParameters() - { - $vatsim = new VatsimData(); - $vatsim->loadData(); - - try { - $pilots = $vatsim->getPilots()->toArray(); - }catch (Exception $e){ - return false; - } - - // INSERT TEST PILOTS - //$pilots[] = array('callsign' => "TEST", "latitude" => 55.949228, "longitude" => -3.364303, "altitude" => 0, "groundspeed" => 0, "planned_destairport" => "TEST", "planned_depairport" => "TEST"); - - if (count($pilots) == 0) { - return false; - } - if (($this->airportCoordinates['lat'] == null) || ($this->airportCoordinates['long'] == null)) { - return false; - } - - - $filteredResults = []; - foreach ($pilots as $pilot) { - if (($this->getCoordDistance($pilot['latitude'], $pilot['longitude'], $this->airportCoordinates['lat'], $this->airportCoordinates['long']) < $this->maxDistanceFromAirport)) { - if (($pilot['groundspeed'] <= $this->maxAircraftGroundspeed) && ($pilot['altitude'] <= $this->maxAircraftAltitude)) { - $filteredResults[] = $pilot; - } - } + /** + * Returns all loaded stands, irrespective of if they are hidden or not + * + * @param bool $assoc + * @return Stand[] + */ + public function allStands($assoc = false) + { + return $assoc ? $this->stands : array_values($this->stands); + } - } - $this->aircraftSearchResults = $filteredResults; - return true; + /** + * Returns a list of all occupied stands + * + * @param bool $assoc If true, the indexes of the array will be equal to the stand name/id. Default false + * @return Stand[] + */ + public function occupiedStands($assoc = false) + { + if (!$this->occupiedStandsCache) { + $this->occupiedStandsCache = array_filter($this->stands(true), function (Stand $stand) { + return $stand->isOccupied(); + }); } - function checkIfAircraftAreOnStand() - { - $pilots = $this->aircraftSearchResults; - $stands = $this->stands; - $standDistanceBoundary = $this->maxStandDistance; - - foreach ($pilots as $pilot) { - - // Array to hold the stands they could possibly be on - $possibleStands = []; - - // Check each stand to see how close they are - foreach ($stands as $stand) { - - // Find distance between aircraft and stand - $distance = $this->getCoordDistance($stand['latcoord'], $stand['longcoord'], $pilot['latitude'], $pilot['longitude']); - - - if ($distance < $standDistanceBoundary) { - // This could be a possible stand as the aircraft is close - $possibleStands[] = array('id' => $stand['id'], 'distance' => $distance); - } - - } + return $assoc ? $this->occupiedStandsCache : array_values($this->occupiedStandsCache); + } - // Check how many stands are possible - if (count($possibleStands) > 1) { + /** + * Returns a list of all unoccupied stands + * + * @param bool $assoc If true, the indexes of the array will be equal to the stand name/id. Default false + * @return Stand[] + */ + public function unoccupiedStands($assoc = false) + { + if (!$this->unoccupiedStandsCache) { + $this->unoccupiedStandsCache = array_filter($this->stands(true), function (Stand $stand) { + return !$stand->isOccupied(); + }); + } - $minDistance = $standDistanceBoundary; // Cant be more than $standDistanceBoundary - $minStandID = null; + return $assoc ? $this->unoccupiedStandsCache : array_values($this->unoccupiedStandsCache); + } - foreach ($possibleStands as $stand) { + /** + * Returns all the aircraft that are deemed on the ground + * + * @return Aircraft[] + */ + public function getAllAircraft() + { + return $this->aircraftSearchResults; + } - if ($stand['distance'] < $minDistance) { - // New smallest distance from stand - $minDistance = $stand['distance']; - $minStandID = $stand['id']; - } + /* + * Internal Processing Function + */ - } - $this->checkAndSetStandOccupied($minStandID, $pilot); - } else if (count($possibleStands) == 1) { - $this->checkAndSetStandOccupied($possibleStands[0]['id'], $pilot); - } - } + /** + * Returns an array of pilots from the VATSIM data feed + * + * @param VatsimData $vatsimData + * @return array + */ + public function getVATSIMPilots(VatsimData $vatsimData) + { + if (!$vatsimData->loadData()) { + // VATSIM data file is down. + return null; } - function checkAndSetStandOccupied($standID, $pilot) - { - - // Firstly set the acutal stand as occupied - $this->setStandOccupied($standID, $pilot); + return $vatsimData->getPilots()->toArray(); + } - // Check for side stands - $standSides = $this->standSides($standID); - if ($standSides) { + /** + * Filters network pilot data for aircraft meeting ground conditions + * + * @param array $pilots + * @return bool + */ + private function getAircraftWithinParameters(array $pilots) + { + $filteredAircraft = []; + foreach ($pilots as $pilot) { + $aircraft = new Aircraft($pilot); - foreach ($standSides as $stand) { - $this->setStandOccupied($stand, $pilot); - } + $insideAirfieldRange = DecimalCoordinateHelper::distanceBetweenCoordinates($aircraft->latitude, $aircraft->longitude, $this->airportLatitude, $this->airportLongitude) + < $this->maxDistanceFromAirport; + $belowSpecifiedGroundspeed = $aircraft->groundspeed <= $this->maxAircraftGroundspeed; + $belowSpecifiedAltitude = $aircraft->altitude <= $this->maxAircraftAltitude; - // Hide the side stands when option is set - if ($this->hideStandSidesWhenOccupied) { - - // Get the stand root number - $standRoot = str_replace("R", "", $standID); - $standRoot = str_replace("L", "", $standRoot); - $standRoot = str_replace("A", "", $standRoot); - $standRoot = str_replace("B", "", $standRoot); - $standRoot = str_replace("C", "", $standRoot); - - if (isset($this->stands[$standRoot])) { - // Stand root is an actual stand - if (isset($this->stands[$standRoot . "R"])) { - $this->unsetStandOccupied($standRoot . "R"); - unset($this->stands[$standRoot . "R"]); - } - if (isset($this->stands[$standRoot . "L"])) { - $this->unsetStandOccupied($standRoot . "L"); - unset($this->stands[$standRoot . "L"]); - } - if (isset($this->stands[$standRoot . "A"])) { - $this->unsetStandOccupied($standRoot . "A"); - unset($this->stands[$standRoot . "A"]); - } - if (isset($this->stands[$standRoot . "B"])) { - $this->unsetStandOccupied($standRoot . "B"); - unset($this->stands[$standRoot . "B"]); - } - - } else if (isset($this->stands[$standRoot . "C"])) { - // Stand Root + C (i.e 551C) is an actual stand - if (isset($this->stands[$standRoot . "R"])) { - $this->unsetStandOccupied($standRoot . "R"); - unset($this->stands[$standRoot . "R"]); - } - if (isset($this->stands[$standRoot . "L"])) { - $this->unsetStandOccupied($standRoot . "L"); - unset($this->stands[$standRoot . "L"]); - } - if (isset($this->stands[$standRoot . "A"])) { - $this->unsetStandOccupied($standRoot . "A"); - unset($this->stands[$standRoot . "A"]); - } - if (isset($this->stands[$standRoot . "B"])) { - $this->unsetStandOccupied($standRoot . "B"); - unset($this->stands[$standRoot . "B"]); - } - } - } + if ($insideAirfieldRange && $belowSpecifiedGroundspeed && $belowSpecifiedAltitude) { + $filteredAircraft[] = $aircraft; } - } - - function setStandOccupied($standID, $pilot) - { - $this->stands[$standID]['occupied'] = $pilot; - $this->occupiedStands[$standID] = $standID; - } - function unsetStandOccupied($standID) - { - if (isset($this->stands[$standID]['occupied'])) { - unset($this->stands[$standID]['occupied']); - } - unset($this->occupiedStands[$standID]); } + $this->aircraftSearchResults = $filteredAircraft; + return true; + } - function standSides($standID) - { - $standSides = []; - $stands = $this->stands; - - //Find the 'base' stand number - $standBase = str_replace("R", "", $standID); - $standBase = str_replace("L", "", $standBase); - $standBase = str_replace("A", "", $standBase); - $standBase = str_replace("B", "", $standBase); - $standBase = str_replace("C", "", $standBase); - - - // Check if stand has a side already - if (strstr($standID, "R") || strstr($standID, "L")) { - // Our stand is already L/R - if (strstr($standID, "R")) { - // Set the right hand side to occupied aswell - $newStand = str_replace("R", "L", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("R", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("R", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } else { - // Set the left hand side to occupied aswell - $newStand = str_replace("L", "R", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("L", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("L", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } - } else if (strstr($standID, "A") || strstr($standID, "B")) { - // Our stand already is A / B - - if (strstr($standID, "A")) { - // Set the right hand side to occupied aswell - $newStand = str_replace("A", "B", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("A", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("A", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } else { - // Set the right hand side to occupied aswell - $newStand = str_replace("B", "A", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the base stand to occupied also - $newStand = str_replace("B", "", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - // Set the center stand to occupied also - $newStand = str_replace("B", "C", $standID); - if (isset($stands[$newStand])) { - $standSides[] = $newStand; - } - } - - } else { - // Stand itself has no side, but may have L / R / A / B sides - if (isset($stands[$standBase . "L"])) { - $standSides[] = $standBase . "L"; - } - if (isset($stands[$standBase . "R"])) { - $standSides[] = $standBase . "R"; - } - if (isset($stands[$standBase . "C"])) { - $standSides[] = $standBase . "C"; - } - if (isset($stands[$standBase . "A"])) { - $standSides[] = $standBase . "A"; - } - if (isset($stands[$standBase . "B"])) { - $standSides[] = $standBase . "B"; + /** + * Runs stand assignment algorithm + * + * @return void + */ + private function checkIfAircraftAreOnStand() + { + foreach ($this->aircraftSearchResults as $aircraft) { + // Best stand match + $standMatch = null; + // Check each stand to see how close they are + foreach ($this->stands as $standIndex => $stand) { + + // Find distance between aircraft and stand + $distance = DecimalCoordinateHelper::distanceBetweenCoordinates($stand->latitude, $stand->longitude, $aircraft->latitude, $aircraft->longitude); + + $distanceInsideBound = $distance < $this->maxStandDistance; + + if ($distanceInsideBound && (!$standMatch || $distance < $standMatch['distance'])) { + // Best match at the moment + $standMatch = [ + 'index' => $standIndex, + 'stand' => $stand, + 'distance' => $distance, + ]; } } - if (count($standSides) == 0) { - return false; - } else { - return $standSides; + // If we have a match, set it as occupied + if ($standMatch) { + $this->setStandGroupOccupied($standMatch['stand'], $aircraft); } - } + } + /** + * Adds a stand to the lift of stands + * + * @param $id + * @param $latitude + * @param $longitude + * @throws Exceptions\InvalidStandException + * @throws UnableToParseStandDataException + */ + private function addStand($id, $latitude, $longitude) + { + $stand = new Stand($id, $latitude, $longitude, $this->standExtensions, $this->standExtensionPattern); - /* - - Support Functions - - */ - - function getCoordDistance($latitude1, $longitude1, $latitude2, $longitude2) - { - $earth_radius = 6371; - - $latitude1 = floatval($latitude1); - $longitude1 = floatval($longitude1); - $latitude2 = floatval($latitude2); - $longitude2 = floatval($longitude2); + if (isset($this->stands[$stand->getKey()])) { + throw new UnableToParseStandDataException("A stand ID was defined twice in the stand data! Stand ID: {$stand->getKey()}"); + } + $this->stands[$stand->getKey()] = $stand; + } - $dLat = deg2rad($latitude2 - $latitude1); - $dLon = deg2rad($longitude2 - $longitude1); + /** + * Sets the given stand (by index reference) to occupied + * + * @param Stand $stand + * @param Aircraft $aircraft + */ + private function setStandOccupied(Stand $stand, Aircraft $aircraft) + { + $this->stands[$stand->getKey()]->setOccupier($aircraft); + } - $a = sin($dLat / 2) * sin($dLat / 2) + cos(deg2rad($latitude1)) * cos(deg2rad($latitude2)) * sin($dLon / 2) * sin($dLon / 2); - $c = 2 * asin(sqrt($a)); - $d = $earth_radius * $c; + /** + * Sets a stand and its complementing stands as occupied + * + * @param Stand $stand + * @param Aircraft $aircraft + */ + private function setStandGroupOccupied(Stand $stand, Aircraft $aircraft) + { + // Firstly set the actual stand as occupied + $this->setStandOccupied($stand, $aircraft); + $aircraft->setStandIndex($stand->getKey()); + + // Get complementary stands + $standSides = $this->complementaryStands($stand); + if ($standSides) { + foreach ($standSides as $stand) { + $this->setStandOccupied($stand, $aircraft); + } + } + } - return $d; + /** + * Generates possible matching side stands for a given stand + * + * @param Stand $stand + * @return array|null + */ + private function complementaryStands(Stand $stand) + { + $root = $stand->getRoot(); + $stands = []; + foreach (array_merge([''], $this->standExtensions) as $extension) { + // Generate expected stand name + $standName = str_replace(['', ''], [$root, $extension], $this->standExtensionPattern); + if ($standName != $stand->getName() && isset($this->stands[$standName])) $stands[] = $this->stands[$standName]; } - function convertCoordinateToDecimal($deg, $min, $sec, $dir) - { - // Converting DMS ( Degrees / minutes / seconds ) to decimal format - if ($dir == "W") { - return "-" . ($deg + ((($min * 60) + ($sec)) / 3600)); - } else if ($dir == "S") { - return "-" . ($deg + ((($min * 60) + ($sec)) / 3600)); - } - return $deg + ((($min * 60) + ($sec)) / 3600); - } + return count($stands) > 0 ? $stands : null; + } - function convertCAALatCoord($coord) - { - $deg = substr($coord, 0, 2); - $min = substr($coord, 2, 2); - $sec = substr($coord, 4, 5); - $dir = substr($coord, -1); - return $this->convertCoordinateToDecimal($deg, $min, $sec, $dir); - } - function convertCAALongCoord($coord) - { - $deg = substr($coord, 0, 3); - $min = substr($coord, 3, 2); - $sec = substr($coord, 5, 5); - $dir = substr($coord, -1); - return $this->convertCoordinateToDecimal($deg, $min, $sec, $dir); - } + /* + * Getters and Setters + */ - function getMaxStandDistance() - { - return $this->maxStandDistance; - } + /** + * @return float + */ + public function getMaxStandDistance() + { + return $this->maxStandDistance; + } - function setMaxStandDistance($distance) - { - $this->maxStandDistance = $distance; - return $this; - } + /** + * @param float|int $distance + * @return $this + */ + public function setMaxStandDistance($distance) + { + $this->maxStandDistance = $distance; + return $this; + } - function getHideStandSidesWhenOccupied() - { - return $this->hideStandSidesWhenOccupied; - } + /** + * @return bool + */ + public function getHideStandSidesWhenOccupied() + { + return $this->hideStandSidesWhenOccupied; + } - function setHideStandSidesWhenOccupied($bool) - { - $this->hideStandSidesWhenOccupied = $bool; - return $this; - } + /** + * @param bool $bool + * @return $this + */ + public function setHideStandSidesWhenOccupied($bool) + { + $this->hideStandSidesWhenOccupied = $bool; + return $this; + } - function getMaxDistanceFromAirport() - { - return $this->maxDistanceFromAirport; - } + /** + * @return float|int + */ + public function getMaxDistanceFromAirport() + { + return $this->maxDistanceFromAirport; + } - function setMaxDistanceFromAirport($distance) - { - $this->maxDistanceFromAirport = $distance; - return $this; - } + /** + * @param float|int $distance + * @return $this + */ + public function setMaxDistanceFromAirport($distance) + { + $this->maxDistanceFromAirport = $distance; + return $this; + } - function getMaxAircraftAltitude() - { - return $this->maxAircraftAltitude; - } + /** + * @return int + */ + public function getMaxAircraftAltitude() + { + return $this->maxAircraftAltitude; + } - function setMaxAircraftAltitude($altitude) - { - $this->maxAircraftAltitude = $altitude; - return $this; - } + /** + * @param int $altitude + * @return $this + */ + public function setMaxAircraftAltitude($altitude) + { + $this->maxAircraftAltitude = $altitude; + return $this; + } - function getMaxAircraftGroundspeed() - { - return $this->maxAircraftGroundspeed; - } + /** + * @return int + */ + public function getMaxAircraftGroundspeed() + { + return $this->maxAircraftGroundspeed; + } - function setMaxAircraftGroundspeed($speed) - { - $this->maxAircraftGroundspeed = $speed; - return $this; - } + /** + * @param int $speed + * @return $this + */ + public function setMaxAircraftGroundspeed($speed) + { + $this->maxAircraftGroundspeed = $speed; + return $this; + } - function getStandExtensions() - { - return $this->standExtensions; - } + /** + * @return string[] + */ + public function getStandExtensions() + { + return $this->standExtensions; + } - function setStandExtensions($standArray) - { - $this->standExtensions = $standArray; - return $this; - } + /** + * @param string[] $standArray + * @return $this + */ + public function setStandExtensions($standArray) + { + $this->standExtensions = $standArray; + return $this; + } + /** + * @return string + */ + public function getStandExtensionPattern() + { + return $this->standExtensionPattern; + } + /** + * @param string $standExtensionPattern + * @return StandStatus + */ + public function setStandExtensionPattern(string $standExtensionPattern) + { + $this->standExtensionPattern = $standExtensionPattern; + return $this; } +} -?> diff --git a/storage/data/.gitkeep b/storage/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/OSMExample/exampleAPIResponse.csv b/tests/Fixtures/OSMExample/exampleAPIResponse.csv new file mode 100644 index 0000000..75fd23b --- /dev/null +++ b/tests/Fixtures/OSMExample/exampleAPIResponse.csv @@ -0,0 +1,8 @@ +ref,name,@type,@lat,@lon,Data copyright OpenStreetMap Contributors +520,,way,51.4690687,-0.4864827 +520,,node,51.4693779,-0.4864854 +255L,255L,way,51.4709921,-0.4396740 +255L,255L,way,51.4708921,-0.4398740 +255L,254,way,51.4709921,-0.4396740 +254L,254L,way,51.4711742,-0.4398292 +253R,253R,way,51.4699992,-0.4396253 diff --git a/tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv b/tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv new file mode 100644 index 0000000..a058466 --- /dev/null +++ b/tests/Fixtures/OSMExample/exampleEmptyAPIResponse.csv @@ -0,0 +1 @@ +ref,name,@type,@lat,@lon diff --git a/tests/Fixtures/OSMExample/expectedOutput.csv b/tests/Fixtures/OSMExample/expectedOutput.csv new file mode 100644 index 0000000..7620aca --- /dev/null +++ b/tests/Fixtures/OSMExample/expectedOutput.csv @@ -0,0 +1,6 @@ +id,latitude,longitude,This data was extracted from the OpenStreetMap API and is licensed under the ODbL license. It's use must be attributed +520,51.4693779,-0.4864854 +255L,51.4709921,-0.4396740 +254,51.4709921,-0.4396740 +254L,51.4711742,-0.4398292 +253R,51.4699992,-0.4396253 \ No newline at end of file diff --git a/tests/Fixtures/SampleData/decimalexample.csv b/tests/Fixtures/SampleData/decimalexample.csv new file mode 100644 index 0000000..afbc98e --- /dev/null +++ b/tests/Fixtures/SampleData/decimalexample.csv @@ -0,0 +1,3 @@ +id,latcoord,longcoord +1,51.20,-0.5678 +2,48.0,0.123 diff --git a/tests/Fixtures/SampleData/decimalexamplenoheaders.csv b/tests/Fixtures/SampleData/decimalexamplenoheaders.csv new file mode 100644 index 0000000..38b1a36 --- /dev/null +++ b/tests/Fixtures/SampleData/decimalexamplenoheaders.csv @@ -0,0 +1,2 @@ +1,51.20,-0.5678 +2,48.0,0.123 diff --git a/examples/standData/egkkstands.csv b/tests/Fixtures/SampleData/egkkstands.csv similarity index 100% rename from examples/standData/egkkstands.csv rename to tests/Fixtures/SampleData/egkkstands.csv diff --git a/tests/Fixtures/SampleData/invalidexample.csv b/tests/Fixtures/SampleData/invalidexample.csv new file mode 100644 index 0000000..fb91c71 --- /dev/null +++ b/tests/Fixtures/SampleData/invalidexample.csv @@ -0,0 +1,4 @@ +id,latcoord,longcoord +1,51.20,-0.5678 +2,48.0,0.123 +2,48.0,0.123 diff --git a/tests/Integration/OSMIntegrationTest.php b/tests/Integration/OSMIntegrationTest.php new file mode 100644 index 0000000..12fed70 --- /dev/null +++ b/tests/Integration/OSMIntegrationTest.php @@ -0,0 +1,34 @@ +expectException(InvalidCoordinateFormat::class); + $instance = new StandStatus(51.148056,-0.190278, StandStatus::COORD_FORMAT_CAA); + $instance->fetchAndLoadStandDataFromOSM('EGKK'); + } + + public function testItCanLoadStandDataFromOSM() + { + // Delete file if has + $OSMInstance = new OSMStandData('EGKK'); + $OSMInstance->deleteCachedData(); + + $instance = new StandStatus(51.148056,-0.190278); + $instance->fetchAndLoadStandDataFromOSM('EGKK'); + $this->assertTrue(count($instance->stands()) > 1); + + // Delete data file generated + $OSMInstance->deleteCachedData(); + } +} \ No newline at end of file diff --git a/tests/Integration/StandStatusIntegrationTest.php b/tests/Integration/StandStatusIntegrationTest.php new file mode 100644 index 0000000..ff618bf --- /dev/null +++ b/tests/Integration/StandStatusIntegrationTest.php @@ -0,0 +1,18 @@ +expectNotToPerformAssertions(); + $instance = new StandStatus(51.148056,-0.190278, StandStatus::COORD_FORMAT_CAA); + $instance->loadStandDataFromCSV(dirname(__FILE__) . '/../Fixtures/SampleData/egkkstands.csv')->parseData(); + } +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..f0eaf95 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,20 @@ + "TEST", + "latitude" => 55.949228, + "longitude" => -3.364303, + "altitude" => 0, + "groundspeed" => 0, + "planned_destairport" => "TEST", + "planned_depairport" => "TEST" + ]; +} \ No newline at end of file diff --git a/tests/Unit/AircraftTest.php b/tests/Unit/AircraftTest.php new file mode 100644 index 0000000..ffe4561 --- /dev/null +++ b/tests/Unit/AircraftTest.php @@ -0,0 +1,47 @@ +instance = new Aircraft($this->testPilot); + } + + public function testItMapsClassPropertiesToData() + { + $this->assertEquals($this->testPilot['callsign'], $this->instance->callsign); + $this->assertEquals($this->testPilot['latitude'], $this->instance->latitude); + $this->assertNull($this->instance->unknown_index); + } + + public function testItCanGetAndSetStandIndex() + { + $this->assertNull($this->instance->getStandIndex()); + $this->instance->setStandIndex('21R'); + $this->assertEquals('21R', $this->instance->getStandIndex()); + } + + public function testItCanGetStandFromStands() + { + $this->instance->setStandIndex('21R'); + $stand = new Stand('21R', 1, 1, ['L'], ''); + $this->assertEquals($stand, $this->instance->getStand(['21R' => $stand])); + } + + public function testItCorrectlyReportsIfOnStand() + { + $this->assertFalse($this->instance->onStand()); + $this->instance->setStandIndex('21R'); + $this->assertTrue($this->instance->onStand()); + } +} \ No newline at end of file diff --git a/tests/Unit/Libraries/CoordinateConverterTest.php b/tests/Unit/Libraries/CoordinateConverterTest.php new file mode 100644 index 0000000..b08136e --- /dev/null +++ b/tests/Unit/Libraries/CoordinateConverterTest.php @@ -0,0 +1,43 @@ +assertEquals(51.154819444444444, $converter->latitudeToDecimal()); + $this->assertEquals(-0.1648138888888889, $converter->longitudeToDecimal()); + $this->assertEquals(0.1648138888888889, $converter2->longitudeToDecimal()); + } + + public function testDMSConversion() + { + $class = new class extends CoordinateConverter{ + public function latitudeToDecimal() + { + return $this->convertDMSToDecimal(50, 10, 10); + } + + public function longitudeToDecimal() + { + return $this->convertDMSToDecimal(-50, 10, 10); + } + }; + + $this->assertEquals(50.169444444444444, $class->latitudeToDecimal()); + $this->assertEquals(-50.169444444444444, $class->longitudeToDecimal()); + } +} \ No newline at end of file diff --git a/tests/Unit/Libraries/DecimalCoordinateHelperTest.php b/tests/Unit/Libraries/DecimalCoordinateHelperTest.php new file mode 100644 index 0000000..a0af7fe --- /dev/null +++ b/tests/Unit/Libraries/DecimalCoordinateHelperTest.php @@ -0,0 +1,66 @@ +expectException(CoordinateOutOfBoundsException::class); + DecimalCoordinateHelper::validateCoordinatePairOrFail(1000, 0.1); + } + + public function testItCanValidateALatitudeCoordinate() + { + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(0)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(-1.2123413)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(89.00123)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(90)); + $this->assertTrue(DecimalCoordinateHelper::validateLatitudeCoordinate(-90)); + + $this->assertFalse(DecimalCoordinateHelper::validateLatitudeCoordinate(90.1)); + $this->assertFalse(DecimalCoordinateHelper::validateLatitudeCoordinate(-90.1)); + } + + public function testItCanValidateALongitudeCoordinate() + { + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(0)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(156.01231)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(-156.0123)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(180)); + $this->assertTrue(DecimalCoordinateHelper::validateLongitudeCoordinate(-180)); + + $this->assertFalse(DecimalCoordinateHelper::validateLongitudeCoordinate(180.1)); + $this->assertFalse(DecimalCoordinateHelper::validateLongitudeCoordinate(-180.1)); + } + + public function testItCanFindDistanceBetweenTwoCoordinates() + { + // Moscow to New York + $this->assertEquals(7512, round(DecimalCoordinateHelper::distanceBetweenCoordinates(...array_merge($this->moscowCoords, $this->newYorkCoords)))); + + // Flip direction + $this->assertEquals(7512, round(DecimalCoordinateHelper::distanceBetweenCoordinates(...array_merge($this->newYorkCoords,$this->moscowCoords)))); + } + + public function testItCanFindCoordinatesFromDistanceAndBearing() + { + $set1 = DecimalCoordinateHelper::distanceFromCoordinate($this->newYorkCoords[0], $this->newYorkCoords[1], 10, 45); + $set2 = DecimalCoordinateHelper::distanceFromCoordinate($this->newYorkCoords[0], $this->newYorkCoords[1], 10, 195); + $this->assertEquals(40.72456128648007, $set1->latitude); + $this->assertEquals(-73.86008993799598, $set1->longitude); + + $this->assertEquals(40.574128150080696, $set2->latitude); + $this->assertEquals( -73.9746440460457, $set2->longitude); + } + +} \ No newline at end of file diff --git a/tests/Unit/Libraries/OSMStandDataTest.php b/tests/Unit/Libraries/OSMStandDataTest.php new file mode 100644 index 0000000..3d850d1 --- /dev/null +++ b/tests/Unit/Libraries/OSMStandDataTest.php @@ -0,0 +1,104 @@ +instance = new OSMStandData('EGKK'); + + // Delete any generated files + $this->instance->deleteCachedData(); + } + + public function testItThrowsIfInvalidICAOCode() + { + $this->expectException(InvalidICAOCodeException::class); + new OSMStandData('1234'); + + $this->expectException(InvalidICAOCodeException::class); + new OSMStandData('EGK'); + } + + public function testItCanBeConstructed() + { + $this->expectNotToPerformAssertions(); + new OSMStandData('EGKK', new Client()); + } + + public function testItDefersToCachedData() + { + $client = Mockery::spy(Client::class); + $instance = new OSMStandData('EGKK', $client); + // Insert the file + file_put_contents($this->instance->getCacheFilePath(), ''); + + $this->assertEquals($this->instance->getCacheFilePath(), $instance->fetchStandData(1,1)); + $client->shouldNotHaveBeenCalled(); + } + + public function testItCanParseExampleAPIResponse() + { + $responseFile = file_get_contents(dirname(__FILE__).'/../../Fixtures/OSMExample/exampleAPIResponse.csv'); + $parsedFile = str_replace("\r", "", file_get_contents(dirname(__FILE__).'/../../Fixtures/OSMExample/expectedOutput.csv')); + + $client = Mockery::mock(Client::class); + $client->shouldReceive('get->getBody->getContents') + ->andReturn($responseFile); + + $instance = new OSMStandData('EGKK', $client); + + $this->assertEquals($this->instance->getCacheFilePath(), $instance->fetchStandData(1, 1, 20)); + $this->assertEquals($parsedFile, file_get_contents($this->instance->getCacheFilePath())); + } + + public function testItAllowsEmptyCSV() + { + $responseFile = file_get_contents(dirname(__FILE__).'/../../Fixtures/OSMExample/exampleEmptyAPIResponse.csv'); + + $client = Mockery::mock(Client::class); + $client->shouldReceive('get->getBody->getContents') + ->andReturn($responseFile); + + $instance = new OSMStandData('EGKK', $client); + + $this->assertEquals($this->instance->getCacheFilePath(), $instance->fetchStandData(1, 1, 20)); + $this->assertFileExists($this->instance->getCacheFilePath()); + } + + public function testItCanSetCacheTTL() + { + $this->assertInstanceOf(OSMStandData::class, $this->instance->setCacheTTL(60)); + } + + public function testItCanSetTimeout() + { + $this->assertInstanceOf(OSMStandData::class, $this->instance->setTimeout(60)); + } + + public function testItCanDeleteCachedData() + { + // Insert the file + file_put_contents($this->instance->getCacheFilePath(), ''); + + $this->assertTrue($this->instance->deleteCachedData()); + + // No file to delete, expect false + $this->assertFalse($this->instance->deleteCachedData()); + } + +} \ No newline at end of file diff --git a/tests/Unit/StandStatusTest.php b/tests/Unit/StandStatusTest.php new file mode 100644 index 0000000..8d7bdb9 --- /dev/null +++ b/tests/Unit/StandStatusTest.php @@ -0,0 +1,253 @@ + "TEST1", + "latitude" => 51.148056, + "longitude" => -0.190278, + "altitude" => 0, + "groundspeed" => 0 + ], + [ // Not on stand + 'callsign' => "TEST2", + "latitude" => 51.15092, + "longitude" => -0.18304, + "altitude" => 0, + "groundspeed" => 5 + ], + [ // Doesn't match altitude filter + 'callsign' => "TEST3", + "latitude" => 51.15092, + "longitude" => -0.18304, + "altitude" => 5000, + "groundspeed" => 5, + ], + [ // Doesn't match distance filter + 'callsign' => "TEST4", + "latitude" => 53.15092, + "longitude" => -0.18304, + "altitude" => 0, + "groundspeed" => 0, + ], + [ // On Stand 43N + 'callsign' => "TEST5", + "latitude" => 51.15712, + "longitude" => -0.17373, + "altitude" => 0, + "groundspeed" => 0, + ] + + ]; + + private $instance; + + protected function setUp(): void + { + parent::setUp(); + $this->standDataFileCAA = dirname(__DIR__)."/Fixtures/SampleData/egkkstands.csv"; + $this->standDataFileDecimal = dirname(__DIR__)."/Fixtures/SampleData/decimalexample.csv"; + $this->standDataFileInvalid = dirname(__DIR__)."/Fixtures/SampleData/invalidexample.csv"; + $this->standDataFileNoHeaders = dirname(__DIR__)."/Fixtures/SampleData/decimalexamplenoheaders.csv"; + + $this->instance = \Mockery::mock(StandStatus::class, [ + 51.148056, + -0.190278, + StandStatus::COORD_FORMAT_CAA, + ])->makePartial(); + $this->instance->loadStandDataFromCSV($this->standDataFileCAA); + $this->instance->shouldReceive('getVATSIMPilots')->andReturn($this->testPilots); + $this->instance->parseData(); + } + + public function testItRefreshesAssignmentsWhenParsedAgain() + { + $this->instance = \Mockery::mock(StandStatus::class, [ + 51.148056, + -0.190278, + StandStatus::COORD_FORMAT_CAA, + ])->makePartial(); + $this->instance->shouldReceive('getVATSIMPilots')->andReturn($this->testPilots, array_slice($this->testPilots, 0, 3)); + $this->instance->loadStandDataFromCSV($this->standDataFileCAA)->parseData(); + + $this->assertTrue($this->instance->stands(true)['43N']->isOccupied()); + $this->assertTrue($this->instance->allStands(true)['43W']->isOccupied()); + $this->assertFalse(isset($this->instance->stands(true)['43W'])); + + $this->instance->parseData(); + + $this->assertFalse($this->instance->stands(true)['43N']->isOccupied()); + $this->assertFalse($this->instance->allStands(true)['43W']->isOccupied()); + $this->assertTrue(isset($this->instance->stands(true)['43W'])); + } + + public function testItCanParseDecimalFormat() + { + $mock = \Mockery::mock(StandStatus::class)->makePartial(); + $mock->shouldReceive('getVATSIMPilots')->andReturn($this->testPilots); + $mock->shouldReceive('parseData')->once(); + $mock->__construct( + 51.148056, + -0.190278 + ); + $mock->loadStandDataFromCSV($this->standDataFileDecimal)->parseData(); + } + + public function testItCanParseWithNoHeaders() + { + $instance = $this->createNewInstance(); + $instance->loadStandDataFromCSV($this->standDataFileNoHeaders); + $this->assertEquals('1', $instance->stands(true)['1']->getKey()); + } + + public function testItThrowsWithInvalidCoordinates() + { + $this->expectException(CoordinateOutOfBoundsException::class); + new StandStatus($this->standDataFileCAA, 1000, 0.1); + } + + public function testItThrowsIfItCantFindStandDataFile() + { + $instance = $this->createNewInstance(); + $this->expectException(UnableToLoadStandDataFileException::class); + $instance->loadStandDataFromCSV(''); + } + + public function testItThrowsIfItDataFileContentInvalid() + { + $instance = $this->createNewInstance(); + $this->expectException(UnableToParseStandDataException::class); + $instance->loadStandDataFromCSV($this->standDataFileInvalid); + } + + public function testItThrowsIfNoStandDataLoaded() + { + $instance = $this->createNewInstance(); + $this->expectException(NoStandDataException::class); + $instance->parseData(); + } + + public function testItCanLoadFromArray() + { + $this->instance->loadStandDataFromArray([ + ['1','510917.35N','0000953.33W'], + ['2','510915.83N','0000952.81W'], + ['3','510914.31N','0000952.28W'] + ]); + $this->assertCount(3, $this->instance->stands()); + } + + public function testItParsesOk() + { + $this->instance->setHideStandSidesWhenOccupied(false)->parseData(); + $this->assertCount(186, $this->instance->stands()); + } + + public function testItCanGetVATSIMPilots() + { + $mock = \Mockery::mock(VatsimData::class); + $mock->shouldReceive('loadData')->andReturn(true); + $mock->shouldReceive('getPilots->toArray')->andReturn($this->testPilots); + + $instance = $this->createNewInstance(); + $this->assertIsArray($instance->getVATSIMPilots($mock)); + } + + public function testGetVATSIMPilotsReturnsNullIfLoadDataFails() + { + $mock = \Mockery::mock(VatsimData::class); + $mock->shouldReceive('loadData')->andReturn(false); + + $instance = $this->createNewInstance(); + $this->assertNull($instance->getVATSIMPilots($mock)); + } + + public function testItFiltersPilotsCorrectly() + { + $this->assertEquals(['TEST1', 'TEST2', 'TEST5'], array_map(function(Aircraft $aircraft){ + return $aircraft->callsign; + }, $this->instance->getAllAircraft())); + } + public function testItAssignsStandsCorrectly() + { + $this->assertNull($this->instance->getAllAircraft()[0]->getStandIndex()); + $this->assertNull($this->instance->getAllAircraft()[1]->getStandIndex()); + $this->assertEquals('43N', $this->instance->getAllAircraft()[2]->getStandIndex()); + } + + public function testItReturnsListOfStandsAndAllStands() + { + $this->assertCount(186, $this->instance->allStands()); + $this->assertCount(183, $this->instance->stands()); + } + + public function testItReturnsListOfOccupiedStands() + { + $this->assertCount(1, $this->instance->occupiedStands()); + $this->assertEquals('TEST5', $this->instance->occupiedStands()[0]->occupier->callsign); + $this->assertEquals('TEST5', $this->instance->occupiedStands(true)['43N']->occupier->callsign); + } + + public function testItReturnsListOfUnoccupiedStands() + { + // 186 stands, 1 occupied which includes 3 side stands = 183 + $this->assertCount(182, $this->instance->unoccupiedStands()); + $this->assertNull($this->instance->unoccupiedStands()[0]->occupier); + $this->assertNull($this->instance->unoccupiedStands(true)['42']->occupier); + } + + public function testGettersAndSetters() + { + $instance = $this->createNewInstance(); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxStandDistance(1.11)); + $this->assertEquals(1.11, $instance->getMaxStandDistance()); + + $this->assertInstanceOf(StandStatus::class, $instance->setHideStandSidesWhenOccupied(false)); + $this->assertFalse($instance->getHideStandSidesWhenOccupied()); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxDistanceFromAirport(1.11)); + $this->assertEquals(1.11, $instance->getMaxDistanceFromAirport()); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxAircraftAltitude(111)); + $this->assertEquals(111, $instance->getMaxAircraftAltitude()); + + $this->assertInstanceOf(StandStatus::class, $instance->setMaxAircraftGroundspeed(11)); + $this->assertEquals(11, $instance->getMaxAircraftGroundspeed()); + + $this->assertInstanceOf(StandStatus::class, $instance->setStandExtensions(['A', 'B', 'C'])); + $this->assertEquals(['A', 'B', 'C'], $instance->getStandExtensions()); + + $this->assertInstanceOf(StandStatus::class, $instance->setStandExtensionPattern('')); + $this->assertEquals('', $instance->getStandExtensionPattern()); + } + + private function createNewInstance() + { + return new StandStatus( + 51.148056, + -0.190278, + StandStatus::COORD_FORMAT_CAA); + } +} \ No newline at end of file diff --git a/tests/Unit/StandTest.php b/tests/Unit/StandTest.php new file mode 100644 index 0000000..21f25c7 --- /dev/null +++ b/tests/Unit/StandTest.php @@ -0,0 +1,99 @@ +'; + + protected function setUp(): void + { + parent::setUp(); + $this->instance = new Stand($this->standID, $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); + } + + public function testItThrowsIfNullID() + { + $this->expectException(InvalidStandException::class); + new Stand(null,1,2, $this->standExtensions, $this->standPattern); + } + + public function testItThrowsIfEmptyID() + { + $this->expectException(InvalidStandException::class); + new Stand('',1,2, $this->standExtensions, $this->standPattern); + } + + public function testItMapsClassPropertiesToData() + { + $this->assertEquals($this->standID, $this->instance->id); + $this->assertEquals($this->standLatitude, $this->instance->latitude); + $this->assertEquals($this->standLongitude, $this->instance->longitude); + $this->assertNull($this->instance->occupier); + $this->assertNull($this->instance->invalid_property); + } + + public function testItCanSetOccupier() + { + $this->assertFalse($this->instance->isOccupied()); + $this->instance->setOccupier(new Aircraft($this->testPilot)); + $this->assertTrue($this->instance->isOccupied()); + $this->instance->setOccupier(null); + $this->assertFalse($this->instance->isOccupied()); + } + + public function testOccupierMustBeAircraftOrNull() + { + $this->expectException(\TypeError::class); + $this->instance->setOccupier('Not an aircraft'); + } + + public function testItCanGetStandKey() + { + $this->assertEquals($this->standID, $this->instance->getKey()); + } + + public function testItCanGetStandName() + { + $this->assertEquals($this->standID, $this->instance->getName()); + } + + public function testItCanDetermineRoot() + { + $stand2 = new Stand('144', $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); + $stand3 = new Stand('L73', $this->standLatitude, $this->standLongitude, $this->standExtensions, ''); + + $this->assertEquals('25', $this->instance->getRoot()); + $this->assertEquals('144', $stand2->getRoot()); + $this->assertEquals('73', $stand3->getRoot()); + } + + public function testItFailsGracefullyIfNoMatches() + { + $stand = new Stand('144R', $this->standLatitude, $this->standLongitude, $this->standExtensions, ''); + + $this->assertNull($stand->getRoot()); + $this->assertNull($stand->getExtension()); + } + + public function testItCanDetermineExtension() + { + $stand2 = new Stand('144', $this->standLatitude, $this->standLongitude, $this->standExtensions, $this->standPattern); + + $this->assertEquals('R', $this->instance->getExtension()); + $this->assertNull($stand2->getExtension()); + } + +} \ No newline at end of file