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
Five tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is
released, because pinning five panels into a phone viewport produces unreadable
Six tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is
released, because pinning six panels into a phone viewport produces unreadable
eight-pixel type.
| Tab | Answers |
@@ -170,6 +170,7 @@ eight-pixel type.
| **History** | What did it do, over any timeframe you ask for |
| **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 |
| **Settings** | Surroundings, site geometry, the matrix, and maintenance actions |
| **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%
+102 -1
View File
@@ -34,7 +34,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from .config import CONFIG
from .config import CONFIG, load_overrides, save_overrides
from .dashboard import DASHBOARD_HTML
from .features import FEATURE_NAMES
from .led import LedDisplay
@@ -128,6 +128,19 @@ class CalibrationIn(BaseModel):
"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):
environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor")
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))
@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")
def status() -> Dict:
st = _st()
+45
View File
@@ -21,6 +21,7 @@ variables prefixed `ASHVALE_` (e.g. `ASHVALE_SITE__ALTITUDE_M=42`).
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field, fields, is_dataclass
from pathlib import Path
@@ -175,6 +176,45 @@ def _apply_env(obj: Any, prefix: str = "ASHVALE_") -> None:
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:
cfg = Config()
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:
_apply(cfg, yaml.safe_load(fh) or {})
_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.state_dir).mkdir(parents=True, exist_ok=True)
return cfg
+182 -1
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# 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
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="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>
@@ -465,6 +466,97 @@ DASHBOARD_HTML = r"""
</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="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">
@@ -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>';
});
/* ---------------- 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 ---------------- */
let nerdDoc = null, nerdHead = null;
const HL = (h) => h<3600 ? (h/60)+'m' : h<86400 ? (h/3600)+'h' : (h/86400)+'d';