Files
ashvale-station/ashvale/dashboard.py
T
kemal 4cca40388f Heated environment: a thermostat member in the forecast ensemble
A room held at a setpoint is a different process from one left to drift. It is
a closed loop, and persistence, the baseline everything here is scored against,
is the wrong statement about it: the truth is not that it stays where it is, it
is that it returns to the setpoint.

So site.heating adds a fourth ensemble member, first order because that is what
a controlled system is:

    dT_set(h) = (T_set - T_now) * (1 - exp(-h / tau))

Humidity follows and is the part that is easy to get wrong. Heating adds no
moisture, so vapour pressure is conserved and not relative humidity:

    RH(h) = RH_now * es(T_now) / es(T_now + dT_set(h))

Warm the air and RH falls although nothing was dried, which is why a heated
house in winter is dry. The test asserts the dew point is unchanged to 1e-6.
Pressure gets zero: a thermostat cannot move the synoptic field.

Offered, not imposed. Hedge scores this member on realised error like any
other, so a wrong tau or a stale setpoint costs accuracy and gets down-weighted
rather than quietly biasing every forecast. Verified: on history with no
heating the ensemble assigned it weight 0.000. With heating off it returns zero
and is identical to persistence.

Going from three members to four means old saved heads must migrate.
from_dict reinitialises weights and member_mae. I missed member_mae first time
and it did not fail on load, it failed later inside learn() on a broadcast
error, which is a much worse place to find out; the migration test now covers
both and calls learn() to prove it.

Settings tab gains the toggle, setpoint and time constant. Turning heating on
or off is treated as a regime change like a door: discontinuity marker plus a
queued retrain.
2026-08-16 16:39:54 +01:00

1493 lines
94 KiB
Python

