Merge pull request #73 from pimoroni/patch-user-projects-readme
[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 pytz import timezone
16 from astral.geocoder import database, lookup
17 from astral.sun import sun
18 from datetime import datetime, timedelta
19
20 try:
21 from smbus2 import SMBus
22 except ImportError:
23 from smbus import SMBus
24
25
26 def 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
34 def 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
45 def 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
75 def 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
83 def 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
134 def 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
170 def 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
193 def 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
200 def 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
206 def 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
251 def 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
268 def 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
277 def 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
291 disp = ST7735.ST7735(
292 port=0,
293 cs=1,
294 dc=9,
295 backlight=12,
296 rotation=270,
297 spi_speed_hz=10000000
298 )
299
300 disp.begin()
301
302 WIDTH = disp.width
303 HEIGHT = disp.height
304
305 # The city and timezone that you want to display.
306 city_name = "Sheffield"
307 time_zone = "Europe/London"
308
309 # Values that alter the look of the background
310 blur = 50
311 opacity = 125
312
313 mid_hue = 0
314 day_hue = 25
315
316 sun_radius = 50
317
318 # Fonts
319 font_sm = ImageFont.truetype(UserFont, 12)
320 font_lg = ImageFont.truetype(UserFont, 14)
321
322 # Margins
323 margin = 3
324
325
326 # Set up BME280 weather sensor
327 bus = SMBus(1)
328 bme280 = BME280(i2c_dev=bus)
329
330 min_temp = None
331 max_temp = None
332
333 factor = 2.25
334 cpu_temps = [get_cpu_temperature()] * 5
335
336 # Set up light sensor
337 ltr559 = LTR559()
338
339 # Pressure variables
340 pressure_vals = []
341 time_vals = []
342 num_vals = 1000
343 interval = 1
344 trend = "-"
345
346 # Keep track of time elapsed
347 start_time = time.time()
348
349 while 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)