├── lib ├── exceptions.rb ├── fetch.rb ├── metar.rb └── groups.rb ├── example.rb └── README.markdown /lib/exceptions.rb: -------------------------------------------------------------------------------- 1 | module METAR 2 | # May be raised during parsing 3 | class ParseError < StandardError 4 | end 5 | end 6 | 7 | -------------------------------------------------------------------------------- /example.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | 3 | require 'rubygems' 4 | require 'lib/metar' 5 | require 'lib/fetch' 6 | require 'lib/groups' 7 | 8 | ## argument is ICAO-format station code 9 | raw = METAR::Fetch.station('KSAN') 10 | 11 | puts decode = METAR::Report.parse(raw) 12 | 13 | -------------------------------------------------------------------------------- /lib/fetch.rb: -------------------------------------------------------------------------------- 1 | require 'net/http' 2 | require 'uri' 3 | 4 | module METAR 5 | 6 | class Fetch 7 | 8 | def self.station(station_id) 9 | server = 'weather.noaa.gov' 10 | port = '80' 11 | path = '/cgi-bin/mgetmetar.pl?cccc=' +station_id 12 | 13 | begin 14 | http = Net::HTTP.new(server, port) 15 | http.read_timeout = 300 16 | res = http.get(path) 17 | rescue SocketError => e 18 | puts "Could not connect!" 19 | exit 20 | end 21 | 22 | case res 23 | when Net::HTTPSuccess 24 | data = res.body 25 | data.split(/\n/).each do |line| 26 | if line =~ /^#{station_id}/ 27 | report = line 28 | return report 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | ## ruby-metar 2 | 3 | This is a fork of Hans Fugal's possibly-not-maintained 'ruby-wx' library 4 | which parses METAR data into human readable output. 5 | 6 | For clarity, the "WX" module has been renamed to "METAR" and the former "METAR" class becomes "Report". 7 | 8 | I have introduced a "Fetch" class which handles the automatic retrieval of reports from a NOAA source. 9 | 10 | ### example.rb 11 | 12 | usage: ./example.rb 13 | 14 | KSAN 251851Z 31007KT 10SM BKN025 16/09 A3019 RMK AO2 SLP222 T01560094 15 | Conditions at: KSAN 16 | Temperature/Dewpoint: 16°C / 9°C (60.8°F / 48.2°F) [RH 62.8%] 17 | Pressure (altimeter): 30.19 inches Hg (1022.4 mb) 18 | Winds: 310.0 degrees (NW) at 7 knots (8.1 MPH; 3.6 m/s) 19 | Visibility: 10 mi 20 | Clouds: Broken at 2500 ft 21 | Remarks: AO2 SLP222 T01560094 22 | -------------------------------------------------------------------------------- /lib/metar.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' rescue nil # Insert swear word here. 2 | require 'lib/exceptions' 3 | require 'lib/groups' 4 | require 'ruby-units' 5 | require 'stringio' 6 | 7 | module METAR 8 | # METAR is short for a bunch of French words meaning "aviation routine 9 | # weather report". An example METAR code looks like: 10 | # KLRU 261453Z AUTO 00000KT 3SM -RA OVC004 02/02 A3008 RMK AO2 11 | # This is intimidating, to say the least, to nonaviators. This class will 12 | # parse a METAR code and provide the information in its attribute readers. 13 | class Report 14 | include Groups 15 | # The raw METAR observation as passed to parse 16 | attr_accessor :raw 17 | # The METAR station, as found in 18 | # stations.txt[http://weather.aero/metars/stations.txt] 19 | attr_accessor :station 20 | # A ::Time object. Note that METAR doesn't include year or month 21 | # information, so it is assumed that this time is intended to be within the 22 | # past month of ::Time.now 23 | attr_accessor :time 24 | # A Groups::Wind object. 25 | attr_accessor :wind 26 | # A Groups::Visibility object. 27 | attr_accessor :visibility 28 | alias :vis :visibility 29 | # An array of Groups::RunwayVisualRange objects. 30 | attr_accessor :rvr 31 | alias :runway_visual_range :rvr 32 | # An array of Groups::PresentWeather objects. 33 | attr_accessor :weather 34 | alias :present_weather :weather 35 | alias :present_wx :weather 36 | alias :wx :weather 37 | # An array of Groups::Sky objects. 38 | attr_accessor :sky 39 | alias :clouds :sky 40 | alias :cloud_cover :sky 41 | # A temperature Unit 42 | attr_accessor :temp 43 | alias :temperature :temp 44 | def tempF 45 | @temp.to 'tempF' 46 | end 47 | # A temperature Unit 48 | attr_accessor :dewpoint 49 | alias :dew :dewpoint 50 | # A pressure Unit, giving atmospheric pressure (by which one calibrates an 51 | # altimiter) 52 | attr_accessor :altimiter 53 | alias :pressure :altimiter 54 | def pressure_mb 55 | @altimiter.to 'millibar' 56 | end 57 | # Remarks. 58 | attr_accessor :rmk 59 | alias :remarks :rmk 60 | 61 | # Was this report entirely automated? (i.e. not checked by a human) 62 | def auto? 63 | @auto ? true : false 64 | end 65 | alias :automated? :auto? 66 | 67 | # Was this report corrected by a human? 68 | def cor? 69 | @cor ? true : false 70 | end 71 | alias :corrected? :cor? 72 | 73 | # Was this a SPECI report? SPECI (special) reports are issued when weather 74 | # changes significantly between regular reports, which are generally every 75 | # hour. 76 | def speci? 77 | @speci ? true : false 78 | end 79 | alias :special? :speci? 80 | 81 | # CLR means clear below 12,000 feet (because automated equipment can't tell 82 | # above 12,000 feet) 83 | def clr? 84 | @sky.first.clr? 85 | end 86 | alias :auto_clear? :clr? 87 | 88 | # SKC means sky clear. Only humans can report SKC 89 | def skc? 90 | @sky.first.skc? 91 | end 92 | alias :clear? :skc? 93 | 94 | # Parse a raw METAR code and return a METAR object 95 | def self.parse(raw) 96 | m = Report.new 97 | m.raw = raw 98 | groups = raw.split 99 | 100 | # type 101 | m.speci = false 102 | case g = groups.shift 103 | when 'METAR' 104 | g = groups.shift 105 | when 'SPECI' 106 | m.speci = true 107 | g = groups.shift 108 | end 109 | 110 | # station 111 | if g =~ /^([a-zA-Z0-9]{4})$/ 112 | m.station = $1 113 | g = groups.shift 114 | else 115 | raise ParseError, "Invalid Station Identifier '#{g}'" 116 | end 117 | 118 | # date and time 119 | if g =~ /^(\d\d)(\d\d)(\d\d)Z$/ 120 | m.time = Time.parse(g) 121 | g = groups.shift 122 | else 123 | raise ParseError, "Invalid Date and Time '#{g}'" 124 | end 125 | 126 | # modifier 127 | if g == 'AUTO' 128 | m.auto = true 129 | g = groups.shift 130 | elsif g == 'COR' 131 | m.cor = true 132 | g = groups.shift 133 | end 134 | 135 | # wind 136 | if g =~ /^((\d\d\d)|VRB)(\d\d\d?)(G(\d\d\d?))?(KT|KMH|MPS)$/ 137 | if groups.first =~ /^(\d\d\d)V(\d\d\d)$/ 138 | g = g + ' ' + groups.shift 139 | end 140 | m.wind = Wind.new(g) 141 | g = groups.shift 142 | end 143 | 144 | # visibility 145 | if g =~ /^\d+$/ and groups.first =~ /^M?\d+\/\d+SM$/ 146 | m.visibility = Visibility.new(g+' '+groups.shift) 147 | g = groups.shift 148 | elsif g =~ /^M?\d+(\/\d+)?SM$/ 149 | m.visibility = Visibility.new(g) 150 | g = groups.shift 151 | end 152 | 153 | # RVR 154 | m.rvr = [] 155 | while g =~ /^R(\d+[LCR]?)\/([PM]?)(\d+)(V([PM]?)(\d+))?FT$/ 156 | m.rvr.push RunwayVisualRange.new(g) 157 | g = groups.shift 158 | end 159 | 160 | # present weather 161 | m.weather = [] 162 | while g =~ /^([-+]|VC)?(MI|PR|BC|DR|BL|SH|TS|FZ|DZ|RA|SN|SG|IC|PE|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)+$/ 163 | m.weather.push PresentWeather.new(g) 164 | g = groups.shift 165 | end 166 | 167 | # sky condition 168 | m.sky = [] 169 | while g =~ /^(SKC|CLR)|(VV|FEW|SCT|BKN|OVC)/ 170 | m.sky.push Sky.new(g) 171 | g = groups.shift 172 | end 173 | 174 | # temperature and dew point 175 | if g =~ /^(M?)(\d\d)\/((M?)(\d\d))?$/ 176 | t = $2.to_i 177 | t = -t if $1 == 'M' 178 | m.temp = "#{t} tempC".unit 179 | 180 | if $3 181 | d = $5.to_i 182 | d = -d if $4 == 'M' 183 | m.dewpoint = "#{d} tempC".unit 184 | end 185 | 186 | g = groups.shift 187 | end 188 | 189 | if g =~ /^A(\d\d\d\d)$/ 190 | m.altimiter = "#{$1.to_f / 100} inHg".unit 191 | g = groups.shift 192 | end 193 | 194 | if g == 'RMK' 195 | m.rmk = groups.join(' ') 196 | groups = [] 197 | end 198 | 199 | unless groups.empty? 200 | raise ParseError, "Leftovers after parsing: #{groups.join(' ')}" 201 | end 202 | 203 | return m 204 | end 205 | 206 | attr_accessor :speci, :auto, :cor 207 | 208 | # The output of this aims to be similar to 209 | # http://adds.aviationweather.gov/tafs/index.php?station_ids=klru&std_trans=translated 210 | # But just plain text, not HTML tables or anything. 211 | def to_s 212 | s = StringIO.new 213 | deg = Degree 214 | 215 | # print a float to d decimal places leaving off trailing 0s 216 | def pf(f,d=2) 217 | f = f.scalar if Unit === f 218 | s = sprintf("%.#{d}f",f) 219 | s.gsub!(/(\.0+|(\.\d*[1-9])0+)$/, '\2') 220 | s 221 | end 222 | 223 | s.puts raw if raw 224 | s.puts "Conditions at: #{station}" 225 | if temp 226 | s.puts "Temperature/Dewpoint: #{pf temp.to('tempC').abs}#{deg}C / #{pf dewpoint.to('tempC').abs}#{deg}C (#{pf tempF.abs}#{deg}F / #{pf dewpoint.to('tempF').abs}#{deg}F) [RH #{pf rh,1}%]" 227 | end 228 | if altimiter 229 | s.puts "Pressure (altimiter): #{pf altimiter.to('inHg').abs} inches Hg (#{pf altimiter.to('millibar').abs, 1} mb)" 230 | end 231 | if wind 232 | s.puts "Winds: #{wind}" 233 | end 234 | if visibility 235 | s.puts "Visibility: #{visibility}" 236 | end 237 | if sky 238 | s.puts "Clouds: #{sky.first}" 239 | sky[1..-1].each do |c| 240 | s.puts " "*22 + c.to_s 241 | end 242 | end 243 | if rvr and not rvr.empty? 244 | s.puts "Runway Visual Range: #{rvr.first}" 245 | rvr[1..-1].each do |r| 246 | s.puts " "*22 + r.to_s 247 | end 248 | end 249 | if rmk 250 | s.puts "Remarks: #{rmk}" 251 | end 252 | s.string 253 | end 254 | 255 | # Relative Humidity 256 | # See http://www.faqs.org/faqs/meteorology/temp-dewpoint/ 257 | def relative_humidity 258 | if (!dewpoint.nil? && !temp.nil?) 259 | es0 = 6.11 # hPa 260 | t0 = 273.15 # kelvin 261 | td = self.dewpoint.to('tempK').abs 262 | t = self.temp.to('tempK').abs 263 | lv = 2500000 # joules/kg 264 | rv = 461.5 # joules*kelvin/kg 265 | e = es0 * Math::exp(lv/rv * (1.0/t0 - 1.0/td)) 266 | es = es0 * Math::exp(lv/rv * (1.0/t0 - 1.0/t)) 267 | rh = 100 * e/es 268 | (rh.to_s+'%').unit 269 | else 270 | nil 271 | end 272 | end 273 | alias :rh :relative_humidity 274 | end 275 | 276 | protected 277 | Degree = "\xc2\xb0" # UTF-8 degree symbol 278 | 279 | end 280 | -------------------------------------------------------------------------------- /lib/groups.rb: -------------------------------------------------------------------------------- 1 | require 'ruby-units' 2 | 3 | # Extends ruby-unit's Unit class 4 | class Unit 5 | attr_accessor :minus 6 | attr_accessor :plus 7 | # Was this value reported as "less than"? 8 | def minus? 9 | @minus 10 | end 11 | # Was this value reported as "greater than"? 12 | def plus? 13 | @plus 14 | end 15 | alias :greater_than :plus 16 | alias :less_than :minus 17 | alias :greater_than? :plus? 18 | alias :less_than? :minus? 19 | alias :old_to_s :to_s #:nodoc: 20 | def to_s #:nodoc: 21 | s = old_to_s 22 | if minus? 23 | s = "<"+s 24 | elsif plus? 25 | s = ">"+s 26 | end 27 | s 28 | end 29 | end 30 | 31 | module METAR 32 | # METAR codes are subdivided into "groups". The classes in this module do the 33 | # heavy lifting of parsing, and provide the API to access the relevant 34 | # information 35 | module Groups 36 | class Time < ::Time 37 | # raw date/time group, e.g. 252018Z 38 | # creates a ::Time object within the past month 39 | def self.parse(raw) 40 | raise ArgumentError unless raw =~ /^(\d\d)(\d\d)(\d\d)Z$/ 41 | t = ::Time.now.utc 42 | y = t.year 43 | m = t.month 44 | mday = $1.to_i 45 | hour = $2.to_i 46 | min = $3.to_i 47 | 48 | if t.mday < mday 49 | m -= 1 50 | end 51 | if m < 1 52 | m = 12 53 | y -= 1 54 | end 55 | return ::Time.utc(y,m,mday,hour,min) 56 | end 57 | end 58 | 59 | class Wind 60 | # Angle Unit 61 | attr_reader :direction 62 | alias :dir :direction 63 | alias :deg :direction 64 | def radians 65 | @direction.to 'rad' 66 | end 67 | def degrees 68 | @direction.to 'deg' 69 | end 70 | alias :rads :radians 71 | alias :rad :radians 72 | # Speed Unit 73 | attr_reader :speed 74 | def mph 75 | @speed.to 'mph' 76 | end 77 | def knots 78 | @speed.to 'knots' 79 | end 80 | alias :kts :knots 81 | # Speed Unit 82 | attr_reader :gust 83 | alias :gusts :gust 84 | alias :gusting_to :gust 85 | def gusting? 86 | @gust 87 | end 88 | alias :gusts? :gusting? 89 | alias :gust? :gusting? 90 | # If wind is strong and variable, this will be a two-element Array 91 | # containing the angle Unit limits of the range, e.g. ['10 deg'.unit, 92 | # '200 deg'.unit] 93 | attr_reader :variable 94 | alias :variable_range :variable 95 | def initialize(raw) 96 | raise ArgumentError unless raw =~/(\d\d\d|VRB)(\d\d\d?)(G(\d\d\d?))?(KT|KMH|MPS)( (\d\d\d)V(\d\d\d))?/ 97 | 98 | case $5 99 | when 'KT' 100 | unit = 'knots' 101 | when 'KMH' 102 | unit = 'kph' 103 | when 'MPS' 104 | unit = 'm/s' 105 | end 106 | @speed = "#{$2} #{unit}".unit 107 | if $1 == 'VRB' 108 | @direction = 'VRB' 109 | else 110 | @direction = "#{$1} degrees".unit 111 | end 112 | 113 | @gust = "#{$4} knots".unit if $3 114 | 115 | if $6 116 | @variable = ["#{$7} deg".unit, "#{$8} deg".unit] 117 | end 118 | end 119 | # If wind is strong and variable or light and variable 120 | def variable? 121 | @variable or vrb? 122 | end 123 | def vrb? 124 | @direction == 'VRB' 125 | end 126 | def calm? 127 | @speed == '0 knots'.unit 128 | end 129 | # returns one of the eight compass rose names 130 | # e.g. N, NNE, NE, ENE, etc. 131 | def compass 132 | a = degrees.abs 133 | i = (a/22.5).round % 16 134 | %w{N NNE NE ENE E ESE SE SSE S SSW SW WSW W WNW NW NNW}[i] 135 | end 136 | def to_s 137 | return "calm" if calm? 138 | # print a float to d decimal places leaving off trailing 0s 139 | def pf(f,d=2) 140 | s = sprintf("%.#{d}f",f) 141 | s.gsub!(/(\.0+|(\.\d*[1-9])0+)$/, '\2') 142 | s 143 | end 144 | 145 | if vrb? 146 | s = "Variable" 147 | elsif variable? 148 | v1, v2 = variable 149 | s = "From #{pf v1.to('deg').abs} to #{pf v2.to('deg').abs} degrees" 150 | else 151 | s = "#{degrees.abs} degrees (#{compass})" 152 | end 153 | 154 | s += " at #{pf speed.to('knots').abs} knots " 155 | s += "(#{pf speed.to('mph').abs,1} MPH; #{pf speed.to('m/s').abs,1} m/s)" 156 | if gusting? 157 | s += "\n" + (" "*22) 158 | s += "gusting to #{pf gust.to('knots').abs,1} knots " 159 | s += "(#{pf gust.to('mph').abs,1} MPH; #{pf gust.to('m/s').abs,1} m/s)" 160 | end 161 | s 162 | end 163 | end 164 | 165 | # How many statute miles of horizontal visibility. May be reported as less 166 | # than so many miles, in which case Unit#minus? returns true. 167 | class Visibility < Unit 168 | def initialize(raw) 169 | raise ArgumentError unless raw =~ /^(M?)(\d+ )?(\d+)(\/(\d+))?SM$/ 170 | @minus = true if $1 == 'M' 171 | if $4 172 | d = $3.to_f / $5.to_f 173 | else 174 | d = $3.to_f 175 | end 176 | if $2 177 | d += $2.to_f 178 | end 179 | super("#{d} mi") 180 | end 181 | end 182 | 183 | # How far down a runway the lights can be seen 184 | class RunwayVisualRange 185 | # Which runway 186 | attr_reader :runway 187 | alias :rwy :runway 188 | # How far. If variable, this is a two-element Array giving the limits. 189 | # Otherwise it's a Unit. 190 | attr_reader :range 191 | alias :distance :range 192 | alias :dist :range 193 | def initialize(raw) 194 | raise ArgumentError unless raw =~ /^R(\d+[LCR]?)\/([PM]?)(\d+)(V([P]?)(\d+))?FT$/ 195 | @runway = $1 196 | @range = ($3+' feet').unit 197 | @range.minus = true if $2 == 'M' 198 | @range.plus = true if $2 == 'P' 199 | if $4 200 | r1 = @range 201 | r2 = "#{$6} feet".unit 202 | r2.plus = true if $5 == 'P' 203 | @range = [r1,r2] 204 | end 205 | end 206 | # Is the visibility range variable? 207 | def variable? 208 | Array === @range 209 | end 210 | def to_s 211 | if variable? 212 | "On runway #{rwy}, from #{dist[0]} to #{dist[1]}" 213 | else 214 | "On runway #{rwy}, #{dist}" 215 | end 216 | end 217 | end 218 | # Weather phenomena in the area. At the moment this is a very thin layer 219 | # over the present weather group of METAR. Please see 220 | # FMH-1 Chapter 221 | # 12[http://www.nws.noaa.gov/oso/oso1/oso12/fmh1/fmh1ch12.htm#ch12link] 222 | # section 6.8 for more details. 223 | class PresentWeather 224 | # One of [:light, :moderate, :heavy] 225 | attr_reader :intensity 226 | # The descriptor. e.g. 'SH' means showers 227 | attr_reader :descriptor 228 | # The phenomena. An array of two-character codes, e.g. 'FC' for funnel 229 | # cloud or 'RA' for rain. 230 | attr_reader :phenomena 231 | def phenomenon 232 | @phenomena.first 233 | end 234 | def initialize(raw) 235 | r = /^([-+]|VC)?(MI|PR|BC|DR|BL|SH|TS|FZ)?((DZ|RA|SN|SG|IC|PE|PL|GR|GS|UP)*|(BR|FG|FU|VA|DU|SA|HZ|PY)*|(PO|SQ|FC|SS|DS)*)$/ 236 | raise ArgumentError unless raw =~ r 237 | 238 | case $1 239 | when '-' 240 | @intensity = :light 241 | when nil 242 | @intensity = :moderate 243 | when '+' 244 | @intensity = :heavy 245 | when 'VC' 246 | @intensity = :vicinity 247 | end 248 | 249 | @descriptor = $2 250 | 251 | @phenomena = [] 252 | s = $3 253 | until s.empty? 254 | @phenomena.push(s.slice!(0..1)) 255 | end 256 | end 257 | # Alias for intensity 258 | def proximity 259 | @intensity 260 | end 261 | end 262 | # Information about clouds or lack thereof 263 | class Sky 264 | # Cloud cover. A two-character code. (See FMH-1 265 | # 12.6.9[http://www.nws.noaa.gov/oso/oso1/oso12/fmh1/fmh1ch12.htm#ch12link]) 266 | attr_reader :cover 267 | alias :clouds :cover 268 | # Distance Unit to the base of the cover type. 269 | attr_reader :height 270 | alias :base :height 271 | def initialize(raw) 272 | raise ArgumentError unless raw =~ /^(SKC|CLR)|(VV|FEW|SCT|BKN|OVC)(\d\d\d|\/\/\/)(CB|TCU)?$/ 273 | 274 | if $1 275 | @clr = ($1 == 'CLR') 276 | @skc = ($1 == 'SKC') 277 | else 278 | @cover = $2 279 | @cb = ($4 == 'CB') 280 | @tcu = ($4 == 'TCU') 281 | @height = "#{$1}00 feet".unit if $3 =~ /(\d\d\d)/ 282 | end 283 | end 284 | # Is the sky clear? 285 | def skc? 286 | @skc 287 | end 288 | alias :clear? :skc? 289 | # Is the sky reported clear by automated equipment (meaning it's clear up 290 | # to 12,000 feet at least)? 291 | def clr? 292 | @clr 293 | end 294 | alias :auto_clear? :clr? 295 | # Are there cumulonimbus clouds? Only when reported by humans. 296 | def cb? 297 | @cb 298 | end 299 | alias :cumulonimbus? :cb? 300 | # Are there towering cumulus clouds? Only when reported by humans. 301 | def tcu? 302 | @tcu 303 | end 304 | alias :towering_cumulus? :tcu? 305 | # Is this a vertical visibility restriction (meaning they can't tell 306 | # what's up there above this height) 307 | def vv? 308 | @cover == 'VV' 309 | end 310 | alias :vertical_visibility? :vv? 311 | def to_s 312 | if skc? 313 | s = "Clear" 314 | elsif clr? 315 | s = "Clear below 12000 ft" 316 | elsif vv? 317 | s = "Vertical visibility #{height}" 318 | else 319 | s = Contractions[@cover] + " at #{height}" 320 | end 321 | end 322 | Contractions = {'FEW'=>'Few', 'SCT'=>'Scattered', 'BKN' => 'Broken', 'OVC' => 'Overcast'} 323 | end 324 | end 325 | end 326 | --------------------------------------------------------------------------------