Esvanth commited on
Commit
cf9af78
·
1 Parent(s): a061693

Fix animation: bind slider directly to session state, simplify UI, remove clutter

Browse files
Files changed (1) hide show
  1. app.py +610 -925
app.py CHANGED
@@ -1,10 +1,9 @@
1
  """
2
  EcoCart AI System — Streamlit App
3
- Runs the actual task scripts and displays their real outputs.
4
  NCI MSCAI | Fundamentals of AI TABA 2026
5
  """
6
 
7
- import sys, io, os, math, heapq, time, importlib.util
8
  from contextlib import redirect_stdout
9
  from collections import deque
10
 
@@ -22,9 +21,8 @@ import plotly.graph_objects as go
22
  from plotly.subplots import make_subplots
23
 
24
  # ══════════════════════════════════════════════════════════════════════════════
25
- # REPORT GENERATOR
26
  # ══════════════════════════════════════════════════════════════════════════════
27
-
28
  def _build_report(t2_text, t3_text, t5_text):
29
  TNR = "Times New Roman"
30
  doc = Document()
@@ -37,7 +35,6 @@ def _build_report(t2_text, t3_text, t5_text):
37
  s.font.color.rgb = RGBColor(0,0,0)
38
  s.paragraph_format.space_before = Pt(12)
39
  s.paragraph_format.space_after = Pt(4)
40
-
41
  def H(txt, lv=1):
42
  p = doc.add_heading(txt, level=lv); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
43
  for r in p.runs: r.font.name=TNR; r.font.color.rgb=RGBColor(0,0,0)
@@ -57,7 +54,6 @@ def _build_report(t2_text, t3_text, t5_text):
57
  cp.paragraph_format.space_after = Pt(8)
58
  for r in cp.runs: r.font.name=TNR; r.font.size=Pt(10); r.font.italic=True
59
  def SP(): doc.add_paragraph("").paragraph_format.space_after = Pt(4)
60
-
61
  SP(); SP()
62
  p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER
63
  r = p.add_run("EcoCart AI System"); r.font.name=TNR; r.font.size=Pt(24); r.font.bold=True
@@ -76,218 +72,132 @@ def _build_report(t2_text, t3_text, t5_text):
76
  lr2.font.name=TNR; lr2.font.size=Pt(11); lr2.font.bold=True
77
  lr2.font.color.rgb = RGBColor(37,99,235)
78
  doc.add_page_break()
79
-
80
  H("Task 2 — Customer Segmentation & Bias Mitigation")
81
  P("Running task2_segmentation.py produced the following output:")
82
  if t2_text: CODE(t2_text)
83
- SP()
84
- IMG("output/bias_before_after.png","Figure 1: Customer clusters before and after bias mitigation")
85
- SP()
86
- IMG("output/disparate_impact.png","Figure 2: Disparate Impact and High Value rates before and after fix")
87
- SP()
88
- P("Before the fix: 0.0% of rural customers were in High Value (DI = 0.0 — biased). "
89
- "After the fix: 57.3% of rural customers are in High Value (DI = 0.847 — fair, above 0.80 threshold).")
90
  doc.add_page_break()
91
-
92
  H("Tasks 3 & 4 — Route Optimisation and Algorithm Comparison")
93
  P("Running task3_4_routing.py produced the following output:")
94
  if t3_text: CODE(t3_text)
95
- SP()
96
- IMG("output/network_map.png","Figure 3: EcoCart 20-node delivery network")
97
- SP()
98
- IMG("output/algo_comparison.png","Figure 4: A* vs IDA* comparison across urban and rural routes")
99
- SP()
100
- IMG("output/green_vs_fast.png","Figure 5: Fastest route vs lowest CO2 route comparison")
101
- SP()
102
- P("A* found the optimal path on every route with the fewest nodes expanded. "
103
- "DFS was the only algorithm that did not find the shortest path. "
104
- "Both A* and IDA* found identical optimal paths — A* is faster on small graphs, "
105
- "IDA* uses less memory and is better suited for large-scale networks.")
106
  doc.add_page_break()
107
-
108
  H("Task 5 — Demand Forecasting with Machine Learning")
109
  P("Running task5_forecasting.py produced the following output:")
110
  if t5_text: CODE(t5_text)
111
- SP()
112
- IMG("output/forecast.png","Figure 6: Actual vs predicted daily sales on the 140-day test set")
113
- SP()
114
- IMG("output/residuals.png","Figure 7: Residuals for Linear Regression and Random Forest")
115
- SP()
116
- IMG("output/feature_importance.png","Figure 8: Random Forest feature importance — lag_7 is the strongest predictor")
117
- SP()
118
- P("Linear Regression: MAE=9.62, RMSE=12.38, R2=0.762, MAPE=9.41%. "
119
- "Random Forest: MAE=9.75, RMSE=13.50, R2=0.716, MAPE=9.43%. "
120
- "Linear Regression performed slightly better on this dataset. "
121
- "The top predictors were lag_7 (same weekday last week), lag_14, and is_promo.")
122
  doc.add_page_break()
123
-
124
  H("References")
125
- refs = [
126
- "[1] S. Russell and P. Norvig, Artificial Intelligence: A Modern Approach, 4th ed. Hoboken, NJ: Pearson, 2020.",
127
- "[2] F. Pedregosa et al., \"Scikit-learn: Machine Learning in Python,\" JMLR, vol. 12, pp. 2825-2830, 2011.",
128
- "[3] M. Feldman et al., \"Certifying and Removing Disparate Impact,\" in Proc. ACM SIGKDD, 2015.",
129
- "[4] P. E. Hart, N. J. Nilsson, and B. Raphael, \"A Formal Basis for the Heuristic Determination of "
130
- "Minimum Cost Paths,\" IEEE Trans. Syst. Sci. Cybern., vol. 4, no. 2, pp. 100-107, 1968.",
131
- ]
132
- for ref in refs:
133
  p = doc.add_paragraph(ref)
134
  p.paragraph_format.space_after = Pt(5)
135
  p.paragraph_format.left_indent = Inches(0.3)
136
  p.paragraph_format.first_line_indent = Inches(-0.3)
137
  for r in p.runs: r.font.name=TNR; r.font.size=Pt(11)
138
-
139
  buf = io.BytesIO(); doc.save(buf); buf.seek(0)
140
  return buf
141
 
142
  # ══════════════════════════════════════════════════════════════════════════════
143
- # PAGE CONFIG & DESIGN TOKENS
144
  # ══════════════════════════════════════════════════════════════════════════════
145
-
146
- st.set_page_config(page_title="EcoCart AI System", page_icon="🛒",
147
  layout="wide", initial_sidebar_state="expanded")
148
 
149
- NAVY = "#0f172a"
150
- SLATE = "#1e293b"
151
- MUTED = "#64748b"
152
- BORDER = "#e2e8f0"
153
- SURF = "#ffffff"
154
- BG = "#f8fafc"
155
- GREEN = "#059669"
156
- BLUE = "#2563eb"
157
- AMBER = "#d97706"
158
- RED = "#dc2626"
159
-
160
- def _rgba(hex_c, a=1.0):
161
- h = hex_c.lstrip("#")
162
- r, g, b = int(h[0:2],16), int(h[2:4],16), int(h[4:6],16)
163
  return f"rgba({r},{g},{b},{a})"
164
 
165
- def _ch(h=400, title=""):
166
  return dict(height=h, paper_bgcolor=SURF, plot_bgcolor=BG,
167
- font=dict(color=SLATE, size=11, family="system-ui,-apple-system,sans-serif"),
168
- title=dict(text=title, font=dict(size=13, color=SLATE), x=0),
169
- margin=dict(l=10, r=10, t=44, b=10),
170
- legend=dict(bgcolor=SURF, bordercolor=BORDER, borderwidth=1))
171
 
172
- # ── global CSS ────────────────────────────────────────────────────────────────
173
  st.markdown("""
174
  <style>
175
- /* Layout */
176
  [data-testid="stAppViewContainer"] { background:#f1f5f9; }
177
- .block-container { padding:1.5rem 2rem 4rem; max-width:1400px; }
178
 
179
  /* Tabs */
180
  .stTabs [data-baseweb="tab-list"] {
181
- background:#fff; border-radius:12px; padding:4px;
182
- box-shadow:0 2px 8px rgba(0,0,0,.06); border:1px solid #e2e8f0; gap:2px;
183
  }
184
  .stTabs [data-baseweb="tab"] {
185
- border-radius:8px; font-size:.84rem; font-weight:600;
186
- padding:8px 16px; color:#64748b;
187
  }
188
- .stTabs [aria-selected="true"] { background:#0f172a !important; color:#f8fafc !important; }
189
 
190
  /* Cards */
191
  .card {
192
- background:#fff; border-radius:12px; padding:18px 22px;
193
- box-shadow:0 1px 5px rgba(0,0,0,.06); border:1px solid #f1f5f9;
194
- margin-bottom:14px;
195
- }
196
- .card-green { border-left:4px solid #059669; }
197
- .card-blue { border-left:4px solid #2563eb; }
198
- .card-amber { border-left:4px solid #d97706; }
199
- .card-navy { border-left:4px solid #0f172a; }
200
- .card-purple { border-left:4px solid #7c3aed; }
201
-
202
- /* Step header */
203
- .step-hd { display:flex; align-items:center; font-size:1rem; font-weight:700;
204
- color:#0f172a; margin-bottom:8px; }
205
- .badge {
206
- display:inline-flex; align-items:center; justify-content:center;
207
- background:#0f172a; color:#fff; width:26px; height:26px; border-radius:50%;
208
- font-size:.78rem; font-weight:800; margin-right:10px; flex-shrink:0;
209
  }
210
- .badge-q { background:#475569; }
 
 
 
211
 
212
  /* Terminal */
213
- .term-wrap { border-radius:10px; overflow:hidden; border:1px solid #1e293b; margin:8px 0; }
214
- .term-bar { background:#1e293b; padding:8px 14px; display:flex; gap:6px; align-items:center; }
215
- .td { width:10px; height:10px; border-radius:50%; }
216
- .td-r{background:#ef4444;} .td-y{background:#f59e0b;} .td-g{background:#10b981;}
217
  .term-body {
218
- background:#0f172a; padding:14px 18px;
219
- font-family:'Courier New',monospace; font-size:.79rem;
220
- color:#94a3b8; white-space:pre-wrap; line-height:1.65;
221
- max-height:320px; overflow-y:auto;
222
  }
223
 
224
- /* Insight / warn / info */
225
  .insight {
226
  background:#ecfdf5; border-left:4px solid #059669;
227
- border-radius:0 10px 10px 0; padding:14px 18px;
228
- color:#064e3b; font-size:.875rem; line-height:1.65; margin:10px 0;
229
  }
230
- .warn-note {
231
  background:#fffbeb; border-left:4px solid #d97706;
232
- border-radius:0 10px 10px 0; padding:14px 18px;
233
- color:#78350f; font-size:.875rem; line-height:1.65; margin:10px 0;
234
- }
235
- .info-note {
236
- background:#eff6ff; border-left:4px solid #2563eb;
237
- border-radius:0 10px 10px 0; padding:14px 18px;
238
- color:#1e3a5f; font-size:.875rem; line-height:1.65; margin:10px 0;
239
  }
240
 
241
- /* Visit log */
242
- .vlog {
243
- background:#f8fafc; border-radius:10px;
244
- border:1px solid #e2e8f0; padding:10px 12px;
245
- max-height:480px; overflow-y:auto;
246
- }
247
- .vi {
248
- display:flex; align-items:center; gap:8px; padding:5px 2px;
249
- font-size:.79rem; border-bottom:1px dashed #f1f5f9;
250
- }
251
- .vi:last-child { border-bottom:none; }
252
- .vd { width:9px; height:9px; border-radius:50%; flex-shrink:0; }
253
- .vd-done { background:#059669; }
254
- .vd-current { background:#2563eb; box-shadow:0 0 7px #2563eb; }
255
- .vd-pending { background:#cbd5e1; }
256
-
257
- /* Legend */
258
- .lgd { display:flex; gap:14px; flex-wrap:wrap; margin:8px 0 2px; }
259
  .li { display:flex; align-items:center; gap:5px; font-size:.78rem; color:#475569; }
260
- .ld { width:11px; height:11px; border-radius:50%; }
 
 
 
 
 
 
261
 
262
  /* Metrics */
263
  div[data-testid="metric-container"] {
264
- background:#fff; border-radius:10px; padding:14px 18px;
265
- border:1px solid #e2e8f0; box-shadow:0 1px 3px rgba(0,0,0,.05);
266
  }
267
 
268
  /* Sidebar */
269
  [data-testid="stSidebar"] { background:#fff; border-right:1px solid #e2e8f0; }
270
- [data-testid="stSidebarContent"] { padding:1.2rem 1rem; }
271
-
272
- /* App header */
273
- .app-hdr {
274
- background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 100%);
275
- border-radius:14px; padding:22px 28px; margin-bottom:20px;
276
- }
277
- .app-hdr h1 { color:#f8fafc; margin:0 0 4px; font-size:1.55rem; font-weight:800; }
278
- .app-hdr p { color:#94a3b8; margin:0; font-size:.875rem; }
279
- .tag {
280
- display:inline-block; background:rgba(59,130,246,.25);
281
- color:#93c5fd; font-size:.7rem; font-weight:700;
282
- padding:2px 8px; border-radius:4px; margin-right:6px;
283
- border:1px solid rgba(59,130,246,.4);
284
- }
285
-
286
- /* Agent type mini-cards */
287
- .acard {
288
- border-radius:10px; padding:14px; border:2px solid #e2e8f0;
289
- background:#fff; transition:all .2s; margin-bottom:4px;
290
- }
291
  </style>
292
  """, unsafe_allow_html=True)
293
 
