Esvanth commited on
Commit
f149318
·
1 Parent(s): 900fed5

Redesign: guided flow, run actual task scripts, real outputs only

Browse files
Files changed (1) hide show
  1. app.py +728 -983
app.py CHANGED
@@ -1,1044 +1,789 @@
1
  """
2
- EcoCart AI System TABA Section II
3
- NCI MSCAI 2026
 
4
  """
5
 
6
- import math, heapq, time
 
7
  from collections import deque
8
 
9
  import numpy as np
10
  import pandas as pd
 
11
  import plotly.graph_objects as go
12
  from plotly.subplots import make_subplots
13
- import streamlit as st
14
- from sklearn.cluster import KMeans
15
- from sklearn.preprocessing import StandardScaler
16
- from sklearn.linear_model import LinearRegression
17
- from sklearn.ensemble import RandomForestRegressor
18
- from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
19
 
20
- # ── page ──────────────────────────────────────────────────────────────────────
21
  st.set_page_config(page_title="EcoCart AI", layout="wide",
22
- initial_sidebar_state="collapsed")
23
 
24
  st.markdown("""
25
  <style>
26
- [data-testid="stAppViewContainer"] { background:#f0f4f8; }
27
- [data-testid="stHeader"] { background:transparent; }
28
  .block-container { padding:1rem 2rem 3rem; }
29
- .stTabs [data-baseweb="tab-list"] { background:#fff; border-radius:12px;
30
- padding:4px; box-shadow:0 1px 4px rgba(0,0,0,.08); }
31
- .stTabs [data-baseweb="tab"] { font-size:.88rem; font-weight:600;
32
- border-radius:8px; padding:8px 20px; }
33
- div[data-testid="metric-container"]{ background:#fff; border-radius:10px;
34
- padding:14px 18px;
35
- box-shadow:0 1px 4px rgba(0,0,0,.07); }
36
- .card { background:#fff; border-radius:14px; padding:20px 24px;
37
- box-shadow:0 1px 5px rgba(0,0,0,.08); margin-bottom:14px; }
38
- .badge-green { display:inline-block; background:#d1fae5; color:#065f46;
39
- border-radius:99px; padding:3px 12px; font-size:.78rem;
40
- font-weight:700; }
41
- .badge-red { display:inline-block; background:#fee2e2; color:#991b1b;
42
- border-radius:99px; padding:3px 12px; font-size:.78rem;
43
- font-weight:700; }
44
- .badge-blue { display:inline-block; background:#dbeafe; color:#1e40af;
45
- border-radius:99px; padding:3px 12px; font-size:.78rem;
46
- font-weight:700; }
47
- .tip { background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;
48
- padding:10px 14px; font-size:.82rem; color:#475569; margin:8px 0; }
49
- .section-label { font-size:.72rem; font-weight:700; letter-spacing:.08em;
50
- color:#94a3b8; text-transform:uppercase; margin-bottom:4px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  </style>
52
  """, unsafe_allow_html=True)
53
 
54
  # ── colours ───────────────────────────────────────────────────────────────────
55
- BG,SURF,LINE = "#f0f4f8","#ffffff","#e2e8f0"
56
- FG,MUTE = "#1e293b","#64748b"
57
- GREEN,BLUE,RED,AMBER,PURPLE = "#10b981","#3b82f6","#ef4444","#f59e0b","#8b5cf6"
58
-
59
- SEG_COL={"High Value":GREEN,"Medium":AMBER,"Low Value":RED,"Group 4":PURPLE}
60
-
61
- def _ch(h=380,title=""):
62
- return dict(height=h,paper_bgcolor=SURF,plot_bgcolor=BG,
63
- font=dict(color=FG,size=11),
64
- title=dict(text=title,font=dict(size=13,color=FG),x=0),
65
- margin=dict(l=50,r=20,t=48,b=40),
66
- legend=dict(bgcolor=SURF,bordercolor=LINE,borderwidth=1))
67
-
68
- def _xax(**k): return dict(gridcolor=LINE,zeroline=False,linecolor=LINE,**k)
69
- def _yax(**k): return dict(gridcolor=LINE,zeroline=False,linecolor=LINE,**k)
70
-
71
- # ══════════════════════════════════════════════════════════════════════════════
72
- # NETWORK DATA
73
- # ══════════════════════════════════════════════════════════════════════════════
74
- NODES={
75
- "U1":(1.0,1.0,"urban"), "U2":(2.0,1.5,"urban"), "U3":(3.0,1.0,"urban"),
76
- "U4":(1.5,2.5,"urban"), "U5":(2.5,3.0,"urban"), "U6":(3.5,2.0,"urban"),
77
- "U7":(1.0,3.5,"urban"), "U8":(2.0,4.0,"urban"), "U9":(3.0,4.0,"urban"),
78
- "U10":(4.0,3.5,"urban"),
79
- "R1":(6.0,1.0,"rural"), "R2":(8.0,2.0,"rural"), "R3":(10.0,1.5,"rural"),
80
- "R4":(7.0,4.0,"rural"), "R5":(9.0,4.5,"rural"), "R6":(11.0,3.5,"rural"),
81
- "R7":(6.5,6.0,"rural"), "R8":(9.0,7.0,"rural"), "R9":(11.0,6.0,"rural"),
82
- "R10":(8.0,5.5,"rural"),
83
- }
84
- _EP=[("U1","U2"),("U2","U3"),("U1","U4"),("U2","U4"),("U2","U5"),
85
- ("U3","U6"),("U4","U5"),("U5","U6"),("U4","U7"),("U5","U8"),
86
- ("U6","U10"),("U7","U8"),("U8","U9"),("U9","U10"),("U5","U9"),
87
- ("R1","R2"),("R2","R3"),("R1","R4"),("R2","R4"),("R3","R6"),
88
- ("R4","R5"),("R5","R6"),("R4","R7"),("R5","R10"),("R7","R10"),
89
- ("R7","R8"),("R8","R9"),("R6","R9"),("R8","R10"),("R5","R8"),
90
- ("U3","R1"),("U10","R4"),("U6","R1"),("U9","R7")]
91
-
92
- def _nd(a,b): return math.hypot(NODES[a][0]-NODES[b][0],NODES[a][1]-NODES[b][1])
93
- def _cr(a,b):
94
- za,zb=NODES[a][2],NODES[b][2]
95
- return 0.28 if za==zb=="urban" else 0.18 if za!=zb else 0.10
96
-
97
- EDGES =[(a,b,round(_nd(a,b)*1.15,2)) for a,b in _EP]
98
- CO2_EDGES=[(a,b,round(_nd(a,b)*1.15*_cr(a,b),3)) for a,b in _EP]
99
- ADJ_KM={n:[] for n in NODES}; ADJ_CO2={n:[] for n in NODES}
100
- for i,(a,b,w) in enumerate(EDGES):
101
- ADJ_KM[a].append((b,w)); ADJ_KM[b].append((a,w))
102
- c=CO2_EDGES[i][2]; ADJ_CO2[a].append((b,c)); ADJ_CO2[b].append((a,c))
103
-
104
- def _ew(a,b,adj):
105
- for nb,w in adj[a]:
106
- if nb==b: return w
107
- return math.inf
108
-
109
- # ── algorithms (return path, cost, exploration_order) ─────────────────────────
110
- def bfs(s,g,adj):
111
- q=deque([(s,[s])]); seen={s}; expl=[]
112
- while q:
113
- n,p=q.popleft(); expl.append(n)
114
- if n==g:
115
- return p,round(sum(_ew(p[i],p[i+1],adj) for i in range(len(p)-1)),2),expl
116
- for nb,_ in adj[n]:
117
- if nb not in seen: seen.add(nb); q.append((nb,p+[nb]))
118
- return None,0.0,expl
119
-
120
- def dfs(s,g,adj):
121
- stack=[(s,[s])]; seen={s}; expl=[]
122
- while stack:
123
- n,p=stack.pop(); expl.append(n)
124
- if n==g:
125
- return p,round(sum(_ew(p[i],p[i+1],adj) for i in range(len(p)-1)),2),expl
126
- if len(p)>=50: continue
127
- for nb,_ in adj[n]:
128
- if nb not in seen: seen.add(nb); stack.append((nb,p+[nb]))
129
- return None,0.0,expl
130
-
131
- def astar(s,g,adj):
132
- ctr=0; h=lambda n:_nd(n,g); expl=[]
133
- heap=[(h(s),0.0,ctr,s,[s])]; best={s:0.0}
134
- while heap:
135
- _,gc,_,n,p=heapq.heappop(heap)
136
- if n==g: return p,round(gc,2),expl
137
- if gc>best.get(n,math.inf): continue
138
- expl.append(n)
139
- for nb,w in adj[n]:
140
- ng=gc+w
141
- if ng<best.get(nb,math.inf):
142
- best[nb]=ng; ctr+=1
143
- heapq.heappush(heap,(ng+h(nb),ng,ctr,nb,p+[nb]))
144
- return None,0.0,expl
145
-
146
- def ida_star(s,g,adj):
147
- expl=[]; h=lambda n:_nd(n,g)
148
- def _dfs(n,gc,bound,path,vis):
149
- f=gc+h(n)
150
- if f>bound: return None,f
151
- expl.append(n)
152
- if n==g: return list(path),gc
153
- nxt=math.inf
154
- for nb,w in adj[n]:
155
- if nb in vis: continue
156
- vis.add(nb); path.append(nb)
157
- r,t=_dfs(nb,gc+w,bound,path,vis)
158
- if r is not None: return r,t
159
- if t<nxt: nxt=t
160
- path.pop(); vis.remove(nb)
161
- return None,nxt
162
- bound=h(s)
163
- while True:
164
- r,t=_dfs(s,0.0,bound,[s],{s})
165
- if r is not None: return r,round(t,2),expl
166
- if t==math.inf: return None,0.0,expl
167
- bound=t
168
-
169
- ALGOS={"BFS":bfs,"DFS":dfs,"A*":astar,"IDA*":ida_star}
170
-
171
- # ── network figure builder ────────────────────────────────────────────────────
172
- def build_network(sn,en,path,explored_so_far,adj,unit,algo_name):
173
- pc=GREEN if unit=="CO2" else AMBER
174
- path_set=set(path) if path else set()
175
- fig=go.Figure()
176
-
177
- # edges
178
- for a,b,w in EDGES:
179
- on_path=(a in path_set and b in path_set and
180
- any((path[i]==a and path[i+1]==b) or
181
- (path[i]==b and path[i+1]==a)
182
- for i in range(len(path)-1)) if path else False)
183
- lc=pc if on_path else "#dde3ed"
184
- lw=5 if on_path else 1.5
185
- co2w=_ew(a,b,ADJ_CO2)
186
- fig.add_trace(go.Scatter(
187
- x=[NODES[a][0],NODES[b][0],None],y=[NODES[a][1],NODES[b][1],None],
188
- mode="lines",line=dict(color=lc,width=lw),
189
- showlegend=False,hoverinfo="skip"))
190
-
191
- # nodes
192
- for zone,bc in [("urban","#ef4444"),("rural",GREEN)]:
193
- ns=[(n,d) for n,d in NODES.items() if d[2]==zone]
194
- cols,sizes=[],[]
195
- for n,_ in ns:
196
- if n==sn: cols.append("#fff"); sizes.append(28)
197
- elif n==en: cols.append("#facc15"); sizes.append(28)
198
- elif n in path_set:cols.append(pc); sizes.append(22)
199
- elif n in explored_so_far: cols.append("#bfdbfe"); sizes.append(18)
200
- else: cols.append(bc); sizes.append(18)
201
- fig.add_trace(go.Scatter(
202
- x=[d[0] for _,d in ns],y=[d[1] for _,d in ns],
203
- mode="markers+text",name=zone.title(),
204
- marker=dict(size=sizes,color=cols,line=dict(color=FG,width=1.5)),
205
- text=[n for n,_ in ns],textposition="middle center",
206
- textfont=dict(size=8,color=FG,family="monospace"),
207
- hovertemplate="<b>%{text}</b><br>"+zone+"<extra></extra>"))
208
-
209
- title=(f"{algo_name}: {sn} → {en} | "
210
- f"{'Explored '+str(len(explored_so_far))+' nodes' if explored_so_far else 'Ready'}")
211
- fig.update_layout(**_ch(480,title))
212
- fig.update_layout(legend=dict(bgcolor=SURF,bordercolor=LINE,x=0.01,y=0.99))
213
- fig.update_xaxes(showgrid=False,showticklabels=False,zeroline=False)
214
- fig.update_yaxes(showgrid=False,showticklabels=False,zeroline=False)
215
- return fig
216
-
217
- # ══════════════════════════════════════════════════════════════════════════════
218
- # AGENT SIMULATION
219
- # ══════════════════════════════════════════════════════════════════════════════
220
- STOPS={
221
- "Depot": (0.0,0.0,0), "Shop A":(2.0,3.0,3), "Shop B":(5.0,1.0,4),
222
- "Shop C":(7.0,4.0,2), "Shop D":(3.0,6.0,5), "Shop E":(8.0,7.0,1),
223
- "Shop F":(1.0,8.0,3), "Shop G":(6.0,9.0,4), "Shop H":(9.0,2.0,2),
224
- }
225
- def _sd(a,b): ax,ay,_=STOPS[a]; bx,by,_=STOPS[b]; return math.hypot(ax-bx,ay-by)
226
-
227
- def _reactive():
228
- r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; cur="Depot"
229
- while u: nb=min(u,key=lambda n:_sd(cur,n)); r.append(nb); u.remove(nb); cur=nb
230
- return r+["Depot"]
231
-
232
- def _goal():
233
- r=_reactive()[:-1]
234
- td=lambda x:sum(_sd(x[i],x[i+1]) for i in range(len(x)-1))+_sd(x[-1],x[0])
235
- ok=True
236
- while ok:
237
- ok=False
238
- for i in range(1,len(r)-1):
239
- for j in range(i+1,len(r)):
240
- nr=r[:i]+r[i:j+1][::-1]+r[j+1:]
241
- if td(nr)<td(r)-1e-9: r=nr; ok=True
242
- return r+["Depot"]
243
-
244
- def _utility():
245
- r=["Depot"]; u=[k for k in STOPS if k!="Depot"]; cur="Depot"
246
- while u:
247
- nb=max(u,key=lambda n:STOPS[n][2]/(_sd(cur,n)+.1))
248
- r.append(nb); u.remove(nb); cur=nb
249
- return r+["Depot"]
250
-
251
- ROUTES={"Nearest stop":_reactive(),"Planned route":_goal(),"Priority first":_utility()}
252
- AGENT_COL={"Nearest stop":BLUE,"Planned route":GREEN,"Priority first":AMBER}
253
- AGENT_DESC={
254
- "Nearest stop": "Reactive agent — goes to the closest unvisited stop. Simple and fast, no planning.",
255
- "Planned route": "Goal-based agent — computes the shortest full route before departing.",
256
- "Priority first":"Utility-based agent — balances urgency vs distance. Starred stops are served first.",
257
- }
258
-
259
- def _route_km(r): return round(sum(_sd(r[i],r[i+1]) for i in range(len(r)-1)),2)
260
-
261
- def draw_agent(route,step,ac):
262
- visited=set(route[:step+1]); pso=route[:step+1]
263
- km=sum(_sd(pso[i],pso[i+1]) for i in range(len(pso)-1))
264
- cur=route[step]
265
- fig=go.Figure()
266
- for na in STOPS:
267
- for nb in STOPS:
268
- if na>=nb: continue
269
- x1,y1,_=STOPS[na]; x2,y2,_=STOPS[nb]
270
- if math.hypot(x1-x2,y1-y2)<5.5:
271
- fig.add_trace(go.Scatter(x=[x1,x2,None],y=[y1,y2,None],mode="lines",
272
- line=dict(color="#e2e8f0",width=1),showlegend=False,hoverinfo="skip"))
273
- if len(pso)>1:
274
- fig.add_trace(go.Scatter(
275
- x=[STOPS[n][0] for n in pso],y=[STOPS[n][1] for n in pso],
276
- mode="lines+markers",line=dict(color=ac,width=3),
277
- marker=dict(size=6,color=ac),showlegend=False,hoverinfo="skip"))
278
- for name,(nx,ny,pri) in STOPS.items():
279
- if name=="Depot": nc,sz,sym="#3b82f6",26,"square"
280
- elif name==cur: nc,sz,sym=ac,28,"circle"
281
- elif name in visited: nc,sz,sym=GREEN,18,"circle"
282
- else: nc,sz,sym="#cbd5e1",18,"circle"
283
- label=("⭐" if pri>=4 else "")+" "+name.replace("Shop ","")
284
- fig.add_trace(go.Scatter(x=[nx],y=[ny],mode="markers+text",showlegend=False,
285
- marker=dict(size=sz,color=nc,line=dict(color="#fff",width=2)),
286
- text=[label.strip()],textposition="top center",textfont=dict(size=9,color=FG),
287
- hovertemplate=f"<b>{name}</b><br>Priority {pri}/5<br>{'✓ Visited' if name in visited else 'Pending'}<extra></extra>"))
288
- fig.update_layout(**_ch(400,f"Step {step}/{len(route)-1} — {km:.1f} km so far"))
289
- fig.update_xaxes(showgrid=False,showticklabels=False,zeroline=False,range=[-0.5,10.5])
290
- fig.update_yaxes(showgrid=False,showticklabels=False,zeroline=False,range=[-0.5,10.5])
291
- return fig, round(km,2)
292
-
293
- # ══════════════════════════════════════════════════════════════════════════════
294
- # SEGMENTATION
295
- # ══════════════════════════════════════════════════════════════════════════════
296
- @st.cache_data
297
- def _customers(nu,nr):
298
- rng=np.random.default_rng(42)
299
- u=pd.DataFrame({"freq":rng.normal(6,2,nu).clip(.5),"spend":rng.normal(120,40,nu).clip(10),
300
- "recency":rng.exponential(10,nu).clip(1,90),"region":"urban"})
301
- r=pd.DataFrame({"freq":rng.normal(3,1.5,nr).clip(.5),"spend":rng.normal(65,30,nr).clip(10),
302
- "recency":rng.exponential(15,nr).clip(1,90),"region":"rural"})
303
- return pd.concat([u,r],ignore_index=True).round(1)
304
-
305
- def _kmeans(df,k):
306
- X=StandardScaler().fit_transform(df[["freq","spend","recency"]])
307
- df=df.copy(); df["cluster"]=KMeans(n_clusters=k,random_state=42,n_init=10).fit_predict(X)
308
- order=df.groupby("cluster")["spend"].mean().sort_values(ascending=False).index
309
- names=(["High Value","Medium","Low Value","Group 4"])[:k]
310
- df["segment"]=df["cluster"].map({order[i]:names[i] for i in range(k)})
311
- return df
312
-
313
- def _di(df):
314
- u=(df[df.region=="urban"].segment=="High Value").mean()
315
- r=(df[df.region=="rural"].segment=="High Value").mean()
316
- return round(u*100,1),round(r*100,1),round(r/u if u else 0,3)
317
-
318
- @st.cache_data
319
- def _fix(nu,nr,k):
320
- df=_customers(nu,nr)
321
- bal=pd.concat([df[df.region=="urban"],
322
- df[df.region=="rural"].sample(len(df[df.region=="urban"]),replace=True,random_state=42)],
323
- ignore_index=True).copy()
324
- bal.loc[bal.region=="rural","spend"]+=12
325
- bal.loc[bal.region=="rural","freq"]*=1.5
326
- bal=_kmeans(bal,k)
327
- rm=bal.region=="rural"; um=bal.region=="urban"
328
- need=int((bal[um].segment=="High Value").mean()*.85*rm.sum())-(bal[rm].segment=="High Value").sum()
329
- if need>0:
330
- cands=bal[rm&(bal.segment!="High Value")]
331
- bal.loc[cands.nlargest(min(need,len(cands)),"spend").index,"segment"]="High Value"
332
- return bal
333
 
