null, '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, 'clouds_report_ft' => null, 'cloud_height' => null, 'cavok' => null, 'temperature' => null, 'dew_point' => null, 'humidity' => null, 'heat_index' => null, 'wind_chill' => null, 'barometer' => null, 'barometer_in' => null, 'barometer_mb' => 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 = [ '' => 'moderate', '-' => '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; /** * 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 * @param $raw * @param bool $taf * @param bool $debug * @param bool $icao */ public function __construct($raw, $taf = false, $debug = false, $icao = true) { $this->debug_enabled = $debug; $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); } } else { $raw = trim($raw_lines[0]); } $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(); } /** * Shortcut to call * @param $metar * @param string $taf * @return mixed */ public static function parse($metar, $taf = '') { $mtr = new static($metar, $taf); return $mtr->parse_all(); } /** * Gets the value from result array as class property. * @param $parameter * @return mixed|null */ public function __get($parameter) { if (isset($this->result[$parameter])) { return $this->result[$parameter]; } return null; } /** * Parses the METAR or TAF information and returns result array. */ public function parse_all(): array { $this->raw_parts = explode(' ', $this->raw); $current_method = 0; $raw_part_count = \count($this->raw_parts); $method_name_count = \count(static::$method_names); while ($this->part < $raw_part_count) { $this->method = $current_method; 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++; } 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]); } } } // Finally determine if it's VFR or IFR conditions // https://www.aviationweather.gov/cva/help if(array_key_exists('cavok', $this->result) && $this->result['cavok']) { $this->result['category'] = 'VFR'; } else { if(array_key_exists('cloud_height', $this->result) && array_key_exists('visibility', $this->result)) { if ($this->result['cloud_height']['ft'] > 3000 && $this->result['visibility']['nmi'] > 5) { $this->result['category'] = 'VFR'; } else { $this->result['category'] = 'IFR'; } } } return $this->result; } /** * Returns array with debug information. */ public function debug() { 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.' '.trans_choice('widgets.weather.minago', $time_diff)); } else { $this->set_result_value('observed_age', floor($time_diff / 60).':'.sprintf("%02d", $time_diff % 60).' '.trans_choice('widgets.weather.hrago', floor($time_diff / 60))); } } /** * 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; } } else { $this->result[$parameter] = $value; } } /** * Sets the data group to parameter in result array. */ private function set_result_group($parameter, $group) { if ($this->result[$parameter] === null) { $this->result[$parameter] = []; } $this->result[$parameter][] = $group; } /** * 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)); } } /** * 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) { $r = '@^([A-Z]{1}'.'[A-Z0-9]{3})$@'; // 1 if (!preg_match($r, $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) { $r = '@^([\d]{2})' // 1 .'([\d]{2})' // 2 .'([\d]{2})Z$@'; // 3 if (!preg_match($r, $part, $found)) { return false; } $day = (int) $found[1]; $hour = (int) $found[2]; $minute = (int) $found[3]; if ($this->result['observed_date'] === null) { // Take one month, if the observed day is greater than the current day if ($day > date('j')) { $month = date('n') - 1; } else { $month = date('n'); } // Get observed time from a METAR/TAF part $observed_time = mktime($hour, $minute, 0, $month, $day, date('Y')); $this->set_observed_date($observed_time); } $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. * @param $part * @return bool */ private function get_wind($part) { $r = '@^([\d]{3}|VRB|///)P?' // 1 .'([/0-9]{2,3}|//)' // 2 .'(GP?' // 3 .'([\d]{2,3}))?' // 4 .'(KT|MPS|KPH)@'; // 5 if (!preg_match($r, $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) { $r = '@^([\d]{3})' // 1 .'V([\d]{3})$@'; // 2 if (!preg_match($r, $part, $found)) { return false; } $min_direction = (int) $found[1]; $max_direction = (int) $found[2]; if ($min_direction >= 0 && $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 && $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. * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue */ private function get_visibility($part) { $r = '@^(CAVOK|([\d]{4})' // 1 .'|(M)?' // 2 .'([\d]{0,2})?' // 3 .'(([1357])' // 4 .'/(2|4|8|16))?' // 5 .'SM|////)$@'; // 6 if (!preg_match($r, $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' || $found[1] === '9999') { $this->set_result_value('visibility', new Distance(10000, 'm')); $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]) && !empty($found[2])) { $visibility = new Distance((int) $found[2], 'm'); } // US visibility (in miles) else { if (isset($found[3]) && !empty($found[3])) { $prefix = 'Less than '; } if (isset($found[7]) && !empty($found[7])) { $visibility = (int) $found[4] + (int) $found[6] / (int) $found[7]; } else { $visibility = (int) $found[4]; } $visibility = new Distance($visibility, 'mi'); } $unit = ' meters'; if ($visibility['m'] <= 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, ...). * @param $part * @return bool * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName */ private function get_visibility_min($part) { if (!preg_match('@^([\d]{4})(NE|NW|SE|SW|N|E|S|W|)?$@', $part, $found)) { return false; } $meters = new Distance((int) $found[1], 'm'); $this->set_result_value('visibility_min', $meters); if (isset($found[2]) && !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. * @param $part * @return bool * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue */ private function get_runway_vr($part) { $r = '@^R([\d]{2}[LCR]?)/' // 1 .'(([PM])?' // 2 .'([\d]{4})V)?' // 3 .'([PM])?([\d]{4})' // 4 .'(FT)?/?' // 6 .'([UDN]?)$@'; // 7 if (!preg_match($r, $part, $found)) { return false; } if ((int) $found[1] > 36 || (int) $found[1] < 1) { return false; } $unit = 'meter'; if (isset($found[6]) && $found[6] === 'FT') { $unit = 'feet'; } $observed = [ 'runway' => $found[1], 'variable' => null, 'variable_prefix' => null, 'interval_min' => null, 'interval_max' => null, 'tendency' => null, 'report' => null, ]; // Runway past tendency if (isset($found[8]) && isset(static::$rvr_tendency_codes[$found[8]])) { $observed['tendency'] = $found[8]; } // Runway visual range if (isset($found[6])) { if (!empty($found[4])) { $observed['interval_min'] = new Distance($found[4], $unit); $observed['interval_max'] = new Distance($found[6], $unit); if (!empty($found[5])) { $observed['variable_prefix'] = $found[5]; } } else { $observed['variable'] = new Distance($found[6], $unit); } } // 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 (null !== $observed['interval_min'] && 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 (null !== $observed['tendency'] && 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; } /** * 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 * @param $part * @return bool */ 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. * @param $part * @return bool * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue */ private function get_clouds($part) { $r = '@^((NSW|NSC|NCD|CLR|SKC|NOBS)|' // 1 .'((VV|FEW|SCT|BKN|OVC|///)' // 2 .'([\d]{3}|///)' // 3 .'(CB|TCU|///)?))$@'; // 4 if (!preg_match($r, $part, $found)) { return false; } $observed = [ 'amount' => null, 'height' => null, 'type' => null, 'report' => null, ]; // Clear skies or no observation if (isset($found[2]) && !empty($found[2])) { if (isset(static::$cloud_codes[$found[2]])) { $observed['amount'] = $found[2]; } } // Cloud cover observed elseif (isset($found[5]) && !empty($found[5])) { $observed['height'] = new Altitude($found[5] * 100, 'feet'); // Cloud height if (null === $this->result['cloud_height']['m'] || $observed['height']['m'] < $this->result['cloud_height']['m']) { $this->set_result_value('cloud_height', $observed['height']); } if (isset(static::$cloud_codes[$found[4]])) { $observed['amount'] = $found[4]; } } // Type if (isset($found[6]) && !empty($found[6]) && isset(static::$cloud_type_codes[$found[6]]) && $found[4] != 'VV') { $observed['type'] = $found[6]; } // Build clouds report if (null !== $observed['amount']) { $report = []; $report_ft = []; $report[] = static::$cloud_codes[$observed['amount']]; $report_ft[] = static::$cloud_codes[$observed['amount']]; if ($observed['height']) { if (null !== $observed['type']) { $report[] = 'at '.round($observed['height']['m'], 0).' meters, '.static::$cloud_type_codes[$observed['type']]; $report_ft[] = 'at '.round($observed['height']['ft'], 0).' feet, '.static::$cloud_type_codes[$observed['type']]; } else { $report[] = 'at ' . round($observed['height']['m'], 0) . ' meters'; $report_ft[] = 'at ' . round($observed['height']['ft'], 0) . ' feet'; } } $report = implode(' ', $report); $report_ft = implode(' ', $report_ft); $observed['report'] = ucfirst($report); $observed['report_ft'] = ucfirst($report_ft); $this->set_result_report('clouds_report', $report); $this->set_result_report('clouds_report_ft', $report_ft); } $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. * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue */ private function get_temperature($part) { $r = '@^(M?[\d]{2})' // 1 .'/(M?[\d]{2}' // 2 .'|[X]{2})?@'; // 3 if (!preg_match($r, $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 = (int) str_replace('M', '-', $found[1]); $temperature = new Temperature($temperature_c, 'C'); $this->set_result_value('temperature', $temperature); $this->calculate_wind_chill($temperature['f']); // Dew point if (isset($found[2]) && '' !== $found[2] && $found[2] !== 'XX') { $dew_point_c = (int) str_replace('M', '-', $found[2]); $dew_point = new Temperature($dew_point_c, 'C'); $rh = round(100 * (((112 - (0.1 * $temperature_c) + $dew_point_c) / (112 + (0.9 * $temperature_c))) ** 8)); $this->set_result_value('dew_point', $dew_point); $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)(////|[\d]{4})@', $part, $found)) { return false; } $pressure = (int) $found[2]; if ($found[1] === 'A') { $pressure /= 100; } $this->set_result_value('barometer', $pressure); // units are hPa $this->set_result_value('barometer_mb', $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) { $r = '@^R?' .'(/?(SNOCLO)' // 1 .'|([\d]{2}[LCR]?)/?' // 2 .'(CLRD|([\d]{1}|/)' // 3 .'([\d]{1}|/)' // 4 .'([\d]{2}|//))' // 5 .'([\d]{2}|//))$@'; // 6 if (!preg_match($r, $part, $found)) { return false; } $this->set_result_value('runways_snoclo', false, true); // Airport closed due to snow if (isset($found[2]) && $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]) && $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 ((int) $deposits_depth >= 1 && (int) $deposits_depth <= 90) { $observed['deposits_depth'] = (int) $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 ((int) $friction > 0 && (int) $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 (null !== $observed['deposits']) { $report[] = static::$runway_deposits_codes[$observed['deposits']]; if (null !== $observed['deposits_extent']) { $report[] = 'contamination '.static::$runway_deposits_extent_codes[$observed['deposits_extent']]; } if (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 (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])) { $r = '@^R(WY)?' // 1 .'([\d]{2}[LCR]?)$@'; // 2 $part = $this->raw_parts[$this->part]; if (!preg_match($r, $part, $found)) { return false; } if ((int) $found[2] > 36 || (int) $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. * @param string $part * 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. * @return bool * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue */ private function get_forecast_temperature($part): bool { $r = '@^(TX|TN)' // 1 .'(M?[\d]{2})' // 2 .'/([\d]{2})?' // 3 .'([\d]{2})Z$@'; // 4 if (!preg_match($r, $this->raw_parts[$this->part], $found)) { return false; } // Temperature $temperature_c = (int) str_replace('M', '-', $found[2]); $temperture = new Temperature($temperature_c, 'C'); $forecast = [ 'value' => $temperture, 'day' => null, 'time' => null, ]; if (!empty($found[3])) { $forecast['day'] = (int) $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) { $r = '@^((NOSIG|BECMG|TEMPO|INTER|CNL|NIL|PROV|(PROB)' // 1 .'([\d]{2})|' // 2 .'(AT|FM|TL)' // 3 .'([\d]{2})?' // 4 .'([\d]{2})' // 5 .'([\d]{2}))|' // 6 .'(([\d]{2})' // 7 .'([\d]{2}))/' // 8 .'(([\d]{2})' // 9 .'([\d]{2})))$@'; // 10 if (!preg_match($r, $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_report' => null, 'period' => [ 'flag' => null, 'day' => null, 'time' => null, 'from_day' => null, 'from_time' => null, 'to_day' => null, 'to_time' => null, ], ]; $raw_parts = []; // Get all parts after trend part while ($this->part < \count($this->raw_parts)) { if (preg_match($r, $this->raw_parts[$this->part], $found)) { // Get trend flag if (isset($found[2]) && isset(static::$trends_flag_codes[$found[2]])) { $trend['flag'] = $found[2]; } // Get PROBpp formatted period elseif (isset($found[3]) && $found[3] === 'PROB') { $trend['probability'] = $found[4]; } // Get AT, FM, TL formatted period elseif (isset($found[8]) && isset(static::$trends_time_codes[$found[5]])) { $trend['period']['flag'] = $found[5]; if (!empty($found[6])) { $trend['period']['day'] = (int) $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) && (!isset($this->raw_parts[$this->part]) || preg_match($r, $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' && $trend['flag'] !== 'NIL') { $this->part--; // return pointer to previous part return false; } } // Parse raw data from trend else { $parser = new static(implode(' ', $raw_parts), true, $this->debug_enabled, false); if ($parsed = $parser->parse_all()) { 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 (null !== $trend['flag']) { $report[] = static::$trends_flag_codes[$trend['flag']]; } if (null !== $trend['period']['flag']) { if (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 (null !== $trend['period']['from_day'] && 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 (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. * @param string $part * @return bool */ private function get_remarks($part): bool { 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; } /** * Decodes present or recent weather conditions. * @param $part * @param $method * @param string $regexp_prefix * @return bool */ 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 (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 (null !== $observed['characteristics'] || null !== $observed['types']) { $report = []; if (null !== $observed['intensity']) { if ($observed['intensity'] === 'VC') { $report[] = static::$weather_intensity_codes[$observed['intensity']].','; } else { $report[] = static::$weather_intensity_codes[$observed['intensity']]; } } if (null !== $observed['characteristics']) { $report[] = static::$weather_char_codes[$observed['characteristics']]; } if (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%) * @param $temperature_f * @param $rh * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName */ private function calculate_heat_index($temperature_f, $rh): void { if ($temperature_f > 79 && $rh > 39) { $hi_f = -42.379 + 2.04901523 * $temperature_f + 10.14333127 * $rh - 0.22475541 * $temperature_f * $rh; $hi_f += -0.00683783 * ($temperature_f ** 2) - 0.05481717 * ($rh ** 2); $hi_f += 0.00122874 * ($temperature_f ** 2) * $rh + 0.00085282 * $temperature_f * ($rh ** 2); $hi_f += -0.00000199 * ($temperature_f ** 2) * ($rh ** 2); $hi_f = round($hi_f); $hi_c = round(($hi_f - 32) / 1.8); $this->set_result_value('heat_index', new Temperature($hi_c, 'C')); } } /** * Calculate Wind Chill Temperature based on temperature in F * and wind speed in miles per hour. * @param $temperature_f * @throws \PhpUnitsOfMeasure\Exception\NonNumericValue * @throws \PhpUnitsOfMeasure\Exception\NonStringUnitName */ private function calculate_wind_chill($temperature_f): void { if ($temperature_f < 51 && $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 * ($windspeed ** 0.16); $chill_f += 0.4275 * $temperature_f * ($windspeed ** 0.16); $chill_f = round($chill_f); $chill_c = round(($chill_f - 32) / 1.8); $this->set_result_value('wind_chill', new Temperature($chill_c, 'C')); } } } /** * 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 * @param $speed * @param $unit * @return float|null */ private function convert_speed($speed, $unit) { // TODO: return dict w/ multiple units - NS 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 * @param $distance * @param $unit * @return float|null */ private function convert_distance($distance, $unit) { // TODO: return dict w/ multiple units - NS 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 && $direction <= 360) { return static::$direction_codes[round($direction / 22.5) % 16]; } return null; } /** * These methods below the implementation of the stubs for ArrayAccess */ /** * Whether a offset exists * @link http://php.net/manual/en/arrayaccess.offsetexists.php * @param mixed $offset
* An offset to check for. *
* @return boolean true on success or false on failure. * ** The return value will be casted to boolean if non-boolean was returned. * @since 5.0.0 */ public function offsetExists($offset) { return array_key_exists($offset, $this->result); } /** * Offset to retrieve * @link http://php.net/manual/en/arrayaccess.offsetget.php * @param mixed $offset
* The offset to retrieve. *
* @return mixed Can return all value types. * @since 5.0.0 */ public function offsetGet($offset) { return $this->result[$offset]; } /** * Offset to set * @link http://php.net/manual/en/arrayaccess.offsetset.php * @param mixed $offset* The offset to assign the value to. *
* @param mixed $value* The value to set. *
* @return void * @since 5.0.0 */ public function offsetSet($offset, $value) { $this->result[$offset] = $value; } /** * Offset to unset * @link http://php.net/manual/en/arrayaccess.offsetunset.php * @param mixed $offset* The offset to unset. *
* @return void * @since 5.0.0 */ public function offsetUnset($offset) { $this->result[$offset] = null; } }