### SWEDEN TAC FOR 100 TRAIN-KM AND PR ### ### WORKS PERFECT ### import gradio as gr import pandas as pd import numpy as np import plotly.graph_objects as go from datetime import datetime, timedelta # ========================================== # 1. CLASS DEFINITIONS & INFRASTRUCTURE # ========================================== class Train: def __init__(self, id, direction, priority, stops, start_time_offset, speed_kmh, dwell_min): self.id = str(id) self.direction = direction self.priority = int(priority) self.speed_kmh = float(speed_kmh) self.dwell_min = float(dwell_min) self.stops = stops self.start_time_offset = float(start_time_offset) self.schedule = [] def copy(self): new_t = Train(self.id, self.direction, self.priority, self.stops, self.start_time_offset, self.speed_kmh, self.dwell_min) new_t.schedule = [] for s in self.schedule: node_copy = s.copy() if 'min_arr_constraint' not in node_copy: node_copy['min_arr_constraint'] = None new_t.schedule.append(node_copy) return new_t class Infrastructure: def __init__(self, station_df, spacing_km=5.0): # Access by index to be robust against header names self.stations = station_df.iloc[:, 0].astype(str).tolist() self.distances = pd.to_numeric(station_df.iloc[:, 1], errors='coerce').fillna(0.0).tolist() self.station_map = {name: i for i, name in enumerate(self.stations)} self.dist_map = {name: d for name, d in zip(self.stations, self.distances)} self.spacing_km = float(spacing_km) def get_travel_time(self, s1, s2, speed_kmh): d1 = self.dist_map.get(s1, 0.0) d2 = self.dist_map.get(s2, 0.0) dist_km = abs(d1 - d2) if speed_kmh <= 0: return timedelta(minutes=1) hours = dist_km / speed_kmh return timedelta(hours=hours) def get_dynamic_headway(self, speed_kmh): if speed_kmh <= 0: return timedelta(minutes=5) minutes = (self.spacing_km / speed_kmh) * 60.0 return timedelta(minutes=max(1.0, minutes)) # ========================================== # 2. PHYSICS ENGINE # ========================================== def update_train_times(train, infra, base_time): if not train.schedule: current_time = base_time + timedelta(minutes=train.start_time_offset) train.schedule = [] for s in train.stops: train.schedule.append({ 'station': s, 'arr': current_time, 'dep': current_time, 'min_arr_constraint': None, 'manual_delay': timedelta(0) }) for i in range(len(train.schedule)): current_node = train.schedule[i] manual_delay = current_node.get('manual_delay', timedelta(0)) # A. ARRIVAL if i == 0: base_origin_time = base_time + timedelta(minutes=train.start_time_offset) current_node['arr'] = base_origin_time + manual_delay else: prev_node = train.schedule[i-1] phys_run_time = infra.get_travel_time(prev_node['station'], current_node['station'], train.speed_kmh) min_phys_arr = prev_node['dep'] + phys_run_time safety_limit = current_node.get('min_arr_constraint') delayed_phys_arr = min_phys_arr + manual_delay if safety_limit: current_node['arr'] = max(delayed_phys_arr, safety_limit) else: current_node['arr'] = delayed_phys_arr # B. DEPARTURE dwell = timedelta(minutes=train.dwell_min) if i < len(train.schedule) - 1 else timedelta(0) min_dep = current_node['arr'] + dwell current_node['dep'] = max(current_node['dep'], min_dep) # ========================================== # 3. CONFLICT DETECTOR # ========================================== def detect_conflicts(trains, infra): conflicts = [] for i in range(len(trains)): for j in range(i + 1, len(trains)): t1 = trains[i] t2 = trains[j] for k in range(len(t1.schedule) - 1): u, v = t1.schedule[k]['station'], t1.schedule[k+1]['station'] t2_stations = [s['station'] for s in t2.schedule] if u in t2_stations and v in t2_stations: idx_u_t2 = t2_stations.index(u) idx_v_t2 = t2_stations.index(v) if abs(idx_u_t2 - idx_v_t2) == 1: t1_ent, t1_ext = t1.schedule[k]['dep'], t1.schedule[k+1]['arr'] if idx_u_t2 < idx_v_t2: t2_ent, t2_ext = t2.schedule[idx_u_t2]['dep'], t2.schedule[idx_v_t2]['arr'] direction = "Same" else: t2_ent, t2_ext = t2.schedule[idx_v_t2]['dep'], t2.schedule[idx_u_t2]['arr'] direction = "Opposite" is_conflict = False conflict_reason = "" if direction == "Opposite": if max(t1_ent, t2_ent) < min(t1_ext, t2_ext): is_conflict = True; conflict_reason = "Head-on" else: if t1_ent <= t2_ent: l_ent, f_ent = t1_ent, t2_ent leader_ext, follower_ext = t1_ext, t2_ext hw = infra.get_dynamic_headway(t2.speed_kmh) elif t1_ent > t2_ent: l_ent, f_ent = t2_ent, t1_ent leader_ext, follower_ext = t2_ext, t1_ext hw = infra.get_dynamic_headway(t1.speed_kmh) tol = timedelta(seconds=1) if f_ent < l_ent + hw - tol: is_conflict = True; conflict_reason = f"Entry HW ({hw.seconds//60}m)" elif follower_ext < leader_ext + hw - tol: is_conflict = True; conflict_reason = f"Exit HW ({hw.seconds//60}m)" if is_conflict: conflicts.append({'time': max(t1_ent, t2_ent), 'trains': (t1, t2), 'segment': (u, v), 'dir': direction, 'desc': f"{conflict_reason} on {u}->{v}"}) conflicts.sort(key=lambda x: x['time']) return conflicts # ========================================== # 4. RESOLVER (With Max Buffer Logic) # ========================================== def resolve_all_conflicts(trains, infra, base_time, selected_train_id=None, max_buffer_min=30.0): iteration = 0 max_iter = 200 logs = [] max_buffer_td = timedelta(minutes=float(max_buffer_min)) while iteration < max_iter: conflicts = detect_conflicts(trains, infra) if not conflicts: logs.append("No conflicts found.") break c = conflicts[0] t1, t2 = c['trains'] u, v = c['segment'] direction = c['dir'] # If the user selected a train in the View & Optimization panel, # treat it as the optimization target for this conflict. This makes the # Select Train list-box functional: the selected service is protected # before normal priority/tie-break logic is applied. selected_id = str(selected_train_id) if selected_train_id not in (None, "", "None") else None if selected_id and str(t1.id) == selected_id and str(t2.id) != selected_id: winner, loser = t1, t2 logs.append(f"Iter {iteration}: Optimization target {winner.id} given dispatch priority.") elif selected_id and str(t2.id) == selected_id and str(t1.id) != selected_id: winner, loser = t2, t1 logs.append(f"Iter {iteration}: Optimization target {winner.id} given dispatch priority.") elif t1.priority > t2.priority: winner, loser = t1, t2 elif t2.priority > t1.priority: winner, loser = t2, t1 else: winner, loser = (t1, t2) if t1.id < t2.id else (t2, t1) def get_idx(train, station): return [s['station'] for s in train.schedule].index(station) if direction == "Same": u_w, v_w = get_idx(winner, u), get_idx(winner, v) u_l, v_l = get_idx(loser, u), get_idx(loser, v) if u_w > v_w: u_w, v_w = v_w, u_w if u_l > v_l: u_l, v_l = v_l, u_l if winner.schedule[u_w]['dep'] <= loser.schedule[u_l]['dep']: leader, follower = winner, loser l_u, l_v = u_w, v_w f_u, f_v = u_l, v_l follower_is_winner = False else: leader, follower = loser, winner l_u, l_v = u_l, v_l f_u, f_v = u_w, v_w follower_is_winner = True hw = infra.get_dynamic_headway(follower.speed_kmh) applied_buffer = False if follower_is_winner and selected_train_id and str(follower.id) == str(selected_train_id): gap_needed = (follower.schedule[f_u]['dep'] + hw) - leader.schedule[l_u]['dep'] current_buffer = leader.schedule[l_u].get('manual_delay', timedelta(0)) total_new_buffer = current_buffer + gap_needed if gap_needed > timedelta(0): if total_new_buffer <= max_buffer_td: logs.append(f"Iter {iteration}: Selected Train Optimization -> Applying {gap_needed.total_seconds()//60:.1f}m Buffer to {leader.id} at {u}. Total: {total_new_buffer.total_seconds()//60:.1f}m") leader.schedule[l_u]['manual_delay'] = total_new_buffer update_train_times(leader, infra, base_time) applied_buffer = True else: logs.append(f"Iter {iteration}: Selected Train Optimization Rejected. Total buffer needed ({total_new_buffer.total_seconds()//60:.1f}m) exceeds Limit ({max_buffer_min}m).") if not applied_buffer: req_dep = leader.schedule[l_u]['dep'] + hw if follower.schedule[f_u]['dep'] < req_dep: logs.append(f"Iter {iteration}: Holding {follower.id} at {u}. Req HW: {hw}") follower.schedule[f_u]['dep'] = req_dep req_arr = leader.schedule[l_v]['arr'] + hw current_const = follower.schedule[f_v].get('min_arr_constraint') if current_const is None or req_arr > current_const: logs.append(f"Iter {iteration}: Slowing {follower.id} on {u}->{v}. Req HW: {hw}") follower.schedule[f_v]['min_arr_constraint'] = req_arr update_train_times(follower, infra, base_time) if follower_is_winner: if l_v < len(leader.schedule): follower_next_dep = follower.schedule[f_v]['dep'] req_leader_dep = follower_next_dep + hw if leader.schedule[l_v]['dep'] < req_leader_dep: logs.append(f"Iter {iteration}: Overtake at {v}. Holding {leader.id}.") leader.schedule[l_v]['dep'] = req_leader_dep update_train_times(leader, infra, base_time) else: u_w, v_w = get_idx(winner, u), get_idx(winner, v) t_clear = max(winner.schedule[u_w]['arr'], winner.schedule[v_w]['arr']) u_l, v_l = get_idx(loser, u), get_idx(loser, v) l_ent = min(u_l, v_l) if loser.schedule[l_ent]['dep'] < t_clear: logs.append(f"Iter {iteration}: Opp Dir. Holding {loser.id} at {u}.") loser.schedule[l_ent]['dep'] = t_clear update_train_times(loser, infra, base_time) iteration += 1 return logs # ========================================== # 5. DATA EXPORT & VISUALS # ========================================== def generate_timetable_matrix(trains, infra): data = [] columns = ["Train ID", "Direction"] for s in infra.stations: columns.append(f"{s} Arr") columns.append(f"{s} Dep") for t in trains: row = {"Train ID": t.id, "Direction": t.direction} for s_data in t.schedule: s_name = s_data['station'] row[f"{s_name} Arr"] = s_data['arr'].strftime("%H:%M:%S") row[f"{s_name} Dep"] = s_data['dep'].strftime("%H:%M:%S") for s in infra.stations: if f"{s} Arr" not in row: row[f"{s} Arr"] = "-" if f"{s} Dep" not in row: row[f"{s} Dep"] = "-" data.append(row) df = pd.DataFrame(data) final_cols = ["Train ID", "Direction"] for s in infra.stations: final_cols.append(f"{s} Arr") final_cols.append(f"{s} Dep") return df[final_cols] def calculate_delay_stats(nom_trains, adj_trains, primary_delay_df): stats = [] primary_map = {} if primary_delay_df is not None and not primary_delay_df.empty: for _, row in primary_delay_df.iterrows(): if not pd.isna(row.iloc[0]): tid = str(row.iloc[0]) try: val = float(row.iloc[2]) except: val = 0.0 primary_map[tid] = primary_map.get(tid, 0) + val for t_nom, t_adj in zip(nom_trains, adj_trains): nom_end = t_nom.schedule[-1]['arr'] adj_end = t_adj.schedule[-1]['arr'] total_delay = (adj_end - nom_end).total_seconds() / 60.0 p_delay = primary_map.get(t_nom.id, 0.0) s_delay = max(0.0, total_delay - p_delay) if p_delay > 0 and s_delay > 0.1: d_type = "Both" elif p_delay > 0: d_type = "Primary Only" elif s_delay > 0.1: d_type = "Secondary" else: d_type = "On Time" stats.append({ "Train ID": t_nom.id, "Total Delay": round(total_delay, 1), "Primary": round(p_delay, 1), "Secondary": round(s_delay, 1), "Type": d_type }) return pd.DataFrame(stats) def generate_buffer_report(trains, primary_delay_df): report_data = [] requested_delays = {} if primary_delay_df is not None and not primary_delay_df.empty: for _, row in primary_delay_df.iterrows(): if not pd.isna(row.iloc[0]): tid = str(row.iloc[0]) st = str(row.iloc[1]) try: val = float(row.iloc[2]) except: val = 0.0 requested_delays[(tid, st)] = val for t in trains: for node in t.schedule: st = node['station'] total_manual = node.get('manual_delay', timedelta(0)).total_seconds() / 60.0 requested = requested_delays.get((t.id, st), 0.0) buffer_val = total_manual - requested if buffer_val > 0.1: report_data.append({ "Train ID": t.id, "Station": st, "Requested Delay (min)": round(requested, 1), "Total Added Delay (min)": round(total_manual, 1), "System Buffer (min)": round(buffer_val, 1) }) if not report_data: return pd.DataFrame(columns=["Train ID", "Station", "Requested Delay (min)", "Total Added Delay (min)", "System Buffer (min)"]) return pd.DataFrame(report_data) # ========================================== # 3B. ICONOGRAPHY & LIVE METRIC STRIP # ========================================== # Gradio has no npm/CDN icon library, so section glyphs are a small set of # hand-drawn, single-stroke geometric marks (uniform stroke-width, one accent # color) instead of emoji - consistent, offline-safe, and not decorative. _ICON_PATHS = { "train": '' '' '', "clock": '', "target": '' '', "chart": '', "coin": '' '', "building": '' '', "dice": '' '', "scale": '' '', "list": '', "bars": '', "alert": '' '', "check-circle": '', } def icon_svg(name, size=18): return (f'{_ICON_PATHS[name]}') def section_header(icon, text, level=3): return f'{icon_svg(icon)}{text}' def build_metric_html(df_stats, logs_df): """Live KPI row: recomputed after every simulation run instead of showing fixed placeholder copy.""" if df_stats is None or df_stats.empty: trains_n, conflicts_n, avg_delay, on_time_pct = "-", "-", "-", "-" flag_conflicts = False else: trains_n = str(len(df_stats)) avg_delay = f"{df_stats['Total Delay'].mean():.1f}" on_time = int((df_stats['Type'] == 'On Time').sum()) on_time_pct = str(round(on_time / len(df_stats) * 100)) if logs_df is not None and not logs_df.empty: conflicts = sum(1 for v in logs_df.iloc[:, 0] if isinstance(v, str) and v.startswith("Iter")) else: conflicts = 0 conflicts_n = str(conflicts) flag_conflicts = conflicts > 0 conflict_icon_cls = "kpi-icon is-flagged" if flag_conflicts else "kpi-icon" conflict_val_cls = "kpi-value is-flagged" if flag_conflicts else "kpi-value" def card(icon, icon_cls, label_text, value_cls, value_html, caption): return f'''
{icon_svg(icon, 15)}
{label_text}
{value_html}
{caption}
''' return f'''
{card("train", "kpi-icon", "Trains simulated", "kpi-value", trains_n, "from Train Requests")} {card("alert", conflict_icon_cls, "Conflicts resolved", conflict_val_cls, conflicts_n, "auto-resolved")} {card("clock", "kpi-icon is-neutral", "Avg total delay", "kpi-value", f'{avg_delay} min', "across all trains")} {card("check-circle", "kpi-icon", "On-time trains", "kpi-value", f'{on_time_pct}%', "within buffer cap")}
''' def create_filtered_marey(t_nom, t_del, t_adj, infra, view_mode, selected_train_id): fig = go.Figure() # Categorical palette tuned bright enough to read on a near-black surface. colors = ['#5DB8FF', '#FF8A8A', '#FFD166', '#7EE9A8', '#C9A6FF', '#FFA45C'] def get_dist(s): return infra.dist_map.get(s, 0) def add_lines(trains, color, style, name_suffix, opacity, visible, width=2): for i, t in enumerate(trains): is_selected = (str(t.id) == str(selected_train_id)) x, y, txt = [], [], [] if is_selected and style == 'solid': c = '#3DDC84'; w = 5; op = 1.0 else: c = colors[i % len(colors)] if color == 'auto' else color w = width; op = opacity for s in t.schedule: d = get_dist(s['station']) x.extend([s['arr'], s['dep']]) y.extend([d, d]) txt.extend([f"Arrival at {s['station']}", f"Departure from {s['station']}"]) fig.add_trace(go.Scatter( x=x, y=y, mode='lines+markers' if style == 'solid' else 'lines', name=f"T{t.id} {name_suffix}" + (" · selected" if is_selected else ""), line=dict(color=c, width=w, dash='solid' if style == 'solid' else style, shape='spline'), marker=dict(size=7, color=c, line=dict(width=1, color='#141815')) if style == 'solid' else None, hovertext=txt, hovertemplate="%{hovertext}
%{x|%H:%M:%S}
%{y} km", opacity=op, visible=visible )) if view_mode in ["All Scenarios", "Nominal Only"]: add_lines(t_nom, '#5A6660', 'dash', "(Nominal)", 0.55, True) if view_mode in ["All Scenarios", "Conflicted Only"]: add_lines(t_del, '#FF6B6B', 'dot', "(Conflicted)", 0.78, True if view_mode != "All Scenarios" else 'legendonly') if view_mode in ["All Scenarios", "Resolved Only"]: add_lines(t_adj, 'auto', 'solid', "(Resolved)", 1.0, True, width=3) fig.update_layout( title=dict( text=f"Marey Diagram · {view_mode}", x=0.02, y=0.98, xanchor='left', yanchor='top', font=dict(size=14, color='#E7ECE7', family='JetBrains Mono, monospace') ), xaxis_title="Time", yaxis_title="Station / distance", yaxis=dict(tickmode='array', tickvals=infra.distances, ticktext=infra.stations, gridcolor='rgba(231,236,231,0.07)', zeroline=False), xaxis=dict(tickformat="%H:%M", gridcolor='rgba(231,236,231,0.07)', zeroline=False), plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', font=dict(color='#7C8C82', family='JetBrains Mono, monospace'), legend=dict( orientation='h', yanchor='top', y=-0.20, xanchor='center', x=0.5, bgcolor='rgba(20,24,21,0.9)', bordercolor='rgba(231,236,231,0.12)', borderwidth=1 ), hoverlabel=dict(bgcolor='#191F1B', font_color='#E7ECE7', bordercolor='rgba(231,236,231,0.15)'), margin=dict(l=58, r=24, t=70, b=120), height=650 ) return fig def simulate_capacity_vs_reliability(infra, fleet_profiles, delay_data, max_trains_input, tac_fee, pr_rate, d_mode, r_prob, r_min, r_max, im_resp_share): import random from datetime import datetime, timedelta import pandas as pd import plotly.graph_objects as go results = [] try: max_t = int(max_trains_input) except: max_t = 20 if max_t < 2: max_t = 2 densities = list(range(2, max_t + 1)) base_time = datetime(2024, 1, 1, 8, 0, 0) try: im_share = float(im_resp_share) except: im_share = 50.0 best_profit = -float('inf') best_trains = [] best_delay_df = pd.DataFrame() # <-- Initialize the dataframe to hold the best delays for tph in densities: scheduled_headway = 60.0 / tph trains = [] current_run_delays = [] # <-- Track delays just for this specific TPH run for i in range(tph): profile = fleet_profiles[i % len(fleet_profiles)] new_train = profile.copy() generated_id = str(i + 1) new_train.original_id = generated_id new_train.id = f"T{generated_id}_{profile.id}" new_train.start_time_offset = i * scheduled_headway new_train.had_primary_delay = False # Track this cleanly! update_train_times(new_train, infra, base_time) # --- INJECT DELAYS BASED ON USER MODE --- if d_mode == "Predefined (from Delays Tab)" and delay_data is not None and not delay_data.empty: for _, d_row in delay_data.iterrows(): d_keys = d_row.keys() delay_t_id = "" if "Train ID" in d_keys: delay_t_id = str(d_row["Train ID"]).strip() elif "Trian ID" in d_keys: delay_t_id = str(d_row["Trian ID"]).strip() st_name = str(d_row.get("Station", "")).strip() d_val = 0.0 if "Delay" in d_keys: d_val = float(d_row["Delay"]) elif "Delay (min)" in d_keys: d_val = float(d_row["Delay (min)"]) if delay_t_id == generated_id and d_val > 0: new_train.had_primary_delay = True # Responsibility is decided by the same rule for every # primary delay, regardless of whether the delay is # predefined (from the Delays tab) or randomly generated - # it's set once, before the run, via the "IM Responsibility # Share" slider, so results across capacity levels stay # comparable and consistent between delay modes. party = "IM" if random.uniform(0, 100) <= im_share else "RU" amount = round(d_val * pr_rate, 2) current_run_delays.append({ "Train ID": new_train.id, "Station": st_name, "Delay (min)": d_val, "Responsible Party": party, "Amount (€)": amount, "Effect on IM": f"Penalty to IM: -€{amount:,.2f}" if party == "IM" else f"Income for IM: +€{amount:,.2f}" }) shift_active = False for stop_event in new_train.schedule: if st_name and st_name.lower() == str(stop_event['station']).lower(): shift_active = True if shift_active: stop_event['arr'] += timedelta(minutes=d_val) stop_event['dep'] += timedelta(minutes=d_val) elif d_mode == "Randomized": # Roll the dice to see if this train gets delayed if random.uniform(0, 100) <= float(r_prob): new_train.had_primary_delay = True # Pick a random station (excluding the final destination) rand_idx = random.randint(0, len(new_train.schedule) - 2) rand_station = new_train.schedule[rand_idx]['station'] d_val = round(random.uniform(float(r_min), float(r_max)), 1) # Same responsibility rule as the predefined branch above - # the IM Responsibility Share slider, not a fresh 50/50 pick. party = "IM" if random.uniform(0, 100) <= im_share else "RU" amount = round(d_val * pr_rate, 2) # Log the generated delay current_run_delays.append({ "Train ID": new_train.id, "Station": rand_station, "Delay (min)": d_val, "Responsible Party": party, "Amount (€)": amount, "Effect on IM": f"Penalty to IM: -€{amount:,.2f}" if party == "IM" else f"Income for IM: +€{amount:,.2f}" }) shift_active = False for stop_event in new_train.schedule: if stop_event['station'] == rand_station: shift_active = True if shift_active: stop_event['arr'] += timedelta(minutes=d_val) stop_event['dep'] += timedelta(minutes=d_val) trains.append(new_train) resolve_all_conflicts(trains, infra, base_time) # --- CALCULATE SECONDARY DELAY --- total_secondary_delay = 0 for i, train in enumerate(trains): if getattr(train, 'had_primary_delay', False): continue dummy = fleet_profiles[i % len(fleet_profiles)].copy() dummy.start_time_offset = train.start_time_offset update_train_times(dummy, infra, base_time) last_station_idx = len(dummy.stops) - 1 expected_arr = dummy.schedule[last_station_idx]['arr'] actual_arr = train.schedule[last_station_idx]['arr'] delay_min = (actual_arr - expected_arr).total_seconds() / 60.0 if delay_min > 0: total_secondary_delay += delay_min # --- PRIMARY DELAY RESPONSIBILITY COST (assigned before the run) --- # Secondary delays are always paid by IM (PR Penalty above, unchanged). # Primary delays are charged to whichever party (IM/RU) was assigned: # IM-caused = penalty for IM; RU-caused = income for IM (RU pays IM). primary_im_penalty = sum(d["Amount (€)"] for d in current_run_delays if d["Responsible Party"] == "IM") primary_ru_income = sum(d["Amount (€)"] for d in current_run_delays if d["Responsible Party"] == "RU") revenue = tph * tac_fee penalty_cost = total_secondary_delay * pr_rate profit = revenue - penalty_cost - primary_im_penalty + primary_ru_income # --- CHECK IF THIS IS THE BEST SCENARIO --- if profit > best_profit: best_profit = profit best_trains = [t.copy() for t in trains] # Save the delays that were applied to this specific winning scenario! if current_run_delays: best_delay_df = pd.DataFrame(current_run_delays) else: best_delay_df = pd.DataFrame(columns=["Train ID", "Station", "Delay (min)", "Responsible Party", "Amount (€)", "Effect on IM"]) results.append({ 'Capacity (Trains/Hour)': tph, 'Total Secondary Delay (min)': total_secondary_delay, 'Revenue (€)': revenue, 'PR Penalty (€)': penalty_cost, 'IM Primary Penalty (€)': primary_im_penalty, 'IM Primary Income (€)': primary_ru_income, 'Total Profit (€)': profit }) df = pd.DataFrame(results) timetable_data = [] if best_trains: for t in best_trains: for stop in t.schedule: timetable_data.append({ "Train ID": t.id, "Station": stop['station'], "Arrival": stop['arr'].strftime("%H:%M:%S"), "Departure": stop['dep'].strftime("%H:%M:%S") }) best_timetable_df = pd.DataFrame(timetable_data) fig = go.Figure() fig.add_trace(go.Scatter(x=df['Capacity (Trains/Hour)'], y=df['Revenue (€)'], mode='lines+markers', name='TAC Revenue', line=dict(color='#5DB8FF', width=3, shape='spline'), marker=dict(size=7))) fig.add_trace(go.Scatter(x=df['Capacity (Trains/Hour)'], y=df['PR Penalty (€)'], mode='lines+markers', name='PR Penalty Cost', line=dict(dash='dash', color='#FF6B6B', width=3, shape='spline'), marker=dict(size=7))) fig.add_trace(go.Scatter(x=df['Capacity (Trains/Hour)'], y=df['IM Primary Income (€)'] - df['IM Primary Penalty (€)'], mode='lines+markers', name='Primary Delay Net (IM)', line=dict(dash='dot', color='#7EE9A8', width=3, shape='spline'), marker=dict(size=7))) fig.add_trace(go.Scatter(x=df['Capacity (Trains/Hour)'], y=df['Total Profit (€)'], mode='lines+markers', name='Total Profit', line=dict(width=4, color='#E7ECE7', shape='spline'), marker=dict(size=8))) best_tph = df.loc[df['Total Profit (€)'].idxmax()]['Capacity (Trains/Hour)'] fig.add_vline(x=best_tph, line_dash="dot", line_color="#3DDC84", annotation_text="Sweet Spot", annotation_position="top left") fig.update_layout( title=dict(text="Capacity vs. Reliability Sweet Spot", x=0.02, xanchor='left', font=dict(size=18, color='#E7ECE7', family='JetBrains Mono, monospace')), xaxis_title="Scheduled trains per hour", yaxis_title="Euros (€)", plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', font=dict(color='#7C8C82', family='JetBrains Mono, monospace'), xaxis=dict(gridcolor='rgba(231,236,231,0.07)', zeroline=False), yaxis=dict(gridcolor='rgba(231,236,231,0.07)', zeroline=False), legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, bgcolor='rgba(20,24,21,0.9)', bordercolor='rgba(231,236,231,0.12)', borderwidth=1), hoverlabel=dict(bgcolor='#191F1B', font_color='#E7ECE7', bordercolor='rgba(231,236,231,0.15)'), margin=dict(l=64, r=24, t=88, b=58) ) # --- GENERATE THE TRADITIONAL MAREY DIAGRAM (BOTTOM-LEFT TO TOP-RIGHT) --- best_marey_fig = go.Figure() dist_map = {st: dist for st, dist in zip(infra.stations, infra.distances)} if best_trains: for t in best_trains: times = [] locs = [] for stop in t.schedule: times.append(stop['arr']) locs.append(dist_map[stop['station']]) times.append(stop['dep']) locs.append(dist_map[stop['station']]) color = '#FF6B6B' if getattr(t, 'had_primary_delay', False) else '#5DB8FF' best_marey_fig.add_trace(go.Scatter( x=times, y=locs, mode='lines+markers', name=t.id, line=dict(color=color, width=3, shape='spline'), marker=dict(size=5, color=color, line=dict(width=1, color='#141815')) )) station_names = list(dist_map.keys()) station_distances = list(dist_map.values()) best_marey_fig.update_layout( title=dict( text=f"Optimal Space-Time Diagram · {best_tph} Trains/Hour", x=0.02, y=0.98, xanchor='left', yanchor='top', font=dict(size=14, color='#E7ECE7', family='JetBrains Mono, monospace') ), xaxis_title="Time", yaxis_title="Station / distance", xaxis=dict(tickformat="%H:%M", tickangle=-35, gridcolor='rgba(231,236,231,0.07)', zeroline=False), yaxis=dict( tickmode='array', tickvals=station_distances, ticktext=[f"{st} ({d}km)" for st, d in zip(station_names, station_distances)], gridcolor='rgba(231,236,231,0.07)', zeroline=False ), plot_bgcolor='rgba(0,0,0,0)', paper_bgcolor='rgba(0,0,0,0)', font=dict(color='#7C8C82', family='JetBrains Mono, monospace'), hoverlabel=dict(bgcolor='#191F1B', font_color='#E7ECE7', bordercolor='rgba(231,236,231,0.15)'), showlegend=False, margin=dict(l=72, r=24, t=70, b=64) ) # Returning 5 elements, matching what run_economic_analysis expects! return df, fig, best_timetable_df, best_marey_fig, best_delay_df # ========================================== # 6. SIMULATION & GRADIO # ========================================== def run_simulation(station_data, train_data, delay_data, spacing_input, plot_view, selected_train_input, max_buffer_input, d_mode, r_prob, r_min, r_max): import random from datetime import datetime, timedelta import pandas as pd # Just in case it's not globally available here # ---------------------------------------------------- # DATA CLEANING & HEADER RECOVERY (Robust to CSV errors) # ---------------------------------------------------- # 1. Clean Train Data if train_data is not None: train_data = _normalize_uploaded_dataframe(train_data) # 2. Clean Delay Data if delay_data is not None: delay_data = _normalize_uploaded_dataframe(delay_data) # 3. Clean Station Data if station_data is not None: station_data = _normalize_uploaded_dataframe(station_data) # ---------------------------------------------------- try: sp_km = float(spacing_input) except: sp_km = 5.0 try: max_buff = float(max_buffer_input) except: max_buff = 15.0 infra = Infrastructure(station_data, spacing_km=sp_km) base_time = datetime.strptime("08:00", "%H:%M") # 1. Nominal trains_nom = [] stations_list = infra.stations if train_data is None or train_data.empty: return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(["No train data"]), go.Figure(), build_metric_html(None, None) for _, row in train_data.iterrows(): if pd.isna(row.iloc[0]): continue try: t_id = str(row['Train ID']) direction = row['Direction'] stops = stations_list[:] if str(direction).strip().lower() == "outbound" else stations_list[::-1] t = Train(t_id, direction, row['Priority'], stops, row['Start Time Offset (min)'], row['Speed (km/h)'], row['Dwell (min)']) update_train_times(t, infra, base_time) trains_nom.append(t) except KeyError as e: return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame([f"CSV Error: Column missing - {e}"]), go.Figure(), build_metric_html(None, None) # 2. Delayed trains_del = [t.copy() for t in trains_nom] # --- THE BAIT AND SWITCH: GENERATE RANDOM DATAFRAME IF SELECTED --- if d_mode == "Randomized (Monte Carlo)": random_delays = [] for t in trains_del: if random.uniform(0, 100) <= float(r_prob): if len(t.stops) > 1: # Pick a random station (not the final destination) rand_idx = random.randint(0, len(t.stops) - 2) rand_station = t.stops[rand_idx] d_val = round(random.uniform(float(r_min), float(r_max)), 1) random_delays.append({"Train ID": t.id, "Station": rand_station, "Delay": d_val}) # Override the delay_data variable so your existing code thinks it came from the CSV! if random_delays: delay_data = pd.DataFrame(random_delays) else: delay_data = pd.DataFrame(columns=["Train ID", "Station", "Delay"]) # --------------------------------------------------------------- delay_lookup = {} if delay_data is not None and not delay_data.empty: for _, row in delay_data.iterrows(): if pd.isna(row.iloc[0]): continue tid = str(row.iloc[0]) st = str(row.iloc[1]) try: val = float(row.iloc[2]) except: val = 0.0 delay_lookup[(tid, st)] = val directions = set(t.direction for t in trains_del) for d in directions: d_trains = [t for t in trains_del if t.direction == d] d_trains.sort(key=lambda x: x.start_time_offset) last_departure_time = None for t in d_trains: if not t.schedule: update_train_times(t, infra, base_time) origin_s = t.schedule[0]['station'] prim_delay_min = delay_lookup.get((t.id, origin_s), 0.0) nominal_start = base_time + timedelta(minutes=t.start_time_offset) earliest_start = nominal_start + timedelta(minutes=prim_delay_min) if last_departure_time: h_min = infra.get_dynamic_headway(t.speed_kmh) min_safe_start = last_departure_time + h_min actual_start = max(earliest_start, min_safe_start) else: actual_start = earliest_start t.schedule[0]['manual_delay'] = actual_start - nominal_start for node in t.schedule[1:]: st_name = node['station'] d_val = delay_lookup.get((t.id, st_name), 0.0) if d_val > 0: node['manual_delay'] = timedelta(minutes=d_val) update_train_times(t, infra, base_time) last_departure_time = t.schedule[0]['dep'] # 3. Adjusted (Resolution with Max Buffer) trains_adj = [t.copy() for t in trains_del] selected_train_id = None if selected_train_input and selected_train_input != "None": selected_train_id = str(selected_train_input) logs = resolve_all_conflicts(trains_adj, infra, base_time, selected_train_id=selected_train_id, max_buffer_min=max_buff) # 4. Outputs df_nom_matrix = generate_timetable_matrix(trains_nom, infra) df_adj_matrix = generate_timetable_matrix(trains_adj, infra) df_stats = calculate_delay_stats(trains_nom, trains_adj, delay_data) df_buffer = generate_buffer_report(trains_adj, delay_data) fig = create_filtered_marey(trains_nom, trains_del, trains_adj, infra, plot_view, selected_train_id) logs_df = pd.DataFrame(logs, columns=["Logs"]) metric_html = build_metric_html(df_stats, logs_df) return df_nom_matrix, df_adj_matrix, df_stats, df_buffer, logs_df, fig, metric_html def get_train_choices(df): if df is None or df.empty: return gr.update(choices=["None"], value="None", interactive=True) choices = ["None"] try: # Check potential ID columns col = None if 'Train ID' in df.columns: col = 'Train ID' elif len(df.columns) > 0: col = df.columns[0] # Fallback to first column if col: valid_ids = df[col].dropna().astype(str).str.strip().tolist() # Keep the list stable and remove duplicates while preserving order. seen = set(choices) for train_id in valid_ids: if train_id and train_id not in seen: choices.append(train_id) seen.add(train_id) except Exception: pass return gr.update(choices=choices, value="None", interactive=True) # Helper function to load the trains CSV and refresh the selected-train dropdown at the same time. def load_train_csv(file): df = load_csv(file) return df, get_train_choices(df) # --- PRIMARY DELAY RESPONSIBILITY --- # Responsibility for a primary delay (IM vs RU) is decided by a single shared # rule - the "IM Responsibility Share" percentage set before running the # sweet-spot search - applied identically whether the delay is predefined # (from the Delays tab) or randomly generated during the run. This keeps the # assumption consistent across delay modes and across every capacity level # tested (see simulate_capacity_vs_reliability). If IM is responsible it's a # penalty for IM; if RU is responsible it's income for IM (RU pays IM). # Secondary delays are untouched - always a cost paid by IM (PR Penalty). # Add delay_data to the function inputs def run_economic_analysis(station_data, fleet_data, delay_data, spacing_input, max_trains, tac_val, pr_val, d_mode, r_prob, r_min, r_max, im_resp_share): import pandas as pd import plotly.graph_objects as go try: sp_km = float(spacing_input) except: sp_km = 5.0 if station_data is not None: station_data = _normalize_uploaded_dataframe(station_data) if delay_data is not None: delay_data = _normalize_uploaded_dataframe(delay_data) if fleet_data is not None: fleet_data = _normalize_uploaded_dataframe(fleet_data) infra = Infrastructure(station_data, spacing_km=sp_km) if fleet_data is None or fleet_data.empty: # We now return 5 empty/error elements return pd.DataFrame([{"Error": "No fleet data defined"}]), go.Figure(), pd.DataFrame(), go.Figure(), pd.DataFrame() fleet_data = fleet_data.dropna(how='all') fleet_data.columns = fleet_data.columns.astype(str).str.strip() fleet_profiles = [] for _, row in fleet_data.iterrows(): t_id = str(row.get("Type Name", "Standard")).strip() speed = float(row.get("Speed (km/h)", 100)) dwell = float(row.get("Dwell (min)", 2)) priority = int(row.get("Priority", 1)) stops = infra.stations[:] fleet_profiles.append(Train(t_id, "Outbound", priority, stops, 0, speed, dwell)) # Now unpack 5 variables from the simulation engine! df, fig, best_timetable_df, best_marey_fig, best_delay_df = simulate_capacity_vs_reliability( infra, fleet_profiles, delay_data, max_trains, tac_val, pr_val, d_mode, r_prob, r_min, r_max, im_resp_share ) return df, fig, best_timetable_df, best_marey_fig, best_delay_df # CSV loading and normalization helpers # ---------------------------------------------------- # Uploaded files often use slightly different header names, for example # "Trian ID", "Speed", or "Start time". The simulator expects canonical # internal names, so normalize them immediately when the CSV is loaded. def _compact_header(name): import re return re.sub(r"[^a-z0-9]+", "", str(name).strip().lower()) def _normalize_uploaded_dataframe(df): if df is None: return None df = df.copy() df = df.dropna(how='all') df.columns = df.columns.astype(str).str.strip() compact_cols = [_compact_header(c) for c in df.columns] compact_set = set(compact_cols) train_markers = { "trainid", "trianid", "direction", "priority", "speed", "speedkmh", "starttime", "startoffset", "starttimeoffset", "starttimeoffsetmin", "dwell", "dwellmin", "dwellminutes" } station_markers = {"distance", "distancekm", "km", "stationdistance"} delay_markers = {"delay", "delaymin", "delayminutes", "delaymins"} if compact_set & train_markers: csv_kind = "trains" elif compact_set & station_markers: csv_kind = "stations" elif compact_set & delay_markers: csv_kind = "delays" else: csv_kind = "unknown" base_aliases = { "trainid": "Train ID", "trianid": "Train ID", # common typo in uploaded files "trainno": "Train ID", "trainnumber": "Train ID", "id": "Train ID" if csv_kind == "trains" else "ID", "direction": "Direction", "dir": "Direction", "priority": "Priority", "speed": "Speed (km/h)", "speedkmh": "Speed (km/h)", "speedkmph": "Speed (km/h)", "start": "Start Time Offset (min)", "starttime": "Start Time Offset (min)", "startoffset": "Start Time Offset (min)", "starttimeoffset": "Start Time Offset (min)", "starttimeoffsetmin": "Start Time Offset (min)", "starttimemin": "Start Time Offset (min)", "startminutes": "Start Time Offset (min)", "dwell": "Dwell (min)", "dwelltime": "Dwell (min)", "dwellmin": "Dwell (min)", "dwellminutes": "Dwell (min)", "delay": "Delay (min)", "delaymin": "Delay (min)", "delaymins": "Delay (min)", "delayminutes": "Delay (min)", "distance": "Distance (km)", "distancekm": "Distance (km)", "km": "Distance (km)", "typename": "Type Name", "type": "Type Name", } station_aliases = { "station": "Station Name" if csv_kind == "stations" else "Station", "stationname": "Station Name" if csv_kind == "stations" else "Station", "stationnames": "Station Name" if csv_kind == "stations" else "Station", "stop": "Station", "stops": "Station", } rename_map = {} used = set() for original in df.columns: key = _compact_header(original) new_name = station_aliases.get(key, base_aliases.get(key, original.strip())) # Avoid duplicate column names after normalization. if new_name in used: suffix = 2 candidate = f"{new_name} {suffix}" while candidate in used: suffix += 1 candidate = f"{new_name} {suffix}" new_name = candidate rename_map[original] = new_name used.add(new_name) df = df.rename(columns=rename_map) # Positional fallbacks for common no/weak-header files. if csv_kind == "trains" and "Train ID" not in df.columns and len(df.columns) >= 6: df.columns = [ "Train ID", "Direction", "Priority", "Speed (km/h)", "Start Time Offset (min)", "Dwell (min)" ] + list(df.columns[6:]) elif csv_kind == "stations" and "Station Name" not in df.columns and len(df.columns) >= 2: df.columns = ["Station Name", "Distance (km)"] + list(df.columns[2:]) elif csv_kind == "delays" and "Train ID" not in df.columns and len(df.columns) >= 3: df.columns = ["Train ID", "Station", "Delay (min)"] + list(df.columns[3:]) # Reorder known tables so the displayed matrix is consistent. desired_order = [] if csv_kind == "trains": desired_order = ["Train ID", "Direction", "Priority", "Speed (km/h)", "Start Time Offset (min)", "Dwell (min)"] elif csv_kind == "stations": desired_order = ["Station Name", "Distance (km)"] elif csv_kind == "delays": desired_order = ["Train ID", "Station", "Delay (min)"] if desired_order: ordered = [c for c in desired_order if c in df.columns] extras = [c for c in df.columns if c not in ordered] df = df[ordered + extras] return df.reset_index(drop=True) # Helper function to load CSV def load_csv(file): if file is None: return None file_path = getattr(file, "name", file) suffix = str(file_path).lower().rsplit(".", 1)[-1] if "." in str(file_path) else "" try: if suffix in {"xlsx", "xls"}: # .xlsx uses openpyxl in normal pandas installations; .xls uses xlrd when available. df = pd.read_excel(file_path) else: # sep=None lets pandas detect comma/semicolon/tab-delimited CSVs. df = pd.read_csv(file_path, sep=None, engine="python") return _normalize_uploaded_dataframe(df) except Exception: try: if suffix in {"xlsx", "xls"}: df = pd.read_excel(file_path) else: df = pd.read_csv(file_path) return _normalize_uploaded_dataframe(df) except Exception: return None d_stations = pd.DataFrame({'Station Name': ['A', 'B', 'C', 'D', 'E'], 'Distance (km)': [0, 15, 35, 45, 60]}) d_trains = pd.DataFrame({ 'Train ID': [101, 102], 'Direction': ['Outbound', 'Outbound'], 'Priority': [3, 2], 'Speed (km/h)': [60, 120], 'Start Time Offset (min)': [0, 5], 'Dwell (min)': [2, 2] }) d_delays = pd.DataFrame({'Train ID': [101], 'Station': ['B'], 'Delay (min)': [10]}) CUSTOM_CSS = """ :root { /* Dispatcher-console dark theme: near-black canvas, one locked phosphor- green accent, everything set in monospace. Amber is reserved strictly for the "conflicts > 0" warning state - never decoration. */ --hort-bg-1: #141815; /* card / surface, one step above canvas */ --hort-bg-2: #0A0C0A; /* page canvas, near-black (never pure #000) */ --hort-bg-3: #191F1B; /* table heads, input fields, hover fills */ --hort-border: rgba(231, 236, 231, 0.12); --hort-text: #E7ECE7; /* phosphor off-white, never pure #fff */ --hort-muted: #7C8C82; /* dim sage-gray secondary text */ --hort-cyan: #3DDC84; /* locked accent - phosphor green */ --hort-cyan-bg: rgba(61, 220, 132, 0.10); --hort-amber: #FFB100; /* semantic warning text (conflicts > 0 only) */ --hort-amber-bg: rgba(255, 177, 0, 0.10); } html, body, gradio-app { background: var(--hort-bg-2) !important; min-height: 100%; } .gradio-container { max-width: 1440px !important; margin: 0 auto !important; color: var(--hort-text) !important; background: var(--hort-bg-2) !important; font-family: 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', monospace !important; overflow: visible !important; position: relative; } /* Faint ambient glow pooling behind the top bar - depth without the CRT scanline texture, which read more "novelty terminal" than commercial product chrome. Fixed + pointer-events:none so it never repaints on scroll. */ .gradio-container::before { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; background: radial-gradient(ellipse 900px 420px at 50% -8%, rgba(61,220,132,0.04), transparent 70%); } .gradio-container h1, .gradio-container h2, .gradio-container h3, .gradio-container h4, .gradio-container p, .gradio-container label, .gradio-container .label-wrap, .gradio-container .prose, .gradio-container .prose *, .gradio-container .markdown, .gradio-container .markdown *, .gradio-container .gr-markdown, .gradio-container .gr-markdown * { color: var(--hort-text) !important; font-family: 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', monospace !important; } /* The Soft theme renders every field label as a solid primary-hue chip (a Gradio default, not something we set) - strip it back to plain small muted text so labels read as UI copy, not colored badges. */ .gradio-container [data-testid="block-info"] { background: transparent !important; color: var(--hort-muted) !important; padding: 0 !important; font-size: 12.5px !important; font-weight: 500 !important; box-shadow: none !important; } /* Prose (paragraph copy) reads better in a humanist sans than all-mono - headings, labels, data and buttons stay mono for the technical voice. */ .gradio-container .prose p, .gradio-container .markdown p, .gradio-container .gr-markdown p { font-family: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif !important; color: var(--hort-muted) !important; line-height: 1.7; font-size: 14px; } /* --------------------------------------------------------------------- App shell: a slim brand strip + the tab nav directly beneath it, both on the same surface, standing in for a real product top bar. Soft 14px cards below it instead of last build's sharp terminal edges - the corner-radius scale for THIS build is: bar/sidebar flush (0), inputs/buttons 8px, cards 14px. Held everywhere, no mixing. --------------------------------------------------------------------- */ .brand-strip { display: flex; align-items: center; gap: 14px; padding: 13px 6px; background: var(--hort-bg-1); border-bottom: 1px solid var(--hort-border); } .brand-mark { width: 24px; height: 24px; border-radius: 7px; background: var(--hort-cyan); display: flex; align-items: center; justify-content: center; flex: 0 0 auto; box-shadow: 0 0 14px rgba(61,220,132,0.35); } .brand-mark svg { color: #0A0C0A; } .brand-name { font-family: 'JetBrains Mono', monospace; font-weight: 600; font-size: 14px; letter-spacing: -0.01em; color: var(--hort-text) !important; } .brand-spacer { flex: 1 1 auto; } .status-pill { display: inline-flex; align-items: center; gap: 6px; background: var(--hort-cyan-bg); border-radius: 999px; padding: 5px 10px 5px 8px; } .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--hort-cyan); box-shadow: 0 0 5px rgba(61,220,132,0.8); } .status-pill span.label { font-size: 11px; font-weight: 600; color: var(--hort-cyan); } .avatar-chip { width: 28px; height: 28px; border-radius: 50%; background: var(--hort-bg-3); border: 1px solid var(--hort-border); display: flex; align-items: center; justify-content: center; font-size: 10.5px; color: var(--hort-muted); } /* KPI row: real stat cards (icon + label, big number, context line) instead of the previous bracket-hairline readout. */ .kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0 0 22px 0; } .kpi-card { background: var(--hort-bg-1); border: 1px solid var(--hort-border); border-radius: 14px; padding: 18px 20px; box-shadow: 0 20px 40px -28px rgba(0,0,0,0.6), inset 0 1px 0 rgba(231,236,231,0.03); display: flex; flex-direction: column; gap: 12px; } .kpi-head { display: flex; align-items: center; gap: 10px; } .kpi-icon { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; background: var(--hort-cyan-bg); } .kpi-icon.is-flagged { background: var(--hort-amber-bg); } .kpi-icon.is-neutral { background: rgba(231,236,231,0.06); } .kpi-icon svg { color: var(--hort-cyan); } .kpi-icon.is-flagged svg { color: var(--hort-amber); } .kpi-icon.is-neutral svg { color: var(--hort-muted); } .kpi-label { font-size: 12.5px; color: var(--hort-muted); } .kpi-value { font-family: 'JetBrains Mono', monospace; color: var(--hort-text) !important; font-size: 26px; font-weight: 600; } .kpi-value.is-flagged { color: var(--hort-amber) !important; } .kpi-value .unit { font-size: 14px; font-weight: 500; color: var(--hort-muted); } .kpi-caption { font-size: 11.5px; color: var(--hort-muted); margin-top: 2px; } @media (max-width: 900px) { .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 480px) { .kpi-grid { grid-template-columns: 1fr; } } /* Sidebar (run configuration) + main content columns. */ .app-sidebar { background: var(--hort-bg-1); border-right: 1px solid var(--hort-border); padding: 22px 20px !important; display: flex !important; flex-direction: column !important; } .app-sidebar .side-label { font-size: 11px; font-weight: 600; color: var(--hort-muted) !important; text-transform: uppercase; letter-spacing: .04em; margin: 4px 0 2px; } .sidebar-spacer { flex: 1 1 auto; min-height: 12px; } .app-main { padding: 24px 28px !important; } .data-row-card { min-height: 0; } /* Raw editable tables tuck behind a collapsed accordion by default, so the main pane reads as header + KPI row + one results panel, not a stack of six cards. */ .data-accordion { background: transparent !important; border: 1px dashed var(--hort-border) !important; border-radius: 10px !important; margin-bottom: 20px !important; } .data-accordion > .label-wrap, .data-accordion .label-wrap button { font-size: 13px !important; color: var(--hort-muted) !important; font-family: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif !important; } /* Section-header icon + label, replacing emoji glyphs. Bolder stroke reads closer to a Phosphor "Bold" weight than a generic thin-line icon set. */ .hort-heading { display: flex !important; align-items: center; gap: 10px; font-weight: 600 !important; text-transform: uppercase; font-size: 14px !important; letter-spacing: .03em; } .hort-heading .hort-icon { flex: 0 0 auto; color: var(--hort-cyan); opacity: .9; } /* --------------------------------------------------------------------- Cards: dark surface, hairline border, sharp 4px radius, top bezel highlight for depth - a green-tinted glow appears on hover only. --------------------------------------------------------------------- */ .panel-card, .chart-card, .results-card { position: relative !important; overflow: visible !important; background: var(--hort-bg-1) !important; border: 1px solid var(--hort-border) !important; border-radius: 14px !important; padding: 24px !important; box-shadow: inset 0 1px 0 rgba(231,236,231,0.04) !important; backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } .chart-card { min-height: 720px; } .panel-card, .results-card { transition: border-color .2s ease, box-shadow .2s ease; } .panel-card:hover, .results-card:hover { border-color: rgba(61,220,132,0.30) !important; box-shadow: inset 0 1px 0 rgba(231,236,231,0.04), 0 0 24px rgba(61,220,132,0.06) !important; } .panel-card h3, .chart-card h3, .results-card h3 { color: var(--hort-text) !important; font-weight: 600 !important; margin-top: 0 !important; } /* Keep the visual layer smooth while allowing dropdown menus to open. */ .gradio-container .block, .gradio-container .form, .gradio-container .panel, .gradio-container .gr-box, .gradio-container .contain, .gradio-container .wrap { border-color: var(--hort-border) !important; border-radius: 8px !important; box-shadow: none !important; overflow: visible !important; } .panel-card .block, .panel-card .form, .panel-card .gr-box, .panel-card .contain, .panel-card .wrap, .chart-card .block, .chart-card .form, .chart-card .gr-box, .chart-card .contain, .chart-card .wrap, .results-card .block, .results-card .form, .results-card .gr-box, .results-card .contain, .results-card .wrap { background: transparent !important; border-color: var(--hort-border) !important; } .gradio-container input, .gradio-container textarea, .gradio-container select, .gradio-container button { border-radius: 8px !important; font-family: 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', monospace !important; } .gradio-container input, .gradio-container textarea, .gradio-container select { background: var(--hort-bg-3) !important; color: var(--hort-text) !important; border: 1px solid var(--hort-border) !important; box-shadow: none !important; } .gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus { border-color: var(--hort-cyan) !important; box-shadow: 0 0 0 2px rgba(61, 220, 132, 0.14) !important; } /* View & Optimization selectors Keep dropdowns native. Do not style Gradio's internal combobox/listbox/menu/button elements; doing so can make the menu jump, flicker, or open in the wrong place. */ .hort-listbox { border-radius: 8px !important; } .hort-listbox label, .hort-listbox .label-wrap { color: var(--hort-text) !important; font-weight: 600 !important; } .selector-strip { padding: 4px 0 2px 0 !important; overflow: visible !important; position: relative !important; z-index: 20 !important; } #view_listbox, #train_listbox { position: relative !important; z-index: 30 !important; } /* Important: do not apply blur/filter/transform around Gradio dropdowns. These can offset the popover menu and make it appear in the wrong section. */ .hort-view-select, .hort-train-select { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; filter: none !important; transform: none !important; } .gradio-container input[type="radio"], .gradio-container input[type="checkbox"] { accent-color: var(--hort-cyan) !important; } .gradio-container fieldset { background: transparent !important; border: 1px solid var(--hort-border) !important; border-radius: 8px !important; } .hort-radio fieldset { padding: 6px !important; display: flex !important; gap: 6px !important; } .hort-radio label { display: flex !important; align-items: center !important; gap: 8px; margin: 0 !important; padding: 10px 14px !important; border-radius: 6px !important; background: transparent !important; border: 1px solid transparent !important; color: var(--hort-muted) !important; font-weight: 500 !important; transition: all .15s ease !important; } .hort-radio label:hover { background: var(--hort-bg-3) !important; } .hort-radio label:has(input[type="radio"]:checked), .hort-radio label[aria-checked="true"], .hort-radio .selected { background: var(--hort-cyan-bg) !important; border: 1px solid rgba(61,220,132,0.35) !important; border-radius: 6px !important; box-shadow: 0 0 12px rgba(61,220,132,0.08) !important; color: var(--hort-cyan) !important; font-weight: 700 !important; } /* File input laid out as one horizontal row: label | compact file picker | helper text. */ .input-file-example { width: 100% !important; max-width: none !important; margin: 0 !important; padding: 0 !important; display: flex !important; flex-direction: row !important; align-items: center !important; justify-content: flex-start !important; flex-wrap: nowrap !important; gap: 0.75rem !important; background: transparent !important; border: 0 !important; box-shadow: none !important; } /* Keep Gradio's child wrappers from stretching each item onto its own line. */ .input-file-example > * { margin: 0 !important; } .input-file-example .input-file-label-wrap { flex: 0 0 auto !important; width: auto !important; min-width: max-content !important; } .input-file-example .input-file-control { flex: 0 0 20rem !important; width: 20rem !important; max-width: 20rem !important; min-width: 16rem !important; } .input-file-example .input-file-help-wrap { flex: 1 1 auto !important; width: auto !important; min-width: 0 !important; } .input-file-example .input-file-label { display: block !important; margin: 0 !important; color: var(--hort-text) !important; font-size: 0.8rem !important; line-height: 1.25rem !important; font-weight: 500 !important; white-space: nowrap !important; } /* Gradio component shell: compact native-style file field. */ .input-file-control { margin: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; box-shadow: none !important; } .input-file-control > div, .input-file-control .wrap, .input-file-control .block, .input-file-control .container { width: 100% !important; min-width: 0 !important; padding: 0 !important; margin: 0 !important; background: transparent !important; border: 0 !important; box-shadow: none !important; } /* Empty-state uploader. Gradio still handles click/drop/upload; only its appearance changes. */ .input-file-control button[aria-label*="upload" i], .input-file-control button[aria-label*="drop" i] { width: 100% !important; height: 2.25rem !important; /* compact Input height: 36px */ min-height: 2.25rem !important; max-height: 2.25rem !important; padding: 0 0.75rem !important; display: flex !important; align-items: center !important; justify-content: flex-start !important; gap: 0 !important; overflow: hidden !important; border: 1px solid var(--hort-border) !important; border-style: solid !important; border-radius: 6px !important; background: var(--hort-bg-3) !important; color: var(--hort-text) !important; box-shadow: none !important; outline: none !important; cursor: pointer !important; transition: border-color 120ms ease, box-shadow 120ms ease !important; } /* Hide Gradio's upload icon / drag-drop copy and render the native-file-input wording. */ .input-file-control button[aria-label*="upload" i] > *, .input-file-control button[aria-label*="drop" i] > * { display: none !important; } .input-file-control button[aria-label*="upload" i]::before, .input-file-control button[aria-label*="drop" i]::before { content: "Choose File"; display: inline-block; flex: 0 0 auto; margin-right: 0.75rem; color: var(--hort-text); font-size: 0.8rem; line-height: 2rem; font-weight: 500; white-space: nowrap; } .input-file-control button[aria-label*="upload" i]:focus-visible, .input-file-control button[aria-label*="drop" i]:focus-visible { border-color: var(--hort-cyan) !important; box-shadow: 0 0 0 2px rgba(61, 220, 132, 0.14) !important; } /* Selected-file state: keep the control compact instead of expanding into a preview card. */ .input-file-control .file, .input-file-control .file-preview, .input-file-control [class*="file-preview"] { min-height: 2.25rem !important; height: 2.25rem !important; max-height: 2.25rem !important; width: 100% !important; padding: 0 0.75rem !important; display: flex !important; align-items: center !important; overflow: hidden !important; border: 1px solid var(--hort-border) !important; border-radius: 6px !important; background: var(--hort-bg-3) !important; color: var(--hort-text) !important; box-shadow: none !important; } .input-file-control .file-name, .input-file-control [class*="file-name"] { min-width: 0 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; color: var(--hort-text) !important; font-size: 0.8rem !important; font-weight: 400 !important; } .input-file-control .file svg, .input-file-control .file-preview svg { display: none !important; } .input-file-example .input-file-help { margin: 0 !important; color: var(--hort-muted) !important; font-size: 0.75rem !important; line-height: 1rem !important; font-weight: 400 !important; white-space: nowrap !important; } .gradio-container table, .gradio-container thead, .gradio-container tbody, .gradio-container td, .gradio-container th { color: var(--hort-text) !important; border-color: var(--hort-border) !important; font-family: 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', monospace !important; } .gradio-container th { background: var(--hort-bg-3) !important; color: var(--hort-cyan) !important; font-weight: 600 !important; font-size: 12px !important; } .gradio-container td { background: var(--hort-bg-1) !important; font-size: 13px !important; } .gradio-container td, .gradio-container th { padding-top: 10px !important; padding-bottom: 10px !important; } .gradio-container .gradio-dataframe, .gradio-container [data-testid="dataframe"], .gradio-container .table-wrap { border: 1px solid var(--hort-border) !important; box-shadow: none !important; background: var(--hort-bg-1) !important; border-radius: 10px !important; overflow: auto !important; } /* Do not clip uploaded CSV matrices. Gradio virtualizes dataframe rows inside scrollable wrappers; forcing overflow:hidden can hide rows after upload. */ .gradio-container [data-testid="dataframe"] > div, .gradio-container .gradio-dataframe > div, .gradio-container .table-wrap > div { overflow: auto !important; } .gradio-container [data-testid="dataframe"] table, .gradio-container .gradio-dataframe table, .gradio-container .table-wrap table { overflow: visible !important; } .gradio-container .gradio-dataframe button[title*="fullscreen" i], .gradio-container .gradio-dataframe button[aria-label*="fullscreen" i], .gradio-container [data-testid="dataframe"] button[title*="fullscreen" i], .gradio-container [data-testid="dataframe"] button[aria-label*="fullscreen" i] { display: none !important; } .gradio-container button { font-weight: 600 !important; font-family: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif !important; font-size: 14px !important; } .gradio-container button.primary { color: #0A0C0A !important; background: var(--hort-cyan) !important; border: 1px solid var(--hort-cyan) !important; border-radius: 8px !important; box-shadow: 0 0 0 rgba(61,220,132,0) !important; transition: background-color .15s ease, transform .1s ease, box-shadow .2s ease !important; } .gradio-container button.primary:hover { background: #57E89C !important; border-color: #57E89C !important; box-shadow: 0 0 20px rgba(61,220,132,0.35) !important; } .gradio-container button.primary:active { transform: scale(0.98); } /* Underline tabs - the second tier of the top bar, directly under the brand strip (same background, hairline bottom border). */ .gradio-container .tab-nav { background: var(--hort-bg-1) !important; border: none !important; border-bottom: 1px solid var(--hort-border) !important; border-radius: 0 !important; padding: 0 !important; gap: 22px; } .gradio-container .tab-nav button { border-radius: 0 !important; background: transparent !important; color: var(--hort-muted) !important; border: none !important; border-bottom: 2px solid transparent !important; margin-bottom: -1px; font-family: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif !important; transition: color .15s ease; } .gradio-container .tab-nav button:hover { color: var(--hort-text) !important; } .gradio-container .tab-nav button.selected { color: var(--hort-text) !important; background: transparent !important; border-bottom: 2px solid var(--hort-cyan) !important; box-shadow: none !important; font-weight: 600 !important; } .hort-main-tabs > .tab-nav, .gradio-container .hort-main-tabs .tab-nav { margin-bottom: 22px !important; overflow: visible !important; padding: 0 6px !important; } .hort-main-tabs > .tab-nav button, .gradio-container .hort-main-tabs .tab-nav button { min-height: 44px !important; padding: 10px 2px !important; font-size: 14px !important; font-weight: 500 !important; } .plot-container, .js-plotly-plot, .plotly, .gr-plot { border-radius: 10px !important; overflow: hidden !important; } /* Keep the space-time diagram on the same dark surface as its card, while allowing Plotly's built-in download/export to use a transparent figure. */ .chart-card .gr-plot, .chart-card .plot-container, .chart-card .js-plotly-plot, .chart-card .plotly { background: var(--hort-bg-1) !important; } .footer-note { text-align: center; color: var(--hort-muted) !important; font-size: 12px; margin: 28px 0 8px 0; } footer { display: none !important; } /* Dropdown overlay stability fix for Hugging Face Spaces / Gradio: remove visual effects that can create a wrong containing block for the popup menu. */ .panel-card *, .results-card *, .chart-card * { scroll-margin-top: 0; } @media (max-width: 900px) { .app-sidebar { border-right: none; border-bottom: 1px solid var(--hort-border); } } @media (max-width: 560px) { .panel-card, .chart-card, .results-card { padding: 16px !important; } .kpi-value { font-size: 20px; } } """ # Dispatcher-console type system: JetBrains Mono for system chrome (headings, # labels, data, buttons, tabs) and Space Grotesk for human-readable prose # (hero subtitle, section descriptions) - mono everywhere reads efficient but # hurts paragraph legibility, so prose gets a companion sans built for the # same technical mood. Loaded via instead of a CSS @import so it # resolves before first paint on Hugging Face Spaces. CUSTOM_HEAD = """ """ try: HORT_THEME = gr.themes.Soft( primary_hue="green", secondary_hue="stone", neutral_hue="stone", radius_size="sm", spacing_size="md", ) except Exception: HORT_THEME = None def make_exact_file_input(): """Render label, file picker, and helper text on one horizontal line.""" with gr.Row(elem_classes=["input-file-example"]): gr.HTML( '', elem_classes=["input-file-label-wrap"] ) file_component = gr.File( label="Upload a file", show_label=False, file_types=[".csv", ".xlsx", ".xls"], height=36, elem_classes=["input-file-control"] ) gr.HTML( '

You are only allowed to upload .CSV, .XLSX or .XLS files.

', elem_classes=["input-file-help-wrap"] ) return file_component with gr.Blocks(title="Rail Delay Lab", theme=HORT_THEME, css=CUSTOM_CSS) as demo: gr.HTML(f"""
{icon_svg('train', 13)}
Rail Delay Lab
Live
OP
""") with gr.Tabs(elem_classes=["hort-main-tabs"]): with gr.TabItem("Delay Propagation and Conflict Resolution"): gr.Markdown("## Delay Propagation and Conflict Resolution") gr.Markdown("Upload CSV files to populate the tables below, or edit them manually, then run the resolver.") with gr.Row(equal_height=True): with gr.Column(scale=0, min_width=300, elem_classes=["app-sidebar"]): gr.HTML('

Infrastructure

') txt_spacing = gr.Number(value=5.0, label="Min Safety Spacing (km)", step=0.1) gr.HTML('

Delay Injection

') main_delay_mode = gr.Radio( choices=["Use Uploaded/Inserted Delay", "Randomized (Monte Carlo)"], value="Use Uploaded/Inserted Delay", label="Simulation Delay Mode", elem_classes=["hort-radio"] ) main_rand_prob = gr.Slider(0, 100, value=25, step=5, label="Delay Probability (%)") main_min_delay = gr.Number(value=5, label="Min Delay (min)") main_max_delay = gr.Number(value=15, label="Max Delay (min)") gr.HTML('

View & Optimization

') with gr.Column(elem_classes=["selector-strip"]): dd_view = gr.Dropdown( choices=["All Scenarios", "Nominal Only", "Conflicted Only", "Resolved Only"], value="All Scenarios", label="Select View", interactive=True, filterable=False, allow_custom_value=False, elem_id="view_listbox", elem_classes=["hort-listbox", "hort-view-select"] ) dd_selected_train = gr.Dropdown( choices=["None", "101", "102"], value="None", label="Select Train", interactive=True, filterable=False, allow_custom_value=False, elem_id="train_listbox", elem_classes=["hort-listbox", "hort-train-select"] ) txt_max_buffer = gr.Number(value=15.0, label="Max Buffer Cap (min)", step=1.0) gr.HTML('') btn = gr.Button("Simulate", variant="primary", size="lg") with gr.Column(scale=1, elem_classes=["app-main"]): metric_strip = gr.HTML(build_metric_html(None, None)) with gr.Accordion("Edit input data", open=False, elem_classes=["data-accordion"]): with gr.Row(): with gr.Column(): with gr.Group(elem_classes=["panel-card", "data-row-card"]): gr.HTML(section_header("building", "Infrastructure Data")) file_infra = make_exact_file_input() t_stations = gr.Dataframe(value=d_stations, label="Stations Data", interactive=True, max_height=300, wrap=True, show_row_numbers=True, buttons=[]) file_infra.upload(fn=load_csv, inputs=file_infra, outputs=t_stations) with gr.Column(): with gr.Group(elem_classes=["panel-card", "data-row-card"]): gr.HTML(section_header("clock", "Delay Data")) file_delays = make_exact_file_input() t_delays = gr.Dataframe(value=d_delays, label="Delay Data", interactive=True, max_height=300, wrap=True, show_row_numbers=True, buttons=[]) file_delays.upload(fn=load_csv, inputs=file_delays, outputs=t_delays) with gr.Group(elem_classes=["panel-card"]): gr.HTML(section_header("train", "Train Requests")) file_trains = make_exact_file_input() t_trains = gr.Dataframe(value=d_trains, label="Train Requests Data", interactive=True, max_height=320, wrap=True, show_row_numbers=True, buttons=[]) file_trains.upload(fn=load_train_csv, inputs=file_trains, outputs=[t_trains, dd_selected_train]) with gr.Group(elem_classes=["chart-card"]): gr.HTML(section_header("chart", "Operational Space-Time Diagram")) plot = gr.Plot(label="Marey Diagram") gr.HTML(section_header("list", "Delay & Conflict Outputs")) with gr.Tabs(): with gr.TabItem("Resolved Timetable"): out_adj = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[]) with gr.TabItem("Nominal Timetable"): out_nom = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[]) with gr.TabItem("Delay Breakdown"): out_stats = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[]) with gr.TabItem("Buffer Report"): out_buffer = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[]) with gr.TabItem("Conflict Logs"): out_logs = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[]) with gr.TabItem("Economic Capacity Analysis"): gr.Markdown("## Economic Capacity Analysis") gr.Markdown("Evaluate the capacity/reliability trade-off, TAC revenue, performance-regime penalties, and the best operating sweet spot.") with gr.Row(equal_height=True): with gr.Column(scale=0, min_width=300, elem_classes=["app-sidebar"]): gr.HTML('

Infrastructure

') econ_txt_spacing = gr.Number(value=5.0, label="Min Safety Spacing (km)", step=0.1) gr.HTML('

Delay Injection Strategy

') delay_mode = gr.Radio( choices=["Predefined (from Delays Tab)", "Randomized"], value="Predefined (from Delays Tab)", label="Delay Mode", elem_classes=["hort-radio"] ) rand_prob = gr.Slider(0, 100, value=25, step=5, label="Probability of Delay per Train (%)") min_delay = gr.Number(value=5, label="Min Random Delay (min)") max_delay = gr.Number(value=15, label="Max Random Delay (min)") gr.HTML('

Primary Delay Responsibility

') gr.Markdown("_Share of every primary delay attributed to the Infrastructure Manager (IM) vs the Railway Undertaking (RU), applied before the search runs._") im_resp_share = gr.Slider(0, 100, value=50, step=5, label="IM Responsibility Share (%)") gr.HTML('

Economic & Search Parameters

') max_trains_in = gr.Number(value=25, label="Max Trains to Evaluate") num_tac = gr.Number(value=115.84, label="TAC Fee (€ per train)") num_pr = gr.Number(value=7.09, label="PR Penalty (€ per min)") gr.HTML('') btn_econ = gr.Button("Calculate Sweet Spot", variant="primary", size="lg") with gr.Column(scale=1, elem_classes=["app-main"]): with gr.Accordion("Edit input data", open=False, elem_classes=["data-accordion"]): with gr.Row(): with gr.Column(): with gr.Group(elem_classes=["panel-card", "data-row-card"]): gr.HTML(section_header("building", "Infrastructure Data")) econ_file_infra = make_exact_file_input() econ_t_stations = gr.Dataframe(value=d_stations, label="Stations Data", interactive=True, max_height=300, wrap=True, show_row_numbers=True, buttons=[]) econ_file_infra.upload(fn=load_csv, inputs=econ_file_infra, outputs=econ_t_stations) with gr.Column(): with gr.Group(elem_classes=["panel-card", "data-row-card"]): gr.HTML(section_header("clock", "Delay Data")) econ_file_delays = make_exact_file_input() econ_t_delays = gr.Dataframe(value=d_delays, label="Delay Data", interactive=True, max_height=300, wrap=True, show_row_numbers=True, buttons=[]) econ_file_delays.upload(fn=load_csv, inputs=econ_file_delays, outputs=econ_t_delays) with gr.Group(elem_classes=["panel-card"]): gr.HTML(section_header("train", "Fleet Mix")) t_fleet = gr.Dataframe( headers=["Type Name", "Speed (km/h)", "Dwell (min)", "Priority"], value=[["High-Speed", 130, 2, 1], ["Regional", 100, 4, 2], ["Freight", 80, 5, 3]], interactive=True, label="Fleet Composition", max_height=320, wrap=True, show_row_numbers=True, buttons=[] ) with gr.Group(elem_classes=["chart-card"]): gr.HTML(section_header("bars", "Economic Capacity Outputs")) with gr.Row(): out_econ_plot = gr.Plot(label="Profit Curve") out_best_marey = gr.Plot(label="Optimal Timetable (Space-Time Diagram)") with gr.Row(): out_econ_df = gr.Dataframe(label="Raw Economic Data", max_height=460, wrap=True, show_row_numbers=True, buttons=[]) out_best_timetable = gr.Dataframe(label="Optimal Timetable (Data)", max_height=460, wrap=True, show_row_numbers=True, buttons=[]) gr.Markdown("### Best Scenario Primary Delays Applied (with Responsibility)") out_econ_delays = gr.Dataframe(label="Primary Delays & Responsibility (Best Scenario)", max_height=460, wrap=True, show_row_numbers=True, buttons=[]) gr.HTML("") t_trains.change(fn=get_train_choices, inputs=[t_trains], outputs=[dd_selected_train]) btn.click( fn=run_simulation, inputs=[ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer, main_delay_mode, main_rand_prob, main_min_delay, main_max_delay ], outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot, metric_strip] ) dd_view.change( fn=run_simulation, inputs=[ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer, main_delay_mode, main_rand_prob, main_min_delay, main_max_delay ], outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot, metric_strip] ) dd_selected_train.change( fn=run_simulation, inputs=[ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer, main_delay_mode, main_rand_prob, main_min_delay, main_max_delay ], outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot, metric_strip] ) txt_max_buffer.change( fn=run_simulation, inputs=[ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer, main_delay_mode, main_rand_prob, main_min_delay, main_max_delay ], outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot, metric_strip] ) main_delay_mode.change( fn=run_simulation, inputs=[ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer, main_delay_mode, main_rand_prob, main_min_delay, main_max_delay ], outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot, metric_strip] ) btn_econ.click( fn=run_economic_analysis, inputs=[ econ_t_stations, t_fleet, econ_t_delays, econ_txt_spacing, max_trains_in, num_tac, num_pr, delay_mode, rand_prob, min_delay, max_delay, im_resp_share ], outputs=[out_econ_df, out_econ_plot, out_best_timetable, out_best_marey, out_econ_delays] ) # Launch the app. Gradio 6 moved css/theme from Blocks() to launch(), while # Gradio 4/5 accepts them in Blocks(). This keeps the visual theme working on # both current and older Hugging Face Spaces runtimes. if __name__ == "__main__": import inspect launch_kwargs = {"debug": False} if "css" in inspect.signature(demo.launch).parameters: launch_kwargs.update({"css": CUSTOM_CSS, "theme": HORT_THEME}) if "head" in inspect.signature(demo.launch).parameters: launch_kwargs.update({"head": CUSTOM_HEAD}) demo.launch(**launch_kwargs)