flexbus-plzen / app.py
simikkk's picture
Update app.py
f434195 verified
Raw
History Blame
33.1 kB
"""
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 &nbsp;·&nbsp; ±{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> &nbsp;·&nbsp;
Baseline (průměr): <b>{mae_b:.2f}</b> &nbsp;·&nbsp;
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ů &nbsp;·&nbsp; Plzeňský kraj
&nbsp;·&nbsp; 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()