diff --git a/CLAUDE.md b/CLAUDE.md index 9f3af7f..d958331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st | `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_modbus_device_state_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_ev_session_planning_json` (EV session pro LP; nevyřazuje při needed=0), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_modbus_device_state_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. --- diff --git a/backend/services/planning/db_io.py b/backend/services/planning/db_io.py index f6aff1a..5e5fde5 100644 --- a/backend/services/planning/db_io.py +++ b/backend/services/planning/db_io.py @@ -31,12 +31,15 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]: obj = json.loads(obj) if not isinstance(obj, dict): return None + # target_deadline SMÍ být None: oportunistická session (auto nad targetem, + # nebo bez nastaveného cíle) zůstává v plánu kvůli headroomu i jako známá + # zátěž. Tvrdý deadline constraint se aplikuje jen při energy_needed_wh > 0 + # (a needed > 0 nastane jen s deadlinem). Dřív se taková session zahazovala + # (None) a plánovač pak neviděl zátěž auta — bug 2026-06-13. td = _parse_json_dt(obj.get("target_deadline")) - if td is None: - return None return SimpleNamespace( target_deadline=td, - energy_needed_wh=float(obj["energy_needed_wh"]), + energy_needed_wh=float(obj.get("energy_needed_wh") or 0.0), headroom_wh=float(obj.get("headroom_wh") or 0.0), opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0), ) diff --git a/backend/tests/test_ev_session_parse.py b/backend/tests/test_ev_session_parse.py new file mode 100644 index 0000000..0f19f8b --- /dev/null +++ b/backend/tests/test_ev_session_parse.py @@ -0,0 +1,66 @@ +"""Parser EV session z fn_planning_site_context (_ev_session_from_json). + +Bug 2026-06-13: session BEZ deadline (auto nad targetem / bez cíle) se v +parseru zahazovala (None), takže plánovač neviděl zátěž auta ani oportunismus. +Oprava: session bez deadline zůstává objektem s energy_needed_wh=0 a headroom. +""" + +import unittest + +from services.planning.db_io import _ev_session_from_json + + +class EvSessionParseTests(unittest.TestCase): + def test_none_and_empty_return_none(self) -> None: + self.assertIsNone(_ev_session_from_json(None)) + self.assertIsNone(_ev_session_from_json([])) + self.assertIsNone(_ev_session_from_json(123)) + + def test_session_without_deadline_kept_for_opportunism(self) -> None: + sess = _ev_session_from_json( + { + "target_deadline": None, + "energy_needed_wh": 0, + "headroom_wh": 18000.0, + "opportunistic_value_czk_kwh": 1.0, + } + ) + self.assertIsNotNone(sess) + assert sess is not None + self.assertIsNone(sess.target_deadline) + self.assertEqual(sess.energy_needed_wh, 0.0) + self.assertEqual(sess.headroom_wh, 18000.0) + self.assertEqual(sess.opportunistic_value_czk_kwh, 1.0) + + def test_session_with_deadline_and_need(self) -> None: + sess = _ev_session_from_json( + { + "target_deadline": "2026-06-14T05:00:00+00:00", + "energy_needed_wh": 12000.0, + "headroom_wh": 6000.0, + "opportunistic_value_czk_kwh": 1.0, + } + ) + assert sess is not None + self.assertIsNotNone(sess.target_deadline) + self.assertEqual(sess.energy_needed_wh, 12000.0) + + def test_missing_needed_defaults_zero(self) -> None: + sess = _ev_session_from_json( + {"target_deadline": None, "headroom_wh": 1000.0} + ) + assert sess is not None + self.assertEqual(sess.energy_needed_wh, 0.0) + self.assertEqual(sess.opportunistic_value_czk_kwh, 0.0) + + def test_json_string_payload(self) -> None: + sess = _ev_session_from_json( + '{"target_deadline": null, "energy_needed_wh": 0, ' + '"headroom_wh": 5000, "opportunistic_value_czk_kwh": 1.0}' + ) + assert sess is not None + self.assertEqual(sess.headroom_wh, 5000.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/routines/R__038_fn_ev_session_planning_json.sql b/db/routines/R__038_fn_ev_session_planning_json.sql new file mode 100644 index 0000000..6ec783f --- /dev/null +++ b/db/routines/R__038_fn_ev_session_planning_json.sql @@ -0,0 +1,76 @@ +-- jeden EV session objekt pro LP (fn_planning_site_context). +-- Vrací jsonb objekt session na daném wallboxu, nebo null::jsonb pokud session +-- není nebo nemá použitelná data (kapacita vozidla, SoC při připojení). +-- +-- KLÍČOVÝ ROZDÍL oproti dřívější inline logice (bug 2026-06-13): session se +-- NEVYŘAZUJE jen proto, že needed_wh = 0 (auto už nad targetem). Plánovač pak +-- neviděl ~6 kW zátěž auta a špatně rozvrhl baterii. Session zůstává v plánu, +-- dokud má oportunistický headroom (cena rozhodne, jestli se nabíjí) — měkký +-- cíl řeší solver dekompozicí Σ == needed − unmet + opp. +-- +-- Vyřazení (null) jen když chybí tvrdá data: +-- - žádná otevřená session na wallboxu, nebo +-- - neznámá kapacita vozidla / SoC při připojení (nelze spočítat Wh). +-- target_deadline SMÍ být NULL (žádný tvrdý cíl) — solver to zvládá +-- (deadline constraint se aplikuje jen při needed_wh > 0). + +drop function if exists ems.fn_ev_session_planning_json; + +create or replace function ems.fn_ev_session_planning_json( + p_site_id int, + p_charger_code text +) +returns jsonb +language sql +stable +as $fn$ + select case + when v.battery_capacity_kwh is null then null::jsonb + when es.soc_at_connect_pct is null then null::jsonb + else jsonb_build_object( + -- tvrdý cíl: jen pokud je nastaven deadline I cílový SoC (jinak null → + -- solver hard constraint vynechá, energy_needed_wh = 0). + 'target_deadline', case + when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null + else es.target_deadline + end, + 'energy_needed_wh', case + when es.target_deadline is null then 0::numeric + when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then 0::numeric + else greatest( + 0, + (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric + - es.soc_at_connect_pct::numeric) / 100.0 + * (v.battery_capacity_kwh * 1000) + - coalesce(es.energy_delivered_wh, 0)::numeric + ) + end, + -- headroom do 100 % od max(target, SoC při připojení): „nenabíjet" (nízký + -- target) nesmí ZVĚTŠIT oportunistickou vrstvu; auto fyzicky bere jen + -- energii nad svým aktuálním SoC. Při vypnutém oportunismu (value <= 0) + -- headroom = 0 — session zůstane v plánu, ale solver ji nebude doplňovat. + 'headroom_wh', case + when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( + 0, + (100 - greatest( + coalesce(es.target_soc_pct, v.default_target_soc_pct, es.soc_at_connect_pct)::numeric, + es.soc_at_connect_pct::numeric + )) / 100.0 * (v.battery_capacity_kwh * 1000) + ) + else 0 + end, + 'opportunistic_value_czk_kwh', + coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) + ) + end + from ems.ev_session es + join ems.asset_ev_charger ch on ch.id = es.charger_id + left join ems.asset_vehicle v on v.id = es.vehicle_id + where es.site_id = p_site_id + and es.session_end is null + and ch.code = p_charger_code + limit 1; +$fn$; + +comment on function ems.fn_ev_session_planning_json is + 'EV session objekt pro LP (fn_planning_site_context). Session se NEvyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunistickému headroomu i jako známá zátěž. Null jen bez použitelných dat (kapacita / soc_at_connect). target_deadline smí být NULL (bez tvrdého cíle).'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index f42e0ae..fd06f5c 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -179,113 +179,12 @@ begin where v.site_id = p_site_id and ch.code in ('ev-charger-1', 'ev-charger-2'); + -- EV session per wallbox — logika v ems.fn_ev_session_planning_json + -- (R__038): session se NEvyřazuje při needed_wh=0 (auto nad targetem), + -- zůstává v plánu kvůli oportunistickému headroomu i jako známá zátěž. v_ev := jsonb_build_array( - ( - select case - when es.target_deadline is null then null::jsonb - when v.battery_capacity_kwh is null then null::jsonb - when es.soc_at_connect_pct is null then null::jsonb - when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb - when greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ) <= 0 - and ( - coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0 - or (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) <= 0 - ) then null::jsonb - else jsonb_build_object( - 'target_deadline', es.target_deadline, - 'energy_needed_wh', greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ), - -- headroom od max(target, SoC při připojení): „nenabíjet" (nízký - -- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může - -- fyzicky vzít jen energii nad svým aktuálním SoC. - 'headroom_wh', case - when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( - 0, - (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) / 100.0 * (v.battery_capacity_kwh * 1000) - ) - else 0 - end, - 'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) - ) - end - from ems.ev_session es - join ems.asset_ev_charger ch on ch.id = es.charger_id - left join ems.asset_vehicle v on v.id = es.vehicle_id - where es.site_id = p_site_id - and es.session_end is null - and ch.code = 'ev-charger-1' - limit 1 - ), - ( - select case - when es.target_deadline is null then null::jsonb - when v.battery_capacity_kwh is null then null::jsonb - when es.soc_at_connect_pct is null then null::jsonb - when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb - when greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ) <= 0 - and ( - coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0 - or (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) <= 0 - ) then null::jsonb - else jsonb_build_object( - 'target_deadline', es.target_deadline, - 'energy_needed_wh', greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ), - -- headroom od max(target, SoC při připojení): „nenabíjet" (nízký - -- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může - -- fyzicky vzít jen energii nad svým aktuálním SoC. - 'headroom_wh', case - when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( - 0, - (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) / 100.0 * (v.battery_capacity_kwh * 1000) - ) - else 0 - end, - 'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) - ) - end - from ems.ev_session es - join ems.asset_ev_charger ch on ch.id = es.charger_id - left join ems.asset_vehicle v on v.id = es.vehicle_id - where es.site_id = p_site_id - and es.session_end is null - and ch.code = 'ev-charger-2' - limit 1 - ) + ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-1'), + ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-2') ); select ti.battery_soc_percent @@ -351,4 +250,4 @@ end; $fn$; comment on function ems.fn_planning_site_context is - 'Kontext pro planning_engine / LP (bez samotného solveru). EV session: opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou min_power_w wallboxu.'; + 'Kontext pro planning_engine / LP (bez samotného solveru). EV session přes fn_ev_session_planning_json: session se nevyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunismu i jako známá zátěž; opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou min_power_w wallboxu.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 3215b4d..fe63891 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -394,6 +394,26 @@ oportunismus). Session zůstává v plánu i po dosažení targetu, dokud má he **oportunistická vrstva není omezená deadline** (auto bývá doma dál, odjezd řeší rolling replan — rozhodnutí 2026-06-12). +### Session se NEvyřazuje při needed_wh=0 (fix 2026-06-13) + +Dřív `fn_planning_site_context` vracela `ev_sessions[e] = null`, když +`needed_wh = 0` (auto už nad targetem) **a** oportunismus byl vypnutý/headroom +nulový — a navíc úplně, když `target_deadline is null`. Druhá past byla v +Pythonu: `_ev_session_from_json` zahazovala session bez deadline. Důsledek +incidentu: aktivní plán měl `ev_sessions:0`, ač session běžela; **plánovač +neviděl ~6 kW zátěž auta** a špatně rozvrhl baterii (zbytečný večerní import). + +Oprava (R__038 `ems.fn_ev_session_planning_json` + `db_io._ev_session_from_json`): + +- Session se vyřadí (`null`) **jen** bez tvrdých dat — neznámá kapacita vozidla + nebo `soc_at_connect_pct` (nelze spočítat Wh). Jinak vždy objekt. +- **`target_deadline` smí být NULL** (žádný tvrdý cíl) — solver_v2 hard + deadline constraint aplikuje jen při `energy_needed_wh > 0`; oportunistická + vrstva běží i bez deadline. Auto nad targetem nebo bez cíle tak zůstává v + plánu jako známá zátěž i s headroomem k případnému levnému doplnění. +- `energy_needed_wh` = 0 bez deadline / cíle; headroom a opportunistic_value + beze změny (coalesce session → vozidlo). + ### Min. výkon wallboxu a účtování via-bat (2026-06-12, dev) - **`asset_ev_charger.min_power_w`** (1380 W = 6 A IEC 61851) jde přes diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 68117f9..a884b83 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -342,6 +342,47 @@ if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not Non # energy_needed = (default_target_soc - estimated_soc_from_session) * capacity ``` +### EV oportunismus — návrh agresivnějšího ocenění z cen (K ROZHODNUTÍ, 2026-06-13) + +**Stav (nasazeno):** měkký cíl = dekompozice `Σ(EV) == needed − unmet + opp`, +`opp ∈ [0, headroom]`, hodnota `opportunistic_value_czk_kwh` (default vozidla +**1 Kč/kWh**, konstanta). Session zůstává v plánu i bez deadline / nad targetem +(fix 2026-06-13). Filozofie v2: ceny, ne heuristiky priorit — solver srovná +oportunistický bonus s reálným nákladem nabití (slotový buy + degradace), takže +auto se opp vrstvou doplní **jen** když je energie levnější než bonus: typicky +**záporná cena** (auto vydělá / lepší než curtail) nebo velmi levné okno. + +**Problém uživatele:** „když je auto k dispozici, chci ho nabíjet hlavně při +ZÁPORNÉ ceně (vydělám), ne ať si to šetří na bůhvíkdy." Konstanta 1 Kč/kWh je +sice korektní (= ušetřené budoucí nabití, auto neumí prodat zpět), ale je tupá: +neodráží, jak levné jsou skutečně budoucí okna daného horizontu. + +**Návrh (NEnasazeno — ověřit ekonomikou + golden):** +1. **`opportunistic_value` odvozený z cen, ne konstanta.** Místo fixní 1 Kč/kWh + vzít **P50 budoucích levných nákupních oken** z `market_price_stats` + (`fn_get_predicted_price` / kvantil za OTE horizont) — „kolik bych typicky + zaplatil, kdybych to NEnabil teď". Drahá budoucnost → vyšší bonus (nabít teď + se vyplatí), levná budoucnost → nízký bonus (počká si). Spočítat v SQL + (`fn_planning_site_context` / nový `fn_ev_opportunistic_value`), ne v Pythonu. +2. **Záporná cena = agresivní strop = plné auto.** Při `buy < 0` (a v rozumné + míře i hluboce levných slotech) je nabití auta **zisk**: solver to už vidí + přes zápornou cenu v objective, ale headroom musí sahat k **100 %**, ne jen + k targetu — to dnes platí (headroom = 100 − max(target, soc_at_connect)), + takže stačí, aby opp vrstva nebyla zbytečně škrcená nízkým bonusem. Pro + záporné ceny lze bonus „zvednout" implicitně (cena sama < 0 stačí), explicitní + navýšení netřeba. +3. **Sladění s baterií (přirozeně z cen):** záporná cena → nabíjet auto i + baterii (oba mají kladnou hodnotu uložení / zisk); vysoká cena → ani auto, + ani export z baterie do sítě (degradace + ušlý budoucí prodej to zaplatí). + **Žádné explicitní priority** — správné účtování (slotová cena, degradace, + terminal/arbitrage hodnota) to vyřeší samo (pravidlo 8 / arbitrage-accounting). + +**Rozhodnout:** zda nahradit konstantu cenovým kvantilem (riziko: rozkmitá +golden ekonomiku — nutný eval na fixtures s EV session, které zatím nejsou). +Minimum, co je nasazeno bezpečně: session viditelná + headroom k plnému; bonus +zůstává konfigurovatelný per vozidlo/session. Až bude EV golden fixture, doplnit +bod 1 za flagem a změřit Kč. + ### SoC kontinuita ```python # battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge). diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index cd48a4b..3afc318 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-13 — EV session viditelná i bez deadline; reg 15 re-asert (2 bugy home-01) + +- **BUG1 (Modbus zápis EV rozbitý):** od ~22:45 UTC 12.6. nevznikl žádný telto journal řádek (ani failed), auto jelo failsafe 8 A místo plánovaných 0 A. **Příčina:** reg 15 (amps) byl write-on-change proti journalu (`fn_modbus_device_state_map`). Jakmile měl reg 15 řádek „0 verified", a plán dál chtěl 0, **nikdy nevznikl nový příkaz** — a TeltoCharge si po výpadku komunikace sám přepsal reg 15 na failsafe (reg 20) **bez journal řádku**. Verify čte zpět jen `written` řádky, takže drift 0 → 8 A nikdo neviděl ani neopravil (tichá divergence). **Fix:** reg 15 se zapisuje **každý tick** (re-asert), reg 19/20 zůstávají write-on-change (EEPROM); per-charger failsafe/timeout (V106 `asset_ev_charger.watchdog_failsafe_a` / `watchdog_comm_timeout_s`). „Zákaz nabíjení" = reg 15 = 0 (protokol rev 0.5 nemá samostatný enable registr). +- **BUG2 (plánovač slepý k autu):** aktivní plán měl `ev_sessions:0`, ač session běžela (target 70 %) → plán neviděl ~6 kW zátěž, špatně rozvrhl baterii (zbytečný večerní import). **Příčina:** `fn_planning_site_context` vracela session jako `null`, když `needed_wh=0` (auto nad targetem) i když `target_deadline is null`; navíc `_ev_session_from_json` zahazovala session bez deadline (Python). **Fix:** R__038 `fn_ev_session_planning_json` — session se vyřadí jen bez tvrdých dat (kapacita / soc_at_connect); `target_deadline` smí být NULL (solver hard constraint aplikuje jen při needed>0; oportunistická vrstva běží i bez deadline). `_ev_session_from_json` si NULL deadline ponechá. +- **Soubory:** V106, R__038, R__039 (volá helper), `services/control/outputs.py`, `services/planning/db_io.py`; testy `test_ev_write_on_change.py`, `test_ev_session_parse.py`; docs teltocharge / journal / ev-charging. +- **Ověření:** `pytest -q` 362 passed; golden replay gate 7 passed; solver_v2_eval beze změny (fixtures bez EV session — golden potvrzuje žádnou regresi na neEV cestě). +- **K ROZHODNUTÍ (nenasazeno):** agresivnější oportunistický algoritmus z cen (P50 levných oken z `market_price_stats` místo konstanty 1 Kč/kWh) — návrh v `docs/04-modules/planning.md` sekce „EV oportunismus — návrh". + ## 2026-06-13 — degradační cena dle skutečných cen packů (V103) - **Problém:** seedy nesly default 0.50 Kč/kWh u KV1/BA81/HU1 — u malých packů zabíjel mělké arbitráže, u HU1 zkresloval studii spotové smlouvy.