Files
ashvale-station/ashvale/config.py
T
kemal 98210bff8f Six enhancements: recompute, markers, vendoring, tests, nerd stats, DS18B20
1. POST /api/recompute re-derives every compensated column from the untouched
   raw values, removing the step a calibration otherwise leaves through the
   history. Possible because temp_raw, cpu_temp and hum are never overwritten.
   Idempotent by construction and tested per row: 0 of 6051 rows change on a
   second run. 6069 rows in 0.25 s here, so a few seconds on the Pi.

2. Calibration now emits a 'discontinuity' event alongside the calibration log,
   so downstream views can find the boundary without parsing prose.

3. Vendored Tailwind, Chart.js, hammer, the zoom plugin, KaTeX with its 20
   woff2 faces, and both Google fonts into ashvale/static, served by the
   station. 1.4 MB. Verified with every non-localhost request aborted in the
   browser: zero external requests, equations still render, fonts still load.
   The dashboard no longer needs internet.

4. 54 pytest cases over the pure numerics: physics closed forms and round
   trips, both compensator inverse properties, the Kalman covariance
   invariants and NIS consistency, the RLS trace cap under a deliberately
   unexcited regressor, conformal coverage, and the Zambretti ordering. Wired
   into CI after the seed step so the recompute cases have history. Writing
   them caught my own sign error on the conformal update: a hit raises alpha
   and narrows the band, which reads backwards until you follow it through.

5. Stats for Nerds gains the condition number of each head's covariance, a
   standardised innovation histogram per Kalman filter from a bounded 600
   sample ring buffer, and a reliability strip of realised against nominal
   coverage. All arithmetic on data already in memory.

6. OutdoorProbe reads a DS18B20 over the kernel 1-Wire driver, no new
   dependency. Polled on its own slower cadence because the sensor blocks for
   up to 750 ms during conversion, which would eat a third of the 2 s sample
   budget. Rejects the 85000 power-on sentinel and out-of-range values, and
   reports age so a dead probe cannot masquerade as fresh.
2026-08-15 22:39:37 +01:00

171 lines
6.3 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.
# Optional DS18B20 on the 1-Wire bus, outside the window. When present its
# reading is logged as outdoor_c and surfaced in the API. It does not feed
# the forecasting features yet: that needs history to train against.
outdoor_probe: bool = True
outdoor_probe_period_s: float = 20.0
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()