""" FlexBus Dispatch — AI systém pro optimalizaci spojů Plzeňský kraj | pro dopravní podniky a krajské koordinátory """ import warnings warnings.filterwarnings('ignore') import pandas as pd import numpy as np import json import datetime import urllib.request import urllib.parse from sklearn.ensemble import GradientBoostingRegressor from sklearn.preprocessing import LabelEncoder from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error import gradio as gr # ───────────────────────────────────────────── # 1. NAČTENÍ DAT # ───────────────────────────────────────────── df = pd.read_csv('02_simpleml_komplet_pohyb.csv') zony = pd.read_csv('00_zony.csv') # GTFS zastávky a linky stops = pd.read_csv('stops.txt', sep=';', low_memory=False) routes = pd.read_csv('routes.txt', sep=';', low_memory=False) stop_times = pd.read_csv('stop_times.txt', sep=';', low_memory=False) df = df.dropna() df = df[df['pocet_cest'] >= 0] df = df[df['vzdalenost'] >= 0] # ───────────────────────────────────────────── # 2. FEATURE ENGINEERING # ───────────────────────────────────────────── zony_cil = zony[['nazev','velikost','izolace','skola','zamestnavatel','uzel']].copy() zony_cil.columns = ['cil','cil_velikost','cil_izolace','cil_skola','cil_zamestnavatel','cil_uzel'] df = df.merge(zony_cil, on='cil', how='left') zony_src = zony[['nazev','velikost','izolace']].copy() zony_src.columns = ['zdroj','zdroj_velikost','zdroj_izolace'] df = df.merge(zony_src, on='zdroj', how='left') df = df.fillna(df.median(numeric_only=True)) le_dict = {} for col in ['denni_typ','casove_okno','vekova_skupina','ucel','hlavni_mod']: le = LabelEncoder() df[col+'_enc'] = le.fit_transform(df[col].astype(str)) le_dict[col] = le FEAT = [ 'rok','ctvrtleti','denni_typ_enc','casove_okno_enc', 'vekova_skupina_enc','ucel_enc','hlavni_mod_enc', 'udalost','udalost_velikost','podil_spatne_pocasi', 'vzdalenost','cil_velikost','cil_izolace','cil_skola', 'cil_zamestnavatel','cil_uzel','zdroj_velikost','zdroj_izolace' ] X = df[FEAT]; y = df['pocet_cest'] Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42) mdl = GradientBoostingRegressor(n_estimators=200, learning_rate=0.1, max_depth=4, random_state=42) mdl.fit(Xtr, ytr) mae_m = mean_absolute_error(yte, mdl.predict(Xte)) mae_b = mean_absolute_error(yte, [ytr.mean()]*len(yte)) zlep = round((1 - mae_m/mae_b)*100, 1) FEAT_LABELS = { 'rok':'Rok','ctvrtleti':'Čtvrtletí','denni_typ_enc':'Typ dne', 'casove_okno_enc':'Čas odjezdu','vekova_skupina_enc':'Věk cestujících', 'ucel_enc':'Účel cesty','hlavni_mod_enc':'Dopravní mód', 'udalost':'Událost v cíli','udalost_velikost':'Velikost události', 'podil_spatne_pocasi':'Počasí','vzdalenost':'Vzdálenost trasy', 'cil_velikost':'Velikost cílové obce','cil_izolace':'Izolovanost cíle', 'cil_skola':'Škola v cíli','cil_zamestnavatel':'Zaměstnavatel v cíli', 'cil_uzel':'Dopravní uzel v cíli','zdroj_velikost':'Velikost výchozí obce', 'zdroj_izolace':'Izolovanost výchozí obce' } top5 = sorted(zip([FEAT_LABELS[f] for f in FEAT], mdl.feature_importances_), key=lambda x: -x[1])[:5] # ───────────────────────────────────────────── # 3. LOOKUP MAPY A KONSTANTY # ───────────────────────────────────────────── def hodina_na_okno(h): h = int(h) if 6 <= h <= 8: return 'rano_spicka' elif 9 <= h <= 11: return 'dopoledne' elif 12 <= h <= 14: return 'odpoledne' elif 15 <= h <= 18: return 'vecer_spicka' else: return 'vecer_pozde' OKNO_CZ = { 'rano_spicka': 'Ranní špička (6–8 h)', 'dopoledne': 'Dopoledne (9–11 h)', 'odpoledne': 'Odpoledne (12–14 h)', 'vecer_spicka': 'Večerní špička (15–18 h)', 'vecer_pozde': 'Pozdní večer (19 h+)', } OKNO_REVERSE = {v: k for k, v in OKNO_CZ.items()} # Věkové skupiny — profesionální popis pro dopravní podniky, žádné zneužití VEK_CZ = { 'deti_6_14': 'Žáci ZŠ (6–14 let)', 'studenti_15_19': 'Studenti SŠ/VOŠ (15–19 let)', 'dospeli_prace_20_64': 'Ekonomicky aktivní (20–64 let)', 'seniori_65plus': 'Senioři (65+ let)', } VEK_REVERSE = {v: k for k, v in VEK_CZ.items()} UCEL_CZ = { 'prace': 'Dojížďka do práce', 'skola': 'Dojížďka do školy', 'nakup_zdravi': 'Nákupy / zdravotní péče', 'volny_cas': 'Volný čas / kultura', 'ostatni': 'Ostatní účely', } UCEL_REVERSE = {v: k for k, v in UCEL_CZ.items()} # Počasí — kategorie pro dispatcher UI POCASI_LABELS = ['Slunečno', 'Polojasno', 'Oblačno / déšť', 'Bouřky / sníh'] POCASI_MAP = {'Slunečno': 0.05, 'Polojasno': 0.25, 'Oblačno / déšť': 0.65, 'Bouřky / sníh': 1.0} def enc(col, val): le = le_dict[col] return int(le.transform([val])[0]) if val in le.classes_ else 0 vsechny_zony = sorted(df['zdroj'].unique().tolist()) # ───────────────────────────────────────────── # 4. POČASÍ — OPEN-METEO (zdarma, bez klíče) # ───────────────────────────────────────────── # Střed Plzeňského kraje PLZEN_LAT = 49.7477 PLZEN_LON = 13.3776 _weather_cache = {"fetched_at": None, "data": None} def get_weather_forecast(): """Stáhne 16denní předpověď z Open-Meteo API (cache 1 hod).""" now = datetime.datetime.utcnow() if _weather_cache["fetched_at"] and (now - _weather_cache["fetched_at"]).seconds < 3600: return _weather_cache["data"] try: url = ( f"https://api.open-meteo.com/v1/forecast" f"?latitude={PLZEN_LAT}&longitude={PLZEN_LON}" f"&daily=precipitation_sum,weathercode,temperature_2m_max,temperature_2m_min" f"&timezone=Europe%2FPrague&forecast_days=16" ) with urllib.request.urlopen(url, timeout=5) as r: data = json.loads(r.read()) _weather_cache["fetched_at"] = now _weather_cache["data"] = data return data except Exception: return None def pocasi_pro_datum(datum_str): """Vrátí (label, podil_spatne) pro konkrétní datum z předpovědi.""" data = get_weather_forecast() if not data: return "Polojasno", 0.25 try: dates = data["daily"]["time"] codes = data["daily"]["weathercode"] precip = data["daily"]["precipitation_sum"] if datum_str in dates: idx = dates.index(datum_str) wc = codes[idx] pr = precip[idx] or 0 # WMO weather codes → kategorie if wc in [0, 1]: return "Slunečno", 0.05 elif wc in [2, 3, 45, 48]: return "Polojasno", 0.25 elif wc in [51,53,55,61,63,71,73,80,81,82]: return "Oblačno / déšť", min(0.5 + pr/20, 0.9) else: return "Bouřky / sníh", 1.0 except Exception: pass return "Polojasno", 0.25 def get_forecast_html(): """Vrátí HTML tabulku 16denní předpovědi pro zobrazení v UI.""" data = get_weather_forecast() if not data: return "

