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
+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