'''
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('
')
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('