Předpověď počasí není dostupná.

" try: dates = data["daily"]["time"] codes = data["daily"]["weathercode"] tmax = data["daily"]["temperature_2m_max"] tmin = data["daily"]["temperature_2m_min"] precip = data["daily"]["precipitation_sum"] WC_ICON = { 0:"☀️",1:"🌤",2:"⛅",3:"☁️",45:"🌫",48:"🌫", 51:"🌦",53:"🌧",55:"🌧",61:"🌧",63:"🌧",65:"🌧", 71:"🌨",73:"🌨",75:"❄️",80:"🌦",81:"🌧",82:"⛈", 95:"⛈",96:"⛈",99:"⛈" } rows = "" for i, d in enumerate(dates[:16]): dt = datetime.datetime.strptime(d, "%Y-%m-%d") day_name = ["Po","Út","St","Čt","Pá","So","Ne"][dt.weekday()] icon = WC_ICON.get(codes[i], "🌡") pr = precip[i] or 0 bg = "#fff3cd" if pr > 5 else ("#d4edda" if codes[i] <= 1 else "#f8f9fa") rows += ( f'' f'{day_name} {dt.strftime("%d.%m")}' f'{icon}' f'{tmax[i]:.0f}°/{tmin[i]:.0f}°C' f'{pr:.1f} mm' f'' ) return ( '' '' '' '' '' '' '' + rows + '
DatumPočasíTeplotaSrážky
' ) except Exception as e: return f"

