# 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.