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