Skip to content

Commit 34650d7

Browse files
albinaticlaude
andcommitted
docs: V9 release notes — solar_charge, MPC cadence, BST fix, preset DHW
- CHANGELOG: full V9 entry covering all 4 fixes, MPC changes, issues closed/opened - ARCHITECTURE: solar_charge slot table, MPC loop schedule, V8→V9 rename - RUNBOOK: LP_MPC_HOURS / LP_MPC_WRITE_DEVICES / FOX_SOLAR_CHARGE_MIN_SOC_PERCENT in .env table; MPC schedule table; updated Fox V3 example with solar_charge groups - CLAUDE.md: initial commit (Claude context file — deployment, tokens, key paths) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 069c919 commit 34650d7

4 files changed

Lines changed: 248 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Changelog
22

3+
## 2026-04-19 — V9: solar_charge, MPC cadence, BST fix, preset DHW
4+
5+
### Solar-only charging (Fox ESS)
6+
- **`solar_charge` slot kind** (`lp_dispatch.py`): LP slots where `battery_charge > 0` and `grid_import ≈ 0` are now `SelfUse minSocOnGrid=100%` instead of `ForceCharge`. Eliminates the "blind ForceCharge" that pulled up to 4.8 kW from grid during PV generation hours. Hardware-tested on 2026-04-19; saves ~£2.50–3.20/day on sunny days vs the prior schedule. Closes #14.
7+
- `FOX_SOLAR_CHARGE_MIN_SOC_PERCENT` env var (default 100) controls the floor.
8+
- Fox group builder extended to carry `minSocOnGrid` per-group through merge pipeline (4-tuple).
9+
10+
### MPC intra-day re-plans
11+
- `LP_MPC_HOURS=6,9,12,15` — four checkpoints covering solar window start (09:00), mid-day (12:00), pre-peak (15:00), and morning anchor (06:00). Closes #13.
12+
- `LP_MPC_WRITE_DEVICES=true` — MPC and Octopus-fetch-triggered re-plans now push to Fox/Daikin hardware. Previously compute-only.
13+
- The Octopus fetch job at 16:05 already called `run_optimizer()`; with `LP_MPC_WRITE_DEVICES=true` this is now the critical post-rate-publish re-plan that adjusts the overnight 00:00–08:00 cheap strategy.
14+
- API budget headroom confirmed: Fox ~384/day (27% of 1440), Daikin ~99/day (50% of 200).
15+
16+
### Bug fixes
17+
- **BST timezone**: `agile.py:get_current_and_next_slots()` now converts UTC `valid_from` to `Europe/London` before comparing against `peak_start`/`peak_end`. Previously the 15:00 UTC slot (= 16:00 BST) was outside the peak window during summer.
18+
- **False notifications**: `push_cheap_window_start` / `push_peak_window_start` removed from optimizer planning loop; re-emitted in `runner.py` heartbeat with live SoC + fox_mode. Eliminates "SoC=None" Telegram alerts.
19+
- **Preset-aware DHW**: `lp_optimizer.solve_lp()` reads `OPTIMIZATION_PRESET` and selects `TARGET_DHW_TEMP_MIN_GUESTS_C` (48°C) or `TARGET_DHW_TEMP_MIN_NORMAL_C` (45°C). Previously hardcoded to normal.
20+
- **Strategy string** now includes `solar=N` slot count.
21+
22+
### Issues closed
23+
- #12 — FoxESS V3 has no native solar-only charge mode; `SelfUse + minSocOnGrid=100%` is the correct workaround.
24+
- #13 — MPC frequency: `LP_MPC_HOURS=6,9,12,15` + `LP_MPC_WRITE_DEVICES=true`.
25+
- #14 — Blind ForceCharge replaced by solar_charge logic.
26+
- #16 — V8 refactor: notifications, BST fix, preset DHW, solar-only charging.
27+
28+
### Issues opened
29+
- #18 — Daikin HTTP 400 payload pruning: `lwt_offset` is read-only when `climate_on=false` and zone already off; re-send without it on 400.
30+
331
## 2026-04-19 — OpenClaw hook-only notifications
432

533
- **Breaking:** User-facing notifications no longer use `openclaw message send` (subprocess). All deliveries use **`POST` to `OPENCLAW_HOOKS_URL`** (Gateway `/hooks/agent`). Set **`OPENCLAW_HOOKS_URL`** and **`OPENCLAW_HOOKS_TOKEN`** when `OPENCLAW_NOTIFY_ENABLED=true`.

