maj via codex

This commit is contained in:
2026-02-22 18:34:50 +01:00
parent 20af00d653
commit 55387f4b0e
90 changed files with 9902 additions and 1251 deletions

View File

@@ -19,6 +19,17 @@ SIGN_TO_TYPE = {
"Bélier": "Fruit", "Lion": "Fruit", "Sagittaire": "Fruit",
}
SAINTS_BY_MMDD = {
"04-23": "Saint Georges",
"04-25": "Saint Marc",
"05-11": "Saint Mamert",
"05-12": "Saint Pancrace",
"05-13": "Saint Servais",
"05-14": "Saint Boniface",
"05-19": "Saint Yves",
"05-25": "Saint Urbain",
}
@dataclass
class DayInfo:
@@ -29,6 +40,7 @@ class DayInfo:
montante_descendante: str
signe: str
type_jour: str
saint_du_jour: str
perigee: bool
apogee: bool
noeud_lunaire: bool
@@ -126,6 +138,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
lat, lon, dist = v_moon.ecliptic_latlon()
signe = zodiac_sign_from_lon(lon.degrees % 360.0)
type_jour = SIGN_TO_TYPE[signe]
saint_du_jour = SAINTS_BY_MMDD.get(d.strftime("%m-%d"), "")
result.append(
DayInfo(
date=d.isoformat(),
@@ -135,6 +148,7 @@ def build_calendar(start: date, end: date) -> list[DayInfo]:
montante_descendante=montante,
signe=signe,
type_jour=type_jour,
saint_du_jour=saint_du_jour,
perigee=(d in perigee_days),
apogee=(d in apogee_days),
noeud_lunaire=(d in node_days),

View File

@@ -32,6 +32,46 @@ _DAILY_FIELDS = [
"et0_fao_evapotranspiration",
]
_HOURLY_FIELDS = [
"soil_temperature_0cm",
]
def _to_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _value_at(values: list[Any], index: int, default: Any = None) -> Any:
if index < 0 or index >= len(values):
return default
return values[index]
def _daily_soil_average(raw: dict[str, Any]) -> dict[str, float]:
"""Construit un mapping ISO-date -> moyenne de soil_temperature_0cm."""
hourly = raw.get("hourly", {})
times = hourly.get("time", []) or []
soils = hourly.get("soil_temperature_0cm", []) or []
by_day: dict[str, list[float]] = {}
for idx, ts in enumerate(times):
soil = _to_float(_value_at(soils, idx))
if soil is None or not isinstance(ts, str) or len(ts) < 10:
continue
day = ts[:10]
by_day.setdefault(day, []).append(soil)
return {
day: round(sum(vals) / len(vals), 2)
for day, vals in by_day.items()
if vals
}
def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]:
"""Appelle Open-Meteo et retourne la liste des jours (past_days=7 + forecast=8).
@@ -50,6 +90,8 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) ->
]
for field in _DAILY_FIELDS:
params.append(("daily", field))
for field in _HOURLY_FIELDS:
params.append(("hourly", field))
try:
r = httpx.get(url, params=params, timeout=15)
@@ -61,22 +103,23 @@ def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) ->
daily = raw.get("daily", {})
dates = daily.get("time", [])
soil_by_day = _daily_soil_average(raw)
now_iso = datetime.now(timezone.utc).isoformat()
rows = []
for i, d in enumerate(dates):
code = int(daily.get("weather_code", [0] * len(dates))[i] or 0)
code = int(_value_at(daily.get("weather_code", []), i, 0) or 0)
row = {
"date": d,
"t_min": daily.get("temperature_2m_min", [None] * len(dates))[i],
"t_max": daily.get("temperature_2m_max", [None] * len(dates))[i],
"pluie_mm": daily.get("precipitation_sum", [0] * len(dates))[i] or 0.0,
"vent_kmh": daily.get("wind_speed_10m_max", [0] * len(dates))[i] or 0.0,
"t_min": _to_float(_value_at(daily.get("temperature_2m_min", []), i)),
"t_max": _to_float(_value_at(daily.get("temperature_2m_max", []), i)),
"pluie_mm": _to_float(_value_at(daily.get("precipitation_sum", []), i, 0.0)) or 0.0,
"vent_kmh": _to_float(_value_at(daily.get("wind_speed_10m_max", []), i, 0.0)) or 0.0,
"wmo": code,
"label": WMO_LABELS.get(code, f"Code {code}"),
"humidite_moy": daily.get("relative_humidity_2m_max", [None] * len(dates))[i],
"sol_0cm": None, # soil_temperature_0cm est hourly uniquement
"etp_mm": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i],
"humidite_moy": _to_float(_value_at(daily.get("relative_humidity_2m_max", []), i)),
"sol_0cm": soil_by_day.get(d),
"etp_mm": _to_float(_value_at(daily.get("et0_fao_evapotranspiration", []), i)),
"fetched_at": now_iso,
}
rows.append(row)

View File

@@ -1,6 +1,8 @@
"""Service de collecte des données de la station météo locale WeeWX."""
import html
import logging
import re
import unicodedata
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
@@ -17,13 +19,37 @@ def _safe_float(text: str | None) -> float | None:
try:
cleaned = text.strip().replace(",", ".")
# Retirer unités courantes
for unit in [" °C", " %", " hPa", " km/h", " W/m²", "°C", "%", "hPa"]:
for unit in [
" °C",
" %", " %",
" hPa", " mbar",
" km/h", " m/s",
" mm/h", " mm",
" W/m²", " W/m2",
"°C", "%", "hPa", "mbar",
]:
cleaned = cleaned.replace(unit, "")
return float(cleaned.strip())
except (ValueError, AttributeError):
return None
def _normalize(text: str) -> str:
text = unicodedata.normalize("NFKD", text)
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = text.lower()
return re.sub(r"\s+", " ", text).strip()
def _to_kmh(value: float | None, unit: str | None) -> float | None:
if value is None:
return None
u = (unit or "").strip().lower()
if u == "m/s":
return round(value * 3.6, 1)
return round(value, 1)
def _direction_to_abbr(deg: float | None) -> str | None:
if deg is None:
return None
@@ -51,37 +77,51 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None:
if item is None:
return None
desc = item.findtext("description") or ""
desc = html.unescape(item.findtext("description") or "")
result: dict = {}
segments = [seg.strip() for seg in desc.split(";") if seg.strip()]
for seg in segments:
if ":" not in seg:
continue
raw_key, raw_value = seg.split(":", 1)
key = _normalize(raw_key)
value = raw_value.strip()
patterns = {
"temp_ext": r"(?:Outside|Ext(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
"temp_int": r"(?:Inside|Int(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
"humidite": r"(?:Outside\s*)?Hum(?:idity)?\s*[:\s]+(\d+(?:[.,]\d+)?)",
"pression": r"(?:Bar(?:ometer)?|Pression)\s*[:\s]+(\d+(?:[.,]\d+)?)",
"pluie_mm": r"(?:Rain(?:fall)?|Pluie)\s*[:\s]+(\d+(?:[.,]\d+)?)",
"vent_kmh": r"(?:Wind\s*Speed|Vent)\s*[:\s]+(\d+(?:[.,]\d+)?)",
"uv": r"UV\s*[:\s]+(\d+(?:[.,]\d+)?)",
"solaire": r"(?:Solar\s*Radiation|Solaire)\s*[:\s]+(\d+(?:[.,]\d+)?)",
}
if "temperature exterieure" in key or "outside temperature" in key:
result["temp_ext"] = _safe_float(value)
continue
if "temperature interieure" in key or "inside temperature" in key:
result["temp_int"] = _safe_float(value)
continue
if "hygrometrie exterieure" in key or "outside humidity" in key:
result["humidite"] = _safe_float(value)
continue
if "pression atmospherique" in key or "barometer" in key:
result["pression"] = _safe_float(value)
continue
if "precipitations" in key and "taux" not in key and "rate" not in key:
result["pluie_mm"] = _safe_float(value)
continue
if key in {"uv", "ultra-violet"} or "ultra violet" in key:
result["uv"] = _safe_float(value)
continue
if "rayonnement solaire" in key or "solar radiation" in key:
result["solaire"] = _safe_float(value)
continue
if key == "vent" or "wind" in key:
speed_match = re.search(r"(-?\d+(?:[.,]\d+)?)\s*(m/s|km/h)?", value, re.IGNORECASE)
speed_val = _safe_float(speed_match.group(1)) if speed_match else None
speed_unit = speed_match.group(2) if speed_match else None
result["vent_kmh"] = _to_kmh(speed_val, speed_unit)
for key, pattern in patterns.items():
m = re.search(pattern, desc, re.IGNORECASE)
result[key] = _safe_float(m.group(1)) if m else None
deg_match = re.search(r"(\d{1,3}(?:[.,]\d+)?)\s*°", value)
if deg_match:
result["vent_dir"] = _direction_to_abbr(_safe_float(deg_match.group(1)))
continue
vent_dir_m = re.search(
r"(?:Wind\s*Dir(?:ection)?)\s*[:\s]+([NSEO]{1,2}|Nord|Sud|Est|Ouest|\d+)",
desc, re.IGNORECASE,
)
if vent_dir_m:
val = vent_dir_m.group(1).strip()
if val.isdigit():
result["vent_dir"] = _direction_to_abbr(float(val))
else:
result["vent_dir"] = val[:2].upper()
else:
result["vent_dir"] = None
card_match = re.search(r"\b(N|NE|E|SE|S|SO|O|NO|NNE|ENE|ESE|SSE|SSO|OSO|ONO|NNO)\b", value, re.IGNORECASE)
result["vent_dir"] = card_match.group(1).upper() if card_match else None
return result if any(v is not None for v in result.values()) else None
@@ -107,15 +147,28 @@ def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
for line in r.text.splitlines():
parts = line.split()
if len(parts) >= 7 and parts[0].isdigit() and int(parts[0]) == day:
# Format NOAA : jour tmax tmin tmoy precip ...
if not parts or not parts[0].isdigit() or int(parts[0]) != day:
continue
# Format WeeWX NOAA (fréquent) :
# day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir
if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]:
return {
"t_max": _safe_float(parts[1]),
"t_min": _safe_float(parts[2]),
"temp_ext": _safe_float(parts[3]),
"pluie_mm": _safe_float(parts[5]),
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
"temp_ext": _safe_float(parts[1]),
"t_max": _safe_float(parts[2]),
"t_min": _safe_float(parts[4]),
"pluie_mm": _safe_float(parts[8]),
"vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"),
}
# Fallback générique (anciens formats)
return {
"t_max": _safe_float(parts[1]) if len(parts) > 1 else None,
"t_min": _safe_float(parts[2]) if len(parts) > 2 else None,
"temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None,
"pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None,
"vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
}
except Exception as e:
logger.warning(f"Station fetch_yesterday_summary error: {e}")
return None