mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Initial release: Ashvale Station 1.0.0
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Rolling-origin backtest. The only number that decides whether to ship.
|
||||
|
||||
Protocol, strictly walk-forward:
|
||||
|
||||
1. Build the 5-minute feature grid from stored telemetry.
|
||||
2. Split at `--train-frac`. Fit the ensemble and the climatology on the
|
||||
first part only.
|
||||
3. Walk the second part one step at a time. At each step, forecast,
|
||||
record the error, and only then let the model learn from the target
|
||||
that has just matured. No target is ever visible before its time.
|
||||
4. Report MAE against three baselines:
|
||||
persistence the value now
|
||||
climatology the harmonic fit
|
||||
the ensemble
|
||||
|
||||
Skill = 1 - MAE_model / MAE_persistence. A positive number means the
|
||||
model earns its electricity. A negative number at a given horizon is not
|
||||
a failure of the exercise, it is the exercise working: ship persistence
|
||||
at that horizon and stop pretending.
|
||||
|
||||
python scripts/evaluate.py --train-frac 0.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from ashvale.config import load_config # noqa: E402
|
||||
from ashvale.features import build_features # noqa: E402
|
||||
from ashvale.models.climatology import HarmonicClimatology # noqa: E402
|
||||
from ashvale.models.nowcast import NowcastEnsemble # noqa: E402
|
||||
from ashvale.storage import Store, resample # noqa: E402
|
||||
|
||||
|
||||
def horizon_label(seconds: int) -> str:
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m"
|
||||
if seconds < 86400:
|
||||
return f"{seconds // 3600}h"
|
||||
return f"{seconds // 86400}d"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--train-frac", type=float, default=0.6)
|
||||
ap.add_argument("--hours", type=float, default=24 * 60)
|
||||
ap.add_argument("--config", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = load_config(args.config)
|
||||
store = Store(cfg.storage.db_path)
|
||||
|
||||
raw = store.window(args.hours, ["ts", "temp_smooth", "hum_smooth", "press_slp", "lux"])
|
||||
if raw["ts"].size < 200:
|
||||
print("Not enough history. Run: python scripts/simulate.py --days 14")
|
||||
return
|
||||
|
||||
grid_ts, cols = resample(
|
||||
raw["ts"],
|
||||
{"temperature": raw["temp_smooth"], "humidity": raw["hum_smooth"],
|
||||
"pressure": raw["press_slp"], "lux": raw["lux"]},
|
||||
cfg.model.grid_s,
|
||||
)
|
||||
X, valid = build_features(grid_ts, cols["temperature"], cols["humidity"],
|
||||
cols["pressure"], cols["lux"], cfg.model.grid_s,
|
||||
cfg.site.latitude, cfg.site.longitude)
|
||||
|
||||
n = grid_ts.size
|
||||
split = int(n * args.train_frac)
|
||||
span_days = (grid_ts[-1] - grid_ts[0]) / 86400.0
|
||||
print(f"grid rows : {n} ({span_days:.2f} days at {cfg.model.grid_s}s)")
|
||||
print(f"train / test : {split} / {n - split}")
|
||||
|
||||
clim = HarmonicClimatology(cfg.model.targets,
|
||||
min_days_annual=cfg.model.climatology_min_days_annual)
|
||||
clim.fit(grid_ts[:split], {k: v[:split] for k, v in cols.items() if k in cfg.model.targets},
|
||||
valid[:split])
|
||||
|
||||
ens = NowcastEnsemble(cfg.model.targets, cfg.model.horizons_s, cfg.model)
|
||||
t0 = time.time()
|
||||
ens.fit(X[:split], valid[:split],
|
||||
{k: v[:split] for k, v in cols.items() if k in cfg.model.targets},
|
||||
clim, grid_ts[:split])
|
||||
print(f"fit : {time.time() - t0:.1f}s\n")
|
||||
|
||||
per_step = cfg.model.grid_s
|
||||
results = {}
|
||||
|
||||
for target in cfg.model.targets:
|
||||
y = cols[target]
|
||||
for h in cfg.model.horizons_s:
|
||||
steps = max(int(round(h / per_step)), 1)
|
||||
errs, pers, clims, covered = [], [], [], []
|
||||
head = ens.heads[(target, h)]
|
||||
|
||||
for i in range(split, n - steps):
|
||||
if not valid[i] or not np.isfinite(y[i]) or not np.isfinite(y[i + steps]):
|
||||
continue
|
||||
x = ens.scaler.transform(X[i:i + 1])[0]
|
||||
anchor = float(y[i])
|
||||
truth = float(y[i + steps])
|
||||
cd = 0.0
|
||||
if clim.ready:
|
||||
cd = float(clim.predict(target, np.array([grid_ts[i] + h]))[0]
|
||||
- clim.predict(target, np.array([grid_ts[i]]))[0])
|
||||
pred = head.predict(x, anchor, cd)
|
||||
errs.append(truth - pred["mu"])
|
||||
pers.append(truth - anchor)
|
||||
clims.append(truth - (anchor + cd))
|
||||
covered.append(1.0 if pred["lo"] <= truth <= pred["hi"] else 0.0)
|
||||
head.learn(x, anchor, truth, cd) # learn only after scoring
|
||||
|
||||
if len(errs) < 5:
|
||||
continue
|
||||
e = np.abs(errs)
|
||||
p = np.abs(pers)
|
||||
c = np.abs(clims)
|
||||
results[(target, h)] = {
|
||||
"mae": e.mean(), "persistence": p.mean(), "climatology": c.mean(),
|
||||
"skill": 1.0 - e.mean() / max(p.mean(), 1e-9),
|
||||
"bias": float(np.mean(errs)),
|
||||
"coverage": float(np.mean(covered)),
|
||||
"n": len(errs),
|
||||
"weights": {k: round(float(v), 2) for k, v in
|
||||
zip(("pers", "clim", "rls"), head.weights)},
|
||||
}
|
||||
|
||||
units = {"temperature": "C", "humidity": "%", "pressure": "hPa"}
|
||||
header = f"{'target':<12}{'lead':>6}{'MAE':>9}{'persist':>9}{'clim':>9}{'skill':>8}{'cover':>7}{'bias':>8} weights"
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for target in cfg.model.targets:
|
||||
for h in cfg.model.horizons_s:
|
||||
r = results.get((target, h))
|
||||
if not r:
|
||||
continue
|
||||
flag = " <-- persistence wins" if r["skill"] < 0 else ""
|
||||
print(f"{target:<12}{horizon_label(h):>6}{r['mae']:>9.3f}{r['persistence']:>9.3f}"
|
||||
f"{r['climatology']:>9.3f}{r['skill'] * 100:>7.1f}%{r['coverage'] * 100:>6.0f}%"
|
||||
f"{r['bias']:>+8.3f} {r['weights']}{flag}")
|
||||
print()
|
||||
|
||||
print(f"units: temperature C, humidity %, pressure hPa")
|
||||
print("coverage should sit near 90% if the conformal calibration is honest.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""Seed the database with synthetic history.
|
||||
|
||||
Why this exists: a freshly flashed Pi has no history, and a forecaster
|
||||
with no history is a random number generator with a nice dashboard. This
|
||||
script writes physically plausible past telemetry so you can exercise
|
||||
training, verification and the whole dashboard before the real station
|
||||
has logged its first night.
|
||||
|
||||
The generator is not a toy. It is a three-component stochastic model:
|
||||
|
||||
pressure Ornstein-Uhlenbeck, tau = 30 h, sigma = 9 hPa
|
||||
(roughly the observed synoptic variability of NW Europe)
|
||||
temperature seasonal harmonic + solar-driven diurnal cycle
|
||||
+ OU anomaly (tau = 6 h), with a nocturnal inversion term
|
||||
humidity driven inversely by temperature about a dew point that
|
||||
itself performs a slow random walk, which is what makes
|
||||
RH and T correlate the way they actually do
|
||||
|
||||
Everything is then pushed through the same CPU-heating and noise model
|
||||
the real sensor suffers from, so a model trained here does not fall over
|
||||
when it meets real data.
|
||||
|
||||
python scripts/simulate.py --days 21 --wipe
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from ashvale.config import load_config # noqa: E402
|
||||
from ashvale.estimation import SignalTracker # noqa: E402
|
||||
from ashvale.physics import (dew_point, sea_level_pressure, # noqa: E402
|
||||
solar_position, clear_sky_irradiance)
|
||||
from ashvale.storage import Store # noqa: E402
|
||||
|
||||
|
||||
def generate(days: float, step_s: int, lat: float, lon: float,
|
||||
seed: int = 11, end: float | None = None) -> dict:
|
||||
rng = np.random.default_rng(seed)
|
||||
n = int(days * 86400 / step_s)
|
||||
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
|
||||
# the OU realisation repeats, but the timestamps shift, which moves solar
|
||||
# elevation, day of year and the seasonal harmonic. Those feed the temperature
|
||||
# model directly, so two same-seed runs produce different data. Pin end as well
|
||||
# and the backfill becomes bit-reproducible, which is what before/after
|
||||
# evidence on a model change actually requires.
|
||||
end = time.time() if end is None else end
|
||||
ts = end - np.arange(n)[::-1] * step_s
|
||||
|
||||
# --- synoptic pressure: OU process
|
||||
tau_p, sigma_p = 30 * 3600.0, 9.0
|
||||
press = np.zeros(n)
|
||||
a = math.exp(-step_s / tau_p)
|
||||
noise_scale = sigma_p * math.sqrt(1 - a * a)
|
||||
for i in range(1, n):
|
||||
press[i] = a * press[i - 1] + noise_scale * rng.normal()
|
||||
press_slp = 1013.0 + press
|
||||
|
||||
# --- solar forcing
|
||||
elev, _ = solar_position(ts, lat, lon)
|
||||
elev = np.atleast_1d(elev)
|
||||
ghi = clear_sky_irradiance(elev)
|
||||
cloud = np.clip(0.45 + 0.35 * np.sin(2 * np.pi * ts / (4.5 * 86400)) +
|
||||
0.25 * rng.normal(size=n).cumsum() / math.sqrt(n), 0.0, 1.0)
|
||||
lux = np.maximum(ghi * 45.0 * (1.0 - 0.85 * cloud), 0.0) + 6.0
|
||||
|
||||
# --- temperature: season + diurnal + OU anomaly + inversion at night
|
||||
doy = np.array([time.gmtime(float(t)).tm_yday for t in ts])
|
||||
seasonal = 6.5 * np.sin(2 * np.pi * (doy - 105) / 365.25)
|
||||
diurnal = 0.011 * ghi * (1.0 - 0.6 * cloud)
|
||||
inversion = -1.8 * (elev < -3).astype(float) * (1.0 - cloud)
|
||||
|
||||
tau_t, sigma_t = 6 * 3600.0, 1.9
|
||||
at = math.exp(-step_s / tau_t)
|
||||
anom = np.zeros(n)
|
||||
for i in range(1, n):
|
||||
anom[i] = at * anom[i - 1] + sigma_t * math.sqrt(1 - at * at) * rng.normal()
|
||||
# pressure and temperature anomalies are correlated in the real world
|
||||
anom += 0.12 * press
|
||||
|
||||
temp = 11.5 + seasonal + diurnal + inversion + anom
|
||||
|
||||
# --- humidity via a slowly wandering dew point
|
||||
dew = temp - 4.5 + 2.5 * np.sin(2 * np.pi * ts / (3.2 * 86400))
|
||||
dew -= 0.10 * press
|
||||
dew = np.minimum(dew, temp - 0.2)
|
||||
es_t = 6.112 * np.exp(17.625 * temp / (243.04 + temp))
|
||||
es_d = 6.112 * np.exp(17.625 * dew / (243.04 + dew))
|
||||
rh = np.clip(100.0 * es_d / es_t, 8.0, 100.0)
|
||||
|
||||
# CPU temperature: a slow AR(1) load process, not white noise. A Zero 2 W
|
||||
# under a steady FastAPI load drifts by a degree or two over minutes, it
|
||||
# does not jitter by four degrees between samples.
|
||||
cpu_load = np.zeros(n)
|
||||
a_cpu = math.exp(-step_s / (900.0))
|
||||
for i in range(1, n):
|
||||
cpu_load[i] = a_cpu * cpu_load[i - 1] + 1.6 * math.sqrt(1 - a_cpu * a_cpu) * rng.normal()
|
||||
cpu = temp + 21.0 + cpu_load
|
||||
|
||||
# The sensor sits in a thermal gradient between the room and the SoC.
|
||||
# The compensator inverts T = T_raw - k (T_cpu - T_raw), so the forward
|
||||
# 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
|
||||
# no amount of calibration can remove, and quietly caps your skill score.
|
||||
k_true = 0.55
|
||||
temp_raw = (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * rng.normal(size=n)
|
||||
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
|
||||
press_station += 0.05 * rng.normal(size=n)
|
||||
|
||||
return {
|
||||
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh + 0.4 * rng.normal(size=n),
|
||||
"press": press_station, "press_slp": press_slp, "cpu": cpu,
|
||||
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--days", type=float, default=14.0)
|
||||
ap.add_argument("--step", type=int, default=300, help="seconds between rows")
|
||||
ap.add_argument("--seed", type=int, default=11)
|
||||
ap.add_argument("--end", type=float, default=None,
|
||||
help="unix timestamp the history ends at; defaults to now. "
|
||||
"Pin it with --seed for a bit-reproducible backfill")
|
||||
ap.add_argument("--wipe", action="store_true", help="clear existing telemetry first")
|
||||
ap.add_argument("--config", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = load_config(args.config)
|
||||
store = Store(cfg.storage.db_path)
|
||||
|
||||
# The Kalman process noise is tuned for the real sampling cadence (2 s).
|
||||
# Backfilling at 300 s steps with the same q gives Q_level = q*dt^3/3, which
|
||||
# is five orders of magnitude larger, so the filter abandons smoothing and
|
||||
# tracks measurement noise. Its rate estimates then blow past anything
|
||||
# physical and poison the all-time records. Scale q by (real_dt/step)^3 so
|
||||
# the synthetic history has the same effective smoothing as the live station.
|
||||
scale = (cfg.sensor.sample_period_s / float(args.step)) ** 3
|
||||
cfg.sensor.kalman_q_temp *= scale
|
||||
cfg.sensor.kalman_q_hum *= scale
|
||||
cfg.sensor.kalman_q_press *= scale
|
||||
|
||||
if args.wipe:
|
||||
with store._conn() as conn:
|
||||
conn.execute("DELETE FROM telemetry")
|
||||
conn.execute("DELETE FROM forecasts")
|
||||
conn.execute("DELETE FROM scores")
|
||||
print("cleared existing telemetry, forecasts and scores")
|
||||
|
||||
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
|
||||
args.seed, args.end)
|
||||
tracker = SignalTracker(cfg)
|
||||
|
||||
n = data["ts"].size
|
||||
t0 = time.time()
|
||||
for i in range(n):
|
||||
ts = float(data["ts"][i])
|
||||
est = tracker.step(ts, float(data["temp_raw"][i]), float(data["rh"][i]),
|
||||
float(data["press"][i]), float(data["cpu"][i]))
|
||||
slp = float(sea_level_pressure(est["press_smooth"], est["temp_smooth"],
|
||||
cfg.site.altitude_m))
|
||||
store.insert_telemetry({
|
||||
"ts": ts,
|
||||
"temp_raw": data["temp_raw"][i],
|
||||
"temp_c": est["temp_c"],
|
||||
"temp_smooth": est["temp_smooth"],
|
||||
"temp_rate": est["temp_rate"],
|
||||
"hum": data["rh"][i],
|
||||
"hum_smooth": est["hum_smooth"],
|
||||
"press": data["press"][i],
|
||||
"press_slp": slp,
|
||||
"press_smooth": est["press_smooth"],
|
||||
"press_rate": est["press_rate"],
|
||||
"cpu_temp": data["cpu"][i],
|
||||
"dew_c": float(dew_point(est["temp_smooth"], est["hum_smooth"])),
|
||||
"lux": data["lux"][i],
|
||||
"r": data["lux"][i] * 0.30, "g": data["lux"][i] * 0.34, "b": data["lux"][i] * 0.28,
|
||||
"pitch": 0.0, "roll": 0.0, "yaw": 180.0, "compass": 180.0,
|
||||
"ax": 0.0, "ay": 0.0, "az": 1.0, "gx": 0.0, "gy": 0.0, "gz": 0.0,
|
||||
})
|
||||
if i % 500 == 0:
|
||||
print(f" {i}/{n} rows", end="\r", flush=True)
|
||||
|
||||
print(f"\nwrote {n} rows spanning {args.days:.1f} days in {time.time() - t0:.1f}s")
|
||||
print(f"database: {cfg.storage.db_path}")
|
||||
print("next: python scripts/evaluate.py (or just start the server)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user