Update python versions for CI (#96)
[EVA-2020-02-2.git] / examples / weather-and-light.py
CommitLineData
df20089d 1#!/usr/bin/env python3
3e4b64c3
PH
2# -*- coding: utf-8 -*-
3
4f"Sorry! This program requires Python >= 3.6 😅"
df20089d 5
20442c9a 6import os
2fe226f8 7import time
8import numpy
9import colorsys
10from PIL import Image, ImageDraw, ImageFont, ImageFilter
11from fonts.ttf import RobotoMedium as UserFont
12
13import ST7735
14from bme280 import BME280
15from ltr559 import LTR559
16
17import pytz
7614d188 18from pytz import timezone
20442c9a 19from astral.geocoder import database, lookup
20from astral.sun import sun
2fe226f8 21from datetime import datetime, timedelta
22
23try:
24 from smbus2 import SMBus
25except ImportError:
26 from smbus import SMBus
27
28
29def calculate_y_pos(x, centre):
30 """Calculates the y-coordinate on a parabolic curve, given x."""
31 centre = 80
32 y = 1 / centre * (x - centre) ** 2
33
34 return int(y)
35
36
37def circle_coordinates(x, y, radius):
38 """Calculates the bounds of a circle, given centre and radius."""
39
40 x1 = x - radius # Left
41 x2 = x + radius # Right
42 y1 = y - radius # Bottom
43 y2 = y + radius # Top
44
45 return (x1, y1, x2, y2)
46
47
48def map_colour(x, centre, start_hue, end_hue, day):
49 """Given an x coordinate and a centre point, a start and end hue (in degrees),
50 and a Boolean for day or night (day is True, night False), calculate a colour
51 hue representing the 'colour' of that time of day."""
52
53 start_hue = start_hue / 360 # Rescale to between 0 and 1
54 end_hue = end_hue / 360
55
56 sat = 1.0
57
58 # Dim the brightness as you move from the centre to the edges
59 val = 1 - (abs(centre - x) / (2 * centre))
60
61 # Ramp up towards centre, then back down
62 if x > centre:
63 x = (2 * centre) - x
64
65 # Calculate the hue
66 hue = start_hue + ((x / centre) * (end_hue - start_hue))
67
68 # At night, move towards purple/blue hues and reverse dimming
69 if not day:
70 hue = 1 - hue
71 val = 1 - val
72
73 r, g, b = [int(c * 255) for c in colorsys.hsv_to_rgb(hue, sat, val)]
74
75 return (r, g, b)
76
77
78def x_from_sun_moon_time(progress, period, x_range):
79 """Recalculate/rescale an amount of progress through a time period."""
80
81 x = int((progress / period) * x_range)
82
83 return x
84
85
7614d188 86def sun_moon_time(city_name, time_zone):
2fe226f8 87 """Calculate the progress through the current sun/moon period (i.e day or
88 night) from the last sunrise or sunset, given a datetime object 't'."""
89
20442c9a 90 city = lookup(city_name, database())
2fe226f8 91
92 # Datetime objects for yesterday, today, tomorrow
7614d188
RF
93 utc = pytz.utc
94 utc_dt = datetime.now(tz=utc)
95 local_dt = utc_dt.astimezone(pytz.timezone(time_zone))
96 today = local_dt.date()
2fe226f8 97 yesterday = today - timedelta(1)
98 tomorrow = today + timedelta(1)
99
7614d188 100 # Sun objects for yesterday, today, tomorrow
20442c9a 101 sun_yesterday = sun(city.observer, date=yesterday)
102 sun_today = sun(city.observer, date=today)
103 sun_tomorrow = sun(city.observer, date=tomorrow)
2fe226f8 104
105 # Work out sunset yesterday, sunrise/sunset today, and sunrise tomorrow
106 sunset_yesterday = sun_yesterday["sunset"]
20442c9a 107 sunrise_today = sun_today["sunrise"]
108 sunset_today = sun_today["sunset"]
2fe226f8 109 sunrise_tomorrow = sun_tomorrow["sunrise"]
110
111 # Work out lengths of day or night period and progress through period
7614d188 112 if sunrise_today < local_dt < sunset_today:
2fe226f8 113 day = True
114 period = sunset_today - sunrise_today
be4d0fc9 115 # mid = sunrise_today + (period / 2)
7614d188 116 progress = local_dt - sunrise_today
2fe226f8 117
7614d188 118 elif local_dt > sunset_today:
2fe226f8 119 day = False
120 period = sunrise_tomorrow - sunset_today
be4d0fc9 121 # mid = sunset_today + (period / 2)
7614d188 122 progress = local_dt - sunset_today
2fe226f8 123
124 else:
125 day = False
126 period = sunrise_today - sunset_yesterday
be4d0fc9 127 # mid = sunset_yesterday + (period / 2)
7614d188 128 progress = local_dt - sunset_yesterday
2fe226f8 129
130 # Convert time deltas to seconds
131 progress = progress.total_seconds()
132 period = period.total_seconds()
133
7614d188 134 return (progress, period, day, local_dt)
2fe226f8 135
136
137def draw_background(progress, period, day):
138 """Given an amount of progress through the day or night, draw the
139 background colour and overlay a blurred sun/moon."""
140
141 # x-coordinate for sun/moon
142 x = x_from_sun_moon_time(progress, period, WIDTH)
143
144 # If it's day, then move right to left
145 if day:
146 x = WIDTH - x
147
7614d188 148 # Calculate position on sun/moon's curve
2fe226f8 149 centre = WIDTH / 2
150 y = calculate_y_pos(x, centre)
151
152 # Background colour
153 background = map_colour(x, 80, mid_hue, day_hue, day)
154
155 # New image for background colour
156 img = Image.new('RGBA', (WIDTH, HEIGHT), color=background)
be4d0fc9 157 # draw = ImageDraw.Draw(img)
2fe226f8 158
159 # New image for sun/moon overlay
160 overlay = Image.new('RGBA', (WIDTH, HEIGHT), color=(0, 0, 0, 0))
161 overlay_draw = ImageDraw.Draw(overlay)
162
163 # Draw the sun/moon
164 circle = circle_coordinates(x, y, sun_radius)
165 overlay_draw.ellipse(circle, fill=(200, 200, 50, opacity))
166
167 # Overlay the sun/moon on the background as an alpha matte
168 composite = Image.alpha_composite(img, overlay).filter(ImageFilter.GaussianBlur(radius=blur))
169
170 return composite
171
172
173def overlay_text(img, position, text, font, align_right=False, rectangle=False):
174 draw = ImageDraw.Draw(img)
175 w, h = font.getsize(text)
176 if align_right:
177 x, y = position
178 x -= w
179 position = (x, y)
180 if rectangle:
181 x += 1
182 y += 1
183 position = (x, y)
184 border = 1
185 rect = (x - border, y, x + w, y + h + border)
186 rect_img = Image.new('RGBA', (WIDTH, HEIGHT), color=(0, 0, 0, 0))
187 rect_draw = ImageDraw.Draw(rect_img)
188 rect_draw.rectangle(rect, (255, 255, 255))
189 rect_draw.text(position, text, font=font, fill=(0, 0, 0, 0))
190 img = Image.alpha_composite(img, rect_img)
191 else:
192 draw.text(position, text, font=font, fill=(255, 255, 255))
193 return img
194
195
196def get_cpu_temperature():
197 with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
198 temp = f.read()
199 temp = int(temp) / 1000.0
200 return temp
201
202
203def correct_humidity(humidity, temperature, corr_temperature):
204 dewpoint = temperature - ((100 - humidity) / 5)
205 corr_humidity = 100 - (5 * (corr_temperature - dewpoint))
206 return min(100, corr_humidity)
207
208
209def analyse_pressure(pressure, t):
210 global time_vals, pressure_vals, trend
211 if len(pressure_vals) > num_vals:
212 pressure_vals = pressure_vals[1:] + [pressure]
213 time_vals = time_vals[1:] + [t]
214
215 # Calculate line of best fit
216 line = numpy.polyfit(time_vals, pressure_vals, 1, full=True)
217
218 # Calculate slope, variance, and confidence
219 slope = line[0][0]
220 intercept = line[0][1]
221 variance = numpy.var(pressure_vals)
be4d0fc9 222 residuals = numpy.var([(slope * x + intercept - y) for x, y in zip(time_vals, pressure_vals)])
2fe226f8 223 r_squared = 1 - residuals / variance
224
225 # Calculate change in pressure per hour
226 change_per_hour = slope * 60 * 60
be4d0fc9 227 # variance_per_hour = variance * 60 * 60
2fe226f8 228
229 mean_pressure = numpy.mean(pressure_vals)
230
231 # Calculate trend
232 if r_squared > 0.5:
233 if change_per_hour > 0.5:
234 trend = ">"
235 elif change_per_hour < -0.5:
236 trend = "<"
237 elif -0.5 <= change_per_hour <= 0.5:
238 trend = "-"
239
240 if trend != "-":
241 if abs(change_per_hour) > 3:
242 trend *= 2
243 else:
244 pressure_vals.append(pressure)
245 time_vals.append(t)
246 mean_pressure = numpy.mean(pressure_vals)
247 change_per_hour = 0
248 trend = "-"
249
be4d0fc9 250 # time.sleep(interval)
2fe226f8 251 return (mean_pressure, change_per_hour, trend)
252
be4d0fc9 253
2fe226f8 254def describe_pressure(pressure):
255 """Convert pressure into barometer-type description."""
256 if pressure < 970:
257 description = "storm"
258 elif 970 <= pressure < 990:
259 description = "rain"
260 elif 990 <= pressure < 1010:
261 description = "change"
262 elif 1010 <= pressure < 1030:
263 description = "fair"
264 elif pressure >= 1030:
265 description = "dry"
266 else:
267 description = ""
268 return description
269
270
271def describe_humidity(humidity):
272 """Convert relative humidity into good/bad description."""
273 if 40 < humidity < 60:
274 description = "good"
275 else:
276 description = "bad"
277 return description
278
279
280def describe_light(light):
281 """Convert light level in lux to descriptive value."""
282 if light < 50:
283 description = "dark"
284 elif 50 <= light < 100:
285 description = "dim"
286 elif 100 <= light < 500:
287 description = "light"
288 elif light >= 500:
289 description = "bright"
290 return description
291
292
293# Initialise the LCD
294disp = ST7735.ST7735(
295 port=0,
296 cs=1,
297 dc=9,
298 backlight=12,
299 rotation=270,
300 spi_speed_hz=10000000
301)
302
303disp.begin()
304
305WIDTH = disp.width
306HEIGHT = disp.height
307
308# The city and timezone that you want to display.
309city_name = "Sheffield"
310time_zone = "Europe/London"
311
312# Values that alter the look of the background
313blur = 50
314opacity = 125
315
316mid_hue = 0
317day_hue = 25
318
319sun_radius = 50
320
321# Fonts
322font_sm = ImageFont.truetype(UserFont, 12)
323font_lg = ImageFont.truetype(UserFont, 14)
324
325# Margins
326margin = 3
327
2fe226f8 328
329# Set up BME280 weather sensor
330bus = SMBus(1)
331bme280 = BME280(i2c_dev=bus)
332
20442c9a 333min_temp = None
334max_temp = None
2fe226f8 335
336factor = 2.25
337cpu_temps = [get_cpu_temperature()] * 5
338
339# Set up light sensor
340ltr559 = LTR559()
341
342# Pressure variables
343pressure_vals = []
344time_vals = []
345num_vals = 1000
346interval = 1
347trend = "-"
348
20442c9a 349# Keep track of time elapsed
350start_time = time.time()
351
2fe226f8 352while True:
20442c9a 353 path = os.path.dirname(os.path.realpath(__file__))
7614d188 354 progress, period, day, local_dt = sun_moon_time(city_name, time_zone)
2fe226f8 355 background = draw_background(progress, period, day)
356
357 # Time.
20442c9a 358 time_elapsed = time.time() - start_time
7614d188
RF
359 date_string = local_dt.strftime("%d %b %y").lstrip('0')
360 time_string = local_dt.strftime("%H:%M")
2fe226f8 361 img = overlay_text(background, (0 + margin, 0 + margin), time_string, font_lg)
362 img = overlay_text(img, (WIDTH - margin, 0 + margin), date_string, font_lg, align_right=True)
363
364 # Temperature
365 temperature = bme280.get_temperature()
366
367 # Corrected temperature
368 cpu_temp = get_cpu_temperature()
369 cpu_temps = cpu_temps[1:] + [cpu_temp]
370 avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps))
371 corr_temperature = temperature - ((avg_cpu_temp - temperature) / factor)
372
20442c9a 373 if time_elapsed > 30:
374 if min_temp is not None and max_temp is not None:
375 if corr_temperature < min_temp:
376 min_temp = corr_temperature
377 elif corr_temperature > max_temp:
378 max_temp = corr_temperature
379 else:
380 min_temp = corr_temperature
381 max_temp = corr_temperature
2fe226f8 382
383 temp_string = f"{corr_temperature:.0f}°C"
384 img = overlay_text(img, (68, 18), temp_string, font_lg, align_right=True)
385 spacing = font_lg.getsize(temp_string)[1] + 1
20442c9a 386 if min_temp is not None and max_temp is not None:
387 range_string = f"{min_temp:.0f}-{max_temp:.0f}"
388 else:
389 range_string = "------"
2fe226f8 390 img = overlay_text(img, (68, 18 + spacing), range_string, font_sm, align_right=True, rectangle=True)
be4d0fc9 391 temp_icon = Image.open(f"{path}/icons/temperature.png")
2fe226f8 392 img.paste(temp_icon, (margin, 18), mask=temp_icon)
393
394 # Humidity
395 humidity = bme280.get_humidity()
396 corr_humidity = correct_humidity(humidity, temperature, corr_temperature)
397 humidity_string = f"{corr_humidity:.0f}%"
398 img = overlay_text(img, (68, 48), humidity_string, font_lg, align_right=True)
399 spacing = font_lg.getsize(humidity_string)[1] + 1
400 humidity_desc = describe_humidity(corr_humidity).upper()
401 img = overlay_text(img, (68, 48 + spacing), humidity_desc, font_sm, align_right=True, rectangle=True)
be4d0fc9 402 humidity_icon = Image.open(f"{path}/icons/humidity-{humidity_desc.lower()}.png")
2fe226f8 403 img.paste(humidity_icon, (margin, 48), mask=humidity_icon)
404
405 # Light
406 light = ltr559.get_lux()
407 light_string = f"{int(light):,}"
408 img = overlay_text(img, (WIDTH - margin, 18), light_string, font_lg, align_right=True)
409 spacing = font_lg.getsize(light_string.replace(",", ""))[1] + 1
410 light_desc = describe_light(light).upper()
411 img = overlay_text(img, (WIDTH - margin - 1, 18 + spacing), light_desc, font_sm, align_right=True, rectangle=True)
be4d0fc9 412 light_icon = Image.open(f"{path}/icons/bulb-{light_desc.lower()}.png")
2fe226f8 413 img.paste(humidity_icon, (80, 18), mask=light_icon)
414
415 # Pressure
416 pressure = bme280.get_pressure()
417 t = time.time()
418 mean_pressure, change_per_hour, trend = analyse_pressure(pressure, t)
419 pressure_string = f"{int(mean_pressure):,} {trend}"
420 img = overlay_text(img, (WIDTH - margin, 48), pressure_string, font_lg, align_right=True)
421 pressure_desc = describe_pressure(mean_pressure).upper()
422 spacing = font_lg.getsize(pressure_string.replace(",", ""))[1] + 1
423 img = overlay_text(img, (WIDTH - margin - 1, 48 + spacing), pressure_desc, font_sm, align_right=True, rectangle=True)
be4d0fc9 424 pressure_icon = Image.open(f"{path}/icons/weather-{pressure_desc.lower()}.png")
2fe226f8 425 img.paste(pressure_icon, (80, 48), mask=pressure_icon)
426
427 # Display image
428 disp.display(img)