"""
A small Flask web application that fetches METAR reports and converts them
into human-readable text summaries.
Author: Jeremy Morgan
License: MIT
"""
from flask import Flask, render_template, request
import requests
import re
app = Flask(__name__)
class METARDecoder:
"""
Decode METAR weather reports into a human-readable summary.
"""
def __init__(self):
# 16-point compass abbreviations mapped to plain English
self._compass = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
]
self.wind_directions = {
'N': 'north', 'NNE': 'north-northeast', 'NE': 'northeast', 'ENE': 'east-northeast',
'E': 'east', 'ESE': 'east-southeast', 'SE': 'southeast', 'SSE': 'south-southeast',
'S': 'south', 'SSW': 'south-southwest', 'SW': 'southwest', 'WSW': 'west-southwest',
'W': 'west', 'WNW': 'west-northwest', 'NW': 'northwest', 'NNW': 'north-northwest'
}
def get_wind_direction_text(self, degrees):
"""
Convert numeric wind direction in degrees to a plain-English direction.
Expects degrees as an int (0-360). Treat 0 or 360 as 'north'.
"""
try:
deg = int(degrees) % 360
except (TypeError, ValueError):
return "variable"
# Determine sector index for 16-point compass (22.5° sectors)
index = int((deg + 11.25) / 22.5) % 16
abbr = self._compass[index]
return self.wind_directions.get(abbr, "variable")
def decode_visibility(self, vis_str):
"""Decode visibility reported in statute miles (e.g. '10SM')."""
match = re.match(r'(\d+)(SM)', vis_str)
if match:
miles = int(match.group(1))
if miles >= 10:
return "10+ miles visibility"
return f"{miles} statute miles visibility"
return "visibility not reported"
def decode_weather_phenomena(self, wx_str):
"""
Map common METAR weather codes to plain English.
Supports RA, SN, DZ, FG, BR, HZ, TS, SH, and combinations.
"""
mapping = {
'RA': 'rain', 'SN': 'snow', 'DZ': 'drizzle', 'FG': 'fog',
'BR': 'mist', 'HZ': 'haze', 'TS': 'thunderstorms', 'SH': 'showers'
}
found = []
for code, desc in mapping.items():
if code in wx_str:
found.append(desc)
return ", ".join(found) if found else None
def decode_clouds(self, cloud_str):
"""
Decode cloud layer tokens such as FEW030, SCT045, BKN100, OVC008, CLR/SKC.
Convert 3-digit cloud bases to feet (hundreds of feet -> multiply by 100).
"""
if cloud_str in ('CLR', 'SKC'):
return "clear skies"
match = re.search(r'(FEW|SCT|BKN|OVC)(\d{3})', cloud_str)
if match:
coverage = match.group(1)
altitude = int(match.group(2)) * 100 # Convert hundreds of feet to feet
description = {
'FEW': 'few clouds',
'SCT': 'scattered clouds',
'BKN': 'broken clouds',
'OVC': 'overcast'
}.get(coverage, coverage)
return f"{description} at {altitude} feet"
return "cloud conditions not reported"
def decode_metar(self, metar):
"""
Parse a METAR string and return a structured dictionary and a short summary.
This is a simple, forgiving parser sufficient for typical METARs used in the app.
"""
decoded = {}
parts = metar.split()
# Example METAR header: KHPN 051953Z 36008KT 10SM CLR 21/M01 A3012
# Basic parsing loop:
for part in parts:
# Wind (e.g., 36008KT or VRB03KT)
if re.match(r'^\d{3}\d{2}KT$|^VRB\d{2}KT$', part):
# Extract direction and speed
if part.startswith('VRB'):
wind_dir_text = 'variable'
speed = part[3:5]
else:
wind_deg = int(part[0:3])
wind_dir_text = self.get_wind_direction_text(wind_deg)
speed = part[3:5]
decoded['wind'] = f"Wind from the {wind_dir_text} at {int(speed)} knots"
# Visibility in statute miles, e.g. 10SM
elif part.endswith('SM'):
decoded['visibility'] = self.decode_visibility(part)
# Weather phenomena tokens
elif any(wx in part for wx in ['RA', 'SN', 'DZ', 'FG', 'BR', 'HZ', 'TS', 'SH']):
weather = self.decode_weather_phenomena(part)
if weather:
decoded['weather'] = weather
# Cloud coverage tokens
elif any(part.startswith(cloud) for cloud in ['CLR', 'SKC', 'FEW', 'SCT', 'BKN', 'OVC']):
decoded['clouds'] = self.decode_clouds(part)
# Temperature/dewpoint (e.g., 21/M01)
elif re.match(r'^(M?\d{2})/(M?\d{2})$', part):
t, d = part.split('/')
def to_c(x): return int(x.replace('M', '-'))
temp_c = to_c(t)
dew_c = to_c(d)
temp_f = round((temp_c * 9/5) + 32)
decoded['temperature'] = f"{temp_c}°C ({temp_f}°F)"
decoded['dewpoint'] = f"{dew_c}°C"
# Altimeter (e.g., A3012 -> 30.12 inHg)
elif part.startswith('A') and len(part) == 5 and part[1:].isdigit():
alt_inhg = float(part[1:]) / 100
decoded['pressure'] = f"{alt_inhg:.2f} inHg"
# Build a short human-readable summary
summary_parts = []
for key in ('weather', 'clouds', 'temperature', 'wind', 'visibility', 'pressure'):
if key in decoded:
summary_parts.append(decoded[key])
summary = "; ".join(summary_parts) if summary_parts else "No readable data extracted"
return {'decoded': decoded, 'summary': summary}
# Flask routes (skeleton)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/metar', methods=['POST'])
def metar():
station = request.form.get('station', '').strip().upper()
if not station:
return render_template('result.html', error="Please provide a station code.")
# Fetch METAR from aviationweather.gov (simple example URL)
url = (
"https://aviationweather.gov/adds/dataserver_current/httpparam?"
f"dataSource=metars&requestType=retrieve&format=xml&stationString={station}&hoursBeforeNow=1"
)
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
except requests.RequestException:
return render_template('result.html', error="Failed to fetch METAR data.")
# For this example, assume we retrieved a METAR string
# In a production app parse XML and extract the raw_text field
raw_metar = "KHPN 051953Z 36008KT 10SM CLR 21/M01 A3012"
decoder = METARDecoder()
result = decoder.decode_metar(raw_metar)
return render_template('result.html', metar=raw_metar, summary=result['summary'])