334
  # ══════════════════════════════════════════════════════════════════════════════
335
- # FORECASTING
336
  # ══════════════════════════════════════════════════════════════════════════════
337
- @st.cache_data
338
- def _sales():
339
- rng=np.random.default_rng(42); days=730
340
- t=np.arange(days); dates=pd.date_range("2023-01-01",periods=days,freq="D")
341
- promo=np.zeros(days); promo[rng.choice(days,int(days*.06),replace=False)]=rng.uniform(30,70,int(days*.06))
342
- sales=np.clip(100+.05*t+25*np.sin(2*np.pi*t/7)+40*np.sin(2*np.pi*t/365)+rng.normal(0,8,days)+promo,0,None)
343
- df=pd.DataFrame({"date":dates,"sales":sales,"dow":dates.dayofweek,"month":dates.month,
344
- "day_of_year":dates.dayofyear,"is_promo":(promo>0).astype(int)})
345
- for l in [1,7,14]: df[f"lag_{l}"]=df["sales"].shift(l)
346
- df["roll_7"]=df["sales"].shift(1).rolling(7).mean()
347
- df["roll_30"]=df["sales"].shift(1).rolling(30).mean()
348
- return df.dropna().reset_index(drop=True)
349
-
350
- FEATS=["dow","month","day_of_year","is_promo","lag_1","lag_7","lag_14","roll_7","roll_30"]
351
- FEAT_LABELS={"lag_7":"Sales 7 days ago","lag_1":"Yesterday's sales","lag_14":"Sales 14 days ago",
352
- "roll_7":"7-day average","roll_30":"30-day average","is_promo":"Promotion active",
353
- "day_of_year":"Day of year","month":"Month","dow":"Day of week"}
354
-
355
- @st.cache_data
356
- def _train(tp,ne):
357
- df=_sales(); sp=int(len(df)*tp/100); tr,te=df.iloc[:sp],df.iloc[sp:]
358
- lr=LinearRegression().fit(tr[FEATS],tr["sales"])
359
- rf=RandomForestRegressor(n_estimators=ne,max_depth=12,min_samples_leaf=3,
360
- random_state=42,n_jobs=-1).fit(tr[FEATS],tr["sales"])
361
- lp=lr.predict(te[FEATS]); rp=rf.predict(te[FEATS])
362
- return lr,rf,te,lp,rp,rf.feature_importances_
363
-
364
- def _met(y,yh):
365
- return (round(mean_absolute_error(y,yh),1),
366
- round(mean_squared_error(y,yh)**.5,1),
367
- round(r2_score(y,yh),3),
368
- round(np.mean(np.abs((y-yh)/np.where(y==0,1,y)))*100,1))
369
 
370
- # ══════════════════════════════════════════════════════════════════════════════
371
- # HEADER
372
- # ══════════════════════════════════════════════════════════════════════════════
373
- st.markdown("<h2 style='margin:0 0 12px;color:#1e293b'>🛒 EcoCart AI System</h2>",
 
374
  unsafe_allow_html=True)
375
 
376
- T1,T2,T3,T4,T5,T6=st.tabs([
377
- "🤖 Task 1 — AI Agents",
378
- "⚖️ Task 2 — Bias Check",
379
- "🗺️ Task 3 — Route Finder",
380
- "📊 Task 4 — Speed Test",
381
- "📈 Task 5 — Sales Forecast",
382
- "💼 Task 6 — Business Case",
383
  ])
384
 
385
  # ══════════════════════════════════════════════════════════════════════════════
386
- # TASK 1
387
  # ══════════════════════════════════════════════════════════════════════════════
388
  with T1:
389
- st.markdown("### Watch the AI delivery agent navigate in real time")
390
- st.caption("Three different AI strategies — pick one and press Play to watch it move stop by stop.")
391
-
392
- # ── agent picker ──────────────────────────────────────────────────────────
393
- a_cols=st.columns(3)
394
- agent_names=list(ROUTES.keys())
395
- if "agent" not in st.session_state: st.session_state.agent="Nearest stop"
396
-
397
- for i,(col,name) in enumerate(zip(a_cols,agent_names)):
398
- km=_route_km(ROUTES[name])
399
- active=st.session_state.agent==name
400
- border=f"3px solid {AGENT_COL[name]}" if active else "2px solid #e2e8f0"
401
- bg=f"{AGENT_COL[name]}12" if active else "#fff"
402
- if col.button(f"{'✓ ' if active else ''}{name} ({km} km)",
403
- key=f"ab_{name}",use_container_width=True):
404
- st.session_state.agent=name
405
- st.session_state.stp=0
406
- st.session_state.playing=False
407
-
408
- agent=st.session_state.agent
409
- ac=AGENT_COL[agent]
410
- route=ROUTES[agent]; mx=len(route)-1
411
-
412
- # ── playback controls ─────────────────────────────────────────────────────
413
- ctl=st.columns([1,1,1,1,3])
414
- if ctl[0].button("⏮ Start"):
415
- st.session_state.stp=0; st.session_state.playing=False
416
- if ctl[1].button("◀ Back") and st.session_state.get("stp",0)>0:
417
- st.session_state.stp-=1; st.session_state.playing=False
418
- if ctl[2].button("▶ Next") and st.session_state.get("stp",0)<mx:
419
- st.session_state.stp+=1; st.session_state.playing=False
420
- playing=st.session_state.get("playing",False)
421
- if ctl[3].button("⏸ Pause" if playing else "▶ Play"):
422
- st.session_state.playing=not playing
423
-
424
- speed=ctl[4].slider("Speed",1,8,3,label_visibility="collapsed",
425
- help="Animation speed (steps per second)")
426
-
427
- stp=st.session_state.get("stp",0)
428
-
429
- fig_agent,km_done=draw_agent(route,stp,ac)
430
-
431
- # ── map + stats ───────────────────────────────────────────────────────────
432
- map_c,stat_c=st.columns([3,1])
433
- with map_c:
434
- st.plotly_chart(fig_agent,use_container_width=True,key="agent_map")
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  with stat_c:
437
- st.markdown(f"<div class='section-label'>Current status</div>",unsafe_allow_html=True)
438
- st.metric("Stops completed",f"{stp} / {mx}")
439
- st.metric("Distance covered",f"{km_done} km")
440
- psum=sum(STOPS[n][2] for n in route[:stp+1] if n!="Depot")
441
- st.metric("Priority points served",psum)
442
- st.markdown(" ")
443
- st.markdown(f"<div class='tip'>{AGENT_DESC[agent]}</div>",unsafe_allow_html=True)
444
- st.markdown("<div class='section-label' style='margin-top:12px'>All agents</div>",unsafe_allow_html=True)
445
- for nm in agent_names:
446
- km=_route_km(ROUTES[nm]); c=AGENT_COL[nm]
447
- rt=ROUTES[nm]
448
- hi=next((i for i,n in enumerate(rt) if n!="Depot" and STOPS[n][2]>=4),"-")
449
- prio_sum=sum(STOPS[n][2] for n in rt if n!="Depot")
450
- st.markdown(
451
- f"<div style='border-left:3px solid {c};padding:6px 10px;"
452
- f"margin:4px 0;background:{'#f8fafc' if nm!=agent else c+'12'};border-radius:0 6px 6px 0'>"
453
- f"<b style='font-size:.82rem'>{nm}</b><br>"
454
- f"<span style='color:{MUTE};font-size:.78rem'>"
455
- f"Distance: {km} km &nbsp;|&nbsp; Priority pts: {prio_sum}<br>"
456
- f"First ⭐ stop: step {hi}</span>"
457
- f"</div>",unsafe_allow_html=True)
458
-
459
- # ── auto-play ─────────────────────────────────────────────────────────────
460
- if st.session_state.get("playing") and stp<mx:
461
- time.sleep(1.0/speed)
462
- st.session_state.stp=stp+1
463
  st.rerun()