Chyba při načítání předpovědi: {e}

" # ───────────────────────────────────────────── # 5. MAPA — LEAFLET.JS přes HTML # ───────────────────────────────────────────── def build_map_html(zdroj_filter=None, cil_filter=None, predikce_pocet=None): """ Sestaví interaktivní Leaflet mapu se zastávkami a linkami. - Zastávky: barevné body dle zóny, velikost dle vytížení - Linky: barevné podle typu (MHD=modrá, autobus=zelená, suburbanní=oranžová) - Tooltip na každé zastávce s názvem a predikcí """ # Připrav stops jako JSON stops_data = [] for _, row in stops.iterrows(): z = int(row.get('zone_id', 1)) # Barva zóny if z == 1: color = "#1a56db" # Plzeň centrum — modrá elif z <= 21: color = "#0e9f6e" # blízké okolí — zelená elif z <= 41: color = "#ff8c00" # vzdálenější — oranžová else: color = "#e02424" # venkov — červená # Zvýraznění vybrané trasy highlight = "" if zdroj_filter and row['stop_name'] == zdroj_filter: highlight = "ZDROJ" elif cil_filter and row['stop_name'] == cil_filter: highlight = "CIL" stops_data.append({ "id": int(row['stop_id']), "name": str(row['stop_name']), "lat": float(row['stop_lat']), "lon": float(row['stop_lon']), "zone": z, "color": color, "hl": highlight }) # Připrav linky jako JSON (trasy přes zastávky) # Seskup stop_times podle trip_id, vezmi první a poslední zastávku pro vizualizaci # (plná rekonstrukce tras by byla příliš velká — ukážeme reprezentativní sample) routes_data = [] sample_trips = stop_times['trip_id'].unique()[:80] # max 80 linek pro výkon st_indexed = stop_times[stop_times['trip_id'].isin(sample_trips)] stops_idx = stops.set_index('stop_id') for trip_id, grp in st_indexed.groupby('trip_id'): grp = grp.sort_values('stop_sequence') coords = [] for sid in grp['stop_id'].values: if sid in stops_idx.index: r = stops_idx.loc[sid] coords.append([float(r['stop_lat']), float(r['stop_lon'])]) if len(coords) >= 2: # Typ linky → barva routes_data.append({ "trip": int(trip_id), "coords": coords }) # Předpověď pro tooltip pred_info = "" if predikce_pocet is not None: pred_info = f"{predikce_pocet} cestujících" stops_json = json.dumps(stops_data) routes_json = json.dumps(routes_data) zdroj_js = json.dumps(zdroj_filter or "") cil_js = json.dumps(cil_filter or "") html = f"""
""" return html # ───────────────────────────────────────────── # 6. PREDIKČNÍ FUNKCE # ───────────────────────────────────────────── def predikuj(zdroj, cil, rezim_casu, okno_vyber, hodina_odjezdu, vekova_skupina, ucel, je_udalost, udalost_vel, pocasi_manual, pouzit_predpoved_pocasi, datum): # --- Datum a čas --- try: d = datetime.datetime.strptime(datum, "%Y-%m-%d") rok = d.year; ctvrtleti = (d.month-1)//3+1; dow = d.weekday() denni_typ = 'vsedni' if dow < 5 else ('sobota' if dow == 5 else 'nedele') except Exception: rok = 2025; ctvrtleti = 2; denni_typ = 'vsedni' d = datetime.datetime.today() if rezim_casu == "Časové okno": casove_okno = OKNO_REVERSE.get(okno_vyber, 'odpoledne') hodina_disp = {"rano_spicka":"~7:00","dopoledne":"~10:00","odpoledne":"~13:00", "vecer_spicka":"~17:00","vecer_pozde":"~20:00"}.get(casove_okno,"") else: casove_okno = hodina_na_okno(hodina_odjezdu) hodina_disp = f"{int(hodina_odjezdu):02d}:00" # --- Počasí --- if pouzit_predpoved_pocasi: pocasi_label, pc = pocasi_pro_datum(datum) else: pocasi_label = pocasi_manual pc = POCASI_MAP.get(pocasi_manual, 0.25) # --- Encoding --- vc = VEK_REVERSE.get(vekova_skupina, 'dospeli_prace_20_64') uc = UCEL_REVERSE.get(ucel, 'prace') mask = (df['zdroj']==zdroj) & (df['cil']==cil) vzdal = float(df[mask]['vzdalenost'].mean()) if mask.any() else float(df['vzdalenost'].mean()) zc = zony[zony['nazev']==cil] zs = zony[zony['nazev']==zdroj] row = { 'rok':rok, 'ctvrtleti':ctvrtleti, 'denni_typ_enc': enc('denni_typ', denni_typ), 'casove_okno_enc': enc('casove_okno', casove_okno), 'vekova_skupina_enc': enc('vekova_skupina', vc), 'ucel_enc': enc('ucel', uc), 'hlavni_mod_enc': enc('hlavni_mod', 'autobus'), 'udalost': 1 if je_udalost else 0, 'udalost_velikost': float(udalost_vel), 'podil_spatne_pocasi': pc, 'vzdalenost': vzdal, 'cil_velikost': float(zc['velikost'].values[0]) if len(zc) else 0.5, 'cil_izolace': float(zc['izolace'].values[0]) if len(zc) else 0.5, 'cil_skola': int(zc['skola'].values[0]) if len(zc) else 0, 'cil_zamestnavatel':int(zc['zamestnavatel'].values[0]) if len(zc) else 0, 'cil_uzel': int(zc['uzel'].values[0]) if len(zc) else 0, 'zdroj_velikost': float(zs['velikost'].values[0]) if len(zs) else 0.5, 'zdroj_izolace': float(zs['izolace'].values[0]) if len(zs) else 0.5, } pocet = max(0.0, round(float(mdl.predict(pd.DataFrame([row])[FEAT])[0]), 1)) # --- Rozhodovací logika --- if pocet >= 8: stav = "PEVNÝ SPOJ" rezim = "Spoj jede automaticky — dostatečná poptávka." akce = "Standardní provoz. Není třeba zásah." bg = "#d4edda"; bc = "#28a745"; ikona = "🟢" elif pocet >= 4: stav = "FLEXBUS — ON DEMAND" rezim = "Spoj jede pouze při objednávce přes aplikaci (min. 1 hod. předem)." akce = "Aktivovat FlexBus okno. Sledujte objednávky do 1 hod. před odjezdem." bg = "#fff3cd"; bc = "#e0a800"; ikona = "🟡" else: stav = "SPOJ NEJEDE" rezim = "Poptávka příliš nízká — spoj se nevyplatí." akce = "Zvažte záchranný tarif (taxi voucher) pro izolované oblasti." bg = "#f8d7da"; bc = "#dc3545"; ikona = "🔴" # --- Klíčové faktory --- faktory = [] if je_udalost: faktory.append(f"událost v cíli (vel. {udalost_vel})") if pc >= 0.65: faktory.append(f"nepříznivé počasí ({pocasi_label})") if len(zc) and float(zc['izolace'].values[0]) > 0.4: faktory.append("izolovaná cílová obec") if denni_typ in ['sobota','nedele']: faktory.append("víkend — jiný vzorec pohybu") if casove_okno in ['rano_spicka','vecer_spicka']: faktory.append("špičková hodina — vyšší poptávka") if uc == 'skola' and denni_typ != 'vsedni': faktory.append("školní dojížďka o víkendu — nižší poptávka") fakt = " | ".join(faktory) if faktory else "standardní podmínky" okno_label = OKNO_CZ.get(casove_okno, casove_okno) vek_label = VEK_CZ.get(vc, vc) ucel_label = UCEL_CZ.get(uc, uc) # --- HTML výsledek --- result_html = f"""
{ikona} {stav}
{pocet}
odhadovaných cestujících  ·  ±{round(mae_m,1)} (přesnost modelu)

