Esvanth commited on
Commit
0b2ad97
·
1 Parent(s): ff6f491

Redesign UI: professional design system, animated node halos, visit-order numbers, auto-play for route replay

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