mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Initial release: Ashvale Station 1.0.0
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user