Files

163 lines
6.2 KiB
Python

# Copyright 2026 Kemal Yaylali
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Physics that the model does not have to learn.
Every function here is a closed-form relationship that would otherwise
have to be discovered from data. Feeding a learner `dew point` instead of
making it infer the Magnus curve from (T, RH) is the cheapest accuracy
you will ever buy, especially on 512 MB of RAM.
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
import numpy as np
MAGNUS_A = 17.625
MAGNUS_B = 243.04 # degrees C
P_STD = 1013.25 # hPa
def saturation_vapour_pressure(temp_c):
"""Tetens / Magnus saturation vapour pressure in hPa."""
t = np.asarray(temp_c, dtype=float)
return 6.112 * np.exp(MAGNUS_A * t / (MAGNUS_B + t))
def vapour_pressure(temp_c, rh_pct):
return saturation_vapour_pressure(temp_c) * np.clip(np.asarray(rh_pct, float), 0.0, 100.0) / 100.0
def vapour_pressure_deficit(temp_c, rh_pct):
"""VPD in hPa. Bioprocess people know this one from headspace humidity control."""
return saturation_vapour_pressure(temp_c) - vapour_pressure(temp_c, rh_pct)
def dew_point(temp_c, rh_pct):
"""Magnus-Tetens dew point in degrees C."""
t = np.asarray(temp_c, dtype=float)
rh = np.clip(np.asarray(rh_pct, dtype=float), 1e-3, 100.0)
gamma = (MAGNUS_A * t) / (MAGNUS_B + t) + np.log(rh / 100.0)
return (MAGNUS_B * gamma) / (MAGNUS_A - gamma)
def absolute_humidity(temp_c, rh_pct):
"""Water content in g/m^3 via the ideal gas law."""
e = vapour_pressure(temp_c, rh_pct) * 100.0 # Pa
t_k = np.asarray(temp_c, dtype=float) + 273.15
return e / (461.5 * t_k) * 1000.0
def heat_index(temp_c, rh_pct):
"""Rothfusz apparent temperature, valid above roughly 26 C."""
t = np.asarray(temp_c, dtype=float) * 9.0 / 5.0 + 32.0
r = np.asarray(rh_pct, dtype=float)
hi = (-42.379 + 2.04901523 * t + 10.14333127 * r - 0.22475541 * t * r
- 6.83783e-3 * t ** 2 - 5.481717e-2 * r ** 2 + 1.22874e-3 * t ** 2 * r
+ 8.5282e-4 * t * r ** 2 - 1.99e-6 * t ** 2 * r ** 2)
hi = np.where(t < 80.0, t, hi)
return (hi - 32.0) * 5.0 / 9.0
def sea_level_pressure(press_hpa, temp_c, altitude_m):
"""Reduce station pressure to mean sea level (barometric formula).
Without this, a 15 m elevation offset masquerades as a permanent
low-pressure system and every rule-of-thumb forecaster gets it wrong.
"""
p = np.asarray(press_hpa, dtype=float)
t = np.asarray(temp_c, dtype=float)
h = float(altitude_m)
return p * (1.0 - (0.0065 * h) / (t + 0.0065 * h + 273.15)) ** -5.257
def station_pressure(slp_hpa, temp_c, altitude_m):
p = np.asarray(slp_hpa, dtype=float)
t = np.asarray(temp_c, dtype=float)
h = float(altitude_m)
return p * (1.0 - (0.0065 * h) / (t + 0.0065 * h + 273.15)) ** 5.257
# ---------------------------------------------------------------- solar
def _day_of_year(ts: float) -> float:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
return dt.timetuple().tm_yday + dt.hour / 24.0 + dt.minute / 1440.0
def solar_position(ts, latitude: float, longitude: float):
"""Return (elevation_deg, azimuth_deg) using the NOAA low-precision model.
Accurate to a few tenths of a degree, which is far beyond what a
diurnal-cycle feature needs, and costs about twenty flops.
"""
ts_arr = np.atleast_1d(np.asarray(ts, dtype=float))
doy = np.array([_day_of_year(float(t)) for t in ts_arr])
frac_hour = np.array([
datetime.fromtimestamp(float(t), tz=timezone.utc).hour
+ datetime.fromtimestamp(float(t), tz=timezone.utc).minute / 60.0
+ datetime.fromtimestamp(float(t), tz=timezone.utc).second / 3600.0
for t in ts_arr
])
gamma = 2.0 * math.pi / 365.0 * (doy - 1.0)
eqtime = 229.18 * (0.000075 + 0.001868 * np.cos(gamma) - 0.032077 * np.sin(gamma)
- 0.014615 * np.cos(2 * gamma) - 0.040849 * np.sin(2 * gamma))
decl = (0.006918 - 0.399912 * np.cos(gamma) + 0.070257 * np.sin(gamma)
- 0.006758 * np.cos(2 * gamma) + 0.000907 * np.sin(2 * gamma)
- 0.002697 * np.cos(3 * gamma) + 0.00148 * np.sin(3 * gamma))
true_solar_min = frac_hour * 60.0 + eqtime + 4.0 * longitude
hour_angle = np.radians(true_solar_min / 4.0 - 180.0)
lat = math.radians(latitude)
cos_zenith = (np.sin(lat) * np.sin(decl)
+ np.cos(lat) * np.cos(decl) * np.cos(hour_angle))
cos_zenith = np.clip(cos_zenith, -1.0, 1.0)
elevation = np.degrees(np.arcsin(cos_zenith))
azimuth = np.degrees(np.arctan2(
-np.sin(hour_angle),
np.tan(decl) * np.cos(lat) - np.sin(lat) * np.cos(hour_angle)
)) % 360.0
if np.isscalar(ts) or np.asarray(ts).ndim == 0:
return float(elevation[0]), float(azimuth[0])
return elevation, azimuth
def clear_sky_irradiance(elevation_deg):
"""Rough clear-sky global horizontal irradiance, W/m^2.
Used as the denominator of a `cloudiness proxy` when the TCS3400 sees
daylight: measured_lux / expected_lux is a surprisingly decent
okta estimate through a south-facing window.
"""
el = np.clip(np.asarray(elevation_deg, dtype=float), 0.0, 90.0)
sin_el = np.sin(np.radians(el))
air_mass = np.where(el > 0.5, 1.0 / np.maximum(sin_el, 1e-3), 40.0)
return np.where(el > 0.0, 1353.0 * 0.7 ** (air_mass ** 0.678) * sin_el, 0.0)
def wet_bulb(temp_c, rh_pct):
"""Stull's empirical wet-bulb approximation, degrees C."""
t = np.asarray(temp_c, dtype=float)
rh = np.clip(np.asarray(rh_pct, dtype=float), 5.0, 99.0)
return (t * np.arctan(0.151977 * np.sqrt(rh + 8.313659))
+ np.arctan(t + rh) - np.arctan(rh - 1.676331)
+ 0.00391838 * rh ** 1.5 * np.arctan(0.023101 * rh) - 4.686035)