mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
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.
255 lines
10 KiB
Python
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()
|