Add two third party projects to the README
[EVA-2020-02-2.git] / examples / weather-and-light.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 f"Sorry! This program requires Python >= 3.6 😅"
5
6 import os
7 import time
8 import numpy
9 import colorsys
10 from PIL import Image, ImageDraw, ImageFont, ImageFilter
11 from fonts.ttf import RobotoMedium as UserFont
12
13 import ST7735
14 from bme280 import BME280
15 from ltr559 import LTR559
16
17 import pytz
18 from pytz import timezone
19 from astral.geocoder import database, lookup
20 from astral.sun import sun
21 from datetime import datetime, timedelta
22
23 try:
24 from smbus2 import SMBus
25 except ImportError:
26 from smbus import SMBus
27
28
29 def 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
37 def 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
48 def 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
78 def 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
86 def sun_moon_time(city_name, time_zone):
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
90 city = lookup(city_name, database())
91
92 # Datetime objects for yesterday, today, tomorrow
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()
97 yesterday = today - timedelta(1)
98 tomorrow = today + timedelta(1)
99
100 # Sun objects for yesterday, today, tomorrow
101 sun_yesterday = sun(city.observer, date=yesterday)
102 sun_today = sun(city.observer, date=today)
103 sun_tomorrow = sun(city.observer, date=tomorrow)
104
105 # Work out sunset yesterday, sunrise/sunset today, and sunrise tomorrow
106 sunset_yesterday = sun_yesterday["sunset"]
107 sunrise_today = sun_today["sunrise"]
108 sunset_today = sun_today["sunset"]
109 sunrise_tomorrow = sun_tomorrow["sunrise"]
110
111 # Work out lengths of day or night period and progress through period
112 if sunrise_today < local_dt < sunset_today:
113 day = True
114 period = sunset_today - sunrise_today
115 # mid = sunrise_today + (period / 2)
116 progress = local_dt - sunrise_today
117
118 elif local_dt > sunset_today:
119 day = False
120 period = sunrise_tomorrow - sunset_today
121 # mid = sunset_today + (period / 2)
122 progress = local_dt - sunset_today
123
124 else:
125 day = False
126 period = sunrise_today - sunset_yesterday
127 # mid = sunset_yesterday + (period / 2)
128 progress = local_dt - sunset_yesterday
129
130 # Convert time deltas to seconds
131 progress = progress.total_seconds()
132 period = period.total_seconds()
133
134 return (progress, period, day, local_dt)
135
136
137 def 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
148 # Calculate position on sun/moon's curve
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)
157 # draw = ImageDraw.Draw(img)
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
173 def 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
196 def 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
203 def 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
209 def 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)
222 residuals = numpy.var([(slope * x + intercept - y) for x, y in zip(time_vals, pressure_vals)])
223 r_squared = 1 - residuals / variance
224
225 # Calculate change in pressure per hour
226 change_per_hour = slope * 60 * 60
227 # variance_per_hour = variance * 60 * 60
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
250 # time.sleep(interval)
251 return (mean_pressure, change_per_hour, trend)
252
253
254 def 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
271 def 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
280 def 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
294 disp = ST7735.ST7735(
295 port=0,
296 cs=1,
297 dc=9,
298 backlight=12,
299 rotation=270,
300 spi_speed_hz=10000000
301 )
302
303 disp.begin()
304
305 WIDTH = disp.width
306 HEIGHT = disp.height
307
308 # The city and timezone that you want to display.
309 city_name = "Sheffield"
310 time_zone = "Europe/London"
311
312 # Values that alter the look of the background
313 blur = 50
314 opacity = 125
315
316 mid_hue = 0
317 day_hue = 25
318
319 sun_radius = 50
320
321 # Fonts
322 font_sm = ImageFont.truetype(UserFont, 12)
323 font_lg = ImageFont.truetype(UserFont, 14)
324
325 # Margins
326 margin = 3
327
328
329 # Set up BME280 weather sensor
330 bus = SMBus(1)
331 bme280 = BME280(i2c_dev=bus)
332
333 min_temp = None
334 max_temp = None
335
336 factor = 2.25
337 cpu_temps = [get_cpu_temperature()] * 5
338
339 # Set up light sensor
340 ltr559 = LTR559()
341
342 # Pressure variables
343 pressure_vals = []
344 time_vals = []
345 num_vals = 1000
346 interval = 1
347 trend = "-"
348
349 # Keep track of time elapsed
350 start_time = time.time()
351
352 while True:
353 path = os.path.dirname(os.path.realpath(__file__))
354 progress, period, day, local_dt = sun_moon_time(city_name, time_zone)
355 background = draw_background(progress, period, day)
356
357 # Time.
358 time_elapsed = time.time() - start_time
359 date_string = local_dt.strftime("%d %b %y").lstrip('0')
360 time_string = local_dt.strftime("%H:%M")
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
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
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
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 = "------"
390 img = overlay_text(img, (68, 18 + spacing), range_string, font_sm, align_right=True, rectangle=True)
391 temp_icon = Image.open(f"{path}/icons/temperature.png")
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)
402 humidity_icon = Image.open(f"{path}/icons/humidity-{humidity_desc.lower()}.png")
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)
412 light_icon = Image.open(f"{path}/icons/bulb-{light_desc.lower()}.png")
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)
424 pressure_icon = Image.open(f"{path}/icons/weather-{pressure_desc.lower()}.png")
425 img.paste(pressure_icon, (80, 48), mask=pressure_icon)
426
427 # Display image
428 disp.display(img)