mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 20:52:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3193cc151 | ||
|
|
8515f4fb03 | ||
|
|
d5b3cdad7c | ||
|
|
2b2a3b7741 | ||
|
|
0672ee80ec | ||
|
|
2e6d84f17d | ||
|
|
6501652e83 | ||
|
|
f48cd61c10 | ||
|
|
f873b0b09d | ||
|
|
154071a791 | ||
|
|
485affe956 | ||
|
|
bd233d7b27 | ||
|
|
4eefd8816b | ||
|
|
34f62222e1 | ||
|
|
372ea4f067 | ||
|
|
ca1d6ba528 | ||
|
|
aea740e85b | ||
|
|
5fe309a8a6 | ||
|
|
4cca40388f | ||
|
|
498b3f6e38 | ||
|
|
50b29f8077 | ||
|
|
23cd76c96e | ||
|
|
3c99cd53e3 | ||
|
|
db0f877052 | ||
|
|
e05667d75b | ||
|
|
30944c2978 | ||
|
|
e767ca2105 | ||
|
|
98210bff8f | ||
|
|
9d2e884b74 | ||
|
|
bb9f0a588f | ||
|
|
e27a4b41c8 | ||
|
|
49c0aee2e1 | ||
|
|
dbbea1a95f |
@@ -11,7 +11,7 @@ labels: forecasting
|
|||||||
```
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
**Scorecard from the Models tab** (or `GET /api/scorecard`)
|
**Scorecard from the Models and Calibration tab** (or `GET /api/scorecard`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install httpx
|
pip install httpx pytest
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
pip install ruff
|
||||||
|
ruff check .
|
||||||
|
|
||||||
- name: Byte-compile every module
|
- name: Byte-compile every module
|
||||||
run: python -m compileall -q ashvale scripts run.py
|
run: python -m compileall -q ashvale scripts run.py
|
||||||
@@ -38,6 +43,13 @@ jobs:
|
|||||||
- name: Seed synthetic history
|
- name: Seed synthetic history
|
||||||
run: python scripts/simulate.py --days 10 --wipe
|
run: python scripts/simulate.py --days 10 --wipe
|
||||||
|
|
||||||
|
# Unit tests over the pure numerics: physics closed forms, the compensator
|
||||||
|
# inverse properties, the Kalman covariance invariants and the RLS trace
|
||||||
|
# cap. Run after seeding so the recompute tests have history rather than skipping.
|
||||||
|
- name: Unit tests
|
||||||
|
run: python -m pytest tests/ -q
|
||||||
|
|
||||||
|
|
||||||
# The backtest is the real test: it exercises features, the RLS heads,
|
# The backtest is the real test: it exercises features, the RLS heads,
|
||||||
# climatology and conformal calibration end to end, and fails loudly if
|
# climatology and conformal calibration end to end, and fails loudly if
|
||||||
# any of them stop producing finite numbers.
|
# any of them stop producing finite numbers.
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: Pi image
|
||||||
|
|
||||||
|
# Builds a ready-to-flash Raspberry Pi OS Lite image with Ashvale Station
|
||||||
|
# preinstalled, and attaches it to a GitHub Release.
|
||||||
|
#
|
||||||
|
# Built in CI rather than on a laptop on purpose. The artifact is something
|
||||||
|
# other people flash onto their own hardware, so it should be reproducible from
|
||||||
|
# a public log by anyone who wants to check what went into it, rather than
|
||||||
|
# appearing from a machine only I can see.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish:
|
||||||
|
description: "Attach the image to a release"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out Ashvale
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: ashvale
|
||||||
|
|
||||||
|
# pi-gen needs about 10 GB and a stock runner does not have it spare.
|
||||||
|
- name: Reclaim disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||||
|
/usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
|
||||||
|
df -h / | tail -1
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
# Taken from pi-gen's own `depends` file at the pinned commit rather
|
||||||
|
# than guessed. My first attempt installed qemu-user-static, and
|
||||||
|
# pi-gen wants qemu-user-binfmt: it checks for the binfmt handler, not
|
||||||
|
# the emulator binary, and failed in under a second.
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
quilt parted coreutils qemu-user-binfmt debootstrap zerofree zip \
|
||||||
|
dosfstools e2fsprogs libcap2-bin libarchive-tools grep rsync \
|
||||||
|
xz-utils curl xxd file git kmod bc gpg pigz arch-test
|
||||||
|
# qemu-user-static too: qemu-user-binfmt registers a dynamically linked
|
||||||
|
# emulator, which cannot run once debootstrap chroots into a rootfs
|
||||||
|
# that has no loader for it. That fails as "E: Unable to execute
|
||||||
|
# target architecture" several steps later.
|
||||||
|
sudo apt-get install -y --no-install-recommends qemu-user-static binfmt-support
|
||||||
|
|
||||||
|
# Registers the static handlers with the F flag, which preloads the
|
||||||
|
# emulator so it survives the chroot. Run after apt, because installing
|
||||||
|
# binfmt-support re-registers the dynamic handlers over the top.
|
||||||
|
- name: Register qemu binfmt handlers
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
with:
|
||||||
|
platforms: arm64
|
||||||
|
|
||||||
|
- name: Verify the aarch64 handler is usable in a chroot
|
||||||
|
run: |
|
||||||
|
F=/proc/sys/fs/binfmt_misc/qemu-aarch64
|
||||||
|
test -f "$F" || { echo "no aarch64 binfmt handler registered"; exit 1; }
|
||||||
|
cat "$F"
|
||||||
|
# The F flag is the whole point: without it the interpreter is resolved
|
||||||
|
# inside the chroot, where it does not exist.
|
||||||
|
grep -q 'flags:.*F' "$F" || { echo "handler lacks the F flag; chroot will fail"; exit 1; }
|
||||||
|
echo "aarch64 handler OK"
|
||||||
|
|
||||||
|
|
||||||
|
# Pinned to a commit, not a branch. An image other people flash should not
|
||||||
|
# change because an upstream branch moved between builds.
|
||||||
|
- name: Check out pi-gen
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: RPi-Distro/pi-gen
|
||||||
|
ref: ca8aeed0ae300c2a89f55ce9617d5f96a27e99e5 # arm64 branch, pinned
|
||||||
|
path: pi-gen
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Assemble the custom stage
|
||||||
|
run: |
|
||||||
|
cp ashvale/deploy/pi-image/config pi-gen/config
|
||||||
|
cp -r ashvale/deploy/pi-image/stage-ashvale pi-gen/stage-ashvale
|
||||||
|
# Lite only: everything from stage3 up is the desktop.
|
||||||
|
touch pi-gen/stage3/SKIP pi-gen/stage4/SKIP pi-gen/stage5/SKIP
|
||||||
|
touch pi-gen/stage4/SKIP_IMAGES pi-gen/stage5/SKIP_IMAGES
|
||||||
|
# stage2 stops exporting so ours is the only image produced.
|
||||||
|
rm -f pi-gen/stage2/EXPORT_IMAGE
|
||||||
|
echo "ASHVALE_SRC=${GITHUB_WORKSPACE}/ashvale" >> pi-gen/config
|
||||||
|
echo "--- config ---" && cat pi-gen/config
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: pi-gen
|
||||||
|
run: sudo -E ./build.sh
|
||||||
|
|
||||||
|
- name: Collect artifact
|
||||||
|
id: artifact
|
||||||
|
run: |
|
||||||
|
IMG=$(find pi-gen/deploy -name '*.img.xz' | head -1)
|
||||||
|
test -n "$IMG" || { echo "no image produced"; ls -R pi-gen/deploy; exit 1; }
|
||||||
|
mkdir -p out && mv "$IMG" out/
|
||||||
|
cd out
|
||||||
|
NAME=$(basename *.img.xz)
|
||||||
|
sha256sum "$NAME" > "$NAME.sha256"
|
||||||
|
echo "name=$NAME" >> "$GITHUB_OUTPUT"
|
||||||
|
ls -lh
|
||||||
|
# A release asset is capped at 2 GB; Lite compresses to well under that,
|
||||||
|
# but fail loudly here rather than at upload time.
|
||||||
|
SIZE=$(stat -c%s "$NAME")
|
||||||
|
echo "compressed size: $((SIZE/1024/1024)) MiB"
|
||||||
|
test "$SIZE" -lt 2000000000 || { echo "image exceeds the 2 GB release limit"; exit 1; }
|
||||||
|
|
||||||
|
- name: Upload as a workflow artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ashvale-pi-image
|
||||||
|
path: out/*
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Attach to release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/') || inputs.publish
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 \
|
||||||
|
|| gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||||
|
--title "$TAG" --notes "Ashvale Station image for Raspberry Pi."
|
||||||
|
gh release upload "$TAG" out/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
+29
-10
@@ -1,7 +1,21 @@
|
|||||||
# Contributing to Ashvale Station
|
# Contributing to Ashvale Station
|
||||||
|
|
||||||
Thanks for taking an interest. This is a small project maintained by one person,
|
Read this first, so nobody wastes an afternoon.
|
||||||
so the bar here is "make it easy to say yes", not "follow a 40-page process".
|
|
||||||
|
**This is a solo project.** It is written and maintained by one person, for one
|
||||||
|
weather station, and it is published because the methods may be useful to
|
||||||
|
someone else, not because it is looking for a team.
|
||||||
|
|
||||||
|
**Bug reports are genuinely welcome.** If something crashes, forecasts badly, or
|
||||||
|
the documentation is wrong, open an issue. That is useful and I will read it.
|
||||||
|
|
||||||
|
**Pull requests are unlikely to be merged.** Not from lack of gratitude: this
|
||||||
|
codebase carries a lot of hard-won reasoning in its comments and docstrings, and
|
||||||
|
reviewing changes to it properly costs more time than I have. If you want it to
|
||||||
|
do something different, fork it. Apache 2.0 exists precisely so you can.
|
||||||
|
|
||||||
|
The rest of this file documents how the project holds itself to a standard. It
|
||||||
|
is written for anyone reading the code, including future me.
|
||||||
|
|
||||||
## Ground rules that actually matter
|
## Ground rules that actually matter
|
||||||
|
|
||||||
@@ -54,7 +68,10 @@ python run.py --no-led # dashboard on :8000
|
|||||||
No Sense HAT needed. The simulator kicks in automatically and exercises every
|
No Sense HAT needed. The simulator kicks in automatically and exercises every
|
||||||
code path.
|
code path.
|
||||||
|
|
||||||
## Before you open a pull request
|
## The bar any change has to clear
|
||||||
|
|
||||||
|
Whether it is my own commit or a fork of yours, a change to the forecasting
|
||||||
|
path is not finished until it can show:
|
||||||
|
|
||||||
- [ ] `python scripts/evaluate.py` runs clean, and you have posted before/after numbers
|
- [ ] `python scripts/evaluate.py` runs clean, and you have posted before/after numbers
|
||||||
- [ ] Those numbers came from a backfill with `--seed` and `--end` both pinned
|
- [ ] Those numbers came from a backfill with `--seed` and `--end` both pinned
|
||||||
@@ -63,16 +80,18 @@ code path.
|
|||||||
- [ ] New model code explains its failure mode in the docstring
|
- [ ] New model code explains its failure mode in the docstring
|
||||||
- [ ] The dashboard still fits one viewport at 1280x800 if you changed the UI
|
- [ ] The dashboard still fits one viewport at 1280x800 if you changed the UI
|
||||||
|
|
||||||
## Good first contributions
|
## Roadmap
|
||||||
|
|
||||||
|
Where this is going, in rough order of value. Listed so a forker knows what is
|
||||||
|
already planned rather than as an invitation.
|
||||||
|
|
||||||
- **DS18B20 or BME280 support.** An outdoor sensor removes the single biggest
|
- **DS18B20 or BME280 support.** An outdoor sensor removes the single biggest
|
||||||
limitation in the project. High impact, self-contained.
|
limitation in the project. High impact, self-contained.
|
||||||
- **Tipping-bucket rain gauge on GPIO.** Real precipitation labels would
|
- **Tipping-bucket rain gauge on GPIO.** Real precipitation labels would
|
||||||
transform the precipitation model.
|
transform the precipitation model.
|
||||||
- **METAR ingestion** from a nearby airfield as a calibration reference.
|
- **METAR ingestion** from a nearby airfield as a calibration reference.
|
||||||
- **Translations** for the dashboard.
|
|
||||||
- **Tests.** There is a walk-forward backtest but no unit test suite. A pytest
|
- **Tests.** There is a walk-forward backtest but no unit test suite. A pytest
|
||||||
suite over `physics.py`, `estimation.py` and `models/rls.py` would be very welcome.
|
suite over `physics.py`, `estimation.py` and `models/rls.py` is the main gap.
|
||||||
|
|
||||||
## Reporting bugs
|
## Reporting bugs
|
||||||
|
|
||||||
@@ -81,8 +100,8 @@ output of `GET /api/status`, and what you expected instead. If it is a
|
|||||||
forecasting problem rather than a crash, the output of `scripts/evaluate.py`
|
forecasting problem rather than a crash, the output of `scripts/evaluate.py`
|
||||||
helps enormously.
|
helps enormously.
|
||||||
|
|
||||||
## Licensing of contributions
|
## Licensing
|
||||||
|
|
||||||
By contributing you agree that your work is licensed under the Apache License
|
The project is Apache 2.0. Fork it, modify it, ship it, subject to the licence
|
||||||
2.0, the same terms as the project. You keep the copyright in your own
|
terms. In the unlikely event a patch is accepted, it is taken under the same
|
||||||
contributions.
|
terms and you keep the copyright in your own work.
|
||||||
|
|||||||
@@ -131,6 +131,22 @@ after a fortnight of real data and believe those instead.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Getting it onto a Pi
|
||||||
|
|
||||||
|
Already have a working Raspberry Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/lynchaos/ashvale-station/main/deploy/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Blank SD card: download the prebuilt Raspberry Pi OS Lite (Trixie, arm64) image
|
||||||
|
from [Releases](https://github.com/lynchaos/ashvale-station/releases), verify the
|
||||||
|
checksum, and flash it with Raspberry Pi Imager, setting your own username,
|
||||||
|
password and WiFi in the customisation dialog. The image carries no credentials
|
||||||
|
and no SSH host keys: those are generated on first boot.
|
||||||
|
|
||||||
|
Either way, see [deploy/README.md](deploy/README.md).
|
||||||
|
|
||||||
## Install on the Pi
|
## Install on the Pi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -160,18 +176,30 @@ permanently.
|
|||||||
|
|
||||||
## The dashboard
|
## The dashboard
|
||||||
|
|
||||||
Five tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is
|
Six tabs, one viewport, no scrolling on desktop. Below 1024 px wide the tabs
|
||||||
released, because pinning five panels into a phone viewport produces unreadable
|
become a dropdown and the page scrolls, because pinning six panels into a phone
|
||||||
eight-pixel type.
|
viewport produces unreadable eight-pixel type. The same release applies below
|
||||||
|
700 px tall: a short screen gets a scrollbar rather than a one-pixel chart.
|
||||||
|
|
||||||
|
Light and dark themes, defaulting to your system setting, switchable from the
|
||||||
|
header or the Settings tab and remembered per browser.
|
||||||
|
|
||||||
|
Verified across ten viewports from 1920x1080 to 375x667, six tabs each, in
|
||||||
|
both themes.
|
||||||
|
|
||||||
| Tab | Answers |
|
| Tab | Answers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Live** | What is it doing right now |
|
| **Live** | The week ahead, current readings, the forecast with its band, and conditions |
|
||||||
| **Forecast** | What is it about to do, and how sure are we |
|
|
||||||
| **History** | What did it do, over any timeframe you ask for |
|
| **History** | What did it do, over any timeframe you ask for |
|
||||||
| **Models** | Has the model earned its confidence |
|
| **Models and Calibration** | Has the model earned its confidence, and the calibration inputs |
|
||||||
|
| **Stats for Nerds** | Every internal the estimator and the 18 learners are carrying |
|
||||||
|
| **Settings** | Surroundings, site geometry, the matrix, and maintenance actions |
|
||||||
| **Methods** | How the whole thing is wired, and how each stage fails |
|
| **Methods** | How the whole thing is wired, and how each stage fails |
|
||||||
|
|
||||||
|
Live carries the current readings, the observed-and-forecast chart with its 90%
|
||||||
|
conformal band, and the precipitation panel together, so the question "what is
|
||||||
|
it doing and what happens next" is answered without changing tab.
|
||||||
|
|
||||||
### History
|
### History
|
||||||
|
|
||||||
Presets from 6 hours to a year, plus an explicit from/to range picker. Aggregation
|
Presets from 6 hours to a year, plus an explicit from/to range picker. Aggregation
|
||||||
|
|||||||
+399
-10
@@ -25,15 +25,18 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .config import CONFIG
|
from .config import CONFIG, load_overrides, save_overrides
|
||||||
from .dashboard import DASHBOARD_HTML
|
from .dashboard import DASHBOARD_HTML
|
||||||
|
from .features import FEATURE_NAMES
|
||||||
from .led import LedDisplay
|
from .led import LedDisplay
|
||||||
from .methods import describe
|
from .methods import describe
|
||||||
from .station import Station
|
from .station import Station
|
||||||
@@ -42,6 +45,14 @@ station: Optional[Station] = None
|
|||||||
display: Optional[LedDisplay] = None
|
display: Optional[LedDisplay] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Set when the app is shutting down. The SSE generator watches it: without
|
||||||
|
# that, an open dashboard is an in-flight request that never completes, so
|
||||||
|
# uvicorn's graceful shutdown blocks until systemd's timeout SIGKILLs the
|
||||||
|
# process. Reproduced: with no stream client the service stops in 2 s, with
|
||||||
|
# one open client it was still running after 15 s.
|
||||||
|
_shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global station, display
|
global station, display
|
||||||
@@ -49,11 +60,13 @@ async def lifespan(app: FastAPI):
|
|||||||
station.sample_once()
|
station.sample_once()
|
||||||
station.start()
|
station.start()
|
||||||
if CONFIG.server.led_enabled:
|
if CONFIG.server.led_enabled:
|
||||||
display = LedDisplay(station, CONFIG.server.led_cycle_s)
|
display = LedDisplay(station, CONFIG.server.led_cycle_s, CONFIG.server.led_fps)
|
||||||
|
station.display = display # lets the joystick drive the panel
|
||||||
display.start()
|
display.start()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
_shutdown.set()
|
||||||
if display is not None:
|
if display is not None:
|
||||||
await display.stop()
|
await display.stop()
|
||||||
if station is not None:
|
if station is not None:
|
||||||
@@ -68,6 +81,14 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Vendored browser libraries. The dashboard used to pull Tailwind, Chart.js,
|
||||||
|
# hammer, the zoom plugin, KaTeX and two Google fonts from CDNs at runtime,
|
||||||
|
# which meant the Pi needed internet to render its own UI. Serving them from
|
||||||
|
# disk costs about 1.4 MB and removes that dependency entirely.
|
||||||
|
_STATIC = Path(__file__).resolve().parent / "static"
|
||||||
|
if _STATIC.is_dir():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
|
||||||
|
|
||||||
|
|
||||||
def _st() -> Station:
|
def _st() -> Station:
|
||||||
if station is None:
|
if station is None:
|
||||||
@@ -108,6 +129,35 @@ class CalibrationIn(BaseModel):
|
|||||||
"covariance, returning to the configured prior")
|
"covariance, returning to the configured prior")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsIn(BaseModel):
|
||||||
|
"""Every field optional: the UI sends only what changed."""
|
||||||
|
environment: Optional[str] = None
|
||||||
|
enclosure: Optional[str] = None
|
||||||
|
note: str = ""
|
||||||
|
altitude_m: Optional[float] = Field(None, ge=-430, le=9000)
|
||||||
|
latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||||
|
longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||||
|
heating: Optional[bool] = None
|
||||||
|
heating_setpoint_c: Optional[float] = Field(None, ge=5, le=35)
|
||||||
|
thermal_time_constant_h: Optional[float] = Field(None, ge=0.1, le=24)
|
||||||
|
hum_psychrometric: Optional[bool] = None
|
||||||
|
led_enabled: Optional[bool] = None
|
||||||
|
led_fps: Optional[float] = Field(None, ge=4, le=30)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentIn(BaseModel):
|
||||||
|
environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor")
|
||||||
|
enclosure: Optional[str] = Field(None, description="closed | ventilated | open")
|
||||||
|
note: str = Field("", description="what changed, for the log")
|
||||||
|
|
||||||
|
|
||||||
|
class HumidityCalibrationIn(BaseModel):
|
||||||
|
reference_pct: Optional[float] = Field(None, ge=0, le=100,
|
||||||
|
description="Trusted relative humidity in %")
|
||||||
|
reset: bool = Field(False, description="Discard the learned offset, returning to "
|
||||||
|
"the configured prior")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ endpoints
|
# ------------------------------------------------------------ endpoints
|
||||||
|
|
||||||
@app.get("/api/telemetry")
|
@app.get("/api/telemetry")
|
||||||
@@ -140,6 +190,9 @@ def telemetry() -> Dict:
|
|||||||
"cpu_temp": live.get("cpu_temp"),
|
"cpu_temp": live.get("cpu_temp"),
|
||||||
"cpu_offset": live.get("cpu_offset"),
|
"cpu_offset": live.get("cpu_offset"),
|
||||||
"compensator_k": live.get("compensator_k"),
|
"compensator_k": live.get("compensator_k"),
|
||||||
|
"hum_offset": live.get("hum_offset"),
|
||||||
|
"outdoor_c": live.get("outdoor_c"),
|
||||||
|
"hum_psychrometric": live.get("hum_psychrometric"),
|
||||||
"rates": {
|
"rates": {
|
||||||
"temperature_c_per_h": live.get("temp_rate"),
|
"temperature_c_per_h": live.get("temp_rate"),
|
||||||
"humidity_pct_per_h": live.get("hum_rate"),
|
"humidity_pct_per_h": live.get("hum_rate"),
|
||||||
@@ -342,6 +395,169 @@ def models() -> Dict:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _innovation_histogram(st, bins: int = 21) -> Dict:
|
||||||
|
"""Distribution of recent standardised Kalman innovations, per signal.
|
||||||
|
|
||||||
|
y/sqrt(S) should be standard normal when a filter is consistent. The single
|
||||||
|
NIS number says whether the spread is right on average; this says whether
|
||||||
|
the *shape* is right. Skew means systematic bias, excess kurtosis means the
|
||||||
|
filter is surprised more often than it admits.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for name, buf in st.tracker.innovations.items():
|
||||||
|
z = np.array(buf, dtype=float)
|
||||||
|
z = z[np.isfinite(z)]
|
||||||
|
if z.size < 20:
|
||||||
|
out[name] = {"counts": [], "n": int(z.size)}
|
||||||
|
continue
|
||||||
|
clipped = np.clip(z, -4.0, 4.0)
|
||||||
|
counts, edges = np.histogram(clipped, bins=bins, range=(-4.0, 4.0))
|
||||||
|
out[name] = {
|
||||||
|
"counts": [int(c) for c in counts],
|
||||||
|
"edges": [round(float(e), 2) for e in edges],
|
||||||
|
"n": int(z.size),
|
||||||
|
"mean": round(float(np.mean(z)), 4),
|
||||||
|
"std": round(float(np.std(z)), 4),
|
||||||
|
"skew": round(float(np.mean(((z - z.mean()) / (z.std() or 1.0)) ** 3)), 3),
|
||||||
|
"kurtosis": round(float(np.mean(((z - z.mean()) / (z.std() or 1.0)) ** 4)), 3),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _reliability_curve(st) -> Dict:
|
||||||
|
"""Realised coverage against nominal, per horizon.
|
||||||
|
|
||||||
|
The scorecard reports one coverage number per head. This asks the sharper
|
||||||
|
question: is the *shape* right. Points below the diagonal mean the intervals
|
||||||
|
are lying, and by how much.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for (target, h), head in sorted(st.nowcast.heads.items()):
|
||||||
|
cov = head.conformal.empirical_coverage
|
||||||
|
if not np.isfinite(cov):
|
||||||
|
continue
|
||||||
|
out.append({"target": target, "horizon_s": h,
|
||||||
|
"nominal": round(1.0 - head.conformal.alpha_target, 4),
|
||||||
|
"realised": round(float(cov), 4),
|
||||||
|
"n": int(head.n_scored)})
|
||||||
|
return {"points": out}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/nerd")
|
||||||
|
def nerd() -> Dict:
|
||||||
|
"""Every internal number the estimator and the learners are carrying.
|
||||||
|
|
||||||
|
Deliberately read-only and computed from live objects rather than stored, so
|
||||||
|
it cannot drift from what the station is actually using. Everything here is
|
||||||
|
cheap: no matrix inversions, no queries beyond what the caller already pays
|
||||||
|
for. `theta` is returned per head so the UI can show which of the 33 features
|
||||||
|
each horizon actually leans on, which is the single most revealing view of
|
||||||
|
what the model has learned.
|
||||||
|
"""
|
||||||
|
st = _st()
|
||||||
|
tr = st.tracker
|
||||||
|
|
||||||
|
filters = {}
|
||||||
|
for name, kf in tr.filters.items():
|
||||||
|
P = np.asarray(kf.P, dtype=float)
|
||||||
|
filters[name] = {
|
||||||
|
"level": float(kf.x[0]), "rate_per_h": float(kf.x[1]) * 3600.0,
|
||||||
|
"nis": float(kf.nis),
|
||||||
|
"p_level": float(P[0, 0]), "p_rate": float(P[1, 1]),
|
||||||
|
"p_cross": float(P[0, 1]),
|
||||||
|
"sigma_level": float(np.sqrt(max(P[0, 0], 0.0))),
|
||||||
|
"q": float(kf.q), "r": float(kf.r),
|
||||||
|
"initialised": bool(kf.initialised),
|
||||||
|
}
|
||||||
|
|
||||||
|
heads = []
|
||||||
|
for (target, h), head in sorted(st.nowcast.heads.items()):
|
||||||
|
m = head.model
|
||||||
|
P = np.asarray(m.P, dtype=float)
|
||||||
|
theta = np.asarray(m.theta, dtype=float)
|
||||||
|
# Condition number of P says whether the 33 directions are being excited
|
||||||
|
# evenly. A huge value means some directions carry almost no information
|
||||||
|
# and the fit there is effectively arbitrary, which is the quiet failure
|
||||||
|
# the trace cap only partly protects against. eigvalsh because P is
|
||||||
|
# symmetric by construction.
|
||||||
|
try:
|
||||||
|
ev = np.linalg.eigvalsh(P)
|
||||||
|
lo, hi = float(np.min(ev)), float(np.max(ev))
|
||||||
|
cond = float(hi / lo) if lo > 1e-12 else float("inf")
|
||||||
|
except np.linalg.LinAlgError:
|
||||||
|
cond = float("nan")
|
||||||
|
heads.append({
|
||||||
|
"target": target, "horizon_s": h,
|
||||||
|
"n_updates": int(m.n_updates),
|
||||||
|
"trace_p": float(np.trace(P)),
|
||||||
|
"cond_p": cond,
|
||||||
|
"theta_norm": float(np.linalg.norm(theta)),
|
||||||
|
"rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))),
|
||||||
|
"lam": float(m.lam), "p_max": float(m.p_max),
|
||||||
|
"eff_memory": float(1.0 / max(1.0 - m.lam, 1e-9)),
|
||||||
|
"alpha": float(head.conformal.alpha),
|
||||||
|
"alpha_target": float(head.conformal.alpha_target),
|
||||||
|
"coverage": (float(head.conformal.empirical_coverage)
|
||||||
|
if np.isfinite(head.conformal.empirical_coverage) else None),
|
||||||
|
"halfwidth": (float(head.conformal.quantile())
|
||||||
|
if np.isfinite(head.conformal.quantile()) else None),
|
||||||
|
"weights": {k: float(v) for k, v in
|
||||||
|
zip(("persistence", "climatology", "learned"), head.weights)},
|
||||||
|
"theta": [round(float(v), 6) for v in theta],
|
||||||
|
})
|
||||||
|
|
||||||
|
mono = st.monitor
|
||||||
|
nov = getattr(mono, "novelty", None)
|
||||||
|
ph = getattr(mono, "drift", None)
|
||||||
|
monitoring = {
|
||||||
|
"novelty": {
|
||||||
|
"d2": float(getattr(nov, "last_d2", 0.0)) if nov is not None else None,
|
||||||
|
"threshold": float(getattr(nov, "threshold", 0.0)) if nov is not None else None,
|
||||||
|
"n": int(getattr(nov, "n", 0)) if nov is not None else None,
|
||||||
|
"dims": int(getattr(nov, "d", 0)) if nov is not None else None,
|
||||||
|
"z": [round(float(v), 4) for v in np.asarray(getattr(nov, "z", []), dtype=float)]
|
||||||
|
if nov is not None else [],
|
||||||
|
},
|
||||||
|
"drift": {
|
||||||
|
"m_pos": float(getattr(ph, "m_pos", 0.0)) if ph is not None else None,
|
||||||
|
"m_neg": float(getattr(ph, "m_neg", 0.0)) if ph is not None else None,
|
||||||
|
"mean": float(getattr(ph, "mean", 0.0)) if ph is not None else None,
|
||||||
|
"n": int(getattr(ph, "n", 0)) if ph is not None else None,
|
||||||
|
"alarms": int(getattr(ph, "n_alarms", 0)) if ph is not None else None,
|
||||||
|
"delta": float(getattr(ph, "delta", 0.0)) if ph is not None else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return _clean({
|
||||||
|
"feature_names": list(FEATURE_NAMES),
|
||||||
|
"filters": filters,
|
||||||
|
"compensators": {
|
||||||
|
"thermal": tr.compensator.to_dict(),
|
||||||
|
"humidity": tr.hum_compensator.to_dict(),
|
||||||
|
},
|
||||||
|
"heads": heads,
|
||||||
|
"climatology": {
|
||||||
|
"ready": st.climatology.ready,
|
||||||
|
"annual_terms": st.climatology.use_annual,
|
||||||
|
"history_days": round(st.climatology.n_days, 3),
|
||||||
|
"diurnal_harmonics": st.climatology.kd,
|
||||||
|
"annual_harmonics": st.climatology.ka,
|
||||||
|
"ridge": st.climatology.ridge,
|
||||||
|
"residual_std": st.climatology.resid_std,
|
||||||
|
"n_coefficients": {k: len(v) for k, v in st.climatology.coef.items()},
|
||||||
|
},
|
||||||
|
"precipitation": {
|
||||||
|
"coefficients": st.precip.coefficients(),
|
||||||
|
"strong_labels": st.precip.n_strong,
|
||||||
|
"weak_labels": st.precip.n_weak,
|
||||||
|
"logloss_ewma": st.precip.ewma_logloss,
|
||||||
|
},
|
||||||
|
"monitoring": monitoring,
|
||||||
|
"innovation": _innovation_histogram(st),
|
||||||
|
"reliability": _reliability_curve(st),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/scorecard")
|
@app.get("/api/scorecard")
|
||||||
def scorecard() -> Dict:
|
def scorecard() -> Dict:
|
||||||
st = _st()
|
st = _st()
|
||||||
@@ -382,12 +598,167 @@ def calibrate(body: CalibrationIn) -> Dict:
|
|||||||
return _clean(result)
|
return _clean(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calibrate/humidity")
|
||||||
|
def calibrate_humidity(body: HumidityCalibrationIn) -> Dict:
|
||||||
|
st = _st()
|
||||||
|
if body.reset:
|
||||||
|
return _clean(st.reset_humidity_calibration())
|
||||||
|
if body.reference_pct is None:
|
||||||
|
raise HTTPException(422, "provide reference_pct, or reset=true")
|
||||||
|
result = st.calibrate_humidity(body.reference_pct)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(409, result["error"])
|
||||||
|
return _clean(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/recompute")
|
||||||
|
def recompute() -> Dict:
|
||||||
|
"""Re-derive every compensated column in the history from the raw values.
|
||||||
|
|
||||||
|
Run after a calibration to remove the step it leaves behind. Safe to repeat:
|
||||||
|
it always starts from the untouched raw columns, never from a previous
|
||||||
|
result, so it cannot compound.
|
||||||
|
"""
|
||||||
|
result = _st().recompute_history()
|
||||||
|
return _clean(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/environment")
|
||||||
|
def environment(body: EnvironmentIn) -> Dict:
|
||||||
|
"""Tell the station its surroundings changed, and have it react.
|
||||||
|
|
||||||
|
Marks a discontinuity and queues a retrain, because the learners' 55 hour
|
||||||
|
memory would otherwise keep predicting the old regime for two days.
|
||||||
|
"""
|
||||||
|
valid_env = {"indoor", "sheltered", "outdoor"}
|
||||||
|
valid_enc = {"closed", "ventilated", "open"}
|
||||||
|
if body.environment and body.environment not in valid_env:
|
||||||
|
raise HTTPException(422, f"environment must be one of {sorted(valid_env)}")
|
||||||
|
if body.enclosure and body.enclosure not in valid_enc:
|
||||||
|
raise HTTPException(422, f"enclosure must be one of {sorted(valid_enc)}")
|
||||||
|
if not body.environment and not body.enclosure:
|
||||||
|
raise HTTPException(422, "provide environment, enclosure, or both")
|
||||||
|
return _clean(_st().set_environment(body.environment, body.enclosure, body.note))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/settings")
|
||||||
|
def get_settings() -> Dict:
|
||||||
|
st = _st()
|
||||||
|
return _clean({
|
||||||
|
"site": {"environment": CONFIG.site.environment,
|
||||||
|
"enclosure": CONFIG.site.enclosure,
|
||||||
|
"altitude_m": CONFIG.site.altitude_m,
|
||||||
|
"latitude": CONFIG.site.latitude,
|
||||||
|
"longitude": CONFIG.site.longitude,
|
||||||
|
"timezone": CONFIG.site.timezone,
|
||||||
|
"heating": CONFIG.site.heating,
|
||||||
|
"heating_setpoint_c": CONFIG.site.heating_setpoint_c,
|
||||||
|
"thermal_time_constant_h": CONFIG.site.thermal_time_constant_h,
|
||||||
|
"name": CONFIG.site.name},
|
||||||
|
"sensor": {"hum_psychrometric": CONFIG.sensor.hum_psychrometric,
|
||||||
|
"cpu_heat_k": round(st.tracker.compensator.k, 4),
|
||||||
|
"hum_offset": round(st.tracker.hum_compensator.offset, 3)},
|
||||||
|
"server": {"led_enabled": CONFIG.server.led_enabled,
|
||||||
|
"led_fps": CONFIG.server.led_fps},
|
||||||
|
"options": {
|
||||||
|
"environment": ["indoor", "sheltered", "outdoor"],
|
||||||
|
"enclosure": ["closed", "ventilated", "open"],
|
||||||
|
},
|
||||||
|
"overrides": load_overrides(CONFIG),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/settings")
|
||||||
|
def post_settings(body: SettingsIn) -> Dict:
|
||||||
|
"""Apply settings live and persist them to the overlay.
|
||||||
|
|
||||||
|
Everything here takes effect without a restart, because a settings page that
|
||||||
|
needs one is a settings page people stop trusting. Site geometry is read per
|
||||||
|
sample, the compensator flag is a field on a live object, and the display
|
||||||
|
reads its own rate each frame.
|
||||||
|
"""
|
||||||
|
st = _st()
|
||||||
|
patch: Dict[str, Dict] = {}
|
||||||
|
applied, needs_recompute = [], False
|
||||||
|
|
||||||
|
if body.environment or body.enclosure:
|
||||||
|
r = st.set_environment(body.environment, body.enclosure, body.note)
|
||||||
|
if r.get("changed"):
|
||||||
|
applied.append(r["detail"])
|
||||||
|
patch.setdefault("site", {}).update(
|
||||||
|
{"environment": CONFIG.site.environment,
|
||||||
|
"enclosure": CONFIG.site.enclosure})
|
||||||
|
|
||||||
|
for name, value in (("altitude_m", body.altitude_m),
|
||||||
|
("latitude", body.latitude),
|
||||||
|
("longitude", body.longitude)):
|
||||||
|
if value is not None and value != getattr(CONFIG.site, name):
|
||||||
|
applied.append(f"{name} {getattr(CONFIG.site, name)} -> {value}")
|
||||||
|
setattr(CONFIG.site, name, float(value))
|
||||||
|
patch.setdefault("site", {})[name] = float(value)
|
||||||
|
# Altitude feeds the sea-level reduction on every stored row, so the
|
||||||
|
# history is now inconsistent with the new value until re-derived.
|
||||||
|
needs_recompute = needs_recompute or name == "altitude_m"
|
||||||
|
|
||||||
|
# Turning the thermostat model on or off changes which process the heads are
|
||||||
|
# fitting, so it is a regime change and gets the same treatment as a door.
|
||||||
|
if body.heating is not None and body.heating != CONFIG.site.heating:
|
||||||
|
CONFIG.site.heating = bool(body.heating)
|
||||||
|
patch.setdefault("site", {})["heating"] = bool(body.heating)
|
||||||
|
applied.append(f"heating {'on' if body.heating else 'off'}")
|
||||||
|
st.store.log_event("discontinuity", "warn",
|
||||||
|
f"heating {'on' if body.heating else 'off'}")
|
||||||
|
st.monitor.retrain_requested = True
|
||||||
|
|
||||||
|
for name, value, label in (
|
||||||
|
("heating_setpoint_c", body.heating_setpoint_c, "setpoint"),
|
||||||
|
("thermal_time_constant_h", body.thermal_time_constant_h, "time constant")):
|
||||||
|
if value is not None and value != getattr(CONFIG.site, name):
|
||||||
|
applied.append(f"{label} {getattr(CONFIG.site, name)} -> {value}")
|
||||||
|
setattr(CONFIG.site, name, float(value))
|
||||||
|
patch.setdefault("site", {})[name] = float(value)
|
||||||
|
|
||||||
|
if body.hum_psychrometric is not None and \
|
||||||
|
body.hum_psychrometric != CONFIG.sensor.hum_psychrometric:
|
||||||
|
CONFIG.sensor.hum_psychrometric = bool(body.hum_psychrometric)
|
||||||
|
st.tracker.hum_compensator.psychrometric = bool(body.hum_psychrometric)
|
||||||
|
patch.setdefault("sensor", {})["hum_psychrometric"] = bool(body.hum_psychrometric)
|
||||||
|
applied.append(f"psychrometric correction {'on' if body.hum_psychrometric else 'off'}")
|
||||||
|
needs_recompute = True
|
||||||
|
|
||||||
|
if body.led_enabled is not None and body.led_enabled != CONFIG.server.led_enabled:
|
||||||
|
CONFIG.server.led_enabled = bool(body.led_enabled)
|
||||||
|
patch.setdefault("server", {})["led_enabled"] = bool(body.led_enabled)
|
||||||
|
if display is not None:
|
||||||
|
display.enabled = bool(body.led_enabled)
|
||||||
|
if not body.led_enabled:
|
||||||
|
st.board.clear()
|
||||||
|
applied.append(f"matrix {'on' if body.led_enabled else 'off'}")
|
||||||
|
|
||||||
|
if body.led_fps is not None and body.led_fps != CONFIG.server.led_fps:
|
||||||
|
CONFIG.server.led_fps = float(body.led_fps)
|
||||||
|
patch.setdefault("server", {})["led_fps"] = float(body.led_fps)
|
||||||
|
if display is not None:
|
||||||
|
display.fps = float(body.led_fps)
|
||||||
|
applied.append(f"matrix {body.led_fps:g} fps")
|
||||||
|
|
||||||
|
if patch:
|
||||||
|
save_overrides(CONFIG, patch)
|
||||||
|
st.store.log_event("settings", "info", "; ".join(applied))
|
||||||
|
|
||||||
|
return _clean({"applied": applied, "changed": bool(applied),
|
||||||
|
"needs_recompute": needs_recompute})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status() -> Dict:
|
def status() -> Dict:
|
||||||
st = _st()
|
st = _st()
|
||||||
return _clean({
|
return _clean({
|
||||||
**st.status(),
|
**st.status(),
|
||||||
"display_frame": display.frame_name if display else None,
|
"display_frame": display.frame_name if display else None,
|
||||||
|
"outdoor_probe": (st.probe.status() if st.probe is not None else None),
|
||||||
|
"environment": CONFIG.site.environment,
|
||||||
|
"enclosure": CONFIG.site.enclosure,
|
||||||
"events": st.store.recent_events(15),
|
"events": st.store.recent_events(15),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -398,11 +769,18 @@ def events(limit: int = Query(50, ge=1, le=500)) -> List[Dict]:
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/stream")
|
@app.get("/api/stream")
|
||||||
async def stream():
|
async def stream(request: Request):
|
||||||
"""Server-sent events. One connection instead of a poll every 2 seconds,
|
"""Server-sent events. One connection instead of a poll every 2 seconds,
|
||||||
which on a Zero 2 W is the difference between 4% and 0.4% CPU."""
|
which on a Zero 2 W is the difference between 4% and 0.4% CPU.
|
||||||
|
|
||||||
|
The loop exits on shutdown or client disconnect. Both matter: an endless
|
||||||
|
generator keeps the response in flight, and uvicorn will not finish a
|
||||||
|
graceful shutdown while one is open.
|
||||||
|
"""
|
||||||
async def gen():
|
async def gen():
|
||||||
while True:
|
while not _shutdown.is_set():
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
st = _st()
|
st = _st()
|
||||||
payload = {
|
payload = {
|
||||||
"telemetry": telemetry(),
|
"telemetry": telemetry(),
|
||||||
@@ -411,7 +789,12 @@ async def stream():
|
|||||||
"drift_stress": round(st.monitor.drift.stress, 3),
|
"drift_stress": round(st.monitor.drift.stress, 3),
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(payload)}\n\n"
|
yield f"data: {json.dumps(payload)}\n\n"
|
||||||
await asyncio.sleep(2.0)
|
# Wait on the shutdown event rather than sleeping blindly, so a stop
|
||||||
|
# is honoured immediately instead of up to 2 s later.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_shutdown.wait(), timeout=2.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream",
|
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache",
|
headers={"Cache-Control": "no-cache",
|
||||||
@@ -419,5 +802,11 @@ async def stream():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def dashboard() -> str:
|
def dashboard() -> Response:
|
||||||
return DASHBOARD_HTML
|
# The page is generated from live config and changes with every deploy, and
|
||||||
|
# it carried no cache headers, so a browser could hold an old copy
|
||||||
|
# indefinitely and show layout bugs that were already fixed. The vendored
|
||||||
|
# assets under /static are fingerprint-free too, but they only change when
|
||||||
|
# the station is updated, so revalidation is enough for them.
|
||||||
|
return HTMLResponse(DASHBOARD_HTML,
|
||||||
|
headers={"Cache-Control": "no-cache, must-revalidate"})
|
||||||
|
|||||||
+126
-4
@@ -21,6 +21,7 @@ variables prefixed `ASHVALE_` (e.g. `ASHVALE_SITE__ALTITUDE_M=42`).
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass, field, fields, is_dataclass
|
from dataclasses import dataclass, field, fields, is_dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -41,7 +42,40 @@ class SiteConfig:
|
|||||||
longitude: float = 0.1218
|
longitude: float = 0.1218
|
||||||
altitude_m: float = 15.0 # for sea-level pressure reduction
|
altitude_m: float = 15.0 # for sea-level pressure reduction
|
||||||
timezone: str = "Europe/London"
|
timezone: str = "Europe/London"
|
||||||
indoors: bool = True # honest flag, changes how forecasts are worded
|
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
|
@dataclass
|
||||||
@@ -55,12 +89,50 @@ class SensorConfig:
|
|||||||
cpu_heat_k: float = 0.55
|
cpu_heat_k: float = 0.55
|
||||||
cpu_heat_k_min: float = 0.15
|
cpu_heat_k_min: float = 0.15
|
||||||
cpu_heat_k_max: float = 1.20
|
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 process/measurement noise (per-signal)
|
||||||
kalman_q_temp: float = 2.0e-6
|
kalman_q_temp: float = 1.0e-9
|
||||||
kalman_r_temp: float = 0.02
|
kalman_r_temp: float = 0.02
|
||||||
kalman_q_press: float = 1.0e-5
|
# Process noise, retuned against measured sensor noise rather than guessed.
|
||||||
|
#
|
||||||
|
# The originals tracked far faster than any of these signals move. In a
|
||||||
|
# still room the temperature filter reported a median rate of 12.4 C/h
|
||||||
|
# while the air moved 0.37 C/h, and it overshot a real -36 C/h event by
|
||||||
|
# 77%. Sweeping q against the RMSE of the reported rate versus the true
|
||||||
|
# rate, using noise measured on the board (temp 0.088 C, press 0.022 hPa,
|
||||||
|
# hum 0.40 %), puts the minimum about two to three decades lower:
|
||||||
|
#
|
||||||
|
# temperature 6.45 -> 0.37 C/h RMSE at 2e-6 -> 1e-9
|
||||||
|
# pressure 2.15 -> 0.24 hPa/h RMSE at 1e-5 -> 1e-8
|
||||||
|
# humidity 27.94 -> 3.55 %/h RMSE at 5e-5 -> 2e-8
|
||||||
|
#
|
||||||
|
# Tracking does not suffer: lag against a genuine 2 C/h ramp is 0.003 C at
|
||||||
|
# both the old and new values, and peak response to a 5-minute event is
|
||||||
|
# closer to the truth, not further from it. What is lost is response to
|
||||||
|
# sub-minute transients, which for a station forecasting 15 minutes to a
|
||||||
|
# day ahead is noise to reject rather than signal to chase.
|
||||||
|
kalman_q_press: float = 1.0e-8
|
||||||
kalman_r_press: float = 0.05
|
kalman_r_press: float = 0.05
|
||||||
kalman_q_hum: float = 5.0e-5
|
kalman_q_hum: float = 2.0e-8
|
||||||
kalman_r_hum: float = 0.60
|
kalman_r_hum: float = 0.60
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +144,7 @@ class ModelConfig:
|
|||||||
rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min
|
rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min
|
||||||
rls_delta: float = 100.0 # P0 = delta * I
|
rls_delta: float = 100.0 # P0 = delta * I
|
||||||
conformal_window: int = 400 # residuals kept per head
|
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_alpha: float = 0.10 # 90% intervals
|
||||||
conformal_gamma: float = 0.01 # adaptive conformal step
|
conformal_gamma: float = 0.01 # adaptive conformal step
|
||||||
train_period_s: float = 600.0 # retrain cadence
|
train_period_s: float = 600.0 # retrain cadence
|
||||||
@@ -98,6 +171,11 @@ class ServerConfig:
|
|||||||
port: int = 8000
|
port: int = 8000
|
||||||
led_enabled: bool = True
|
led_enabled: bool = True
|
||||||
led_cycle_s: float = 0.4
|
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
|
@dataclass
|
||||||
@@ -135,6 +213,45 @@ def _apply_env(obj: Any, prefix: str = "ASHVALE_") -> None:
|
|||||||
setattr(obj, f.name, raw)
|
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:
|
def load_config(path: str | os.PathLike | None = None) -> Config:
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
candidate = Path(path) if path else REPO_ROOT / "config.yaml"
|
candidate = Path(path) if path else REPO_ROOT / "config.yaml"
|
||||||
@@ -142,6 +259,11 @@ def load_config(path: str | os.PathLike | None = None) -> Config:
|
|||||||
with open(candidate, "r", encoding="utf-8") as fh:
|
with open(candidate, "r", encoding="utf-8") as fh:
|
||||||
_apply(cfg, yaml.safe_load(fh) or {})
|
_apply(cfg, yaml.safe_load(fh) or {})
|
||||||
_apply_env(cfg)
|
_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.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
|
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
+1180
-225
File diff suppressed because it is too large
Load Diff
+145
-7
@@ -30,12 +30,14 @@ Two jobs here, both familiar from soft-sensor work:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from .physics import saturation_vapour_pressure
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KalmanCV:
|
class KalmanCV:
|
||||||
@@ -53,6 +55,7 @@ class KalmanCV:
|
|||||||
P: np.ndarray = field(default_factory=lambda: np.eye(2) * 1e3)
|
P: np.ndarray = field(default_factory=lambda: np.eye(2) * 1e3)
|
||||||
initialised: bool = False
|
initialised: bool = False
|
||||||
nis: float = 0.0 # normalised innovation squared, for health monitoring
|
nis: float = 0.0 # normalised innovation squared, for health monitoring
|
||||||
|
innovation_z: float = 0.0
|
||||||
|
|
||||||
def update(self, z: float, dt: float) -> tuple[float, float]:
|
def update(self, z: float, dt: float) -> tuple[float, float]:
|
||||||
if not np.isfinite(z):
|
if not np.isfinite(z):
|
||||||
@@ -82,6 +85,11 @@ class KalmanCV:
|
|||||||
self.P = I_KH @ self.P @ I_KH.T + K @ K.T * self.r # Joseph form, stays PSD
|
self.P = I_KH @ self.P @ I_KH.T + K @ K.T * self.r # Joseph form, stays PSD
|
||||||
|
|
||||||
self.nis = (y * y) / S
|
self.nis = (y * y) / S
|
||||||
|
# y/sqrt(S) is the innovation in units of its own predicted spread, so it
|
||||||
|
# is comparable across signals and should look standard normal when the
|
||||||
|
# filter is consistent. Cheap to keep, and the only honest way to see
|
||||||
|
# skew or fat tails rather than inferring them from a single NIS value.
|
||||||
|
self.innovation_z = float(y / np.sqrt(S)) if S > 0 else 0.0
|
||||||
return float(self.x[0]), float(self.x[1])
|
return float(self.x[0]), float(self.x[1])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -97,12 +105,28 @@ class KalmanCV:
|
|||||||
return {"q": self.q, "r": self.r, "x": self.x.tolist(),
|
return {"q": self.q, "r": self.r, "x": self.x.tolist(),
|
||||||
"P": self.P.tolist(), "initialised": self.initialised}
|
"P": self.P.tolist(), "initialised": self.initialised}
|
||||||
|
|
||||||
|
def load_state(self, d: Dict) -> None:
|
||||||
|
"""Restore the estimate only, leaving q and r as configured.
|
||||||
|
|
||||||
|
q and r are tuning, not something the filter learned. Taking them from
|
||||||
|
the state file pins whatever values were in force when it was written,
|
||||||
|
so editing them in config.yaml does nothing until someone thinks to
|
||||||
|
delete the state, and nobody thinks to delete the state. That cost a
|
||||||
|
retune here: the new q was deployed, the service restarted, and the
|
||||||
|
filters quietly carried on with the old one.
|
||||||
|
|
||||||
|
P may be inconsistent with a newly changed q. That is harmless: the
|
||||||
|
filter re-converges within a few hundred samples, which is far cheaper
|
||||||
|
than a tuning change that appears to work and does not.
|
||||||
|
"""
|
||||||
|
self.x = np.array(d["x"], dtype=float)
|
||||||
|
self.P = np.array(d["P"], dtype=float)
|
||||||
|
self.initialised = bool(d["initialised"])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: Dict) -> "KalmanCV":
|
def from_dict(cls, d: Dict) -> "KalmanCV":
|
||||||
kf = cls(q=d["q"], r=d["r"])
|
kf = cls(q=d["q"], r=d["r"])
|
||||||
kf.x = np.array(d["x"], dtype=float)
|
kf.load_state(d)
|
||||||
kf.P = np.array(d["P"], dtype=float)
|
|
||||||
kf.initialised = bool(d["initialised"])
|
|
||||||
return kf
|
return kf
|
||||||
|
|
||||||
|
|
||||||
@@ -159,20 +183,117 @@ class ThermalCompensator:
|
|||||||
return tc
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
class HumidityCompensator:
|
||||||
|
"""Corrects relative humidity for a sensor sitting hotter than the air.
|
||||||
|
|
||||||
|
The HTS221 reports RH at its own temperature, but the air you care about is
|
||||||
|
at the compensated temperature. Vapour pressure is what is conserved between
|
||||||
|
the two, so
|
||||||
|
|
||||||
|
RH_true = RH_sensor * es(T_sensor) / es(T_true)
|
||||||
|
|
||||||
|
Because the element runs hot, es(T_sensor) > es(T_true) and an uncorrected
|
||||||
|
reading is biased low, by several points on a warm board. That term needs no
|
||||||
|
calibration constant at all: it falls out of the thermal compensation that is
|
||||||
|
already running.
|
||||||
|
|
||||||
|
Measured on real hardware the psychrometric term is the wrong model: against
|
||||||
|
a reference hygrometer reading 50.4%, this board's HTS221 reported 75.4%, so
|
||||||
|
it reads HIGH where the thermal argument predicts LOW. The dominant error is
|
||||||
|
an additive element bias, which is what `offset` corrects, estimated from a
|
||||||
|
trusted reference by the same one-step RLS used for `k` with the regressor
|
||||||
|
fixed at 1, so repeated calibrations converge to a weighted mean. The
|
||||||
|
psychrometric term is therefore off by default and gated on config.
|
||||||
|
|
||||||
|
How it fails: calibrate against a reference while the board is cool, then let
|
||||||
|
CPU load rise, and an offset-only correction drifts because the psychrometric
|
||||||
|
error grows with the gradient. Applying the vapour-pressure term first is
|
||||||
|
exactly what keeps `offset` a constant rather than a function of CPU load.
|
||||||
|
Clamped for the same reason `k` is: one mistyped reference otherwise biases
|
||||||
|
every reading until you notice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, offset: float = 0.0, off_min: float = -35.0,
|
||||||
|
off_max: float = 35.0, forgetting: float = 0.98,
|
||||||
|
psychrometric: bool = False):
|
||||||
|
self.psychrometric = bool(psychrometric)
|
||||||
|
self.offset = float(offset)
|
||||||
|
self.off_min, self.off_max = float(off_min), float(off_max)
|
||||||
|
self.P = 10.0
|
||||||
|
self.lam = float(forgetting)
|
||||||
|
self.n_calibrations = 0
|
||||||
|
self.last_residual = 0.0
|
||||||
|
|
||||||
|
def _psychrometric(self, rh_sensor: float, t_sensor: float, t_true: float) -> float:
|
||||||
|
if not self.psychrometric:
|
||||||
|
return float(rh_sensor)
|
||||||
|
es_s = float(saturation_vapour_pressure(t_sensor))
|
||||||
|
es_t = float(saturation_vapour_pressure(t_true))
|
||||||
|
if not np.isfinite(es_s) or not np.isfinite(es_t) or es_t <= 1e-9:
|
||||||
|
return float(rh_sensor)
|
||||||
|
return float(rh_sensor * es_s / es_t)
|
||||||
|
|
||||||
|
def compensate(self, rh_sensor: float, t_sensor: float, t_true: float) -> float:
|
||||||
|
if not (np.isfinite(rh_sensor) and np.isfinite(t_sensor) and np.isfinite(t_true)):
|
||||||
|
return float(rh_sensor)
|
||||||
|
base = self._psychrometric(rh_sensor, t_sensor, t_true)
|
||||||
|
return float(np.clip(base + self.offset, 0.0, 100.0))
|
||||||
|
|
||||||
|
def calibrate(self, rh_sensor: float, t_sensor: float, t_true: float,
|
||||||
|
rh_reference: float) -> Dict:
|
||||||
|
"""One RLS step on the residual offset. Regressor is 1."""
|
||||||
|
base = self._psychrometric(rh_sensor, t_sensor, t_true)
|
||||||
|
target = float(rh_reference) - base
|
||||||
|
denom = self.lam + self.P
|
||||||
|
gain = self.P / denom if denom > 1e-12 else 0.0
|
||||||
|
residual = target - self.offset
|
||||||
|
self.offset = float(np.clip(self.offset + gain * residual,
|
||||||
|
self.off_min, self.off_max))
|
||||||
|
self.P = float(np.clip((self.P - gain * self.P) / self.lam, 1e-6, 1e4))
|
||||||
|
self.n_calibrations += 1
|
||||||
|
self.last_residual = float(residual)
|
||||||
|
return {"offset": self.offset, "residual": self.last_residual,
|
||||||
|
"n": self.n_calibrations, "psychrometric": base - float(rh_sensor)}
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
return {"offset": self.offset, "P": self.P, "lam": self.lam,
|
||||||
|
"off_min": self.off_min, "off_max": self.off_max,
|
||||||
|
"n": self.n_calibrations, "psychrometric": self.psychrometric}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict) -> "HumidityCompensator":
|
||||||
|
hc = cls(d["offset"], d["off_min"], d["off_max"], d["lam"],
|
||||||
|
d.get("psychrometric", False))
|
||||||
|
hc.P = d["P"]
|
||||||
|
hc.n_calibrations = d.get("n", 0)
|
||||||
|
return hc
|
||||||
|
|
||||||
|
|
||||||
class SignalTracker:
|
class SignalTracker:
|
||||||
"""Bank of Kalman filters plus the compensator, driven at sample rate."""
|
"""Bank of Kalman filters plus the compensators, driven at sample rate."""
|
||||||
|
|
||||||
def __init__(self, cfg):
|
def __init__(self, cfg):
|
||||||
self.compensator = ThermalCompensator(
|
self.compensator = ThermalCompensator(
|
||||||
cfg.sensor.cpu_heat_k, cfg.sensor.cpu_heat_k_min,
|
cfg.sensor.cpu_heat_k, cfg.sensor.cpu_heat_k_min,
|
||||||
cfg.sensor.cpu_heat_k_max,
|
cfg.sensor.cpu_heat_k_max,
|
||||||
)
|
)
|
||||||
|
self.hum_compensator = HumidityCompensator(
|
||||||
|
cfg.sensor.hum_offset, cfg.sensor.hum_offset_min,
|
||||||
|
cfg.sensor.hum_offset_max,
|
||||||
|
psychrometric=cfg.sensor.hum_psychrometric,
|
||||||
|
)
|
||||||
self.filters = {
|
self.filters = {
|
||||||
"temperature": KalmanCV(cfg.sensor.kalman_q_temp, cfg.sensor.kalman_r_temp),
|
"temperature": KalmanCV(cfg.sensor.kalman_q_temp, cfg.sensor.kalman_r_temp),
|
||||||
"humidity": KalmanCV(cfg.sensor.kalman_q_hum, cfg.sensor.kalman_r_hum),
|
"humidity": KalmanCV(cfg.sensor.kalman_q_hum, cfg.sensor.kalman_r_hum),
|
||||||
"pressure": KalmanCV(cfg.sensor.kalman_q_press, cfg.sensor.kalman_r_press),
|
"pressure": KalmanCV(cfg.sensor.kalman_q_press, cfg.sensor.kalman_r_press),
|
||||||
}
|
}
|
||||||
self.last_ts: Optional[float] = None
|
self.last_ts: Optional[float] = None
|
||||||
|
# 600 samples per signal is 20 minutes at the live 2 s cadence, about
|
||||||
|
# 14 kB total. Bounded on purpose: this board has 512 MB and an
|
||||||
|
# unbounded diagnostic buffer is a slow memory leak with a nice name.
|
||||||
|
self.innovations: Dict[str, deque] = {
|
||||||
|
k: deque(maxlen=600) for k in self.filters
|
||||||
|
}
|
||||||
|
|
||||||
def step(self, ts: float, temp_raw: float, hum: float, press: float,
|
def step(self, ts: float, temp_raw: float, hum: float, press: float,
|
||||||
cpu_temp: float) -> Dict[str, float]:
|
cpu_temp: float) -> Dict[str, float]:
|
||||||
@@ -180,12 +301,19 @@ class SignalTracker:
|
|||||||
self.last_ts = ts
|
self.last_ts = ts
|
||||||
|
|
||||||
temp_c = self.compensator.compensate(temp_raw, cpu_temp)
|
temp_c = self.compensator.compensate(temp_raw, cpu_temp)
|
||||||
|
# RH is reported at the element's temperature, not the air's, so it must
|
||||||
|
# be moved onto the compensated temperature before it is filtered.
|
||||||
|
hum_c = self.hum_compensator.compensate(hum, temp_raw, temp_c)
|
||||||
t_lvl, t_rate = self.filters["temperature"].update(temp_c, dt)
|
t_lvl, t_rate = self.filters["temperature"].update(temp_c, dt)
|
||||||
h_lvl, h_rate = self.filters["humidity"].update(hum, dt)
|
h_lvl, h_rate = self.filters["humidity"].update(hum_c, dt)
|
||||||
p_lvl, p_rate = self.filters["pressure"].update(press, dt)
|
p_lvl, p_rate = self.filters["pressure"].update(press, dt)
|
||||||
|
for name, kf in self.filters.items():
|
||||||
|
if kf.initialised:
|
||||||
|
self.innovations[name].append(kf.innovation_z)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"temp_c": temp_c,
|
"temp_c": temp_c,
|
||||||
|
"hum_c": hum_c,
|
||||||
"temp_smooth": t_lvl,
|
"temp_smooth": t_lvl,
|
||||||
"temp_rate": t_rate * 3600.0, # C per hour
|
"temp_rate": t_rate * 3600.0, # C per hour
|
||||||
"hum_smooth": h_lvl,
|
"hum_smooth": h_lvl,
|
||||||
@@ -199,13 +327,23 @@ class SignalTracker:
|
|||||||
def to_dict(self) -> Dict:
|
def to_dict(self) -> Dict:
|
||||||
return {
|
return {
|
||||||
"compensator": self.compensator.to_dict(),
|
"compensator": self.compensator.to_dict(),
|
||||||
|
"hum_compensator": self.hum_compensator.to_dict(),
|
||||||
"filters": {k: v.to_dict() for k, v in self.filters.items()},
|
"filters": {k: v.to_dict() for k, v in self.filters.items()},
|
||||||
"last_ts": self.last_ts,
|
"last_ts": self.last_ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_dict(self, d: Dict) -> None:
|
def load_dict(self, d: Dict) -> None:
|
||||||
self.compensator = ThermalCompensator.from_dict(d["compensator"])
|
self.compensator = ThermalCompensator.from_dict(d["compensator"])
|
||||||
self.filters = {k: KalmanCV.from_dict(v) for k, v in d["filters"].items()}
|
# Absent from state files written before humidity compensation existed.
|
||||||
|
if d.get("hum_compensator"):
|
||||||
|
self.hum_compensator = HumidityCompensator.from_dict(d["hum_compensator"])
|
||||||
|
# Deliberately not KalmanCV.from_dict here: that would restore the
|
||||||
|
# persisted q and r over the configured ones. Only the estimate is
|
||||||
|
# restored, and only for filters this build still has.
|
||||||
|
for name, saved in d.get("filters", {}).items():
|
||||||
|
kf = self.filters.get(name)
|
||||||
|
if kf is not None:
|
||||||
|
kf.load_state(saved)
|
||||||
self.last_ts = d.get("last_ts")
|
self.last_ts = d.get("last_ts")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+31
-5
@@ -32,8 +32,14 @@ from typing import Dict, List, Tuple
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .physics import (absolute_humidity, clear_sky_irradiance, dew_point,
|
from .physics import (
|
||||||
solar_position, vapour_pressure_deficit, wet_bulb)
|
absolute_humidity,
|
||||||
|
clear_sky_irradiance,
|
||||||
|
dew_point,
|
||||||
|
solar_position,
|
||||||
|
vapour_pressure_deficit,
|
||||||
|
wet_bulb,
|
||||||
|
)
|
||||||
|
|
||||||
FEATURE_NAMES: List[str] = [
|
FEATURE_NAMES: List[str] = [
|
||||||
"bias",
|
"bias",
|
||||||
@@ -76,7 +82,8 @@ def _rolling(a: np.ndarray, win: int, fn) -> np.ndarray:
|
|||||||
|
|
||||||
def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
|
def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
|
||||||
press_slp: np.ndarray, lux: np.ndarray,
|
press_slp: np.ndarray, lux: np.ndarray,
|
||||||
grid_s: int, latitude: float, longitude: float
|
grid_s: int, latitude: float, longitude: float,
|
||||||
|
min_days_annual: float = 120.0
|
||||||
) -> Tuple[np.ndarray, np.ndarray]:
|
) -> Tuple[np.ndarray, np.ndarray]:
|
||||||
"""Return (X of shape (n, N_FEATURES), valid mask of shape (n,))."""
|
"""Return (X of shape (n, N_FEATURES), valid mask of shape (n,))."""
|
||||||
n = grid_ts.size
|
n = grid_ts.size
|
||||||
@@ -121,6 +128,20 @@ def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
|
|||||||
hour = (grid_ts % 86400.0) / 86400.0
|
hour = (grid_ts % 86400.0) / 86400.0
|
||||||
doy = (grid_ts % 31557600.0) / 31557600.0
|
doy = (grid_ts % 31557600.0) / 31557600.0
|
||||||
|
|
||||||
|
# Annual harmonics are held at zero until the record spans enough of a year
|
||||||
|
# to excite them, exactly as the climatology fit already gates its annual
|
||||||
|
# terms. Left on from day one they are near-constant, near-collinear with
|
||||||
|
# each other and with the bias, and RLS answers that rank-deficient system
|
||||||
|
# with enormous cancelling weights. Measured on a real station after 1.5
|
||||||
|
# days: cos_doy +1191, sin_doy +1174, ||theta|| 1680 against a median |theta|
|
||||||
|
# of 1.67, cond(P) 3.1e9, and a six hour forecast of 53 C in a 24 C room.
|
||||||
|
# Zero is the honest value: with a day and a half of data the station knows
|
||||||
|
# nothing whatsoever about the season.
|
||||||
|
span_days = float(grid_ts[-1] - grid_ts[0]) / 86400.0 if n > 1 else 0.0
|
||||||
|
annual_on = 1.0 if span_days >= min_days_annual else 0.0
|
||||||
|
sin_doy = np.sin(2 * np.pi * doy) * annual_on
|
||||||
|
cos_doy = np.cos(2 * np.pi * doy) * annual_on
|
||||||
|
|
||||||
X = np.column_stack([
|
X = np.column_stack([
|
||||||
np.ones(n),
|
np.ones(n),
|
||||||
temp, temp_rate_1h, temp_rate_3h, temp_std_3h, temp_dev_24h,
|
temp, temp_rate_1h, temp_rate_3h, temp_std_3h, temp_dev_24h,
|
||||||
@@ -130,7 +151,7 @@ def build_features(grid_ts: np.ndarray, temp: np.ndarray, hum: np.ndarray,
|
|||||||
log_lux, cloud, elev, np.clip(elev, 0.0, None), (elev > 0.0).astype(float),
|
log_lux, cloud, elev, np.clip(elev, 0.0, None), (elev > 0.0).astype(float),
|
||||||
np.sin(2 * np.pi * hour), np.cos(2 * np.pi * hour),
|
np.sin(2 * np.pi * hour), np.cos(2 * np.pi * hour),
|
||||||
np.sin(4 * np.pi * hour), np.cos(4 * np.pi * hour),
|
np.sin(4 * np.pi * hour), np.cos(4 * np.pi * hour),
|
||||||
np.sin(2 * np.pi * doy), np.cos(2 * np.pi * doy),
|
sin_doy, cos_doy,
|
||||||
press_anom * (hum - 70.0) / 100.0,
|
press_anom * (hum - 70.0) / 100.0,
|
||||||
press_tend_3h * dep,
|
press_tend_3h * dep,
|
||||||
])
|
])
|
||||||
@@ -166,7 +187,12 @@ class Standardiser:
|
|||||||
if self.n < 2:
|
if self.n < 2:
|
||||||
return np.atleast_2d(X)
|
return np.atleast_2d(X)
|
||||||
std = np.sqrt(self.m2 / max(self.n - 1, 1))
|
std = np.sqrt(self.m2 / max(self.n - 1, 1))
|
||||||
std = np.where(std < 1e-8, 1.0, std)
|
# 1e-8 was a token guard: it only catches a bit-exactly constant column.
|
||||||
|
# A feature that merely barely moves sails through and gets divided by
|
||||||
|
# its own noise, which manufactures a large z-score out of nothing. A
|
||||||
|
# feature with this little spread carries no information, so scale it by
|
||||||
|
# one and let it stay near zero rather than amplifying it.
|
||||||
|
std = np.where(std < 1e-3, 1.0, std)
|
||||||
out = (np.atleast_2d(X) - self.mean) / std
|
out = (np.atleast_2d(X) - self.mean) / std
|
||||||
out[:, 0] = 1.0 # keep the bias column intact
|
out[:, 0] = 1.0 # keep the bias column intact
|
||||||
return out
|
return out
|
||||||
|
|||||||
+990
-197
File diff suppressed because it is too large
Load Diff
+95
-15
@@ -73,9 +73,22 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
|||||||
"failure": "A mistyped reference drives k to its clamp and stays there "
|
"failure": "A mistyped reference drives k to its clamp and stays there "
|
||||||
"across restarts, because state persists. The reset button "
|
"across restarts, because state persists. The reset button "
|
||||||
"on the Models tab exists for exactly that.",
|
"on the Models tab exists for exactly that.",
|
||||||
"math": r"k_{t} = k_{t-1} + \frac{P\varphi}{\lambda + \varphi P \varphi}"
|
"math": [
|
||||||
r"\left[(T_{raw} - T_{ref}) - k_{t-1}\varphi\right],"
|
r"T = T_{raw} - k\,(T_{cpu} - T_{raw}), \qquad k \ge 0",
|
||||||
r"\quad \varphi = T_{cpu} - T_{raw}",
|
r"\varphi = \max(T_{cpu} - T_{raw},\,0), \qquad "
|
||||||
|
r"y = T_{raw} - T_{ref}",
|
||||||
|
r"g = \frac{P\varphi}{\lambda + \varphi^{2} P}, \qquad "
|
||||||
|
r"k_t = \operatorname{clip}\!\big(k_{t-1} + g\,(y - k_{t-1}\varphi),\;"
|
||||||
|
r"k_{\min},\,k_{\max}\big)",
|
||||||
|
r"P_t = \frac{P_{t-1} - g\,\varphi\,P_{t-1}}{\lambda}",
|
||||||
|
],
|
||||||
|
"symbols": {
|
||||||
|
r"k": "self-heating coefficient, the one estimated parameter",
|
||||||
|
r"\varphi": "regressor: the CPU-to-sensor gradient, floored at zero",
|
||||||
|
r"P": "scalar parameter variance; large means uncertain, so large steps",
|
||||||
|
r"\lambda": "forgetting factor, 0.98. Old calibrations decay",
|
||||||
|
r"g": "RLS gain. Note it is the Kalman gain for a one-dimensional state",
|
||||||
|
},
|
||||||
"params": {"current k": f"{s.cpu_heat_k:g} (prior)",
|
"params": {"current k": f"{s.cpu_heat_k:g} (prior)",
|
||||||
"clamp": f"{s.cpu_heat_k_min:g} to {s.cpu_heat_k_max:g}"},
|
"clamp": f"{s.cpu_heat_k_min:g} to {s.cpu_heat_k_max:g}"},
|
||||||
},
|
},
|
||||||
@@ -96,9 +109,26 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
|||||||
"failure": "Process noise too low and the filter lags real weather; too "
|
"failure": "Process noise too low and the filter lags real weather; too "
|
||||||
"high and you have an expensive passthrough. The innovation "
|
"high and you have an expensive passthrough. The innovation "
|
||||||
"statistic is logged so you can tell which.",
|
"statistic is logged so you can tell which.",
|
||||||
"math": r"x = \begin{bmatrix} \text{level} \\ \text{rate} \end{bmatrix},"
|
"math": [
|
||||||
r"\quad Q = q\begin{bmatrix} \Delta t^3/3 & \Delta t^2/2 \\"
|
r"x = \begin{bmatrix} \text{level} \\ \text{rate} \end{bmatrix}, \qquad "
|
||||||
r"\Delta t^2/2 & \Delta t \end{bmatrix}",
|
r"F = \begin{bmatrix} 1 & \Delta t \\ 0 & 1 \end{bmatrix}, \qquad "
|
||||||
|
r"H = \begin{bmatrix} 1 & 0 \end{bmatrix}",
|
||||||
|
r"Q = q\begin{bmatrix} \Delta t^{3}/3 & \Delta t^{2}/2 \\"
|
||||||
|
r"\Delta t^{2}/2 & \Delta t \end{bmatrix}"
|
||||||
|
r"\qquad\text{(continuous white-noise acceleration)}",
|
||||||
|
r"x^{-}_t = Fx_{t-1}, \qquad P^{-}_t = FP_{t-1}F^{\top} + Q",
|
||||||
|
r"y = z - Hx^{-}_t, \qquad S = HP^{-}_tH^{\top} + r, \qquad "
|
||||||
|
r"K = P^{-}_tH^{\top}S^{-1}",
|
||||||
|
r"P_t = (I - KH)P^{-}_t(I - KH)^{\top} + KrK^{\top}"
|
||||||
|
r"\qquad\text{(Joseph form, stays positive semi-definite)}",
|
||||||
|
r"\text{NIS} = y^{\top}S^{-1}y \;\approx\; 1 \text{ when the filter is tuned}",
|
||||||
|
],
|
||||||
|
"symbols": {
|
||||||
|
r"q": "process noise density. The only knob that really matters",
|
||||||
|
r"r": "measurement noise variance, from the sensor datasheet",
|
||||||
|
r"S": "innovation covariance: how surprised the filter expects to be",
|
||||||
|
r"\text{NIS}": "normalised innovation squared. Above 1 means overconfident and lagging",
|
||||||
|
},
|
||||||
"params": {"q temperature": f"{s.kalman_q_temp:g}",
|
"params": {"q temperature": f"{s.kalman_q_temp:g}",
|
||||||
"r temperature": f"{s.kalman_r_temp:g}",
|
"r temperature": f"{s.kalman_r_temp:g}",
|
||||||
"q pressure": f"{s.kalman_q_press:g}"},
|
"q pressure": f"{s.kalman_q_press:g}"},
|
||||||
@@ -147,9 +177,32 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
|||||||
"through quiet nights when the regressor barely moves, and "
|
"through quiet nights when the regressor barely moves, and "
|
||||||
"the model then detonates at sunrise. The trace is capped. "
|
"the model then detonates at sunrise. The trace is capped. "
|
||||||
"This is the most common way a field RLS deployment dies.",
|
"This is the most common way a field RLS deployment dies.",
|
||||||
"math": r"P_t = \frac{1}{\lambda}\left(P_{t-1} - "
|
"math": [
|
||||||
r"\frac{P_{t-1}x x^{\top}P_{t-1}}{\lambda + x^{\top}P_{t-1}x}"
|
r"dT_{set}(h) = (T_{set} - T_{now})\left(1 - e^{-h/\tau}\right)"
|
||||||
r"\right)",
|
r"\qquad\text{(thermostat member, first-order closed loop)}",
|
||||||
|
r"RH(h) = RH_{now}\,\frac{e_s(T_{now})}{e_s(T_{now} + dT_{set}(h))}"
|
||||||
|
r"\qquad\text{(heating adds no moisture, so dew point is conserved)}",
|
||||||
|
r"\hat{\theta} = \arg\min_{\theta}\; \sum_{i=1}^{t}"
|
||||||
|
r"\lambda^{\,t-i}\big(y_i - \theta^{\top}x_i\big)^{2}"
|
||||||
|
r"\qquad\text{(exponentially weighted least squares)}",
|
||||||
|
r"g_t = \frac{P_{t-1}x_t}{\lambda + x_t^{\top}P_{t-1}x_t}, \qquad "
|
||||||
|
r"\theta_t = \theta_{t-1} + g_t\big(y_t - \theta_{t-1}^{\top}x_t\big)",
|
||||||
|
r"P_t = \frac{1}{\lambda}\Big(P_{t-1} - g_t x_t^{\top} P_{t-1}\Big), "
|
||||||
|
r"\qquad P_t \leftarrow \tfrac{1}{2}\big(P_t + P_t^{\top}\big)",
|
||||||
|
r"\operatorname{tr}(P_t) > P_{\max} \;\Longrightarrow\; "
|
||||||
|
r"P_t \leftarrow P_t\,\frac{P_{\max}}{\operatorname{tr}(P_t)}"
|
||||||
|
r"\qquad\text{(the guard that stops covariance blow-up)}",
|
||||||
|
r"N_{\text{eff}} = \frac{1}{1-\lambda}"
|
||||||
|
r"\qquad\text{effective memory in samples}",
|
||||||
|
r"\hat{y}_{t+h} = y_t + \theta_h^{\top}x_t"
|
||||||
|
r"\qquad\text{each head predicts a delta, not a level}",
|
||||||
|
],
|
||||||
|
"symbols": {
|
||||||
|
r"\theta": "33 weights, one bank per (target, horizon): 18 banks",
|
||||||
|
r"P": "parameter covariance. Its trace is the total uncertainty",
|
||||||
|
r"\lambda": "forgetting factor 0.9985, about 55 hours of memory",
|
||||||
|
r"P_{\max}": "trace cap. Without it, quiet nights inflate P until sunrise detonates the model",
|
||||||
|
},
|
||||||
"params": {"forgetting": f"{m.rls_forgetting:g}",
|
"params": {"forgetting": f"{m.rls_forgetting:g}",
|
||||||
"effective memory": _memory(m.rls_forgetting, m.grid_s),
|
"effective memory": _memory(m.rls_forgetting, m.grid_s),
|
||||||
"members": ", ".join(MEMBERS)},
|
"members": ", ".join(MEMBERS)},
|
||||||
@@ -171,8 +224,21 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
|||||||
"does underneath.",
|
"does underneath.",
|
||||||
"failure": "If coverage sits far from target, the feedback rate is "
|
"failure": "If coverage sits far from target, the feedback rate is "
|
||||||
"wrong, not the model. Both are shown on the Models tab.",
|
"wrong, not the model. Both are shown on the Models tab.",
|
||||||
"math": r"\alpha_{t+1} = \alpha_t + \gamma\left(\alpha^{*} - "
|
"math": [
|
||||||
r"\mathbb{1}[y_t \notin C_t]\right)",
|
r"C_t = \big[\hat{y}_t - q_{1-\alpha_t},\; \hat{y}_t + q_{1-\alpha_t}\big], "
|
||||||
|
r"\qquad q_{1-\alpha} = \operatorname{Quantile}_{1-\alpha}\big(|e_i|\big)",
|
||||||
|
r"\alpha_{t+1} = \operatorname{clip}\Big(\alpha_t + \gamma\big(\alpha^{*} - "
|
||||||
|
r"\mathbb{1}\left[y_t \notin C_t\right]\big),\; 0.005,\; 0.75\Big)",
|
||||||
|
r"\frac{1}{T}\sum_{t=1}^{T}\mathbb{1}\left[y_t \in C_t\right] "
|
||||||
|
r"\;\xrightarrow[T\to\infty]{}\; 1-\alpha^{*}"
|
||||||
|
r"\qquad\text{without assuming exchangeability}",
|
||||||
|
],
|
||||||
|
"symbols": {
|
||||||
|
r"\alpha^{*}": "target miss rate, 0.10 for a 90% band",
|
||||||
|
r"\alpha_t": "working miss rate. It moves; the target does not",
|
||||||
|
r"\gamma": "adaptation rate. Larger reacts faster and wanders more",
|
||||||
|
r"\mathbb{1}[\cdot]": "1 when the truth fell outside the band, else 0",
|
||||||
|
},
|
||||||
"params": {"target coverage": f"{int((1 - m.conformal_alpha) * 100)}%",
|
"params": {"target coverage": f"{int((1 - m.conformal_alpha) * 100)}%",
|
||||||
"gamma": f"{m.conformal_gamma:g}",
|
"gamma": f"{m.conformal_gamma:g}",
|
||||||
"window": f"{m.conformal_window} residuals"},
|
"window": f"{m.conformal_window} residuals"},
|
||||||
@@ -196,9 +262,23 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
|||||||
"a 365-day sine to three weeks of data produces a "
|
"a 365-day sine to three weeks of data produces a "
|
||||||
"magnificent extrapolation straight off the edge of the "
|
"magnificent extrapolation straight off the edge of the "
|
||||||
"physical world.",
|
"physical world.",
|
||||||
"math": r"y \sim \beta_0 + \beta_1 t + \sum_{k=1}^{3}"
|
"math": [
|
||||||
r"\left[a_k\sin\tfrac{2\pi k t}{\text{day}} + "
|
r"y(t) \approx \beta_0 + \beta_1 t + \sum_{k=1}^{K_d}"
|
||||||
r"b_k\cos\tfrac{2\pi k t}{\text{day}}\right] + \text{annual}",
|
r"\left[a_k\sin\frac{2\pi k t}{\tau_d} + b_k\cos\frac{2\pi k t}{\tau_d}\right]"
|
||||||
|
r" + \sum_{j=1}^{K_a}\left[c_j\sin\frac{2\pi j t}{\tau_a} + "
|
||||||
|
r"d_j\cos\frac{2\pi j t}{\tau_a}\right]",
|
||||||
|
r"\hat{\beta} = \big(X^{\top}X + \rho I\big)^{-1}X^{\top}y"
|
||||||
|
r"\qquad\text{(ridge, because harmonics get collinear on short records)}",
|
||||||
|
r"\hat{y}(t+h) = \underbrace{\mu(t+h)}_{\text{harmonic fit}} + "
|
||||||
|
r"\underbrace{\big(y(t)-\mu(t)\big)}_{\text{today's anomaly}}\cdot"
|
||||||
|
r"\,2^{-h/h_{1/2}}",
|
||||||
|
],
|
||||||
|
"symbols": {
|
||||||
|
r"\tau_d,\ \tau_a": "one day and one tropical year",
|
||||||
|
r"K_a": "annual harmonics, held at zero below 120 days of history",
|
||||||
|
r"\rho": "ridge penalty",
|
||||||
|
r"h_{1/2}": "anomaly half-life. Today's departure decays toward climatology",
|
||||||
|
},
|
||||||
"params": {"diurnal harmonics": "3", "annual harmonics": "2",
|
"params": {"diurnal harmonics": "3", "annual harmonics": "2",
|
||||||
"anomaly half-life": "30 h"},
|
"anomaly half-life": "30 h"},
|
||||||
},
|
},
|
||||||
@@ -221,7 +301,7 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
|||||||
"label abstains in the ambiguous middle rather than "
|
"label abstains in the ambiguous middle rather than "
|
||||||
"guessing, because a poisoned training set costs more than "
|
"guessing, because a poisoned training set costs more than "
|
||||||
"the extra samples buy. Trust grows as n/(n+25) in strong "
|
"the extra samples buy. Trust grows as n/(n+25) in strong "
|
||||||
"labels, so the two buttons on the Forecast tab matter.",
|
"labels, so the two buttons on the Live tab matter.",
|
||||||
"params": {"prior": "Zambretti, three-branch",
|
"params": {"prior": "Zambretti, three-branch",
|
||||||
"learner": "logistic, AdaGrad",
|
"learner": "logistic, AdaGrad",
|
||||||
"strong label weight": "10x proxy"},
|
"strong label weight": "10x proxy"},
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from .rls import RecursiveLeastSquares, AdaptiveConformal
|
|
||||||
from .nowcast import NowcastEnsemble
|
|
||||||
from .climatology import HarmonicClimatology
|
|
||||||
from .precip import PrecipitationModel, zambretti
|
|
||||||
from .anomaly import AnomalyMonitor
|
from .anomaly import AnomalyMonitor
|
||||||
|
from .climatology import HarmonicClimatology
|
||||||
|
from .nowcast import NowcastEnsemble
|
||||||
|
from .precip import PrecipitationModel, zambretti
|
||||||
|
from .rls import AdaptiveConformal, RecursiveLeastSquares
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RecursiveLeastSquares", "AdaptiveConformal", "NowcastEnsemble",
|
"RecursiveLeastSquares", "AdaptiveConformal", "NowcastEnsemble",
|
||||||
|
|||||||
@@ -141,9 +141,9 @@ class HarmonicClimatology:
|
|||||||
sigma0 = self.resid_std.get(target, 1.0)
|
sigma0 = self.resid_std.get(target, 1.0)
|
||||||
sigma = sigma0 * np.sqrt(1.0 + lead_h / 24.0)
|
sigma = sigma0 * np.sqrt(1.0 + lead_h / 24.0)
|
||||||
return [
|
return [
|
||||||
{"ts": float(t), "lead_h": float(l), "mu": float(m),
|
{"ts": float(t), "lead_h": float(lh), "mu": float(m),
|
||||||
"lo": float(m - 1.645 * s), "hi": float(m + 1.645 * s)}
|
"lo": float(m - 1.645 * s), "hi": float(m + 1.645 * s)}
|
||||||
for t, l, m, s in zip(grid, lead_h, mu, sigma)
|
for t, lh, m, s in zip(grid, lead_h, mu, sigma)
|
||||||
]
|
]
|
||||||
|
|
||||||
def anomaly_now(self, target: str, ts: float, observed: float) -> float:
|
def anomaly_now(self, target: str, ts: float, observed: float) -> float:
|
||||||
|
|||||||
+202
-36
@@ -43,7 +43,12 @@ import numpy as np
|
|||||||
from ..features import N_FEATURES, Standardiser, supervised_pairs
|
from ..features import N_FEATURES, Standardiser, supervised_pairs
|
||||||
from .rls import AdaptiveConformal, RecursiveLeastSquares
|
from .rls import AdaptiveConformal, RecursiveLeastSquares
|
||||||
|
|
||||||
MEMBERS = ("persistence", "climatology", "learned")
|
MEMBERS = ("persistence", "climatology", "learned", "setpoint")
|
||||||
|
|
||||||
|
# Scored forecasts before the Hedge loss scale switches from this sample's
|
||||||
|
# worst loss to the running member MAE. Until member_mae has seen anything it
|
||||||
|
# is zeros, and dividing by that would hand every member the same penalty.
|
||||||
|
_SCALE_WARMUP = 20
|
||||||
|
|
||||||
|
|
||||||
class ForecastHead:
|
class ForecastHead:
|
||||||
@@ -61,13 +66,17 @@ class ForecastHead:
|
|||||||
self.eta = float(hedge_eta)
|
self.eta = float(hedge_eta)
|
||||||
self.member_mae = np.zeros(len(MEMBERS))
|
self.member_mae = np.zeros(len(MEMBERS))
|
||||||
self.n_scored = 0
|
self.n_scored = 0
|
||||||
|
# Validity time of the last outcome the Hedge weights learned from.
|
||||||
|
self.last_hedge_ts = -np.inf
|
||||||
|
|
||||||
# -------------------------------------------------------- prediction
|
# -------------------------------------------------------- prediction
|
||||||
|
|
||||||
def predict(self, x: np.ndarray, anchor: float,
|
def predict(self, x: np.ndarray, anchor: float,
|
||||||
climatology_delta: float = 0.0) -> Dict[str, float]:
|
climatology_delta: float = 0.0,
|
||||||
|
setpoint_delta: float = 0.0) -> Dict[str, float]:
|
||||||
learned_delta = self.model.predict(x)
|
learned_delta = self.model.predict(x)
|
||||||
deltas = np.array([0.0, float(climatology_delta), float(learned_delta)])
|
deltas = np.array([0.0, float(climatology_delta), float(learned_delta),
|
||||||
|
float(setpoint_delta)])
|
||||||
blended = float(np.dot(self.weights, deltas))
|
blended = float(np.dot(self.weights, deltas))
|
||||||
mu = float(anchor + blended)
|
mu = float(anchor + blended)
|
||||||
sigma = self.model.predict_std(x, self.model.noise_var)
|
sigma = self.model.predict_std(x, self.model.noise_var)
|
||||||
@@ -84,26 +93,81 @@ class ForecastHead:
|
|||||||
|
|
||||||
# ---------------------------------------------------------- learning
|
# ---------------------------------------------------------- learning
|
||||||
|
|
||||||
def learn(self, x: np.ndarray, anchor: float, truth: float,
|
def refit_step(self, x: np.ndarray, anchor: float, truth: float) -> None:
|
||||||
climatology_delta: float = 0.0) -> float:
|
"""One regression update from a replayed historical pair.
|
||||||
"""One supervised step given a matured target."""
|
|
||||||
deltas = np.array([0.0, float(climatology_delta),
|
This deliberately touches nothing but the RLS. The blend weights and
|
||||||
float(self.model.predict(x))])
|
the conformal calibrator are statements about how this head's issued
|
||||||
member_pred = anchor + deltas
|
forecasts actually turned out, and a refit is not an outcome: it is the
|
||||||
|
same week of weather being read again.
|
||||||
|
|
||||||
|
Measured on 8.2 days of real station data, the previous arrangement
|
||||||
|
(fit() calling a combined learn()) had put 977,078 Hedge updates through
|
||||||
|
the 15 minute head from 758 distinct supervised pairs, a factor of 1,289,
|
||||||
|
and 296,715 through the 1 day head from 12 pairs, a factor of 24,726.
|
||||||
|
Hedge is multiplicative, so an edge far too small to be real compounds
|
||||||
|
to certainty: twelve of twelve temperature and humidity heads had
|
||||||
|
collapsed onto climatology at a weight of 0.991 or above. The ACI
|
||||||
|
integrator, which moves by gamma per observation, had likewise pinned
|
||||||
|
against its clips, giving a 6 hour band narrower than the 3 hour one.
|
||||||
|
Feeding these two from verify() instead is worth 14.4% of MAE across
|
||||||
|
17 of 18 heads, and takes mean absolute coverage error from 0.188
|
||||||
|
to 0.059.
|
||||||
|
"""
|
||||||
|
self.model.update(x, truth - anchor)
|
||||||
|
|
||||||
|
def observe_outcome(self, members: np.ndarray, truth: float,
|
||||||
|
covered: Optional[bool] = None,
|
||||||
|
valid_ts: Optional[float] = None) -> float:
|
||||||
|
"""One matured forecast, scored against what actually happened.
|
||||||
|
|
||||||
|
`members` are the four point predictions this head issued, recovered
|
||||||
|
from the forecasts table. The learned one cannot be recomputed here
|
||||||
|
because the RLS has moved on since.
|
||||||
|
"""
|
||||||
|
member_pred = np.asarray(members, dtype=float)
|
||||||
losses = np.abs(member_pred - truth)
|
losses = np.abs(member_pred - truth)
|
||||||
|
|
||||||
# Hedge / exponentiated gradient on normalised losses
|
# Score the blend with the weights predict() actually used, before this
|
||||||
scale = max(float(np.max(losses)), 1e-6)
|
# outcome moves them. Doing it after is look-ahead: the residual handed
|
||||||
|
# to the conformal calibrator is then better than anything the
|
||||||
|
# forecaster can produce, so the intervals come out about 2% too narrow.
|
||||||
|
# Coverage survived it only because ACI notices the extra misses and
|
||||||
|
# reopens the band, which is a correction that should not be needed.
|
||||||
|
blended = float(np.dot(self.weights, member_pred))
|
||||||
|
residual = truth - blended
|
||||||
|
self.conformal.observe(residual, covered=covered)
|
||||||
|
|
||||||
|
# The Hedge weights take an outcome only if it does not overlap the last
|
||||||
|
# one they took. Forecasts are issued every retrain tick, so at the 1 day
|
||||||
|
# horizon roughly two hundred of them per day resolve against what is
|
||||||
|
# very nearly the same outcome. The conformal window can absorb that,
|
||||||
|
# since a quantile over duplicated scores is merely over-confident about
|
||||||
|
# its sample size, but exponentiated gradient cannot: it would apply the
|
||||||
|
# same evidence two hundred times and saturate. This is the stride rule
|
||||||
|
# from fit(), applied on the scoring side.
|
||||||
|
if valid_ts is not None:
|
||||||
|
if valid_ts - self.last_hedge_ts < self.horizon_s:
|
||||||
|
return residual
|
||||||
|
self.last_hedge_ts = float(valid_ts)
|
||||||
|
|
||||||
|
# Hedge / exponentiated gradient on normalised losses.
|
||||||
|
#
|
||||||
|
# The scale must be a stable quantity, not this sample's worst loss.
|
||||||
|
# Dividing by max(losses) means that on a quiet step where every member
|
||||||
|
# agrees to within 0.01 C, whichever one happens to be worst still takes
|
||||||
|
# the full exp(-eta) penalty, exactly as if it had been wrong by 5 C, so
|
||||||
|
# the weights churn on noise. Hedge's regret bound assumes a fixed loss
|
||||||
|
# range. Normalising by the running member MAE instead is worth 1.8% of
|
||||||
|
# MAE across 106 of 126 heads on real data, with coverage unchanged.
|
||||||
|
if self.n_scored > _SCALE_WARMUP:
|
||||||
|
scale = max(float(np.mean(self.member_mae)), 1e-6)
|
||||||
|
else:
|
||||||
|
scale = max(float(np.max(losses)), 1e-6)
|
||||||
self.weights *= np.exp(-self.eta * losses / scale)
|
self.weights *= np.exp(-self.eta * losses / scale)
|
||||||
self.weights = np.clip(self.weights, 1e-4, None)
|
self.weights = np.clip(self.weights, 1e-4, None)
|
||||||
self.weights /= self.weights.sum()
|
self.weights /= self.weights.sum()
|
||||||
|
|
||||||
blended = float(np.dot(self.weights, member_pred))
|
|
||||||
residual = truth - blended
|
|
||||||
self.conformal.observe(residual)
|
|
||||||
|
|
||||||
self.model.update(x, truth - anchor)
|
|
||||||
|
|
||||||
self.member_mae = 0.98 * self.member_mae + 0.02 * losses
|
self.member_mae = 0.98 * self.member_mae + 0.02 * losses
|
||||||
self.n_scored += 1
|
self.n_scored += 1
|
||||||
return residual
|
return residual
|
||||||
@@ -112,16 +176,56 @@ class ForecastHead:
|
|||||||
return {"target": self.target, "horizon_s": self.horizon_s,
|
return {"target": self.target, "horizon_s": self.horizon_s,
|
||||||
"model": self.model.to_dict(), "conformal": self.conformal.to_dict(),
|
"model": self.model.to_dict(), "conformal": self.conformal.to_dict(),
|
||||||
"weights": self.weights.tolist(), "eta": self.eta,
|
"weights": self.weights.tolist(), "eta": self.eta,
|
||||||
"member_mae": self.member_mae.tolist(), "n_scored": self.n_scored}
|
"member_mae": self.member_mae.tolist(), "n_scored": self.n_scored,
|
||||||
|
"last_hedge_ts": (float(self.last_hedge_ts)
|
||||||
|
if np.isfinite(self.last_hedge_ts) else None)}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, s: Dict) -> "ForecastHead":
|
def from_dict(cls, s: Dict) -> "ForecastHead":
|
||||||
h = cls(s["target"], s["horizon_s"])
|
h = cls(s["target"], s["horizon_s"])
|
||||||
h.model = RecursiveLeastSquares.from_dict(s["model"])
|
h.model = RecursiveLeastSquares.from_dict(s["model"])
|
||||||
h.conformal = AdaptiveConformal.from_dict(s["conformal"])
|
h.conformal = AdaptiveConformal.from_dict(s["conformal"])
|
||||||
h.weights = np.array(s["weights"], dtype=float)
|
w = np.array(s["weights"], dtype=float)
|
||||||
|
if w.size != len(MEMBERS):
|
||||||
|
# A saved head from before the setpoint member existed. Reinitialise
|
||||||
|
# uniformly rather than guessing: the Hedge weights re-converge in
|
||||||
|
# about a day, which is far cheaper than silently mismatching a
|
||||||
|
# member to the wrong loss and corrupting every blend until someone
|
||||||
|
# notices.
|
||||||
|
w = np.ones(len(MEMBERS)) / len(MEMBERS)
|
||||||
|
h.weights = w
|
||||||
h.eta = s["eta"]
|
h.eta = s["eta"]
|
||||||
h.member_mae = np.array(s["member_mae"], dtype=float)
|
# A state file with no last_hedge_ts was written before the Hedge
|
||||||
|
# weights and the ACI integrator were cut off from fit()'s replay, so
|
||||||
|
# everything they hold is the product of the same week of weather read
|
||||||
|
# about a thousand times: weights pinned on one member at 0.99, alpha
|
||||||
|
# against a clip, member_mae an EMA over a million duplicated steps.
|
||||||
|
# None of that is evidence, and it does not decay on its own, because
|
||||||
|
# Hedge needs about twenty independent outcomes to climb back off the
|
||||||
|
# 1e-4 floor and the 1 d head sees one a day.
|
||||||
|
#
|
||||||
|
# The conformal scores are kept. They were also fed by the replay, so
|
||||||
|
# they are biased a little narrow, but they are absolute residuals of
|
||||||
|
# roughly the right size and the window refreshes within about two days
|
||||||
|
# of real outcomes. Clearing them instead would drop the long horizons
|
||||||
|
# onto 1.645*sigma for nine days, which is how you get a plus or minus
|
||||||
|
# of 115% relative humidity.
|
||||||
|
if "last_hedge_ts" not in s:
|
||||||
|
h.weights = np.ones(len(MEMBERS)) / len(MEMBERS)
|
||||||
|
h.member_mae = np.zeros(len(MEMBERS))
|
||||||
|
h.n_scored = 0
|
||||||
|
h.conformal.alpha = h.conformal.alpha_target
|
||||||
|
h.last_hedge_ts = -np.inf
|
||||||
|
return h
|
||||||
|
lh = s.get("last_hedge_ts")
|
||||||
|
h.last_hedge_ts = -np.inf if lh is None else float(lh)
|
||||||
|
mae = np.array(s["member_mae"], dtype=float)
|
||||||
|
# Same migration as the weights. Missing this one did not fail on load,
|
||||||
|
# it failed later inside the Hedge update on a shape mismatch, which is
|
||||||
|
# a worse place to find out.
|
||||||
|
if mae.size != len(MEMBERS):
|
||||||
|
mae = np.zeros(len(MEMBERS))
|
||||||
|
h.member_mae = mae
|
||||||
h.n_scored = s.get("n_scored", 0)
|
h.n_scored = s.get("n_scored", 0)
|
||||||
return h
|
return h
|
||||||
|
|
||||||
@@ -133,6 +237,7 @@ class NowcastEnsemble:
|
|||||||
cfg_model):
|
cfg_model):
|
||||||
self.targets = tuple(targets)
|
self.targets = tuple(targets)
|
||||||
self.horizons = tuple(int(h) for h in horizons_s)
|
self.horizons = tuple(int(h) for h in horizons_s)
|
||||||
|
self.cfg = cfg_model
|
||||||
self.grid_s = int(cfg_model.grid_s)
|
self.grid_s = int(cfg_model.grid_s)
|
||||||
self.scaler = Standardiser(N_FEATURES)
|
self.scaler = Standardiser(N_FEATURES)
|
||||||
self.heads: Dict[Tuple[str, int], ForecastHead] = {
|
self.heads: Dict[Tuple[str, int], ForecastHead] = {
|
||||||
@@ -144,11 +249,15 @@ class NowcastEnsemble:
|
|||||||
for t in self.targets for h in self.horizons
|
for t in self.targets for h in self.horizons
|
||||||
}
|
}
|
||||||
self.trained_rows = 0
|
self.trained_rows = 0
|
||||||
|
self.min_pairs = int(getattr(cfg_model, "min_pairs_per_head", 12))
|
||||||
|
# Which phase of the stride this refit starts on. Rotated so that over
|
||||||
|
# successive retrains every offset is eventually trained on, rather
|
||||||
|
# than the model permanently seeing one sample in `steps` forever.
|
||||||
|
self.refit_phase = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------ train
|
# ------------------------------------------------------------ train
|
||||||
|
|
||||||
def fit(self, X: np.ndarray, valid: np.ndarray, series: Dict[str, np.ndarray],
|
def fit(self, X: np.ndarray, valid: np.ndarray, series: Dict[str, np.ndarray],
|
||||||
climatology=None, grid_ts: Optional[np.ndarray] = None,
|
|
||||||
passes: int = 1, max_pairs: int = 2500) -> Dict[str, int]:
|
passes: int = 1, max_pairs: int = 2500) -> Dict[str, int]:
|
||||||
"""Batch-update every head from history.
|
"""Batch-update every head from history.
|
||||||
|
|
||||||
@@ -161,6 +270,21 @@ class NowcastEnsemble:
|
|||||||
"""Batch pass over history. Called on startup and every retrain tick."""
|
"""Batch pass over history. Called on startup and every retrain tick."""
|
||||||
if X.shape[0] < 10:
|
if X.shape[0] < 10:
|
||||||
return {"rows": 0}
|
return {"rows": 0}
|
||||||
|
# A refit starts from the prior. Without this, every retrain tick replays
|
||||||
|
# the same history into a live filter, and RLS with forgetting reads that
|
||||||
|
# as new evidence each time: measured on a real station after 1.5 days,
|
||||||
|
# 453 grid rows had produced 64,676 updates, cond(P) of 3.1e9 and a
|
||||||
|
# weight vector of norm 1680 whose two largest entries were the annual
|
||||||
|
# harmonics the record cannot yet resolve. The result was a six hour
|
||||||
|
# forecast of 53 C in a 24 C room, with a plus or minus of 0.43.
|
||||||
|
#
|
||||||
|
# The conformal calibrators and the Hedge weights are left alone here,
|
||||||
|
# and refit_step is what enforces that. They are earned from scored
|
||||||
|
# forecasts in verify(), not from this regression. The climatology and
|
||||||
|
# setpoint members used to be evaluated in this loop purely to feed
|
||||||
|
# them, which is why this method no longer needs either.
|
||||||
|
for head in self.heads.values():
|
||||||
|
head.model.reset()
|
||||||
self.scaler.partial_fit(X[valid][:: max(1, X.shape[0] // 2000)])
|
self.scaler.partial_fit(X[valid][:: max(1, X.shape[0] // 2000)])
|
||||||
Xs = self.scaler.transform(X)
|
Xs = self.scaler.transform(X)
|
||||||
|
|
||||||
@@ -176,26 +300,44 @@ class NowcastEnsemble:
|
|||||||
if Xa.shape[0] > max_pairs:
|
if Xa.shape[0] > max_pairs:
|
||||||
Xa, dy, anchor = Xa[-max_pairs:], dy[-max_pairs:], anchor[-max_pairs:]
|
Xa, dy, anchor = Xa[-max_pairs:], dy[-max_pairs:], anchor[-max_pairs:]
|
||||||
head = self.heads[(target, h)]
|
head = self.heads[(target, h)]
|
||||||
clim = np.zeros(Xa.shape[0])
|
# One pair per horizon, not one per grid row. Adjacent pairs at
|
||||||
if climatology is not None and grid_ts is not None and climatology.ready:
|
# the 1 d horizon share 287 of their 288 samples, so training on
|
||||||
n = grid_ts.size
|
# every row hands the filter the same outcome 288 times and RLS
|
||||||
ts_a = grid_ts[:n - steps]
|
# with forgetting reads each as fresh evidence. A 400-score
|
||||||
mask_len = min(ts_a.size, Xa.shape[0])
|
# conformal window then holds 1.4 independent outcomes while
|
||||||
clim_now = climatology.predict(target, ts_a[-mask_len:])
|
# believing it holds 400.
|
||||||
clim_fut = climatology.predict(target, ts_a[-mask_len:] + h)
|
#
|
||||||
clim = np.zeros(Xa.shape[0])
|
# This is not a compute shortcut that costs accuracy. Measured
|
||||||
clim[-mask_len:] = clim_fut - clim_now
|
# walk-forward on four days of real station data, striding cut
|
||||||
|
# MAE at every horizon past an hour (temperature 6h -30%,
|
||||||
|
# humidity 6h -48%, pressure 12h -68%) with coverage unchanged,
|
||||||
|
# and made the fit 12x faster. The redundancy was not merely
|
||||||
|
# wasted work, it was collapsing P onto the repeated direction.
|
||||||
|
stride = steps
|
||||||
|
if stride > 1 and Xa.shape[0] // stride < self.min_pairs:
|
||||||
|
# A long horizon on a short record would otherwise train
|
||||||
|
# on one or two pairs, which is worse than the redundancy
|
||||||
|
# it avoids. The floor was chosen by sweeping it over five
|
||||||
|
# train splits of real data: 12 was best at every horizon,
|
||||||
|
# and the apparent 1 d regressions at other values were
|
||||||
|
# noise, since a 1 d head on four days of record is fitted
|
||||||
|
# and scored on well under two independent outcomes.
|
||||||
|
stride = max(1, Xa.shape[0] // self.min_pairs)
|
||||||
|
idx = np.arange(self.refit_phase % stride, Xa.shape[0], stride)
|
||||||
|
if idx.size > max_pairs:
|
||||||
|
idx = idx[-max_pairs:]
|
||||||
for _ in range(max(int(passes), 1)):
|
for _ in range(max(int(passes), 1)):
|
||||||
for i in range(Xa.shape[0]):
|
for i in idx:
|
||||||
head.learn(Xa[i], anchor[i], anchor[i] + dy[i], clim[i])
|
head.refit_step(Xa[i], anchor[i], anchor[i] + dy[i])
|
||||||
counts[f"{target}@{h}"] = int(Xa.shape[0])
|
counts[f"{target}@{h}"] = int(idx.size)
|
||||||
self.trained_rows = int(X.shape[0])
|
self.trained_rows = int(X.shape[0])
|
||||||
|
self.refit_phase += 1
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
# --------------------------------------------------------- inference
|
# --------------------------------------------------------- inference
|
||||||
|
|
||||||
def forecast(self, x_raw: np.ndarray, anchors: Dict[str, float], now: float,
|
def forecast(self, x_raw: np.ndarray, anchors: Dict[str, float], now: float,
|
||||||
climatology=None) -> Dict[str, Dict[int, Dict[str, float]]]:
|
climatology=None, setpoint_fn=None) -> Dict[str, Dict[int, Dict[str, float]]]:
|
||||||
x = self.scaler.transform(np.atleast_2d(x_raw))[0]
|
x = self.scaler.transform(np.atleast_2d(x_raw))[0]
|
||||||
out: Dict[str, Dict[int, Dict[str, float]]] = {}
|
out: Dict[str, Dict[int, Dict[str, float]]] = {}
|
||||||
for target in self.targets:
|
for target in self.targets:
|
||||||
@@ -206,7 +348,8 @@ class NowcastEnsemble:
|
|||||||
if climatology is not None and climatology.ready:
|
if climatology is not None and climatology.ready:
|
||||||
clim_delta = float(climatology.predict(target, np.array([now + h]))[0]
|
clim_delta = float(climatology.predict(target, np.array([now + h]))[0]
|
||||||
- climatology.predict(target, np.array([now]))[0])
|
- climatology.predict(target, np.array([now]))[0])
|
||||||
out[target][h] = self.heads[(target, h)].predict(x, anchor, clim_delta)
|
sp = setpoint_fn(target, h, anchor) if setpoint_fn else 0.0
|
||||||
|
out[target][h] = self.heads[(target, h)].predict(x, anchor, clim_delta, sp)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def diagnostics(self) -> List[Dict]:
|
def diagnostics(self) -> List[Dict]:
|
||||||
@@ -235,11 +378,34 @@ class NowcastEnsemble:
|
|||||||
"scaler": self.scaler.to_dict(),
|
"scaler": self.scaler.to_dict(),
|
||||||
"heads": [h.to_dict() for h in self.heads.values()],
|
"heads": [h.to_dict() for h in self.heads.values()],
|
||||||
"trained_rows": self.trained_rows,
|
"trained_rows": self.trained_rows,
|
||||||
|
"refit_phase": self.refit_phase,
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_dict(self, s: Dict) -> None:
|
def load_dict(self, s: Dict) -> None:
|
||||||
self.scaler = Standardiser.from_dict(s["scaler"])
|
self.scaler = Standardiser.from_dict(s["scaler"])
|
||||||
for hs in s["heads"]:
|
for hs in s["heads"]:
|
||||||
head = ForecastHead.from_dict(hs)
|
head = ForecastHead.from_dict(hs)
|
||||||
self.heads[(head.target, head.horizon_s)] = head
|
key = (head.target, head.horizon_s)
|
||||||
|
if key not in self.heads:
|
||||||
|
continue # a target or horizon this build no longer has
|
||||||
|
self.heads[key] = head
|
||||||
self.trained_rows = s.get("trained_rows", 0)
|
self.trained_rows = s.get("trained_rows", 0)
|
||||||
|
self.refit_phase = int(s.get("refit_phase", 0))
|
||||||
|
self._apply_config_tuning()
|
||||||
|
|
||||||
|
def _apply_config_tuning(self) -> None:
|
||||||
|
"""Tuning comes from config; only the estimate comes from the file.
|
||||||
|
|
||||||
|
Every from_dict below this point restores its knobs alongside its data:
|
||||||
|
lambda, delta and p_max in the RLS, alpha, gamma and the window in the
|
||||||
|
conformal calibrator. So each of those was immutable in the field. Edit
|
||||||
|
config.yaml, restart, and the state file quietly puts the old value
|
||||||
|
back, which looks exactly like a change that had no effect. The same
|
||||||
|
defect cost a Kalman retune here before it was found.
|
||||||
|
"""
|
||||||
|
c = self.cfg
|
||||||
|
for head in self.heads.values():
|
||||||
|
head.model.lam = float(c.rls_forgetting)
|
||||||
|
head.model.delta = float(c.rls_delta)
|
||||||
|
head.conformal.retune(c.conformal_alpha, c.conformal_gamma,
|
||||||
|
c.conformal_window)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|||||||
+50
-3
@@ -47,8 +47,9 @@ class RecursiveLeastSquares:
|
|||||||
self.d = int(n_features)
|
self.d = int(n_features)
|
||||||
self.lam = float(forgetting)
|
self.lam = float(forgetting)
|
||||||
self.p_max = float(p_max)
|
self.p_max = float(p_max)
|
||||||
|
self.delta = float(delta) # kept so a refit can return to the prior
|
||||||
self.theta = np.zeros(self.d)
|
self.theta = np.zeros(self.d)
|
||||||
self.P = np.eye(self.d) * float(delta)
|
self.P = np.eye(self.d) * self.delta
|
||||||
self.n_updates = 0
|
self.n_updates = 0
|
||||||
self.ewma_sq_error = 0.0
|
self.ewma_sq_error = 0.0
|
||||||
|
|
||||||
@@ -102,14 +103,32 @@ class RecursiveLeastSquares:
|
|||||||
def noise_var(self) -> float:
|
def noise_var(self) -> float:
|
||||||
return float(max(self.ewma_sq_error, 1e-9))
|
return float(max(self.ewma_sq_error, 1e-9))
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Return to the prior, keeping the configuration.
|
||||||
|
|
||||||
|
A batch refit has to start from here rather than continuing, because
|
||||||
|
replaying the same history into a live filter is not the same as seeing
|
||||||
|
new data. RLS with forgetting treats every update as fresh evidence, so
|
||||||
|
feeding it the same rows on each retrain tick makes it believe it has
|
||||||
|
many times the data it has: P collapses, and the weights in directions
|
||||||
|
the data never excites drift without anything to pull them back.
|
||||||
|
"""
|
||||||
|
self.theta = np.zeros(self.d)
|
||||||
|
self.P = np.eye(self.d) * self.delta
|
||||||
|
self.n_updates = 0
|
||||||
|
self.ewma_sq_error = 0.0
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
def to_dict(self) -> Dict:
|
||||||
return {"d": self.d, "lam": self.lam, "p_max": self.p_max,
|
return {"d": self.d, "lam": self.lam, "p_max": self.p_max,
|
||||||
|
"delta": self.delta,
|
||||||
"theta": self.theta.tolist(), "P": self.P.tolist(),
|
"theta": self.theta.tolist(), "P": self.P.tolist(),
|
||||||
"n": self.n_updates, "ewma": self.ewma_sq_error}
|
"n": self.n_updates, "ewma": self.ewma_sq_error}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, s: Dict) -> "RecursiveLeastSquares":
|
def from_dict(cls, s: Dict) -> "RecursiveLeastSquares":
|
||||||
m = cls(s["d"], s["lam"], 1.0, s.get("p_max", 1e6))
|
# delta must survive the round trip or a refit after a restart would
|
||||||
|
# return to the wrong prior.
|
||||||
|
m = cls(s["d"], s["lam"], s.get("delta", 100.0), s.get("p_max", 1e6))
|
||||||
m.theta = np.array(s["theta"], dtype=float)
|
m.theta = np.array(s["theta"], dtype=float)
|
||||||
m.P = np.array(s["P"], dtype=float)
|
m.P = np.array(s["P"], dtype=float)
|
||||||
m.n_updates = s.get("n", 0)
|
m.n_updates = s.get("n", 0)
|
||||||
@@ -139,8 +158,22 @@ class AdaptiveConformal:
|
|||||||
self.scores: Deque[float] = deque(maxlen=int(window))
|
self.scores: Deque[float] = deque(maxlen=int(window))
|
||||||
self.hits: Deque[int] = deque(maxlen=int(window))
|
self.hits: Deque[int] = deque(maxlen=int(window))
|
||||||
|
|
||||||
|
# Fewest scores from which a (1-alpha) empirical quantile exists at all.
|
||||||
|
# For alpha = 0.10 the band is the ceil(0.9*(k+1))-th of k order statistics,
|
||||||
|
# which needs k >= 9. Below that there is no quantile to take and the
|
||||||
|
# Gaussian fallback is the only option.
|
||||||
|
#
|
||||||
|
# This was 20, which is arbitrary and became actively harmful once training
|
||||||
|
# pairs were strided by the horizon: the long-horizon heads then earn about
|
||||||
|
# 13 scores per refit, so twelve of eighteen heads fell through to
|
||||||
|
# 1.645*sigma with sigma taken from an unconstrained x'Px. That produced
|
||||||
|
# bands of +/- 45 C and +/- 115% RH on a young station. They cover, being
|
||||||
|
# far too wide, but a plus or minus of 115% relative humidity is not a
|
||||||
|
# forecast.
|
||||||
|
MIN_SCORES = 9
|
||||||
|
|
||||||
def quantile(self) -> float:
|
def quantile(self) -> float:
|
||||||
if len(self.scores) < 20:
|
if len(self.scores) < self.MIN_SCORES:
|
||||||
return float("nan")
|
return float("nan")
|
||||||
a = float(np.clip(self.alpha, 0.005, 0.75))
|
a = float(np.clip(self.alpha, 0.005, 0.75))
|
||||||
return float(np.quantile(np.asarray(self.scores), 1.0 - a, method="higher"))
|
return float(np.quantile(np.asarray(self.scores), 1.0 - a, method="higher"))
|
||||||
@@ -164,6 +197,20 @@ class AdaptiveConformal:
|
|||||||
self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err),
|
self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err),
|
||||||
0.005, 0.75))
|
0.005, 0.75))
|
||||||
|
|
||||||
|
def retune(self, alpha: float, gamma: float, window: int) -> None:
|
||||||
|
"""Re-apply configured tuning, keeping the observed scores.
|
||||||
|
|
||||||
|
from_dict restores alpha_target, gamma and the window alongside the
|
||||||
|
data, so editing any of them in config.yaml did nothing on a station
|
||||||
|
that already had state: the file put the old values straight back.
|
||||||
|
"""
|
||||||
|
self.alpha_target = float(alpha)
|
||||||
|
self.gamma = float(gamma)
|
||||||
|
window = int(window)
|
||||||
|
if self.scores.maxlen != window:
|
||||||
|
self.scores = deque(self.scores, maxlen=window)
|
||||||
|
self.hits = deque(self.hits, maxlen=window)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def empirical_coverage(self) -> float:
|
def empirical_coverage(self) -> float:
|
||||||
return float(np.mean(self.hits)) if self.hits else float("nan")
|
return float(np.mean(self.hits)) if self.hits else float("nan")
|
||||||
|
|||||||
+245
-5
@@ -24,21 +24,56 @@ same code to the Pi unchanged.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import random
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .physics import dew_point, sea_level_pressure, solar_position
|
from .physics import dew_point, sea_level_pressure, solar_position
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
TCS3400_ENABLE = 0x80
|
TCS3400_ENABLE = 0x80
|
||||||
TCS3400_ATIME = 0x81
|
TCS3400_ATIME = 0x81
|
||||||
TCS3400_CONTROL = 0x8F
|
TCS3400_CONTROL = 0x8F
|
||||||
TCS3400_CDATA = 0x94
|
TCS3400_CDATA = 0x94
|
||||||
|
|
||||||
|
|
||||||
|
def read_throttled() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Raspberry Pi undervoltage and throttling flags, or None if all clear.
|
||||||
|
|
||||||
|
Bit 0 is undervoltage now, 16 is undervoltage since boot, 2 is arm
|
||||||
|
frequency capped, 3 is thermal throttling. A capped or browning-out board
|
||||||
|
runs its SoC at a different temperature, and the SoC temperature is the
|
||||||
|
regressor in the self-heating compensation, so the visible symptom is a
|
||||||
|
temperature bias with no apparent cause.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True,
|
||||||
|
text=True, timeout=5).stdout.strip()
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
if "=" not in out:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
bits = int(out.split("=", 1)[1], 0)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if bits == 0:
|
||||||
|
return None
|
||||||
|
now = {0: "undervoltage", 1: "arm_capped", 2: "throttled", 3: "soft_temp_limit"}
|
||||||
|
ever = {16: "undervoltage_since_boot", 17: "arm_capped_since_boot",
|
||||||
|
18: "throttled_since_boot", 19: "soft_temp_limit_since_boot"}
|
||||||
|
active = [name for bit, name in now.items() if bits & (1 << bit)]
|
||||||
|
historic = [name for bit, name in ever.items() if bits & (1 << bit)]
|
||||||
|
return {"raw": hex(bits), "active": active, "since_boot": historic,
|
||||||
|
"severity": "warn" if active else "info"}
|
||||||
|
|
||||||
|
|
||||||
def read_cpu_temperature() -> float:
|
def read_cpu_temperature() -> float:
|
||||||
"""Core temperature in C. This is the single most important nuisance
|
"""Core temperature in C. This is the single most important nuisance
|
||||||
variable on a Sense HAT: the HTS221 and LPS25HB sit millimetres above a
|
variable on a Sense HAT: the HTS221 and LPS25HB sit millimetres above a
|
||||||
@@ -50,6 +85,55 @@ def read_cpu_temperature() -> float:
|
|||||||
return float("nan")
|
return float("nan")
|
||||||
|
|
||||||
|
|
||||||
|
# Per-chip thermal coupling to the SoC, and per-chip noise.
|
||||||
|
#
|
||||||
|
# The Sense HAT carries two independent thermometers at different distances
|
||||||
|
# from the SoC, and they are not equally good. Measured over 12 samples on a
|
||||||
|
# real board: HTS221 30.973 C at sd 0.060, LPS25HB 29.810 C at sd 0.443, a
|
||||||
|
# standing gradient of 1.163 C with the SoC at 44.55 C.
|
||||||
|
#
|
||||||
|
# These two couplings are chosen so their forward models average to exactly the
|
||||||
|
# k = 0.55 the compensator is tuned against. The aggregate behaviour is
|
||||||
|
# therefore unchanged and only the per-channel detail is new, which matters
|
||||||
|
# because that gradient is a second observation of self-heating.
|
||||||
|
K_HTS221, K_LPS25HB = 0.6164, 0.4889
|
||||||
|
SD_HTS221, SD_LPS25HB = 0.049, 0.007
|
||||||
|
|
||||||
|
|
||||||
|
class _ChannelNoise:
|
||||||
|
"""Running white-noise variance of one thermometer.
|
||||||
|
|
||||||
|
Taken from the first difference rather than a windowed variance. Over one
|
||||||
|
2 s sample the air moves far less than either chip's own jitter, so
|
||||||
|
var(diff)/2 is the noise and is blind to the weather underneath it. A
|
||||||
|
windowed variance would measure the weather instead and would rise, not
|
||||||
|
fall, on a calm day.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, prior_sd: float, lam: float = 0.995, warmup: int = 200):
|
||||||
|
self.var = float(prior_sd) ** 2
|
||||||
|
self.prior = self.var
|
||||||
|
self.lam = float(lam)
|
||||||
|
self.warmup = int(warmup)
|
||||||
|
self.last: Optional[float] = None
|
||||||
|
self.n = 0
|
||||||
|
|
||||||
|
def update(self, value: float) -> float:
|
||||||
|
if not math.isfinite(value):
|
||||||
|
return max(self.var, 1e-8)
|
||||||
|
if self.last is not None:
|
||||||
|
d = value - self.last
|
||||||
|
self.var = self.lam * self.var + (1.0 - self.lam) * (d * d / 2.0)
|
||||||
|
self.n += 1
|
||||||
|
self.last = value
|
||||||
|
if self.n < self.warmup:
|
||||||
|
# Blend toward the prior while the estimate is young, so one quiet
|
||||||
|
# minute cannot hand a channel 100% of the weight on no evidence.
|
||||||
|
w = self.n / float(self.warmup)
|
||||||
|
return max(w * self.var + (1.0 - w) * self.prior, 1e-8)
|
||||||
|
return max(self.var, 1e-8)
|
||||||
|
|
||||||
|
|
||||||
class SimulatedBoard:
|
class SimulatedBoard:
|
||||||
"""Ornstein-Uhlenbeck weather with a diurnal driver. Good enough to
|
"""Ornstein-Uhlenbeck weather with a diurnal driver. Good enough to
|
||||||
exercise every code path and to sanity-check a model's skill score."""
|
exercise every code path and to sanity-check a model's skill score."""
|
||||||
@@ -88,9 +172,12 @@ class SimulatedBoard:
|
|||||||
lux = max(0.0, 60000.0 * max(math.sin(math.radians(max(elev, 0.0))), 0.0)) + 8.0
|
lux = max(0.0, 60000.0 * max(math.sin(math.radians(max(elev, 0.0))), 0.0)) + 8.0
|
||||||
cpu = temp + 22.0 + 1.5 * self.rng.normal()
|
cpu = temp + 22.0 + 1.5 * self.rng.normal()
|
||||||
# forward model must invert the compensator exactly, see scripts/simulate.py
|
# forward model must invert the compensator exactly, see scripts/simulate.py
|
||||||
k_true = 0.55
|
t_h = (temp + K_HTS221 * cpu) / (1.0 + K_HTS221) + SD_HTS221 * self.rng.normal()
|
||||||
|
t_p = (temp + K_LPS25HB * cpu) / (1.0 + K_LPS25HB) + SD_LPS25HB * self.rng.normal()
|
||||||
return {
|
return {
|
||||||
"temp_raw": (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * self.rng.normal(),
|
"temp_raw": (t_h + t_p) / 2.0,
|
||||||
|
"temp_h": t_h,
|
||||||
|
"temp_p": t_p,
|
||||||
"hum": rh + 0.4 * self.rng.normal(),
|
"hum": rh + 0.4 * self.rng.normal(),
|
||||||
"press": press + 0.05 * self.rng.normal(),
|
"press": press + 0.05 * self.rng.normal(),
|
||||||
"cpu_temp": cpu,
|
"cpu_temp": cpu,
|
||||||
@@ -106,6 +193,95 @@ class SimulatedBoard:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OutdoorProbe:
|
||||||
|
"""Optional DS18B20 on the 1-Wire bus, read through the kernel's w1 driver.
|
||||||
|
|
||||||
|
Why this matters more than any model change: indoors the station forecasts
|
||||||
|
a room. Pressure passes through walls, temperature and humidity do not. One
|
||||||
|
three-pound sensor on a metre of cable outside the window removes the single
|
||||||
|
largest caveat in the project.
|
||||||
|
|
||||||
|
No new dependency. The kernel exposes each probe as a text file under
|
||||||
|
/sys/bus/w1/devices/28-*/w1_slave, so this is a file read and two string
|
||||||
|
splits. Enable with `dtoverlay=w1-gpio` in /boot/firmware/config.txt.
|
||||||
|
|
||||||
|
How it fails: the DS18B20 takes up to 750 ms to convert, and the driver
|
||||||
|
blocks for that whole time. Reading it on the 2 s sample loop would eat a
|
||||||
|
third of the budget on a single-issue core, so it is polled on its own
|
||||||
|
slower cadence and the last good value is reused in between. A probe that
|
||||||
|
goes missing (cable pulled, bad CRC) returns None rather than a stale value
|
||||||
|
forever: `age_s` lets the caller decide when to stop trusting it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ROOT = "/sys/bus/w1/devices"
|
||||||
|
|
||||||
|
def __init__(self, min_period_s: float = 20.0) -> None:
|
||||||
|
self.min_period_s = float(min_period_s)
|
||||||
|
self.device: Optional[str] = None
|
||||||
|
self.available = False
|
||||||
|
self.last_value: Optional[float] = None
|
||||||
|
self.last_ts: Optional[float] = None
|
||||||
|
self.errors = 0
|
||||||
|
self._discover()
|
||||||
|
|
||||||
|
def _discover(self) -> None:
|
||||||
|
try:
|
||||||
|
root = Path(self.ROOT)
|
||||||
|
if not root.is_dir():
|
||||||
|
return
|
||||||
|
probes = sorted(p for p in root.glob("28-*") if (p / "w1_slave").exists())
|
||||||
|
if probes:
|
||||||
|
self.device = str(probes[0] / "w1_slave")
|
||||||
|
self.available = True
|
||||||
|
log.info("outdoor probe found at %s", self.device)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("1-wire scan failed: %r", exc)
|
||||||
|
|
||||||
|
def read(self) -> Optional[float]:
|
||||||
|
"""Celsius, or None. Cached between polls so the sample loop never blocks."""
|
||||||
|
if not self.available or self.device is None:
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
if self.last_ts is not None and (now - self.last_ts) < self.min_period_s:
|
||||||
|
return self.last_value
|
||||||
|
try:
|
||||||
|
with open(self.device, "r") as fh:
|
||||||
|
text = fh.read()
|
||||||
|
except OSError as exc:
|
||||||
|
self.errors += 1
|
||||||
|
log.warning("outdoor probe read failed: %r", exc)
|
||||||
|
return self.last_value
|
||||||
|
# Two lines: the first ends in YES only when the CRC checked out.
|
||||||
|
if "YES" not in text.split("\n")[0]:
|
||||||
|
self.errors += 1
|
||||||
|
return self.last_value
|
||||||
|
marker = text.find("t=")
|
||||||
|
if marker < 0:
|
||||||
|
self.errors += 1
|
||||||
|
return self.last_value
|
||||||
|
try:
|
||||||
|
milli = int(text[marker + 2:].strip())
|
||||||
|
except ValueError:
|
||||||
|
self.errors += 1
|
||||||
|
return self.last_value
|
||||||
|
# 85000 is the DS18B20 power-on default and means "never converted".
|
||||||
|
if milli == 85000:
|
||||||
|
self.errors += 1
|
||||||
|
return self.last_value
|
||||||
|
value = milli / 1000.0
|
||||||
|
if not (-55.0 <= value <= 125.0):
|
||||||
|
self.errors += 1
|
||||||
|
return self.last_value
|
||||||
|
self.last_value = value
|
||||||
|
self.last_ts = now
|
||||||
|
return value
|
||||||
|
|
||||||
|
def status(self) -> Dict[str, Any]:
|
||||||
|
age = None if self.last_ts is None else round(time.time() - self.last_ts, 1)
|
||||||
|
return {"available": self.available, "device": self.device,
|
||||||
|
"value_c": self.last_value, "age_s": age, "errors": self.errors}
|
||||||
|
|
||||||
|
|
||||||
class SenseBoard:
|
class SenseBoard:
|
||||||
"""Real hardware wrapper. Attribute `available` tells you which world
|
"""Real hardware wrapper. Attribute `available` tells you which world
|
||||||
you are in without try/except at every call site."""
|
you are in without try/except at every call site."""
|
||||||
@@ -118,6 +294,13 @@ class SenseBoard:
|
|||||||
self.bus = None
|
self.bus = None
|
||||||
self.tcs_addr = tcs_addr
|
self.tcs_addr = tcs_addr
|
||||||
self._sim = SimulatedBoard(latitude, longitude)
|
self._sim = SimulatedBoard(latitude, longitude)
|
||||||
|
self._noise_h = _ChannelNoise(SD_HTS221)
|
||||||
|
self._noise_p = _ChannelNoise(SD_LPS25HB)
|
||||||
|
# Slow EWMA of the standing gradient between the two chips. About a
|
||||||
|
# 10-minute time constant at the 2 s cadence: long enough to ignore
|
||||||
|
# per-sample noise, short enough to follow a real change in SoC load.
|
||||||
|
self._gradient: Optional[float] = None
|
||||||
|
self._gradient_lam = 0.9967
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from sense_hat import SenseHat # type: ignore
|
from sense_hat import SenseHat # type: ignore
|
||||||
@@ -154,6 +337,48 @@ class SenseBoard:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return {"clear": 0, "red": 0, "green": 0, "blue": 0, "hex": "#334155", "cct": None}
|
return {"clear": 0, "red": 0, "green": 0, "blue": 0, "hex": "#334155", "cct": None}
|
||||||
|
|
||||||
|
def _fuse(self, t_h: float, t_p: float) -> tuple[float, float]:
|
||||||
|
"""Combine the two thermometers by inverse variance.
|
||||||
|
|
||||||
|
A plain average of a quiet sensor and a noisy one throws the quiet one
|
||||||
|
away. Measured on the board at 0.5 s: the LPS25HB carries a white-noise
|
||||||
|
sd of 0.007 C against the HTS221's 0.049 C, so optimal weighting is
|
||||||
|
about 98/2 and cuts the raw noise by roughly 3.7x.
|
||||||
|
|
||||||
|
The trap is that the two chips do not agree. They sit at different
|
||||||
|
distances from the SoC and stand about 1.3 C apart, so weighting them
|
||||||
|
by variance would drag temp_raw most of the way onto the LPS25HB and
|
||||||
|
shift it by more than half a degree. The compensator's k was fitted
|
||||||
|
against the mean of the two, and after the 1.55x gain of the inverse
|
||||||
|
model that is a full degree of silent bias on every reading and every
|
||||||
|
forecast built from it.
|
||||||
|
|
||||||
|
So the gradient is tracked and removed before weighting, and only the
|
||||||
|
deviations are fused. The mean is left exactly where the average put
|
||||||
|
it, k stays valid, and the noise still falls. The gradient itself is
|
||||||
|
kept because it is a second observation of self-heating and is what
|
||||||
|
would let k be identified without a reference thermometer.
|
||||||
|
"""
|
||||||
|
if not (math.isfinite(t_h) and math.isfinite(t_p)):
|
||||||
|
good = [v for v in (t_h, t_p) if math.isfinite(v)]
|
||||||
|
return (good[0] if good else float("nan")), float("nan")
|
||||||
|
|
||||||
|
var_h = self._noise_h.update(t_h)
|
||||||
|
var_p = self._noise_p.update(t_p)
|
||||||
|
|
||||||
|
gap = t_h - t_p
|
||||||
|
if self._gradient is None:
|
||||||
|
self._gradient = gap
|
||||||
|
else:
|
||||||
|
lam = self._gradient_lam
|
||||||
|
self._gradient = lam * self._gradient + (1.0 - lam) * gap
|
||||||
|
|
||||||
|
# Centre both channels on what the plain average would have reported.
|
||||||
|
half = self._gradient / 2.0
|
||||||
|
w_h, w_p = 1.0 / var_h, 1.0 / var_p
|
||||||
|
fused = (w_h * (t_h - half) + w_p * (t_p + half)) / (w_h + w_p)
|
||||||
|
return float(fused), float(1.0 / (w_h + w_p))
|
||||||
|
|
||||||
def read(self) -> Dict[str, Any]:
|
def read(self) -> Dict[str, Any]:
|
||||||
"""One full multi-sensor sample. Raw, uncompensated, untouched."""
|
"""One full multi-sensor sample. Raw, uncompensated, untouched."""
|
||||||
if not self.available:
|
if not self.available:
|
||||||
@@ -166,6 +391,7 @@ class SenseBoard:
|
|||||||
s = self.sense
|
s = self.sense
|
||||||
t_h = s.get_temperature_from_humidity()
|
t_h = s.get_temperature_from_humidity()
|
||||||
t_p = s.get_temperature_from_pressure()
|
t_p = s.get_temperature_from_pressure()
|
||||||
|
temp_raw, temp_var = self._fuse(t_h, t_p)
|
||||||
orientation = s.get_orientation_degrees()
|
orientation = s.get_orientation_degrees()
|
||||||
accel = s.get_accelerometer_raw()
|
accel = s.get_accelerometer_raw()
|
||||||
gyro = s.get_gyroscope_raw()
|
gyro = s.get_gyroscope_raw()
|
||||||
@@ -175,7 +401,8 @@ class SenseBoard:
|
|||||||
return v - 360.0 if v > 180.0 else v
|
return v - 360.0 if v > 180.0 else v
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"temp_raw": (t_h + t_p) / 2.0,
|
"temp_raw": temp_raw,
|
||||||
|
"temp_var": temp_var,
|
||||||
"temp_h": t_h,
|
"temp_h": t_h,
|
||||||
"temp_p": t_p,
|
"temp_p": t_p,
|
||||||
"hum": s.get_humidity(),
|
"hum": s.get_humidity(),
|
||||||
@@ -192,6 +419,19 @@ class SenseBoard:
|
|||||||
"simulated": False,
|
"simulated": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def stick_events(self) -> List[Tuple[str, str]]:
|
||||||
|
"""Pending joystick events as (direction, action), oldest first.
|
||||||
|
|
||||||
|
Non-blocking, and returns [] when nothing has happened. The library
|
||||||
|
buffers events, so polling slowly loses none of them.
|
||||||
|
"""
|
||||||
|
if self.sense is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return [(e.direction, e.action) for e in self.sense.stick.get_events()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
# --------------------------------------------------------------- LED
|
# --------------------------------------------------------------- LED
|
||||||
|
|
||||||
def clear(self, *args):
|
def clear(self, *args):
|
||||||
|
|||||||
Vendored
+20
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,396 @@
|
|||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* cyrillic */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||||
|
}
|
||||||
|
/* greek */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxPcwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx_cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx7cwgknk-6nFg.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwgknk-4.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
/* cyrillic-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko70yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||||
|
}
|
||||||
|
/* vietnamese */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko50yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||||
|
}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko40yygg_vbd-E.woff2) format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
/* latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Plus Jakarta Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url(fonts/gf-LDIoaomQNQcsA88c7O9yZ4KMCoOg4Ko20yygg_vb.woff2) format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
Vendored
+7
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+380
-11
@@ -37,19 +37,19 @@ import json
|
|||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from . import physics
|
from . import physics
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .estimation import SignalTracker
|
from .estimation import KalmanCV, SignalTracker
|
||||||
from .features import N_FEATURES, build_features
|
from .features import build_features
|
||||||
from .models.anomaly import AnomalyMonitor
|
from .models.anomaly import AnomalyMonitor
|
||||||
from .models.climatology import HarmonicClimatology
|
from .models.climatology import HarmonicClimatology
|
||||||
from .models.nowcast import NowcastEnsemble
|
from .models.nowcast import NowcastEnsemble
|
||||||
from .models.precip import PrecipitationModel, proxy_wet_label, zambretti
|
from .models.precip import PrecipitationModel, proxy_wet_label, zambretti
|
||||||
from .sensors import SenseBoard, enrich
|
from .sensors import OutdoorProbe, SenseBoard, enrich, read_throttled
|
||||||
from .storage import Store, resample
|
from .storage import Store, resample
|
||||||
|
|
||||||
STATE_VERSION = 1
|
STATE_VERSION = 1
|
||||||
@@ -66,6 +66,14 @@ class Station:
|
|||||||
latitude=cfg.site.latitude,
|
latitude=cfg.site.latitude,
|
||||||
longitude=cfg.site.longitude,
|
longitude=cfg.site.longitude,
|
||||||
)
|
)
|
||||||
|
# Optional and entirely absent on a board without one wired up.
|
||||||
|
# Set by the API layer once the LED display exists, so the joystick
|
||||||
|
# can acknowledge a press and cycle scenes. None when there is no HAT.
|
||||||
|
self.display = None
|
||||||
|
self._last_tilt: Optional[Tuple[float, float]] = None
|
||||||
|
self._started_at = time.time()
|
||||||
|
self.probe = (OutdoorProbe(cfg.sensor.outdoor_probe_period_s)
|
||||||
|
if cfg.sensor.outdoor_probe else None)
|
||||||
self.tracker = SignalTracker(cfg)
|
self.tracker = SignalTracker(cfg)
|
||||||
self.nowcast = NowcastEnsemble(cfg.model.targets, cfg.model.horizons_s, cfg.model)
|
self.nowcast = NowcastEnsemble(cfg.model.targets, cfg.model.horizons_s, cfg.model)
|
||||||
self.climatology = HarmonicClimatology(
|
self.climatology = HarmonicClimatology(
|
||||||
@@ -149,6 +157,8 @@ class Station:
|
|||||||
row = {
|
row = {
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
"temp_raw": raw.get("temp_raw"),
|
"temp_raw": raw.get("temp_raw"),
|
||||||
|
"temp_h": raw.get("temp_h"),
|
||||||
|
"temp_p": raw.get("temp_p"),
|
||||||
"temp_c": est["temp_c"],
|
"temp_c": est["temp_c"],
|
||||||
"temp_smooth": temp_c,
|
"temp_smooth": temp_c,
|
||||||
"temp_rate": est["temp_rate"],
|
"temp_rate": est["temp_rate"],
|
||||||
@@ -174,6 +184,7 @@ class Station:
|
|||||||
"dew_c": dew, "cpu_temp": raw.get("cpu_temp"),
|
"dew_c": dew, "cpu_temp": raw.get("cpu_temp"),
|
||||||
})
|
})
|
||||||
self.anomaly_bundle = anomaly
|
self.anomaly_bundle = anomaly
|
||||||
|
self._check_moved(ts, row)
|
||||||
|
|
||||||
self.live = {
|
self.live = {
|
||||||
**row,
|
**row,
|
||||||
@@ -191,6 +202,9 @@ class Station:
|
|||||||
"cloud_index": cloud,
|
"cloud_index": cloud,
|
||||||
"cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")),
|
"cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")),
|
||||||
"compensator_k": self.tracker.compensator.k,
|
"compensator_k": self.tracker.compensator.k,
|
||||||
|
"hum_offset": self.tracker.hum_compensator.offset,
|
||||||
|
"outdoor_c": (self.probe.read() if self.probe is not None else None),
|
||||||
|
"hum_psychrometric": float(est["hum_c"]) - float(raw.get("hum") or float("nan")),
|
||||||
"health": anomaly["health_overall"],
|
"health": anomaly["health_overall"],
|
||||||
"novelty_d2": anomaly["novelty"].get("d2", 0.0),
|
"novelty_d2": anomaly["novelty"].get("d2", 0.0),
|
||||||
}
|
}
|
||||||
@@ -264,8 +278,256 @@ class Station:
|
|||||||
result = self.tracker.compensator.calibrate(float(raw), float(cpu), float(reference_c))
|
result = self.tracker.compensator.calibrate(float(raw), float(cpu), float(reference_c))
|
||||||
self.store.log_event("calibration", "info",
|
self.store.log_event("calibration", "info",
|
||||||
f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)")
|
f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)")
|
||||||
|
# Discontinuity marker: everything logged before this instant used a
|
||||||
|
# different coefficient. Kept as its own event kind so the scorecard and
|
||||||
|
# the records view can find it without parsing prose.
|
||||||
|
self.store.log_event("discontinuity", "warn",
|
||||||
|
f"temperature k {result['k']:.4f}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def calibrate_humidity(self, reference_pct: float) -> Dict:
|
||||||
|
raw_h = self.live.get("hum")
|
||||||
|
raw_t = self.live.get("temp_raw")
|
||||||
|
temp_c = self.live.get("temp_c")
|
||||||
|
if raw_h is None or raw_t is None or temp_c is None:
|
||||||
|
return {"error": "no live reading yet"}
|
||||||
|
result = self.tracker.hum_compensator.calibrate(
|
||||||
|
float(raw_h), float(raw_t), float(temp_c), float(reference_pct))
|
||||||
|
self.save_state()
|
||||||
|
self.store.log_event("calibration", "info",
|
||||||
|
f"rh offset -> {result['offset']:+.2f}% "
|
||||||
|
f"(residual {result['residual']:+.2f}%)")
|
||||||
|
self.store.log_event("discontinuity", "warn",
|
||||||
|
f"humidity offset {result['offset']:+.4f}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def reset_humidity_calibration(self) -> Dict:
|
||||||
|
from .estimation import HumidityCompensator
|
||||||
|
self.tracker.hum_compensator = HumidityCompensator(
|
||||||
|
self.cfg.sensor.hum_offset, self.cfg.sensor.hum_offset_min,
|
||||||
|
self.cfg.sensor.hum_offset_max,
|
||||||
|
psychrometric=self.cfg.sensor.hum_psychrometric,
|
||||||
|
)
|
||||||
|
self.save_state()
|
||||||
|
self.store.log_event("calibration", "info",
|
||||||
|
f"rh offset reset to prior {self.cfg.sensor.hum_offset}")
|
||||||
|
return {"offset": self.tracker.hum_compensator.offset, "reset": True, "n": 0}
|
||||||
|
|
||||||
|
def recompute_history(self) -> Dict:
|
||||||
|
"""Re-derive every compensated column from the stored raw values.
|
||||||
|
|
||||||
|
Why this exists: calibration only changes readings from that moment on,
|
||||||
|
so a correction of any size leaves a step in the record. Measured on this
|
||||||
|
station, one humidity calibration put a 25-point discontinuity through
|
||||||
|
the middle of the day. That contaminates the all-time records with values
|
||||||
|
that were never real weather, and makes the learners train across a jump.
|
||||||
|
|
||||||
|
It is possible at all because the raw columns are never overwritten:
|
||||||
|
`temp_raw`, `cpu_temp` and `hum` are exactly what the sensor reported, so
|
||||||
|
the current coefficients can be applied to the whole history.
|
||||||
|
|
||||||
|
The Kalman levels are re-run rather than shifted, because the filter is
|
||||||
|
not a constant offset. That means the smoothing is *re-derived*, not bit
|
||||||
|
identical to what was logged live: the replay sees the stored cadence,
|
||||||
|
which for tiered rows is coarser than the 2 s the filter runs at. The
|
||||||
|
levels are right, the fine texture of old raw rows is not recoverable.
|
||||||
|
"""
|
||||||
|
data = self.store.all_for_recompute()
|
||||||
|
ts = data["ts"]
|
||||||
|
if ts.size == 0:
|
||||||
|
return {"rows": 0, "reason": "no history"}
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
comp, hcomp = self.tracker.compensator, self.tracker.hum_compensator
|
||||||
|
n = ts.size
|
||||||
|
temp_c = np.empty(n)
|
||||||
|
hum_c = np.empty(n)
|
||||||
|
for i in range(n):
|
||||||
|
tr, cp, hu = data["temp_raw"][i], data["cpu_temp"][i], data["hum"][i]
|
||||||
|
temp_c[i] = comp.compensate(tr, cp) if np.isfinite(tr) and np.isfinite(cp) else tr
|
||||||
|
hum_c[i] = (hcomp.compensate(hu, tr, temp_c[i])
|
||||||
|
if np.isfinite(hu) and np.isfinite(tr) else hu)
|
||||||
|
|
||||||
|
# Replay the filters over the corrected series. Fresh instances, so an
|
||||||
|
# old contaminated state cannot leak into the re-derivation.
|
||||||
|
kt = KalmanCV(self.cfg.sensor.kalman_q_temp, self.cfg.sensor.kalman_r_temp)
|
||||||
|
kh = KalmanCV(self.cfg.sensor.kalman_q_hum, self.cfg.sensor.kalman_r_hum)
|
||||||
|
q_temp = float(self.cfg.sensor.kalman_q_temp)
|
||||||
|
q_hum = float(self.cfg.sensor.kalman_q_hum)
|
||||||
|
live_dt = float(self.cfg.sensor.sample_period_s)
|
||||||
|
temp_s = np.empty(n)
|
||||||
|
temp_r = np.empty(n)
|
||||||
|
hum_s = np.empty(n)
|
||||||
|
prev = None
|
||||||
|
for i in range(n):
|
||||||
|
dt = live_dt if prev is None else max(ts[i] - prev, 1e-3)
|
||||||
|
prev = ts[i]
|
||||||
|
# q is tuned for the live 2 s cadence and Q scales with dt^3, so
|
||||||
|
# replaying stored rows at their own spacing (30 s raw, 300 s and
|
||||||
|
# 3600 s once tiered) inflates the process noise by up to seven
|
||||||
|
# orders of magnitude. The filter then abandons smoothing and tracks
|
||||||
|
# measurement noise, which showed up as indoor rates of +/-20 C/h.
|
||||||
|
# Rescaled per step because tiers mean the cadence is not constant.
|
||||||
|
scale = (live_dt / dt) ** 3
|
||||||
|
kt.q = q_temp * scale
|
||||||
|
kh.q = q_hum * scale
|
||||||
|
lvl, rate = kt.update(temp_c[i], dt)
|
||||||
|
temp_s[i], temp_r[i] = lvl, rate * 3600.0
|
||||||
|
hum_s[i], _ = kh.update(hum_c[i], dt)
|
||||||
|
|
||||||
|
dew = np.asarray(physics.dew_point(temp_s, hum_s), dtype=float)
|
||||||
|
slp = np.asarray(physics.sea_level_pressure(
|
||||||
|
data["press"], temp_s, self.cfg.site.altitude_m), dtype=float)
|
||||||
|
|
||||||
|
written = self.store.apply_recompute(ts, {
|
||||||
|
"temp_c": temp_c, "temp_smooth": temp_s, "temp_rate": temp_r,
|
||||||
|
"hum_smooth": hum_s, "dew_c": dew, "press_slp": slp,
|
||||||
|
})
|
||||||
|
secs = time.time() - t0
|
||||||
|
self.store.log_event(
|
||||||
|
"recompute", "info",
|
||||||
|
f"re-derived {written} rows from raw with k={comp.k:.4f}, "
|
||||||
|
f"rh offset={hcomp.offset:+.2f}% in {secs:.1f}s")
|
||||||
|
return {"rows": written, "seconds": round(secs, 2),
|
||||||
|
"k": comp.k, "hum_offset": hcomp.offset}
|
||||||
|
|
||||||
|
def _setpoint_delta(self, target: str, horizon_s: int, anchor: float) -> float:
|
||||||
|
"""Where a thermostatted room is heading, as a delta from now.
|
||||||
|
|
||||||
|
A controlled room is first order: the heating closes the gap to the
|
||||||
|
setpoint exponentially, so after time h the remaining error is
|
||||||
|
exp(-h/tau) of what it was. The expected change is therefore
|
||||||
|
|
||||||
|
dT(h) = (T_set - T_now) * (1 - exp(-h / tau))
|
||||||
|
|
||||||
|
which is zero at h=0 and asymptotes to the full correction. That is a
|
||||||
|
much better statement about a heated room than persistence, which claims
|
||||||
|
the room stays wherever it happens to be.
|
||||||
|
|
||||||
|
Humidity follows for free and is the part people get wrong. Heating adds
|
||||||
|
no moisture, so vapour pressure is what is conserved, not relative
|
||||||
|
humidity. Warm the air and RH falls even though nothing was dried:
|
||||||
|
|
||||||
|
RH(h) = RH_now * es(T_now) / es(T_now + dT(h))
|
||||||
|
|
||||||
|
This is why a heated house in winter is dry. Pressure is unaffected: a
|
||||||
|
thermostat cannot move the synoptic field, so that member stays at zero
|
||||||
|
and the ensemble will correctly ignore it.
|
||||||
|
|
||||||
|
Returns 0.0 when heating is off, which makes this member identical to
|
||||||
|
persistence and therefore harmless.
|
||||||
|
"""
|
||||||
|
site = self.cfg.site
|
||||||
|
if not site.heating:
|
||||||
|
return 0.0
|
||||||
|
tau_s = max(float(site.thermal_time_constant_h), 0.05) * 3600.0
|
||||||
|
closed = 1.0 - math.exp(-float(horizon_s) / tau_s)
|
||||||
|
|
||||||
|
temp_now = self.live.get("temp_smooth")
|
||||||
|
if temp_now is None:
|
||||||
|
return 0.0
|
||||||
|
d_temp = (float(site.heating_setpoint_c) - float(temp_now)) * closed
|
||||||
|
|
||||||
|
if target == "temperature":
|
||||||
|
return d_temp
|
||||||
|
if target == "humidity":
|
||||||
|
# Constant vapour pressure, so RH moves only because es(T) moved.
|
||||||
|
es_now = float(physics.saturation_vapour_pressure(temp_now))
|
||||||
|
es_fut = float(physics.saturation_vapour_pressure(temp_now + d_temp))
|
||||||
|
if es_fut <= 1e-9:
|
||||||
|
return 0.0
|
||||||
|
rh_now = float(anchor)
|
||||||
|
return float(np.clip(rh_now * es_now / es_fut, 0.0, 100.0)) - rh_now
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Fused tilt change that counts as the board having been picked up. The
|
||||||
|
# noise floor of the fused pitch and roll is 0.0001 degrees at p99, so a
|
||||||
|
# one degree trigger carries four decades of headroom and false positives
|
||||||
|
# are not a concern.
|
||||||
|
#
|
||||||
|
# The raw accelerometer is the obvious input and is the wrong one. Over the
|
||||||
|
# same four and a half days it fires 112 times against this detector's 4,
|
||||||
|
# because RTIMULib's gyro fusion removes exactly the desk vibration that a
|
||||||
|
# bare gravity vector picks up. Yaw and compass are excluded for the
|
||||||
|
# opposite reason: they depend on the magnetometer, which indoors is
|
||||||
|
# measuring the building.
|
||||||
|
TILT_MOVED_DEG = 1.0
|
||||||
|
# RTIMULib restarts its fusion from a default attitude when SenseHat is
|
||||||
|
# reconstructed, which put an 18 degree step in the record on every one of
|
||||||
|
# this station's service restarts. Without this guard every deploy would
|
||||||
|
# look like someone had picked the board up.
|
||||||
|
TILT_SETTLE_S = 300.0
|
||||||
|
|
||||||
|
def _check_moved(self, ts: float, row: Dict[str, Any]) -> None:
|
||||||
|
"""Detect the board being moved, and treat it as a regime change.
|
||||||
|
|
||||||
|
Measured on this station, a move steps the temperature by a median of
|
||||||
|
1.02 C against an ordinary fifteen minute change of 0.107 C, which puts
|
||||||
|
it past the 95th percentile of normal variation. The heads carry about
|
||||||
|
55 hours of memory, so an undeclared move contaminates two days of
|
||||||
|
training with a discontinuity they will try to fit rather than ignore.
|
||||||
|
|
||||||
|
This is the same treatment set_environment gives a window being opened,
|
||||||
|
because it is the same kind of event: the coupling between the sensor
|
||||||
|
and what it is measuring changed, and nothing in the data says so.
|
||||||
|
"""
|
||||||
|
p, r = row.get("pitch"), row.get("roll")
|
||||||
|
if p is None or r is None:
|
||||||
|
return
|
||||||
|
p, r = float(p), float(r)
|
||||||
|
if not (np.isfinite(p) and np.isfinite(r)):
|
||||||
|
return
|
||||||
|
prev, self._last_tilt = self._last_tilt, (p, r)
|
||||||
|
if prev is None or ts - self._started_at < self.TILT_SETTLE_S:
|
||||||
|
return
|
||||||
|
moved = float(np.hypot(p - prev[0], r - prev[1]))
|
||||||
|
if moved < self.TILT_MOVED_DEG:
|
||||||
|
return
|
||||||
|
detail = json.dumps({"tilt_deg": round(moved, 2),
|
||||||
|
"pitch": round(p, 2), "roll": round(r, 2),
|
||||||
|
"temp_c": row.get("temp_smooth")})
|
||||||
|
self.store.log_event("moved", "warn", detail, ts)
|
||||||
|
self.store.log_event("discontinuity", "warn",
|
||||||
|
f"board moved {moved:.1f} degrees, queuing a retrain", ts)
|
||||||
|
self.monitor.retrain_requested = True
|
||||||
|
|
||||||
|
def set_environment(self, environment: Optional[str] = None,
|
||||||
|
enclosure: Optional[str] = None,
|
||||||
|
note: str = "") -> Dict:
|
||||||
|
"""Record a change in the sensor's surroundings and act on it.
|
||||||
|
|
||||||
|
Not cosmetic. A door closing changes how strongly the sensor couples to
|
||||||
|
outside, which is a regime change in the very process the heads are
|
||||||
|
fitting. Their forgetting factor is 0.9985 on a five minute grid, about
|
||||||
|
55 hours of memory, so left alone they keep predicting the old room for
|
||||||
|
two days. Page-Hinkley would eventually notice from forecast error, but
|
||||||
|
it needs matured forecasts to do it, which at the longer horizons is
|
||||||
|
exactly the two days you were trying to skip.
|
||||||
|
|
||||||
|
So this does three things: writes a discontinuity marker so the record
|
||||||
|
shows where the regime changed, requests a retrain so the fit is redone
|
||||||
|
against recent data rather than drifting, and stores the new state for
|
||||||
|
the API and the Methods page to report honestly.
|
||||||
|
"""
|
||||||
|
changed = []
|
||||||
|
if environment and environment != self.cfg.site.environment:
|
||||||
|
changed.append(f"environment {self.cfg.site.environment} -> {environment}")
|
||||||
|
self.cfg.site.environment = environment
|
||||||
|
if enclosure and enclosure != self.cfg.site.enclosure:
|
||||||
|
changed.append(f"enclosure {self.cfg.site.enclosure} -> {enclosure}")
|
||||||
|
self.cfg.site.enclosure = enclosure
|
||||||
|
if not changed:
|
||||||
|
return {"changed": False, "environment": self.cfg.site.environment,
|
||||||
|
"enclosure": self.cfg.site.enclosure}
|
||||||
|
|
||||||
|
detail = "; ".join(changed) + (f" ({note})" if note else "")
|
||||||
|
self.store.log_event("environment", "info", detail)
|
||||||
|
self.store.log_event("discontinuity", "warn", detail)
|
||||||
|
self.monitor.retrain_requested = True
|
||||||
|
return {"changed": True, "environment": self.cfg.site.environment,
|
||||||
|
"enclosure": self.cfg.site.enclosure,
|
||||||
|
"retrain_requested": True, "detail": detail}
|
||||||
|
|
||||||
def reset_calibration(self) -> Dict:
|
def reset_calibration(self) -> Dict:
|
||||||
"""Return the self-heating coefficient to its configured prior.
|
"""Return the self-heating coefficient to its configured prior.
|
||||||
|
|
||||||
@@ -285,6 +547,22 @@ class Station:
|
|||||||
|
|
||||||
# ------------------------------------------------------------ train
|
# ------------------------------------------------------------ train
|
||||||
|
|
||||||
|
# 2025-01-01. Any clock below this has not been set since boot, because
|
||||||
|
# this project did not exist before it.
|
||||||
|
CLOCK_FLOOR = 1735689600.0
|
||||||
|
|
||||||
|
def clock_sanity(self) -> Dict[str, Any]:
|
||||||
|
"""Is the wall clock usable for anything time-dependent?"""
|
||||||
|
now = time.time()
|
||||||
|
if now < self.CLOCK_FLOOR:
|
||||||
|
return {"ok": False, "now": now,
|
||||||
|
"reason": "clock is before 2025, so it has not been set since boot"}
|
||||||
|
newest = self.store.newest_ts()
|
||||||
|
if newest is not None and now < newest - 60.0:
|
||||||
|
return {"ok": False, "now": now, "newest": newest,
|
||||||
|
"reason": f"clock is {newest - now:.0f}s behind the newest stored row"}
|
||||||
|
return {"ok": True, "now": now}
|
||||||
|
|
||||||
def build_training_grid(self, hours: float = 24 * 30):
|
def build_training_grid(self, hours: float = 24 * 30):
|
||||||
raw = self.store.window(hours, ["ts", "temp_smooth", "hum_smooth",
|
raw = self.store.window(hours, ["ts", "temp_smooth", "hum_smooth",
|
||||||
"press_slp", "lux"])
|
"press_slp", "lux"])
|
||||||
@@ -302,11 +580,22 @@ class Station:
|
|||||||
grid_ts, cols["temperature"], cols["humidity"], cols["pressure"],
|
grid_ts, cols["temperature"], cols["humidity"], cols["pressure"],
|
||||||
cols["lux"], self.cfg.model.grid_s,
|
cols["lux"], self.cfg.model.grid_s,
|
||||||
self.cfg.site.latitude, self.cfg.site.longitude,
|
self.cfg.site.latitude, self.cfg.site.longitude,
|
||||||
|
self.cfg.model.climatology_min_days_annual,
|
||||||
)
|
)
|
||||||
return grid_ts, cols, X, valid
|
return grid_ts, cols, X, valid
|
||||||
|
|
||||||
def train(self, hours: float = 24 * 30) -> Dict:
|
def train(self, hours: float = 24 * 30) -> Dict:
|
||||||
t_start = time.time()
|
t_start = time.time()
|
||||||
|
clock = self.clock_sanity()
|
||||||
|
if not clock["ok"]:
|
||||||
|
# The board has no RTC. A power cut without a network gives a clock
|
||||||
|
# somewhere in 1970 on the next boot, and every feature that depends
|
||||||
|
# on absolute time then lies with total confidence: solar elevation,
|
||||||
|
# the diurnal harmonics, the position of a sample on the 5-minute
|
||||||
|
# grid. Training on that poisons the weights, and unlike a gap in
|
||||||
|
# the record it cannot be spotted afterwards.
|
||||||
|
self.store.log_event("clock", "error", json.dumps(clock))
|
||||||
|
return {"trained": False, "reason": clock["reason"]}
|
||||||
built = self.build_training_grid(hours)
|
built = self.build_training_grid(hours)
|
||||||
if built is None:
|
if built is None:
|
||||||
return {"trained": False,
|
return {"trained": False,
|
||||||
@@ -314,7 +603,7 @@ class Station:
|
|||||||
grid_ts, cols, X, valid = built
|
grid_ts, cols, X, valid = built
|
||||||
|
|
||||||
clim_scores = self.climatology.fit(grid_ts, cols, valid)
|
clim_scores = self.climatology.fit(grid_ts, cols, valid)
|
||||||
counts = self.nowcast.fit(X, valid, cols, self.climatology, grid_ts)
|
counts = self.nowcast.fit(X, valid, cols)
|
||||||
|
|
||||||
self.last_train = time.time()
|
self.last_train = time.time()
|
||||||
self.monitor.clear_retrain_flag()
|
self.monitor.clear_retrain_flag()
|
||||||
@@ -350,7 +639,7 @@ class Station:
|
|||||||
"humidity": float(self.live.get("hum_smooth", cols["humidity"][-1])),
|
"humidity": float(self.live.get("hum_smooth", cols["humidity"][-1])),
|
||||||
"pressure": float(self.live.get("press_slp", cols["pressure"][-1])),
|
"pressure": float(self.live.get("press_slp", cols["pressure"][-1])),
|
||||||
}
|
}
|
||||||
fc = self.nowcast.forecast(x_now, anchors, now, self.climatology)
|
fc = self.nowcast.forecast(x_now, anchors, now, self.climatology, setpoint_fn=self._setpoint_delta)
|
||||||
|
|
||||||
bundle: Dict[str, Any] = {"issued_ts": now, "anchors": anchors, "targets": {}}
|
bundle: Dict[str, Any] = {"issued_ts": now, "anchors": anchors, "targets": {}}
|
||||||
for target, per_h in fc.items():
|
for target, per_h in fc.items():
|
||||||
@@ -369,7 +658,8 @@ class Station:
|
|||||||
})
|
})
|
||||||
if persist:
|
if persist:
|
||||||
self.store.insert_forecast(now, h, target, p["mu"], p["lo"],
|
self.store.insert_forecast(now, h, target, p["mu"], p["lo"],
|
||||||
p["hi"], "ensemble")
|
p["hi"], "ensemble",
|
||||||
|
members=p["members"])
|
||||||
bundle["targets"][target] = series
|
bundle["targets"][target] = series
|
||||||
self.forecast_bundle = bundle
|
self.forecast_bundle = bundle
|
||||||
|
|
||||||
@@ -390,6 +680,20 @@ class Station:
|
|||||||
|
|
||||||
# ----------------------------------------------------------- verify
|
# ----------------------------------------------------------- verify
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _members_of(row) -> Optional[np.ndarray]:
|
||||||
|
"""The four member predictions a stored forecast was blended from.
|
||||||
|
|
||||||
|
None for rows written before the columns existed, which are still worth
|
||||||
|
giving to the conformal calibrator but cannot move the Hedge weights.
|
||||||
|
"""
|
||||||
|
vals = [row["m_persistence"], row["m_climatology"],
|
||||||
|
row["m_learned"], row["m_setpoint"]]
|
||||||
|
if any(v is None for v in vals):
|
||||||
|
return None
|
||||||
|
out = np.array([float(v) for v in vals])
|
||||||
|
return out if np.all(np.isfinite(out)) else None
|
||||||
|
|
||||||
def verify(self) -> Dict:
|
def verify(self) -> Dict:
|
||||||
"""Score matured forecasts against truth and against persistence."""
|
"""Score matured forecasts against truth and against persistence."""
|
||||||
due = self.store.due_forecasts()
|
due = self.store.due_forecasts()
|
||||||
@@ -411,6 +715,7 @@ class Station:
|
|||||||
return float(series[target][idx])
|
return float(series[target][idx])
|
||||||
|
|
||||||
buckets: Dict[tuple, Dict[str, List[float]]] = {}
|
buckets: Dict[tuple, Dict[str, List[float]]] = {}
|
||||||
|
fed: List[tuple] = []
|
||||||
scored = 0
|
scored = 0
|
||||||
for row in due:
|
for row in due:
|
||||||
target, h = row["target"], int(row["horizon_s"])
|
target, h = row["target"], int(row["horizon_s"])
|
||||||
@@ -423,10 +728,21 @@ class Station:
|
|||||||
err = truth - row["mu"]
|
err = truth - row["mu"]
|
||||||
b["err"].append(err)
|
b["err"].append(err)
|
||||||
b["pers"].append(truth - anchor)
|
b["pers"].append(truth - anchor)
|
||||||
b["cov"].append(1.0 if row["lo"] <= truth <= row["hi"] else 0.0)
|
covered = bool(row["lo"] <= truth <= row["hi"])
|
||||||
|
b["cov"].append(1.0 if covered else 0.0)
|
||||||
head = self.nowcast.heads.get(key)
|
head = self.nowcast.heads.get(key)
|
||||||
if head is not None:
|
# A matured forecast stays readable for an hour so the buckets above
|
||||||
head.conformal.observe(err, covered=bool(row["lo"] <= truth <= row["hi"]))
|
# can aggregate a rolling window, which means verify() sees it about
|
||||||
|
# twelve times. Aggregating it twelve times is harmless. Teaching
|
||||||
|
# the learner from it twelve times is not, so that happens once.
|
||||||
|
if head is not None and not row["scored"]:
|
||||||
|
members = self._members_of(row)
|
||||||
|
if members is None:
|
||||||
|
head.conformal.observe(err, covered=covered)
|
||||||
|
else:
|
||||||
|
head.observe_outcome(members, truth, covered=covered,
|
||||||
|
valid_ts=float(row["valid_ts"]))
|
||||||
|
fed.append((row["issued_ts"], h, target))
|
||||||
if h <= 10800:
|
if h <= 10800:
|
||||||
self.monitor.observe_error(row["valid_ts"], abs(err))
|
self.monitor.observe_error(row["valid_ts"], abs(err))
|
||||||
scored += 1
|
scored += 1
|
||||||
@@ -448,9 +764,10 @@ class Station:
|
|||||||
n=int(e.size),
|
n=int(e.size),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.store.mark_forecasts_scored(fed)
|
||||||
with self.store._conn() as conn:
|
with self.store._conn() as conn:
|
||||||
conn.execute("DELETE FROM forecasts WHERE valid_ts <= ?", (now - 3600,))
|
conn.execute("DELETE FROM forecasts WHERE valid_ts <= ?", (now - 3600,))
|
||||||
return {"scored": scored, "buckets": len(buckets)}
|
return {"scored": scored, "learned": len(fed), "buckets": len(buckets)}
|
||||||
|
|
||||||
# ------------------------------------------------------------ loops
|
# ------------------------------------------------------------ loops
|
||||||
|
|
||||||
@@ -492,10 +809,61 @@ class Station:
|
|||||||
self.store.log_event("verify", "error", repr(exc))
|
self.store.log_event("verify", "error", repr(exc))
|
||||||
await asyncio.sleep(300)
|
await asyncio.sleep(300)
|
||||||
|
|
||||||
|
# Joystick bindings. Left and right are the two answers to the only
|
||||||
|
# question the precipitation model cannot answer for itself.
|
||||||
|
STICK_LABELS = {"left": 0.0, "right": 1.0}
|
||||||
|
STICK_COLOURS = {0.0: (90, 90, 110), 1.0: (40, 110, 220)}
|
||||||
|
|
||||||
|
async def _loop_joystick(self):
|
||||||
|
"""Rain labels without a browser.
|
||||||
|
|
||||||
|
Precipitation is the weakest model in the bank and it is starved of the
|
||||||
|
only thing that would fix it. This station has 80 strong labels against
|
||||||
|
thousands of proxy ones, because the label button lives in a web page
|
||||||
|
and a web page is not where anyone is standing when it starts raining.
|
||||||
|
A button on the device is the whole difference between labelling and
|
||||||
|
intending to label.
|
||||||
|
|
||||||
|
Middle cycles the LED scene, which is the other thing you want from a
|
||||||
|
headless box and otherwise requires a laptop.
|
||||||
|
"""
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
for direction, action in self.board.stick_events():
|
||||||
|
if action != "pressed":
|
||||||
|
continue
|
||||||
|
if direction in self.STICK_LABELS:
|
||||||
|
value = self.STICK_LABELS[direction]
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
self.add_label, "rain", value, None, "joystick")
|
||||||
|
self.store.log_event(
|
||||||
|
"joystick", "info",
|
||||||
|
json.dumps({"direction": direction, "rain": value,
|
||||||
|
"strong_labels": result.get("strong_labels")}))
|
||||||
|
if self.display is not None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.display.flash, self.STICK_COLOURS[value])
|
||||||
|
elif direction == "middle" and self.display is not None:
|
||||||
|
name = self.display.next_scene()
|
||||||
|
self.store.log_event("joystick", "info",
|
||||||
|
json.dumps({"scene": name}))
|
||||||
|
except Exception as exc:
|
||||||
|
self.store.log_event("joystick", "error", repr(exc))
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
async def _loop_maintenance(self):
|
async def _loop_maintenance(self):
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
await asyncio.sleep(3600)
|
await asyncio.sleep(3600)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
# Undervoltage and thermal capping both move the SoC temperature,
|
||||||
|
# which is the input to the self-heating compensation, so a weak
|
||||||
|
# supply shows up as a temperature bias rather than as anything
|
||||||
|
# that looks like a power problem. Recorded so the anomaly is
|
||||||
|
# labelled rather than mysterious.
|
||||||
|
flags = read_throttled()
|
||||||
|
if flags:
|
||||||
|
self.store.log_event("throttled", flags["severity"],
|
||||||
|
json.dumps(flags))
|
||||||
if now - self.last_compact >= self.cfg.storage.vacuum_period_s:
|
if now - self.last_compact >= self.cfg.storage.vacuum_period_s:
|
||||||
try:
|
try:
|
||||||
removed = await asyncio.to_thread(
|
removed = await asyncio.to_thread(
|
||||||
@@ -516,6 +884,7 @@ class Station:
|
|||||||
asyncio.create_task(self._loop_train()),
|
asyncio.create_task(self._loop_train()),
|
||||||
asyncio.create_task(self._loop_verify()),
|
asyncio.create_task(self._loop_verify()),
|
||||||
asyncio.create_task(self._loop_maintenance()),
|
asyncio.create_task(self._loop_maintenance()),
|
||||||
|
asyncio.create_task(self._loop_joystick()),
|
||||||
]
|
]
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
|
|||||||
+130
-6
@@ -26,7 +26,7 @@ from __future__ import annotations
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -35,7 +35,8 @@ TIER_5MIN = 1
|
|||||||
TIER_HOUR = 2
|
TIER_HOUR = 2
|
||||||
|
|
||||||
COLUMNS = [
|
COLUMNS = [
|
||||||
"ts", "temp_raw", "temp_c", "temp_smooth", "temp_rate", "hum", "hum_smooth",
|
"ts", "temp_raw", "temp_h", "temp_p", "temp_c", "temp_smooth", "temp_rate",
|
||||||
|
"hum", "hum_smooth",
|
||||||
"press", "press_slp", "press_smooth", "press_rate", "cpu_temp", "dew_c",
|
"press", "press_slp", "press_smooth", "press_rate", "cpu_temp", "dew_c",
|
||||||
"lux", "r", "g", "b", "pitch", "roll", "yaw", "compass",
|
"lux", "r", "g", "b", "pitch", "roll", "yaw", "compass",
|
||||||
"ax", "ay", "az", "gx", "gy", "gz",
|
"ax", "ay", "az", "gx", "gy", "gz",
|
||||||
@@ -60,6 +61,14 @@ CREATE TABLE IF NOT EXISTS forecasts (
|
|||||||
target TEXT NOT NULL,
|
target TEXT NOT NULL,
|
||||||
mu REAL, lo REAL, hi REAL,
|
mu REAL, lo REAL, hi REAL,
|
||||||
model TEXT,
|
model TEXT,
|
||||||
|
-- What each ensemble member said at issue time. The blend weights are
|
||||||
|
-- learned from how these actually turned out, and the learned member is
|
||||||
|
-- not reconstructable after the fact: the RLS has moved on. If they are
|
||||||
|
-- not written down here the Hedge update has nothing honest to eat.
|
||||||
|
m_persistence REAL, m_climatology REAL, m_learned REAL, m_setpoint REAL,
|
||||||
|
-- A matured forecast stays readable for an hour so the scorecard can
|
||||||
|
-- aggregate a rolling window, but it must teach the learner exactly once.
|
||||||
|
scored INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (issued_ts, horizon_s, target)
|
PRIMARY KEY (issued_ts, horizon_s, target)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_forecast_valid ON forecasts(valid_ts);
|
CREATE INDEX IF NOT EXISTS idx_forecast_valid ON forecasts(valid_ts);
|
||||||
@@ -91,12 +100,60 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _f(v: Any) -> Optional[float]:
|
||||||
|
return None if v is None else float(v)
|
||||||
|
|
||||||
|
|
||||||
|
# Columns the forecasts table has gained since the first schema, with their
|
||||||
|
# declarations. Same hazard as COLUMNS above: a live station's table already
|
||||||
|
# exists, so CREATE TABLE IF NOT EXISTS will not add them.
|
||||||
|
FORECAST_COLUMNS = [
|
||||||
|
("m_persistence", "REAL"),
|
||||||
|
("m_climatology", "REAL"),
|
||||||
|
("m_learned", "REAL"),
|
||||||
|
("m_setpoint", "REAL"),
|
||||||
|
("scored", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Store:
|
class Store:
|
||||||
def __init__(self, path: str):
|
def __init__(self, path: str):
|
||||||
self.path = path
|
self.path = path
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
|
self._migrate(conn)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add any column COLUMNS has gained since this database was created.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS is a no-op against a table that already
|
||||||
|
exists, so a new entry in COLUMNS reaches a fresh install and silently
|
||||||
|
misses every station that has been running. The failure then surfaces
|
||||||
|
as an OperationalError inside insert_telemetry, which is on the sample
|
||||||
|
loop, so a column addition would take a live station down rather than
|
||||||
|
merely leaving a gap in its record.
|
||||||
|
"""
|
||||||
|
have = {row[1] for row in conn.execute("PRAGMA table_info(telemetry)")}
|
||||||
|
if not have:
|
||||||
|
return
|
||||||
|
for col in COLUMNS:
|
||||||
|
if col in have:
|
||||||
|
continue
|
||||||
|
if not col.isidentifier():
|
||||||
|
raise ValueError(f"refusing to splice a non-identifier column: {col!r}")
|
||||||
|
conn.execute(f"ALTER TABLE telemetry ADD COLUMN {col} REAL")
|
||||||
|
|
||||||
|
have = {row[1] for row in conn.execute("PRAGMA table_info(forecasts)")}
|
||||||
|
if not have:
|
||||||
|
return
|
||||||
|
for col, decl in FORECAST_COLUMNS:
|
||||||
|
if col in have:
|
||||||
|
continue
|
||||||
|
if not col.isidentifier():
|
||||||
|
raise ValueError(f"refusing to splice a non-identifier column: {col!r}")
|
||||||
|
conn.execute(f"ALTER TABLE forecasts ADD COLUMN {col} {decl}")
|
||||||
|
|
||||||
def _conn(self) -> sqlite3.Connection:
|
def _conn(self) -> sqlite3.Connection:
|
||||||
conn = getattr(self._local, "conn", None)
|
conn = getattr(self._local, "conn", None)
|
||||||
@@ -120,16 +177,32 @@ class Store:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def insert_forecast(self, issued_ts: float, horizon_s: int, target: str,
|
def insert_forecast(self, issued_ts: float, horizon_s: int, target: str,
|
||||||
mu: float, lo: float, hi: float, model: str) -> None:
|
mu: float, lo: float, hi: float, model: str,
|
||||||
|
members: Optional[Dict[str, float]] = None) -> None:
|
||||||
|
m = members or {}
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO forecasts "
|
"INSERT OR REPLACE INTO forecasts "
|
||||||
"(issued_ts, valid_ts, horizon_s, target, mu, lo, hi, model) "
|
"(issued_ts, valid_ts, horizon_s, target, mu, lo, hi, model, "
|
||||||
"VALUES (?,?,?,?,?,?,?,?)",
|
" m_persistence, m_climatology, m_learned, m_setpoint, scored) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0)",
|
||||||
(issued_ts, issued_ts + horizon_s, horizon_s, target,
|
(issued_ts, issued_ts + horizon_s, horizon_s, target,
|
||||||
float(mu), float(lo), float(hi), model),
|
float(mu), float(lo), float(hi), model,
|
||||||
|
_f(m.get("persistence")), _f(m.get("climatology")),
|
||||||
|
_f(m.get("learned")), _f(m.get("setpoint"))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def mark_forecasts_scored(self, keys: Iterable[Tuple[float, int, str]]) -> int:
|
||||||
|
"""Flag forecasts as already fed to the learner."""
|
||||||
|
rows = [(float(i), int(h), str(t)) for i, h, t in keys]
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
with self._conn() as conn:
|
||||||
|
cur = conn.executemany(
|
||||||
|
"UPDATE forecasts SET scored = 1 "
|
||||||
|
"WHERE issued_ts = ? AND horizon_s = ? AND target = ?", rows)
|
||||||
|
return int(cur.rowcount)
|
||||||
|
|
||||||
def insert_label(self, ts: float, kind: str, value: float, note: str = "") -> None:
|
def insert_label(self, ts: float, kind: str, value: float, note: str = "") -> None:
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -154,6 +227,52 @@ class Store:
|
|||||||
|
|
||||||
# ------------------------------------------------------------- reads
|
# ------------------------------------------------------------- reads
|
||||||
|
|
||||||
|
def all_for_recompute(self) -> Dict[str, np.ndarray]:
|
||||||
|
"""Every stored row's *raw* inputs, oldest first.
|
||||||
|
|
||||||
|
Only the columns a re-derivation actually needs. The raw sensor values
|
||||||
|
are never overwritten, which is precisely what makes recomputation
|
||||||
|
possible after a calibration changes k or the humidity offset.
|
||||||
|
"""
|
||||||
|
cols = ["ts", "temp_raw", "cpu_temp", "hum", "press"]
|
||||||
|
with self._conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT {', '.join(cols)} FROM telemetry ORDER BY ts ASC").fetchall()
|
||||||
|
if not rows:
|
||||||
|
return {c: np.empty(0) for c in cols}
|
||||||
|
arr = np.array(rows, dtype=object)
|
||||||
|
out = {}
|
||||||
|
for i, c in enumerate(cols):
|
||||||
|
out[c] = np.array([np.nan if v is None else float(v) for v in arr[:, i]],
|
||||||
|
dtype=float)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def apply_recompute(self, ts: np.ndarray, updates: Dict[str, np.ndarray],
|
||||||
|
chunk: int = 2000) -> int:
|
||||||
|
"""Write recomputed derived columns back, in chunks.
|
||||||
|
|
||||||
|
Chunked because a year of tiered history is a six-figure row count and a
|
||||||
|
single statement would hold the whole parameter list in memory on a
|
||||||
|
512 MB board.
|
||||||
|
"""
|
||||||
|
names = list(updates.keys())
|
||||||
|
sql = (f"UPDATE telemetry SET {', '.join(n + ' = ?' for n in names)} "
|
||||||
|
f"WHERE ts = ?")
|
||||||
|
n_written = 0
|
||||||
|
with self._conn() as conn:
|
||||||
|
for start in range(0, ts.size, chunk):
|
||||||
|
stop = min(start + chunk, ts.size)
|
||||||
|
batch = [
|
||||||
|
tuple(
|
||||||
|
[None if not np.isfinite(updates[n][i]) else float(updates[n][i])
|
||||||
|
for n in names] + [float(ts[i])]
|
||||||
|
)
|
||||||
|
for i in range(start, stop)
|
||||||
|
]
|
||||||
|
conn.executemany(sql, batch)
|
||||||
|
n_written += len(batch)
|
||||||
|
return n_written
|
||||||
|
|
||||||
def window(self, hours: float, columns: Optional[Iterable[str]] = None) -> Dict[str, np.ndarray]:
|
def window(self, hours: float, columns: Optional[Iterable[str]] = None) -> Dict[str, np.ndarray]:
|
||||||
"""Return the last `hours` of telemetry as column arrays, oldest first."""
|
"""Return the last `hours` of telemetry as column arrays, oldest first."""
|
||||||
cols = list(columns) if columns else COLUMNS
|
cols = list(columns) if columns else COLUMNS
|
||||||
@@ -348,6 +467,11 @@ class Store:
|
|||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
return int(conn.execute("SELECT COUNT(*) FROM telemetry").fetchone()[0])
|
return int(conn.execute("SELECT COUNT(*) FROM telemetry").fetchone()[0])
|
||||||
|
|
||||||
|
def newest_ts(self) -> Optional[float]:
|
||||||
|
with self._conn() as conn:
|
||||||
|
row = conn.execute("SELECT MAX(ts) FROM telemetry").fetchone()
|
||||||
|
return float(row[0]) if row and row[0] is not None else None
|
||||||
|
|
||||||
def span_days(self) -> float:
|
def span_days(self) -> float:
|
||||||
with self._conn() as conn:
|
with self._conn() as conn:
|
||||||
row = conn.execute("SELECT MIN(ts), MAX(ts) FROM telemetry").fetchone()
|
row = conn.execute("SELECT MIN(ts), MAX(ts) FROM telemetry").fetchone()
|
||||||
|
|||||||
+12
-1
@@ -14,8 +14,19 @@ sensor:
|
|||||||
persist_period_s: 30.0
|
persist_period_s: 30.0
|
||||||
rotation_deg: 90
|
rotation_deg: 90
|
||||||
cpu_heat_k: 0.55 # starting point only, calibrate from the dashboard
|
cpu_heat_k: 0.55 # starting point only, calibrate from the dashboard
|
||||||
kalman_q_temp: 2.0e-6 # raise to track faster, lower to smooth harder
|
# Process noise. Raise to track faster, lower to smooth harder. These were
|
||||||
|
# retuned against noise measured on a real board by sweeping each q against
|
||||||
|
# the RMSE of the reported rate versus the true rate. The originals tracked
|
||||||
|
# two to three decades faster than any of these signals move: in a still room
|
||||||
|
# the temperature filter reported a median rate of 12.4 C/h while the air
|
||||||
|
# moved 0.4 C/h. All three are listed because this file shadows the defaults
|
||||||
|
# in ashvale/config.py, and a value present here silently wins.
|
||||||
|
kalman_q_temp: 1.0e-9
|
||||||
kalman_r_temp: 0.02
|
kalman_r_temp: 0.02
|
||||||
|
kalman_q_press: 1.0e-8
|
||||||
|
kalman_r_press: 0.05
|
||||||
|
kalman_q_hum: 2.0e-8
|
||||||
|
kalman_r_hum: 0.60
|
||||||
|
|
||||||
model:
|
model:
|
||||||
grid_s: 300
|
grid_s: 300
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Getting Ashvale Station onto a Pi
|
||||||
|
|
||||||
|
Two routes. Pick the second one unless the card is empty.
|
||||||
|
|
||||||
|
| | Use when | Cost |
|
||||||
|
|---|---|---|
|
||||||
|
| **Prebuilt image** | A blank SD card, or you want a known-good starting point | ~500 MB download, reflashes the card |
|
||||||
|
| **Install script** | You already have a working Pi OS | ~2 minutes, keeps everything else |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install script (recommended)
|
||||||
|
|
||||||
|
Works on any Raspberry Pi already running Raspberry Pi OS (Bookworm or Trixie).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/lynchaos/ashvale-station/main/deploy/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Installs the apt dependencies, clones into `/opt/ashvale`, builds a venv with
|
||||||
|
`--system-site-packages`, enables I2C, and installs and starts a systemd unit.
|
||||||
|
Then it **polls the HTTP endpoint** rather than trusting systemd, because a unit
|
||||||
|
that is crash-looping on a port clash reports `active` for the instant between
|
||||||
|
exec and its first failed bind.
|
||||||
|
|
||||||
|
Re-running it upgrades in place. It never touches `data/` or `config.yaml`,
|
||||||
|
because those are your history and your coordinates and neither is recoverable.
|
||||||
|
|
||||||
|
Environment overrides, mostly useful for running a second instance beside a
|
||||||
|
live one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ASHVALE_DEST=/opt/ashvale-2 ASHVALE_SERVICE=ashvale-2 ASHVALE_PORT=8099 \
|
||||||
|
sudo -E bash deploy/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot afterwards if it told you it enabled I2C: the Sense HAT is not detected
|
||||||
|
until you do, and until then the station silently runs its simulator, which
|
||||||
|
looks like it is working and is measuring nothing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prebuilt image
|
||||||
|
|
||||||
|
Download the `.img.xz` and its `.sha256` from
|
||||||
|
[Releases](https://github.com/lynchaos/ashvale-station/releases), verify, and
|
||||||
|
flash with Raspberry Pi Imager.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c ashvale-station-*.img.xz.sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
**Set your username, password and WiFi in Imager's customisation dialog.** The
|
||||||
|
image deliberately contains none of them. It also contains no SSH host keys:
|
||||||
|
those are generated on first boot, because an image shipping real host keys
|
||||||
|
would give every person who flashed it the same identity and make them trivially
|
||||||
|
impersonable on their own network.
|
||||||
|
|
||||||
|
Boot it, wait a minute or two for the first-boot expansion, then open
|
||||||
|
`http://<your-pi>:8000`.
|
||||||
|
|
||||||
|
### What is in it
|
||||||
|
|
||||||
|
Raspberry Pi OS Lite, Trixie, arm64, plus:
|
||||||
|
|
||||||
|
- the application in `/opt/ashvale` with its venv already built
|
||||||
|
- `ashvale.service`, enabled
|
||||||
|
- I2C enabled in `config.txt`, which the Sense HAT needs
|
||||||
|
- a login banner with the address and the security caveat
|
||||||
|
- `/opt/ashvale/README.first-boot`
|
||||||
|
|
||||||
|
Lite, not Desktop: the station is headless and a desktop would eat the 512 MB
|
||||||
|
budget the whole project is designed around.
|
||||||
|
|
||||||
|
### What is deliberately not in it
|
||||||
|
|
||||||
|
No password, no WiFi credentials, no SSH host keys, no database, no trained
|
||||||
|
model state, and coordinates set to Greenwich at 0 m. That last one is wrong for
|
||||||
|
everybody on purpose: altitude feeds the sea-level pressure reduction on every
|
||||||
|
stored row, and pressure tendency is what drives the precipitation forecast, so
|
||||||
|
a plausible-looking wrong altitude is worse than an obviously wrong one.
|
||||||
|
|
||||||
|
The build copies the working tree through a `.gitignore` filter rather than a
|
||||||
|
hand-written exclude list. That is a security property: a hand-written list
|
||||||
|
missed `HANDOVER.md`, which is gitignored precisely because it holds LAN
|
||||||
|
addresses and SSH details, and a local build would have baked one person's
|
||||||
|
network into an image other people flash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building the image yourself
|
||||||
|
|
||||||
|
CI does it on every tag via `.github/workflows/image.yml`, on a pinned pi-gen
|
||||||
|
commit so the output does not move when an upstream branch does. To build
|
||||||
|
locally you need a Linux host (or Docker) with `qemu-user-static`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --branch arm64 https://github.com/RPi-Distro/pi-gen
|
||||||
|
cp deploy/pi-image/config pi-gen/config
|
||||||
|
cp -r deploy/pi-image/stage-ashvale pi-gen/
|
||||||
|
touch pi-gen/stage3/SKIP pi-gen/stage4/SKIP pi-gen/stage5/SKIP
|
||||||
|
rm -f pi-gen/stage2/EXPORT_IMAGE
|
||||||
|
echo "ASHVALE_SRC=$PWD" >> pi-gen/config
|
||||||
|
cd pi-gen && sudo -E ./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect roughly an hour and about 10 GB of scratch space.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
Ashvale Station is Apache 2.0. The image also contains Raspberry Pi OS and
|
||||||
|
Debian, which carry their own licences including some non-free firmware. It is
|
||||||
|
an unofficial image and is not endorsed by or affiliated with Raspberry Pi Ltd
|
||||||
|
or the Debian project.
|
||||||
Executable
+147
@@ -0,0 +1,147 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install Ashvale Station onto a Raspberry Pi that is already running.
|
||||||
|
#
|
||||||
|
# Most people already have a working Pi and should not have to reflash a card to
|
||||||
|
# try this. The prebuilt image exists for a fresh board; this exists for
|
||||||
|
# everything else, and it is the same install the image performs.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/lynchaos/ashvale-station/main/deploy/install.sh | bash
|
||||||
|
#
|
||||||
|
# Idempotent: safe to re-run to upgrade. It never touches data/ or config.yaml
|
||||||
|
# on a machine that already has them, because those are your history and your
|
||||||
|
# coordinates and neither can be regenerated.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="${ASHVALE_REPO:-https://github.com/lynchaos/ashvale-station.git}"
|
||||||
|
DEST="${ASHVALE_DEST:-/opt/ashvale}"
|
||||||
|
BRANCH="${ASHVALE_BRANCH:-main}"
|
||||||
|
SERVICE="${ASHVALE_SERVICE:-ashvale}"
|
||||||
|
# Overridable so a second instance can be installed alongside a live one,
|
||||||
|
# which is also the only way to test this script without stopping the real
|
||||||
|
# station. Empty means "whatever config.yaml says".
|
||||||
|
PORT_OVERRIDE="${ASHVALE_PORT:-}" # overridable so the installer can be tested without clobbering a live unit
|
||||||
|
|
||||||
|
say() { printf '\n\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
|
||||||
|
die() { printf '\033[1;31m x\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "run with sudo: curl ... | sudo bash"
|
||||||
|
|
||||||
|
if ! grep -qi raspberry /proc/device-tree/model 2>/dev/null; then
|
||||||
|
warn "this does not look like a Raspberry Pi; continuing anyway"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN_USER="${SUDO_USER:-$(getent passwd 1000 | cut -d: -f1)}"
|
||||||
|
[ -n "$RUN_USER" ] || die "could not determine a non-root user to run the service as"
|
||||||
|
|
||||||
|
say "Installing system packages"
|
||||||
|
# Hardware libraries come from apt, never pip. sense-hat pulls in RTIMULib, and
|
||||||
|
# building that inside a clean venv on ARM is a genuine ordeal.
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
git python3-venv python3-numpy python3-smbus2 sense-hat sqlite3
|
||||||
|
|
||||||
|
say "Fetching the application into $DEST"
|
||||||
|
if [ -d "$DEST/.git" ]; then
|
||||||
|
git -C "$DEST" fetch --depth 1 origin "$BRANCH"
|
||||||
|
git -C "$DEST" reset --hard "origin/$BRANCH"
|
||||||
|
else
|
||||||
|
install -d "$DEST"
|
||||||
|
git clone --depth 1 --branch "$BRANCH" "$REPO" "$DEST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Creating the virtual environment"
|
||||||
|
# --system-site-packages so numpy and the Sense HAT stack come from apt rather
|
||||||
|
# than being compiled here. Plain uvicorn, never uvicorn[standard]: that extra
|
||||||
|
# drags in watchfiles and uvloop, which compile Rust and C from source on ARM
|
||||||
|
# for features this does not use.
|
||||||
|
[ -d "$DEST/.venv" ] || python3 -m venv --system-site-packages "$DEST/.venv"
|
||||||
|
"$DEST/.venv/bin/pip" install -q --upgrade pip
|
||||||
|
"$DEST/.venv/bin/pip" install -q --no-cache-dir -r "$DEST/requirements.txt"
|
||||||
|
|
||||||
|
install -d -o "$RUN_USER" -g "$RUN_USER" "$DEST/data" "$DEST/data/state"
|
||||||
|
chown -R "$RUN_USER":"$RUN_USER" "$DEST"
|
||||||
|
|
||||||
|
say "Enabling I2C for the Sense HAT"
|
||||||
|
CFG=/boot/firmware/config.txt
|
||||||
|
[ -f "$CFG" ] || CFG=/boot/config.txt
|
||||||
|
if [ -f "$CFG" ] && ! grep -q '^dtparam=i2c_arm=on' "$CFG"; then
|
||||||
|
printf '\n# --- Ashvale Station ---\ndtparam=i2c_arm=on\n' >> "$CFG"
|
||||||
|
warn "I2C enabled: reboot before the Sense HAT is detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Installing the service"
|
||||||
|
cat > "/etc/systemd/system/$SERVICE.service" <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Ashvale Station forecast service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$RUN_USER
|
||||||
|
WorkingDirectory=$DEST
|
||||||
|
${PORT_OVERRIDE:+Environment=ASHVALE_SERVER__PORT=$PORT_OVERRIDE}
|
||||||
|
ExecStart=$DEST/.venv/bin/python run.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# A Zero 2 W has 512 MB. Cap the service so a runaway allocation takes the
|
||||||
|
# service down instead of the whole board.
|
||||||
|
MemoryMax=280M
|
||||||
|
CPUWeight=70
|
||||||
|
Nice=5
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ashvale
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
UNIT
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE" >/dev/null
|
||||||
|
systemctl restart "$SERVICE"
|
||||||
|
|
||||||
|
# Poll the endpoint, not systemd. "active" is true for the instant between
|
||||||
|
# exec and the first failed bind, so a unit that is crash-looping on a port
|
||||||
|
# clash reports healthy and the installer congratulates you on a broken install.
|
||||||
|
# Asking the thing whether it answers is the only check that means anything.
|
||||||
|
PORT="$PORT_OVERRIDE"
|
||||||
|
[ -n "$PORT" ] || PORT="$(sed -n 's/^ *port: *\([0-9]\+\).*/\1/p' "$DEST/config.yaml" | head -1)"
|
||||||
|
PORT="${PORT:-8000}"
|
||||||
|
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS -o /dev/null --max-time 2 "http://127.0.0.1:${PORT}/api/status"; then
|
||||||
|
say "Running: http://${IP:-<this-pi>}:${PORT}"
|
||||||
|
HEALTHY=1; break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "${HEALTHY:-0}" != 1 ]; then
|
||||||
|
systemctl is-active --quiet "$SERVICE" \
|
||||||
|
&& warn "unit is up but nothing is answering on port ${PORT}; is it already in use?" \
|
||||||
|
|| warn "unit is not running"
|
||||||
|
die "install finished but the station is not serving: journalctl -u $SERVICE -n 40"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<NOTE
|
||||||
|
|
||||||
|
Three things worth doing, in order of how much they matter:
|
||||||
|
|
||||||
|
1. Settings tab: set your latitude, longitude and altitude. Altitude feeds
|
||||||
|
the sea-level pressure reduction on every row, and pressure tendency is
|
||||||
|
what drives the precipitation forecast.
|
||||||
|
2. Models and Calibration tab: put a thermometer next to the board and enter
|
||||||
|
the reading. The Sense HAT sits above a SoC running 20 C hotter than the
|
||||||
|
room; one reading fixes the bias on every forecast that follows.
|
||||||
|
3. Settings tab: say whether it is indoors, and tell it when you open a
|
||||||
|
window or turn the heating on. Those are regime changes and the models
|
||||||
|
carry about 55 hours of memory.
|
||||||
|
|
||||||
|
Forecasts need about 10 hours of history before the first training pass.
|
||||||
|
|
||||||
|
No authentication and no TLS: trusted LAN only, do not port-forward it.
|
||||||
|
|
||||||
|
NOTE
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# pi-gen configuration for the Ashvale Station image.
|
||||||
|
#
|
||||||
|
# Built on Raspberry Pi OS Lite (Trixie, arm64), which is what a Zero 2 W
|
||||||
|
# actually runs: aarch64, Debian 13. Lite because the station is headless and a
|
||||||
|
# desktop would eat the 512 MB budget the whole project is built around.
|
||||||
|
#
|
||||||
|
# Nothing secret belongs in this file. No password, no WiFi, no SSH host keys.
|
||||||
|
# The first two are supplied by the user in Raspberry Pi Imager, and the third
|
||||||
|
# is generated on first boot: an image with baked host keys gives every user
|
||||||
|
# the same key and makes them trivially impersonable on their own network.
|
||||||
|
|
||||||
|
IMG_NAME=ashvale-station
|
||||||
|
RELEASE=trixie
|
||||||
|
# ARCH is not set here: the arm64 branch of pi-gen exports it unconditionally,
|
||||||
|
# so a value here would be silently ignored and imply a choice that is not ours.
|
||||||
|
|
||||||
|
# Lite only. stage3 and up add the desktop.
|
||||||
|
STAGE_LIST="stage0 stage1 stage2 stage-ashvale"
|
||||||
|
|
||||||
|
# No default user. Raspberry Pi OS refuses to boot to a login without one, which
|
||||||
|
# is deliberate: it forces the person flashing the card to choose credentials in
|
||||||
|
# Imager rather than inheriting ours.
|
||||||
|
DISABLE_FIRST_BOOT_USER_RENAME=0
|
||||||
|
|
||||||
|
DEPLOY_COMPRESSION=xz
|
||||||
|
COMPRESSION_LEVEL=6
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash -e
|
||||||
|
# Runs inside the target filesystem, so pip resolves against the image's Python.
|
||||||
|
|
||||||
|
# --system-site-packages so numpy and the Sense HAT stack come from apt. Building
|
||||||
|
# them in a clean venv on ARM means compiling RTIMULib and numpy from source,
|
||||||
|
# which is an ordeal on a Zero 2 W and pointless when Debian ships both.
|
||||||
|
python3 -m venv --system-site-packages /opt/ashvale/.venv
|
||||||
|
/opt/ashvale/.venv/bin/pip install --no-cache-dir --upgrade pip
|
||||||
|
# Plain uvicorn, never uvicorn[standard]: the extra pulls watchfiles and uvloop,
|
||||||
|
# both of which compile Rust and C from source on ARM for features unused here.
|
||||||
|
/opt/ashvale/.venv/bin/pip install --no-cache-dir -r /opt/ashvale/requirements.txt
|
||||||
|
|
||||||
|
install -d -o 1000 -g 1000 /opt/ashvale/data
|
||||||
|
|
||||||
|
systemctl enable ashvale-firstboot.service
|
||||||
|
systemctl enable ashvale.service
|
||||||
|
|
||||||
|
# Belt and braces. Raspberry Pi OS regenerates these on first boot, but an image
|
||||||
|
# that shipped real host keys would give every flasher the same identity, so
|
||||||
|
# make certain none are present in the artifact.
|
||||||
|
rm -f /etc/ssh/ssh_host_*
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash -e
|
||||||
|
# Runs on the build host with ${ROOTFS_DIR} pointing at the target filesystem.
|
||||||
|
|
||||||
|
install -d "${ROOTFS_DIR}/opt/ashvale"
|
||||||
|
|
||||||
|
# The application source, straight from the repository being built.
|
||||||
|
#
|
||||||
|
# The filter honours .gitignore rather than listing excludes by hand, and that
|
||||||
|
# is a security property, not tidiness. A hand-written list missed HANDOVER.md,
|
||||||
|
# which is gitignored precisely because it contains LAN addresses and SSH
|
||||||
|
# details: a local build would have baked one person's network into an image
|
||||||
|
# other people flash. Anything not fit to commit is not fit to ship.
|
||||||
|
rsync -a --delete \
|
||||||
|
--filter=':- .gitignore' \
|
||||||
|
--exclude '.git/' --exclude '.gitignore' --exclude 'deploy/' \
|
||||||
|
--exclude 'tests/' --exclude '.github/' --exclude '.DS_Store' \
|
||||||
|
"${ASHVALE_SRC}/" "${ROOTFS_DIR}/opt/ashvale/"
|
||||||
|
|
||||||
|
install -m 644 files/ashvale.service "${ROOTFS_DIR}/etc/systemd/system/ashvale.service"
|
||||||
|
install -m 755 files/ashvale-firstboot "${ROOTFS_DIR}/usr/local/sbin/ashvale-firstboot"
|
||||||
|
install -m 644 files/ashvale-firstboot.service "${ROOTFS_DIR}/etc/systemd/system/ashvale-firstboot.service"
|
||||||
|
install -m 755 files/motd.sh "${ROOTFS_DIR}/etc/update-motd.d/20-ashvale"
|
||||||
|
install -m 644 files/README.first-boot "${ROOTFS_DIR}/opt/ashvale/README.first-boot"
|
||||||
|
|
||||||
|
# I2C is not optional: without it the Sense HAT is invisible and the station
|
||||||
|
# silently falls back to its simulator, which looks like it works and is not
|
||||||
|
# measuring anything.
|
||||||
|
CONFIG_TXT="${ROOTFS_DIR}/boot/firmware/config.txt"
|
||||||
|
[ -f "$CONFIG_TXT" ] || CONFIG_TXT="${ROOTFS_DIR}/boot/config.txt"
|
||||||
|
if ! grep -q '^dtparam=i2c_arm=on' "$CONFIG_TXT"; then
|
||||||
|
cat >> "$CONFIG_TXT" <<'CFG'
|
||||||
|
|
||||||
|
# --- Ashvale Station ---
|
||||||
|
# Sense HAT sits on I2C. Without this the board is not detected at all.
|
||||||
|
dtparam=i2c_arm=on
|
||||||
|
# Uncomment for a DS18B20 outdoor probe on GPIO4. Left off by default because
|
||||||
|
# it claims that pin whether or not a sensor is attached.
|
||||||
|
#dtoverlay=w1-gpio
|
||||||
|
CFG
|
||||||
|
fi
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
Ashvale Station
|
||||||
|
===============
|
||||||
|
|
||||||
|
The service is already running. Open http://<this-pi>:8000 from the same network.
|
||||||
|
|
||||||
|
Three things to do, in order of how much they matter.
|
||||||
|
|
||||||
|
1. Set your location. Settings tab -> Site and model. Altitude feeds the
|
||||||
|
sea-level pressure reduction on every stored row, and pressure tendency is
|
||||||
|
what drives the precipitation forecast, so a wrong altitude quietly biases
|
||||||
|
the thing this station is best at. The image ships Greenwich at 0 m, which
|
||||||
|
is wrong for you on purpose.
|
||||||
|
|
||||||
|
2. Calibrate the temperature. Models and Calibration tab. Put any thermometer
|
||||||
|
next to the board, wait ten minutes, type the number in. The Sense HAT sits
|
||||||
|
millimetres above a SoC running 20 C hotter than the room, and one reading
|
||||||
|
fixes the bias on every forecast that follows. This is the highest value per
|
||||||
|
minute of anything you can do.
|
||||||
|
|
||||||
|
3. Tell it where it lives. Settings tab -> Surroundings. Indoors the building
|
||||||
|
governs temperature and humidity rather than the sky. Say so, and say when
|
||||||
|
you open a window or turn the heating on, because those are changes of
|
||||||
|
regime and the models carry about 55 hours of memory.
|
||||||
|
|
||||||
|
Forecasts appear after about 10 hours of history and the scorecard fills in over
|
||||||
|
the following day as each horizon matures. Until a head has been scored, its
|
||||||
|
forecast is an opinion.
|
||||||
|
|
||||||
|
Security
|
||||||
|
--------
|
||||||
|
No authentication, no TLS, binds 0.0.0.0. It is built for a trusted home
|
||||||
|
network. Do not port-forward it: /api/train, /api/calibrate, /api/label and
|
||||||
|
/api/settings all mutate model state.
|
||||||
|
|
||||||
|
Where things are
|
||||||
|
----------------
|
||||||
|
/opt/ashvale application
|
||||||
|
/opt/ashvale/config.yaml site configuration
|
||||||
|
/opt/ashvale/data database and trained state (never in the image)
|
||||||
|
systemctl status ashvale service
|
||||||
|
journalctl -u ashvale -f logs
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs once, before the station starts. Everything here is deliberately absent
|
||||||
|
# from the image itself, because baking it in would mean every person who
|
||||||
|
# flashed this card shared the same secrets or the same location.
|
||||||
|
set -e
|
||||||
|
STATE=/opt/ashvale/data
|
||||||
|
CONF=/opt/ashvale/config.yaml
|
||||||
|
UID_MAIN=$(getent passwd 1000 | cut -d: -f1)
|
||||||
|
|
||||||
|
install -d -o 1000 -g 1000 "$STATE" "$STATE/state"
|
||||||
|
|
||||||
|
# Site coordinates default to Greenwich, not to the author's house. They are
|
||||||
|
# wrong for everyone, which is the point: the Methods tab and the sea-level
|
||||||
|
# reduction both depend on them, so they should be conspicuously wrong until set.
|
||||||
|
if [ -f "$CONF" ] && ! grep -q 'ASHVALE_FIRSTBOOT_DONE' "$CONF"; then
|
||||||
|
sed -i 's/^\( *latitude:\).*/\1 51.4779 # CHANGE ME: Settings tab or this file/' "$CONF"
|
||||||
|
sed -i 's/^\( *longitude:\).*/\1 0.0015 # CHANGE ME/' "$CONF"
|
||||||
|
sed -i 's/^\( *altitude_m:\).*/\1 0.0 # CHANGE ME: wrong altitude skews sea-level pressure/' "$CONF"
|
||||||
|
echo "# ASHVALE_FIRSTBOOT_DONE" >> "$CONF"
|
||||||
|
chown 1000:1000 "$CONF"
|
||||||
|
fi
|
||||||
|
|
||||||
|
logger -t ashvale-firstboot "prepared state for user ${UID_MAIN:-uid1000}"
|
||||||
|
systemctl disable ashvale-firstboot.service || true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ashvale Station first-boot preparation
|
||||||
|
After=local-fs.target
|
||||||
|
Before=ashvale.service
|
||||||
|
ConditionPathExists=!/opt/ashvale/data/state
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/ashvale-firstboot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Ashvale Station forecast service
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=1000
|
||||||
|
Group=1000
|
||||||
|
WorkingDirectory=/opt/ashvale
|
||||||
|
ExecStart=/opt/ashvale/.venv/bin/python run.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# A Zero 2 W has 512 MB. Cap the service so a runaway allocation takes the
|
||||||
|
# service down instead of the whole board.
|
||||||
|
MemoryMax=280M
|
||||||
|
CPUWeight=70
|
||||||
|
Nice=5
|
||||||
|
|
||||||
|
# The SD card is a consumable: keep journald from writing every heartbeat.
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=ashvale
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
printf '\n Ashvale Station -> http://%s:8000\n' "${IP:-<this-pi>}"
|
||||||
|
printf ' status: %s\n' "$(systemctl is-active ashvale 2>/dev/null || echo unknown)"
|
||||||
|
printf '\n No authentication and no TLS. Trusted LAN only: do not port-forward it.\n'
|
||||||
|
printf ' Set your coordinates and altitude on the Settings tab before trusting\n'
|
||||||
|
printf ' the pressure readings. See /opt/ashvale/README.first-boot\n\n'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
python3-venv
|
||||||
|
python3-numpy
|
||||||
|
python3-smbus2
|
||||||
|
sense-hat
|
||||||
|
sqlite3
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
IMG_SUFFIX=""
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash -e
|
||||||
|
if [ ! -d "${ROOTFS_DIR}" ]; then
|
||||||
|
copy_previous
|
||||||
|
fi
|
||||||
+62
-1
@@ -99,6 +99,27 @@ exact inverse:** `T_raw = (T + k·T_cpu)/(1 + k)`. Generating the bias as
|
|||||||
1.2 °C of phantom noise floor that caps every skill score. This has already
|
1.2 °C of phantom noise floor that caps every skill score. This has already
|
||||||
happened once.
|
happened once.
|
||||||
|
|
||||||
|
### Humidity compensation
|
||||||
|
|
||||||
|
`HumidityCompensator` carries an additive `offset` on relative humidity,
|
||||||
|
estimated from a trusted hygrometer by the same one-step RLS used for `k`, with
|
||||||
|
the regressor fixed at 1 so repeated calibrations converge to a weighted mean.
|
||||||
|
Clamped to +/-35% for the same reason `k` is clamped.
|
||||||
|
|
||||||
|
It also implements a psychrometric term, moving RH from the element's
|
||||||
|
temperature onto the compensated air temperature through conserved vapour
|
||||||
|
pressure, `RH_true = RH_sensor * es(T_sensor) / es(T_true)`. That term is
|
||||||
|
**off by default**, and the reason is worth recording. 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 by 25 points, and
|
||||||
|
the correction would have pushed it further the wrong way. The dominant error on
|
||||||
|
this hardware is additive element bias, not a thermal gradient.
|
||||||
|
|
||||||
|
If you enable `sensor.hum_psychrometric`, `scripts/simulate.py` applies the exact
|
||||||
|
inverse when generating synthetic humidity. It has to: the same
|
||||||
|
simulator/compensator algebra trap described above for temperature applies here,
|
||||||
|
and getting it wrong bakes in a bias no calibration can remove.
|
||||||
|
|
||||||
### The Kalman bank
|
### The Kalman bank
|
||||||
|
|
||||||
One constant-velocity filter per signal. State `x = [level, rate]`, standard
|
One constant-velocity filter per signal. State `x = [level, rate]`, standard
|
||||||
@@ -219,6 +240,45 @@ re-weights within about a day when the season turns.
|
|||||||
15-minute pressure it typically parks most of its weight on persistence. That
|
15-minute pressure it typically parks most of its weight on persistence. That
|
||||||
is correct behaviour surfaced honestly, not a defect to engineer away.
|
is correct behaviour surfaced honestly, not a defect to engineer away.
|
||||||
|
|
||||||
|
### The thermostat member
|
||||||
|
|
||||||
|
A room held at a setpoint is not the same process as a room that is free to
|
||||||
|
drift. It is a closed loop, and persistence, the baseline everything here is
|
||||||
|
scored against, is simply the wrong statement about it: the truth is not "it
|
||||||
|
stays where it is", it is "it returns to the setpoint".
|
||||||
|
|
||||||
|
So when `site.heating` is on, the ensemble gains a fourth member:
|
||||||
|
|
||||||
|
```
|
||||||
|
dT_set(h) = (T_set - T_now) * (1 - exp(-h / tau))
|
||||||
|
```
|
||||||
|
|
||||||
|
First order, because that is what a controlled system is: `tau` is the time to
|
||||||
|
close about 63% of the gap. Zero at h = 0, asymptotic to the full correction.
|
||||||
|
|
||||||
|
Humidity follows and is the part that is easy to get wrong. Heating adds no
|
||||||
|
moisture, so what is conserved is vapour pressure, not relative humidity:
|
||||||
|
|
||||||
|
```
|
||||||
|
RH(h) = RH_now * es(T_now) / es(T_now + dT_set(h))
|
||||||
|
```
|
||||||
|
|
||||||
|
Warm the air and RH falls although nothing was dried. This is why a heated house
|
||||||
|
in winter is dry, and the test asserts the dew point is unchanged to 1e-6.
|
||||||
|
|
||||||
|
Pressure gets zero: a thermostat cannot move the synoptic field.
|
||||||
|
|
||||||
|
**It is offered, not imposed.** The Hedge weights score this member against the
|
||||||
|
others on realised error like any other, so a wrong `tau` or a setpoint you
|
||||||
|
forgot to update costs accuracy and gets down-weighted, rather than quietly
|
||||||
|
biasing every forecast. With heating off the member returns zero, which makes it
|
||||||
|
identical to persistence and therefore harmless.
|
||||||
|
|
||||||
|
Adding it changed the member count from three to four, so `ForecastHead.from_dict`
|
||||||
|
reinitialises `weights` **and** `member_mae` when a saved head has the old
|
||||||
|
length. Missing the second one did not fail on load: it failed later inside
|
||||||
|
`learn()` on a broadcast error, which is a much worse place to find out.
|
||||||
|
|
||||||
### Adaptive conformal intervals
|
### Adaptive conformal intervals
|
||||||
|
|
||||||
Split conformal is valid only under exchangeability, and weather is emphatically
|
Split conformal is valid only under exchangeability, and weather is emphatically
|
||||||
@@ -302,7 +362,8 @@ consecutive readings are the only tell.
|
|||||||
|
|
||||||
| Symptom | Knob | Direction |
|
| Symptom | Knob | Direction |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Temperature reads consistently high | Calibrate from the Models tab, or `sensor.cpu_heat_k` | Raise |
|
| Temperature reads consistently high | Calibrate from the Models and Calibration tab, or `sensor.cpu_heat_k` | Raise |
|
||||||
|
| Humidity reads consistently off | Calibrate against a reference hygrometer, or `sensor.hum_offset` | Either |
|
||||||
| Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise |
|
| Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise |
|
||||||
| Rates look noisy | `sensor.kalman_q_*` down, or `kalman_r_*` up | |
|
| Rates look noisy | `sensor.kalman_q_*` down, or `kalman_r_*` up | |
|
||||||
| NIS persistently much above 1 | Filter too confident, raise `q` | Raise |
|
| NIS persistently much above 1 | Filter too confident, raise `q` | Raise |
|
||||||
|
|||||||
+4
-1
@@ -77,5 +77,8 @@ line-length = 100
|
|||||||
target-version = "py39"
|
target-version = "py39"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "W", "I", "UP", "B"]
|
# UP is deliberately absent. With target-version = "py39" it fights the
|
||||||
|
# `from __future__ import annotations` style used throughout and generates
|
||||||
|
# several hundred findings for no behavioural gain. B currently finds nothing.
|
||||||
|
select = ["E", "F", "W", "I"]
|
||||||
ignore = ["E501"]
|
ignore = ["E501"]
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ def main() -> None:
|
|||||||
|
|
||||||
# One worker, one event loop. The station owns mutable model state, so a
|
# One worker, one event loop. The station owns mutable model state, so a
|
||||||
# second worker would give you two divergent forecasters sharing a socket.
|
# second worker would give you two divergent forecasters sharing a socket.
|
||||||
|
# timeout_graceful_shutdown bounds the wait for in-flight requests. Without
|
||||||
|
# it, the dashboard's server-sent-events connection never completes, so a
|
||||||
|
# stop blocks until systemd's 90 s timeout and ends in SIGKILL. Measured:
|
||||||
|
# with one stream client open, shutdown went from "never" to under 2 s.
|
||||||
uvicorn.run("ashvale.api:app", host=args.host, port=args.port,
|
uvicorn.run("ashvale.api:app", host=args.host, port=args.port,
|
||||||
reload=args.reload, workers=1, log_level="info",
|
reload=args.reload, workers=1, log_level="info",
|
||||||
limit_concurrency=32)
|
limit_concurrency=32, timeout_graceful_shutdown=5)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+9
-6
@@ -47,11 +47,11 @@ import numpy as np
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from ashvale.config import load_config # noqa: E402
|
from ashvale.config import load_config # noqa: E402
|
||||||
from ashvale.features import build_features # noqa: E402
|
from ashvale.features import build_features # noqa: E402
|
||||||
from ashvale.models.climatology import HarmonicClimatology # noqa: E402
|
from ashvale.models.climatology import HarmonicClimatology # noqa: E402
|
||||||
from ashvale.models.nowcast import NowcastEnsemble # noqa: E402
|
from ashvale.models.nowcast import NowcastEnsemble # noqa: E402
|
||||||
from ashvale.storage import Store, resample # noqa: E402
|
from ashvale.storage import Store, resample # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def horizon_label(seconds: int) -> str:
|
def horizon_label(seconds: int) -> str:
|
||||||
@@ -86,7 +86,8 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
X, valid = build_features(grid_ts, cols["temperature"], cols["humidity"],
|
X, valid = build_features(grid_ts, cols["temperature"], cols["humidity"],
|
||||||
cols["pressure"], cols["lux"], cfg.model.grid_s,
|
cols["pressure"], cols["lux"], cfg.model.grid_s,
|
||||||
cfg.site.latitude, cfg.site.longitude)
|
cfg.site.latitude, cfg.site.longitude,
|
||||||
|
cfg.model.climatology_min_days_annual)
|
||||||
|
|
||||||
n = grid_ts.size
|
n = grid_ts.size
|
||||||
split = int(n * args.train_frac)
|
split = int(n * args.train_frac)
|
||||||
@@ -163,7 +164,9 @@ def main() -> None:
|
|||||||
f"{r['bias']:>+8.3f} {r['weights']}{flag}")
|
f"{r['bias']:>+8.3f} {r['weights']}{flag}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
print(f"units: temperature C, humidity %, pressure hPa")
|
# Driven off the dict rather than hardcoded, so adding a target cannot leave
|
||||||
|
# the units line silently describing the wrong columns.
|
||||||
|
print("units: " + ", ".join(f"{t} {units[t]}" for t in cfg.model.targets if t in units))
|
||||||
print("coverage should sit near 90% if the conformal calibration is honest.")
|
print("coverage should sit near 90% if the conformal calibration is honest.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+47
-11
@@ -50,15 +50,26 @@ import numpy as np
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from ashvale.config import load_config # noqa: E402
|
from ashvale.config import load_config # noqa: E402
|
||||||
from ashvale.estimation import SignalTracker # noqa: E402
|
from ashvale.estimation import SignalTracker # noqa: E402
|
||||||
from ashvale.physics import (dew_point, sea_level_pressure, # noqa: E402
|
from ashvale.physics import ( # noqa: E402
|
||||||
solar_position, clear_sky_irradiance)
|
clear_sky_irradiance,
|
||||||
from ashvale.storage import Store # noqa: E402
|
dew_point,
|
||||||
|
sea_level_pressure,
|
||||||
|
solar_position,
|
||||||
|
)
|
||||||
|
from ashvale.sensors import ( # noqa: E402
|
||||||
|
K_HTS221,
|
||||||
|
K_LPS25HB,
|
||||||
|
SD_HTS221,
|
||||||
|
SD_LPS25HB,
|
||||||
|
)
|
||||||
|
from ashvale.storage import Store # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def generate(days: float, step_s: int, lat: float, lon: float,
|
def generate(days: float, step_s: int, lat: float, lon: float,
|
||||||
seed: int = 11, end: float | None = None) -> dict:
|
seed: int = 11, end: float | None = None,
|
||||||
|
psychrometric: bool = False) -> dict:
|
||||||
rng = np.random.default_rng(seed)
|
rng = np.random.default_rng(seed)
|
||||||
n = int(days * 86400 / step_s)
|
n = int(days * 86400 / step_s)
|
||||||
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
|
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
|
||||||
@@ -125,13 +136,36 @@ def generate(days: float, step_s: int, lat: float, lon: float,
|
|||||||
# model must be its exact inverse: T_raw = (T + k T_cpu) / (1 + k).
|
# model must be its exact inverse: T_raw = (T + k T_cpu) / (1 + k).
|
||||||
# Generating it any other way bakes a bias into the synthetic data that
|
# Generating it any other way bakes a bias into the synthetic data that
|
||||||
# no amount of calibration can remove, and quietly caps your skill score.
|
# no amount of calibration can remove, and quietly caps your skill score.
|
||||||
k_true = 0.55
|
# Two thermometers, not one, because the board has two. Their forward
|
||||||
temp_raw = (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * rng.normal(size=n)
|
# models average to the k = 0.55 case this used to generate directly, so
|
||||||
|
# temp_raw is unchanged in expectation. Its noise is not: a real board
|
||||||
|
# averages sd 0.060 with sd 0.443 and lands at 0.223, where this used to
|
||||||
|
# claim 0.05. Simulating the quiet sensor and calling it the average is
|
||||||
|
# what let an over-optimistic measurement noise go unnoticed.
|
||||||
|
temp_h = (temp + K_HTS221 * cpu) / (1.0 + K_HTS221) + SD_HTS221 * rng.normal(size=n)
|
||||||
|
temp_p = (temp + K_LPS25HB * cpu) / (1.0 + K_LPS25HB) + SD_LPS25HB * rng.normal(size=n)
|
||||||
|
temp_raw = (temp_h + temp_p) / 2.0
|
||||||
|
# If the compensator will move RH from the element temperature onto the air
|
||||||
|
# temperature, the forward model here must be its exact inverse, or the
|
||||||
|
# synthetic data bakes in a bias no calibration can remove. Same trap as the
|
||||||
|
# thermal algebra above. Off by default, matching sensor.hum_psychrometric.
|
||||||
|
if psychrometric:
|
||||||
|
es_raw = 6.112 * np.exp(17.625 * temp_raw / (243.04 + temp_raw))
|
||||||
|
rh_sensor = np.clip(rh * es_t / es_raw, 0.0, 100.0)
|
||||||
|
else:
|
||||||
|
rh_sensor = rh
|
||||||
|
|
||||||
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
|
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
|
||||||
press_station += 0.05 * rng.normal(size=n)
|
# 0.02 hPa, measured on a real LPS25HB as the sd of the change between
|
||||||
|
# 30 s samples. The 0.05 this used to carry made simulated pressure about
|
||||||
|
# twice as noisy as the real thing, which flatters any smoother tested
|
||||||
|
# against it and understates the skill available at short lead.
|
||||||
|
press_station += 0.02 * rng.normal(size=n)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh + 0.4 * rng.normal(size=n),
|
"ts": ts, "temp": temp, "temp_raw": temp_raw,
|
||||||
|
"temp_h": temp_h, "temp_p": temp_p,
|
||||||
|
"rh": rh_sensor + 0.4 * rng.normal(size=n),
|
||||||
"press": press_station, "press_slp": press_slp, "cpu": cpu,
|
"press": press_station, "press_slp": press_slp, "cpu": cpu,
|
||||||
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
|
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
|
||||||
}
|
}
|
||||||
@@ -172,7 +206,7 @@ def main() -> None:
|
|||||||
print("cleared existing telemetry, forecasts and scores")
|
print("cleared existing telemetry, forecasts and scores")
|
||||||
|
|
||||||
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
|
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
|
||||||
args.seed, args.end)
|
args.seed, args.end, cfg.sensor.hum_psychrometric)
|
||||||
tracker = SignalTracker(cfg)
|
tracker = SignalTracker(cfg)
|
||||||
|
|
||||||
n = data["ts"].size
|
n = data["ts"].size
|
||||||
@@ -186,6 +220,8 @@ def main() -> None:
|
|||||||
store.insert_telemetry({
|
store.insert_telemetry({
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
"temp_raw": data["temp_raw"][i],
|
"temp_raw": data["temp_raw"][i],
|
||||||
|
"temp_h": data["temp_h"][i],
|
||||||
|
"temp_p": data["temp_p"][i],
|
||||||
"temp_c": est["temp_c"],
|
"temp_c": est["temp_c"],
|
||||||
"temp_smooth": est["temp_smooth"],
|
"temp_smooth": est["temp_smooth"],
|
||||||
"temp_rate": est["temp_rate"],
|
"temp_rate": est["temp_rate"],
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Compensators and the Kalman bank.
|
||||||
|
|
||||||
|
The inverse-property tests here exist because getting that algebra wrong has
|
||||||
|
already cost this project twice: once on temperature, where a mismatched
|
||||||
|
simulator injected 1.2 C of phantom noise floor, and once on humidity, where
|
||||||
|
the correction ran the wrong way against a reference hygrometer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ashvale.estimation import HumidityCompensator, KalmanCV, ThermalCompensator
|
||||||
|
from ashvale.physics import dew_point, saturation_vapour_pressure
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- thermal
|
||||||
|
|
||||||
|
def test_thermal_forward_model_is_the_exact_inverse_of_the_compensator():
|
||||||
|
"""T_raw = (T + k*T_cpu)/(1+k) must invert T = T_raw - k(T_cpu - T_raw)."""
|
||||||
|
for k, t_true, t_cpu in [(0.55, 19.0, 40.0), (0.26, 24.4, 40.2), (1.0, 5.0, 30.0)]:
|
||||||
|
c = ThermalCompensator(k0=k, k_min=0.0, k_max=2.0)
|
||||||
|
t_raw = (t_true + k * t_cpu) / (1.0 + k)
|
||||||
|
assert c.compensate(t_raw, t_cpu) == pytest.approx(t_true, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thermal_calibration_moves_k_toward_the_truth():
|
||||||
|
c = ThermalCompensator(k0=0.30, k_min=0.05, k_max=1.5)
|
||||||
|
k_true, t_true, t_cpu = 0.62, 19.0, 41.0
|
||||||
|
t_raw = (t_true + k_true * t_cpu) / (1.0 + k_true)
|
||||||
|
before = abs(c.k - k_true)
|
||||||
|
c.calibrate(t_raw, t_cpu, t_true)
|
||||||
|
assert abs(c.k - k_true) < before
|
||||||
|
|
||||||
|
|
||||||
|
def test_thermal_clamp_survives_a_mistyped_reference():
|
||||||
|
c = ThermalCompensator(k0=0.55, k_min=0.15, k_max=1.20)
|
||||||
|
for _ in range(50):
|
||||||
|
c.calibrate(25.0, 40.0, -300.0) # absurd reference
|
||||||
|
assert c.k_min <= c.k <= c.k_max
|
||||||
|
|
||||||
|
|
||||||
|
def test_thermal_compensation_is_a_noop_without_a_gradient():
|
||||||
|
c = ThermalCompensator(k0=0.8)
|
||||||
|
assert c.compensate(21.0, 21.0) == pytest.approx(21.0)
|
||||||
|
# and never amplifies when the CPU is cooler than the sensor
|
||||||
|
assert c.compensate(21.0, 15.0) == pytest.approx(21.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- humidity
|
||||||
|
|
||||||
|
def test_humidity_psychrometric_round_trip():
|
||||||
|
"""The simulator's forward model must invert the compensator exactly."""
|
||||||
|
rh_true, t_true, t_raw = 62.0, 19.0, 25.6
|
||||||
|
rh_sensor = rh_true * float(saturation_vapour_pressure(t_true) /
|
||||||
|
saturation_vapour_pressure(t_raw))
|
||||||
|
hc = HumidityCompensator(psychrometric=True)
|
||||||
|
assert hc.compensate(rh_sensor, t_raw, t_true) == pytest.approx(rh_true, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_humidity_psychrometric_preserves_dew_point():
|
||||||
|
"""Vapour pressure is the conserved quantity, so dew point must not move."""
|
||||||
|
rh_sensor, t_raw, t_true = 60.0, 25.6, 19.0
|
||||||
|
hc = HumidityCompensator(psychrometric=True)
|
||||||
|
out = hc.compensate(rh_sensor, t_raw, t_true)
|
||||||
|
assert float(dew_point(t_true, out)) == pytest.approx(float(dew_point(t_raw, rh_sensor)),
|
||||||
|
abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_humidity_psychrometric_disabled_by_default():
|
||||||
|
hc = HumidityCompensator()
|
||||||
|
assert hc.compensate(60.0, 25.6, 19.0) == pytest.approx(60.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_humidity_offset_converges_on_a_reference():
|
||||||
|
"""The measured case: board reads 75.35% where the truth is 50.4%."""
|
||||||
|
hc = HumidityCompensator()
|
||||||
|
errors = []
|
||||||
|
for _ in range(6):
|
||||||
|
hc.calibrate(75.35, 27.94, 24.86, 50.4)
|
||||||
|
errors.append(abs(hc.compensate(75.35, 27.94, 24.86) - 50.4))
|
||||||
|
assert errors[-1] < errors[0]
|
||||||
|
assert errors[-1] < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_humidity_offset_is_clamped():
|
||||||
|
hc = HumidityCompensator()
|
||||||
|
for _ in range(50):
|
||||||
|
hc.calibrate(50.0, 20.0, 20.0, 100.0)
|
||||||
|
assert hc.off_min <= hc.offset <= hc.off_max
|
||||||
|
|
||||||
|
|
||||||
|
def test_humidity_output_stays_in_range():
|
||||||
|
hc = HumidityCompensator(offset=30.0)
|
||||||
|
assert 0.0 <= hc.compensate(95.0, 20.0, 20.0) <= 100.0
|
||||||
|
hc2 = HumidityCompensator(offset=-30.0)
|
||||||
|
assert 0.0 <= hc2.compensate(5.0, 20.0, 20.0) <= 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_humidity_state_round_trips_through_dict():
|
||||||
|
hc = HumidityCompensator(offset=-24.2, psychrometric=True)
|
||||||
|
hc.calibrate(70.0, 25.0, 21.0, 50.0)
|
||||||
|
back = HumidityCompensator.from_dict(hc.to_dict())
|
||||||
|
assert back.offset == pytest.approx(hc.offset)
|
||||||
|
assert back.psychrometric is hc.psychrometric
|
||||||
|
assert back.n_calibrations == hc.n_calibrations
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- kalman
|
||||||
|
|
||||||
|
def test_kalman_covariance_stays_symmetric_and_psd():
|
||||||
|
"""Joseph form exists precisely so this holds over a long run."""
|
||||||
|
kf = KalmanCV(q=1e-6, r=0.05)
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
for _ in range(20000):
|
||||||
|
kf.update(20.0 + 0.05 * rng.normal(), 2.0)
|
||||||
|
P = np.asarray(kf.P, dtype=float)
|
||||||
|
assert np.allclose(P, P.T, atol=1e-12)
|
||||||
|
assert np.all(np.linalg.eigvalsh(P) > -1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kalman_tracks_a_constant_and_reports_zero_rate():
|
||||||
|
kf = KalmanCV(q=1e-8, r=0.01)
|
||||||
|
for _ in range(2000):
|
||||||
|
kf.update(15.0, 2.0)
|
||||||
|
assert kf.level == pytest.approx(15.0, abs=1e-3)
|
||||||
|
assert kf.rate == pytest.approx(0.0, abs=1e-5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kalman_recovers_a_known_ramp_rate():
|
||||||
|
kf = KalmanCV(q=1e-4, r=0.01)
|
||||||
|
true_rate = 0.5 / 3600.0 # 0.5 units per hour
|
||||||
|
for i in range(6000):
|
||||||
|
kf.update(10.0 + true_rate * i * 2.0, 2.0)
|
||||||
|
assert kf.rate * 3600.0 == pytest.approx(0.5, rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kalman_ignores_non_finite_measurements():
|
||||||
|
kf = KalmanCV(q=1e-6, r=0.05)
|
||||||
|
kf.update(20.0, 2.0)
|
||||||
|
lvl_before = kf.level
|
||||||
|
kf.update(float("nan"), 2.0)
|
||||||
|
assert kf.level == pytest.approx(lvl_before)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kalman_nis_is_near_one_when_noise_matches_the_model():
|
||||||
|
"""NIS is the honest self-check: consistent filter, NIS about 1."""
|
||||||
|
r = 0.04
|
||||||
|
kf = KalmanCV(q=1e-7, r=r)
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
nis = []
|
||||||
|
for i in range(4000):
|
||||||
|
kf.update(18.0 + np.sqrt(r) * rng.normal(), 2.0)
|
||||||
|
if i > 500:
|
||||||
|
nis.append(kf.nis)
|
||||||
|
assert 0.5 < float(np.mean(nis)) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_kalman_state_round_trips_through_dict():
|
||||||
|
kf = KalmanCV(q=1e-6, r=0.05)
|
||||||
|
for _ in range(50):
|
||||||
|
kf.update(12.0, 2.0)
|
||||||
|
back = KalmanCV.from_dict(kf.to_dict())
|
||||||
|
assert back.level == pytest.approx(kf.level)
|
||||||
|
assert back.rate == pytest.approx(kf.rate)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- thermostat
|
||||||
|
|
||||||
|
def test_thermostat_reversion_is_first_order_and_preserves_dew_point():
|
||||||
|
"""A heated room is a closed loop, and heating adds no moisture.
|
||||||
|
|
||||||
|
Two properties, both easy to get wrong. The temperature must close the gap
|
||||||
|
to the setpoint exponentially rather than jumping or drifting, and the
|
||||||
|
implied humidity change must leave the dew point exactly where it was: RH
|
||||||
|
falls only because es(T) rose, which is why a heated house in winter is dry.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.physics import dew_point, saturation_vapour_pressure
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.site.heating = True
|
||||||
|
cfg.site.heating_setpoint_c = 23.0
|
||||||
|
cfg.site.thermal_time_constant_h = 1.5
|
||||||
|
st = Station(cfg)
|
||||||
|
st.live = {"temp_smooth": 18.0}
|
||||||
|
|
||||||
|
tau = 1.5 * 3600.0
|
||||||
|
for h in (900, 3600, 10800, 86400):
|
||||||
|
expected = (23.0 - 18.0) * (1.0 - math.exp(-h / tau))
|
||||||
|
assert st._setpoint_delta("temperature", h, 18.0) == pytest.approx(expected, rel=1e-9)
|
||||||
|
|
||||||
|
# monotonic toward the setpoint, never past it
|
||||||
|
deltas = [st._setpoint_delta("temperature", h, 18.0)
|
||||||
|
for h in (900, 3600, 10800, 21600, 86400)]
|
||||||
|
assert all(a < b for a, b in zip(deltas, deltas[1:]))
|
||||||
|
assert deltas[-1] <= 5.0 + 1e-9
|
||||||
|
|
||||||
|
# dew point invariant
|
||||||
|
t0, rh0 = 18.0, 55.0
|
||||||
|
d_t = st._setpoint_delta("temperature", 86400, t0)
|
||||||
|
d_rh = st._setpoint_delta("humidity", 86400, rh0)
|
||||||
|
assert float(dew_point(t0 + d_t, rh0 + d_rh)) == pytest.approx(
|
||||||
|
float(dew_point(t0, rh0)), abs=1e-6)
|
||||||
|
assert d_rh < 0.0, "warming a room at constant moisture must lower RH"
|
||||||
|
assert float(saturation_vapour_pressure(t0 + d_t)) > float(
|
||||||
|
saturation_vapour_pressure(t0))
|
||||||
|
|
||||||
|
# a thermostat cannot move the synoptic field
|
||||||
|
assert st._setpoint_delta("pressure", 86400, 1013.0) == 0.0
|
||||||
|
|
||||||
|
# and off, the member is exactly persistence
|
||||||
|
cfg.site.heating = False
|
||||||
|
assert st._setpoint_delta("temperature", 86400, 18.0) == 0.0
|
||||||
|
assert st._setpoint_delta("humidity", 86400, 55.0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_forecast_head_migrates_state_from_before_the_setpoint_member():
|
||||||
|
"""An old save has three weights where there are now four."""
|
||||||
|
from ashvale.models.nowcast import MEMBERS, ForecastHead
|
||||||
|
|
||||||
|
h = ForecastHead(target="temperature", horizon_s=900, n_features=4)
|
||||||
|
state = h.to_dict()
|
||||||
|
state["weights"] = [0.2, 0.3, 0.5] # a pre-setpoint save
|
||||||
|
state["member_mae"] = [0.4, 0.5, 0.6]
|
||||||
|
back = ForecastHead.from_dict(state)
|
||||||
|
assert back.weights.size == len(MEMBERS)
|
||||||
|
assert float(back.weights.sum()) == pytest.approx(1.0)
|
||||||
|
# member_mae must migrate too. Missing it did not fail on load, it failed
|
||||||
|
# later inside the Hedge update on a broadcast error, which is a worse place
|
||||||
|
# to discover a migration bug.
|
||||||
|
assert back.member_mae.size == len(MEMBERS)
|
||||||
|
members = np.full(len(MEMBERS), 20.0)
|
||||||
|
back.observe_outcome(members, 20.5, covered=True) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- dual-thermometer fusion
|
||||||
|
|
||||||
|
def _bare_board():
|
||||||
|
from ashvale.sensors import SD_HTS221, SD_LPS25HB, SenseBoard, _ChannelNoise
|
||||||
|
b = SenseBoard.__new__(SenseBoard)
|
||||||
|
b._noise_h = _ChannelNoise(SD_HTS221)
|
||||||
|
b._noise_p = _ChannelNoise(SD_LPS25HB)
|
||||||
|
b._gradient = None
|
||||||
|
b._gradient_lam = 0.9967
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _two_channels(n=4000, seed=5):
|
||||||
|
from ashvale.sensors import K_HTS221, K_LPS25HB
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
cpu = 43.0 + 0.5 * np.sin(np.arange(n) / 500.0)
|
||||||
|
th = (24.0 + K_HTS221 * cpu) / (1 + K_HTS221) + 0.049 * rng.normal(size=n)
|
||||||
|
tp = (24.0 + K_LPS25HB * cpu) / (1 + K_LPS25HB) + 0.007 * rng.normal(size=n)
|
||||||
|
return th, tp
|
||||||
|
|
||||||
|
|
||||||
|
def test_fusion_does_not_move_the_mean():
|
||||||
|
"""The whole point of removing the gradient first.
|
||||||
|
|
||||||
|
The two chips stand about 1.3 C apart, so weighting them by variance drags
|
||||||
|
temp_raw onto the quieter one. k was fitted against the mean of the two, and
|
||||||
|
after the 1.55x gain of the inverse model that shift becomes about a degree
|
||||||
|
of silent bias on every reading downstream.
|
||||||
|
"""
|
||||||
|
th, tp = _two_channels()
|
||||||
|
board = _bare_board()
|
||||||
|
fused = np.array([board._fuse(th[i], tp[i])[0] for i in range(th.size)])
|
||||||
|
avg = (th + tp) / 2.0
|
||||||
|
w = slice(1000, None)
|
||||||
|
assert abs(fused[w].mean() - avg[w].mean()) < 0.01, "fusion shifted the calibration"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fusion_is_quieter_than_the_average():
|
||||||
|
th, tp = _two_channels()
|
||||||
|
board = _bare_board()
|
||||||
|
fused = np.array([board._fuse(th[i], tp[i])[0] for i in range(th.size)])
|
||||||
|
avg = (th + tp) / 2.0
|
||||||
|
w = slice(1000, None)
|
||||||
|
|
||||||
|
def wn(x):
|
||||||
|
return np.std(np.diff(x)) / np.sqrt(2)
|
||||||
|
|
||||||
|
assert wn(fused[w]) < wn(avg[w]) / 2.0, "fusion did not halve the noise"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fusion_survives_one_dead_channel():
|
||||||
|
board = _bare_board()
|
||||||
|
value, var = board._fuse(float("nan"), 29.5)
|
||||||
|
assert value == 29.5, "a dead HTS221 must not poison the reading"
|
||||||
|
value, var = board._fuse(30.5, float("nan"))
|
||||||
|
assert value == 30.5
|
||||||
|
value, var = board._fuse(float("nan"), float("nan"))
|
||||||
|
assert not np.isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kalman_rate_is_physical_in_a_still_room():
|
||||||
|
"""The tuning failure this guards against.
|
||||||
|
|
||||||
|
On a real station the temperature filter reported a median rate of
|
||||||
|
12.4 C/h while the room moved 0.37 C/h. Process noise was set to track
|
||||||
|
perhaps a hundred times faster than any of these signals actually move.
|
||||||
|
"""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.estimation import KalmanCV
|
||||||
|
|
||||||
|
dt = CONFIG.sensor.sample_period_s
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
n = 6000
|
||||||
|
truth = 24.0 + 0.4 * np.arange(n) * dt / 3600.0 # a real 0.4 C/h drift
|
||||||
|
z = truth + 0.0877 * rng.normal(size=n) # measured input noise
|
||||||
|
|
||||||
|
kf = KalmanCV(CONFIG.sensor.kalman_q_temp, CONFIG.sensor.kalman_r_temp)
|
||||||
|
rates = [kf.update(z[i], dt)[1] * 3600.0 for i in range(n)]
|
||||||
|
settled = np.abs(np.array(rates[600:]))
|
||||||
|
|
||||||
|
assert np.median(settled) < 3.0, (
|
||||||
|
f"median |rate| {np.median(settled):.1f} C/h in a room drifting 0.4 C/h")
|
||||||
|
assert np.percentile(settled, 95) < 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_retuning_q_survives_a_reload():
|
||||||
|
"""Tuning lives in config, not in the state file.
|
||||||
|
|
||||||
|
q and r were persisted and restored, so a retune deployed to a running
|
||||||
|
station did nothing: the service restarted and the filters carried on with
|
||||||
|
whatever tuning was in force when the state was last written. The symptom
|
||||||
|
is a config change that appears to work and does not, which is the worst
|
||||||
|
kind.
|
||||||
|
"""
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.estimation import SignalTracker
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.sensor.kalman_q_temp = 2.0e-6 # an old, badly tuned state file
|
||||||
|
old = SignalTracker(cfg)
|
||||||
|
for i in range(50):
|
||||||
|
old.step(1.7554e9 + i * 2.0, 24.0, 50.0, 1013.0, 43.0)
|
||||||
|
saved = old.to_dict()
|
||||||
|
assert saved["filters"]["temperature"]["q"] == 2.0e-6
|
||||||
|
|
||||||
|
cfg.sensor.kalman_q_temp = 1.0e-9 # the retune
|
||||||
|
fresh = SignalTracker(cfg)
|
||||||
|
fresh.load_dict(saved)
|
||||||
|
assert fresh.filters["temperature"].q == 1.0e-9, \
|
||||||
|
"the state file overrode the configured tuning"
|
||||||
|
# the estimate itself must still be carried across
|
||||||
|
assert fresh.filters["temperature"].initialised
|
||||||
|
assert fresh.filters["temperature"].x[0] == pytest.approx(
|
||||||
|
old.filters["temperature"].x[0])
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""The learners: RLS, adaptive conformal, and the Zambretti prior.
|
||||||
|
|
||||||
|
The covariance-cap test is the important one in this file. Unbounded P growth
|
||||||
|
through an unexcited subspace is the most common way a field RLS deployment
|
||||||
|
dies, and it dies silently until the first excited sample.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ashvale.models.precip import zambretti
|
||||||
|
from ashvale.models.rls import AdaptiveConformal, RecursiveLeastSquares
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- RLS
|
||||||
|
|
||||||
|
def test_rls_recovers_known_coefficients():
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
truth = np.array([0.5, -1.25, 2.0, 0.0])
|
||||||
|
m = RecursiveLeastSquares(n_features=4, forgetting=0.999)
|
||||||
|
for _ in range(4000):
|
||||||
|
x = rng.normal(size=4)
|
||||||
|
m.update(x, float(truth @ x))
|
||||||
|
assert np.allclose(m.theta, truth, atol=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rls_covariance_trace_never_exceeds_the_cap():
|
||||||
|
"""A quiet regressor is exactly what inflates P. It must not run away."""
|
||||||
|
m = RecursiveLeastSquares(n_features=8, forgetting=0.99, p_max=1e4)
|
||||||
|
quiet = np.zeros(8)
|
||||||
|
quiet[0] = 1.0 # only one direction ever excited
|
||||||
|
for _ in range(50000):
|
||||||
|
m.update(quiet, 1.0)
|
||||||
|
tr = float(np.trace(np.asarray(m.P, dtype=float)))
|
||||||
|
assert np.isfinite(tr)
|
||||||
|
assert tr <= 1e4 * (1.0 + 1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rls_covariance_stays_symmetric():
|
||||||
|
rng = np.random.default_rng(5)
|
||||||
|
m = RecursiveLeastSquares(n_features=6, forgetting=0.995)
|
||||||
|
for _ in range(5000):
|
||||||
|
m.update(rng.normal(size=6), float(rng.normal()))
|
||||||
|
P = np.asarray(m.P, dtype=float)
|
||||||
|
assert np.allclose(P, P.T, atol=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rls_survives_a_non_finite_sample_without_poisoning_theta():
|
||||||
|
m = RecursiveLeastSquares(n_features=3, forgetting=0.99)
|
||||||
|
for _ in range(100):
|
||||||
|
m.update(np.array([1.0, 0.5, -0.2]), 1.0)
|
||||||
|
good = m.theta.copy()
|
||||||
|
m.update(np.array([np.nan, 1.0, 1.0]), 1.0)
|
||||||
|
assert np.all(np.isfinite(m.theta)), "a NaN sample must not poison the weights"
|
||||||
|
m.update(np.array([1.0, 1.0, 1.0]), float("inf"))
|
||||||
|
assert np.all(np.isfinite(m.theta))
|
||||||
|
assert good.shape == m.theta.shape
|
||||||
|
|
||||||
|
|
||||||
|
def test_rls_forgetting_gives_the_documented_effective_memory():
|
||||||
|
m = RecursiveLeastSquares(n_features=2, forgetting=0.9985)
|
||||||
|
assert 1.0 / (1.0 - m.lam) == pytest.approx(666.67, rel=1e-3)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- conformal
|
||||||
|
|
||||||
|
def test_conformal_coverage_tracks_the_target_on_stationary_noise():
|
||||||
|
ac = AdaptiveConformal(alpha=0.1, gamma=0.02)
|
||||||
|
rng = np.random.default_rng(17)
|
||||||
|
inside = 0
|
||||||
|
n = 4000
|
||||||
|
for i in range(n):
|
||||||
|
err = float(rng.normal())
|
||||||
|
q = float(ac.quantile())
|
||||||
|
covered = bool(np.isfinite(q) and abs(err) <= q)
|
||||||
|
if i > 400 and covered:
|
||||||
|
inside += 1
|
||||||
|
ac.observe(err, covered)
|
||||||
|
assert 0.84 <= inside / (n - 400) <= 0.96
|
||||||
|
|
||||||
|
|
||||||
|
def test_conformal_alpha_is_clamped():
|
||||||
|
ac = AdaptiveConformal(alpha=0.1, gamma=0.2)
|
||||||
|
for _ in range(5000):
|
||||||
|
ac.observe(1e9, False) # always a miss, alpha should rise then stop
|
||||||
|
assert 0.005 <= ac.alpha <= 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_conformal_widens_after_misses_and_narrows_after_hits():
|
||||||
|
"""Mind the sign. The update is
|
||||||
|
|
||||||
|
alpha <- alpha + gamma * (alpha_target - 1[miss])
|
||||||
|
|
||||||
|
so a hit adds +gamma*alpha_target and a miss subtracts gamma*(1-alpha_target).
|
||||||
|
Since the band is the (1-alpha) quantile, a *rising* alpha is a *narrowing*
|
||||||
|
band. Hits therefore push alpha up and misses push it down, which reads
|
||||||
|
backwards until you follow it through.
|
||||||
|
"""
|
||||||
|
ac = AdaptiveConformal(alpha=0.1, gamma=0.05)
|
||||||
|
for _ in range(200):
|
||||||
|
ac.observe(0.1, True)
|
||||||
|
a_hits = ac.alpha
|
||||||
|
assert a_hits > 0.1, "a run of hits should raise alpha, narrowing the band"
|
||||||
|
|
||||||
|
for _ in range(200):
|
||||||
|
ac.observe(1e6, False)
|
||||||
|
assert ac.alpha < a_hits, "a run of misses should lower alpha, widening the band"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- zambretti
|
||||||
|
|
||||||
|
def test_zambretti_ordering_rising_is_never_worse_than_falling():
|
||||||
|
"""Z increases toward bad weather, so falling must not score below rising."""
|
||||||
|
for p in [980.0, 1000.0, 1013.0, 1030.0]:
|
||||||
|
rising = zambretti(p, +1.2, 6)["z"]
|
||||||
|
steady = zambretti(p, 0.0, 6)["z"]
|
||||||
|
falling = zambretti(p, -1.2, 6)["z"]
|
||||||
|
assert rising <= steady <= falling, f"ordering broken at {p} hPa"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zambretti_z_decreases_with_pressure_within_a_branch():
|
||||||
|
for tend in (-1.2, 0.0, 1.2):
|
||||||
|
zs = [zambretti(p, tend, 6)["z"] for p in (985.0, 1000.0, 1015.0, 1030.0)]
|
||||||
|
assert all(a >= b for a, b in zip(zs, zs[1:])), f"not monotonic for tend={tend}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zambretti_stays_on_the_26_point_scale():
|
||||||
|
for p in (940.0, 1050.0):
|
||||||
|
for tend in (-5.0, 0.0, 5.0):
|
||||||
|
assert 1 <= zambretti(p, tend, 6)["z"] <= 26
|
||||||
|
|
||||||
|
|
||||||
|
def test_zambretti_rain_prior_rises_with_z():
|
||||||
|
settled = zambretti(1035.0, 1.5, 6)
|
||||||
|
stormy = zambretti(960.0, -2.5, 6)
|
||||||
|
assert stormy["prior_rain_prob"] > settled["prior_rain_prob"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- refit safety
|
||||||
|
|
||||||
|
def test_rls_reset_returns_to_the_prior():
|
||||||
|
m = RecursiveLeastSquares(n_features=5, forgetting=0.999, delta=100.0)
|
||||||
|
rng = np.random.default_rng(9)
|
||||||
|
for _ in range(500):
|
||||||
|
m.update(rng.normal(size=5), float(rng.normal()))
|
||||||
|
assert m.n_updates == 500
|
||||||
|
m.reset()
|
||||||
|
assert m.n_updates == 0
|
||||||
|
assert np.allclose(m.theta, 0.0)
|
||||||
|
assert np.allclose(m.P, np.eye(5) * 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rls_delta_survives_serialisation():
|
||||||
|
"""A refit after a restart must return to the same prior it started from."""
|
||||||
|
m = RecursiveLeastSquares(n_features=4, forgetting=0.99, delta=100.0)
|
||||||
|
m.update(np.ones(4), 1.0)
|
||||||
|
back = RecursiveLeastSquares.from_dict(m.to_dict())
|
||||||
|
back.reset()
|
||||||
|
assert np.allclose(back.P, np.eye(4) * 100.0), "reload lost the prior"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_refits_do_not_accumulate():
|
||||||
|
"""Refitting the same history must be idempotent, not cumulative.
|
||||||
|
|
||||||
|
This is the bug that put a 53 C six-hour forecast on a real station in a
|
||||||
|
24 C room. fit() replayed history into a live filter on every retrain tick
|
||||||
|
and never reset, so 453 grid rows had produced 64,676 updates in a day and a
|
||||||
|
half. RLS with forgetting reads each update as fresh evidence, so P
|
||||||
|
collapsed and the weights drifted without bound in the directions the data
|
||||||
|
never excited.
|
||||||
|
"""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(3)
|
||||||
|
n = 400
|
||||||
|
cols = {
|
||||||
|
"temperature": 22 + 2 * np.sin(np.arange(n) / 40.0) + 0.2 * rng.normal(size=n),
|
||||||
|
"humidity": 50 + 5 * np.cos(np.arange(n) / 33.0),
|
||||||
|
"pressure": 1013 + np.sin(np.arange(n) / 77.0),
|
||||||
|
"lux": np.clip(300 * np.sin(np.arange(n) / 120.0), 0, None),
|
||||||
|
}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
valid = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
head = ens.heads[("temperature", 21600)]
|
||||||
|
|
||||||
|
ens.fit(X, valid, cols)
|
||||||
|
first_updates = head.model.n_updates
|
||||||
|
|
||||||
|
norms = []
|
||||||
|
for _ in range(15):
|
||||||
|
ens.fit(X, valid, cols)
|
||||||
|
norms.append(float(np.linalg.norm(head.model.theta)))
|
||||||
|
|
||||||
|
# Exact equality is no longer the right assertion: the stride rotates its
|
||||||
|
# phase each refit, so a given refit trains on 12 or 13 pairs depending on
|
||||||
|
# where the offset lands. One update of slack covers that. Sixteen passes
|
||||||
|
# of accumulation would show up as 16x, not as 1.
|
||||||
|
assert abs(head.model.n_updates - first_updates) <= 1, \
|
||||||
|
"updates accumulated across refits; a refit must start from the prior"
|
||||||
|
|
||||||
|
# The failure this guards against put ||theta|| at 1680 against a median
|
||||||
|
# weight of 1.67. Phase rotation moves the norm by about 25% on these
|
||||||
|
# deliberately signal-free features, so bound the magnitude rather than
|
||||||
|
# pinning the value, and check it is not climbing refit on refit.
|
||||||
|
assert max(norms) < 20.0, f"weights drifting without bound: {max(norms):.1f}"
|
||||||
|
assert np.mean(norms[-5:]) < 3.0 * np.mean(norms[:5]), "weights growing across refits"
|
||||||
|
|
||||||
|
|
||||||
|
def test_annual_harmonics_are_zero_until_the_record_spans_a_season():
|
||||||
|
"""Two near-constant, near-collinear columns are a rank-deficient regressor.
|
||||||
|
|
||||||
|
Left on from day one, sin_doy and cos_doy carried +1174 and +1191 on a real
|
||||||
|
station whose median weight was 1.67. Zero is the honest value: a day and a
|
||||||
|
half of data says nothing whatsoever about the season.
|
||||||
|
"""
|
||||||
|
from ashvale.features import FEATURE_NAMES, build_features
|
||||||
|
|
||||||
|
n = 450
|
||||||
|
ts = np.arange(n) * 300.0 + 1.7554e9 # about 1.5 days
|
||||||
|
t = 22 + 2 * np.sin(np.arange(n) / 40.0)
|
||||||
|
h = 50 + 5 * np.cos(np.arange(n) / 33.0)
|
||||||
|
p = 1013 + np.sin(np.arange(n) / 77.0)
|
||||||
|
lux = np.clip(300 * np.sin(np.arange(n) / 120.0), 0, None)
|
||||||
|
|
||||||
|
si, ci = FEATURE_NAMES.index("sin_doy"), FEATURE_NAMES.index("cos_doy")
|
||||||
|
|
||||||
|
X, _ = build_features(ts, t, h, p, lux, 300, 52.2, 0.12, min_days_annual=120.0)
|
||||||
|
assert np.all(X[:, si] == 0.0) and np.all(X[:, ci] == 0.0)
|
||||||
|
|
||||||
|
# A record that does span the year keeps them.
|
||||||
|
ts_long = np.arange(n) * (200 * 86400.0 / n) + 1.7554e9
|
||||||
|
X2, _ = build_features(ts_long, t, h, p, lux, 300, 52.2, 0.12, min_days_annual=120.0)
|
||||||
|
assert X2[:, si].std() > 0.1, "annual terms should return once the record is long enough"
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_pairs_are_strided_by_the_horizon():
|
||||||
|
"""Overlapping windows must not be counted as independent observations.
|
||||||
|
|
||||||
|
At the 1 d horizon on a 5-minute grid adjacent pairs share 287 of their 288
|
||||||
|
samples. Training on every row hands the filter the same outcome 288 times
|
||||||
|
and RLS with forgetting reads each as fresh evidence, so a 400-score
|
||||||
|
conformal window ends up holding 1.4 independent outcomes while believing
|
||||||
|
it holds 400.
|
||||||
|
"""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
n = 4000 # ~14 days at 5 minutes
|
||||||
|
g = CONFIG.model.grid_s
|
||||||
|
cols = {
|
||||||
|
"temperature": 20 + 4 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
|
||||||
|
"humidity": 55 + 8 * np.cos(np.arange(n) / 288.0),
|
||||||
|
"pressure": 1013 + 4 * np.sin(np.arange(n) / 900.0),
|
||||||
|
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
|
||||||
|
}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
valid = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
counts = ens.fit(X, valid, cols)
|
||||||
|
|
||||||
|
for h in CONFIG.model.horizons_s:
|
||||||
|
steps = max(round(h / g), 1)
|
||||||
|
got = counts[f"temperature@{h}"]
|
||||||
|
# fit() bounds recency to max_pairs rows before it strides them.
|
||||||
|
available = min(n - steps, 2500)
|
||||||
|
expected = available // steps
|
||||||
|
if expected >= CONFIG.model.min_pairs_per_head:
|
||||||
|
assert abs(got - expected) <= 1, (
|
||||||
|
f"horizon {h}s trained on {got} pairs, expected about {expected}")
|
||||||
|
assert got < available / 2, "pairs were not strided"
|
||||||
|
else:
|
||||||
|
# The floor relaxes the stride rather than letting a long horizon
|
||||||
|
# train on a handful of pairs.
|
||||||
|
assert got >= CONFIG.model.min_pairs_per_head
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stride_floor_protects_a_short_record():
|
||||||
|
"""A 1 d horizon on two days of data must not train on two pairs."""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(12)
|
||||||
|
n = 700 # ~2.4 days at 5 minutes
|
||||||
|
cols = {"temperature": 21 + rng.normal(size=n) * 0.1,
|
||||||
|
"humidity": 50 + rng.normal(size=n) * 0.1,
|
||||||
|
"pressure": 1013 + rng.normal(size=n) * 0.1,
|
||||||
|
"lux": np.zeros(n)}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
counts = ens.fit(X, np.ones(n, dtype=bool), cols)
|
||||||
|
|
||||||
|
day = counts["temperature@86400"]
|
||||||
|
assert day >= CONFIG.model.min_pairs_per_head, (
|
||||||
|
f"1 d head trained on only {day} pairs; the floor did not engage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_refit_phase_rotates_and_survives_serialisation():
|
||||||
|
"""Every offset must eventually be trained on, across restarts too."""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(13)
|
||||||
|
n = 600
|
||||||
|
cols = {k: 20 + rng.normal(size=n) * 0.1 for k in CONFIG.model.targets}
|
||||||
|
cols["lux"] = np.zeros(n)
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
valid = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
assert ens.refit_phase == 0
|
||||||
|
ens.fit(X, valid, cols)
|
||||||
|
ens.fit(X, valid, cols)
|
||||||
|
assert ens.refit_phase == 2
|
||||||
|
|
||||||
|
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
back.load_dict(ens.to_dict())
|
||||||
|
assert back.refit_phase == 2, "a restart must not reset the stride to phase 0 forever"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_tuning_comes_from_config_not_from_the_state_file():
|
||||||
|
"""The same defect that made a Kalman retune silently do nothing.
|
||||||
|
|
||||||
|
RecursiveLeastSquares.from_dict and AdaptiveConformal.from_dict restore
|
||||||
|
lambda, delta, alpha, gamma and the conformal window alongside their data,
|
||||||
|
and load_dict replaces the config-built heads with those. So every one of
|
||||||
|
those knobs was immutable on any station that already had state: edit
|
||||||
|
config.yaml, restart, and the old value comes straight back.
|
||||||
|
"""
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
old = cfg.model
|
||||||
|
ens = NowcastEnsemble(old.targets, old.horizons_s, old)
|
||||||
|
saved = ens.to_dict()
|
||||||
|
|
||||||
|
import copy
|
||||||
|
new = copy.deepcopy(old)
|
||||||
|
new.rls_forgetting = 0.995
|
||||||
|
new.rls_delta = 55.0
|
||||||
|
new.conformal_alpha = 0.20
|
||||||
|
new.conformal_gamma = 0.05
|
||||||
|
new.conformal_window = 250
|
||||||
|
|
||||||
|
back = NowcastEnsemble(new.targets, new.horizons_s, new)
|
||||||
|
back.load_dict(saved)
|
||||||
|
head = next(iter(back.heads.values()))
|
||||||
|
assert head.model.lam == 0.995, "state file pinned the forgetting factor"
|
||||||
|
assert head.model.delta == 55.0, "state file pinned the RLS prior"
|
||||||
|
assert head.conformal.alpha_target == 0.20
|
||||||
|
assert head.conformal.gamma == 0.05
|
||||||
|
assert head.conformal.scores.maxlen == 250
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_state_ignores_heads_this_build_no_longer_has():
|
||||||
|
"""A stale state file must not resurrect a retired target or horizon."""
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
cfg = load_config().model
|
||||||
|
ens = NowcastEnsemble(cfg.targets, cfg.horizons_s, cfg)
|
||||||
|
saved = ens.to_dict()
|
||||||
|
saved["heads"].append({**saved["heads"][0], "target": "retired_signal"})
|
||||||
|
|
||||||
|
back = NowcastEnsemble(cfg.targets, cfg.horizons_s, cfg)
|
||||||
|
back.load_dict(saved)
|
||||||
|
assert not any(t == "retired_signal" for t, _ in back.heads)
|
||||||
|
assert len(back.heads) == len(cfg.targets) * len(cfg.horizons_s)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conformal_produces_a_band_from_the_fewest_scores_that_permit_one():
|
||||||
|
"""20 was arbitrary and became harmful once pairs were strided.
|
||||||
|
|
||||||
|
The (1-alpha) empirical quantile is the ceil((k+1)(1-alpha))-th of k order
|
||||||
|
statistics, so alpha = 0.10 needs k >= 9. Requiring 20 threw away a valid
|
||||||
|
band at k = 13, which is roughly what a long-horizon head earns per refit
|
||||||
|
after striding, and dropped twelve of eighteen heads onto 1.645*sigma with
|
||||||
|
sigma from an unconstrained x'Px. That produced +/- 115% relative humidity.
|
||||||
|
"""
|
||||||
|
from ashvale.models.rls import AdaptiveConformal
|
||||||
|
|
||||||
|
assert AdaptiveConformal.MIN_SCORES == 9
|
||||||
|
|
||||||
|
ac = AdaptiveConformal(alpha=0.10, gamma=0.01)
|
||||||
|
for i in range(8):
|
||||||
|
ac.observe(0.1 * (i + 1), True)
|
||||||
|
assert not np.isfinite(ac.quantile()), "8 scores cannot support a 90% band"
|
||||||
|
|
||||||
|
ac.observe(0.9, True)
|
||||||
|
q = ac.quantile()
|
||||||
|
assert np.isfinite(q), "9 scores must produce a band"
|
||||||
|
assert q > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_strided_heads_do_not_fall_back_to_the_gaussian():
|
||||||
|
"""The end-to-end version of the same thing, through a real fit."""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.climatology import HarmonicClimatology
|
||||||
|
from ashvale.models.nowcast import MEMBERS, NowcastEnsemble
|
||||||
|
from ashvale.models.rls import AdaptiveConformal
|
||||||
|
|
||||||
|
rng = np.random.default_rng(19)
|
||||||
|
n = 700 # ~2.4 days, a young station
|
||||||
|
g = CONFIG.model.grid_s
|
||||||
|
ts = np.arange(n) * g + 1.7554e9
|
||||||
|
cols = {
|
||||||
|
"temperature": 22 + 3 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
|
||||||
|
"humidity": 50 + 9 * np.cos(np.arange(n) / 288.0),
|
||||||
|
"pressure": 1013 + 2 * np.sin(np.arange(n) / 600.0),
|
||||||
|
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
|
||||||
|
}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
valid = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
clim = HarmonicClimatology(CONFIG.model.targets,
|
||||||
|
min_days_annual=CONFIG.model.climatology_min_days_annual)
|
||||||
|
clim.fit(ts, {k: cols[k] for k in CONFIG.model.targets}, valid)
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
ens.fit(X, valid, {k: cols[k] for k in CONFIG.model.targets})
|
||||||
|
|
||||||
|
# A refit alone must leave the calibrators empty: they are earned from
|
||||||
|
# scored forecasts now, not from replayed history.
|
||||||
|
for head in ens.heads.values():
|
||||||
|
assert len(head.conformal.scores) == 0
|
||||||
|
|
||||||
|
# Feed each head the fewest outcomes a 90% band can be built from and the
|
||||||
|
# band must exist and be physical. MIN_SCORES was 20, which is arbitrary,
|
||||||
|
# and left twelve of eighteen heads on 1.645*sigma with sigma from an
|
||||||
|
# unconstrained x'Px: bands of +/- 45 C and +/- 115% RH.
|
||||||
|
rng2 = np.random.default_rng(21)
|
||||||
|
for (target, h), head in ens.heads.items():
|
||||||
|
base = {"temperature": 22.0, "humidity": 50.0, "pressure": 1013.0}[target]
|
||||||
|
for k in range(AdaptiveConformal.MIN_SCORES):
|
||||||
|
truth = base + float(rng2.normal(scale=0.5))
|
||||||
|
head.observe_outcome(np.full(len(MEMBERS), base), truth,
|
||||||
|
covered=True, valid_ts=1.7554e9 + k * h)
|
||||||
|
q = head.conformal.quantile()
|
||||||
|
assert np.isfinite(q), (
|
||||||
|
f"{target}@{h}s has {len(head.conformal.scores)} scores and no band, "
|
||||||
|
"so it falls back to the Gaussian")
|
||||||
|
limit = {"temperature": 25.0, "humidity": 60.0, "pressure": 40.0}[target]
|
||||||
|
assert q < limit, f"{target}@{h}s band is +/- {q:.1f}, which is not a forecast"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# 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 closed forms.
|
||||||
|
|
||||||
|
These are properties, not golden numbers. A golden number test tells you the
|
||||||
|
output changed; a property test tells you the output became unphysical, which
|
||||||
|
is the failure that actually matters here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ashvale import physics
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("t", [-20.0, -5.0, 0.0, 12.3, 25.0, 40.0])
|
||||||
|
def test_dew_point_at_saturation_equals_temperature(t):
|
||||||
|
"""100% RH means the air is already at its dew point."""
|
||||||
|
assert float(physics.dew_point(t, 100.0)) == pytest.approx(t, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("t,rh", [(20.0, 50.0), (5.0, 80.0), (30.0, 20.0), (-3.0, 95.0)])
|
||||||
|
def test_dew_point_never_exceeds_temperature(t, rh):
|
||||||
|
assert float(physics.dew_point(t, rh)) <= t + 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_dew_point_round_trip_through_vapour_pressure():
|
||||||
|
"""e(T, RH) evaluated at the dew point must be the saturation pressure."""
|
||||||
|
for t, rh in [(20.0, 50.0), (25.6, 72.9), (0.5, 90.0)]:
|
||||||
|
td = float(physics.dew_point(t, rh))
|
||||||
|
assert float(physics.vapour_pressure(t, rh)) == pytest.approx(
|
||||||
|
float(physics.saturation_vapour_pressure(td)), rel=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_saturation_vapour_pressure_is_monotonic_in_temperature():
|
||||||
|
t = np.linspace(-30.0, 50.0, 400)
|
||||||
|
es = np.asarray(physics.saturation_vapour_pressure(t), dtype=float)
|
||||||
|
assert np.all(np.diff(es) > 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("t,rh", [(20.0, 50.0), (30.0, 30.0), (10.0, 95.0)])
|
||||||
|
def test_wet_bulb_between_dew_point_and_temperature(t, rh):
|
||||||
|
"""The psychrometric ordering Td <= Tw <= T is not optional."""
|
||||||
|
td = float(physics.dew_point(t, rh))
|
||||||
|
tw = float(physics.wet_bulb(t, rh))
|
||||||
|
assert td - 1e-6 <= tw <= t + 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def test_vpd_is_zero_at_saturation_and_positive_below():
|
||||||
|
assert float(physics.vapour_pressure_deficit(20.0, 100.0)) == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert float(physics.vapour_pressure_deficit(20.0, 40.0)) > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sea_level_pressure_round_trips_with_station_pressure():
|
||||||
|
for p, t, alt in [(1000.0, 15.0, 11.0), (1024.5, -2.0, 250.0), (985.0, 28.0, 0.0)]:
|
||||||
|
slp = float(physics.sea_level_pressure(p, t, alt))
|
||||||
|
back = float(physics.station_pressure(slp, t, alt))
|
||||||
|
assert back == pytest.approx(p, rel=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sea_level_pressure_is_above_station_pressure_when_elevated():
|
||||||
|
assert float(physics.sea_level_pressure(1000.0, 15.0, 100.0)) > 1000.0
|
||||||
|
assert float(physics.sea_level_pressure(1000.0, 15.0, 0.0)) == pytest.approx(1000.0, rel=1e-12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_elevation_is_higher_at_local_noon_than_midnight():
|
||||||
|
# 21 June 2026, Cambridge. Noon UTC against midnight UTC.
|
||||||
|
noon, _ = physics.solar_position(np.array([1781784000.0]), 52.2053, 0.1218)
|
||||||
|
midnight, _ = physics.solar_position(np.array([1781740800.0]), 52.2053, 0.1218)
|
||||||
|
assert float(np.atleast_1d(noon)[0]) > float(np.atleast_1d(midnight)[0])
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_sky_irradiance_is_zero_below_the_horizon():
|
||||||
|
assert float(np.atleast_1d(physics.clear_sky_irradiance(np.array([-10.0])))[0]) == 0.0
|
||||||
|
assert float(np.atleast_1d(physics.clear_sky_irradiance(np.array([45.0])))[0]) > 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute_humidity_rises_with_temperature_at_fixed_rh():
|
||||||
|
a = float(physics.absolute_humidity(10.0, 60.0))
|
||||||
|
b = float(physics.absolute_humidity(25.0, 60.0))
|
||||||
|
assert b > a
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""History re-derivation after a calibration.
|
||||||
|
|
||||||
|
The property that matters is idempotence. Recompute always starts from the
|
||||||
|
untouched raw columns, so running it twice must land in exactly the same place.
|
||||||
|
If it ever compounds, a user who clicks the button twice silently corrupts
|
||||||
|
their entire record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("fastapi")
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
import ashvale.api as api # noqa: E402
|
||||||
|
from ashvale.config import CONFIG # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _avg(col: str) -> float:
|
||||||
|
with sqlite3.connect(CONFIG.storage.db_path) as c:
|
||||||
|
return c.execute(f"SELECT round(avg({col}), 6) FROM telemetry").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot() -> dict:
|
||||||
|
"""Per-row values keyed by timestamp.
|
||||||
|
|
||||||
|
Deliberately not an aggregate. The station's sample loop is live under
|
||||||
|
TestClient, so rows arrive between calls and any average over the whole
|
||||||
|
table is a moving target. Comparing the rows present in both snapshots
|
||||||
|
tests the property that actually matters.
|
||||||
|
"""
|
||||||
|
with sqlite3.connect(CONFIG.storage.db_path) as c:
|
||||||
|
return {r[0]: (r[1], r[2]) for r in
|
||||||
|
c.execute("SELECT ts, hum_smooth, temp_smooth FROM telemetry")}
|
||||||
|
|
||||||
|
|
||||||
|
def _rows() -> int:
|
||||||
|
with sqlite3.connect(CONFIG.storage.db_path) as c:
|
||||||
|
return c.execute("SELECT count(*) FROM telemetry").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
with TestClient(api.app) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_recompute_is_idempotent(client):
|
||||||
|
"""Running it twice must land in exactly the same place, row for row.
|
||||||
|
|
||||||
|
It always starts from the untouched raw columns, so it cannot compound. If
|
||||||
|
that ever breaks, a user clicking the button twice silently corrupts their
|
||||||
|
whole record, which is why this is tested per row rather than on an average.
|
||||||
|
"""
|
||||||
|
if _rows() == 0:
|
||||||
|
pytest.skip("no history in the database")
|
||||||
|
client.post("/api/recompute")
|
||||||
|
first = _snapshot()
|
||||||
|
client.post("/api/recompute")
|
||||||
|
second = _snapshot()
|
||||||
|
common = set(first) & set(second)
|
||||||
|
assert common, "no overlapping rows to compare"
|
||||||
|
differing = [ts for ts in common if first[ts] != second[ts]]
|
||||||
|
assert not differing, f"{len(differing)} of {len(common)} rows changed on re-run"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recompute_preserves_row_count(client):
|
||||||
|
"""Recompute must never drop a row.
|
||||||
|
|
||||||
|
Asserted as "no fewer than before" rather than equality: the sample loop is
|
||||||
|
live under TestClient and legitimately inserts rows mid-test. Equality here
|
||||||
|
was flaky for that reason, and a flaky test is worse than no test because it
|
||||||
|
trains you to ignore red.
|
||||||
|
"""
|
||||||
|
if _rows() == 0:
|
||||||
|
pytest.skip("no history in the database")
|
||||||
|
before = _rows()
|
||||||
|
result = client.post("/api/recompute").json()
|
||||||
|
after = _rows()
|
||||||
|
assert after >= before, f"rows lost: {before} -> {after}"
|
||||||
|
assert result["rows"] >= before, "recompute touched fewer rows than existed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recompute_tracks_the_current_offset(client):
|
||||||
|
"""Changing the calibration must move the whole history, not just new rows."""
|
||||||
|
if _rows() == 0:
|
||||||
|
pytest.skip("no history in the database")
|
||||||
|
client.post("/api/calibrate/humidity", json={"reset": True})
|
||||||
|
client.post("/api/recompute")
|
||||||
|
base = _avg("hum_smooth")
|
||||||
|
|
||||||
|
client.post("/api/calibrate/humidity", json={"reference_pct": 30.0})
|
||||||
|
client.post("/api/recompute")
|
||||||
|
shifted = _avg("hum_smooth")
|
||||||
|
assert shifted != pytest.approx(base), "history did not follow the new offset"
|
||||||
|
|
||||||
|
client.post("/api/calibrate/humidity", json={"reset": True})
|
||||||
|
client.post("/api/recompute")
|
||||||
|
assert _avg("hum_smooth") == pytest.approx(base, abs=0.5), "reset did not restore"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_logs_a_discontinuity_marker(client):
|
||||||
|
client.post("/api/calibrate/humidity", json={"reference_pct": 55.0})
|
||||||
|
kinds = [e["kind"] for e in client.get("/api/status").json()["events"]]
|
||||||
|
assert "discontinuity" in kinds
|
||||||
|
client.post("/api/calibrate/humidity", json={"reset": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ clock guard
|
||||||
|
|
||||||
|
def test_training_refuses_a_clock_that_has_not_been_set(tmp_path, monkeypatch):
|
||||||
|
"""The board has no RTC.
|
||||||
|
|
||||||
|
A power cut without a network gives a clock somewhere in 1970 on the next
|
||||||
|
boot. Solar elevation, the diurnal harmonics and a sample's position on the
|
||||||
|
5-minute grid all then lie with total confidence, and unlike a gap in the
|
||||||
|
record the damage cannot be spotted afterwards.
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / "clock.db")
|
||||||
|
st = Station(cfg)
|
||||||
|
|
||||||
|
assert st.clock_sanity()["ok"], "a correct clock must pass"
|
||||||
|
|
||||||
|
monkeypatch.setattr(_time, "time", lambda: 1000.0) # 1970
|
||||||
|
verdict = st.clock_sanity()
|
||||||
|
assert not verdict["ok"]
|
||||||
|
assert "2025" in verdict["reason"]
|
||||||
|
result = st.train()
|
||||||
|
assert result["trained"] is False
|
||||||
|
# and specifically for the clock, not because the database is empty
|
||||||
|
assert "2025" in result["reason"], result["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_refuses_a_clock_that_went_backwards(tmp_path, monkeypatch):
|
||||||
|
"""NTP stepping backwards past stored data is equally unusable."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / "back.db")
|
||||||
|
st = Station(cfg)
|
||||||
|
future = _time.time() + 7200.0
|
||||||
|
st.store.insert_telemetry({"ts": future, "temp_raw": 20.0})
|
||||||
|
|
||||||
|
verdict = st.clock_sanity()
|
||||||
|
assert not verdict["ok"]
|
||||||
|
assert "behind" in verdict["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ joystick
|
||||||
|
|
||||||
|
def test_joystick_left_and_right_record_rain_labels(tmp_path):
|
||||||
|
"""The button that fixes the precipitation model.
|
||||||
|
|
||||||
|
Strong labels are the binding constraint on that head: 80 against thousands
|
||||||
|
of proxy ones on a real station, because the only label control lives in a
|
||||||
|
web page. Left is dry, right is wet.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / "stick.db")
|
||||||
|
st = Station(cfg)
|
||||||
|
st.sample_once()
|
||||||
|
|
||||||
|
pending = [("left", "pressed"), ("right", "pressed"),
|
||||||
|
("up", "pressed"), ("right", "released")]
|
||||||
|
|
||||||
|
def fake_events():
|
||||||
|
out, pending[:] = list(pending), []
|
||||||
|
return out
|
||||||
|
|
||||||
|
st.board.stick_events = fake_events
|
||||||
|
|
||||||
|
async def one_pass():
|
||||||
|
task = asyncio.create_task(st._loop_joystick())
|
||||||
|
await asyncio.sleep(0.6)
|
||||||
|
st._stop.set()
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.run(one_pass())
|
||||||
|
|
||||||
|
with sqlite3.connect(cfg.storage.db_path) as c:
|
||||||
|
rows = sorted(r[0] for r in c.execute("SELECT value FROM labels WHERE kind='rain'"))
|
||||||
|
assert rows == [0.0, 1.0], f"expected one dry and one wet label, got {rows}"
|
||||||
|
# 'up' is unbound and 'released' is not a press: neither may label anything.
|
||||||
|
|
||||||
|
|
||||||
|
def test_joystick_survives_a_board_with_no_hat(tmp_path):
|
||||||
|
"""The simulator path has no stick. The loop must not spin on exceptions."""
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / "nohat.db")
|
||||||
|
st = Station(cfg)
|
||||||
|
assert st.board.stick_events() == []
|
||||||
|
assert st.display is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- movement detection
|
||||||
|
|
||||||
|
def _moved_station(tmp_path, name):
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / name)
|
||||||
|
st = Station(cfg)
|
||||||
|
st._started_at = 0.0 # long settled
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_moved_board_marks_a_discontinuity_and_queues_a_retrain(tmp_path):
|
||||||
|
"""Measured on a real station: a move steps the temperature by a median of
|
||||||
|
1.02 C against an ordinary fifteen minute change of 0.107 C. The heads
|
||||||
|
carry 55 hours of memory, so an undeclared move contaminates two days.
|
||||||
|
"""
|
||||||
|
st = _moved_station(tmp_path, "moved.db")
|
||||||
|
base = 1.7554e9
|
||||||
|
|
||||||
|
for i in range(20): # sitting still, with realistic jitter
|
||||||
|
st._check_moved(base + i, {"pitch": -64.05 + 1e-5 * i,
|
||||||
|
"roll": 1.36, "temp_smooth": 24.0})
|
||||||
|
assert not st.monitor.retrain_requested, "noise must not trigger a retrain"
|
||||||
|
|
||||||
|
st._check_moved(base + 100, {"pitch": -53.5, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
assert st.monitor.retrain_requested, "a 10 degree move must queue a retrain"
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(st.cfg.storage.db_path) as c:
|
||||||
|
kinds = [r[0] for r in c.execute("SELECT kind FROM events")]
|
||||||
|
assert "moved" in kinds and "discontinuity" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_attitude_jump_is_not_mistaken_for_a_move(tmp_path):
|
||||||
|
"""RTIMULib restarts its fusion from a default attitude when SenseHat is
|
||||||
|
reconstructed, which put an 18 degree step in the record on every service
|
||||||
|
restart. Without the settle window every deploy looks like a move.
|
||||||
|
"""
|
||||||
|
st = _moved_station(tmp_path, "restart.db")
|
||||||
|
now = 1.7554e9
|
||||||
|
st._started_at = now # just booted
|
||||||
|
|
||||||
|
st._check_moved(now + 1, {"pitch": -46.0, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
st._check_moved(now + 60, {"pitch": -64.1, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
st._check_moved(now + 180, {"pitch": -46.0, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
assert not st.monitor.retrain_requested, "startup convergence must be ignored"
|
||||||
|
|
||||||
|
# past the settle window, the same step is a real move
|
||||||
|
st._check_moved(now + st.TILT_SETTLE_S + 10, {"pitch": -64.1, "roll": 1.4,
|
||||||
|
"temp_smooth": 24.0})
|
||||||
|
assert st.monitor.retrain_requested
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaw_and_compass_are_not_used_for_movement(tmp_path):
|
||||||
|
"""They depend on the magnetometer, which indoors measures the building."""
|
||||||
|
st = _moved_station(tmp_path, "yaw.db")
|
||||||
|
base = 1.7554e9
|
||||||
|
st._check_moved(base, {"pitch": -64.0, "roll": 1.4, "yaw": 10.0,
|
||||||
|
"compass": 10.0, "temp_smooth": 24.0})
|
||||||
|
st._check_moved(base + 30, {"pitch": -64.0, "roll": 1.4, "yaw": 300.0,
|
||||||
|
"compass": 300.0, "temp_smooth": 24.0})
|
||||||
|
assert not st.monitor.retrain_requested, "a 290 degree yaw swing is not a move"
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Where the blend weights and the prediction intervals are allowed to learn.
|
||||||
|
|
||||||
|
Both are statements about how this head's issued forecasts actually turned out.
|
||||||
|
A refit is not an outcome, it is the same week of weather being read again, and
|
||||||
|
a matured forecast that stays readable for an hour is one outcome and not
|
||||||
|
twelve. Getting either wrong does not raise: it quietly multiplies the evidence
|
||||||
|
until Hedge saturates and the ACI integrator pins against its clips.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ashvale.config import CONFIG, load_config
|
||||||
|
from ashvale.models.nowcast import MEMBERS, NowcastEnsemble
|
||||||
|
from ashvale.station import Station
|
||||||
|
from ashvale.storage import Store
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic(n: int = 700, seed: int = 5):
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
g = CONFIG.model.grid_s
|
||||||
|
ts = np.arange(n) * g + 1.7554e9
|
||||||
|
cols = {
|
||||||
|
"temperature": 22 + 3 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
|
||||||
|
"humidity": 50 + 9 * np.cos(np.arange(n) / 288.0),
|
||||||
|
"pressure": 1013 + 2 * np.sin(np.arange(n) / 600.0),
|
||||||
|
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
|
||||||
|
}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
return ts, cols, X, np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------ a refit is not an outcome
|
||||||
|
|
||||||
|
def test_refitting_does_not_touch_the_weights_or_the_calibrator():
|
||||||
|
"""The defect this file exists for.
|
||||||
|
|
||||||
|
Measured on 8.2 days of a real station, fit() had put 977,078 Hedge updates
|
||||||
|
through the 15 minute head from 758 distinct supervised pairs, and 296,715
|
||||||
|
through the 1 day head from 12. Hedge is multiplicative, so an edge far too
|
||||||
|
small to be real compounds to certainty.
|
||||||
|
"""
|
||||||
|
_, cols, X, valid = _synthetic()
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
before = {k: h.weights.copy() for k, h in ens.heads.items()}
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
ens.fit(X, valid, cols)
|
||||||
|
|
||||||
|
for k, head in ens.heads.items():
|
||||||
|
assert np.allclose(head.weights, before[k]), \
|
||||||
|
f"{k} blend weights moved during a refit"
|
||||||
|
assert len(head.conformal.scores) == 0, \
|
||||||
|
f"{k} fed {len(head.conformal.scores)} scores to the calibrator from a refit"
|
||||||
|
assert head.n_scored == 0
|
||||||
|
# The regression itself must still be learning.
|
||||||
|
assert head.model.n_updates > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_outcome_does_move_them():
|
||||||
|
"""The other half: verify()'s channel has to work, or nothing ever learns."""
|
||||||
|
_, cols, X, valid = _synthetic()
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
ens.fit(X, valid, cols)
|
||||||
|
head = ens.heads[("temperature", 900)]
|
||||||
|
before = head.weights.copy()
|
||||||
|
|
||||||
|
# Persistence right, everything else wrong, so the weights must move to it.
|
||||||
|
members = np.array([22.0, 30.0, 30.0, 30.0])
|
||||||
|
for k in range(20):
|
||||||
|
head.observe_outcome(members, 22.0, covered=True, valid_ts=1e9 + k * 900)
|
||||||
|
|
||||||
|
assert head.n_scored == 20
|
||||||
|
assert len(head.conformal.scores) == 20
|
||||||
|
assert head.weights[MEMBERS.index("persistence")] > before[MEMBERS.index("persistence")]
|
||||||
|
assert head.weights[MEMBERS.index("climatology")] < before[MEMBERS.index("climatology")]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- overlapping outcomes are one fact
|
||||||
|
|
||||||
|
def test_hedge_ignores_outcomes_that_overlap_the_last_one_it_took():
|
||||||
|
"""Forecasts are issued every retrain tick, so at the 1 day horizon about
|
||||||
|
two hundred a day resolve against very nearly the same outcome. The
|
||||||
|
conformal window can absorb that, being an order statistic. Exponentiated
|
||||||
|
gradient cannot."""
|
||||||
|
_, cols, X, valid = _synthetic()
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
head = ens.heads[("temperature", 86400)]
|
||||||
|
members = np.array([22.0, 30.0, 30.0, 30.0])
|
||||||
|
|
||||||
|
t0 = 1.7554e9
|
||||||
|
for k in range(50): # 50 forecasts, 10 minutes apart
|
||||||
|
head.observe_outcome(members, 22.0, covered=True, valid_ts=t0 + k * 600)
|
||||||
|
|
||||||
|
assert head.n_scored == 1, \
|
||||||
|
f"Hedge took {head.n_scored} of 50 overlapping outcomes at a 1 d horizon"
|
||||||
|
# The calibrator still sees all of them: a quantile over duplicated scores
|
||||||
|
# is over-confident about its sample size, not wrong about its value.
|
||||||
|
assert len(head.conformal.scores) == 50
|
||||||
|
|
||||||
|
head.observe_outcome(members, 22.0, covered=True, valid_ts=t0 + 86400)
|
||||||
|
assert head.n_scored == 2, "a genuinely new outcome must be taken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_decimation_clock_survives_a_restart():
|
||||||
|
"""Otherwise every deploy hands the weights a free duplicate."""
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
head = ens.heads[("pressure", 3600)]
|
||||||
|
head.observe_outcome(np.full(len(MEMBERS), 1013.0), 1013.5,
|
||||||
|
covered=True, valid_ts=1.7554e9)
|
||||||
|
|
||||||
|
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
back.load_dict(ens.to_dict())
|
||||||
|
assert back.heads[("pressure", 3600)].last_hedge_ts == head.last_hedge_ts
|
||||||
|
|
||||||
|
# A head that has never scored must not refuse its first outcome.
|
||||||
|
fresh = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
fresh.load_dict(NowcastEnsemble(CONFIG.model.targets,
|
||||||
|
CONFIG.model.horizons_s, CONFIG.model).to_dict())
|
||||||
|
h = fresh.heads[("pressure", 3600)]
|
||||||
|
h.observe_outcome(np.full(len(MEMBERS), 1013.0), 1013.5,
|
||||||
|
covered=True, valid_ts=1.7554e9)
|
||||||
|
assert h.n_scored == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- persistence
|
||||||
|
|
||||||
|
def test_forecast_members_round_trip(tmp_path):
|
||||||
|
"""The learned member cannot be recomputed at maturity, the RLS has moved
|
||||||
|
on, so it has to be written down at issue time."""
|
||||||
|
store = Store(str(tmp_path / "f.db"))
|
||||||
|
members = {"persistence": 21.0, "climatology": 22.0,
|
||||||
|
"learned": 23.0, "setpoint": 24.0}
|
||||||
|
store.insert_forecast(1000.0, 900, "temperature", 22.5, 21.0, 24.0,
|
||||||
|
"ensemble", members=members)
|
||||||
|
row = store.due_forecasts(now=2000.0)[0]
|
||||||
|
assert Station._members_of(row).tolist() == [21.0, 22.0, 23.0, 24.0]
|
||||||
|
assert row["scored"] == 0
|
||||||
|
|
||||||
|
store.mark_forecasts_scored([(1000.0, 900, "temperature")])
|
||||||
|
assert store.due_forecasts(now=2000.0)[0]["scored"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_forecast_from_before_the_columns_existed_is_not_fatal(tmp_path):
|
||||||
|
"""It still deserves a conformal score. It just cannot move the weights."""
|
||||||
|
store = Store(str(tmp_path / "old.db"))
|
||||||
|
store.insert_forecast(1000.0, 900, "temperature", 22.5, 21.0, 24.0, "ensemble")
|
||||||
|
row = store.due_forecasts(now=2000.0)[0]
|
||||||
|
assert Station._members_of(row) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_adds_the_forecast_columns_to_a_live_table(tmp_path):
|
||||||
|
"""Same hazard as the telemetry columns: CREATE TABLE IF NOT EXISTS is a
|
||||||
|
no-op against a table that already exists."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
path = str(tmp_path / "legacy.db")
|
||||||
|
with sqlite3.connect(path) as c:
|
||||||
|
c.execute("CREATE TABLE forecasts ("
|
||||||
|
"issued_ts REAL NOT NULL, valid_ts REAL NOT NULL, "
|
||||||
|
"horizon_s INTEGER NOT NULL, target TEXT NOT NULL, "
|
||||||
|
"mu REAL, lo REAL, hi REAL, model TEXT, "
|
||||||
|
"PRIMARY KEY (issued_ts, horizon_s, target))")
|
||||||
|
c.execute("INSERT INTO forecasts VALUES (1.0, 901.0, 900, 'temperature',"
|
||||||
|
" 22.0, 21.0, 23.0, 'ensemble')")
|
||||||
|
|
||||||
|
store = Store(path)
|
||||||
|
with sqlite3.connect(path) as c:
|
||||||
|
have = {r[1] for r in c.execute("PRAGMA table_info(forecasts)")}
|
||||||
|
for col in ("m_persistence", "m_climatology", "m_learned", "m_setpoint", "scored"):
|
||||||
|
assert col in have, f"migration missed {col}"
|
||||||
|
|
||||||
|
rows = store.due_forecasts(now=2000.0)
|
||||||
|
assert len(rows) == 1, "the existing row must survive the migration"
|
||||||
|
assert rows[0]["scored"] == 0
|
||||||
|
assert Station._members_of(rows[0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- end to end
|
||||||
|
|
||||||
|
def test_verify_teaches_from_a_matured_forecast_exactly_once(tmp_path):
|
||||||
|
"""A matured forecast stays readable for an hour so the scorecard can
|
||||||
|
aggregate a rolling window, which means verify() sees it about twelve
|
||||||
|
times. Aggregating it twelve times is harmless. Teaching from it twelve
|
||||||
|
times is how the calibrator ends up believing it holds four hundred
|
||||||
|
independent scores when it holds one."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / "verify.db")
|
||||||
|
# Without this the station loads the developer's own saved state, whose
|
||||||
|
# heads already carry six figures of n_scored.
|
||||||
|
cfg.storage.state_dir = str(tmp_path)
|
||||||
|
st = Station(cfg)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
# The record has to run past the forecast's validity time, or verify()
|
||||||
|
# finds no truth to score it against.
|
||||||
|
for k in range(61):
|
||||||
|
ts = now - 1800 + k * 30
|
||||||
|
st.store.insert_telemetry({"ts": ts, "temp_raw": 21.0, "temp_smooth": 21.0,
|
||||||
|
"hum": 50.0, "hum_smooth": 50.0,
|
||||||
|
"press": 1013.0, "press_slp": 1013.0})
|
||||||
|
|
||||||
|
issued = now - 1500
|
||||||
|
st.store.insert_forecast(issued, 900, "temperature", 21.4, 20.9, 21.9,
|
||||||
|
"ensemble",
|
||||||
|
members={"persistence": 21.0, "climatology": 21.6,
|
||||||
|
"learned": 21.5, "setpoint": 21.0})
|
||||||
|
|
||||||
|
head = st.nowcast.heads[("temperature", 900)]
|
||||||
|
first = st.verify()
|
||||||
|
assert first["learned"] == 1, first
|
||||||
|
assert head.n_scored == 1
|
||||||
|
assert len(head.conformal.scores) == 1
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
again = st.verify()
|
||||||
|
assert again["learned"] == 0, "the same outcome was taught again"
|
||||||
|
assert head.n_scored == 1
|
||||||
|
assert len(head.conformal.scores) == 1
|
||||||
|
# and it is still being aggregated into the scorecard
|
||||||
|
assert again["scored"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_from_before_the_fix_is_not_trusted():
|
||||||
|
"""The saturated weights do not decay on their own.
|
||||||
|
|
||||||
|
A pre-fix state file holds weights produced by the same week of weather
|
||||||
|
read about a thousand times. Hedge needs roughly twenty independent
|
||||||
|
outcomes to climb back off its 1e-4 floor, and the 1 d head sees one a day,
|
||||||
|
so leaving them in place would mean three weeks of a forecast pinned to
|
||||||
|
whichever member the replay happened to favour.
|
||||||
|
"""
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
head = ens.heads[("temperature", 86400)]
|
||||||
|
for k in range(400):
|
||||||
|
head.observe_outcome(np.array([22.0, 30.0, 30.0, 30.0]), 22.0,
|
||||||
|
covered=False, valid_ts=1.7554e9 + k * 86400)
|
||||||
|
saved = ens.to_dict()
|
||||||
|
assert head.weights.max() > 0.9 and head.conformal.alpha < 0.02
|
||||||
|
|
||||||
|
for h in saved["heads"]:
|
||||||
|
h.pop("last_hedge_ts") # a state file from before
|
||||||
|
|
||||||
|
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
back.load_dict(saved)
|
||||||
|
b = back.heads[("temperature", 86400)]
|
||||||
|
assert np.allclose(b.weights, 1.0 / len(MEMBERS)), "saturated weights were trusted"
|
||||||
|
assert b.n_scored == 0
|
||||||
|
assert b.conformal.alpha == b.conformal.alpha_target, "the ACI integrator kept its wind-up"
|
||||||
|
# but the residual magnitudes are kept, or the long horizons spend nine
|
||||||
|
# days on the Gaussian fallback
|
||||||
|
assert len(b.conformal.scores) == 400
|
||||||
|
|
||||||
|
# A state file written after the fix must survive untouched.
|
||||||
|
keep = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
keep.load_dict(ens.to_dict())
|
||||||
|
assert np.allclose(keep.heads[("temperature", 86400)].weights, head.weights)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""Schema migration.
|
||||||
|
|
||||||
|
The column-addition test is the important one. CREATE TABLE IF NOT EXISTS is a
|
||||||
|
no-op against a table that already exists, so every new entry in COLUMNS
|
||||||
|
reaches a fresh install and silently misses every station already running. It
|
||||||
|
then surfaces as an OperationalError inside insert_telemetry, which sits on the
|
||||||
|
sample loop, so a column addition takes a live station down rather than merely
|
||||||
|
leaving a gap in its record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ashvale.storage import COLUMNS, Store
|
||||||
|
|
||||||
|
|
||||||
|
def _cols(path: str) -> set[str]:
|
||||||
|
with sqlite3.connect(path) as c:
|
||||||
|
return {r[1] for r in c.execute("PRAGMA table_info(telemetry)")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fresh_database_has_every_declared_column(tmp_path):
|
||||||
|
p = str(tmp_path / "fresh.db")
|
||||||
|
Store(p)
|
||||||
|
assert not [c for c in COLUMNS if c not in _cols(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_adds_a_new_column_without_touching_the_rows(tmp_path):
|
||||||
|
"""Simulates a station that has been running since before a column existed."""
|
||||||
|
p = str(tmp_path / "old.db")
|
||||||
|
legacy = [c for c in COLUMNS if c not in ("temp_h", "temp_p")]
|
||||||
|
with sqlite3.connect(p) as c:
|
||||||
|
c.execute(f"CREATE TABLE telemetry (ts REAL PRIMARY KEY, "
|
||||||
|
f"{', '.join(f'{x} REAL' for x in legacy if x != 'ts')}, "
|
||||||
|
f"tier INTEGER NOT NULL DEFAULT 0)")
|
||||||
|
c.executemany("INSERT INTO telemetry (ts, temp_raw) VALUES (?, ?)",
|
||||||
|
[(float(i), 20.0 + i) for i in range(50)])
|
||||||
|
|
||||||
|
assert "temp_h" not in _cols(p)
|
||||||
|
store = Store(p)
|
||||||
|
assert "temp_h" in _cols(p) and "temp_p" in _cols(p)
|
||||||
|
|
||||||
|
with sqlite3.connect(p) as c:
|
||||||
|
n = c.execute("SELECT COUNT(*) FROM telemetry").fetchone()[0]
|
||||||
|
old = c.execute("SELECT temp_raw FROM telemetry WHERE ts = 7.0").fetchone()[0]
|
||||||
|
assert n == 50, "migration must not lose rows"
|
||||||
|
assert old == 27.0, "migration must not disturb existing values"
|
||||||
|
|
||||||
|
# The point of the exercise: a write using the new columns must now work.
|
||||||
|
store.insert_telemetry({"ts": 999.0, "temp_raw": 20.0, "temp_h": 20.6, "temp_p": 19.4})
|
||||||
|
with sqlite3.connect(p) as c:
|
||||||
|
row = c.execute("SELECT temp_h, temp_p FROM telemetry WHERE ts = 999.0").fetchone()
|
||||||
|
assert row == (20.6, 19.4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_is_idempotent(tmp_path):
|
||||||
|
p = str(tmp_path / "twice.db")
|
||||||
|
Store(p)
|
||||||
|
Store(p)
|
||||||
|
Store(p)
|
||||||
|
assert not [c for c in COLUMNS if c not in _cols(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_thermometers_survive_a_round_trip(tmp_path):
|
||||||
|
"""temp_h and temp_p are logged so the self-heating gradient can be
|
||||||
|
recovered later. They cannot be backfilled, so a silent drop is permanent."""
|
||||||
|
p = str(tmp_path / "rt.db")
|
||||||
|
store = Store(p)
|
||||||
|
now = time.time()
|
||||||
|
store.insert_telemetry({"ts": now, "temp_raw": 30.39, "temp_h": 30.973,
|
||||||
|
"temp_p": 29.810, "cpu_temp": 44.55})
|
||||||
|
got = store.window(24.0, ["ts", "temp_h", "temp_p", "cpu_temp"])
|
||||||
|
assert got["temp_h"][0] == 30.973
|
||||||
|
assert got["temp_p"][0] == 29.810
|
||||||
|
assert got["temp_h"][0] - got["temp_p"][0] == pytest.approx(1.163)
|
||||||
Reference in New Issue
Block a user