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,563 @@
|
||||
# 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 station: everything wired together and running on its own clocks.
|
||||
|
||||
Four asynchronous loops, deliberately decoupled so a slow one cannot
|
||||
starve a fast one:
|
||||
|
||||
sample (2 s) read hardware, run the Kalman bank, keep live state
|
||||
persist (30 s) one row to SQLite
|
||||
train (10 min) rebuild the feature grid, update every head, re-fit
|
||||
climatology, emit a fresh forecast bundle
|
||||
verify (5 min) score forecasts whose validity time has arrived, feed
|
||||
the errors to conformal calibration and drift
|
||||
detection, write the scorecard
|
||||
|
||||
The verify loop is the one most projects skip and the one that makes the
|
||||
difference. A forecast that is never scored is an opinion; a forecast
|
||||
that is scored against persistence is a measurement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import physics
|
||||
from .config import Config
|
||||
from .estimation import SignalTracker
|
||||
from .features import N_FEATURES, build_features
|
||||
from .models.anomaly import AnomalyMonitor
|
||||
from .models.climatology import HarmonicClimatology
|
||||
from .models.nowcast import NowcastEnsemble
|
||||
from .models.precip import PrecipitationModel, proxy_wet_label, zambretti
|
||||
from .sensors import SenseBoard, enrich
|
||||
from .storage import Store, resample
|
||||
|
||||
STATE_VERSION = 1
|
||||
|
||||
|
||||
class Station:
|
||||
def __init__(self, cfg: Config):
|
||||
self.cfg = cfg
|
||||
self.store = Store(cfg.storage.db_path)
|
||||
self.board = SenseBoard(
|
||||
rotation=cfg.sensor.rotation_deg,
|
||||
low_light=cfg.sensor.low_light,
|
||||
tcs_addr=cfg.sensor.tcs3400_addr,
|
||||
latitude=cfg.site.latitude,
|
||||
longitude=cfg.site.longitude,
|
||||
)
|
||||
self.tracker = SignalTracker(cfg)
|
||||
self.nowcast = NowcastEnsemble(cfg.model.targets, cfg.model.horizons_s, cfg.model)
|
||||
self.climatology = HarmonicClimatology(
|
||||
cfg.model.targets, min_days_annual=cfg.model.climatology_min_days_annual
|
||||
)
|
||||
self.precip = PrecipitationModel()
|
||||
self.monitor = AnomalyMonitor(cfg.model)
|
||||
|
||||
self.live: Dict[str, Any] = {}
|
||||
self.forecast_bundle: Dict[str, Any] = {}
|
||||
self.outlook_bundle: Dict[str, Any] = {}
|
||||
self.precip_bundle: Dict[str, Any] = {}
|
||||
self.anomaly_bundle: Dict[str, Any] = {}
|
||||
self.last_train: float = 0.0
|
||||
self.last_persist: float = 0.0
|
||||
self.last_compact: float = 0.0
|
||||
self.training_log: List[Dict] = []
|
||||
self._tasks: List[asyncio.Task] = []
|
||||
self._stop = asyncio.Event()
|
||||
|
||||
self.state_path = Path(cfg.storage.state_dir) / "station_state.json"
|
||||
self.load_state()
|
||||
|
||||
# ------------------------------------------------------------ state
|
||||
|
||||
def save_state(self) -> None:
|
||||
payload = {
|
||||
"version": STATE_VERSION,
|
||||
"saved_at": time.time(),
|
||||
"tracker": self.tracker.to_dict(),
|
||||
"nowcast": self.nowcast.to_dict(),
|
||||
"climatology": self.climatology.to_dict(),
|
||||
"precip": self.precip.to_dict(),
|
||||
"monitor": self.monitor.to_dict(),
|
||||
}
|
||||
tmp = self.state_path.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh)
|
||||
tmp.replace(self.state_path) # atomic, survives a power cut mid-write
|
||||
|
||||
def load_state(self) -> bool:
|
||||
if not self.state_path.exists():
|
||||
return False
|
||||
try:
|
||||
with open(self.state_path, "r", encoding="utf-8") as fh:
|
||||
s = json.load(fh)
|
||||
if s.get("version") != STATE_VERSION:
|
||||
return False
|
||||
self.tracker.load_dict(s["tracker"])
|
||||
self.nowcast.load_dict(s["nowcast"])
|
||||
self.climatology.load_dict(s["climatology"])
|
||||
self.precip.load_dict(s["precip"])
|
||||
self.monitor.load_dict(s["monitor"])
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.store.log_event("state", "warn", f"could not restore state: {exc}")
|
||||
return False
|
||||
|
||||
# ----------------------------------------------------------- sample
|
||||
|
||||
def sample_once(self) -> Dict[str, Any]:
|
||||
ts = time.time()
|
||||
raw = self.board.read()
|
||||
raw = enrich(raw, self.cfg.site.altitude_m)
|
||||
est = self.tracker.step(ts, raw.get("temp_raw", float("nan")),
|
||||
raw.get("hum", float("nan")),
|
||||
raw.get("press", float("nan")),
|
||||
raw.get("cpu_temp", float("nan")))
|
||||
|
||||
temp_c = est["temp_smooth"]
|
||||
slp = float(physics.sea_level_pressure(est["press_smooth"], temp_c,
|
||||
self.cfg.site.altitude_m))
|
||||
dew = float(physics.dew_point(temp_c, est["hum_smooth"]))
|
||||
elev, azim = physics.solar_position(ts, self.cfg.site.latitude,
|
||||
self.cfg.site.longitude)
|
||||
expected = float(physics.clear_sky_irradiance(elev))
|
||||
lux = float(raw.get("lux", 0.0) or 0.0)
|
||||
cloud = (float(np.clip(1.0 - lux / max(expected * 45.0, 1.0), 0.0, 1.0))
|
||||
if elev > 5.0 else 0.5)
|
||||
|
||||
row = {
|
||||
"ts": ts,
|
||||
"temp_raw": raw.get("temp_raw"),
|
||||
"temp_c": est["temp_c"],
|
||||
"temp_smooth": temp_c,
|
||||
"temp_rate": est["temp_rate"],
|
||||
"hum": raw.get("hum"),
|
||||
"hum_smooth": est["hum_smooth"],
|
||||
"press": raw.get("press"),
|
||||
"press_slp": slp,
|
||||
"press_smooth": est["press_smooth"],
|
||||
"press_rate": est["press_rate"],
|
||||
"cpu_temp": raw.get("cpu_temp"),
|
||||
"dew_c": dew,
|
||||
"lux": lux,
|
||||
"r": raw.get("r"), "g": raw.get("g"), "b": raw.get("b"),
|
||||
"pitch": raw.get("pitch"), "roll": raw.get("roll"),
|
||||
"yaw": raw.get("yaw"), "compass": raw.get("compass"),
|
||||
"ax": raw.get("ax"), "ay": raw.get("ay"), "az": raw.get("az"),
|
||||
"gx": raw.get("gx"), "gy": raw.get("gy"), "gz": raw.get("gz"),
|
||||
}
|
||||
|
||||
anomaly = self.monitor.observe(ts, {
|
||||
"temp_c": temp_c, "hum": est["hum_smooth"], "press_slp": slp,
|
||||
"temp_rate": est["temp_rate"], "press_rate": est["press_rate"],
|
||||
"dew_c": dew, "cpu_temp": raw.get("cpu_temp"),
|
||||
})
|
||||
self.anomaly_bundle = anomaly
|
||||
|
||||
self.live = {
|
||||
**row,
|
||||
"timestamp": time.strftime("%H:%M:%S", time.localtime(ts)),
|
||||
"colour": raw.get("colour", {}),
|
||||
"simulated": bool(raw.get("simulated", not self.board.available)),
|
||||
"dew_depression": temp_c - dew,
|
||||
"vpd": float(physics.vapour_pressure_deficit(temp_c, est["hum_smooth"])),
|
||||
"wet_bulb": float(physics.wet_bulb(temp_c, est["hum_smooth"])),
|
||||
"heat_index": float(physics.heat_index(temp_c, est["hum_smooth"])),
|
||||
"abs_humidity": float(physics.absolute_humidity(temp_c, est["hum_smooth"])),
|
||||
"solar_elevation": float(elev),
|
||||
"solar_azimuth": float(azim),
|
||||
"clear_sky_wm2": expected,
|
||||
"cloud_index": cloud,
|
||||
"cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")),
|
||||
"compensator_k": self.tracker.compensator.k,
|
||||
"health": anomaly["health_overall"],
|
||||
"novelty_d2": anomaly["novelty"].get("d2", 0.0),
|
||||
}
|
||||
self._update_precip()
|
||||
return self.live
|
||||
|
||||
def _observation_vector(self) -> Dict[str, float]:
|
||||
live = self.live
|
||||
hist = self.store.window(8.0, ["ts", "press_slp", "temp_c", "dew_c"])
|
||||
tend = {"tend_1h": live.get("press_rate", 0.0),
|
||||
"tend_3h": live.get("press_rate", 0.0),
|
||||
"tend_6h": live.get("press_rate", 0.0)}
|
||||
if hist["ts"].size > 5:
|
||||
now = hist["ts"][-1]
|
||||
for key, hours in (("tend_1h", 1.0), ("tend_3h", 3.0), ("tend_6h", 6.0)):
|
||||
idx = np.searchsorted(hist["ts"], now - hours * 3600.0)
|
||||
if 0 <= idx < hist["ts"].size - 1:
|
||||
dtp = (now - hist["ts"][idx]) / 3600.0
|
||||
if dtp > 0.25:
|
||||
tend[key] = float((hist["press_slp"][-1] - hist["press_slp"][idx]) / dtp)
|
||||
dew_dep = live.get("dew_depression", 5.0)
|
||||
dew_dep_rate = 0.0
|
||||
if hist["ts"].size > 5:
|
||||
idx = np.searchsorted(hist["ts"], hist["ts"][-1] - 3600.0)
|
||||
if 0 <= idx < hist["ts"].size - 1:
|
||||
past = hist["temp_c"][idx] - hist["dew_c"][idx]
|
||||
dew_dep_rate = float(dew_dep - past)
|
||||
return {
|
||||
"slp": live.get("press_slp", 1013.25),
|
||||
"rh": live.get("hum_smooth", 60.0),
|
||||
"dew_depression": dew_dep,
|
||||
"dew_dep_rate": dew_dep_rate,
|
||||
"cloud_index": live.get("cloud_index", 0.5),
|
||||
"temp_dev": self.climatology.anomaly_now(
|
||||
"temperature", live.get("ts", time.time()), live.get("temp_smooth", 0.0)
|
||||
),
|
||||
"wet_bulb_depression": live.get("temp_smooth", 0.0) - live.get("wet_bulb", 0.0),
|
||||
**tend,
|
||||
}
|
||||
|
||||
def _update_precip(self) -> None:
|
||||
obs = self._observation_vector()
|
||||
zam = zambretti(obs["slp"], obs["tend_3h"], self.live.get("ts"),
|
||||
self.cfg.site.latitude)
|
||||
self.precip_bundle = self.precip.predict(obs, zam)
|
||||
self.precip_bundle["indoors_caveat"] = self.cfg.site.indoors
|
||||
|
||||
y = proxy_wet_label(obs["rh"], obs["dew_depression"], obs["cloud_index"])
|
||||
if y is not None and int(self.live.get("ts", 0)) % 300 < self.cfg.sensor.sample_period_s:
|
||||
self.precip.learn(obs, zam, y, strong=False)
|
||||
|
||||
def add_label(self, kind: str, value: float, ts: Optional[float] = None,
|
||||
note: str = "") -> Dict:
|
||||
"""Human-in-the-loop ground truth. Worth ten times a proxy label."""
|
||||
ts = ts or time.time()
|
||||
self.store.insert_label(ts, kind, value, note)
|
||||
if kind == "rain":
|
||||
obs = self._observation_vector()
|
||||
zam = zambretti(obs["slp"], obs["tend_3h"], ts, self.cfg.site.latitude)
|
||||
loss = self.precip.learn(obs, zam, float(value), strong=True)
|
||||
self.store.log_event("label", "info",
|
||||
f"strong rain label {value} accepted, loss {loss:.3f}", ts)
|
||||
return {"accepted": True, "loss": loss, "strong_labels": self.precip.n_strong}
|
||||
return {"accepted": True}
|
||||
|
||||
def calibrate_temperature(self, reference_c: float) -> Dict:
|
||||
raw = self.live.get("temp_raw")
|
||||
cpu = self.live.get("cpu_temp")
|
||||
if raw is None or cpu is None:
|
||||
return {"error": "no live reading yet"}
|
||||
result = self.tracker.compensator.calibrate(float(raw), float(cpu), float(reference_c))
|
||||
self.store.log_event("calibration", "info",
|
||||
f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)")
|
||||
return result
|
||||
|
||||
def reset_calibration(self) -> Dict:
|
||||
"""Return the self-heating coefficient to its configured prior.
|
||||
|
||||
Worth having: a single mistyped reference reading can drive `k`
|
||||
to its clamp, and because state persists across restarts it will
|
||||
stay there quietly biasing every reading until you notice.
|
||||
"""
|
||||
from .estimation import ThermalCompensator
|
||||
self.tracker.compensator = ThermalCompensator(
|
||||
self.cfg.sensor.cpu_heat_k, self.cfg.sensor.cpu_heat_k_min,
|
||||
self.cfg.sensor.cpu_heat_k_max,
|
||||
)
|
||||
self.save_state()
|
||||
self.store.log_event("calibration", "info",
|
||||
f"coefficient reset to prior k={self.cfg.sensor.cpu_heat_k}")
|
||||
return {"k": self.tracker.compensator.k, "reset": True, "n": 0}
|
||||
|
||||
# ------------------------------------------------------------ train
|
||||
|
||||
def build_training_grid(self, hours: float = 24 * 30):
|
||||
raw = self.store.window(hours, ["ts", "temp_smooth", "hum_smooth",
|
||||
"press_slp", "lux"])
|
||||
if raw["ts"].size < 10:
|
||||
return None
|
||||
grid_ts, cols = resample(
|
||||
raw["ts"],
|
||||
{"temperature": raw["temp_smooth"], "humidity": raw["hum_smooth"],
|
||||
"pressure": raw["press_slp"], "lux": raw["lux"]},
|
||||
self.cfg.model.grid_s,
|
||||
)
|
||||
if grid_ts.size < self.cfg.model.min_rows_to_train:
|
||||
return None
|
||||
X, valid = build_features(
|
||||
grid_ts, cols["temperature"], cols["humidity"], cols["pressure"],
|
||||
cols["lux"], self.cfg.model.grid_s,
|
||||
self.cfg.site.latitude, self.cfg.site.longitude,
|
||||
)
|
||||
return grid_ts, cols, X, valid
|
||||
|
||||
def train(self, hours: float = 24 * 30) -> Dict:
|
||||
t_start = time.time()
|
||||
built = self.build_training_grid(hours)
|
||||
if built is None:
|
||||
return {"trained": False,
|
||||
"reason": f"need at least {self.cfg.model.min_rows_to_train} grid rows"}
|
||||
grid_ts, cols, X, valid = built
|
||||
|
||||
clim_scores = self.climatology.fit(grid_ts, cols, valid)
|
||||
counts = self.nowcast.fit(X, valid, cols, self.climatology, grid_ts)
|
||||
|
||||
self.last_train = time.time()
|
||||
self.monitor.clear_retrain_flag()
|
||||
entry = {
|
||||
"ts": self.last_train,
|
||||
"grid_rows": int(grid_ts.size),
|
||||
"valid_rows": int(valid.sum()),
|
||||
"span_days": round(float((grid_ts[-1] - grid_ts[0]) / 86400.0), 2),
|
||||
"pairs": counts,
|
||||
"climatology_resid_std": {k: round(v, 3) for k, v in clim_scores.items()},
|
||||
"annual_terms": self.climatology.use_annual,
|
||||
"seconds": round(time.time() - t_start, 2),
|
||||
}
|
||||
self.training_log = ([entry] + self.training_log)[:20]
|
||||
self.store.log_event("train", "info",
|
||||
f"retrained on {grid_ts.size} grid rows in {entry['seconds']}s")
|
||||
self.refresh_forecasts()
|
||||
self.save_state()
|
||||
return {"trained": True, **entry}
|
||||
|
||||
# --------------------------------------------------------- forecast
|
||||
|
||||
def refresh_forecasts(self, persist: bool = True) -> Dict:
|
||||
built = self.build_training_grid(hours=48.0)
|
||||
now = time.time()
|
||||
if built is None or not self.live:
|
||||
return {}
|
||||
grid_ts, cols, X, valid = built
|
||||
x_now = X[-1]
|
||||
|
||||
anchors = {
|
||||
"temperature": float(self.live.get("temp_smooth", cols["temperature"][-1])),
|
||||
"humidity": float(self.live.get("hum_smooth", cols["humidity"][-1])),
|
||||
"pressure": float(self.live.get("press_slp", cols["pressure"][-1])),
|
||||
}
|
||||
fc = self.nowcast.forecast(x_now, anchors, now, self.climatology)
|
||||
|
||||
bundle: Dict[str, Any] = {"issued_ts": now, "anchors": anchors, "targets": {}}
|
||||
for target, per_h in fc.items():
|
||||
series = []
|
||||
for h in sorted(per_h):
|
||||
p = per_h[h]
|
||||
series.append({
|
||||
"horizon_s": h,
|
||||
"horizon_label": _fmt_horizon(h),
|
||||
"valid_ts": now + h,
|
||||
"mu": round(p["mu"], 3),
|
||||
"lo": round(p["lo"], 3),
|
||||
"hi": round(p["hi"], 3),
|
||||
"delta": round(p["delta"], 3),
|
||||
"weights": {k: round(v, 3) for k, v in p["weights"].items()},
|
||||
})
|
||||
if persist:
|
||||
self.store.insert_forecast(now, h, target, p["mu"], p["lo"],
|
||||
p["hi"], "ensemble")
|
||||
bundle["targets"][target] = series
|
||||
self.forecast_bundle = bundle
|
||||
|
||||
self.outlook_bundle = {
|
||||
"issued_ts": now,
|
||||
"ready": self.climatology.ready,
|
||||
"annual_terms": self.climatology.use_annual,
|
||||
"history_days": round(self.store.span_days(), 2),
|
||||
"targets": {
|
||||
t: self.climatology.outlook(
|
||||
t, now, days=7,
|
||||
anomaly=self.climatology.anomaly_now(t, now, anchors.get(t, 0.0)),
|
||||
)
|
||||
for t in self.cfg.model.targets
|
||||
},
|
||||
}
|
||||
return bundle
|
||||
|
||||
# ----------------------------------------------------------- verify
|
||||
|
||||
def verify(self) -> Dict:
|
||||
"""Score matured forecasts against truth and against persistence."""
|
||||
due = self.store.due_forecasts()
|
||||
if not due:
|
||||
return {"scored": 0}
|
||||
|
||||
hist = self.store.window(24 * 8, ["ts", "temp_smooth", "hum_smooth", "press_slp"])
|
||||
if hist["ts"].size < 5:
|
||||
return {"scored": 0}
|
||||
series = {"temperature": hist["temp_smooth"], "humidity": hist["hum_smooth"],
|
||||
"pressure": hist["press_slp"]}
|
||||
|
||||
def value_at(target: str, ts: float) -> Optional[float]:
|
||||
idx = int(np.searchsorted(hist["ts"], ts))
|
||||
if idx <= 0 or idx >= hist["ts"].size:
|
||||
return None
|
||||
if abs(hist["ts"][idx] - ts) > 900:
|
||||
return None
|
||||
return float(series[target][idx])
|
||||
|
||||
buckets: Dict[tuple, Dict[str, List[float]]] = {}
|
||||
scored = 0
|
||||
for row in due:
|
||||
target, h = row["target"], int(row["horizon_s"])
|
||||
truth = value_at(target, row["valid_ts"])
|
||||
anchor = value_at(target, row["issued_ts"])
|
||||
if truth is None or anchor is None:
|
||||
continue
|
||||
key = (target, h)
|
||||
b = buckets.setdefault(key, {"err": [], "pers": [], "cov": []})
|
||||
err = truth - row["mu"]
|
||||
b["err"].append(err)
|
||||
b["pers"].append(truth - anchor)
|
||||
b["cov"].append(1.0 if row["lo"] <= truth <= row["hi"] else 0.0)
|
||||
head = self.nowcast.heads.get(key)
|
||||
if head is not None:
|
||||
head.conformal.observe(err, covered=bool(row["lo"] <= truth <= row["hi"]))
|
||||
if h <= 10800:
|
||||
self.monitor.observe_error(row["valid_ts"], abs(err))
|
||||
scored += 1
|
||||
|
||||
now = time.time()
|
||||
for (target, h), b in buckets.items():
|
||||
e = np.asarray(b["err"], dtype=float)
|
||||
p = np.asarray(b["pers"], dtype=float)
|
||||
mae = float(np.mean(np.abs(e)))
|
||||
mae_p = float(np.mean(np.abs(p)))
|
||||
self.store.insert_score(
|
||||
now, target, h,
|
||||
mae=mae,
|
||||
rmse=float(np.sqrt(np.mean(e ** 2))),
|
||||
bias=float(np.mean(e)),
|
||||
mae_persistence=mae_p,
|
||||
skill=float(1.0 - mae / mae_p) if mae_p > 1e-9 else 0.0,
|
||||
coverage=float(np.mean(b["cov"])),
|
||||
n=int(e.size),
|
||||
)
|
||||
|
||||
with self.store._conn() as conn:
|
||||
conn.execute("DELETE FROM forecasts WHERE valid_ts <= ?", (now - 3600,))
|
||||
return {"scored": scored, "buckets": len(buckets)}
|
||||
|
||||
# ------------------------------------------------------------ loops
|
||||
|
||||
async def _loop_sample(self):
|
||||
period = self.cfg.sensor.sample_period_s
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.sample_once()
|
||||
now = time.time()
|
||||
if now - self.last_persist >= self.cfg.sensor.persist_period_s:
|
||||
self.store.insert_telemetry(self.live)
|
||||
self.last_persist = now
|
||||
except Exception as exc:
|
||||
self.store.log_event("sample", "error", repr(exc))
|
||||
await asyncio.sleep(period)
|
||||
|
||||
async def _loop_train(self):
|
||||
await asyncio.sleep(5)
|
||||
try:
|
||||
self.train()
|
||||
except Exception as exc:
|
||||
self.store.log_event("train", "error", repr(exc))
|
||||
while not self._stop.is_set():
|
||||
await asyncio.sleep(30)
|
||||
now = time.time()
|
||||
due = (now - self.last_train) >= self.cfg.model.train_period_s
|
||||
if due or self.monitor.retrain_requested:
|
||||
try:
|
||||
await asyncio.to_thread(self.train)
|
||||
except Exception as exc:
|
||||
self.store.log_event("train", "error", repr(exc))
|
||||
|
||||
async def _loop_verify(self):
|
||||
await asyncio.sleep(60)
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
await asyncio.to_thread(self.verify)
|
||||
except Exception as exc:
|
||||
self.store.log_event("verify", "error", repr(exc))
|
||||
await asyncio.sleep(300)
|
||||
|
||||
async def _loop_maintenance(self):
|
||||
while not self._stop.is_set():
|
||||
await asyncio.sleep(3600)
|
||||
now = time.time()
|
||||
if now - self.last_compact >= self.cfg.storage.vacuum_period_s:
|
||||
try:
|
||||
removed = await asyncio.to_thread(
|
||||
self.store.compact,
|
||||
self.cfg.storage.raw_retention_days,
|
||||
self.cfg.storage.five_min_retention_days,
|
||||
)
|
||||
self.last_compact = now
|
||||
self.store.log_event("compact", "info", json.dumps(removed))
|
||||
except Exception as exc:
|
||||
self.store.log_event("compact", "error", repr(exc))
|
||||
self.save_state()
|
||||
|
||||
def start(self) -> None:
|
||||
self._stop.clear()
|
||||
self._tasks = [
|
||||
asyncio.create_task(self._loop_sample()),
|
||||
asyncio.create_task(self._loop_train()),
|
||||
asyncio.create_task(self._loop_verify()),
|
||||
asyncio.create_task(self._loop_maintenance()),
|
||||
]
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._stop.set()
|
||||
for t in self._tasks:
|
||||
t.cancel()
|
||||
for t in self._tasks:
|
||||
try:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
try:
|
||||
self.save_state()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------ views
|
||||
|
||||
def status(self) -> Dict:
|
||||
return {
|
||||
"site": self.cfg.site.name,
|
||||
"hardware": "sense-hat-v2" if self.board.available else "simulator",
|
||||
"colour_sensor": self.board.has_colour,
|
||||
"rows": self.store.row_count(),
|
||||
"history_days": round(self.store.span_days(), 3),
|
||||
"last_train": self.last_train,
|
||||
"next_train_in_s": max(0.0, self.cfg.model.train_period_s
|
||||
- (time.time() - self.last_train)),
|
||||
"climatology_ready": self.climatology.ready,
|
||||
"annual_terms": self.climatology.use_annual,
|
||||
"compensator_k": round(self.tracker.compensator.k, 4),
|
||||
"calibrations": self.tracker.compensator.n_calibrations,
|
||||
"health": self.monitor.health.overall,
|
||||
"drift_stress": round(self.monitor.drift.stress, 3),
|
||||
"retrain_requested": self.monitor.retrain_requested,
|
||||
"training_log": self.training_log[:5],
|
||||
}
|
||||
|
||||
|
||||
def _fmt_horizon(seconds: int) -> str:
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}m"
|
||||
if seconds < 86400:
|
||||
return f"{seconds // 3600}h"
|
||||
return f"{seconds // 86400}d"
|
||||
Reference in New Issue
Block a user