@@ -296,111 +206,96 @@ div[data-testid="metric-container"] {
296
  # ══════════════════════════════════════════════════════════════════════════════
297
  with st.sidebar:
298
  st.markdown("""
299
- <div style='text-align:center;margin-bottom:16px;'>
300
- <div style='font-size:2.2rem;'>🛒</div>
301
- <div style='font-size:1.1rem;font-weight:800;color:#0f172a;'>EcoCart AI</div>
302
- </div>
303
- """, unsafe_allow_html=True)
304
  st.divider()
305
 
306
- st.markdown("<div style='font-weight:700;font-size:.875rem;color:#0f172a;margin-bottom:8px;'>How to use</div>",
307
- unsafe_allow_html=True)
308
- for n, desc in [("1","Click a tab above"),("2","Read the context card"),
309
- ("3","Press Run (Tasks 2, 3, 5)"),("4","Watch the live output"),
310
- ("5","Read the insight panel")]:
311
- st.markdown(f"""
312
- <div style='display:flex;align-items:center;gap:8px;margin:5px 0;'>
313
- <div style='background:#0f172a;color:#fff;width:20px;height:20px;border-radius:50%;
314
- font-size:.7rem;font-weight:800;display:flex;align-items:center;
315
- justify-content:center;flex-shrink:0;'>{n}</div>
316
- <span style='font-size:.82rem;color:#1e293b;'>{desc}</span>
317
- </div>""", unsafe_allow_html=True)
318
-
319
  st.divider()
320
- st.markdown("<div style='font-weight:700;font-size:.875rem;color:#0f172a;margin-bottom:8px;'>Task status</div>",
321
- unsafe_allow_html=True)
322
  t2_done = st.session_state.get("t2_done", False)
323
  t3_done = st.session_state.get("t3_done", False)
324
  t5_done = st.session_state.get("t5_done", False)
325
- for label, done in [("Task 2 — Segmentation", t2_done),
326
- ("Task 3Routing", t3_done),
327
- ("Task 5Forecast", t5_done)]:
328
- icon = "✅" if done else "○"
329
- color = GREEN if done else "#94a3b8"
330
- st.markdown(f"<div style='color:{color};font-size:.82rem;margin:4px 0;'>{icon} {label}</div>",
 
331
  unsafe_allow_html=True)
332
 
333
- st.divider()
334
- st.caption("All outputs are generated by the actual task Python scripts.")
335
-
336
- t2_text = st.session_state.get("t2_text","")
337
- t3_text = st.session_state.get("t3_text","")
338
- t5_text = st.session_state.get("t5_text","")
339
  if t2_done or t3_done or t5_done:
340
- buf = _build_report(t2_text, t3_text, t5_text)
341
- st.download_button("⬇ Download Word Report", buf,
 
 
 
342
  file_name="EcoCart_Report.docx",
343
  mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
344
  use_container_width=True)
 
 
345
 
346
- # ── app header ────────────────────────────────────────────────────────────────
347
  st.markdown("""
348
- <div class='app-hdr'>
349
- <div style='display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:12px;'>
350
- <div>
351
- <h1>EcoCart AI System</h1>
352
- <p>Route optimisation &nbsp;·&nbsp; Customer segmentation &nbsp;·&nbsp; Demand forecasting</p>
353
- </div>
354
  </div>
355
- </div>
356
- """, unsafe_allow_html=True)
357
 
358
  T1, T2, T3, T4, T5, T6 = st.tabs([
359
- "🤖 Task 1 — AI Agents",
360
- "⚖️ Task 2 — Bias",
361
- "🗺️ Task 3 — Routes",
362
- "📊 Task 4 — A* vs IDA*",
363
- "📈 Task 5 — Forecast",
364
- "💼 Task 6 — Business",
365
  ])
366
 
367
  # ══════════════════════════════════════════════════════════════════════════════
368
- # TASK 1 — AI AGENTS (animated delivery simulation)
369
  # ══════════════════════════════════════════════════════════════════════════════
370
  with T1:
371
- # ── context ───────────────────────────────────────────────────────────────
372
  st.markdown("""
373
  <div class='card card-navy'>
374
- <div class='step-hd'><div class='badge badge-q'>?</div>What is this?</div>
375
- <p style='margin:0;color:#475569;font-size:.875rem;line-height:1.65;'>
376
- An <strong>AI agent</strong> perceives its environment and takes actions to achieve a goal.
377
- EcoCart uses agents to plan delivery routes, forecast demand, and segment customers.<br><br>
378
- This simulation shows <strong>three different agent types</strong> navigating the same
379
- 9-stop delivery map. Each one makes decisions differently — compare them to see the trade-offs.
380
- </p>
381
- </div>
382
- """, unsafe_allow_html=True)
383
-
384
- # ── data ──────────────────────────────────────────────────────────────────
385
  STOPS = {
386
- "Depot": (0.0, 0.0, 0), "Shop A": (2.0, 3.0, 3), "Shop B": (5.0, 1.0, 4),
387
- "Shop C": (7.0, 4.0, 2), "Shop D": (3.0, 6.0, 5), "Shop E": (8.0, 7.0, 1),
388
- "Shop F": (1.0, 8.0, 3), "Shop G": (6.0, 9.0, 4), "Shop H": (9.0, 2.0, 2),
389
  }
390
- def _sd(a, b):
391
- ax,ay,_ = STOPS[a]; bx,by,_ = STOPS[b]
392
- return math.hypot(ax-bx, ay-by)
393
 
394
  @st.cache_data
395
  def _get_routes():
396
  def reactive():
397
  r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; c="Depot"
398
  while u:
399
- nb=min(u, key=lambda n: _sd(c,n)); r.append(nb); u.remove(nb); c=nb
400
  return r+["Depot"]
401
  def goal():
402
  r=reactive()[:-1]
403
- td=lambda x: sum(_sd(x[i],x[i+1]) for i in range(len(x)-1))+_sd(x[-1],x[0])
404
  ok=True
405
  while ok:
406
  ok=False
@@ -412,258 +307,196 @@ with T1:
412
  def utility():
413
  r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; c="Depot"
414
  while u:
415
- nb=max(u, key=lambda n: STOPS[n][2]/(_sd(c,n)+.1))
416
  r.append(nb); u.remove(nb); c=nb
417
  return r+["Depot"]
418
- return {
419
- "Reactive Agent": reactive(),
420
- "Goal-Based Agent": goal(),
421
- "Utility-Based Agent": utility(),
422
- }
423
 
424
  ROUTES = _get_routes()
425
- RCOLS = {"Reactive Agent": BLUE, "Goal-Based Agent": GREEN, "Utility-Based Agent": AMBER}
426
  RDESC = {
427
- "Reactive Agent":
428
- ("Goes to the nearest unvisited stop every time.",
429
- "Simple and fast to compute, but often creates a longer total route."),
430
- "Goal-Based Agent":
431
- ("Plans the entire route before moving using 2-opt optimisation.",
432
- "Finds the shortest total distance — the gold standard for route planning."),
433
- "Utility-Based Agent":
434
- ("Scores each stop as urgency ÷ distance and visits the highest scorer first.",
435
- "Reaches high-priority (★) stops early — useful when some deliveries are time-critical."),
436
  }
437
 
438
- # ── step 1: choose agent ──────────────────────────────────────────────────
439
- st.markdown("""
440
- <div class='step-hd' style='margin-top:4px;'><div class='badge'>1</div>
441
- Choose an agent type each one makes decisions differently
442
- </div>""", unsafe_allow_html=True)
443
-
444
- c1, c2, c3 = st.columns(3)
445
- for col, (name, col_hex) in zip([c1,c2,c3], RCOLS.items()):
446
- short, long_desc = RDESC[name]
447
- is_sel = st.session_state.get("_ag", "Reactive Agent") == name
448
- border = f"2px solid {col_hex}" if is_sel else "2px solid #e2e8f0"
449
- bg = _rgba(col_hex, 0.06) if is_sel else "#fff"
450
  with col:
 
 
451
  st.markdown(f"""
452
- <div style='border-radius:10px;padding:14px 16px;border:{border};
453
- background:{bg};margin-bottom:6px;'>
454
- <div style='font-weight:800;font-size:.9rem;color:{col_hex};margin-bottom:4px;'>
455
- {name}
456
- </div>
457
- <div style='font-size:.8rem;color:#0f172a;font-weight:600;margin-bottom:3px;'>{short}</div>
458
- <div style='font-size:.76rem;color:#64748b;line-height:1.45;'>{long_desc}</div>
459
  </div>""", unsafe_allow_html=True)
460
- if st.button(f"Select {name.split()[0]}", key=f"sel_{name[:5]}",
461
- use_container_width=True,
462
- type="primary" if is_sel else "secondary"):
463
- st.session_state["_ag"] = name
464
- st.session_state["stp"] = 0
465
- st.session_state["playing"] = False
 
466
  st.rerun()
467
 
468
- agent = st.session_state.get("_ag", "Reactive Agent")
 
469
  if agent not in RCOLS:
470
- agent = "Reactive Agent"
471
- st.session_state["_ag"] = agent
472
- ac = RCOLS[agent]
473
- route = ROUTES[agent]
474
- mx = len(route) - 1
475
 
476
  if st.session_state.get("_ag_prev") != agent:
477
  st.session_state["_ag_prev"] = agent
478
- st.session_state["stp"] = 0
479
- st.session_state["playing"] = False
480
-
481
- stp = st.session_state.get("stp", 0)
482
 
483
- # ── step 2: controls ──────────────────────────────────────────────────────
484
- st.markdown("""
485
- <div class='step-hd' style='margin-top:14px;'><div class='badge'>2</div>
486
- Control the animation — press Play or step through manually
487
- </div>""", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
- r0, r1, r2, r3, r4 = st.columns([1.1, 1.1, 1.1, 1.8, 3.5])
490
- if r0.button("⏮ Reset", use_container_width=True):
491
- stp = 0; st.session_state["playing"] = False
492
- if r1.button("◀ Back", use_container_width=True) and stp > 0:
493
- stp -= 1; st.session_state["playing"] = False
494
- if r2.button("Next ", use_container_width=True) and stp < mx:
495
- stp += 1; st.session_state["playing"] = False
496
- playing = st.session_state.get("playing", False)
497
- if r3.button("⏸ Pause" if playing else "▶ Play animation",
498
- type="primary", use_container_width=True):
499
- st.session_state["playing"] = not playing
500
- speed = r4.slider("Speed", 1, 8, 3, format="%dx", label_visibility="collapsed")
501
-
502
- stp = st.slider("Step through the route", 0, mx, stp,
503
- key="stp_slider",
504
- help="Drag to jump to any step in the delivery sequence")
505
- st.session_state["stp"] = stp
506
-
507
- path_so_far = route[:stp+1]
508
- visited = set(route[:stp+1])
509
- km_done = sum(_sd(path_so_far[i], path_so_far[i+1]) for i in range(len(path_so_far)-1))
510
- total_km = sum(_sd(route[i], route[i+1]) for i in range(len(route)-1))
511
-
512
- prog_pct = stp / mx if mx > 0 else 0
513
- st.progress(prog_pct,
514
- text=f"Progress: stop {stp} of {mx} · {km_done:.1f} km done · {total_km-km_done:.1f} km remaining")
515
-
516
- # ── map + visit log ───────────────────────────────────────────────────────
517
- map_col, log_col = st.columns([3, 1])
518
-
519
- with map_col:
520
- fig = go.Figure()
521
-
522
- # Background edges
523
- for na in STOPS:
524
- for nb in STOPS:
525
- if na >= nb: continue
526
- x1,y1,_ = STOPS[na]; x2,y2,_ = STOPS[nb]
527
- if math.hypot(x1-x2,y1-y2) < 5.5:
528
- fig.add_trace(go.Scatter(x=[x1,x2,None], y=[y1,y2,None], mode="lines",
529
- line=dict(color="#dde6f0", width=1.5), showlegend=False, hoverinfo="skip"))
530
-
531
- # Path shadow + path
532
- if len(path_so_far) > 1:
533
- px = [STOPS[n][0] for n in path_so_far]
534
- py = [STOPS[n][1] for n in path_so_far]
535
- fig.add_trace(go.Scatter(x=px, y=py, mode="lines",
536
- line=dict(color=_rgba(ac, 0.15), width=14),
537
- showlegend=False, hoverinfo="skip"))
538
- fig.add_trace(go.Scatter(x=px, y=py, mode="lines",
539
- line=dict(color=ac, width=3.5),
540
- showlegend=False, hoverinfo="skip"))
541
-
542
- # Unvisited nodes
543
- for name, (nx, ny, pri) in STOPS.items():
544
- if name == "Depot" or name in visited: continue
545
- star = "★ " if pri >= 4 else ""
546
- fig.add_trace(go.Scatter(x=[nx], y=[ny], mode="markers+text",
547
- marker=dict(size=22, color="#f1f5f9", line=dict(color="#94a3b8", width=2)),
548
- text=[star + name.replace("Shop ","")],
549
- textposition="top center", textfont=dict(size=9, color="#64748b"),
550
- showlegend=False,
551
- hovertemplate=f"<b>{name}</b><br>Priority {pri}/5<br>Not yet visited<extra></extra>"))
552
-
553
- # Visited nodes — show visit-order number inside
554
- for i, name in enumerate(path_so_far):
555
- if name == "Depot": continue
556
- if name == route[stp] and stp > 0: continue
557
- nx, ny, pri = STOPS[name]
558
- fig.add_trace(go.Scatter(x=[nx], y=[ny], mode="markers+text",
559
- marker=dict(size=28, color=GREEN, line=dict(color="#fff", width=2.5)),
560
- text=[str(i)],
561
- textposition="middle center",
562
- textfont=dict(size=10, color="#fff", family="monospace"),
563
- showlegend=False,
564
- hovertemplate=f"<b>{name}</b><br>Visited #{i} — Priority {pri}/5<extra></extra>"))
565
-
566
- # Current node — triple ring halo (simulates pulse)
567
- cn = route[stp]
568
- cx, cy, cpri = STOPS[cn]
569
- if cn != "Depot":
570
- for sz, alpha in [(54, 0.10), (38, 0.20), (28, 1.0)]:
571
- col_val = _rgba(ac, alpha) if sz < 54 else _rgba(ac, 0.10)
572
- fig.add_trace(go.Scatter(x=[cx], y=[cy], mode="markers",
573
- marker=dict(size=sz, color=col_val,
574
- line=dict(color=_rgba(ac, 0.5 if sz<28 else 0.8), width=2)),
575
- showlegend=False, hoverinfo="skip"))
576
- star = "★ " if cpri >= 4 else ""
577
- fig.add_trace(go.Scatter(x=[cx], y=[cy], mode="markers+text",
578
- marker=dict(size=28, color=ac, line=dict(color="#fff", width=3)),
579
- text=[star + cn.replace("Shop ","")],
580
- textposition="top center",
581
- textfont=dict(size=9, color=SLATE, family="system-ui"),
582
- showlegend=False,
583
- hovertemplate=f"<b>{cn}</b><br>Currently delivering here<br>Priority {cpri}/5<extra></extra>"))
584
-
585
- # Depot
586
- dx, dy, _ = STOPS["Depot"]
587
- fig.add_trace(go.Scatter(x=[dx], y=[dy], mode="markers+text",
588
- marker=dict(size=30, color=NAVY, symbol="square",
589
- line=dict(color="#fff", width=2.5)),
590
- text=["D"], textposition="top center",
591
- textfont=dict(size=9, color=NAVY),
592
  showlegend=False,
593
- hovertemplate="<b>Depot</b><br>Start and end point<extra></extra>"))
594
-
595
- fig.update_layout(**_ch(450, ""), showlegend=False)
596
- fig.update_xaxes(showgrid=False, showticklabels=False, zeroline=False, range=[-0.8,10.5])
597
- fig.update_yaxes(showgrid=False, showticklabels=False, zeroline=False, range=[-0.8,10.5])
598
- st.plotly_chart(fig, use_container_width=True, key="agent_chart")
599
-
600
- # Legend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  st.markdown(f"""
602
- <div class='lgd'>
603
- <div class='li'><div class='ld' style='background:{NAVY};border-radius:3px;'></div> Depot</div>
604
- <div class='li'><div class='ld' style='background:{ac};'></div> Currently visiting</div>
605
- <div class='li'><div class='ld' style='background:{GREEN};'></div> Visited (# = order)</div>
606
- <div class='li'><div class='ld' style='background:#f1f5f9;border:1.5px solid #94a3b8;'></div> Not yet visited</div>
607
- <div class='li' style='margin-left:4px;'>★ = High priority (4–5/5)</div>
608
- </div>""", unsafe_allow_html=True)
609
-
610
- with log_col:
611
- st.markdown(f"<div style='font-weight:700;font-size:.875rem;color:{SLATE};margin-bottom:4px;'>Delivery sequence</div>",
612
- unsafe_allow_html=True)
613
- st.markdown(f"<div style='font-size:.75rem;color:{MUTED};margin-bottom:8px;'>Total route: {total_km:.2f} km</div>",
614
- unsafe_allow_html=True)
615
- html = "<div class='vlog'>"
616
- for i, stop in enumerate(route):
617
- if i < stp:
618
- dc, tc, fw = "vd-done", GREEN, "600"
619
- sub = f"+{_sd(route[i-1],stop):.1f} km" if i>0 else "start"
620
- elif i == stp:
621
- dc, tc, fw = "vd-current", BLUE, "800"
622
- sub = "← here now"
623
- else:
624
- dc, tc, fw = "vd-pending", MUTED, "400"
625
- sub = f"{_sd(route[i-1],stop):.1f} km" if i>0 else ""
626
- pri = STOPS[stop][2]
627
- star = "★ " if pri >= 4 else ""
628
- lbl = stop.replace("Shop ","Sh.")
629
- html += f"""<div class='vi'>
630
- <div class='vd {dc}'></div>
631
- <div style='flex:1;'>
632
- <div style='color:{tc};font-weight:{fw};font-size:.79rem;'>{i}. {star}{lbl}</div>
633
- <div style='color:#94a3b8;font-size:.7rem;'>{sub}</div>
634
- </div></div>"""
635
- html += "</div>"
636
- st.markdown(html, unsafe_allow_html=True)
637
- st.markdown("<br>", unsafe_allow_html=True)
638
- st.metric("Stop", f"{stp} / {mx}")
639
- st.metric("km done", f"{km_done:.1f}")
640
- st.metric("km left", f"{total_km-km_done:.1f}")
641
-
642
- # ── step 3: explanation ───────────────────────────────────────────────────
643
- st.markdown("""
644
- <div class='step-hd' style='margin-top:16px;'><div class='badge'>3</div>
645
- What you are seeing — how each agent decides where to go next
646
- </div>""", unsafe_allow_html=True)
647
-
648
- ea, eb, ec = st.columns(3)
649
- for col, (name, col_hex, short, long_d) in zip(
650
- [ea, eb, ec],
651
- [(n, RCOLS[n], RDESC[n][0], RDESC[n][1]) for n in RCOLS]):
652
- col.markdown(f"""
653
- <div style='background:{_rgba(col_hex,0.06)};border-radius:10px;padding:14px;
654
- border:1px solid {_rgba(col_hex,0.25)};'>
655
- <div style='font-weight:800;color:{col_hex};font-size:.85rem;margin-bottom:5px;'>{name}</div>
656
- <div style='font-size:.8rem;color:#0f172a;font-weight:600;'>{short}</div>
657
- <div style='font-size:.76rem;color:#475569;margin-top:4px;line-height:1.5;'>{long_d}</div>
658
  </div>""", unsafe_allow_html=True)
 
 
 
 
 
659
 
660
  # ── auto-play ─────────────────────────────────────────────────────────────
661
- if st.session_state.get("playing") and stp < mx:
662
- time.sleep(1.0 / speed)
663
- st.session_state["stp"] = stp + 1
664
  st.rerun()
665
- elif st.session_state.get("playing") and stp >= mx:
666
- st.session_state["playing"] = False
667
 
668
  # ══════════════════════════════════════════════════════════════════════════════
669
  # TASK 2 — BIAS
@@ -671,81 +504,53 @@ with T1:
671
  with T2:
672
  st.markdown("""
673
  <div class='card card-amber'>
674
- <div class='step-hd'><div class='badge badge-q'>?</div>What is this?</div>
675
- <p style='margin:0;color:#475569;font-size:.875rem;line-height:1.65;'>
676
- EcoCart uses <strong>K-Means clustering</strong> to group customers into High, Medium, and Low
677
- Value segments for targeted marketing. The algorithm was found to be <strong>biased</strong> —
678
- rural customers were almost entirely placed in Low Value groups even though they are genuine buyers.
679
- <br><br>
680
- This task identifies why the bias exists and applies a fix to make the results fair.
681
- The fairness threshold used is <strong>Disparate Impact ≥ 0.80</strong> (the 4/5ths rule).
682
- </p>
683
- </div>
684
- """, unsafe_allow_html=True)
685
-
686
- st.markdown("""
687
- <div class='step-hd'><div class='badge'>1</div>
688
- Run the segmentation script — executes task2_segmentation.py
689
  </div>""", unsafe_allow_html=True)
690
 
691
  run_t2 = st.button("▶ Run Task 2 — Segmentation & Bias Fix",
692
  type="primary", use_container_width=True, key="run_t2")
693
-
694
  if run_t2 or st.session_state.get("t2_done"):
695
  st.session_state["t2_done"] = True
696
 
697
  @st.cache_data
698
  def _run_task2():
699
- spec = importlib.util.spec_from_file_location("task2","task2_segmentation.py")
700
- m = importlib.util.module_from_spec(spec)
701
- buf = io.StringIO()
702
- with redirect_stdout(buf):
703
- spec.loader.exec_module(m); m.main()
704
  return buf.getvalue()
705
 
706
  with st.spinner("Running task2_segmentation.py …"):
707
- t2_output = _run_task2()
708
- st.session_state["t2_text"] = t2_output
709
-
710
- st.markdown("""
711
- <div class='step-hd'><div class='badge'>2</div>
712
- Terminal output — exactly what the script printed
713
- </div>""", unsafe_allow_html=True)
714
- st.markdown(f"""
715
- <div class='term-wrap'>
716
- <div class='term-bar'>
717
- <div class='td td-r'></div><div class='td td-y'></div><div class='td td-g'></div>
718
- <span style='font-size:.72rem;color:#64748b;margin-left:6px;'>task2_segmentation.py</span>
719
- </div>
720
- <div class='term-body'>{t2_output}</div>
721
- </div>""", unsafe_allow_html=True)
722
-
723
- st.markdown("""
724
- <div class='step-hd'><div class='badge'>3</div>
725
- Charts saved by the script — visualising the bias and the fix
726
- </div>""", unsafe_allow_html=True)
727
- c1, c2 = st.columns(2)
728
  with c1:
729
  if os.path.exists("output/bias_before_after.png"):
730
  st.image("output/bias_before_after.png",
731
- caption="bias_before_after.png — clusters before and after mitigation",
732
  use_container_width=True)
733
  with c2:
734
  if os.path.exists("output/disparate_impact.png"):
735
  st.image("output/disparate_impact.png",
736
- caption="disparate_impact.pngfairness metrics comparison",
737
  use_container_width=True)
738
 
739
  st.markdown("""
740
  <div class='insight'>
741
- <strong>What the output tells us</strong><br><br>
742
- <strong>Before the fix:</strong> 0% of rural customers were in High Value.
743
- Disparate Impact = 0.0 (heavily biased).<br>
744
- <strong>After the fix:</strong> 57.3% of rural customers are in High Value.
745
- Disparate Impact = 0.847 — above the 0.80 fairness threshold.<br><br>
746
- The fix worked by: (1) oversampling rural customers for equal representation,
747
- (2) adjusting rural spend for delivery cost and frequency,
748
- (3) promoting borderline rural customers after re-clustering.
749
  </div>""", unsafe_allow_html=True)
750
 
751
  # ══════════════════════════════════════════════════════════════════════════════
@@ -754,88 +559,61 @@ with T2:
754
  with T3:
755
  st.markdown("""
756
  <div class='card card-blue'>
757
- <div class='step-hd'><div class='badge badge-q'>?</div>What is this?</div>
758
- <p style='margin:0;color:#475569;font-size:.875rem;line-height:1.65;'>
759
- EcoCart needs to find the shortest delivery route across a network of <strong>20 nodes</strong>
760
- (10 urban, 10 rural). Four search algorithms were implemented: BFS, DFS, A*, and IDA*.<br><br>
761
- This tab has <strong>two parts</strong>: (1) run the full script to see all results and charts,
762
- then (2) use the interactive replay to watch BFS or A* explore the network step by step.
763
- </p>
764
- </div>""", unsafe_allow_html=True)
765
-
766
- st.markdown("""
767
- <div class='step-hd'><div class='badge'>1</div>
768
- Run all four algorithms — executes task3_4_routing.py
769
  </div>""", unsafe_allow_html=True)
770
 
771
  run_t3 = st.button("▶ Run Task 3 — Route Optimisation",
772
  type="primary", use_container_width=True, key="run_t3")
773
-
774
  if run_t3 or st.session_state.get("t3_done"):
775
  st.session_state["t3_done"] = True
776
 
777
  @st.cache_data
778
  def _run_task3():
779
- spec = importlib.util.spec_from_file_location("task3","task3_4_routing.py")
780
- m = importlib.util.module_from_spec(spec)
781
- buf = io.StringIO()
782
- with redirect_stdout(buf):
783
- spec.loader.exec_module(m); m.main()
784
  return buf.getvalue()
785
 
786
  with st.spinner("Running task3_4_routing.py …"):
787
- t3_output = _run_task3()
788
- st.session_state["t3_text"] = t3_output
 
 
 
 
 
 
 
 
 
789
 
790
- st.markdown("""
791
- <div class='step-hd'><div class='badge'>2</div>
792
- Terminal output from task3_4_routing.py
793
- </div>""", unsafe_allow_html=True)
794
- st.markdown(f"""
795
- <div class='term-wrap'>
796
- <div class='term-bar'>
797
- <div class='td td-r'></div><div class='td td-y'></div><div class='td td-g'></div>
798
- <span style='font-size:.72rem;color:#64748b;margin-left:6px;'>task3_4_routing.py</span>
799
- </div>
800
- <div class='term-body'>{t3_output}</div>
801
- </div>""", unsafe_allow_html=True)
802
-
803
- st.markdown("""
804
- <div class='step-hd'><div class='badge'>3</div>
805
- Charts saved by the script
806
- </div>""", unsafe_allow_html=True)
807
  if os.path.exists("output/network_map.png"):
808
  st.image("output/network_map.png",
809
- caption="network_map.png — the 20-node delivery network",
810
- use_container_width=True)
811
- c1, c2 = st.columns(2)
812
  with c1:
813
  if os.path.exists("output/algo_comparison.png"):
814
  st.image("output/algo_comparison.png",
815
- caption="algo_comparison.png — A* vs IDA* across all routes",
816
- use_container_width=True)
817
  with c2:
818
  if os.path.exists("output/green_vs_fast.png"):
819
  st.image("output/green_vs_fast.png",
820
- caption="green_vs_fast.png — fastest vs lowest CO₂ route",
821
- use_container_width=True)
822
 
823
  st.markdown("""
824
  <div class='insight'>
825
- <strong>What the output tells us</strong><br><br>
826
- On route U1→U10: BFS found 5.69 km (11 nodes), DFS found 6.84 km (18 nodes — not optimal),
827
- A* found 5.69 km (only 7 nodes expanded), IDA* found 5.69 km (43 nodes).<br><br>
828
- <strong>A* is the recommended algorithm</strong> — it always finds the optimal path and
829
- expands the fewest nodes. DFS is the only algorithm that does not guarantee the shortest path.<br><br>
830
- Green routing: a slightly longer path (e.g. U1→R9: 16.4 km vs 14.7 km) can reduce CO₂
831
- by 0.25 kg by choosing roads with lower emission rates.
832
  </div>""", unsafe_allow_html=True)
833
 
834
- # ── step 4: interactive route replay ────────────────────────────────────
835
- st.markdown("<div style='margin-top:8px;'>", unsafe_allow_html=True)
836
  st.markdown("""
837
- <div class='step-hd'><div class='badge'>4</div>
838
- Interactive replay — watch an algorithm search the network step by step
839
  </div>""", unsafe_allow_html=True)
840
 
841
  NODES_R = {
@@ -848,7 +626,7 @@ with T3:
848
  "R7":(6.5,6.0,"rural"), "R8":(9.0,7.0,"rural"), "R9":(11.0,6.0,"rural"),
849
  "R10":(8.0,5.5,"rural"),
850
  }
851
- _EP2 = [
852
  ("U1","U2"),("U2","U3"),("U1","U4"),("U2","U4"),("U2","U5"),
853
  ("U3","U6"),("U4","U5"),("U5","U6"),("U4","U7"),("U5","U8"),
854
  ("U6","U10"),("U7","U8"),("U8","U9"),("U9","U10"),("U5","U9"),
@@ -857,281 +635,235 @@ with T3:
857
  ("R7","R8"),("R8","R9"),("R6","R9"),("R8","R10"),("R5","R8"),
858
  ("U3","R1"),("U10","R4"),("U6","R1"),("U9","R7"),
859
  ]
860
- def _nd2(a,b): return math.hypot(NODES_R[a][0]-NODES_R[b][0],NODES_R[a][1]-NODES_R[b][1])
861
- EDGES2 = [(a,b,round(_nd2(a,b)*1.15,2)) for a,b in _EP2]
862
- ADJ2 = {n:[] for n in NODES_R}
863
- for a,b,w in EDGES2: ADJ2[a].append((b,w)); ADJ2[b].append((a,w))
864
- def _ew2(a,b):
865
- for nb,w in ADJ2[a]:
866
  if nb==b: return w
867
  return math.inf
868
 
869
- def _astar_traced(s,g):
870
- ctr=0; h=lambda n:_nd2(n,g); expl=[]
871
  heap=[(h(s),0.0,ctr,s,[s])]; best={s:0.0}
872
  while heap:
873
  _,gc,_,n,p=heapq.heappop(heap)
874
  if n==g: return p,round(gc,2),expl
875
  if gc>best.get(n,math.inf): continue
876
  expl.append(n)
877
- for nb,w in ADJ2[n]:
878
  ng=gc+w
879
  if ng<best.get(nb,math.inf):
880
  best[nb]=ng; ctr+=1
881
  heapq.heappush(heap,(ng+h(nb),ng,ctr,nb,p+[nb]))
882
  return None,0.0,expl
883
 
884
- def _bfs_traced(s,g):
885
  q=deque([(s,[s])]); seen={s}; expl=[]
886
  while q:
887
  n,p=q.popleft(); expl.append(n)
888
- if n==g: return p,round(sum(_ew2(p[i],p[i+1]) for i in range(len(p)-1)),2),expl
889
- for nb,_ in ADJ2[n]:
890
  if nb not in seen: seen.add(nb); q.append((nb,p+[nb]))
891
  return None,0.0,expl
892
 
893
- cfg_col, rpl_col = st.columns([1, 3])
 
 
 
 
 
 
 
 
 
 
 
 
894
 
895
- with cfg_col:
896
- st.markdown("""
897
- <div style='background:#f8fafc;border-radius:10px;padding:14px;border:1px solid #e2e8f0;'>
898
- <div style='font-weight:700;color:#0f172a;font-size:.875rem;margin-bottom:10px;'>Configuration</div>
899
- """, unsafe_allow_html=True)
900
- all_n = list(NODES_R.keys())
901
- sn_r = st.selectbox("Start node", all_n, index=0, key="r_sn")
902
- en_r = st.selectbox("End node", all_n, index=19, key="r_en")
903
- algo_r = st.radio("Algorithm", ["A*","BFS"], key="r_algo",
904
- captions=["Guided heuristic — expands fewest nodes",
905
- "Level-by-level guaranteed shortest hops"])
906
- st.markdown("</div>", unsafe_allow_html=True)
907
-
908
- # algorithm legend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
909
  st.markdown(f"""
910
- <div style='margin-top:10px;background:#f8fafc;border-radius:10px;padding:12px;
911
- border:1px solid #e2e8f0;font-size:.78rem;color:#475569;line-height:1.7;'>
912
- <strong style='color:#0f172a;'>Node colours</strong><br>
913
- <span style='color:#fff;background:#334155;padding:0 4px;border-radius:3px;'>D</span> Start &nbsp;
914
- <span style='color:#92400e;background:#fde68a;padding:0 4px;border-radius:3px;'>G</span> End<br>
915
- <span style='background:#93c5fd;padding:0 4px;border-radius:3px;'>U</span> Explored<br>
916
- <span style='background:{AMBER};color:#fff;padding:0 4px;border-radius:3px;'>P</span> Final path<br>
917
- <span style='color:#dc2626;'>■</span> Urban &nbsp;<span style='color:#059669;'>■</span> Rural
918
  </div>""", unsafe_allow_html=True)
919
 
920
- fn_r = _astar_traced if algo_r == "A*" else _bfs_traced
921
- if sn_r != en_r:
922
- path_r, cost_r, expl_r = fn_r(sn_r, en_r)
923
- else:
924
- path_r, cost_r, expl_r = [], 0.0, []
 
 
 
 
 
925
 
926
- with rpl_col:
927
- if sn_r != en_r and expl_r:
928
- max_rpl = len(expl_r)
929
-
930
- # auto-play controls for replay
931
- rp_cols = st.columns([1, 1, 1, 2, 3])
932
- if rp_cols[0].button("", use_container_width=True, key="rp_reset"):
933
- st.session_state["rp_step"] = 0; st.session_state["rp_play"] = False
934
- if rp_cols[1].button("◀", use_container_width=True, key="rp_back"):
935
- st.session_state["rp_step"] = max(0, st.session_state.get("rp_step",max_rpl)-1)
936
- st.session_state["rp_play"] = False
937
- if rp_cols[2].button("▶", use_container_width=True, key="rp_fwd"):
938
- st.session_state["rp_step"] = min(max_rpl, st.session_state.get("rp_step",max_rpl)+1)
939
- st.session_state["rp_play"] = False
940
- rp_playing = st.session_state.get("rp_play", False)
941
- if rp_cols[3].button("⏸ Pause" if rp_playing else "▶ Play search",
942
- type="primary", use_container_width=True, key="rp_playbtn"):
943
- st.session_state["rp_play"] = not rp_playing
944
- if not rp_playing and st.session_state.get("rp_step", max_rpl) >= max_rpl:
945
- st.session_state["rp_step"] = 0
946
- rp_spd = rp_cols[4].slider("Speed", 1, 8, 3, format="%dx",
947
- label_visibility="collapsed", key="rp_spd")
948
-
949
- replay = st.slider(
950
- "Drag to replay the search step by step",
951
- 0, max_rpl,
952
- st.session_state.get("rp_step", max_rpl),
953
- key="replay_sl")
954
- st.session_state["rp_step"] = replay
955
-
956
- explored_now = set(expl_r[:replay])
957
- current_node = expl_r[replay-1] if replay > 0 else None
958
- path_set = set(path_r) if path_r else set()
959
- done = (replay == max_rpl)
960
- pct = int(replay / max_rpl * 100)
961
-
962
- st.progress(replay / max_rpl,
963
- text=f"{replay}/{max_rpl} nodes explored ({pct}%)"
964
- + (" · Path found ✓" if done and path_r else ""))
965
-
966
- fn2 = go.Figure()
967
-
968
- # Edges
969
- for a,b,_ in EDGES2:
970
- fn2.add_trace(go.Scatter(
971
- x=[NODES_R[a][0],NODES_R[b][0],None],
972
- y=[NODES_R[a][1],NODES_R[b][1],None], mode="lines",
973
- line=dict(color="#dde6f0", width=1.5), showlegend=False, hoverinfo="skip"))
974
-
975
- # Final path highlight
976
- if path_r and done:
977
- for i in range(len(path_r)-1):
978
- pa,pb = path_r[i],path_r[i+1]
979
- fn2.add_trace(go.Scatter(
980
- x=[NODES_R[pa][0],NODES_R[pb][0],None],
981
- y=[NODES_R[pa][1],NODES_R[pb][1],None], mode="lines",
982
- line=dict(color=AMBER, width=5), showlegend=False, hoverinfo="skip"))
983
-
984
- # Nodes
985
- for zone, zone_col in [("urban", RED), ("rural", GREEN)]:
986
- ns = [(n,d) for n,d in NODES_R.items() if d[2]==zone]
987
- for n, d in ns:
988
- if n == sn_r:
989
- nc, sz = "#334155", 24
990
- elif n == en_r:
991
- nc, sz = "#fde68a", 24
992
- elif n in path_set and done:
993
- nc, sz = AMBER, 22
994
- elif n in explored_now:
995
- nc, sz = "#93c5fd", 20
996
- else:
997
- nc, sz = zone_col, 16
998
-
999
- # Ring halo on current node
1000
- if n == current_node and not done:
1001
- fn2.add_trace(go.Scatter(
1002
- x=[d[0]], y=[d[1]], mode="markers",
1003
- marker=dict(size=36, color=_rgba(BLUE, 0.18),
1004
- line=dict(color=_rgba(BLUE, 0.6), width=2)),
1005
- showlegend=False, hoverinfo="skip"))
1006
-
1007
- state = ("Path node" if n in path_set and done
1008
- else "Explored" if n in explored_now
1009
- else "Not yet visited")
1010
- fn2.add_trace(go.Scatter(
1011
- x=[d[0]], y=[d[1]], mode="markers+text",
1012
- marker=dict(size=sz, color=nc, line=dict(color=SLATE, width=1.5)),
1013
- text=[n], textposition="middle center",
1014
- textfont=dict(size=7.5, color="#fff" if n in explored_now and n not in (sn_r,en_r) else SLATE),
1015
- showlegend=False,
1016
- hovertemplate=f"<b>{n}</b><br>{state}<extra></extra>"))
1017
-
1018
- fn2.update_layout(**_ch(460, f"{algo_r}: {sn_r} → {en_r}"), showlegend=False)
1019
- fn2.update_xaxes(showgrid=False, showticklabels=False, zeroline=False)
1020
- fn2.update_yaxes(showgrid=False, showticklabels=False, zeroline=False)
1021
- st.plotly_chart(fn2, use_container_width=True, key="route_replay")
1022
-
1023
- if path_r and done:
1024
- mc = st.columns(3)
1025
- mc[0].metric("Path cost", f"{cost_r:.2f} km")
1026
- mc[1].metric("Nodes explored", len(expl_r))
1027
- mc[2].metric("Path length", f"{len(path_r)} nodes")
1028
- st.markdown(f"""
1029
- <div class='info-note'>
1030
- <strong>Path found:</strong> {" → ".join(path_r)}<br>
1031
- <strong>{algo_r}</strong> explored {len(expl_r)} nodes to find a {cost_r:.2f} km route.
1032
- </div>""", unsafe_allow_html=True)
1033
-
1034
- # Auto-play replay
1035
- if st.session_state.get("rp_play") and replay < max_rpl:
1036
- time.sleep(1.0 / rp_spd)
1037
- st.session_state["rp_step"] = replay + 1
1038
- st.rerun()
1039
- elif st.session_state.get("rp_play") and replay >= max_rpl:
1040
- st.session_state["rp_play"] = False
1041
 
1042
  # ══════════════════════════════════════════════════════════════════════════════
1043
  # TASK 4 — A* vs IDA*
1044
  # ══════════════════════════════════════════════════════════════════════════════
1045
  with T4:
1046
  st.markdown("""
1047
- <div class='card card-purple'>
1048
- <div class='step-hd'><div class='badge badge-q'>?</div>What is this?</div>
1049
- <p style='margin:0;color:#475569;font-size:.875rem;line-height:1.65;'>
1050
- Both A* and IDA* find the <strong>optimal route</strong>, but they work differently.
1051
- A* stores all explored nodes in memory (fast but uses more RAM).
1052
- IDA* uses almost no memory by re-searching with a stricter cost limit each iteration.<br><br>
1053
- This tab shows the benchmark results: 10 routes, 20 runs each — comparing nodes expanded
1054
- and time taken.
1055
- </p>
1056
- </div>""", unsafe_allow_html=True)
1057
-
1058
- st.markdown("""
1059
- <div class='step-hd'><div class='badge'>1</div>
1060
- Benchmark results — from task3_4_routing.py (20 runs per route)
1061
  </div>""", unsafe_allow_html=True)
1062
 
1063
- urban_data = [
1064
- ["U1→U10","5.69","7", "0.170","5.69","43","0.559"],
1065
- ["U7→U6", "4.21","5", "0.088","4.21","22","0.472"],
1066
- ["U2→U9", "3.11","2", "0.073","3.11","6", "0.129"],
1067
- ["U1→U9", "4.40","4", "0.088","4.40","15","0.223"],
1068
- ["U3→U8", "4.21","5", "0.114","4.21","19","0.283"],
1069
  ]
1070
- rural_data = [
1071
  ["R1→R9", "10.39","6","0.134","10.39","34","0.466"],
1072
  ["R2→R8", "7.82", "4","0.101","7.82", "14","0.224"],
1073
  ["R3→R10","6.77", "5","0.107","6.77", "21","0.279"],
1074
  ["R1→R6", "7.51", "3","0.065","7.51", "10","0.149"],
1075
  ["R4→R9", "7.82", "7","0.130","7.82", "50","0.642"],
1076
  ]
1077
- headers = ["Route","A* km","A* nodes","A* ms","IDA* km","IDA* nodes","IDA* ms"]
1078
-
1079
- cu, cr = st.columns(2)
1080
  with cu:
1081
- st.markdown("**Urban routes (U cluster)**")
1082
- st.dataframe(pd.DataFrame(urban_data, columns=headers),
1083
- use_container_width=True, hide_index=True)
1084
  with cr:
1085
- st.markdown("**Rural routes (R cluster)**")
1086
- st.dataframe(pd.DataFrame(rural_data, columns=headers),
1087
- use_container_width=True, hide_index=True)
1088
-
1089
- st.markdown("""
1090
- <div class='step-hd'><div class='badge'>2</div>
1091
- Interactive comparison chart
1092
- </div>""", unsafe_allow_html=True)
1093
-
1094
- all_rows = urban_data + rural_data
1095
- routes = [r[0] for r in all_rows]
1096
- a_nodes = [int(r[2]) for r in all_rows]
1097
- i_nodes = [int(r[5]) for r in all_rows]
1098
- a_ms = [float(r[3]) for r in all_rows]
1099
- i_ms = [float(r[6]) for r in all_rows]
1100
-
1101
- fig_cmp = make_subplots(rows=1, cols=2,
1102
- subplot_titles=["Nodes expanded (fewer = smarter)",
1103
- "Time in ms (lower = faster)"])
1104
- for ci, (av, iv) in enumerate([(a_nodes,i_nodes),(a_ms,i_ms)], 1):
1105
- fig_cmp.add_trace(go.Bar(name="A*", x=routes, y=av, marker_color=BLUE,
1106
- showlegend=(ci==1)), row=1, col=ci)
1107
- fig_cmp.add_trace(go.Bar(name="IDA*", x=routes, y=iv, marker_color=AMBER,
1108
- showlegend=(ci==1)), row=1, col=ci)
1109
- fig_cmp.update_layout(paper_bgcolor=SURF, plot_bgcolor=BG, font_color=SLATE,
1110
- barmode="group", height=360,
1111
- margin=dict(l=40,r=20,t=50,b=80),
1112
- legend=dict(bgcolor=SURF,bordercolor=BORDER))
1113
- fig_cmp.update_xaxes(gridcolor=BORDER, tickangle=45)
1114
- fig_cmp.update_yaxes(gridcolor=BORDER)
1115
- st.plotly_chart(fig_cmp, use_container_width=True)
1116
 
1117
  if os.path.exists("output/algo_comparison.png"):
1118
- st.markdown("""
1119
- <div class='step-hd'><div class='badge'>3</div>
1120
- Chart from task3_4_routing.py
1121
- </div>""", unsafe_allow_html=True)
1122
- st.image("output/algo_comparison.png",
1123
- caption="algo_comparison.png — generated by task3_4_routing.py",
1124
- use_container_width=True)
1125
 
1126
  st.markdown("""
1127
  <div class='insight'>
1128
- <strong>What the output tells us</strong><br><br>
1129
- Both algorithms found <strong>identical optimal paths</strong> on every single route
1130
- the kilometre costs match exactly.<br><br>
1131
- A* expanded fewer nodes in every case. For example on R4→R9: A* checked <strong>7 nodes</strong>,
1132
- IDA* checked <strong>50 nodes</strong>. A* was also faster in ms on this 20-node graph.<br><br>
1133
- However, IDA* uses almost no memory (O(depth)) while A* stores all explored nodes (O(b<sup>d</sup>)).
1134
- On a national road network with millions of nodes, IDA* would be the only practical choice.
1135
  </div>""", unsafe_allow_html=True)
1136
 
1137
  # ══════════════════════════════════════════════════════════════════════════════
@@ -1140,92 +872,57 @@ with T4:
1140
  with T5:
1141
  st.markdown("""
1142
  <div class='card card-green'>
1143
- <div class='step-hd'><div class='badge badge-q'>?</div>What is this?</div>
1144
- <p style='margin:0;color:#475569;font-size:.875rem;line-height:1.65;'>
1145
- EcoCart wants to predict how many units it will sell each day so it can stock the right
1146
- inventory. This task trains two machine learning models —
1147
- <strong>Linear Regression</strong> and <strong>Random Forest</strong> —
1148
- on <strong>730 days</strong> of sales data and evaluates which one predicts more accurately
1149
- on <strong>140 unseen test days</strong>.
1150
- </p>
1151
- </div>""", unsafe_allow_html=True)
1152
-
1153
- st.markdown("""
1154
- <div class='step-hd'><div class='badge'>1</div>
1155
- Run the forecast — executes task5_forecasting.py
1156
  </div>""", unsafe_allow_html=True)
1157
 
1158
  run_t5 = st.button("▶ Run Task 5 — Demand Forecasting",
1159
  type="primary", use_container_width=True, key="run_t5")
1160
-
1161
  if run_t5 or st.session_state.get("t5_done"):
1162
  st.session_state["t5_done"] = True
1163
 
1164
  @st.cache_data
1165
  def _run_task5():
1166
- spec = importlib.util.spec_from_file_location("task5","task5_forecasting.py")
1167
- m = importlib.util.module_from_spec(spec)
1168
- buf = io.StringIO()
1169
- with redirect_stdout(buf):
1170
- spec.loader.exec_module(m); m.main()
1171
  return buf.getvalue()
1172
 
1173
  with st.spinner("Running task5_forecasting.py …"):
1174
- t5_output = _run_task5()
1175
- st.session_state["t5_text"] = t5_output
1176
-
1177
- st.markdown("""
1178
- <div class='step-hd'><div class='badge'>2</div>
1179
- Terminal output from task5_forecasting.py
1180
- </div>""", unsafe_allow_html=True)
1181
- st.markdown(f"""
1182
- <div class='term-wrap'>
1183
- <div class='term-bar'>
1184
- <div class='td td-r'></div><div class='td td-y'></div><div class='td td-g'></div>
1185
- <span style='font-size:.72rem;color:#64748b;margin-left:6px;'>task5_forecasting.py</span>
1186
- </div>
1187
- <div class='term-body'>{t5_output}</div>
1188
- </div>""", unsafe_allow_html=True)
1189
 
1190
- st.markdown("""
1191
- <div class='step-hd'><div class='badge'>3</div>
1192
- Charts saved by the script
1193
- </div>""", unsafe_allow_html=True)
1194
  if os.path.exists("output/forecast.png"):
1195
  st.image("output/forecast.png",
1196
- caption="forecast.png — actual vs predicted daily sales (140-day test set)",
1197
  use_container_width=True)
1198
- c1, c2 = st.columns(2)
1199
  with c1:
1200
  if os.path.exists("output/residuals.png"):
1201
- st.image("output/residuals.png",
1202
- caption="residuals.png — prediction errors for both models",
1203
- use_container_width=True)
1204
  with c2:
1205
  if os.path.exists("output/feature_importance.png"):
1206
  st.image("output/feature_importance.png",
1207
- caption="feature_importance.png — which features matter most",
1208
- use_container_width=True)
1209
-
1210
- st.markdown("""
1211
- <div class='step-hd'><div class='badge'>4</div>
1212
- Key metrics from the actual run
1213
- </div>""", unsafe_allow_html=True)
1214
- mc = st.columns(4)
1215
- mc[0].metric("LR — MAE", "9.62 units")
1216
- mc[1].metric("LR — R²", "0.762")
1217
- mc[2].metric("RF — MAE", "9.75 units")
1218
- mc[3].metric("RF — R²", "0.716")
1219
 
1220
  st.markdown("""
1221
  <div class='insight'>
1222
- <strong>What the output tells us</strong><br><br>
1223
- Linear Regression achieved = 0.762, meaning it explains <strong>76.2%</strong>
1224
- of the variation in daily sales. Random Forest achieved R² = 0.716.
1225
- On this dataset, <strong>Linear Regression performed slightly better</strong>.<br><br>
1226
- The top 3 most important features were: <strong>lag_7</strong> (sales 7 days ago),
1227
- <strong>lag_14</strong>, and <strong>is_promo</strong>. This confirms that weekly
1228
- sales patterns and promotional activity are the strongest predictors of demand.
1229
  </div>""", unsafe_allow_html=True)
1230
 
1231
  # ══════════════════════════════════════════════════════════════════════════════
@@ -1234,79 +931,67 @@ with T5:
1234
  with T6:
1235
  st.markdown("""
1236
  <div class='card card-amber'>
1237
- <div class='step-hd'><div class='badge badge-q'>?</div>What is this?</div>
1238
- <p style='margin:0;color:#475569;font-size:.875rem;line-height:1.65;'>
1239
- This task is attempted <strong>voluntarily</strong> (AI students are permitted to do so).
1240
- It estimates the business value and environmental impact of deploying the AI system.<br><br>
1241
- <strong>Important:</strong> All numbers are <em>assumptions for illustration</em>,
1242
- not measured values. Adjust the sliders to model different scenarios.
1243
- </p>
1244
  </div>""", unsafe_allow_html=True)
1245
 
1246
- c_ctrl, c_main = st.columns([1, 3])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1247
 
1248
- with c_ctrl:
1249
- st.markdown("""
1250
- <div style='background:#f8fafc;border-radius:10px;padding:14px;border:1px solid #e2e8f0;'>
1251
- <div style='font-weight:700;font-size:.875rem;color:#0f172a;margin-bottom:10px;'>Your assumptions</div>
1252
- """, unsafe_allow_html=True)
1253
- fleet = st.slider("Fleet (vehicles)", 5, 100, 30, 5)
1254
- daily = st.slider("Deliveries/vehicle/day", 10, 80, 40, 5)
1255
- avg_km = st.slider("Avg km per delivery", 2, 30, 12, 1)
1256
- fuel = st.slider("Fuel €/km", 0.10, 0.60,0.32,0.01, format="€%.2f")
1257
- wage = st.slider("Driver wage €/hr", 10, 35, 18, 1, format="€%d")
1258
- days_yr = st.slider("Working days/year", 200, 365, 300,10)
1259
- rt_save = st.slider("Route saving % (A*)", 5, 35, 18, 1, format="%d%%")
1260
- seg_rev = st.slider("Rural revenue uplift €k", 10, 200, 65, 5)
1261
- st.markdown("</div>", unsafe_allow_html=True)
1262
-
1263
- with c_main:
1264
- total_km = fleet * daily * days_yr * avg_km
1265
- km_saved = total_km * rt_save / 100
1266
- fuel_saved = km_saved * fuel
1267
- time_saved = (km_saved / 40) * wage
1268
- route_save = fuel_saved + time_saved
1269
- seg_save = seg_rev * 1000
1270
- total_eur = route_save + seg_save
1271
- co2 = km_saved * 0.24 / 1000
1272
- dev = 45000; ops = 8000
1273
- payback = round((dev+ops)/total_eur*12, 1) if total_eur > 0 else 0
1274
- roi3 = round((total_eur*3-(dev+ops*3))/(dev+ops*3)*100, 1)
1275
-
1276
- mc = st.columns(4)
1277
- mc[0].metric("Est. annual saving", f"€{round(total_eur/1000,1)}k")
1278
- mc[1].metric("Est. payback", f"{payback} months")
1279
- mc[2].metric("3-year ROI", f"{roi3}%")
1280
- mc[3].metric("CO₂ saved/year", f"{co2:.1f} tonnes")
1281
-
1282
- cats = ["Route Optimisation\n(A*)","Fairer Segmentation\n(rural uplift)"]
1283
- vals = [round(route_save/1000,1), round(seg_save/1000,1)]
1284
- fig_b = go.Figure(go.Bar(x=cats, y=vals, marker_color=[BLUE, GREEN],
1285
- text=[f"€{v}k" for v in vals], textposition="outside",
1286
- textfont_color=SLATE, width=0.4))
1287
- fig_b.update_layout(**_ch(270,"Estimated annual savings by area (€ thousands)"))
1288
- fig_b.update_xaxes(gridcolor=BORDER)
1289
- fig_b.update_yaxes(gridcolor=BORDER, title="€ thousands")
1290
- st.plotly_chart(fig_b, use_container_width=True)
1291
-
1292
- years = [0, 1, 2, 3]
1293
- benefit = [0, total_eur, total_eur*2, total_eur*3]
1294
- cost = [0, dev+ops, dev+ops*2, dev+ops*3]
1295
- fig_r = go.Figure()
1296
- fig_r.add_trace(go.Scatter(x=years, y=[v/1000 for v in benefit],
1297
- name="Cumulative benefit", line=dict(color=GREEN, width=2.5), mode="lines+markers"))
1298
- fig_r.add_trace(go.Scatter(x=years, y=[v/1000 for v in cost],
1299
- name="Cumulative cost", line=dict(color=RED, width=2.5, dash="dash"), mode="lines+markers"))
1300
- fig_r.add_hline(y=0, line_color=MUTED, line_width=1, line_dash="dot")
1301
- fig_r.update_layout(**_ch(290,"3-year ROI projection (€ thousands — assumed values)"))
1302
- fig_r.update_xaxes(gridcolor=BORDER, tickvals=[0,1,2,3],
1303
- ticktext=["Now","Year 1","Year 2","Year 3"])
1304
- fig_r.update_yaxes(gridcolor=BORDER, title="€ thousands")
1305
- st.plotly_chart(fig_r, use_container_width=True)
1306
-
1307
- st.markdown(
1308
- f"<div class='warn-note'>"
1309
- f"All numbers are <strong>estimated assumptions</strong> for illustration only — not measured values. "
1310
- f"Based on your inputs: {fleet} vehicles, {daily} deliveries/day, {avg_km} km avg, "
1311
- f"{rt_save}% route saving from A*, €{seg_rev}k rural revenue uplift."
1312
- f"</div>", unsafe_allow_html=True)
 
1
  """
2
  EcoCart AI System — Streamlit App
 
3
  NCI MSCAI | Fundamentals of AI TABA 2026
4
  """
5
 
6
+ import io, os, math, heapq, time, importlib.util
7
  from contextlib import redirect_stdout
8
  from collections import deque
9
 
 
21
  from plotly.subplots import make_subplots
22
 
23
  # ══════════════════════════════════════════════════════════════════════════════
24
+ # REPORT GENERATOR (unchanged)
25
  # ══════════════════════════════════════════════════════════════════════════════
 
26
  def _build_report(t2_text, t3_text, t5_text):
27
  TNR = "Times New Roman"
28
  doc = Document()
 
35
  s.font.color.rgb = RGBColor(0,0,0)
36
  s.paragraph_format.space_before = Pt(12)
37
  s.paragraph_format.space_after = Pt(4)
 
38
  def H(txt, lv=1):
39
  p = doc.add_heading(txt, level=lv); p.alignment = WD_ALIGN_PARAGRAPH.LEFT
40
  for r in p.runs: r.font.name=TNR; r.font.color.rgb=RGBColor(0,0,0)
 
54
  cp.paragraph_format.space_after = Pt(8)
55
  for r in cp.runs: r.font.name=TNR; r.font.size=Pt(10); r.font.italic=True
56
  def SP(): doc.add_paragraph("").paragraph_format.space_after = Pt(4)
 
57
  SP(); SP()
58
  p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER
59
  r = p.add_run("EcoCart AI System"); r.font.name=TNR; r.font.size=Pt(24); r.font.bold=True
 
72
  lr2.font.name=TNR; lr2.font.size=Pt(11); lr2.font.bold=True
73
  lr2.font.color.rgb = RGBColor(37,99,235)
74
  doc.add_page_break()
 
75
  H("Task 2 — Customer Segmentation & Bias Mitigation")
76
  P("Running task2_segmentation.py produced the following output:")
77
  if t2_text: CODE(t2_text)
78
+ SP(); IMG("output/bias_before_after.png","Figure 1: Customer clusters before and after bias mitigation")
79
+ SP(); IMG("output/disparate_impact.png","Figure 2: Disparate Impact and High Value rates before and after fix")
80
+ SP(); P("Before: DI=0.0 (biased). After: DI=0.847 (fair, above 0.80).")
 
 
 
 
81
  doc.add_page_break()
 
82
  H("Tasks 3 & 4 — Route Optimisation and Algorithm Comparison")
83
  P("Running task3_4_routing.py produced the following output:")
84
  if t3_text: CODE(t3_text)
85
+ SP(); IMG("output/network_map.png","Figure 3: EcoCart 20-node delivery network")
86
+ SP(); IMG("output/algo_comparison.png","Figure 4: A* vs IDA* comparison")
87
+ SP(); IMG("output/green_vs_fast.png","Figure 5: Fastest vs lowest CO2 route")
88
+ SP(); P("A* found the optimal path on every route with fewest nodes expanded.")
 
 
 
 
 
 
 
89
  doc.add_page_break()
 
90
  H("Task 5 — Demand Forecasting with Machine Learning")
91
  P("Running task5_forecasting.py produced the following output:")
92
  if t5_text: CODE(t5_text)
93
+ SP(); IMG("output/forecast.png","Figure 6: Actual vs predicted daily sales")
94
+ SP(); IMG("output/residuals.png","Figure 7: Residuals for both models")
95
+ SP(); IMG("output/feature_importance.png","Figure 8: Random Forest feature importance")
96
+ SP(); P("LR: MAE=9.62, R2=0.762. RF: MAE=9.75, R2=0.716. Top feature: lag_7.")
 
 
 
 
 
 
 
97
  doc.add_page_break()
 
98
  H("References")
99
+ for ref in [
100
+ "[1] S. Russell and P. Norvig, Artificial Intelligence: A Modern Approach, 4th ed. Pearson, 2020.",
101
+ "[2] F. Pedregosa et al., Scikit-learn: Machine Learning in Python, JMLR, 2011.",
102
+ "[3] M. Feldman et al., Certifying and Removing Disparate Impact, ACM SIGKDD, 2015.",
103
+ "[4] P. E. Hart et al., A Formal Basis for the Heuristic Determination of Minimum Cost Paths, IEEE, 1968.",
104
+ ]:
 
 
105
  p = doc.add_paragraph(ref)
106
  p.paragraph_format.space_after = Pt(5)
107
  p.paragraph_format.left_indent = Inches(0.3)
108
  p.paragraph_format.first_line_indent = Inches(-0.3)
109
  for r in p.runs: r.font.name=TNR; r.font.size=Pt(11)
 
110
  buf = io.BytesIO(); doc.save(buf); buf.seek(0)
111
  return buf
112
 
113
  # ══════════════════════════════════════════════════════════════════════════════
114
+ # PAGE SETUP
115
  # ══════════════════════════════════════════════════════════════════════════════
116
+ st.set_page_config(page_title="EcoCart AI", page_icon="🛒",
 
117
  layout="wide", initial_sidebar_state="expanded")
118
 
119
+ # design tokens
120
+ NAVY = "#0f172a"; SLATE = "#1e293b"; MUTED = "#64748b"
121
+ BORDER = "#e2e8f0"; SURF = "#ffffff"; BG = "#f8fafc"
122
+ GREEN = "#059669"; BLUE = "#2563eb"; AMBER = "#d97706"; RED = "#dc2626"
123
+
124
+ def _rgba(h, a=1.0):
125
+ h = h.lstrip("#"); r,g,b = int(h[:2],16),int(h[2:4],16),int(h[4:],16)
 
 
 
 
 
 
 
126
  return f"rgba({r},{g},{b},{a})"
127
 
128
+ def _layout(h=420):
129
  return dict(height=h, paper_bgcolor=SURF, plot_bgcolor=BG,
130
+ font=dict(color=SLATE, size=11),
131
+ margin=dict(l=8,r=8,t=8,b=8),
132
+ showlegend=False)
 
133
 
 
134
  st.markdown("""
135
  <style>
 
136
  [data-testid="stAppViewContainer"] { background:#f1f5f9; }
137
+ .block-container { padding:1.2rem 1.8rem 3rem; }
138
 
139
  /* Tabs */
140
  .stTabs [data-baseweb="tab-list"] {
141
+ background:#fff; border-radius:10px; padding:3px;
142
+ box-shadow:0 1px 4px rgba(0,0,0,.06); border:1px solid #e2e8f0;
143
  }
144
  .stTabs [data-baseweb="tab"] {
145
+ border-radius:7px; font-size:.83rem; font-weight:600; padding:7px 15px; color:#64748b;
 
146
  }
147
+ .stTabs [aria-selected="true"] { background:#0f172a !important; color:#fff !important; }
148
 
149
  /* Cards */
150
  .card {
151
+ background:#fff; border-radius:10px; padding:16px 20px;
152
+ border:1px solid #f1f5f9; box-shadow:0 1px 4px rgba(0,0,0,.05); margin-bottom:12px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
+ .card-blue { border-left:4px solid #2563eb; }
155
+ .card-amber { border-left:4px solid #d97706; }
156
+ .card-green { border-left:4px solid #059669; }
157
+ .card-navy { border-left:4px solid #0f172a; }
158
 
159
  /* Terminal */
160
+ .term { border-radius:10px; overflow:hidden; border:1px solid #1e293b; margin:6px 0; }
161
+ .term-top { background:#1e293b; padding:7px 12px; display:flex; gap:5px; align-items:center; }
162
+ .dot { width:9px; height:9px; border-radius:50%; }
 
163
  .term-body {
164
+ background:#0f172a; padding:12px 16px;
165
+ font-family:'Courier New',monospace; font-size:.78rem;
166
+ color:#94a3b8; white-space:pre-wrap; line-height:1.6;
167
+ max-height:300px; overflow-y:auto;
168
  }
169
 
170
+ /* Insight boxes */
171
  .insight {
172
  background:#ecfdf5; border-left:4px solid #059669;
173
+ border-radius:0 8px 8px 0; padding:12px 16px;
174
+ color:#064e3b; font-size:.85rem; line-height:1.6; margin:8px 0;
175
  }
176
+ .warn-box {
177
  background:#fffbeb; border-left:4px solid #d97706;
178
+ border-radius:0 8px 8px 0; padding:12px 16px;
179
+ color:#78350f; font-size:.85rem; line-height:1.6; margin:8px 0;
 
 
 
 
 
180
  }
181
 
182
+ /* Legend row */
183
+ .leg { display:flex; gap:14px; flex-wrap:wrap; margin:6px 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  .li { display:flex; align-items:center; gap:5px; font-size:.78rem; color:#475569; }
185
+ .ld { width:11px; height:11px; border-radius:50%; flex-shrink:0; }
186
+
187
+ /* Section label */
188
+ .slabel {
189
+ font-size:.8rem; font-weight:700; color:#64748b;
190
+ text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
191
+ }
192
 
193
  /* Metrics */
194
  div[data-testid="metric-container"] {
195
+ background:#fff; border-radius:8px; padding:12px 16px;
196
+ border:1px solid #e2e8f0; box-shadow:0 1px 3px rgba(0,0,0,.04);
197
  }
198
 
199
  /* Sidebar */
200
  [data-testid="stSidebar"] { background:#fff; border-right:1px solid #e2e8f0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  </style>
202
  """, unsafe_allow_html=True)
203
 
 
206
  # ══════════════════════════════════════════════════════════════════════════════
207
  with st.sidebar:
208
  st.markdown("""
209
+ <div style='text-align:center;padding:8px 0 16px;'>
210
+ <div style='font-size:2rem;'>🛒</div>
211
+ <div style='font-weight:800;font-size:1.05rem;color:#0f172a;'>EcoCart AI</div>
212
+ </div>""", unsafe_allow_html=True)
 
213
  st.divider()
214
 
215
+ st.markdown("**How to use**")
216
+ for n, t in [("1","Pick a tab above"),("2","Read the short description"),
217
+ ("3","Press Run (Tasks 2, 3, 5)"),("4","Use Play to animate (Tasks 1, 3)")]:
218
+ st.markdown(f"""<div style='display:flex;gap:8px;align-items:center;margin:5px 0;'>
219
+ <div style='background:#0f172a;color:#fff;width:18px;height:18px;border-radius:50%;
220
+ font-size:.68rem;font-weight:800;display:flex;align-items:center;
221
+ justify-content:center;flex-shrink:0;'>{n}</div>
222
+ <span style='font-size:.82rem;color:#1e293b;'>{t}</span></div>""",
223
+ unsafe_allow_html=True)
 
 
 
 
224
  st.divider()
225
+
 
226
  t2_done = st.session_state.get("t2_done", False)
227
  t3_done = st.session_state.get("t3_done", False)
228
  t5_done = st.session_state.get("t5_done", False)
229
+ st.markdown("**Task status**")
230
+ for lbl, done in [("Task 2Bias", t2_done),
231
+ ("Task 3Routes", t3_done),
232
+ ("Task 5 Forecast",t5_done)]:
233
+ icon = "✅" if done else ""
234
+ col = GREEN if done else "#94a3b8"
235
+ st.markdown(f"<div style='color:{col};font-size:.82rem;margin:3px 0;'>{icon} {lbl}</div>",
236
  unsafe_allow_html=True)
237
 
 
 
 
 
 
 
238
  if t2_done or t3_done or t5_done:
239
+ st.divider()
240
+ buf = _build_report(st.session_state.get("t2_text",""),
241
+ st.session_state.get("t3_text",""),
242
+ st.session_state.get("t5_text",""))
243
+ st.download_button("⬇ Download Report (.docx)", buf,
244
  file_name="EcoCart_Report.docx",
245
  mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
246
  use_container_width=True)
247
+ st.divider()
248
+ st.caption("All outputs are from the real task Python scripts.")
249
 
250
+ # ── header ────────────────────────────────────────────────────────────────────
251
  st.markdown("""
252
+ <div style='background:linear-gradient(135deg,#0f172a,#1e3a5f);border-radius:12px;
253
+ padding:20px 26px;margin-bottom:18px;'>
254
+ <div style='color:#f8fafc;font-size:1.5rem;font-weight:800;margin-bottom:2px;'>EcoCart AI System</div>
255
+ <div style='color:#94a3b8;font-size:.85rem;'>
256
+ Route optimisation &nbsp;·&nbsp; Customer segmentation &nbsp;·&nbsp; Demand forecasting
 
257
  </div>
258
+ </div>""", unsafe_allow_html=True)
 
259
 
260
  T1, T2, T3, T4, T5, T6 = st.tabs([
261
+ "🤖 Task 1 — AI Agents",
262
+ "⚖️ Task 2 — Bias",
263
+ "🗺️ Task 3 — Routes",
264
+ "📊 Task 4 — A* vs IDA*",
265
+ "📈 Task 5 — Forecast",
266
+ "💼 Task 6 — Business",
267
  ])
268
 
269
  # ══════════════════════════════════════════════════════════════════════════════
270
+ # TASK 1 — AI AGENTS (step-by-step animated map)
271
  # ══════════════════════════════════════════════════════════════════════════════
272
  with T1:
 
273
  st.markdown("""
274
  <div class='card card-navy'>
275
+ <strong>What is this?</strong>&nbsp; Three AI agent types navigate the same 9-stop delivery map.
276
+ Each makes decisions differently. Use <b>Play</b> to watch the route animate stop by stop,
277
+ or drag the slider to step through manually.
278
+ </div>""", unsafe_allow_html=True)
279
+
280
+ # ── route data ────────────────────────────────────────────────────────────
 
 
 
 
 
281
  STOPS = {
282
+ "Depot": (0.0,0.0,0), "Shop A":(2.0,3.0,3), "Shop B":(5.0,1.0,4),
283
+ "Shop C": (7.0,4.0,2), "Shop D":(3.0,6.0,5), "Shop E":(8.0,7.0,1),
284
+ "Shop F": (1.0,8.0,3), "Shop G":(6.0,9.0,4), "Shop H":(9.0,2.0,2),
285
  }
286
+ def _sd(a,b):
287
+ ax,ay,_=STOPS[a]; bx,by,_=STOPS[b]; return math.hypot(ax-bx,ay-by)
 
288
 
289
  @st.cache_data
290
  def _get_routes():
291
  def reactive():
292
  r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; c="Depot"
293
  while u:
294
+ nb=min(u,key=lambda n:_sd(c,n)); r.append(nb); u.remove(nb); c=nb
295
  return r+["Depot"]
296
  def goal():
297
  r=reactive()[:-1]
298
+ td=lambda x:sum(_sd(x[i],x[i+1]) for i in range(len(x)-1))+_sd(x[-1],x[0])
299
  ok=True
300
  while ok:
301
  ok=False
 
307
  def utility():
308
  r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; c="Depot"
309
  while u:
310
+ nb=max(u,key=lambda n:STOPS[n][2]/(_sd(c,n)+.1))
311
  r.append(nb); u.remove(nb); c=nb
312
  return r+["Depot"]
313
+ return {"Reactive Agent":reactive(), "Goal-Based Agent":goal(),
314
+ "Utility-Based Agent":utility()}
 
 
 
315
 
316
  ROUTES = _get_routes()
317
+ RCOLS = {"Reactive Agent":BLUE, "Goal-Based Agent":GREEN, "Utility-Based Agent":AMBER}
318
  RDESC = {
319
+ "Reactive Agent": "Always picks the nearest unvisited stop. Simple, but not the shortest route.",
320
+ "Goal-Based Agent": "Plans the full route before starting using 2-opt optimisation. Shortest total km.",
321
+ "Utility-Based Agent":"Scores stops by urgency ÷ distance. Reaches high-priority stops first.",
 
 
 
 
 
 
322
  }
323
 
324
+ # ── agent selection row ──────────────────────────────────────────────────
325
+ st.markdown("<div class='slabel'>Choose agent type</div>", unsafe_allow_html=True)
326
+ sel_cols = st.columns(3)
327
+ for col, (name, col_hex) in zip(sel_cols, RCOLS.items()):
328
+ is_sel = st.session_state.get("_ag","Reactive Agent") == name
 
 
 
 
 
 
 
329
  with col:
330
+ border = f"2px solid {col_hex}" if is_sel else "2px solid #e2e8f0"
331
+ bg = _rgba(col_hex, 0.07) if is_sel else "#fff"
332
  st.markdown(f"""
333
+ <div style='border-radius:8px;padding:10px 14px;border:{border};
334
+ background:{bg};margin-bottom:6px;min-height:72px;'>
335
+ <div style='font-weight:800;font-size:.85rem;color:{col_hex};'>{name}</div>
336
+ <div style='font-size:.76rem;color:#475569;margin-top:3px;line-height:1.4;'>
337
+ {RDESC[name]}</div>
 
 
338
  </div>""", unsafe_allow_html=True)
339
+ if st.button("Select" if not is_sel else " Selected",
340
+ key=f"ag_{name[:4]}",
341
+ type="secondary" if not is_sel else "primary",
342
+ use_container_width=True):
343
+ st.session_state["_ag"] = name
344
+ st.session_state["ag_stp"] = 0
345
+ st.session_state["ag_play"] = False
346
  st.rerun()
347
 
348
+ # ── resolve agent state ───────────────────────────────────────────────────
349
+ agent = st.session_state.get("_ag","Reactive Agent")
350
  if agent not in RCOLS:
351
+ agent = "Reactive Agent"; st.session_state["_ag"] = agent
 
 
 
 
352
 
353
  if st.session_state.get("_ag_prev") != agent:
354
  st.session_state["_ag_prev"] = agent
355
+ st.session_state["ag_stp"] = 0
356
+ st.session_state["ag_play"] = False
 
 
357
 
358
+ ac = RCOLS[agent]
359
+ route = ROUTES[agent]
360
+ mx = len(route) - 1
361
+
362
+ if "ag_stp" not in st.session_state:
363
+ st.session_state["ag_stp"] = 0
364
+ if "ag_play" not in st.session_state:
365
+ st.session_state["ag_play"] = False
366
+
367
+ # ── playback controls ─────────────────────────────────────────────────────
368
+ st.markdown("<div style='height:4px;'></div>", unsafe_allow_html=True)
369
+ st.markdown("<div class='slabel'>Playback controls</div>", unsafe_allow_html=True)
370
+
371
+ cb1, cb2, cb3, cb4, cb5 = st.columns([1, 1, 1, 1.8, 2.5])
372
+ if cb1.button("⏮", use_container_width=True, help="Reset to start"):
373
+ st.session_state["ag_stp"] = 0; st.session_state["ag_play"] = False; st.rerun()
374
+ if cb2.button("◀", use_container_width=True, help="Previous stop"):
375
+ st.session_state["ag_stp"] = max(0, st.session_state["ag_stp"]-1)
376
+ st.session_state["ag_play"] = False; st.rerun()
377
+ if cb3.button("▶", use_container_width=True, help="Next stop"):
378
+ st.session_state["ag_stp"] = min(mx, st.session_state["ag_stp"]+1)
379
+ st.session_state["ag_play"] = False; st.rerun()
380
+
381
+ play_lbl = "⏸ Pause" if st.session_state["ag_play"] else "▶ Play"
382
+ if cb4.button(play_lbl, use_container_width=True, type="primary"):
383
+ if st.session_state["ag_stp"] >= mx:
384
+ st.session_state["ag_stp"] = 0
385
+ st.session_state["ag_play"] = not st.session_state["ag_play"]
386
+ st.rerun()
387
 
388
+ ag_speed = cb5.slider("Speed", 1, 8, 3, format="%dx",
389
+ label_visibility="collapsed", key="ag_speed")
390
+
391
+ # step slider — key="ag_stp" binds directly to session state
392
+ st.slider("Step", 0, mx, key="ag_stp",
393
+ format="Stop %d",
394
+ help="Drag to jump to any stop in the route")
395
+
396
+ stp = st.session_state["ag_stp"]
397
+ path_sf = route[:stp+1]
398
+ visited = set(path_sf)
399
+ km_done = sum(_sd(path_sf[i], path_sf[i+1]) for i in range(len(path_sf)-1))
400
+ total_km = sum(_sd(route[i], route[i+1]) for i in range(len(route)-1))
401
+
402
+ # status line
403
+ curr_stop = route[stp]
404
+ st.markdown(
405
+ f"<div style='background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;"
406
+ f"padding:8px 14px;font-size:.85rem;color:#1e293b;margin-bottom:4px;'>"
407
+ f"<b>Currently at:</b> {curr_stop} &nbsp;|&nbsp; "
408
+ f"<b>km done:</b> {km_done:.1f} km &nbsp;|&nbsp; "
409
+ f"<b>Total route:</b> {total_km:.2f} km"
410
+ f"</div>", unsafe_allow_html=True)
411
+
412
+ # ── map ───────────────────────────────────────────────────────────────────
413
+ fig = go.Figure()
414
+
415
+ # background edges
416
+ for na in STOPS:
417
+ for nb in STOPS:
418
+ if na >= nb: continue
419
+ x1,y1,_=STOPS[na]; x2,y2,_=STOPS[nb]
420
+ if math.hypot(x1-x2,y1-y2) < 5.5:
421
+ fig.add_trace(go.Scatter(x=[x1,x2,None],y=[y1,y2,None],mode="lines",
422
+ line=dict(color="#dde6f0",width=1.5),showlegend=False,hoverinfo="skip"))
423
+
424
+ # traveled path drawn so far (thick animated line)
425
+ if len(path_sf) > 1:
426
+ px=[STOPS[n][0] for n in path_sf]; py=[STOPS[n][1] for n in path_sf]
427
+ fig.add_trace(go.Scatter(x=px,y=py,mode="lines",
428
+ line=dict(color=ac,width=6),showlegend=False,hoverinfo="skip"))
429
+
430
+ # unvisited nodes
431
+ for name,(nx,ny,pri) in STOPS.items():
432
+ if name in visited or name=="Depot": continue
433
+ star="★ " if pri>=4 else ""
434
+ fig.add_trace(go.Scatter(x=[nx],y=[ny],mode="markers+text",
435
+ marker=dict(size=20,color="#e2e8f0",line=dict(color="#94a3b8",width=2)),
436
+ text=[star+name.replace("Shop","").strip()],
437
+ textposition="top center",textfont=dict(size=9,color="#94a3b8"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  showlegend=False,
439
+ hovertemplate=f"<b>{name}</b><br>Priority {pri}/5 not visited yet<extra></extra>"))
440
+
441
+ # visited nodes — show visit order number inside circle
442
+ for i,name in enumerate(path_sf):
443
+ if name=="Depot" or name==route[stp]: continue
444
+ nx,ny,pri=STOPS[name]
445
+ fig.add_trace(go.Scatter(x=[nx],y=[ny],mode="markers+text",
446
+ marker=dict(size=30,color=GREEN,line=dict(color="#fff",width=2.5)),
447
+ text=[str(i)],textposition="middle center",
448
+ textfont=dict(size=10,color="#fff",family="monospace"),
449
+ showlegend=False,
450
+ hovertemplate=f"<b>{name}</b><br>Stop #{i} — delivered ✓<extra></extra>"))
451
+
452
+ # current node — large, distinct, clearly highlighted
453
+ cn=route[stp]; cx,cy,cpri=STOPS[cn]
454
+ if cn!="Depot":
455
+ star="★ " if cpri>=4 else ""
456
+ fig.add_trace(go.Scatter(x=[cx],y=[cy],mode="markers+text",
457
+ marker=dict(size=38,color=ac,line=dict(color="#fff",width=4)),
458
+ text=[star+cn.replace("Shop","").strip()],
459
+ textposition="top center",
460
+ textfont=dict(size=10,color=SLATE,family="system-ui",weight=700),
461
+ showlegend=False,
462
+ hovertemplate=f"<b>{cn}</b><br>Delivering here now — Priority {cpri}/5<extra></extra>"))
463
+
464
+ # depot (always on top)
465
+ dx,dy,_=STOPS["Depot"]
466
+ fig.add_trace(go.Scatter(x=[dx],y=[dy],mode="markers+text",
467
+ marker=dict(size=30,color=NAVY,symbol="square",line=dict(color="#fff",width=2.5)),
468
+ text=["D"],textposition="top center",textfont=dict(size=9,color=NAVY),
469
+ showlegend=False,hovertemplate="<b>Depot</b><br>Start & end<extra></extra>"))
470
+
471
+ fig.update_layout(**_layout(460))
472
+ fig.update_xaxes(showgrid=False,showticklabels=False,zeroline=False,range=[-0.8,10.5])
473
+ fig.update_yaxes(showgrid=False,showticklabels=False,zeroline=False,range=[-0.8,10.5])
474
+ st.plotly_chart(fig, use_container_width=True, key="ag_chart")
475
+
476
+ # legend + metrics
477
+ lcol, mcol = st.columns([3,1])
478
+ with lcol:
479
  st.markdown(f"""
480
+ <div class='leg'>
481
+ <div class='li'><div class='ld' style='background:{NAVY};border-radius:3px;'></div>Depot</div>
482
+ <div class='li'><div class='ld' style='background:{ac};'></div>Current stop</div>
483
+ <div class='li'><div class='ld' style='background:{GREEN};'></div>Visited (# = order)</div>
484
+ <div class='li'><div class='ld' style='background:#e2e8f0;border:1.5px solid #94a3b8;'></div>Not visited</div>
485
+ <div class='li'>★ = high priority</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  </div>""", unsafe_allow_html=True)
487
+ with mcol:
488
+ c1,c2,c3=st.columns(3)
489
+ c1.metric("Stop",f"{stp}/{mx}")
490
+ c2.metric("Done",f"{km_done:.1f} km")
491
+ c3.metric("Total",f"{total_km:.2f} km")
492
 
493
  # ── auto-play ─────────────────────────────────────────────────────────────
494
+ if st.session_state["ag_play"] and stp < mx:
495
+ time.sleep(1.0 / ag_speed)
496
+ st.session_state["ag_stp"] = stp + 1
497
  st.rerun()
498
+ elif st.session_state["ag_play"] and stp >= mx:
499
+ st.session_state["ag_play"] = False
500
 
501
  # ══════════════════════════════════════════════════════════════════════════════
502
  # TASK 2 — BIAS
 
504
  with T2:
505
  st.markdown("""
506
  <div class='card card-amber'>
507
+ <strong>What is this?</strong>&nbsp; EcoCart uses K-Means clustering to group customers into
508
+ High / Medium / Low Value segments. Rural customers were being unfairly placed in Low Value —
509
+ this task finds why and fixes it. Fairness threshold: Disparate Impact ≥ 0.80.
 
 
 
 
 
 
 
 
 
 
 
 
510
  </div>""", unsafe_allow_html=True)
511
 
512
  run_t2 = st.button("▶ Run Task 2 — Segmentation & Bias Fix",
513
  type="primary", use_container_width=True, key="run_t2")
 
514
  if run_t2 or st.session_state.get("t2_done"):
515
  st.session_state["t2_done"] = True
516
 
517
  @st.cache_data
518
  def _run_task2():
519
+ spec=importlib.util.spec_from_file_location("task2","task2_segmentation.py")
520
+ m=importlib.util.module_from_spec(spec); buf=io.StringIO()
521
+ with redirect_stdout(buf): spec.loader.exec_module(m); m.main()
 
 
522
  return buf.getvalue()
523
 
524
  with st.spinner("Running task2_segmentation.py …"):
525
+ t2_out = _run_task2()
526
+ st.session_state["t2_text"] = t2_out
527
+
528
+ with st.expander("Terminal output", expanded=False):
529
+ st.markdown(f"""<div class='term'>
530
+ <div class='term-top'>
531
+ <div class='dot' style='background:#ef4444;'></div>
532
+ <div class='dot' style='background:#f59e0b;'></div>
533
+ <div class='dot' style='background:#10b981;'></div>
534
+ <span style='font-size:.7rem;color:#64748b;margin-left:6px;'>task2_segmentation.py</span>
535
+ </div><div class='term-body'>{t2_out}</div></div>""", unsafe_allow_html=True)
536
+
537
+ c1,c2=st.columns(2)
 
 
 
 
 
 
 
 
538
  with c1:
539
  if os.path.exists("output/bias_before_after.png"):
540
  st.image("output/bias_before_after.png",
541
+ caption="Clusters before and after bias mitigation",
542
  use_container_width=True)
543
  with c2:
544
  if os.path.exists("output/disparate_impact.png"):
545
  st.image("output/disparate_impact.png",
546
+ caption="Fairness metrics before vs after",
547
  use_container_width=True)
548
 
549
  st.markdown("""
550
  <div class='insight'>
551
+ <b>Before:</b> 0% of rural customers in High Value — Disparate Impact = 0.0 (biased).<br>
552
+ <b>After fix:</b> 57.3% of rural customers in High Value — Disparate Impact = 0.847 (fair, above 0.80).<br>
553
+ Fix: oversample rural customers + adjust spend features + re-cluster.
 
 
 
 
 
554
  </div>""", unsafe_allow_html=True)
555
 
556
  # ══════════════════════════════════════════════════════════════════════════════
 
559
  with T3:
560
  st.markdown("""
561
  <div class='card card-blue'>
562
+ <strong>What is this?</strong>&nbsp; Four search algorithms (BFS, DFS, A*, IDA*) find routes across
563
+ a 20-node delivery network (10 urban, 10 rural). Run the script to see all results,
564
+ then use the <b>interactive replay</b> below to watch BFS or A* explore node by node.
 
 
 
 
 
 
 
 
 
565
  </div>""", unsafe_allow_html=True)
566
 
567
  run_t3 = st.button("▶ Run Task 3 — Route Optimisation",
568
  type="primary", use_container_width=True, key="run_t3")
 
569
  if run_t3 or st.session_state.get("t3_done"):
570
  st.session_state["t3_done"] = True
571
 
572
  @st.cache_data
573
  def _run_task3():
574
+ spec=importlib.util.spec_from_file_location("task3","task3_4_routing.py")
575
+ m=importlib.util.module_from_spec(spec); buf=io.StringIO()
576
+ with redirect_stdout(buf): spec.loader.exec_module(m); m.main()
 
 
577
  return buf.getvalue()
578
 
579
  with st.spinner("Running task3_4_routing.py …"):
580
+ t3_out = _run_task3()
581
+ st.session_state["t3_text"] = t3_out
582
+
583
+ with st.expander("Terminal output", expanded=False):
584
+ st.markdown(f"""<div class='term'>
585
+ <div class='term-top'>
586
+ <div class='dot' style='background:#ef4444;'></div>
587
+ <div class='dot' style='background:#f59e0b;'></div>
588
+ <div class='dot' style='background:#10b981;'></div>
589
+ <span style='font-size:.7rem;color:#64748b;margin-left:6px;'>task3_4_routing.py</span>
590
+ </div><div class='term-body'>{t3_out}</div></div>""", unsafe_allow_html=True)
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  if os.path.exists("output/network_map.png"):
593
  st.image("output/network_map.png",
594
+ caption="20-node delivery network", use_container_width=True)
595
+ c1,c2=st.columns(2)
 
596
  with c1:
597
  if os.path.exists("output/algo_comparison.png"):
598
  st.image("output/algo_comparison.png",
599
+ caption="A* vs IDA* comparison", use_container_width=True)
 
600
  with c2:
601
  if os.path.exists("output/green_vs_fast.png"):
602
  st.image("output/green_vs_fast.png",
603
+ caption="Fastest vs lowest CO₂ route", use_container_width=True)
 
604
 
605
  st.markdown("""
606
  <div class='insight'>
607
+ A* found the optimal path (5.69 km) expanding only 7 nodes.
608
+ BFS found same path (11 nodes). DFS was not optimal (6.84 km, 18 nodes).
609
+ IDA* matched A* cost but expanded 43 nodes better for very large networks (no RAM needed).
 
 
 
 
610
  </div>""", unsafe_allow_html=True)
611
 
612
+ # ── interactive route replay ──────────────────────────────────────────────
613
+ st.markdown("<br>", unsafe_allow_html=True)
614
  st.markdown("""
615
+ <div style='font-weight:700;font-size:1rem;color:#0f172a;margin-bottom:10px;'>
616
+ Interactive search replay — watch the algorithm explore step by step
617
  </div>""", unsafe_allow_html=True)
618
 
619
  NODES_R = {
 
626
  "R7":(6.5,6.0,"rural"), "R8":(9.0,7.0,"rural"), "R9":(11.0,6.0,"rural"),
627
  "R10":(8.0,5.5,"rural"),
628
  }
629
+ _EP = [
630
  ("U1","U2"),("U2","U3"),("U1","U4"),("U2","U4"),("U2","U5"),
631
  ("U3","U6"),("U4","U5"),("U5","U6"),("U4","U7"),("U5","U8"),
632
  ("U6","U10"),("U7","U8"),("U8","U9"),("U9","U10"),("U5","U9"),
 
635
  ("R7","R8"),("R8","R9"),("R6","R9"),("R8","R10"),("R5","R8"),
636
  ("U3","R1"),("U10","R4"),("U6","R1"),("U9","R7"),
637
  ]
638
+ def _nd(a,b): return math.hypot(NODES_R[a][0]-NODES_R[b][0],NODES_R[a][1]-NODES_R[b][1])
639
+ EDGES = [(a,b,round(_nd(a,b)*1.15,2)) for a,b in _EP]
640
+ ADJ = {n:[] for n in NODES_R}
641
+ for a,b,w in EDGES: ADJ[a].append((b,w)); ADJ[b].append((a,w))
642
+ def _ew(a,b):
643
+ for nb,w in ADJ[a]:
644
  if nb==b: return w
645
  return math.inf
646
 
647
+ def _astar(s,g):
648
+ ctr=0; h=lambda n:_nd(n,g); expl=[]
649
  heap=[(h(s),0.0,ctr,s,[s])]; best={s:0.0}
650
  while heap:
651
  _,gc,_,n,p=heapq.heappop(heap)
652
  if n==g: return p,round(gc,2),expl
653
  if gc>best.get(n,math.inf): continue
654
  expl.append(n)
655
+ for nb,w in ADJ[n]:
656
  ng=gc+w
657
  if ng<best.get(nb,math.inf):
658
  best[nb]=ng; ctr+=1
659
  heapq.heappush(heap,(ng+h(nb),ng,ctr,nb,p+[nb]))
660
  return None,0.0,expl
661
 
662
+ def _bfs(s,g):
663
  q=deque([(s,[s])]); seen={s}; expl=[]
664
  while q:
665
  n,p=q.popleft(); expl.append(n)
666
+ if n==g: return p,round(sum(_ew(p[i],p[i+1]) for i in range(len(p)-1)),2),expl
667
+ for nb,_ in ADJ[n]:
668
  if nb not in seen: seen.add(nb); q.append((nb,p+[nb]))
669
  return None,0.0,expl
670
 
671
+ # config row
672
+ cfg1, cfg2, cfg3, cfg4 = st.columns([1.5, 1.5, 1.5, 1.5])
673
+ all_n = list(NODES_R.keys())
674
+ sn = cfg1.selectbox("Start", all_n, index=0, key="r_sn")
675
+ en = cfg2.selectbox("End", all_n, index=19, key="r_en")
676
+ algo = cfg3.radio("Algorithm", ["A*","BFS"], key="r_algo", horizontal=True)
677
+ rp_speed = cfg4.slider("Speed", 1, 8, 3, format="%dx", key="rp_spd")
678
+
679
+ fn = _astar if algo=="A*" else _bfs
680
+ if sn != en:
681
+ path_r, cost_r, expl_r = fn(sn, en)
682
+ else:
683
+ path_r, cost_r, expl_r = [], 0.0, []
684
 
685
+ if sn != en and expl_r:
686
+ max_rp = len(expl_r)
687
+
688
+ # reset replay step when route/algo changes
689
+ route_key = f"{sn}_{en}_{algo}"
690
+ if st.session_state.get("_rk") != route_key:
691
+ st.session_state["_rk"] = route_key
692
+ st.session_state["rp"] = max_rp
693
+ st.session_state["rp_pl"] = False
694
+
695
+ if "rp" not in st.session_state: st.session_state["rp"] = max_rp
696
+ if "rp_pl" not in st.session_state: st.session_state["rp_pl"] = False
697
+
698
+ # playback controls
699
+ rb1,rb2,rb3,rb4 = st.columns([1,1,1,2])
700
+ if rb1.button("⏮", use_container_width=True, key="rp_rst"):
701
+ st.session_state["rp"]=0; st.session_state["rp_pl"]=False; st.rerun()
702
+ if rb2.button("◀", use_container_width=True, key="rp_bk"):
703
+ st.session_state["rp"]=max(0,st.session_state["rp"]-1)
704
+ st.session_state["rp_pl"]=False; st.rerun()
705
+ if rb3.button("▶", use_container_width=True, key="rp_fw"):
706
+ st.session_state["rp"]=min(max_rp,st.session_state["rp"]+1)
707
+ st.session_state["rp_pl"]=False; st.rerun()
708
+ rp_lbl = "⏸ Pause" if st.session_state["rp_pl"] else "▶ Play search"
709
+ if rb4.button(rp_lbl, use_container_width=True, type="primary", key="rp_btn"):
710
+ if st.session_state["rp"] >= max_rp: st.session_state["rp"]=0
711
+ st.session_state["rp_pl"] = not st.session_state["rp_pl"]; st.rerun()
712
+
713
+ # slider — key="rp" binds directly to session state
714
+ st.slider("Nodes explored", 0, max_rp, key="rp",
715
+ help="Drag to replay how the algorithm searches node by node")
716
+
717
+ rp = st.session_state["rp"]
718
+ done = (rp == max_rp)
719
+ explored = set(expl_r[:rp])
720
+ cur_node = expl_r[rp-1] if rp > 0 else None
721
+ path_set = set(path_r) if path_r else set()
722
+
723
+ st.progress(rp/max_rp,
724
+ text=f"{rp}/{max_rp} nodes explored"
725
+ +(" · Path found ✓" if done and path_r else ""))
726
+
727
+ # map
728
+ f2 = go.Figure()
729
+
730
+ # edges
731
+ for a,b,_ in EDGES:
732
+ f2.add_trace(go.Scatter(x=[NODES_R[a][0],NODES_R[b][0],None],
733
+ y=[NODES_R[a][1],NODES_R[b][1],None],mode="lines",
734
+ line=dict(color="#dde6f0",width=1.5),showlegend=False,hoverinfo="skip"))
735
+
736
+ # final path highlight (thick amber)
737
+ if path_r and done:
738
+ for i in range(len(path_r)-1):
739
+ pa,pb=path_r[i],path_r[i+1]
740
+ f2.add_trace(go.Scatter(
741
+ x=[NODES_R[pa][0],NODES_R[pb][0],None],
742
+ y=[NODES_R[pa][1],NODES_R[pb][1],None],mode="lines",
743
+ line=dict(color=AMBER,width=7),showlegend=False,hoverinfo="skip"))
744
+
745
+ # nodes
746
+ for zone, zcol in [("urban",RED),("rural",GREEN)]:
747
+ ns=[(n,d) for n,d in NODES_R.items() if d[2]==zone]
748
+ for n,d in ns:
749
+ if n==sn: nc,sz="white",26
750
+ elif n==en: nc,sz="#fde68a",26
751
+ elif n in path_set and done: nc,sz=AMBER,24
752
+ elif n in explored: nc,sz="#93c5fd",20
753
+ else: nc,sz=zcol,16
754
+
755
+ # ring around currently expanding node
756
+ if n==cur_node and not done:
757
+ f2.add_trace(go.Scatter(x=[d[0]],y=[d[1]],mode="markers",
758
+ marker=dict(size=34,color=_rgba(BLUE,0.2),
759
+ line=dict(color=BLUE,width=2)),
760
+ showlegend=False,hoverinfo="skip"))
761
+
762
+ state=("Final path" if n in path_set and done
763
+ else "Exploring now" if n==cur_node
764
+ else "Explored" if n in explored
765
+ else "Not explored")
766
+ f2.add_trace(go.Scatter(x=[d[0]],y=[d[1]],mode="markers+text",
767
+ marker=dict(size=sz,color=nc,line=dict(color=SLATE,width=1.5)),
768
+ text=[n],textposition="middle center",
769
+ textfont=dict(size=7.5,color="#fff" if n in explored and n not in (sn,en) else SLATE),
770
+ showlegend=False,
771
+ hovertemplate=f"<b>{n}</b><br>{state}<extra></extra>"))
772
+
773
+ f2.update_layout(**_layout(460))
774
+ f2.update_xaxes(showgrid=False,showticklabels=False,zeroline=False)
775
+ f2.update_yaxes(showgrid=False,showticklabels=False,zeroline=False)
776
+ st.plotly_chart(f2, use_container_width=True, key="rp_chart")
777
+
778
+ # legend
779
  st.markdown(f"""
780
+ <div class='leg'>
781
+ <div class='li'><div class='ld' style='background:white;border:1.5px solid #334155;'></div>Start</div>
782
+ <div class='li'><div class='ld' style='background:#fde68a;'></div>End</div>
783
+ <div class='li'><div class='ld' style='background:#93c5fd;'></div>Explored</div>
784
+ <div class='li'><div class='ld' style='background:{AMBER};'></div>Final path</div>
785
+ <div class='li'><div class='ld' style='background:{RED};'></div>Urban</div>
786
+ <div class='li'><div class='ld' style='background:{GREEN};'></div>Rural</div>
 
787
  </div>""", unsafe_allow_html=True)
788
 
789
+ if path_r and done:
790
+ m1,m2,m3=st.columns(3)
791
+ m1.metric("Path cost", f"{cost_r:.2f} km")
792
+ m2.metric("Nodes explored",len(expl_r))
793
+ m3.metric("Path", f"{len(path_r)} nodes")
794
+ st.markdown(f"""
795
+ <div class='insight'>
796
+ <b>Route:</b> {" → ".join(path_r)}<br>
797
+ {algo} explored {len(expl_r)} nodes to find a {cost_r:.2f} km route from {sn} to {en}.
798
+ </div>""", unsafe_allow_html=True)
799
 
800
+ # auto-play replay
801
+ if st.session_state["rp_pl"] and rp < max_rp:
802
+ time.sleep(0.9 / rp_speed)
803
+ st.session_state["rp"] = rp + 1
804
+ st.rerun()
805
+ elif st.session_state["rp_pl"] and rp >= max_rp:
806
+ st.session_state["rp_pl"] = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
 
808
  # ══════════════════════════════════════════════════════════════════════════════
809
  # TASK 4 — A* vs IDA*
810
  # ══════════════════════════════════════════════════════════════════════════════
811
  with T4:
812
  st.markdown("""
813
+ <div class='card card-navy'>
814
+ <strong>What is this?</strong>&nbsp; A* and IDA* both find the optimal route but work differently.
815
+ A* stores all explored nodes in memory (fast). IDA* uses almost no memory by re-searching with a
816
+ stricter cost limit each pass. This tab shows benchmark results: 10 routes, 20 runs each.
 
 
 
 
 
 
 
 
 
 
817
  </div>""", unsafe_allow_html=True)
818
 
819
+ urban_data=[
820
+ ["U1→U10","5.69","7","0.170","5.69","43","0.559"],
821
+ ["U7→U6", "4.21","5","0.088","4.21","22","0.472"],
822
+ ["U2→U9", "3.11","2","0.073","3.11","6", "0.129"],
823
+ ["U1→U9", "4.40","4","0.088","4.40","15","0.223"],
824
+ ["U3→U8", "4.21","5","0.114","4.21","19","0.283"],
825
  ]
826
+ rural_data=[
827
  ["R1→R9", "10.39","6","0.134","10.39","34","0.466"],
828
  ["R2→R8", "7.82", "4","0.101","7.82", "14","0.224"],
829
  ["R3→R10","6.77", "5","0.107","6.77", "21","0.279"],
830
  ["R1→R6", "7.51", "3","0.065","7.51", "10","0.149"],
831
  ["R4→R9", "7.82", "7","0.130","7.82", "50","0.642"],
832
  ]
833
+ hdr=["Route","A* km","A* nodes","A* ms","IDA* km","IDA* nodes","IDA* ms"]
834
+ cu,cr=st.columns(2)
 
835
  with cu:
836
+ st.markdown("**Urban routes**")
837
+ st.dataframe(pd.DataFrame(urban_data,columns=hdr),use_container_width=True,hide_index=True)
 
838
  with cr:
839
+ st.markdown("**Rural routes**")
840
+ st.dataframe(pd.DataFrame(rural_data,columns=hdr),use_container_width=True,hide_index=True)
841
+
842
+ all_rows=urban_data+rural_data
843
+ routes=[r[0] for r in all_rows]
844
+ a_n=[int(r[2]) for r in all_rows]; i_n=[int(r[5]) for r in all_rows]
845
+ a_m=[float(r[3]) for r in all_rows]; i_m=[float(r[6]) for r in all_rows]
846
+ fig4=make_subplots(rows=1,cols=2,
847
+ subplot_titles=["Nodes expanded (fewer = smarter)","Time ms (lower = faster)"])
848
+ for ci,(av,iv) in enumerate([(a_n,i_n),(a_m,i_m)],1):
849
+ fig4.add_trace(go.Bar(name="A*", x=routes,y=av,marker_color=BLUE, showlegend=(ci==1)),row=1,col=ci)
850
+ fig4.add_trace(go.Bar(name="IDA*",x=routes,y=iv,marker_color=AMBER,showlegend=(ci==1)),row=1,col=ci)
851
+ fig4.update_layout(paper_bgcolor=SURF,plot_bgcolor=BG,font_color=SLATE,
852
+ barmode="group",height=360,margin=dict(l=40,r=20,t=50,b=80),
853
+ legend=dict(bgcolor=SURF,bordercolor=BORDER))
854
+ fig4.update_xaxes(gridcolor=BORDER,tickangle=45)
855
+ fig4.update_yaxes(gridcolor=BORDER)
856
+ st.plotly_chart(fig4,use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
857
 
858
  if os.path.exists("output/algo_comparison.png"):
859
+ st.image("output/algo_comparison.png",caption="From task3_4_routing.py",use_container_width=True)
 
 
 
 
 
 
860
 
861
  st.markdown("""
862
  <div class='insight'>
863
+ Both algorithms found <b>identical optimal paths</b> on every route.<br>
864
+ A* expanded fewer nodes every time (e.g. R4→R9: A*=7, IDA*=50). A* is faster on small graphs.<br>
865
+ IDA* uses O(depth) memory vs A*'s O(b<sup>d</sup>). On a national network with millions of nodes,
866
+ IDA* would be the only practical choice.
 
 
 
867
  </div>""", unsafe_allow_html=True)
868
 
869
  # ══════════════════════════════════════════════════════════════════════════════
 
872
  with T5:
873
  st.markdown("""
874
  <div class='card card-green'>
875
+ <strong>What is this?</strong>&nbsp; Two ML models — Linear Regression and Random Forest —
876
+ are trained on 730 days of sales data and tested on 140 unseen days to predict daily demand.
 
 
 
 
 
 
 
 
 
 
 
877
  </div>""", unsafe_allow_html=True)
878
 
879
  run_t5 = st.button("▶ Run Task 5 — Demand Forecasting",
880
  type="primary", use_container_width=True, key="run_t5")
 
881
  if run_t5 or st.session_state.get("t5_done"):
882
  st.session_state["t5_done"] = True
883
 
884
  @st.cache_data
885
  def _run_task5():
886
+ spec=importlib.util.spec_from_file_location("task5","task5_forecasting.py")
887
+ m=importlib.util.module_from_spec(spec); buf=io.StringIO()
888
+ with redirect_stdout(buf): spec.loader.exec_module(m); m.main()
 
 
889
  return buf.getvalue()
890
 
891
  with st.spinner("Running task5_forecasting.py …"):
892
+ t5_out = _run_task5()
893
+ st.session_state["t5_text"] = t5_out
894
+
895
+ with st.expander("Terminal output", expanded=False):
896
+ st.markdown(f"""<div class='term'>
897
+ <div class='term-top'>
898
+ <div class='dot' style='background:#ef4444;'></div>
899
+ <div class='dot' style='background:#f59e0b;'></div>
900
+ <div class='dot' style='background:#10b981;'></div>
901
+ <span style='font-size:.7rem;color:#64748b;margin-left:6px;'>task5_forecasting.py</span>
902
+ </div><div class='term-body'>{t5_out}</div></div>""", unsafe_allow_html=True)
903
+
904
+ m1,m2,m3,m4=st.columns(4)
905
+ m1.metric("LR — MAE","9.62 units"); m2.metric("LR — R²","0.762")
906
+ m3.metric("RF — MAE","9.75 units"); m4.metric("RF — R²","0.716")
907
 
 
 
 
 
908
  if os.path.exists("output/forecast.png"):
909
  st.image("output/forecast.png",
910
+ caption="Actual vs predicted sales 140 test days",
911
  use_container_width=True)
912
+ c1,c2=st.columns(2)
913
  with c1:
914
  if os.path.exists("output/residuals.png"):
915
+ st.image("output/residuals.png",caption="Residuals",use_container_width=True)
 
 
916
  with c2:
917
  if os.path.exists("output/feature_importance.png"):
918
  st.image("output/feature_importance.png",
919
+ caption="Feature importance",use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
920
 
921
  st.markdown("""
922
  <div class='insight'>
923
+ Linear Regression (R²=0.762) beat Random Forest (R²=0.716) on this dataset.<br>
924
+ Top 3 features: <b>lag_7</b> (same day last week), <b>lag_14</b>, and <b>is_promo</b>.
925
+ Weekly patterns and promotions drive demand most.
 
 
 
 
926
  </div>""", unsafe_allow_html=True)
927
 
928
  # ══════════════════════════════════════════════════════════════════════════════
 
931
  with T6:
932
  st.markdown("""
933
  <div class='card card-amber'>
934
+ <strong>What is this?</strong>&nbsp; An estimated business case for deploying the AI system.
935
+ All numbers are assumptions — adjust the sliders to model different scenarios.
 
 
 
 
 
936
  </div>""", unsafe_allow_html=True)
937
 
938
+ ctrl, main = st.columns([1, 3])
939
+ with ctrl:
940
+ fleet =st.slider("Fleet (vehicles)", 5,100,30,5)
941
+ daily =st.slider("Deliveries/vehicle/day",10, 80,40,5)
942
+ avg_km =st.slider("Avg km per delivery", 2, 30,12,1)
943
+ fuel =st.slider("Fuel €/km", 0.10,0.60,0.32,0.01,format="€%.2f")
944
+ wage =st.slider("Driver wage €/hr", 10, 35,18,1,format="€%d")
945
+ days_yr=st.slider("Working days/year", 200,365,300,10)
946
+ rt_save=st.slider("Route saving % (A*)", 5, 35,18,1,format="%d%%")
947
+ seg_rev=st.slider("Rural revenue uplift €k",10,200,65,5)
948
+
949
+ with main:
950
+ total_km =fleet*daily*days_yr*avg_km
951
+ km_saved =total_km*rt_save/100
952
+ fuel_saved=km_saved*fuel
953
+ time_saved=(km_saved/40)*wage
954
+ route_save=fuel_saved+time_saved
955
+ seg_save =seg_rev*1000
956
+ total_eur =route_save+seg_save
957
+ co2 =km_saved*0.24/1000
958
+ dev=45000; ops=8000
959
+ payback=round((dev+ops)/total_eur*12,1) if total_eur>0 else 0
960
+ roi3 =round((total_eur*3-(dev+ops*3))/(dev+ops*3)*100,1)
961
+
962
+ mc=st.columns(4)
963
+ mc[0].metric("Est. annual saving",f"€{round(total_eur/1000,1)}k")
964
+ mc[1].metric("Est. payback",f"{payback} months")
965
+ mc[2].metric("3-year ROI",f"{roi3}%")
966
+ mc[3].metric("CO₂ saved/yr",f"{co2:.1f} t")
967
+
968
+ cats=["Route Optimisation (A*)","Fairer Segmentation (rural)"]
969
+ vals=[round(route_save/1000,1),round(seg_save/1000,1)]
970
+ fb=go.Figure(go.Bar(x=cats,y=vals,marker_color=[BLUE,GREEN],
971
+ text=[f"€{v}k" for v in vals],textposition="outside",
972
+ textfont_color=SLATE,width=0.4))
973
+ fb.update_layout(**_layout(250)); fb.update_xaxes(gridcolor=BORDER)
974
+ fb.update_yaxes(gridcolor=BORDER,title="€ thousands")
975
+ st.plotly_chart(fb,use_container_width=True)
976
+
977
+ years=[0,1,2,3]
978
+ ben=[0,total_eur,total_eur*2,total_eur*3]
979
+ cost=[0,dev+ops,dev+ops*2,dev+ops*3]
980
+ fr=go.Figure()
981
+ fr.add_trace(go.Scatter(x=years,y=[v/1000 for v in ben],name="Benefit",
982
+ line=dict(color=GREEN,width=2.5),mode="lines+markers"))
983
+ fr.add_trace(go.Scatter(x=years,y=[v/1000 for v in cost],name="Cost",
984
+ line=dict(color=RED,width=2.5,dash="dash"),mode="lines+markers"))
985
+ fr.add_hline(y=0,line_color=MUTED,line_width=1,line_dash="dot")
986
+ fr.update_layout(**_layout(270))
987
+ fr.update_xaxes(gridcolor=BORDER,tickvals=[0,1,2,3],
988
+ ticktext=["Now","Year 1","Year 2","Year 3"])
989
+ fr.update_yaxes(gridcolor=BORDER,title="€ thousands")
990
+ st.plotly_chart(fr,use_container_width=True)
991
 
992
+ st.markdown(f"""
993
+ <div class='warn-box'>
994
+ All numbers are <b>assumptions for illustration</b>, not measured values.
995
+ Based on: {fleet} vehicles · {daily} deliveries/day · {avg_km} km avg ·
996
+ {rt_save}% A* saving · €{seg_rev}k rural uplift.
997
+ </div>""", unsafe_allow_html=True)