5a69bb3ec5c86cb91e391404a40d61cad16fe767
[metar.git] / src / metar.lua
1 -------------------------------------------------------------------------------
2 -- Lua class to parse METAR coded weather reports and fetch current METAR
3 -- reports from <a href="http://www.noaa.gov">NOAA</a> <a href="http://weather.noaa.gov">Internet Weather Service</a>.
4 -- The parser is pretty simple and by no means claims to support every feature
5 -- one might find in METAR coded weather reports. For example, weather forecasts
6 -- and automatic weather reports are not detected. Unsupported features in
7 -- the weather reports are silently dropped.
8 -- @author Tuomas Jormola
9 -- @copyright © 2011-2016 Tuomas Jormola <a href="mailto:tj@solitudo.net">tj@solitudo.net</a> <a href="http://solitudo.net">http://solitudo.net</a>
10 --  Licensed under the terms of
11 -- the <a href="http://www.gnu.org/licenses/gpl-2.0.html">GNU General Public License Version 2.0</a>.
12 --
13 -------------------------------------------------------------------------------
14
15 local socket_http  = require('socket.http')
16
17 local weatherlib   = require('weatherlib')
18
19 local os           = { date = os.date, time = os.time }
20 local pairs        = pairs
21 local print        = print
22 local setmetatable = setmetatable
23 local string       = { format = string.format }
24 local table        = { insert = table.insert, maxn = table.maxn}
25 local tonumber     = tonumber
26 local type         = type
27
28 module('metar')
29
30 local token_data = {
31     wind = {
32         directions = {
33             { key = 'N',   min = 349, max = 11                              },
34             { key = 'NNE', min = 12,  max = 33                              },
35             { key = 'NE',  min = 34,  max = 56                              },
36             { key = 'ENE', min = 57,  max = 78                              },
37             { key = 'E',   min = 79,  max = 101                             },
38             { key = 'ESE', min = 102, max = 123                             },
39             { key = 'SE',  min = 124, max = 146                             },
40             { key = 'SSE', min = 147, max = 168                             },
41             { key = 'S',   min = 169, max = 191                             },
42             { key = 'SSW', min = 192, max = 213                             },
43             { key = 'SW',  min = 214, max = 236                             },
44             { key = 'WSW', min = 237, max = 258                             },
45             { key = 'W',   min = 259, max = 281                             },
46             { key = 'WNW', min = 282, max = 303                             },
47             { key = 'NW',  min = 304, max = 326                             },
48             { key = 'NNW', min = 327, max = 348                             },
49         },
50         speeds = {
51             KT  = weatherlib.SPEED_UNITS.KNOT,
52             MPS = weatherlib.SPEED_UNITS.MS,
53             KMH = weatherlib.SPEED_UNITS.KMH,
54         },
55         offset = 1,
56     },
57     clouds = {
58         coverages = {
59             { token = 'SKC', alias = 'CLR',   key = 'CLEAR'                 },
60             { token = 'FEW',                  key = 'FEW'                   },
61             { token = 'SCT',                  key = 'SCATTERED'             },
62             { token = 'BKN',                  key = 'BROKEN_SKY'            },
63             { token = 'OVC',                  key = 'OVERCAST'              },
64         },
65         types = {
66             { token = 'CB',                   key = 'CUMULONIMBUS'          },
67             { token = 'TCU',                  key = 'TOWERING_CUMULUS'      },
68         },
69     },
70     sky = {
71         types = {
72             { token = 'CAVOK', alias = 'SKC', key = 'CLEAR'                 },
73             { token = 'NSC',                  key = 'NO_SIGNIFICANT_CLOUDS' },
74             { token = 'NCD',                  key = 'NO_CLOUDS_DETECTED'    },
75         },
76         offset = 3,
77     },
78     weather_intensity = {
79         types = {
80             { token = '-',                    key = 'LIGHT'                 },
81             { token = '+',                    key = 'HEAVY'                 },
82             { token = 'VC',                   key = 'VICINITY'              },
83         },
84         offset = 1,
85     },
86     weather_descriptor = {
87         types = {
88             { token = 'MI',                   key = 'SHALLOW'               },
89             { token = 'PR',                   key = 'PARTIAL'               },
90             { token = 'BC',                   key = 'PATCHES'               },
91             { token = 'DR',                   key = 'DRIFTING'              },
92             { token = 'BL',                   key = 'BLOWING'               },
93             { token = 'SH',                   key = 'SHOWERS'               },
94             { token = 'TS',                   key = 'THUNDERSTORM'          },
95             { token = 'FZ',                   key = 'FREEZING'              },
96         },
97     },
98     weather_phenomena = {
99         types = {
100             { token = 'DZ',                   key = 'DRIZZLE'               },
101             { token = 'RA',                   key = 'RAIN'                  },
102             { token = 'SN',                   key = 'SNOW'                  },
103             { token = 'SG',                   key = 'SNOW_GRAINS'           },
104             { token = 'IC',                   key = 'ICE_CRYSTALS'          },
105             { token = 'PL', alias = 'PE',     key = 'ICE_PELLETS'           },
106             { token = 'GR',                   key = 'HAIL'                  },
107             { token = 'GS',                   key = 'SMALL_HAIL'            },
108             { token = 'UP',                   key = 'UNKNOWN'               },
109             { token = 'BR',                   key = 'MIST'                  },
110             { token = 'FG',                   key = 'FOG'                   },
111             { token = 'FU',                   key = 'SMOKE'                 },
112             { token = 'VA',                   key = 'VOLCANIC_ASH'          },
113             { token = 'DU',                   key = 'WIDESPREAD_DUST'       },
114             { token = 'SA',                   key = 'SAND'                  },
115             { token = 'HZ',                   key = 'HAZE'                  },
116             { token = 'PY',                   key = 'SPRAY'                 },
117             { token = 'PO',                   key = 'DUST_WHIRLS'           },
118             { token = 'SQ',                   key = 'SQUALLS'               },
119             { token = 'FC',                   key = 'FUNNEL_CLOUD'          },
120             { token = 'SS',                   key = 'SAND_STORM'            },
121             { token = 'DS',                   key = 'DUST_STORM'            },
122         },
123     }
124 }
125
126 -------------------------------------------------------------------------------
127 -- Wind direction table.
128 -- Values from this table are used in the result table with key <code>wind.direction</code>
129 -- returned by <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
130 -- @class table
131 -- @name WIND_DIRECTION
132 -- @field VRB Index value for variable speed direction
133 -- @field N Index value for North
134 -- @field NNE Index value for North - North East
135 -- @field NE Index value for Nort - East
136 -- @field ENE Index value for East - North East
137 -- @field E Index value for East
138 -- @field ESE Index value for East - South East
139 -- @field SE Index value for South East
140 -- @field SSE Index value for South - South Est
141 -- @field S Index value for South
142 -- @field SSW Index value for South - South West
143 -- @field SW Index value for South West
144 -- @field WSW Index value for West - South West
145 -- @field W  Index value for West
146 -- @field WNW Index value for West - North West
147 -- @field NW Index value for North West
148 -- @field NNW Index value for North - North West
149 WIND_DIRECTION      = { VRB = 1 }
150
151 -------------------------------------------------------------------------------
152 -- Could coverage table.
153 -- Values from this table are used in the result table with key <code>clouds[n].coverage</code> returned by
154 -- <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
155 -- @class table
156 -- @name CLOUD_COVERAGE
157 -- @field CLEAR Clear
158 -- @field FEW Few clouds
159 -- @field SCATTERED Scattered clouds
160 -- @field BROKEN_SKY Broken sky
161 -- @field OVERCAST Overcast
162 CLOUD_COVERAGE      = {}
163
164 -------------------------------------------------------------------------------
165 -- Could type table.
166 -- Values from this table are used in the result table with key <code>clouds[n].type</code> returned by
167 -- <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
168 -- @class table
169 -- @name CLOUD_TYPE
170 -- @field CUMULONIMBUS Cumulonimbus clouds
171 -- @field TOWERING_CUMULUS Towering Cumulus clouds
172 CLOUD_TYPE          = {}
173
174 -------------------------------------------------------------------------------
175 -- Could type table.
176 -- Values from this table are used in the result table with key <code>sky</code> returned by
177 -- <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
178 -- @class table
179 -- @name SKY_STATUS
180 -- @field UNKNOWN Sky type is unknown
181 -- @field OBSCURE Obscured sky
182 -- @field CLOUDS Clouds in the sky
183 -- @field CLEAR Clear sky
184 -- @field NO_SIGNIFICANT_CLOUDS No significant clouds detected
185 -- @field NO_CLOUDS_DETECTED No clouds detected
186 SKY_STATUS          = { UNKNOWN = 1, OBSCURE = 2, CLOUDS = 3}
187
188 -------------------------------------------------------------------------------
189 -- Weather intensity table.
190 -- Values from this table are used in the result table with key <code>weather.intensity</code> returned by
191 -- <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
192 -- @class table
193 -- @name WEATHER_INTENSITY
194 -- @field MODERATE Moderate phenomena
195 -- @field LIGHT Light phenomena
196 -- @field HEAVY Heavy phenomena
197 -- @field VICINITY In the vicinity of the weather observation point
198 WEATHER_INTENSITY   = { MODERATE = 1 }
199
200 -------------------------------------------------------------------------------
201 -- Weather descriptor table.
202 -- Values from this table are used in the result table with key <code>weather.descriptor</code> returned by
203 -- <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
204 -- @class table
205 -- @name WEATHER_DESCRIPTOR
206 -- @field SHALLOW Shallow phenomena
207 -- @field PARTIAL Partial phenomena
208 -- @field PATCHES Patches phenomena
209 -- @field DRIFTING Drifring phenomena
210 -- @field BLOWING Blowing phenomena
211 -- @field SHOWERS Showers phenomena
212 -- @field THUNDERSTORM Thunderstorm phenomena
213 -- @field FREEZING Freezing phenomena
214 WEATHER_DESCRIPTOR   = {}
215
216 -------------------------------------------------------------------------------
217 -- Weather phenomena table.
218 -- Values from this table are used in the result table with key <code>weather.phenomena</code> returned by
219 -- <a href="#metatable.__index:get_metar_data"><code>get_metar_data()</code></a>.
220 -- @class table
221 -- @name WEATHER_PHENOMENA
222 -- @field DRIZZLE Drizzle
223 -- @field RAIN Rain
224 -- @field SNOW Snow
225 -- @field SNOW_GRAINS Snow grains
226 -- @field ICE_CRYSTALS Ice crystals
227 -- @field ICE_PELLETS Ice pellets
228 -- @field HAIL Hail
229 -- @field SMALL_HAIL Small hail
230 -- @field UNKNOWN Unknown phenomena
231 -- @field MIST Mist
232 -- @field FOG Fog
233 -- @field SMOKE Smoke
234 -- @field VOLCANIC_ASH Volcanic ash
235 -- @field WIDESPREAD_DUST Widespread dust
236 -- @field SAND Sand
237 -- @field HAZE Haze
238 -- @field SPRAY Spray
239 -- @field DUST_WHIRLS Dust whirls
240 -- @field SQUALLS Squalls
241 -- @field FUNNEL_CLOUD Funnel cloud
242 -- @field SAND_STORM Sand storm
243 -- @field DUST_STORM Dust storm
244 WEATHER_PHENOMENA   = {}
245
246 -- Fill the constant tables
247 for i, key in pairs(token_data.wind.directions) do
248     WIND_DIRECTION[key.key] = i + token_data.wind.offset
249 end
250 for i, key in pairs(token_data.clouds.coverages) do
251     CLOUD_COVERAGE[key.key] = i
252 end
253 for i, key in pairs(token_data.clouds.types) do
254     CLOUD_TYPE[key.key] = i
255 end
256 for i, key in pairs(token_data.sky.types) do
257     SKY_STATUS[key.key] = i + token_data.sky.offset
258 end
259 for i, key in pairs(token_data.weather_intensity.types) do
260     WEATHER_INTENSITY[key.key] = i + token_data.weather_intensity.offset
261 end
262 for i, key in pairs(token_data.weather_descriptor.types) do
263     WEATHER_DESCRIPTOR[key.key] = i
264 end
265 for i, key in pairs(token_data.weather_phenomena.types) do
266     WEATHER_PHENOMENA[key.key] = i
267 end
268
269 local function parse_metar_date(day, hour, min)
270     if not day or not hour or not min then
271         return
272     end
273     day  = tonumber(day)
274     hour = tonumber(hour)
275     min  = tonumber(min)
276     local now = os.date('!*t')
277     if day > now.day and now.day == 1 then
278         now.month = _get_previous_month(now)
279         now.day = _get_last_day_of_month(now)
280         if now.month == 12 then
281             now.year = now.year - 1
282         end
283     else
284         now.day = day
285     end
286     now.hour = hour
287     now.min  = min
288     now.sec  = 0
289     return { timestamp = os.time(now) }
290 end
291
292 -- The parse_* routines parse snippets of METAR data
293
294 local function parse_metar_wind(dir, speed, gust, unit)
295     local direction
296     if gust and token_data.wind.speeds[gust] then
297             unit = gust
298             gust = nil
299     end
300     if not dir or not speed or not unit or not token_data.wind.speeds[unit] then
301         return
302     end
303     if dir:match('^%d%d%d$') then
304         dir = tonumber(dir)
305         for _, test_direction in pairs(token_data.wind.directions) do
306             if (
307                             (test_direction.min > test_direction.max)
308                         and
309                             (test_direction.min <= dir or dir <= test_direction.max)
310                     or
311                             (test_direction.max > test_direction.min)
312                         and
313                             (test_direction.min <= dir and dir <= test_direction.max)
314                     ) then
315                 direction = WIND_DIRECTION[test_direction.key]
316                 break
317             end
318         end
319     else
320         direction = WIND_DIRECTION[dir]
321     end
322     if not direction then
323         return
324     end
325     local data = { direction = direction, speed = weatherlib.convert_speed(token_data.wind.speeds[unit], weatherlib.SPEED_UNITS.KNOT, tonumber(speed)) }
326     if gust ~= nil then
327         data['gust'] = weatherlib.convert_speed(token_data.wind.speeds[unit], weatherlib.SPEED_UNITS.KNOT, tonumber(gust))
328     end
329     return { wind = data }
330 end
331
332 local function parse_metar_visibility(visibility, direction)
333     if visibility == 'CAVOK' then
334         visibility = 9999
335     end
336     local data = { distance = tonumber(visibility) }
337     if direction and direction:len() > 0 then
338         if WIND_DIRECTION[direction] then
339             data['direction'] = WIND_DIRECTION[direction]
340         else
341             return
342         end
343     end
344     return { visibility = data }
345 end
346
347 local function parse_metar_runway_visual_range(runway, visibility)
348     return { runway_visual_range = { runway = tonumber(runway), visibility = tonumber(visibility) } } 
349 end
350
351 local function parse_metar_clouds(coverage, altitude, type)
352     local coverage_key
353     for _, coverage_data in pairs(token_data.clouds.coverages) do
354         if coverage == coverage_data.token or coverage == coverage_data.alias then
355             coverage_key = coverage_data.key
356             break
357         end
358     end
359     if not coverage_key then
360         return
361     end
362     if not CLOUD_COVERAGE[coverage_key] then
363         return
364     end
365     local data = { coverage = CLOUD_COVERAGE[coverage_key], altitude = tonumber(altitude) }
366     if type and type:len() > 0 then
367         local type_key
368         for _, type_data in pairs(token_data.clouds.types) do
369             if type == type_data.token then
370                 type_key = type_data.key
371                 break
372             end
373         end
374         if not type_key then
375             return
376         end
377         if not CLOUD_TYPE[type_key] then
378             return
379         end
380         data['type'] = CLOUD_TYPE[type_key]
381     end
382     return { clouds = data }
383 end
384
385 local function parse_metar_vertical_visibility(visibility)
386     if visibility and visibility:find('^%d%d%d$') then
387         visibility = tonumber(visibility)
388     elseif visibility == '///' then
389         visibility = 0
390     else
391         return
392     end
393     return { vertical_visibility = visibility }
394 end
395
396 local function parse_metar_sky(status)
397     local sky_key
398     for _, sky_data in pairs(token_data.sky.types) do
399         if status == sky_data.token or status == sky_data.alias then
400             sky_key = sky_data.key
401             break
402         end
403     end
404     if not sky_key then
405         return
406     end
407     if not SKY_STATUS[sky_key] then
408         return
409     end
410     return { sky = SKY_STATUS[sky_key] }
411 end
412
413 local function parse_metar_weather(intensity, descriptor, phenomena)
414     if not phenomena or phenomena:len() == 0 then
415         phenomena = descriptor
416         descriptor = nil
417     end
418     local intensity_key, descriptor_key, phenomena_key
419     if not intensity or intensity:len() == 0 then
420         intensity = WEATHER_INTENSITY.MODERATE
421     else
422         for _, intensity_data in pairs(token_data.weather_intensity.types) do
423             if intensity == intensity_data.token then
424                 intensity_key = intensity_data.key
425                 break
426             end
427         end
428         if not intensity_key or not WEATHER_INTENSITY[intensity_key] then
429             return
430         end
431         intensity = WEATHER_INTENSITY[intensity_key]
432     end
433     if descriptor and descriptor:len() > 0 then
434         for _, descriptor_data in pairs(token_data.weather_descriptor.types) do
435             if descriptor == descriptor_data.token then
436                 descriptor_key = descriptor_data.key
437                 break
438             end
439         end
440         if not descriptor_key or not WEATHER_DESCRIPTOR[descriptor_key] then
441             return
442         end
443         descriptor = WEATHER_DESCRIPTOR[descriptor_key]
444     end
445     if not phenomena or phenomena:len() == 0 then
446         return
447     end
448     for _, phenomena_data in pairs(token_data.weather_phenomena.types) do
449         if phenomena == phenomena_data.token or phenomena == phenomena_data.alias then
450             phenomena_key = phenomena_data.key
451             break
452         end
453     end
454     if not phenomena_key or not WEATHER_PHENOMENA[phenomena_key] then
455         return
456     end
457     phenomena = WEATHER_PHENOMENA[phenomena_key]
458     local weather_data = { intensity = intensity, phenomena = phenomena }
459     if descriptor then
460         weather_data['descriptor'] = descriptor
461     end
462     return { weather = weather_data }
463 end
464
465 local function parse_metar_temperature(temperature, dewpoint)
466     temperature = temperature:gsub('^M', '-')
467     temperature = temperature:gsub('^-0+$', '0')
468     dewpoint = dewpoint:gsub('^M', '-')
469     dewpoint = dewpoint:gsub('^-0+$', '0')
470     return { temperature = tonumber(temperature), dewpoint = tonumber(dewpoint) }
471 end
472
473 local function parse_metar_pressure(unit, pressure)
474     if unit == 'A' then
475         pressure = weatherlib.convert.pressure[weatherlib.PRESSURE_UNITS.INHG][weatherlib.PRESSURE_UNITS.HPA](tonumber(pressure) / 100)
476     else
477         pressure = tonumber(pressure)
478     end
479     return { pressure = pressure }
480 end
481
482 -- Define patterns for each snippet of METAR data to parse
483 local metar_token_handlers = {
484     {
485         pattern = '^(%d%d)(%d%d)(%d%d)Z$',
486         handler = parse_metar_date,
487     },
488     {
489         pattern = '^(%w%w%w)(%d+)G(%d+)([A-Z]+)$',
490         handler = parse_metar_wind,
491     },
492     {
493         pattern = '^(%w%w%w)(%d+)([A-Z]+)$',
494         handler = parse_metar_wind,
495     },
496     {
497         pattern = '^(%d%d%d%d)([A-Z]*)$',
498         handler = parse_metar_visibility,
499     },
500     {
501         pattern = '^(CAVOK)$',
502         handler = parse_metar_visibility,
503     },
504     {
505         pattern = '^R(%d+)/(%d+)$',
506         handler = parse_metar_runway_visual_range,
507     },
508     {
509         pattern = '^([A-Z][A-Z][A-Z])(%d%d%d)([A-Z]*)$',
510         handler = parse_metar_clouds,
511     },
512     {
513         pattern = '^VV([%d/][%d/][%d/])$',
514         handler = parse_metar_vertical_visibility,
515     },
516     {
517         pattern = '^([A-Z]+)$',
518         handler = parse_metar_sky,
519     },
520     {
521         pattern = '^([+-]*)([A-Z][A-Z])$',
522         handler = parse_metar_weather,
523     },
524     {
525         pattern = '^([+-]*)([A-Z][A-Z])([A-Z][A-Z])$',
526         handler = parse_metar_weather,
527     },
528     {
529         pattern = '^(VC)([A-Z][A-Z])*$',
530         handler = parse_metar_weather,
531     },
532     {
533         pattern = '^(VC)([A-Z][A-Z])([A-Z][A-Z])*$',
534         handler = parse_metar_weather,
535     },
536     {
537         pattern = '^(M*%d+)/(M*%d+)$',
538         handler = parse_metar_temperature,
539     },
540     {
541         pattern = '^([QA])(%d+)$',
542         handler = parse_metar_pressure,
543     },
544 }
545
546 local function _get_previous_month(date)
547     if date.month == 1 then
548         return 12
549     end
550     return date.month - 1
551 end
552
553 local function _get_last_day_of_month(date)
554     date.day  = 1
555     date.hour = 0
556     date.min  = 0
557     date.sec  = 0
558     date.wday = 0
559     date.yday = 0
560     local day_seconds = 60 * 60 * 24
561     local timestamp = os.time(date)
562     local test_date = date
563     -- count days forward until month changes
564     while(date.month == test_date.month) do
565         timestamp = os.time(test_date) + day_seconds
566         test_date = os.date('*t', timestamp)
567     end
568     -- rewind one day to get the last day of the month
569     timestamp = timestamp - day_seconds
570     return os.date('*t', timestamp).day
571 end
572
573 local metatable = { __index = {} }
574
575 -------------------------------------------------------------------------------
576 -- Create a new METAR object
577 -- @param args String that is either the METAR data string (one line) to parse
578 -- or the four-letter, upper-case <a href="http://en.wikipedia.org/wiki/International_Civil_Aviation_Organization_airport_code">ICAO code</a>
579 -- for the weather station. If weather station code is given, the current
580 -- METAR data for the station is downloaded from <a href="http://weather.noaa.gov">IWS</a>.
581 -- @return A table which is the metar object for METAR data given or
582 -- downloaded from IWS for the given weather station code
583 function new(args)
584     local attributes = {}
585     if args and type(args) == 'string' then
586         if args:find('^[A-Z][A-Z][A-Z][A-Z]$') then
587             attributes.station_id = args
588         else
589             attributes.metar_string = args
590         end
591     end
592     return setmetatable(attributes, metatable)
593 end
594
595 -------------------------------------------------------------------------------
596 -- Return parsed METAR data as a table
597 -- @return Table containing the data parsed from the METAR data. 
598 -- If an error occurs, returns nil as the first return value.
599 -- The table may contain following entries
600 -- <ul>
601 -- <li><code>timestamp</code> <code>os.time</code> table which represents the timestamp when the METAR data was generated. Time is in UTC. Always included.</li>
602 -- <li><code>wind</code> A table representing the wind phenomena with the following keys. Optional, but usually included.</li>
603 --     <ul>
604 --     <li><code>direction</code> Wind direction as a value of the <a href="#WIND_DIRECTION">WIND_DIRECTION</a> table.</li>
605 --     <li><code>speed</code> Wind speed in knots.</li>
606 --     <li><code>gust</code> Gust speed in knots, optional.</li>
607 --     </ul>
608 -- <li><code>visibility</code> A list of tables that represent the visibility towards different directions. Tables contain the following keys. Optional, but if defined, at least one visibility entry exists in the list. Usually included.</li>
609 --     <ul>
610 --     <li><code>direction</code> Direction as a value of the <a href="#WIND_DIRECTION">WIND_DIRECTION</a> table. Optional.</li>
611 --     <li><code>distance</code> Visibility distance in meters</li>
612 --     </ul>
613 -- <li><code>vertical_visibility</code> Vertical visibility in meters. Optional.</li>
614 -- <li><code>runway_visual_range</code> A table representing runway visual range with the following keys. Optional.</li>
615 --     <ul>
616 --     <li><code>runway</code> Runway code</li>
617 --     <li><code>visibility</code> Visibility in meters</li>
618 --     </ul>
619 -- <li><code>clouds</code> A list of tables that represent clouds at different altitudes. Tables contain the following keys. Optional, but if defined, at least one cloud entry exists in the list. Usually included.</li>
620 --     <ul>
621 --     <li><code>coverage</code> Cloud coverate as a value of the <a href="#CLOUD_COVERAGE">CLOUD_COVERAGE</a> table.</li>
622 --     <li><code>altitude</code> Altitude of the clouds in feet.</li>
623 --     <li><code>type</code> Cloud type as a value of the <a href="#CLOUD_TYPE">CLOUD_TYPE</a> table.</li>
624 --     </ul>
625 -- <li><code>weather</code> A table representing weather conditions with the following keys. Optional, but usually included.</li>
626 --     <ul>
627 --     <li><code>intensity</code> Weather intensity as a value of the <a href="#WEATHER_INTENSITY">WEATHER_INTENSITY</a> table. Optional.</li></li>
628 --     <li><code>descriptor</code> Weather descriptor as a value of the <a href="#WEATHER_DESCRIPTOR">WEATHER_DESCRIPTOR</a> table. Optional.</li>
629 --     <li><code>phenomena</code> Weather phenomena as a value of the <a href="#WEATHER_PHENOMENA">WEATHER_PHENOMENA</a> table. Always included.</li>
630 --     </ul>
631 -- <li><code>sky</code> Sky status as a value of the <a href="#SKY_STATUS">SKY_STATUS</a> table. Always included.</li>
632 -- <li><code>temperature</code> Temperature in Celcius. Always  included.</li>
633 -- <li><code>dewpoint</code> Dewpoint temperature in Celcius. Always included.</li>
634 -- <li><code>pressure</code> Pressure in hectopascals. Optional, but usually included.</li>
635 --</ul>
636 -- @return Error string in case an error occurred and nil METAR table is returned
637 -- @usage var m = metar.new('EFHF')          -- Weather station Helsinki/Malmi
638 -- @usage var md = m:get_metar_data()        -- metardata.temperature contains the temperature etc.
639 -- @usage if md.temperature >= 30 then print("It's hot!") end
640 -- @usage if md.weather.intensity and md.weather.intensity == m.WEATHER_INTENSITY.HEAVY and md.weather.phenomena and md.weather.phenomena == m.WEATHER_PHENOMENA.RAIN then print("It's raining a lot!") end
641 function metatable.__index:get_metar_data()
642     if self.station_id then
643         local metar_string, error_string = self:_fetch_metar_string(self.station_id)
644         if not metar_string then
645             return nil, error_string
646         end
647         self.metar_string = metar_string
648     end
649     if not self.metar_string then
650         return nil, 'No METAR string or station id'
651     end
652     local metar_data = self:_parse_metar_string(self.metar_string)
653     if self.station_id then
654         metar_data['station'] = self.station_id
655     end
656     metar_data['metar_string'] = self.metar_string
657     return metar_data
658 end
659
660 function metatable.__index:_parse_metar_string(metar_string)
661     local multi_entry = { visibility = 1, runway_visual_range = 1, clouds = 1 }
662     local metar_data  = {}
663     local token_data
664     -- tokenize METAR data
665     metar_string:gsub('%S+', function(token)
666             local data = {}
667             -- test each parser against the token and
668             -- if it matches, pass to the handler
669             for _, token_handler in pairs(metar_token_handlers) do
670                 token:gsub(token_handler.pattern,
671                     function(...)
672                         local value = token_handler.handler(...)
673                         table.insert(data, value)
674                     end)
675             end
676             -- save results
677             for _, data_entry in pairs(data) do
678                 if data_entry then
679                     for key, value in pairs(data_entry) do
680                         if multi_entry[key] then
681                             if not metar_data[key] then
682                                 metar_data[key] = {}
683                             end
684                             table.insert(metar_data[key], value)
685                         else
686                         metar_data[key] = value
687                         end
688                     end
689                 end
690             end
691     end)
692     -- METAR data parsed, do some post processing
693     if metar_data.clouds then
694         metar_data.sky = SKY_STATUS.CLOUDS
695     elseif metar_data.vertical_visibility then
696         metar_data.sky = SKY_STATUS.OBSCURE
697     end
698     if not metar_data.sky then
699         metar_data.sky = SKY_STATUS.UNKNOWN
700     end
701     return metar_data
702 end
703
704 -- Download and validate METAR string for a weather station
705 function metatable.__index:_fetch_metar_string(station_id)
706     local body, error_string = self:_download_metar_file(station_id)
707     if not body then
708         return nil, error_string
709     end
710     local metar_string
711     local metar_pattern = string.format('^%s%%s+%%d%%d%%d%%d%%d%%dZ%%s+', self.station_id)
712     body:gsub('(.-)\n', function (line)
713             if line:find(metar_pattern) then
714                 metar_string = line
715                 return
716             end
717     end)
718     if metar_string then
719         return metar_string
720     else
721         return nil, 'Failed to find METAR string from input'
722     end
723 end
724
725 -- Download the METAR data for a weather station from IWS
726 function metatable.__index:_download_metar_file(station_id)
727     local metar_url = string.format('http://weather.noaa.gov/pub/data/observations/metar/stations/%s.TXT', station_id)
728     local body, status, _, status_string = socket_http.request(metar_url)
729     local error_details
730     if not status or (type(status) ~= 'number' and type(status) ~= 'string') then
731         error_details = 'Unknown error'
732     elseif type(status) == 'string' then
733         error_details = status
734     elseif type(status) == 'number' and status ~= 200 then
735         error_details = status_string
736     end
737     if error_details then
738         local error_string = string.format('Failed to fetch METAR data for station %s: %s', self.station_id, error_details)
739         self:log_error(error_string)
740         return nil, error_string
741     end
742     return body
743 end
744
745 function metatable.__index:log_error(error_string)
746     print(string.format('[metar] %s', error_string))
747 end