CLAUDE.md

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# home-energy-manager — Claude context
2+
3+
## Deployment (native, no Docker)
4+
5+
As of 2026-04-18 the service runs **natively on the host as root** — Docker has been removed.
6+
7+
| Thing | Path / value |
8+
|---|---|
9+
| Python | `/root/home-energy-manager/.venv/bin/python` (3.12.3) |
10+
| SQLite DB | `/root/home-energy-manager/data/energy_state.db` |
11+
| Daikin token | `/root/home-energy-manager/data/.daikin-tokens.json` |
12+
| API server | `http://127.0.0.1:8000` |
13+
| Systemd unit | `home-energy-manager.service` |
14+
| Config | `/root/home-energy-manager/.env` |
15+
| Run command | `.venv/bin/python -m src.cli serve` |
16+
17+
### Service management
18+
19+
```bash
20+
systemctl status home-energy-manager
21+
systemctl restart home-energy-manager
22+
journalctl -u home-energy-manager -f # live logs
23+
curl http://127.0.0.1:8000/api/v1/health # quick health check
24+
```
25+
26+
### BOOT.md is outdated — ignore it
27+
The old BOOT.md refers to a `venv/` and `daemon start`. The active venv is `.venv/`, the service is systemd-managed, and `src.cli serve` is the entrypoint (not `daemon start`).
28+
29+
---
30+
31+
## Daikin Onecta — token management
32+
33+
Tokens live at `data/.daikin-tokens.json`. The access token expires every **3 hours**; the service auto-refreshes it via the refresh_token as long as the refresh_token is valid (~30 days).
34+
35+
### Refresh access token (refresh_token still valid)
36+
37+
```bash
38+
cd /root/home-energy-manager
39+
.venv/bin/python - <<'EOF'
40+
import json, time
41+
from src.daikin.auth import refresh_tokens
42+
43+
tokens = json.load(open("data/.daikin-tokens.json"))
44+
new = refresh_tokens(tokens)
45+
new["obtained_at"] = int(time.time())
46+
json.dump(new, open("data/.daikin-tokens.json", "w"), indent=2)
47+
print("Done. Expires in", new["expires_in"], "s")
48+
EOF
49+
```
50+
51+
Then `systemctl restart home-energy-manager` so the service picks it up.
52+
53+
### Full re-auth (refresh_token expired or 401 after refresh)
54+
55+
The auth flow starts a local HTTP server on port 18080 and opens a browser to the Daikin login page. On this headless VPS you need to either:
56+
57+
**Option A — SSH port-forward (recommended):**
58+
```bash
59+
# On your local machine:
60+
ssh -L 18080:localhost:18080 root@116.203.242.63
61+
# Then on the server:
62+
cd /root/home-energy-manager
63+
DAIKIN_REDIRECT_URI=http://localhost:18080/callback .venv/bin/python -m src.daikin.auth
64+
# Open the printed URL in your local browser, log in, approve
65+
```
66+
67+
**Option B — run auth, copy the code manually:**
68+
```bash
69+
cd /root/home-energy-manager
70+
.venv/bin/python -m src.daikin.auth --code CODE
71+
# where CODE is the `code=` param from the redirect URL
72+
```
73+
74+
After successful auth, new tokens are written to `DAIKIN_TOKEN_FILE` (i.e. `data/.daikin-tokens.json` when run from the project root with the env loaded). Restart the service.
75+
76+
### Check current token state
77+
78+
```bash
79+
python3 - <<'EOF'
80+
import json, datetime, time
81+
d = json.load(open("/root/home-energy-manager/data/.daikin-tokens.json"))
82+
print("obtained:", datetime.datetime.fromtimestamp(d["obtained_at"]))
83+
print("expires :", datetime.datetime.fromtimestamp(d["obtained_at"] + d["expires_in"]))
84+
print("expired :", time.time() > d["obtained_at"] + d["expires_in"])
85+
print("has refresh_token:", bool(d.get("refresh_token")))
86+
EOF
87+
```
88+
89+
### Daikin API daily rate limit
90+
91+
- **Limit:** 200 requests/day, resets ~midnight UTC.
92+
- On 2026-04-18 the limit was exhausted during migration testing.
93+
- **`DAIKIN_HTTP_429_MAX_RETRIES=0`** is set in `.env` so the client fails fast on 429 instead of sleeping for `Retry-After` seconds (which Daikin sets to ~86400 on daily-limit exhaustion). Without this the server would hang for hours on startup.
94+
- When rate-limited, Daikin MCP tools return errors immediately. The service still starts and everything else (Fox ESS, Octopus, SQLite) works normally.
95+
96+
---
97+
98+
## Key `.env` settings to know
99+
100+
```
101+
DAIKIN_TOKEN_FILE=.daikin-tokens.json # relative to cwd → data/ path set in systemd service
102+
DAIKIN_HTTP_429_MAX_RETRIES=0 # fail fast on rate limit — do not remove
103+
OPENCLAW_READ_ONLY=false # allows hardware writes via MCP
104+
OPERATION_MODE=operational # operational = live hardware writes; simulation = dry-run
105+
DB_PATH=/root/home-energy-manager/data/energy_state.db # set in systemd service env
106+
```
107+
108+
---
109+
110+
## OpenClaw MCP integration
111+
112+
OpenClaw (running at `http://127.0.0.1:18789`) connects to this project via two channels:
113+
114+
1. **nikola MCP server** — started by openclaw via `/root/.openclaw/bin/nikola-mcp`:
115+
```bash
116+
cd /root/home-energy-manager
117+
exec /root/home-energy-manager/.venv/bin/python -m src.mcp_server
118+
```
119+
This exposes 35 tools (Fox ESS, Daikin, Octopus tariffs, optimization) to the LLM.
120+
121+
2. **Skills**`/root/home-energy-manager/skills/` is loaded as an extra skill dir in openclaw.
122+
123+
The MCP server is stateless (per-call); the API server (`home-energy-manager.service`) holds all state in SQLite.
124+
125+
---
126+
127+
## Project structure (key files)
128+
129+
```
130+
src/
131+
cli/__main__.py # entrypoint: `python -m src.cli serve`
132+
api/main.py # FastAPI app + lifespan (DB init, recover_on_boot, scheduler)
133+
daikin/
134+
auth.py # OAuth2 flow + token refresh
135+
client.py # DaikinClient (wraps Onecta API)
136+
state_machine.py # recover_on_boot, apply_safe_defaults
137+
config.py # all env-var config (Config dataclass)
138+
physics.py # DHW setpoint calculations (restored from git HEAD 2026-04-18)
139+
mcp_server.py # MCP server entrypoint (used by openclaw)
140+
data/
141+
energy_state.db # SQLite (migrated from Docker volume 2026-04-18)
142+
.daikin-tokens.json # OAuth2 tokens (active)
143+
.env # secrets + config
144+
.venv/ # Python 3.12.3 venv (use this, not venv/)
145+
```
146+
147+
---
148+
149+
## What changed on 2026-04-18 (migration from Docker)
150+
151+
- Docker container (`home-energy-manager-energy-manager-1`) removed
152+
- Docker volume data (`energy_state.db`, `.daikin-tokens.json`) migrated to `data/`
153+
- `home-energy-manager.service` rewritten: native `.venv/bin/python -m src.cli serve`, no Docker
154+
- `home-energy-manager` directory chowned to root (was uid 1000)
155+
- `physics.py` restored from git HEAD (working copy had `calculate_dhw_setpoint` etc. stripped)
156+
- `DAIKIN_HTTP_429_MAX_RETRIES=0` added to `.env`
157+
- Daikin access token refreshed (valid ~3h; refresh_token intact)