Trasa {zdroj} → {cil}
Datum / čas {d.strftime("%d. %m. %Y")} v {hodina_disp} ({okno_label})
Profil cestujících {vek_label} — {ucel_label}
Počasí {pocasi_label} {'(z předpovědi Open-Meteo)' if pouzit_predpoved_pocasi else ''}
Klíč. faktory {fakt}

Režim spoje: {rezim}
Doporučená akce: {akce}
""" # --- Model info --- imp_bars = "".join([ f'
' f'
{n}
' f'
' f'
{v:.3f}
' for n, v in top5 ]) model_html = f"""
Výkon modelu
MAE: {mae_m:.2f}  ·  Baseline (průměr): {mae_b:.2f}  ·  Zlepšení: {zlep} %
Top 5 faktorů ovlivňujících předpověď:
{imp_bars}
""" # --- Mapa --- map_html = build_map_html( zdroj_filter=zdroj, cil_filter=cil, predikce_pocet=pocet ) return result_html, model_html, map_html # ───────────────────────────────────────────── # 7. GRADIO UI # ───────────────────────────────────────────── with gr.Blocks(title="FlexBus Dispatch | Plzeňský kraj", theme=gr.themes.Base()) as demo: # Hlavička gr.HTML("""
🚌
FlexBus Dispatch
AI systém pro optimalizaci spojů  ·  Plzeňský kraj  ·  Určeno dopravním podnikům a krajským koordinátorům
""") with gr.Tabs(): # ── TAB 1: Analýza spoje ────────────────────── with gr.Tab("📊 Analýza spoje"): with gr.Row(): # Levý sloupec — vstupy with gr.Column(scale=1): gr.Markdown("### Trasa") zdroj_in = gr.Dropdown( vsechny_zony, label="Výchozí oblast / obec", value=vsechny_zony[0] if vsechny_zony else None ) cil_in = gr.Dropdown( vsechny_zony, label="Cílová oblast / obec", value=vsechny_zony[-1] if len(vsechny_zony) > 1 else None ) gr.Markdown("### Datum a čas") datum_in = gr.Textbox( label="Datum odjezdu (RRRR-MM-DD)", value=datetime.date.today().isoformat(), placeholder="2025-09-20" ) rezim_in = gr.Radio( ["Konkrétní hodina", "Časové okno"], label="Způsob zadání času", value="Konkrétní hodina" ) hodina_in = gr.Slider(0, 23, value=17, step=1, label="Hodina odjezdu", visible=True) okno_in = gr.Dropdown( list(OKNO_CZ.values()), label="Časové okno", value="Večerní špička (15–18 h)", visible=False ) def prepni_cas(r): return (gr.update(visible=r=="Konkrétní hodina"), gr.update(visible=r=="Časové okno")) rezim_in.change(prepni_cas, inputs=rezim_in, outputs=[hodina_in, okno_in]) # Pravý sloupec with gr.Column(scale=1): gr.Markdown("### Profil cestujících") gr.HTML("""
ℹ️ Data popisují agregované skupiny cestujících — žádné osobní údaje nejsou zpracovávány.
""") vek_in = gr.Dropdown( list(VEK_CZ.values()), label="Věková skupina cestujících", value="Ekonomicky aktivní (20–64 let)" ) ucel_in = gr.Dropdown( list(UCEL_CZ.values()), label="Hlavní účel cesty", value="Dojížďka do práce" ) gr.Markdown("### Kontext") udalost_in = gr.Checkbox( label="V cíli se koná událost (koncert, zápas, festival...)" ) udalost_vel = gr.Slider(0, 5, value=0, step=1, label="Velikost události (0=žádná, 5=velká)") gr.Markdown("### Počasí") pouzit_predpoved = gr.Checkbox( label="📡 Načíst předpověď automaticky (Open-Meteo, 16 dní)", value=True ) pocasi_manual = gr.Dropdown( POCASI_LABELS, label="Nebo zadat ručně", value="Polojasno", visible=False ) def prepni_pocasi(v): return gr.update(visible=not v) pouzit_predpoved.change(prepni_pocasi, inputs=pouzit_predpoved, outputs=pocasi_manual) gr.HTML("
") btn = gr.Button("🔍 Analyzovat spoj", variant="primary", size="lg") gr.HTML("
") # Výsledky with gr.Row(): with gr.Column(scale=1): gr.Markdown("#### Výsledek analýzy") result_out = gr.HTML() model_out = gr.HTML() with gr.Column(scale=1): gr.Markdown("#### Mapa sítě zastávek") map_out = gr.HTML(value=build_map_html(), label="Mapa") btn.click( predikuj, inputs=[zdroj_in, cil_in, rezim_in, okno_in, hodina_in, vek_in, ucel_in, udalost_in, udalost_vel, pocasi_manual, pouzit_predpoved, datum_in], outputs=[result_out, model_out, map_out] ) # ── TAB 2: Předpověď počasí ─────────────────── with gr.Tab("🌤 Předpověď počasí (16 dní)"): gr.HTML("""
📡 Zdroj: Open-Meteo API — zdarma, bez registrace
Data pro Plzeň (49.75° N, 13.38° E) · obnoveno každou hodinu
""") refresh_btn = gr.Button("🔄 Načíst / Obnovit předpověď", variant="secondary") weather_out = gr.HTML() refresh_btn.click(fn=get_forecast_html, inputs=[], outputs=weather_out) demo.load(fn=get_forecast_html, inputs=[], outputs=weather_out) # ── TAB 3: O modelu ─────────────────────────── with gr.Tab("🧠 O modelu a etice"): gr.Markdown(f""" ## Jak model funguje **Proč GradientBoosting a ne prostý průměr?** Prostý průměr vidí jen „kolik jelo minule" — náš model navíc zohledňuje vzdálenost trasy, izolovanost obce, typ dne, přítomnost události a počasí. Výsledek: MAE **{mae_m:.2f}** vs. baseline **{mae_b:.2f}** — o **{zlep} %** přesnější. Na trasách s málo daty si model „půjčuje" vzorec od zón s podobnými vlastnostmi. Finální rozhodnutí je vždy na dispečerovi — model doporučuje, nerozhoduje. ## Použitá data | Zdroj | Obsah | Aktualizace | |---|---|---| | IDPK GTFS | Zastávky, linky, jízdní řády | Dle vydání IDPK | | Historická OD data 2019–2025 | Toky cestujících dle zón | Jednou ročně | | Open-Meteo API | Předpověď počasí 16 dní | Každou hodinu | | Kalendář událostí | Velké akce v Plzeňském kraji | Manuálně / PINE | ## Etika a ochrana dat - **Výhradně agregovaná data** — žádná jména, tváře ani osobní identifikátory - **Věkové skupiny** jsou statistické kategorie pro plánování dopravní kapacity, nikoli pro identifikaci osob - **Malé obce** mají garantovaný minimální spoj bez ohledu na předpověď - **Nejistota** je vždy zobrazena s intervalem ±{round(mae_m,1)} - **Transparentnost** — model a jeho rozhodovací logika jsou veřejně dostupné ## Technický stack ``` GradientBoostingRegressor (scikit-learn) Training data: 02_simpleml_komplet_pohyb.csv (2019–2025) Features: {len(FEAT)} proměnných Train/test split: 80/20 MAE: {mae_m:.2f} cestujících Baseline MAE: {mae_b:.2f} cestujících Zlepšení: {zlep} % Počasí: Open-Meteo API (bez API klíče) Zastávky: IDPK GTFS (stops.txt / routes.txt) ``` """) demo.launch()