Redesign: guided flow, run actual task scripts, real outputs only
Browse files
app.py
CHANGED
|
@@ -1,1044 +1,789 @@
|
|
| 1 |
"""
|
| 2 |
-
EcoCart AI System
|
| 3 |
-
|
|
|
|
| 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="
|
| 23 |
|
| 24 |
st.markdown("""
|
| 25 |
<style>
|
| 26 |
-
[data-testid="stAppViewContainer"] { background:#
|
| 27 |
-
[data-testid="stHeader"] { background:transparent; }
|
| 28 |
.block-container { padding:1rem 2rem 3rem; }
|
| 29 |
-
.stTabs [data-baseweb="tab
|
| 30 |
-
|
| 31 |
-
.
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</style>
|
| 52 |
""", unsafe_allow_html=True)
|
| 53 |
|
| 54 |
# ── colours ───────────────────────────────────────────────────────────────────
|
| 55 |
-
BG,SURF,LINE = "#
|
| 56 |
-
FG,MUTE
|
| 57 |
-
GREEN,BLUE,RED,AMBER
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
font=dict(color=FG,
|
| 64 |
-
|
| 65 |
-
|
| 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 |
-
#
|
| 336 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 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 |
-
#
|
| 372 |
-
|
| 373 |
-
st.markdown("<
|
|
|
|
| 374 |
unsafe_allow_html=True)
|
| 375 |
|
| 376 |
-
T1,T2,T3,T4,T5,T6=st.tabs([
|
| 377 |
-
"🤖
|
| 378 |
-
"⚖️
|
| 379 |
-
"🗺️
|
| 380 |
-
"📊
|
| 381 |
-
"📈
|
| 382 |
-
"💼
|
| 383 |
])
|
| 384 |
|
| 385 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 386 |
-
# TASK 1
|
| 387 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 388 |
with T1:
|
| 389 |
-
st.markdown("
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 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.
|
| 438 |
-
st.metric("
|
| 439 |
-
st.metric("
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 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
|
| 466 |
|
| 467 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 468 |
-
# TASK 2
|
| 469 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 470 |
with T2:
|
| 471 |
-
st.markdown("
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
|
| 560 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 561 |
-
# TASK 3
|
| 562 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 563 |
with T3:
|
| 564 |
-
st.markdown("
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
unsafe_allow_html=True)
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
else:
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
|
| 647 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 648 |
-
# TASK 4
|
| 649 |
# ═══════════════════════════════��══════════════════════════════════════════════
|
| 650 |
with T4:
|
| 651 |
-
st.markdown("
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
|
| 725 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 726 |
-
# TASK 5
|
| 727 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 728 |
with T5:
|
| 729 |
-
st.markdown("
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 740 |
unsafe_allow_html=True)
|
| 741 |
-
|
| 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 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 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
|
| 869 |
# ══════════════════════════════════════════════════════════════════════════════
|
| 870 |
with T6:
|
| 871 |
-
st.markdown("
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 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 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 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 segmentation — click 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 |
+
["R1→R9", "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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|