docs/ARCHITECTURE.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,41 @@ flowchart LR
6464
H --> F
6565
```
6666

67-
## V8 Optimizer (PuLP MILP)
67+
## V8/V9 Optimizer (PuLP MILP)
6868

6969
Production path when `OPTIMIZER_BACKEND=lp` (default). Implementation: `src/scheduler/lp_optimizer.py` (pure model), `src/scheduler/lp_dispatch.py` (Fox + Daikin writers), `src/weather.forecast_to_lp_inputs` (half-hour PV + COP series).
7070

7171
- **Objective:** Minimize \(\sum_i (\text{import}_i \cdot \text{price}_i - \text{export}_i \cdot \text{EXPORT_RATE_PENCE})\) plus tiny battery-cycle penalty and comfort-band slack penalty.
7272
- **Constraints:** Half-hour energy balance; battery SoC with round-trip efficiency; mutex grid import vs export and charge vs discharge binaries; SEG-style **export ≤ PV use + discharge**; discrete heat-pump power buckets; DHW tank dynamics (UA loss to room); single-zone building / radiators (UA to outdoor, solar gain fraction, radiator thermal cap); shower windows and Legionella; terminal SoC/tank/indoor stitches.
7373
- **Inputs:** Octopus rates for the horizon, Open-Meteo → `WeatherLpSeries`, initial SoC (Fox cache), tank + room temps (Daikin / execution log fallbacks).
7474
- **Rollback:** Set `OPTIMIZER_BACKEND=heuristic` or POST `/api/v1/optimization/backend` with `{"backend":"heuristic"}` to use the legacy classifier in the same `run_optimizer()` entrypoint.
75+
76+
### Slot classification (`lp_dispatch.py`)
77+
78+
After solving, each half-hour slot is classified by `lp_plan_to_slots()`:
79+
80+
| Kind | Condition | Fox action |
81+
|---|---|---|
82+
| `negative` | charge > 0 **and** grid_import > 0 **and** price ≤ 0 | `ForceCharge` fdSoc=100% |
83+
| `cheap` | charge > 0 **and** grid_import > 0 | `ForceCharge` fdSoc=95% with LP-derived `fdPwr` |
84+
| `solar_charge` | charge > 0 **and** grid_import ≈ 0 | `SelfUse` **minSocOnGrid=100%** — holds battery, PV fills it |
85+
| `peak` | no HP, price ≥ peak threshold | `SelfUse` minSocOnGrid=10% |
86+
| `peak_export` | discharge + export, travel/away preset | `ForceDischarge` |
87+
| `standard` | all other | `SelfUse` minSocOnGrid=10% |
88+
89+
`solar_charge` is the key distinction from V8: the LP saying "charge from PV, no grid import" now maps to `SelfUse` instead of `ForceCharge`. FoxESS `SelfUse` mode never actively imports from grid — `minSocOnGrid=100%` only blocks battery discharge, allowing excess PV to accumulate freely.
90+
91+
### MPC loop (Model Predictive Control)
92+
93+
The plan is re-computed at four intra-day checkpoints and after the Octopus rate publish:
94+
95+
| Time (BST) | Trigger | Purpose |
96+
|---|---|---|
97+
| 06:00 | `LP_MPC_HOURS` cron | Morning anchor: live SoC after overnight ForceCharge |
98+
| 09:00 | `LP_MPC_HOURS` cron | Solar window start: correct overnight discharge shortfall |
99+
| 12:00 | `LP_MPC_HOURS` cron | Mid-day: add ForceCharge if solar underdelivered |
100+
| 15:00 | `LP_MPC_HOURS` cron | Pre-peak: last cheap window before 16:00–19:00 peak |
101+
| ~16:05 | `bulletproof_octopus_fetch_job` | **Critical**: tomorrow's rates published → LP replans full 36h horizon, adjusting tonight's discharge and overnight cheap strategy |
102+
| 23:00 | `LP_PLAN_PUSH_HOUR` | Nightly full-day dispatch |
103+
104+
`LP_MPC_WRITE_DEVICES=true` makes each checkpoint push the updated Fox schedule to hardware. API budget impact: Fox ~384/day (27% of 1440 hard limit), Daikin ~99/day (50% of 200 limit) — Daikin PATCH calls only happen at slot transitions in the heartbeat, not on every MPC compute.

docs/RUNBOOK.md

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ The script: backs up DB → git pull → pip install → DB migration → restar
7070
| `OPENCLAW_READ_ONLY` | `false` | Must be `false` for hardware writes via MCP |
7171
| `DAIKIN_DAILY_BUDGET` | `180` | Hard cap below Daikin's 200/day limit |
7272
| `FOX_DAILY_BUDGET` | `1200` | Conservative cap below Fox's 1440/day |
73+
| `LP_MPC_HOURS` | `6,9,12,15` | Intra-day MPC re-plan hours (BST/local). See MPC schedule below. |
74+
| `LP_MPC_WRITE_DEVICES` | `true` | MPC and Octopus-fetch re-plans push updated Fox/Daikin schedule to hardware |
75+
| `FOX_SOLAR_CHARGE_MIN_SOC_PERCENT` | `100` | `minSocOnGrid` for `solar_charge` SelfUse windows (blocks discharge, lets PV fill) |
7376
| `OPENCLAW_HOOKS_URL` | required if notifications on | Full URL, e.g. `http://127.0.0.1:18789/hooks/agent` |
7477
| `OPENCLAW_HOOKS_TOKEN` | required if notifications on | Same secret as Gateway `hooks.token` |
7578
| `OPENCLAW_INTERNAL_API_BASE_URL` | `http://127.0.0.1:8000` | Inserted into hook payload so the agent can `GET /api/v1/optimization/plan` |
@@ -267,18 +270,41 @@ The Fox V3 `fdPwr` parameter tells the inverter how many Watts to draw from the
267270
- `MAX_INVERTER_KW` — LP model constraint (kW). Must match your inverter nameplate. The LP will never plan more than `MAX_INVERTER_KW × 0.5 kWh` per slot regardless.
268271

