Initial import: home-battery ROI simulator + cracked thuisbatterijgids calc
- Hourly data exporter (InfluxDB → CSV) for prices, P1, irradiance. - LP-based 24h-foresight oracle dispatch with SoC-consistent state engine. - Reverse-engineered thuisbatterijgids.nl formula (matches their quotes to within €0.50 across three battery configs). - Catalog scraper for the 52 batteries on thuisbatterijgids.net via their /wp-json REST endpoint. - Web app (Flask) that ranks every catalog battery by honest payback and contrasts with the store's quote, deployable via the included Procfile.
This commit is contained in:
commit
60e0706736
29 changed files with 30358 additions and 0 deletions
147
docs/quick-index.md
Normal file
147
docs/quick-index.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Quick index
|
||||
|
||||
Where each concept lives in the codebase. Update as code is added.
|
||||
|
||||
## Code map
|
||||
|
||||
| Concept | Location |
|
||||
| --- | --- |
|
||||
| InfluxDB → CSV export | `scripts/export_from_influx.py` |
|
||||
| Hourly data loader | `pluginbattery.sim.load_hourly` |
|
||||
| Battery spec + presets (`ECOFLOW_STREAM_AC`) | `pluginbattery.sim.Battery` |
|
||||
| State engine (walks SoC, clamps to limits) | `pluginbattery.sim.simulate` |
|
||||
| 24h-foresight oracle (4-var LP per UTC day) | `pluginbattery.sim.oracle_daily_schedule` |
|
||||
| NL consumer-price tariff helper | `pluginbattery.sim.apply_nl_tariff` |
|
||||
| PV synthesis from horizontal irradiance | `pluginbattery.sim.synthesize_pv` |
|
||||
| Driver: compare multiple batteries side-by-side | `scripts/compare_batteries.py` |
|
||||
| Driver: run oracle for one battery (default EcoFlow) | `scripts/run_oracle.py` |
|
||||
| Driver: honest LP vs cracked store calc, single scenario | `scripts/compare_with_store.py` |
|
||||
| Driver: capacity-vs-savings sweep | `scripts/capacity_sweep.py` |
|
||||
| Driver: rank every catalog battery by honest payback | `scripts/battery_leaderboard.py` |
|
||||
| Catalog scraper (thuisbatterijgids.net REST API) | `scripts/scrape_batteries.py` |
|
||||
| **Reverse-engineered store calculator (thuisbatterijgids.nl)** | `pluginbattery.store_calc` |
|
||||
| **Web app (Flask): live leaderboard + scenario inputs** | `pluginbattery.web` |
|
||||
| LP-side smoke tests | `tests/test_sim.py` |
|
||||
| Store-formula regression tests (locked to 3 quotes) | `tests/test_store_calc.py` |
|
||||
|
||||
## Web app
|
||||
|
||||
```
|
||||
PORT=8765 uv run python -m pluginbattery.web # local dev
|
||||
gunicorn pluginbattery.web:app # production (Procfile is set up for h4a)
|
||||
```
|
||||
|
||||
The page renders the default scenario server-side at first load (Marstek Venus B / €124/yr / 3.84 yr) so there's no blank flash. Inputs trigger a POST to `/api/calculate` which returns JSON. Results are cached per scenario hash — the first cold scenario takes ~20 s for 37 unique (cap, power) LP runs; identical re-requests are instant.
|
||||
|
||||
## Cracked store formula (thuisbatterijgids.nl)
|
||||
|
||||
Verified against three observed quotes within €0.5 each. Source: `src/pluginbattery/store_calc.py`.
|
||||
|
||||
```
|
||||
hours_to_fill = capacity_kwh / max_charge_kw
|
||||
cycles_per_day = min(1.0, 4 hours / hours_to_fill)
|
||||
arbitrage = 365 × cycles_per_day × capacity × 0.85 × (avg_retail − avg_EPEX)
|
||||
self_consume = 195 × capacity × 0.85 × avg_retail (if PV and no saldering)
|
||||
= 0 (if full saldering)
|
||||
year1 = arbitrage + self_consume
|
||||
payback @ 3% i = log(1 + cost·i/year1) / log(1+i)
|
||||
```
|
||||
|
||||
Where `avg_EPEX ≈ €0.0824/kWh` (their apparent assumed wholesale baseline).
|
||||
|
||||
The deception is in the `(avg_retail − avg_EPEX)` term: charged kWh are valued at avg wholesale (no VAT, no energy tax), discharged kWh at full retail. No NL supplier will sell you wholesale-priced kWh, so that spread doesn't exist for any real customer. Inflates dynamic-mode savings ~2× for EcoFlow, up to ~3.5× for Marstek 2.5 kW. Fixed-rate PV self-consumption side is honest within ~5%.
|
||||
|
||||
## Running comparisons
|
||||
|
||||
```
|
||||
# No PV, no saldering (post-2027 future)
|
||||
uv run python scripts/compare_batteries.py
|
||||
|
||||
# 3 kWp PV, no saldering (likely dad's situation 2027+)
|
||||
uv run python scripts/compare_batteries.py --pv-kwp 3.0
|
||||
|
||||
# 3 kWp PV with saldering still active (current 2023-24 reality)
|
||||
uv run python scripts/compare_batteries.py --pv-kwp 3.0 --saldering full
|
||||
```
|
||||
|
||||
PV total is auto-calibrated to 900 kWh/kWp/year (NL norm). Override with `--pv-target`.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
uv sync
|
||||
uv run python scripts/run_oracle.py # writes data/processed/oracle_daily.csv
|
||||
uv run pytest # smoke tests
|
||||
```
|
||||
|
||||
## Latest oracle results — payback in years
|
||||
|
||||
Window 2023-09-01 → 2024-09-01 (8745 h). Tariff: `consumer = EPEX × 1.21 + 0.136 EUR/kWh`. PV calibrated to 2700 kWh/yr at 3 kWp. 24h-foresight oracle, daily LP per UTC day.
|
||||
|
||||
The number depends *strongly* on what you assume about saldering and price level. Same hardware, same data, same dispatch — three very different paybacks:
|
||||
|
||||
| Scenario | EcoFlow €699 | Marstek 0.8 kW €1339 | Marstek 2.5 kW €1339 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| No PV, saldering on/off (same — no PV means no exports either way) | 12.0 | 16.3 | 12.6 |
|
||||
| 3 kWp PV, **full saldering** (current 2024 reality) | 13.3 | 18.9 | 14.6 |
|
||||
| 3 kWp PV, no saldering, export at raw EPEX (~€0.075/kWh) | 6.8 | 9.7 | 8.2 |
|
||||
| 3 kWp PV, no saldering, export = €0 | 5.9 | 7.7 | 6.7 |
|
||||
| **3 kWp PV, export = €0, prices × 1.5** *(matches stroomstoring.nl style sales calc)* | **3.96** | **5.81** | **4.51** |
|
||||
|
||||
The `× 1.5` factor lifts our 2023-24 average consumer price (€0.226) to ≈€0.34, the typical Dutch retail level in 2025. At that price level + zero export compensation, the EcoFlow at €699 pays back in 4.0 years — exactly matching the **stroomstoring.nl** quote of €176/yr / 3.9 years.
|
||||
|
||||
The simulator gives a different answer than the store **only because of two assumption changes**:
|
||||
1. Export rate: the store assumes €0 (no compensation at all), our default uses raw EPEX (~€0.075/kWh).
|
||||
2. Price level: the store uses current retail (~€0.34/kWh), our backtest uses what *actually happened* in 2023-24 (~€0.23/kWh).
|
||||
|
||||
Both positions are defensible — the store's is forward-looking ("buy this today and save under post-saldering 2025+ pricing"), ours is historically grounded ("had we owned this in 2023-24"). Use `--price-mult 1.5 --export-rate 0` on `compare_batteries.py` to reproduce the store quote.
|
||||
|
||||
Annual electricity bill (no battery), our house, our 2023-24 prices:
|
||||
|
||||
| Setup | Imports | Exports | Bill |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| No PV | 7277 kWh | 0 | €1497.38 |
|
||||
| 3 kWp PV, full saldering | 5708 | 1125 | €995.86 |
|
||||
| 3 kWp PV, no saldering, export = raw EPEX | 5708 | 1125 | €1158.18 |
|
||||
| 3 kWp PV, no saldering, export = 0, prices × 1.5 | 5708 | 1125 | €1803.75 |
|
||||
|
||||
Validation against dad's actual bill (different house, similar 3 kWp PV): dad reported 1976 kWh exported. With ~2700 kWh PV that implies 25–30% self-consumption — typical NL. Our simulation lands at 60% self-consumption because this house has higher base demand to soak up more PV directly.
|
||||
|
||||
## Raw data files (`data/raw/`)
|
||||
|
||||
Generated by `scripts/export_from_influx.py`. All hourly, UTC, ISO-8601 timestamps.
|
||||
|
||||
| File | Column | Unit | Source field on data-vm |
|
||||
| --- | --- | --- | --- |
|
||||
| `prices_hourly.csv` | `eur_per_kwh` | EUR/kWh, **raw EPEX** (no taxes, no markup) | `epex-price` (market='nl') / 1000 |
|
||||
| `p1_hourly.csv` | `power_w` | watts, hour-mean, **household import only** (no PV in this house) | `power-current` |
|
||||
| `solar_hourly.csv` | `irradiance_w_m2` | W/m², horizontal (zenith-facing) | `ws1-solarradiation` |
|
||||
|
||||
Tariff additions (BTW, energy tax, supplier markup, network charges, terugleverkosten, saldering) are layered on downstream (UI / cost calc), not in the export.
|
||||
|
||||
### Working window
|
||||
|
||||
**2023-09-01T00:00:00Z → 2024-09-01T00:00:00Z** (8784 hours, 366 days incl. 2024-02-29).
|
||||
|
||||
Picked over 2024-09→2025-09 because the latter contains a 19-day P1 outage (2025-03-05→23).
|
||||
|
||||
### Known gaps in the working window
|
||||
|
||||
| File | Coverage | Largest gap |
|
||||
| --- | --- | --- |
|
||||
| prices_hourly.csv | 8782/8784 (100.0%) | 1h at each DST transition — ENTSO-E artefact |
|
||||
| p1_hourly.csv | 8764/8784 (99.8%) | 12h on 2024-05-09 18:00→2024-05-10 05:00 |
|
||||
| solar_hourly.csv | 8765/8784 (99.8%) | 8h overnight 2024-01-04→05 |
|
||||
|
||||
Drop hours where any signal is missing rather than interpolating, unless an interpolation model is explicitly chosen.
|
||||
|
||||
### Sign / sense conventions
|
||||
|
||||
- `power_w`: this house has no PV today, so the field is pure household consumption. Always positive. For "with solar" scenarios, synthesize PV output from `irradiance_w_m2` × assumed kWp/tilt/azimuth and subtract from `power_w` to get net.
|
||||
- `eur_per_kwh`: pre-tax EPEX. Genuinely negative in some hours (mean 0.075, min −0.200, max 0.464 over the window). Adding 21% BTW on a negative wholesale price is a tariff modelling decision, not a data one.
|
||||
|
||||
### Value-range sanity checks (current window)
|
||||
|
||||
- Prices (EPEX raw): mean 0.075 EUR/kWh, min −0.200, max 0.464.
|
||||
- Solar: mean 92.8 W/m², max 762 W/m². Plausible for NL horizontal.
|
||||
- P1: mean 833 W, median ~335 W, max 6135 W. Reasonable household consumption.
|
||||
Loading…
Add table
Add a link
Reference in a new issue