Files changed (1) hide show
  1. app.py +2075 -68
app.py CHANGED
@@ -1,77 +1,2084 @@
1
- """Interactive Lynote Notes demo (kept ready for PRO hosting).
 
2
 
3
- Paste text, get a cited note, then ask questions grounded in the same text.
4
- The demo uses the library's offline extractive provider, so it needs no model
5
- and no API key — only Gradio and the lynote-notes package.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
- import tempfile
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
- import gradio as gr
11
 
12
- from lynote_notes import Workspace
13
-
14
- MAX_CHARS = 5_000
15
- UTM = (
16
- "https://lynote.ai/ai-notes?utm_source=huggingface"
17
- "&utm_medium=space&utm_campaign=hf_launch&utm_content=oss_notes"
18
- )
19
-
20
-
21
- def _workspace(text: str) -> Workspace:
22
- ws = Workspace(tempfile.mkdtemp())
23
- ws.add_text(text, "Pasted text")
24
- return ws
25
-
26
-
27
- def make_note(text: str, title: str):
28
- text = (text or "").strip()
29
- if len(text) < 80:
30
- return "Add at least 80 characters of source text."
31
- if len(text) > MAX_CHARS:
32
- return f"Input is limited to {MAX_CHARS:,} characters in this demo."
33
- ws = _workspace(text)
34
- note = ws.make_note(title or "Your note")
35
- return ws.export_note(note, "md")
36
-
37
-
38
- def ask_question(text: str, question: str):
39
- text = (text or "").strip()
40
- question = (question or "").strip()
41
- if not text or not question:
42
- return "Paste some source text and ask a question."
43
- ws = _workspace(text)
44
- answer = ws.ask(question)
45
- lines = [answer.text, ""]
46
- for index, citation in enumerate(answer.citations, start=1):
47
- lines.append(f"[{index}] {citation.source_title} · {citation.locator} — {citation.snippet}")
48
- lines.append("")
49
- lines.append(f"Try the full product: {UTM}")
50
- return "\n".join(lines)
51
-
52
-
53
- with gr.Blocks(title="AI Notes — Source-Grounded AI Note Taker") as demo:
54
- gr.Markdown(
55
- "# 📝 AI Notes\n"
56
- "Source-grounded notes with citations — every bullet links back to the exact "
57
- "source chunk. Open source: [github.com/lynote-ai/lynote-notes]"
58
- "(https://github.com/lynote-ai/lynote-notes)"
59
  )
60
- source = gr.Textbox(
61
- label="Source text (80–5,000 characters)",
62
- placeholder="Paste a lecture transcript, article, or meeting notes…",
63
- lines=10,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  )
65
- with gr.Tab("Generate note"):
66
- title = gr.Textbox(label="Note title", value="Your note")
67
- note_button = gr.Button("Generate cited note", variant="primary")
68
- note_output = gr.Markdown()
69
- note_button.click(make_note, inputs=[source, title], outputs=note_output)
70
- with gr.Tab("Ask a question"):
71
- question = gr.Textbox(label="Question", placeholder="What did we decide about pricing?")
72
- ask_button = gr.Button("Ask", variant="primary")
73
- answer_output = gr.Markdown()
74
- ask_button.click(ask_question, inputs=[source, question], outputs=answer_output)
75
 
 
 
 
76
  if __name__ == "__main__":
77
- demo.launch()
 
 
 
 
 
1
+ ### SWEDEN TAC FOR 100 TRAIN-KM AND PR ###
2
+ ### WORKS PERFECT ###
3
 
4
+
5
+ import gradio as gr
6
+ import pandas as pd
7
+ import numpy as np
8
+ import plotly.graph_objects as go
9
+ from datetime import datetime, timedelta
10
+
11
+ # ==========================================
12
+ # 1. CLASS DEFINITIONS & INFRASTRUCTURE
13
+ # ==========================================
14
+
15
+ class Train:
16
+ def __init__(self, id, direction, priority, stops, start_time_offset, speed_kmh, dwell_min):
17
+ self.id = str(id)
18
+ self.direction = direction
19
+ self.priority = int(priority)
20
+ self.speed_kmh = float(speed_kmh)
21
+ self.dwell_min = float(dwell_min)
22
+ self.stops = stops
23
+ self.start_time_offset = float(start_time_offset)
24
+ self.schedule = []
25
+
26
+ def copy(self):
27
+ new_t = Train(self.id, self.direction, self.priority, self.stops,
28
+ self.start_time_offset, self.speed_kmh, self.dwell_min)
29
+ new_t.schedule = []
30
+ for s in self.schedule:
31
+ node_copy = s.copy()
32
+ if 'min_arr_constraint' not in node_copy: node_copy['min_arr_constraint'] = None
33
+ new_t.schedule.append(node_copy)
34
+ return new_t
35
+
36
+ class Infrastructure:
37
+ def __init__(self, station_df, spacing_km=5.0):
38
+ # Access by index to be robust against header names
39
+ self.stations = station_df.iloc[:, 0].astype(str).tolist()
40
+ self.distances = pd.to_numeric(station_df.iloc[:, 1], errors='coerce').fillna(0.0).tolist()
41
+ self.station_map = {name: i for i, name in enumerate(self.stations)}
42
+ self.dist_map = {name: d for name, d in zip(self.stations, self.distances)}
43
+ self.spacing_km = float(spacing_km)
44
+
45
+ def get_travel_time(self, s1, s2, speed_kmh):
46
+ d1 = self.dist_map.get(s1, 0.0)
47
+ d2 = self.dist_map.get(s2, 0.0)
48
+ dist_km = abs(d1 - d2)
49
+ if speed_kmh <= 0: return timedelta(minutes=1)
50
+ hours = dist_km / speed_kmh
51
+ return timedelta(hours=hours)
52
+
53
+ def get_dynamic_headway(self, speed_kmh):
54
+ if speed_kmh <= 0: return timedelta(minutes=5)
55
+ minutes = (self.spacing_km / speed_kmh) * 60.0
56
+ return timedelta(minutes=max(1.0, minutes))
57
+
58
+ # ==========================================
59
+ # 2. PHYSICS ENGINE
60
+ # ==========================================
61
+
62
+ def update_train_times(train, infra, base_time):
63
+ if not train.schedule:
64
+ current_time = base_time + timedelta(minutes=train.start_time_offset)
65
+ train.schedule = []
66
+ for s in train.stops:
67
+ train.schedule.append({
68
+ 'station': s,
69
+ 'arr': current_time,
70
+ 'dep': current_time,
71
+ 'min_arr_constraint': None,
72
+ 'manual_delay': timedelta(0)
73
+ })
74
+
75
+ for i in range(len(train.schedule)):
76
+ current_node = train.schedule[i]
77
+ manual_delay = current_node.get('manual_delay', timedelta(0))
78
+
79
+ # A. ARRIVAL
80
+ if i == 0:
81
+ base_origin_time = base_time + timedelta(minutes=train.start_time_offset)
82
+ current_node['arr'] = base_origin_time + manual_delay
83
+ else:
84
+ prev_node = train.schedule[i-1]
85
+ phys_run_time = infra.get_travel_time(prev_node['station'], current_node['station'], train.speed_kmh)
86
+ min_phys_arr = prev_node['dep'] + phys_run_time
87
+ safety_limit = current_node.get('min_arr_constraint')
88
+
89
+ delayed_phys_arr = min_phys_arr + manual_delay
90
+
91
+ if safety_limit:
92
+ current_node['arr'] = max(delayed_phys_arr, safety_limit)
93
+ else:
94
+ current_node['arr'] = delayed_phys_arr
95
+
96
+ # B. DEPARTURE
97
+ dwell = timedelta(minutes=train.dwell_min) if i < len(train.schedule) - 1 else timedelta(0)
98
+ min_dep = current_node['arr'] + dwell
99
+ current_node['dep'] = max(current_node['dep'], min_dep)
100
+
101
+ # ==========================================
102
+ # 3. CONFLICT DETECTOR
103
+ # ==========================================
104
+
105
+ def detect_conflicts(trains, infra):
106
+ conflicts = []
107
+ for i in range(len(trains)):
108
+ for j in range(i + 1, len(trains)):
109
+ t1 = trains[i]
110
+ t2 = trains[j]
111
+ for k in range(len(t1.schedule) - 1):
112
+ u, v = t1.schedule[k]['station'], t1.schedule[k+1]['station']
113
+ t2_stations = [s['station'] for s in t2.schedule]
114
+ if u in t2_stations and v in t2_stations:
115
+ idx_u_t2 = t2_stations.index(u)
116
+ idx_v_t2 = t2_stations.index(v)
117
+ if abs(idx_u_t2 - idx_v_t2) == 1:
118
+ t1_ent, t1_ext = t1.schedule[k]['dep'], t1.schedule[k+1]['arr']
119
+ if idx_u_t2 < idx_v_t2:
120
+ t2_ent, t2_ext = t2.schedule[idx_u_t2]['dep'], t2.schedule[idx_v_t2]['arr']
121
+ direction = "Same"
122
+ else:
123
+ t2_ent, t2_ext = t2.schedule[idx_v_t2]['dep'], t2.schedule[idx_u_t2]['arr']
124
+ direction = "Opposite"
125
+
126
+ is_conflict = False
127
+ conflict_reason = ""
128
+
129
+ if direction == "Opposite":
130
+ if max(t1_ent, t2_ent) < min(t1_ext, t2_ext):
131
+ is_conflict = True; conflict_reason = "Head-on"
132
+ else:
133
+ if t1_ent <= t2_ent:
134
+ l_ent, f_ent = t1_ent, t2_ent
135
+ leader_ext, follower_ext = t1_ext, t2_ext
136
+ hw = infra.get_dynamic_headway(t2.speed_kmh)
137
+ elif t1_ent > t2_ent:
138
+ l_ent, f_ent = t2_ent, t1_ent
139
+ leader_ext, follower_ext = t2_ext, t1_ext
140
+ hw = infra.get_dynamic_headway(t1.speed_kmh)
141
+
142
+ tol = timedelta(seconds=1)
143
+ if f_ent < l_ent + hw - tol:
144
+ is_conflict = True; conflict_reason = f"Entry HW ({hw.seconds//60}m)"
145
+ elif follower_ext < leader_ext + hw - tol:
146
+ is_conflict = True; conflict_reason = f"Exit HW ({hw.seconds//60}m)"
147
+
148
+ if is_conflict:
149
+ conflicts.append({'time': max(t1_ent, t2_ent), 'trains': (t1, t2), 'segment': (u, v), 'dir': direction, 'desc': f"{conflict_reason} on {u}->{v}"})
150
+ conflicts.sort(key=lambda x: x['time'])
151
+ return conflicts
152
+
153
+ # ==========================================
154
+ # 4. RESOLVER (With Max Buffer Logic)
155
+ # ==========================================
156
+
157
+ def resolve_all_conflicts(trains, infra, base_time, selected_train_id=None, max_buffer_min=30.0):
158
+ iteration = 0
159
+ max_iter = 200
160
+ logs = []
161
+ max_buffer_td = timedelta(minutes=float(max_buffer_min))
162
+
163
+ while iteration < max_iter:
164
+ conflicts = detect_conflicts(trains, infra)
165
+ if not conflicts:
166
+ logs.append("No conflicts found.")
167
+ break
168
+
169
+ c = conflicts[0]
170
+ t1, t2 = c['trains']
171
+ u, v = c['segment']
172
+ direction = c['dir']
173
+
174
+ # If the user selected a train in the View & Optimization panel,
175
+ # treat it as the optimization target for this conflict. This makes the
176
+ # Select Train list-box functional: the selected service is protected
177
+ # before normal priority/tie-break logic is applied.
178
+ selected_id = str(selected_train_id) if selected_train_id not in (None, "", "None") else None
179
+ if selected_id and str(t1.id) == selected_id and str(t2.id) != selected_id:
180
+ winner, loser = t1, t2
181
+ logs.append(f"Iter {iteration}: Optimization target {winner.id} given dispatch priority.")
182
+ elif selected_id and str(t2.id) == selected_id and str(t1.id) != selected_id:
183
+ winner, loser = t2, t1
184
+ logs.append(f"Iter {iteration}: Optimization target {winner.id} given dispatch priority.")
185
+ elif t1.priority > t2.priority:
186
+ winner, loser = t1, t2
187
+ elif t2.priority > t1.priority:
188
+ winner, loser = t2, t1
189
+ else:
190
+ winner, loser = (t1, t2) if t1.id < t2.id else (t2, t1)
191
+
192
+ def get_idx(train, station): return [s['station'] for s in train.schedule].index(station)
193
+
194
+ if direction == "Same":
195
+ u_w, v_w = get_idx(winner, u), get_idx(winner, v)
196
+ u_l, v_l = get_idx(loser, u), get_idx(loser, v)
197
+ if u_w > v_w: u_w, v_w = v_w, u_w
198
+ if u_l > v_l: u_l, v_l = v_l, u_l
199
+
200
+ if winner.schedule[u_w]['dep'] <= loser.schedule[u_l]['dep']:
201
+ leader, follower = winner, loser
202
+ l_u, l_v = u_w, v_w
203
+ f_u, f_v = u_l, v_l
204
+ follower_is_winner = False
205
+ else:
206
+ leader, follower = loser, winner
207
+ l_u, l_v = u_l, v_l
208
+ f_u, f_v = u_w, v_w
209
+ follower_is_winner = True
210
+
211
+ hw = infra.get_dynamic_headway(follower.speed_kmh)
212
+
213
+ applied_buffer = False
214
+ if follower_is_winner and selected_train_id and str(follower.id) == str(selected_train_id):
215
+ gap_needed = (follower.schedule[f_u]['dep'] + hw) - leader.schedule[l_u]['dep']
216
+ current_buffer = leader.schedule[l_u].get('manual_delay', timedelta(0))
217
+ total_new_buffer = current_buffer + gap_needed
218
+
219
+ if gap_needed > timedelta(0):
220
+ if total_new_buffer <= max_buffer_td:
221
+ 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")
222
+ leader.schedule[l_u]['manual_delay'] = total_new_buffer
223
+ update_train_times(leader, infra, base_time)
224
+ applied_buffer = True
225
+ else:
226
+ 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).")
227
+
228
+ if not applied_buffer:
229
+ req_dep = leader.schedule[l_u]['dep'] + hw
230
+ if follower.schedule[f_u]['dep'] < req_dep:
231
+ logs.append(f"Iter {iteration}: Holding {follower.id} at {u}. Req HW: {hw}")
232
+ follower.schedule[f_u]['dep'] = req_dep
233
+
234
+ req_arr = leader.schedule[l_v]['arr'] + hw
235
+ current_const = follower.schedule[f_v].get('min_arr_constraint')
236
+ if current_const is None or req_arr > current_const:
237
+ logs.append(f"Iter {iteration}: Slowing {follower.id} on {u}->{v}. Req HW: {hw}")
238
+ follower.schedule[f_v]['min_arr_constraint'] = req_arr
239
+
240
+ update_train_times(follower, infra, base_time)
241
+
242
+ if follower_is_winner:
243
+ if l_v < len(leader.schedule):
244
+ follower_next_dep = follower.schedule[f_v]['dep']
245
+ req_leader_dep = follower_next_dep + hw
246
+ if leader.schedule[l_v]['dep'] < req_leader_dep:
247
+ logs.append(f"Iter {iteration}: Overtake at {v}. Holding {leader.id}.")
248
+ leader.schedule[l_v]['dep'] = req_leader_dep
249
+ update_train_times(leader, infra, base_time)
250
+
251
+ else:
252
+ u_w, v_w = get_idx(winner, u), get_idx(winner, v)
253
+ t_clear = max(winner.schedule[u_w]['arr'], winner.schedule[v_w]['arr'])
254
+ u_l, v_l = get_idx(loser, u), get_idx(loser, v)
255
+ l_ent = min(u_l, v_l)
256
+ if loser.schedule[l_ent]['dep'] < t_clear:
257
+ logs.append(f"Iter {iteration}: Opp Dir. Holding {loser.id} at {u}.")
258
+ loser.schedule[l_ent]['dep'] = t_clear
259
+ update_train_times(loser, infra, base_time)
260
+ iteration += 1
261
+ return logs
262
+
263
+ # ==========================================
264
+ # 5. DATA EXPORT & VISUALS
265
+ # ==========================================
266
+
267
+ def generate_timetable_matrix(trains, infra):
268
+ data = []
269
+ columns = ["Train ID", "Direction"]
270
+ for s in infra.stations:
271
+ columns.append(f"{s} Arr")
272
+ columns.append(f"{s} Dep")
273
+
274
+ for t in trains:
275
+ row = {"Train ID": t.id, "Direction": t.direction}
276
+ for s_data in t.schedule:
277
+ s_name = s_data['station']
278
+ row[f"{s_name} Arr"] = s_data['arr'].strftime("%H:%M:%S")
279
+ row[f"{s_name} Dep"] = s_data['dep'].strftime("%H:%M:%S")
280
+
281
+ for s in infra.stations:
282
+ if f"{s} Arr" not in row: row[f"{s} Arr"] = "-"
283
+ if f"{s} Dep" not in row: row[f"{s} Dep"] = "-"
284
+
285
+ data.append(row)
286
+
287
+ df = pd.DataFrame(data)
288
+ final_cols = ["Train ID", "Direction"]
289
+ for s in infra.stations:
290
+ final_cols.append(f"{s} Arr")
291
+ final_cols.append(f"{s} Dep")
292
+
293
+ return df[final_cols]
294
+
295
+ def calculate_delay_stats(nom_trains, adj_trains, primary_delay_df):
296
+ stats = []
297
+ primary_map = {}
298
+ if primary_delay_df is not None and not primary_delay_df.empty:
299
+ for _, row in primary_delay_df.iterrows():
300
+ if not pd.isna(row.iloc[0]):
301
+ tid = str(row.iloc[0])
302
+ try: val = float(row.iloc[2])
303
+ except: val = 0.0
304
+ primary_map[tid] = primary_map.get(tid, 0) + val
305
+
306
+ for t_nom, t_adj in zip(nom_trains, adj_trains):
307
+ nom_end = t_nom.schedule[-1]['arr']
308
+ adj_end = t_adj.schedule[-1]['arr']
309
+ total_delay = (adj_end - nom_end).total_seconds() / 60.0
310
+ p_delay = primary_map.get(t_nom.id, 0.0)
311
+ s_delay = max(0.0, total_delay - p_delay)
312
+
313
+ if p_delay > 0 and s_delay > 0.1: d_type = "Both"
314
+ elif p_delay > 0: d_type = "Primary Only"
315
+ elif s_delay > 0.1: d_type = "Secondary"
316
+ else: d_type = "On Time"
317
+
318
+ stats.append({
319
+ "Train ID": t_nom.id,
320
+ "Total Delay": round(total_delay, 1),
321
+ "Primary": round(p_delay, 1),
322
+ "Secondary": round(s_delay, 1),
323
+ "Type": d_type
324
+ })
325
+ return pd.DataFrame(stats)
326
+
327
+ def generate_buffer_report(trains, primary_delay_df):
328
+ report_data = []
329
+ requested_delays = {}
330
+ if primary_delay_df is not None and not primary_delay_df.empty:
331
+ for _, row in primary_delay_df.iterrows():
332
+ if not pd.isna(row.iloc[0]):
333
+ tid = str(row.iloc[0])
334
+ st = str(row.iloc[1])
335
+ try: val = float(row.iloc[2])
336
+ except: val = 0.0
337
+ requested_delays[(tid, st)] = val
338
+
339
+ for t in trains:
340
+ for node in t.schedule:
341
+ st = node['station']
342
+ total_manual = node.get('manual_delay', timedelta(0)).total_seconds() / 60.0
343
+ requested = requested_delays.get((t.id, st), 0.0)
344
+ buffer_val = total_manual - requested
345
+
346
+ if buffer_val > 0.1:
347
+ report_data.append({
348
+ "Train ID": t.id,
349
+ "Station": st,
350
+ "Requested Delay (min)": round(requested, 1),
351
+ "Total Added Delay (min)": round(total_manual, 1),
352
+ "System Buffer (min)": round(buffer_val, 1)
353
+ })
354
+
355
+ if not report_data:
356
+ return pd.DataFrame(columns=["Train ID", "Station", "Requested Delay (min)", "Total Added Delay (min)", "System Buffer (min)"])
357
+
358
+ return pd.DataFrame(report_data)
359
+
360
+ def create_filtered_marey(t_nom, t_del, t_adj, infra, view_mode, selected_train_id):
361
+ fig = go.Figure()
362
+ colors = ['#7C5CFF', '#4C8DFF', '#A978FF', '#45BFA7', '#6EC6FF', '#FF8AA1']
363
+ def get_dist(s): return infra.dist_map.get(s, 0)
364
+
365
+ def add_lines(trains, color, style, name_suffix, opacity, visible, width=2):
366
+ for i, t in enumerate(trains):
367
+ is_selected = (str(t.id) == str(selected_train_id))
368
+ x, y, txt = [], [], []
369
+ if is_selected and style == 'solid':
370
+ c = '#F2B84B'; w = 5; op = 1.0
371
+ else:
372
+ c = colors[i % len(colors)] if color == 'auto' else color
373
+ w = width; op = opacity
374
+
375
+ for s in t.schedule:
376
+ d = get_dist(s['station'])
377
+ x.extend([s['arr'], s['dep']])
378
+ y.extend([d, d])
379
+ txt.extend([f"Arrival at {s['station']}", f"Departure from {s['station']}"])
380
+
381
+ fig.add_trace(go.Scatter(
382
+ x=x,
383
+ y=y,
384
+ mode='lines+markers' if style == 'solid' else 'lines',
385
+ name=f"T{t.id} {name_suffix}" + (" · selected" if is_selected else ""),
386
+ line=dict(color=c, width=w, dash='solid' if style == 'solid' else style, shape='spline'),
387
+ marker=dict(size=7, color=c, line=dict(width=1, color='rgba(255,255,255,0.92)')) if style == 'solid' else None,
388
+ hovertext=txt,
389
+ hovertemplate="%{hovertext}<br>%{x|%H:%M:%S}<br>%{y} km<extra></extra>",
390
+ opacity=op,
391
+ visible=visible
392
+ ))
393
+
394
+ if view_mode in ["All Scenarios", "Nominal Only"]: add_lines(t_nom, '#9AA2B1', 'dash', "(Nominal)", 0.58, True)
395
+ if view_mode in ["All Scenarios", "Conflicted Only"]: add_lines(t_del, '#EE7486', 'dot', "(Conflicted)", 0.82, True if view_mode != "All Scenarios" else 'legendonly')
396
+ if view_mode in ["All Scenarios", "Resolved Only"]: add_lines(t_adj, 'auto', 'solid', "(Resolved)", 1.0, True, width=3)
397
+
398
+ fig.update_layout(
399
+ title=dict(
400
+ text=f"Marey diagram · {view_mode}",
401
+ x=0.01,
402
+ y=0.98,
403
+ xanchor='left',
404
+ yanchor='top',
405
+ font=dict(size=17, color='#262936')
406
+ ),
407
+ xaxis_title="Time",
408
+ yaxis_title="Station / distance",
409
+ yaxis=dict(
410
+ tickmode='array', tickvals=infra.distances, ticktext=infra.stations,
411
+ gridcolor='rgba(147,153,170,0.18)', zeroline=False,
412
+ tickfont=dict(color='#687083'), title_font=dict(color='#9299AA')
413
+ ),
414
+ xaxis=dict(
415
+ tickformat="%H:%M", gridcolor='rgba(147,153,170,0.14)', zeroline=False,
416
+ tickfont=dict(color='#687083'), title_font=dict(color='#9299AA')
417
+ ),
418
+ plot_bgcolor='#FFFFFF',
419
+ paper_bgcolor='rgba(0,0,0,0)',
420
+ font=dict(color='#5F6677', family='Inter, Segoe UI, sans-serif'),
421
+ legend=dict(
422
+ orientation='h',
423
+ yanchor='top', y=-0.18,
424
+ xanchor='left', x=0,
425
+ bgcolor='rgba(0,0,0,0)',
426
+ borderwidth=0,
427
+ font=dict(color='#5F6677')
428
+ ),
429
+ hoverlabel=dict(bgcolor='#FFFFFF', font_color='#262936', bordercolor='#DED8F3'),
430
+ margin=dict(l=58, r=24, t=68, b=105),
431
+ height=620,
432
+ transition=dict(duration=220, easing='cubic-in-out')
433
+ )
434
+ return fig
435
+
436
+ 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):
437
+ import random
438
+ from datetime import datetime, timedelta
439
+ import pandas as pd
440
+ import plotly.graph_objects as go
441
+
442
+ results = []
443
+
444
+ try: max_t = int(max_trains_input)
445
+ except: max_t = 20
446
+ if max_t < 2: max_t = 2
447
+
448
+ densities = list(range(2, max_t + 1))
449
+ base_time = datetime(2024, 1, 1, 8, 0, 0)
450
+
451
+ try: im_share = float(im_resp_share)
452
+ except: im_share = 50.0
453
+
454
+ best_profit = -float('inf')
455
+ best_trains = []
456
+ best_delay_df = pd.DataFrame() # <-- Initialize the dataframe to hold the best delays
457
+
458
+ for tph in densities:
459
+ scheduled_headway = 60.0 / tph
460
+ trains = []
461
+ current_run_delays = [] # <-- Track delays just for this specific TPH run
462
+
463
+ for i in range(tph):
464
+ profile = fleet_profiles[i % len(fleet_profiles)]
465
+ new_train = profile.copy()
466
+
467
+ generated_id = str(i + 1)
468
+ new_train.original_id = generated_id
469
+ new_train.id = f"T{generated_id}_{profile.id}"
470
+ new_train.start_time_offset = i * scheduled_headway
471
+ new_train.had_primary_delay = False # Track this cleanly!
472
+
473
+ update_train_times(new_train, infra, base_time)
474
+
475
+ # --- INJECT DELAYS BASED ON USER MODE ---
476
+ if d_mode == "Predefined (from Delays Tab)" and delay_data is not None and not delay_data.empty:
477
+ for _, d_row in delay_data.iterrows():
478
+ d_keys = d_row.keys()
479
+ delay_t_id = ""
480
+ if "Train ID" in d_keys: delay_t_id = str(d_row["Train ID"]).strip()
481
+ elif "Trian ID" in d_keys: delay_t_id = str(d_row["Trian ID"]).strip()
482
+
483
+ st_name = str(d_row.get("Station", "")).strip()
484
+ d_val = 0.0
485
+ if "Delay" in d_keys: d_val = float(d_row["Delay"])
486
+ elif "Delay (min)" in d_keys: d_val = float(d_row["Delay (min)"])
487
+
488
+ if delay_t_id == generated_id and d_val > 0:
489
+ new_train.had_primary_delay = True
490
+ # Responsibility is decided by the same rule for every
491
+ # primary delay, regardless of whether the delay is
492
+ # predefined (from the Delays tab) or randomly generated -
493
+ # it's set once, before the run, via the "IM Responsibility
494
+ # Share" slider, so results across capacity levels stay
495
+ # comparable and consistent between delay modes.
496
+ party = "IM" if random.uniform(0, 100) <= im_share else "RU"
497
+ amount = round(d_val * pr_rate, 2)
498
+ current_run_delays.append({
499
+ "Train ID": new_train.id, "Station": st_name, "Delay (min)": d_val,
500
+ "Responsible Party": party, "Amount (€)": amount,
501
+ "Effect on IM": f"Penalty to IM: -€{amount:,.2f}" if party == "IM" else f"Income for IM: +€{amount:,.2f}"
502
+ })
503
+ shift_active = False
504
+ for stop_event in new_train.schedule:
505
+ if st_name and st_name.lower() == str(stop_event['station']).lower():
506
+ shift_active = True
507
+ if shift_active:
508
+ stop_event['arr'] += timedelta(minutes=d_val)
509
+ stop_event['dep'] += timedelta(minutes=d_val)
510
+
511
+ elif d_mode == "Randomized":
512
+ # Roll the dice to see if this train gets delayed
513
+ if random.uniform(0, 100) <= float(r_prob):
514
+ new_train.had_primary_delay = True
515
+ # Pick a random station (excluding the final destination)
516
+ rand_idx = random.randint(0, len(new_train.schedule) - 2)
517
+ rand_station = new_train.schedule[rand_idx]['station']
518
+ d_val = round(random.uniform(float(r_min), float(r_max)), 1)
519
+
520
+ # Same responsibility rule as the predefined branch above -
521
+ # the IM Responsibility Share slider, not a fresh 50/50 pick.
522
+ party = "IM" if random.uniform(0, 100) <= im_share else "RU"
523
+ amount = round(d_val * pr_rate, 2)
524
+
525
+ # Log the generated delay
526
+ current_run_delays.append({
527
+ "Train ID": new_train.id, "Station": rand_station, "Delay (min)": d_val,
528
+ "Responsible Party": party, "Amount (€)": amount,
529
+ "Effect on IM": f"Penalty to IM: -€{amount:,.2f}" if party == "IM" else f"Income for IM: +€{amount:,.2f}"
530
+ })
531
+
532
+ shift_active = False
533
+ for stop_event in new_train.schedule:
534
+ if stop_event['station'] == rand_station:
535
+ shift_active = True
536
+ if shift_active:
537
+ stop_event['arr'] += timedelta(minutes=d_val)
538
+ stop_event['dep'] += timedelta(minutes=d_val)
539
+
540
+ trains.append(new_train)
541
+
542
+ resolve_all_conflicts(trains, infra, base_time)
543
+
544
+ # --- CALCULATE SECONDARY DELAY ---
545
+ total_secondary_delay = 0
546
+ for i, train in enumerate(trains):
547
+ if getattr(train, 'had_primary_delay', False):
548
+ continue
549
+
550
+ dummy = fleet_profiles[i % len(fleet_profiles)].copy()
551
+ dummy.start_time_offset = train.start_time_offset
552
+ update_train_times(dummy, infra, base_time)
553
+
554
+ last_station_idx = len(dummy.stops) - 1
555
+ expected_arr = dummy.schedule[last_station_idx]['arr']
556
+ actual_arr = train.schedule[last_station_idx]['arr']
557
+
558
+ delay_min = (actual_arr - expected_arr).total_seconds() / 60.0
559
+ if delay_min > 0: total_secondary_delay += delay_min
560
+
561
+ # --- PRIMARY DELAY RESPONSIBILITY COST (assigned before the run) ---
562
+ # Secondary delays are always paid by IM (PR Penalty above, unchanged).
563
+ # Primary delays are charged to whichever party (IM/RU) was assigned:
564
+ # IM-caused = penalty for IM; RU-caused = income for IM (RU pays IM).
565
+ primary_im_penalty = sum(d["Amount (€)"] for d in current_run_delays if d["Responsible Party"] == "IM")
566
+ primary_ru_income = sum(d["Amount (€)"] for d in current_run_delays if d["Responsible Party"] == "RU")
567
+
568
+ revenue = tph * tac_fee
569
+ penalty_cost = total_secondary_delay * pr_rate
570
+ profit = revenue - penalty_cost - primary_im_penalty + primary_ru_income
571
+
572
+ # --- CHECK IF THIS IS THE BEST SCENARIO ---
573
+ if profit > best_profit:
574
+ best_profit = profit
575
+ best_trains = [t.copy() for t in trains]
576
+ # Save the delays that were applied to this specific winning scenario!
577
+ if current_run_delays:
578
+ best_delay_df = pd.DataFrame(current_run_delays)
579
+ else:
580
+ best_delay_df = pd.DataFrame(columns=["Train ID", "Station", "Delay (min)", "Responsible Party", "Amount (€)", "Effect on IM"])
581
+
582
+ results.append({
583
+ 'Capacity (Trains/Hour)': tph,
584
+ 'Total Secondary Delay (min)': total_secondary_delay,
585
+ 'Revenue (€)': revenue,
586
+ 'PR Penalty (€)': penalty_cost,
587
+ 'IM Primary Penalty (€)': primary_im_penalty,
588
+ 'IM Primary Income (€)': primary_ru_income,
589
+ 'Total Profit (€)': profit
590
+ })
591
+
592
+ df = pd.DataFrame(results)
593
+
594
+ timetable_data = []
595
+ if best_trains:
596
+ for t in best_trains:
597
+ for stop in t.schedule:
598
+ timetable_data.append({
599
+ "Train ID": t.id,
600
+ "Station": stop['station'],
601
+ "Arrival": stop['arr'].strftime("%H:%M:%S"),
602
+ "Departure": stop['dep'].strftime("%H:%M:%S")
603
+ })
604
+ best_timetable_df = pd.DataFrame(timetable_data)
605
+
606
+ fig = go.Figure()
607
+ fig.add_trace(go.Scatter(x=df['Capacity (Trains/Hour)'], y=df['Revenue (€)'], mode='lines+markers', name='TAC Revenue', line=dict(color='#4C8DFF', width=3, shape='spline'), marker=dict(size=7)))
608
+ 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='#EE7486', width=2.5, shape='spline'), marker=dict(size=7)))
609
+ 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='#45BFA7', width=2.5, shape='spline'), marker=dict(size=7)))
610
+ 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='#7C5CFF', shape='spline'), marker=dict(size=8)))
611
+
612
+ best_tph = df.loc[df['Total Profit (€)'].idxmax()]['Capacity (Trains/Hour)']
613
+ fig.add_vline(x=best_tph, line_dash="dot", line_color="#F2B84B", annotation_text="Sweet Spot", annotation_position="top left")
614
+
615
+ fig.update_layout(
616
+ title=dict(text="Capacity vs. reliability", x=0.01, xanchor='left', font=dict(size=18, color='#262936')),
617
+ xaxis_title="Scheduled trains per hour",
618
+ yaxis_title="Euros (€)",
619
+ plot_bgcolor='#FFFFFF',
620
+ paper_bgcolor='rgba(0,0,0,0)',
621
+ font=dict(color='#5F6677', family='Inter, Segoe UI, sans-serif'),
622
+ xaxis=dict(gridcolor='rgba(147,153,170,0.14)', zeroline=False, tickfont=dict(color='#687083'), title_font=dict(color='#9299AA')),
623
+ yaxis=dict(gridcolor='rgba(147,153,170,0.14)', zeroline=False, tickfont=dict(color='#687083'), title_font=dict(color='#9299AA')),
624
+ legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, bgcolor='rgba(0,0,0,0)', borderwidth=0, font=dict(color='#5F6677')),
625
+ hoverlabel=dict(bgcolor='#FFFFFF', font_color='#262936', bordercolor='#DED8F3'),
626
+ margin=dict(l=62, r=24, t=82, b=56),
627
+ transition=dict(duration=220, easing='cubic-in-out')
628
+ )
629
+
630
+ # --- GENERATE THE TRADITIONAL MAREY DIAGRAM (BOTTOM-LEFT TO TOP-RIGHT) ---
631
+ best_marey_fig = go.Figure()
632
+ dist_map = {st: dist for st, dist in zip(infra.stations, infra.distances)}
633
+
634
+ if best_trains:
635
+ for t in best_trains:
636
+ times = []
637
+ locs = []
638
+ for stop in t.schedule:
639
+ times.append(stop['arr'])
640
+ locs.append(dist_map[stop['station']])
641
+ times.append(stop['dep'])
642
+ locs.append(dist_map[stop['station']])
643
+
644
+ color = '#EE7486' if getattr(t, 'had_primary_delay', False) else '#7C5CFF'
645
+
646
+ best_marey_fig.add_trace(go.Scatter(
647
+ x=times,
648
+ y=locs,
649
+ mode='lines+markers',
650
+ name=t.id,
651
+ line=dict(color=color, width=3, shape='spline'),
652
+ marker=dict(size=5, color=color, line=dict(width=1, color='rgba(255,255,255,0.92)'))
653
+ ))
654
+
655
+ station_names = list(dist_map.keys())
656
+ station_distances = list(dist_map.values())
657
+
658
+ best_marey_fig.update_layout(
659
+ title=dict(
660
+ text=f"Optimal space-time diagram · {best_tph} trains/hour",
661
+ x=0.01, y=0.98, xanchor='left', yanchor='top',
662
+ font=dict(size=17, color='#262936')
663
+ ),
664
+ xaxis_title="Time",
665
+ yaxis_title="Station / distance",
666
+ xaxis=dict(
667
+ tickformat="%H:%M", tickangle=-25,
668
+ gridcolor='rgba(147,153,170,0.14)', zeroline=False,
669
+ tickfont=dict(color='#687083'), title_font=dict(color='#9299AA')
670
+ ),
671
+ yaxis=dict(
672
+ tickmode='array', tickvals=station_distances,
673
+ ticktext=[f"{st} ({d}km)" for st, d in zip(station_names, station_distances)],
674
+ gridcolor='rgba(147,153,170,0.18)', zeroline=False,
675
+ tickfont=dict(color='#687083'), title_font=dict(color='#9299AA')
676
+ ),
677
+ plot_bgcolor='#FFFFFF',
678
+ paper_bgcolor='rgba(0,0,0,0)',
679
+ font=dict(color='#5F6677', family='Inter, Segoe UI, sans-serif'),
680
+ hoverlabel=dict(bgcolor='#FFFFFF', font_color='#262936', bordercolor='#DED8F3'),
681
+ showlegend=False,
682
+ margin=dict(l=72, r=24, t=68, b=64),
683
+ transition=dict(duration=220, easing='cubic-in-out')
684
+ )
685
+
686
+ # Returning 5 elements, matching what run_economic_analysis expects!
687
+ return df, fig, best_timetable_df, best_marey_fig, best_delay_df
688
+
689
+ # ==========================================
690
+ # 6. SIMULATION & GRADIO
691
+ # ==========================================
692
+
693
+ 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):
694
+ import random
695
+ from datetime import datetime, timedelta
696
+ import pandas as pd # Just in case it's not globally available here
697
+
698
+ # ----------------------------------------------------
699
+ # DATA CLEANING & HEADER RECOVERY (Robust to CSV errors)
700
+ # ----------------------------------------------------
701
+
702
+ # 1. Clean Train Data
703
+ if train_data is not None:
704
+ train_data = _normalize_uploaded_dataframe(train_data)
705
+
706
+ # 2. Clean Delay Data
707
+ if delay_data is not None:
708
+ delay_data = _normalize_uploaded_dataframe(delay_data)
709
+
710
+ # 3. Clean Station Data
711
+ if station_data is not None:
712
+ station_data = _normalize_uploaded_dataframe(station_data)
713
+
714
+ # ----------------------------------------------------
715
+
716
+ try: sp_km = float(spacing_input)
717
+ except: sp_km = 5.0
718
+
719
+ try: max_buff = float(max_buffer_input)
720
+ except: max_buff = 15.0
721
+
722
+ infra = Infrastructure(station_data, spacing_km=sp_km)
723
+ base_time = datetime.strptime("08:00", "%H:%M")
724
+
725
+ # 1. Nominal
726
+ trains_nom = []
727
+ stations_list = infra.stations
728
+
729
+ if train_data is None or train_data.empty:
730
+ return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(["No train data"]), go.Figure()
731
+
732
+ for _, row in train_data.iterrows():
733
+ if pd.isna(row.iloc[0]): continue
734
+
735
+ try:
736
+ t_id = str(row['Train ID'])
737
+ direction = row['Direction']
738
+ stops = stations_list[:] if str(direction).strip().lower() == "outbound" else stations_list[::-1]
739
+
740
+ t = Train(t_id, direction, row['Priority'], stops, row['Start Time Offset (min)'], row['Speed (km/h)'], row['Dwell (min)'])
741
+ update_train_times(t, infra, base_time)
742
+ trains_nom.append(t)
743
+ except KeyError as e:
744
+ return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), pd.DataFrame([f"CSV Error: Column missing - {e}"]), go.Figure()
745
+
746
+ # 2. Delayed
747
+ trains_del = [t.copy() for t in trains_nom]
748
+
749
+ # --- THE BAIT AND SWITCH: GENERATE RANDOM DATAFRAME IF SELECTED ---
750
+ if d_mode == "Randomized (Monte Carlo)":
751
+ random_delays = []
752
+ for t in trains_del:
753
+ if random.uniform(0, 100) <= float(r_prob):
754
+ if len(t.stops) > 1: # Pick a random station (not the final destination)
755
+ rand_idx = random.randint(0, len(t.stops) - 2)
756
+ rand_station = t.stops[rand_idx]
757
+ d_val = round(random.uniform(float(r_min), float(r_max)), 1)
758
+ random_delays.append({"Train ID": t.id, "Station": rand_station, "Delay": d_val})
759
+
760
+ # Override the delay_data variable so your existing code thinks it came from the CSV!
761
+ if random_delays:
762
+ delay_data = pd.DataFrame(random_delays)
763
+ else:
764
+ delay_data = pd.DataFrame(columns=["Train ID", "Station", "Delay"])
765
+ # ---------------------------------------------------------------
766
+
767
+ delay_lookup = {}
768
+ if delay_data is not None and not delay_data.empty:
769
+ for _, row in delay_data.iterrows():
770
+ if pd.isna(row.iloc[0]): continue
771
+ tid = str(row.iloc[0])
772
+ st = str(row.iloc[1])
773
+ try: val = float(row.iloc[2])
774
+ except: val = 0.0
775
+ delay_lookup[(tid, st)] = val
776
+
777
+ directions = set(t.direction for t in trains_del)
778
+ for d in directions:
779
+ d_trains = [t for t in trains_del if t.direction == d]
780
+ d_trains.sort(key=lambda x: x.start_time_offset)
781
+
782
+ last_departure_time = None
783
+ for t in d_trains:
784
+ if not t.schedule: update_train_times(t, infra, base_time)
785
+
786
+ origin_s = t.schedule[0]['station']
787
+ prim_delay_min = delay_lookup.get((t.id, origin_s), 0.0)
788
+
789
+ nominal_start = base_time + timedelta(minutes=t.start_time_offset)
790
+ earliest_start = nominal_start + timedelta(minutes=prim_delay_min)
791
+
792
+ if last_departure_time:
793
+ h_min = infra.get_dynamic_headway(t.speed_kmh)
794
+ min_safe_start = last_departure_time + h_min
795
+ actual_start = max(earliest_start, min_safe_start)
796
+ else:
797
+ actual_start = earliest_start
798
+
799
+ t.schedule[0]['manual_delay'] = actual_start - nominal_start
800
+
801
+ for node in t.schedule[1:]:
802
+ st_name = node['station']
803
+ d_val = delay_lookup.get((t.id, st_name), 0.0)
804
+ if d_val > 0:
805
+ node['manual_delay'] = timedelta(minutes=d_val)
806
+
807
+ update_train_times(t, infra, base_time)
808
+ last_departure_time = t.schedule[0]['dep']
809
+
810
+ # 3. Adjusted (Resolution with Max Buffer)
811
+ trains_adj = [t.copy() for t in trains_del]
812
+
813
+ selected_train_id = None
814
+ if selected_train_input and selected_train_input != "None":
815
+ selected_train_id = str(selected_train_input)
816
+
817
+ logs = resolve_all_conflicts(trains_adj, infra, base_time, selected_train_id=selected_train_id, max_buffer_min=max_buff)
818
+
819
+ # 4. Outputs
820
+ df_nom_matrix = generate_timetable_matrix(trains_nom, infra)
821
+ df_adj_matrix = generate_timetable_matrix(trains_adj, infra)
822
+ df_stats = calculate_delay_stats(trains_nom, trains_adj, delay_data)
823
+ df_buffer = generate_buffer_report(trains_adj, delay_data)
824
+ fig = create_filtered_marey(trains_nom, trains_del, trains_adj, infra, plot_view, selected_train_id)
825
+
826
+ return df_nom_matrix, df_adj_matrix, df_stats, df_buffer, pd.DataFrame(logs, columns=["Logs"]), fig
827
+
828
+ def get_train_choices(df):
829
+ if df is None or df.empty:
830
+ return gr.update(choices=["None"], value="None", interactive=True)
831
+
832
+ choices = ["None"]
833
+ try:
834
+ # Check potential ID columns
835
+ col = None
836
+ if 'Train ID' in df.columns:
837
+ col = 'Train ID'
838
+ elif len(df.columns) > 0:
839
+ col = df.columns[0] # Fallback to first column
840
+
841
+ if col:
842
+ valid_ids = df[col].dropna().astype(str).str.strip().tolist()
843
+ # Keep the list stable and remove duplicates while preserving order.
844
+ seen = set(choices)
845
+ for train_id in valid_ids:
846
+ if train_id and train_id not in seen:
847
+ choices.append(train_id)
848
+ seen.add(train_id)
849
+ except Exception:
850
+ pass
851
+
852
+ return gr.update(choices=choices, value="None", interactive=True)
853
+
854
+ # Helper function to load the trains CSV and refresh the selected-train dropdown at the same time.
855
+ def load_train_csv(file):
856
+ df = load_csv(file)
857
+ return df, get_train_choices(df)
858
+
859
+ # --- PRIMARY DELAY RESPONSIBILITY ---
860
+ # Responsibility for a primary delay (IM vs RU) is decided by a single shared
861
+ # rule - the "IM Responsibility Share" percentage set before running the
862
+ # sweet-spot search - applied identically whether the delay is predefined
863
+ # (from the Delays tab) or randomly generated during the run. This keeps the
864
+ # assumption consistent across delay modes and across every capacity level
865
+ # tested (see simulate_capacity_vs_reliability). If IM is responsible it's a
866
+ # penalty for IM; if RU is responsible it's income for IM (RU pays IM).
867
+ # Secondary delays are untouched - always a cost paid by IM (PR Penalty).
868
+
869
+ # Add delay_data to the function inputs
870
+ 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):
871
+ import pandas as pd
872
+ import plotly.graph_objects as go
873
+
874
+ try: sp_km = float(spacing_input)
875
+ except: sp_km = 5.0
876
+ if station_data is not None:
877
+ station_data = _normalize_uploaded_dataframe(station_data)
878
+ if delay_data is not None:
879
+ delay_data = _normalize_uploaded_dataframe(delay_data)
880
+ if fleet_data is not None:
881
+ fleet_data = _normalize_uploaded_dataframe(fleet_data)
882
+ infra = Infrastructure(station_data, spacing_km=sp_km)
883
+
884
+ if fleet_data is None or fleet_data.empty:
885
+ # We now return 5 empty/error elements
886
+ return pd.DataFrame([{"Error": "No fleet data defined"}]), go.Figure(), pd.DataFrame(), go.Figure(), pd.DataFrame()
887
+
888
+ fleet_data = fleet_data.dropna(how='all')
889
+ fleet_data.columns = fleet_data.columns.astype(str).str.strip()
890
+
891
+ fleet_profiles = []
892
+ for _, row in fleet_data.iterrows():
893
+ t_id = str(row.get("Type Name", "Standard")).strip()
894
+ speed = float(row.get("Speed (km/h)", 100))
895
+ dwell = float(row.get("Dwell (min)", 2))
896
+ priority = int(row.get("Priority", 1))
897
+
898
+ stops = infra.stations[:]
899
+ fleet_profiles.append(Train(t_id, "Outbound", priority, stops, 0, speed, dwell))
900
+
901
+ # Now unpack 5 variables from the simulation engine!
902
+ df, fig, best_timetable_df, best_marey_fig, best_delay_df = simulate_capacity_vs_reliability(
903
+ infra, fleet_profiles, delay_data, max_trains, tac_val, pr_val, d_mode, r_prob, r_min, r_max, im_resp_share
904
+ )
905
+
906
+ return df, fig, best_timetable_df, best_marey_fig, best_delay_df
907
+
908
+ # CSV loading and normalization helpers
909
+ # ----------------------------------------------------
910
+ # Uploaded files often use slightly different header names, for example
911
+ # "Trian ID", "Speed", or "Start time". The simulator expects canonical
912
+ # internal names, so normalize them immediately when the CSV is loaded.
913
+ def _compact_header(name):
914
+ import re
915
+ return re.sub(r"[^a-z0-9]+", "", str(name).strip().lower())
916
+
917
+ def _normalize_uploaded_dataframe(df):
918
+ if df is None:
919
+ return None
920
+
921
+ df = df.copy()
922
+ df = df.dropna(how='all')
923
+ df.columns = df.columns.astype(str).str.strip()
924
+
925
+ compact_cols = [_compact_header(c) for c in df.columns]
926
+ compact_set = set(compact_cols)
927
+
928
+ train_markers = {
929
+ "trainid", "trianid", "direction", "priority", "speed", "speedkmh",
930
+ "starttime", "startoffset", "starttimeoffset", "starttimeoffsetmin",
931
+ "dwell", "dwellmin", "dwellminutes"
932
+ }
933
+ station_markers = {"distance", "distancekm", "km", "stationdistance"}
934
+ delay_markers = {"delay", "delaymin", "delayminutes", "delaymins"}
935
+
936
+ if compact_set & train_markers:
937
+ csv_kind = "trains"
938
+ elif compact_set & station_markers:
939
+ csv_kind = "stations"
940
+ elif compact_set & delay_markers:
941
+ csv_kind = "delays"
942
+ else:
943
+ csv_kind = "unknown"
944
+
945
+ base_aliases = {
946
+ "trainid": "Train ID",
947
+ "trianid": "Train ID", # common typo in uploaded files
948
+ "trainno": "Train ID",
949
+ "trainnumber": "Train ID",
950
+ "id": "Train ID" if csv_kind == "trains" else "ID",
951
+ "direction": "Direction",
952
+ "dir": "Direction",
953
+ "priority": "Priority",
954
+ "speed": "Speed (km/h)",
955
+ "speedkmh": "Speed (km/h)",
956
+ "speedkmph": "Speed (km/h)",
957
+ "start": "Start Time Offset (min)",
958
+ "starttime": "Start Time Offset (min)",
959
+ "startoffset": "Start Time Offset (min)",
960
+ "starttimeoffset": "Start Time Offset (min)",
961
+ "starttimeoffsetmin": "Start Time Offset (min)",
962
+ "starttimemin": "Start Time Offset (min)",
963
+ "startminutes": "Start Time Offset (min)",
964
+ "dwell": "Dwell (min)",
965
+ "dwelltime": "Dwell (min)",
966
+ "dwellmin": "Dwell (min)",
967
+ "dwellminutes": "Dwell (min)",
968
+ "delay": "Delay (min)",
969
+ "delaymin": "Delay (min)",
970
+ "delaymins": "Delay (min)",
971
+ "delayminutes": "Delay (min)",
972
+ "distance": "Distance (km)",
973
+ "distancekm": "Distance (km)",
974
+ "km": "Distance (km)",
975
+ "typename": "Type Name",
976
+ "type": "Type Name",
977
+ }
978
+
979
+ station_aliases = {
980
+ "station": "Station Name" if csv_kind == "stations" else "Station",
981
+ "stationname": "Station Name" if csv_kind == "stations" else "Station",
982
+ "stationnames": "Station Name" if csv_kind == "stations" else "Station",
983
+ "stop": "Station",
984
+ "stops": "Station",
985
+ }
986
+
987
+ rename_map = {}
988
+ used = set()
989
+ for original in df.columns:
990
+ key = _compact_header(original)
991
+ new_name = station_aliases.get(key, base_aliases.get(key, original.strip()))
992
+ # Avoid duplicate column names after normalization.
993
+ if new_name in used:
994
+ suffix = 2
995
+ candidate = f"{new_name} {suffix}"
996
+ while candidate in used:
997
+ suffix += 1
998
+ candidate = f"{new_name} {suffix}"
999
+ new_name = candidate
1000
+ rename_map[original] = new_name
1001
+ used.add(new_name)
1002
+
1003
+ df = df.rename(columns=rename_map)
1004
+
1005
+ # Positional fallbacks for common no/weak-header files.
1006
+ if csv_kind == "trains" and "Train ID" not in df.columns and len(df.columns) >= 6:
1007
+ df.columns = [
1008
+ "Train ID", "Direction", "Priority", "Speed (km/h)",
1009
+ "Start Time Offset (min)", "Dwell (min)"
1010
+ ] + list(df.columns[6:])
1011
+ elif csv_kind == "stations" and "Station Name" not in df.columns and len(df.columns) >= 2:
1012
+ df.columns = ["Station Name", "Distance (km)"] + list(df.columns[2:])
1013
+ elif csv_kind == "delays" and "Train ID" not in df.columns and len(df.columns) >= 3:
1014
+ df.columns = ["Train ID", "Station", "Delay (min)"] + list(df.columns[3:])
1015
+
1016
+ # Reorder known tables so the displayed matrix is consistent.
1017
+ desired_order = []
1018
+ if csv_kind == "trains":
1019
+ desired_order = ["Train ID", "Direction", "Priority", "Speed (km/h)", "Start Time Offset (min)", "Dwell (min)"]
1020
+ elif csv_kind == "stations":
1021
+ desired_order = ["Station Name", "Distance (km)"]
1022
+ elif csv_kind == "delays":
1023
+ desired_order = ["Train ID", "Station", "Delay (min)"]
1024
+
1025
+ if desired_order:
1026
+ ordered = [c for c in desired_order if c in df.columns]
1027
+ extras = [c for c in df.columns if c not in ordered]
1028
+ df = df[ordered + extras]
1029
+
1030
+ return df.reset_index(drop=True)
1031
+
1032
+ # Helper function to load CSV
1033
+ def load_csv(file):
1034
+ if file is None:
1035
+ return None
1036
+
1037
+ file_path = getattr(file, "name", file)
1038
+ suffix = str(file_path).lower().rsplit(".", 1)[-1] if "." in str(file_path) else ""
1039
+
1040
+ try:
1041
+ if suffix in {"xlsx", "xls"}:
1042
+ # .xlsx uses openpyxl in normal pandas installations; .xls uses xlrd when available.
1043
+ df = pd.read_excel(file_path)
1044
+ else:
1045
+ # sep=None lets pandas detect comma/semicolon/tab-delimited CSVs.
1046
+ df = pd.read_csv(file_path, sep=None, engine="python")
1047
+ return _normalize_uploaded_dataframe(df)
1048
+ except Exception:
1049
+ try:
1050
+ if suffix in {"xlsx", "xls"}:
1051
+ df = pd.read_excel(file_path)
1052
+ else:
1053
+ df = pd.read_csv(file_path)
1054
+ return _normalize_uploaded_dataframe(df)
1055
+ except Exception:
1056
+ return None
1057
+
1058
+ d_stations = pd.DataFrame({'Station Name': ['A', 'B', 'C', 'D', 'E'], 'Distance (km)': [0, 15, 35, 45, 60]})
1059
+ d_trains = pd.DataFrame({
1060
+ 'Train ID': [101, 102],
1061
+ 'Direction': ['Outbound', 'Outbound'],
1062
+ 'Priority': [3, 2],
1063
+ 'Speed (km/h)': [60, 120],
1064
+ 'Start Time Offset (min)': [0, 5],
1065
+ 'Dwell (min)': [2, 2]
1066
+ })
1067
+ d_delays = pd.DataFrame({'Train ID': [101], 'Station': ['B'], 'Delay (min)': [10]})
1068
+
1069
+
1070
+ CUSTOM_CSS = """
1071
+ :root {
1072
+ --ly-bg: #f7f8fc;
1073
+ --ly-surface: #ffffff;
1074
+ --ly-surface-soft: #fbfaff;
1075
+ --ly-surface-tint: #f5f1ff;
1076
+ --ly-border: #e9eaf2;
1077
+ --ly-border-strong: #dcdfea;
1078
+ --ly-text: #202431;
1079
+ --ly-text-2: #5f6677;
1080
+ --ly-muted: #9299aa;
1081
+ --ly-primary: #7c5cff;
1082
+ --ly-primary-2: #4c8dff;
1083
+ --ly-primary-soft: #f1edff;
1084
+ --ly-cyan-soft: #edf7ff;
1085
+ --ly-success: #46b99a;
1086
+ --ly-danger: #ee7486;
1087
+ --ly-warning: #f2b84b;
1088
+ --ly-radius: 16px;
1089
+ --ly-radius-sm: 11px;
1090
+ --ly-shadow: 0 10px 30px rgba(70, 61, 110, 0.065);
1091
+ --matrix-cycle: 1200ms;
1092
+ --matrix-base: #d8d5e6;
1093
+ --matrix-active: #7c5cff;
1094
+ --matrix-ease: ease-in-out;
1095
+ }
1096
+
1097
+ html, body {
1098
+ min-height: 100%;
1099
+ background: var(--ly-bg) !important;
1100
+ }
1101
+
1102
+ body, .gradio-container {
1103
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;
1104
+ }
1105
+
1106
+ .gradio-container {
1107
+ max-width: 1480px !important;
1108
+ margin: 0 auto !important;
1109
+ padding: 0 24px 34px !important;
1110
+ color: var(--ly-text) !important;
1111
+ background:
1112
+ radial-gradient(circle at 10% -8%, rgba(124, 92, 255, .07), transparent 24rem),
1113
+ radial-gradient(circle at 88% -6%, rgba(76, 141, 255, .07), transparent 26rem),
1114
+ var(--ly-bg) !important;
1115
+ overflow: visible !important;
1116
+ }
1117
+
1118
+ .gradio-container h1,
1119
+ .gradio-container h2,
1120
+ .gradio-container h3,
1121
+ .gradio-container h4,
1122
+ .gradio-container label,
1123
+ .gradio-container .label-wrap {
1124
+ color: var(--ly-text) !important;
1125
+ }
1126
+ .gradio-container p,
1127
+ .gradio-container .prose,
1128
+ .gradio-container .markdown,
1129
+ .gradio-container .gr-markdown {
1130
+ color: var(--ly-text-2) !important;
1131
+ }
1132
+
1133
+ /* Lynote-inspired top navigation and soft introduction band. */
1134
+ .app-topbar {
1135
+ position: sticky;
1136
+ top: 0;
1137
+ z-index: 80;
1138
+ display: flex;
1139
+ align-items: center;
1140
+ justify-content: space-between;
1141
+ gap: 18px;
1142
+ margin: 0 -24px;
1143
+ padding: 13px 24px;
1144
+ background: rgba(255,255,255,.92);
1145
+ border-bottom: 1px solid rgba(229,231,240,.92);
1146
+ backdrop-filter: blur(14px);
1147
+ -webkit-backdrop-filter: blur(14px);
1148
+ }
1149
+ .app-brand {
1150
+ display: flex;
1151
+ align-items: center;
1152
+ gap: 10px;
1153
+ min-width: 0;
1154
+ }
1155
+ .app-mark {
1156
+ display: grid;
1157
+ place-items: center;
1158
+ width: 34px;
1159
+ height: 34px;
1160
+ flex: 0 0 34px;
1161
+ border-radius: 10px;
1162
+ color: #fff !important;
1163
+ background: linear-gradient(145deg, var(--ly-primary), var(--ly-primary-2));
1164
+ font-size: 16px;
1165
+ font-weight: 800;
1166
+ box-shadow: 0 7px 16px rgba(124,92,255,.18);
1167
+ }
1168
+ .app-title {
1169
+ color: var(--ly-text) !important;
1170
+ font-size: 16px;
1171
+ line-height: 1.2;
1172
+ font-weight: 760;
1173
+ letter-spacing: -0.02em;
1174
+ }
1175
+ .app-badge {
1176
+ flex: 0 0 auto;
1177
+ padding: 6px 10px;
1178
+ border-radius: 999px;
1179
+ color: #6750d8 !important;
1180
+ background: var(--ly-primary-soft);
1181
+ border: 1px solid #e5ddff;
1182
+ font-size: 11.5px;
1183
+ font-weight: 700;
1184
+ }
1185
+ .workspace-hero {
1186
+ position: relative;
1187
+ overflow: hidden;
1188
+ margin: 24px 0 22px;
1189
+ padding: 28px 30px;
1190
+ border: 1px solid #ebe8f7;
1191
+ border-radius: 20px;
1192
+ background:
1193
+ radial-gradient(circle at 12% 20%, rgba(180,151,255,.20), transparent 19rem),
1194
+ radial-gradient(circle at 85% 25%, rgba(125,197,255,.20), transparent 21rem),
1195
+ linear-gradient(120deg, #fbf8ff 0%, #f5f7ff 48%, #f2faff 100%);
1196
+ }
1197
+ .workspace-hero::after {
1198
+ content: "";
1199
+ position: absolute;
1200
+ width: 260px;
1201
+ height: 260px;
1202
+ right: -100px;
1203
+ bottom: -170px;
1204
+ border-radius: 50%;
1205
+ background: rgba(124,92,255,.06);
1206
+ pointer-events: none;
1207
+ }
1208
+ .workspace-kicker {
1209
+ position: relative;
1210
+ z-index: 1;
1211
+ display: inline-flex;
1212
+ margin-bottom: 8px;
1213
+ padding: 5px 9px;
1214
+ border: 1px solid #e3dcff;
1215
+ border-radius: 999px;
1216
+ color: #6f55d9 !important;
1217
+ background: rgba(255,255,255,.62);
1218
+ font-size: 11px;
1219
+ font-weight: 750;
1220
+ }
1221
+ .workspace-title {
1222
+ position: relative;
1223
+ z-index: 1;
1224
+ margin: 0;
1225
+ max-width: 860px;
1226
+ color: #232431 !important;
1227
+ font-size: clamp(28px, 3.2vw, 42px);
1228
+ line-height: 1.08;
1229
+ letter-spacing: -0.035em;
1230
+ font-weight: 780;
1231
+ }
1232
+ .workspace-subtitle {
1233
+ position: relative;
1234
+ z-index: 1;
1235
+ max-width: 850px;
1236
+ margin-top: 10px;
1237
+ color: #687083 !important;
1238
+ font-size: 14px;
1239
+ line-height: 1.6;
1240
+ }
1241
+
1242
+ /* Page headings */
1243
+ .gradio-container h2 {
1244
+ margin: 2px 0 6px !important;
1245
+ font-size: 21px !important;
1246
+ line-height: 1.3 !important;
1247
+ font-weight: 740 !important;
1248
+ letter-spacing: -0.025em !important;
1249
+ }
1250
+ .gradio-container h2 + p {
1251
+ margin-top: 0 !important;
1252
+ margin-bottom: 18px !important;
1253
+ color: var(--ly-muted) !important;
1254
+ font-size: 13.5px !important;
1255
+ }
1256
+ .panel-card h3,
1257
+ .chart-card h3,
1258
+ .results-card h3 {
1259
+ margin: 0 0 13px !important;
1260
+ color: var(--ly-text) !important;
1261
+ font-size: 15px !important;
1262
+ line-height: 1.35 !important;
1263
+ font-weight: 720 !important;
1264
+ letter-spacing: -0.012em !important;
1265
+ }
1266
+
1267
+ /* White Lynote-style workspace cards. */
1268
+ .panel-card,
1269
+ .chart-card,
1270
+ .results-card {
1271
+ position: relative !important;
1272
+ overflow: visible !important;
1273
+ margin-bottom: 14px !important;
1274
+ padding: 18px !important;
1275
+ border: 1px solid var(--ly-border) !important;
1276
+ border-radius: var(--ly-radius) !important;
1277
+ background: var(--ly-surface) !important;
1278
+ box-shadow: var(--ly-shadow) !important;
1279
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease !important;
1280
+ }
1281
+ .panel-card:hover,
1282
+ .results-card:hover {
1283
+ border-color: #dedbea !important;
1284
+ box-shadow: 0 12px 34px rgba(70,61,110,.085) !important;
1285
+ }
1286
+ .chart-card {
1287
+ min-height: 650px;
1288
+ }
1289
+
1290
+ /* Remove nested Gradio visual noise while keeping white workspace surfaces. */
1291
+ .gradio-container .block,
1292
+ .gradio-container .form,
1293
+ .gradio-container .panel,
1294
+ .gradio-container .gr-box,
1295
+ .gradio-container .contain,
1296
+ .gradio-container .wrap {
1297
+ border-radius: var(--ly-radius-sm) !important;
1298
+ box-shadow: none !important;
1299
+ overflow: visible !important;
1300
+ }
1301
+ .panel-card .block,
1302
+ .panel-card .form,
1303
+ .panel-card .gr-box,
1304
+ .panel-card .contain,
1305
+ .panel-card .wrap,
1306
+ .chart-card .block,
1307
+ .chart-card .form,
1308
+ .chart-card .gr-box,
1309
+ .chart-card .contain,
1310
+ .chart-card .wrap,
1311
+ .results-card .block,
1312
+ .results-card .form,
1313
+ .results-card .gr-box,
1314
+ .results-card .contain,
1315
+ .results-card .wrap {
1316
+ background: transparent !important;
1317
+ border-color: transparent !important;
1318
+ }
1319
+
1320
+ /* Form controls */
1321
+ .gradio-container input,
1322
+ .gradio-container textarea,
1323
+ .gradio-container select {
1324
+ min-height: 40px !important;
1325
+ color: var(--ly-text) !important;
1326
+ background: #fcfcfe !important;
1327
+ border: 1px solid var(--ly-border-strong) !important;
1328
+ border-radius: 10px !important;
1329
+ box-shadow: none !important;
1330
+ transition: border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease !important;
1331
+ }
1332
+ .gradio-container input:hover,
1333
+ .gradio-container textarea:hover,
1334
+ .gradio-container select:hover {
1335
+ border-color: #cfc7f4 !important;
1336
+ }
1337
+ .gradio-container input:focus,
1338
+ .gradio-container textarea:focus,
1339
+ .gradio-container select:focus,
1340
+ .gradio-container input:focus-visible,
1341
+ .gradio-container textarea:focus-visible,
1342
+ .gradio-container select:focus-visible {
1343
+ border-color: var(--ly-primary) !important;
1344
+ box-shadow: 0 0 0 3px rgba(124,92,255,.10) !important;
1345
+ outline: none !important;
1346
+ background: #fff !important;
1347
+ }
1348
+ .gradio-container input[type="radio"],
1349
+ .gradio-container input[type="checkbox"],
1350
+ .gradio-container input[type="range"] {
1351
+ accent-color: var(--ly-primary) !important;
1352
+ }
1353
+ .gradio-container label,
1354
+ .gradio-container .label-wrap {
1355
+ font-weight: 630 !important;
1356
+ }
1357
+
1358
+ /* Dropdown stability */
1359
+ .selector-strip,
1360
+ #view_listbox,
1361
+ #train_listbox,
1362
+ .hort-view-select,
1363
+ .hort-train-select {
1364
+ overflow: visible !important;
1365
+ filter: none !important;
1366
+ transform: none !important;
1367
+ backdrop-filter: none !important;
1368
+ -webkit-backdrop-filter: none !important;
1369
+ }
1370
+ .selector-strip { z-index: 20 !important; }
1371
+ #view_listbox, #train_listbox { position: relative !important; z-index: 30 !important; }
1372
+ .hort-listbox label,
1373
+ .hort-listbox .label-wrap {
1374
+ color: var(--ly-text-2) !important;
1375
+ font-weight: 650 !important;
1376
+ }
1377
+
1378
+ /* Radio groups as calm option cards. */
1379
+ .gradio-container fieldset {
1380
+ padding: 6px !important;
1381
+ background: #fafafe !important;
1382
+ border: 1px solid var(--ly-border) !important;
1383
+ border-radius: 12px !important;
1384
+ }
1385
+ .hort-radio label {
1386
+ display: flex !important;
1387
+ align-items: center !important;
1388
+ gap: 8px !important;
1389
+ margin: 3px 0 !important;
1390
+ padding: 9px 10px !important;
1391
+ border: 1px solid transparent !important;
1392
+ border-radius: 9px !important;
1393
+ color: var(--ly-text-2) !important;
1394
+ background: transparent !important;
1395
+ transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease !important;
1396
+ }
1397
+ .hort-radio label:hover {
1398
+ color: var(--ly-text) !important;
1399
+ background: #f6f4ff !important;
1400
+ }
1401
+ .hort-radio label:has(input[type="radio"]:checked),
1402
+ .hort-radio label[aria-checked="true"],
1403
+ .hort-radio .selected {
1404
+ color: #654bd0 !important;
1405
+ background: var(--ly-primary-soft) !important;
1406
+ border-color: #ded5ff !important;
1407
+ box-shadow: none !important;
1408
+ font-weight: 700 !important;
1409
+ }
1410
+
1411
+ /* Compact horizontal uploader with Lynote's pale violet treatment. */
1412
+ .input-file-example {
1413
+ width: 100% !important;
1414
+ margin: 0 0 13px !important;
1415
+ padding: 9px 10px !important;
1416
+ display: flex !important;
1417
+ flex-flow: row nowrap !important;
1418
+ align-items: center !important;
1419
+ gap: 10px !important;
1420
+ background: #fbfaff !important;
1421
+ border: 1px dashed #dcd3ff !important;
1422
+ border-radius: 12px !important;
1423
+ box-shadow: none !important;
1424
+ }
1425
+ .input-file-example > * { margin: 0 !important; }
1426
+ .input-file-example .input-file-label-wrap {
1427
+ flex: 0 0 auto !important;
1428
+ width: auto !important;
1429
+ min-width: max-content !important;
1430
+ }
1431
+ .input-file-example .input-file-control {
1432
+ flex: 0 1 210px !important;
1433
+ width: 210px !important;
1434
+ min-width: 170px !important;
1435
+ max-width: 230px !important;
1436
+ }
1437
+ .input-file-example .input-file-help-wrap {
1438
+ flex: 1 1 180px !important;
1439
+ min-width: 140px !important;
1440
+ }
1441
+ .input-file-example .input-file-label {
1442
+ margin: 0 !important;
1443
+ color: #4d5363 !important;
1444
+ font-size: 12px !important;
1445
+ line-height: 1.2 !important;
1446
+ font-weight: 700 !important;
1447
+ white-space: nowrap !important;
1448
+ }
1449
+ .input-file-control,
1450
+ .input-file-control > div,
1451
+ .input-file-control .wrap,
1452
+ .input-file-control .block,
1453
+ .input-file-control .container {
1454
+ margin: 0 !important;
1455
+ padding: 0 !important;
1456
+ width: 100% !important;
1457
+ min-width: 0 !important;
1458
+ background: transparent !important;
1459
+ border: 0 !important;
1460
+ box-shadow: none !important;
1461
+ }
1462
+ .input-file-control button[aria-label*="upload" i],
1463
+ .input-file-control button[aria-label*="drop" i],
1464
+ .input-file-control .file,
1465
+ .input-file-control .file-preview,
1466
+ .input-file-control [class*="file-preview"] {
1467
+ width: 100% !important;
1468
+ height: 36px !important;
1469
+ min-height: 36px !important;
1470
+ max-height: 36px !important;
1471
+ padding: 0 11px !important;
1472
+ display: flex !important;
1473
+ align-items: center !important;
1474
+ justify-content: flex-start !important;
1475
+ overflow: hidden !important;
1476
+ color: #5d6473 !important;
1477
+ background: #ffffff !important;
1478
+ border: 1px solid #cfc3ff !important;
1479
+ border-radius: 9px !important;
1480
+ box-shadow: 0 2px 6px rgba(124,92,255,.045) !important;
1481
+ cursor: pointer !important;
1482
+ transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease !important;
1483
+ }
1484
+ .input-file-control button[aria-label*="upload" i]:hover,
1485
+ .input-file-control button[aria-label*="drop" i]:hover {
1486
+ border-color: var(--ly-primary) !important;
1487
+ background: #fdfcff !important;
1488
+ box-shadow: 0 0 0 3px rgba(124,92,255,.07) !important;
1489
+ }
1490
+ .input-file-control button[aria-label*="upload" i] > *,
1491
+ .input-file-control button[aria-label*="drop" i] > *,
1492
+ .input-file-control .file svg,
1493
+ .input-file-control .file-preview svg {
1494
+ display: none !important;
1495
+ }
1496
+ .input-file-control button[aria-label*="upload" i]::before,
1497
+ .input-file-control button[aria-label*="drop" i]::before {
1498
+ content: "Browse file";
1499
+ color: #6c52d9 !important;
1500
+ font-size: 12px !important;
1501
+ font-weight: 700 !important;
1502
+ white-space: nowrap;
1503
+ }
1504
+ .input-file-control .file-name,
1505
+ .input-file-control [class*="file-name"] {
1506
+ min-width: 0 !important;
1507
+ overflow: hidden !important;
1508
+ text-overflow: ellipsis !important;
1509
+ white-space: nowrap !important;
1510
+ color: var(--ly-text-2) !important;
1511
+ font-size: 12px !important;
1512
+ }
1513
+ .input-file-example .input-file-help {
1514
+ margin: 0 !important;
1515
+ color: var(--ly-muted) !important;
1516
+ font-size: 10.5px !important;
1517
+ line-height: 1.3 !important;
1518
+ font-weight: 400 !important;
1519
+ }
1520
+
1521
+ /* Actions */
1522
+ .gradio-container button {
1523
+ border-radius: 10px !important;
1524
+ font-weight: 700 !important;
1525
+ transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease !important;
1526
+ }
1527
+ .gradio-container button.primary {
1528
+ min-height: 42px !important;
1529
+ color: #ffffff !important;
1530
+ background: linear-gradient(135deg, var(--ly-primary), #6f68f6) !important;
1531
+ border: 1px solid var(--ly-primary) !important;
1532
+ box-shadow: 0 7px 16px rgba(124,92,255,.17) !important;
1533
+ }
1534
+ .gradio-container button.primary:hover {
1535
+ background: linear-gradient(135deg, #6f50f0, #5e7bf2) !important;
1536
+ border-color: #6f50f0 !important;
1537
+ box-shadow: 0 9px 20px rgba(124,92,255,.20) !important;
1538
+ }
1539
+ .gradio-container button.secondary {
1540
+ color: var(--ly-text-2) !important;
1541
+ background: #fff !important;
1542
+ border-color: var(--ly-border) !important;
1543
+ }
1544
+
1545
+ /* Main tabs resemble Lynote's flat source tabs. */
1546
+ .hort-main-tabs > .tab-nav,
1547
+ .gradio-container .hort-main-tabs .tab-nav {
1548
+ display: flex !important;
1549
+ width: 100% !important;
1550
+ max-width: 100% !important;
1551
+ gap: 24px !important;
1552
+ margin-bottom: 20px !important;
1553
+ padding: 0 2px !important;
1554
+ overflow: auto hidden !important;
1555
+ background: transparent !important;
1556
+ border: 0 !important;
1557
+ border-bottom: 1px solid var(--ly-border) !important;
1558
+ border-radius: 0 !important;
1559
+ }
1560
+ .gradio-container .tab-nav button {
1561
+ position: relative !important;
1562
+ min-height: 42px !important;
1563
+ padding: 8px 2px 11px !important;
1564
+ color: var(--ly-muted) !important;
1565
+ background: transparent !important;
1566
+ border: 0 !important;
1567
+ border-radius: 0 !important;
1568
+ box-shadow: none !important;
1569
+ font-size: 13px !important;
1570
+ font-weight: 650 !important;
1571
+ white-space: nowrap !important;
1572
+ }
1573
+ .gradio-container .tab-nav button:hover {
1574
+ color: #5e4bc3 !important;
1575
+ background: transparent !important;
1576
+ }
1577
+ .gradio-container .tab-nav button.selected,
1578
+ .gradio-container .tab-nav button[aria-selected="true"] {
1579
+ color: #5f48cb !important;
1580
+ background: transparent !important;
1581
+ font-weight: 750 !important;
1582
+ }
1583
+ .gradio-container .tab-nav button.selected::after,
1584
+ .gradio-container .tab-nav button[aria-selected="true"]::after {
1585
+ content: "";
1586
+ position: absolute;
1587
+ left: 0;
1588
+ right: 0;
1589
+ bottom: -1px;
1590
+ height: 2px;
1591
+ border-radius: 999px 999px 0 0;
1592
+ background: var(--ly-primary);
1593
+ }
1594
+
1595
+ /* Result tabs inside cards stay compact. */
1596
+ .results-card .tab-nav {
1597
+ gap: 14px !important;
1598
+ margin: 0 0 12px !important;
1599
+ border-bottom: 1px solid var(--ly-border) !important;
1600
+ }
1601
+ .results-card .tab-nav button {
1602
+ min-height: 36px !important;
1603
+ padding: 6px 1px 9px !important;
1604
+ font-size: 12px !important;
1605
+ }
1606
+
1607
+ /* Dataframes */
1608
+ .gradio-container .table-wrap,
1609
+ .gradio-container [class*="dataframe"] {
1610
+ border-radius: 11px !important;
1611
+ }
1612
+ .gradio-container table {
1613
+ color: var(--ly-text-2) !important;
1614
+ background: #ffffff !important;
1615
+ border-color: var(--ly-border) !important;
1616
+ }
1617
+ .gradio-container table th {
1618
+ color: #5b6272 !important;
1619
+ background: #f8f8fb !important;
1620
+ border-color: var(--ly-border) !important;
1621
+ font-weight: 700 !important;
1622
+ }
1623
+ .gradio-container table td {
1624
+ background: #ffffff !important;
1625
+ border-color: #eef0f4 !important;
1626
+ }
1627
+ .gradio-container table tbody tr:hover td {
1628
+ background: #faf9ff !important;
1629
+ }
1630
+ .gradio-container [role="grid"],
1631
+ .gradio-container [role="gridcell"],
1632
+ .gradio-container [role="columnheader"] {
1633
+ border-color: var(--ly-border) !important;
1634
+ }
1635
+
1636
+ /* Plot shells */
1637
+ .plot-container,
1638
+ .js-plotly-plot,
1639
+ .plotly,
1640
+ .gr-plot {
1641
+ overflow: hidden !important;
1642
+ border-radius: 12px !important;
1643
+ }
1644
+ .chart-card .gr-plot,
1645
+ .chart-card .plot-container,
1646
+ .chart-card .js-plotly-plot,
1647
+ .chart-card .plotly,
1648
+ .results-card .gr-plot,
1649
+ .results-card .plot-container,
1650
+ .results-card .js-plotly-plot,
1651
+ .results-card .plotly {
1652
+ background: #ffffff !important;
1653
+ }
1654
+
1655
+ /* Matrix loader retained, recoloured for Lynote. */
1656
+ .t-matrix {
1657
+ display: grid;
1658
+ grid-template-columns: repeat(4, 2px);
1659
+ grid-auto-rows: 2px;
1660
+ gap: 2px;
1661
+ width: max-content;
1662
+ height: max-content;
1663
+ }
1664
+ .t-matrix i {
1665
+ display: block;
1666
+ width: 2px;
1667
+ height: 2px;
1668
+ margin: 0;
1669
+ padding: 0;
1670
+ border: 0;
1671
+ border-radius: 0;
1672
+ background: var(--matrix-base);
1673
+ animation: t-matrix-pulse var(--matrix-cycle) var(--matrix-ease) infinite;
1674
+ animation-delay: calc(var(--d, 0) * 1ms);
1675
+ }
1676
+ .t-matrix i.is-gap { visibility: hidden; animation: none; }
1677
+ @keyframes t-matrix-pulse {
1678
+ 0%, 45%, 100% { background-color: var(--matrix-base); }
1679
+ 15% { background-color: var(--matrix-active); }
1680
+ }
1681
+ .gradio-container [data-testid="status-tracker"].matrix-loading {
1682
+ display: flex !important;
1683
+ align-items: center !important;
1684
+ justify-content: center !important;
1685
+ background: transparent !important;
1686
+ border: 0 !important;
1687
+ box-shadow: none !important;
1688
+ animation: none !important;
1689
+ opacity: 1 !important;
1690
+ }
1691
+ .gradio-container [data-testid="status-tracker"].matrix-loading > :not(.t-matrix) { display: none !important; }
1692
+ .gradio-container [data-testid="status-tracker"].matrix-loading > .t-matrix { display: grid !important; }
1693
+ .gradio-container .matrix-spinner-host {
1694
+ display: inline-grid !important;
1695
+ place-items: center !important;
1696
+ width: 14px !important;
1697
+ height: 14px !important;
1698
+ min-width: 14px !important;
1699
+ min-height: 14px !important;
1700
+ padding: 0 !important;
1701
+ border: 0 !important;
1702
+ background: transparent !important;
1703
+ box-shadow: none !important;
1704
+ animation: none !important;
1705
+ }
1706
+ .gradio-container .matrix-spinner-host > :not(.t-matrix) { display: none !important; }
1707
+ .gradio-container .matrix-spinner-host > .t-matrix { display: grid !important; }
1708
+
1709
+ .footer-note {
1710
+ margin: 20px 0 4px !important;
1711
+ text-align: center;
1712
+ color: #a1a7b5 !important;
1713
+ font-size: 11px;
1714
+ }
1715
+ footer { display: none !important; }
1716
+
1717
+ /* Dropdown overlays and scrollbars */
1718
+ .panel-card *, .results-card *, .chart-card * { scroll-margin-top: 0; }
1719
+ .gradio-container * {
1720
+ scrollbar-width: thin;
1721
+ scrollbar-color: #d8dbe5 transparent;
1722
+ }
1723
+ .gradio-container *::-webkit-scrollbar { width: 8px; height: 8px; }
1724
+ .gradio-container *::-webkit-scrollbar-thumb { background: #d8dbe5; border-radius: 999px; }
1725
+ .gradio-container *::-webkit-scrollbar-track { background: transparent; }
1726
+
1727
+ @media (max-width: 1120px) {
1728
+ .input-file-example { flex-wrap: wrap !important; }
1729
+ .input-file-example .input-file-help-wrap {
1730
+ flex: 1 0 100% !important;
1731
+ padding-left: 0 !important;
1732
+ }
1733
+ }
1734
+ @media (max-width: 760px) {
1735
+ .gradio-container { padding: 0 12px 24px !important; }
1736
+ .app-topbar { margin: 0 -12px; padding: 12px; }
1737
+ .app-badge { display: none; }
1738
+ .workspace-hero { margin-top: 16px; padding: 22px 18px; border-radius: 16px; }
1739
+ .workspace-title { font-size: 28px; }
1740
+ .panel-card, .chart-card, .results-card { padding: 14px !important; border-radius: 13px !important; }
1741
+ .chart-card { min-height: 520px; }
1742
+ .hort-main-tabs > .tab-nav,
1743
+ .gradio-container .hort-main-tabs .tab-nav {
1744
+ gap: 17px !important;
1745
+ }
1746
+ }
1747
+ @media (prefers-reduced-motion: reduce) {
1748
+ .t-matrix i { animation: none !important; }
1749
+ * { scroll-behavior: auto !important; transition-duration: 0.01ms !important; }
1750
+ }
1751
  """
1752
 
1753
+ # Gradio DOM adapter for the requested 4x4 matrix loading animation.
1754
+ MATRIX_LOADER_JS = r"""
1755
+ () => {
1756
+ const MATRIX_CLASS = "t-matrix";
1757
+
1758
+ function buildMatrix(host) {
1759
+ if (!host || host.querySelector(":scope > ." + MATRIX_CLASS)) return;
1760
+ const matrix = document.createElement("div");
1761
+ matrix.className = MATRIX_CLASS;
1762
+ matrix.dataset.variant = "scan";
1763
+ matrix.setAttribute("aria-hidden", "true");
1764
+ for (let index = 0; index < 16; index++) {
1765
+ const dot = document.createElement("i");
1766
+ dot.style.setProperty("--d", String((index % 4) * 120));
1767
+ matrix.appendChild(dot);
1768
+ }
1769
+ host.appendChild(matrix);
1770
+ }
1771
+
1772
+ function syncStatusTrackers(root = document) {
1773
+ const trackers = root.querySelectorAll ? root.querySelectorAll('[data-testid="status-tracker"]') : [];
1774
+ trackers.forEach((tracker) => {
1775
+ const active = Boolean(tracker.querySelector(".loading") || tracker.classList.contains("generating"));
1776
+ if (active) {
1777
+ tracker.classList.add("matrix-loading");
1778
+ buildMatrix(tracker);
1779
+ } else {
1780
+ tracker.classList.remove("matrix-loading");
1781
+ const matrix = tracker.querySelector(":scope > ." + MATRIX_CLASS);
1782
+ if (matrix) matrix.remove();
1783
+ }
1784
+ });
1785
+ }
1786
+
1787
+ function syncStandaloneSpinners(root = document) {
1788
+ if (!root.querySelectorAll) return;
1789
+ root.querySelectorAll('.loading-spinner, .spinner:not(.spinner-button)').forEach((spinner) => {
1790
+ if (spinner.closest("." + MATRIX_CLASS)) return;
1791
+ spinner.classList.add("matrix-spinner-host");
1792
+ buildMatrix(spinner);
1793
+ });
1794
+ }
1795
+
1796
+ function syncAll() {
1797
+ syncStatusTrackers(document);
1798
+ syncStandaloneSpinners(document);
1799
+ }
1800
+
1801
+ syncAll();
1802
+ if (window.__railDelayMatrixLoaderObserver) {
1803
+ try { window.__railDelayMatrixLoaderObserver.disconnect(); } catch (_) {}
1804
+ }
1805
+ const observer = new MutationObserver(syncAll);
1806
+ observer.observe(document.body, {
1807
+ childList: true,
1808
+ subtree: true,
1809
+ attributes: true,
1810
+ attributeFilter: ["class", "data-testid"]
1811
+ });
1812
+ window.__railDelayMatrixLoaderObserver = observer;
1813
+ return [];
1814
+ }
1815
+ """
1816
 
 
1817
 
1818
+ try:
1819
+ HORT_THEME = gr.themes.Soft(
1820
+ primary_hue="violet",
1821
+ secondary_hue="blue",
1822
+ neutral_hue="slate",
1823
+ radius_size="md",
1824
+ spacing_size="md",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1825
  )
1826
+ except Exception:
1827
+ HORT_THEME = None
1828
+
1829
+ def make_exact_file_input():
1830
+ """Render label, file picker, and helper text on one horizontal line."""
1831
+ with gr.Row(elem_classes=["input-file-example"]):
1832
+ gr.HTML(
1833
+ '<label class="input-file-label">Upload a file</label>',
1834
+ elem_classes=["input-file-label-wrap"]
1835
+ )
1836
+ file_component = gr.File(
1837
+ label="Upload a file",
1838
+ show_label=False,
1839
+ file_types=[".csv", ".xlsx", ".xls"],
1840
+ height=36,
1841
+ elem_classes=["input-file-control"]
1842
+ )
1843
+ gr.HTML(
1844
+ '<p class="input-file-help">You are only allowed to upload .CSV, .XLSX or .XLS files.</p>',
1845
+ elem_classes=["input-file-help-wrap"]
1846
+ )
1847
+ return file_component
1848
+
1849
+
1850
+ with gr.Blocks(title="Rail Delay Lab", theme=HORT_THEME, css=CUSTOM_CSS) as demo:
1851
+ gr.HTML("""
1852
+ <header class="app-topbar">
1853
+ <div class="app-brand">
1854
+ <div class="app-mark">R</div>
1855
+ <div class="app-title">Rail Delay Lab</div>
1856
+ </div>
1857
+ <div class="app-badge">Interactive railway simulator</div>
1858
+ </header>
1859
+ <section class="workspace-hero">
1860
+ <div class="workspace-kicker">Railway capacity workspace</div>
1861
+ <div class="workspace-title">Plan, simulate and understand railway delay propagation.</div>
1862
+ <div class="workspace-subtitle">Configure infrastructure, train requests and delay scenarios, then inspect conflict resolution, space-time behaviour and the capacity–reliability trade-off in one clean workspace.</div>
1863
+ </section>
1864
+ """)
1865
+
1866
+ with gr.Tabs(elem_classes=["hort-main-tabs"]):
1867
+ with gr.TabItem("Delay Propagation and Conflict Resolution"):
1868
+ gr.Markdown("## Delay Propagation and Conflict Resolution")
1869
+ gr.Markdown("Upload or edit the input tables, configure the scenario, then run the simulation.")
1870
+
1871
+ with gr.Row():
1872
+ with gr.Column(scale=5):
1873
+ with gr.Group(elem_classes=["panel-card"]):
1874
+ gr.Markdown("### Infrastructure")
1875
+ file_infra = make_exact_file_input()
1876
+ t_stations = gr.Dataframe(value=d_stations, label="Stations Data", interactive=True, max_height=560, wrap=True, show_row_numbers=True, buttons=[])
1877
+ file_infra.upload(fn=load_csv, inputs=file_infra, outputs=t_stations)
1878
+ txt_spacing = gr.Number(value=5.0, label="Min Safety Spacing (km)", step=0.1)
1879
+
1880
+ with gr.Group(elem_classes=["panel-card"]):
1881
+ gr.Markdown("### Delay injection")
1882
+ file_delays = make_exact_file_input()
1883
+ t_delays = gr.Dataframe(value=d_delays, label="Delay Data", interactive=True, max_height=520, wrap=True, show_row_numbers=True, buttons=[])
1884
+ file_delays.upload(fn=load_csv, inputs=file_delays, outputs=t_delays)
1885
+
1886
+ main_delay_mode = gr.Radio(
1887
+ choices=["Use Uploaded/Inserted Delay", "Randomized (Monte Carlo)"],
1888
+ value="Use Uploaded/Inserted Delay",
1889
+ label="Simulation Delay Mode",
1890
+ elem_classes=["hort-radio"]
1891
+ )
1892
+ with gr.Row():
1893
+ main_rand_prob = gr.Slider(0, 100, value=25, step=5, label="Delay Probability (%)")
1894
+ main_min_delay = gr.Number(value=5, label="Min Delay (min)")
1895
+ main_max_delay = gr.Number(value=15, label="Max Delay (min)")
1896
+
1897
+ with gr.Group(elem_classes=["panel-card"]):
1898
+ gr.Markdown("### View & optimization")
1899
+ with gr.Row(elem_classes=["selector-strip"]):
1900
+ dd_view = gr.Dropdown(
1901
+ choices=["All Scenarios", "Nominal Only", "Conflicted Only", "Resolved Only"],
1902
+ value="All Scenarios",
1903
+ label="Select View",
1904
+ interactive=True,
1905
+ filterable=False,
1906
+ allow_custom_value=False,
1907
+ elem_id="view_listbox",
1908
+ elem_classes=["hort-listbox", "hort-view-select"]
1909
+ )
1910
+ dd_selected_train = gr.Dropdown(
1911
+ choices=["None", "101", "102"],
1912
+ value="None",
1913
+ label="Select Train",
1914
+ interactive=True,
1915
+ filterable=False,
1916
+ allow_custom_value=False,
1917
+ elem_id="train_listbox",
1918
+ elem_classes=["hort-listbox", "hort-train-select"]
1919
+ )
1920
+ txt_max_buffer = gr.Number(value=15.0, label="Max Buffer Cap (min)", step=1.0)
1921
+
1922
+ with gr.Column(scale=7):
1923
+ with gr.Group(elem_classes=["panel-card"]):
1924
+ gr.Markdown("### Train requests")
1925
+ file_trains = make_exact_file_input()
1926
+ t_trains = gr.Dataframe(value=d_trains, label="Train Requests Data", interactive=True, max_height=680, wrap=True, show_row_numbers=True, buttons=[])
1927
+ file_trains.upload(fn=load_train_csv, inputs=file_trains, outputs=[t_trains, dd_selected_train])
1928
+ btn = gr.Button("Simulate", variant="primary", size="lg")
1929
+
1930
+ with gr.Group(elem_classes=["chart-card"]):
1931
+ gr.Markdown("### Operational space-time diagram")
1932
+ plot = gr.Plot(label="Marey Diagram")
1933
+
1934
+ with gr.Group(elem_classes=["results-card"]):
1935
+ gr.Markdown("### Results")
1936
+ with gr.Tabs():
1937
+ with gr.TabItem("Resolved Timetable"):
1938
+ out_adj = gr.Dataframe(max_height=620, wrap=True, show_row_numbers=True, buttons=[])
1939
+ with gr.TabItem("Nominal Timetable"):
1940
+ out_nom = gr.Dataframe(max_height=620, wrap=True, show_row_numbers=True, buttons=[])
1941
+ with gr.TabItem("Delay Breakdown"):
1942
+ out_stats = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[])
1943
+ with gr.TabItem("Buffer Report"):
1944
+ out_buffer = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[])
1945
+ with gr.TabItem("Conflict Logs"):
1946
+ out_logs = gr.Dataframe(max_height=460, wrap=True, show_row_numbers=True, buttons=[])
1947
+
1948
+ with gr.TabItem("Economic Capacity Analysis"):
1949
+ gr.Markdown("## Economic Capacity Analysis")
1950
+ gr.Markdown("Evaluate capacity, secondary delay, TAC revenue, penalties, and the operating sweet spot.")
1951
+
1952
+ with gr.Row():
1953
+ with gr.Column(scale=5):
1954
+ with gr.Group(elem_classes=["panel-card"]):
1955
+ gr.Markdown("### Infrastructure & delays")
1956
+ econ_file_infra = make_exact_file_input()
1957
+ econ_t_stations = gr.Dataframe(value=d_stations, label="Stations Data", interactive=True, max_height=560, wrap=True, show_row_numbers=True, buttons=[])
1958
+ econ_file_infra.upload(fn=load_csv, inputs=econ_file_infra, outputs=econ_t_stations)
1959
+ econ_txt_spacing = gr.Number(value=5.0, label="Min Safety Spacing (km)", step=0.1)
1960
+
1961
+ econ_file_delays = make_exact_file_input()
1962
+ econ_t_delays = gr.Dataframe(value=d_delays, label="Delay Data", interactive=True, max_height=520, wrap=True, show_row_numbers=True, buttons=[])
1963
+ econ_file_delays.upload(fn=load_csv, inputs=econ_file_delays, outputs=econ_t_delays)
1964
+
1965
+ with gr.Group(elem_classes=["panel-card"]):
1966
+ gr.Markdown("### Delay strategy")
1967
+ delay_mode = gr.Radio(
1968
+ choices=["Predefined (from Delays Tab)", "Randomized"],
1969
+ value="Predefined (from Delays Tab)",
1970
+ label="Delay Mode",
1971
+ elem_classes=["hort-radio"]
1972
+ )
1973
+ with gr.Row():
1974
+ rand_prob = gr.Slider(0, 100, value=25, step=5, label="Probability of Delay per Train (%)")
1975
+ min_delay = gr.Number(value=5, label="Min Random Delay (min)")
1976
+ max_delay = gr.Number(value=15, label="Max Random Delay (min)")
1977
+
1978
+ with gr.Group(elem_classes=["panel-card"]):
1979
+ gr.Markdown("### Delay responsibility")
1980
+ gr.Markdown("_Sets, **before** the sweet-spot search runs, what share of every primary delay is attributed to the Infrastructure Manager (IM) vs the Railway Undertaking (RU) - applied the same way whether the delay is Predefined or Randomized above. IM-caused delays are a penalty for IM; RU-caused delays are income for IM. Secondary delays always stay a cost for IM (PR Penalty)._")
1981
+ im_resp_share = gr.Slider(0, 100, value=50, step=5, label="IM Responsibility Share for Primary Delays (%)")
1982
+
1983
+ with gr.Column(scale=7):
1984
+ with gr.Group(elem_classes=["panel-card"]):
1985
+ gr.Markdown("### Fleet mix")
1986
+ t_fleet = gr.Dataframe(
1987
+ headers=["Type Name", "Speed (km/h)", "Dwell (min)", "Priority"],
1988
+ value=[["High-Speed", 130, 2, 1], ["Regional", 100, 4, 2], ["Freight", 80, 5, 3]],
1989
+ interactive=True,
1990
+ label="Fleet Composition",
1991
+ max_height=420,
1992
+ wrap=True,
1993
+ show_row_numbers=True,
1994
+ buttons=[]
1995
+ )
1996
+
1997
+ with gr.Group(elem_classes=["panel-card"]):
1998
+ gr.Markdown("### Economic parameters")
1999
+ with gr.Row():
2000
+ max_trains_in = gr.Number(value=25, label="Max Trains to Evaluate")
2001
+ num_tac = gr.Number(value=115.84, label="TAC Fee (€ per train)")
2002
+ num_pr = gr.Number(value=7.09, label="PR Penalty (€ per min)")
2003
+ btn_econ = gr.Button("Calculate Sweet Spot", variant="primary", size="lg")
2004
+
2005
+ with gr.Group(elem_classes=["results-card"]):
2006
+ gr.Markdown("### Capacity analysis")
2007
+ with gr.Row():
2008
+ out_econ_plot = gr.Plot(label="Profit Curve")
2009
+ out_best_marey = gr.Plot(label="Optimal Timetable (Space-Time Diagram)")
2010
+ with gr.Row():
2011
+ out_econ_df = gr.Dataframe(label="Raw Economic Data", max_height=620, wrap=True, show_row_numbers=True, buttons=[])
2012
+ out_best_timetable = gr.Dataframe(label="Optimal Timetable (Data)", max_height=620, wrap=True, show_row_numbers=True, buttons=[])
2013
+ gr.Markdown("### Primary delays in the best scenario")
2014
+ out_econ_delays = gr.Dataframe(label="Primary Delays & Responsibility (Best Scenario)", max_height=460, wrap=True, show_row_numbers=True, buttons=[])
2015
+
2016
+ gr.HTML("<div class='footer-note'>HoRT RailCap Lab · Educational railway capacity and reliability simulator</div>")
2017
+
2018
+ demo.load(fn=None, js=MATRIX_LOADER_JS, queue=False, show_progress="hidden")
2019
+
2020
+ t_trains.change(fn=get_train_choices, inputs=[t_trains], outputs=[dd_selected_train])
2021
+
2022
+ btn.click(
2023
+ fn=run_simulation,
2024
+ inputs=[
2025
+ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer,
2026
+ main_delay_mode, main_rand_prob, main_min_delay, main_max_delay
2027
+ ],
2028
+ outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot]
2029
+ )
2030
+
2031
+ dd_view.change(
2032
+ fn=run_simulation,
2033
+ inputs=[
2034
+ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer,
2035
+ main_delay_mode, main_rand_prob, main_min_delay, main_max_delay
2036
+ ],
2037
+ outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot]
2038
+ )
2039
+
2040
+ dd_selected_train.change(
2041
+ fn=run_simulation,
2042
+ inputs=[
2043
+ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer,
2044
+ main_delay_mode, main_rand_prob, main_min_delay, main_max_delay
2045
+ ],
2046
+ outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot]
2047
+ )
2048
+
2049
+ txt_max_buffer.change(
2050
+ fn=run_simulation,
2051
+ inputs=[
2052
+ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer,
2053
+ main_delay_mode, main_rand_prob, main_min_delay, main_max_delay
2054
+ ],
2055
+ outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot]
2056
+ )
2057
+
2058
+ main_delay_mode.change(
2059
+ fn=run_simulation,
2060
+ inputs=[
2061
+ t_stations, t_trains, t_delays, txt_spacing, dd_view, dd_selected_train, txt_max_buffer,
2062
+ main_delay_mode, main_rand_prob, main_min_delay, main_max_delay
2063
+ ],
2064
+ outputs=[out_nom, out_adj, out_stats, out_buffer, out_logs, plot]
2065
+ )
2066
+
2067
+ btn_econ.click(
2068
+ fn=run_economic_analysis,
2069
+ inputs=[
2070
+ econ_t_stations, t_fleet, econ_t_delays, econ_txt_spacing, max_trains_in,
2071
+ num_tac, num_pr, delay_mode, rand_prob, min_delay, max_delay, im_resp_share
2072
+ ],
2073
+ outputs=[out_econ_df, out_econ_plot, out_best_timetable, out_best_marey, out_econ_delays]
2074
  )
 
 
 
 
 
 
 
 
 
 
2075
 
2076
+ # Launch the app. Gradio 6 moved css/theme from Blocks() to launch(), while
2077
+ # Gradio 4/5 accepts them in Blocks(). This keeps the visual theme working on
2078
+ # both current and older Hugging Face Spaces runtimes.
2079
  if __name__ == "__main__":
2080
+ import inspect
2081
+ launch_kwargs = {"debug": False}
2082
+ if "css" in inspect.signature(demo.launch).parameters:
2083
+ launch_kwargs.update({"css": CUSTOM_CSS, "theme": HORT_THEME})
2084
+ demo.launch(**launch_kwargs)