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