mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Six enhancements: recompute, markers, vendoring, tests, nerd stats, DS18B20
1. POST /api/recompute re-derives every compensated column from the untouched raw values, removing the step a calibration otherwise leaves through the history. Possible because temp_raw, cpu_temp and hum are never overwritten. Idempotent by construction and tested per row: 0 of 6051 rows change on a second run. 6069 rows in 0.25 s here, so a few seconds on the Pi. 2. Calibration now emits a 'discontinuity' event alongside the calibration log, so downstream views can find the boundary without parsing prose. 3. Vendored Tailwind, Chart.js, hammer, the zoom plugin, KaTeX with its 20 woff2 faces, and both Google fonts into ashvale/static, served by the station. 1.4 MB. Verified with every non-localhost request aborted in the browser: zero external requests, equations still render, fonts still load. The dashboard no longer needs internet. 4. 54 pytest cases over the pure numerics: physics closed forms and round trips, both compensator inverse properties, the Kalman covariance invariants and NIS consistency, the RLS trace cap under a deliberately unexcited regressor, conformal coverage, and the Zambretti ordering. Wired into CI after the seed step so the recompute cases have history. Writing them caught my own sign error on the conformal update: a hit raises alpha and narrows the band, which reads backwards until you follow it through. 5. Stats for Nerds gains the condition number of each head's covariance, a standardised innovation histogram per Kalman filter from a bounded 600 sample ring buffer, and a reliability strip of realised against nominal coverage. All arithmetic on data already in memory. 6. OutdoorProbe reads a DS18B20 over the kernel 1-Wire driver, no new dependency. Polled on its own slower cadence because the sensor blocks for up to 750 ms during conversion, which would eat a third of the 2 s sample budget. Rejects the 85000 power-on sentinel and out-of-range values, and reports age so a dead probe cannot masquerade as fresh.
This commit is contained in:
@@ -27,7 +27,7 @@ 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
|
- name: Lint
|
||||||
run: |
|
run: |
|
||||||
@@ -43,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.
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ 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, Request
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .config import CONFIG
|
from .config import CONFIG
|
||||||
@@ -78,6 +80,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:
|
||||||
@@ -158,6 +168,7 @@ def telemetry() -> Dict:
|
|||||||
"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"),
|
"hum_offset": live.get("hum_offset"),
|
||||||
|
"outdoor_c": live.get("outdoor_c"),
|
||||||
"hum_psychrometric": live.get("hum_psychrometric"),
|
"hum_psychrometric": live.get("hum_psychrometric"),
|
||||||
"rates": {
|
"rates": {
|
||||||
"temperature_c_per_h": live.get("temp_rate"),
|
"temperature_c_per_h": live.get("temp_rate"),
|
||||||
@@ -361,6 +372,54 @@ 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")
|
@app.get("/api/nerd")
|
||||||
def nerd() -> Dict:
|
def nerd() -> Dict:
|
||||||
"""Every internal number the estimator and the learners are carrying.
|
"""Every internal number the estimator and the learners are carrying.
|
||||||
@@ -393,10 +452,22 @@ def nerd() -> Dict:
|
|||||||
m = head.model
|
m = head.model
|
||||||
P = np.asarray(m.P, dtype=float)
|
P = np.asarray(m.P, dtype=float)
|
||||||
theta = np.asarray(m.theta, 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({
|
heads.append({
|
||||||
"target": target, "horizon_s": h,
|
"target": target, "horizon_s": h,
|
||||||
"n_updates": int(m.n_updates),
|
"n_updates": int(m.n_updates),
|
||||||
"trace_p": float(np.trace(P)),
|
"trace_p": float(np.trace(P)),
|
||||||
|
"cond_p": cond,
|
||||||
"theta_norm": float(np.linalg.norm(theta)),
|
"theta_norm": float(np.linalg.norm(theta)),
|
||||||
"rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))),
|
"rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))),
|
||||||
"lam": float(m.lam), "p_max": float(m.p_max),
|
"lam": float(m.lam), "p_max": float(m.p_max),
|
||||||
@@ -459,6 +530,8 @@ def nerd() -> Dict:
|
|||||||
"logloss_ewma": st.precip.ewma_logloss,
|
"logloss_ewma": st.precip.ewma_logloss,
|
||||||
},
|
},
|
||||||
"monitoring": monitoring,
|
"monitoring": monitoring,
|
||||||
|
"innovation": _innovation_histogram(st),
|
||||||
|
"reliability": _reliability_curve(st),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -515,12 +588,25 @@ def calibrate_humidity(body: HumidityCalibrationIn) -> Dict:
|
|||||||
return _clean(result)
|
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.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),
|
||||||
"events": st.store.recent_events(15),
|
"events": st.store.recent_events(15),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ class SensorConfig:
|
|||||||
# HIGH and this correction would push it higher still. The error is an
|
# 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
|
# additive element bias, not a thermal gradient. Leave off unless your own
|
||||||
# reference says otherwise.
|
# 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_psychrometric: bool = False
|
||||||
hum_offset: float = 0.0
|
hum_offset: float = 0.0
|
||||||
hum_offset_min: float = -35.0
|
hum_offset_min: float = -35.0
|
||||||
|
|||||||
+49
-9
@@ -48,14 +48,15 @@ DASHBOARD_HTML = r"""
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Ashvale Station</title>
|
<title>Ashvale Station</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!-- Served from the station, not a CDN, so the dashboard renders on a LAN with
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
|
no internet at all. See ashvale/static/. -->
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js"></script>
|
<script src="/static/tailwind.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
<link rel="stylesheet" href="/static/katex.min.css">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
|
<script defer src="/static/katex.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
|
<script src="/static/chart.umd.min.js"></script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<script src="/static/hammer.min.js"></script>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap">
|
<script src="/static/chartjs-plugin-zoom.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/static/gfonts.css">
|
||||||
<style>
|
<style>
|
||||||
body { font-family:'Plus Jakarta Sans',sans-serif; }
|
body { font-family:'Plus Jakarta Sans',sans-serif; }
|
||||||
.font-mono { font-family:'JetBrains Mono',monospace; }
|
.font-mono { font-family:'JetBrains Mono',monospace; }
|
||||||
@@ -404,7 +405,7 @@ DASHBOARD_HTML = r"""
|
|||||||
<!-- Everything the estimator and the 18 learners are actually carrying, read
|
<!-- Everything the estimator and the 18 learners are actually carrying, read
|
||||||
straight off the live objects. No internal scrollers: the head bank is a
|
straight off the live objects. No internal scrollers: the head bank is a
|
||||||
fixed 18 rows and the attribution list is capped at what fits. -->
|
fixed 18 rows and the attribution list is capped at what fits. -->
|
||||||
<section id="pane-nerd" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr]">
|
<section id="pane-nerd" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr_auto]">
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-3 lg:col-span-3">
|
<div class="glass rounded-2xl p-3 lg:col-span-3">
|
||||||
<div class="flex items-baseline justify-between mb-1.5">
|
<div class="flex items-baseline justify-between mb-1.5">
|
||||||
@@ -450,6 +451,14 @@ DASHBOARD_HTML = r"""
|
|||||||
<div id="n-precip" class="font-mono text-[10px] space-y-0.5"></div>
|
<div id="n-precip" class="font-mono text-[10px] space-y-0.5"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-2xl p-3 lg:col-span-4 shrink-0">
|
||||||
|
<div class="flex items-baseline justify-between mb-1.5">
|
||||||
|
<h2 class="text-sm font-bold">Reliability</h2>
|
||||||
|
<span class="text-[9px] font-mono text-slate-600 uppercase">realised coverage against the 90% nominal</span>
|
||||||
|
</div>
|
||||||
|
<div id="n-rel" class="grid grid-cols-3 sm:grid-cols-6 gap-x-3 gap-y-0.5 font-mono text-[9px]"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ---------------- METHODS ---------------- -->
|
<!-- ---------------- METHODS ---------------- -->
|
||||||
@@ -965,6 +974,15 @@ el('m-hcalrst').addEventListener('click', async () => {
|
|||||||
/* ---------------- STATS FOR NERDS ---------------- */
|
/* ---------------- STATS FOR NERDS ---------------- */
|
||||||
let nerdDoc = null, nerdHead = null;
|
let nerdDoc = null, nerdHead = null;
|
||||||
const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d';
|
const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d';
|
||||||
|
// A 21-bin histogram drawn as inline divs. No canvas: Chart.js instances cost
|
||||||
|
// memory and this is three tiny plots that never need interaction.
|
||||||
|
function histo(counts, colour, h) {
|
||||||
|
if (!counts || !counts.length) return '<div class="text-slate-700 text-[9px]">warming up</div>';
|
||||||
|
const mx = Math.max.apply(null, counts) || 1;
|
||||||
|
return '<div class="flex items-end gap-px" style="height:'+h+'px">'+
|
||||||
|
counts.map(c=>'<div style="flex:1;height:'+Math.max(1,(c/mx)*h)+'px;background:'+colour+
|
||||||
|
';opacity:'+(0.35+0.65*(c/mx))+'"></div>').join('')+'</div>';
|
||||||
|
}
|
||||||
function bar(frac, colour) {
|
function bar(frac, colour) {
|
||||||
const w = Math.max(0, Math.min(1, frac||0))*100;
|
const w = Math.max(0, Math.min(1, frac||0))*100;
|
||||||
return '<div class="h-1 bg-slate-950 rounded-full overflow-hidden border border-slate-800/70">'+
|
return '<div class="h-1 bg-slate-950 rounded-full overflow-hidden border border-slate-800/70">'+
|
||||||
@@ -975,6 +993,7 @@ async function loadNerd() {
|
|||||||
nerdDoc = d;
|
nerdDoc = d;
|
||||||
|
|
||||||
const COL = {temperature:'#f59e0b', humidity:'#06b6d4', pressure:'#a78bfa'};
|
const COL = {temperature:'#f59e0b', humidity:'#06b6d4', pressure:'#a78bfa'};
|
||||||
|
const iv = d.innovation||{};
|
||||||
el('n-filters').innerHTML = Object.keys(d.filters||{}).map(k=>{
|
el('n-filters').innerHTML = Object.keys(d.filters||{}).map(k=>{
|
||||||
const f = d.filters[k];
|
const f = d.filters[k];
|
||||||
// NIS is chi-square(1) distributed when consistent, so 1 is the target and
|
// NIS is chi-square(1) distributed when consistent, so 1 is the target and
|
||||||
@@ -987,6 +1006,12 @@ async function loadNerd() {
|
|||||||
'<div class="flex justify-between text-slate-500 mt-1">σ level<span class="text-slate-300">'+fmt(f.sigma_level,4)+'</span></div>'+
|
'<div class="flex justify-between text-slate-500 mt-1">σ level<span class="text-slate-300">'+fmt(f.sigma_level,4)+'</span></div>'+
|
||||||
'<div class="flex justify-between text-slate-500">P rate<span class="text-slate-300">'+f.p_rate.toExponential(2)+'</span></div>'+
|
'<div class="flex justify-between text-slate-500">P rate<span class="text-slate-300">'+f.p_rate.toExponential(2)+'</span></div>'+
|
||||||
'<div class="flex justify-between text-slate-600">q / r<span>'+f.q.toExponential(1)+' / '+fmt(f.r,3)+'</span></div>'+
|
'<div class="flex justify-between text-slate-600">q / r<span>'+f.q.toExponential(1)+' / '+fmt(f.r,3)+'</span></div>'+
|
||||||
|
// innovation shape: should look standard normal if the filter is honest
|
||||||
|
'<div class="mt-1 pt-1 border-t border-slate-800/70">'+
|
||||||
|
histo((iv[k]||{}).counts, COL[k], 16)+
|
||||||
|
'<div class="flex justify-between text-slate-600 mt-0.5 text-[9px]">'+
|
||||||
|
'<span>innov μ='+fmt((iv[k]||{}).mean,2)+' σ='+fmt((iv[k]||{}).std,2)+'</span>'+
|
||||||
|
'<span>skew '+fmt((iv[k]||{}).skew,2)+'</span></div></div>'+
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -1004,6 +1029,7 @@ async function loadNerd() {
|
|||||||
el('n-heads').innerHTML = heads.length ? '<table class="w-full font-mono text-[10px]">'+
|
el('n-heads').innerHTML = heads.length ? '<table class="w-full font-mono text-[10px]">'+
|
||||||
'<thead class="text-slate-600 uppercase text-[9px]"><tr class="border-b border-slate-800">'+
|
'<thead class="text-slate-600 uppercase text-[9px]"><tr class="border-b border-slate-800">'+
|
||||||
'<th class="text-left py-0.5">head</th><th class="text-right">n</th><th class="text-right">tr P</th>'+
|
'<th class="text-left py-0.5">head</th><th class="text-right">n</th><th class="text-right">tr P</th>'+
|
||||||
|
'<th class="text-right">cond</th>'+
|
||||||
'<th class="text-right">|θ|</th><th class="text-right">rmse</th><th class="text-right">α</th>'+
|
'<th class="text-right">|θ|</th><th class="text-right">rmse</th><th class="text-right">α</th>'+
|
||||||
'<th class="text-right">cov</th><th class="text-right">±</th><th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+
|
'<th class="text-right">cov</th><th class="text-right">±</th><th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+
|
||||||
heads.map(h=>{
|
heads.map(h=>{
|
||||||
@@ -1015,6 +1041,10 @@ async function loadNerd() {
|
|||||||
'<td class="py-0.5 text-slate-400">'+h.target.slice(0,4)+' <span class="text-slate-600">'+HL(h.horizon_s)+'</span></td>'+
|
'<td class="py-0.5 text-slate-400">'+h.target.slice(0,4)+' <span class="text-slate-600">'+HL(h.horizon_s)+'</span></td>'+
|
||||||
'<td class="text-right text-slate-600">'+h.n_updates+'</td>'+
|
'<td class="text-right text-slate-600">'+h.n_updates+'</td>'+
|
||||||
'<td class="text-right '+(sat>0.95?'text-rose-300':'text-slate-400')+'">'+h.trace_p.toExponential(1)+'</td>'+
|
'<td class="text-right '+(sat>0.95?'text-rose-300':'text-slate-400')+'">'+h.trace_p.toExponential(1)+'</td>'+
|
||||||
|
// a huge condition number means some of the 33 directions are barely
|
||||||
|
// excited, so their weights are close to arbitrary
|
||||||
|
'<td class="text-right '+(h.cond_p>1e8?'text-amber-300':'text-slate-500')+'">'+
|
||||||
|
(isFinite(h.cond_p)?h.cond_p.toExponential(0):'inf')+'</td>'+
|
||||||
'<td class="text-right text-slate-300">'+fmt(h.theta_norm,1)+'</td>'+
|
'<td class="text-right text-slate-300">'+fmt(h.theta_norm,1)+'</td>'+
|
||||||
'<td class="text-right text-slate-300">'+fmt(h.rmse_ewma,3)+'</td>'+
|
'<td class="text-right text-slate-300">'+fmt(h.rmse_ewma,3)+'</td>'+
|
||||||
'<td class="text-right text-indigo-300">'+fmt(h.alpha,3)+'</td>'+
|
'<td class="text-right text-indigo-300">'+fmt(h.alpha,3)+'</td>'+
|
||||||
@@ -1025,6 +1055,16 @@ async function loadNerd() {
|
|||||||
}).join('')+'</tbody></table>'
|
}).join('')+'</tbody></table>'
|
||||||
: '<p class="text-[10px] text-slate-600 font-mono">No heads trained yet.</p>';
|
: '<p class="text-[10px] text-slate-600 font-mono">No heads trained yet.</p>';
|
||||||
|
|
||||||
|
const rel = ((d.reliability||{}).points)||[];
|
||||||
|
el('n-rel').innerHTML = rel.length ? rel.map(pt=>{
|
||||||
|
const gap = pt.realised - pt.nominal;
|
||||||
|
const cls = Math.abs(gap)<0.03 ? '#34d399' : Math.abs(gap)<0.06 ? '#fbbf24' : '#fb7185';
|
||||||
|
return '<div class="flex items-center gap-1.5">'+
|
||||||
|
'<span class="text-slate-600" style="width:42%">'+pt.target.slice(0,4)+' '+HL(pt.horizon_s)+'</span>'+
|
||||||
|
'<span class="flex-1">'+bar(pt.realised, cls)+'</span>'+
|
||||||
|
'<span style="width:26%;text-align:right;color:'+cls+'">'+(pt.realised*100).toFixed(0)+'%</span></div>';
|
||||||
|
}).join('') : '<p class="text-slate-600">Nothing scored yet.</p>';
|
||||||
|
|
||||||
const sel = el('n-head-sel');
|
const sel = el('n-head-sel');
|
||||||
if (sel.options.length !== heads.length) {
|
if (sel.options.length !== heads.length) {
|
||||||
sel.innerHTML = heads.map((h,i)=>'<option value="'+i+'">'+h.target.slice(0,4)+' '+HL(h.horizon_s)+'</option>').join('');
|
sel.innerHTML = heads.map((h,i)=>'<option value="'+i+'">'+h.target.slice(0,4)+' '+HL(h.horizon_s)+'</option>').join('');
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Two jobs here, both familiar from soft-sensor work:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
@@ -54,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):
|
||||||
@@ -83,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
|
||||||
@@ -265,6 +272,12 @@ class SignalTracker:
|
|||||||
"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]:
|
||||||
@@ -278,6 +291,9 @@ class SignalTracker:
|
|||||||
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_c, 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,
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ same code to the Pi unchanged.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
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
|
||||||
@@ -105,6 +109,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."""
|
||||||
|
|||||||
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
+79
-2
@@ -42,13 +42,13 @@ 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 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
|
||||||
from .storage import Store, resample
|
from .storage import Store, resample
|
||||||
|
|
||||||
STATE_VERSION = 1
|
STATE_VERSION = 1
|
||||||
@@ -65,6 +65,9 @@ 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.
|
||||||
|
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(
|
||||||
@@ -191,6 +194,7 @@ class Station:
|
|||||||
"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,
|
"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")),
|
"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),
|
||||||
@@ -265,6 +269,11 @@ 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:
|
def calibrate_humidity(self, reference_pct: float) -> Dict:
|
||||||
@@ -279,6 +288,8 @@ class Station:
|
|||||||
self.store.log_event("calibration", "info",
|
self.store.log_event("calibration", "info",
|
||||||
f"rh offset -> {result['offset']:+.2f}% "
|
f"rh offset -> {result['offset']:+.2f}% "
|
||||||
f"(residual {result['residual']:+.2f}%)")
|
f"(residual {result['residual']:+.2f}%)")
|
||||||
|
self.store.log_event("discontinuity", "warn",
|
||||||
|
f"humidity offset {result['offset']:+.4f}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def reset_humidity_calibration(self) -> Dict:
|
def reset_humidity_calibration(self) -> Dict:
|
||||||
@@ -293,6 +304,72 @@ class Station:
|
|||||||
f"rh offset reset to prior {self.cfg.sensor.hum_offset}")
|
f"rh offset reset to prior {self.cfg.sensor.hum_offset}")
|
||||||
return {"offset": self.tracker.hum_compensator.offset, "reset": True, "n": 0}
|
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)
|
||||||
|
temp_s = np.empty(n)
|
||||||
|
temp_r = np.empty(n)
|
||||||
|
hum_s = np.empty(n)
|
||||||
|
prev = None
|
||||||
|
for i in range(n):
|
||||||
|
dt = 1.0 if prev is None else max(ts[i] - prev, 1e-3)
|
||||||
|
prev = ts[i]
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,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
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# 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"]
|
||||||
@@ -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,115 @@
|
|||||||
|
# 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):
|
||||||
|
if _rows() == 0:
|
||||||
|
pytest.skip("no history in the database")
|
||||||
|
before = _rows()
|
||||||
|
client.post("/api/recompute")
|
||||||
|
assert _rows() == before
|
||||||
|
|
||||||
|
|
||||||
|
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})
|
||||||
Reference in New Issue
Block a user