mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Outlook to Live, humidity calibration, Models tab rebuilt without scrollers
Seven day outlook moves from History to Live, which now runs four rows. Conditions ahead tightened so the Live column no longer needs a scroller. Adds HumidityCompensator: an additive RH offset estimated by one-step RLS from a trusted hygrometer, clamped to +/-35%, persisted, exposed at POST /api/calibrate/humidity and on the renamed Models and calibration tab. It also implements the psychrometric term (RH moved from element temperature onto air temperature via conserved vapour pressure) but leaves it OFF by default. The thermal argument predicts a hot element reads low; measured against a reference hygrometer this board read 75.4% where the truth was 50.4%, so it reads HIGH and that correction would push it the wrong way. When the flag is enabled, simulate.py applies the exact inverse, per the simulator/compensator trap in DESIGN.md section 2. Models pane rebuilt: the scorecard is one column per target so all 18 heads are visible, and no panel on the tab uses an internal scroller. Verified in Chromium at 1600x900: Live, History and Models all report zero scrollbars, zero clipping, no page scroll, zero console errors. Backtest is numerically identical to the previous commit, confirming the humidity work is a no-op while the flag is off.
This commit is contained in:
@@ -11,7 +11,7 @@ labels: forecasting
|
|||||||
```
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
**Scorecard from the Models tab** (or `GET /api/scorecard`)
|
**Scorecard from the Models and calibration tab** (or `GET /api/scorecard`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -166,9 +166,9 @@ eight-pixel type.
|
|||||||
|
|
||||||
| Tab | Answers |
|
| Tab | Answers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Live** | What is it doing now, what it expects next, and how sure it is |
|
| **Live** | What is it doing now, what it expects next, how sure it is, and the week ahead |
|
||||||
| **History** | What did it do, over any timeframe you ask for |
|
| **History** | What did it do, over any timeframe you ask for |
|
||||||
| **Models** | Has the model earned its confidence |
|
| **Models and calibration** | Has the model earned its confidence, and the calibration inputs |
|
||||||
| **Methods** | How the whole thing is wired, and how each stage fails |
|
| **Methods** | How the whole thing is wired, and how each stage fails |
|
||||||
|
|
||||||
Live carries the current readings, the observed-and-forecast chart with its 90%
|
Live carries the current readings, the observed-and-forecast chart with its 90%
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ class CalibrationIn(BaseModel):
|
|||||||
"covariance, returning to the configured prior")
|
"covariance, returning to the configured prior")
|
||||||
|
|
||||||
|
|
||||||
|
class HumidityCalibrationIn(BaseModel):
|
||||||
|
reference_pct: Optional[float] = Field(None, ge=0, le=100,
|
||||||
|
description="Trusted relative humidity in %")
|
||||||
|
reset: bool = Field(False, description="Discard the learned offset, returning to "
|
||||||
|
"the configured prior")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ endpoints
|
# ------------------------------------------------------------ endpoints
|
||||||
|
|
||||||
@app.get("/api/telemetry")
|
@app.get("/api/telemetry")
|
||||||
@@ -140,6 +147,8 @@ def telemetry() -> Dict:
|
|||||||
"cpu_temp": live.get("cpu_temp"),
|
"cpu_temp": live.get("cpu_temp"),
|
||||||
"cpu_offset": live.get("cpu_offset"),
|
"cpu_offset": live.get("cpu_offset"),
|
||||||
"compensator_k": live.get("compensator_k"),
|
"compensator_k": live.get("compensator_k"),
|
||||||
|
"hum_offset": live.get("hum_offset"),
|
||||||
|
"hum_psychrometric": live.get("hum_psychrometric"),
|
||||||
"rates": {
|
"rates": {
|
||||||
"temperature_c_per_h": live.get("temp_rate"),
|
"temperature_c_per_h": live.get("temp_rate"),
|
||||||
"humidity_pct_per_h": live.get("hum_rate"),
|
"humidity_pct_per_h": live.get("hum_rate"),
|
||||||
@@ -382,6 +391,19 @@ def calibrate(body: CalibrationIn) -> Dict:
|
|||||||
return _clean(result)
|
return _clean(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calibrate/humidity")
|
||||||
|
def calibrate_humidity(body: HumidityCalibrationIn) -> Dict:
|
||||||
|
st = _st()
|
||||||
|
if body.reset:
|
||||||
|
return _clean(st.reset_humidity_calibration())
|
||||||
|
if body.reference_pct is None:
|
||||||
|
raise HTTPException(422, "provide reference_pct, or reset=true")
|
||||||
|
result = st.calibrate_humidity(body.reference_pct)
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(409, result["error"])
|
||||||
|
return _clean(result)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status() -> Dict:
|
def status() -> Dict:
|
||||||
st = _st()
|
st = _st()
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ class SensorConfig:
|
|||||||
cpu_heat_k: float = 0.55
|
cpu_heat_k: float = 0.55
|
||||||
cpu_heat_k_min: float = 0.15
|
cpu_heat_k_min: float = 0.15
|
||||||
cpu_heat_k_max: float = 1.20
|
cpu_heat_k_max: float = 1.20
|
||||||
|
# Additive RH bias of the element. The datasheet claims about +/-3.5%, but
|
||||||
|
# measured against a reference hygrometer this board read 75.4% where the
|
||||||
|
# truth was 50.4%, so the clamp has to allow far more than spec. Kept finite
|
||||||
|
# so one mistyped reference still cannot run away.
|
||||||
|
# Move RH from the element's temperature onto the compensated air temperature
|
||||||
|
# via conserved vapour pressure. Physically correct IF the humidity element
|
||||||
|
# really sits at temp_raw. Measured on this board it does not: against a
|
||||||
|
# reference hygrometer reading 50.4%, the HTS221 reported 75.4%, so it reads
|
||||||
|
# HIGH and this correction would push it higher still. The error is an
|
||||||
|
# additive element bias, not a thermal gradient. Leave off unless your own
|
||||||
|
# reference says otherwise.
|
||||||
|
hum_psychrometric: bool = False
|
||||||
|
hum_offset: float = 0.0
|
||||||
|
hum_offset_min: float = -35.0
|
||||||
|
hum_offset_max: float = 35.0
|
||||||
# Kalman process/measurement noise (per-signal)
|
# Kalman process/measurement noise (per-signal)
|
||||||
kalman_q_temp: float = 2.0e-6
|
kalman_q_temp: float = 2.0e-6
|
||||||
kalman_r_temp: float = 0.02
|
kalman_r_temp: float = 0.02
|
||||||
|
|||||||
+118
-77
@@ -120,14 +120,14 @@ DASHBOARD_HTML = r"""
|
|||||||
<nav role="tablist" class="glass rounded-2xl p-1.5 flex gap-1.5 overflow-x-auto">
|
<nav role="tablist" class="glass rounded-2xl p-1.5 flex gap-1.5 overflow-x-auto">
|
||||||
<button role="tab" data-tab="live" aria-selected="true" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">Live</button>
|
<button role="tab" data-tab="live" aria-selected="true" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">Live</button>
|
||||||
<button role="tab" data-tab="history" aria-selected="false" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">History</button>
|
<button role="tab" data-tab="history" aria-selected="false" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">History</button>
|
||||||
<button role="tab" data-tab="models" aria-selected="false" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">Models</button>
|
<button role="tab" data-tab="models" aria-selected="false" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">Models and calibration</button>
|
||||||
<button role="tab" data-tab="methods" aria-selected="false" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">Methods</button>
|
<button role="tab" data-tab="methods" aria-selected="false" class="tabbtn shrink-0 px-4 py-2 rounded-xl text-xs font-semibold text-slate-400 border border-transparent hover:text-slate-200">Methods</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="min-h-0">
|
<main class="min-h-0">
|
||||||
|
|
||||||
<!-- ---------------- LIVE ---------------- -->
|
<!-- ---------------- LIVE ---------------- -->
|
||||||
<section id="pane-live" class="pane active h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr_auto]">
|
<section id="pane-live" class="pane active h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr_auto_auto]">
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-4 flex flex-col justify-between">
|
<div class="glass rounded-2xl p-4 flex flex-col justify-between">
|
||||||
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-amber-400">
|
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-amber-400">
|
||||||
@@ -216,9 +216,9 @@ DASHBOARD_HTML = r"""
|
|||||||
<h2 class="text-sm font-bold">Conditions ahead</h2>
|
<h2 class="text-sm font-bold">Conditions ahead</h2>
|
||||||
<p class="text-[10px] text-indigo-400 font-mono">Zambretti prior + online logistic</p>
|
<p class="text-[10px] text-indigo-400 font-mono">Zambretti prior + online logistic</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0 scroller space-y-3 pr-1">
|
<div class="flex-1 min-h-0 space-y-2 pr-1">
|
||||||
<div class="text-center py-1">
|
<div class="text-center">
|
||||||
<div id="c-label" class="text-lg font-extrabold leading-tight">--</div>
|
<div id="c-label" class="text-base font-extrabold leading-tight">--</div>
|
||||||
<div class="text-[10px] text-slate-500 font-mono mt-0.5">Z=<span id="c-z">-</span> · <span id="c-trend">-</span></div>
|
<div class="text-[10px] text-slate-500 font-mono mt-0.5">Z=<span id="c-z">-</span> · <span id="c-trend">-</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -226,7 +226,7 @@ DASHBOARD_HTML = r"""
|
|||||||
<div class="h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800"><div id="c-bar" class="tick h-full bg-gradient-to-r from-cyan-500 to-blue-600" style="width:0%"></div></div>
|
<div class="h-2 bg-slate-900 rounded-full overflow-hidden border border-slate-800"><div id="c-bar" class="tick h-full bg-gradient-to-r from-cyan-500 to-blue-600" style="width:0%"></div></div>
|
||||||
<div class="flex justify-between text-[9px] font-mono text-slate-600 mt-1"><span>prior <span id="c-prior">-</span></span><span>learner <span id="c-model">-</span></span><span>trust <span id="c-trust">-</span></span></div>
|
<div class="flex justify-between text-[9px] font-mono text-slate-600 mt-1"><span>prior <span id="c-prior">-</span></span><span>learner <span id="c-model">-</span></span><span>trust <span id="c-trust">-</span></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5 space-y-2">
|
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2 space-y-1.5">
|
||||||
<div class="text-[10px] text-slate-400 font-mono">Was it wet in the last hour?</div>
|
<div class="text-[10px] text-slate-400 font-mono">Was it wet in the last hour?</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button data-label="1" class="rain-label flex-1 px-2 py-1.5 rounded-lg bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 border border-blue-500/30 text-[11px] font-medium">Yes, rain</button>
|
<button data-label="1" class="rain-label flex-1 px-2 py-1.5 rounded-lg bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 border border-blue-500/30 text-[11px] font-medium">Yes, rain</button>
|
||||||
@@ -239,12 +239,23 @@ DASHBOARD_HTML = r"""
|
|||||||
<span class="text-[10px] text-slate-500 font-mono">pressure, last 24 h</span>
|
<span class="text-[10px] text-slate-500 font-mono">pressure, last 24 h</span>
|
||||||
<span id="c-tend" class="text-[10px] text-violet-300 font-mono">--</span>
|
<span id="c-tend" class="text-[10px] text-violet-300 font-mono">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-20 relative"><canvas id="tendChart"></canvas></div>
|
<div class="h-14 relative"><canvas id="tendChart"></canvas></div>
|
||||||
<p class="text-[9px] text-slate-600 font-mono mt-1 leading-snug">The only signal here that sees past your walls. Its slope, not its level, is what drives the forecast above.</p>
|
<p class="text-[9px] text-slate-600 font-mono mt-1 leading-snug">The only signal that sees past your walls. Its slope drives the forecast.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-2xl p-4 lg:col-span-4 shrink-0">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-bold">Seven day outlook</h2>
|
||||||
|
<p class="text-[10px] text-slate-500 font-mono">climatology plus decaying anomaly, not a synoptic forecast</p>
|
||||||
|
</div>
|
||||||
|
<span id="o-badge" class="px-2 py-0.5 rounded-lg bg-amber-500/10 text-amber-300 border border-amber-500/20 text-[9px] font-mono uppercase font-semibold">warming up</span>
|
||||||
|
</div>
|
||||||
|
<div id="o-strip" class="grid grid-cols-4 sm:grid-cols-7 gap-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="glass rounded-2xl px-4 py-2.5 lg:col-span-4 grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-10 gap-x-4 gap-y-1.5 font-mono text-[10px]">
|
<div class="glass rounded-2xl px-4 py-2.5 lg:col-span-4 grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-10 gap-x-4 gap-y-1.5 font-mono text-[10px]">
|
||||||
<div><div class="text-slate-600 uppercase">wet bulb</div><div id="d-wb" class="text-slate-200 font-semibold">--</div></div>
|
<div><div class="text-slate-600 uppercase">wet bulb</div><div id="d-wb" class="text-slate-200 font-semibold">--</div></div>
|
||||||
<div><div class="text-slate-600 uppercase">vpd</div><div id="d-vpd" class="text-slate-200 font-semibold">--</div></div>
|
<div><div class="text-slate-600 uppercase">vpd</div><div id="d-vpd" class="text-slate-200 font-semibold">--</div></div>
|
||||||
@@ -260,7 +271,7 @@ DASHBOARD_HTML = r"""
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ---------------- HISTORY ---------------- -->
|
<!-- ---------------- HISTORY ---------------- -->
|
||||||
<section id="pane-history" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr_auto]">
|
<section id="pane-history" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr]">
|
||||||
<div class="glass rounded-2xl px-4 py-3 lg:col-span-4 flex flex-wrap items-end gap-3">
|
<div class="glass rounded-2xl px-4 py-3 lg:col-span-4 flex flex-wrap items-end gap-3">
|
||||||
<div class="flex gap-1 flex-wrap" id="h-presets">
|
<div class="flex gap-1 flex-wrap" id="h-presets">
|
||||||
<button data-h="6" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">6 h</button>
|
<button data-h="6" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">6 h</button>
|
||||||
@@ -300,85 +311,86 @@ DASHBOARD_HTML = r"""
|
|||||||
<div id="rec-all" class="flex-1 min-h-0 scroller pr-1 hidden space-y-1.5"></div>
|
<div id="rec-all" class="flex-1 min-h-0 scroller pr-1 hidden space-y-1.5"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-4 lg:col-span-4 shrink-0">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-bold">Seven day outlook</h2>
|
|
||||||
<p class="text-[10px] text-slate-500 font-mono">climatology plus decaying anomaly, not a synoptic forecast</p>
|
|
||||||
</div>
|
|
||||||
<span id="o-badge" class="px-2 py-0.5 rounded-lg bg-amber-500/10 text-amber-300 border border-amber-500/20 text-[9px] font-mono uppercase font-semibold">warming up</span>
|
|
||||||
</div>
|
|
||||||
<div id="o-strip" class="grid grid-cols-4 sm:grid-cols-7 gap-2"></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ---------------- MODELS ---------------- -->
|
<!-- ---------------- MODELS AND CALIBRATION ---------------- -->
|
||||||
<section id="pane-models" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-3 lg:grid-rows-[1fr_auto]">
|
<!-- Four rows, all sized by content except the log, which absorbs the slack.
|
||||||
<div class="glass rounded-2xl p-4 lg:col-span-2 flex flex-col min-h-0">
|
Nothing here uses an internal scroller: the scorecard is split into one
|
||||||
|
column per target so all 18 heads are visible at once rather than hidden
|
||||||
|
behind a scrollbar. -->
|
||||||
|
<section id="pane-models" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_auto_1fr]">
|
||||||
|
|
||||||
|
<div class="glass rounded-2xl p-4 lg:col-span-4 flex flex-col min-h-0">
|
||||||
<div class="flex items-center justify-between pb-2 mb-2 border-b border-slate-800 shrink-0">
|
<div class="flex items-center justify-between pb-2 mb-2 border-b border-slate-800 shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-sm font-bold">Verification scorecard</h2>
|
<h2 class="text-sm font-bold">Verification scorecard</h2>
|
||||||
<p class="text-[10px] text-slate-500 font-mono">skill above zero means it beats persistence</p>
|
<p class="text-[10px] text-slate-500 font-mono">skill above zero means it beats persistence · p/c/l is the ensemble weight</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
<button id="m-verify" class="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-slate-800/60 hover:bg-slate-800 text-slate-300 border border-slate-700">Score now</button>
|
<button id="m-verify" class="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-slate-800/60 hover:bg-slate-800 text-slate-300 border border-slate-700">Score now</button>
|
||||||
<button id="m-train" class="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30">Retrain</button>
|
<button id="m-train" class="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30">Retrain</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-h-0 scroller pr-1">
|
<div id="m-score" class="grid grid-cols-1 md:grid-cols-3 gap-3"></div>
|
||||||
<table class="w-full text-[11px] font-mono">
|
<p id="m-empty" class="text-[10px] text-slate-600 font-mono mt-2 leading-relaxed">No matured forecasts yet. Rows appear as each horizon reaches its validity time: 15 minutes first, 24 hours tomorrow.</p>
|
||||||
<thead class="text-slate-600 uppercase text-[9px] sticky top-0 bg-slate-950/90 backdrop-blur">
|
|
||||||
<tr class="border-b border-slate-800">
|
|
||||||
<th class="text-left py-1.5">target</th><th class="text-right">lead</th><th class="text-right">MAE</th>
|
|
||||||
<th class="text-right">persist</th><th class="text-right">skill</th><th class="text-right">cover</th>
|
|
||||||
<th class="text-right">n</th><th class="text-right pl-3">p/c/l</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="m-score" class="text-slate-300"></tbody>
|
|
||||||
</table>
|
|
||||||
<p id="m-empty" class="text-[10px] text-slate-600 font-mono mt-3 leading-relaxed">No matured forecasts yet. Rows appear as each horizon reaches its validity time: 15 minutes first, 24 hours tomorrow. The p/c/l column is the ensemble weight on persistence, climatology and the learned model.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-4 flex flex-col min-h-0">
|
<div class="glass rounded-2xl p-4 flex flex-col">
|
||||||
<div class="pb-2 mb-2 border-b border-slate-800 shrink-0"><h2 class="text-sm font-bold">Calibration and state</h2></div>
|
<div class="pb-2 mb-2 border-b border-slate-800 flex items-baseline justify-between">
|
||||||
<div class="flex-1 min-h-0 scroller space-y-2.5 pr-1 font-mono text-[10px]">
|
<h2 class="text-sm font-bold">Temperature</h2>
|
||||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5">
|
<span class="text-[9px] font-mono text-slate-600 uppercase">self-heating</span>
|
||||||
<div class="flex justify-between text-slate-400"><span>self-heating k</span><span id="e-k" class="text-emerald-300 font-bold">--</span></div>
|
|
||||||
<div class="flex justify-between text-slate-500 mt-1"><span>cpu offset</span><span id="e-off">--</span></div>
|
|
||||||
<div class="text-[9px] text-slate-600 mt-1.5 leading-snug">Removes the SoC bias. Set it from the reading below.</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5 space-y-2">
|
<div class="font-mono text-[10px] space-y-1.5">
|
||||||
<div class="text-slate-400">Trusted thermometer reading</div>
|
<div class="flex justify-between text-slate-400"><span>coefficient k</span><span id="e-k" class="text-emerald-300 font-bold">--</span></div>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex justify-between text-slate-500"><span>cpu offset</span><span id="e-off">--</span></div>
|
||||||
|
<div class="flex gap-1.5 pt-1">
|
||||||
<input id="m-calin" type="number" step="0.1" placeholder="20.5" class="flex-1 min-w-0 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white">
|
<input id="m-calin" type="number" step="0.1" placeholder="20.5" class="flex-1 min-w-0 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white">
|
||||||
<button id="m-calgo" class="px-2.5 py-1.5 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/30">Set</button>
|
<button id="m-calgo" class="px-2.5 py-1.5 rounded-lg bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/30">Set</button>
|
||||||
<button id="m-calrst" class="px-2.5 py-1.5 rounded-lg bg-slate-800/60 hover:bg-slate-800 text-slate-400 border border-slate-700">Reset</button>
|
<button id="m-calrst" class="px-2.5 py-1.5 rounded-lg bg-slate-800/60 hover:bg-slate-800 text-slate-400 border border-slate-700">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="m-calstat" class="text-[9px] text-slate-600 leading-snug">Recursive least squares on the self-heating coefficient. One good reading is enough.</div>
|
<div id="m-calstat" class="text-[9px] text-slate-600 leading-snug">Enter a trusted thermometer reading in °C. One good reading is enough.</div>
|
||||||
</div>
|
|
||||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5">
|
|
||||||
<div class="text-slate-400 mb-1.5">Storage tiers</div>
|
|
||||||
<div id="m-storage" class="space-y-1"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5">
|
|
||||||
<div class="text-slate-400 mb-1.5">Precipitation coefficients</div>
|
|
||||||
<div id="m-coef" class="space-y-0.5"></div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5">
|
|
||||||
<div class="flex justify-between text-slate-400 mb-1"><span>novelty d²</span><span id="e-nov" class="text-slate-200 font-bold">--</span></div>
|
|
||||||
<div class="h-1.5 bg-slate-950 rounded-full overflow-hidden border border-slate-800"><div id="e-nov-bar" class="tick h-full bg-gradient-to-r from-emerald-500 to-amber-500" style="width:0%"></div></div>
|
|
||||||
<div class="flex justify-between text-slate-400 mt-2 mb-1"><span>drift pressure</span><span id="e-drift" class="text-slate-200 font-bold">--</span></div>
|
|
||||||
<div class="h-1.5 bg-slate-950 rounded-full overflow-hidden border border-slate-800"><div id="e-drift-bar" class="tick h-full bg-gradient-to-r from-indigo-500 to-rose-500" style="width:0%"></div></div>
|
|
||||||
<div class="text-[9px] text-slate-600 mt-1.5 leading-snug">Novelty is a multivariate departure from the recent norm. Drift reaching 100% queues a retrain.</div>
|
|
||||||
</div>
|
|
||||||
<div id="e-health" class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2.5 space-y-1"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-4 lg:col-span-3 shrink-0">
|
<div class="glass rounded-2xl p-4 flex flex-col">
|
||||||
<h2 class="text-sm font-bold mb-2">Station log</h2>
|
<div class="pb-2 mb-2 border-b border-slate-800 flex items-baseline justify-between">
|
||||||
<div id="m-log" class="max-h-28 scroller space-y-1 font-mono text-[10px] pr-1"></div>
|
<h2 class="text-sm font-bold">Humidity</h2>
|
||||||
|
<span class="text-[9px] font-mono text-slate-600 uppercase">element bias</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-mono text-[10px] space-y-1.5">
|
||||||
|
<div class="flex justify-between text-slate-400"><span>offset</span><span id="e-hoff" class="text-cyan-300 font-bold">--</span></div>
|
||||||
|
<div class="flex justify-between text-slate-500"><span>psychrometric</span><span id="e-hpsy">--</span></div>
|
||||||
|
<div class="flex gap-1.5 pt-1">
|
||||||
|
<input id="m-hcalin" type="number" step="0.5" min="0" max="100" placeholder="55" class="flex-1 min-w-0 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white">
|
||||||
|
<button id="m-hcalgo" class="px-2.5 py-1.5 rounded-lg bg-cyan-600/20 hover:bg-cyan-600/30 text-cyan-300 border border-cyan-500/30">Set</button>
|
||||||
|
<button id="m-hcalrst" class="px-2.5 py-1.5 rounded-lg bg-slate-800/60 hover:bg-slate-800 text-slate-400 border border-slate-700">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div id="m-hcalstat" class="text-[9px] text-slate-600 leading-snug">Enter a trusted hygrometer reading in %. The psychrometric term is derived, not fitted.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-2xl p-4 flex flex-col">
|
||||||
|
<div class="pb-2 mb-2 border-b border-slate-800"><h2 class="text-sm font-bold">Estimator</h2></div>
|
||||||
|
<div class="font-mono text-[10px] space-y-1">
|
||||||
|
<div class="flex justify-between text-slate-400"><span>novelty d²</span><span id="e-nov" class="text-slate-200 font-bold">--</span></div>
|
||||||
|
<div class="h-1.5 bg-slate-950 rounded-full overflow-hidden border border-slate-800"><div id="e-nov-bar" class="tick h-full bg-gradient-to-r from-emerald-500 to-amber-500" style="width:0%"></div></div>
|
||||||
|
<div class="flex justify-between text-slate-400 pt-1"><span>drift pressure</span><span id="e-drift" class="text-slate-200 font-bold">--</span></div>
|
||||||
|
<div class="h-1.5 bg-slate-950 rounded-full overflow-hidden border border-slate-800"><div id="e-drift-bar" class="tick h-full bg-gradient-to-r from-indigo-500 to-rose-500" style="width:0%"></div></div>
|
||||||
|
<div id="e-health" class="pt-1 space-y-0.5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-2xl p-4 flex flex-col">
|
||||||
|
<div class="pb-2 mb-2 border-b border-slate-800"><h2 class="text-sm font-bold">Storage and weights</h2></div>
|
||||||
|
<div class="font-mono text-[10px] space-y-1">
|
||||||
|
<div id="m-storage" class="space-y-1"></div>
|
||||||
|
<div id="m-coef" class="space-y-0.5 pt-1.5 mt-1.5 border-t border-slate-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-2xl p-4 lg:col-span-4 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
<h2 class="text-sm font-bold mb-2 shrink-0">Station log</h2>
|
||||||
|
<div id="m-log" class="flex-1 min-h-0 overflow-hidden space-y-1 font-mono text-[10px]"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -453,7 +465,7 @@ el('live-span').addEventListener('click', e => {
|
|||||||
(x===b ? 'bg-indigo-600/20 text-indigo-300' : 'text-slate-400 hover:text-slate-200'));
|
(x===b ? 'bg-indigo-600/20 text-indigo-300' : 'text-slate-400 hover:text-slate-200'));
|
||||||
loadForecast();
|
loadForecast();
|
||||||
});
|
});
|
||||||
loaders.live = loadForecast;
|
loaders.live = () => { loadForecast(); loadOutlook(); };
|
||||||
|
|
||||||
function setFlash(id,val) {
|
function setFlash(id,val) {
|
||||||
const node = el(id); if (!node || node.innerText===val) return;
|
const node = el(id); if (!node || node.innerText===val) return;
|
||||||
@@ -491,6 +503,8 @@ function applyTelemetry(d) {
|
|||||||
el('d-az').innerText = fmt(d.accel && d.accel.z,2);
|
el('d-az').innerText = fmt(d.accel && d.accel.z,2);
|
||||||
|
|
||||||
el('e-k').innerText = fmt(d.compensator_k,4);
|
el('e-k').innerText = fmt(d.compensator_k,4);
|
||||||
|
el('e-hoff').innerText = (d.hum_offset===undefined||d.hum_offset===null) ? '--' : (d.hum_offset>=0?'+':'')+fmt(d.hum_offset,2)+'%';
|
||||||
|
el('e-hpsy').innerText = (d.hum_psychrometric===undefined||d.hum_psychrometric===null) ? '--' : (d.hum_psychrometric>=0?'+':'')+fmt(d.hum_psychrometric,2)+'%';
|
||||||
el('e-off').innerText = fmt(d.cpu_offset,1)+' K';
|
el('e-off').innerText = fmt(d.cpu_offset,1)+' K';
|
||||||
el('e-nov').innerText = fmt(d.novelty_d2,1);
|
el('e-nov').innerText = fmt(d.novelty_d2,1);
|
||||||
el('e-nov-bar').style.width = Math.min((d.novelty_d2||0)/24,1)*100+'%';
|
el('e-nov-bar').style.width = Math.min((d.novelty_d2||0)/24,1)*100+'%';
|
||||||
@@ -754,7 +768,7 @@ function toggleRec(daily) {
|
|||||||
}
|
}
|
||||||
el('rec-tab-daily').addEventListener('click', ()=>toggleRec(true));
|
el('rec-tab-daily').addEventListener('click', ()=>toggleRec(true));
|
||||||
el('rec-tab-all').addEventListener('click', ()=>toggleRec(false));
|
el('rec-tab-all').addEventListener('click', ()=>toggleRec(false));
|
||||||
loaders.history = () => { if (!histData) loadHistory(); loadOutlook(); };
|
loaders.history = () => { if (!histData) loadHistory(); };
|
||||||
|
|
||||||
/* ---------------- MODELS ---------------- */
|
/* ---------------- MODELS ---------------- */
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
@@ -768,19 +782,31 @@ async function loadModels() {
|
|||||||
(md.nowcast||[]).forEach(h => { wmap[h.target+'@'+h.horizon_s] = h.weights; });
|
(md.nowcast||[]).forEach(h => { wmap[h.target+'@'+h.horizon_s] = h.weights; });
|
||||||
const rows = sc.rows||[];
|
const rows = sc.rows||[];
|
||||||
el('m-empty').style.display = rows.length ? 'none' : 'block';
|
el('m-empty').style.display = rows.length ? 'none' : 'block';
|
||||||
el('m-score').innerHTML = rows.map(r=>{
|
// One column per target rather than one 18-row table: every head stays
|
||||||
|
// visible instead of hiding behind a scrollbar.
|
||||||
|
const byTarget = {};
|
||||||
|
rows.forEach(r => { (byTarget[r.target] = byTarget[r.target] || []).push(r); });
|
||||||
|
el('m-score').innerHTML = Object.keys(byTarget).map(target => {
|
||||||
|
const body = byTarget[target].map(r => {
|
||||||
const sk = r.skill||0;
|
const sk = r.skill||0;
|
||||||
const cls = sk>0.05?'text-emerald-300':sk<-0.05?'text-rose-300':'text-slate-400';
|
const cls = sk>0.05?'text-emerald-300':sk<-0.05?'text-rose-300':'text-slate-400';
|
||||||
const lead = r.horizon_s<3600 ? (r.horizon_s/60)+'m' : r.horizon_s<86400 ? (r.horizon_s/3600)+'h' : (r.horizon_s/86400)+'d';
|
const lead = r.horizon_s<3600 ? (r.horizon_s/60)+'m' : r.horizon_s<86400 ? (r.horizon_s/3600)+'h' : (r.horizon_s/86400)+'d';
|
||||||
const w = wmap[r.target+'@'+r.horizon_s];
|
const w = wmap[r.target+'@'+r.horizon_s];
|
||||||
const ws = w ? Math.round(w.persistence*100)+'/'+Math.round(w.climatology*100)+'/'+Math.round(w.learned*100) : '-';
|
const ws = w ? Math.round(w.persistence*100)+'/'+Math.round(w.climatology*100)+'/'+Math.round(w.learned*100) : '-';
|
||||||
return '<tr class="border-b border-slate-800/40"><td class="py-1.5 text-slate-300">'+r.target+'</td>'+
|
return '<tr class="border-b border-slate-800/40">'+
|
||||||
'<td class="text-right text-slate-500">'+lead+'</td><td class="text-right">'+fmt(r.mae,3)+'</td>'+
|
'<td class="py-1 text-slate-500">'+lead+'</td>'+
|
||||||
'<td class="text-right text-slate-600">'+fmt(r.mae_persistence,3)+'</td>'+
|
'<td class="text-right text-slate-200">'+fmt(r.mae,2)+'</td>'+
|
||||||
|
'<td class="text-right text-slate-600">'+fmt(r.mae_persistence,2)+'</td>'+
|
||||||
'<td class="text-right font-bold '+cls+'">'+Math.round(sk*100)+'%</td>'+
|
'<td class="text-right font-bold '+cls+'">'+Math.round(sk*100)+'%</td>'+
|
||||||
'<td class="text-right text-slate-400">'+Math.round((r.coverage||0)*100)+'%</td>'+
|
'<td class="text-right text-slate-400">'+Math.round((r.coverage||0)*100)+'%</td>'+
|
||||||
'<td class="text-right text-slate-700">'+r.n+'</td>'+
|
'<td class="text-right text-slate-600 pl-2">'+ws+'</td></tr>';
|
||||||
'<td class="text-right text-slate-500 pl-3">'+ws+'</td></tr>';
|
}).join('');
|
||||||
|
return '<div class="bg-slate-900/40 rounded-xl border border-slate-800/70 p-2.5">'+
|
||||||
|
'<div class="text-[10px] font-semibold uppercase tracking-wider text-slate-300 mb-1">'+target+'</div>'+
|
||||||
|
'<table class="w-full text-[10px] font-mono"><thead class="text-slate-600 uppercase text-[9px]">'+
|
||||||
|
'<tr class="border-b border-slate-800"><th class="text-left py-1">lead</th><th class="text-right">MAE</th>'+
|
||||||
|
'<th class="text-right">pers</th><th class="text-right">skill</th><th class="text-right">cov</th>'+
|
||||||
|
'<th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+body+'</tbody></table></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
el('m-storage').innerHTML = (sg.tiers||[]).map(t=>
|
el('m-storage').innerHTML = (sg.tiers||[]).map(t=>
|
||||||
@@ -837,6 +863,20 @@ el('m-calrst').addEventListener('click', async () => {
|
|||||||
body: JSON.stringify({reset:true})}).then(r=>r.json());
|
body: JSON.stringify({reset:true})}).then(r=>r.json());
|
||||||
el('m-calstat').innerHTML = 'Reset to prior k = <span class="text-emerald-300">'+r.k+'</span>';
|
el('m-calstat').innerHTML = 'Reset to prior k = <span class="text-emerald-300">'+r.k+'</span>';
|
||||||
});
|
});
|
||||||
|
el('m-hcalgo').addEventListener('click', async () => {
|
||||||
|
const v = parseFloat(el('m-hcalin').value);
|
||||||
|
if (Number.isNaN(v) || v < 0 || v > 100) { el('m-hcalstat').innerText = 'Enter a relative humidity between 0 and 100%.'; return; }
|
||||||
|
const r = await fetch('/api/calibrate/humidity', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({reference_pct:v})}).then(r=>r.json());
|
||||||
|
el('m-hcalstat').innerHTML = r.offset!==undefined
|
||||||
|
? 'offset is now <span class="text-cyan-300">'+r.offset.toFixed(2)+'%</span>, residual '+r.residual.toFixed(2)+'%'
|
||||||
|
: 'Rejected: no live reading yet.';
|
||||||
|
});
|
||||||
|
el('m-hcalrst').addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/calibrate/humidity', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({reset:true})}).then(r=>r.json());
|
||||||
|
el('m-hcalstat').innerHTML = 'Reset to prior offset = <span class="text-cyan-300">'+r.offset+'%</span>';
|
||||||
|
});
|
||||||
|
|
||||||
/* ---------------- METHODS ---------------- */
|
/* ---------------- METHODS ---------------- */
|
||||||
let methodsDoc = null, methodSel = 'acquire';
|
let methodsDoc = null, methodSel = 'acquire';
|
||||||
@@ -917,10 +957,11 @@ loaders.methods = loadMethods;
|
|||||||
/* ---------------- BOOT ---------------- */
|
/* ---------------- BOOT ---------------- */
|
||||||
connectStream();
|
connectStream();
|
||||||
loadForecast();
|
loadForecast();
|
||||||
|
loadOutlook();
|
||||||
fetch('/api/status').then(r=>r.json()).then(s => { el('hd-days').innerText = fmt(s.history_days,2); });
|
fetch('/api/status').then(r=>r.json()).then(s => { el('hd-days').innerText = fmt(s.history_days,2); });
|
||||||
setInterval(() => { if (activeTab==='live') loadForecast(); }, 60000);
|
setInterval(() => { if (activeTab==='live') { loadForecast(); loadOutlook(); } }, 60000);
|
||||||
setInterval(() => { if (activeTab==='models') loadModels(); }, 60000);
|
setInterval(() => { if (activeTab==='models') loadModels(); }, 60000);
|
||||||
setInterval(() => { if (activeTab==='history') { loadHistory(); loadOutlook(); } }, 300000);
|
setInterval(() => { if (activeTab==='history') loadHistory(); }, 300000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+103
-2
@@ -35,6 +35,8 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from .physics import saturation_vapour_pressure
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KalmanCV:
|
class KalmanCV:
|
||||||
@@ -158,14 +160,105 @@ class ThermalCompensator:
|
|||||||
return tc
|
return tc
|
||||||
|
|
||||||
|
|
||||||
|
class HumidityCompensator:
|
||||||
|
"""Corrects relative humidity for a sensor sitting hotter than the air.
|
||||||
|
|
||||||
|
The HTS221 reports RH at its own temperature, but the air you care about is
|
||||||
|
at the compensated temperature. Vapour pressure is what is conserved between
|
||||||
|
the two, so
|
||||||
|
|
||||||
|
RH_true = RH_sensor * es(T_sensor) / es(T_true)
|
||||||
|
|
||||||
|
Because the element runs hot, es(T_sensor) > es(T_true) and an uncorrected
|
||||||
|
reading is biased low, by several points on a warm board. That term needs no
|
||||||
|
calibration constant at all: it falls out of the thermal compensation that is
|
||||||
|
already running.
|
||||||
|
|
||||||
|
Measured on real hardware the psychrometric term is the wrong model: against
|
||||||
|
a reference hygrometer reading 50.4%, this board's HTS221 reported 75.4%, so
|
||||||
|
it reads HIGH where the thermal argument predicts LOW. The dominant error is
|
||||||
|
an additive element bias, which is what `offset` corrects, estimated from a
|
||||||
|
trusted reference by the same one-step RLS used for `k` with the regressor
|
||||||
|
fixed at 1, so repeated calibrations converge to a weighted mean. The
|
||||||
|
psychrometric term is therefore off by default and gated on config.
|
||||||
|
|
||||||
|
How it fails: calibrate against a reference while the board is cool, then let
|
||||||
|
CPU load rise, and an offset-only correction drifts because the psychrometric
|
||||||
|
error grows with the gradient. Applying the vapour-pressure term first is
|
||||||
|
exactly what keeps `offset` a constant rather than a function of CPU load.
|
||||||
|
Clamped for the same reason `k` is: one mistyped reference otherwise biases
|
||||||
|
every reading until you notice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, offset: float = 0.0, off_min: float = -35.0,
|
||||||
|
off_max: float = 35.0, forgetting: float = 0.98,
|
||||||
|
psychrometric: bool = False):
|
||||||
|
self.psychrometric = bool(psychrometric)
|
||||||
|
self.offset = float(offset)
|
||||||
|
self.off_min, self.off_max = float(off_min), float(off_max)
|
||||||
|
self.P = 10.0
|
||||||
|
self.lam = float(forgetting)
|
||||||
|
self.n_calibrations = 0
|
||||||
|
self.last_residual = 0.0
|
||||||
|
|
||||||
|
def _psychrometric(self, rh_sensor: float, t_sensor: float, t_true: float) -> float:
|
||||||
|
if not self.psychrometric:
|
||||||
|
return float(rh_sensor)
|
||||||
|
es_s = float(saturation_vapour_pressure(t_sensor))
|
||||||
|
es_t = float(saturation_vapour_pressure(t_true))
|
||||||
|
if not np.isfinite(es_s) or not np.isfinite(es_t) or es_t <= 1e-9:
|
||||||
|
return float(rh_sensor)
|
||||||
|
return float(rh_sensor * es_s / es_t)
|
||||||
|
|
||||||
|
def compensate(self, rh_sensor: float, t_sensor: float, t_true: float) -> float:
|
||||||
|
if not (np.isfinite(rh_sensor) and np.isfinite(t_sensor) and np.isfinite(t_true)):
|
||||||
|
return float(rh_sensor)
|
||||||
|
base = self._psychrometric(rh_sensor, t_sensor, t_true)
|
||||||
|
return float(np.clip(base + self.offset, 0.0, 100.0))
|
||||||
|
|
||||||
|
def calibrate(self, rh_sensor: float, t_sensor: float, t_true: float,
|
||||||
|
rh_reference: float) -> Dict:
|
||||||
|
"""One RLS step on the residual offset. Regressor is 1."""
|
||||||
|
base = self._psychrometric(rh_sensor, t_sensor, t_true)
|
||||||
|
target = float(rh_reference) - base
|
||||||
|
denom = self.lam + self.P
|
||||||
|
gain = self.P / denom if denom > 1e-12 else 0.0
|
||||||
|
residual = target - self.offset
|
||||||
|
self.offset = float(np.clip(self.offset + gain * residual,
|
||||||
|
self.off_min, self.off_max))
|
||||||
|
self.P = float(np.clip((self.P - gain * self.P) / self.lam, 1e-6, 1e4))
|
||||||
|
self.n_calibrations += 1
|
||||||
|
self.last_residual = float(residual)
|
||||||
|
return {"offset": self.offset, "residual": self.last_residual,
|
||||||
|
"n": self.n_calibrations, "psychrometric": base - float(rh_sensor)}
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
return {"offset": self.offset, "P": self.P, "lam": self.lam,
|
||||||
|
"off_min": self.off_min, "off_max": self.off_max,
|
||||||
|
"n": self.n_calibrations, "psychrometric": self.psychrometric}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: Dict) -> "HumidityCompensator":
|
||||||
|
hc = cls(d["offset"], d["off_min"], d["off_max"], d["lam"],
|
||||||
|
d.get("psychrometric", False))
|
||||||
|
hc.P = d["P"]
|
||||||
|
hc.n_calibrations = d.get("n", 0)
|
||||||
|
return hc
|
||||||
|
|
||||||
|
|
||||||
class SignalTracker:
|
class SignalTracker:
|
||||||
"""Bank of Kalman filters plus the compensator, driven at sample rate."""
|
"""Bank of Kalman filters plus the compensators, driven at sample rate."""
|
||||||
|
|
||||||
def __init__(self, cfg):
|
def __init__(self, cfg):
|
||||||
self.compensator = ThermalCompensator(
|
self.compensator = ThermalCompensator(
|
||||||
cfg.sensor.cpu_heat_k, cfg.sensor.cpu_heat_k_min,
|
cfg.sensor.cpu_heat_k, cfg.sensor.cpu_heat_k_min,
|
||||||
cfg.sensor.cpu_heat_k_max,
|
cfg.sensor.cpu_heat_k_max,
|
||||||
)
|
)
|
||||||
|
self.hum_compensator = HumidityCompensator(
|
||||||
|
cfg.sensor.hum_offset, cfg.sensor.hum_offset_min,
|
||||||
|
cfg.sensor.hum_offset_max,
|
||||||
|
psychrometric=cfg.sensor.hum_psychrometric,
|
||||||
|
)
|
||||||
self.filters = {
|
self.filters = {
|
||||||
"temperature": KalmanCV(cfg.sensor.kalman_q_temp, cfg.sensor.kalman_r_temp),
|
"temperature": KalmanCV(cfg.sensor.kalman_q_temp, cfg.sensor.kalman_r_temp),
|
||||||
"humidity": KalmanCV(cfg.sensor.kalman_q_hum, cfg.sensor.kalman_r_hum),
|
"humidity": KalmanCV(cfg.sensor.kalman_q_hum, cfg.sensor.kalman_r_hum),
|
||||||
@@ -179,12 +272,16 @@ class SignalTracker:
|
|||||||
self.last_ts = ts
|
self.last_ts = ts
|
||||||
|
|
||||||
temp_c = self.compensator.compensate(temp_raw, cpu_temp)
|
temp_c = self.compensator.compensate(temp_raw, cpu_temp)
|
||||||
|
# RH is reported at the element's temperature, not the air's, so it must
|
||||||
|
# be moved onto the compensated temperature before it is filtered.
|
||||||
|
hum_c = self.hum_compensator.compensate(hum, temp_raw, temp_c)
|
||||||
t_lvl, t_rate = self.filters["temperature"].update(temp_c, dt)
|
t_lvl, t_rate = self.filters["temperature"].update(temp_c, dt)
|
||||||
h_lvl, h_rate = self.filters["humidity"].update(hum, dt)
|
h_lvl, h_rate = self.filters["humidity"].update(hum_c, dt)
|
||||||
p_lvl, p_rate = self.filters["pressure"].update(press, dt)
|
p_lvl, p_rate = self.filters["pressure"].update(press, dt)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"temp_c": temp_c,
|
"temp_c": temp_c,
|
||||||
|
"hum_c": hum_c,
|
||||||
"temp_smooth": t_lvl,
|
"temp_smooth": t_lvl,
|
||||||
"temp_rate": t_rate * 3600.0, # C per hour
|
"temp_rate": t_rate * 3600.0, # C per hour
|
||||||
"hum_smooth": h_lvl,
|
"hum_smooth": h_lvl,
|
||||||
@@ -198,12 +295,16 @@ class SignalTracker:
|
|||||||
def to_dict(self) -> Dict:
|
def to_dict(self) -> Dict:
|
||||||
return {
|
return {
|
||||||
"compensator": self.compensator.to_dict(),
|
"compensator": self.compensator.to_dict(),
|
||||||
|
"hum_compensator": self.hum_compensator.to_dict(),
|
||||||
"filters": {k: v.to_dict() for k, v in self.filters.items()},
|
"filters": {k: v.to_dict() for k, v in self.filters.items()},
|
||||||
"last_ts": self.last_ts,
|
"last_ts": self.last_ts,
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_dict(self, d: Dict) -> None:
|
def load_dict(self, d: Dict) -> None:
|
||||||
self.compensator = ThermalCompensator.from_dict(d["compensator"])
|
self.compensator = ThermalCompensator.from_dict(d["compensator"])
|
||||||
|
# Absent from state files written before humidity compensation existed.
|
||||||
|
if d.get("hum_compensator"):
|
||||||
|
self.hum_compensator = HumidityCompensator.from_dict(d["hum_compensator"])
|
||||||
self.filters = {k: KalmanCV.from_dict(v) for k, v in d["filters"].items()}
|
self.filters = {k: KalmanCV.from_dict(v) for k, v in d["filters"].items()}
|
||||||
self.last_ts = d.get("last_ts")
|
self.last_ts = d.get("last_ts")
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ class Station:
|
|||||||
"cloud_index": cloud,
|
"cloud_index": cloud,
|
||||||
"cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")),
|
"cpu_offset": (raw.get("cpu_temp") or float("nan")) - (raw.get("temp_raw") or float("nan")),
|
||||||
"compensator_k": self.tracker.compensator.k,
|
"compensator_k": self.tracker.compensator.k,
|
||||||
|
"hum_offset": self.tracker.hum_compensator.offset,
|
||||||
|
"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 +267,32 @@ class Station:
|
|||||||
f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)")
|
f"k -> {result['k']:.3f} (residual {result['residual']:+.2f} C)")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def calibrate_humidity(self, reference_pct: float) -> Dict:
|
||||||
|
raw_h = self.live.get("hum")
|
||||||
|
raw_t = self.live.get("temp_raw")
|
||||||
|
temp_c = self.live.get("temp_c")
|
||||||
|
if raw_h is None or raw_t is None or temp_c is None:
|
||||||
|
return {"error": "no live reading yet"}
|
||||||
|
result = self.tracker.hum_compensator.calibrate(
|
||||||
|
float(raw_h), float(raw_t), float(temp_c), float(reference_pct))
|
||||||
|
self.save_state()
|
||||||
|
self.store.log_event("calibration", "info",
|
||||||
|
f"rh offset -> {result['offset']:+.2f}% "
|
||||||
|
f"(residual {result['residual']:+.2f}%)")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def reset_humidity_calibration(self) -> Dict:
|
||||||
|
from .estimation import HumidityCompensator
|
||||||
|
self.tracker.hum_compensator = HumidityCompensator(
|
||||||
|
self.cfg.sensor.hum_offset, self.cfg.sensor.hum_offset_min,
|
||||||
|
self.cfg.sensor.hum_offset_max,
|
||||||
|
psychrometric=self.cfg.sensor.hum_psychrometric,
|
||||||
|
)
|
||||||
|
self.save_state()
|
||||||
|
self.store.log_event("calibration", "info",
|
||||||
|
f"rh offset reset to prior {self.cfg.sensor.hum_offset}")
|
||||||
|
return {"offset": self.tracker.hum_compensator.offset, "reset": True, "n": 0}
|
||||||
|
|
||||||
def 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.
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -99,6 +99,27 @@ exact inverse:** `T_raw = (T + k·T_cpu)/(1 + k)`. Generating the bias as
|
|||||||
1.2 °C of phantom noise floor that caps every skill score. This has already
|
1.2 °C of phantom noise floor that caps every skill score. This has already
|
||||||
happened once.
|
happened once.
|
||||||
|
|
||||||
|
### Humidity compensation
|
||||||
|
|
||||||
|
`HumidityCompensator` carries an additive `offset` on relative humidity,
|
||||||
|
estimated from a trusted hygrometer by the same one-step RLS used for `k`, with
|
||||||
|
the regressor fixed at 1 so repeated calibrations converge to a weighted mean.
|
||||||
|
Clamped to +/-35% for the same reason `k` is clamped.
|
||||||
|
|
||||||
|
It also implements a psychrometric term, moving RH from the element's
|
||||||
|
temperature onto the compensated air temperature through conserved vapour
|
||||||
|
pressure, `RH_true = RH_sensor * es(T_sensor) / es(T_true)`. That term is
|
||||||
|
**off by default**, and the reason is worth recording. The thermal argument
|
||||||
|
predicts a hot element reads LOW. Measured against a reference hygrometer this
|
||||||
|
board read 75.4% where the truth was 50.4%, so it reads HIGH by 25 points, and
|
||||||
|
the correction would have pushed it further the wrong way. The dominant error on
|
||||||
|
this hardware is additive element bias, not a thermal gradient.
|
||||||
|
|
||||||
|
If you enable `sensor.hum_psychrometric`, `scripts/simulate.py` applies the exact
|
||||||
|
inverse when generating synthetic humidity. It has to: the same
|
||||||
|
simulator/compensator algebra trap described above for temperature applies here,
|
||||||
|
and getting it wrong bakes in a bias no calibration can remove.
|
||||||
|
|
||||||
### The Kalman bank
|
### The Kalman bank
|
||||||
|
|
||||||
One constant-velocity filter per signal. State `x = [level, rate]`, standard
|
One constant-velocity filter per signal. State `x = [level, rate]`, standard
|
||||||
@@ -302,7 +323,8 @@ consecutive readings are the only tell.
|
|||||||
|
|
||||||
| Symptom | Knob | Direction |
|
| Symptom | Knob | Direction |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Temperature reads consistently high | Calibrate from the Models tab, or `sensor.cpu_heat_k` | Raise |
|
| Temperature reads consistently high | Calibrate from the Models and calibration tab, or `sensor.cpu_heat_k` | Raise |
|
||||||
|
| Humidity reads consistently off | Calibrate against a reference hygrometer, or `sensor.hum_offset` | Either |
|
||||||
| Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise |
|
| Readings over-smoothed, lag real change | `sensor.kalman_q_temp` | Raise |
|
||||||
| Rates look noisy | `sensor.kalman_q_*` down, or `kalman_r_*` up | |
|
| Rates look noisy | `sensor.kalman_q_*` down, or `kalman_r_*` up | |
|
||||||
| NIS persistently much above 1 | Filter too confident, raise `q` | Raise |
|
| NIS persistently much above 1 | Filter too confident, raise `q` | Raise |
|
||||||
|
|||||||
+14
-3
@@ -62,7 +62,8 @@ from ashvale.storage import Store # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
def generate(days: float, step_s: int, lat: float, lon: float,
|
def generate(days: float, step_s: int, lat: float, lon: float,
|
||||||
seed: int = 11, end: float | None = None) -> dict:
|
seed: int = 11, end: float | None = None,
|
||||||
|
psychrometric: bool = False) -> dict:
|
||||||
rng = np.random.default_rng(seed)
|
rng = np.random.default_rng(seed)
|
||||||
n = int(days * 86400 / step_s)
|
n = int(days * 86400 / step_s)
|
||||||
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
|
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
|
||||||
@@ -131,11 +132,21 @@ def generate(days: float, step_s: int, lat: float, lon: float,
|
|||||||
# no amount of calibration can remove, and quietly caps your skill score.
|
# no amount of calibration can remove, and quietly caps your skill score.
|
||||||
k_true = 0.55
|
k_true = 0.55
|
||||||
temp_raw = (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * rng.normal(size=n)
|
temp_raw = (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * rng.normal(size=n)
|
||||||
|
# If the compensator will move RH from the element temperature onto the air
|
||||||
|
# temperature, the forward model here must be its exact inverse, or the
|
||||||
|
# synthetic data bakes in a bias no calibration can remove. Same trap as the
|
||||||
|
# thermal algebra above. Off by default, matching sensor.hum_psychrometric.
|
||||||
|
if psychrometric:
|
||||||
|
es_raw = 6.112 * np.exp(17.625 * temp_raw / (243.04 + temp_raw))
|
||||||
|
rh_sensor = np.clip(rh * es_t / es_raw, 0.0, 100.0)
|
||||||
|
else:
|
||||||
|
rh_sensor = rh
|
||||||
|
|
||||||
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
|
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
|
||||||
press_station += 0.05 * rng.normal(size=n)
|
press_station += 0.05 * rng.normal(size=n)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh + 0.4 * rng.normal(size=n),
|
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh_sensor + 0.4 * rng.normal(size=n),
|
||||||
"press": press_station, "press_slp": press_slp, "cpu": cpu,
|
"press": press_station, "press_slp": press_slp, "cpu": cpu,
|
||||||
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
|
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
|
||||||
}
|
}
|
||||||
@@ -176,7 +187,7 @@ def main() -> None:
|
|||||||
print("cleared existing telemetry, forecasts and scores")
|
print("cleared existing telemetry, forecasts and scores")
|
||||||
|
|
||||||
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
|
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
|
||||||
args.seed, args.end)
|
args.seed, args.end, cfg.sensor.hum_psychrometric)
|
||||||
tracker = SignalTracker(cfg)
|
tracker = SignalTracker(cfg)
|
||||||
|
|
||||||
n = data["ts"].size
|
n = data["ts"].size
|
||||||
|
|||||||
Reference in New Issue
Block a user