Spaces:
Sleeping
Sleeping
| """ | |
| 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 style='color:#888'>Předpověď počasí není dostupná.</p>" | |
| 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'<tr style="background:{bg}">' | |
| f'<td style="padding:4px 8px;font-weight:600">{day_name} {dt.strftime("%d.%m")}</td>' | |
| f'<td style="padding:4px 8px;text-align:center;font-size:1.2em">{icon}</td>' | |
| f'<td style="padding:4px 8px;text-align:right">{tmax[i]:.0f}°/{tmin[i]:.0f}°C</td>' | |
| f'<td style="padding:4px 8px;text-align:right;color:#1a56db">{pr:.1f} mm</td>' | |
| f'</tr>' | |
| ) | |
| return ( | |
| '<table style="border-collapse:collapse;width:100%;font-size:0.88em;font-family:sans-serif">' | |
| '<thead><tr style="background:#1a56db;color:white">' | |
| '<th style="padding:5px 8px;text-align:left">Datum</th>' | |
| '<th style="padding:5px 8px">Počasí</th>' | |
| '<th style="padding:5px 8px;text-align:right">Teplota</th>' | |
| '<th style="padding:5px 8px;text-align:right">Srážky</th>' | |
| '</tr></thead><tbody>' + rows + '</tbody></table>' | |
| ) | |
| except Exception as e: | |
| return f"<p style='color:#888'>Chyba při načítání předpovědi: {e}</p>" | |
| # ───────────────────────────────────────────── | |
| # 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"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/> | |
| <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> | |
| <style> | |
| body {{ margin:0; padding:0; font-family: 'Segoe UI', sans-serif; }} | |
| #map {{ width:100%; height:500px; }} | |
| .legend {{ | |
| background:white; padding:10px 14px; border-radius:8px; | |
| box-shadow:0 2px 8px rgba(0,0,0,0.2); font-size:12px; line-height:1.8; | |
| }} | |
| .dot {{ display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; }} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="map"></div> | |
| <script> | |
| var map = L.map('map', {{zoomControl: true}}).setView([49.738, 13.373], 11); | |
| L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{ | |
| attribution: '© OpenStreetMap contributors', | |
| maxZoom: 17 | |
| }}).addTo(map); | |
| var stopsData = {stops_json}; | |
| var routesData = {routes_json}; | |
| var zdrojName = {zdroj_js}; | |
| var cilName = {cil_js}; | |
| var predInfo = "{pred_info}"; | |
| // Kreslení linek | |
| var lineLayer = L.layerGroup().addTo(map); | |
| var routeColors = ["#3b82f6","#10b981","#f59e0b","#ef4444","#8b5cf6","#06b6d4"]; | |
| routesData.forEach(function(r, i) {{ | |
| var col = routeColors[i % routeColors.length]; | |
| L.polyline(r.coords, {{color: col, weight: 2.5, opacity: 0.55}}).addTo(lineLayer); | |
| }}); | |
| // Kreslení zastávek | |
| var stopLayer = L.layerGroup().addTo(map); | |
| stopsData.forEach(function(s) {{ | |
| var radius = s.zone <= 1 ? 5 : (s.zone <= 21 ? 4 : 3); | |
| var color = s.color; | |
| var zIndex = 100; | |
| var tooltip = "<b>" + s.name + "</b><br>Zóna: " + s.zone; | |
| if (s.hl === "ZDROJ") {{ | |
| radius = 12; color = "#22c55e"; zIndex = 1000; | |
| tooltip += "<br><b style='color:#22c55e'>▶ Výchozí zastávka</b>"; | |
| if (predInfo) tooltip += "<br>Předpověď: <b>" + predInfo + "</b>"; | |
| }} else if (s.hl === "CIL") {{ | |
| radius = 12; color = "#ef4444"; zIndex = 1000; | |
| tooltip += "<br><b style='color:#ef4444'>⬛ Cílová zastávka</b>"; | |
| if (predInfo) tooltip += "<br>Předpověď: <b>" + predInfo + "</b>"; | |
| }} | |
| var marker = L.circleMarker([s.lat, s.lon], {{ | |
| radius: radius, | |
| fillColor: color, | |
| color: "#fff", | |
| weight: 1.5, | |
| opacity: 1, | |
| fillOpacity: s.hl ? 0.95 : 0.75, | |
| zIndexOffset: zIndex | |
| }}).bindTooltip(tooltip, {{sticky: true}}); | |
| marker.addTo(stopLayer); | |
| // Zoom na vybrané zastávky | |
| if (s.hl === "ZDROJ" || s.hl === "CIL") {{ | |
| marker.openTooltip(); | |
| }} | |
| }}); | |
| // Legenda | |
| var legend = L.control({{position: 'bottomright'}}); | |
| legend.onAdd = function() {{ | |
| var div = L.DomUtil.create('div', 'legend'); | |
| div.innerHTML = | |
| '<b style="font-size:13px">🗺 Legenda</b><br>' + | |
| '<span class="dot" style="background:#1a56db"></span>Zóna 1 – Plzeň<br>' + | |
| '<span class="dot" style="background:#0e9f6e"></span>Zóna 2 – Blízké okolí<br>' + | |
| '<span class="dot" style="background:#ff8c00"></span>Zóna 3 – Příměstí<br>' + | |
| '<span class="dot" style="background:#e02424"></span>Zóna 4+ – Venkov<br>' + | |
| '<span class="dot" style="background:#22c55e;border:2px solid #15803d"></span>Výchozí zastávka<br>' + | |
| '<span class="dot" style="background:#ef4444;border:2px solid #b91c1c"></span>Cílová zastávka'; | |
| return div; | |
| }}; | |
| legend.addTo(map); | |
| // Přizpůsobení pohledu na vybrané body | |
| var highlighted = stopsData.filter(function(s) {{ return s.hl !== ""; }}); | |
| if (highlighted.length >= 2) {{ | |
| var bounds = highlighted.map(function(s) {{ return [s.lat, s.lon]; }}); | |
| map.fitBounds(bounds, {{padding: [60, 60]}}); | |
| }} | |
| </script> | |
| </body> | |
| </html>""" | |
| 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""" | |
| <div style="border:2px solid {bc};background:{bg};padding:20px;border-radius:12px; | |
| font-family:'Segoe UI',sans-serif;margin-bottom:4px"> | |
| <div style="font-size:1.3em;font-weight:700;margin-bottom:2px">{ikona} {stav}</div> | |
| <div style="font-size:2.6em;font-weight:800;color:{bc};line-height:1">{pocet}</div> | |
| <div style="color:#555;font-size:0.9em;margin-bottom:12px"> | |
| odhadovaných cestujících · ±{round(mae_m,1)} (přesnost modelu) | |
| </div> | |
| <hr style="border:none;border-top:1px solid rgba(0,0,0,0.1);margin:8px 0"> | |
| <table style="width:100%;font-size:0.91em;border-collapse:collapse"> | |
| <tr> | |
| <td style="padding:3px 10px;color:#555;width:155px;vertical-align:top">Trasa</td> | |
| <td><b>{zdroj} → {cil}</b></td> | |
| </tr> | |
| <tr> | |
| <td style="padding:3px 10px;color:#555">Datum / čas</td> | |
| <td><b>{d.strftime("%d. %m. %Y")}</b> v {hodina_disp} | |
| <span style="color:#777;font-size:0.9em">({okno_label})</span></td> | |
| </tr> | |
| <tr> | |
| <td style="padding:3px 10px;color:#555">Profil cestujících</td> | |
| <td>{vek_label} — {ucel_label}</td> | |
| </tr> | |
| <tr> | |
| <td style="padding:3px 10px;color:#555">Počasí</td> | |
| <td>{pocasi_label} | |
| {'<span style="font-size:0.8em;color:#888">(z předpovědi Open-Meteo)</span>' | |
| if pouzit_predpoved_pocasi else ''}</td> | |
| </tr> | |
| <tr> | |
| <td style="padding:3px 10px;color:#555;vertical-align:top">Klíč. faktory</td> | |
| <td>{fakt}</td> | |
| </tr> | |
| </table> | |
| <hr style="border:none;border-top:1px solid rgba(0,0,0,0.1);margin:8px 0"> | |
| <div style="margin-bottom:4px"><b>Režim spoje:</b> {rezim}</div> | |
| <div><b>Doporučená akce:</b> <span style="color:{bc};font-weight:600">{akce}</span></div> | |
| </div>""" | |
| # --- Model info --- | |
| imp_bars = "".join([ | |
| f'<div style="display:flex;align-items:center;gap:8px;margin:3px 0">' | |
| f'<div style="width:195px;font-size:0.84em;color:#444;flex-shrink:0">{n}</div>' | |
| f'<div style="background:#1a56db;height:9px;width:{max(int(v*480),4)}px;border-radius:3px"></div>' | |
| f'<div style="font-size:0.78em;color:#888">{v:.3f}</div></div>' | |
| for n, v in top5 | |
| ]) | |
| model_html = f""" | |
| <div style="background:#f0f4ff;border:1px solid #c7d9ff;padding:14px 16px; | |
| border-radius:10px;font-family:'Segoe UI',sans-serif"> | |
| <div style="font-weight:700;margin-bottom:4px">Výkon modelu</div> | |
| <div style="font-size:0.9em;color:#444;margin-bottom:8px"> | |
| MAE: <b>{mae_m:.2f}</b> · | |
| Baseline (průměr): <b>{mae_b:.2f}</b> · | |
| Zlepšení: <b style="color:#1a56db">{zlep} %</b> | |
| </div> | |
| <div style="font-size:0.87em;font-weight:600;margin-bottom:5px;color:#333"> | |
| Top 5 faktorů ovlivňujících předpověď: | |
| </div> | |
| {imp_bars} | |
| </div>""" | |
| # --- 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(""" | |
| <div style="background:linear-gradient(135deg,#1a56db 0%,#1e40af 100%); | |
| padding:24px 28px;border-radius:14px;margin-bottom:4px"> | |
| <div style="display:flex;align-items:center;gap:16px"> | |
| <span style="font-size:2.6em">🚌</span> | |
| <div> | |
| <div style="color:white;font-size:1.8em;font-weight:800;letter-spacing:-0.5px"> | |
| FlexBus Dispatch | |
| </div> | |
| <div style="color:#bfdbfe;font-size:0.94em;margin-top:2px"> | |
| AI systém pro optimalizaci spojů · Plzeňský kraj | |
| · Určeno dopravním podnikům a krajským koordinátorům | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| 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("""<div style="background:#f0f9ff;border:1px solid #bae6fd; | |
| border-radius:6px;padding:8px 12px;font-size:0.84em;color:#0369a1; | |
| margin-bottom:8px"> | |
| ℹ️ Data popisují <b>agregované skupiny cestujících</b> — | |
| žádné osobní údaje nejsou zpracovávány. | |
| </div>""") | |
| 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("<div style='height:4px'></div>") | |
| btn = gr.Button("🔍 Analyzovat spoj", variant="primary", size="lg") | |
| gr.HTML("<hr style='margin:6px 0;border:none;border-top:1px solid #e5e7eb'>") | |
| # 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(""" | |
| <div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px; | |
| padding:12px 16px;margin-bottom:12px;font-family:sans-serif"> | |
| <b>📡 Zdroj: Open-Meteo API</b> — zdarma, bez registrace<br> | |
| <span style="font-size:0.88em;color:#555"> | |
| Data pro Plzeň (49.75° N, 13.38° E) · obnoveno každou hodinu | |
| </span> | |
| </div>""") | |
| 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() |