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