mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Seven day outlook moves from History to Live, which now runs four rows. Conditions ahead tightened so the Live column no longer needs a scroller. Adds HumidityCompensator: an additive RH offset estimated by one-step RLS from a trusted hygrometer, clamped to +/-35%, persisted, exposed at POST /api/calibrate/humidity and on the renamed Models and calibration tab. It also implements the psychrometric term (RH moved from element temperature onto air temperature via conserved vapour pressure) but leaves it OFF by default. The thermal argument predicts a hot element reads low; measured against a reference hygrometer this board read 75.4% where the truth was 50.4%, so it reads HIGH and that correction would push it the wrong way. When the flag is enabled, simulate.py applies the exact inverse, per the simulator/compensator trap in DESIGN.md section 2. Models pane rebuilt: the scorecard is one column per target so all 18 heads are visible, and no panel on the tab uses an internal scroller. Verified in Chromium at 1600x900: Live, History and Models all report zero scrollbars, zero clipping, no page scroll, zero console errors. Backtest is numerically identical to the previous commit, confirming the humidity work is a no-op while the flag is off.
166 lines
6.1 KiB
Python
166 lines
6.1 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.
|
|
|
|
"""Configuration for the Ashvale station.
|
|
|
|
Everything tunable lives here. Override any field with a YAML file
|
|
(default `config.yaml` next to the repo root) or with environment
|
|
variables prefixed `ASHVALE_` (e.g. `ASHVALE_SITE__ALTITUDE_M=42`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass, field, fields, is_dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
try:
|
|
import yaml # optional
|
|
except Exception: # pragma: no cover
|
|
yaml = None
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
@dataclass
|
|
class SiteConfig:
|
|
name: str = "ashvale-labs-weather-station"
|
|
latitude: float = 52.2053 # Cambridge, UK
|
|
longitude: float = 0.1218
|
|
altitude_m: float = 15.0 # for sea-level pressure reduction
|
|
timezone: str = "Europe/London"
|
|
indoors: bool = True # honest flag, changes how forecasts are worded
|
|
|
|
|
|
@dataclass
|
|
class SensorConfig:
|
|
sample_period_s: float = 2.0 # how often we read the HAT
|
|
persist_period_s: float = 30.0 # how often a row hits the database
|
|
rotation_deg: int = 90
|
|
low_light: bool = True
|
|
tcs3400_addr: int = 0x39
|
|
# CPU self-heating compensation: T_true = T_sensor - k * (T_cpu - T_sensor)
|
|
cpu_heat_k: float = 0.55
|
|
cpu_heat_k_min: float = 0.15
|
|
cpu_heat_k_max: float = 1.20
|
|
# Additive RH bias of the element. The datasheet claims about +/-3.5%, but
|
|
# measured against a reference hygrometer this board read 75.4% where the
|
|
# truth was 50.4%, so the clamp has to allow far more than spec. Kept finite
|
|
# so one mistyped reference still cannot run away.
|
|
# Move RH from the element's temperature onto the compensated air temperature
|
|
# via conserved vapour pressure. Physically correct IF the humidity element
|
|
# really sits at temp_raw. Measured on this board it does not: against a
|
|
# reference hygrometer reading 50.4%, the HTS221 reported 75.4%, so it reads
|
|
# HIGH and this correction would push it higher still. The error is an
|
|
# additive element bias, not a thermal gradient. Leave off unless your own
|
|
# reference says otherwise.
|
|
hum_psychrometric: bool = False
|
|
hum_offset: float = 0.0
|
|
hum_offset_min: float = -35.0
|
|
hum_offset_max: float = 35.0
|
|
# Kalman process/measurement noise (per-signal)
|
|
kalman_q_temp: float = 2.0e-6
|
|
kalman_r_temp: float = 0.02
|
|
kalman_q_press: float = 1.0e-5
|
|
kalman_r_press: float = 0.05
|
|
kalman_q_hum: float = 5.0e-5
|
|
kalman_r_hum: float = 0.60
|
|
|
|
|
|
@dataclass
|
|
class ModelConfig:
|
|
grid_s: int = 300 # 5-minute feature grid
|
|
horizons_s: tuple = (900, 3600, 10800, 21600, 43200, 86400)
|
|
targets: tuple = ("temperature", "humidity", "pressure")
|
|
rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min
|
|
rls_delta: float = 100.0 # P0 = delta * I
|
|
conformal_window: int = 400 # residuals kept per head
|
|
conformal_alpha: float = 0.10 # 90% intervals
|
|
conformal_gamma: float = 0.01 # adaptive conformal step
|
|
train_period_s: float = 600.0 # retrain cadence
|
|
min_rows_to_train: int = 120
|
|
climatology_min_days_annual: float = 120.0
|
|
anomaly_ewma_lambda: float = 0.15
|
|
anomaly_threshold: float = 12.0 # Mahalanobis^2 alarm level
|
|
drift_delta: float = 0.05
|
|
drift_lambda: float = 8.0
|
|
|
|
|
|
@dataclass
|
|
class StorageConfig:
|
|
db_path: str = str(REPO_ROOT / "data" / "ashvale.db")
|
|
state_dir: str = str(REPO_ROOT / "data" / "state")
|
|
raw_retention_days: float = 7.0
|
|
five_min_retention_days: float = 90.0
|
|
vacuum_period_s: float = 86400.0
|
|
|
|
|
|
@dataclass
|
|
class ServerConfig:
|
|
host: str = "0.0.0.0"
|
|
port: int = 8000
|
|
led_enabled: bool = True
|
|
led_cycle_s: float = 0.4
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
site: SiteConfig = field(default_factory=SiteConfig)
|
|
sensor: SensorConfig = field(default_factory=SensorConfig)
|
|
model: ModelConfig = field(default_factory=ModelConfig)
|
|
storage: StorageConfig = field(default_factory=StorageConfig)
|
|
server: ServerConfig = field(default_factory=ServerConfig)
|
|
|
|
|
|
def _apply(obj: Any, patch: Dict[str, Any]) -> None:
|
|
for key, value in (patch or {}).items():
|
|
if not hasattr(obj, key):
|
|
continue
|
|
current = getattr(obj, key)
|
|
if is_dataclass(current) and isinstance(value, dict):
|
|
_apply(current, value)
|
|
else:
|
|
setattr(obj, key, type(current)(value) if current is not None else value)
|
|
|
|
|
|
def _apply_env(obj: Any, prefix: str = "ASHVALE_") -> None:
|
|
for f in fields(obj):
|
|
current = getattr(obj, f.name)
|
|
if is_dataclass(current):
|
|
_apply_env(current, f"{prefix}{f.name.upper()}__")
|
|
continue
|
|
env_key = f"{prefix}{f.name.upper()}"
|
|
if env_key in os.environ:
|
|
raw = os.environ[env_key]
|
|
try:
|
|
setattr(obj, f.name, type(current)(raw))
|
|
except Exception:
|
|
setattr(obj, f.name, raw)
|
|
|
|
|
|
def load_config(path: str | os.PathLike | None = None) -> Config:
|
|
cfg = Config()
|
|
candidate = Path(path) if path else REPO_ROOT / "config.yaml"
|
|
if candidate.exists() and yaml is not None:
|
|
with open(candidate, "r", encoding="utf-8") as fh:
|
|
_apply(cfg, yaml.safe_load(fh) or {})
|
|
_apply_env(cfg)
|
|
Path(cfg.storage.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
|
|
return cfg
|
|
|
|
|
|
CONFIG = load_config()
|