Files
ashvale-station/ashvale/config.py
T
kemalandClaude Opus 5 f873b0b09d Stride training pairs by the horizon instead of by the grid row
fit() trained every head on every consecutive grid row. At the 1 d horizon on
a 5-minute grid adjacent pairs share 287 of their 288 samples, so the filter
was handed the same outcome 288 times and RLS with forgetting read each one as
fresh evidence:

   horizon  steps  overlap  independent events in a 400-score window
       15m      3   66.7%    133.3
        1h     12   91.7%     33.3
        3h     36   97.2%     11.1
        6h     72   98.6%      5.6
       12h    144   99.3%      2.8
        1d    288   99.7%      1.4

The day-ahead head was therefore fitted on roughly two independent outcomes by
a filter carrying 667 updates of memory, and its interval was a 90th percentile
of a sample of size one.

This is not a compute shortcut that trades accuracy for speed. Measured
walk-forward on four days of real station data and averaged over five train
splits, striding improves every horizon past fifteen minutes:

   15m  +0.6%   1h -12.2%   3h -31.7%   6h -33.3%   12h -39.5%   1d -14.4%

with coverage unchanged at 87 to 92%, and the fit 11.6x faster. The redundancy
was not merely wasted work, it was collapsing P onto the one direction the
repeated sample excited.

The stride phase rotates each refit and is persisted, so a long-lived station
eventually trains on every offset rather than seeing one sample in 288 forever,
and a restart does not pin it to phase 0. A floor relaxes the stride when a
long horizon on a short record would otherwise yield one or two pairs; 12 was
chosen by sweeping it across five splits rather than picked.

Single-split runs showed 10 to 17% regressions at the 1 d horizon that moved
with the parameter. Averaging over five splits removed them, which is the
expected result for a head fitted and scored on under two independent
outcomes. That horizon cannot be evaluated on a four-day record and was not
tuned against.

Incidentally, this also retires the parallel-retrain idea: the Pi's 42 s
retrain becomes a few seconds, and multiprocessing inside a 280 MB cap buys
nothing for a job that short.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-19 18:50:07 +01:00

255 lines
10 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 json
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
# Where the sensor actually lives, and what has changed around it.
#
# This matters more than it looks. Indoors, temperature and humidity are
# governed by the building, not the sky: the diurnal swing is damped and
# lagged, and the solar features the model is given correlate weakly with
# what the thermometer does. Pressure is the exception, which is why the
# precipitation model runs on tendency rather than indoor humidity.
#
# "enclosure" is the part worth changing at runtime. Closing a door or
# opening a window is a step change in how strongly the sensor is coupled to
# outside, and the learners carry roughly 55 hours of memory, so they will
# keep predicting the old regime for two days unless told. POST
# /api/environment marks the moment and asks for a retrain.
environment: str = "indoor" # indoor | sheltered | outdoor
enclosure: str = "closed" # closed | ventilated | open
# Central heating or air conditioning holding the room at a setpoint.
#
# This is a genuine change of process, not a label. A free-running room
# follows outdoor forcing and drifts; a thermostatted one is a closed loop
# that pulls back toward heating_setpoint_c whenever it strays. Persistence
# ("tomorrow equals today") is the wrong baseline for a controlled system,
# because the truth is "it returns to the setpoint".
#
# thermal_time_constant_h is how fast that pull acts: the time to close
# about 63% of a gap. A small well-insulated flat with responsive heating is
# under an hour; a large draughty house with slow radiators is several. If
# you do not know it, leave it: the ensemble weights this member against the
# others from measured error, so a wrong constant costs accuracy, not
# correctness.
heating: bool = False
heating_setpoint_c: float = 21.0
thermal_time_constant_h: float = 1.5 # 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
min_pairs_per_head: int = 12 # floor before the stride relaxes
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
# Matrix frame rate. 24 is smooth and costs about 11% of one core on a
# Zero 2 W. 16 is still fluid and roughly a third cheaper; below about 12
# the crossfades and sub-pixel motion start to judder, which defeats the
# point. Set 0 to keep the panel enabled but static-cheap.
led_fps: float = 24.0
@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)
# Settings changed from the dashboard land here, not in config.yaml. That file
# is hand-annotated and hand-edited per station, and rewriting it from an API
# would destroy the comments and risk clobbering something the owner set. A
# separate overlay keeps both: the file stays yours, the UI stays useful, and
# either can be reverted independently by deleting the other.
OVERRIDES_NAME = "settings.json"
def overrides_path(cfg: "Config") -> Path:
return Path(cfg.storage.state_dir) / OVERRIDES_NAME
def load_overrides(cfg: "Config") -> Dict[str, Any]:
path = overrides_path(cfg)
if not path.exists():
return {}
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh) or {}
except (OSError, ValueError):
return {}
def save_overrides(cfg: "Config", patch: Dict[str, Any]) -> Dict[str, Any]:
"""Merge a patch into the overlay and write it back."""
current = load_overrides(cfg)
for section, values in patch.items():
if not isinstance(values, dict):
continue
current.setdefault(section, {}).update(values)
path = overrides_path(cfg)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(current, fh, indent=2, sort_keys=True)
tmp.replace(path) # atomic, so a crash cannot truncate it
return current
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)
# Applied last: a change made from the dashboard is the most recent explicit
# instruction from a human, so it wins over both the file and the
# environment. Delete data/state/settings.json to fall back.
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
_apply(cfg, load_overrides(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()