mirror of
https://github.com/adamzend/Uploading-server-data-meteo-to-Lemmy-instance.git
synced 2026-10-30 19:02:00 +01:00
166 lines
7.8 KiB
Python
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))
|