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