269272

270-
### Fox V3 schedule structure (example from 2026-04-19)
273+
### MPC re-plan schedule
274+
275+
| Time (BST) | Trigger | Purpose |
276+
|---|---|---|
277+
| 06:00 | `LP_MPC_HOURS` cron | Morning anchor: live SoC after overnight ForceCharge |
278+
| 09:00 | `LP_MPC_HOURS` cron | Solar window start: correct overnight discharge shortfall |
279+
| 12:00 | `LP_MPC_HOURS` cron | Mid-day: add ForceCharge if solar underdelivered |
280+
| 15:00 | `LP_MPC_HOURS` cron | Pre-peak: last cheap window before 16:00–19:00 peak |
281+
| ~16:05 | Octopus fetch job | **Critical**: tomorrow's rates arrive → LP replans full 36h horizon, adjusting tonight's discharge and overnight cheap strategy |
282+
| 23:00 | Nightly push | Full next-day plan with final Agile rates |
283+
284+
Each MPC checkpoint pushes the revised Fox V3 schedule to hardware (`LP_MPC_WRITE_DEVICES=true`).
285+
286+
### Fox V3 schedule structure (example from 2026-04-20)
271287

272288
```json
273289
[
274-
{"startHour": 12, "startMinute": 30, "endHour": 14, "endMinute": 59,
275-
"workMode": "ForceCharge", "extraParam": {"minSocOnGrid": 10, "fdSoc": 95}},
276-
{"startHour": 15, "startMinute": 0, "endHour": 22, "endMinute": 59,
277-
"workMode": "SelfUse", "extraParam": {"minSocOnGrid": 10}}
290+
{"startHour": 0, "startMinute": 0, "endHour": 8, "endMinute": 59,
291+
"workMode": "SelfUse", "extraParam": {"minSocOnGrid": 10}},
292+
{"startHour": 9, "startMinute": 0, "endHour": 10, "endMinute": 30,
293+
"workMode": "SelfUse", "extraParam": {"minSocOnGrid": 100}},
294+
{"startHour": 10, "startMinute": 30, "endHour": 10, "endMinute": 59,
295+
"workMode": "ForceCharge","extraParam": {"minSocOnGrid": 10, "fdSoc": 95, "fdPwr": 1150}},
296+
{"startHour": 11, "startMinute": 0, "endHour": 12, "endMinute": 59,
297+
"workMode": "SelfUse", "extraParam": {"minSocOnGrid": 100}},
298+
{"startHour": 13, "startMinute": 0, "endHour": 14, "endMinute": 59,
299+
"workMode": "ForceCharge","extraParam": {"minSocOnGrid": 10, "fdSoc": 95, "fdPwr": 4700}},
300+
{"startHour": 15, "startMinute": 0, "endHour": 21, "endMinute": 30,
301+
"workMode": "SelfUse", "extraParam": {"minSocOnGrid": 10}},
302+
{"startHour": 21, "startMinute": 30, "endHour": 22, "endMinute": 59,
303+
"workMode": "ForceCharge","extraParam": {"minSocOnGrid": 10, "fdSoc": 95, "fdPwr": 5600}}
278304
]
279305
```
280306

281-
This means: charge to 95% SoC before peak (15:00–18:30), then self-use through peak.
307+
`SelfUse minSocOnGrid=100%` = solar_charge window: battery holds charge, PV fills it, no grid import.
282308

283309
---
284310

0 commit comments

Comments
 (0)