Commit graph

5 commits

Author SHA1 Message Date
Michiel Berger
fc90e65271 refactor: drop PV synthesis, model raw P1 net signal directly
The simulator used to reconstruct "gross household demand" by adding
back a synthesized PV trace (irradiance × kWp peak-match) to the P1 net
meter, then re-subtract a different synthesized PV per scenario. That
reconstruction was leaky — Michiel's horizontal pyranometer is at a
different location and orientation than dad's SE-facing array, so the
synthesis can't reproduce dad's actual production curve. Result: 511
hours of negative "gross demand" and phantom export inflation up to
~6 kW peak in scenarios where pv_kwp ≠ 3.

New shape: simulator works on a single signed signal, raw_demand_kw
(the P1 reading as recorded). No solar synthesis. Whatever the meter
shows is the input.

Concretely:
  - sim.py: drop synthesize_pv, reconstruct_gross_demand,
    schedule_with_planning_pv, no_foresight_schedule, groundhog_schedule,
    _oracle_daily_schedule_legacy. Rename column convention demand_kwh →
    raw_demand_kw. Plug-in discharge cap becomes max(0, raw_demand_kw).
  - web.py: drop pv_kwp/pv_yield/strategy form params. Demand slider
    now applies as an *additive* baseline shift (not multiplicative —
    multiplying scaled the export bursts too, which is wrong since dad's
    PV stays the same regardless of household consumption). Default
    demand_kwh = 2325 (dad's actual full-year net per his quote;
    extrapolated 8-month window comes out to ~1515, partial coverage).
    Saturation metric now measures (surplus ≥ pc_max), not (charge ≥
    pc_max) — the latter conflated arbitrage top-off with power-bottleneck.
  - templates/index.html: drop PV input, drop strategy radios, drop
    irradiance chart. Modal charts collapsed from 4 to 3: price, net
    meter (toggles between with/without battery), SoC.
  - app.js: mirror the above, drop pv_kwp/strategy plumbing.
  - tests: rebase fixtures on raw_demand_kw, drop synthesize_pv test.
  - scripts: drop --pv-kwp/--pv-yield flags throughout, switch column
    references to raw_demand_kw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:32:59 +02:00
Michiel Berger
9e77b35ff4 Hybrid dispatcher: greedy self-consumption + per-day grid arbitrage
Single algorithm with two passes:

Pass 1 — greedy self-consumption (real-firmware default):
    For each hour, charge any surplus, discharge into any demand.
    This nails sunny days: battery fills from morning surplus, exports
    only after capacity is reached, drains during evening peak.

Pass 2 — daily price-spread arbitrage on the residual capacity:
    For each UTC day, repeatedly find the most profitable
    cheap-charge → expensive-discharge pair (positive after round-trip
    efficiency), execute it, recompute SoC trajectory, repeat until no
    profitable cycle remains. Discharge must fit within hourly demand
    for plug-in batteries (no grid push). Sees only that day's prices,
    matching what a Tibber/Frank-style smart-charging controller does
    with day-ahead price visibility.

Effect:
- Sunny weeks (June 17-19): unchanged — greedy fill + evening discharge.
- No-sun weeks (Feb 7+): now generates arbitrage savings instead of €0.
- Mixed weeks: greedy handles surplus, arbitrage handles the rest.

Updated: test_grid_arbitrage_kicks_in_on_no_sun_days replaces the prior
"greedy doesn't arbitrage" test; now verifies the dispatcher charges
cheap hours and discharges expensive ones when no PV is available.
2026-05-01 10:36:07 +02:00
Michiel Berger
6f8238376b tests: import pytest for the new approx() use 2026-05-01 10:20:38 +02:00
Michiel Berger
b4d895224b Replace LP with greedy self-consumption — match real battery firmware
The LP was producing visually unintuitive schedules: when several surplus
hours had identical cost benefit (true with constant export_rate), the
solver picked an arbitrary subset, which the user couldn't read as 'this
is the battery doing its job'. Worse, the daily-LP variant drained to 0
every midnight because it placed zero value on next-day SoC.

Replaced oracle_daily_schedule() with a one-pass greedy dispatcher that
matches what every plug-in battery in the catalog (Marstek, Zendure,
EcoFlow, HomeWizard) actually does in 'self-consumption mode':

  for each hour:
      if exporting at meter: charge as fast as power+capacity allows
      if importing at meter: discharge to cover net demand

Trade-offs:
- Greedy doesn't do grid arbitrage (charge cheap → discharge expensive
  without a surplus source). The LP would; real plug-in firmware
  doesn't, so greedy is more honest about what dad's battery would do.
- Charts now show 'fill in the morning, overflow at midday' which is
  what users expect to see.
- Updated tests: dropped LP-arbitrage assertion, added a greedy-fills-
  from-surplus-then-overflows test that locks the new behaviour in.
2026-05-01 10:20:23 +02:00
Michiel Berger
60e0706736 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.
2026-04-30 13:46:27 +02:00