diff --git a/.github/ISSUE_TEMPLATE/forecast_quality.md b/.github/ISSUE_TEMPLATE/forecast_quality.md
index fa50b83..c5d7100 100644
--- a/.github/ISSUE_TEMPLATE/forecast_quality.md
+++ b/.github/ISSUE_TEMPLATE/forecast_quality.md
@@ -11,7 +11,7 @@ labels: forecasting
```
```
-**Scorecard from the Models and calibration tab** (or `GET /api/scorecard`)
+**Scorecard from the Models and Calibration tab** (or `GET /api/scorecard`)
```json
```
diff --git a/README.md b/README.md
index c727526..364b71d 100644
--- a/README.md
+++ b/README.md
@@ -160,15 +160,16 @@ permanently.
## The dashboard
-Four tabs, one viewport, no scrolling on desktop. Below 1024 px the constraint is
-released, because pinning four panels into a phone viewport produces unreadable
+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
eight-pixel type.
| Tab | Answers |
| --- | --- |
-| **Live** | What is it doing now, what it expects next, how sure it is, and the week ahead |
+| **Live** | The week ahead, current readings, the forecast with its band, and conditions |
| **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 |
| **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 b3ec24c..7f88753 100644
--- a/ashvale/api.py
+++ b/ashvale/api.py
@@ -34,6 +34,7 @@ from pydantic import BaseModel, Field
from .config import CONFIG
from .dashboard import DASHBOARD_HTML
+from .features import FEATURE_NAMES
from .led import LedDisplay
from .methods import describe
from .station import Station
@@ -351,6 +352,107 @@ def models() -> Dict:
})
+@app.get("/api/nerd")
+def nerd() -> Dict:
+ """Every internal number the estimator and the learners are carrying.
+
+ Deliberately read-only and computed from live objects rather than stored, so
+ it cannot drift from what the station is actually using. Everything here is
+ cheap: no matrix inversions, no queries beyond what the caller already pays
+ for. `theta` is returned per head so the UI can show which of the 33 features
+ each horizon actually leans on, which is the single most revealing view of
+ what the model has learned.
+ """
+ st = _st()
+ tr = st.tracker
+
+ filters = {}
+ for name, kf in tr.filters.items():
+ P = np.asarray(kf.P, dtype=float)
+ filters[name] = {
+ "level": float(kf.x[0]), "rate_per_h": float(kf.x[1]) * 3600.0,
+ "nis": float(kf.nis),
+ "p_level": float(P[0, 0]), "p_rate": float(P[1, 1]),
+ "p_cross": float(P[0, 1]),
+ "sigma_level": float(np.sqrt(max(P[0, 0], 0.0))),
+ "q": float(kf.q), "r": float(kf.r),
+ "initialised": bool(kf.initialised),
+ }
+
+ heads = []
+ for (target, h), head in sorted(st.nowcast.heads.items()):
+ m = head.model
+ P = np.asarray(m.P, dtype=float)
+ theta = np.asarray(m.theta, dtype=float)
+ heads.append({
+ "target": target, "horizon_s": h,
+ "n_updates": int(m.n_updates),
+ "trace_p": float(np.trace(P)),
+ "theta_norm": float(np.linalg.norm(theta)),
+ "rmse_ewma": float(np.sqrt(max(m.ewma_sq_error, 0.0))),
+ "lam": float(m.lam), "p_max": float(m.p_max),
+ "eff_memory": float(1.0 / max(1.0 - m.lam, 1e-9)),
+ "alpha": float(head.conformal.alpha),
+ "alpha_target": float(head.conformal.alpha_target),
+ "coverage": (float(head.conformal.empirical_coverage)
+ if np.isfinite(head.conformal.empirical_coverage) else None),
+ "halfwidth": (float(head.conformal.quantile())
+ if np.isfinite(head.conformal.quantile()) else None),
+ "weights": {k: float(v) for k, v in
+ zip(("persistence", "climatology", "learned"), head.weights)},
+ "theta": [round(float(v), 6) for v in theta],
+ })
+
+ mono = st.monitor
+ nov = getattr(mono, "novelty", None)
+ ph = getattr(mono, "drift", None)
+ monitoring = {
+ "novelty": {
+ "d2": float(getattr(nov, "last_d2", 0.0)) if nov is not None else None,
+ "threshold": float(getattr(nov, "threshold", 0.0)) if nov is not None else None,
+ "n": int(getattr(nov, "n", 0)) if nov is not None else None,
+ "dims": int(getattr(nov, "d", 0)) if nov is not None else None,
+ "z": [round(float(v), 4) for v in np.asarray(getattr(nov, "z", []), dtype=float)]
+ if nov is not None else [],
+ },
+ "drift": {
+ "m_pos": float(getattr(ph, "m_pos", 0.0)) if ph is not None else None,
+ "m_neg": float(getattr(ph, "m_neg", 0.0)) if ph is not None else None,
+ "mean": float(getattr(ph, "mean", 0.0)) if ph is not None else None,
+ "n": int(getattr(ph, "n", 0)) if ph is not None else None,
+ "alarms": int(getattr(ph, "n_alarms", 0)) if ph is not None else None,
+ "delta": float(getattr(ph, "delta", 0.0)) if ph is not None else None,
+ },
+ }
+
+ return _clean({
+ "feature_names": list(FEATURE_NAMES),
+ "filters": filters,
+ "compensators": {
+ "thermal": tr.compensator.to_dict(),
+ "humidity": tr.hum_compensator.to_dict(),
+ },
+ "heads": heads,
+ "climatology": {
+ "ready": st.climatology.ready,
+ "annual_terms": st.climatology.use_annual,
+ "history_days": round(st.climatology.n_days, 3),
+ "diurnal_harmonics": st.climatology.kd,
+ "annual_harmonics": st.climatology.ka,
+ "ridge": st.climatology.ridge,
+ "residual_std": st.climatology.resid_std,
+ "n_coefficients": {k: len(v) for k, v in st.climatology.coef.items()},
+ },
+ "precipitation": {
+ "coefficients": st.precip.coefficients(),
+ "strong_labels": st.precip.n_strong,
+ "weak_labels": st.precip.n_weak,
+ "logloss_ewma": st.precip.ewma_logloss,
+ },
+ "monitoring": monitoring,
+ })
+
+
@app.get("/api/scorecard")
def scorecard() -> Dict:
st = _st()
diff --git a/ashvale/dashboard.py b/ashvale/dashboard.py
index c0faf40..1408bed 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: four tabs, one viewport, no scrolling.
+"""The dashboard: five tabs, 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
@@ -49,6 +49,8 @@ DASHBOARD_HTML = r"""