collector.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. #!/usr/bin/env python3
  2. """Välistemperatuuri koguja (Open-Meteo).
  3. Toob Open-Meteo API-st tunniandmed (temperatuur, niiskus, tuul, sademed)
  4. asukoha jaoks (vaikimisi Suuremõisa, Hiiumaa) ja kirjutab need ClickHouse'i
  5. tabelisse ``uus67.valisilm``.
  6. - Käivitusel backfill alates ``BACKFILL_FROM`` (vaikimisi 2026-09-17).
  7. - Edasi iga ``POLL_INTERVAL`` sekundi järel uuendab viimaseid tunde
  8. (Open-Meteo uuendab hiljutisi väärtusi) ja täidab vahelejäänud tunnid.
  9. - Auke ei jäta: kui mõne tunni andmed puuduvad, kasutab eelmise teadaoleva
  10. tunni väärtust ja märgib ``taidetud = true``.
  11. Tabel on ReplacingMergeTree (ORDER BY hour) — sama tunni uuesti kirjutamine
  12. asendab varasema rea. Päringud peaksid kasutama ``FINAL``.
  13. """
  14. from __future__ import annotations
  15. import json
  16. import logging
  17. import os
  18. import signal
  19. import time
  20. from datetime import date, datetime, timedelta
  21. from typing import Any
  22. from zoneinfo import ZoneInfo
  23. import requests
  24. logging.basicConfig(
  25. level=logging.INFO,
  26. format="%(asctime)s %(levelname)s %(message)s",
  27. )
  28. LOG = logging.getLogger("weather-collector")
  29. TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
  30. LATITUDE = float(os.environ.get("LATITUDE", "58.87009"))
  31. LONGITUDE = float(os.environ.get("LONGITUDE", "22.9479"))
  32. BACKFILL_FROM = os.environ.get("BACKFILL_FROM", "2026-09-17")
  33. POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "1800"))
  34. FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
  35. ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
  36. HOURLY_VARS = "temperature_2m,relative_humidity_2m,wind_speed_10m,precipitation"
  37. CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
  38. CH_USER = os.environ.get("CH_USER", "zigbee")
  39. CH_PASS = os.environ.get("CH_PASS", "")
  40. CH_DB = os.environ.get("CH_DB", "uus67")
  41. CH_TABLE = os.environ.get("CH_WEATHER_TABLE", "valisilm")
  42. MAX_PAST_DAYS = 92
  43. def current_hour(now: datetime | None = None) -> datetime:
  44. now = now or datetime.now(TZ)
  45. return now.replace(minute=0, second=0, microsecond=0)
  46. def parse_dt(value: str) -> datetime:
  47. return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=TZ)
  48. def _num(value: Any) -> float | None:
  49. if isinstance(value, bool) or not isinstance(value, (int, float)):
  50. return None
  51. return float(value)
  52. def _parse_hourly(payload: dict) -> dict[datetime, dict[str, float | None]]:
  53. hourly = payload.get("hourly") or {}
  54. times = hourly.get("time") or []
  55. temps = hourly.get("temperature_2m") or []
  56. hums = hourly.get("relative_humidity_2m") or []
  57. winds = hourly.get("wind_speed_10m") or []
  58. rains = hourly.get("precipitation") or []
  59. out: dict[datetime, dict[str, float | None]] = {}
  60. for i, ts in enumerate(times):
  61. dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M").replace(tzinfo=TZ)
  62. out[dt] = {
  63. "temperature": _num(temps[i]) if i < len(temps) else None,
  64. "humidity": _num(hums[i]) if i < len(hums) else None,
  65. "wind": _num(winds[i]) if i < len(winds) else None,
  66. "rain": _num(rains[i]) if i < len(rains) else None,
  67. }
  68. return out
  69. def fetch_forecast(past_days: int) -> dict[datetime, dict[str, float | None]]:
  70. params = {
  71. "latitude": LATITUDE,
  72. "longitude": LONGITUDE,
  73. "hourly": HOURLY_VARS,
  74. "timezone": "Europe/Tallinn",
  75. "past_days": max(1, min(past_days, MAX_PAST_DAYS)),
  76. "forecast_days": 1,
  77. "wind_speed_unit": "kmh",
  78. }
  79. resp = requests.get(FORECAST_URL, params=params, timeout=30)
  80. resp.raise_for_status()
  81. return _parse_hourly(resp.json())
  82. def fetch_archive(start: date, end: date) -> dict[datetime, dict[str, float | None]]:
  83. params = {
  84. "latitude": LATITUDE,
  85. "longitude": LONGITUDE,
  86. "start_date": start.isoformat(),
  87. "end_date": end.isoformat(),
  88. "hourly": HOURLY_VARS,
  89. "timezone": "Europe/Tallinn",
  90. "wind_speed_unit": "kmh",
  91. }
  92. resp = requests.get(ARCHIVE_URL, params=params, timeout=30)
  93. resp.raise_for_status()
  94. return _parse_hourly(resp.json())
  95. def gather(start: datetime, now: datetime) -> dict[datetime, dict[str, float | None]]:
  96. """Kogu API-andmed vahemikus [start, now] (lõpp kaasatud)."""
  97. today = now.replace(hour=0, minute=0, second=0, microsecond=0)
  98. api: dict[datetime, dict[str, float | None]] = {}
  99. if start < today:
  100. archive_end = today - timedelta(days=1)
  101. try:
  102. api.update(fetch_archive(start.date(), archive_end.date()))
  103. except Exception:
  104. LOG.exception("Open-Meteo archive päring ebaõnnestus")
  105. past_days = max(1, (today - start).days + 1)
  106. try:
  107. api.update(fetch_forecast(past_days))
  108. except Exception:
  109. LOG.exception("Open-Meteo forecast päring ebaõnnestus")
  110. return {h: v for h, v in api.items() if start <= h <= now}
  111. def build_rows(
  112. api: dict[datetime, dict[str, float | None]],
  113. start: datetime,
  114. end: datetime,
  115. ) -> list[dict[str, Any]]:
  116. """Ehita tunniread, täites puuduvad tunnid eelmise väärtusega."""
  117. rows: list[dict[str, Any]] = []
  118. last: dict[str, float | None] | None = None
  119. hour = start
  120. while hour <= end:
  121. values = api.get(hour)
  122. has_data = values is not None and values.get("temperature") is not None
  123. if has_data:
  124. last = values
  125. rows.append(_row(hour, values, False))
  126. elif last is not None:
  127. rows.append(_row(hour, last, True))
  128. hour += timedelta(hours=1)
  129. return rows
  130. def _row(hour: datetime, values: dict[str, float | None], taidetud: bool) -> dict[str, Any]:
  131. return {
  132. "hour": hour.strftime("%Y-%m-%d %H:%M:%S"),
  133. "temperature": values.get("temperature"),
  134. "humidity": values.get("humidity"),
  135. "wind": values.get("wind"),
  136. "rain": values.get("rain"),
  137. "taidetud": taidetud,
  138. }
  139. def ch_query(query: str) -> str:
  140. resp = requests.post(
  141. f"{CH_URL}/",
  142. params={"database": CH_DB, "query": query},
  143. auth=(CH_USER, CH_PASS),
  144. timeout=20,
  145. )
  146. resp.raise_for_status()
  147. return resp.text
  148. def load_last_hour() -> datetime | None:
  149. try:
  150. text = ch_query(
  151. f"SELECT toString(max(hour)) AS mh FROM {CH_DB}.{CH_TABLE} FORMAT JSONEachRow"
  152. ).strip()
  153. if text:
  154. obj = json.loads(text.splitlines()[0])
  155. if obj.get("mh"):
  156. return datetime.strptime(obj["mh"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ)
  157. except Exception:
  158. LOG.exception("Viimase tunni laadimine ebaõnnestus")
  159. return None
  160. def insert_rows(rows: list[dict[str, Any]]) -> None:
  161. if not rows:
  162. return
  163. body = "\n".join(json.dumps(r, ensure_ascii=False) for r in rows)
  164. resp = requests.post(
  165. f"{CH_URL}/",
  166. params={
  167. "database": CH_DB,
  168. "query": f"INSERT INTO {CH_DB}.{CH_TABLE} FORMAT JSONEachRow",
  169. },
  170. data=body.encode("utf-8"),
  171. auth=(CH_USER, CH_PASS),
  172. timeout=30,
  173. )
  174. resp.raise_for_status()
  175. LOG.info("ClickHouse'i (%s) kirjutatud %d rida", CH_TABLE, len(rows))
  176. def run_once(backfill_start: datetime, full: bool) -> None:
  177. now = current_hour()
  178. if full:
  179. start = backfill_start
  180. else:
  181. last = load_last_hour()
  182. start = backfill_start if last is None else max(backfill_start, last - timedelta(days=2))
  183. if start > now:
  184. start = now
  185. api = gather(start, now)
  186. rows = build_rows(api, start, now)
  187. insert_rows(rows)
  188. LOG.info(
  189. "Vahemik %s .. %s, API ridu %d, kirjutatud %d",
  190. start.strftime("%Y-%m-%d %H:%M"),
  191. now.strftime("%Y-%m-%d %H:%M"),
  192. len(api),
  193. len(rows),
  194. )
  195. def main() -> int:
  196. running = {"stop": False}
  197. def shutdown(signum, frame):
  198. LOG.info("Lõpetamissignaal (%s), sulgen", signum)
  199. running["stop"] = True
  200. signal.signal(signal.SIGTERM, shutdown)
  201. signal.signal(signal.SIGINT, shutdown)
  202. backfill_start = parse_dt(BACKFILL_FROM).replace(hour=0, minute=0, second=0, microsecond=0)
  203. LOG.info(
  204. "Välistemperatuuri koguja: lat=%.5f lon=%.5f, backfill alates %s, intervall %ds",
  205. LATITUDE,
  206. LONGITUDE,
  207. backfill_start.strftime("%Y-%m-%d"),
  208. POLL_INTERVAL,
  209. )
  210. # Käivitusel täisbackfill (kuni aegunud aukudeni)
  211. try:
  212. run_once(backfill_start, full=True)
  213. except Exception:
  214. LOG.exception("Backfill ebaõnnestus, jätkan pollimisega")
  215. while not running["stop"]:
  216. now = datetime.now(TZ)
  217. nxt = now.replace(minute=5, second=0, microsecond=0)
  218. if nxt <= now:
  219. nxt += timedelta(hours=1)
  220. wait_s = min(POLL_INTERVAL, max(30.0, (nxt - now).total_seconds()))
  221. waited = 0.0
  222. while waited < wait_s and not running["stop"]:
  223. time.sleep(min(5.0, wait_s - waited))
  224. waited += 5.0
  225. if running["stop"]:
  226. break
  227. try:
  228. run_once(backfill_start, full=False)
  229. except Exception:
  230. LOG.exception("Tunnipoll ebaõnnestus, proovin hiljem uuesti")
  231. return 0
  232. if __name__ == "__main__":
  233. raise SystemExit(main())