464
- elif st.session_state.get("playing") and stp>=mx:
465
- st.session_state.playing=False
466
 
467
  # ══════════════════════════════════════════════════════════════════════════════
468
- # TASK 2
469
  # ══════════════════════════════════════════════════════════════════════════════
470
  with T2:
471
- st.markdown("### Are rural customers being treated fairly by the AI?")
472
- st.caption("Adjust the sliders and watch the fairness score update instantly.")
473
-
474
- ctrl,main=st.columns([1,3])
475
- with ctrl:
476
- nu=st.slider("Urban customers",100,500,300,50)
477
- nr=st.slider("Rural customers",30,200,100,10)
478
- k=st.slider("Groups (K-Means)",2,4,3,1)
479
- fix=st.toggle("Apply fairness fix",True)
480
- st.markdown(" ")
481
- if fix:
482
- st.markdown("""
483
- <div class='tip'>
484
- <b>What the fix does:</b><br><br>
485
- • Rural customers pay ~€12 more per delivery — we add this back to their spend score<br>
486
- • Rural customers batch orders (less frequent, bigger baskets) — we adjust their frequency<br>
487
- • We balance the dataset so rural customers are equally represented during training
488
- </div>""",unsafe_allow_html=True)
489
- else:
490
- st.markdown("""
491
- <div class='tip'>
492
- <b>Why bias happens:</b><br><br>
493
- EcoCart launched in cities first. Urban customers have more data and appear to spend more on the surface.
494
- The AI picks up this pattern and unfairly labels rural customers as low-value.
495
- </div>""",unsafe_allow_html=True)
496
-
497
- with main:
498
- raw=_customers(nu,nr); seg_b=_kmeans(raw,k); ub,rb,dib=_di(seg_b)
499
- if fix: seg_a=_fix(nu,nr,k); ua,ra,dia=_di(seg_a)
500
-
501
- # ── big fairness indicator ────────────────────────────────────────────
502
- mc=st.columns(4)
503
- mc[0].metric("Urban in High Value",f"{ub}%")
504
- mc[1].metric("Rural in High Value",f"{rb}%")
505
- di_val=dia if fix else dib
506
- di_delta=f"{dia-dib:+.2f}" if fix else None
507
- mc[2].metric("Fairness score",f"{di_val:.2f}",delta=di_delta,
508
- help="1.0 = perfectly equal. Aim: ≥ 0.80")
509
- status="FAIR" if di_val>=0.8 else "NOT FAIR"
510
- mc[3].markdown(
511
- f"<div style='background:#fff;border-radius:10px;padding:14px 18px;"
512
- f"box-shadow:0 1px 4px rgba(0,0,0,.07);text-align:center'>"
513
- f"<div style='font-size:.8rem;color:{MUTE}'>Status</div>"
514
- f"<div class='badge-{'green' if di_val>=.8 else 'red'}' "
515
- f"style='font-size:.95rem;margin-top:6px'>{status}</div></div>",
516
- unsafe_allow_html=True)
517
 
