from __future__ import annotations import io import os import logging import locale from datetime import date, datetime, timedelta, time from typing import Dict, Any, Optional import numpy as np import pandas as pd import requests import matplotlib.pyplot as plt import matplotlib.patches as mpatches from matplotlib.backends.backend_pdf import PdfPages from matplotlib.patches import Patch from flask import Flask, request, send_file, jsonify from flask_cors import CORS # --- Configuration ------------------------------------------------- LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "https://sglh.nl,https://www.sglh.nl").split(",") DEFAULT_ROLLUP = os.environ.get("DEFAULT_ROLLUP", "10s") # Locale try: locale.setlocale(locale.LC_TIME, "nl_NL.utf8") except Exception: try: locale.setlocale(locale.LC_TIME, "nl_NL") except Exception: logging.getLogger().warning("nl_NL locale not available; falling back to system locale") logging.basicConfig(level=LOG_LEVEL) logger = logging.getLogger(__name__) app = Flask(__name__) CORS(app, resources={r"/*": {"origins": ALLOWED_ORIGINS}}) # --- Available Kits --- AVAILABLE_KITS = [ {"id": 19067, "name": "Julianalaan 1"}, {"id": 19068, "name": "de Wiel 14"}, {"id": 19057, "name": "Kanaaldijk N.W. 77"}, {"id": 19185, "name": "Kanaaldijk N.W. 49B"}, {"id": 19062, "name": "Torenstraat 40"}, {"id": 19128, "name": "Wethouder van Wellaan 81"}, {"id": 19146, "name": "Sneeuwheide 20"}, {"id": 19122, "name": "Kerkstraat Zuid 17"}, {"id": 19148, "name": "Meester strikstraat 42"}, {"id": 19127, "name": "Binderseind 47"}, {"id": 19137, "name": "Berkendonk 73"}, {"id": 19139, "name": "Brabanthof 23"}, {"id": 19088, "name": "Zwanebloemsingel 40"}, {"id": 19131, "name": "Gerwenseweg 48"} ] # --- Helper functions ----------------------------------------------- def iso_week_start_date(year: int, week: int) -> Optional[date]: try: return datetime.fromisocalendar(year, week, 1).date() except Exception: return None def safe_get_json(url: str, params: Optional[Dict[str, Any]] = None, timeout: int = 10) -> Dict[str, Any]: try: resp = requests.get(url, params=params, timeout=timeout) resp.raise_for_status() return resp.json() except requests.RequestException as e: logger.exception("HTTP error while requesting %s", url) raise except ValueError as e: logger.exception("Invalid JSON from %s", url) raise def extract_sensors_from_meta(meta: Dict[str, Any]) -> Dict[str, int]: sensors_root = None if isinstance(meta, dict): sensors_root = meta.get("data", {}).get("sensors") or meta.get("sensors") or meta.get("data") result: Dict[str, int] = {} if not sensors_root: return result for s in sensors_root: name = (s.get("name") or "").lower() sid = s.get("id") if not sid: continue if "noise level a weighting" in name: result["Geluid"] = sid elif "pm1" in name: result["PM1"] = sid elif "pm2.5" in name or "pm25" in name: result["PM2.5"] = sid elif "pm4.0" in name or "pm40" in name: result["PM4.0"] = sid elif "pm10" in name: result["PM10"] = sid return result def haal_readings(device_id: int, sensor_id: int, start_iso: str, end_iso: str, functie: str = "avg", rollup: str = "30m") -> pd.DataFrame: def ensure_z(ts: str) -> str: return ts if ts.endswith("Z") else f"{ts}Z" from_ts = ensure_z(start_iso) to_ts = ensure_z(end_iso) url = f"https://api.smartcitizen.me/v0/devices/{device_id}/readings" params = {"sensor_id": sensor_id, "rollup": rollup, "from": from_ts, "to": to_ts, "function": functie} try: data = safe_get_json(url, params=params) except Exception: return pd.DataFrame() rows = [] if isinstance(data, list): rows = data elif isinstance(data, dict): if "readings" in data and isinstance(data["readings"], list): rows = data["readings"] elif "results" in data and isinstance(data["results"], list): rows = data["results"] else: try: rows = [{"recorded_at": k, "waarde": v} for k, v in data.items()] except Exception: rows = [] if not rows: return pd.DataFrame() df = pd.DataFrame(rows) if "recorded_at" not in df.columns and "timestamp" in df.columns: df = df.rename(columns={"timestamp": "recorded_at"}) if "recorded_at" not in df.columns or "waarde" not in df.columns: for col in df.columns: if df[col].dtype == object and pd.to_datetime(df[col], errors="coerce").notna().any(): df = df.rename(columns={col: "recorded_at"}) break if "waarde" not in df.columns: numcols = [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])] if numcols: df = df.rename(columns={numcols[0]: "waarde"}) df = df[[c for c in ["recorded_at", "waarde"] if c in df.columns]] if df.empty: return pd.DataFrame() df["recorded_at"] = pd.to_datetime(df["recorded_at"], errors="coerce") df = df.dropna(subset=["recorded_at"]) if df.empty: return pd.DataFrame() df = df.set_index("recorded_at").sort_index() return df def kleur_nachtzones(ax, df: pd.DataFrame) -> None: if df is None or df.empty: return start_date = df.index.min().date() end_date = df.index.max().date() for dag in pd.date_range(start_date, end_date): start = datetime.combine(dag, time(22, 0)) einde = datetime.combine(dag + timedelta(days=1), time(7, 0)) ax.axvspan(start, einde, color="lightgrey", alpha=0.3) def bereken_nachtstatistiek(boven_extreem: pd.DataFrame) -> Optional[Dict[str, Any]]: if boven_extreem is None or boven_extreem.empty: return None df = boven_extreem.copy() df["nacht"] = df.index.to_series().apply(lambda x: x.date() if x.time() >= time(22, 0) else (x - timedelta(days=1)).date()) groeps = df.groupby("nacht")["waarde"] try: return { "gemiddeld_aantal": groeps.count().mean(), "min_waarde": groeps.min().min(), "max_waarde": groeps.max().max(), "totaal_pieken": len(df), } except Exception: return None def bereken_loki_score(groen_percentages: Dict[str, float], weights: Optional[Dict[str, int]] = None) -> float: if weights is None: weights = {"Geluid": 2, "PM2.5": 2, "PM10": 1} relevant = [k for k in weights if k in groen_percentages] if not relevant: return 0.0 try: totaal = sum(groen_percentages[k] * weights[k] for k in relevant) score = round(totaal / sum(weights[k] for k in relevant) / 10, 2) except Exception: score = 0.0 return score def bereken_loki_week(device_id: int, weeknummer: int, jaar: Optional[int] = None) -> Dict[str, Any]: jaar = jaar or date.today().year start_date = iso_week_start_date(jaar, weeknummer) if not start_date: return {"error": "Ongeldig weeknummer"} end_date = start_date + timedelta(days=6) start_iso, end_iso = start_date.isoformat(), end_date.isoformat() meta_url = f"https://api.smartcitizen.me/v0/devices/{device_id}" try: meta = safe_get_json(meta_url) except Exception as e: return {"error": f"Fout bij ophalen device meta: {e}"} target_sensors = extract_sensors_from_meta(meta) if not target_sensors: return {"error": "Geen sensoren gevonden voor dit device"} groen_percentages: Dict[str, float] = {} for naam, sid in target_sensors.items(): df_avg = haal_readings(device_id, sid, start_iso, end_iso, "avg") if df_avg.empty: groen_percentages[naam] = 0.0 continue if naam == "Geluid": normen = {"nacht": 45} elif naam in ["PM1","PM2.5","PM4.0"]: normen = {"nacht": 2} else: normen = {"nacht": 5} total = len(df_avg) groen_percentages[naam] = ((df_avg["waarde"] <= normen["nacht"]).sum() / total) * 100 if total > 0 else 0.0 loki_weights = {"Geluid": 2, "PM2.5": 2, "PM10": 1} loki_score = bereken_loki_score(groen_percentages, loki_weights) return {"loki_score": loki_score, "percentages": groen_percentages} # --- Flask endpoints ----------------------------------------------- @app.route("/get_kits", methods=["GET"]) def get_kits(): return jsonify(AVAILABLE_KITS) @app.route("/get_weeks", methods=["GET"]) def get_weeks(): jaar = date.today().year weeknr = date.today().isocalendar().week week_items = [] for i in range(43, weeknr): start_date = iso_week_start_date(jaar, i) if not start_date: continue end_date = start_date + timedelta(days=6) week_items.append({ "weeknummer": i, "periode": f"{start_date.strftime('%d %B %Y')} - {end_date.strftime('%d %B %Y')}", }) return jsonify(week_items) @app.route("/api/readings", methods=["GET"]) def api_readings(): device_id = request.args.get("device_id") sensor_id = request.args.get("sensor_id") from_date = request.args.get("from") to_date = request.args.get("to") rollup = request.args.get("rollup", DEFAULT_ROLLUP) if not all([device_id, sensor_id, from_date, to_date]): return jsonify({"error": "device_id, sensor_id, from en to zijn verplicht"}), 400 df = haal_readings(int(device_id), int(sensor_id), from_date, to_date, rollup=rollup) if df.empty: return jsonify({"error": "Geen readings gevonden"}), 404 return jsonify(df.reset_index().rename(columns={"recorded_at": "timestamp", "waarde": "value"}).to_dict(orient="records")) # --- generate_pdf endpoint ----------------------------------------- @app.route("/generate_pdf", methods=["POST"]) def generate_pdf(): data = request.get_json(silent=True) or {} try: weeknummer = int(data.get("weeknummer")) device_id = int(data.get("device_id")) rollup = data.get("rollup", DEFAULT_ROLLUP) except Exception: return jsonify({"error": "weeknummer en device_id verplicht en moeten integers zijn"}), 400 kit_name = data.get("kit_name") or f"Device_{device_id}" jaar = date.today().year start_date = iso_week_start_date(jaar, weeknummer) if not start_date: return jsonify({"error": "Ongeldig weeknummer"}), 400 end_date = start_date + timedelta(days=6) start_iso, end_iso = start_date.isoformat(), end_date.isoformat() meta_url = f"https://api.smartcitizen.me/v0/devices/{device_id}" try: meta = safe_get_json(meta_url) except Exception as e: return jsonify({"error": f"Fout bij ophalen device meta: {e}"}), 500 target_sensors = extract_sensors_from_meta(meta) if not target_sensors: return jsonify({"error": "Geen sensoren gevonden"}), 400 kleuren = {"groen": "#00b050","geel": "#ffff00","oranje": "#ffc000","rood": "#ff0000","paars": "#7030a0"} legenda_labels_staaf = {"groen": "goed","geel": "matig","oranje": "onvoldoende","rood": "slecht","paars": "zeer slecht"} figuren = [] sensor_namen = [] zone_data = {k: [] for k in kleuren.keys()} groen_percentages_for_loki: Dict[str, float] = {} for naam, sid in target_sensors.items(): df_avg = haal_readings(device_id, sid, start_iso, end_iso, "avg", rollup=rollup) if df_avg.empty: continue df_max = haal_readings(device_id, sid, start_iso, end_iso, "max", rollup=rollup) if naam == "Geluid": normen = {"nacht": 45, "dag": 53, "max": 55, "extreem": 63} eenheid = "dB(A)" elif naam in ["PM1","PM2.5","PM4.0"]: normen = {"nacht": 2, "dag": 5, "max": 10, "extreem": 20} eenheid = "µg/m³" else: normen = {"nacht": 5, "dag": 10, "max": 15, "extreem": 20} eenheid = "µg/m³" fig, ax = plt.subplots(figsize=(10, 6)) kleur_nachtzones(ax, df_avg) total = len(df_avg) if total == 0: continue ax.fill_between(df_avg.index, 0, df_avg["waarde"], where=(df_avg["waarde"] <= normen["nacht"]), color=kleuren["groen"], alpha=0.4) ax.fill_between(df_avg.index, 0, df_avg["waarde"], where=(df_avg["waarde"] > normen["nacht"]) & (df_avg["waarde"] <= normen["dag"]), color=kleuren["geel"], alpha=0.4) ax.fill_between(df_avg.index, 0, df_avg["waarde"], where=(df_avg["waarde"] > normen["dag"]) & (df_avg["waarde"] <= normen["max"]), color=kleuren["oranje"], alpha=0.4) ax.fill_between(df_avg.index, 0, df_avg["waarde"], where=(df_avg["waarde"] > normen["max"]) & (df_avg["waarde"] <= normen["extreem"]), color=kleuren["rood"], alpha=0.4) ax.fill_between(df_avg.index, 0, df_avg["waarde"], where=(df_avg["waarde"] > normen["extreem"]), color=kleuren["paars"], alpha=0.4) ax.axhline(normen["nacht"], color='green', linestyle='--', linewidth=1) ax.axhline(normen["dag"], color='yellow', linestyle='--', linewidth=1) ax.axhline(normen["max"], color='orange', linestyle='--', linewidth=1) ax.axhline(normen["extreem"], color='red', linestyle='--', linewidth=1) ax.plot(df_avg.index, df_avg["waarde"], color="black", linewidth=1) ax.set_title(f"{naam} ({eenheid}) — {start_date.strftime('%d %B %Y')} t/m {end_date.strftime('%d %B %Y')} — {kit_name}") ax.set_ylabel(eenheid) ax.set_xlabel("Datum/tijd") boven_extreem = df_max[df_max["waarde"] > normen["extreem"]] if not df_max.empty else pd.DataFrame() stats = bereken_nachtstatistiek(boven_extreem) or {"gemiddeld_aantal": 0.0, "min_waarde": 0.0, "max_waarde": 0.0, "totaal_pieken": 0} ypos = ax.get_ylim()[1] * 1.02 ax.text(0.01, 0.98, f"Gem. pieken/nacht: {stats['gemiddeld_aantal']:.1f} - Min/Max: {stats['min_waarde']:.1f}/{stats['max_waarde']:.1f} - Totaal: {stats['totaal_pieken']}", transform=ax.transAxes, fontsize=10, verticalalignment='top') sensor_namen.append(naam) figuren.append(fig) groen_percentages_for_loki[naam] = ((df_avg["waarde"] <= normen["nacht"]).sum() / total) * 100 if total > 0 else 0.0 loki_score = bereken_loki_score(groen_percentages_for_loki) # Generate PDF pdf_buffer = io.BytesIO() with PdfPages(pdf_buffer) as pdf: for fig in figuren: pdf.savefig(fig) plt.close(fig) d = pdf.infodict() d['Title'] = f"Loki weekrapport {weeknummer}" d['Author'] = "SG-LH" pdf_buffer.seek(0) return send_file(pdf_buffer, mimetype='application/pdf', as_attachment=True, download_name=f"loki_week_{weeknummer}_{kit_name}.pdf") # --- Run server ---------------------------------------------------- if __name__ == "__main__": host = os.environ.get("FLASK_RUN_HOST", "0.0.0.0") port = int(os.environ.get("FLASK_RUN_PORT", "5000")) debug = os.environ.get("FLASK_DEBUG", "False").lower() in ("1", "true", "yes") app.run(host=host, port=port, debug=debug)