diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index 75d445e..5fa6fde 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -257,6 +257,14 @@ async def lifespan(app: FastAPI): "scheduled_tuv_usage_stats site=%s failed", site["id"] ) + async def scheduled_ev_usage_stats() -> None: + async with app.state.pg_pool.acquire() as conn: + try: + n = await conn.fetchval("select ems.fn_update_ev_usage_stats(60)") + logger.info("ev_usage_stats updated %s rows", n) + except Exception: + logger.exception("scheduled_ev_usage_stats failed") + async def scheduled_forecast_refresh() -> None: async with app.state.pg_pool.acquire() as conn: for site in await _active_site_rows(conn): @@ -423,6 +431,14 @@ async def lifespan(app: FastAPI): id="tuv_usage_stats", replace_existing=True, ) + scheduler.add_job( + scheduled_ev_usage_stats, + "cron", + hour=0, + minute=50, + id="ev_usage_stats", + replace_existing=True, + ) scheduler.add_job( scheduled_ote_import, "cron", diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 93976dc..8ef7c92 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -55,6 +55,49 @@ async def _on_ev_arrival(site_id: int, charger_code: str) -> None: ) +async def _on_ev_departure(site_id: int, charger_code: str) -> None: + """Odjezd: zapsat pozorování (odometer+SoC) — auto právě jede, je vzhůru. + + Pár odjezd→příjezd dává jízdu (km, kWh) pro ev_usage_stats. Spící/nečitelné + auto (408) = tiché přeskočení, jízda se dopočítá z příštích pozorování. + """ + if _BG_POOL is None: + return + try: + from app.db_json import fetch_json + from services.tesla_client import get_charge_state + + async with _BG_POOL.acquire() as conn: + ctx = await fetch_json( + conn, + "select ems.fn_tesla_arrival_context($1::int, $2::text)", + site_id, + charger_code, + ) + if not isinstance(ctx, dict) or ctx.get("api_type") != "tesla": + return + state = await get_charge_state(conn, ctx.get("vin")) + if state is None: + return + await conn.execute( + "select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'departure', $3::numeric, $4::numeric, $5::text)", + site_id, + int(ctx["vehicle_id"]), + state.get("odometer_km"), + float(state["battery_level"]), + state.get("charging_state"), + ) + logger.info( + "EV departure obs (site=%s, %s): soc=%s%%, odo=%s km", + site_id, charger_code, + state["battery_level"], state.get("odometer_km"), + ) + except Exception: + logger.exception( + "EV departure obs failed (site=%s, %s)", site_id, charger_code + ) + + async def _patch_session_from_tesla( site_id: int, charger_code: str, conn: asyncpg.Connection ) -> None: @@ -83,6 +126,15 @@ async def _patch_session_from_tesla( if state is None: return + await conn.execute( + "select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'arrival', $3::numeric, $4::numeric, $5::text)", + site_id, + int(ctx["vehicle_id"]), + state.get("odometer_km"), + float(state["battery_level"]), + state.get("charging_state"), + ) + if not ctx.get("vin") and state.get("vin"): await conn.execute( "select ems.fn_vehicle_set_vin($1::int, $2::text)", @@ -353,6 +405,7 @@ async def poll_ev_chargers(site_id: int, db: asyncpg.Connection) -> None: asyncio.create_task(_on_ev_arrival(site_id, str(code))) elif previous_status != "available" and current_status == "available": logger.info("EV departure detected on charger %s", code) + asyncio.create_task(_on_ev_departure(site_id, str(code))) async def poll_heat_pump(site_id: int, db: asyncpg.Connection) -> None: diff --git a/backend/services/tesla_client.py b/backend/services/tesla_client.py index 9d661a4..600287a 100644 --- a/backend/services/tesla_client.py +++ b/backend/services/tesla_client.py @@ -32,18 +32,27 @@ HTTP_TIMEOUT_S = 15.0 ACCESS_EXPIRY_MARGIN_S = 120 +MILES_TO_KM = 1.609344 + + def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None: - """Čistý parser odpovědi vehicle_data → {battery_level, charge_limit_soc, charging_state}.""" + """Parser vehicle_data → battery_level, charge_limit_soc, charging_state, odometer_km. + + POZOR: Tesla API vrací odometer v MÍLÍCH → převod na km. + """ resp = vehicle_data.get("response") or {} cs = resp.get("charge_state") or {} + vs = resp.get("vehicle_state") or {} level = cs.get("battery_level") if level is None: return None + odo_miles = vs.get("odometer") return { "vin": resp.get("vin"), "battery_level": int(level), "charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None, "charging_state": cs.get("charging_state"), + "odometer_km": round(float(odo_miles) * MILES_TO_KM, 1) if odo_miles is not None else None, } @@ -135,7 +144,7 @@ async def get_charge_state( r = await client.get( f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data", - params={"endpoints": "charge_state"}, + params={"endpoints": "charge_state;vehicle_state"}, ) if r.status_code == 408: logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno") diff --git a/backend/tests/test_tesla_client.py b/backend/tests/test_tesla_client.py index 4bf66b8..6159723 100644 --- a/backend/tests/test_tesla_client.py +++ b/backend/tests/test_tesla_client.py @@ -28,6 +28,20 @@ class ParseChargeStateTests(unittest.TestCase): self.assertIsNone(parse_charge_state({"response": {"charge_state": {}}})) self.assertIsNone(parse_charge_state({})) + def test_odometer_miles_to_km(self) -> None: + data = { + "response": { + "charge_state": {"battery_level": 60}, + "vehicle_state": {"odometer": 12345.6}, # míle! + } + } + out = parse_charge_state(data) + self.assertAlmostEqual(out["odometer_km"], 19868.3, places=1) + + def test_missing_odometer_is_none(self) -> None: + data = {"response": {"charge_state": {"battery_level": 60}}} + self.assertIsNone(parse_charge_state(data)["odometer_km"]) + def test_zero_limit_normalized_to_none(self) -> None: data = {"response": {"charge_state": {"battery_level": 10, "charge_limit_soc": 0}}} self.assertIsNone(parse_charge_state(data)["charge_limit_soc"]) diff --git a/db/migration/V089__ev_usage_forecast.sql b/db/migration/V089__ev_usage_forecast.sql new file mode 100644 index 0000000..3cd22f6 --- /dev/null +++ b/db/migration/V089__ev_usage_forecast.sql @@ -0,0 +1,63 @@ +-- EV spotřební forecast: pozorování (odometer+SoC při příjezdu/odjezdu, auto je +-- vzhůru — žádné buzení navíc), jízdy, statistiky per den v týdnu. Cíl: target +-- SoC a deadline session z reálného týdenního rytmu (pondělí služebka ~150 km +-- → skoro plná; konec týdne míň; víkend = levné sloty na přípravu pondělka). +-- Zapnutí per vozidlo: target_soc_forecast_enabled (default false = sbírá se, +-- session jedou na defaultech). + +alter table ems.asset_vehicle + add column if not exists target_soc_forecast_enabled boolean not null default false, + add column if not exists min_target_soc_pct numeric(5,2) not null default 30.0; + +comment on column ems.asset_vehicle.target_soc_forecast_enabled is +'true = target SoC + deadline session z ev_usage_stats (fn_ev_required_soc); false = default_target_soc_pct/default_deadline_hour. Forecast vyžaduje >= 4 vzorky pro daný den v týdnu, jinak fallback.'; +comment on column ems.asset_vehicle.min_target_soc_pct is +'Komfortní spodní mez forecast targetu (%). Forecast smí jít pod default_target_soc_pct (např. pátek), ne pod tuto mez.'; + +create table ems.ev_vehicle_obs ( + id bigserial primary key, + site_id int not null references ems.site (id), + vehicle_id int not null references ems.asset_vehicle (id), + observed_at timestamptz not null default now(), + trigger text not null check (trigger in ('arrival', 'departure', 'manual')), + odometer_km numeric(10, 1), + soc_pct numeric(5, 2), + charging_state text +); + +create index idx_ev_vehicle_obs_vehicle_time + on ems.ev_vehicle_obs (vehicle_id, observed_at desc); + +comment on table ems.ev_vehicle_obs is +'Pozorování vozidla z API výrobce (Tesla Fleet) při příjezdu/odjezdu — auto je v těch okamžicích vzhůru, čtení nebudí. Zdroj pro ev_trip.'; + +create table ems.ev_trip ( + id serial primary key, + vehicle_id int not null references ems.asset_vehicle (id), + departure_obs_id bigint not null references ems.ev_vehicle_obs (id), + arrival_obs_id bigint not null references ems.ev_vehicle_obs (id), + departed_at timestamptz not null, + arrived_at timestamptz not null, + km numeric(8, 1), + kwh_est numeric(7, 2), + charged_away boolean not null default false, + constraint uq_ev_trip_departure unique (departure_obs_id) +); + +comment on table ems.ev_trip is +'Jízda = pár odjezd→příjezd: km z odometru, kWh z ΔSoC × kapacita. charged_away = SoC po cestě vzrostlo (nabíjení mimo dům) — kWh nevypovídá, vyloučit ze statistik.'; + +create table ems.ev_usage_stats ( + vehicle_id int not null references ems.asset_vehicle (id), + day_of_week int not null check (day_of_week between 0 and 6), + avg_day_kwh numeric(7, 2), + stddev_day_kwh numeric(7, 2), + avg_day_km numeric(8, 1), + avg_departure_hour numeric(4, 2), + sample_count int not null default 0, + last_updated timestamptz not null default now(), + primary key (vehicle_id, day_of_week) +); + +comment on table ems.ev_usage_stats is +'Týdenní rytmus vozidla per den v týdnu (0=neděle, Europe/Prague): průměrná denní spotřeba jízdou (kWh), km, typická hodina prvního odjezdu. Plní fn_update_ev_usage_stats (job 00:50). Vstup fn_ev_required_soc / fn_ev_expected_departure.'; diff --git a/db/routines/R__016_fn_ev_session_transition.sql b/db/routines/R__016_fn_ev_session_transition.sql index d21f3d9..493a46a 100644 --- a/db/routines/R__016_fn_ev_session_transition.sql +++ b/db/routines/R__016_fn_ev_session_transition.sql @@ -45,17 +45,23 @@ begin ac.id, av.id, now(), - av.default_target_soc_pct, - 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 + -- 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 + ) from ems.asset_ev_charger ac left join lateral ( - select v.id, v.default_target_soc_pct, v.default_deadline_hour + select v.id, v.default_target_soc_pct, v.default_deadline_hour, + v.target_soc_forecast_enabled from ems.asset_vehicle v where v.default_charger_id = ac.id and v.site_id = ac.site_id @@ -63,6 +69,15 @@ 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 where ac.id = p_charger_id and ac.site_id = p_site_id on conflict (charger_id) where session_end is null do nothing; diff --git a/db/routines/R__096_fn_ev_usage.sql b/db/routines/R__096_fn_ev_usage.sql new file mode 100644 index 0000000..9c089d9 --- /dev/null +++ b/db/routines/R__096_fn_ev_usage.sql @@ -0,0 +1,205 @@ +-- EV spotřební forecast (SQL-first): vložení pozorování, párování jízd, +-- statistiky per DOW, odvození target SoC a očekávaného odjezdu. + +create or replace function ems.fn_ev_vehicle_obs_insert( + p_site_id int, + p_vehicle_id int, + p_trigger text, + p_odometer_km numeric, + p_soc_pct numeric, + p_charging_state text +) +returns bigint +language sql +as $fn$ + insert into ems.ev_vehicle_obs ( + site_id, vehicle_id, trigger, odometer_km, soc_pct, charging_state + ) + values ( + p_site_id, p_vehicle_id, p_trigger, p_odometer_km, p_soc_pct, p_charging_state + ) + returning id; +$fn$; + +-- Spáruje odjezdy s nejbližším následujícím příjezdem téhož vozidla. +create or replace function ems.fn_ev_build_trips() +returns int +language plpgsql +as $fn$ +declare + v_count int := 0; + r record; + v_arr record; +begin + for r in + select o.* + from ems.ev_vehicle_obs o + where o.trigger = 'departure' + and o.odometer_km is not null + and not exists ( + select 1 from ems.ev_trip t where t.departure_obs_id = o.id + ) + order by o.vehicle_id, o.observed_at + loop + select a.* into v_arr + from ems.ev_vehicle_obs a + where a.vehicle_id = r.vehicle_id + and a.trigger = 'arrival' + and a.observed_at > r.observed_at + and a.odometer_km is not null + order by a.observed_at + limit 1; + + if v_arr.id is null then + continue; -- jízda ještě neskončila + end if; + + insert into ems.ev_trip ( + vehicle_id, departure_obs_id, arrival_obs_id, + departed_at, arrived_at, km, kwh_est, charged_away + ) + select + r.vehicle_id, r.id, v_arr.id, + r.observed_at, v_arr.observed_at, + greatest(0, v_arr.odometer_km - r.odometer_km), + case + when r.soc_pct is null or v_arr.soc_pct is null then null + when v_arr.soc_pct > r.soc_pct then null -- nabíjeno cestou + else round(((r.soc_pct - v_arr.soc_pct) / 100.0 + * av.battery_capacity_kwh)::numeric, 2) + end, + coalesce(v_arr.soc_pct > r.soc_pct, false) + from ems.asset_vehicle av + where av.id = r.vehicle_id + on conflict (departure_obs_id) do nothing; + + v_count := v_count + 1; + end loop; + return v_count; +end; +$fn$; + +-- Přepočet týdenního rytmu z jízd za lookback okno (plný přepočet, ne EMA — +-- rebuild-friendly; jízdy s nabíjením cestou se počítají do km, ne do kWh). +create or replace function ems.fn_update_ev_usage_stats( + p_lookback_days int default 60 +) +returns int +language plpgsql +as $fn$ +declare + v_built int; + v_count int; +begin + v_built := ems.fn_ev_build_trips(); + + with daily as ( + select + t.vehicle_id, + (t.departed_at at time zone 'Europe/Prague')::date as d, + extract(dow from t.departed_at at time zone 'Europe/Prague')::int as dow, + sum(t.kwh_est) filter (where not t.charged_away) as day_kwh, + sum(t.km) as day_km, + min(extract(hour from t.departed_at at time zone 'Europe/Prague') + + extract(minute from t.departed_at at time zone 'Europe/Prague') / 60.0 + ) as first_departure_hour + from ems.ev_trip t + where t.departed_at >= now() - make_interval(days => p_lookback_days) + and t.km >= 1.0 -- přepojení kabelu bez jízdy nepočítat + group by 1, 2, 3 + ), + agg as ( + select + vehicle_id, dow, + avg(day_kwh) as avg_kwh, + stddev(day_kwh) as sd_kwh, + avg(day_km) as avg_km, + avg(first_departure_hour) as avg_dep, + count(*) as samples + from daily + group by 1, 2 + ) + insert into ems.ev_usage_stats ( + vehicle_id, day_of_week, avg_day_kwh, stddev_day_kwh, + avg_day_km, avg_departure_hour, sample_count, last_updated + ) + select + vehicle_id, dow, round(avg_kwh::numeric, 2), round(coalesce(sd_kwh, 0)::numeric, 2), + round(avg_km::numeric, 1), round(avg_dep::numeric, 2), samples, now() + from agg + on conflict (vehicle_id, day_of_week) do update set + avg_day_kwh = excluded.avg_day_kwh, + stddev_day_kwh = excluded.stddev_day_kwh, + avg_day_km = excluded.avg_day_km, + avg_departure_hour = excluded.avg_departure_hour, + sample_count = excluded.sample_count, + last_updated = now(); + + get diagnostics v_count = row_count; + return v_count; +end; +$fn$; + +comment on function ems.fn_update_ev_usage_stats is +'Spáruje nové jízdy (fn_ev_build_trips) a přepočte ev_usage_stats za lookback okno. Job 00:50.'; + +-- Příští očekávaný den s jízdou (>= 3 km průměru a >= 4 vzorky) v horizontu 7 dní. +create or replace function ems.fn_ev_next_departure( + p_vehicle_id int, + p_from timestamptz default now() +) +returns timestamptz +language sql +stable +as $fn$ + select min(dep) + from ( + select ( + ((p_from at time zone 'Europe/Prague')::date + offs)::timestamp + + make_interval(mins => (round(s.avg_departure_hour * 60))::int) + ) at time zone 'Europe/Prague' as dep + from generate_series(0, 7) offs + join ems.ev_usage_stats s + on s.vehicle_id = p_vehicle_id + and s.day_of_week = extract( + dow from (p_from at time zone 'Europe/Prague')::date + offs + )::int + where s.sample_count >= 4 + and s.avg_day_km >= 3.0 + ) cand + where cand.dep > p_from + interval '30 minutes'; +$fn$; + +comment on function ems.fn_ev_next_departure is +'Nejbližší typický odjezd vozidla dle ev_usage_stats (DOW s >=4 vzorky a >=3 km). NULL = málo dat → volající použije default_deadline_hour.'; + +-- Target SoC pro odjezd v daný den: P80 denní spotřeby + rezerva 10 p.b., +-- clamp do [min_target_soc_pct, 100]. NULL = málo dat. +create or replace function ems.fn_ev_required_soc( + p_vehicle_id int, + p_departure_at timestamptz +) +returns numeric +language sql +stable +as $fn$ + select least(100.0, greatest( + av.min_target_soc_pct, + ceil( + (s.avg_day_kwh + 0.8416 * s.stddev_day_kwh) -- ~P80 + / nullif(av.battery_capacity_kwh, 0) * 100.0 + ) + 10.0 + )) + from ems.asset_vehicle av + join ems.ev_usage_stats s + on s.vehicle_id = av.id + and s.day_of_week = extract( + dow from p_departure_at at time zone 'Europe/Prague' + )::int + where av.id = p_vehicle_id + and s.sample_count >= 4 + and s.avg_day_kwh is not null; +$fn$; + +comment on function ems.fn_ev_required_soc is +'Cílové SoC (%) pro odjezd: P80 spotřeby dne v týdnu + 10 p.b. rezerva, mez [min_target_soc_pct, 100]. NULL = málo dat → default_target_soc_pct.';