Weather-Crop-Damage/src/damage_engine/sources/derived.py
2026-10-27 14:13:32 +01:00

109 lines
5.0 KiB
Python

"""
Quantities CALCULATED from 3A sensor data, one implementation for the whole
pipeline. Every block imports them from here and never keeps its own formula,
so a value (e.g. the wet-bulb temperature of an hour) is the same in every
block and in every report.
Reference [3A-DOC]: 3A, "Algoritmi di stima di alcune grandezze
agrometeorologiche" (docs/ImplementazioneAlgoritmiSensori.pdf), section 4
"Temperatura bulbo umido (Tw)". It is the formula 3A uses for its own TBulbMed:
on (hourly mean temperature, mean humidity) it gives 3A's TBulbMed in 49 of 49
hours at station 566 (to 3A's 0.1 °C rounding, checked 2026-10-21).
Wet-bulb of one hour, from the 3A hourly columns (add_wet_bulb):
WetBulbMin = Tw(TemperaturaAriaMin, UmiditaRelativaMax) lowest of the hour
WetBulbMax = Tw(TemperaturaAriaMax, UmiditaRelativaMin) highest of the hour
Why these pairs: within an hour the water vapour in the air changes little, so
the coldest moment is also the most humid one (and the warmest the driest).
The same pairing (Tmin with RHmax, Tmax with RHmin) is used by the 3A document
for the vapour pressure, section 3 eq. (2) (FAO-56). Checked on 566: the vapour
pressure of (Tmin, RHmax), (Tmean, RHmean) and (Tmax, RHmin) differs by < 1 hPa.
Missing values: no hourly minimum / maximum temperature -> hourly mean
temperature; no hourly maximum / minimum humidity -> hourly mean humidity; no
temperature or no humidity at all -> NaN (the block decides what to do).
from damage_engine.sources import derived
df = fetch_hourly(ids, start, end, [*derived.WET_BULB_INPUTS, ...])
df = derived.add_wet_bulb(df) # adds WetBulbMin, WetBulbMax (°C)
"""
import numpy as np
import pandas as pd
K0 = 273.15 # 0 °C in K
# constants of the 3A document, section 4 [3A-DOC]
RV = 0.11 # cal/(Kg K) gas constant of moist air
CP = 0.24 # cal/(Kg K) specific heat at constant pressure
L_EVAPORATION = 597.0 # cal/Kg latent heat, T > 0 °C (water)
L_SUBLIMATION = 677.0 # cal/Kg latent heat, T <= 0 °C (ice)
PRESSURE_HPA = 1000.0 # mbar atmospheric pressure ("generalmente si può porre 1000 mbar")
# saturation vapour pressure e*(T) = 6.1 * 10^(a*T / (T + b)), T in °C
MAGNUS_WATER = (7.5, 237.3) # T > 0 °C
MAGNUS_ICE = (9.5, 265.5) # T <= 0 °C
# 3A hourly columns (api_parameters) used, and the columns added by add_wet_bulb
T_MIN, T_MEAN, T_MAX = "TemperaturaAriaMin", "TemperaturaAriaMedia", "TemperaturaAriaMax"
RH_MIN, RH_MEAN, RH_MAX = "UmiditaRelativaMin", "UmiditaRelativaMedia", "UmiditaRelativaMax"
WET_BULB_INPUTS = [T_MIN, T_MEAN, T_MAX, RH_MIN, RH_MEAN, RH_MAX]
WET_BULB_MIN = "WetBulbMin" # °C, lowest wet-bulb of the hour
WET_BULB_MAX = "WetBulbMax" # °C, highest wet-bulb of the hour
def _branch(t_c):
"""(a, b, L) per value: ice constants for T <= 0 °C, water constants above."""
ice = t_c <= 0
a = np.where(ice, MAGNUS_ICE[0], MAGNUS_WATER[0])
b = np.where(ice, MAGNUS_ICE[1], MAGNUS_WATER[1])
latent = np.where(ice, L_SUBLIMATION, L_EVAPORATION)
return a, b, latent
def vapour_pressure_hpa(temp_c, rh_pct):
"""Vapour pressure e = RH * e*(T) / 100 (hPa) [3A-DOC sec. 4]."""
t = np.asarray(temp_c, dtype=float)
rh = np.asarray(rh_pct, dtype=float)
a, b, _ = _branch(t)
return rh / 100.0 * 6.1 * 10.0 ** (a * t / (t + b))
def dew_point_c(temp_c, rh_pct):
"""Dew point (frost point for T <= 0 °C), °C [3A-DOC sec. 4 eq. 2-3, sec. 5]."""
t = np.asarray(temp_c, dtype=float)
a, b, _ = _branch(t)
with np.errstate(divide="ignore", invalid="ignore"):
x = np.log10(vapour_pressure_hpa(t, rh_pct) / 6.1)
return b * x / (a - x)
def wet_bulb_c(temp_c, rh_pct, pressure_hpa=PRESSURE_HPA):
"""Wet-bulb temperature (°C) [3A-DOC sec. 4 eq. 1]:
Tw = Td + (T - Td) / (1 + L² s / (Rv Cp T²)) - 273.15 (T, Td in K)
s = 0.622 e / (P - 0.378 e) (specific humidity)
Water constants for T > 0 °C, ice constants for T <= 0 °C. RH <= 0 gives NaN.
"""
t = np.asarray(temp_c, dtype=float)
_, _, latent = _branch(t)
e = vapour_pressure_hpa(t, rh_pct)
with np.errstate(divide="ignore", invalid="ignore"):
s = 0.622 * e / (pressure_hpa - 0.378 * e)
t_k, td_k = t + K0, dew_point_c(t, rh_pct) + K0
return td_k + (t_k - td_k) / (1.0 + latent ** 2 * s / (RV * CP * t_k ** 2)) - K0
def _col(df, name):
return pd.to_numeric(df[name], errors="coerce") if name in df else pd.Series(np.nan, index=df.index)
def add_wet_bulb(df: pd.DataFrame) -> pd.DataFrame:
"""Copy of the hourly rows with WetBulbMin and WetBulbMax (°C) added (see module doc)."""
df = df.copy()
t_mean, rh_mean = _col(df, T_MEAN), _col(df, RH_MEAN)
t_lo, rh_hi = _col(df, T_MIN).fillna(t_mean), _col(df, RH_MAX).fillna(rh_mean)
t_hi, rh_lo = _col(df, T_MAX).fillna(t_mean), _col(df, RH_MIN).fillna(rh_mean)
df[WET_BULB_MIN] = np.round(wet_bulb_c(t_lo, rh_hi), 2)
df[WET_BULB_MAX] = np.round(wet_bulb_c(t_hi, rh_lo), 2)
return df