Settings tab

Everything that was previously a curl command now has a surface: surroundings,
site geometry, the matrix, the psychrometric flag, and the maintenance actions.

Changes persist to data/state/settings.json, not config.yaml. That file is
hand-annotated and hand-edited per station, and rewriting it from an API would
destroy the comments and risk clobbering something the owner set. The overlay is
applied last in load_config, so a dashboard change beats both the file and the
environment, and deleting the overlay reverts everything. Written atomically via
a temp file so a crash cannot truncate it.

Every field applies live. A settings page that needs a restart is one people
stop trusting, so site geometry is re-read per sample, the compensator flag is
set on the live object, and the display picks up its rate the next frame.

Two deliberate frictions. Selecting a surroundings pill only stages it: nothing
is recorded until you press the button, because that writes a discontinuity
marker and queues a retrain. And changing altitude or the psychrometric flag
says outright that the stored history is now inconsistent and offers the
re-derive, rather than leaving a silent mismatch.

Verified in a browser: pills stage and apply, the toggle round-trips, re-derive
ran 6201 rows in 0.29 s from the button, all six tabs report zero scrollbars and
zero clipping, zero console errors.
This commit is contained in:
2026-08-15 23:46:57 +01:00
parent 50b29f8077
commit 498b3f6e38
4 changed files with 332 additions and 4 deletions
+3 -2
View File
@@ -160,8 +160,8 @@ permanently.
## The dashboard ## The dashboard
Five tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is Six tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is
released, because pinning five panels into a phone viewport produces unreadable released, because pinning six panels into a phone viewport produces unreadable
eight-pixel type. eight-pixel type.
| Tab | Answers | | Tab | Answers |
@@ -170,6 +170,7 @@ eight-pixel type.
| **History** | What did it do, over any timeframe you ask for | | **History** | What did it do, over any timeframe you ask for |
| **Models and Calibration** | Has the model earned its confidence, and the calibration inputs | | **Models and Calibration** | Has the model earned its confidence, and the calibration inputs |
| **Stats for Nerds** | Every internal the estimator and the 18 learners are carrying | | **Stats for Nerds** | Every internal the estimator and the 18 learners are carrying |
| **Settings** | Surroundings, site geometry, the matrix, and maintenance actions |
| **Methods** | How the whole thing is wired, and how each stage fails | | **Methods** | How the whole thing is wired, and how each stage fails |
Live carries the current readings, the observed-and-forecast chart with its 90% Live carries the current readings, the observed-and-forecast chart with its 90%
+102 -1
View File
@@ -34,7 +34,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .config import CONFIG from .config import CONFIG, load_overrides, save_overrides
from .dashboard import DASHBOARD_HTML from .dashboard import DASHBOARD_HTML
from .features import FEATURE_NAMES from .features import FEATURE_NAMES
from .led import LedDisplay from .led import LedDisplay
@@ -128,6 +128,19 @@ class CalibrationIn(BaseModel):
"covariance, returning to the configured prior") "covariance, returning to the configured prior")
class SettingsIn(BaseModel):
"""Every field optional: the UI sends only what changed."""
environment: Optional[str] = None
enclosure: Optional[str] = None
note: str = ""
altitude_m: Optional[float] = Field(None, ge=-430, le=9000)
latitude: Optional[float] = Field(None, ge=-90, le=90)
longitude: Optional[float] = Field(None, ge=-180, le=180)
hum_psychrometric: Optional[bool] = None
led_enabled: Optional[bool] = None
led_fps: Optional[float] = Field(None, ge=4, le=30)
class EnvironmentIn(BaseModel): class EnvironmentIn(BaseModel):
environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor") environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor")
enclosure: Optional[str] = Field(None, description="closed | ventilated | open") enclosure: Optional[str] = Field(None, description="closed | ventilated | open")
@@ -624,6 +637,94 @@ def environment(body: EnvironmentIn) -> Dict:
return _clean(_st().set_environment(body.environment, body.enclosure, body.note)) return _clean(_st().set_environment(body.environment, body.enclosure, body.note))
@app.get("/api/settings")
def get_settings() -> Dict:
st = _st()
return _clean({
"site": {"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure,
"altitude_m": CONFIG.site.altitude_m,
"latitude": CONFIG.site.latitude,
"longitude": CONFIG.site.longitude,
"timezone": CONFIG.site.timezone,
"name": CONFIG.site.name},
"sensor": {"hum_psychrometric": CONFIG.sensor.hum_psychrometric,
"cpu_heat_k": round(st.tracker.compensator.k, 4),
"hum_offset": round(st.tracker.hum_compensator.offset, 3)},
"server": {"led_enabled": CONFIG.server.led_enabled,
"led_fps": CONFIG.server.led_fps},
"options": {
"environment": ["indoor", "sheltered", "outdoor"],
"enclosure": ["closed", "ventilated", "open"],
},
"overrides": load_overrides(CONFIG),
})
@app.post("/api/settings")
def post_settings(body: SettingsIn) -> Dict:
"""Apply settings live and persist them to the overlay.
Everything here takes effect without a restart, because a settings page that
needs one is a settings page people stop trusting. Site geometry is read per
sample, the compensator flag is a field on a live object, and the display
reads its own rate each frame.
"""
st = _st()
patch: Dict[str, Dict] = {}
applied, needs_recompute = [], False
if body.environment or body.enclosure:
r = st.set_environment(body.environment, body.enclosure, body.note)
if r.get("changed"):
applied.append(r["detail"])
patch.setdefault("site", {}).update(
{"environment": CONFIG.site.environment,
"enclosure": CONFIG.site.enclosure})
for name, value in (("altitude_m", body.altitude_m),
("latitude", body.latitude),
("longitude", body.longitude)):
if value is not None and value != getattr(CONFIG.site, name):
applied.append(f"{name} {getattr(CONFIG.site, name)} -> {value}")
setattr(CONFIG.site, name, float(value))
patch.setdefault("site", {})[name] = float(value)
# Altitude feeds the sea-level reduction on every stored row, so the
# history is now inconsistent with the new value until re-derived.
needs_recompute = needs_recompute or name == "altitude_m"
if body.hum_psychrometric is not None and \
body.hum_psychrometric != CONFIG.sensor.hum_psychrometric:
CONFIG.sensor.hum_psychrometric = bool(body.hum_psychrometric)
st.tracker.hum_compensator.psychrometric = bool(body.hum_psychrometric)
patch.setdefault("sensor", {})["hum_psychrometric"] = bool(body.hum_psychrometric)
applied.append(f"psychrometric correction {'on' if body.hum_psychrometric else 'off'}")
needs_recompute = True
if body.led_enabled is not None and body.led_enabled != CONFIG.server.led_enabled:
CONFIG.server.led_enabled = bool(body.led_enabled)
patch.setdefault("server", {})["led_enabled"] = bool(body.led_enabled)
if display is not None:
display.enabled = bool(body.led_enabled)
if not body.led_enabled:
st.board.clear()
applied.append(f"matrix {'on' if body.led_enabled else 'off'}")
if body.led_fps is not None and body.led_fps != CONFIG.server.led_fps:
CONFIG.server.led_fps = float(body.led_fps)
patch.setdefault("server", {})["led_fps"] = float(body.led_fps)
if display is not None:
display.fps = float(body.led_fps)
applied.append(f"matrix {body.led_fps:g} fps")
if patch:
save_overrides(CONFIG, patch)
st.store.log_event("settings", "info", "; ".join(applied))
return _clean({"applied": applied, "changed": bool(applied),
"needs_recompute": needs_recompute})
@app.get("/api/status") @app.get("/api/status")
def status() -> Dict: def status() -> Dict:
st = _st() st = _st()
+45
View File
@@ -21,6 +21,7 @@ variables prefixed `ASHVALE_` (e.g. `ASHVALE_SITE__ALTITUDE_M=42`).
from __future__ import annotations from __future__ import annotations
import json
import os import os
from dataclasses import dataclass, field, fields, is_dataclass from dataclasses import dataclass, field, fields, is_dataclass
from pathlib import Path from pathlib import Path
@@ -175,6 +176,45 @@ def _apply_env(obj: Any, prefix: str = "ASHVALE_") -> None:
setattr(obj, f.name, raw) setattr(obj, f.name, raw)
# Settings changed from the dashboard land here, not in config.yaml. That file
# is hand-annotated and hand-edited per station, and rewriting it from an API
# would destroy the comments and risk clobbering something the owner set. A
# separate overlay keeps both: the file stays yours, the UI stays useful, and
# either can be reverted independently by deleting the other.
OVERRIDES_NAME = "settings.json"
def overrides_path(cfg: "Config") -> Path:
return Path(cfg.storage.state_dir) / OVERRIDES_NAME
def load_overrides(cfg: "Config") -> Dict[str, Any]:
path = overrides_path(cfg)
if not path.exists():
return {}
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh) or {}
except (OSError, ValueError):
return {}
def save_overrides(cfg: "Config", patch: Dict[str, Any]) -> Dict[str, Any]:
"""Merge a patch into the overlay and write it back."""
current = load_overrides(cfg)
for section, values in patch.items():
if not isinstance(values, dict):
continue
current.setdefault(section, {}).update(values)
path = overrides_path(cfg)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(current, fh, indent=2, sort_keys=True)
tmp.replace(path) # atomic, so a crash cannot truncate it
return current
def load_config(path: str | os.PathLike | None = None) -> Config: def load_config(path: str | os.PathLike | None = None) -> Config:
cfg = Config() cfg = Config()
candidate = Path(path) if path else REPO_ROOT / "config.yaml" candidate = Path(path) if path else REPO_ROOT / "config.yaml"
@@ -182,6 +222,11 @@ def load_config(path: str | os.PathLike | None = None) -> Config:
with open(candidate, "r", encoding="utf-8") as fh: with open(candidate, "r", encoding="utf-8") as fh:
_apply(cfg, yaml.safe_load(fh) or {}) _apply(cfg, yaml.safe_load(fh) or {})
_apply_env(cfg) _apply_env(cfg)
# Applied last: a change made from the dashboard is the most recent explicit
# instruction from a human, so it wins over both the file and the
# environment. Delete data/state/settings.json to fall back.
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
_apply(cfg, load_overrides(cfg))
Path(cfg.storage.db_path).parent.mkdir(parents=True, exist_ok=True) Path(cfg.storage.db_path).parent.mkdir(parents=True, exist_ok=True)
Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True) Path(cfg.storage.state_dir).mkdir(parents=True, exist_ok=True)
return cfg return cfg
+182 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The dashboard: five tabs in the header, one viewport, no scrolling. """The dashboard: six tabs in the header, one viewport, no scrolling.
Layout contract. The page is a fixed three-row grid pinned to the 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 viewport height: header, tab bar, then a content region that takes the
@@ -119,6 +119,7 @@ DASHBOARD_HTML = r"""
<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="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="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="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> <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> </nav>
@@ -465,6 +466,97 @@ DASHBOARD_HTML = r"""
</div> </div>
</section> </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="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 ---------------- --> <!-- ---------------- METHODS ---------------- -->
<section id="pane-methods" class="pane h-full min-h-0 gap-3 grid-cols-1 lg:grid-cols-5"> <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="glass rounded-2xl p-4 lg:col-span-2 flex flex-col min-h-0">
@@ -990,6 +1082,95 @@ el('m-hcalrst').addEventListener('click', async () => {
el('m-hcalstat').innerHTML = 'Reset to prior offset = <span class="text-cyan-300">'+r.offset+'%</span>'; 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;
}
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-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 ---------------- */ /* ---------------- STATS FOR NERDS ---------------- */
let nerdDoc = null, nerdHead = null; let nerdDoc = null, nerdHead = null;
const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d'; const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d';