v2: měkký EV cíl — oportunistické nabíjení nad target (+ strop energie)
All checks were successful
CI and deploy / migration-check (push) Successful in 44s
CI and deploy / deploy (push) Has been skipped

Uživatel: 'potřebuju do X % (tvrdý), ale klidně dobij na 100 % když je to
skoro zadarmo; při záporných cenách radši do auta než nechat na střeše'.

- V094 asset_vehicle.opportunistic_value_czk_kwh (default 1.0; = hodnota
  ušetřeného BUDOUCÍHO nabíjení — auto neumí zpět, žádný noční prodej)
- R__039 ev_sessions: + headroom_wh ((100−target) % kapacity) + opp value;
  session se nenuluje po dosažení targetu, dokud má headroom
- solver_v2: dekompozice Σ(EV) == needed − unmet + opp, opp ∈ [0, headroom],
  odměna opp×value; zároveň FIX latentního bugu — při buy<0 chyběl strop
  celkové energie do auta (model mohl pumpovat bez limitu)
- 3 testy (neg ceny sají nad target po strop; běžné ceny ne; cap při opp=0);
  eval fixtures beze změny (sessions null)

Víkend (pátek nízký tvrdý cíl + víkendová negativa → samo doplní do 100 %)
vyplývá z mechanismu, žádná speciální logika.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-12 12:17:59 +02:00
parent 2325bbcbd6
commit 85dff7f13e
6 changed files with 117 additions and 4 deletions

View File

@@ -247,6 +247,41 @@ class PvRiskFrontloadTests(unittest.TestCase):
)
class EvOpportunisticTests(unittest.TestCase):
def _session(self, needed=4000.0, headroom=20000.0, opp=1.0):
return SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=needed,
headroom_wh=headroom,
opportunistic_value_czk_kwh=opp,
)
def test_negative_prices_fill_beyond_target(self) -> None:
# buy<0 celé okno → nad target se vyplatí brát (hodnota 1 Kč/kWh + platí ti síť)
slots = [_slot(_BASE, i, buy=-1.0, sell=-0.5, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreater(delivered, 4000.0 + 2000.0, "měkký cíl má nasávat")
self.assertLessEqual(delivered, 4000.0 + 20000.0 + 1.0, "strop headroom")
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0)
def test_normal_prices_no_opportunistic(self) -> None:
# běžné ceny (buy 3) > hodnota 1 Kč/kWh → jen tvrdý cíl
slots = [_slot(_BASE, i, buy=3.0, sell=2.0, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLess(delivered, 4000.0 + 200.0)
self.assertLess(snap["objective_terms"]["ev_opp_wh"][0], 100.0)
def test_total_energy_capped_even_at_negative_buy(self) -> None:
# fix latentního bugu: bez headroom (opp=0) nesmí buy<0 pumpovat nad needed
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(16)]
sess = self._session(needed=3000.0, headroom=0.0, opp=0.0)
results, _, _ = _solve(slots, ev_sessions=(sess, None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLessEqual(delivered, 3000.0 + 1.0)
class EvDeadlineTests(unittest.TestCase):
def test_ev_energy_delivered_before_deadline(self) -> None:
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]