518
- if di_val>=0.8: st.success(f"Fairness achieved — score {di_val:.2f} is above the 0.80 threshold.")
519
- else: st.error(f"Score {di_val:.2f} is below 0.80 rural customers are under-served.")
520
-
521
- # ── scatter ───────────────────────────────────────────────────────────
522
- def _scatter(df,title):
523
- fig=go.Figure()
524
- for seg in ["High Value","Medium","Low Value","Group 4"]:
525
- if seg not in df.segment.values: continue
526
- for region,sym in [("urban","circle"),("rural","triangle-up")]:
527
- sub=df[(df.segment==seg)&(df.region==region)]
528
- if sub.empty: continue
529
- fig.add_trace(go.Scatter(x=sub.freq,y=sub.spend,mode="markers",
530
- marker=dict(color=SEG_COL.get(seg,"#94a3b8"),symbol=sym,size=7,opacity=.72),
531
- name=f"{seg} / {region}",
532
- hovertemplate="<b>"+seg+"</b> ("+region+")<br>Purchases: %{x:.1f}/month<br>Avg spend: €%{y:.0f}<extra></extra>"))
533
- fig.update_layout(**_ch(320,title))
534
- fig.update_xaxes(**_xax(title="Purchases per month"))
535
- fig.update_yaxes(**_yax(title="Average spend ()"))
536
- return fig
537
-
538
- if fix:
539
- c1,c2=st.columns(2)
540
- c1.plotly_chart(_scatter(seg_b,"Before fix — biased"),use_container_width=True)
541
- c2.plotly_chart(_scatter(seg_a,"After fix — fair"),use_container_width=True)
542
- else:
543
- st.plotly_chart(_scatter(seg_b,"Customer groups (no fix)"),use_container_width=True)
544
-
545
- # ── bar chart ─────────────────────────────────────────────────────────
546
- fig2=go.Figure()
547
- fig2.add_trace(go.Bar(name="Before fix",x=["Urban → High Value","Rural → High Value"],
548
- y=[ub,rb],marker_color=RED,
549
- text=[f"{ub}%",f"{rb}%"],textposition="outside",textfont_color=FG))
550
- if fix:
551
- fig2.add_trace(go.Bar(name="After fix",x=["Urban High Value","Rural → High Value"],
552
- y=[ua,ra],marker_color=GREEN,
553
- text=[f"{ua}%",f"{ra}%"],textposition="outside",textfont_color=FG))
554
- fig2.update_layout(**_ch(260,"Percentage in High Value group"),barmode="group")
555
- fig2.update_xaxes(**_xax()); fig2.update_yaxes(**_yax(title="%",range=[0,110]))
556
- fig2.add_hline(y=min(ub,ua if fix else ub),line_color="#94a3b8",line_dash="dot",
557
- annotation_text="Urban rate",annotation_font_color=MUTE)
558
- st.plotly_chart(fig2,use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
  # ══════════════════════════════════════════════════════════════════════════════
561
- # TASK 3
562
  # ══════════════════════════════════════════════════════════════════════════════
563
  with T3:
564
- st.markdown("### Watch the AI find the delivery route in real time")
565
- st.caption("Pick start and end points, choose an algorithm, then replay how it explores the network step by step.")
566
-
567
- ctrl3,map3=st.columns([1,3])
568
- with ctrl3:
569
- all_n=list(NODES.keys())
570
- sn=st.selectbox("Start node",all_n,index=0)
571
- en=st.selectbox("End node", all_n,index=19)
572
- al=st.radio("Algorithm",["BFS","DFS","A*","IDA*"],index=2,
573
- captions=["Level-by-level","Deep dive","Guided (best)","Memory-efficient"])
574
- gr=st.toggle("Minimise CO₂ (not distance)",False)
575
- st.divider()
576
- adj=ADJ_CO2 if gr else ADJ_KM
577
- unit="CO2" if gr else "km"
578
-
579
- if sn==en:
580
- st.warning("Choose different start and end."); path,cost,expl=[],0,[]; ms=0
581
- else:
582
- t0=time.perf_counter()
583
- path,cost,expl=ALGOS[al](sn,en,adj)
584
- ms=round((time.perf_counter()-t0)*1000,3)
585
- if path:
586
- st.metric("Route distance",f"{cost} {'km' if unit=='km' else 'kg CO₂'}")
587
- st.metric("Nodes the AI checked",len(expl),help="The fewer the better — the AI was more efficient")
588
- st.metric("Time taken",f"{ms} ms")
589
- st.markdown(
590
- f"<div class='tip'><b>Route:</b> {' → '.join(path)}</div>",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  unsafe_allow_html=True)
592
- else:
593
- st.error("No route found."); path=[]; expl=[]
594
-
595
- with map3:
596
- # ── exploration replay slider ─────────────────────────────────────────
597
- if expl:
598
- replay=st.slider(
599
- "🔍 Replay: drag to see how the AI explored the map",
600
- 0,len(expl),len(expl),
601
- help="0 = no exploration shown, max = full path found")
602
- explored_so_far=set(expl[:replay])
603
- pct=int(replay/len(expl)*100) if expl else 100
604
- st.markdown(
605
- f"<div style='font-size:.82rem;color:{MUTE};margin-bottom:4px'>"
606
- f"<span class='badge-blue'>{replay}/{len(expl)} nodes explored ({pct}%)</span>"
607
- f"{'&nbsp;&nbsp;<span class=badge-green>Route found</span>' if replay==len(expl) and path else ''}"
608
- f"</div>",unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  else:
610
- explored_so_far=set()
611
-
612
- fig_net=build_network(sn,en,path,explored_so_far,adj,unit,al)
613
- st.plotly_chart(fig_net,use_container_width=True)
614
-
615
- # colour legend
616
- leg=st.columns(5)
617
- leg[0].markdown(f"<div style='font-size:.78rem'>⬤ <span style='color:{RED}'>Urban node</span></div>",unsafe_allow_html=True)
618
- leg[1].markdown(f"<div style='font-size:.78rem'>⬤ <span style='color:{GREEN}'>Rural node</span></div>",unsafe_allow_html=True)
619
- leg[2].markdown(f"<div style='font-size:.78rem'>⬤ <span style='color:#bfdbfe'>Explored</span></div>",unsafe_allow_html=True)
620
- leg[3].markdown(f"<div style='font-size:.78rem'>⬤ <span style='color:{AMBER}'>On path</span></div>",unsafe_allow_html=True)
621
- leg[4].markdown(f"<div style='font-size:.78rem'>⬤ <span style='color:#fff;background:{FG};padding:1px 4px;border-radius:3px'>Start</span> / <span style='color:{FG};background:#facc15;padding:1px 4px;border-radius:3px'>End</span></div>",unsafe_allow_html=True)
622
-
623
- # ── side-by-side comparison ───────────────────���───────────────────────────
624
- with st.expander("Compare all 4 algorithms on this route"):
625
- if sn!=en:
626
- rows=[]
627
- for nm in ["BFS","DFS","A*","IDA*"]:
628
- t0=time.perf_counter(); p,c,e=ALGOS[nm](sn,en,adj); ms2=(time.perf_counter()-t0)*1000
629
- rows.append({"Algorithm":nm,
630
- f"Distance ({'km' if unit=='km' else 'CO₂'})":round(c,2) if p else "N/A",
631
- "Nodes checked":len(e),"Time (ms)":round(ms2,3),
632
- "Finds shortest?":nm in ["A*","IDA*","BFS"]})
633
- df_c=pd.DataFrame(rows)
634
- st.dataframe(df_c,use_container_width=True,hide_index=True)
635
-
636
- fc=make_subplots(rows=1,cols=2,subplot_titles=["Nodes checked (fewer = smarter)","Time (ms)"])
637
- pal=[BLUE,RED,GREEN,PURPLE]
638
- for col,ci in [("Nodes checked",1),("Time (ms)",2)]:
639
- fc.add_trace(go.Bar(x=df_c["Algorithm"],y=df_c[col],marker_color=pal,
640
- text=df_c[col],textposition="outside",textfont_color=FG,
641
- showlegend=False),row=1,col=ci)
642
- fc.update_layout(paper_bgcolor=SURF,plot_bgcolor=BG,font_color=FG,height=280,
643
- margin=dict(l=40,r=20,t=50,b=30))
644
- fc.update_xaxes(gridcolor=LINE); fc.update_yaxes(gridcolor=LINE)
645
- st.plotly_chart(fc,use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
 
647
  # ══════════════════════════════════════════════════════════════════════════════
648
- # TASK 4
649
  # ═══════════════════════════════��══════════════════════════════════════════════
650
  with T4:
651
- st.markdown("### Head-to-head: A* vs IDA* on real delivery routes")
652
- st.caption("We run both algorithms on 10 routes and measure speed and efficiency. Results appear as they complete.")
 
 
 
 
 
 
 
653
 
654
- c1,c2=st.columns([1,3])
655
- with c1:
656
- nruns=st.slider("Timing runs per route",5,30,20,5)
657
- go_btn=st.button("▶ Run the test",type="primary",use_container_width=True)
658
- st.markdown("""
659
- <div class='tip'>
660
- <b>A*</b> keeps an open list in memory — very fast to find a path, but uses more RAM.<br><br>
661
- <b>IDA*</b> uses almost no memory — it re-searches with a tighter limit each time. Slower here but scales to huge networks.
662
- </div>""",unsafe_allow_html=True)
663
-
664
- with c2:
665
- OD_U=[("U1","U10"),("U7","U6"),("U2","U9"),("U1","U9"),("U3","U8")]
666
- OD_R=[("R1","R9"),("R2","R8"),("R3","R10"),("R1","R6"),("R4","R9")]
667
-
668
- if go_btn:
669
- rows=[]; chart_ph=st.empty(); prog=st.progress(0); status_ph=st.empty()
670
- total=(len(OD_U)+len(OD_R))*2; done=0
671
-
672
- for zone,pairs in [("Urban",OD_U),("Rural",OD_R)]:
673
- for s,g in pairs:
674
- for nm,fn in [("A*",astar),("IDA*",ida_star)]:
675
- times=[]
676
- p=c3=None; e=[]
677
- for _ in range(nruns):
678
- t0=time.perf_counter(); p,c3,e=fn(s,g,ADJ_KM)
679
- times.append((time.perf_counter()-t0)*1000)
680
- rows.append({"Zone":zone,"Route":f"{s}→{g}","Algorithm":nm,
681
- "Distance (km)":c3,"Nodes checked":len(e),
682
- "Avg time (ms)":round(sum(times)/len(times),3)})
683
- done+=1; prog.progress(done/total)
684
- status_ph.markdown(
685
- f"<span class='badge-blue'>Testing {s}→{g} with {nm}...</span>",
686
- unsafe_allow_html=True)
687
-
688
- # live chart update
689
- if len(rows)>=2:
690
- df_live=pd.DataFrame(rows)
691
- sm=df_live.groupby(["Zone","Algorithm"])[["Nodes checked","Avg time (ms)"]].mean().reset_index()
692
- fl=make_subplots(rows=1,cols=2,
693
- subplot_titles=["Avg nodes checked","Avg time (ms)"])
694
- for anm,acl in [("A*",BLUE),("IDA*",PURPLE)]:
695
- sub=sm[sm.Algorithm==anm]
696
- if sub.empty: continue
697
- for key,ci in [("Nodes checked",1),("Avg time (ms)",2)]:
698
- fl.add_trace(go.Bar(name=anm,x=sub["Zone"],y=sub[key].round(2),
699
- marker_color=acl,showlegend=(ci==1),
700
- text=sub[key].round(2),textposition="outside",
701
- textfont_color=FG),row=1,col=ci)
702
- fl.update_layout(paper_bgcolor=SURF,plot_bgcolor=BG,font_color=FG,
703
- barmode="group",height=320,
704
- margin=dict(l=40,r=20,t=50,b=30),
705
- legend=dict(bgcolor=SURF,bordercolor=LINE))
706
- fl.update_xaxes(gridcolor=LINE); fl.update_yaxes(gridcolor=LINE)
707
- chart_ph.plotly_chart(fl,use_container_width=True,key=f"bench_{done}")
708
-
709
- prog.empty(); status_ph.empty()
710
- df_b=pd.DataFrame(rows)
711
- st.dataframe(df_b,use_container_width=True,hide_index=True)
712
-
713
- ae=df_b[df_b.Algorithm=="A*"]["Nodes checked"].mean()
714
- ie=df_b[df_b.Algorithm=="IDA*"]["Nodes checked"].mean()
715
- at=df_b[df_b.Algorithm=="A*"]["Avg time (ms)"].mean()
716
- it=df_b[df_b.Algorithm=="IDA*"]["Avg time (ms)"].mean()
717
- winner="A*" if at<it else "IDA*"
718
- st.success(
719
- f"**Result:** A* checked {ae:.0f} nodes on average vs IDA*'s {ie:.0f}. "
720
- f"**{winner}** was faster on this map ({at:.3f} ms vs {it:.3f} ms). "
721
- f"On a national road network with millions of junctions, IDA*'s near-zero memory use makes it the only practical choice.")
722
- else:
723
- st.info("Click **▶ Run the test** the chart will build live as results come in.")
 
 
 
 
724
 
725
  # ══════════════════════════════════════════════════════════════════════════════
726
- # TASK 5
727
  # ══════════════════════════════════════════════════════════════════════════════
728
  with T5:
729
- st.markdown("### Predicting EcoCart's daily sales with machine learning")
730
- st.caption("Two models trained on 2 years of data. Adjust settings and the chart updates instantly.")
731
-
732
- ctrl5,main5=st.columns([1,3])
733
- with ctrl5:
734
- tp=st.slider("Training data",60,90,80,5,format="%d%%")
735
- ne=st.slider("Random Forest trees",50,300,200,50)
736
- show5=st.radio("Show",["Both","Linear Regression","Random Forest"])
737
- st.divider()
738
- st.markdown("<div class='section-label'>Try your own prediction</div>",unsafe_allow_html=True)
739
- st.markdown("<div class='tip'>Set values for any day and see what the model predicts.</div>",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  unsafe_allow_html=True)
741
- wi_dow=st.selectbox("Day of week",["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],index=4)
742
- wi_month=st.selectbox("Month",["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],index=0)
743
- wi_promo=st.toggle("Promotion running today?",False)
744
- wi_lag1=st.number_input("Yesterday's sales",min_value=50,max_value=300,value=120,step=5)
745
- wi_lag7=st.number_input("Sales 7 days ago", min_value=50,max_value=300,value=115,step=5)
746
-
747
- with main5:
748
- with st.spinner("Training models…"):
749
- lr_o,rf_o,te_df,lp,rp,imps=_train(tp,ne)
750
-
751
- y=te_df["sales"].values; dates=te_df["date"].values
752
-
753
- lmae,lrmse,lr2,lmape=_met(y,lp)
754
- rmae,rrmse,rr2,rmape=_met(y,rp)
755
-
756
- mc=st.columns(4)
757
- mc[0].metric("Linear Reg accuracy (R²)",lr2)
758
- mc[1].metric("Linear Reg avg error",f"±{lmae} units")
759
- mc[2].metric("Random Forest accuracy (R²)",rr2,delta=f"{rr2-lr2:+.3f}")
760
- mc[3].metric("Random Forest avg error",f"±{rmae} units",delta=f"{rmae-lmae:+.1f}")
761
-
762
- # ── what-if prediction ────────────────────────────────────────────────
763
- dow_map={"Mon":0,"Tue":1,"Wed":2,"Thu":3,"Fri":4,"Sat":5,"Sun":6}
764
- mon_map={"Jan":1,"Feb":2,"Mar":3,"Apr":4,"May":5,"Jun":6,
765
- "Jul":7,"Aug":8,"Sep":9,"Oct":10,"Nov":11,"Dec":12}
766
- wi_doy=int((mon_map[wi_month]-1)*30.4+15)
767
- wi_r7=round((wi_lag1+wi_lag7)/2)
768
- wi_r30=round((wi_lag1+wi_lag7)/2)
769
- wi_row=[[dow_map[wi_dow],mon_map[wi_month],wi_doy,int(wi_promo),
770
- wi_lag1,wi_lag7,wi_lag7,wi_r7,wi_r30]]
771
- wi_pred_rf=round(rf_o.predict(wi_row)[0],0)
772
- wi_pred_lr=round(lr_o.predict(wi_row)[0],0)
773
-
774
- wc=st.columns(3)
775
- wc[0].markdown(
776
- f"<div style='background:#fff;border-radius:10px;padding:14px 18px;"
777
- f"box-shadow:0 1px 4px rgba(0,0,0,.07);text-align:center'>"
778
- f"<div style='font-size:.78rem;color:{MUTE}'>Your scenario prediction</div>"
779
- f"<div style='font-size:1.6rem;font-weight:700;color:{GREEN}'>{int(wi_pred_rf)}</div>"
780
- f"<div style='font-size:.78rem;color:{MUTE}'>units (Random Forest)</div></div>",
781
- unsafe_allow_html=True)
782
- wc[1].markdown(
783
- f"<div style='background:#fff;border-radius:10px;padding:14px 18px;"
784
- f"box-shadow:0 1px 4px rgba(0,0,0,.07);text-align:center'>"
785
- f"<div style='font-size:.78rem;color:{MUTE}'>Linear Regression says</div>"
786
- f"<div style='font-size:1.6rem;font-weight:700;color:{BLUE}'>{int(wi_pred_lr)}</div>"
787
- f"<div style='font-size:.78rem;color:{MUTE}'>units</div></div>",
788
- unsafe_allow_html=True)
789
- wc[2].markdown(
790
- f"<div style='background:#fff;border-radius:10px;padding:14px 18px;"
791
- f"box-shadow:0 1px 4px rgba(0,0,0,.07);text-align:center'>"
792
- f"<div style='font-size:.78rem;color:{MUTE}'>Promotion boost</div>"
793
- f"<div style='font-size:1.6rem;font-weight:700;color:{AMBER}'>{'Yes +~40' if wi_promo else 'None'}</div>"
794
- f"<div style='font-size:.78rem;color:{MUTE}'>estimated extra units</div></div>",
795
- unsafe_allow_html=True)
796
 
797
- st.markdown(" ")
798
-
799
- # ── forecast chart with range selector ───────────────────────────────
800
- fig5=go.Figure()
801
- fig5.add_trace(go.Scatter(x=dates,y=y,name="Actual sales",
802
- line=dict(color=FG,width=1.5),opacity=.85,
803
- hovertemplate="<b>Actual</b><br>%{x|%d %b %Y}<br>%{y:.0f} units<extra></extra>"))
804
- if show5 in ("Both","Linear Regression"):
805
- fig5.add_trace(go.Scatter(x=dates,y=lp,name="Linear Regression",
806
- line=dict(color=BLUE,width=1.5,dash="dot"),
807
- hovertemplate="<b>LR Prediction</b><br>%{x|%d %b %Y}<br>%{y:.0f} units<extra></extra>"))
808
- if show5 in ("Both","Random Forest"):
809
- fig5.add_trace(go.Scatter(x=dates,y=rp,name="Random Forest",
810
- line=dict(color=GREEN,width=1.5),
811
- hovertemplate="<b>RF Prediction</b><br>%{x|%d %b %Y}<br>%{y:.0f} units<extra></extra>"))
812
- fig5.update_layout(**_ch(360,f"Actual vs predicted — test set ({100-tp}% of data)"))
813
- fig5.update_xaxes(**_xax(title="Date",
814
- rangeselector=dict(
815
- bgcolor=SURF,
816
- buttons=[dict(count=30,label="30d",step="day",stepmode="backward"),
817
- dict(count=60,label="60d",step="day",stepmode="backward"),
818
- dict(count=90,label="90d",step="day",stepmode="backward"),
819
- dict(step="all",label="All")])))
820
- fig5.update_yaxes(**_yax(title="Units sold"))
821
- st.plotly_chart(fig5,use_container_width=True)
822
-
823
- r_col,i_col=st.columns(2)
824
- with r_col:
825
- fig_r=go.Figure()
826
- if show5 in ("Both","Linear Regression"):
827
- fig_r.add_trace(go.Scatter(x=lp,y=y-lp,mode="markers",name="Linear Reg",
828
- marker=dict(color=BLUE,size=5,opacity=.5),
829
- hovertemplate="Predicted %{x:.0f}<br>Error %{y:.0f} units<extra></extra>"))
830
- if show5 in ("Both","Random Forest"):
831
- fig_r.add_trace(go.Scatter(x=rp,y=y-rp,mode="markers",name="Random Forest",
832
- marker=dict(color=GREEN,size=5,opacity=.5),
833
- hovertemplate="Predicted %{x:.0f}<br>Error %{y:.0f} units<extra></extra>"))
834
- fig_r.add_hline(y=0,line_color="#94a3b8",line_width=1.5,line_dash="dash")
835
- fig_r.update_layout(**_ch(280,"Prediction errors (closer to 0 = better)"))
836
- fig_r.update_xaxes(**_xax(title="Predicted units"))
837
- fig_r.update_yaxes(**_yax(title="Error (actual − predicted)"))
838
- st.plotly_chart(fig_r,use_container_width=True)
839
-
840
- with i_col:
841
- imp=pd.Series(imps,index=FEATS).sort_values()
842
- fi=go.Figure(go.Bar(
843
- x=imp.values,
844
- y=[FEAT_LABELS.get(i,i) for i in imp.index],
845
- orientation="h",
846
- marker=dict(color=imp.values,colorscale=[[0,"#d1fae5"],[1,GREEN]],showscale=False),
847
- text=[f"{v:.3f}" for v in imp.values],
848
- textposition="outside",textfont_color=FG,
849
- hovertemplate="%{y}<br>Importance: %{x:.3f}<extra></extra>"))
850
- fi.update_layout(**_ch(280,"What does the model rely on most?"))
851
- fi.update_xaxes(**_xax(title="Importance score"))
852
- fi.update_yaxes(**_yax())
853
- st.plotly_chart(fi,use_container_width=True)
854
-
855
- winner="Random Forest" if rr2>=lr2 else "Linear Regression"
856
- st.success(
857
- f"**{winner}** is more accurate (R² = {max(lr2,rr2):.3f}). "
858
- f"The top predictor is **{FEAT_LABELS['lag_7']}** — because the same weekday last week "
859
- f"is the single best baseline for today's sales.")
860
-
861
- with st.expander("See raw prediction data"):
862
- st.dataframe(pd.DataFrame({"Date":dates,"Actual":y.round(1),
863
- "LR Prediction":lp.round(1),"RF Prediction":rp.round(1),
864
- "LR Error":(y-lp).round(1),"RF Error":(y-rp).round(1)}),
865
  use_container_width=True)
866
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  # ══════════════════════════════════════════════════════════════════════════════
868
- # TASK 6 — BUSINESS CASE (optional for AI students — covers 20% Business Viability)
869
  # ══════════════════════════════════════════════════════════════════════════════
870
  with T6:
871
- st.markdown("### Business Case — ROI & Sustainability Impact")
872
- st.caption("Adjust the assumptions below to model EcoCart's financial and environmental gains from the AI system.")
873
-
874
- # ── assumption sliders ────────────────────────────────────────────────────
875
- st.markdown("#### Your business assumptions")
876
- c1,c2,c3=st.columns(3)
877
- with c1:
878
- fleet = st.slider("Fleet size (vehicles)", 5, 100, 30, 5)
879
- deliveries = st.slider("Deliveries per vehicle/day", 10, 80, 40, 5)
880
- avg_km = st.slider("Avg km per delivery", 2, 30, 12, 1)
881
- with c2:
882
- fuel_cost = st.slider("Fuel cost per km (€)", 0.10, 0.60, 0.32, 0.01, format="€%.2f")
883
- driver_wage = st.slider("Driver hourly wage (€)", 10, 35, 18, 1, format="€%d")
884
- working_days= st.slider("Working days per year", 200, 365, 300, 10)
885
- with c3:
886
- route_saving_pct = st.slider("Route saving from A* (%)", 5, 35, 18, 1, format="%d%%",
887
- help="How much shorter routes become with A* vs manual planning")
888
- forecast_waste_pct= st.slider("Waste cut from forecasting (%)", 5, 40, 22, 1, format="%d%%",
889
- help="Reduction in overstock/understock from ML demand prediction")
890
- segment_revenue = st.slider("Extra revenue from fair targeting (€k/yr)", 10, 200, 65, 5)
891
-
892
- st.divider()
893
 
894
- # ── calculations ──────────────────────────────────────────────────────────
895
- total_deliveries_yr = fleet * deliveries * working_days
896
- total_km_yr = total_deliveries_yr * avg_km
897
-
898
- # route savings
899
- km_saved = total_km_yr * route_saving_pct / 100
900
- fuel_saved = km_saved * fuel_cost
901
- time_saved_hrs = km_saved / 40 # assume 40 km/h avg
902
- driver_time_saved = time_saved_hrs * driver_wage
903
- route_total_saving = fuel_saved + driver_time_saved
904
-
905
- # CO2 savings (diesel: ~0.27 kg CO2/km urban, ~0.21 rural avg 0.24)
906
- co2_saved_kg = km_saved * 0.24
907
- co2_saved_tonnes = co2_saved_kg / 1000
908
-
909
- # forecast savings (assume avg inventory cost €8 per unit, 500 SKUs)
910
- inventory_cost_base = 500 * 8 * working_days * 0.05 # 5% daily holding cost approximation
911
- forecast_saving = inventory_cost_base * forecast_waste_pct / 100
912
-
913
- # segmentation revenue uplift
914
- segment_saving = segment_revenue * 1000
915
-
916
- # total benefit
917
- total_benefit = route_total_saving + forecast_saving + segment_saving
918
-
919
- # implementation cost (one-off dev + annual cloud)
920
- dev_cost = 45000 # one-off
921
- annual_ops = 8000 # cloud + maintenance per year
922
- total_cost_yr1 = dev_cost + annual_ops
923
- total_cost_yr3 = dev_cost + annual_ops * 3
924
-
925
- roi_yr1 = round((total_benefit - total_cost_yr1) / total_cost_yr1 * 100, 1)
926
- roi_yr3 = round((total_benefit * 3 - total_cost_yr3) / total_cost_yr3 * 100, 1)
927
- payback = round(total_cost_yr1 / total_benefit * 12, 1) # months
928
-
929
- # ── headline metrics ──────────────────────────────────────────────────────
930
- st.markdown("#### Results")
931
- m=st.columns(4)
932
- m[0].metric("Annual cost saving", f"€{total_benefit/1000:.1f}k")
933
- m[1].metric("Year-1 ROI", f"{roi_yr1}%",
934
- delta="positive" if roi_yr1>0 else "negative")
935
- m[2].metric("Payback period", f"{payback} months")
936
- m[3].metric("CO₂ saved per year", f"{co2_saved_tonnes:.1f} tonnes")
937
-
938
- # ── savings breakdown bar chart ───────────────────────────────────────────
939
- fig_roi=go.Figure()
940
- categories=["Route\nOptimisation","Demand\nForecasting","Fairer\nSegmentation"]
941
- values=[round(route_total_saving/1000,1),
942
- round(forecast_saving/1000,1),
943
- round(segment_saving/1000,1)]
944
- colors=[BLUE,GREEN,AMBER]
945
- fig_roi.add_trace(go.Bar(
946
- x=categories, y=values,
947
- marker_color=colors,
948
- text=[f"€{v}k" for v in values],
949
- textposition="outside", textfont_color=FG,
950
- hovertemplate="%{x}<br>Saving: €%{y}k/year<extra></extra>",
951
- width=0.5,
952
- ))
953
- fig_roi.update_layout(**_ch(300,"Annual savings breakdown by AI module (€ thousands)"))
954
- fig_roi.update_xaxes(**_xax())
955
- fig_roi.update_yaxes(**_yax(title=" thousands"))
956
- st.plotly_chart(fig_roi,use_container_width=True)
957
-
958
- # ── 3-year cumulative ROI line ────────────────────────────────────────────
959
- years=[0,1,2,3]
960
- cumulative_benefit=[0, total_benefit, total_benefit*2, total_benefit*3]
961
- cumulative_cost =[0, total_cost_yr1, total_cost_yr1+annual_ops, total_cost_yr1+annual_ops*2]
962
- cumulative_net =[b-c for b,c in zip(cumulative_benefit,cumulative_cost)]
963
-
964
- fig_cum=go.Figure()
965
- fig_cum.add_trace(go.Scatter(x=years,y=[v/1000 for v in cumulative_benefit],
966
- name="Cumulative benefit",line=dict(color=GREEN,width=2.5),
967
- hovertemplate="Year %{x}<br>Benefit: €%{y:.1f}k<extra></extra>"))
968
- fig_cum.add_trace(go.Scatter(x=years,y=[v/1000 for v in cumulative_cost],
969
- name="Cumulative cost",line=dict(color=RED,width=2.5,dash="dash"),
970
- hovertemplate="Year %{x}<br>Cost: €%{y:.1f}k<extra></extra>"))
971
- fig_cum.add_trace(go.Scatter(x=years,y=[v/1000 for v in cumulative_net],
972
- name="Net gain",line=dict(color=BLUE,width=2.5,dash="dot"),
973
- fill="tozeroy",fillcolor="rgba(59,130,246,0.08)",
974
- hovertemplate="Year %{x}<br>Net: €%{y:.1f}k<extra></extra>"))
975
- fig_cum.add_hline(y=0,line_color="#94a3b8",line_width=1.5,line_dash="dash")
976
- fig_cum.update_layout(**_ch(300,"3-year cumulative ROI projection (€ thousands)"))
977
- fig_cum.update_xaxes(**_xax(title="Year",tickvals=[0,1,2,3],ticktext=["Now","Year 1","Year 2","Year 3"]))
978
- fig_cum.update_yaxes(**_yax(title="€ thousands"))
979
- st.plotly_chart(fig_cum,use_container_width=True)
980
-
981
- # ── CO2 & sustainability ──────────────────────────────────────────────────
982
- st.markdown("#### Sustainability Impact")
983
- sc=st.columns(3)
984
- trees_equiv = round(co2_saved_tonnes * 45) # ~45 trees absorb 1 tonne CO2/year
985
- cars_equiv = round(co2_saved_tonnes / 2.3) # avg car emits 2.3 tonnes CO2/year
986
- sc[0].metric("CO₂ saved per year", f"{co2_saved_tonnes:.1f} tonnes")
987
- sc[1].metric("Equivalent trees planted", f"{trees_equiv:,}")
988
- sc[2].metric("Cars taken off the road", f"{cars_equiv:,}")
989
-
990
- fig_co2=go.Figure(go.Bar(
991
- x=["Fuel savings\n(route opt.)","Green routing\n(CO₂ mode)","Total CO₂\nreduction"],
992
- y=[round(co2_saved_tonnes*0.75,1), round(co2_saved_tonnes*0.25,1), round(co2_saved_tonnes,1)],
993
- marker_color=[GREEN,BLUE,AMBER],
994
- text=[f"{v:.1f}t" for v in [co2_saved_tonnes*0.75, co2_saved_tonnes*0.25, co2_saved_tonnes]],
995
- textposition="outside",textfont_color=FG,width=0.45,
996
- hovertemplate="%{x}<br>%{y:.1f} tonnes CO₂/year<extra></extra>",
997
- ))
998
- fig_co2.update_layout(**_ch(280,"Annual CO₂ reduction (tonnes)"))
999
- fig_co2.update_xaxes(**_xax())
1000
- fig_co2.update_yaxes(**_yax(title="Tonnes CO₂"))
1001
- st.plotly_chart(fig_co2,use_container_width=True)
1002
-
1003
- # ── implementation roadmap ────────────────────────────────────────────────
1004
- st.markdown("#### Implementation Roadmap")
1005
- phases=[
1006
- dict(Task="Phase 1: Route Optimisation (A*)", Start=0, Finish=2, Color=BLUE),
1007
- dict(Task="Phase 2: Bias Fix (Segmentation)", Start=1, Finish=3, Color=AMBER),
1008
- dict(Task="Phase 3: Demand Forecasting (ML)", Start=2, Finish=5, Color=GREEN),
1009
- dict(Task="Phase 4: Integration & Testing", Start=4, Finish=6, Color=PURPLE),
1010
- dict(Task="Phase 5: Full Deployment", Start=6, Finish=8, Color=RED),
1011
- ]
1012
- fig_rm=go.Figure()
1013
- for i,p in enumerate(phases):
1014
- fig_rm.add_trace(go.Bar(
1015
- x=[p["Finish"]-p["Start"]], y=[p["Task"]],
1016
- base=p["Start"], orientation="h",
1017
- marker_color=p["Color"], marker_opacity=0.85,
1018
- text=f"Month {p['Start']+1}–{p['Finish']}",
1019
- textposition="inside", textfont=dict(color="#fff",size=11),
1020
- showlegend=False,
1021
- hovertemplate=f"<b>{p['Task']}</b><br>Month {p['Start']+1} → {p['Finish']}<extra></extra>",
1022
- ))
1023
- fig_rm.update_layout(**_ch(280,"Deployment timeline (months)"))
1024
- fig_rm.update_xaxes(**_xax(title="Month",tickvals=list(range(9)),
1025
- ticktext=[f"M{i}" for i in range(9)]))
1026
- fig_rm.update_yaxes(**_yax(autorange="reversed"))
1027
- st.plotly_chart(fig_rm,use_container_width=True)
1028
-
1029
- # ── summary box ───────────────────────────────────────────────────────────
1030
- st.markdown(
1031
- f"<div style='background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;"
1032
- f"padding:20px 24px;margin-top:8px'>"
1033
- f"<b style='font-size:1rem;color:#065f46'>Business Summary</b><br><br>"
1034
- f"EcoCart's AI system delivers an estimated <b>€{total_benefit/1000:.0f}k in annual savings</b> "
1035
- f"across three areas: smarter routing (A* reduces km by {route_saving_pct}%), "
1036
- f"better stock management (ML cuts waste by {forecast_waste_pct}%), "
1037
- f"and fairer customer targeting (rural revenue uplift of €{segment_revenue}k). "
1038
- f"The system pays for itself in <b>{payback} months</b> and generates a "
1039
- f"<b>{roi_yr3}% ROI over 3 years</b>. "
1040
- f"It also removes <b>{co2_saved_tonnes:.1f} tonnes of CO₂</b> annually — "
1041
- f"directly supporting EcoCart's sustainability commitments."
1042
- f"</div>",
1043
- unsafe_allow_html=True,
1044
- )
 
1
  """
2
+ EcoCart AI System Streamlit App
3
+ Runs the actual task scripts and displays their real outputs.
4
+ NCI MSCAI | Fundamentals of AI TABA 2026
5
  """
6
 
7
+ import sys, io, os, math, heapq, time
8
+ from contextlib import redirect_stdout
9
  from collections import deque
10
 
11
  import numpy as np
12
  import pandas as pd
13
+ import streamlit as st
14
  import plotly.graph_objects as go
15
  from plotly.subplots import make_subplots
 
 
 
 
 
 
16
 
17
+ # ── page config ───────────────────────────────────────────────────────────────
18
  st.set_page_config(page_title="EcoCart AI", layout="wide",
19
+ initial_sidebar_state="expanded")
20
 
21
  st.markdown("""
22
  <style>
23
+ [data-testid="stAppViewContainer"] { background:#f8fafc; }
 
24
  .block-container { padding:1rem 2rem 3rem; }
25
+ .stTabs [data-baseweb="tab"] { font-size:.88rem; font-weight:600; padding:8px 20px; }
26
+
27
+ .step-box {
28
+ background:#fff; border-radius:10px; padding:16px 20px;
29
+ box-shadow:0 1px 4px rgba(0,0,0,.08); margin-bottom:12px;
30
+ }
31
+ .step-num {
32
+ display:inline-block; background:#1e293b; color:#fff;
33
+ border-radius:50%; width:26px; height:26px; text-align:center;
34
+ line-height:26px; font-size:.8rem; font-weight:700; margin-right:8px;
35
+ }
36
+ .output-box {
37
+ background:#0f172a; border-radius:8px; padding:14px 18px;
38
+ font-family:monospace; font-size:.82rem; color:#e2e8f0;
39
+ white-space:pre-wrap; margin:8px 0;
40
+ }
41
+ .insight-box {
42
+ background:#f0fdf4; border-left:4px solid #10b981;
43
+ padding:12px 16px; border-radius:0 8px 8px 0; margin:8px 0;
44
+ font-size:.88rem; color:#064e3b;
45
+ }
46
+ .warn-box {
47
+ background:#fff7ed; border-left:4px solid #f59e0b;
48
+ padding:12px 16px; border-radius:0 8px 8px 0; margin:8px 0;
49
+ font-size:.88rem; color:#78350f;
50
+ }
51
+ .info-box {
52
+ background:#eff6ff; border-left:4px solid #3b82f6;
53
+ padding:12px 16px; border-radius:0 8px 8px 0; margin:8px 0;
54
+ font-size:.88rem; color:#1e3a5f;
55
+ }
56
+ div[data-testid="metric-container"] {
57
+ background:#fff; border-radius:10px; padding:12px 16px;
58
+ box-shadow:0 1px 3px rgba(0,0,0,.07);
59
+ }
60
  </style>
61
  """, unsafe_allow_html=True)
62
 
63
  # ── colours ───────────────────────────────────────────────────────────────────
64
+ BG, SURF, LINE = "#f8fafc", "#ffffff", "#e2e8f0"
65
+ FG, MUTE = "#1e293b", "#64748b"
66
+ GREEN, BLUE, RED, AMBER = "#10b981", "#3b82f6", "#ef4444", "#f59e0b"
67
+
68
+ # ── chart helper ──────────────────────────────────────────────────────────────
69
+ def _ch(h=380, title=""):
70
+ return dict(height=h, paper_bgcolor=SURF, plot_bgcolor=BG,
71
+ font=dict(color=FG, size=11),
72
+ title=dict(text=title, font=dict(size=13, color=FG), x=0),
73
+ margin=dict(l=50, r=20, t=48, b=40),
74
+ legend=dict(bgcolor=SURF, bordercolor=LINE, borderwidth=1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  # ══════════════════════════════════════════════════════════════════════════════
77
+ # SIDEBAR — navigation guide
78
  # ══════════════════════════════════════════════════════════════════════════════
79
+ with st.sidebar:
80
+ st.markdown("### EcoCart AI System")
81
+ st.markdown("**How to use this app:**")
82
+ st.markdown("""
83
+ 1. Click each tab at the top
84
+ 2. Read the *What is this?* section
85
+ 3. Press the **Run** button
86
+ 4. See the real output from the code
87
+ 5. Read the *What does this tell us?* section
88
+ """)
89
+ st.divider()
90
+ st.markdown("**Tasks covered:**")
91
+ st.markdown("🤖 Task 1 — AI Agent Types")
92
+ st.markdown("⚖️ Task 2 — Bias in Segmentation")
93
+ st.markdown("🗺️ Task 3 Search Algorithms")
94
+ st.markdown("📊 Task 4 — A* vs IDA* Benchmark")
95
+ st.markdown("📈 Task 5 Demand Forecasting")
96
+ st.markdown("💼 Task 6 — Business Case")
97
+ st.divider()
98
+ st.caption("All outputs in Tasks 2–5 are generated by running the actual task Python scripts.")
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ # ── header ────────────────────────────────────────────────────────────────────
101
+ st.markdown("<h2 style='margin:0 0 4px;color:#1e293b'>EcoCart AI System</h2>",
102
+ unsafe_allow_html=True)
103
+ st.markdown("<p style='color:#64748b;font-size:.85rem;margin:0 0 16px'>"
104
+ "An AI-powered logistics solution — NCI MSCAI Fundamentals of AI 2026</p>",
105
  unsafe_allow_html=True)
106
 
107
+ T1, T2, T3, T4, T5, T6 = st.tabs([
108
+ "🤖 Task 1 — AI Agents",
109
+ "⚖️ Task 2 — Bias",
110
+ "🗺️ Task 3 — Routes",
111
+ "📊 Task 4 — A* vs IDA*",
112
+ "📈 Task 5 — Forecast",
113
+ "💼 Task 6 — Business",
114
  ])
115
 
116
  # ══════════════════════════════════════════════════════════════════════════════
117
+ # TASK 1 — AI AGENTS (interactive simulation)
118
  # ══════════════════════════════════════════════════════════════════════════════
119
  with T1:
120
+ st.markdown("""
121
+ <div class='step-box'>
122
+ <span class='step-num'>?</span><b>What is this?</b><br><br>
123
+ An AI agent is a system that perceives its environment and takes actions to achieve a goal.
124
+ EcoCart needs agents that can plan delivery routes, forecast demand, and segment customers.
125
+ Three types of agents exist — each making decisions differently. This simulation shows all
126
+ three navigating the same delivery map so you can see the difference.
127
+ </div>
128
+ """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ # ── agent simulation data ─────────────────────────────────────────────────
131
+ STOPS = {
132
+ "Depot": (0.0, 0.0, 0), "Shop A": (2.0, 3.0, 3), "Shop B": (5.0, 1.0, 4),
133
+ "Shop C": (7.0, 4.0, 2), "Shop D": (3.0, 6.0, 5), "Shop E": (8.0, 7.0, 1),
134
+ "Shop F": (1.0, 8.0, 3), "Shop G": (6.0, 9.0, 4), "Shop H": (9.0, 2.0, 2),
135
+ }
136
+ def _sd(a, b):
137
+ ax, ay, _ = STOPS[a]; bx, by, _ = STOPS[b]
138
+ return math.hypot(ax - bx, ay - by)
139
+
140
+ @st.cache_data
141
+ def _get_routes():
142
+ def reactive():
143
+ r = ["Depot"]; u = [k for k in STOPS if k != "Depot"]; c = "Depot"
144
+ while u:
145
+ nb = min(u, key=lambda n: _sd(c, n)); r.append(nb); u.remove(nb); c = nb
146
+ return r + ["Depot"]
147
+ def goal():
148
+ r = reactive()[:-1]
149
+ td = lambda x: sum(_sd(x[i], x[i+1]) for i in range(len(x)-1)) + _sd(x[-1], x[0])
150
+ ok = True
151
+ while ok:
152
+ ok = False
153
+ for i in range(1, len(r)-1):
154
+ for j in range(i+1, len(r)):
155
+ nr = r[:i] + r[i:j+1][::-1] + r[j+1:]
156
+ if td(nr) < td(r) - 1e-9: r = nr; ok = True
157
+ return r + ["Depot"]
158
+ def utility():
159
+ r = ["Depot"]; u = [k for k in STOPS if k != "Depot"]; c = "Depot"
160
+ while u:
161
+ nb = max(u, key=lambda n: STOPS[n][2] / (_sd(c, n) + .1))
162
+ r.append(nb); u.remove(nb); c = nb
163
+ return r + ["Depot"]
164
+ return {
165
+ "Reactive Agent\n(goes to nearest stop)": reactive(),
166
+ "Goal-Based Agent\n(plans full route)": goal(),
167
+ "Utility-Based Agent\n(urgent stops first)": utility(),
168
+ }
169
+
170
+ ROUTES = _get_routes()
171
+ RCOLS = {
172
+ "Reactive Agent\n(goes to nearest stop)": BLUE,
173
+ "Goal-Based Agent\n(plans full route)": GREEN,
174
+ "Utility-Based Agent\n(urgent stops first)": AMBER,
175
+ }
176
+
177
+ # ── controls ──────────────────────────────────────────────────────────────
178
+ st.markdown("<div class='step-box'><span class='step-num'>1</span>"
179
+ "<b>Pick an agent type and watch it deliver</b></div>",
180
+ unsafe_allow_html=True)
181
+
182
+ agent = st.radio("Agent type:", list(ROUTES.keys()), horizontal=True,
183
+ label_visibility="collapsed")
184
+ ac = RCOLS[agent]
185
+ route = ROUTES[agent]
186
+ mx = len(route) - 1
187
+
188
+ if st.session_state.get("_ag") != agent:
189
+ st.session_state["_ag"] = agent
190
+ st.session_state["stp"] = 0
191
+ st.session_state["playing"] = False
192
+
193
+ stp = st.session_state.get("stp", 0)
194
+
195
+ bc1, bc2, bc3, bc4, sld = st.columns([1, 1, 1, 1, 5])
196
+ if bc1.button("⏮", use_container_width=True): stp = 0; st.session_state["playing"] = False
197
+ if bc2.button("◀", use_container_width=True) and stp > 0: stp -= 1; st.session_state["playing"] = False
198
+ if bc3.button("▶", use_container_width=True) and stp < mx: stp += 1; st.session_state["playing"] = False
199
+ playing = st.session_state.get("playing", False)
200
+ if bc4.button("⏸ Pause" if playing else "▶ Play", use_container_width=True):
201
+ st.session_state["playing"] = not playing
202
+ speed = sld.slider("Speed", 1, 8, 3, label_visibility="collapsed")
203
+ stp = st.slider("Step", 0, mx, stp, label_visibility="collapsed", key="stp_slider")
204
+ st.session_state["stp"] = stp
205
+
206
+ visited = set(route[:stp+1])
207
+ path_so_far = route[:stp+1]
208
+ km_done = sum(_sd(path_so_far[i], path_so_far[i+1]) for i in range(len(path_so_far)-1))
209
+
210
+ fig_ag = go.Figure()
211
+ for na in STOPS:
212
+ for nb in STOPS:
213
+ if na >= nb: continue
214
+ x1, y1, _ = STOPS[na]; x2, y2, _ = STOPS[nb]
215
+ if math.hypot(x1-x2, y1-y2) < 5.5:
216
+ fig_ag.add_trace(go.Scatter(x=[x1,x2,None], y=[y1,y2,None], mode="lines",
217
+ line=dict(color="#e2e8f0", width=1), showlegend=False, hoverinfo="skip"))
218
+ if len(path_so_far) > 1:
219
+ fig_ag.add_trace(go.Scatter(
220
+ x=[STOPS[n][0] for n in path_so_far],
221
+ y=[STOPS[n][1] for n in path_so_far],
222
+ mode="lines+markers", line=dict(color=ac, width=3),
223
+ marker=dict(size=6, color=ac), showlegend=False, hoverinfo="skip"))
224
+ for name, (nx, ny, pri) in STOPS.items():
225
+ if name == "Depot": nc, sz = "#1e293b", 24
226
+ elif name == route[stp]: nc, sz = ac, 26
227
+ elif name in visited: nc, sz = GREEN, 18
228
+ else: nc, sz = "#cbd5e1", 16
229
+ lbl = ("⭐ " if pri >= 4 else "") + name.replace("Shop ", "")
230
+ fig_ag.add_trace(go.Scatter(x=[nx], y=[ny], mode="markers+text", showlegend=False,
231
+ marker=dict(size=sz, color=nc, line=dict(color="#fff", width=2)),
232
+ text=[lbl], textposition="top center", textfont=dict(size=9, color=FG),
233
+ hovertemplate=f"<b>{name}</b><br>Priority {pri}/5<extra></extra>"))
234
+ fig_ag.update_layout(**_ch(400, f"Step {stp}/{mx} — {km_done:.1f} km traveled"))
235
+ fig_ag.update_xaxes(showgrid=False, showticklabels=False, zeroline=False, range=[-0.5,10.5])
236
+ fig_ag.update_yaxes(showgrid=False, showticklabels=False, zeroline=False, range=[-0.5,10.5])
237
+
238
+ map_c, stat_c = st.columns([3, 1])
239
+ with map_c:
240
+ st.plotly_chart(fig_ag, use_container_width=True, key="agent_chart")
241
  with stat_c:
242
+ st.metric("Steps completed", f"{stp} / {mx}")
243
+ st.metric("Distance so far", f"{km_done:.1f} km")
244
+ st.metric("Total route km", f"{sum(_sd(route[i],route[i+1]) for i in range(len(route)-1)):.2f} km")
245
+ st.markdown(f"<div class='info-box'>"
246
+ f" = high priority stop<br>(priority 4 or 5 out of 5)</div>",
247
+ unsafe_allow_html=True)
248
+
249
+ st.markdown("""
250
+ <div class='step-box'>
251
+ <span class='step-num'>2</span><b>What agent types are shown?</b><br><br>
252
+ <b>Reactive:</b> always goes to the nearest unvisited stop. Simple but often takes a longer overall route.<br>
253
+ <b>Goal-Based:</b> plans the full route before moving using 2-opt optimisation. Finds the shortest total distance.<br>
254
+ <b>Utility-Based:</b> scores each stop by urgency divided by distance. Reaches ⭐ high-priority stops earlier.
255
+ </div>
256
+ """, unsafe_allow_html=True)
257
+
258
+ if st.session_state.get("playing") and stp < mx:
259
+ time.sleep(1.0 / speed)
260
+ st.session_state["stp"] = stp + 1
 
 
 
 
 
 
 
261
  st.rerun()
262
+ elif st.session_state.get("playing") and stp >= mx:
263
+ st.session_state["playing"] = False
264
 
265
  # ══════════════════════════════════════════════════════════════════════════════
266
+ # TASK 2 — BIAS (runs actual task2_segmentation.py functions)
267
  # ══════════════════════════════════════════════════════════════════════════════
268
  with T2:
269
+ st.markdown("""
270
+ <div class='step-box'>
271
+ <span class='step-num'>?</span><b>What is this?</b><br><br>
272
+ EcoCart uses K-Means clustering to group customers into High Value, Medium, and Low Value segments
273
+ for targeted marketing. The algorithm was found to be biased — rural customers were almost entirely
274
+ placed in Low Value groups even though they are genuine buyers. This task identifies why the bias
275
+ exists and applies a fix to make the results fair.
276
+ </div>
277
+ """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
279
+ st.markdown("<div class='step-box'><span class='step-num'>1</span>"
280
+ "<b>Run the segmentationclick below to execute task2_segmentation.py</b></div>",
281
+ unsafe_allow_html=True)
282
+
283
+ run_t2 = st.button("▶ Run Task 2 — Segmentation & Bias Fix", type="primary",
284
+ use_container_width=True, key="run_t2")
285
+
286
+ if run_t2 or st.session_state.get("t2_done"):
287
+ st.session_state["t2_done"] = True
288
+
289
+ # import and run the actual task2 logic
290
+ import importlib.util, sys as _sys
291
+
292
+ @st.cache_data
293
+ def _run_task2():
294
+ spec = importlib.util.spec_from_file_location(
295
+ "task2", "task2_segmentation.py")
296
+ m = importlib.util.module_from_spec(spec)
297
+ buf = io.StringIO()
298
+ with redirect_stdout(buf):
299
+ spec.loader.exec_module(m)
300
+ m.main()
301
+ return buf.getvalue(), m
302
+
303
+ with st.spinner("Running task2_segmentation.py..."):
304
+ t2_output, t2_mod = _run_task2()
305
+
306
+ st.markdown("<div class='step-box'><span class='step-num'>2</span>"
307
+ "<b>Terminal output from task2_segmentation.py</b></div>",
308
+ unsafe_allow_html=True)
309
+ st.markdown(f"<div class='output-box'>{t2_output}</div>", unsafe_allow_html=True)
310
+
311
+ st.markdown("<div class='step-box'><span class='step-num'>3</span>"
312
+ "<b>Charts saved by task2_segmentation.py</b></div>",
313
+ unsafe_allow_html=True)
314
+
315
+ c1, c2 = st.columns(2)
316
+ with c1:
317
+ if os.path.exists("output/bias_before_after.png"):
318
+ st.image("output/bias_before_after.png",
319
+ caption="bias_before_after.png — customer clusters before and after mitigation",
320
+ use_container_width=True)
321
+ with c2:
322
+ if os.path.exists("output/disparate_impact.png"):
323
+ st.image("output/disparate_impact.png",
324
+ caption="disparate_impact.png — fairness metrics comparison",
325
+ use_container_width=True)
326
+
327
+ st.markdown("""
328
+ <div class='insight-box'>
329
+ <b>What the output tells us:</b><br><br>
330
+ Before the fix: 0% of rural customers were in High Value. Disparate Impact = 0.0 (heavily biased).<br>
331
+ After the fix: 57.3% of rural customers are in High Value. Disparate Impact = 0.847 (above the 0.80 fairness threshold).<br><br>
332
+ The fix worked by: (1) oversampling rural customers so they have equal representation,
333
+ (2) adjusting rural spend for delivery cost and frequency for batching behaviour,
334
+ (3) promoting borderline rural customers after re-clustering.
335
+ </div>
336
+ """, unsafe_allow_html=True)
337
 
338
  # ══════════════════════════════════════════════════════════════════════════════
339
+ # TASK 3 — ROUTES (runs actual task3_4_routing.py functions)
340
  # ══════════════════════════════════════════════════════════════════════════════
341
  with T3:
342
+ st.markdown("""
343
+ <div class='step-box'>
344
+ <span class='step-num'>?</span><b>What is this?</b><br><br>
345
+ EcoCart needs to find the shortest delivery route across a network of 20 nodes (10 urban, 10 rural).
346
+ Four search algorithms were implemented: BFS, DFS, A*, and IDA*. Each one searches the network
347
+ differently. This task runs all four and compares their paths and efficiency. You can also replay
348
+ how each algorithm explores the network step by step.
349
+ </div>
350
+ """, unsafe_allow_html=True)
351
+
352
+ st.markdown("<div class='step-box'><span class='step-num'>1</span>"
353
+ "<b>Run all four algorithms — click below to execute task3_4_routing.py</b></div>",
354
+ unsafe_allow_html=True)
355
+
356
+ run_t3 = st.button("▶ Run Task 3 — Route Optimisation", type="primary",
357
+ use_container_width=True, key="run_t3")
358
+
359
+ if run_t3 or st.session_state.get("t3_done"):
360
+ st.session_state["t3_done"] = True
361
+
362
+ @st.cache_data
363
+ def _run_task3():
364
+ spec = importlib.util.spec_from_file_location(
365
+ "task3", "task3_4_routing.py")
366
+ m = importlib.util.module_from_spec(spec)
367
+ buf = io.StringIO()
368
+ with redirect_stdout(buf):
369
+ spec.loader.exec_module(m)
370
+ m.main()
371
+ return buf.getvalue(), m
372
+
373
+ with st.spinner("Running task3_4_routing.py..."):
374
+ t3_output, t3_mod = _run_task3()
375
+
376
+ st.markdown("<div class='step-box'><span class='step-num'>2</span>"
377
+ "<b>Terminal output from task3_4_routing.py</b></div>",
378
+ unsafe_allow_html=True)
379
+ st.markdown(f"<div class='output-box'>{t3_output}</div>", unsafe_allow_html=True)
380
+
381
+ st.markdown("<div class='step-box'><span class='step-num'>3</span>"
382
+ "<b>Charts saved by task3_4_routing.py</b></div>",
383
  unsafe_allow_html=True)
384
+
385
+ if os.path.exists("output/network_map.png"):
386
+ st.image("output/network_map.png",
387
+ caption="network_map.png — the 20-node delivery network",
388
+ use_container_width=True)
389
+ c1, c2 = st.columns(2)
390
+ with c1:
391
+ if os.path.exists("output/algo_comparison.png"):
392
+ st.image("output/algo_comparison.png",
393
+ caption="algo_comparison.png A* vs IDA* across urban and rural routes",
394
+ use_container_width=True)
395
+ with c2:
396
+ if os.path.exists("output/green_vs_fast.png"):
397
+ st.image("output/green_vs_fast.png",
398
+ caption="green_vs_fast.png fastest route vs lowest CO₂ route",
399
+ use_container_width=True)
400
+
401
+ st.markdown("""
402
+ <div class='insight-box'>
403
+ <b>What the output tells us:</b><br><br>
404
+ On route U1→U10: BFS found 5.69 km (11 nodes), DFS found 6.84 km (18 nodes — not optimal),
405
+ A* found 5.69 km (only 7 nodes), IDA* found 5.69 km (43 nodes).<br><br>
406
+ A* is the recommended algorithm — it always finds the optimal path and checks the fewest nodes.
407
+ DFS is the only algorithm that does not guarantee the shortest path.<br><br>
408
+ Green routing: A slightly longer path can reduce CO₂ emissions by choosing roads with lower
409
+ emission rates (e.g. U1→R9: 14.7 km saves 0.25 kg CO₂ when rerouted to 16.4 km green path).
410
+ </div>
411
+ """, unsafe_allow_html=True)
412
+
413
+ # ── live exploration replay ───────────────────────────────────────────────
414
+ st.markdown("<div class='step-box'><span class='step-num'>4</span>"
415
+ "<b>Interactive: watch an algorithm search the network step by step</b></div>",
416
+ unsafe_allow_html=True)
417
+
418
+ # inline network + algorithm for replay
419
+ NODES = {
420
+ "U1":(1.0,1.0,"urban"), "U2":(2.0,1.5,"urban"), "U3":(3.0,1.0,"urban"),
421
+ "U4":(1.5,2.5,"urban"), "U5":(2.5,3.0,"urban"), "U6":(3.5,2.0,"urban"),
422
+ "U7":(1.0,3.5,"urban"), "U8":(2.0,4.0,"urban"), "U9":(3.0,4.0,"urban"),
423
+ "U10":(4.0,3.5,"urban"),
424
+ "R1":(6.0,1.0,"rural"), "R2":(8.0,2.0,"rural"), "R3":(10.0,1.5,"rural"),
425
+ "R4":(7.0,4.0,"rural"), "R5":(9.0,4.5,"rural"), "R6":(11.0,3.5,"rural"),
426
+ "R7":(6.5,6.0,"rural"), "R8":(9.0,7.0,"rural"), "R9":(11.0,6.0,"rural"),
427
+ "R10":(8.0,5.5,"rural"),
428
+ }
429
+ _EP2 = [
430
+ ("U1","U2"),("U2","U3"),("U1","U4"),("U2","U4"),("U2","U5"),
431
+ ("U3","U6"),("U4","U5"),("U5","U6"),("U4","U7"),("U5","U8"),
432
+ ("U6","U10"),("U7","U8"),("U8","U9"),("U9","U10"),("U5","U9"),
433
+ ("R1","R2"),("R2","R3"),("R1","R4"),("R2","R4"),("R3","R6"),
434
+ ("R4","R5"),("R5","R6"),("R4","R7"),("R5","R10"),("R7","R10"),
435
+ ("R7","R8"),("R8","R9"),("R6","R9"),("R8","R10"),("R5","R8"),
436
+ ("U3","R1"),("U10","R4"),("U6","R1"),("U9","R7"),
437
+ ]
438
+ def _nd2(a,b): return math.hypot(NODES[a][0]-NODES[b][0],NODES[a][1]-NODES[b][1])
439
+ EDGES2 = [(a,b,round(_nd2(a,b)*1.15,2)) for a,b in _EP2]
440
+ ADJ2 = {n:[] for n in NODES}
441
+ for a,b,w in EDGES2: ADJ2[a].append((b,w)); ADJ2[b].append((a,w))
442
+ def _ew2(a,b):
443
+ for nb,w in ADJ2[a]:
444
+ if nb==b: return w
445
+ return math.inf
446
+
447
+ def _astar_traced(s,g):
448
+ ctr=0; h=lambda n:_nd2(n,g); expl=[]
449
+ heap=[(h(s),0.0,ctr,s,[s])]; best={s:0.0}
450
+ while heap:
451
+ _,gc,_,n,p=heapq.heappop(heap)
452
+ if n==g: return p,round(gc,2),expl
453
+ if gc>best.get(n,math.inf): continue
454
+ expl.append(n)
455
+ for nb,w in ADJ2[n]:
456
+ ng=gc+w
457
+ if ng<best.get(nb,math.inf):
458
+ best[nb]=ng; ctr+=1
459
+ heapq.heappush(heap,(ng+h(nb),ng,ctr,nb,p+[nb]))
460
+ return None,0.0,expl
461
+
462
+ def _bfs_traced(s,g):
463
+ q=deque([(s,[s])]); seen={s}; expl=[]
464
+ while q:
465
+ n,p=q.popleft(); expl.append(n)
466
+ if n==g: return p,round(sum(_ew2(p[i],p[i+1]) for i in range(len(p)-1)),2),expl
467
+ for nb,_ in ADJ2[n]:
468
+ if nb not in seen: seen.add(nb); q.append((nb,p+[nb]))
469
+ return None,0.0,expl
470
+
471
+ rc1, rc2 = st.columns([1, 3])
472
+ with rc1:
473
+ all_n = list(NODES.keys())
474
+ sn_r = st.selectbox("Start", all_n, index=0, key="r_sn")
475
+ en_r = st.selectbox("End", all_n, index=19, key="r_en")
476
+ algo_r = st.radio("Algorithm", ["A*","BFS"], key="r_algo",
477
+ captions=["Guided, optimal","Level-by-level, optimal"])
478
+ fn_r = _astar_traced if algo_r == "A*" else _bfs_traced
479
+ if sn_r != en_r:
480
+ path_r, cost_r, expl_r = fn_r(sn_r, en_r)
481
  else:
482
+ path_r, cost_r, expl_r = [], 0.0, []
483
+
484
+ with rc2:
485
+ if sn_r != en_r and expl_r:
486
+ replay = st.slider(
487
+ "Drag to replay the search — see which nodes the algorithm visits",
488
+ 0, len(expl_r), len(expl_r), key="replay_sl")
489
+ explored_now = set(expl_r[:replay])
490
+ pct = int(replay / len(expl_r) * 100)
491
+ st.caption(f"{replay}/{len(expl_r)} nodes explored ({pct}%) "
492
+ f"{'— path found ✓' if replay == len(expl_r) and path_r else ''}")
493
+
494
+ pcol = AMBER
495
+ fn2 = go.Figure()
496
+ for a,b,_ in EDGES2:
497
+ fn2.add_trace(go.Scatter(x=[NODES[a][0],NODES[b][0],None],
498
+ y=[NODES[a][1],NODES[b][1],None], mode="lines",
499
+ line=dict(color="#e2e8f0",width=1.5), showlegend=False, hoverinfo="skip"))
500
+ if path_r and replay == len(expl_r):
501
+ for i in range(len(path_r)-1):
502
+ a,b=path_r[i],path_r[i+1]
503
+ fn2.add_trace(go.Scatter(x=[NODES[a][0],NODES[b][0],None],
504
+ y=[NODES[a][1],NODES[b][1],None], mode="lines",
505
+ line=dict(color=pcol,width=5), showlegend=False, hoverinfo="skip"))
506
+ ps = set(path_r) if path_r else set()
507
+ for zone, bc in [("urban",RED),("rural",GREEN)]:
508
+ ns = [(n,d) for n,d in NODES.items() if d[2]==zone]
509
+ cols = ["#fff" if n==sn_r else "#facc15" if n==en_r
510
+ else pcol if n in ps
511
+ else "#bfdbfe" if n in explored_now
512
+ else bc for n,_ in ns]
513
+ fn2.add_trace(go.Scatter(
514
+ x=[d[0] for _,d in ns], y=[d[1] for _,d in ns],
515
+ mode="markers+text", name=zone.title(),
516
+ marker=dict(size=22, color=cols, line=dict(color=FG,width=1.5)),
517
+ text=[n for n,_ in ns], textposition="middle center",
518
+ textfont=dict(size=8,color=FG),
519
+ hovertemplate="<b>%{text}</b><extra></extra>"))
520
+ fn2.update_layout(**_ch(460, f"{algo_r}: {sn_r} → {en_r}"))
521
+ fn2.update_xaxes(showgrid=False, showticklabels=False, zeroline=False)
522
+ fn2.update_yaxes(showgrid=False, showticklabels=False, zeroline=False)
523
+ st.plotly_chart(fn2, use_container_width=True, key="route_replay")
524
+
525
+ lc = st.columns(4)
526
+ lc[0].markdown(f"<span style='color:{RED}'>⬤</span> Urban", unsafe_allow_html=True)
527
+ lc[1].markdown(f"<span style='color:{GREEN}'>⬤</span> Rural", unsafe_allow_html=True)
528
+ lc[2].markdown("<span style='color:#bfdbfe'>⬤</span> Explored", unsafe_allow_html=True)
529
+ lc[3].markdown(f"<span style='color:{AMBER}'>⬤</span> Final path", unsafe_allow_html=True)
530
+
531
+ if path_r and replay == len(expl_r):
532
+ mc = st.columns(3)
533
+ mc[0].metric("Path cost", f"{cost_r:.2f} km")
534
+ mc[1].metric("Nodes explored", len(expl_r))
535
+ mc[2].metric("Path", " → ".join(path_r))
536
 
537
  # ══════════════════════════════════════════════════════════════════════════════
538
+ # TASK 4 — A* vs IDA* benchmark (from task3_4_routing.py output)
539
  # ═══════════════════════════════��══════════════════════════════════════════════
540
  with T4:
541
+ st.markdown("""
542
+ <div class='step-box'>
543
+ <span class='step-num'>?</span><b>What is this?</b><br><br>
544
+ Both A* and IDA* find the optimal route, but they work differently. A* stores all explored
545
+ nodes in memory (fast but uses more RAM). IDA* uses almost no memory by re-searching with
546
+ a stricter cost limit each time. This task runs both algorithms on 10 real delivery routes
547
+ and compares their speed and the number of nodes they check.
548
+ </div>
549
+ """, unsafe_allow_html=True)
550
 
551
+ st.markdown("<div class='step-box'><span class='step-num'>1</span>"
552
+ "<b>Results from task3_4_routing.py — the benchmark ran 20 times per route</b></div>",
553
+ unsafe_allow_html=True)
554
+
555
+ urban_data = [
556
+ ["U1→U10", "5.69", "7", "0.170", "5.69", "43", "0.559"],
557
+ ["U7→U6", "4.21", "5", "0.088", "4.21", "22", "0.472"],
558
+ ["U2→U9", "3.11", "2", "0.073", "3.11", "6", "0.129"],
559
+ ["U1→U9", "4.40", "4", "0.088", "4.40", "15", "0.223"],
560
+ ["U3→U8", "4.21", "5", "0.114", "4.21", "19", "0.283"],
561
+ ]
562
+ rural_data = [
563
+ ["R1R9", "10.39","6", "0.134", "10.39","34", "0.466"],
564
+ ["R2→R8", "7.82", "4", "0.101", "7.82", "14", "0.224"],
565
+ ["R3→R10", "6.77", "5", "0.107", "6.77", "21", "0.279"],
566
+ ["R1→R6", "7.51", "3", "0.065", "7.51", "10", "0.149"],
567
+ ["R4→R9", "7.82", "7", "0.130", "7.82", "50", "0.642"],
568
+ ]
569
+ headers = ["Route","A* km","A* nodes","A* ms","IDA* km","IDA* nodes","IDA* ms"]
570
+
571
+ cu, cr = st.columns(2)
572
+ with cu:
573
+ st.markdown("**Urban routes (U cluster)**")
574
+ st.dataframe(pd.DataFrame(urban_data, columns=headers),
575
+ use_container_width=True, hide_index=True)
576
+ with cr:
577
+ st.markdown("**Rural routes (R cluster)**")
578
+ st.dataframe(pd.DataFrame(rural_data, columns=headers),
579
+ use_container_width=True, hide_index=True)
580
+
581
+ st.markdown("<div class='step-box'><span class='step-num'>2</span>"
582
+ "<b>Chart from task3_4_routing.py</b></div>", unsafe_allow_html=True)
583
+ if os.path.exists("output/algo_comparison.png"):
584
+ st.image("output/algo_comparison.png",
585
+ caption="algo_comparison.png generated by task3_4_routing.py",
586
+ use_container_width=True)
587
+
588
+ st.markdown("<div class='step-box'><span class='step-num'>3</span>"
589
+ "<b>Interactive comparison</b></div>", unsafe_allow_html=True)
590
+
591
+ all_rows = urban_data + rural_data
592
+ routes = [r[0] for r in all_rows]
593
+ a_nodes = [int(r[2]) for r in all_rows]
594
+ i_nodes = [int(r[5]) for r in all_rows]
595
+ a_ms = [float(r[3]) for r in all_rows]
596
+ i_ms = [float(r[6]) for r in all_rows]
597
+
598
+ fig_cmp = make_subplots(rows=1, cols=2,
599
+ subplot_titles=["Nodes expanded (fewer = smarter)",
600
+ "Time in ms (lower = faster)"])
601
+ for ci, (av, iv, label) in enumerate([(a_nodes,i_nodes,"Nodes expanded"),
602
+ (a_ms, i_ms, "Time (ms)")], 1):
603
+ fig_cmp.add_trace(go.Bar(name="A*", x=routes, y=av, marker_color=BLUE,
604
+ showlegend=(ci==1)), row=1, col=ci)
605
+ fig_cmp.add_trace(go.Bar(name="IDA*", x=routes, y=iv, marker_color=AMBER,
606
+ showlegend=(ci==1)), row=1, col=ci)
607
+ fig_cmp.update_layout(paper_bgcolor=SURF, plot_bgcolor=BG, font_color=FG,
608
+ barmode="group", height=360,
609
+ margin=dict(l=40,r=20,t=50,b=80),
610
+ legend=dict(bgcolor=SURF,bordercolor=LINE))
611
+ fig_cmp.update_xaxes(gridcolor=LINE, tickangle=45)
612
+ fig_cmp.update_yaxes(gridcolor=LINE)
613
+ st.plotly_chart(fig_cmp, use_container_width=True)
614
+
615
+ st.markdown("""
616
+ <div class='insight-box'>
617
+ <b>What the output tells us:</b><br><br>
618
+ Both algorithms found <b>identical optimal paths</b> on every single route the costs match exactly.<br><br>
619
+ A* expanded fewer nodes in every case. For example on R4→R9: A* checked 7 nodes, IDA* checked 50.<br>
620
+ A* was also faster in ms on this 20-node graph.<br><br>
621
+ However, IDA* uses almost no memory (O(depth)) while A* stores all explored nodes (O(b^d)).
622
+ On a national road network with millions of nodes, IDA* would be the only practical choice.
623
+ </div>
624
+ """, unsafe_allow_html=True)
625
 
626
  # ══════════════════════════════════════════════════════════════════════════════
627
+ # TASK 5 — FORECASTING (runs actual task5_forecasting.py)
628
  # ══════════════════════════════════════════════════════════════════════════════
629
  with T5:
630
+ st.markdown("""
631
+ <div class='step-box'>
632
+ <span class='step-num'>?</span><b>What is this?</b><br><br>
633
+ EcoCart wants to predict how many units it will sell each day so it can stock the right
634
+ amount of inventory. This task trains two machine learning models — Linear Regression and
635
+ Random Forest — on 730 days of sales data and evaluates which one predicts more accurately
636
+ on 140 unseen test days.
637
+ </div>
638
+ """, unsafe_allow_html=True)
639
+
640
+ st.markdown("<div class='step-box'><span class='step-num'>1</span>"
641
+ "<b>Run the forecast — click below to execute task5_forecasting.py</b></div>",
642
+ unsafe_allow_html=True)
643
+
644
+ run_t5 = st.button("▶ Run Task 5 — Demand Forecasting", type="primary",
645
+ use_container_width=True, key="run_t5")
646
+
647
+ if run_t5 or st.session_state.get("t5_done"):
648
+ st.session_state["t5_done"] = True
649
+
650
+ @st.cache_data
651
+ def _run_task5():
652
+ spec = importlib.util.spec_from_file_location(
653
+ "task5", "task5_forecasting.py")
654
+ m = importlib.util.module_from_spec(spec)
655
+ buf = io.StringIO()
656
+ with redirect_stdout(buf):
657
+ spec.loader.exec_module(m)
658
+ m.main()
659
+ return buf.getvalue()
660
+
661
+ with st.spinner("Running task5_forecasting.py..."):
662
+ t5_output = _run_task5()
663
+
664
+ st.markdown("<div class='step-box'><span class='step-num'>2</span>"
665
+ "<b>Terminal output from task5_forecasting.py</b></div>",
666
  unsafe_allow_html=True)
667
+ st.markdown(f"<div class='output-box'>{t5_output}</div>", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
 
669
+ st.markdown("<div class='step-box'><span class='step-num'>3</span>"
670
+ "<b>Charts saved by task5_forecasting.py</b></div>",
671
+ unsafe_allow_html=True)
672
+
673
+ if os.path.exists("output/forecast.png"):
674
+ st.image("output/forecast.png",
675
+ caption="forecast.png actual vs predicted daily sales on the test set",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  use_container_width=True)
677
 
678
+ c1, c2 = st.columns(2)
679
+ with c1:
680
+ if os.path.exists("output/residuals.png"):
681
+ st.image("output/residuals.png",
682
+ caption="residuals.png — prediction errors for both models",
683
+ use_container_width=True)
684
+ with c2:
685
+ if os.path.exists("output/feature_importance.png"):
686
+ st.image("output/feature_importance.png",
687
+ caption="feature_importance.png — which features matter most",
688
+ use_container_width=True)
689
+
690
+ st.markdown("""
691
+ <div class='step-box'><span class='step-num'>4</span>
692
+ <b>Metrics from the actual run</b></div>
693
+ """, unsafe_allow_html=True)
694
+
695
+ mc = st.columns(4)
696
+ mc[0].metric("LR — MAE", "9.62 units")
697
+ mc[1].metric("LR — R²", "0.762")
698
+ mc[2].metric("RF — MAE", "9.75 units")
699
+ mc[3].metric("RF — R²", "0.716")
700
+
701
+ st.markdown("""
702
+ <div class='insight-box'>
703
+ <b>What the output tells us:</b><br><br>
704
+ Linear Regression achieved R² = 0.762, meaning it explains 76.2% of the variation in daily sales.
705
+ Random Forest achieved R² = 0.716. On this dataset, Linear Regression performed slightly better.<br><br>
706
+ The top 3 most important features were: <b>lag_7</b> (sales 7 days ago), <b>lag_14</b>, and <b>is_promo</b>.
707
+ This confirms that weekly sales patterns and promotional activity are the strongest predictors of demand.
708
+ </div>
709
+ """, unsafe_allow_html=True)
710
+
711
  # ══════════════════════════════════════════════════════════════════════════════
712
+ # TASK 6 — BUSINESS CASE
713
  # ══════════════════════════════════════════════════════════════════════════════
714
  with T6:
715
+ st.markdown("""
716
+ <div class='step-box'>
717
+ <span class='step-num'>?</span><b>What is this?</b><br><br>
718
+ This task is attempted voluntarily (AI students are permitted to do so).
719
+ It estimates the business value and environmental impact of deploying the AI system.
720
+ <br><br>
721
+ <b>Important:</b> All numbers here are <em>assumptions for illustration</em>, not measured values.
722
+ Adjust the sliders to model different scenarios.
723
+ </div>
724
+ """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
725
 
726
+ c_ctrl, c_main = st.columns([1, 3])
727
+
728
+ with c_ctrl:
729
+ st.markdown("**Your assumptions**")
730
+ fleet = st.slider("Fleet (vehicles)", 5, 100, 30, 5)
731
+ daily = st.slider("Deliveries/vehicle/day",10, 80, 40, 5)
732
+ avg_km = st.slider("Avg km per delivery", 2, 30, 12, 1)
733
+ fuel = st.slider("Fuel /km", 0.10,0.60,0.32,0.01, format="€%.2f")
734
+ wage = st.slider("Driver wage €/hr", 10, 35, 18, 1, format="€%d")
735
+ days_yr = st.slider("Working days/year", 200, 365,300, 10)
736
+ rt_save = st.slider("Route saving % (A*)", 5, 35, 18, 1, format="%d%%")
737
+ seg_rev = st.slider("Rural revenue uplift €k", 10, 200, 65, 5)
738
+
739
+ with c_main:
740
+ total_km = fleet * daily * days_yr * avg_km
741
+ km_saved = total_km * rt_save / 100
742
+ fuel_saved = km_saved * fuel
743
+ time_saved = (km_saved / 40) * wage
744
+ route_save = fuel_saved + time_saved
745
+ seg_save = seg_rev * 1000
746
+ total_eur = route_save + seg_save
747
+ co2 = km_saved * 0.24 / 1000
748
+ dev = 45000; ops = 8000
749
+ payback = round((dev + ops) / total_eur * 12, 1) if total_eur > 0 else 0
750
+ roi3 = round((total_eur*3 - (dev+ops*3)) / (dev+ops*3) * 100, 1)
751
+
752
+ mc = st.columns(4)
753
+ mc[0].metric("Est. annual saving", f"€{round(total_eur/1000,1)}k")
754
+ mc[1].metric("Est. payback", f"{payback} months")
755
+ mc[2].metric("3-year ROI", f"{roi3}%")
756
+ mc[3].metric("CO₂ saved/year", f"{co2:.1f} tonnes")
757
+
758
+ cats = ["Route Optimisation\n(A*)","Fairer Segmentation\n(rural uplift)"]
759
+ vals = [round(route_save/1000,1), round(seg_save/1000,1)]
760
+ fig_b = go.Figure(go.Bar(x=cats, y=vals, marker_color=[BLUE, GREEN],
761
+ text=[f"€{v}k" for v in vals], textposition="outside",
762
+ textfont_color=FG, width=0.4))
763
+ fig_b.update_layout(**_ch(260, "Estimated annual savings by area (€ thousands)"))
764
+ fig_b.update_xaxes(gridcolor=LINE)
765
+ fig_b.update_yaxes(gridcolor=LINE, title=" thousands")
766
+ st.plotly_chart(fig_b, use_container_width=True)
767
+
768
+ years = [0,1,2,3]
769
+ benefit = [0, total_eur, total_eur*2, total_eur*3]
770
+ cost = [0, dev+ops, dev+ops*2, dev+ops*3]
771
+ fig_r = go.Figure()
772
+ fig_r.add_trace(go.Scatter(x=years, y=[v/1000 for v in benefit],
773
+ name="Cumulative benefit", line=dict(color=GREEN, width=2.5), mode="lines+markers"))
774
+ fig_r.add_trace(go.Scatter(x=years, y=[v/1000 for v in cost],
775
+ name="Cumulative cost", line=dict(color=RED, width=2.5, dash="dash"), mode="lines+markers"))
776
+ fig_r.add_hline(y=0, line_color=MUTE, line_width=1, line_dash="dot")
777
+ fig_r.update_layout(**_ch(280, "3-year ROI projection (€ thousands, assumed values)"))
778
+ fig_r.update_xaxes(gridcolor=LINE, tickvals=[0,1,2,3],
779
+ ticktext=["Now","Year 1","Year 2","Year 3"])
780
+ fig_r.update_yaxes(gridcolor=LINE, title="€ thousands")
781
+ st.plotly_chart(fig_r, use_container_width=True)
782
+
783
+ st.markdown(
784
+ f"<div class='warn-box'>"
785
+ f"All numbers are <b>estimated assumptions</b> for illustrative purposes only. "
786
+ f"They are not measured from the simulation. "
787
+ f"Based on your inputs: {fleet} vehicles, {daily} deliveries/day, {avg_km} km avg, "
788
+ f"{rt_save}% route saving from A*, €{seg_rev}k rural revenue uplift."
789
+ f"</div>", unsafe_allow_html=True)