Redesign UI: professional design system, animated node halos, visit-order numbers, auto-play for route replay
Browse files
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.
|
| 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.
|
| 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 =
|
| 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 |
-
|
| 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,
|
| 157 |
-
"Random Forest: MAE=9.75, RMSE=13.50,
|
| 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
|
|
|
|
| 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 |
-
#
|
| 183 |
-
|
| 184 |
-
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
st.markdown("""
|
| 187 |
<style>
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 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
|
| 243 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 244 |
with st.sidebar:
|
| 245 |
-
st.markdown("### EcoCart AI System")
|
| 246 |
-
st.markdown("**How to use this app:**")
|
| 247 |
st.markdown("""
|
| 248 |
-
|
| 249 |
-
2.
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
""")
|
| 254 |
st.divider()
|
| 255 |
-
st.caption("All outputs are from the actual task Python scripts.")
|
| 256 |
|
| 257 |
-
#
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
T1, T2, T3, T4, T5, T6 = st.tabs([
|
| 262 |
-
"🤖
|
| 263 |
-
"⚖️
|
| 264 |
-
"🗺️
|
| 265 |
-
"📊
|
| 266 |
-
"📈
|
| 267 |
-
"💼
|
| 268 |
])
|
| 269 |
|
| 270 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 271 |
-
# TASK 1 — AI AGENTS
|
| 272 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 273 |
with T1:
|
|
|
|
| 274 |
st.markdown("""
|
| 275 |
-
<div class='
|
| 276 |
-
<
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
| 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,
|
| 292 |
-
return math.hypot(ax
|
| 293 |
|
| 294 |
@st.cache_data
|
| 295 |
def _get_routes():
|
| 296 |
def reactive():
|
| 297 |
-
r
|
| 298 |
while u:
|
| 299 |
-
nb
|
| 300 |
-
return r
|
| 301 |
def goal():
|
| 302 |
-
r
|
| 303 |
-
td
|
| 304 |
-
ok
|
| 305 |
while ok:
|
| 306 |
-
ok
|
| 307 |
-
for i in range(1,
|
| 308 |
-
for j in range(i+1,
|
| 309 |
-
nr
|
| 310 |
-
if td(nr)
|
| 311 |
-
return r
|
| 312 |
def utility():
|
| 313 |
-
r
|
| 314 |
while u:
|
| 315 |
-
nb
|
| 316 |
-
r.append(nb); u.remove(nb); c
|
| 317 |
-
return r
|
| 318 |
return {
|
| 319 |
-
"Reactive Agent
|
| 320 |
-
"Goal-Based Agent
|
| 321 |
-
"Utility-Based Agent
|
| 322 |
}
|
| 323 |
|
| 324 |
ROUTES = _get_routes()
|
| 325 |
-
RCOLS = {
|
| 326 |
-
|
| 327 |
-
"
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
|
| 331 |
-
# ──
|
| 332 |
-
st.markdown("
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
stp = st.session_state.get("stp", 0)
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
playing = st.session_state.get("playing", False)
|
| 354 |
-
if
|
|
|
|
| 355 |
st.session_state["playing"] = not playing
|
| 356 |
-
speed =
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
unsafe_allow_html=True)
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
st.markdown("""
|
| 404 |
-
<div class='step-
|
| 405 |
-
|
| 406 |
-
<
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 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
|
| 421 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 422 |
with T2:
|
| 423 |
st.markdown("""
|
| 424 |
-
<div class='
|
| 425 |
-
<
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
<
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
-
st.markdown("
|
| 434 |
-
|
| 435 |
-
|
|
|
|
| 436 |
|
| 437 |
-
run_t2 = st.button("▶ Run Task 2 — Segmentation & Bias Fix",
|
| 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("
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 —
|
| 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
|
| 484 |
-
<
|
| 485 |
-
Before the fix: 0% of rural customers were in High Value.
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
(
|
| 490 |
-
|
| 491 |
-
|
|
|
|
| 492 |
|
| 493 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 494 |
-
# TASK 3 — ROUTES (
|
| 495 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 496 |
with T3:
|
| 497 |
st.markdown("""
|
| 498 |
-
<div class='
|
| 499 |
-
<
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
|
|
|
| 506 |
|
| 507 |
-
st.markdown("
|
| 508 |
-
|
| 509 |
-
|
|
|
|
| 510 |
|
| 511 |
-
run_t3 = st.button("▶ Run Task 3 — Route Optimisation",
|
| 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("
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 555 |
use_container_width=True)
|
| 556 |
|
| 557 |
st.markdown("""
|
| 558 |
-
<div class='insight
|
| 559 |
-
<
|
| 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
|
| 563 |
-
DFS is the only algorithm that does not guarantee the shortest path.<br><br>
|
| 564 |
-
Green routing:
|
| 565 |
-
|
| 566 |
-
</div>
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
st.markdown("
|
| 571 |
-
|
| 572 |
-
|
|
|
|
| 573 |
|
| 574 |
-
|
| 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(
|
| 595 |
EDGES2 = [(a,b,round(_nd2(a,b)*1.15,2)) for a,b in _EP2]
|
| 596 |
-
ADJ2 = {n:[] for n in
|
| 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 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
if sn_r != en_r and expl_r:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
replay = st.slider(
|
| 643 |
-
"Drag to replay the search
|
| 644 |
-
0,
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
st.
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=[
|
| 671 |
-
mode="
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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",
|
| 690 |
mc[1].metric("Nodes explored", len(expl_r))
|
| 691 |
-
mc[2].metric("Path",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
|
| 693 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 694 |
-
# TASK 4 — A* vs IDA*
|
| 695 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 696 |
with T4:
|
| 697 |
st.markdown("""
|
| 698 |
-
<div class='
|
| 699 |
-
<
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
|
|
|
|
|
|
| 706 |
|
| 707 |
-
st.markdown("
|
| 708 |
-
|
| 709 |
-
|
|
|
|
| 710 |
|
| 711 |
urban_data = [
|
| 712 |
-
["U1→U10",
|
| 713 |
-
["U7→U6",
|
| 714 |
-
["U2→U9",
|
| 715 |
-
["U1→U9",
|
| 716 |
-
["U3→U8",
|
| 717 |
]
|
| 718 |
rural_data = [
|
| 719 |
-
["R1→R9",
|
| 720 |
-
["R2→R8",
|
| 721 |
-
["R3→R10",
|
| 722 |
-
["R1→R6",
|
| 723 |
-
["R4→R9",
|
| 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("
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 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])
|
| 750 |
-
i_nodes = [int(r[5])
|
| 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
|
| 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=
|
| 764 |
barmode="group", height=360,
|
| 765 |
margin=dict(l=40,r=20,t=50,b=80),
|
| 766 |
-
legend=dict(bgcolor=SURF,bordercolor=
|
| 767 |
-
fig_cmp.update_xaxes(gridcolor=
|
| 768 |
-
fig_cmp.update_yaxes(gridcolor=
|
| 769 |
st.plotly_chart(fig_cmp, use_container_width=True)
|
| 770 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
st.markdown("""
|
| 772 |
-
<div class='insight
|
| 773 |
-
<
|
| 774 |
-
Both algorithms found <
|
| 775 |
-
|
| 776 |
-
A*
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
""", unsafe_allow_html=True)
|
| 781 |
|
| 782 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 783 |
-
# TASK 5 — FORECASTING
|
| 784 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 785 |
with T5:
|
| 786 |
st.markdown("""
|
| 787 |
-
<div class='
|
| 788 |
-
<
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
</
|
| 794 |
-
|
|
|
|
|
|
|
| 795 |
|
| 796 |
-
st.markdown("
|
| 797 |
-
|
| 798 |
-
|
|
|
|
| 799 |
|
| 800 |
-
run_t5 = st.button("▶ Run Task 5 — Demand Forecasting",
|
| 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("
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 829 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
if os.path.exists("output/forecast.png"):
|
| 831 |
st.image("output/forecast.png",
|
| 832 |
-
caption="forecast.png — actual vs predicted daily sales
|
| 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-
|
| 849 |
-
|
| 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
|
| 860 |
-
<
|
| 861 |
-
Linear Regression achieved R² = 0.762, meaning it explains 76.2%
|
| 862 |
-
Random Forest achieved R² = 0.716.
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
</
|
| 866 |
-
|
|
|
|
| 867 |
|
| 868 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 869 |
# TASK 6 — BUSINESS CASE
|
| 870 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 871 |
with T6:
|
| 872 |
st.markdown("""
|
| 873 |
-
<div class='
|
| 874 |
-
<
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
<br><br>
|
| 878 |
-
<
|
| 879 |
-
Adjust the sliders to model different scenarios.
|
| 880 |
-
</
|
| 881 |
-
""", unsafe_allow_html=True)
|
| 882 |
|
| 883 |
c_ctrl, c_main = st.columns([1, 3])
|
| 884 |
|
| 885 |
with c_ctrl:
|
| 886 |
-
st.markdown("
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 907 |
-
roi3 = round((total_eur*3
|
| 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=
|
| 920 |
-
fig_b.update_layout(**_ch(
|
| 921 |
-
fig_b.update_xaxes(gridcolor=
|
| 922 |
-
fig_b.update_yaxes(gridcolor=
|
| 923 |
st.plotly_chart(fig_b, use_container_width=True)
|
| 924 |
|
| 925 |
-
years
|
| 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=
|
| 934 |
-
fig_r.update_layout(**_ch(
|
| 935 |
-
fig_r.update_xaxes(gridcolor=
|
| 936 |
ticktext=["Now","Year 1","Year 2","Year 3"])
|
| 937 |
-
fig_r.update_yaxes(gridcolor=
|
| 938 |
st.plotly_chart(fig_r, use_container_width=True)
|
| 939 |
|
| 940 |
st.markdown(
|
| 941 |
-
f"<div class='warn-
|
| 942 |
-
f"All numbers are <
|
| 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 · Customer segmentation · 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
|
| 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 <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)
|