257 lines
7.6 KiB
Python
257 lines
7.6 KiB
Python
"""FVE production forecast from Open-Meteo + pvlib (15min intervals)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import timedelta, timezone
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
import pandas as pd
|
|
import pvlib
|
|
from pvlib import irradiance
|
|
|
|
from app.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _db_azimuth_to_pvlib(surface_azimuth_db_deg: float) -> float:
|
|
"""
|
|
EMS DB používá standardní azimut (kompasové stupně):
|
|
N=0, E=90, S=180, W=270 (stejně jako pvlib).
|
|
"""
|
|
return float(surface_azimuth_db_deg % 360)
|
|
|
|
|
|
async def fetch_pv_forecast(site_id: int, db) -> tuple[int, int]:
|
|
"""
|
|
Stáhne počasí (Open-Meteo), pro každé FVE pole spočte výkon (pvlib) a uloží intervaly.
|
|
|
|
Open-Meteo nepodporuje název ``diffuse_horizontal_irradiance``; používá se
|
|
``diffuse_radiation`` (DHI) a ``shortwave_radiation`` (GHI). Data jsou
|
|
``minutely_15`` kvůli 15min slotům v ``ems.forecast_pv_interval``.
|
|
|
|
Returns:
|
|
``(celkový_počet_řádků_forecast_pv_interval, počet_FVE_polí)``.
|
|
Při chybě ``(-1, 0)``. Bez polí ``(0, 0)``.
|
|
"""
|
|
site = await db.fetchrow(
|
|
"""
|
|
SELECT latitude, longitude, timezone
|
|
FROM ems.site
|
|
WHERE id = $1
|
|
""",
|
|
site_id,
|
|
)
|
|
if site is None:
|
|
logger.error("fetch_pv_forecast: site id=%s nenalezen", site_id)
|
|
return -1, 0
|
|
|
|
if site["latitude"] is None or site["longitude"] is None:
|
|
logger.error("fetch_pv_forecast: site id=%s nemá latitude/longitude", site_id)
|
|
return -1, 0
|
|
|
|
lat = float(site["latitude"])
|
|
lon = float(site["longitude"])
|
|
tz_name: str = site["timezone"] or "Europe/Prague"
|
|
|
|
try:
|
|
ZoneInfo(tz_name)
|
|
except Exception as e:
|
|
logger.error("fetch_pv_forecast: neplatná timezone %r: %s", tz_name, e)
|
|
return -1, 0
|
|
|
|
arrays = await db.fetch(
|
|
"""
|
|
SELECT id, code, nominal_power_wp, azimuth_deg, tilt_deg,
|
|
shading_factor, controllable
|
|
FROM ems.asset_pv_array
|
|
WHERE site_id = $1
|
|
AND azimuth_deg IS NOT NULL
|
|
AND tilt_deg IS NOT NULL
|
|
ORDER BY id
|
|
""",
|
|
site_id,
|
|
)
|
|
if not arrays:
|
|
logger.info("fetch_pv_forecast: žádná FVE pole pro site_id=%s", site_id)
|
|
return 0, 0
|
|
|
|
n_arrays = len(arrays)
|
|
|
|
settings = get_settings()
|
|
base = settings.open_meteo_api_url.rstrip("/")
|
|
|
|
params = {
|
|
"latitude": lat,
|
|
"longitude": lon,
|
|
"minutely_15": ",".join(
|
|
[
|
|
"direct_normal_irradiance",
|
|
"diffuse_radiation",
|
|
"shortwave_radiation",
|
|
"temperature_2m",
|
|
]
|
|
),
|
|
"forecast_days": max(2, min(int(settings.open_meteo_forecast_days), 16)),
|
|
"timezone": "auto",
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
|
resp = await client.get(base, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except httpx.TimeoutException:
|
|
logger.warning("fetch_pv_forecast: timeout Open-Meteo")
|
|
return -1, 0
|
|
except httpx.HTTPStatusError as e:
|
|
logger.warning(
|
|
"fetch_pv_forecast: HTTP %s Open-Meteo: %s",
|
|
e.response.status_code,
|
|
e.response.text[:500],
|
|
)
|
|
return -1, 0
|
|
except httpx.HTTPError as e:
|
|
logger.warning("fetch_pv_forecast: HTTP chyba Open-Meteo: %s", e)
|
|
return -1, 0
|
|
|
|
m15 = data.get("minutely_15") or {}
|
|
times_raw = m15.get("time")
|
|
if not times_raw or not isinstance(times_raw, list):
|
|
snippet = json.dumps(data, ensure_ascii=False)[:500]
|
|
logger.error("fetch_pv_forecast: chybí minutely_15.time, začátek: %s", snippet)
|
|
return -1, 0
|
|
|
|
api_tz = data.get("timezone") or tz_name
|
|
try:
|
|
tzinfo = ZoneInfo(api_tz)
|
|
except Exception:
|
|
tzinfo = ZoneInfo(tz_name)
|
|
|
|
times = pd.DatetimeIndex(pd.to_datetime(times_raw))
|
|
if times.tz is None:
|
|
times = times.tz_localize(tzinfo)
|
|
|
|
def _series(key: str) -> pd.Series:
|
|
raw = m15.get(key)
|
|
if not isinstance(raw, list) or len(raw) != len(times):
|
|
return pd.Series(0.0, index=times, dtype=float)
|
|
return pd.Series(
|
|
[0.0 if v is None else float(v) for v in raw],
|
|
index=times,
|
|
dtype=float,
|
|
)
|
|
|
|
dni = _series("direct_normal_irradiance")
|
|
ghi = _series("shortwave_radiation")
|
|
dhi = _series("diffuse_radiation")
|
|
temp_air = _series("temperature_2m")
|
|
|
|
loc = pvlib.location.Location(lat, lon, tz=api_tz)
|
|
solar_pos = loc.get_solarposition(times)
|
|
dni_extra = irradiance.get_extra_radiation(times)
|
|
|
|
total_rows = 0
|
|
horizon_start = times[0].tz_convert(timezone.utc).to_pydatetime()
|
|
horizon_end = (
|
|
times[-1].tz_convert(timezone.utc).to_pydatetime() + timedelta(minutes=15)
|
|
)
|
|
|
|
for arr in arrays:
|
|
tilt = float(arr["tilt_deg"])
|
|
az_db = float(arr["azimuth_deg"])
|
|
az_pvlib = _db_azimuth_to_pvlib(az_db)
|
|
nominal_power_wp = float(arr["nominal_power_wp"])
|
|
shading = float(arr["shading_factor"] or 1.0)
|
|
|
|
poa_global = irradiance.get_total_irradiance(
|
|
surface_tilt=tilt,
|
|
surface_azimuth=az_pvlib,
|
|
solar_zenith=solar_pos["apparent_zenith"],
|
|
solar_azimuth=solar_pos["azimuth"],
|
|
dni=dni,
|
|
ghi=ghi,
|
|
dhi=dhi,
|
|
dni_extra=dni_extra,
|
|
model="haydavies",
|
|
)["poa_global"].fillna(0).clip(lower=0)
|
|
|
|
area_m2 = nominal_power_wp / (1000.0 * 0.20)
|
|
power_w = poa_global * area_m2 * 0.20 * shading
|
|
cap_w = nominal_power_wp * 1.1
|
|
power_w = power_w.clip(lower=0, upper=cap_w).round().astype(int)
|
|
|
|
model_params: dict[str, Any] = {
|
|
"source": "open_meteo",
|
|
"endpoint": base,
|
|
"params": params,
|
|
"pvlib_model": "haydavies",
|
|
"nominal_power_wp": nominal_power_wp,
|
|
"shading_factor": shading,
|
|
"area_m2_ref_20pct": area_m2,
|
|
}
|
|
|
|
run_id = await db.fetchval(
|
|
"""
|
|
INSERT INTO ems.forecast_pv_run (
|
|
site_id,
|
|
pv_array_id,
|
|
forecast_source,
|
|
model_params,
|
|
horizon_start,
|
|
horizon_end,
|
|
status
|
|
)
|
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6, 'ok')
|
|
RETURNING id
|
|
""",
|
|
site_id,
|
|
arr["id"],
|
|
"open_meteo",
|
|
json.dumps(model_params),
|
|
horizon_start,
|
|
horizon_end,
|
|
)
|
|
|
|
records = []
|
|
for ts, p, g, t in zip(
|
|
times,
|
|
power_w,
|
|
ghi,
|
|
temp_air,
|
|
strict=True,
|
|
):
|
|
interval_start = ts.tz_convert(timezone.utc).to_pydatetime()
|
|
records.append(
|
|
(
|
|
run_id,
|
|
arr["id"],
|
|
interval_start,
|
|
int(p),
|
|
float(g),
|
|
float(t),
|
|
)
|
|
)
|
|
|
|
await db.executemany(
|
|
"""
|
|
INSERT INTO ems.forecast_pv_interval (
|
|
run_id,
|
|
pv_array_id,
|
|
interval_start,
|
|
power_w,
|
|
irradiance_wm2,
|
|
temp_c
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
""",
|
|
records,
|
|
)
|
|
total_rows += len(records)
|
|
|
|
return total_rows, n_arrays
|