""" EcoCart AI System - Streamlit App NCI MSCAI | Fundamentals of AI TABA 2026 """ import io, os, math, heapq, time, importlib.util from contextlib import redirect_stdout from collections import deque import matplotlib matplotlib.use("Agg") os.makedirs("output", exist_ok=True) import numpy as np import pandas as pd import streamlit as st from docx import Document from docx.shared import Inches, Pt, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH import plotly.graph_objects as go from plotly.subplots import make_subplots # ══════════════════════════════════════════════════════════════════════════════ # REPORT GENERATOR (unchanged) # ══════════════════════════════════════════════════════════════════════════════ def _build_report(t2_text, t3_text, t5_text): TNR = "Times New Roman" doc = Document() doc.styles["Normal"].font.name = TNR doc.styles["Normal"].font.size = Pt(12) doc.styles["Normal"].paragraph_format.space_after = Pt(8) for lvl, sz in [(1,14),(2,13)]: s = doc.styles[f"Heading {lvl}"] s.font.name = TNR; s.font.bold = True; s.font.size = Pt(sz) s.font.color.rgb = RGBColor(0,0,0) s.paragraph_format.space_before = Pt(12) s.paragraph_format.space_after = Pt(4) def H(txt, lv=1): p = doc.add_heading(txt, level=lv); p.alignment = WD_ALIGN_PARAGRAPH.LEFT for r in p.runs: r.font.name=TNR; r.font.color.rgb=RGBColor(0,0,0) def P(txt): p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY p.paragraph_format.space_after = Pt(8) r = p.add_run(txt); r.font.name=TNR; r.font.size=Pt(12) def CODE(txt): p = doc.add_paragraph(); p.paragraph_format.space_after = Pt(6) r = p.add_run(txt); r.font.name="Courier New"; r.font.size=Pt(9) def IMG(path, caption="", width=5.5): if os.path.exists(path): doc.add_picture(path, width=Inches(width)) doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER if caption: cp = doc.add_paragraph(caption); cp.alignment = WD_ALIGN_PARAGRAPH.CENTER cp.paragraph_format.space_after = Pt(8) for r in cp.runs: r.font.name=TNR; r.font.size=Pt(10); r.font.italic=True def SP(): doc.add_paragraph("").paragraph_format.space_after = Pt(4) SP(); SP() p = doc.add_paragraph(); p.alignment = WD_ALIGN_PARAGRAPH.CENTER r = p.add_run("EcoCart AI System"); r.font.name=TNR; r.font.size=Pt(24); r.font.bold=True p.paragraph_format.space_after = Pt(8) p2 = doc.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER r2 = p2.add_run("Technical Report - TABA Section II"); r2.font.name=TNR; r2.font.size=Pt(14) p2.paragraph_format.space_after = Pt(20) for line in ["National College of Ireland","MSc Artificial Intelligence", "Fundamentals of Artificial Intelligence","May 2026"]: lp = doc.add_paragraph(); lp.alignment = WD_ALIGN_PARAGRAPH.CENTER lr = lp.add_run(line); lr.font.name=TNR; lr.font.size=Pt(12) lp.paragraph_format.space_after = Pt(4) SP() lnk = doc.add_paragraph(); lnk.alignment = WD_ALIGN_PARAGRAPH.CENTER lr2 = lnk.add_run("Live Demo: https://ecocart-ai-app-live.streamlit.app") lr2.font.name=TNR; lr2.font.size=Pt(11); lr2.font.bold=True lr2.font.color.rgb = RGBColor(37,99,235) doc.add_page_break() H("Task 2 - Customer Segmentation & Bias Mitigation") P("Running task2_segmentation.py produced the following output:") if t2_text: CODE(t2_text) SP(); IMG("output/bias_before_after.png","Figure 1: Customer clusters before and after bias mitigation") SP(); IMG("output/disparate_impact.png","Figure 2: Disparate Impact and High Value rates before and after fix") SP(); P("Before: DI=0.0 (biased). After: DI=0.847 (fair, above 0.80).") doc.add_page_break() H("Tasks 3 & 4 - Route Optimisation and Algorithm Comparison") P("Running task3_4_routing.py produced the following output:") if t3_text: CODE(t3_text) SP(); IMG("output/network_map.png","Figure 3: EcoCart 20-node delivery network") SP(); IMG("output/algo_comparison.png","Figure 4: A* vs IDA* comparison") SP(); IMG("output/green_vs_fast.png","Figure 5: Fastest vs lowest CO2 route") SP(); P("A* found the optimal path on every route with fewest nodes expanded.") doc.add_page_break() H("Task 5 - Demand Forecasting with Machine Learning") P("Running task5_forecasting.py produced the following output:") if t5_text: CODE(t5_text) SP(); IMG("output/forecast.png","Figure 6: Actual vs predicted daily sales") SP(); IMG("output/residuals.png","Figure 7: Residuals for both models") SP(); IMG("output/feature_importance.png","Figure 8: Random Forest feature importance") SP(); P("LR: MAE=9.62, R2=0.762. RF: MAE=9.75, R2=0.716. Top feature: lag_7.") doc.add_page_break() H("References") for ref in [ "[1] S. Russell and P. Norvig, Artificial Intelligence: A Modern Approach, 4th ed. Pearson, 2020.", "[2] F. Pedregosa et al., Scikit-learn: Machine Learning in Python, JMLR, 2011.", "[3] M. Feldman et al., Certifying and Removing Disparate Impact, ACM SIGKDD, 2015.", "[4] P. E. Hart et al., A Formal Basis for the Heuristic Determination of Minimum Cost Paths, IEEE, 1968.", ]: p = doc.add_paragraph(ref) p.paragraph_format.space_after = Pt(5) p.paragraph_format.left_indent = Inches(0.3) p.paragraph_format.first_line_indent = Inches(-0.3) for r in p.runs: r.font.name=TNR; r.font.size=Pt(11) buf = io.BytesIO(); doc.save(buf); buf.seek(0) return buf # ══════════════════════════════════════════════════════════════════════════════ # PAGE SETUP # ══════════════════════════════════════════════════════════════════════════════ st.set_page_config(page_title="EcoCart AI", page_icon="🛒", layout="wide", initial_sidebar_state="expanded") # design tokens NAVY = "#0f172a"; SLATE = "#1e293b"; MUTED = "#64748b" BORDER = "#e2e8f0"; SURF = "#ffffff"; BG = "#f8fafc" GREEN = "#059669"; BLUE = "#2563eb"; AMBER = "#d97706"; RED = "#dc2626" def _rgba(h, a=1.0): h = h.lstrip("#"); r,g,b = int(h[:2],16),int(h[2:4],16),int(h[4:],16) return f"rgba({r},{g},{b},{a})" def _layout(h=420): return dict(height=h, paper_bgcolor=SURF, plot_bgcolor=BG, font=dict(color=SLATE, size=11), margin=dict(l=8,r=8,t=8,b=8), showlegend=False) st.markdown(""" """, unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # SIDEBAR # ══════════════════════════════════════════════════════════════════════════════ with st.sidebar: st.markdown("""
🛒
EcoCart AI
""", unsafe_allow_html=True) st.markdown("
How to use
", unsafe_allow_html=True) for n, t in [("1","Pick a task tab above"), ("2","Tasks 2, 3, 5 — press Run"), ("3","Tasks 1 & 3 — use Play / Slider"), ("4","Task 6 — adjust the sliders")]: st.markdown(f"""
{n}
{t}
""", unsafe_allow_html=True) st.markdown("
Task progress
", unsafe_allow_html=True) t2_done = st.session_state.get("t2_done", False) t3_done = st.session_state.get("t3_done", False) t5_done = st.session_state.get("t5_done", False) for lbl, icon, done in [ ("Task 2 - Bias", "⚖️", t2_done), ("Task 3 - Routes", "🗺️", t3_done), ("Task 5 - Forecast", "📈", t5_done), ]: cls = "sb-done" if done else "sb-pending" mark = "✓" if done else "·" st.markdown(f"
{icon} {lbl} {mark}
", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) st.caption("NCI · MSc AI · Foundations of AI 2026") # ── header ──────────────────────────────────────────────────────────────────── st.markdown("""
EcoCart AI System
Six AI tasks built to solve one real logistics problem — every chart and number runs from actual Python scripts
6
Tasks
7
Algorithms
730
Days Data
20
Node Network
0.847
DI Score
""", unsafe_allow_html=True) T1, T2, T3, T4, T5, T6 = st.tabs([ "🤖 Task 1 - AI Agents", "⚖️ Task 2 - Bias", "🗺️ Task 3 - Routes", "📊 Task 4 - A* vs IDA*", "📈 Task 5 - Forecast", "💼 Task 6 - Business", ]) # ══════════════════════════════════════════════════════════════════════════════ # TASK 1 - AI AGENTS (step-by-step animated map) # ══════════════════════════════════════════════════════════════════════════════ with T1: st.markdown("""
🤖
Three agents, one delivery map. Completely different decisions.
Reactive rushes to the nearest stop. Goal-Based plans the full route before leaving using 2-opt optimisation. Utility-Based scores stops by urgency ÷ distance and chases high-priority ones first. Same 9-stop map, very different outcomes. Press Play to animate or drag the slider to step through stop by stop.
""", unsafe_allow_html=True) # ── route data ──────────────────────────────────────────────────────────── STOPS = { "Depot": (0.0,0.0,0), "Shop A":(2.0,3.0,3), "Shop B":(5.0,1.0,4), "Shop C": (7.0,4.0,2), "Shop D":(3.0,6.0,5), "Shop E":(8.0,7.0,1), "Shop F": (1.0,8.0,3), "Shop G":(6.0,9.0,4), "Shop H":(9.0,2.0,2), } def _sd(a,b): ax,ay,_=STOPS[a]; bx,by,_=STOPS[b]; return math.hypot(ax-bx,ay-by) @st.cache_data def _get_routes(): def reactive(): r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; c="Depot" while u: nb=min(u,key=lambda n:_sd(c,n)); r.append(nb); u.remove(nb); c=nb return r+["Depot"] def goal(): r=reactive()[:-1] td=lambda x:sum(_sd(x[i],x[i+1]) for i in range(len(x)-1))+_sd(x[-1],x[0]) ok=True while ok: ok=False for i in range(1,len(r)-1): for j in range(i+1,len(r)): nr=r[:i]+r[i:j+1][::-1]+r[j+1:] if td(nr)Choose agent type", unsafe_allow_html=True) sel_cols = st.columns(3) for col, (name, col_hex) in zip(sel_cols, RCOLS.items()): is_sel = st.session_state.get("_ag","Reactive Agent") == name with col: border = f"2px solid {col_hex}" if is_sel else "2px solid #e2e8f0" bg = _rgba(col_hex, 0.07) if is_sel else "#fff" st.markdown(f"""
{name}
{RDESC[name]}
""", unsafe_allow_html=True) if st.button("Select" if not is_sel else "✓ Selected", key=f"ag_{name[:4]}", type="secondary" if not is_sel else "primary", use_container_width=True): st.session_state["_ag"] = name st.session_state["ag_stp"] = 0 st.session_state["ag_play"] = False st.rerun() # ── resolve agent state ─────────────────────────────────────────────────── agent = st.session_state.get("_ag","Reactive Agent") if agent not in RCOLS: agent = "Reactive Agent"; st.session_state["_ag"] = agent if st.session_state.get("_ag_prev") != agent: st.session_state["_ag_prev"] = agent st.session_state["ag_stp"] = 0 st.session_state["ag_play"] = False ac = RCOLS[agent] route = ROUTES[agent] mx = len(route) - 1 if "ag_stp" not in st.session_state: st.session_state["ag_stp"] = 0 if "ag_play" not in st.session_state: st.session_state["ag_play"] = False # ── playback controls ───────────────────────────────────────────────────── st.markdown("
", unsafe_allow_html=True) st.markdown("
Playback controls
", unsafe_allow_html=True) cb1, cb2, cb3, cb4, cb5 = st.columns([1, 1, 1, 1.8, 2.5]) if cb1.button("⏮", use_container_width=True, help="Reset to start"): st.session_state["ag_stp"] = 0; st.session_state["ag_play"] = False; st.rerun() if cb2.button("◀", use_container_width=True, help="Previous stop"): st.session_state["ag_stp"] = max(0, st.session_state["ag_stp"]-1) st.session_state["ag_play"] = False; st.rerun() if cb3.button("▶", use_container_width=True, help="Next stop"): st.session_state["ag_stp"] = min(mx, st.session_state["ag_stp"]+1) st.session_state["ag_play"] = False; st.rerun() play_lbl = "⏸ Pause" if st.session_state["ag_play"] else "▶ Play" if cb4.button(play_lbl, use_container_width=True, type="primary"): if st.session_state["ag_stp"] >= mx: st.session_state["ag_stp"] = 0 st.session_state["ag_play"] = not st.session_state["ag_play"] st.rerun() ag_speed = cb5.slider("Speed", 1, 8, 3, format="%dx", label_visibility="collapsed", key="ag_speed") # step slider - use value= so auto-play can write to ag_stp freely new_stp = st.slider("Step", 0, mx, value=st.session_state["ag_stp"], format="Stop %d", help="Drag to jump to any stop in the route") if new_stp != st.session_state["ag_stp"]: st.session_state["ag_stp"] = new_stp st.session_state["ag_play"] = False stp = st.session_state["ag_stp"] path_sf = route[:stp+1] visited = set(path_sf) km_done = sum(_sd(path_sf[i], path_sf[i+1]) for i in range(len(path_sf)-1)) total_km = sum(_sd(route[i], route[i+1]) for i in range(len(route)-1)) # status line curr_stop = route[stp] st.markdown( f"
" f"Currently at: {curr_stop}  |  " f"km done: {km_done:.1f} km  |  " f"Total route: {total_km:.2f} km" f"
", unsafe_allow_html=True) # ── map ─────────────────────────────────────────────────────────────────── fig = go.Figure() # background edges for na in STOPS: for nb in STOPS: if na >= nb: continue x1,y1,_=STOPS[na]; x2,y2,_=STOPS[nb] if math.hypot(x1-x2,y1-y2) < 5.5: fig.add_trace(go.Scatter(x=[x1,x2,None],y=[y1,y2,None],mode="lines", line=dict(color="#dde6f0",width=1.5),showlegend=False,hoverinfo="skip")) # traveled path - drawn so far (thick animated line) if len(path_sf) > 1: px=[STOPS[n][0] for n in path_sf]; py=[STOPS[n][1] for n in path_sf] fig.add_trace(go.Scatter(x=px,y=py,mode="lines", line=dict(color=ac,width=6),showlegend=False,hoverinfo="skip")) # unvisited nodes for name,(nx,ny,pri) in STOPS.items(): if name in visited or name=="Depot": continue star="★ " if pri>=4 else "" fig.add_trace(go.Scatter(x=[nx],y=[ny],mode="markers+text", marker=dict(size=20,color="#e2e8f0",line=dict(color="#94a3b8",width=2)), text=[star+name.replace("Shop","").strip()], textposition="top center",textfont=dict(size=9,color="#94a3b8"), showlegend=False, hovertemplate=f"{name}
Priority {pri}/5 - not visited yet")) # visited nodes - show visit order number inside circle for i,name in enumerate(path_sf): if name=="Depot" or name==route[stp]: continue nx,ny,pri=STOPS[name] fig.add_trace(go.Scatter(x=[nx],y=[ny],mode="markers+text", marker=dict(size=30,color=GREEN,line=dict(color="#fff",width=2.5)), text=[str(i)],textposition="middle center", textfont=dict(size=10,color="#fff",family="monospace"), showlegend=False, hovertemplate=f"{name}
Stop #{i} - delivered ✓")) # current node - large, distinct, clearly highlighted cn=route[stp]; cx,cy,cpri=STOPS[cn] if cn!="Depot": star="★ " if cpri>=4 else "" fig.add_trace(go.Scatter(x=[cx],y=[cy],mode="markers+text", marker=dict(size=38,color=ac,line=dict(color="#fff",width=4)), text=[star+cn.replace("Shop","").strip()], textposition="top center", textfont=dict(size=10,color=SLATE,family="system-ui",weight=700), showlegend=False, hovertemplate=f"{cn}
Delivering here now - Priority {cpri}/5")) # depot (always on top) dx,dy,_=STOPS["Depot"] fig.add_trace(go.Scatter(x=[dx],y=[dy],mode="markers+text", marker=dict(size=30,color=NAVY,symbol="square",line=dict(color="#fff",width=2.5)), text=["D"],textposition="top center",textfont=dict(size=9,color=NAVY), showlegend=False,hovertemplate="Depot
Start & end")) fig.update_layout(**_layout(460)) fig.update_xaxes(showgrid=False,showticklabels=False,zeroline=False,range=[-0.8,10.5]) fig.update_yaxes(showgrid=False,showticklabels=False,zeroline=False,range=[-0.8,10.5]) st.plotly_chart(fig, use_container_width=True, key="ag_chart") # legend + metrics lcol, mcol = st.columns([3,1]) with lcol: st.markdown(f"""
Depot
Current stop
Visited (# = order)
Not visited
★ = high priority
""", unsafe_allow_html=True) with mcol: c1,c2,c3=st.columns(3) c1.metric("Stop",f"{stp}/{mx}") c2.metric("Done",f"{km_done:.1f} km") c3.metric("Total",f"{total_km:.2f} km") # ── auto-play ───────────────────────────────────────────────────────────── if st.session_state["ag_play"] and stp < mx: time.sleep(1.0 / ag_speed) st.session_state["ag_stp"] = stp + 1 st.rerun() elif st.session_state["ag_play"] and stp >= mx: st.session_state["ag_play"] = False # ══════════════════════════════════════════════════════════════════════════════ # TASK 2 - BIAS # ══════════════════════════════════════════════════════════════════════════════ with T2: st.markdown("""
⚖️
The model was being unfair. Nobody noticed until now.
Not one rural customer made it to High Value. Zero. The K-Means clustering was biased from the start because EcoCart launched in cities first. This task measures the bias using Disparate Impact (threshold ≥ 0.80) and applies a three-step fix: oversample rural customers, adjust for delivery costs, correct for order batching. Press Run to see before and after.
""", unsafe_allow_html=True) run_t2 = st.button("▶ Run Task 2 - Segmentation & Bias Fix", type="primary", use_container_width=True, key="run_t2") if run_t2 or st.session_state.get("t2_done"): st.session_state["t2_done"] = True @st.cache_data def _run_task2(): spec=importlib.util.spec_from_file_location("task2","task2_segmentation.py") m=importlib.util.module_from_spec(spec); buf=io.StringIO() with redirect_stdout(buf): spec.loader.exec_module(m); m.main() return buf.getvalue() with st.spinner("Running task2_segmentation.py …"): t2_out = _run_task2() st.session_state["t2_text"] = t2_out with st.expander("Terminal output", expanded=False): st.markdown(f"""
task2_segmentation.py
{t2_out}
""", unsafe_allow_html=True) c1,c2=st.columns(2) with c1: if os.path.exists("output/bias_before_after.png"): st.image("output/bias_before_after.png", caption="Clusters before and after bias mitigation", use_container_width=True) with c2: if os.path.exists("output/disparate_impact.png"): st.image("output/disparate_impact.png", caption="Fairness metrics - before vs after", use_container_width=True) st.markdown("""
Before the fix, 0% of rural customers reached High Value - Disparate Impact was 0.0, a complete fairness failure. After oversampling rural customers to match urban count, adjusting spend for the delivery cost premium (+€12), and correcting frequency for order batching (×1.5), the Disparate Impact rose to 0.847 - above the 0.80 threshold. The model now treats both groups fairly.
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TASK 3 - ROUTES (run + animated exploration replay) # ══════════════════════════════════════════════════════════════════════════════ with T3: st.markdown("""
🗺️
Four algorithms, one delivery network. Which one wins?
BFS, DFS, A*, and IDA* all search for the shortest route on a custom-built 20-node urban/rural network. Some find the optimal path, one doesn't. The best does it with the fewest node expansions. Press Run for full results, then use the live replay below to watch any algorithm search the network step by step.
""", unsafe_allow_html=True) run_t3 = st.button("▶ Run Task 3 - Route Optimisation", type="primary", use_container_width=True, key="run_t3") if run_t3 or st.session_state.get("t3_done"): st.session_state["t3_done"] = True @st.cache_data def _run_task3(): spec=importlib.util.spec_from_file_location("task3","task3_4_routing.py") m=importlib.util.module_from_spec(spec); buf=io.StringIO() with redirect_stdout(buf): spec.loader.exec_module(m); m.main() return buf.getvalue() with st.spinner("Running task3_4_routing.py …"): t3_out = _run_task3() st.session_state["t3_text"] = t3_out with st.expander("Terminal output", expanded=False): st.markdown(f"""
task3_4_routing.py
{t3_out}
""", unsafe_allow_html=True) if os.path.exists("output/network_map.png"): st.image("output/network_map.png", caption="20-node delivery network", use_container_width=True) c1,c2=st.columns(2) with c1: if os.path.exists("output/algo_comparison.png"): st.image("output/algo_comparison.png", caption="A* vs IDA* comparison", use_container_width=True) with c2: if os.path.exists("output/green_vs_fast.png"): st.image("output/green_vs_fast.png", caption="Fastest vs lowest CO₂ route", use_container_width=True) st.markdown("""
A* found the shortest path (5.69 km) using only 7 node expansions - the most efficient result. BFS found the same optimal path but needed 11 expansions. DFS was the only algorithm that got it wrong, returning a 6.84 km suboptimal route because it dives deep without comparing alternatives. IDA* also found 5.69 km but needed 43 expansions - its advantage is near-zero memory use, which matters at national scale but not here.
""", unsafe_allow_html=True) # ── interactive route replay ────────────────────────────────────────────── st.markdown("
", unsafe_allow_html=True) st.markdown("
Live search replay - pick start, end and algorithm, watch it think
", unsafe_allow_html=True) NODES_R = { "U1":(1.0,1.0,"urban"), "U2":(2.0,1.5,"urban"), "U3":(3.0,1.0,"urban"), "U4":(1.5,2.5,"urban"), "U5":(2.5,3.0,"urban"), "U6":(3.5,2.0,"urban"), "U7":(1.0,3.5,"urban"), "U8":(2.0,4.0,"urban"), "U9":(3.0,4.0,"urban"), "U10":(4.0,3.5,"urban"), "R1":(6.0,1.0,"rural"), "R2":(8.0,2.0,"rural"), "R3":(10.0,1.5,"rural"), "R4":(7.0,4.0,"rural"), "R5":(9.0,4.5,"rural"), "R6":(11.0,3.5,"rural"), "R7":(6.5,6.0,"rural"), "R8":(9.0,7.0,"rural"), "R9":(11.0,6.0,"rural"), "R10":(8.0,5.5,"rural"), } _EP = [ ("U1","U2"),("U2","U3"),("U1","U4"),("U2","U4"),("U2","U5"), ("U3","U6"),("U4","U5"),("U5","U6"),("U4","U7"),("U5","U8"), ("U6","U10"),("U7","U8"),("U8","U9"),("U9","U10"),("U5","U9"), ("R1","R2"),("R2","R3"),("R1","R4"),("R2","R4"),("R3","R6"), ("R4","R5"),("R5","R6"),("R4","R7"),("R5","R10"),("R7","R10"), ("R7","R8"),("R8","R9"),("R6","R9"),("R8","R10"),("R5","R8"), ("U3","R1"),("U10","R4"),("U6","R1"),("U9","R7"), ] def _nd(a,b): return math.hypot(NODES_R[a][0]-NODES_R[b][0],NODES_R[a][1]-NODES_R[b][1]) EDGES = [(a,b,round(_nd(a,b)*1.15,2)) for a,b in _EP] ADJ = {n:[] for n in NODES_R} for a,b,w in EDGES: ADJ[a].append((b,w)); ADJ[b].append((a,w)) def _ew(a,b): for nb,w in ADJ[a]: if nb==b: return w return math.inf def _astar(s,g): ctr=0; h=lambda n:_nd(n,g); expl=[] heap=[(h(s),0.0,ctr,s,[s])]; best={s:0.0} while heap: _,gc,_,n,p=heapq.heappop(heap) if n==g: return p,round(gc,2),expl if gc>best.get(n,math.inf): continue expl.append(n) for nb,w in ADJ[n]: ng=gc+w if ngbound: return f if n==g: return -1 expl.append(n); mn=math.inf for nb,w in sorted(ADJ[n],key=lambda x:h(x[0])): if nb not in path: path.append(nb); t=search(gc+w,bound) if t==-1: return -1 if t= max_rp: st.session_state["rp"]=0 st.session_state["rp_pl"] = not st.session_state["rp_pl"]; st.rerun() # slider - use value= so auto-play can write to rp freely new_rp = st.slider("Nodes explored", 0, max_rp, value=st.session_state["rp"], help="Drag to replay how the algorithm searches node by node") if new_rp != st.session_state["rp"]: st.session_state["rp"] = new_rp st.session_state["rp_pl"] = False rp = st.session_state["rp"] done = (rp == max_rp) explored = set(expl_r[:rp]) cur_node = expl_r[rp-1] if rp > 0 else None path_set = set(path_r) if path_r else set() st.progress(rp/max_rp, text=f"{rp}/{max_rp} nodes explored" +(" · Path found ✓" if done and path_r else "")) # map f2 = go.Figure() # edges for a,b,_ in EDGES: f2.add_trace(go.Scatter(x=[NODES_R[a][0],NODES_R[b][0],None], y=[NODES_R[a][1],NODES_R[b][1],None],mode="lines", line=dict(color="#dde6f0",width=1.5),showlegend=False,hoverinfo="skip")) # final path highlight (thick amber) if path_r and done: for i in range(len(path_r)-1): pa,pb=path_r[i],path_r[i+1] f2.add_trace(go.Scatter( x=[NODES_R[pa][0],NODES_R[pb][0],None], y=[NODES_R[pa][1],NODES_R[pb][1],None],mode="lines", line=dict(color=AMBER,width=7),showlegend=False,hoverinfo="skip")) # nodes for zone, zcol in [("urban",RED),("rural",GREEN)]: ns=[(n,d) for n,d in NODES_R.items() if d[2]==zone] for n,d in ns: if n==sn: nc,sz="white",26 elif n==en: nc,sz="#fde68a",26 elif n in path_set and done: nc,sz=AMBER,24 elif n in explored: nc,sz="#93c5fd",20 else: nc,sz=zcol,16 # ring around currently expanding node if n==cur_node and not done: f2.add_trace(go.Scatter(x=[d[0]],y=[d[1]],mode="markers", marker=dict(size=34,color=_rgba(BLUE,0.2), line=dict(color=BLUE,width=2)), showlegend=False,hoverinfo="skip")) state=("Final path" if n in path_set and done else "Exploring now" if n==cur_node else "Explored" if n in explored else "Not explored") f2.add_trace(go.Scatter(x=[d[0]],y=[d[1]],mode="markers+text", marker=dict(size=sz,color=nc,line=dict(color=SLATE,width=1.5)), text=[n],textposition="middle center", textfont=dict(size=7.5,color="#fff" if n in explored and n not in (sn,en) else SLATE), showlegend=False, hovertemplate=f"{n}
{state}")) f2.update_layout(**_layout(460)) f2.update_xaxes(showgrid=False,showticklabels=False,zeroline=False) f2.update_yaxes(showgrid=False,showticklabels=False,zeroline=False) st.plotly_chart(f2, use_container_width=True, key="rp_chart") # legend st.markdown(f"""
Start
End
Explored
Final path
Urban
Rural
""", unsafe_allow_html=True) if path_r and done: m1,m2,m3=st.columns(3) m1.metric("Path cost", f"{cost_r:.2f} km") m2.metric("Nodes explored",len(expl_r)) m3.metric("Path", f"{len(path_r)} nodes") st.markdown(f"""
Route: {" → ".join(path_r)}
{algo} explored {len(expl_r)} nodes to find a {cost_r:.2f} km route from {sn} to {en}.
""", unsafe_allow_html=True) # auto-play replay if st.session_state["rp_pl"] and rp < max_rp: time.sleep(0.9 / rp_speed) st.session_state["rp"] = rp + 1 st.rerun() elif st.session_state["rp_pl"] and rp >= max_rp: st.session_state["rp_pl"] = False # ══════════════════════════════════════════════════════════════════════════════ # TASK 4 - A* vs IDA* # ══════════════════════════════════════════════════════════════════════════════ with T4: st.markdown("""
📊
Same shortest path, completely different strategies
A* remembers every node it visits - fast, but memory grows with the network. IDA* forgets and re-searches from scratch each pass, tightening its cost bound each time - slower but uses almost no memory. This benchmark runs 10 routes × 20 timing runs across urban and rural pairs to find out which algorithm is right for EcoCart - and at what scale that answer changes.
""", unsafe_allow_html=True) urban_data=[ ["U1→U10","5.69","7","0.160","5.69","43","0.572"], ["U7→U6", "4.21","5","0.086","4.21","22","0.323"], ["U2→U9", "3.11","2","0.079","3.11","6", "0.131"], ["U1→U9", "4.40","4","0.084","4.40","15","0.220"], ["U3→U8", "4.21","5","0.107","4.21","19","0.282"], ] rural_data=[ ["R1→R9", "10.39","6","0.124","10.39","34","0.453"], ["R2→R8", "7.82", "4","0.097","7.82", "14","0.209"], ["R3→R10","6.77", "5","0.103","6.77", "21","0.326"], ["R1→R6", "7.51", "3","0.064","7.51", "10","0.153"], ["R4→R9", "7.82", "7","0.125","7.82", "50","0.673"], ] hdr=["Route","A* km","A* nodes","A* ms","IDA* km","IDA* nodes","IDA* ms"] cu,cr=st.columns(2) with cu: st.markdown("**Urban routes**") st.dataframe(pd.DataFrame(urban_data,columns=hdr),use_container_width=True,hide_index=True) with cr: st.markdown("**Rural routes**") st.dataframe(pd.DataFrame(rural_data,columns=hdr),use_container_width=True,hide_index=True) all_rows=urban_data+rural_data routes=[r[0] for r in all_rows] a_n=[int(r[2]) for r in all_rows]; i_n=[int(r[5]) for r in all_rows] a_m=[float(r[3]) for r in all_rows]; i_m=[float(r[6]) for r in all_rows] fig4=make_subplots(rows=1,cols=2, subplot_titles=["Nodes expanded (fewer = smarter)","Time ms (lower = faster)"]) for ci,(av,iv) in enumerate([(a_n,i_n),(a_m,i_m)],1): fig4.add_trace(go.Bar(name="A*", x=routes,y=av,marker_color=BLUE, showlegend=(ci==1)),row=1,col=ci) fig4.add_trace(go.Bar(name="IDA*",x=routes,y=iv,marker_color=AMBER,showlegend=(ci==1)),row=1,col=ci) fig4.update_layout(paper_bgcolor=SURF,plot_bgcolor=BG,font_color=SLATE, barmode="group",height=360,margin=dict(l=40,r=20,t=50,b=80), legend=dict(bgcolor=SURF,bordercolor=BORDER)) fig4.update_xaxes(gridcolor=BORDER,tickangle=45) fig4.update_yaxes(gridcolor=BORDER) st.plotly_chart(fig4,use_container_width=True) if os.path.exists("output/algo_comparison.png"): st.image("output/algo_comparison.png",caption="From task3_4_routing.py",use_container_width=True) st.markdown("""
Both algorithms found identical optimal paths on every single route - path costs match exactly. But A* was faster and expanded fewer nodes every time. The starkest example: R4→R9, where A* needed 7 node expansions in 0.125 ms while IDA* needed 50 in 0.673 ms. For EcoCart's current network, A* is the clear winner. IDA*'s value shows up at national scale — when the network has millions of nodes and storing A*'s visited set would exhaust memory.
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TASK 5 - FORECASTING # ══════════════════════════════════════════════════════════════════════════════ with T5: st.markdown("""
📈
Can a simple model beat 200 decision trees?
Linear Regression (fast, transparent) goes head-to-head against Random Forest (200 trees, non-linear patterns). Both train on 730 days of EcoCart sales history and are tested blind on 140 days they have never seen. Press Run to see which model wins on MAE, RMSE, R², and MAPE - and why the result is surprising.
""", unsafe_allow_html=True) run_t5 = st.button("▶ Run Task 5 - Demand Forecasting", type="primary", use_container_width=True, key="run_t5") if run_t5 or st.session_state.get("t5_done"): st.session_state["t5_done"] = True @st.cache_data def _run_task5(): spec=importlib.util.spec_from_file_location("task5","task5_forecasting.py") m=importlib.util.module_from_spec(spec); buf=io.StringIO() with redirect_stdout(buf): spec.loader.exec_module(m); m.main() return buf.getvalue() with st.spinner("Running task5_forecasting.py …"): t5_out = _run_task5() st.session_state["t5_text"] = t5_out with st.expander("Terminal output", expanded=False): st.markdown(f"""
task5_forecasting.py
{t5_out}
""", unsafe_allow_html=True) m1,m2,m3,m4=st.columns(4) m1.metric("LR - MAE","9.62 units"); m2.metric("LR - R²","0.762") m3.metric("RF - MAE","9.75 units"); m4.metric("RF - R²","0.716") if os.path.exists("output/forecast.png"): st.image("output/forecast.png", caption="Actual vs predicted sales - 140 test days", use_container_width=True) c1,c2=st.columns(2) with c1: if os.path.exists("output/residuals.png"): st.image("output/residuals.png",caption="Residuals",use_container_width=True) with c2: if os.path.exists("output/feature_importance.png"): st.image("output/feature_importance.png", caption="Feature importance",use_container_width=True) st.markdown("""
Linear Regression won on both accuracy and speed - R²=0.762 vs Random Forest's 0.716, and a fraction of the training time (LR is a single matrix solve; RF trains 200 trees on bootstrap samples). The reason LR wins here: once lag_7 (same weekday last week) is in the features, the demand signal becomes mostly linear. Random Forest's complexity adds noise, not signal. Top predictors: lag_7, lag_14, is_promo - weekly rhythm and promotions drive demand more than anything else.
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TASK 6 - BUSINESS CASE # ══════════════════════════════════════════════════════════════════════════════ with T6: st.markdown("""
💼
What does all of this actually save the business?
This tab turns the technical results into a live financial model — savings from A* route optimisation, revenue unlocked by fixing the segmentation bias, and CO₂ avoided. All numbers are estimates based on assumed fleet inputs. Use the sliders on the left to model EcoCart's real fleet size, fuel costs, and wages — the ROI and payback period update instantly.
""", unsafe_allow_html=True) ctrl, main = st.columns([1, 3]) with ctrl: fleet =st.slider("Fleet (vehicles)", 5,100,30,5) daily =st.slider("Deliveries/vehicle/day",10, 80,40,5) avg_km =st.slider("Avg km per delivery", 2, 30,12,1) fuel =st.slider("Fuel €/km", 0.10,0.60,0.32,0.01,format="€%.2f") wage =st.slider("Driver wage €/hr", 10, 35,18,1,format="€%d") days_yr=st.slider("Working days/year", 200,365,300,10) rt_save=st.slider("Route saving % (A*)", 5, 35,18,1,format="%d%%") seg_rev=st.slider("Rural revenue uplift €k",10,200,65,5) with main: total_km =fleet*daily*days_yr*avg_km km_saved =total_km*rt_save/100 fuel_saved=km_saved*fuel time_saved=(km_saved/40)*wage route_save=fuel_saved+time_saved seg_save =seg_rev*1000 total_eur =route_save+seg_save co2 =km_saved*0.24/1000 dev=45000; ops=8000 payback=round((dev+ops)/total_eur*12,1) if total_eur>0 else 0 roi3 =round((total_eur*3-(dev+ops*3))/(dev+ops*3)*100,1) mc=st.columns(4) mc[0].metric("Est. annual saving",f"€{round(total_eur/1000,1)}k") mc[1].metric("Est. payback",f"{payback} months") mc[2].metric("3-year ROI",f"{roi3}%") mc[3].metric("CO₂ saved/yr",f"{co2:.1f} t") cats=["Route Optimisation (A*)","Fairer Segmentation (rural)"] vals=[round(route_save/1000,1),round(seg_save/1000,1)] fb=go.Figure(go.Bar(x=cats,y=vals,marker_color=[BLUE,GREEN], text=[f"€{v}k" for v in vals],textposition="outside", textfont_color=SLATE,width=0.4)) fb.update_layout(**_layout(250)); fb.update_xaxes(gridcolor=BORDER) fb.update_yaxes(gridcolor=BORDER,title="€ thousands") st.plotly_chart(fb,use_container_width=True) years=[0,1,2,3] ben=[0,total_eur,total_eur*2,total_eur*3] cost=[0,dev+ops,dev+ops*2,dev+ops*3] fr=go.Figure() fr.add_trace(go.Scatter(x=years,y=[v/1000 for v in ben],name="Benefit", line=dict(color=GREEN,width=2.5),mode="lines+markers")) fr.add_trace(go.Scatter(x=years,y=[v/1000 for v in cost],name="Cost", line=dict(color=RED,width=2.5,dash="dash"),mode="lines+markers")) fr.add_hline(y=0,line_color=MUTED,line_width=1,line_dash="dot") fr.update_layout(**_layout(270)) fr.update_layout(showlegend=True,legend=dict(bgcolor=SURF,bordercolor=BORDER,x=0.01,y=0.99)) fr.update_xaxes(gridcolor=BORDER,tickvals=[0,1,2,3], ticktext=["Now","Year 1","Year 2","Year 3"]) fr.update_yaxes(gridcolor=BORDER,title="€ thousands") st.plotly_chart(fr,use_container_width=True) st.markdown(f"""
Reminder: these are estimates for illustration only - not measured values. Current inputs: {fleet} vehicles, {daily} deliveries/day, {avg_km} km avg route, {rt_save}% saving from A* routing, €{seg_rev}k rural revenue uplift assumed. Change the sliders to model your own scenario.
""", unsafe_allow_html=True)