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