Skip to content

HVAC Runtime-vs-Temperature Characterization (WS2) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship the data-ready summer slice of WS2 — a one-time per-condenser runtime-vs-outdoor-temp correlation diagnostic, plus live Grafana panels (dual-axis runtime/temp timeseries + per-floor runtime stat cards).

Architecture: A standalone Python analysis script (scripts/analysis/hvac_runtime_correlation.py) fetches two daily InfluxDB series over SSH (reusing the proven ssh haos + authenticated curl path so no DB creds ever touch the dev machine or repo), computes Pearson r + OLS slope per condenser, and flags weak temperature-coupling. The pure analysis is separated from I/O so it is unit-tested with synthetic data. The live view is added to grafana/dashboard.json by cloning the schema of an existing, guards-clean timeseries panel — no fragile xychart.

Tech Stack: Python ≥3.11 (stdlib statistics + requests for nothing — fetch is via subprocessssh/curl; JSON parsed with stdlib json), pytest, InfluxDB 1.x (InfluxQL), Grafana 12.3 (file-provisioned dashboard).

Global Constraints

  • Python ≥3.11 — the script uses statistics.correlation (3.10+) and statistics.linear_regression (3.11+); no numpy/pandas.
  • No InfluxDB creds in the repo or on the dev machine — the script fetches data by shelling out to ssh haos '<curl with creds read from /config/.storage on the box>'. Never hardcode a password; never write one to a file.
  • InfluxDB facts (verified 2026-07-07): outdoor daily high = max("temperature") from measurement state, tag entity_id='forecast_home'. Condenser daily runtime = max("value") from measurement h, tag entity_id='{first,second,third}_floor_condenser_runtime_today'. The entity_id tag has no sensor. prefix.
  • Grafana datasource UID = P14502BC03F6B8823 on every panel and every target.
  • Grafana query guards (tests/test_static.py): every query must use $timeFilter (never now()-Nunit); if a query contains both fill( and tz(, fill( must come first; no ${DS_INFLUXDB}; no __inputs block.
  • Never hardcode the box LAN IP — always ssh haos / homeassistant.local.
  • Anomaly threshold: r < 0.4 ⇒ FLAG. One documented constant at the top of the script.
  • Deploy: grafana/dashboard.json deploys via CI deploy-grafana (file-provider auto-reload ~30s, no HA restart). The script is an analysis tool, not deployed.

Task 1: Diagnostic script — pure analysis core (TDD)

Files: - Create: scripts/analysis/hvac_runtime_correlation.py - Test: tests/analysis/test_hvac_correlation.py - Create: tests/analysis/__init__.py (empty, so pytest discovers the package)

Interfaces: - Produces (consumed by Task 2): - FLAG_THRESHOLD: float = 0.4 - CONDENSERS: list[tuple[str, str]][("Floor 1", "first_floor_condenser_runtime_today"), ("Floor 2", "second_floor_condenser_runtime_today"), ("Floor 3", "third_floor_condenser_runtime_today")] - pair_by_day(runtime: dict[str, float], temps: dict[str, float]) -> list[tuple[float, float]] — inner-join two {day_iso: value} maps into [(outdoor_high, runtime_hours), ...], dropping days missing either side. - analyze(pairs: list[tuple[float, float]]) -> dict — returns {"n": int, "r": float | None, "slope": float | None, "intercept": float | None, "temp_min": float | None, "temp_max": float | None, "verdict": str} where verdict is "OK", "FLAG", or "INSUFFICIENT" (n < 3 or zero variance). r/slope are None when not computable.

  • [ ] Step 1: Write the failing test
# tests/analysis/test_hvac_correlation.py
import math
import importlib.util
from pathlib import Path

_spec = importlib.util.spec_from_file_location(
    "hvac_corr",
    Path(__file__).resolve().parents[2] / "scripts" / "analysis" / "hvac_runtime_correlation.py",
)
hvac = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(hvac)


def test_pair_by_day_inner_joins_and_drops_unmatched():
    runtime = {"2026-07-01": 8.0, "2026-07-02": 10.0, "2026-07-03": 6.0}
    temps = {"2026-07-01": 96.0, "2026-07-02": 100.0, "2026-07-09": 88.0}
    pairs = hvac.pair_by_day(runtime, temps)
    # only the two overlapping days survive; ordered by day
    assert pairs == [(96.0, 8.0), (100.0, 10.0)]


def test_analyze_strong_positive_correlation_is_ok():
    # runtime rises perfectly with temperature
    pairs = [(80.0, 4.0), (85.0, 6.0), (90.0, 8.0), (95.0, 10.0), (100.0, 12.0)]
    out = hvac.analyze(pairs)
    assert out["n"] == 5
    assert out["r"] is not None and out["r"] > 0.99
    assert out["slope"] is not None and out["slope"] > 0
    assert out["verdict"] == "OK"


def test_analyze_no_coupling_is_flagged():
    # runtime flat/uncorrelated with temperature -> weak r
    pairs = [(80.0, 7.0), (85.0, 7.1), (90.0, 6.9), (95.0, 7.0), (100.0, 7.05)]
    out = hvac.analyze(pairs)
    assert out["verdict"] == "FLAG"
    assert out["r"] is None or abs(out["r"]) < hvac.FLAG_THRESHOLD


def test_analyze_insufficient_samples():
    out = hvac.analyze([(90.0, 8.0), (95.0, 10.0)])
    assert out["verdict"] == "INSUFFICIENT"
    assert out["n"] == 2
  • [ ] Step 2: Run test to verify it fails

Run: python3 -m pytest tests/analysis/test_hvac_correlation.py -v Expected: FAIL — FileNotFoundError / module load error (script doesn't exist yet).

  • [ ] Step 3: Write the minimal implementation (pure core only)
# scripts/analysis/hvac_runtime_correlation.py
"""WS2 HVAC diagnostic: correlate daily AC condenser runtime with outdoor daily high.

Pure analysis functions live here and are unit-tested. The InfluxDB fetch + CLI
(added in Task 2) shells out to `ssh haos` so no DB credentials touch this machine.
"""
from __future__ import annotations

import statistics

# r below this => runtime does not track outdoor temperature (possible stuck relay,
# thermostat fault, or refrigerant problem). Tuned once against real data.
FLAG_THRESHOLD: float = 0.4

# (display name, InfluxDB entity_id tag for *_condenser_runtime_today — no 'sensor.' prefix)
CONDENSERS: list[tuple[str, str]] = [
    ("Floor 1", "first_floor_condenser_runtime_today"),
    ("Floor 2", "second_floor_condenser_runtime_today"),
    ("Floor 3", "third_floor_condenser_runtime_today"),
]


def pair_by_day(runtime: dict[str, float], temps: dict[str, float]) -> list[tuple[float, float]]:
    """Inner-join {day: value} maps into [(outdoor_high, runtime_hours), ...], sorted by day."""
    days = sorted(set(runtime) & set(temps))
    return [(temps[d], runtime[d]) for d in days]


def analyze(pairs: list[tuple[float, float]]) -> dict:
    """Pearson r + OLS slope of runtime (y) vs outdoor high (x), with a verdict."""
    n = len(pairs)
    result = {
        "n": n, "r": None, "slope": None, "intercept": None,
        "temp_min": None, "temp_max": None, "verdict": "INSUFFICIENT",
    }
    if n < 3:
        return result
    xs = [x for x, _ in pairs]
    ys = [y for _, y in pairs]
    result["temp_min"], result["temp_max"] = min(xs), max(xs)
    # zero variance on either axis => correlation undefined
    if statistics.pstdev(xs) == 0 or statistics.pstdev(ys) == 0:
        result["verdict"] = "INSUFFICIENT"
        return result
    r = statistics.correlation(xs, ys)
    slope, intercept = statistics.linear_regression(xs, ys)
    result["r"] = r
    result["slope"] = slope
    result["intercept"] = intercept
    result["verdict"] = "OK" if r >= FLAG_THRESHOLD else "FLAG"
    return result
  • [ ] Step 4: Create the test package marker
mkdir -p tests/analysis
: > tests/analysis/__init__.py
  • [ ] Step 5: Run the test to verify it passes

Run: python3 -m pytest tests/analysis/test_hvac_correlation.py -v Expected: PASS (4 passed).

  • [ ] Step 6: Commit
git add scripts/analysis/hvac_runtime_correlation.py tests/analysis/__init__.py tests/analysis/test_hvac_correlation.py
git commit -m "test(energy): HVAC runtime-vs-temp analysis core + tests (#29)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: Diagnostic script — InfluxDB fetch, CLI, run for real, record findings

Files: - Modify: scripts/analysis/hvac_runtime_correlation.py (add fetch + main()) - Modify: projects/house_energy_strategy.md (record findings under WS2, tick the correlation checkbox)

Interfaces: - Consumes (from Task 1): FLAG_THRESHOLD, CONDENSERS, pair_by_day, analyze. - Produces: a runnable CLI (python3 scripts/analysis/hvac_runtime_correlation.py) that prints a per-condenser table and the real r/slope/verdict values recorded in the doc.

  • [ ] Step 1: Add the InfluxDB fetch + CLI to the script

Append to scripts/analysis/hvac_runtime_correlation.py:

import json
import subprocess
import sys

# Daily-reduced InfluxQL. $timeFilter is replaced by the shell wrapper with an explicit
# range (the script runs curl directly, not through Grafana, so no macro is available).
_TEMP_Q = (
    "SELECT max(\"temperature\") FROM \"state\" "
    "WHERE \"entity_id\"='forecast_home' AND time > now()-{days}d "
    "GROUP BY time(1d) fill(none)"
)
_RUNTIME_Q = (
    "SELECT max(\"value\") FROM \"h\" "
    "WHERE \"entity_id\"='{eid}' AND time > now()-{days}d "
    "GROUP BY time(1d) fill(none)"
)


def _influx_query(q: str) -> dict:
    """Run one InfluxQL query on the HAOS box via SSH; creds read from .storage there.

    Returns the parsed InfluxDB HTTP JSON. Raises on SSH/curl failure.
    """
    remote = (
        'PW=$(jq -r \'.data.entries[] | select(.domain=="influxdb") | .data.password\' '
        '/config/.storage/core.config_entries); '
        'curl -s -m 15 -G "http://a0d7b954-influxdb:8086/query" '
        '-u "homeassistant:$PW" --data-urlencode "db=homeassistant" '
        '--data-urlencode "q=' + q.replace('"', '\\"') + '"'
    )
    proc = subprocess.run(
        ["ssh", "-o", "ConnectTimeout=20", "haos", remote],
        capture_output=True, text=True, timeout=90,
    )
    if proc.returncode != 0:
        raise RuntimeError(f"ssh/curl failed: {proc.stderr.strip()}")
    return json.loads(proc.stdout)


def _series_to_daymap(payload: dict) -> dict[str, float]:
    """Turn an InfluxDB single-series result into {YYYY-MM-DD: value}, skipping nulls."""
    out: dict[str, float] = {}
    results = payload.get("results", [{}])
    series = (results[0] or {}).get("series")
    if not series:
        return out
    for ts, val in series[0].get("values", []):
        if val is None:
            continue
        out[ts[:10]] = float(val)
    return out


def fetch(days: int = 30) -> dict[str, dict[str, float]]:
    """Fetch {'temps': daymap, '<eid>': daymap, ...} for the last `days` days."""
    data = {"temps": _series_to_daymap(_influx_query(_TEMP_Q.format(days=days)))}
    for _name, eid in CONDENSERS:
        data[eid] = _series_to_daymap(_influx_query(_RUNTIME_Q.format(eid=eid, days=days)))
    return data


def main(argv: list[str] | None = None) -> int:
    argv = sys.argv[1:] if argv is None else argv
    days = int(argv[0]) if argv else 30
    data = fetch(days=days)
    temps = data["temps"]
    print(f"HVAC runtime vs outdoor-high correlation — last {days} days "
          f"({len(temps)} days of outdoor data)\n")
    print(f"{'Condenser':<10} {'n':>3} {'r':>7} {'slope(h/°F)':>12} "
          f"{'temp range':>12}  verdict")
    print("-" * 62)
    for name, eid in CONDENSERS:
        pairs = pair_by_day(data[eid], temps)
        a = analyze(pairs)
        r = "  n/a " if a["r"] is None else f"{a['r']:+.3f}"
        slope = "     n/a" if a["slope"] is None else f"{a['slope']:+.4f}"
        rng = ("  n/a" if a["temp_min"] is None
               else f"{a['temp_min']:.0f}-{a['temp_max']:.0f}°F")
        print(f"{name:<10} {a['n']:>3} {r:>7} {slope:>12} {rng:>12}  {a['verdict']}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
  • [ ] Step 2: Re-run the unit tests to confirm the pure core still passes

Run: python3 -m pytest tests/analysis/test_hvac_correlation.py -v Expected: PASS (4 passed) — the additions are import-safe (no top-level side effects).

  • [ ] Step 3: Run the diagnostic against live data

Run: python3 scripts/analysis/hvac_runtime_correlation.py 30 Expected: a 3-row table with real r, slope, temp range, and verdict per condenser. Record the printed values — they go into the doc in Step 4. In a July cooling season, healthy condensers should print OK with a positive r. If any prints FLAG, note it prominently.

  • [ ] Step 4: Record findings in the strategy doc

In projects/house_energy_strategy.md, under the WS2 section: 1. Change the task line - [ ] Build runtime vs. outdoor temperature correlation for each condenser — *needs ≥21 days of data* to - [x] Build runtime vs. outdoor temperature correlation for each condenser — see Findings below. 2. Add a findings block immediately after the WS2 task list (fill in the actual numbers from Step 3 — the row below is a template; replace every <...>):

##### Runtime-vs-outdoor-high correlation — findings (2026-07-07, <N>-day window)

Computed by `scripts/analysis/hvac_runtime_correlation.py` (Pearson r + OLS slope of daily
condenser runtime vs daily outdoor high). Flag threshold r < 0.4.

| Condenser | n | r | slope (h/°F) | temp range | verdict |
|---|---|---|---|---|---|
| Floor 1 | <n> | <r> | <slope> | <range> | <OK/FLAG> |
| Floor 2 | <n> | <r> | <slope> | <range> | <OK/FLAG> |
| Floor 3 | <n> | <r> | <slope> | <range> | <OK/FLAG> |

Interpretation: <one or two sentences — e.g. "all three track outdoor temperature strongly
(r > 0.7), consistent with healthy thermostat-driven cycling; no anomalies." OR call out any
FLAG and the suspected cause.>
  • [ ] Step 5: Commit
git add scripts/analysis/hvac_runtime_correlation.py projects/house_energy_strategy.md
git commit -m "feat(energy): HVAC runtime-vs-temp diagnostic + WS2 findings (#29)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3: Grafana panels — dual-axis runtime/temp timeseries + 3 runtime stat cards

Files: - Modify: grafana/dashboard.json (append 4 panels) - Modify: tests/test_static.py (add a structural guard for the new panels)

Interfaces: - Consumes: nothing from earlier tasks (independent deliverable). - Produces: 4 new panels — one timeseries titled HVAC Runtime vs Outdoor High and three stat panels titled Runtime Today — Floor 1/2/3, all datasource P14502BC03F6B8823.

Panel-placement facts (verified): existing panels occupy through y+h = 75; the largest panel id is 210. Grid width is 24. New panels use ids 211–214 and start at y = 75.

  • [ ] Step 1: Write the failing structural test

Add to tests/test_static.py (uses the existing _grafana() / read_text helpers):

def test_grafana_hvac_runtime_panels_present():
    """WS2 (#29): the runtime-vs-temp timeseries + 3 runtime stat cards exist,
    are well-formed, and query the correct InfluxDB series."""
    d = _grafana()
    panels = {p.get("title", ""): p for p in d.get("panels", [])}

    # dual-axis timeseries: 4 targets (3 runtime + 1 outdoor high)
    ts = panels.get("HVAC Runtime vs Outdoor High")
    assert ts is not None, "missing 'HVAC Runtime vs Outdoor High' panel"
    assert ts["type"] == "timeseries"
    queries = [t.get("query", "") for t in ts.get("targets", [])]
    assert len(queries) == 4, f"expected 4 targets, got {len(queries)}"
    assert any("forecast_home" in q for q in queries), "no outdoor-high target"
    assert sum("condenser_runtime_today" in q for q in queries) == 3
    for q in queries:
        assert "$timeFilter" in q, f"target must use $timeFilter: {q}"

    # 3 runtime stat cards
    for floor, eid in (
        ("Floor 1", "first_floor_condenser_runtime_today"),
        ("Floor 2", "second_floor_condenser_runtime_today"),
        ("Floor 3", "third_floor_condenser_runtime_today"),
    ):
        card = panels.get(f"Runtime Today — {floor}")
        assert card is not None, f"missing 'Runtime Today — {floor}' stat card"
        assert card["type"] == "stat"
        q = card["targets"][0].get("query", "")
        assert eid in q and 'FROM "h"' in q
  • [ ] Step 2: Run the test to verify it fails

Run: python3 -m pytest tests/test_static.py::test_grafana_hvac_runtime_panels_present -v Expected: FAIL — missing 'HVAC Runtime vs Outdoor High' panel.

  • [ ] Step 3: Append the 4 panels with an edit script

Create /tmp/add_hvac_panels.py and run it once (it mutates grafana/dashboard.json in place). The timeseries clones the proven schema of existing panel id 35; the outdoor-high series gets a right-axis override; runtime series draw as bars.

# /tmp/add_hvac_panels.py
import json

PATH = "grafana/dashboard.json"
DS = {"type": "influxdb", "uid": "P14502BC03F6B8823"}
d = json.load(open(PATH))

def runtime_q(eid):
    return (f'SELECT max("value") FROM "h" WHERE "entity_id" = \'{eid}\' '
            f'AND $timeFilter GROUP BY time(1d) fill(none)')

TEMP_Q = ('SELECT max("temperature") FROM "state" WHERE "entity_id" = \'forecast_home\' '
          'AND $timeFilter GROUP BY time(1d) fill(none)')

RUNTIME = [
    ("Floor 1", "first_floor_condenser_runtime_today"),
    ("Floor 2", "second_floor_condenser_runtime_today"),
    ("Floor 3", "third_floor_condenser_runtime_today"),
]

def target(refid, query, alias):
    return {"datasource": DS, "refId": refid, "rawQuery": True, "query": query,
            "resultFormat": "time_series", "alias": alias}

# --- dual-axis timeseries (id 211) ---
ts_targets = [target(chr(65 + i), runtime_q(eid), name)
              for i, (name, eid) in enumerate(RUNTIME)]
ts_targets.append(target("D", TEMP_Q, "Outdoor High"))

timeseries = {
    "id": 211,
    "type": "timeseries",
    "title": "HVAC Runtime vs Outdoor High",
    "datasource": DS,
    "gridPos": {"x": 0, "y": 75, "w": 24, "h": 8},
    "targets": ts_targets,
    "fieldConfig": {
        "defaults": {
            "custom": {"drawStyle": "bars", "fillOpacity": 60,
                       "axisPlacement": "left", "lineWidth": 1},
            "unit": "h",
        },
        "overrides": [{
            "matcher": {"id": "byName", "options": "Outdoor High"},
            "properties": [
                {"id": "custom.drawStyle", "value": "line"},
                {"id": "custom.axisPlacement", "value": "right"},
                {"id": "custom.lineWidth", "value": 2},
                {"id": "custom.fillOpacity", "value": 0},
                {"id": "unit", "value": "fahrenheit"},
                {"id": "color", "value": {"mode": "fixed", "fixedColor": "orange"}},
            ],
        }],
    },
    "options": {"legend": {"displayMode": "list", "placement": "bottom"},
                "tooltip": {"mode": "multi"}},
}

# --- 3 runtime stat cards (ids 212-214), 8-wide across the next row ---
def stat_card(pid, floor, eid, x):
    return {
        "id": pid, "type": "stat", "title": f"Runtime Today — {floor}",
        "datasource": DS,
        "gridPos": {"x": x, "y": 83, "w": 8, "h": 4},
        "targets": [target("A", (f'SELECT last("value") FROM "h" '
                                  f'WHERE "entity_id" = \'{eid}\''), floor)],
        "fieldConfig": {"defaults": {"unit": "h", "color": {"mode": "thresholds"},
                                     "thresholds": {"mode": "absolute",
                                                    "steps": [{"color": "green", "value": None}]}}},
        "options": {"graphMode": "area", "colorMode": "none",
                    "reduceOptions": {"calcs": ["lastNotNull"]}, "textMode": "value_and_name"},
    }

cards = [stat_card(212 + i, name, eid, i * 8)
         for i, (name, eid) in enumerate(RUNTIME)]

d["panels"].extend([timeseries, *cards])
json.dump(d, open(PATH, "w"), indent=2)
print("appended", len([timeseries, *cards]), "panels; total now", len(d["panels"]))

Run: python3 /tmp/add_hvac_panels.py Expected: appended 4 panels; total now <N>.

  • [ ] Step 4: Run the new test + all Grafana guards to verify they pass

Run: python3 -m pytest tests/test_static.py -v -k "grafana or json_is_valid" Expected: PASS — including test_grafana_hvac_runtime_panels_present, test_grafana_panels_have_datasource_uid, test_grafana_queries_use_timefilter_not_hardcoded_window, test_grafana_queries_fill_before_tz, test_grafana_no_unresolved_datasource_template.

Note: the stat-card query intentionally omits $timeFilter (it wants the latest value, last()), which is allowed — the timefilter guard only forbids hardcoded now()-Nunit windows, and last() with no time clause uses the dashboard picker. Confirm the guard still passes; if it flags, it is a real bug in the query, not a false positive.

  • [ ] Step 5: Commit
git add grafana/dashboard.json tests/test_static.py
git commit -m "feat(grafana): HVAC runtime-vs-temp panel + per-floor runtime stat cards (#29)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 4: Docs, final gate, deploy

Files: - Modify: docs/grafana.md (document the new panels + queries) - Modify: projects/house_energy_strategy.md (tick the stat-cards WS2 checkbox)

Interfaces: none — closes out documentation and deploys.

  • [ ] Step 1: Tick the remaining WS2 checkbox

In projects/house_energy_strategy.md WS2, change - [x] Build HVAC health dashboard — **Grafana "HVAC Power — Condensers + Air Handler" panel live**; per-floor runtime stat cards pending to - [x] Build HVAC health dashboard — **Grafana HVAC row live**: HVAC Power, HVAC Runtime vs Outdoor High, + per-floor runtime stat cards (#29).

  • [ ] Step 2: Document the panels in docs/grafana.md

Add an entry to the HVAC section of docs/grafana.md describing the two additions (match the file's existing panel-doc style — read a nearby panel entry first and mirror its format):

### HVAC Runtime vs Outdoor High
Dual-axis timeseries. Left axis: daily AC condenser runtime (hours, bars) for all three floors,
`max("value") FROM "h"` per `*_condenser_runtime_today`, `GROUP BY time(1d)`. Right axis: daily
outdoor high (°F, line), `max("temperature") FROM "state"` where `entity_id='forecast_home'`.
Shows at a glance whether runtime tracks temperature; the quantitative correlation (Pearson r) is
produced by `scripts/analysis/hvac_runtime_correlation.py` and recorded in
`projects/house_energy_strategy.md` (WS2).

### Runtime Today — Floor 1 / 2 / 3
Three stat cards, `last("value") FROM "h"` per `*_condenser_runtime_today`, with a daily-history
area sparkline. At-a-glance per-floor cooling runtime for today.
  • [ ] Step 3: Run the full suite (static + structural tiers)

Run: make lint && make pytest Expected: PASS — yamllint clean, all pytest green (new analysis test, new Grafana structural test, existing guards). make check-config (Tier 1) is unaffected — no HA config files changed.

  • [ ] Step 4: Commit
git add docs/grafana.md projects/house_energy_strategy.md
git commit -m "docs(grafana): document HVAC runtime-vs-temp panels; close WS2 dashboard task (#29)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
  • [ ] Step 5: Push and verify deploy

git push
gh run list --workflow=ci.yml --limit 1
Expected: the test job passes and deploy-grafana scp's grafana/dashboard.json; the provider auto-reloads within ~30s. After deploy, open the Grafana Home Intelligence dashboard → HVAC row and confirm (screenshot check): the runtime-vs-temp panel shows runtime bars + an outdoor-high line tracking together, and the three stat cards show plausible hours with sparklines and a resolved datasource (not "datasource not found").

  • [ ] Step 6: Close the ticket
gh issue close 29 --comment "WS2 summer slice done: runtime-vs-temp diagnostic (per-condenser Pearson r + slope, findings in house_energy_strategy.md) and Grafana HVAC row panels (runtime-vs-outdoor-high timeseries + 3 runtime stat cards). Winter items (heat-pump crossover, heat/cool conflict, furnace-blower draw) remain out of scope until heating season."

Notes for the implementer

  • Data path: the diagnostic never needs InfluxDB creds locally — it runs ssh haos '<curl>' and the box reads its own password from /config/.storage/core.config_entries via jq. This is the exact path verified during design. If SSH is unavailable, the script will raise a clear RuntimeError; that is a real environment problem, not a code bug.
  • The $timeFilter split: Grafana panel queries use $timeFilter (the guard enforces it); the standalone script uses time > now()-{days}d because it hits InfluxDB directly with no Grafana macro. These are intentionally different and both correct in their context.
  • Stat card last() query: deliberately has no time clause so it reports the newest runtime value regardless of the picker. Do not add $timeFilter to it.
  • Grafana 12.3 — the timeseries schema cloned here (drawStyle/axisPlacement overrides) is stable across 11/12; no xychart is used, avoiding the version-fragile mapping the design rejected.