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';