Files

166 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Generate three monthly PNG charts from the recorded observations."""
import argparse
import calendar
from datetime import datetime, timedelta, timezone
import json
import math
import os
from pathlib import Path
import sqlite3
from zoneinfo import ZoneInfo
from settings import load as load_config
BASE = Path(__file__).resolve().parent
os.environ['MPLCONFIGDIR'] = str(BASE / '.cache/matplotlib')
os.environ['XDG_CACHE_HOME'] = str(BASE / '.cache')
MONTHS = ('styczeń', 'luty', 'marzec', 'kwiecień', 'maj', 'czerwiec',
'lipiec', 'sierpień', 'wrzesień', 'październik', 'listopad', 'grudzień')
SPECS = (
('temperatura', 'Temperatura', '°C', '#C15E29', 2),
('wilgotnosc', 'Wilgotność powietrza', '%', '#2176B5', 3),
('cisnienie', 'Ciśnienie atmosferyczne', 'hPa', '#18847B', 4),
)
def month_bounds(year, month, tz):
start = datetime(year, month, 1, tzinfo=tz)
end = datetime(year + (month == 12), 1 if month == 12 else month + 1, 1, tzinfo=tz)
return start, end
def load_rows(db_path, start, end, now):
with sqlite3.connect(db_path.resolve().as_uri() + '?mode=ro', uri=True) as db:
return db.execute('''SELECT checked_at,status,temperature_c,humidity_percent,pressure_hpa
FROM readings WHERE checked_at >= ? AND checked_at < ? AND checked_at <= ?
ORDER BY checked_at,id''', tuple(
value.astimezone(timezone.utc).isoformat(timespec='seconds')
for value in (start, end, now))).fetchall()
def series(rows, column, tz, max_gap_minutes=90):
x, y = [], []
previous = None
for row in rows:
stamp = datetime.fromisoformat(row[0])
if previous is not None and (stamp - previous).total_seconds() > max_gap_minutes * 60:
x.append((previous + timedelta(seconds=1)).astimezone(tz))
y.append(float('nan'))
x.append(stamp.astimezone(tz))
value = row[column]
y.append(float(value) if row[1] == 'ok' and value is not None else float('nan'))
previous = stamp
return x, y
def number(value):
return f'{value:.1f}'.replace('.', ',')
def generate(month=None, *, db_path=None, output=None, now=None):
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.ticker import FuncFormatter, MaxNLocator
os.umask(0o077)
config = load_config()
tz = ZoneInfo(config['timezone'])
now = now or datetime.now(tz)
local_now = now.astimezone(tz)
year, month_number = map(int, month.split('-')) if month else (local_now.year, local_now.month)
start, end = month_bounds(year, month_number, tz)
rows = load_rows(db_path or BASE / 'data/meteo.sqlite3', start, end, now)
output = output or BASE / 'wykresy'
output.mkdir(parents=True, exist_ok=True)
month_label = f'{MONTHS[month_number - 1]} {year}'
plt.rcParams.update({'font.family': 'DejaVu Sans', 'font.size': 12,
'axes.unicode_minus': True, 'axes.formatter.useoffset': False})
manifest = {'month': f'{year:04d}-{month_number:02d}',
'generated_at': local_now.isoformat(timespec='seconds'),
'timezone': config['timezone'], 'charts': {}}
for slug, title, unit, color, column in SPECS:
x, y = series(rows, column, tz, config["stale_after_minutes"])
valid = [(stamp, value) for stamp, value in zip(x, y) if math.isfinite(value)]
# Use a 16:9 canvas compatible with common image preview crops.
fig, ax = plt.subplots(figsize=(14, 7.875), dpi=120)
fig.patch.set_facecolor('#F5F7FA')
ax.set_facecolor('white')
fig.subplots_adjust(left=.09, right=.96, bottom=.22, top=.69)
fig.text(.09, .92, 'STACJA METEO / ' + month_label.upper(),
color='#64748B', fontsize=11, weight='bold')
fig.text(.09, .845, title, fontsize=25, weight='bold', color='#172B42')
if valid:
last_time, last_value = valid[-1]
vals = [v for _, v in valid]
fig.text(.96, .845, f'{number(last_value)} {unit}', ha='right',
fontsize=29, weight='bold', color=color)
fig.text(.96, .79, 'Ostatni pomiar: ' + last_time.strftime('%d.%m, %H:%M'),
ha='right', fontsize=10, color='#64748B')
fig.text(.09, .775,
f'Minimum: {number(min(vals))} {unit} • Maksimum: {number(max(vals))} {unit}',
fontsize=12, color='#475569')
ax.plot(x, y, color=color, linewidth=1.7, marker='o',
markersize=4 if len(valid) < 15 else 2, zorder=3)
ax.scatter([last_time], [last_value], s=55, facecolor=color,
edgecolor='white', linewidth=1.3, zorder=4)
if slug == 'wilgotnosc':
ax.set_ylim(0, 100)
else:
padding = max((max(vals) - min(vals)) * .15, 1 if slug == 'temperatura' else 2)
ax.set_ylim(min(vals) - padding, max(vals) + padding)
else:
fig.text(.96, .845, 'Brak pomiarów', ha='right', fontsize=19, color='#64748B')
ax.text(.5, .5, 'Brak poprawnych pomiarów w tym miesiącu', transform=ax.transAxes,
ha='center', va='center', color='#64748B', fontsize=14)
ax.set_ylim(0, 100 if slug == 'wilgotnosc' else 1)
ax.set_yticks([])
ax.set_xlim(start, end - timedelta(seconds=1))
if start <= local_now < end:
ax.axvspan(local_now, end, color='#F1F5F9', zorder=0)
days = [1, 5, 10, 15, 20, 25, calendar.monthrange(year, month_number)[1]]
ax.set_xticks([datetime(year, month_number, day, tzinfo=tz) for day in days])
ax.xaxis.set_major_formatter(mdates.DateFormatter('%d', tz=tz))
ax.set_xlabel('Dzień miesiąca', labelpad=10, color='#475569')
ax.set_ylabel(unit, labelpad=12, color='#475569')
if valid:
ax.yaxis.set_major_locator(MaxNLocator(nbins=5))
ax.yaxis.set_major_formatter(FuncFormatter(lambda value, _: f'{value:g}'.replace('.', ',')))
ax.grid(axis='y', color='#E2E8F0', linewidth=.8)
ax.grid(axis='x', color='#EDF1F5', linewidth=.6)
ax.set_axisbelow(True)
ax.tick_params(axis='both', length=0, pad=9, colors='#475569')
for spine in ax.spines.values():
spine.set_visible(False)
footer = f'Liczba poprawnych pomiarów: {len(valid)}'
if valid:
footer += ' • Dane od ' + valid[0][0].strftime('%d.%m, %H:%M')
fig.text(.09, .095, footer, fontsize=10, color='#64748B')
fig.text(.09, .052, 'Przerwy oznaczają brak danych. Czas lokalny: ' + config['timezone'],
fontsize=9, color='#64748B')
fig.text(.96, .052, 'Aktualizacja: ' + local_now.strftime('%d.%m.%Y, %H:%M'),
ha='right', fontsize=9, color='#64748B')
target = output / f'{slug}.png'
temporary = target.with_suffix('.tmp')
try:
fig.savefig(temporary, format='png', dpi=120, facecolor=fig.get_facecolor(),
metadata={'Title': f'{title} — {month_label}'})
temporary.replace(target)
finally:
plt.close(fig)
manifest['charts'][slug] = {'file': target.name, 'count': len(valid),
'last_success_at': valid[-1][0].isoformat() if valid else None}
temp_manifest = output / 'manifest.tmp'
temp_manifest.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
temp_manifest.replace(output / 'manifest.json')
return manifest
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--month', help='Optional month YYYY-MM; defaults to current local month')
args = parser.parse_args()
print(json.dumps(generate(args.month), ensure_ascii=False, indent=2))