fix(planner): EV session viditelna i bez deadline / nad targetem (BUG2)
Zivy incident home-01: aktivni plan mel ev_sessions:0, ac session bezela (target 70 %). Planovac neviděl ~6 kW zatez auta a spatne rozvrhl baterii (zbytecny vecerni import). Root cause (dve pasti): - fn_planning_site_context vracela session jako null, kdyz needed_wh=0 (auto nad targetem) i kdyz target_deadline is null. - _ev_session_from_json (Python) zahazovala session bez deadline. Fix: - R__038 fn_ev_session_planning_json: session se vyradi (null) JEN bez tvrdych dat (kapacita vozidla / soc_at_connect). target_deadline smi byt NULL -- solver hard deadline constraint aplikuje jen pri needed>0; oportunisticka vrstva bezi i bez deadline. Auto nad targetem zustava v planu jako znama zatez i s headroomem k levnemu doplneni. R__039 vola helper (deduplikace dvou inline poddotazu, SQL-first). - _ev_session_from_json si NULL deadline ponecha (energy_needed_wh default 0). - testy test_ev_session_parse.py; docs ev-charging + planning-changelog; CLAUDE.md funkce. Navrh agresivnejsiho oportunistickeho algoritmu (P50 levnych oken z market_price_stats misto konstanty 1 Kc/kWh) -- NEnasazeno, k rozhodnuti, sepsano v docs/04-modules/planning.md (EV oportunismus); riziko regrese golden ekonomiky, nutny EV fixture + eval. Overeni: pytest -q 362 passed; golden replay gate 7 passed; solver_v2_eval beze zmeny (fixtures bez EV session). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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). |
|
| `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. |
|
| `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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
|
|||||||
obj = json.loads(obj)
|
obj = json.loads(obj)
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return None
|
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"))
|
td = _parse_json_dt(obj.get("target_deadline"))
|
||||||
if td is None:
|
|
||||||
return None
|
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
target_deadline=td,
|
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),
|
headroom_wh=float(obj.get("headroom_wh") or 0.0),
|
||||||
opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0),
|
opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0),
|
||||||
)
|
)
|
||||||
|
|||||||
66
backend/tests/test_ev_session_parse.py
Normal file
66
backend/tests/test_ev_session_parse.py
Normal file
@@ -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()
|
||||||
76
db/routines/R__038_fn_ev_session_planning_json.sql
Normal file
76
db/routines/R__038_fn_ev_session_planning_json.sql
Normal file
@@ -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).';
|
||||||
@@ -179,113 +179,12 @@ begin
|
|||||||
where v.site_id = p_site_id
|
where v.site_id = p_site_id
|
||||||
and ch.code in ('ev-charger-1', 'ev-charger-2');
|
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(
|
v_ev := jsonb_build_array(
|
||||||
(
|
ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-1'),
|
||||||
select case
|
ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-2')
|
||||||
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
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
select ti.battery_soc_percent
|
select ti.battery_soc_percent
|
||||||
@@ -351,4 +250,4 @@ end;
|
|||||||
$fn$;
|
$fn$;
|
||||||
|
|
||||||
comment on function ems.fn_planning_site_context is
|
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.';
|
||||||
|
|||||||
@@ -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
|
**oportunistická vrstva není omezená deadline** (auto bývá doma dál, odjezd
|
||||||
řeší rolling replan — rozhodnutí 2026-06-12).
|
ř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)
|
### 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
|
- **`asset_ev_charger.min_power_w`** (1380 W = 6 A IEC 61851) jde přes
|
||||||
|
|||||||
@@ -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
|
# 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
|
### SoC kontinuita
|
||||||
```python
|
```python
|
||||||
# battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge).
|
# battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge).
|
||||||
|
|||||||
@@ -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)
|
## 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.
|
- **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user