diff --git a/CLAUDE.md b/CLAUDE.md index 0f065c1..d03d763 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,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_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_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_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_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. --- diff --git a/backend/services/discord_bot.py b/backend/services/discord_bot.py index 7870c12..5da7dc0 100644 --- a/backend/services/discord_bot.py +++ b/backend/services/discord_bot.py @@ -1,16 +1,23 @@ -"""Discord bot (gateway) — interaktivní EV zprávy s tlačítky. +"""Discord bot (gateway) — interaktivní EV zprávy se dvěma výběry. Architektura: websocket spojení jde Z BACKENDU VEN (žádný veřejný endpoint, EMS zůstává na VPN). Bot reaguje výhradně na whitelisted user ID a jediné, co umí, je patch otevřené EV session + okamžitý replan — žádné režimy, žádné registry. Bez DISCORD_BOT_TOKEN modul tiše spí (fáze A webhook). -Tlačítka (custom_id "ev:::" — DynamicItem -template, takže fungují i po restartu backendu): - h2 / h4 — odjezd za 2 / 4 hodiny (deadline = teď + N h) - morning — ráno (další výskyt default_deadline_hour vozidla, Prague) - full — do plna hned (target 100 %, deadline za hodinu → max tempo) - stop — nenabíjet (target = SoC při připojení) +UI: dva persistent Selecty (custom_id template, takže fungují i po +restartu backendu — obsluha jde přes on_interaction + regex, ne přes +zaregistrovanou View instanci): + ev:::dep — „Kdy odjíždíš?" + za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 | + zítra poledne 12:00 | pondělí ráno 7:00 + ev:::tgt — „Kolik potřebuješ?" + 30 % | 50 % | 70 % | 100 % | Nenabíjet (target = SoC při připojení) + +Každý výběr okamžitě PATCHne session (fn_ev_session_apply_patch) jen v dané +dimenzi — druhý rozměr zůstává (default z ems.fn_ev_session_defaults nebo +předchozí výběr). Legacy tlačítka h2/h4/morning/full/stop ze starších zpráv +zůstávají obsloužená (action_to_patch). Postup zřízení bota: docs/discord-ev-interaction.md. """ @@ -34,16 +41,40 @@ _PRAGUE = ZoneInfo("Europe/Prague") _POOL: asyncpg.Pool | None = None _CLIENT: Any = None # discord.Client za lazy importem -CUSTOM_ID_RE = re.compile(r"^ev:(?P\d+):(?P[a-z0-9\-]+):(?Ph2|h4|morning|full|stop)$") +CUSTOM_ID_RE = re.compile( + r"^ev:(?P\d+):(?P[a-z0-9\-]+)" + r":(?Pdep|tgt|h2|h4|morning|full|stop)$" +) -ACTION_LABELS = [ - ("h2", "🕑 za 2 h"), - ("h4", "🕓 za 4 h"), - ("morning", "🌅 ráno"), - ("full", "⚡ do plna"), - ("stop", "✋ nenabíjet"), +#: Výběr 1 — „Kdy odjíždíš?" (value, label); absolutní čas viz +#: departure_choice_to_deadline (Europe/Prague). +DEP_CHOICES: list[tuple[str, str]] = [ + ("h2", "za 2 h"), + ("h4", "za 4 h"), + ("today18", "dnes večer 18:00"), + ("tomorrow7", "zítra ráno 7:00"), + ("tomorrow12", "zítra poledne 12:00"), + ("monday7", "pondělí ráno 7:00"), ] +#: Výběr 2 — „Kolik potřebuješ?" (value, label); "stop" = nenabíjet. +TGT_CHOICES: list[tuple[str, str]] = [ + ("30", "30 %"), + ("50", "50 %"), + ("70", "70 %"), + ("100", "100 %"), + ("stop", "Nenabíjet"), +] + +#: Legacy tlačítka (starší zprávy poslané před přechodem na selecty). +LEGACY_ACTION_LABELS = { + "h2": "za 2 h", + "h4": "za 4 h", + "morning": "ráno", + "full": "do plna", + "stop": "nenabíjet", +} + def parse_custom_id(cid: str) -> tuple[int, str, str] | None: m = CUSTOM_ID_RE.match(cid or "") @@ -52,6 +83,73 @@ def parse_custom_id(cid: str) -> tuple[int, str, str] | None: return int(m.group("site")), m.group("charger"), m.group("action") +def departure_choice_to_deadline(choice: str, *, now: datetime) -> datetime: + """Absolutní deadline (Europe/Prague) pro volbu z výběru „Kdy odjíždíš?". + + Čisté a testovatelné. Volby s pevným časem znamenají NEJBLIŽŠÍ budoucí + výskyt (dnes 18:00 po 18. hodině → zítra 18:00; pondělí 7:00 z pátku je + explicitní volba — smí být i za >48 h). + """ + local = now.astimezone(_PRAGUE) + if choice == "h2": + return local + timedelta(hours=2) + if choice == "h4": + return local + timedelta(hours=4) + if choice == "today18": + candidate = local.replace(hour=18, minute=0, second=0, microsecond=0) + if candidate <= local: + candidate += timedelta(days=1) + return candidate + if choice == "tomorrow7": + return (local + timedelta(days=1)).replace( + hour=7, minute=0, second=0, microsecond=0 + ) + if choice == "tomorrow12": + return (local + timedelta(days=1)).replace( + hour=12, minute=0, second=0, microsecond=0 + ) + if choice == "monday7": + candidate = local.replace(hour=7, minute=0, second=0, microsecond=0) + candidate += timedelta(days=(0 - local.weekday()) % 7) # 0 = pondělí + if candidate <= local: + candidate += timedelta(days=7) + return candidate + raise ValueError(f"unknown departure choice {choice}") + + +def select_to_patch( + kind: str, + value: str, + *, + now: datetime, + soc_at_connect: float | None, +) -> dict: + """Patch pro fn_ev_session_apply_patch z hodnoty selectu (čisté, testovatelné). + + Patchuje VŽDY jen jednu dimenzi — druhá zůstává beze změny + (default z fn_ev_session_defaults, případně dřívější výběr). + """ + if kind == "dep": + deadline = departure_choice_to_deadline(value, now=now) + return {"target_deadline": deadline.isoformat()} + if kind == "tgt": + if value == "stop": + return {"target_soc_pct": float(soc_at_connect or 0)} + return {"target_soc_pct": float(value)} + raise ValueError(f"unknown select kind {kind}") + + +def choice_label(kind: str, value: str) -> str: + """Lidský popisek volby pro potvrzení ve zprávě.""" + if kind == "dep": + return "odjezd " + dict(DEP_CHOICES).get(value, value) + if kind == "tgt": + if value == "stop": + return "nenabíjet" + return "cíl " + dict(TGT_CHOICES).get(value, value) + return LEGACY_ACTION_LABELS.get(value, value) + + def action_to_patch( action: str, *, @@ -59,7 +157,7 @@ def action_to_patch( soc_at_connect: float | None, default_deadline_hour: int | None, ) -> dict: - """Patch pro fn_ev_session_apply_patch dle tlačítka (čisté, testovatelné).""" + """Patch pro legacy tlačítka h2/h4/morning/full/stop (starší zprávy).""" if action == "h2": return {"target_deadline": (now + timedelta(hours=2)).isoformat()} if action == "h4": @@ -96,34 +194,61 @@ def _allowed_user_ids() -> set[int]: return out +def _build_view(site_id: int, charger_code: str): + """View se dvěma selecty (persistent custom_id, timeout=None).""" + import discord + + view = discord.ui.View(timeout=None) + view.add_item( + discord.ui.Select( + custom_id=f"ev:{site_id}:{charger_code}:dep", + placeholder="🕑 Kdy odjíždíš?", + min_values=1, + max_values=1, + options=[ + discord.SelectOption(label=label, value=value) + for value, label in DEP_CHOICES + ], + ) + ) + view.add_item( + discord.ui.Select( + custom_id=f"ev:{site_id}:{charger_code}:tgt", + placeholder="🔋 Kolik potřebuješ?", + min_values=1, + max_values=1, + options=[ + discord.SelectOption(label=label, value=value) + for value, label in TGT_CHOICES + ], + ) + ) + return view + + async def post_ev_arrival( site_id: int, charger_code: str, session_id: int, text: str ) -> bool: - """Pošle zprávu s tlačítky přes bota. False = bot neběží/není kanál (fallback webhook).""" + """Pošle zprávu s výběry přes bota. False = bot neběží/není kanál (fallback webhook).""" if _CLIENT is None or not _CLIENT.is_ready(): return False - import discord - channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0) if not channel_id: return False channel = _CLIENT.get_channel(channel_id) if channel is None: return False - view = discord.ui.View(timeout=None) - for action, label in ACTION_LABELS: - view.add_item( - discord.ui.Button( - label=label, - style=discord.ButtonStyle.secondary, - custom_id=f"ev:{site_id}:{charger_code}:{action}", - ) - ) - await channel.send(content=text, view=view) + await channel.send(content=text, view=_build_view(site_id, charger_code)) return True -async def _handle_action(interaction: Any, site_id: int, charger_code: str, action: str) -> None: +async def _handle_action( + interaction: Any, + site_id: int, + charger_code: str, + action: str, + value: str | None, +) -> None: import json from services.control_exporter import export_setpoints @@ -138,12 +263,25 @@ async def _handle_action(interaction: Any, site_id: int, charger_code: str, acti "Session už není otevřená (auto odpojeno?).", ephemeral=True ) return - patch = action_to_patch( - action, - now=datetime.now(timezone.utc), - soc_at_connect=sess["soc_at_connect_pct"], - default_deadline_hour=sess["default_deadline_hour"], - ) + now = datetime.now(timezone.utc) + if action in ("dep", "tgt"): + if not value: + return + patch = select_to_patch( + action, + value, + now=now, + soc_at_connect=sess["soc_at_connect_pct"], + ) + label = choice_label(action, value) + else: # legacy tlačítka starších zpráv + patch = action_to_patch( + action, + now=now, + soc_at_connect=sess["soc_at_connect_pct"], + default_deadline_hour=sess["default_deadline_hour"], + ) + label = LEGACY_ACTION_LABELS.get(action, action) await conn.fetchval( "select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)", site_id, @@ -156,31 +294,16 @@ async def _handle_action(interaction: Any, site_id: int, charger_code: str, acti await export_setpoints(site_id, conn) new_text = await build_ev_plan_summary(site_id, charger_code, conn) - label = dict(ACTION_LABELS).get(action, action) if new_text: await interaction.message.edit( - content=new_text + f"\n_(upraveno: {label})_", view=interaction.message.components and _rebuild_view(site_id, charger_code) or None + content=new_text + f"\n_(nastaveno: {label})_", + view=_build_view(site_id, charger_code), ) await interaction.followup.send(f"Přeplánováno ✓ ({label})", ephemeral=True) -def _rebuild_view(site_id: int, charger_code: str): - import discord - - view = discord.ui.View(timeout=None) - for action, label in ACTION_LABELS: - view.add_item( - discord.ui.Button( - label=label, - style=discord.ButtonStyle.secondary, - custom_id=f"ev:{site_id}:{charger_code}:{action}", - ) - ) - return view - - async def run_discord_bot() -> None: - """Lifespan task: připojí gateway a obsluhuje tlačítka. Bez tokenu hned končí.""" + """Lifespan task: připojí gateway a obsluhuje selecty/tlačítka. Bez tokenu hned končí.""" token = (getattr(get_settings(), "discord_bot_token", "") or "").strip() if not token: logger.info("Discord bot: token není nastaven — fáze B vypnuta") @@ -205,22 +328,25 @@ async def run_discord_bot() -> None: async def on_interaction(interaction: discord.Interaction) -> None: if interaction.type != discord.InteractionType.component: return - cid = (interaction.data or {}).get("custom_id", "") + data = interaction.data or {} + cid = data.get("custom_id", "") parsed = parse_custom_id(str(cid)) if parsed is None: return allowed = _allowed_user_ids() if allowed and interaction.user.id not in allowed: await interaction.response.send_message( - "Tohle tlačítko není pro tebe. 🙂", ephemeral=True + "Tenhle výběr není pro tebe. 🙂", ephemeral=True ) return await interaction.response.defer(ephemeral=True, thinking=True) site_id, charger_code, action = parsed + values = data.get("values") or [] + value = str(values[0]) if values else None try: - await _handle_action(interaction, site_id, charger_code, action) + await _handle_action(interaction, site_id, charger_code, action, value) except Exception: - logger.exception("Discord akce selhala (%s)", cid) + logger.exception("Discord akce selhala (%s, value=%s)", cid, value) try: await interaction.followup.send( "Akce selhala — mrkni do logů.", ephemeral=True diff --git a/backend/tests/test_discord_bot.py b/backend/tests/test_discord_bot.py index 7b4279e..a12fc11 100644 --- a/backend/tests/test_discord_bot.py +++ b/backend/tests/test_discord_bot.py @@ -1,27 +1,134 @@ -"""Discord bot — čisté helpery (custom_id, patch akcí), bez sítě/discord lib.""" +"""Discord bot — čisté helpery (custom_id, výběry → patch, deadline z voleb), +bez sítě/discord lib.""" from __future__ import annotations import unittest from datetime import datetime, timezone +from zoneinfo import ZoneInfo -from services.discord_bot import action_to_patch, parse_custom_id +from services.discord_bot import ( + action_to_patch, + choice_label, + departure_choice_to_deadline, + parse_custom_id, + select_to_patch, +) -_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc) # 12:00 Prague +_PRAGUE = ZoneInfo("Europe/Prague") +# 2026-06-12 je pátek; 10:00 UTC = 12:00 Europe/Prague (CEST) +_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc) + + +def _prague(dt: datetime) -> str: + return dt.astimezone(_PRAGUE).strftime("%Y-%m-%d %H:%M") class ParseCustomIdTests(unittest.TestCase): - def test_valid(self) -> None: + def test_valid_selects(self) -> None: + self.assertEqual( + parse_custom_id("ev:2:ev-charger-1:dep"), (2, "ev-charger-1", "dep") + ) + self.assertEqual( + parse_custom_id("ev:2:ev-charger-1:tgt"), (2, "ev-charger-1", "tgt") + ) + + def test_valid_legacy_buttons(self) -> None: self.assertEqual( parse_custom_id("ev:2:ev-charger-1:h2"), (2, "ev-charger-1", "h2") ) def test_invalid(self) -> None: - for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2"): + for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2", "ev:1:c:dep:x"): self.assertIsNone(parse_custom_id(bad)) -class ActionPatchTests(unittest.TestCase): +class DepartureChoiceTests(unittest.TestCase): + """Absolutní deadline z výběru „Kdy odjíždíš?" (Europe/Prague).""" + + def test_h2(self) -> None: + dl = departure_choice_to_deadline("h2", now=_NOW) + self.assertEqual(_prague(dl), "2026-06-12 14:00") + + def test_h4(self) -> None: + dl = departure_choice_to_deadline("h4", now=_NOW) + self.assertEqual(_prague(dl), "2026-06-12 16:00") + + def test_today18_before_18(self) -> None: + dl = departure_choice_to_deadline("today18", now=_NOW) # 12:00 Prague + self.assertEqual(_prague(dl), "2026-06-12 18:00") + + def test_today18_after_18_rolls_to_next_day(self) -> None: + late = datetime(2026, 6, 12, 17, 30, tzinfo=timezone.utc) # 19:30 Prague + dl = departure_choice_to_deadline("today18", now=late) + self.assertEqual(_prague(dl), "2026-06-13 18:00") + + def test_tomorrow7_crosses_midnight(self) -> None: + # 23:30 Prague v pátek → zítra (sobota) 07:00, tj. +7,5 h + late = datetime(2026, 6, 12, 21, 30, tzinfo=timezone.utc) + dl = departure_choice_to_deadline("tomorrow7", now=late) + self.assertEqual(_prague(dl), "2026-06-13 07:00") + + def test_tomorrow12(self) -> None: + dl = departure_choice_to_deadline("tomorrow12", now=_NOW) + self.assertEqual(_prague(dl), "2026-06-13 12:00") + + def test_monday7_from_friday_allows_over_48h(self) -> None: + # explicitní volba smí přes 48h limit fn_ev_session_defaults + dl = departure_choice_to_deadline("monday7", now=_NOW) # pátek 12:00 + self.assertEqual(_prague(dl), "2026-06-15 07:00") + self.assertGreater((dl - _NOW).total_seconds(), 48 * 3600) + + def test_monday7_on_monday_before_7_is_today(self) -> None: + mon_early = datetime(2026, 6, 15, 3, 0, tzinfo=timezone.utc) # po 05:00 + dl = departure_choice_to_deadline("monday7", now=mon_early) + self.assertEqual(_prague(dl), "2026-06-15 07:00") + + def test_monday7_on_monday_after_7_is_next_week(self) -> None: + mon_late = datetime(2026, 6, 15, 8, 0, tzinfo=timezone.utc) # po 10:00 + dl = departure_choice_to_deadline("monday7", now=mon_late) + self.assertEqual(_prague(dl), "2026-06-22 07:00") + + def test_unknown_choice_raises(self) -> None: + with self.assertRaises(ValueError): + departure_choice_to_deadline("never", now=_NOW) + + +class SelectPatchTests(unittest.TestCase): + """Mapování výběrů na patch payload pro fn_ev_session_apply_patch.""" + + def test_dep_patches_only_deadline(self) -> None: + p = select_to_patch("dep", "today18", now=_NOW, soc_at_connect=55.0) + self.assertEqual(set(p), {"target_deadline"}) + self.assertIn("2026-06-12T18:00", p["target_deadline"]) + self.assertIn("+02:00", p["target_deadline"]) # Europe/Prague (CEST) + + def test_tgt_patches_only_target(self) -> None: + for value, expected in (("30", 30.0), ("50", 50.0), ("70", 70.0), ("100", 100.0)): + p = select_to_patch("tgt", value, now=_NOW, soc_at_connect=55.0) + self.assertEqual(p, {"target_soc_pct": expected}) + + def test_tgt_stop_targets_connect_soc(self) -> None: + p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=42.5) + self.assertEqual(p, {"target_soc_pct": 42.5}) + + def test_tgt_stop_without_soc(self) -> None: + p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=None) + self.assertEqual(p, {"target_soc_pct": 0.0}) + + def test_unknown_kind_raises(self) -> None: + with self.assertRaises(ValueError): + select_to_patch("foo", "30", now=_NOW, soc_at_connect=None) + + def test_labels(self) -> None: + self.assertEqual(choice_label("dep", "monday7"), "odjezd pondělí ráno 7:00") + self.assertEqual(choice_label("tgt", "70"), "cíl 70 %") + self.assertEqual(choice_label("tgt", "stop"), "nenabíjet") + + +class LegacyActionPatchTests(unittest.TestCase): + """Legacy tlačítka starších zpráv (h2/h4/morning/full/stop).""" + def _patch(self, action: str, **kw): return action_to_patch( action, diff --git a/db/migration/V098__ev_weekly_requirement.sql b/db/migration/V098__ev_weekly_requirement.sql new file mode 100644 index 0000000..7492157 --- /dev/null +++ b/db/migration/V098__ev_weekly_requirement.sql @@ -0,0 +1,36 @@ +-- Týdenní požadavky na EV: explicitní cíl (target SoC) + deadline pro den +-- v týdnu (0 = pondělí .. 6 = neděle; čas Europe/Prague). Při příjezdu vozidla +-- je čte ems.fn_ev_session_defaults (R__099): nejbližší budoucí výskyt do 48 h +-- má přednost před forecastem z ev_usage_stats i před defaulty vozidla. +-- Ruční přepis (Discord výběry / UI → fn_ev_session_apply_patch) vždy vyhrává. + +create table ems.ev_weekly_requirement ( + id serial primary key, + vehicle_id int not null references ems.asset_vehicle (id), + dow int not null check (dow between 0 and 6), + target_soc_pct numeric(5, 2) not null check (target_soc_pct between 0 and 100), + deadline_hour int not null check (deadline_hour between 0 and 23), + enabled boolean not null default true, + created_at timestamptz not null default now(), + unique (vehicle_id, dow) +); + +comment on table ems.ev_weekly_requirement is +'Týdenní požadavek na nabití vozidla: v den dow (0 = pondělí .. 6 = neděle) v deadline_hour (Europe/Prague) má mít vozidlo target_soc_pct. Vstup ems.fn_ev_session_defaults při zakládání ev_session (přednost před forecastem i defaulty).'; +comment on column ems.ev_weekly_requirement.vehicle_id is +'Vozidlo (ems.asset_vehicle), max. 1 řádek na den v týdnu.'; +comment on column ems.ev_weekly_requirement.dow is +'Den v týdnu DEADLINE: 0 = pondělí .. 6 = neděle (ISO pořadí, POZOR: ne postgres extract(dow) ani ev_usage_stats, kde 0 = neděle).'; +comment on column ems.ev_weekly_requirement.target_soc_pct is +'Cílový SoC vozidla (%) v okamžiku deadline.'; +comment on column ems.ev_weekly_requirement.deadline_hour is +'Hodina deadline v Europe/Prague (7 = 07:00 daného dne dow).'; +comment on column ems.ev_weekly_requirement.enabled is +'false = řádek se při výběru defaultů ignoruje (požadavek dočasně vypnut bez smazání).'; + +-- Seed: Tesla Model Y (home-01) — pondělí 07:00 nabitá na 90 % (služebka). +insert into ems.ev_weekly_requirement (vehicle_id, dow, target_soc_pct, deadline_hour) +select av.id, 0, 90.0, 7 +from ems.asset_vehicle av +join ems.site s on s.id = av.site_id +where s.code = 'home-01' and av.code = 'tesla-my'; diff --git a/db/routines/R__016_fn_ev_session_transition.sql b/db/routines/R__016_fn_ev_session_transition.sql index 493a46a..02fddfe 100644 --- a/db/routines/R__016_fn_ev_session_transition.sql +++ b/db/routines/R__016_fn_ev_session_transition.sql @@ -45,23 +45,14 @@ begin ac.id, av.id, now(), - -- forecast z týdenního rytmu (ev_usage_stats), fallback defaulty; - -- ruční přepis přes fn_ev_session_apply_patch vždy vyhrává. - coalesce(fc.required_soc, av.default_target_soc_pct), - coalesce( - fc.expected_departure, - case - when av.default_deadline_hour is not null then - ( - (timezone('Europe/Prague', now()))::date + interval '1 day' - + make_interval(hours => av.default_deadline_hour) - )::timestamp at time zone 'Europe/Prague' - end - ) + -- kaskáda fn_ev_session_defaults: týdenní požadavek (ev_weekly_requirement) + -- → forecast (ev_usage_stats) → defaulty vozidla; ruční přepis přes + -- fn_ev_session_apply_patch vždy vyhrává. + (d.defaults ->> 'target_soc_pct')::double precision, + (d.defaults ->> 'deadline')::timestamptz from ems.asset_ev_charger ac left join lateral ( - select v.id, v.default_target_soc_pct, v.default_deadline_hour, - v.target_soc_forecast_enabled + select v.id from ems.asset_vehicle v where v.default_charger_id = ac.id and v.site_id = ac.site_id @@ -69,15 +60,9 @@ begin order by v.id limit 1 ) av on true - left join lateral ( - select dep.expected_departure, - ems.fn_ev_required_soc(av.id, dep.expected_departure) as required_soc - from ( - select ems.fn_ev_next_departure(av.id, now()) as expected_departure - ) dep - where av.target_soc_forecast_enabled - and dep.expected_departure is not null - ) fc on true + cross join lateral ( + select ems.fn_ev_session_defaults(av.id, now()) as defaults + ) d where ac.id = p_charger_id and ac.site_id = p_site_id on conflict (charger_id) where session_end is null do nothing; @@ -98,5 +83,5 @@ begin end; $fn$; -comment on function ems.fn_ev_session_transition(int, int, text, text, timestamptz) is - 'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector).'; +comment on function ems.fn_ev_session_transition is + 'Detekce příjezdu/odjezdu EV po změně statusu nabíječky (telemetry_collector); defaulty nové session z ems.fn_ev_session_defaults.'; diff --git a/db/routines/R__099_fn_ev_session_defaults.sql b/db/routines/R__099_fn_ev_session_defaults.sql new file mode 100644 index 0000000..ec30296 --- /dev/null +++ b/db/routines/R__099_fn_ev_session_defaults.sql @@ -0,0 +1,103 @@ +-- Defaulty nové ev_session pro vozidlo: kaskáda +-- 1) ems.ev_weekly_requirement — nejbližší budoucí výskyt enabled řádku +-- do 48 h od příjezdu (deadline_hour v den dow, Europe/Prague), +-- 2) forecast z týdenního rytmu (V089: fn_ev_next_departure + +-- fn_ev_required_soc), jen při asset_vehicle.target_soc_forecast_enabled, +-- 3) defaulty vozidla (default_target_soc_pct; deadline = příští výskyt +-- default_deadline_hour v Europe/Prague). +-- Volá fn_ev_session_transition při založení session; ruční přepis přes +-- fn_ev_session_apply_patch (Discord / UI) vždy vyhrává. + +create or replace function ems.fn_ev_session_defaults( + p_vehicle_id int, + p_arrival timestamptz +) +returns jsonb +language plpgsql +stable +as $fn$ +declare + v_vehicle record; + v_weekly record; + v_forecast_departure timestamptz; + v_deadline timestamptz; +begin + select av.default_target_soc_pct, av.default_deadline_hour, + av.target_soc_forecast_enabled + into v_vehicle + from ems.asset_vehicle av + where av.id = p_vehicle_id; + + if not found then + return jsonb_build_object( + 'target_soc_pct', null, 'deadline', null, 'source', 'none' + ); + end if; + + -- 1) týdenní požadavek: nejbližší budoucí výskyt do 48 h (Europe/Prague) + select wr.target_soc_pct, occ.deadline + into v_weekly + from generate_series(0, 2) as offs + cross join lateral ( + select ((p_arrival at time zone 'Europe/Prague')::date + offs) as d + ) day + join ems.ev_weekly_requirement wr + on wr.vehicle_id = p_vehicle_id + and wr.enabled + and wr.dow = extract(isodow from day.d)::int - 1 + cross join lateral ( + select (day.d::timestamp + make_interval(hours => wr.deadline_hour)) + at time zone 'Europe/Prague' as deadline + ) occ + where occ.deadline > p_arrival + and occ.deadline <= p_arrival + interval '48 hours' + order by occ.deadline + limit 1; + + if v_weekly.deadline is not null then + return jsonb_build_object( + 'target_soc_pct', v_weekly.target_soc_pct, + 'deadline', v_weekly.deadline, + 'source', 'weekly' + ); + end if; + + -- 2) forecast z týdenního rytmu (chování shodné s dřívějším + -- fn_ev_session_transition: deadline = typický odjezd; target P80, + -- při málo datech default target) + if v_vehicle.target_soc_forecast_enabled then + v_forecast_departure := ems.fn_ev_next_departure(p_vehicle_id, p_arrival); + if v_forecast_departure is not null then + return jsonb_build_object( + 'target_soc_pct', coalesce( + ems.fn_ev_required_soc(p_vehicle_id, v_forecast_departure), + v_vehicle.default_target_soc_pct + ), + 'deadline', v_forecast_departure, + 'source', 'forecast' + ); + end if; + end if; + + -- 3) defaulty vozidla: deadline = příští výskyt default_deadline_hour + v_deadline := ( + (p_arrival at time zone 'Europe/Prague')::date::timestamp + + make_interval(hours => v_vehicle.default_deadline_hour) + ) at time zone 'Europe/Prague'; + if v_deadline <= p_arrival then + v_deadline := ( + ((p_arrival at time zone 'Europe/Prague')::date + 1)::timestamp + + make_interval(hours => v_vehicle.default_deadline_hour) + ) at time zone 'Europe/Prague'; + end if; + + return jsonb_build_object( + 'target_soc_pct', v_vehicle.default_target_soc_pct, + 'deadline', v_deadline, + 'source', 'default' + ); +end; +$fn$; + +comment on function ems.fn_ev_session_defaults is +'Target SoC + deadline pro novou ev_session: jsonb {target_soc_pct, deadline, source}. Kaskáda ev_weekly_requirement (výskyt do 48 h, Europe/Prague) → forecast (target_soc_forecast_enabled) → defaulty vozidla (deadline = příští výskyt default_deadline_hour). p_vehicle_id null/neznámé → null hodnoty.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 14dfc15..c38dcfc 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -327,8 +327,9 @@ avg/stddev kWh, km, hodina prvního odjezdu. **Použití:** `fn_ev_next_departure` (příští typický odjezd: DOW s ≥4 vzorky a ≥3 km) + `fn_ev_required_soc` (P80 spotřeby dne + 10 p.b., clamp -[`min_target_soc_pct`, 100]) → `fn_ev_session_transition` při příjezdu -(fallback defaulty; ruční patch `fn_ev_session_apply_patch` vždy vyhrává). +[`min_target_soc_pct`, 100]) — od V098 zapojeno jako 2. stupeň kaskády +`fn_ev_session_defaults` (viz níže); ruční patch `fn_ev_session_apply_patch` +vždy vyhrává. **Aktivace per vozidlo** (po ~měsíci dat): `update ems.asset_vehicle set target_soc_forecast_enabled = true where code = 'tesla-my';` @@ -336,6 +337,34 @@ a ≥3 km) + `fn_ev_required_soc` (P80 spotřeby dne + 10 p.b., clamp Tesla napojení (SoC při příjezdu → `soc_at_connect_pct`): `docs/tesla-fleet-api.md`. Registry wallboxu: `docs/04-modules/modbus-registers-teltocharge.md`. +## Týdenní požadavky + fn_ev_session_defaults (2026-06-12) + +Explicitní týdenní rytmus „v pondělí v 7:00 chci 90 %" bez čekání na +naučený forecast: tabulka **`ems.ev_weekly_requirement`** (V098) — +max 1 řádek na (vozidlo, den): `dow` (**0 = pondělí .. 6 = neděle**, ISO +pořadí — POZOR, jiné než postgres `extract(dow)` v `ev_usage_stats`), +`target_soc_pct`, `deadline_hour` (Europe/Prague), `enabled`. +Seed: tesla-my (home-01) pondělí 07:00 → 90 %. + +Defaulty nové session dává **`ems.fn_ev_session_defaults(vehicle_id, +arrival)`** (R__099) → jsonb `{target_soc_pct, deadline, source}`, kaskáda: + +1. **weekly** — nejbližší budoucí výskyt enabled řádku + `ev_weekly_requirement` do **48 h** od příjezdu (deadline = den `dow` + v `deadline_hour`, Europe/Prague). Páteční příjezd tedy pondělní + požadavek NEvyzvedne (>48 h) — nedělní večer už ano; dřívější nabití + na pondělí zajistí levné víkendové sloty samy (v2 + oportunismus), + explicitně jde vybrat „pondělí ráno 7:00" v Discordu. +2. **forecast** — `fn_ev_next_departure` + `fn_ev_required_soc`, jen při + `asset_vehicle.target_soc_forecast_enabled` (chování V089 beze změny). +3. **default** — `default_target_soc_pct`; deadline = příští výskyt + `default_deadline_hour` (Europe/Prague; dnešní, pokud je ještě před ní). + +Volá ji `fn_ev_session_transition` při založení session (SQL-first; Python +nic nepřepočítává). Ruční přepis (Discord selecty / UI → +`fn_ev_session_apply_patch`) má vždy přednost — defaulty se aplikují jen +při vzniku session. + ## Discord notifikace po příjezdu (2026-06-12, dev) Po detekci příjezdu + Tesla SoC + replanu odejde na site webhook souhrn: diff --git a/docs/discord-ev-interaction.md b/docs/discord-ev-interaction.md index 75e6b41..d7c81e3 100644 --- a/docs/discord-ev-interaction.md +++ b/docs/discord-ev-interaction.md @@ -15,34 +15,59 @@ Plán nabíjení: 11:30–13:45; 02:15–04:30 — 34.2 kWh, ø 1.85 Kč/kWh Implementace: `_notify_ev_arrival_plan` v `telemetry_collector.py` (sloty `ev*_setpoint_w > 0` z aktivního plánu shlukované do oken). -## Fáze B — zpětná vazba tlačítkem — ✅ IMPLEMENTOVÁNO (2026-06-12) +## Fáze B — zpětná vazba dvěma výběry — ✅ IMPLEMENTOVÁNO (2026-06-12) **Architektura: Discord BOT přes gateway** — spojení jde Z backendu VEN (websocket), žádný veřejný endpoint do EMS (na rozdíl od interactions webhooku). Knihovna `discord.py`, token v `/opt/ems-deploy/.env`. -Zpráva z fáze A dostane tlačítka: -`[Odjezd za 2 h] [za 4 h] [Ráno (typicky)] [Do plna hned] [Nenabíjet]` +Zpráva z fáze A dostane **dva selecty** (místo dřívější řady tlačítek): -Callback tlačítka: -1. `fn_ev_session_apply_patch(site, session, {"target_deadline": now+2h, …})` - („Do plna hned" navíc `target_soc_pct=100`; „Nenabíjet" `target_soc_pct=soc`), -2. okamžitý `run_rolling_replan` + `export_setpoints` (vzor ev_arrival), -3. bot **edituje původní zprávu** novým plánem (žádný spam). +``` +🔌 Tesla Model Y připojeno +Baterie auta: 55 % → cíl 90 % (~26 kWh) +Deadline: po 15.06. 07:00 +Plán nabíjení: 11:30–13:45; 02:15–04:30 — 26.2 kWh, ø 1.85 Kč/kWh +[ 🕑 Kdy odjíždíš? ▾ ] za 2 h | za 4 h | dnes večer 18:00 | + zítra ráno 7:00 | zítra poledne 12:00 | + pondělí ráno 7:00 +[ 🔋 Kolik potřebuješ? ▾ ] 30 % | 50 % | 70 % | 100 % | Nenabíjet +``` + +**Sémantika:** +- Každý výběr **okamžitě** PATCHne otevřenou session přes + `fn_ev_session_apply_patch` — ale jen ve SVÉ dimenzi: výběr 1 nastaví + `target_deadline` (absolutní čas Europe/Prague; pevné volby = nejbližší + budoucí výskyt, „pondělí ráno" z pátku je validní i přes 48 h), + výběr 2 nastaví `target_soc_pct`. Druhý rozměr zůstává beze změny + (default z `fn_ev_session_defaults`, případně dřívější výběr). +- „Nenabíjet" = stop akce (target = SoC při připojení → solver nic neplánuje). +- **No-click = pohotovostní režim:** bez kliknutí jede session na defaultech + z `ems.fn_ev_session_defaults` (týdenní požadavek `ev_weekly_requirement` + → forecast z rytmu → defaulty vozidla; viz `docs/04-modules/ev-charging.md`). + S `min_target_soc_pct` 30 % to znamená: drž aspoň ~30 % pro pohotovost + a zbytek doplňuj oportunisticky v levných/záporných slotech + (`opportunistic_value_czk_kwh` — měkký cíl). + +Po každém výběru: patch session → okamžitý `run_rolling_replan` + +`export_setpoints` (vzor ev_arrival) → bot **edituje původní zprávu** +přepočteným plánem (`build_ev_plan_summary`) + potvrzením +`_(nastaveno: odjezd dnes večer 18:00)_` (žádný spam). Bezpečnost: bot reaguje jen na whitelisted user ID (majitel), akce omezené -na patch session + replan (žádné režimy/registry). Tlačítka expirují -s koncem session. +na patch session + replan (žádné režimy/registry). Selecty expirují +s koncem session (uzavřená session → ephemeral „Session už není otevřená"). **Implementace:** `services/discord_bot.py` (lifespan task; discord.py gateway), `services/ev_notify.py` (sdílený souhrn plánu; bot-first, webhook -fallback). custom_id `ev:::` — tlačítka přežijí restart. -Env: `DISCORD_BOT_TOKEN`, `DISCORD_EV_CHANNEL_ID`, `DISCORD_ALLOWED_USER_IDS` -(čárkami; prázdné = bot vypnut, jede fáze A webhook). Akce: h2/h4 (deadline -teď+N), morning (další default_deadline_hour vozidla, Prague), full (100 % + -deadline za 1 h → max tempo), stop (target = SoC při připojení). Po akci: -patch session → okamžitý replan + export → bot zedituje zprávu novým plánem. -Testy: tests/test_discord_bot.py (parse, patch akcí). +fallback). custom_id `ev:::dep` / `:tgt` — obsluha přes +`on_interaction` + regex (persistent vzor), selecty přežijí restart backendu. +Legacy tlačítka `h2|h4|morning|full|stop` ze starších zpráv zůstávají +obsloužená (`action_to_patch`). Env: `DISCORD_BOT_TOKEN`, +`DISCORD_EV_CHANNEL_ID`, `DISCORD_ALLOWED_USER_IDS` (čárkami; prázdné = +bot vypnut, jede fáze A webhook). +Testy: tests/test_discord_bot.py (parse, výběr→patch, absolutní deadline +z voleb vč. půlnoci a pondělí z pátku, legacy akce). ## Výhled (fáze C) Stejný bot = kanál pro ranní triáž s dotazy („proč jsi v 19:00 nabíjel?" →