diff --git a/app/Support/Metar.php b/app/Support/Metar.php index dbd34506..eed42ec5 100644 --- a/app/Support/Metar.php +++ b/app/Support/Metar.php @@ -2,213 +2,1416 @@ namespace App\Support; -use MetarDecoder\MetarDecoder; - /** - * Wrapper around the METAR decoder. Compensate for - * errors and have tests around this functionality + * Class Metar * @package App\Support + * + * Modified by Nabeel S, adapted for phpVMS + * Copyright left intact below! */ + +/* + =========================== + HSDN METAR/TAF Parser Class + =========================== + Version: 0.55.4b + Based on GetWx script by Mark Woodward. + (c) 2013-2015, Information Networks, Ltd. (http://www.hsdn.org/) + (c) 2001-2006, Mark Woodward (http://woody.cowpi.com/phpscripts/) + This script is a PHP library which allows to parse the METAR and TAF code, + and convert it to an array of data parameters. These METAR or TAF can be given + in the form of the ICAO code string (in this case, the script will receive data + from the NOAA website) or in raw format (just METAR/TAF code string). METAR or + TAF code parsed using the syntactic analysis and regular expressions. It solves + the problem of parsing the data in the presence of any error in the code METAR + or TAF. In addition to the return METAR parameters, the script also displays the + interpreted (easy to understand) information of these parameters. +*/ class Metar { - private $metar, - $metar_str; + /* + * Array of decoded result, by default all parameters is null. + */ + public $result = [ + 'raw' => null, + 'taf' => null, + 'taf_flag' => null, + 'station' => null, + 'observed_date' => null, + 'observed_day' => null, + 'observed_time' => null, + 'observed_age' => null, + 'wind_speed' => null, + 'wind_gust_speed' => null, + 'wind_direction' => null, + 'wind_direction_label' => null, + 'wind_direction_varies' => null, + 'varies_wind_min' => null, + 'varies_wind_min_label' => null, + 'varies_wind_max' => null, + 'varies_wind_max_label' => null, + 'visibility' => null, + 'visibility_report' => null, + 'visibility_min' => null, + 'visibility_min_direction' => null, + 'runways_visual_range' => null, + 'present_weather' => null, + 'present_weather_report' => null, + 'clouds' => null, + 'clouds_report' => null, + 'cloud_height' => null, + 'cavok' => null, + 'temperature' => null, + 'temperature_f' => null, + 'dew_point' => null, + 'dew_point_f' => null, + 'humidity' => null, + 'heat_index' => null, + 'heat_index_f' => null, + 'wind_chill' => null, + 'wind_chill_f' => null, + 'barometer' => null, + 'barometer_in' => null, + 'recent_weather' => null, + 'recent_weather_report' => null, + 'runways_report' => null, + 'runways_snoclo' => null, + 'wind_shear_all_runways' => null, + 'wind_shear_runways' => null, + 'forecast_temperature_min' => null, + 'forecast_temperature_max' => null, + 'trends' => null, + 'remarks' => null, + ]; + + /* + * Methods used for parsing in the order of data + */ + private static $method_names = [ + 'taf', + 'station', + 'time', + 'station_type', + 'wind', + 'varies_wind', + 'visibility', + 'visibility_min', + 'runway_vr', + 'present_weather', + 'clouds', + 'temperature', + 'pressure', + 'recent_weather', + 'runways_report', + 'wind_shear', + 'forecast_temperature', + 'trends', + 'remarks', + ]; + + /* + * Interpretation of weather conditions intensity codes. + */ + private static $weather_intensity_codes = [ + '-' => 'light', + '+' => 'strong', + 'VC' => 'in the vicinity', + ]; + + /* + * Interpretation of weather conditions characteristics codes. + */ + private static $weather_char_codes = [ + 'MI' => 'shallow', + 'PR' => 'partial', + 'BC' => 'patches of', + 'DR' => 'low drifting', + 'BL' => 'blowing', + 'SH' => 'showers of', + 'TS' => 'thunderstorms', + 'FZ' => 'freezing', + ]; + + /* + * Interpretation of weather conditions type codes. + */ + private static $weather_type_codes = [ + 'DZ' => 'drizzle', + 'RA' => 'rain', + 'SN' => 'snow', + 'SG' => 'snow grains', + 'IC' => 'ice crystals', + 'PE' => 'ice pellets', + 'GR' => 'hail', + 'GS' => 'small hail', // and/or snow pellets + 'UP' => 'unknown', + 'BR' => 'mist', + 'FG' => 'fog', + 'FU' => 'smoke', + 'VA' => 'volcanic ash', + 'DU' => 'widespread dust', + 'SA' => 'sand', + 'HZ' => 'haze', + 'PY' => 'spray', + 'PO' => 'well-developed dust/sand whirls', + 'SQ' => 'squalls', + 'FC' => 'funnel cloud, tornado, or waterspout', + 'SS' => 'sandstorm/duststorm', + ]; + + /* + * Interpretation of cloud cover codes. + */ + private static $cloud_codes = [ + 'NSW' => 'no significant weather are observed', + 'NSC' => 'no significant clouds are observed', + 'NCD' => 'nil cloud detected', + 'SKC' => 'no significant changes expected', + 'CLR' => 'clear skies', + 'NOBS' => 'no observation', + // + 'FEW' => 'a few', + 'SCT' => 'scattered', + 'BKN' => 'broken sky', + 'OVC' => 'overcast sky', + // + 'VV' => 'vertical visibility', + ]; + + /* + * Interpretation of cloud cover type codes. + */ + private static $cloud_type_codes = [ + 'CB' => 'cumulonimbus', + 'TCU' => 'towering cumulus', + ]; + + /* + * Interpretation of runway visual range tendency codes. + */ + private static $rvr_tendency_codes = [ + 'D' => 'decreasing', + 'U' => 'increasing', + 'N' => 'no tendency', + ]; + + /* + * Interpretation of runway visual range prefix codes. + */ + private static $rvr_prefix_codes = [ + 'P' => 'more', + 'M' => 'less', + ]; + + /* + * Interpretation of runway runway deposits codes. + */ + private static $runway_deposits_codes = [ + '0' => 'clear and dry', + '1' => 'damp', + '2' => 'wet or water patches', + '3' => 'rime or frost covered', + '4' => 'dry snow', + '5' => 'wet snow', + '6' => 'slush', + '7' => 'ice', + '8' => 'compacted or rolled snow', + '9' => 'frozen ruts or ridges', + '/' => 'not reported', + ]; + + /* + * Interpretation of runway runway deposits extent codes. + */ + private static $runway_deposits_extent_codes = [ + '1' => 'from 10% or less', + '2' => 'from 11% to 25%', + '5' => 'from 26% to 50%', + '9' => 'from 51% to 100%', + '/' => null, + ]; + + /* + * Interpretation of runway runway deposits depth codes. + */ + private static $runway_deposits_depth_codes = [ + '00' => 'less than 1 mm', + '92' => '10 cm', + '93' => '15 cm', + '94' => '20 cm', + '95' => '25 cm', + '96' => '30 cm', + '97' => '35 cm', + '98' => '40 cm or more', + '99' => 'closed', + '//' => null, + ]; + + /* + * Interpretation of runway runway friction codes. + */ + private static $runway_friction_codes = [ + '91' => 'poor', + '92' => 'medium/poor', + '93' => 'medium', + '94' => 'medium/good', + '95' => 'good', + '99' => 'figures unreliable', + '//' => null, + ]; + + /* + * Trends time codes. + */ + private static $trends_flag_codes = [ + 'BECMG' => 'expected to arise soon', + 'TEMPO' => 'expected to arise temporarily', + 'INTER' => 'expected to arise intermittent', + 'PROV' => 'provisional forecast', + 'CNL' => 'cancelled forecast', + 'NIL' => 'nil forecast', + ]; + + /* + * Trends time codes. + */ + private static $trends_time_codes = [ + 'AT' => 'at', + 'FM' => 'from', + 'TL' => 'until', + ]; + + /* + * Interpretation of compass degrees codes. + */ + private static $direction_codes = [ + 'N', 'NNE', 'NE', 'ENE', + 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW', 'SW', 'WSW', + 'W', 'WNW', 'NW', 'NNW', + ]; + + /* + * Debug and parse errors information. + */ + + private $debug = []; + private $debug_enabled; + + public $errors = []; + + /* + * Other variables. + */ + private $raw; + private $raw_parts = []; + private $method = 0; + private $part = 0; /** - * Metar constructor. - * @param $metar_str + * This method provides METAR and TAF information, you want to parse. + * + * Examples of raw METAR for test: + * UMMS 231530Z 21002MPS 2100 BR OVC002 07/07 Q1008 R13/290062 NOSIG RMK QBB070 + * UWSS 231500Z 14007MPS 9999 -SHRA BR BKN033CB OVC066 03/M02 Q1019 R12/220395 NOSIG RMK QFE752 + * UWSS 241200Z 12003MPS 0300 R12/1000 DZ FG VV003CB 05/05 Q1015 R12/220395 NOSIG RMK QFE749 + * UATT 231530Z 18004MPS 130V200 CAVOK M03/M08 Q1033 R13/0///60 NOSIG RMK QFE755/1006 + * KEYW 231553Z 04008G16KT 10SM FEW060 28/22 A3002 RMK AO2 SLP166 T02780222 + * EFVR 231620Z AUTO 19002KT 5000 BR FEW003 BKN005 OVC007 09/08 Q0998 + * KTTN 051853Z 04011KT M1/2SM VCTS SN FZFG BKN003 OVC010 M02/M02 A3006 RMK AO2 TSB40 SLP176 P0002 T10171017= + * UEEE 072000Z 00000MPS 0150 R23L/0500 R10/1000VP1800D FG VV003 M50/M53 Q1028 RETSRA R12/290395 R31/CLRD// R/SNOCLO WS RWY10L WS RWY11L TEMPO 4000 RADZ BKN010 RMK QBB080 OFE745 + * UKDR 251830Z 00000MPS CAVOK 08/07 Q1019 3619//60 NOSIG + * UBBB 251900Z 34015KT 9999 FEW013 BKN030 16/14 Q1016 88CLRD70 NOSIG + * UMMS 251936Z 19002MPS 9999 SCT006 OVC026 06/05 Q1015 R31/D NOSIG RMK QBB080 OFE745 */ - public function __construct($metar_str) + public function __construct($raw, $taf = false, $debug = false, $icao = true) { - $decoder = new MetarDecoder(); - $this->metar = $decoder->parse($metar_str); - $this->metar_str = $metar_str; - } + $this->debug_enabled = $debug; - /** - * Return if this is VFR or IFR conditions - * @return string - */ - public function getCategory(): string - { - $category = 'VFR'; - - $visibility = $this->getVisibility(false); - $ceiling = $this->getCeiling(false); - - if ($visibility < 3 || $ceiling < 1000) { - $category = 'IFR'; - } - - return $category; - } - - /** - * Return the ceiling - * @param bool $convert - * @return int - */ - public function getCeiling($convert = true): int - { - $ceiling = 1000; - $clouds = $this->metar->getClouds(); - if ($clouds && \count($clouds) > 0) { - $ceiling = $clouds[0]->getBaseHeight()->getValue(); - } - - if(!$convert) { - return $ceiling; - } - - return $ceiling; - } - - /** - * Return all of the cloud layers - */ - public function getClouds(): array - { - if (!$this->metar->getClouds()) { - return []; - } - - $layers = []; - $unit = setting('units.altitude'); - - foreach($this->metar->getClouds() as $cloud) { - if($unit === 'ft') { - $base_height = $cloud->getBaseHeight()->getValue(); - } else { - $base_height = $cloud->getBaseHeight()->getConvertedValue('m'); + $raw_lines = explode("\n", $raw, 2); + if (isset($raw_lines[1])) { + $raw = trim($raw_lines[1]); + // Get observed time from a file data + $observed_time = strtotime(trim($raw_lines[0])); + if ($observed_time !== 0) { + $this->set_observed_date($observed_time); + $this->set_debug('Observation date is set from the METAR/TAF in first line of the file content: '.trim($raw_lines[0])); } - - $layers[] = [ - 'amount' => $cloud->getAmount(), - 'base_height' => $base_height, - - ]; + } else { + $raw = trim($raw_lines[0]); } - return $layers; + $this->raw = rtrim(trim(preg_replace('/[\s\t]+/s', ' ', $raw)), '='); + if ($taf) { + $this->set_debug('Information presented as TAF or trend.'); + } else { + $this->set_debug('Information presented as METAR.'); + } + + $this->set_result_value('taf', $taf); + $this->set_result_value('raw', $this->raw); + + $this->parse_all(); } /** - * Last update time - * @return string - */ - public function getLastUpdate(): string - { - return $this->metar->getTime(); - } - - /** - * Get the pressure, pass in the unit type - * @param string $unit Pass mb for millibars, hg for hg - * @return float|null - */ - public function getPressure($unit = 'mb') - { - if (!$this->metar->getPressure()) { - return null; - } - - $pressure = $this->metar->getPressure()->getValue(); - if (strtolower($unit) === 'mb') { - return $pressure; - } - - return round($pressure * 33.86, 2); - } - - /** - * Return the raw metar string + * Shortcut to call + * @param $metar + * @param string $taf * @return mixed */ - public function getRawMetar() + public static function parse($metar, $taf = '') { - return $this->metar_str; + $mtr = new static($metar, $taf); + return $mtr->parse_all(); } /** - * Return the temperature, if it exists in the METAR - * Convert to the units that are set in the VA - * @return float|null + * Gets the value from result array as class property. */ - public function getTemperature() + public function __get($parameter) { - if (!$this->metar->getAirTemperature()) { - return null; + if (isset($this->result[$parameter])) { + return $this->result[$parameter]; } - - if(setting('units.temperature') === 'c') { - return $this->metar->getAirTemperature()->getValue(); - } - - // Convert to F - round(($this->metar->getAirTemperature()->getValue() * 9 / 5) + 32, 2); + return null; } /** - * Get the visibility - * @param bool $convert - * @return int + * Parses the METAR or TAF information and returns result array. */ - public function getVisibility($convert=true): int + public function parse_all(): array { - // initially in miles - $visibility = 10; // assume it's ok and VFR - $vis = $this->metar->getVisibility(); - if ($vis) { - $vis = $vis->getVisibility(); - if ($vis) { - $visibility = (int) $vis->getValue(); + $this->raw_parts = explode(' ', $this->raw); + $current_method = 0; - if ($convert && setting('units.distance') === 'km') { - return $vis->getConvertedValue('m') / 1000; + $raw_part_count = \count($this->raw_parts); + $method_name_count = \count(static::$method_names); + + while ($this->part < $raw_part_count) { + $this->method = $current_method; + // See methods + while ($this->method < $method_name_count) { + $method = 'get_'.static::$method_names[$this->method]; + $token = $this->raw_parts[$this->part]; + if ($this->$method($token) === true) { + $this->set_debug('Token "'.$token.'" is parsed by method: '.$method.', '. + ($this->method - $current_method).' previous methods skipped.'); + $current_method = $this->method; + $this->method++; + break; } + $this->method++; + } - return $visibility; + if ($current_method !== $this->method - 1) { + $this->set_error('Unknown token: '.$this->raw_parts[$this->part]); + $this->set_debug('Token "'.$this->raw_parts[$this->part].'" is NOT PARSED, '. + ($this->method - $current_method).' methods attempted.'); + } + + $this->part++; + } + // Delete null values from the TAF report + if ($this->result['taf'] === true) { + foreach ($this->result as $parameter => $value) { + if (!$value) { + unset($this->result[$parameter]); + } } } - if($convert && setting('units.distance') === 'km') { - return round($visibility * 1.60934, 2); - } - - return $visibility; + return $this->result; } /** - * Return wind information + * Returns array with debug information. */ - public function getWinds() + public function debug() { - $sw = $this->metar->getSurfaceWind(); - if (!$sw) { - return null; + return $this->debug; + } + + /** + * Returns array with parse errors. + */ + public function errors() + { + return $this->errors; + } + + /** + * This method formats observation date and time in the local time zone of server, + * the current local time on server, and time difference since observation. $time_utc is a + * UNIX timestamp for Universal Coordinated Time (Greenwich Mean Time or Zulu Time). + */ + private function set_observed_date($time_utc) + { + $now = time(); + $local = $time_utc + date('Z'); + + $this->set_result_value('observed_date', date('r', $local)); // or "D M j, H:i T" + $time_diff = floor(($now - $local) / 60); + if ($time_diff < 91) { + $this->set_result_value('observed_age', $time_diff.' min. ago'); + } else { + $this->set_result_value('observed_age', floor($time_diff / 60).':'.sprintf("%02d", $time_diff % 60).' hr. ago'); + } + } + + /** + * Sets the new value to parameter in result array. + * @param $parameter + * @param $value + * @param bool $only_if_null + */ + private function set_result_value($parameter, $value, $only_if_null = false) + { + if ($only_if_null) { + if ($this->result[$parameter] === null) { + $this->result[$parameter] = $value; + //$this->set_debug('Set value "'.$value.'" ('.gettype($value).') for null parameter: '.$parameter); + } + } else { + $this->result[$parameter] = $value; + //$this->set_debug('Set value "'.$value.'" ('.gettype($value).') for parameter: '.$parameter); + } + } + + /** + * Sets the data group to parameter in result array. + */ + private function set_result_group($parameter, $group) + { + if ($this->result[$parameter] === null) { + $this->result[$parameter] = []; } - $ret = [ - 'speed' => null, - 'direction' => null, - 'gusts' => null, + $this->result[$parameter][] = $group; + //$this->set_debug('Add new group value ('.gettype($group).') for parameter: '.$parameter); + } + + /** + * Sets the report text to parameter in result array. + * @param $parameter + * @param $report + * @param string $separator + */ + private function set_result_report($parameter, $report, $separator = ';') + { + $this->result[$parameter] .= $separator.' '.$report; + if ($this->result[$parameter] !== null) { + $this->result[$parameter] = ucfirst(ltrim($this->result[$parameter], ' '.$separator)); + } + + //$this->set_debug('Add group report value "'.$report.'" for parameter: '.$parameter); + } + + /** + * Adds the debug text to debug information array. + */ + private function set_debug($text) + { + if ($this->debug_enabled) { + $this->debug[] = $text; + } + } + + /** + * Adds the error text to parse errors array. + */ + private function set_error($text) + { + $this->errors[] = $text; + } + + // -------------------------------------------------------------------- + // Methods for parsing raw parts + // -------------------------------------------------------------------- + /** + * Decodes TAF code if present. + */ + private function get_taf($part) + { + if ($part !== 'TAF') { + return false; + } + + if ($this->raw_parts[$this->part + 1] === 'COR' + || $this->raw_parts[$this->part + 1] === 'AMD') + { + $this->set_result_value('taf_flag', $this->raw_parts[$this->part + 1], true); + $this->part++; + } + + $this->set_debug('TAF information detected.'); + $this->set_result_value('taf', true); + return true; + } + + /** + * Decodes station code. + */ + private function get_station($part) + { + if (!preg_match('@^([A-Z]{1}[A-Z0-9]{3})$@', $part, $found)) { + return false; + } + + $this->set_result_value('station', $found[1]); + $this->method++; + return true; + } + + /** + * Decodes observation time. + * Format is ddhhmmZ where dd = day, hh = hours, mm = minutes in UTC time. + */ + private function get_time($part) + { + if (!preg_match('@^([\d]{2})([\d]{2})([\d]{2})Z$@', $part, $found)) { + return false; + } + + $day = (int) $found[1]; + $hour = (int) $found[2]; + $minute = (int) $found[3]; + + if ($this->result['observed_date'] === null) { + // Get observed time from a METAR/TAF part + $observed_time = mktime($hour, $minute, 0, date('n'), $day, date('Y')); + // Take one month, if the observed day is greater than the current day + if ($day > date('j')) { + $observed_time = strtotime('-1 month'); + } + + $this->set_observed_date($observed_time); + $this->set_debug('Observation date is set from the METAR/TAF information (presented in format: ddhhmmZ)'); + } + + $this->set_result_value('observed_day', $day); + $this->set_result_value('observed_time', $found[2].':'.$found[3].' UTC'); + $this->method++; + return true; + } + + /** + * Ignore station type if present. + */ + private function get_station_type($part) + { + if ($part !== 'AUTO' && $part !== 'COR') { + return false; + } + + $this->method++; + return true; + } + + /** + * Decodes wind direction and speed information. + * Format is dddssKT where ddd = degrees from North, ss = speed, KT for knots, + * or dddssGggKT where G stands for gust and gg = gust speed. (ss or gg can be a 3-digit number.) + * KT can be replaced with MPH for meters per second or KMH for kilometers per hour. + */ + private function get_wind($part) + { + if (!preg_match('@^([\d]{3}|VRB|///)P?([/0-9]{2,3}|//)(GP?([\d]{2,3}))?(KT|MPS|KPH)@', $part, $found)) { + return false; + } + $this->set_result_value('wind_direction_varies', false, true); + + if ($found[1] === '///' && $found[2] === '//') { + + } // handle the case where nothing is observed + else { + $unit = $found[5]; + // Speed + $this->set_result_value('wind_speed', $this->convert_speed($found[2], $unit)); + + // Direction + if ($found[1] === 'VRB') { + $this->set_result_value('wind_direction_varies', true); + } else { + $direction = (int) $found[1]; + if ($direction >= 0 && $direction <= 360) { + $this->set_result_value('wind_direction', $direction); + $this->set_result_value('wind_direction_label', $this->convert_direction_label($direction)); + } + } + + // Speed variations (gust speed) + if (isset($found[4]) && !empty($found[4])) { + $this->set_result_value('wind_gust_speed', $this->convert_speed($found[4], $unit)); + } + } + + $this->method++; + return true; + } + + /* + * Decodes varies wind direction information if present. + * Format is fffVttt where V stands for varies from fff degrees to ttt degrees. + */ + private function get_varies_wind($part) + { + if (!preg_match('@^([\d]{3})V([\d]{3})$@', $part, $found)) { + return false; + } + $min_direction = (int) $found[1]; + $max_direction = (int) $found[2]; + if ($min_direction >= 0 AND $min_direction <= 360) { + $this->set_result_value('varies_wind_min', $min_direction); + $this->set_result_value('varies_wind_min_label', $this->convert_direction_label($min_direction)); + } + if ($max_direction >= 0 AND $max_direction <= 360) { + $this->set_result_value('varies_wind_max', $max_direction); + $this->set_result_value('varies_wind_max_label', $this->convert_direction_label($max_direction)); + } + $this->method++; + return true; + } + + /** + * Decodes visibility information. This function will be called a second time + * if visibility is limited to an integer mile plus a fraction part. + * Format is mmSM for mm = statute miles, or m n/dSM for m = mile and n/d = fraction of a mile, + * or just a 4-digit number nnnn (with leading zeros) for nnnn = meters. + */ + private function get_visibility($part) + { + if (!preg_match('@^(CAVOK|([0-9]{4})|(M)?([0-9]{0,2})?(([1357])/(2|4|8|16))?SM|////)$@', $part, $found)) { + return false; + } + $this->set_result_value('cavok', false, true); + // Cloud and visibilty OK or ICAO visibilty greater than 10 km + if ($found[1] == 'CAVOK' OR $found[1] == '9999') { + $this->set_result_value('visibility', 10000); + $this->set_result_value('visibility_report', 'Greater than 10 km'); + if ($found[1] == 'CAVOK') { + $this->set_result_value('cavok', true); + $this->method += 4; // can skip the next 4 methods: visibility_min, runway_vr, present_weather, clouds + } + } elseif ($found[1] == '////') { + } // information not available + else { + $prefix = ''; + // ICAO visibility (in meters) + if (isset($found[2]) AND !empty($found[2])) { + $visibility = intval($found[2]); + } // US visibility (in miles) + else { + if (isset($found[3]) AND !empty($found[3])) { + $prefix = 'Less than '; + } + if (isset($found[7]) AND !empty($found[7])) { + $visibility = intval($found[4]) + intval($found[6]) / intval($found[7]); + } else { + $visibility = intval($found[4]); + } + $visibility = $this->convert_distance($visibility, 'SM'); // convert to meters + } + $unit = ' meters'; + if ($visibility <= 1) { + $unit = ' meter'; + } + $this->set_result_value('visibility', $visibility); + $this->set_result_value('visibility_report', $prefix.$visibility.$unit); + } + return true; + } + + /** + * Decodes visibility minimum value and direction if present. + * Format is vvvvDD for vvvv = the minimum horizontal visibility in meters + * (if the visibility is better than 10 km, 9999 is used. 9999 means a minimum + * visibility of 50 m or less), and for DD = the approximate direction of minimum and + * maximum visibility is given as one of eight compass points (N, SW, ...). + */ + private function get_visibility_min($part) + { + if (!preg_match('@^([0-9]{4})(NE|NW|SE|SW|N|E|S|W|)?$@', $part, $found)) { + return false; + } + $this->set_result_value('visibility_min', $found[1]); + if (isset($found[2]) AND !empty($found[2])) { + $this->set_result_value('visibility_min_direction', $found[2]); + } + $this->method++; + return true; + } + + /** + * Decodes runway visual range information if present. + * Format is Rrrr/vvvvFT where rrr = runway number, vvvv = visibility, + * and FT = the visibility in feet. + */ + private function get_runway_vr($part) + { + if (!preg_match('@^R([0-9]{2}[LCR]?)/(([PM])?([0-9]{4})V)?([PM])?([0-9]{4})(FT)?/?([UDN]?)$@', $part, $found)) { + return false; + } + if (intval($found[1]) > 36 OR intval($found[1]) < 1) { + return false; + } + $unit = 'M'; + if (isset($found[6]) AND $found[6] == 'FT') { + $unit = 'FT'; + } + $observed = [ + 'runway' => $found[1], + 'variable' => null, + 'variable_prefix' => null, + 'interval_min' => null, + 'interval_max' => null, + 'tendency' => null, + 'report' => null, ]; - - $mean_speed = $sw->getMeanSpeed(); - if($mean_speed) { - $ret['speed'] = $mean_speed->getConvertedValue('kt'); + // Runway past tendency + if (isset($found[8]) AND isset(static::$rvr_tendency_codes[$found[8]])) { + $observed['tendency'] = $found[8]; } - - $dir = $sw->getMeanDirection(); - if($dir) { - $ret['direction'] = $dir->getValue(); + // Runway visual range + if (isset($found[6])) { + if (!empty($found[4])) { + $observed['interval_min'] = $this->convert_distance($found[4], $unit); + $observed['interval_max'] = $this->convert_distance($found[6], $unit); + if (!empty($found[5])) { + $observed['variable_prefix'] = $found[5]; + } + } else { + $observed['variable'] = $this->convert_distance($found[6], $unit); + } } - - $gusts = $sw->getSpeedVariations(); - if($gusts) { - $ret['gusts'] = $gusts->getConvertedValue('kt'); + // Runway visual range report + if (!empty($observed['runway'])) { + $report = []; + if ($observed['variable'] !== null) { + $unit = ' meters'; + if ($observed['variable'] <= 1) { + $unit = ' meter'; + } + $report[] = $observed['variable'].$unit; + } elseif (!is_null($observed['interval_min']) AND !is_null($observed['interval_max'])) { + if (isset(static::$rvr_prefix_codes[$observed['variable_prefix']])) { + $report[] = 'varying from a min. of '.$observed['interval_min'].' meters until a max. of '. + static::$rvr_prefix_codes[$observed['variable_prefix']].' that '. + $observed['interval_max'].' meters'; + } else { + $report[] = 'varying from a min. of '.$observed['interval_min'].' meters until a max. of '. + $observed['interval_max'].' meters'; + } + } + if (!is_null($observed['tendency'])) { + if (isset(static::$rvr_tendency_codes[$observed['tendency']])) { + $report[] = 'and '.static::$rvr_tendency_codes[$observed['tendency']]; + } + } + $observed['report'] = ucfirst(implode(' ', $report)); } + $this->set_result_group('runways_visual_range', $observed); + return true; + } - return $ret; + /** + * Decodes present weather conditions if present. This function maybe called several times + * to decode all conditions. To learn more about weather condition codes, visit section + * 12.6.8 - Present Weather Group of the Federal Meteorological Handbook No. 1 at + * www.nws.noaa.gov/oso/oso1/oso12/fmh1/fmh1ch12.htm + */ + private function get_present_weather($part) + { + return $this->decode_weather($part, 'present'); + } + + /** + * Decodes cloud cover information if present. This function maybe called several times + * to decode all cloud layer observations. Only the last layer is saved. + * Format is SKC or CLR for clear skies, or cccnnn where ccc = 3-letter code and + * nnn = height of cloud layer in hundreds of feet. 'VV' seems to be used for + * very low cloud layers. + */ + private function get_clouds($part) + { + if (!preg_match('@^((NSW|NSC|NCD|CLR|SKC|NOBS)|((VV|FEW|SCT|BKN|OVC|///)([0-9]{3}|///)(CB|TCU|///)?))$@', $part, $found)) { + return false; + } + $observed = [ + 'amount' => null, + 'height' => null, + 'type' => null, + 'report' => null, + ]; + // Clear skies or no observation + if (isset($found[2]) AND !empty($found[2])) { + if (isset(static::$cloud_codes[$found[2]])) { + $observed['amount'] = $found[2]; + } + } // Cloud cover observed + elseif (isset($found[5]) AND !empty($found[5])) { + $observed['height'] = $this->convert_distance($found[5] * 100, 'FT'); // convert feet to meters + // Cloud height + if (is_null($this->result['cloud_height']) OR $observed['height'] < $this->result['cloud_height']) { + $this->set_result_value('cloud_height', $observed['height']); + } + if (isset(static::$cloud_codes[$found[4]])) { + $observed['amount'] = $found[4]; + } + } + // Type + if (isset($found[6]) AND !empty($found[6])) { + if (isset(static::$cloud_type_codes[$found[6]]) AND $found[4] != 'VV') { + $observed['type'] = $found[6]; + } + } + // Build clouds report + if (!is_null($observed['amount'])) { + $report = []; + $report[] = static::$cloud_codes[$observed['amount']]; + if ($observed['height']) { + if (!is_null($observed['type'])) { + $report[] = 'at '.$observed['height'].' meters, '.static::$cloud_type_codes[$observed['type']]; + } else { + $report[] = 'at '.$observed['height'].' meters'; + } + } + $report = implode(' ', $report); + $observed['report'] = ucfirst($report); + $this->set_result_report('clouds_report', $report); + } + $this->set_result_group('clouds', $observed); + return true; + } + + /** + * Decodes temperature and dew point information. Relative humidity is calculated. Also, + * depending on the temperature, Heat Index or Wind Chill Temperature is calculated. + * Format is tt/dd where tt = temperature and dd = dew point temperature. All units are + * in Celsius. A 'M' preceeding the tt or dd indicates a negative temperature. Some + * stations do not report dew point, so the format is tt/ or tt/XX. + */ + private function get_temperature($part) + { + if (!preg_match('@^(M?[0-9]{2})/(M?[0-9]{2}|[X]{2})?@', $part, $found)) { + return false; + } + // Set clouds and weather reports if its not observed (e.g. clear and dry) + $this->set_result_value('clouds_report', 'Clear skies', true); + $this->set_result_value('present_weather_report', 'Dry', true); + // Temperature + $temperature_c = intval(strtr($found[1], 'M', '-')); + $temperature_f = round(1.8 * $temperature_c + 32); + $this->set_result_value('temperature', $temperature_c); + $this->set_result_value('temperature_f', $temperature_f); + $this->calculate_wind_chill($temperature_f); + // Dew point + if (isset($found[2]) AND strlen($found[2]) != 0 AND $found[2] != 'XX') { + $dew_point_c = intval(strtr($found[2], 'M', '-')); + $dew_point_f = round(1.8 * $dew_point_c + 32); + $rh = round(100 * pow((112 - (0.1 * $temperature_c) + $dew_point_c) / (112 + (0.9 * $temperature_c)), 8)); + $this->set_result_value('dew_point', $dew_point_c); + $this->set_result_value('dew_point_f', $dew_point_f); + $this->set_result_value('humidity', $rh); + $this->calculate_heat_index($temperature_f, $rh); + } + $this->method++; + return true; + } + + /** + * Decodes altimeter or barometer information. + * Format is Annnn where nnnn represents a real number as nn.nn in inches of Hg, + * or Qpppp where pppp = hectoPascals. + * Some other common conversion factors: + * 1 millibar = 1 hPa + * 1 in Hg = 0.02953 hPa + * 1 mm Hg = 25.4 in Hg = 0.750062 hPa + * 1 lb/sq in = 0.491154 in Hg = 0.014504 hPa + * 1 atm = 0.33421 in Hg = 0.0009869 hPa + */ + private function get_pressure($part) + { + if (!preg_match('@^(Q|A)(////|[0-9]{4})@', $part, $found)) { + return false; + } + $pressure = intval($found[2]); + if ($found[1] == 'A') { + $pressure /= 100; + } + $this->set_result_value('barometer', $pressure); // units are hPa + $this->set_result_value('barometer_in', round(0.02953 * $pressure, 2)); // convert to in Hg + $this->method++; + return true; + } + + /** + * Decodes recent weather conditions if present. + * Format is REww where ww = Weather phenomenon code (see get_present_weather above). + */ + private function get_recent_weather($part) + { + return $this->decode_weather($part, 'recent', 'RE'); + } + + /** + * Decodes runways report information if present. + * Format rrrECeeBB or Rrrr/ECeeBB where rr = runway number, E = deposits, + * C = extent of deposit, ee = depth of deposit, BB = friction coefficient. + */ + private function get_runways_report($part) + { + if (!preg_match('@^R?(/?(SNOCLO)|([0-9]{2}[LCR]?)/?(CLRD|([0-9]{1}|/)([0-9]{1}|/)([0-9]{2}|//))([0-9]{2}|//))$@', $part, $found)) { + return false; + } + $this->set_result_value('runways_snoclo', false, true); + // Airport closed due to snow + if (isset($found[2]) AND ($found[2] == 'SNOCLO')) { + $this->set_result_value('runways_snoclo', true); + } else { + $observed = [ + 'runway' => $found[3], // just runway number + 'deposits' => null, + 'deposits_extent' => null, + 'deposits_depth' => null, + 'friction' => null, + 'report' => null, + ]; + // Contamination has disappeared (runway has been cleared) + if (isset($found[4]) AND $found[4] == 'CLRD') { + $observed['deposits'] = 0; // cleared + } // Deposits observed + else { + // Type + $deposits = $found[5]; + if (isset(static::$runway_deposits_codes[$deposits])) { + $observed['deposits'] = $deposits; + } + // Extent + $deposits_extent = $found[6]; + if (isset(static::$runway_deposits_extent_codes[$deposits_extent])) { + $observed['deposits_extent'] = $deposits_extent; + } + // Depth + $deposits_depth = $found[7]; + // Uses in mm + if (intval($deposits_depth) >= 1 AND intval($deposits_depth) <= 90) { + $observed['deposits_depth'] = intval($deposits_depth); + } // Uses codes + elseif (isset(static::$runway_deposits_depth_codes[$deposits_depth])) { + $observed['deposits_depth'] = $deposits_depth; + } + } + // Friction observed + $friction = $found[8]; + // Uses coefficient + if (intval($friction) > 0 AND intval($friction) <= 90) { + $observed['friction'] = round($friction / 100, 2); + } // Uses codes + elseif (isset(static::$runway_friction_codes[$friction])) { + $observed['friction'] = $friction; + } + // Build runways report + $report = []; + if (!is_null($observed['deposits'])) { + $report[] = static::$runway_deposits_codes[$observed['deposits']]; + if (!is_null($observed['deposits_extent'])) { + $report[] = 'contamination '.static::$runway_deposits_extent_codes[$observed['deposits_extent']]; + } + if (!is_null($observed['deposits_depth'])) { + if ($observed['deposits_depth'] == '99') { + $report[] = 'runway closed'; + } elseif (isset(static::$runway_deposits_depth_codes[$observed['deposits_depth']])) { + $report[] = 'deposit is '.static::$runway_deposits_depth_codes[$observed['deposits_depth']].' deep'; + } else { + $report[] = 'deposit is '.$observed['deposits_depth'].' mm deep'; + } + } + } + if (!is_null($observed['friction'])) { + if (isset(static::$runway_friction_codes[$observed['friction']])) { + $report[] = 'a braking action is '.static::$runway_friction_codes[$observed['friction']]; + } else { + $report[] = 'a friction coefficient is '.$observed['friction']; + } + } + $observed['report'] = ucfirst(implode(', ', $report)); + $this->set_result_group('runways_report', $observed); + } + return true; + } + + /** + * Decodes wind shear information if present. + * Format is 'WS ALL RWY' or 'WS RWYdd' where dd = Runway designator (see get_runway_vr above). + */ + private function get_wind_shear($part) + { + if ($part != 'WS') { + return false; + } + $this->set_result_value('wind_shear_all_runways', false, true); + $this->part++; // skip this part with WS + // See two next parts for 'ALL RWY' records + if (implode(' ', array_slice($this->raw_parts, $this->part, 2)) == 'ALL RWY') { + $this->set_result_value('wind_shear_all_runways', true); + $this->part += 2; // can skip neext parts with ALL and RWY records + } // See one next part for RWYdd record + elseif (isset($this->raw_parts[$this->part])) { + $part = $this->raw_parts[$this->part]; + if (!preg_match('@^R(WY)?([0-9]{2}[LCR]?)$@', $part, $found)) { + return false; + } + if (intval($found[2]) > 36 OR intval($found[2]) < 1) { + return false; + } + $this->set_result_group('wind_shear_runways', $found[2]); + } else { + return false; + } + return true; + } + + /** + * Decodes max and min temperature forecast information if present. + * Format TXTtTt/ddHHZ or TNTtTt/ddHHZ, where: + * TX - Indicator for Maximum temperature + * TN - Indicator for Minimum temperature + * TtTt - Temperature value in Celsius + * dd - Forecast day of month + * HH - Forecast hour, i.e. the time(hour) when the temperature is expected + * Z - Time Zone indicator, Z=GMT. + */ + private function get_forecast_temperature($part) + { + if (!preg_match('@^(TX|TN)(M?[0-9]{2})/([0-9]{2})?([0-9]{2})Z$@', $this->raw_parts[$this->part], $found)) { + return false; + } + // Temperature + $temperature_c = intval(strtr($found[2], 'M', '-')); + $temperature_f = round(1.8 * $temperature_c + 32); + $forecast = [ + 'value' => $temperature_c, + 'value_f' => $temperature_f, + 'day' => null, + 'time' => null, + ]; + if (!empty($found[3])) { + $forecast['day'] = intval($found[3]); + } + $forecast['time'] = $found[4].':00 UTC'; + $parameter = 'forecast_temperature_max'; + if ($found[1] == 'TN') { + $parameter = 'forecast_temperature_min'; + } + $this->set_result_group($parameter, $forecast); + return true; + } + + /** + * Decodes trends information if present. + * All METAR trend and TAF records is beginning at: NOSIG, BECMG, TEMP, ATDDhhmm, FMDDhhmm, + * LTDDhhmm or DDhh/DDhh, where hh = hours, mm = minutes, DD = day of month. + */ + private function get_trends($part) + { + $regexp = '@^((NOSIG|BECMG|TEMPO|INTER|CNL|NIL|PROV|(PROB)([0-9]{2})|(AT|FM|TL)([0-9]{2})?([0-9]{2})([0-9]{2}))|(([0-9]{2})([0-9]{2}))/(([0-9]{2})([0-9]{2})))$@'; + if (!preg_match($regexp, $part, $found)) { + return false; + } + // Detects TAF on report + if ($this->part <= 4) { + $this->set_result_value('taf', true); + } + // Nil significant changes, skip trend + if ($found[2] == 'NOSIG') { + return true; + } + $trend = [ + 'flag' => null, + 'probability' => null, + 'period' => [ + 'flag' => null, + 'day' => null, + 'time' => null, + 'from_day' => null, + 'from_time' => null, + 'to_day' => null, + 'to_time' => null, + ], + 'period_report' => null, + ]; + $raw_parts = []; + // Get all parts after trend part + while ($this->part < sizeof($this->raw_parts)) { + if (preg_match($regexp, $this->raw_parts[$this->part], $found)) { + // Get trend flag + if (isset($found[2]) AND isset(static::$trends_flag_codes[$found[2]])) { + $trend['flag'] = $found[2]; + } // Get PROBpp formatted period + elseif (isset($found[3]) AND $found[3] == 'PROB') { + $trend['probability'] = $found[4]; + } // Get AT, FM, TL formatted period + elseif (isset($found[8]) AND isset(static::$trends_time_codes[$found[5]])) { + $trend['period']['flag'] = $found[5]; + if (!empty($found[6])) { + $trend['period']['day'] = intval($found[6]); + } + $trend['period']['time'] = $found[7].':'.$found[8].' UTC'; + } // Get DDhh/DDhh formatted period + elseif (isset($found[14])) { + $trend['period']['from_day'] = $found[10]; + $trend['period']['from_time'] = $found[11].':00 UTC'; + $trend['period']['to_day'] = $found[13]; + $trend['period']['to_time'] = $found[14].':00 UTC'; + } + } // If RMK observed -- the trend is ended + elseif ($this->raw_parts[$this->part] == 'RMK') { + if (!empty($raw_parts)) { + $this->part--; // return pointer to RMK part + } + break; + } // Other data addrs to METAR raw + else { + $raw_parts[] = $this->raw_parts[$this->part]; + } + $this->part++; // go to next part + // Detect ends of this trend, if the METAR raw data observed + if (!empty($raw_parts)) { + if (!isset($this->raw_parts[$this->part]) OR preg_match($regexp, $this->raw_parts[$this->part])) { + $this->part--; // return pointer to finded part + break; + } + } + } + // Empty trend is a bad trend, except for flags CNL and NIL + if (empty($raw_parts)) { + if ($trend['flag'] != 'CNL' AND $trend['flag'] != 'NIL') { + $this->part--; // return pointer to previous part + return false; + } + } // Parse raw data from trend + else { + $class = __CLASS__; + $parser = new $class(implode(' ', $raw_parts), true, $this->debug_enabled, false); + if ($parsed = $parser->parse()) { + unset($parsed['taf']); + // Add parsed data to trend + if (!empty($parsed)) { + $trend = array_merge($trend, $parsed); + } + } + // Process debug messages + if ($debug = $parser->debug()) { + foreach ($debug as $message) { + $this->set_debug('Recursion: '.$message); + } + } + // Process parse errors + if ($errors = $parser->errors()) { + foreach ($errors as $message) { + $this->set_error('Recursion: '.$message); + } + } + } + // Build the report + $report = []; + if (!is_null($trend['flag'])) { + $report[] = static::$trends_flag_codes[$trend['flag']]; + } + if (!is_null($trend['period']['flag'])) { + if (!is_null($trend['period']['day'])) { + $report[] = static::$trends_time_codes[$trend['period']['flag']]. + ' a '.$trend['period']['day'].' day of the month on '.$trend['period']['time']; + } else { + $report[] = static::$trends_time_codes[$trend['period']['flag']].' '.$trend['period']['time']; + } + } + if (!is_null($trend['period']['from_day']) AND !is_null($trend['period']['to_day'])) { + $report[] = 'from a '.$trend['period']['from_day'].' day of the month on '.$trend['period']['from_time']; + $report[] = 'to a '.$trend['period']['to_day'].' day of the month on '.$trend['period']['to_time']; + } + if (!is_null($trend['probability'])) { + $report[] = 'probability '.$trend['probability'].'% of the conditions existing'; + } + if (!empty($report)) { + $trend['period_report'] = ucfirst(implode(', ', $report)); + } + $this->set_result_group('trends', $trend); + return true; + } + + /** + * Get remarks information if present. + * The information is everything that comes after RMK. + */ + private function get_remarks($part) + { + if ($part != 'RMK') { + return false; + } + $this->part++; // skip this part with RMK + $remarks = []; + // Get all parts after + while ($this->part < sizeof($this->raw_parts)) { + if (isset($this->raw_parts[$this->part])) { + $remarks[] = $this->raw_parts[$this->part]; + } + $this->part++; // go to next part + } + if (!empty($remarks)) { + $this->set_result_value('remarks', implode(' ', $remarks)); + } + $this->method++; + return true; + } + // -------------------------------------------------------------------- + // Other methods + // -------------------------------------------------------------------- + /** + * Decodes present or recent weather conditions. + */ + private function decode_weather($part, $method, $regexp_prefix = '') + { + $wx_codes = implode('|', array_keys(array_merge(static::$weather_char_codes, static::$weather_type_codes))); + if (!preg_match('@^'.$regexp_prefix.'([-+]|VC)?('.$wx_codes.')?('.$wx_codes.')?('.$wx_codes.')?('.$wx_codes.')@', $part, $found)) { + return false; + } + $observed = [ + 'intensity' => null, + 'types' => null, + 'characteristics' => null, + 'report' => null, + ]; + // Intensity + if ($found[1] != null) { + $observed['intensity'] = $found[1]; + } + foreach (array_slice($found, 1) as $code) { + // Types + if (isset(static::$weather_type_codes[$code])) { + if (is_null($observed['types'])) { + $observed['types'] = []; + } + $observed['types'][] = $code; + } + // Characteristics (uses last) + if (isset(static::$weather_char_codes[$code])) { + $observed['characteristics'] = $code; + } + } + // Build recent weather report + if (!is_null($observed['characteristics']) OR !is_null($observed['types'])) { + $report = []; + if (!is_null($observed['intensity'])) { + if ($observed['intensity'] == 'VC') { + $report[] = static::$weather_intensity_codes[$observed['intensity']].','; + } else { + $report[] = static::$weather_intensity_codes[$observed['intensity']]; + } + } + if (!is_null($observed['characteristics'])) { + $report[] = static::$weather_char_codes[$observed['characteristics']]; + } + if (!is_null($observed['types'])) { + foreach ($observed['types'] as $code) { + $report[] = static::$weather_type_codes[$code]; + } + } + $report = implode(' ', $report); + $observed['report'] = ucfirst($report); + $this->set_result_report($method.'_weather_report', $report); + } + $this->set_result_group($method.'_weather', $observed); + return true; + } + + /** + * Calculate Heat Index based on temperature in F and relative humidity (65 = 65%) + */ + private function calculate_heat_index($temperature_f, $rh) + { + if ($temperature_f > 79 AND $rh > 39) { + $hi_f = -42.379 + 2.04901523 * $temperature_f + 10.14333127 * $rh - 0.22475541 * $temperature_f * $rh; + $hi_f += -0.00683783 * pow($temperature_f, 2) - 0.05481717 * pow($rh, 2); + $hi_f += 0.00122874 * pow($temperature_f, 2) * $rh + 0.00085282 * $temperature_f * pow($rh, 2); + $hi_f += -0.00000199 * pow($temperature_f, 2) * pow($rh, 2); + $hi_f = round($hi_f); + $hi_c = round(($hi_f - 32) / 1.8); + $this->set_result_value('heat_index', $hi_c); + $this->set_result_value('heat_index_f', $hi_f); + } + } + + /** + * Calculate Wind Chill Temperature based on temperature in F + * and wind speed in miles per hour. + */ + private function calculate_wind_chill($temperature_f) + { + if ($temperature_f < 51 AND $this->result['wind_speed'] != 0) { + $windspeed = round(2.23694 * $this->result['wind_speed']); // convert m/s to mi/h + if ($windspeed > 3) { + $chill_f = 35.74 + 0.6215 * $temperature_f - 35.75 * pow($windspeed, 0.16); + $chill_f += 0.4275 * $temperature_f * pow($windspeed, 0.16); + $chill_f = round($chill_f); + $chill_c = round(($chill_f - 32) / 1.8); + $this->set_result_value('wind_chill', $chill_c); + $this->set_result_value('wind_chill_f', $chill_f); + } + } + } + + /** + * Convert wind speed into meters per second. + * Some other common conversion factors: + * 1 mi/hr = 0.868976 knots = 0.000447 km/hr = 0.44704 m/s = 1.466667 ft/s + * 1 ft/s = 0.592483 knots = 1.097279 km/hr = 0.304799 m/s = 0.681818 mi/hr + * 1 knot = 1.852 km/hr = 0.514444 m/s = 1.687809 ft/s = 1.150779 mi/hr + * 1 km/hr = 0.539957 knots = 0.277778 m/s = 0.911344 ft/s = 0.621371 mi/hr + * 1 m/s = 1.943844 knots = 3.6 km/h = 3.28084 ft/s = 2.236936 mi/hr + */ + private function convert_speed($speed, $unit) + { + switch ($unit) { + case 'KT': + return round(0.514444 * $speed, 2); // from knots + case 'KPH': + return round(0.277778 * $speed, 2); // from km/h + case 'MPS': + return round($speed, 2); // m/s + } + return null; + } + + /** + * Convert distance into meters. + * Some other common conversion factors: + * 1 m = 3.28084 ft = 0.00062 mi + * 1 ft = 0.3048 m = 0.00019 mi + * 1 mi = 5279.99 ft = 1609.34 m + */ + private function convert_distance($distance, $unit) + { + switch ($unit) { + case 'FT': + return round(0.3048 * $distance); // from ft. + case 'SM': + return round(1609.34 * $distance); // from miles + case 'M': + return round($distance); // meters + } + return null; + } + + /** + * Convert direction degrees to compass label. + */ + private function convert_direction_label($direction) + { + if ($direction >= 0 AND $direction <= 360) { + return static::$direction_codes[round($direction / 22.5) % 16]; + } + return null; } } diff --git a/app/Support/MetarWrapper.php b/app/Support/MetarWrapper.php new file mode 100644 index 00000000..953199c3 --- /dev/null +++ b/app/Support/MetarWrapper.php @@ -0,0 +1,214 @@ +metar = $decoder->parse($metar_str); + $this->metar_str = $metar_str; + } + + /** + * Return if this is VFR or IFR conditions + * @return string + */ + public function getCategory(): string + { + $category = 'VFR'; + + $visibility = $this->getVisibility(false); + $ceiling = $this->getCeiling(false); + + if ($visibility < 3 || $ceiling < 1000) { + $category = 'IFR'; + } + + return $category; + } + + /** + * Return the ceiling + * @param bool $convert + * @return int + */ + public function getCeiling($convert = true): int + { + $ceiling = 1000; + $clouds = $this->metar->getClouds(); + if ($clouds && \count($clouds) > 0) { + $ceiling = $clouds[0]->getBaseHeight()->getValue(); + } + + if(!$convert) { + return $ceiling; + } + + return $ceiling; + } + + /** + * Return all of the cloud layers + */ + public function getClouds(): array + { + if (!$this->metar->getClouds()) { + return []; + } + + $layers = []; + $unit = setting('units.altitude'); + + foreach($this->metar->getClouds() as $cloud) { + if($unit === 'ft') { + $base_height = $cloud->getBaseHeight()->getValue(); + } else { + $base_height = $cloud->getBaseHeight()->getConvertedValue('m'); + } + + $layers[] = [ + 'amount' => $cloud->getAmount(), + 'base_height' => $base_height, + + ]; + } + + return $layers; + } + + /** + * Last update time + * @return string + */ + public function getLastUpdate(): string + { + return $this->metar->getTime(); + } + + /** + * Get the pressure, pass in the unit type + * @param string $unit Pass mb for millibars, hg for hg + * @return float|null + */ + public function getPressure($unit = 'mb') + { + if (!$this->metar->getPressure()) { + return null; + } + + $pressure = $this->metar->getPressure()->getValue(); + if (strtolower($unit) === 'mb') { + return $pressure; + } + + return round($pressure * 33.86, 2); + } + + /** + * Return the raw metar string + * @return mixed + */ + public function getRawMetar() + { + return $this->metar_str; + } + + /** + * Return the temperature, if it exists in the METAR + * Convert to the units that are set in the VA + * @return float|null + */ + public function getTemperature() + { + if (!$this->metar->getAirTemperature()) { + return null; + } + + if(setting('units.temperature') === 'c') { + return $this->metar->getAirTemperature()->getValue(); + } + + // Convert to F + round(($this->metar->getAirTemperature()->getValue() * 9 / 5) + 32, 2); + } + + /** + * Get the visibility + * @param bool $convert + * @return int + */ + public function getVisibility($convert=true): int + { + // initially in miles + $visibility = 10; // assume it's ok and VFR + $vis = $this->metar->getVisibility(); + if ($vis) { + $vis = $vis->getVisibility(); + if ($vis) { + $visibility = (int) $vis->getValue(); + + if ($convert && setting('units.distance') === 'km') { + return $vis->getConvertedValue('m') / 1000; + } + + return $visibility; + } + } + + if($convert && setting('units.distance') === 'km') { + return round($visibility * 1.60934, 2); + } + + return $visibility; + } + + /** + * Return wind information + */ + public function getWinds() + { + $sw = $this->metar->getSurfaceWind(); + if (!$sw) { + return null; + } + + $ret = [ + 'speed' => null, + 'direction' => null, + 'gusts' => null, + ]; + + $mean_speed = $sw->getMeanSpeed(); + if($mean_speed) { + $ret['speed'] = $mean_speed->getConvertedValue('kt'); + } + + $dir = $sw->getMeanDirection(); + if($dir) { + $ret['direction'] = $dir->getValue(); + } + + $gusts = $sw->getSpeedVariations(); + if($gusts) { + $ret['gusts'] = $gusts->getConvertedValue('kt'); + } + + return $ret; + } +} diff --git a/app/Widgets/Weather.php b/app/Widgets/Weather.php index e50821c4..9aa6803d 100644 --- a/app/Widgets/Weather.php +++ b/app/Widgets/Weather.php @@ -4,7 +4,7 @@ namespace App\Widgets; use App\Interfaces\Widget; use App\Support\Http; -use App\Support\Metar; +use App\Support\MetarWrapper; use App\Support\Units\Distance; use App\Support\Units\Temperature; use Illuminate\Support\Facades\Cache; @@ -50,7 +50,7 @@ class Weather extends Widget $raw_metar = $metar_class->get_metar($this->config['icao']); if ($raw_metar && $raw_metar !== '') { - $metar = new Metar($raw_metar); + $metar = new MetarWrapper($raw_metar); $wind = $metar->getWinds(); } diff --git a/tests/MetarTest.php b/tests/MetarTest.php index 830640a1..b065fe68 100644 --- a/tests/MetarTest.php +++ b/tests/MetarTest.php @@ -1,24 +1,32 @@ settingsRepo = app(SettingRepository::class); + } + /** * Test adding/subtracting a percentage */ public function testMetar1() { $metar = - 'KJFK 042151Z 28026G39KT 10SM ' - .'FEW055 SCT095 BKN110 BKN230 12/M04 ' - .'A2958 RMK AO2 PK WND 27045/2128 PRESRR ' - .'SLP018 T01221044'; + 'KJFK 042151Z 28026G39KT 10SM FEW055 SCT095 BKN110 BKN230 12/M04 A2958 RMK AO2 PK WND 27045/2128 PRESRR SLP018 T01221044'; - $parsed = new Metar($metar); + #$m = new Metar($metar); + #$parsed = $m->result; + $parsed = Metar::parse($metar); /* Conditions VFR visibility 10NM @@ -29,6 +37,39 @@ class MetarTest extends TestCase BKN @ 23000 ft Wind 26 kts @ 280° gusts to 39 */ + $this->assertEquals('KJFK', $parsed['station']); + $this->assertEquals('Wed, 04 Apr 2018 21:51:00 +0000', $parsed['observed_date']); + $this->assertEquals(4, $parsed['observed_day']); + $this->assertEquals('21:51 UTC', $parsed['observed_time']); + #$this->assertEquals('3:42 hr. ago', $parsed['observed_age']); + $this->assertEquals(13.38, $parsed['wind_speed']); + $this->assertEquals(20.06, $parsed['wind_gust_speed']); + $this->assertEquals(280, $parsed['wind_direction']); + $this->assertEquals('W', $parsed['wind_direction_label']); + $this->assertEquals(false, $parsed['wind_direction_varies']); + $this->assertEquals(16093, $parsed['visibility']); + $this->assertEquals('16093 meters', $parsed['visibility_report']); + $this->assertEquals('Dry', $parsed['present_weather_report']); + + $this->assertCount(4, $parsed['clouds']); + $this->assertEquals( + 'A few at 1676 meters; scattered at 2896 meters; broken sky at 3353 meters; broken sky at 7010 meters', + $parsed['clouds_report']); + $this->assertEquals(1676, $parsed['cloud_height']); + $this->assertEquals(false, $parsed['cavok']); + + $this->assertEquals(12, $parsed['temperature']); + $this->assertEquals(54, $parsed['temperature_f']); + + $this->assertEquals(-4, $parsed['dew_point']); + $this->assertEquals(25, $parsed['dew_point_f']); + + $this->assertEquals(33, $parsed['humidity']); + $this->assertEquals(29.58, $parsed['barometer']); + $this->assertEquals(0.87, $parsed['barometer_in']); + + $this->assertEquals('AO2 PK WND 27045/2128 PRESRR SLP018 T01221044', $parsed['remarks']); + } public function testMetar2()