diff --git a/README.md b/README.md index 364b71d..07bc58f 100644 --- a/README.md +++ b/README.md @@ -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% diff --git a/ashvale/api.py b/ashvale/api.py index d05a157..ef87318 100644 --- a/ashvale/api.py +++ b/ashvale/api.py @@ -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() diff --git a/ashvale/config.py b/ashvale/config.py index 6dd4a3e..6d4c768 100644 --- a/ashvale/config.py +++ b/ashvale/config.py @@ -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 diff --git a/ashvale/dashboard.py b/ashvale/dashboard.py index 36dafd0..6e4dd57 100644 --- a/ashvale/dashboard.py +++ b/ashvale/dashboard.py @@ -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""" + @@ -465,6 +466,97 @@ DASHBOARD_HTML = r""" + +
+ +
+
+
+

Surroundings

+

a change here marks a discontinuity and queues a retrain

+
+ -- +
+
+
+
where it lives
+
+

Indoors the building governs temperature and humidity, not the sky. Pressure passes through walls, which is why precipitation runs on tendency.

+
+
+
enclosure
+
+

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.

+
+
+
+ + +
+
+ +
+

Matrix

+
+
+ 8x8 display + +
+
+
frame rate--
+ +

24 costs about 11% of one core. 16 is still fluid and a third cheaper. Below 12 the crossfades judder.

+
+
+
+ +
+
+

Site and model

+

altitude feeds the sea-level reduction on every row

+
+
+ + + +
+ +
+
+
+
+
Psychrometric humidity correction
+

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.

+
+ +
+
+
+ +
+

Maintenance

+
+ + + +
+
+
+
+
@@ -990,6 +1082,95 @@ el('m-hcalrst').addEventListener('click', async () => { el('m-hcalstat').innerHTML = 'Reset to prior offset = '+r.offset+'%'; }); +/* ---------------- 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 = ''+text+''; + 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 => + '').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(' · '); + // 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 += ' — 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 = ''+label+' running...'; + try { + const r = await fetch(url, {method:'POST'}).then(r=>r.json()); + el(node).innerHTML = ''+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))+''; + } catch (err) { + el(node).innerHTML = ''+label+' failed'; + } +} +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';