Spaces:
Sleeping
Sleeping
Download app.py from Peymanasmari/Rail_Delay_Lab: direct link, hf CLI and curl.
- Browser
- Download file 91.2 kB
-
https://huggingface.co/spaces/Peymanasmari/Rail_Delay_Lab/resolve/main/app.py
- Command line
-
hf download hf://spaces/Peymanasmari/Rail_Delay_Lab/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Peymanasmari/Rail_Delay_Lab/resolve/main/app.py
91.2 kB
| ### 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": '<rect x="5" y="3" width="14" height="13" rx="3"/><path d="M5 11h14"/>' | |
| '<circle cx="8.5" cy="19" r="1.3"/><circle cx="15.5" cy="19" r="1.3"/>' | |
| '<path d="M8 16l-2 3M16 16l2 3"/>', | |
| "clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>', | |
| "target": '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/>' | |
| '<circle cx="12" cy="12" r=".8" fill="currentColor" stroke="none"/>', | |
| "chart": '<path d="M4 19V5"/><path d="M4 19h16"/><path d="M7 15l4-5 3 3 5-7"/>', | |
| "coin": '<circle cx="12" cy="12" r="8.5"/><path d="M14.6 8.3a4.6 4.6 0 1 0 0 7.4"/>' | |
| '<path d="M8 11h5M8 13.2h4.2"/>', | |
| "building": '<rect x="4" y="3" width="10" height="18"/><rect x="14" y="9" width="6" height="12"/>' | |
| '<path d="M7 7h1M10 7h1M7 11h1M10 11h1M7 15h1M10 15h1"/>', | |
| "dice": '<path d="M3 6h4l7 12h4"/><path d="M14 6h4l3 3"/><path d="M3 17h4l3-4"/>' | |
| '<path d="M18 3l3 3-3 3"/><path d="M18 14l3 3-3 3"/>', | |
| "scale": '<path d="M12 4v16M6 8h12"/><path d="M4 8l2.5 5a2.5 2.5 0 0 0 5 0L9 8"/>' | |
| '<path d="M15 8l2.5 5a2.5 2.5 0 0 0 5 0L20 8"/><path d="M9 20h6"/>', | |
| "list": '<path d="M8 6h12M8 12h12M8 18h12"/><path d="M4 6h.01M4 12h.01M4 18h.01"/>', | |
| "bars": '<path d="M4 20V10M10 20V4M16 20v-7M4 20h16"/>', | |
| "alert": '<path d="M10.3 3.86 1.8 18a1 1 0 0 0 .9 1.5h18.6a1 1 0 0 0 .9-1.5L13.7 3.86a1 1 0 0 0-1.72 0Z"/>' | |
| '<path d="M12 9v4"/><path d="M12 16.3h.01"/>', | |
| "check-circle": '<circle cx="12" cy="12" r="9"/><path d="M8.5 12.5l2.5 2.5 5-5"/>', | |
| } | |
| def icon_svg(name, size=18): | |
| return (f'<svg class="hort-icon" width="{size}" height="{size}" viewBox="0 0 24 24" ' | |
| f'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" ' | |
| f'stroke-linejoin="round">{_ICON_PATHS[name]}</svg>') | |
| def section_header(icon, text, level=3): | |
| return f'<h{level} class="hort-heading">{icon_svg(icon)}<span>{text}</span></h{level}>' | |
| 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''' | |
| <div class="kpi-card"> | |
| <div class="kpi-head"> | |
| <div class="{icon_cls}">{icon_svg(icon, 15)}</div> | |
| <span class="kpi-label">{label_text}</span> | |
| </div> | |
| <div> | |
| <div class="{value_cls}">{value_html}</div> | |
| <div class="kpi-caption">{caption}</div> | |
| </div> | |
| </div>''' | |
| return f''' | |
| <div class="kpi-grid"> | |
| {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}<span class="unit"> min</span>', "across all trains")} | |
| {card("check-circle", "kpi-icon", "On-time trains", "kpi-value", f'{on_time_pct}<span class="unit">%</span>', "within buffer cap")} | |
| </div> | |
| ''' | |
| 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}<br>%{x|%H:%M:%S}<br>%{y} km<extra></extra>", | |
| 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 <head> instead of a CSS @import so it | |
| # resolves before first paint on Hugging Face Spaces. | |
| CUSTOM_HEAD = """ | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet"> | |
| """ | |
| 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( | |
| '<label class="input-file-label">Upload a file</label>', | |
| 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( | |
| '<p class="input-file-help">You are only allowed to upload .CSV, .XLSX or .XLS files.</p>', | |
| 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""" | |
| <div class="brand-strip"> | |
| <div class="brand-mark">{icon_svg('train', 13)}</div> | |
| <span class="brand-name">Rail Delay Lab</span> | |
| <div class="brand-spacer"></div> | |
| <div class="status-pill"> | |
| <span class="status-dot"></span> | |
| <span class="label">Live</span> | |
| </div> | |
| <div class="avatar-chip">OP</div> | |
| </div> | |
| """) | |
| 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('<p class="side-label">Infrastructure</p>') | |
| txt_spacing = gr.Number(value=5.0, label="Min Safety Spacing (km)", step=0.1) | |
| gr.HTML('<p class="side-label">Delay Injection</p>') | |
| 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('<p class="side-label">View & Optimization</p>') | |
| 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('<div class="sidebar-spacer"></div>') | |
| 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('<p class="side-label">Infrastructure</p>') | |
| econ_txt_spacing = gr.Number(value=5.0, label="Min Safety Spacing (km)", step=0.1) | |
| gr.HTML('<p class="side-label">Delay Injection Strategy</p>') | |
| 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('<p class="side-label">Primary Delay Responsibility</p>') | |
| 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('<p class="side-label">Economic & Search Parameters</p>') | |
| 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('<div class="sidebar-spacer"></div>') | |
| 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("<div class='footer-note'>HoRT RailCap Lab · Educational railway capacity and reliability simulator</div>") | |
| 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) | |