From 74d005c0ccb8235319343a8a5237f99f10287695 Mon Sep 17 00:00:00 2001 From: Igor Chubin Date: Sun, 25 Aug 2019 20:39:11 +0200 Subject: [PATCH] initial v2 format implementation (fixes #332) --- lib/spark.py | 559 ++++++++++++++++++++++++++++++++++++++++++++ lib/weather_data.py | 2 +- lib/wttr_line.py | 20 +- lib/wttr_srv.py | 16 +- requirements.txt | 3 + 5 files changed, 590 insertions(+), 10 deletions(-) create mode 100644 lib/spark.py diff --git a/lib/spark.py b/lib/spark.py new file mode 100644 index 0000000..65dc8e8 --- /dev/null +++ b/lib/spark.py @@ -0,0 +1,559 @@ +# vim: fileencoding=utf-8 +# vim: foldmethod=marker foldenable: + +""" +[X] emoji +[ ] wego icon +[ ] v2.wttr.in +[X] astronomical (sunset) +[X] time +[X] frames +[X] colorize rain data +[ ] date + locales +[X] wind color +[ ] highlight current date +[ ] bind to real site +[ ] max values: temperature +[X] max value: rain +[ ] comment github +[ ] commit + +""" + +import sys + +import re +import math +import json +import datetime +import StringIO + +import requests +import diagram +import pyjq +import pytz +import numpy as np +from astral import Astral, Location +from scipy.interpolate import interp1d +from babel.dates import format_datetime + +from globals import WWO_KEY +import constants +import translations +import wttr_line + +reload(sys) +sys.setdefaultencoding("utf-8") + +# data processing {{{ + +def get_data(config): + """ + Fetch data for `query_string` + """ + + url = ( + 'http://' + 'localhost:5001/premium/v1/weather.ashx' + '?key=%s' + '&q=%s&format=json&num_of_days=3&tp=3&lang=None' + ) % (WWO_KEY, config["location"]) + text = requests.get(url).text + parsed_data = json.loads(text) + return parsed_data + +def interpolate_data(input_data, max_width): + """ + Resample `input_data` to number of `max_width` counts + """ + + x = list(range(len(input_data))) + y = input_data + xvals = np.linspace(0, len(input_data)-1, max_width) + yinterp = interp1d(x, y, kind='cubic') + return yinterp(xvals) + +def jq_query(query, data_parsed): + """ + Apply `query` to structued data `data_parsed` + """ + + pyjq_data = pyjq.all(query, data_parsed) + data = map(float, pyjq_data) + return data + +# }}} +# utils {{{ +def colorize(string, color_code): + return "\033[%sm%s\033[0m" % (color_code, string) +# }}} +# draw_spark {{{ + + +def draw_spark(data, height, width, color_data): + """ + Spark-style visualize `data` in a region `height` x `width` + """ + + _BARS = u' _▁▂▃▄▅▇█' + + def _box(height, row, value, max_value): + row_height = 1.0 * max_value / height + if row_height * row >= value: + return _BARS[0] + if row_height * (row+1) <= value: + return _BARS[-1] + + return _BARS[int(1.0*(value - row_height*row)/(row_height*1.0)*len(_BARS))] + + max_value = max(data) + + output = "" + color_code = 20 + for i in range(height): + for j in range(width): + character = _box(height, height-i-1, data[j], max_value) + if data[j] != 0: + chance_of_rain = color_data[j]/100.0 * 2 + if chance_of_rain > 1: + chance_of_rain = 1 + color_index = int(5*chance_of_rain) + color_code = 16 + color_index # int(math.floor((20-16) * 1.0 * (height-1-i)/height*(max_value/data[j]))) + output += "\033[38;5;%sm%s\033[0m" % (color_code, character) + output += "\n" + + # labeling max value + if max_value == 0: + max_line = " "*width + else: + max_line = "" + for j in range(width): + if data[j] == max_value: + max_line = "%3.2fmm|%s%%" % (max_value, int(color_data[j])) + orig_max_line = max_line + + # aligning it + if len(max_line)/2 < j and len(max_line)/2 + j < width: + spaces = " "*(j - len(max_line)/2) + max_line = spaces + max_line # + spaces + max_line = max_line + " "*(width - len(max_line)) + elif len(max_line)/2 + j >= width: + max_line = " "*(width - len(max_line)) + max_line + + max_line = max_line.replace(orig_max_line, colorize(orig_max_line, "38;5;33")) + + break + + if max_line: + output = "\n" + max_line + "\n" + output + "\n" + + return output + +# }}} +# draw_diagram {{{ +def draw_diagram(data, height, width): + + option = diagram.DOption() + option.size = diagram.Point([width, height]) + option.mode = 'g' + + stream = StringIO.StringIO() + gram = diagram.DGWrapper( + data=[list(data), range(len(data))], + dg_option=option, + ostream=stream) + gram.show() + return stream.getvalue() +# }}} +# draw_date {{{ + + +def draw_date(config, geo_data): + """ + """ + + tzinfo = pytz.timezone(geo_data["timezone"]) + + locale = config.get("locale", "en_US") + datetime_day_start = datetime.datetime.utcnow() + + answer = "" + for day in range(3): + datetime_ = datetime_day_start + datetime.timedelta(hours=24*day) + date = format_datetime(datetime_, "EEE dd MMM", locale=locale, tzinfo=tzinfo) + + spaces = ((24-len(date))/2)*" " + date = spaces + date + spaces + date = " "*(24-len(date)) + date + answer += date + answer += "\n" + + for _ in range(3): + answer += " "*23 + u"╷" + return answer[:-1] + " " + + +# }}} +# draw_time {{{ + + +def draw_time(geo_data): + """ + """ + + tzinfo = pytz.timezone(geo_data["timezone"]) + + line = ["", ""] + + for _ in range(3): + part = u"─"*5 + u"┴" + u"─"*5 + line[0] += part + u"┼" + part + u"╂" + line[0] += "\n" + + for _ in range(3): + line[1] += " 6 12 18 " + line[1] += "\n" + + # highlight current time + hour_number = \ + (datetime.datetime.now(tzinfo) + - datetime.datetime.now(tzinfo).replace(hour=0, minute=0, second=0, microsecond=0) + ).seconds//3600 + + for line_number, _ in enumerate(line): + line[line_number] = \ + line[line_number][:hour_number] \ + + colorize(line[line_number][hour_number], "46") \ + + line[line_number][hour_number+1:] + + return "".join(line) + + +# }}} +# draw_astronomical {{{ +def draw_astronomical(city_name, geo_data): + datetime_day_start = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + a = Astral() + a.solar_depression = 'civil' + + city = Location() + city.latitude = geo_data["latitude"] + city.longitude = geo_data["longitude"] + city.timezone = geo_data["timezone"] + + answer = "" + moon_line = "" + for time_interval in range(72): + + current_date = ( + datetime_day_start + + datetime.timedelta(hours=1*time_interval)).replace(tzinfo=pytz.timezone(geo_data["timezone"])) + sun = city.sun(date=current_date, local=False) + + dawn = sun['dawn'] # .replace(tzinfo=None) + dusk = sun['dusk'] # .replace(tzinfo=None) + sunrise = sun['sunrise'] # .replace(tzinfo=None) + sunset = sun['sunset'] # .replace(tzinfo=None) + + if current_date < dawn: + char = " " + elif current_date > dusk: + char = " " + elif dawn < current_date and current_date < sunrise: + char = u"─" + elif sunset < current_date and current_date < dusk: + char = u"─" + elif sunrise < current_date and current_date < sunset: + char = u"━" + + answer += char + + # moon + if time_interval % 3 == 0: + moon_phase = city.moon_phase( + date=datetime_day_start + datetime.timedelta(hours=time_interval)) + moon_phase_emoji = constants.MOON_PHASES[int(math.floor(moon_phase*1.0/28.0*8))] + if time_interval in [0, 24, 48, 69]: + moon_line += moon_phase_emoji + " " + else: + moon_line += " " + + + answer = moon_line + "\n" + answer + "\n" + answer += "\n" + return answer +# }}} +# draw_emoji {{{ +def draw_emoji(data): + answer = "" + for i in data: + emoji = constants.WEATHER_SYMBOL.get( + constants.WWO_CODE.get( + str(int(i)), "Unknown")) + space = " "*(3-constants.WEATHER_SYMBOL_WIDTH_VTE.get(emoji)) + answer += emoji + space + answer += "\n" + return answer +# }}} +# draw_wind {{{ +def draw_wind(data, color_data): + + def _color_code_for_wind_speed(wind_speed): + + color_codes = [ + (3, 82), # 82 + (6, 118), # 118 + (9, 154), # 154 + (12, 250), # 190 + (15, 246), # 226 + (19, 253), # 220 + (23, 214), + (27, 208), + (31, 202), + (-1, 196) + ] + + color_codes = [ + (3, 241), # 82 + (6, 242), # 118 + (9, 243), # 154 + (12, 246), # 190 + (15, 250), # 226 + (19, 253), # 220 + (23, 214), + (27, 208), + (31, 202), + (-1, 196) + ] + + for this_wind_speed, this_color_code in color_codes: + if wind_speed <= this_wind_speed: + return this_color_code + return color_codes[-1][1] + + answer = "" + answer_line2 = "" + + for j, degree in enumerate(data): + + degree = int(degree) + if degree: + wind_direction = constants.WIND_DIRECTION[((degree+22)%360)/45] + else: + wind_direction = "" + + color_code = "38;5;%s" % _color_code_for_wind_speed(int(color_data[j])) + answer += " %s " % colorize(wind_direction, color_code) + + # wind_speed + wind_speed = int(color_data[j]) + wind_speed_str = colorize(str(wind_speed), color_code) + if wind_speed < 10: + wind_speed_str = " " + wind_speed_str + " " + elif wind_speed < 100: + wind_speed_str = " " + wind_speed_str + answer_line2 += wind_speed_str + + answer += "\n" + answer += answer_line2 + "\n" + return answer +# }}} +# panel implementation {{{ + +def add_frame(output, width, config): + """ + Add frame arond `output` that has width `width` + """ + + empty_line = " "*width + output = "\n".join(u"│"+(x or empty_line)+u"│" for x in output.splitlines()) + "\n" + + weather_report = \ + translations.CAPTION[config["lang"]] \ + + " " \ + + (config["override_location"] or config["location"]) + + caption = u"┤ " + " " + weather_report + " " + u" ├" + output = u"┌" + caption + u"─"*(width-len(caption)) + u"┐\n" \ + + output + \ + u"└" + u"─"*width + u"┘\n" + + return output + +def generate_panel(data_parsed, geo_data, config): + """ + """ + + max_width = 72 + + precip_mm_query = "[.data.weather[] | .hourly[]] | .[].precipMM" + precip_chance_query = "[.data.weather[] | .hourly[]] | .[].chanceofrain" + feels_like_query = "[.data.weather[] | .hourly[]] | .[].FeelsLikeC" + weather_code_query = "[.data.weather[] | .hourly[]] | .[].weatherCode" + wind_direction_query = "[.data.weather[] | .hourly[]] | .[].winddirDegree" + wind_speed_query = "[.data.weather[] | .hourly[]] | .[].windspeedKmph" + + output = "" + + output += "\n\n" + + output += draw_date(config, geo_data) + output += "\n" + output += "\n" + output += "\n" + + data = jq_query(feels_like_query, data_parsed) + data_interpolated = interpolate_data(data, max_width) + output += draw_diagram(data_interpolated, 10, max_width) + + output += "\n" + + output += draw_time(geo_data) + + data = jq_query(precip_mm_query, data_parsed) + color_data = jq_query(precip_chance_query, data_parsed) + data_interpolated = interpolate_data(data, max_width) + color_data_interpolated = interpolate_data(color_data, max_width) + output += draw_spark(data_interpolated, 5, max_width, color_data_interpolated) + output += "\n" + + data = jq_query(weather_code_query, data_parsed) + output += draw_emoji(data) + + data = jq_query(wind_direction_query, data_parsed) + color_data = jq_query(wind_speed_query, data_parsed) + output += draw_wind(data, color_data) + output += "\n" + + output += draw_astronomical(config["location"], geo_data) + output += "\n" + + output = add_frame(output, max_width, config) + return output + + +# }}} +# textual information {{{ +def textual_information(data_parsed, geo_data, config): + """ + Add textual information about current weather and + astronomical conditions + """ + + def _shorten_full_location(full_location, city_only=False): + + def _count_runes(string): + return len(string.encode('utf-16-le')) // 2 + + words = full_location.split(",") + + output = words[0] + if city_only: + return output + + for word in words[1:]: + if _count_runes(output + "," + word) > 50: + return output + output += "," + word + + return output + + + city = Location() + city.latitude = geo_data["latitude"] + city.longitude = geo_data["longitude"] + city.timezone = geo_data["timezone"] + + output = [] + timezone = city.timezone + + datetime_day_start = datetime.datetime.now()\ + .replace(hour=0, minute=0, second=0, microsecond=0) + sun = city.sun(date=datetime_day_start, local=True) + + format_line = "%c %C, %t, %h, %w, %P" + current_condition = data_parsed['data']['current_condition'][0] + query = {} + weather_line = wttr_line.render_line(format_line, current_condition, query) + output.append('Weather: %s' % weather_line) + + output.append('Timezone: %s' % timezone) + + tmp_output = [] + tmp_output.append(' Now: %s' + % datetime.datetime.now(pytz.timezone(timezone)).strftime("%H:%M:%S%z")) + tmp_output.append('Dawn: %s' + % str(sun['dawn'].strftime("%H:%M:%S"))) + tmp_output.append('Sunrise: %s' + % str(sun['sunrise'].strftime("%H:%M:%S"))) + tmp_output.append(' Noon: %s' + % str(sun['noon'].strftime("%H:%M:%S "))) + tmp_output.append('Sunset: %s' + % str(sun['sunset'].strftime("%H:%M:%S"))) + tmp_output.append('Dusk: %s' + % str(sun['dusk'].strftime("%H:%M:%S"))) + tmp_output = [ + re.sub("^([A-Za-z]*:)", lambda m: colorize(m.group(1), "2"), x) + for x in tmp_output] + + output.append( + "%20s" % tmp_output[0] \ + + " | %20s " % tmp_output[1] \ + + " | %20s" % tmp_output[2]) + output.append( + "%20s" % tmp_output[3] \ + + " | %20s " % tmp_output[4] \ + + " | %20s" % tmp_output[5]) + + city_only = False + suffix = "" + if "Simferopol" in timezone: + city_only = True + suffix = ", Крым" + + if config["full_address"]: + output.append('Location: %s%s [%5.4f,%5.4f]' \ + % ( + _shorten_full_location(config["full_address"], city_only=city_only), + suffix, + geo_data["latitude"], + geo_data["longitude"], + )) + + output = [ + re.sub("^( *[A-Za-z]*:)", lambda m: colorize(m.group(1), "2"), + re.sub("^( +[A-Za-z]*:)", lambda m: colorize(m.group(1), "2"), + re.sub(r"(\|)", lambda m: colorize(m.group(1), "2"), x))) + for x in output] + + return "".join("%s\n" % x for x in output) + +# }}} +# get_geodata {{{ +def get_geodata(location): + text = requests.get("http://localhost:8004/%s" % location).text + return json.loads(text) +# }}} + +def main(location, override_location=None, data=None, full_address=None): + config = { + "lang": "en", + "locale": "en_US", + "location": location, + "override_location": override_location, + "full_address": full_address, + } + + geo_data = get_geodata(location) + if data is None: + data_parsed = get_data(config) + else: + data_parsed = data + + output = generate_panel(data_parsed, geo_data, config) + output += textual_information(data_parsed, geo_data, config) + return output + +if __name__ == '__main__': + sys.stdout.write(main(sys.argv[1])) diff --git a/lib/weather_data.py b/lib/weather_data.py index 72fd3eb..5343425 100644 --- a/lib/weather_data.py +++ b/lib/weather_data.py @@ -16,7 +16,7 @@ def get_weather_data(location, lang): '&num_of_days=3&tp=3&lang=%s') % (key, location, lang) url = 'http://127.0.0.1:5001' + url - response = requests.get(url, timeout=1) + response = requests.get(url, timeout=10) try: data = json.loads(response.content) except ValueError: diff --git a/lib/wttr_line.py b/lib/wttr_line.py index fc26c5d..9c8a441 100644 --- a/lib/wttr_line.py +++ b/lib/wttr_line.py @@ -16,9 +16,11 @@ Initial implementation of one-line output mode. import sys import re import datetime +import json from astral import Astral, Location from constants import WWO_CODE, WEATHER_SYMBOL, WIND_DIRECTION from weather_data import get_weather_data +import spark PRECONFIGURED_FORMAT = { '1': u'%c %t', @@ -229,26 +231,36 @@ def format_weather_data(format_line, location, override_location, full_address, if 'data' not in data: return 'Unknown location; please try ~%s' % location + + if format_line == "j1": + return render_json(data['data']) + if format_line == "v2": + return spark.main(location, + override_location=override_location, + full_address=full_address, data=data) + current_condition = data['data']['current_condition'][0] current_condition['location'] = location current_condition['override_location'] = override_location output = render_line(format_line, current_condition, query) return output -def wttr_line(location, override_location_name, query, lang): +def wttr_line(location, override_location_name, full_address, query, lang, fmt): """ Return 1line weather information for `location` in format `line_format` """ - format_line = query.get('format', '') + format_line = query.get('format', fmt or '') if format_line in PRECONFIGURED_FORMAT: format_line = PRECONFIGURED_FORMAT[format_line] weather_data = get_weather_data(location, lang) - output = format_weather_data(format_line, location, override_location_name, weather_data, query) + output = format_weather_data( + format_line, location, override_location_name, full_address, + weather_data, query) output = output.rstrip("\n")+"\n" return output @@ -262,7 +274,7 @@ def main(): 'line': sys.argv[2], } - sys.stdout.write(wttr_line(location, location, query, 'en')) + sys.stdout.write(wttr_line(location, location, None, query, 'en', "v1")) if __name__ == '__main__': main() diff --git a/lib/wttr_srv.py b/lib/wttr_srv.py index 670515c..0a86db2 100644 --- a/lib/wttr_srv.py +++ b/lib/wttr_srv.py @@ -114,16 +114,20 @@ def _parse_language_header(header): return _find_supported_language(_parse_accept_language(header)) -def get_answer_language(request): +def get_answer_language_and_format(request): """ Return preferred answer language based on domain name, query arguments and headers """ lang = None + fmt = None hostname = request.headers['Host'] if hostname != 'wttr.in' and hostname.endswith('.wttr.in'): lang = hostname[:-8] + if lang == "v2": + fmt = "v2" + lang = None if 'lang' in request.args: lang = request.args.get('lang') @@ -132,7 +136,7 @@ def get_answer_language(request): if lang is None and header_accept_language: lang = _parse_language_header(header_accept_language) - return lang + return lang, fmt def get_output_format(request, query): """ @@ -202,7 +206,7 @@ def wttr(location, request): png_filename = location location = location[:-4] - lang = get_answer_language(request) + lang, fmt = get_answer_language_and_format(request) query = parse_query.parse_query(request.args) html_output = get_output_format(request, query) user_agent = request.headers.get('User-Agent', '').lower() @@ -237,8 +241,10 @@ def wttr(location, request): # We are ready to return the answer try: - if 'format' in query: - return _wrap_response(wttr_line(location, override_location_name, query, lang), html_output) + if fmt or 'format' in query: + return _wrap_response( + wttr_line(location, override_location_name, full_address, query, lang, fmt), + html_output) if png_filename: options = { diff --git a/requirements.txt b/requirements.txt index 6ab23b3..32eb599 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,6 @@ timezonefinder Pillow pyte python-dateutil +diagram +pyjq +scipy