wttr.in/lib/metno.py
2023-04-21 14:19:27 -05:00

466 lines
17 KiB
Python
Executable File

#!/bin/env python
# vim: fileencoding=utf-8
from datetime import datetime, timedelta
import json
import logging
import os
import re
import sys
import timezonefinder
from pytz import timezone
from constants import WWO_CODE
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
logger = logging.getLogger(__name__)
def metno_request(path, query_string):
# We'll need to sanitize the inbound request - ideally the
# premium/v1/weather.ashx portion would have always been here, though
# it seems as though the proxy was built after the majority of the app
# and not refactored. For WAPI we'll strip this and the API key out,
# then manage it on our own.
logger.debug('Original path: ' + path)
logger.debug('Original query: ' + query_string)
path = path.replace('premium/v1/weather.ashx',
'weatherapi/locationforecast/2.0/complete')
query_string = re.sub(r'key=[^&]*&', '', query_string)
query_string = re.sub(r'format=[^&]*&', '', query_string)
days = int(re.search(r'num_of_days=([0-9]+)&', query_string).group(1))
query_string = re.sub(r'num_of_days=[0-9]+&', '', query_string)
# query_string = query_string.replace('key=', '?key=' + WAPI_KEY)
# TP is for hourly forecasting, which isn't available in the free api.
query_string = re.sub(r'tp=[0-9]*&', '', query_string)
# This assumes lang=... is at the end. Also note that the API doesn't
# localize, and we're not either. TODO: add language support
query_string = re.sub(r'lang=[^&]*$', '', query_string)
query_string = re.sub(r'&$', '', query_string)
logger.debug('qs: ' + query_string)
# Deal with coordinates. Need to be rounded to 4 decimals for metno ToC
# and in a different query string format
coord_match = re.search(r'q=[^&]*', query_string)
coords_str = coord_match.group(0)
coords = re.findall(r'[-0-9.]+', coords_str)
lat = str(round(float(coords[0]), 4))
lng = str(round(float(coords[1]), 4))
logger.debug('lat: ' + lat)
logger.debug('lng: ' + lng)
query_string = re.sub(r'q=[^&]*', 'lat=' + lat + '&lon=' + lng + '&',
query_string)
logger.debug('Return path: ' + path)
logger.debug('Return query: ' + query_string)
return path, query_string, days
def celsius_to_f(celsius):
return round((1.8 * celsius) + 32, 1)
def to_weather_code(symbol_code):
logger.debug(symbol_code)
code = re.sub(r'_.*', '', symbol_code)
logger.debug(code)
# symbol codes: https://api.met.no/weatherapi/weathericon/2.0/documentation
# they also have _day, _night and _polartwilight variants
# See json from https://api.met.no/weatherapi/weathericon/2.0/legends
# WWO codes: https://github.com/chubin/wttr.in/blob/master/lib/constants.py
# http://www.worldweatheronline.com/feed/wwoConditionCodes.txt
weather_code_map = {
"clearsky": 113,
"cloudy": 119,
"fair": 116,
"fog": 143,
"heavyrain": 302,
"heavyrainandthunder": 389,
"heavyrainshowers": 305,
"heavyrainshowersandthunder": 386,
"heavysleet": 314, # There's a ton of 'LightSleet' in WWO_CODE...
"heavysleetandthunder": 377,
"heavysleetshowers": 362,
"heavysleetshowersandthunder": 374,
"heavysnow": 230,
"heavysnowandthunder": 392,
"heavysnowshowers": 371,
"heavysnowshowersandthunder": 392,
"lightrain": 266,
"lightrainandthunder": 200,
"lightrainshowers": 176,
"lightrainshowersandthunder": 386,
"lightsleet": 281,
"lightsleetandthunder": 377,
"lightsleetshowers": 284,
"lightsnow": 320,
"lightsnowandthunder": 392,
"lightsnowshowers": 368,
"lightssleetshowersandthunder": 365,
"lightssnowshowersandthunder": 392,
"partlycloudy": 116,
"rain": 293,
"rainandthunder": 389,
"rainshowers": 299,
"rainshowersandthunder": 386,
"sleet": 185,
"sleetandthunder": 392,
"sleetshowers": 263,
"sleetshowersandthunder": 392,
"snow": 329,
"snowandthunder": 392,
"snowshowers": 230,
"snowshowersandthunder": 392,
}
if code not in weather_code_map:
logger.debug('not found')
return -1 # not found
logger.debug(weather_code_map[code])
return weather_code_map[code]
def to_description(symbol_code):
desc = WWO_CODE[str(to_weather_code(symbol_code))]
logger.debug(desc)
return desc
def to_16_point(degrees):
# 360 degrees / 16 = 22.5 degrees of arc or 11.25 degrees around the point
if degrees > (360 - 11.25) or degrees <= 11.25:
return 'N'
if degrees > 11.25 and degrees <= (11.25 + 22.5):
return 'NNE'
if degrees > (11.25 + (22.5 * 1)) and degrees <= (11.25 + (22.5 * 2)):
return 'NE'
if degrees > (11.25 + (22.5 * 2)) and degrees <= (11.25 + (22.5 * 3)):
return 'ENE'
if degrees > (11.25 + (22.5 * 3)) and degrees <= (11.25 + (22.5 * 4)):
return 'E'
if degrees > (11.25 + (22.5 * 4)) and degrees <= (11.25 + (22.5 * 5)):
return 'ESE'
if degrees > (11.25 + (22.5 * 5)) and degrees <= (11.25 + (22.5 * 6)):
return 'SE'
if degrees > (11.25 + (22.5 * 6)) and degrees <= (11.25 + (22.5 * 7)):
return 'SSE'
if degrees > (11.25 + (22.5 * 7)) and degrees <= (11.25 + (22.5 * 8)):
return 'S'
if degrees > (11.25 + (22.5 * 8)) and degrees <= (11.25 + (22.5 * 9)):
return 'SSW'
if degrees > (11.25 + (22.5 * 9)) and degrees <= (11.25 + (22.5 * 10)):
return 'SW'
if degrees > (11.25 + (22.5 * 10)) and degrees <= (11.25 + (22.5 * 11)):
return 'WSW'
if degrees > (11.25 + (22.5 * 11)) and degrees <= (11.25 + (22.5 * 12)):
return 'W'
if degrees > (11.25 + (22.5 * 12)) and degrees <= (11.25 + (22.5 * 13)):
return 'WNW'
if degrees > (11.25 + (22.5 * 13)) and degrees <= (11.25 + (22.5 * 14)):
return 'NW'
if degrees > (11.25 + (22.5 * 14)) and degrees <= (11.25 + (22.5 * 15)):
return 'NNW'
def meters_to_miles(meters):
return round(meters * 0.00062137, 2)
def mm_to_inches(mm):
return round(mm / 25.4, 2)
def hpa_to_mb(hpa):
return hpa
def hpa_to_in(hpa):
return round(hpa * 0.02953, 2)
def hpa_to_mmHg(hpa):
return round(hpa * 0.75006157584566 , 3)
def group_hours_to_days(lat, lng, hourlies, days_to_return):
tf = timezonefinder.TimezoneFinder()
timezone_str = tf.certain_timezone_at(lat=lat, lng=lng)
logger.debug('got TZ: ' + timezone_str)
tz = timezone(timezone_str)
start_day_gmt = datetime.fromisoformat(hourlies[0]['time']
.replace('Z', '+00:00'))
start_day_local = start_day_gmt.astimezone(tz)
end_day_local = (start_day_local + timedelta(days=days_to_return - 1)).date()
logger.debug('series starts at gmt time: ' + str(start_day_gmt))
logger.debug('series starts at local time: ' + str(start_day_local))
logger.debug('series ends on day: ' + str(end_day_local))
days = {}
for hour in hourlies:
current_day_gmt = datetime.fromisoformat(hour['time']
.replace('Z', '+00:00'))
current_local = current_day_gmt.astimezone(tz)
current_day_local = current_local.date()
if current_day_local > end_day_local:
continue
if current_day_local not in days:
days[current_day_local] = {'hourly': []}
hour['localtime'] = current_local.time()
days[current_day_local]['hourly'].append(hour)
# Need a second pass to build the min/max/avg data
for date, day in days.items():
minTempC = -999
maxTempC = 1000
avgTempC = None
n = 0
maxUvIndex = 0
for hour in day['hourly']:
temp = hour['data']['instant']['details']['air_temperature']
if temp > minTempC:
minTempC = temp
if temp < maxTempC:
maxTempC = temp
if avgTempC is None:
avgTempC = temp
n = 1
else:
avgTempC = ((avgTempC * n) + temp) / (n + 1)
n = n + 1
uv = hour['data']['instant']['details']
if 'ultraviolet_index_clear_sky' in uv:
if uv['ultraviolet_index_clear_sky'] > maxUvIndex:
maxUvIndex = uv['ultraviolet_index_clear_sky']
day["maxtempC"] = str(maxTempC)
day["maxtempF"] = str(celsius_to_f(maxTempC))
day["mintempC"] = str(minTempC)
day["mintempF"] = str(celsius_to_f(minTempC))
day["avgtempC"] = str(round(avgTempC, 1))
day["avgtempF"] = str(celsius_to_f(avgTempC))
# day["totalSnow_cm": "not implemented",
# day["sunHour": "12", # This would come from astonomy data
day["uvIndex"] = str(maxUvIndex)
return days
def _convert_hour(hour):
# Whatever is upstream is expecting data in the shape of WWO. This method will
# morph from metno to hourly WWO response format.
# Note that WWO is providing data every 3 hours. Metno provides every hour
# {
# "time": "0",
# "tempC": "19",
# "tempF": "66",
# "windspeedMiles": "6",
# "windspeedKmph": "9",
# "winddirDegree": "276",
# "winddir16Point": "W",
# "weatherCode": "119",
# "weatherIconUrl": [
# {
# "value": "http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0003_white_cloud.png"
# }
# ],
# "weatherDesc": [
# {
# "value": "Cloudy"
# }
# ],
# "precipMM": "0.0",
# "precipInches": "0.0",
# "humidity": "62",
# "visibility": "10",
# "visibilityMiles": "6",
# "pressure": "1017",
# "pressureInches": "31",
# "cloudcover": "66",
# "HeatIndexC": "19",
# "HeatIndexF": "66",
# "DewPointC": "12",
# "DewPointF": "53",
# "WindChillC": "19",
# "WindChillF": "66",
# "WindGustMiles": "8",
# "WindGustKmph": "13",
# "FeelsLikeC": "19",
# "FeelsLikeF": "66",
# "chanceofrain": "0",
# "chanceofremdry": "93",
# "chanceofwindy": "0",
# "chanceofovercast": "89",
# "chanceofsunshine": "18",
# "chanceoffrost": "0",
# "chanceofhightemp": "0",
# "chanceoffog": "0",
# "chanceofsnow": "0",
# "chanceofthunder": "0",
# "uvIndex": "1"
details = hour['data']['instant']['details']
if 'next_1_hours' in hour['data']:
next_hour = hour['data']['next_1_hours']
elif 'next_6_hours' in hour['data']:
next_hour = hour['data']['next_6_hours']
elif 'next_12_hours' in hour['data']:
next_hour = hour['data']['next_12_hours']
else:
next_hour = {}
# Need to dig out symbol_code and precipitation_amount
symbol_code = 'clearsky_day' # Default to sunny
if 'summary' in next_hour and 'symbol_code' in next_hour['summary']:
symbol_code = next_hour['summary']['symbol_code']
precipitation_amount = 0 # Default to no rain
if 'details' in next_hour and 'precipitation_amount' in next_hour['details']:
precipitation_amount = next_hour['details']['precipitation_amount']
uvIndex = 0 # default to 0 index
if 'ultraviolet_index_clear_sky' in details:
uvIndex = details['ultraviolet_index_clear_sky']
localtime = ''
if 'localtime' in hour:
localtime = "{h:02.0f}".format(h=hour['localtime'].hour) + \
"{m:02.0f}".format(m=hour['localtime'].minute)
logger.debug(str(hour['localtime']))
# time property is local time, 4 digit 24 hour, with no :, e.g. 2100
return {
'time': localtime,
'observation_time': hour['time'], # Need to figure out WWO TZ
# temp_C is used in we-lang.go calcs in such a way
# as to expect a whole number
'temp_C': str(int(round(details['air_temperature'], 0))),
# temp_F can be more precise - not used in we-lang.go calcs
'temp_F': str(celsius_to_f(details['air_temperature'])),
'weatherCode': str(to_weather_code(symbol_code)),
'weatherIconUrl': [{
'value': 'not yet implemented',
}],
'weatherDesc': [{
'value': to_description(symbol_code),
}],
# similiarly, windspeedMiles is not used by we-lang.go, but kmph is
"windspeedMiles": str(meters_to_miles(details['wind_speed'])),
"windspeedKmph": str(int(round(details['wind_speed'], 0))),
"winddirDegree": str(details['wind_from_direction']),
"winddir16Point": to_16_point(details['wind_from_direction']),
"precipMM": str(precipitation_amount),
"precipInches": str(mm_to_inches(precipitation_amount)),
"humidity": str(details['relative_humidity']),
"visibility": 'not yet implemented', # str(details['vis_km']),
"visibilityMiles": 'not yet implemented', # str(details['vis_miles']),
"pressure": str(hpa_to_mb(details['air_pressure_at_sea_level'])),
"pressure_mmHg": str(hpa_to_mmHg(details['air_pressure_at_sea_level'])),
"pressureInches": str(hpa_to_in(details['air_pressure_at_sea_level'])),
"cloudcover": 'not yet implemented', # Convert from cloud_area_fraction?? str(details['cloud']),
# metno doesn't have FeelsLikeC, but we-lang.go is using it in calcs,
# so we shall set it to temp_C
"FeelsLikeC": str(int(round(details['air_temperature'], 0))),
"FeelsLikeF": 'not yet implemented', # str(details['feelslike_f']),
"uvIndex": str(uvIndex),
}
def _convert_hourly(hours):
converted_hours = []
for hour in hours:
converted_hours.append(_convert_hour(hour))
return converted_hours
# Whatever is upstream is expecting data in the shape of WWO. This method will
# morph from metno to WWO response format.
def create_standard_json_from_metno(content, days_to_return):
try:
forecast = json.loads(content) # pylint: disable=invalid-name
except (ValueError, TypeError) as exception:
logger.error("---")
logger.error(exception)
logger.error("---")
return {}, ''
hourlies = forecast['properties']['timeseries']
current = hourlies[0]
# We are assuming these units:
# "units": {
# "air_pressure_at_sea_level": "hPa",
# "air_temperature": "celsius",
# "air_temperature_max": "celsius",
# "air_temperature_min": "celsius",
# "cloud_area_fraction": "%",
# "cloud_area_fraction_high": "%",
# "cloud_area_fraction_low": "%",
# "cloud_area_fraction_medium": "%",
# "dew_point_temperature": "celsius",
# "fog_area_fraction": "%",
# "precipitation_amount": "mm",
# "relative_humidity": "%",
# "ultraviolet_index_clear_sky": "1",
# "wind_from_direction": "degrees",
# "wind_speed": "m/s"
# }
content = {
'data': {
'request': [{
'type': 'feature',
'query': str(forecast['geometry']['coordinates'][1]) + ',' +
str(forecast['geometry']['coordinates'][0])
}],
'current_condition': [
_convert_hour(current)
],
'weather': []
}
}
days = group_hours_to_days(forecast['geometry']['coordinates'][1],
forecast['geometry']['coordinates'][0],
hourlies, days_to_return)
# TODO: Astronomy needs to come from this:
# https://api.met.no/weatherapi/sunrise/2.0/.json?lat=40.7127&lon=-74.0059&date=2020-10-07&offset=-05:00
# and obviously can be cached for a while
# https://api.met.no/weatherapi/sunrise/2.0/documentation
# Note that full moon/new moon/first quarter/last quarter aren't returned
# and the moonphase value should match these from WWO:
# New Moon
# Waxing Crescent
# First Quarter
# Waxing Gibbous
# Full Moon
# Waning Gibbous
# Last Quarter
# Waning Crescent
for date, day in days.items():
content['data']['weather'].append({
"date": str(date),
"astronomy": [],
"maxtempC": day['maxtempC'],
"maxtempF": day['maxtempF'],
"mintempC": day['mintempC'],
"mintempF": day['mintempF'],
"avgtempC": day['avgtempC'],
"avgtempF": day['avgtempF'],
"totalSnow_cm": "not implemented",
"sunHour": "12", # This would come from astonomy data
"uvIndex": day['uvIndex'],
'hourly': _convert_hourly(day['hourly']),
})
# for day in forecast.
return json.dumps(content)
if __name__ == "__main__":
# if len(sys.argv) == 1:
# for deg in range(0, 360):
# print('deg: ' + str(deg) + '; 16point: ' + to_16_point(deg))
if len(sys.argv) == 2:
req = sys.argv[1].split('?')
# to_description(sys.argv[1])
metno_request(req[0], req[1])
elif len(sys.argv) == 3:
with open(sys.argv[1], 'r') as contentf:
content = create_standard_json_from_metno(contentf.read(),
int(sys.argv[2]))
print(content)
else:
print('usage: metno <content file> <days>')