# 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 dashboard: six tabs in the header, one viewport, no scrolling.
Layout contract. The page is a fixed three-row grid pinned to the
viewport height: header, tab bar, then a content region that takes the
remaining space and never overflows the fold. Each tab lays its panels
out on an internal grid sized in fractions of that region, so nothing
depends on content height. Where a panel genuinely holds more than fits
(the daily records table, the methods prose) that individual panel
scrolls internally while the page frame stays put. Below 1024 px the
constraint is released, because pinning five panels into a phone
viewport produces unreadable eight-pixel type, and a phone user expects
to scroll anyway.
Visual language carries over unchanged from the previous station page:
slate-950 ground, glass panels, Jakarta for prose and JetBrains Mono for
anything numeric. The one new structural device is the tab bar, and it
earns its place. Five distinct questions (what is it doing, what will it
do, what did it do, is the model any good, how does it work) were
previously one long scroll where the important things sat below the
fold.
The signature element is the estimator internals panel on the Live tab.
Most weather dashboards show numbers. This one shows the state estimator
working: self-heating coefficient, Kalman innovation, novelty distance
and drift pressure, all ticking at 2 Hz. It is the part of the system
that is normally invisible, and watching a filter converge is the most
honest possible demonstration that there is real machinery underneath.
"""
DASHBOARD_HTML = r"""
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ashvale Station</title>
<!-- Served from the station, not a CDN, so the dashboard renders on a LAN with
no internet at all. See ashvale/static/. -->
<script src="/static/tailwind.js"></script>
<link rel="stylesheet" href="/static/katex.min.css">
<script defer src="/static/katex.min.js"></script>
<script src="/static/chart.umd.min.js"></script>
<script src="/static/hammer.min.js"></script>
<script src="/static/chartjs-plugin-zoom.min.js"></script>
<link rel="stylesheet" href="/static/gfonts.css">
<style>
body { font-family:'Plus Jakarta Sans',sans-serif; }
.font-mono { font-family:'JetBrains Mono',monospace; }
.glass {
background: radial-gradient(130% 130% at 50% 0%, rgba(30,41,59,.5) 0%, rgba(15,23,42,.75) 100%);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,.08);
box-shadow: 0 10px 30px -10px rgba(0,0,0,.5);
}
.tick { transition: width .5s cubic-bezier(.4,0,.2,1); }
.tabbtn { transition: all .18s ease; }
.tabbtn[aria-selected="true"] {
background: rgba(99,102,241,.16); color:#c7d2fe; border-color: rgba(99,102,241,.4);
}
.pane { display:none; }
.pane.active { display:grid; }
.scroller { overflow-y:auto; scrollbar-width:thin; }
.scroller::-webkit-scrollbar { width:7px; }
.scroller::-webkit-scrollbar-thumb { background:rgba(148,163,184,.28); border-radius:8px; }
.flash { animation: flash .5s ease-out; }
@keyframes flash { from { color:#a5b4fc; } to { color:inherit; } }
:focus-visible { outline:2px solid #818cf8; outline-offset:2px; border-radius:6px; }
@media (prefers-reduced-motion: reduce) { *{animation:none!important;transition:none!important} }
@media (min-width:1024px) {
html,body { height:100%; overflow:hidden; }
#shell { height:100dvh; }
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 antialiased selection:bg-indigo-500 selection:text-white">
<div class="fixed inset-0 pointer-events-none overflow-hidden -z-10">
<div class="absolute -top-32 left-1/4 w-[500px] h-[500px] bg-indigo-600/15 rounded-full blur-[120px]"></div>
<div class="absolute top-1/3 -right-32 w-[500px] h-[500px] bg-emerald-600/10 rounded-full blur-[120px]"></div>
<div class="absolute bottom-10 left-10 w-[400px] h-[400px] bg-amber-600/10 rounded-full blur-[100px]"></div>
</div>
<div id="shell" class="max-w-[1600px] mx-auto px-3 sm:px-5 py-3 grid grid-rows-[auto_1fr] gap-3 min-h-0">
<!-- Tabs live in the header rather than a row of their own. That row cost about
70 px of vertical space on every tab for five buttons, which is a poor
trade on a layout that refuses to scroll. -->
<header class="glass rounded-2xl px-4 py-2 flex flex-wrap items-center gap-x-4 gap-y-2">
<div class="flex items-center gap-3 shrink-0">
<div class="p-2 bg-gradient-to-tr from-indigo-500/20 to-emerald-500/20 border border-white/10 rounded-xl">
<svg class="w-5 h-5 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 00-9.78 2.096A4.001 4.001 0 003 15z"/>
</svg>
</div>
<div>
<h1 class="text-lg font-extrabold tracking-tight leading-none bg-gradient-to-r from-white to-slate-400 bg-clip-text text-transparent">Ashvale Station</h1>
<p class="text-[10px] text-slate-500 font-mono mt-0.5">
<span id="hd-hw">-</span> &middot; <span id="hd-days">0</span> d logged &middot; k=<span id="hd-k">-</span>
</p>
</div>
</div>
<nav role="tablist" class="flex gap-1 flex-1 min-w-0 overflow-x-auto justify-center">
<button role="tab" data-tab="live" aria-selected="true" class="tabbtn shrink-0 px-3 py-1.5 rounded-lg text-[11px] 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-3 py-1.5 rounded-lg text-[11px] 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-3 py-1.5 rounded-lg text-[11px] font-semibold text-slate-400 border border-transparent hover:text-slate-200">Models and Calibration</button>
<button role="tab" data-tab="nerd" aria-selected="false" class="tabbtn shrink-0 px-3 py-1.5 rounded-lg text-[11px] font-semibold text-slate-400 border border-transparent hover:text-slate-200">Stats for Nerds</button>
<button role="tab" data-tab="settings" aria-selected="false" class="tabbtn shrink-0 px-3 py-1.5 rounded-lg text-[11px] font-semibold text-slate-400 border border-transparent hover:text-slate-200">Settings</button>
<button role="tab" data-tab="methods" aria-selected="false" class="tabbtn shrink-0 px-3 py-1.5 rounded-lg text-[11px] font-semibold text-slate-400 border border-transparent hover:text-slate-200">Methods</button>
</nav>
<div class="flex items-center gap-2 font-mono text-[11px] shrink-0">
<span id="hd-health" class="px-2 py-1 rounded-lg border bg-slate-500/15 text-slate-300 border-slate-500/20 uppercase font-semibold">health</span>
<span class="flex items-center gap-1.5 bg-slate-900/80 px-2.5 py-1 rounded-lg border border-slate-800">
<span id="hd-pulse" class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
<span id="hd-time" class="text-white font-semibold">--:--:--</span>
</span>
</div>
</header>
<main class="min-h-0">
<!-- ---------------- 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_auto_1fr_auto]">
<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 p-4 flex flex-col justify-between">
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-amber-400">
<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>Temperature</span>
<span class="text-slate-600 font-mono">KALMAN</span>
</div>
<div class="flex items-baseline gap-1 my-1"><span id="l-temp" class="text-5xl font-extrabold tracking-tight">--</span><span class="text-lg text-amber-400/70 font-semibold">&deg;C</span></div>
<div class="font-mono text-[10px] text-slate-500 space-y-0.5">
<div class="flex justify-between"><span>rate</span><span id="l-temp-rate" class="text-amber-300">--</span></div>
<div class="flex justify-between"><span>raw / cpu</span><span id="l-temp-raw" class="text-slate-400">--</span></div>
</div>
<!-- Chart.js with maintainAspectRatio:false fills its parent, so a
sparkline needs an explicitly sized relative wrapper or it eats the card. -->
<div class="h-9 mt-1.5 shrink-0 relative"><canvas id="spark-temp"></canvas></div>
</div>
<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-cyan-400">
<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-cyan-400"></span>Humidity</span>
<span class="text-slate-600 font-mono">HTS221</span>
</div>
<div class="flex items-baseline gap-1 my-1"><span id="l-hum" class="text-5xl font-extrabold tracking-tight">--</span><span class="text-lg text-cyan-400/70 font-semibold">%</span></div>
<div class="font-mono text-[10px] text-slate-500 space-y-0.5">
<div class="flex justify-between"><span>dew point</span><span id="l-dew" class="text-cyan-300">--</span></div>
<div class="flex justify-between"><span>depression</span><span id="l-dep" class="text-slate-400">--</span></div>
</div>
<!-- Chart.js with maintainAspectRatio:false fills its parent, so a
sparkline needs an explicitly sized relative wrapper or it eats the card. -->
<div class="h-9 mt-1.5 shrink-0 relative"><canvas id="spark-hum"></canvas></div>
</div>
<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-violet-400">
<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-violet-400"></span>Barometer</span>
<span class="text-slate-600 font-mono">MSL</span>
</div>
<div class="flex items-baseline gap-1 my-1"><span id="l-press" class="text-5xl font-extrabold tracking-tight">--</span><span class="text-lg text-violet-400/70 font-semibold">hPa</span></div>
<div class="font-mono text-[10px] text-slate-500 space-y-0.5">
<div class="flex justify-between"><span>tendency</span><span id="l-press-rate" class="text-violet-300">--</span></div>
<div class="flex justify-between"><span>character</span><span id="l-press-char" class="text-slate-400 truncate ml-2">--</span></div>
</div>
<!-- Chart.js with maintainAspectRatio:false fills its parent, so a
sparkline needs an explicitly sized relative wrapper or it eats the card. -->
<div class="h-9 mt-1.5 shrink-0 relative"><canvas id="spark-press"></canvas></div>
</div>
<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-emerald-400">
<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>Sky</span>
<div id="l-swatch" class="w-3 h-3 rounded-full border border-white/30"></div>
</div>
<div class="flex items-baseline gap-1 my-1"><span id="l-lux" class="text-5xl font-extrabold tracking-tight">--</span><span class="text-lg text-emerald-400/70 font-semibold">clr</span></div>
<div class="font-mono text-[10px] text-slate-500 space-y-0.5">
<div class="flex justify-between"><span>cloud index</span><span id="l-cloud" class="text-emerald-300">--</span></div>
<div class="flex justify-between"><span>sun / cct</span><span id="l-sun" class="text-slate-400">--</span></div>
</div>
<!-- Chart.js with maintainAspectRatio:false fills its parent, so a
sparkline needs an explicitly sized relative wrapper or it eats the card. -->
<div class="h-9 mt-1.5 shrink-0 relative"><canvas id="spark-lux"></canvas></div>
</div>
<div class="glass rounded-2xl p-4 lg:col-span-3 flex flex-col min-h-0">
<div class="flex flex-wrap items-center justify-between gap-2 pb-2 mb-2 border-b border-slate-800 shrink-0">
<div class="flex items-center gap-2">
<h2 class="text-sm font-bold">Observed and forecast</h2>
<span class="px-1.5 py-0.5 text-[9px] font-mono rounded bg-indigo-500/10 text-indigo-300 border border-indigo-500/20 uppercase">90% band</span>
</div>
<div class="flex items-center gap-1.5">
<div class="flex gap-1" id="live-span">
<button data-min="60" class="px-2 py-1 rounded-lg text-[10px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">1 h</button>
<button data-min="360" class="px-2 py-1 rounded-lg text-[10px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">6 h</button>
<button data-min="1440" class="px-2 py-1 rounded-lg text-[10px] font-mono border border-slate-800 bg-indigo-600/20 text-indigo-300">24 h</button>
</div>
<select id="fc-target" class="bg-slate-900/90 border border-slate-800 text-slate-300 text-[11px] font-mono rounded-lg px-2 py-1">
<option value="temperature">temperature</option><option value="humidity">humidity</option><option value="pressure">pressure</option>
</select>
<button id="fc-reset" class="px-2.5 py-1 rounded-lg text-[11px] font-mono bg-slate-800/60 text-slate-300 border border-slate-700 hover:bg-slate-800">Reset</button>
</div>
</div>
<div class="flex-1 min-h-0 relative"><canvas id="fanChart"></canvas></div>
<div id="fc-table" class="shrink-0 mt-2 pt-2 border-t border-slate-800 grid grid-cols-3 sm:grid-cols-6 gap-2 font-mono text-[10px]"></div>
</div>
<div class="glass rounded-2xl p-4 flex flex-col min-h-0">
<div class="pb-2 mb-2 border-b border-slate-800 shrink-0">
<h2 class="text-sm font-bold">Conditions ahead</h2>
<p class="text-[10px] text-indigo-400 font-mono">Zambretti prior + online logistic</p>
</div>
<div class="flex-1 min-h-0 space-y-2 pr-1">
<div class="text-center">
<div id="c-icon" class="flex justify-center mb-0.5"></div>
<div id="c-label" class="text-base font-extrabold leading-tight">--</div>
<div id="c-sky" class="text-[10px] text-slate-400 font-medium">--</div>
<div class="text-[10px] text-slate-500 font-mono mt-0.5">Z=<span id="c-z">-</span> &middot; <span id="c-trend">-</span></div>
</div>
<div>
<div class="flex justify-between items-baseline mb-1"><span class="text-[10px] text-slate-500 font-mono">rain probability</span><span id="c-pct" class="text-base font-bold text-cyan-300 font-mono">--</span></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>
<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="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="0" class="rain-label flex-1 px-2 py-1.5 rounded-lg bg-slate-800/60 hover:bg-slate-800 text-slate-300 border border-slate-700 text-[11px] font-medium">No, dry</button>
</div>
<div id="c-labstat" class="text-[9px] text-slate-600 font-mono"><span id="c-labn">0</span> confirmed observations</div>
</div>
<div>
<div class="flex justify-between items-baseline mb-1">
<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>
</div>
<div class="h-20 relative"><canvas id="tendChart"></canvas></div>
<p class="text-[9px] text-slate-600 font-mono mt-0.5 leading-none">Slope, not level, drives the forecast.</p>
</div>
</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><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">abs hum</div><div id="d-ah" class="text-slate-200 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">heat idx</div><div id="d-hi" class="text-slate-200 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">solar el</div><div id="d-el" class="text-slate-200 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">pitch</div><div id="d-pitch" class="text-amber-300 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">roll</div><div id="d-roll" class="text-cyan-300 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">yaw</div><div id="d-yaw" class="text-indigo-300 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">compass</div><div id="d-comp" class="text-emerald-300 font-semibold">--</div></div>
<div><div class="text-slate-600 uppercase">accel z</div><div id="d-az" class="text-slate-200 font-semibold">--</div></div>
</div>
</section>
<!-- ---------------- 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]">
<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">
<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="24" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 bg-indigo-600/20 text-indigo-300">24 h</button>
<button data-h="168" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">7 d</button>
<button data-h="720" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">30 d</button>
<button data-h="2160" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">90 d</button>
<button data-h="8760" class="px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200">1 y</button>
</div>
<div class="flex items-end gap-2">
<label class="block"><span class="block text-[9px] text-slate-600 font-mono uppercase mb-0.5">from</span>
<input id="h-from" type="datetime-local" class="bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-[11px] font-mono text-white"></label>
<label class="block"><span class="block text-[9px] text-slate-600 font-mono uppercase mb-0.5">to</span>
<input id="h-to" type="datetime-local" class="bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-[11px] font-mono text-white"></label>
<button id="h-apply" class="px-3 py-1.5 rounded-lg text-[11px] font-medium bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/30">Apply range</button>
</div>
<div class="flex items-center gap-2 ml-auto">
<div id="h-series" class="flex gap-1"></div>
<a id="h-csv" href="#" class="px-3 py-1.5 rounded-lg text-[11px] font-medium bg-slate-800/60 hover:bg-slate-800 text-slate-300 border border-slate-700">Export CSV</a>
</div>
<p id="h-meta" class="w-full text-[10px] text-slate-600 font-mono">-</p>
</div>
<div class="glass rounded-2xl p-4 lg:col-span-3 flex flex-col min-h-0">
<div class="flex-1 min-h-0 relative"><canvas id="histChart"></canvas></div>
</div>
<div class="glass rounded-2xl p-4 flex flex-col min-h-0">
<div class="pb-2 mb-2 border-b border-slate-800 shrink-0 flex items-center justify-between">
<h2 class="text-sm font-bold">Records</h2>
<div class="flex gap-1">
<button id="rec-tab-daily" class="px-2 py-0.5 rounded text-[10px] font-mono border bg-indigo-600/20 text-indigo-300 border-indigo-500/30">daily</button>
<button id="rec-tab-all" class="px-2 py-0.5 rounded text-[10px] font-mono border border-slate-800 text-slate-500">all time</button>
</div>
</div>
<div id="rec-daily" class="flex-1 min-h-0 scroller pr-1"></div>
<div id="rec-all" class="flex-1 min-h-0 scroller pr-1 hidden space-y-1.5"></div>
</div>
</section>
<!-- ---------------- MODELS AND CALIBRATION ---------------- -->
<!-- Four rows, all sized by content except the log, which absorbs the slack.
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>
<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 &middot; p/c/l is the ensemble weight</p>
</div>
<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-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 id="m-score" class="grid grid-cols-1 md:grid-cols-3 gap-3"></div>
<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>
</div>
<div class="glass rounded-2xl p-4 flex flex-col">
<div class="pb-2 mb-2 border-b border-slate-800 flex items-baseline justify-between">
<h2 class="text-sm font-bold">Temperature</h2>
<span class="text-[9px] font-mono text-slate-600 uppercase">self-heating</span>
</div>
<div class="font-mono text-[10px] space-y-1.5">
<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 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">
<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>
</div>
<div id="m-calstat" class="text-[9px] text-slate-600 leading-snug">Enter a trusted thermometer reading in &deg;C. One good reading is enough.</div>
</div>
</div>
<div class="glass rounded-2xl p-4 flex flex-col">
<div class="pb-2 mb-2 border-b border-slate-800 flex items-baseline justify-between">
<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&sup2;</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>
</section>
<!-- ---------------- STATS FOR NERDS ---------------- -->
<!-- Everything the estimator and the 18 learners are actually carrying, read
straight off the live objects. No internal scrollers: the head bank is a
fixed 18 rows and the attribution list is capped at what fits. -->
<section id="pane-nerd" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-4 lg:grid-rows-[auto_1fr_auto]">
<div class="glass rounded-2xl p-3 lg:col-span-3">
<div class="flex items-baseline justify-between mb-1.5">
<h2 class="text-sm font-bold">Kalman bank</h2>
<span class="text-[9px] font-mono text-slate-600 uppercase">NIS near 1 means honestly tuned</span>
</div>
<div id="n-filters" class="grid grid-cols-1 sm:grid-cols-3 gap-2 font-mono text-[10px]"></div>
</div>
<div class="glass rounded-2xl p-3">
<h2 class="text-sm font-bold mb-1.5">Compensators</h2>
<div id="n-comp" class="font-mono text-[10px] space-y-1"></div>
</div>
<div class="glass rounded-2xl p-3 lg:col-span-2 flex flex-col min-h-0">
<div class="flex items-baseline justify-between mb-1.5 shrink-0">
<h2 class="text-sm font-bold">Learner bank</h2>
<span class="text-[9px] font-mono text-slate-600 uppercase">18 independent RLS heads</span>
</div>
<div id="n-heads" class="flex-1 min-h-0"></div>
</div>
<div class="glass rounded-2xl p-3 flex flex-col min-h-0">
<div class="flex items-baseline justify-between mb-1.5 shrink-0">
<h2 class="text-sm font-bold">Feature attribution</h2>
<select id="n-head-sel" class="bg-slate-900/90 border border-slate-800 text-slate-300 text-[10px] font-mono rounded px-1.5 py-0.5"></select>
</div>
<p class="text-[9px] text-slate-600 font-mono mb-1 shrink-0">largest |&theta;| after standardisation, so these are comparable</p>
<div id="n-theta" class="flex-1 min-h-0 font-mono text-[10px] space-y-0.5"></div>
</div>
<div class="glass rounded-2xl p-3 flex flex-col min-h-0 gap-2">
<div>
<h2 class="text-sm font-bold mb-1">Detectors</h2>
<div id="n-mon" class="font-mono text-[10px] space-y-1"></div>
</div>
<div>
<h2 class="text-sm font-bold mb-1">Climatology</h2>
<div id="n-clim" class="font-mono text-[10px] space-y-0.5"></div>
</div>
<div>
<h2 class="text-sm font-bold mb-1">Precipitation</h2>
<div id="n-precip" class="font-mono text-[10px] space-y-0.5"></div>
</div>
</div>
<div class="glass rounded-2xl p-3 lg:col-span-4 shrink-0">
<div class="flex items-baseline justify-between mb-1.5">
<h2 class="text-sm font-bold">Reliability</h2>
<span class="text-[9px] font-mono text-slate-600 uppercase">realised coverage against the 90% nominal</span>
</div>
<div id="n-rel" class="grid grid-cols-3 sm:grid-cols-6 gap-x-3 gap-y-0.5 font-mono text-[9px]"></div>
</div>
</section>
<!-- ---------------- SETTINGS ---------------- -->
<section id="pane-settings" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-3 lg:grid-rows-[auto_1fr]">
<div class="glass rounded-2xl p-4 lg:col-span-2 flex flex-col">
<div class="flex items-baseline justify-between pb-2 mb-2 border-b border-slate-800">
<div>
<h2 class="text-sm font-bold">Surroundings</h2>
<p class="text-[10px] text-slate-500 font-mono">a change here marks a discontinuity and queues a retrain</p>
</div>
<span id="s-env-state" class="text-[10px] font-mono text-emerald-300">--</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div class="text-[10px] text-slate-600 font-mono uppercase mb-1">where it lives</div>
<div id="s-environment" class="flex gap-1 flex-wrap"></div>
<p class="text-[9px] text-slate-600 leading-snug mt-1.5">Indoors the building governs temperature and humidity, not the sky. Pressure passes through walls, which is why precipitation runs on tendency.</p>
</div>
<div>
<div class="text-[10px] text-slate-600 font-mono uppercase mb-1">enclosure</div>
<div id="s-enclosure" class="flex gap-1 flex-wrap"></div>
<p class="text-[9px] text-slate-600 leading-snug mt-1.5">Closing a door changes how strongly the sensor couples to outside. The heads carry about 55 hours of memory, so tell them rather than waiting two days.</p>
</div>
</div>
<div class="mt-3 pt-3 border-t border-slate-800">
<div class="flex items-center justify-between mb-1.5">
<div>
<span class="text-[11px] font-semibold text-slate-300">Heated or cooled to a setpoint</span>
<p class="text-[9px] text-slate-600 leading-snug">A thermostat makes the room a closed loop: it returns to the setpoint instead of drifting. Persistence is the wrong baseline for that, so this adds a fourth ensemble member and lets the Hedge weights decide if it earns its place. Humidity follows at constant vapour pressure, which is why a heated house is dry.</p>
</div>
<button id="s-heat" class="shrink-0 ml-3 px-2.5 py-1 rounded-lg border text-[10px] font-mono">--</button>
</div>
<div class="grid grid-cols-2 gap-2 font-mono text-[10px]">
<label class="block"><span class="text-slate-600 uppercase text-[9px]">setpoint &deg;C</span>
<input id="s-setpoint" type="number" step="0.5" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
<label class="block"><span class="text-slate-600 uppercase text-[9px]">time constant h</span>
<input id="s-tau" type="number" step="0.1" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
</div>
</div>
<div class="flex gap-2 mt-3">
<input id="s-note" placeholder="what changed, e.g. doors shut, felt chilly"
class="flex-1 min-w-0 bg-slate-950/70 border border-slate-800 rounded-lg px-2.5 py-1.5 text-[11px] font-mono text-white">
<button id="s-env-apply" class="px-3 py-1.5 rounded-lg text-[11px] font-mono bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30">Record change</button>
</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">Matrix</h2></div>
<div class="font-mono text-[10px] space-y-2.5">
<div class="flex items-center justify-between">
<span class="text-slate-400">8x8 display</span>
<button id="s-led" class="px-2.5 py-1 rounded-lg border text-[10px]">--</button>
</div>
<div>
<div class="flex justify-between text-slate-400 mb-1"><span>frame rate</span><span id="s-fps-val" class="text-indigo-300 font-bold">--</span></div>
<input id="s-fps" type="range" min="4" max="30" step="1" class="w-full accent-indigo-500">
<p class="text-[9px] text-slate-600 leading-snug mt-1">24 costs about 11% of one core. 16 is still fluid and a third cheaper. Below 12 the crossfades judder.</p>
</div>
</div>
</div>
<div class="glass rounded-2xl p-4 lg:col-span-2 flex flex-col min-h-0">
<div class="pb-2 mb-2 border-b border-slate-800 shrink-0">
<h2 class="text-sm font-bold">Site and model</h2>
<p class="text-[10px] text-slate-500 font-mono">altitude feeds the sea-level reduction on every row</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2.5 font-mono text-[10px]">
<label class="block"><span class="text-slate-600 uppercase text-[9px]">altitude m</span>
<input id="s-alt" type="number" step="0.5" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
<label class="block"><span class="text-slate-600 uppercase text-[9px]">latitude</span>
<input id="s-lat" type="number" step="0.0001" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
<label class="block"><span class="text-slate-600 uppercase text-[9px]">longitude</span>
<input id="s-lon" type="number" step="0.0001" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
<div class="flex items-end">
<button id="s-site-apply" class="w-full px-2 py-1.5 rounded-lg text-[10px] bg-slate-800/60 hover:bg-slate-800 text-slate-200 border border-slate-700">Apply</button>
</div>
</div>
<div class="mt-3 pt-3 border-t border-slate-800 flex items-start justify-between gap-3">
<div class="flex-1">
<div class="text-[11px] font-semibold text-slate-300">Psychrometric humidity correction</div>
<p class="text-[9px] text-slate-600 leading-snug mt-0.5">Moves RH from the element's temperature onto air temperature. Off by default: measured against a reference this board read 75.4% where the truth was 50.4%, so it reads high and this would push it higher.</p>
</div>
<button id="s-psy" class="shrink-0 px-2.5 py-1 rounded-lg border text-[10px] font-mono">--</button>
</div>
<div id="s-msg" class="mt-auto pt-2 text-[10px] font-mono text-slate-500"></div>
</div>
<div class="glass rounded-2xl p-4 flex flex-col min-h-0">
<div class="pb-2 mb-2 border-b border-slate-800 shrink-0"><h2 class="text-sm font-bold">Maintenance</h2></div>
<div class="space-y-2 font-mono text-[10px]">
<button id="s-recompute" class="w-full px-2 py-2 rounded-lg bg-amber-600/15 hover:bg-amber-600/25 text-amber-300 border border-amber-500/30 text-left">
<div class="font-semibold">Re-derive history</div>
<div class="text-[9px] text-amber-200/60 leading-snug">Recompute every stored row from the raw values with the current calibration. Removes the step a calibration leaves behind.</div>
</button>
<button id="s-retrain" class="w-full px-2 py-2 rounded-lg bg-indigo-600/15 hover:bg-indigo-600/25 text-indigo-300 border border-indigo-500/30 text-left">
<div class="font-semibold">Retrain now</div>
<div class="text-[9px] text-indigo-200/60 leading-snug">Refit all 18 heads. 60 to 100 s on a Zero 2 W, in a worker thread.</div>
</button>
<button id="s-verify" class="w-full px-2 py-2 rounded-lg bg-slate-800/60 hover:bg-slate-800 text-slate-300 border border-slate-700 text-left">
<div class="font-semibold">Score now</div>
<div class="text-[9px] text-slate-500 leading-snug">Verify matured forecasts against persistence.</div>
</button>
<div id="s-maint" class="text-[9px] text-slate-600 pt-1"></div>
</div>
</div>
</section>
<!-- ---------------- METHODS ---------------- -->
<section id="pane-methods" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-5">
<div class="glass rounded-2xl p-4 lg:col-span-2 flex flex-col min-h-0">
<div class="pb-2 mb-2 border-b border-slate-800 shrink-0">
<h2 class="text-sm font-bold">How it is wired</h2>
<p class="text-[10px] text-slate-500 font-mono">select a stage to read its rationale</p>
</div>
<div class="flex-1 min-h-0 scroller pr-1"><div id="me-diagram"></div></div>
</div>
<div class="glass rounded-2xl p-4 lg:col-span-3 flex flex-col min-h-0">
<div id="me-head" class="pb-2 mb-2 border-b border-slate-800 shrink-0"></div>
<div id="me-body" class="flex-1 min-h-0 scroller pr-1 space-y-3"></div>
</div>
</section>
</main>
</div>
<script>
const el = (id) => document.getElementById(id);
let lastDerived = {};
const esc = (t) => String(t).replace(/&/g,'&amp;').replace(/"/g,'&quot;')
.replace(/</g,'&lt;').replace(/>/g,'&gt;');
// KaTeX renders after the pane is populated, replacing the element's contents.
// The raw TeX is written into the element first so an unreachable CDN degrades
// to ugly-but-readable source rather than six blank boxes. Verified by aborting
// the katex request in a browser test.
function typeset(root) {
if (typeof katex === 'undefined') return;
(root||document).querySelectorAll('.tex[data-tex],.tex-inline[data-tex]').forEach(n => {
if (n.dataset.done) return;
const display = n.classList.contains('tex');
try { katex.render(n.dataset.tex, n, {displayMode:display, throwOnError:false,
output:'html', trust:false}); n.dataset.done='1'; }
catch (e) { n.textContent = n.dataset.tex; }
});
}
const fmt = (v,d=1) => (v===null||v===undefined||Number.isNaN(v)) ? '--' : Number(v).toFixed(d);
const SEV = { info:'text-slate-400', warn:'text-amber-300', error:'text-rose-300' };
const tsFmt = (t) => new Date(t*1000).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
const GRID = 'rgba(255,255,255,.04)';
const MONO = { family:'JetBrains Mono', size:10 };
const charts = {}, loaders = {};
let activeTab = 'live';
document.querySelectorAll('[role=tab]').forEach(b => b.addEventListener('click', () => selectTab(b.dataset.tab)));
function selectTab(name) {
activeTab = name;
document.querySelectorAll('[role=tab]').forEach(b => b.setAttribute('aria-selected', String(b.dataset.tab===name)));
document.querySelectorAll('.pane').forEach(p => p.classList.toggle('active', p.id==='pane-'+name));
if (loaders[name]) loaders[name]();
// Chart.js cannot measure a canvas inside display:none, so resize on reveal.
setTimeout(() => Object.values(charts).forEach(c => c && c.resize()), 40);
}
/* ---------------- LIVE ---------------- */
const sparks = {};
function makeSpark(id, colour) {
return new Chart(el(id).getContext('2d'), {
type:'line',
data:{ labels:[], datasets:[{ data:[], borderColor:colour, borderWidth:1.5, pointRadius:0, tension:.35, fill:false }] },
options:{ responsive:true, maintainAspectRatio:false, animation:false,
scales:{ x:{display:false}, y:{display:false} },
plugins:{ legend:{display:false}, tooltip:{enabled:false} } }
});
}
['temp','hum','press','lux'].forEach((k,i) =>
sparks[k] = makeSpark('spark-'+k, ['#f59e0b','#06b6d4','#a78bfa','#34d399'][i]));
function pushSpark(k,v) {
if (v===null || v===undefined) return;
const d = sparks[k].data;
d.labels.push(''); d.datasets[0].data.push(v);
if (d.labels.length > 90) { d.labels.shift(); d.datasets[0].data.shift(); }
sparks[k].update('none');
}
// The Live tab now carries the forecast chart, which already plots observed
// history alongside the prediction, so a separate rolling-window chart would
// only duplicate its left half. These buttons resize the observed portion.
let liveMin = 1440;
el('live-span').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
liveMin = Number(b.dataset.min);
[...el('live-span').children].forEach(x => x.className =
'px-2 py-1 rounded-lg text-[10px] font-mono border border-slate-800 ' +
(x===b ? 'bg-indigo-600/20 text-indigo-300' : 'text-slate-400 hover:text-slate-200'));
loadForecast();
});
loaders.live = () => { loadForecast(); loadOutlook(); };
function setFlash(id,val) {
const node = el(id); if (!node || node.innerText===val) return;
node.innerText = val; node.classList.remove('flash'); void node.offsetWidth; node.classList.add('flash');
}
function applyTelemetry(d) {
if (!d) return;
el('hd-time').innerText = d.timestamp || '--:--:--';
el('hd-hw').innerText = d.simulated ? 'simulator' : 'sense hat v2';
el('hd-k').innerText = fmt(d.compensator_k,3);
setFlash('l-temp', fmt(d.temperature,2));
setFlash('l-hum', fmt(d.humidity,1));
setFlash('l-press', fmt(d.pressure,1));
setFlash('l-lux', d.color ? String(d.color.clear) : '--');
const rt = d.rates||{}, dv = d.derived||{};
// Kept module-level so the precipitation panel can pick an icon from what
// the sensors actually see, not just from the barometric class.
lastDerived = Object.assign({}, dv, {temperature: d.temperature});
el('l-temp-rate').innerText = (rt.temperature_c_per_h>=0?'+':'')+fmt(rt.temperature_c_per_h,2)+' \u00b0C/h';
el('l-press-rate').innerText = (rt.pressure_hpa_per_h>=0?'+':'')+fmt(rt.pressure_hpa_per_h,2)+' hPa/h';
el('l-temp-raw').innerText = fmt(d.temperature_raw,1)+' / '+fmt(d.cpu_temp,0)+'\u00b0';
el('l-dew').innerText = fmt(dv.dew_point,1)+' \u00b0C';
el('l-dep').innerText = fmt(dv.dew_depression,1)+' K';
el('l-cloud').innerText = fmt(dv.cloud_index,2);
el('l-sun').innerText = fmt(dv.solar_elevation,0)+'\u00b0 / '+(d.color&&d.color.cct?Math.round(d.color.cct)+'K':'n/a');
if (d.color && d.color.hex) el('l-swatch').style.backgroundColor = d.color.hex;
el('d-wb').innerText = fmt(dv.wet_bulb,1)+'\u00b0';
el('d-vpd').innerText = fmt(dv.vpd_hpa,2);
el('d-ah').innerText = fmt(dv.absolute_humidity_g_m3,1);
el('d-hi').innerText = fmt(dv.heat_index,1)+'\u00b0';
el('d-el').innerText = fmt(dv.solar_elevation,0)+'\u00b0';
el('d-pitch').innerText = fmt(d.pitch,1)+'\u00b0';
el('d-roll').innerText = fmt(d.roll,1)+'\u00b0';
el('d-yaw').innerText = fmt(d.yaw,1)+'\u00b0';
el('d-comp').innerText = fmt(d.compass,1)+'\u00b0';
el('d-az').innerText = fmt(d.accel && d.accel.z,2);
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-nov').innerText = fmt(d.novelty_d2,1);
el('e-nov-bar').style.width = Math.min((d.novelty_d2||0)/24,1)*100+'%';
pushSpark('temp', d.temperature); pushSpark('hum', d.humidity);
pushSpark('press', d.pressure); pushSpark('lux', d.color && d.color.clear);
}
function applyPrecip(p) {
if (!p || p.rain_probability===undefined) return;
el('c-label').innerText = p.label||'--';
// The barometer gives the class; the light sensor, sun angle and thermometer
// decide which glyph honestly represents it.
const wx = wxPick(p.condition, lastDerived.cloud_index, lastDerived.solar_elevation,
lastDerived.temperature, p.rain_probability);
el('c-icon').innerHTML = wxSvg(wx[0], 44);
el('c-sky').innerText = wx[1];
el('c-z').innerText = p.zambretti_z!==undefined ? p.zambretti_z : '-';
el('c-trend').innerText = p.pressure_characteristic||'-';
if (p.tendency!==undefined) el('c-tend').innerText = (p.tendency>=0?'+':'')+fmt(p.tendency,2)+' hPa/h';
el('l-press-char').innerText = p.pressure_characteristic||'--';
const pct = Math.round(p.rain_probability*100);
el('c-pct').innerText = pct+'%'; el('c-bar').style.width = pct+'%';
el('c-prior').innerText = Math.round((p.prior_probability||0)*100)+'%';
el('c-model').innerText = Math.round((p.model_probability||0)*100)+'%';
el('c-trust').innerText = Math.round((p.learner_trust||0)*100)+'%';
el('c-labn').innerText = p.strong_labels||0;
}
function connectStream() {
const es = new EventSource('/api/stream');
es.onmessage = ev => {
const d = JSON.parse(ev.data);
applyTelemetry(d.telemetry); applyPrecip(d.precipitation);
const dr = Math.round((d.drift_stress||0)*100);
el('e-drift').innerText = dr+'%'; el('e-drift-bar').style.width = dr+'%';
const b = el('hd-health'); b.innerText = d.health||'unknown';
b.className = 'px-2 py-1 rounded-lg border uppercase font-semibold ' + (
d.health==='ok' ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/20'
: d.health==='warn' ? 'bg-amber-500/15 text-amber-300 border-amber-500/20'
: 'bg-rose-500/15 text-rose-300 border-rose-500/20');
el('hd-pulse').className = 'w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse';
};
es.onerror = () => { el('hd-pulse').className='w-1.5 h-1.5 rounded-full bg-rose-500'; es.close(); setTimeout(connectStream,5000); };
}
/* ---------------- FORECAST ---------------- */
charts.fan = new Chart(el('fanChart').getContext('2d'), {
type:'line',
data:{ datasets:[
{ label:'observed', data:[], borderColor:'#f59e0b', backgroundColor:'rgba(245,158,11,.12)',
fill:true, borderWidth:2, pointRadius:0, tension:.3, parsing:false },
{ label:'upper', data:[], borderColor:'rgba(99,102,241,.25)', backgroundColor:'rgba(99,102,241,.14)',
borderWidth:1, pointRadius:0, tension:.3, fill:'+1', parsing:false },
{ label:'lower', data:[], borderColor:'rgba(99,102,241,.25)', borderWidth:1,
pointRadius:0, tension:.3, fill:false, parsing:false },
{ label:'forecast', data:[], borderColor:'#818cf8', borderDash:[6,4], borderWidth:2,
pointRadius:3, pointBackgroundColor:'#818cf8', tension:.3, parsing:false } ]},
options:{ responsive:true, maintainAspectRatio:false, animation:false,
interaction:{ mode:'nearest', axis:'x', intersect:false },
scales:{ x:{ type:'linear', grid:{color:GRID}, ticks:{ color:'#64748b', font:MONO, maxTicksLimit:7,
callback:v=>new Date(v*1000).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) } },
y:{ grid:{color:GRID}, ticks:{ color:'#cbd5e1', font:MONO } } },
plugins:{ legend:{display:false},
tooltip:{ backgroundColor:'rgba(15,23,42,.95)', borderColor:'rgba(255,255,255,.1)', borderWidth:1,
filter:i=>i.dataset.label!=='lower', titleFont:MONO, callbacks:{ title:i=>tsFmt(i[0].parsed.x) } },
zoom:{ pan:{enabled:true,mode:'xy'}, zoom:{ wheel:{enabled:true,speed:.08}, pinch:{enabled:true}, mode:'xy' } } } }
});
charts.tend = new Chart(el('tendChart').getContext('2d'), {
type:'line',
data:{ datasets:[{ data:[], borderColor:'#a78bfa', backgroundColor:'rgba(167,139,250,.12)',
fill:true, borderWidth:1.6, pointRadius:0, tension:.3, parsing:false }] },
options:{ responsive:true, maintainAspectRatio:false, animation:false,
// Chart.js lays the tick row out inside the canvas. At the old 56 px there
// was not enough of it left, so the labels crowded the edge and the caption
// spilled past the card. The layout padding reserves that strip explicitly.
layout:{ padding:{ bottom:2, left:2, right:4 } },
scales:{ x:{ type:'linear', grid:{display:false},
ticks:{ color:'#64748b', font:{family:'JetBrains Mono',size:9}, maxTicksLimit:4,
maxRotation:0, minRotation:0, padding:2,
// Hour-only labels collapse to three identical ticks when the window is
// short, which happens on a young station. Add minutes below six hours.
callback:function (v) {
const sc = this.chart.scales.x;
const span = (sc.max - sc.min) || 0;
const opts = span > 6 * 3600
? {hour: '2-digit'}
: {hour: '2-digit', minute: '2-digit'};
return new Date(v * 1000).toLocaleTimeString([], opts);
} } },
y:{ grid:{color:GRID}, ticks:{ color:'#a78bfa', font:{family:'JetBrains Mono',size:9}, maxTicksLimit:4, padding:2, callback:v=>v.toFixed(0) } } },
plugins:{ legend:{display:false}, tooltip:{ backgroundColor:'rgba(15,23,42,.95)', titleFont:MONO,
callbacks:{ title:i=>tsFmt(i[0].parsed.x) } } } }
});
el('fc-reset').addEventListener('click', ()=>charts.fan.resetZoom());
el('fc-target').addEventListener('change', loadForecast);
async function loadForecast() {
const target = el('fc-target').value;
const key = {temperature:'temp', humidity:'hum', pressure:'press'}[target];
const res = await Promise.all([
fetch('/api/history/range?hours='+(liveMin/60)).then(r=>r.json()),
fetch('/api/forecast').then(r=>r.json()) ]);
const hist = res[0], fc = res[1], s = hist.series||{};
charts.fan.data.datasets[0].data = (s.ts||[]).map((t,i)=>({x:t,y:s[key][i]})).filter(p=>p.y!=null);
// the pressure trace is the same fetch, reused: one request, two panels
charts.tend.data.datasets[0].data = (s.ts||[]).map((t,i)=>({x:t,y:s.press[i]})).filter(p=>p.y!=null);
charts.tend.update('none');
const series = (fc.targets&&fc.targets[target])||[];
const anchor = fc.anchors ? fc.anchors[target] : undefined;
const head = (anchor!==undefined && fc.issued_ts) ? [{x:fc.issued_ts,y:anchor}] : [];
charts.fan.data.datasets[3].data = head.concat(series.map(p=>({x:p.valid_ts,y:p.mu})));
charts.fan.data.datasets[1].data = head.concat(series.map(p=>({x:p.valid_ts,y:p.hi})));
charts.fan.data.datasets[2].data = head.concat(series.map(p=>({x:p.valid_ts,y:p.lo})));
charts.fan.update('none');
el('fc-table').innerHTML = series.length ? series.map(p=>
'<div class="bg-slate-900/60 rounded-lg border border-slate-800/70 px-2 py-1.5">'+
'<div class="text-slate-600 uppercase text-[9px]">'+p.horizon_label+'</div>'+
'<div class="text-slate-100 font-bold text-xs">'+p.mu.toFixed(1)+'</div>'+
'<div class="text-indigo-400/80 text-[9px]">&plusmn;'+((p.hi-p.lo)/2).toFixed(2)+'</div></div>').join('')
: '<p class="col-span-full text-[10px] text-slate-600">Awaiting the first training pass.</p>';
}
async function loadOutlook() {
const d = await fetch('/api/outlook').then(r=>r.json());
el('o-badge').innerText = d.ready ? (d.annual_terms?'seasonal terms on':'diurnal only') : 'warming up';
const rows = (d.targets&&d.targets.temperature)||[];
if (!rows.length) { el('o-strip').innerHTML = '<p class="col-span-full text-[10px] text-slate-600 font-mono">Needs about two days of history before the harmonic fit means anything.</p>'; return; }
const byDay = {};
rows.forEach(r => { const k = new Date(r.ts*1000).toLocaleDateString([], {weekday:'short'}); (byDay[k]=byDay[k]||[]).push(r); });
el('o-strip').innerHTML = Object.keys(byDay).slice(0,7).map(day=>{
const v = byDay[day];
const hi = Math.max.apply(null, v.map(x=>x.mu)), lo = Math.min.apply(null, v.map(x=>x.mu));
const sp = Math.max.apply(null, v.map(x=>x.hi-x.lo))/2;
return '<div class="bg-slate-900/70 rounded-xl border border-slate-800/80 p-2 text-center">'+
'<div class="text-[9px] uppercase text-slate-600 font-mono">'+day+'</div>'+
'<div class="text-base font-bold font-mono mt-0.5">'+hi.toFixed(1)+'\u00b0</div>'+
'<div class="text-[10px] text-slate-500 font-mono">'+lo.toFixed(1)+'\u00b0</div>'+
'<div class="text-[9px] text-indigo-400/80 font-mono">&plusmn;'+sp.toFixed(1)+'</div></div>';
}).join('');
}
document.querySelectorAll('.rain-label').forEach(b => b.addEventListener('click', async () => {
const res = await fetch('/api/label', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({kind:'rain', value:Number(b.dataset.label)}) }).then(r=>r.json());
el('c-labstat').innerHTML = '<span class="text-emerald-300">Recorded.</span> '+(res.strong_labels||0)+' confirmed, loss '+fmt(res.loss,3);
}));
/* ---------------- HISTORY ---------------- */
let hRange = { hours:24, start:null, end:null }, histData = null;
const SERIES_META = {
temp:{ label:'temperature', colour:'#f59e0b', axis:'y', on:true },
hum:{ label:'humidity', colour:'#06b6d4', axis:'y1', on:false },
press:{ label:'pressure', colour:'#a78bfa', axis:'y2', on:true },
dew:{ label:'dew point', colour:'#34d399', axis:'y', on:false }
};
el('h-series').innerHTML = Object.keys(SERIES_META).map(k=>
'<button data-s="'+k+'" class="px-2 py-1 rounded-lg text-[10px] font-mono border"></button>').join('');
function paintSeriesButtons() {
document.querySelectorAll('#h-series button').forEach(b => {
const m = SERIES_META[b.dataset.s];
b.innerText = b.dataset.s;
b.style.borderColor = m.on ? m.colour+'66' : 'rgb(30,41,59)';
b.style.backgroundColor = m.on ? m.colour+'22' : 'transparent';
b.style.color = m.on ? m.colour : 'rgb(100,116,139)';
});
}
paintSeriesButtons();
charts.hist = new Chart(el('histChart').getContext('2d'), {
type:'line', data:{ datasets:[] },
options:{ responsive:true, maintainAspectRatio:false, animation:false,
interaction:{ mode:'index', intersect:false },
scales:{
x:{ type:'linear', grid:{color:GRID}, ticks:{ color:'#64748b', font:MONO, maxTicksLimit:9,
callback:v=>{ const d=new Date(v*1000); const span=(hRange.end||0)-(hRange.start||0);
return span > 3*86400 ? d.toLocaleDateString([], {month:'short',day:'numeric'})
: d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); } } },
y:{ position:'left', grid:{color:GRID}, ticks:{ color:'#f59e0b', font:MONO, callback:v=>v.toFixed(1)+'\u00b0' } },
y1:{ position:'right', display:false, grid:{drawOnChartArea:false}, ticks:{ color:'#06b6d4', font:MONO } },
y2:{ position:'right', grid:{drawOnChartArea:false}, ticks:{ color:'#a78bfa', font:MONO, callback:v=>v.toFixed(0) } } },
plugins:{ legend:{display:false},
tooltip:{ backgroundColor:'rgba(15,23,42,.95)', borderColor:'rgba(255,255,255,.1)', borderWidth:1,
titleFont:MONO, bodyFont:{family:'Plus Jakarta Sans',size:11},
filter:i=>i.dataset.label.charAt(0)!=='_',
callbacks:{ title:i=>tsFmt(i[0].parsed.x) } },
zoom:{ pan:{enabled:true,mode:'x'}, zoom:{ wheel:{enabled:true,speed:.08}, pinch:{enabled:true}, mode:'x' } } } }
});
el('h-presets').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
[...el('h-presets').children].forEach(x => x.className =
'px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 ' +
(x===b ? 'bg-indigo-600/20 text-indigo-300' : 'text-slate-400 hover:text-slate-200'));
hRange = { hours:Number(b.dataset.h), start:null, end:null };
loadHistory();
});
el('h-series').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
SERIES_META[b.dataset.s].on = !SERIES_META[b.dataset.s].on;
paintSeriesButtons(); drawHistory();
});
el('h-apply').addEventListener('click', () => {
const a = el('h-from').value, z = el('h-to').value;
if (!a || !z) { el('h-meta').innerText = 'Pick both a from and a to date.'; return; }
const s = new Date(a).getTime()/1000, e = new Date(z).getTime()/1000;
if (e <= s) { el('h-meta').innerText = 'The to date must be after the from date.'; return; }
hRange = { hours:null, start:s, end:e };
[...el('h-presets').children].forEach(x => x.className =
'px-2.5 py-1.5 rounded-lg text-[11px] font-mono border border-slate-800 text-slate-400 hover:text-slate-200');
loadHistory();
});
function rangeQuery() {
return hRange.hours!=null ? 'hours='+hRange.hours : 'start='+hRange.start+'&end='+hRange.end;
}
async function loadHistory() {
el('h-meta').innerText = 'Loading...';
const r = await fetch('/api/history/range?'+rangeQuery()).then(r=>r.json());
histData = r;
if (r.start) hRange.start = r.start;
if (r.end) hRange.end = r.end;
el('h-csv').href = '/api/export.csv?'+rangeQuery();
const bs = r.bucket_s||0;
const bl = bs>=86400 ? (bs/86400)+' d' : bs>=3600 ? (bs/3600)+' h' : (bs/60)+' min';
el('h-meta').innerText = r.n
? r.n+' points at '+bl+' resolution, '+tsFmt(r.start)+' to '+tsFmt(r.end)
: 'No data in that range. The station may not have been running then.';
drawHistory(); loadRecords();
}
function drawHistory() {
if (!histData || !histData.n) { charts.hist.data.datasets = []; charts.hist.update('none'); return; }
const s = histData.series, ds = [];
// Range band: min and max within each bucket, so an hourly view still shows
// that the hour spanned four degrees rather than implying a flat mean.
if (SERIES_META.temp.on && s.temp_hi) {
ds.push({ label:'_hi', data:s.ts.map((t,i)=>({x:t,y:s.temp_hi[i]})), borderColor:'transparent',
backgroundColor:'rgba(245,158,11,.10)', fill:'+1', pointRadius:0, parsing:false, yAxisID:'y', order:9 });
ds.push({ label:'_lo', data:s.ts.map((t,i)=>({x:t,y:s.temp_lo[i]})), borderColor:'transparent',
fill:false, pointRadius:0, parsing:false, yAxisID:'y', order:9 });
}
Object.keys(SERIES_META).forEach(k => {
const m = SERIES_META[k];
if (!m.on || !s[k]) return;
ds.push({ label:m.label, data:s.ts.map((t,i)=>({x:t,y:s[k][i]})).filter(p=>p.y!=null),
borderColor:m.colour, borderWidth:1.8, pointRadius:0, tension:.25, parsing:false, yAxisID:m.axis, order:1 });
});
charts.hist.options.scales.y1.display = SERIES_META.hum.on;
charts.hist.options.scales.y2.display = SERIES_META.press.on;
charts.hist.data.datasets = ds;
charts.hist.update('none');
}
async function loadRecords() {
const span = (hRange.end-hRange.start)||86400;
const days = Math.max(2, Math.min(400, Math.ceil(span/86400)));
const res = await Promise.all([
fetch('/api/history/daily?days='+days).then(r=>r.json()),
fetch('/api/records').then(r=>r.json()) ]);
const daily = res[0].days||[], all = res[1];
el('rec-daily').innerHTML = daily.length ?
'<table class="w-full text-[10px] font-mono"><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">day</th><th class="text-right">min</th>'+
'<th class="text-right">max</th><th class="text-right">hPa</th></tr></thead><tbody>'+
daily.map(d=>'<tr class="border-b border-slate-800/40"><td class="py-1 text-slate-400">'+d.day.slice(5)+'</td>'+
'<td class="text-right text-cyan-300">'+fmt(d.temp_min,1)+'</td>'+
'<td class="text-right text-amber-300">'+fmt(d.temp_max,1)+'</td>'+
'<td class="text-right text-slate-500">'+fmt(d.press_mean,0)+'</td></tr>').join('')+
'</tbody></table>'
: '<p class="text-[10px] text-slate-600 font-mono">No completed days yet.</p>';
const R = (k,label,unit,dec) => all[k] ?
'<div class="flex justify-between items-baseline bg-slate-900/60 rounded-lg border border-slate-800/70 px-2 py-1.5">'+
'<span class="text-slate-500 text-[10px]">'+label+'</span><span class="text-right">'+
'<span class="text-slate-100 font-bold text-[11px]">'+fmt(all[k].value,dec===undefined?1:dec)+unit+'</span>'+
'<span class="block text-slate-600 text-[9px]">'+tsFmt(all[k].ts)+'</span></span></div>' : '';
el('rec-all').innerHTML = [
R('temp_max','warmest','\u00b0'), R('temp_min','coldest','\u00b0'),
R('press_max','highest pressure',''), R('press_min','lowest pressure',''),
R('hum_max','most humid','%'), R('hum_min','driest','%'),
R('rate_fall','fastest fall','/h',2), R('rate_rise','fastest rise','/h',2)
].join('') || '<p class="text-[10px] text-slate-600 font-mono">No records yet.</p>';
}
function toggleRec(daily) {
el('rec-daily').classList.toggle('hidden', !daily);
el('rec-all').classList.toggle('hidden', daily);
el('rec-tab-daily').className = 'px-2 py-0.5 rounded text-[10px] font-mono border '+(daily?'bg-indigo-600/20 text-indigo-300 border-indigo-500/30':'border-slate-800 text-slate-500');
el('rec-tab-all').className = 'px-2 py-0.5 rounded text-[10px] font-mono border '+(!daily?'bg-indigo-600/20 text-indigo-300 border-indigo-500/30':'border-slate-800 text-slate-500');
}
el('rec-tab-daily').addEventListener('click', ()=>toggleRec(true));
el('rec-tab-all').addEventListener('click', ()=>toggleRec(false));
loaders.history = () => { if (!histData) loadHistory(); };
/* ---------------- MODELS ---------------- */
async function loadModels() {
const res = await Promise.all([
fetch('/api/scorecard').then(r=>r.json()), fetch('/api/models').then(r=>r.json()),
fetch('/api/status').then(r=>r.json()), fetch('/api/storage').then(r=>r.json()) ]);
const sc = res[0], md = res[1], st = res[2], sg = res[3];
el('hd-days').innerText = fmt(st.history_days,2);
const wmap = {};
(md.nowcast||[]).forEach(h => { wmap[h.target+'@'+h.horizon_s] = h.weights; });
const rows = sc.rows||[];
el('m-empty').style.display = rows.length ? 'none' : 'block';
// 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 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 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) : '-';
return '<tr class="border-b border-slate-800/40">'+
'<td class="py-1 text-slate-500">'+lead+'</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 text-slate-400">'+Math.round((r.coverage||0)*100)+'%</td>'+
'<td class="text-right text-slate-600 pl-2">'+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('');
el('m-storage').innerHTML = (sg.tiers||[]).map(t=>
'<div class="flex justify-between text-slate-500"><span>'+t.label+'</span>'+
'<span class="text-slate-300">'+t.rows.toLocaleString()+' rows</span></div>').join('')+
'<div class="flex justify-between text-slate-600 pt-1 mt-1 border-t border-slate-800"><span>database</span>'+
'<span>'+(sg.bytes/1e6).toFixed(2)+' MB</span></div>';
const coef = ((md.precipitation||{}).coefficients||[]).slice()
.sort((a,b)=>Math.abs(b.weight)-Math.abs(a.weight)).slice(0,6);
const mx = Math.max.apply(null, coef.map(c=>Math.abs(c.weight)).concat([1e-6]));
el('m-coef').innerHTML = coef.map(c=>
'<div class="flex items-center gap-1.5"><span class="w-24 truncate text-slate-600 text-[9px]">'+c.feature+'</span>'+
'<div class="flex-1 h-1 bg-slate-950 rounded-full overflow-hidden"><div class="h-full '+
(c.weight>=0?'bg-emerald-500':'bg-rose-500')+'" style="width:'+(Math.abs(c.weight)/mx*100)+'%"></div></div>'+
'<span class="w-10 text-right text-slate-500 text-[9px]">'+c.weight.toFixed(2)+'</span></div>').join('');
el('m-log').innerHTML = (st.events||[]).map(e=>
'<div class="flex gap-2 border-b border-slate-800/40 pb-1">'+
'<span class="text-slate-700 shrink-0">'+new Date(e.ts*1000).toLocaleTimeString()+'</span>'+
'<span class="text-slate-600 shrink-0 w-16">'+e.kind+'</span>'+
'<span class="'+(SEV[e.severity]||'text-slate-400')+'">'+e.detail+'</span></div>').join('')
|| '<span class="text-slate-700">Nothing logged yet.</span>';
const an = await fetch('/api/anomaly').then(r=>r.json());
el('e-health').innerHTML = Object.keys(an.health||{}).map(k=>{
const v = an.health[k];
const dot = v.status==='ok'?'bg-emerald-400':v.status==='warn'?'bg-amber-400':'bg-rose-400';
return '<div class="flex justify-between items-center text-slate-500">'+
'<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full '+dot+'"></span>'+k+'</span>'+
'<span class="text-slate-600 text-[9px] truncate ml-2">'+v.detail+'</span></div>';
}).join('');
}
loaders.models = loadModels;
el('m-train').addEventListener('click', async () => {
el('m-train').innerText = 'Training...';
const r = await fetch('/api/train', {method:'POST'}).then(r=>r.json());
el('m-train').innerText = r.trained ? 'Trained '+r.grid_rows : 'Not enough data';
setTimeout(()=>{ el('m-train').innerText='Retrain'; }, 4000);
loadModels();
});
el('m-verify').addEventListener('click', async () => { await fetch('/api/verify',{method:'POST'}); loadModels(); });
el('m-calgo').addEventListener('click', async () => {
const v = parseFloat(el('m-calin').value);
if (Number.isNaN(v)) { el('m-calstat').innerText = 'Enter a temperature in degrees Celsius.'; return; }
const r = await fetch('/api/calibrate', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({reference_c:v})}).then(r=>r.json());
el('m-calstat').innerHTML = r.k!==undefined
? 'k is now <span class="text-emerald-300">'+r.k.toFixed(4)+'</span>, residual '+r.residual.toFixed(2)+' \u00b0C'
: 'Rejected: no live reading yet.';
});
el('m-calrst').addEventListener('click', async () => {
const r = await fetch('/api/calibrate', {method:'POST', headers:{'Content-Type':'application/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-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>';
});
/* ---------------- SETTINGS ---------------- */
let settingsDoc = null;
const PILL_ON = 'px-2 py-1 rounded-lg text-[10px] font-mono border bg-indigo-600/25 text-indigo-200 border-indigo-500/40';
const PILL_OFF = 'px-2 py-1 rounded-lg text-[10px] font-mono border border-slate-800 text-slate-500 hover:text-slate-300';
function sMsg(node, text, good) {
el(node).innerHTML = '<span class="'+(good?'text-emerald-300':'text-rose-300')+'">'+text+'</span>';
setTimeout(()=>{ if (el(node).innerText===text) el(node).innerHTML=''; }, 9000);
}
async function loadSettings() {
const d = await fetch('/api/settings').then(r=>r.json());
settingsDoc = d;
const site = d.site||{}, srv = d.server||{}, sen = d.sensor||{};
el('s-env-state').innerText = site.environment+' / '+site.enclosure;
const opts = d.options||{};
for (const [id, key] of [['s-environment','environment'], ['s-enclosure','enclosure']]) {
el(id).innerHTML = (opts[key]||[]).map(v =>
'<button data-group="'+key+'" data-value="'+v+'" class="'+
(v===site[key] ? PILL_ON : PILL_OFF)+'">'+v+'</button>').join('');
}
el('s-led').innerText = srv.led_enabled ? 'on' : 'off';
el('s-led').className = srv.led_enabled ? PILL_ON : PILL_OFF;
el('s-fps').value = srv.led_fps;
el('s-fps-val').innerText = srv.led_fps + ' fps';
el('s-alt').value = site.altitude_m;
el('s-lat').value = site.latitude;
el('s-lon').value = site.longitude;
el('s-psy').innerText = sen.hum_psychrometric ? 'on' : 'off';
el('s-psy').className = sen.hum_psychrometric ? PILL_ON : PILL_OFF;
el('s-heat').innerText = site.heating ? 'on' : 'off';
el('s-heat').className = site.heating ? PILL_ON : PILL_OFF;
el('s-setpoint').value = site.heating_setpoint_c;
el('s-tau').value = site.thermal_time_constant_h;
}
loaders.settings = loadSettings;
async function postSettings(body, node) {
const r = await fetch('/api/settings', {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}).then(r=>r.json());
await loadSettings();
if (!r.changed) { sMsg(node, 'nothing changed', true); return r; }
let msg = r.applied.join(' &middot; ');
// Altitude and the psychrometric flag both change how stored rows should read,
// so say so rather than leaving a silent inconsistency in the history.
if (r.needs_recompute) msg += ' &mdash; history now inconsistent, re-derive it';
sMsg(node, msg, true);
return r;
}
// Selecting a pill only stages it; nothing is recorded until you say so, because
// this writes a discontinuity marker and queues a retrain.
let pending = {};
document.addEventListener('click', e => {
const b = e.target.closest('[data-group]');
if (!b) return;
pending[b.dataset.group] = b.dataset.value;
[...b.parentElement.children].forEach(x =>
x.className = (x===b ? PILL_ON : PILL_OFF));
});
el('s-env-apply').addEventListener('click', async () => {
const body = Object.assign({}, pending, {note: el('s-note').value || ''});
if (!Object.keys(pending).length) { sMsg('s-msg','pick a state first', false); return; }
await postSettings(body, 's-msg');
pending = {}; el('s-note').value = '';
});
el('s-led').addEventListener('click', () =>
postSettings({led_enabled: !(settingsDoc.server||{}).led_enabled}, 's-msg'));
el('s-psy').addEventListener('click', () =>
postSettings({hum_psychrometric: !(settingsDoc.sensor||{}).hum_psychrometric}, 's-msg'));
el('s-heat').addEventListener('click', () =>
postSettings({heating: !(settingsDoc.site||{}).heating}, 's-msg'));
for (const id of ['s-setpoint','s-tau']) {
el(id).addEventListener('change', () => postSettings({
heating_setpoint_c: Number(el('s-setpoint').value),
thermal_time_constant_h: Number(el('s-tau').value)}, 's-msg'));
}
el('s-fps').addEventListener('input', e => el('s-fps-val').innerText = e.target.value + ' fps');
el('s-fps').addEventListener('change', e =>
postSettings({led_fps: Number(e.target.value)}, 's-msg'));
el('s-site-apply').addEventListener('click', () => postSettings({
altitude_m: Number(el('s-alt').value),
latitude: Number(el('s-lat').value),
longitude: Number(el('s-lon').value)}, 's-msg'));
async function maint(url, label, node) {
el(node).innerHTML = '<span class="text-amber-300">'+label+' running...</span>';
try {
const r = await fetch(url, {method:'POST'}).then(r=>r.json());
el(node).innerHTML = '<span class="text-emerald-300">'+label+': '+
(r.rows!==undefined ? r.rows+' rows in '+r.seconds+'s'
: r.trained!==undefined ? (r.trained ? 'trained '+(r.grid_rows||'')+' rows' : (r.reason||'skipped'))
: JSON.stringify(r).slice(0,70))+'</span>';
} catch (err) {
el(node).innerHTML = '<span class="text-rose-300">'+label+' failed</span>';
}
}
el('s-recompute').addEventListener('click', ()=>maint('/api/recompute','re-derive','s-maint'));
el('s-retrain').addEventListener('click', ()=>maint('/api/train','retrain','s-maint'));
el('s-verify').addEventListener('click', ()=>maint('/api/verify','score','s-maint'));
/* ---------------- STATS FOR NERDS ---------------- */
let nerdDoc = null, nerdHead = null;
const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d';
// A 21-bin histogram drawn as inline divs. No canvas: Chart.js instances cost
// memory and this is three tiny plots that never need interaction.
function histo(counts, colour, h) {
if (!counts || !counts.length) return '<div class="text-slate-700 text-[9px]">warming up</div>';
const mx = Math.max.apply(null, counts) || 1;
return '<div class="flex items-end gap-px" style="height:'+h+'px">'+
counts.map(c=>'<div style="flex:1;height:'+Math.max(1,(c/mx)*h)+'px;background:'+colour+
';opacity:'+(0.35+0.65*(c/mx))+'"></div>').join('')+'</div>';
}
function bar(frac, colour) {
const w = Math.max(0, Math.min(1, frac||0))*100;
return '<div class="h-1 bg-slate-950 rounded-full overflow-hidden border border-slate-800/70">'+
'<div class="h-full" style="width:'+w+'%;background:'+colour+'"></div></div>';
}
async function loadNerd() {
const d = await fetch('/api/nerd').then(r=>r.json());
nerdDoc = d;
const COL = {temperature:'#f59e0b', humidity:'#06b6d4', pressure:'#a78bfa'};
const iv = d.innovation||{};
el('n-filters').innerHTML = Object.keys(d.filters||{}).map(k=>{
const f = d.filters[k];
// NIS is chi-square(1) distributed when consistent, so 1 is the target and
// the bar is scaled to 3 as a "clearly wrong" ceiling.
return '<div class="bg-slate-900/50 rounded-lg border border-slate-800/70 p-2">'+
'<div class="flex justify-between mb-1"><span style="color:'+COL[k]+'">'+k+'</span>'+
'<span class="text-slate-500">'+(f.initialised?'':'warming')+'</span></div>'+
'<div class="flex justify-between text-slate-500">NIS<span class="text-slate-200 font-bold">'+fmt(f.nis,3)+'</span></div>'+
bar(f.nis/3, COL[k])+
'<div class="flex justify-between text-slate-500 mt-1">&sigma; level<span class="text-slate-300">'+fmt(f.sigma_level,4)+'</span></div>'+
'<div class="flex justify-between text-slate-500">P rate<span class="text-slate-300">'+f.p_rate.toExponential(2)+'</span></div>'+
'<div class="flex justify-between text-slate-600">q / r<span>'+f.q.toExponential(1)+' / '+fmt(f.r,3)+'</span></div>'+
// innovation shape: should look standard normal if the filter is honest
'<div class="mt-1 pt-1 border-t border-slate-800/70">'+
histo((iv[k]||{}).counts, COL[k], 16)+
'<div class="flex justify-between text-slate-600 mt-0.5 text-[9px]">'+
'<span>innov &mu;='+fmt((iv[k]||{}).mean,2)+' &sigma;='+fmt((iv[k]||{}).std,2)+'</span>'+
'<span>skew '+fmt((iv[k]||{}).skew,2)+'</span></div></div>'+
'</div>';
}).join('');
const th = (d.compensators||{}).thermal||{}, hu = (d.compensators||{}).humidity||{};
el('n-comp').innerHTML =
'<div class="flex justify-between text-slate-500">k<span class="text-emerald-300 font-bold">'+fmt(th.k,4)+'</span></div>'+
'<div class="flex justify-between text-slate-600">P / n<span>'+fmt(th.P,3)+' / '+(th.n||0)+'</span></div>'+
'<div class="flex justify-between text-slate-600">clamp<span>'+fmt(th.k_min,2)+' .. '+fmt(th.k_max,2)+'</span></div>'+
'<div class="border-t border-slate-800 my-1"></div>'+
'<div class="flex justify-between text-slate-500">RH offset<span class="text-cyan-300 font-bold">'+fmt(hu.offset,2)+'%</span></div>'+
'<div class="flex justify-between text-slate-600">P / n<span>'+fmt(hu.P,3)+' / '+(hu.n||0)+'</span></div>'+
'<div class="flex justify-between text-slate-600">psychrometric<span>'+(hu.psychrometric?'on':'off')+'</span></div>';
const heads = d.heads||[];
el('n-heads').innerHTML = heads.length ? '<table class="w-full font-mono text-[10px]">'+
'<thead class="text-slate-600 uppercase text-[9px]"><tr class="border-b border-slate-800">'+
'<th class="text-left py-0.5">head</th><th class="text-right">n</th><th class="text-right">tr P</th>'+
'<th class="text-right">cond</th>'+
'<th class="text-right">|&theta;|</th><th class="text-right">rmse</th><th class="text-right">&alpha;</th>'+
'<th class="text-right">cov</th><th class="text-right">&plusmn;</th><th class="text-right pl-2">p/c/l</th></tr></thead><tbody>'+
heads.map(h=>{
const sat = h.trace_p/h.p_max;
const cov = h.coverage===null||h.coverage===undefined ? '-' : Math.round(h.coverage*100)+'%';
const covCls = (h.coverage!==null && Math.abs(h.coverage-(1-h.alpha_target))<0.03) ? 'text-emerald-300' : 'text-amber-300';
const w = h.weights||{};
return '<tr class="border-b border-slate-800/40">'+
'<td class="py-0.5 text-slate-400">'+h.target.slice(0,4)+' <span class="text-slate-600">'+HL(h.horizon_s)+'</span></td>'+
'<td class="text-right text-slate-600">'+h.n_updates+'</td>'+
'<td class="text-right '+(sat>0.95?'text-rose-300':'text-slate-400')+'">'+h.trace_p.toExponential(1)+'</td>'+
// a huge condition number means some of the 33 directions are barely
// excited, so their weights are close to arbitrary
'<td class="text-right '+(h.cond_p>1e8?'text-amber-300':'text-slate-500')+'">'+
(isFinite(h.cond_p)?h.cond_p.toExponential(0):'inf')+'</td>'+
'<td class="text-right text-slate-300">'+fmt(h.theta_norm,1)+'</td>'+
'<td class="text-right text-slate-300">'+fmt(h.rmse_ewma,3)+'</td>'+
'<td class="text-right text-indigo-300">'+fmt(h.alpha,3)+'</td>'+
'<td class="text-right '+covCls+'">'+cov+'</td>'+
'<td class="text-right text-slate-500">'+(h.halfwidth===null?'-':fmt(h.halfwidth,2))+'</td>'+
'<td class="text-right text-slate-600 pl-2">'+Math.round((w.persistence||0)*100)+'/'+
Math.round((w.climatology||0)*100)+'/'+Math.round((w.learned||0)*100)+'</td></tr>';
}).join('')+'</tbody></table>'
: '<p class="text-[10px] text-slate-600 font-mono">No heads trained yet.</p>';
const rel = ((d.reliability||{}).points)||[];
el('n-rel').innerHTML = rel.length ? rel.map(pt=>{
const gap = pt.realised - pt.nominal;
const cls = Math.abs(gap)<0.03 ? '#34d399' : Math.abs(gap)<0.06 ? '#fbbf24' : '#fb7185';
return '<div class="flex items-center gap-1.5">'+
'<span class="text-slate-600" style="width:42%">'+pt.target.slice(0,4)+' '+HL(pt.horizon_s)+'</span>'+
'<span class="flex-1">'+bar(pt.realised, cls)+'</span>'+
'<span style="width:26%;text-align:right;color:'+cls+'">'+(pt.realised*100).toFixed(0)+'%</span></div>';
}).join('') : '<p class="text-slate-600">Nothing scored yet.</p>';
const sel = el('n-head-sel');
if (sel.options.length !== heads.length) {
sel.innerHTML = heads.map((h,i)=>'<option value="'+i+'">'+h.target.slice(0,4)+' '+HL(h.horizon_s)+'</option>').join('');
}
if (nerdHead===null || nerdHead>=heads.length) nerdHead = 0;
sel.value = String(nerdHead);
drawTheta();
const mo = d.monitoring||{}, nv = mo.novelty||{}, dr = mo.drift||{};
el('n-mon').innerHTML =
'<div class="flex justify-between text-slate-500">Mahalanobis d&sup2;<span class="text-slate-200 font-bold">'+fmt(nv.d2,2)+'</span></div>'+
bar((nv.d2||0)/(nv.threshold||12), '#f59e0b')+
'<div class="flex justify-between text-slate-600">threshold / dims<span>'+fmt(nv.threshold,0)+' / '+(nv.dims||0)+'</span></div>'+
'<div class="flex justify-between text-slate-500 mt-1">Page-Hinkley m&#8314;<span class="text-slate-200 font-bold">'+fmt(dr.m_pos,3)+'</span></div>'+
'<div class="flex justify-between text-slate-600">m&#8315; / alarms<span>'+fmt(dr.m_neg,3)+' / '+(dr.alarms||0)+'</span></div>';
const cl = d.climatology||{};
el('n-clim').innerHTML =
'<div class="flex justify-between text-slate-500">history<span class="text-slate-300">'+fmt(cl.history_days,2)+' d</span></div>'+
'<div class="flex justify-between text-slate-500">harmonics<span class="text-slate-300">'+(cl.diurnal_harmonics||0)+' diurnal, '+
(cl.annual_terms?(cl.annual_harmonics||0):0)+' annual</span></div>'+
Object.keys(cl.residual_std||{}).map(k=>
'<div class="flex justify-between text-slate-600">&sigma; '+k.slice(0,4)+'<span>'+fmt(cl.residual_std[k],3)+'</span></div>').join('');
const pr = d.precipitation||{};
const co = (pr.coefficients||[]).slice().sort((a,b)=>Math.abs(b.weight)-Math.abs(a.weight)).slice(0,4);
el('n-precip').innerHTML =
'<div class="flex justify-between text-slate-500">labels<span class="text-slate-300">'+(pr.strong_labels||0)+' strong, '+(pr.weak_labels||0)+' weak</span></div>'+
'<div class="flex justify-between text-slate-500">logloss<span class="text-slate-300">'+fmt(pr.logloss_ewma,4)+'</span></div>'+
co.map(c=>'<div class="flex justify-between text-slate-600">'+String(c.feature||'?').slice(0,16)+'<span class="'+(c.weight>=0?'text-emerald-400':'text-rose-400')+'">'+
(c.weight>=0?'+':'')+fmt(c.weight,3)+'</span></div>').join('');
}
function drawTheta() {
if (!nerdDoc || !nerdDoc.heads || !nerdDoc.heads.length) return;
const h = nerdDoc.heads[nerdHead], names = nerdDoc.feature_names||[];
const pairs = (h.theta||[]).map((v,i)=>({n:names[i]||('f'+i), v:v}))
.sort((a,b)=>Math.abs(b.v)-Math.abs(a.v)).slice(0,11);
const mx = Math.max.apply(null, pairs.map(p=>Math.abs(p.v)).concat([1e-9]));
el('n-theta').innerHTML = pairs.map(p=>
'<div class="flex items-center gap-1.5">'+
'<span class="text-slate-500 truncate" style="width:46%">'+p.n+'</span>'+
'<span class="flex-1">'+bar(Math.abs(p.v)/mx, p.v>=0?'#34d399':'#fb7185')+'</span>'+
'<span class="'+(p.v>=0?'text-emerald-400':'text-rose-400')+'" style="width:22%;text-align:right">'+
(p.v>=0?'+':'')+fmt(p.v,3)+'</span></div>').join('');
}
loaders.nerd = loadNerd;
/* ---------------- WEATHER ICONS ----------------
Inline SVG, no icon font and no extra request. The glyph is not chosen from
the Zambretti class alone: the station also measures cloud index from the
light sensor and knows the solar elevation, so a "fine" barometer under a
thick overcast still draws a cloud, and after sunset it draws a moon rather
than a sun. Snow is picked on temperature, not on the barometer. */
const WX = {
sun: '<circle cx="32" cy="32" r="13" fill="#fbbf24"/>'+
'<g stroke="#fbbf24" stroke-width="4" stroke-linecap="round">'+
'<path d="M32 6v8M32 50v8M6 32h8M50 32h8M13 13l6 6M45 45l6 6M51 13l-6 6M19 45l-6 6"/></g>',
moon: '<path d="M40 10a22 22 0 1 0 14 40A24 24 0 0 1 40 10z" fill="#e2e8f0"/>'+
'<circle cx="20" cy="16" r="1.8" fill="#94a3b8"/><circle cx="14" cy="26" r="1.2" fill="#94a3b8"/>',
cloud: '<path d="M20 46a11 11 0 0 1 1-22 15 15 0 0 1 28 4 9 9 0 0 1-2 18z" fill="#94a3b8"/>',
partsun: '<circle cx="22" cy="22" r="9" fill="#fbbf24"/>'+
'<g stroke="#fbbf24" stroke-width="3" stroke-linecap="round">'+
'<path d="M22 4v6M4 22h6M9 9l4 4M35 9l-4 4"/></g>'+
'<path d="M26 50a10 10 0 0 1 1-20 13 13 0 0 1 25 4 8 8 0 0 1-2 16z" fill="#cbd5e1"/>',
partmoon: '<path d="M30 8a15 15 0 1 0 10 27A16 16 0 0 1 30 8z" fill="#e2e8f0"/>'+
'<path d="M26 52a10 10 0 0 1 1-20 13 13 0 0 1 25 4 8 8 0 0 1-2 16z" fill="#cbd5e1"/>',
rain: '<path d="M20 40a11 11 0 0 1 1-22 15 15 0 0 1 28 4 9 9 0 0 1-2 18z" fill="#94a3b8"/>'+
'<g stroke="#38bdf8" stroke-width="3.5" stroke-linecap="round">'+
'<path d="M22 46l-3 9M33 46l-3 9M44 46l-3 9"/></g>',
showers: '<path d="M20 40a11 11 0 0 1 1-22 15 15 0 0 1 28 4 9 9 0 0 1-2 18z" fill="#a3adbb"/>'+
'<g stroke="#38bdf8" stroke-width="3.5" stroke-linecap="round">'+
'<path d="M26 46l-2 7M39 46l-2 7"/></g>',
storm: '<path d="M20 38a11 11 0 0 1 1-22 15 15 0 0 1 28 4 9 9 0 0 1-2 18z" fill="#64748b"/>'+
'<path d="M34 36l-10 15h7l-3 12 12-17h-7l4-10z" fill="#fbbf24"/>',
snow: '<path d="M20 40a11 11 0 0 1 1-22 15 15 0 0 1 28 4 9 9 0 0 1-2 18z" fill="#cbd5e1"/>'+
'<g stroke="#e0f2fe" stroke-width="3" stroke-linecap="round">'+
'<path d="M22 48v8M18 52h8M33 48v8M29 52h8M44 48v8M40 52h8"/></g>',
};
function wxPick(condition, cloud, elevation, tempC, rainProb) {
const night = (elevation !== undefined && elevation !== null && elevation < -1);
const c = (cloud === undefined || cloud === null) ? 0.5 : cloud;
if (condition === 'stormy') return ['storm', 'Thunderstorms'];
// Snow is a temperature question, not a barometric one.
if (tempC !== undefined && tempC !== null && tempC <= 1.5 && rainProb > 0.35)
return ['snow', 'Snow possible'];
if (condition === 'wet') return ['rain', 'Wet and windy'];
if (condition === 'rain') return ['rain', 'Rain likely'];
if (condition === 'unsettled') return ['showers', 'Showers around'];
if (condition === 'changeable')
return c > 0.7 ? ['cloud','Cloudy'] : [night?'partmoon':'partsun', 'Partly cloudy'];
// settled / fine / fair: defer to what the light sensor actually sees.
if (c < 0.25) return [night?'moon':'sun', night ? 'Clear skies' : 'Sunny, open skies'];
if (c < 0.65) return [night?'partmoon':'partsun', 'Partly cloudy'];
return ['cloud', 'Cloudy'];
}
function wxSvg(name, size) {
return '<svg viewBox="0 0 64 64" width="'+size+'" height="'+size+'" aria-hidden="true">'+(WX[name]||WX.cloud)+'</svg>';
}
/* ---------------- METHODS ---------------- */
let methodsDoc = null, methodSel = 'acquire';
const STAGE_COLOUR = { acquire:'#94a3b8', compensate:'#34d399', kalman:'#f59e0b', features:'#06b6d4',
nowcast:'#818cf8', conformal:'#a78bfa', climatology:'#38bdf8', precip:'#60a5fa',
monitor:'#fb7185', verify:'#4ade80' };
async function loadMethods() {
if (!methodsDoc) methodsDoc = await fetch('/api/methods').then(r=>r.json());
drawDiagram(); drawStage();
}
function drawDiagram() {
const p = methodsDoc.pipeline;
el('me-diagram').innerHTML = p.map((s,i)=>{
const c = STAGE_COLOUR[s.id]||'#94a3b8', on = s.id===methodSel;
const edge = methodsDoc.flow.filter(f=>f.from===s.id)[0];
return '<button data-stage="'+s.id+'" class="stagebtn w-full text-left rounded-xl border px-3 py-2 transition" '+
'style="border-color:'+(on?c+'88':'rgba(255,255,255,.07)')+';background:'+(on?c+'1a':'rgba(15,23,42,.5)')+'">'+
'<div class="flex items-center gap-2.5"><span class="font-mono text-[10px] w-5 shrink-0" style="color:'+c+'">'+s.stage+'</span>'+
'<div class="min-w-0 flex-1"><div class="text-xs font-semibold '+(on?'text-white':'text-slate-300')+'">'+s.title+'</div>'+
'<div class="text-[9px] font-mono text-slate-600 truncate">'+s.technique+'</div></div></div></button>'+
(i<p.length-1 ? '<div class="flex items-center gap-1.5 pl-[26px] h-4"><div class="w-px h-full" style="background:'+c+'55"></div>'+
'<span class="text-[8px] font-mono text-slate-700">'+(edge?edge.label:'')+'</span></div>' : '');
}).join('')+
'<div class="mt-3 pt-3 border-t border-slate-800"><div class="text-[9px] font-mono text-slate-600 uppercase mb-1.5">feedback edges</div>'+
methodsDoc.flow.filter(f=>['verify','monitor','climatology'].indexOf(f.from)>=0).map(f=>
'<div class="text-[9px] font-mono text-slate-600 flex items-center gap-1.5 mb-0.5">'+
'<span class="text-slate-500">'+f.from+'</span><span class="text-indigo-500">&rarr;</span>'+
'<span class="text-slate-500">'+f.to+'</span><span class="text-slate-700">'+f.label+'</span></div>').join('')+'</div>';
document.querySelectorAll('.stagebtn').forEach(b => b.addEventListener('click', () => {
methodSel = b.dataset.stage; drawDiagram(); drawStage();
}));
}
function drawStage() {
const s = methodsDoc.pipeline.filter(x=>x.id===methodSel)[0];
if (!s) return;
const c = STAGE_COLOUR[s.id]||'#94a3b8';
el('me-head').innerHTML =
'<div class="flex flex-wrap items-baseline gap-2.5"><span class="font-mono text-xs" style="color:'+c+'">stage '+s.stage+'</span>'+
'<h2 class="text-base font-bold">'+s.title+'</h2>'+
'<span class="ml-auto font-mono text-[10px] text-slate-600">'+s.module+'</span></div>'+
'<p class="text-[11px] text-slate-500 font-mono mt-0.5">'+s.technique+'</p>';
const params = Object.keys(s.params||{}).map(k=>
'<div class="bg-slate-900/60 rounded-lg border border-slate-800/70 px-2.5 py-1.5">'+
'<div class="text-[9px] text-slate-600 font-mono uppercase">'+k+'</div>'+
'<div class="text-[11px] text-slate-200 font-mono font-semibold">'+s.params[k]+'</div></div>').join('');
el('me-body').innerHTML =
'<div class="grid grid-cols-2 gap-2 font-mono text-[10px]">'+
'<div class="bg-slate-900/40 rounded-lg border border-slate-800/60 px-2.5 py-1.5">'+
'<div class="text-slate-600 uppercase text-[9px]">consumes</div><div class="text-slate-300 mt-0.5">'+s.consumes+'</div></div>'+
'<div class="bg-slate-900/40 rounded-lg border border-slate-800/60 px-2.5 py-1.5">'+
'<div class="text-slate-600 uppercase text-[9px]">produces</div><div class="text-slate-300 mt-0.5">'+s.produces+'</div></div></div>'+
(s.math ? '<div class="bg-slate-950/60 rounded-lg border border-slate-800/70 px-3 py-2.5 overflow-x-auto">'+
'<div class="text-[9px] text-slate-600 font-mono uppercase mb-1">core relation</div>'+
(Array.isArray(s.math)?s.math:[s.math]).map(m=>'<div class="tex" data-tex="'+esc(m)+'">'+esc(m)+'</div>').join('')+'</div>' : '')+
(s.symbols ? '<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">symbols</div>'+
'<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-1">'+
Object.keys(s.symbols).map(k=>'<div class="flex gap-2 items-baseline">'+
'<span class="tex-inline shrink-0" data-tex="'+esc(k)+'">'+esc(k)+'</span>'+
'<span class="text-[11px] text-slate-500 leading-snug">'+s.symbols[k]+'</span></div>').join('')+
'</div></div>' : '')+
'<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">why it is done this way</div>'+
'<p class="text-[12px] text-slate-300 leading-relaxed">'+s.why+'</p></div>'+
'<div class="border-l-2 pl-3" style="border-color:'+c+'66">'+
'<div class="text-[9px] font-mono uppercase mb-1" style="color:'+c+'">how it fails</div>'+
'<p class="text-[12px] text-slate-400 leading-relaxed">'+s.failure+'</p></div>'+
(params ? '<div><div class="text-[9px] text-slate-600 font-mono uppercase mb-1">live parameters</div>'+
'<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">'+params+'</div></div>' : '')+
(methodSel==='verify' ? '<div class="pt-2 border-t border-slate-800">'+
'<div class="text-[9px] text-slate-600 font-mono uppercase mb-1.5">honest limits of this station</div><ul class="space-y-1.5">'+
methodsDoc.honest_limits.map(l=>'<li class="text-[11px] text-slate-400 leading-relaxed flex gap-2">'+
'<span class="text-amber-500 shrink-0">&middot;</span><span>'+l+'</span></li>').join('')+'</ul></div>' : '')+
(methodSel==='features' ? '<div class="pt-2 border-t border-slate-800">'+
'<div class="text-[9px] text-slate-600 font-mono uppercase mb-1.5">the '+methodsDoc.features.length+' features</div>'+
'<div class="flex flex-wrap gap-1">'+methodsDoc.features.map(f=>
'<span class="px-1.5 py-0.5 rounded bg-slate-900/70 border border-slate-800 text-[9px] font-mono text-slate-500">'+f+'</span>').join('')+
'</div></div>' : '')+
(methodSel==='acquire' ? '<div class="pt-2 border-t border-slate-800">'+
'<div class="text-[9px] text-slate-600 font-mono uppercase mb-1.5">glossary</div>'+
methodsDoc.glossary.map(g=>'<div class="mb-2"><span class="text-[11px] font-semibold text-slate-300">'+g.term+'</span>'+
'<p class="text-[11px] text-slate-500 leading-relaxed">'+g.definition+'</p></div>').join('')+'</div>' : '');
typeset(el('me-body'));
}
loaders.methods = loadMethods;
// A slow CDN can land katex after the pane has already rendered.
window.addEventListener('load', () => typeset());
/* ---------------- BOOT ---------------- */
connectStream();
loadForecast();
loadOutlook();
fetch('/api/status').then(r=>r.json()).then(s => { el('hd-days').innerText = fmt(s.history_days,2); });
setInterval(() => { if (activeTab==='live') { loadForecast(); loadOutlook(); } }, 60000);
el('n-head-sel').addEventListener('change', e => { nerdHead = Number(e.target.value); drawTheta(); });
setInterval(() => { if (activeTab==='models') loadModels(); }, 60000);
setInterval(() => { if (activeTab==='nerd') loadNerd(); }, 30000);
setInterval(() => { if (activeTab==='history') loadHistory(); }, 300000);
</script>
</body>
</html>
"""