# app.py
"""
A web application that fetches METAR (aviation weather reports) and converts
them from cryptic aviation codes into human-readable weather reports.
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 simple, human-readable dictionary.
This example implements a subset of METAR parsing: wind, visibility,
temperature/dewpoint, pressure, and basic cloud groups.
"""
def __init__(self):
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):
"""Return a simple textual wind direction for a given degree value."""
try:
deg = int(degrees) % 360
except (ValueError, TypeError):
return None
# Use 16-point compass to find closest heading
sectors = [
(0, 'N'), (22.5, 'NNE'), (45, 'NE'), (67.5, 'ENE'),
(90, 'E'), (112.5, 'ESE'), (135, 'SE'), (157.5, 'SSE'),
(180, 'S'), (202.5, 'SSW'), (225, 'SW'), (247.5, 'WSW'),
(270, 'W'), (292.5, 'WNW'), (315, 'NW'), (337.5, 'NNW'), (360, 'N')
]
closest = min(sectors, key=lambda s: abs(deg - s[0]))
compass_abbr = closest[1]
return self.wind_directions.get(compass_abbr)
def decode_clouds(self, cloud_str):
"""
Decode a cloud group like 'BKN035', 'OVC012', or 'VV002'.
Returns a human-friendly string such as 'broken clouds at 3500 feet' or
'vertical visibility at 200 feet'.
"""
if not cloud_str:
return None
m = re.match(r'^(CLR|SKC|FEW|SCT|BKN|OVC|VV)(\d{3})?$', cloud_str)
if not m:
return None
code, h = m.group(1), m.group(2)
mapping = {
'CLR': 'clear',
'SKC': 'sky clear',
'FEW': 'few clouds',
'SCT': 'scattered clouds',
'BKN': 'broken clouds',
'OVC': 'overcast',
'VV': 'vertical visibility'
}
description = mapping.get(code, 'clouds')
if h:
altitude_ft = int(h) * 100
# For vertical visibility, it's more common to say "vertical visibility X feet"
if code == 'VV':
return f"{description} at {altitude_ft} feet"
return f"{description} at {altitude_ft} feet"
return description
def decode_metar(self, metar_string):
"""
A lightweight METAR parser for demonstration purposes.
Returns a dict with keys like 'wind', 'visibility', 'temperature', 'clouds', 'pressure'.
"""
decoded = {
'raw': metar_string,
'wind': None,
'visibility': None,
'temperature': None,
'dewpoint': None,
'clouds': None,
'pressure_inhg': None
}
parts = metar_string.split()
for part in parts:
# Wind example: 36008KT, 36008G15KT or VRB03KT
if re.match(r'^(?:\d{3}|VRB)\d{2,3}(?:G\d{2,3})?KT$', part):
# Extract degrees/direction, speed, and optional gusts
m = re.match(r'^(?P<dir>\d{3}|VRB)(?P<speed>\d{2,3})(?:G(?P<gust>\d{2,3}))?KT$', part)
if m:
dir_code = m.group('dir')
speed = int(m.group('speed'))
gust = m.group('gust')
gust_text = f", gusts {int(gust)} knots" if gust else ''
if dir_code == 'VRB':
wind_text = f"Variable wind at {speed} knots{gust_text}"
else:
wind_dir_text = self.get_wind_direction_text(dir_code)
wind_text = f"Wind from the {wind_dir_text} at {speed} knots{gust_text}" if wind_dir_text else f"{speed} knots{gust_text}"
decoded['wind'] = wind_text
# Visibility in statute miles, e.g., 10SM (US format)
elif part.endswith('SM'):
decoded['visibility'] = part
# International visibility in meters (e.g., 9999 = 10 km or more)
elif re.match(r'^\d{4}$', part):
# Treat as meters
try:
meters = int(part)
decoded['visibility'] = f"{meters} meters"
except ValueError:
decoded['visibility'] = part
# Temperature/dewpoint format: 21/M01 or M05/M10
elif re.match(r'^M?\d{1,2}/M?\d{1,2}$', part):
t, d = part.split('/')
def to_celsius(val):
if val.startswith('M'):
return -int(val[1:])
return int(val)
t_c = to_celsius(t)
d_c = to_celsius(d)
decoded['temperature'] = f"{t_c}°C ({t_c * 9/5 + 32:.0f}°F)"
decoded['dewpoint'] = f"{d_c}°C ({d_c * 9/5 + 32:.0f}°F)"
# Pressure: A3012 -> 30.12 inHg (US style), Q1013 -> 1013 hPa (international)
elif part.startswith('A') and re.match(r'^A\d{4}$', part):
inches = float(part[1:]) / 100.0
decoded['pressure_inhg'] = f"{inches:.2f} inHg"
elif part.startswith('Q') and re.match(r'^Q\d{4}$', part):
hpa = int(part[1:])
inches = hpa * 0.0295299830714
decoded['pressure_inhg'] = f"{inches:.2f} inHg ({hpa} hPa)"
# Cloud groups, may appear multiple times; keep the last or modify to accumulate if desired
elif any(part.startswith(cloud) for cloud in ['CLR', 'SKC', 'FEW', 'SCT', 'BKN', 'OVC', 'VV']):
decoded['clouds'] = self.decode_clouds(part)
# Common weather phenomena (RA, SN, FG, BR, HZ, TS, SH)
elif any(wx in part for wx in ['RA', 'SN', 'DZ', 'FG', 'BR', 'HZ', 'TS', 'SH']):
# Simplified mapping for demonstration
mapping = {
'RA': 'rain', 'SN': 'snow', 'DZ': 'drizzle', 'FG': 'fog',
'BR': 'mist', 'HZ': 'haze', 'TS': 'thunderstorm', 'SH': 'showers'
}
for code, desc in mapping.items():
if code in part:
decoded.setdefault('weather', []).append(desc)
# Build a short summary
summary_parts = []
if decoded.get('clouds'):
summary_parts.append(decoded['clouds'])
if decoded.get('temperature'):
summary_parts.append(decoded['temperature'])
if decoded.get('wind'):
summary_parts.append(decoded['wind'])
summary = ', '.join(summary_parts) if summary_parts else "Weather conditions available"
return {
'summary': summary,
'details': decoded,
'raw_metar': metar_string
}
@app.route('/', methods=['GET'])
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="No station provided.")
# Example: fetch METAR from NOAA TGFTP (https://tgftp.nws.noaa.gov) or aviationweather.gov (https://www.aviationweather.gov) (simple direct request)
url = f'https://tgftp.nws.noaa.gov/data/observations/metar/stations/{station}.TXT'
resp = requests.get(url, timeout=10)
if resp.status_code != 200:
return render_template('result.html', error=f'Could not fetch METAR for {station}.')
# Some station METAR data may include a timestamp line — extract the METAR line
metar_lines = resp.text.strip().splitlines()
metar_line = metar_lines[-1] if metar_lines else ''
decoder = METARDecoder()
result = decoder.decode_metar(metar_line)
return render_template('result.html', result=result)