Fix hero stat visibility, new icons, single dashes
Browse files
app.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
EcoCart AI System
|
| 3 |
NCI MSCAI | Fundamentals of AI TABA 2026
|
| 4 |
"""
|
| 5 |
|
|
@@ -59,7 +59,7 @@ def _build_report(t2_text, t3_text, t5_text):
|
|
| 59 |
r = p.add_run("EcoCart AI System"); r.font.name=TNR; r.font.size=Pt(24); r.font.bold=True
|
| 60 |
p.paragraph_format.space_after = Pt(8)
|
| 61 |
p2 = doc.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 62 |
-
r2 = p2.add_run("Technical Report
|
| 63 |
p2.paragraph_format.space_after = Pt(20)
|
| 64 |
for line in ["National College of Ireland","MSc Artificial Intelligence",
|
| 65 |
"Fundamentals of Artificial Intelligence","May 2026"]:
|
|
@@ -72,14 +72,14 @@ def _build_report(t2_text, t3_text, t5_text):
|
|
| 72 |
lr2.font.name=TNR; lr2.font.size=Pt(11); lr2.font.bold=True
|
| 73 |
lr2.font.color.rgb = RGBColor(37,99,235)
|
| 74 |
doc.add_page_break()
|
| 75 |
-
H("Task 2
|
| 76 |
P("Running task2_segmentation.py produced the following output:")
|
| 77 |
if t2_text: CODE(t2_text)
|
| 78 |
SP(); IMG("output/bias_before_after.png","Figure 1: Customer clusters before and after bias mitigation")
|
| 79 |
SP(); IMG("output/disparate_impact.png","Figure 2: Disparate Impact and High Value rates before and after fix")
|
| 80 |
SP(); P("Before: DI=0.0 (biased). After: DI=0.847 (fair, above 0.80).")
|
| 81 |
doc.add_page_break()
|
| 82 |
-
H("Tasks 3 & 4
|
| 83 |
P("Running task3_4_routing.py produced the following output:")
|
| 84 |
if t3_text: CODE(t3_text)
|
| 85 |
SP(); IMG("output/network_map.png","Figure 3: EcoCart 20-node delivery network")
|
|
@@ -87,7 +87,7 @@ def _build_report(t2_text, t3_text, t5_text):
|
|
| 87 |
SP(); IMG("output/green_vs_fast.png","Figure 5: Fastest vs lowest CO2 route")
|
| 88 |
SP(); P("A* found the optimal path on every route with fewest nodes expanded.")
|
| 89 |
doc.add_page_break()
|
| 90 |
-
H("Task 5
|
| 91 |
P("Running task5_forecasting.py produced the following output:")
|
| 92 |
if t5_text: CODE(t5_text)
|
| 93 |
SP(); IMG("output/forecast.png","Figure 6: Actual vs predicted daily sales")
|
|
@@ -375,9 +375,9 @@ with st.sidebar:
|
|
| 375 |
|
| 376 |
st.markdown("<div class='sb-section'>How to use</div>", unsafe_allow_html=True)
|
| 377 |
for n, t in [("1","Pick a task tab above"),
|
| 378 |
-
("2","Tasks 2, 3, 5
|
| 379 |
-
("3","Tasks 1 & 3
|
| 380 |
-
("4","Task 6
|
| 381 |
st.markdown(f"""<div class='sb-step'>
|
| 382 |
<div class='sb-num'>{n}</div>
|
| 383 |
<span class='sb-step-txt'>{t}</span></div>""", unsafe_allow_html=True)
|
|
@@ -387,9 +387,9 @@ with st.sidebar:
|
|
| 387 |
t3_done = st.session_state.get("t3_done", False)
|
| 388 |
t5_done = st.session_state.get("t5_done", False)
|
| 389 |
for lbl, icon, done in [
|
| 390 |
-
("Task 2
|
| 391 |
-
("Task 3
|
| 392 |
-
("Task 5
|
| 393 |
]:
|
| 394 |
cls = "sb-done" if done else "sb-pending"
|
| 395 |
mark = "β" if done else "Β·"
|
|
@@ -403,50 +403,49 @@ with st.sidebar:
|
|
| 403 |
st.markdown("""
|
| 404 |
<div class='hero'>
|
| 405 |
<div class='hero-title'>EcoCart AI System</div>
|
| 406 |
-
<div class='hero-sub'>Six AI tasks built to solve one real logistics problem
|
| 407 |
-
<div style='display:flex;gap:10px;flex-wrap:wrap;'>
|
| 408 |
-
<div style='background:rgba(96,165,250,.
|
| 409 |
-
<div style='color:#
|
| 410 |
-
<div style='color:#
|
| 411 |
</div>
|
| 412 |
-
<div style='background:rgba(167,139,250,.
|
| 413 |
-
<div style='color:#
|
| 414 |
-
<div style='color:#
|
| 415 |
</div>
|
| 416 |
-
<div style='background:rgba(251,191,36,.
|
| 417 |
-
<div style='color:#
|
| 418 |
-
<div style='color:#
|
| 419 |
</div>
|
| 420 |
-
<div style='background:rgba(52,211,153,.
|
| 421 |
-
<div style='color:#
|
| 422 |
-
<div style='color:#
|
| 423 |
</div>
|
| 424 |
-
<div style='background:rgba(34,211,238,.
|
| 425 |
-
<div style='color:#
|
| 426 |
-
<div style='color:#
|
| 427 |
</div>
|
| 428 |
</div>
|
| 429 |
</div>""", unsafe_allow_html=True)
|
| 430 |
|
| 431 |
T1, T2, T3, T4, T5, T6 = st.tabs([
|
| 432 |
-
"π€ Task 1
|
| 433 |
-
"βοΈ Task 2
|
| 434 |
-
"πΊοΈ Task 3
|
| 435 |
-
"π Task 4
|
| 436 |
-
"π Task 5
|
| 437 |
-
"πΌ Task 6
|
| 438 |
])
|
| 439 |
|
| 440 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 441 |
-
# TASK 1
|
| 442 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 443 |
with T1:
|
| 444 |
st.markdown("""
|
| 445 |
<div class='task-card'>
|
| 446 |
-
<div class='task-icon' style='background:linear-gradient(135deg,#4f46e5,#
|
| 447 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 448 |
-
<
|
| 449 |
-
<path d="M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4"/>
|
| 450 |
</svg>
|
| 451 |
</div>
|
| 452 |
<div>
|
|
@@ -497,7 +496,7 @@ with T1:
|
|
| 497 |
ROUTES = _get_routes()
|
| 498 |
RCOLS = {"Reactive Agent":BLUE, "Goal-Based Agent":GREEN, "Utility-Based Agent":AMBER}
|
| 499 |
RDESC = {
|
| 500 |
-
"Reactive Agent": "No planning
|
| 501 |
"Goal-Based Agent": "Plans the full route before moving using 2-opt optimisation. Always finds the shortest total distance.",
|
| 502 |
"Utility-Based Agent":"Scores every stop by priority Γ· distance. Gets to the most urgent β
stops first, not just the closest.",
|
| 503 |
}
|
|
@@ -569,7 +568,7 @@ with T1:
|
|
| 569 |
ag_speed = cb5.slider("Speed", 1, 8, 3, format="%dx",
|
| 570 |
label_visibility="collapsed", key="ag_speed")
|
| 571 |
|
| 572 |
-
# step slider
|
| 573 |
new_stp = st.slider("Step", 0, mx,
|
| 574 |
value=st.session_state["ag_stp"],
|
| 575 |
format="Stop %d",
|
|
@@ -606,7 +605,7 @@ with T1:
|
|
| 606 |
fig.add_trace(go.Scatter(x=[x1,x2,None],y=[y1,y2,None],mode="lines",
|
| 607 |
line=dict(color="#dde6f0",width=1.5),showlegend=False,hoverinfo="skip"))
|
| 608 |
|
| 609 |
-
# traveled path
|
| 610 |
if len(path_sf) > 1:
|
| 611 |
px=[STOPS[n][0] for n in path_sf]; py=[STOPS[n][1] for n in path_sf]
|
| 612 |
fig.add_trace(go.Scatter(x=px,y=py,mode="lines",
|
|
@@ -621,9 +620,9 @@ with T1:
|
|
| 621 |
text=[star+name.replace("Shop","").strip()],
|
| 622 |
textposition="top center",textfont=dict(size=9,color="#94a3b8"),
|
| 623 |
showlegend=False,
|
| 624 |
-
hovertemplate=f"<b>{name}</b><br>Priority {pri}/5
|
| 625 |
|
| 626 |
-
# visited nodes
|
| 627 |
for i,name in enumerate(path_sf):
|
| 628 |
if name=="Depot" or name==route[stp]: continue
|
| 629 |
nx,ny,pri=STOPS[name]
|
|
@@ -632,9 +631,9 @@ with T1:
|
|
| 632 |
text=[str(i)],textposition="middle center",
|
| 633 |
textfont=dict(size=10,color="#fff",family="monospace"),
|
| 634 |
showlegend=False,
|
| 635 |
-
hovertemplate=f"<b>{name}</b><br>Stop #{i}
|
| 636 |
|
| 637 |
-
# current node
|
| 638 |
cn=route[stp]; cx,cy,cpri=STOPS[cn]
|
| 639 |
if cn!="Depot":
|
| 640 |
star="β
" if cpri>=4 else ""
|
|
@@ -644,7 +643,7 @@ with T1:
|
|
| 644 |
textposition="top center",
|
| 645 |
textfont=dict(size=10,color=SLATE,family="system-ui",weight=700),
|
| 646 |
showlegend=False,
|
| 647 |
-
hovertemplate=f"<b>{cn}</b><br>Delivering here now
|
| 648 |
|
| 649 |
# depot (always on top)
|
| 650 |
dx,dy,_=STOPS["Depot"]
|
|
@@ -684,15 +683,15 @@ with T1:
|
|
| 684 |
st.session_state["ag_play"] = False
|
| 685 |
|
| 686 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 687 |
-
# TASK 2
|
| 688 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 689 |
with T2:
|
| 690 |
st.markdown("""
|
| 691 |
<div class='task-card'>
|
| 692 |
-
<div class='task-icon' style='background:linear-gradient(135deg,#
|
| 693 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 694 |
-
<path d="
|
| 695 |
-
<
|
| 696 |
</svg>
|
| 697 |
</div>
|
| 698 |
<div>
|
|
@@ -704,7 +703,7 @@ with T2:
|
|
| 704 |
</div>
|
| 705 |
</div>""", unsafe_allow_html=True)
|
| 706 |
|
| 707 |
-
run_t2 = st.button("βΆ Run Task 2
|
| 708 |
type="primary", use_container_width=True, key="run_t2")
|
| 709 |
if run_t2 or st.session_state.get("t2_done"):
|
| 710 |
st.session_state["t2_done"] = True
|
|
@@ -738,27 +737,27 @@ with T2:
|
|
| 738 |
with c2:
|
| 739 |
if os.path.exists("output/disparate_impact.png"):
|
| 740 |
st.image("output/disparate_impact.png",
|
| 741 |
-
caption="Fairness metrics
|
| 742 |
use_container_width=True)
|
| 743 |
|
| 744 |
st.markdown("""
|
| 745 |
<div class='insight'>
|
| 746 |
-
Before the fix, 0% of rural customers reached High Value
|
| 747 |
After oversampling rural customers to match urban count, adjusting spend for the delivery cost premium (+β¬12),
|
| 748 |
-
and correcting frequency for order batching (Γ1.5), the Disparate Impact rose to <b>0.847</b>
|
| 749 |
The model now treats both groups fairly.
|
| 750 |
</div>""", unsafe_allow_html=True)
|
| 751 |
|
| 752 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 753 |
-
# TASK 3
|
| 754 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 755 |
with T3:
|
| 756 |
st.markdown("""
|
| 757 |
<div class='task-card'>
|
| 758 |
-
<div class='task-icon' style='background:linear-gradient(135deg,#
|
| 759 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 760 |
-
<
|
| 761 |
-
<
|
| 762 |
</svg>
|
| 763 |
</div>
|
| 764 |
<div>
|
|
@@ -770,7 +769,7 @@ with T3:
|
|
| 770 |
</div>
|
| 771 |
</div>""", unsafe_allow_html=True)
|
| 772 |
|
| 773 |
-
run_t3 = st.button("βΆ Run Task 3
|
| 774 |
type="primary", use_container_width=True, key="run_t3")
|
| 775 |
if run_t3 or st.session_state.get("t3_done"):
|
| 776 |
st.session_state["t3_done"] = True
|
|
@@ -810,16 +809,16 @@ with T3:
|
|
| 810 |
|
| 811 |
st.markdown("""
|
| 812 |
<div class='insight'>
|
| 813 |
-
A* found the shortest path (5.69 km) using only 7 node expansions
|
| 814 |
BFS found the same optimal path but needed 11 expansions. DFS was the only algorithm that got
|
| 815 |
it wrong, returning a 6.84 km suboptimal route because it dives deep without comparing alternatives.
|
| 816 |
-
IDA* also found 5.69 km but needed 43 expansions
|
| 817 |
which matters at national scale but not here.
|
| 818 |
</div>""", unsafe_allow_html=True)
|
| 819 |
|
| 820 |
# ββ interactive route replay ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 821 |
st.markdown("<br>", unsafe_allow_html=True)
|
| 822 |
-
st.markdown("<div class='sec-head'>Live search replay
|
| 823 |
unsafe_allow_html=True)
|
| 824 |
|
| 825 |
NODES_R = {
|
|
@@ -906,10 +905,10 @@ with T3:
|
|
| 906 |
bound=t
|
| 907 |
|
| 908 |
ALGO_DESC = {
|
| 909 |
-
"A*": "Guided heuristic
|
| 910 |
-
"BFS": "Level-by-level
|
| 911 |
-
"DFS": "Deep dive
|
| 912 |
-
"IDA*": "Iterative A*
|
| 913 |
}
|
| 914 |
|
| 915 |
# config row
|
|
@@ -919,7 +918,7 @@ with T3:
|
|
| 919 |
en = cfg2.selectbox("End", all_n, index=19, key="r_en")
|
| 920 |
algo = cfg3.radio("Algorithm", ["A*","BFS","DFS","IDA*"], key="r_algo", horizontal=True)
|
| 921 |
rp_speed = cfg4.slider("Speed", 1, 8, 3, format="%dx", key="rp_spd")
|
| 922 |
-
st.caption(f"**{algo}**
|
| 923 |
|
| 924 |
fn = {"A*":_astar, "BFS":_bfs, "DFS":_dfs, "IDA*":_idastar}[algo]
|
| 925 |
if sn != en:
|
|
@@ -955,7 +954,7 @@ with T3:
|
|
| 955 |
if st.session_state["rp"] >= max_rp: st.session_state["rp"]=0
|
| 956 |
st.session_state["rp_pl"] = not st.session_state["rp_pl"]; st.rerun()
|
| 957 |
|
| 958 |
-
# slider
|
| 959 |
new_rp = st.slider("Nodes explored", 0, max_rp,
|
| 960 |
value=st.session_state["rp"],
|
| 961 |
help="Drag to replay how the algorithm searches node by node")
|
|
@@ -1055,23 +1054,23 @@ with T3:
|
|
| 1055 |
st.session_state["rp_pl"] = False
|
| 1056 |
|
| 1057 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1058 |
-
# TASK 4
|
| 1059 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1060 |
with T4:
|
| 1061 |
st.markdown("""
|
| 1062 |
<div class='task-card'>
|
| 1063 |
-
<div class='task-icon' style='background:linear-gradient(135deg,#
|
| 1064 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 1065 |
-
<
|
| 1066 |
-
<
|
| 1067 |
</svg>
|
| 1068 |
</div>
|
| 1069 |
<div>
|
| 1070 |
<div class='task-title'>Same shortest path, completely different strategies</div>
|
| 1071 |
-
<div class='task-desc'>A* remembers every node it visits
|
| 1072 |
-
IDA* forgets and re-searches from scratch each pass, tightening its cost bound each time
|
| 1073 |
but uses almost no memory. This benchmark runs <b>10 routes Γ 20 timing runs</b> across urban
|
| 1074 |
-
and rural pairs to find out which algorithm is right for EcoCart
|
| 1075 |
</div>
|
| 1076 |
</div>""", unsafe_allow_html=True)
|
| 1077 |
|
|
@@ -1119,7 +1118,7 @@ with T4:
|
|
| 1119 |
|
| 1120 |
st.markdown("""
|
| 1121 |
<div class='insight'>
|
| 1122 |
-
Both algorithms found <b>identical optimal paths</b> on every single route
|
| 1123 |
But A* was faster and expanded fewer nodes every time. The starkest example: R4βR9, where
|
| 1124 |
A* needed 7 node expansions in 0.130 ms while IDA* needed 50 in 0.642 ms.
|
| 1125 |
For EcoCart's current network, A* is the clear winner. IDA*'s value shows up at national scale β
|
|
@@ -1127,15 +1126,14 @@ with T4:
|
|
| 1127 |
</div>""", unsafe_allow_html=True)
|
| 1128 |
|
| 1129 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1130 |
-
# TASK 5
|
| 1131 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1132 |
with T5:
|
| 1133 |
st.markdown("""
|
| 1134 |
<div class='task-card'>
|
| 1135 |
-
<div class='task-icon' style='background:linear-gradient(135deg,#
|
| 1136 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 1137 |
-
<polyline points="
|
| 1138 |
-
<polyline points="17 6 23 6 23 12"/>
|
| 1139 |
</svg>
|
| 1140 |
</div>
|
| 1141 |
<div>
|
|
@@ -1143,11 +1141,11 @@ with T5:
|
|
| 1143 |
<div class='task-desc'>Linear Regression (fast, transparent) goes head-to-head against
|
| 1144 |
Random Forest (200 trees, non-linear patterns). Both train on <b>730 days</b> of EcoCart
|
| 1145 |
sales history and are tested blind on <b>140 days they have never seen</b>.
|
| 1146 |
-
Press <b>Run</b> to see which model wins on MAE, RMSE, RΒ², and MAPE
|
| 1147 |
</div>
|
| 1148 |
</div>""", unsafe_allow_html=True)
|
| 1149 |
|
| 1150 |
-
run_t5 = st.button("βΆ Run Task 5
|
| 1151 |
type="primary", use_container_width=True, key="run_t5")
|
| 1152 |
if run_t5 or st.session_state.get("t5_done"):
|
| 1153 |
st.session_state["t5_done"] = True
|
|
@@ -1173,12 +1171,12 @@ with T5:
|
|
| 1173 |
</div><div class='term-body'>{t5_out}</div></div>""", unsafe_allow_html=True)
|
| 1174 |
|
| 1175 |
m1,m2,m3,m4=st.columns(4)
|
| 1176 |
-
m1.metric("LR
|
| 1177 |
-
m3.metric("RF
|
| 1178 |
|
| 1179 |
if os.path.exists("output/forecast.png"):
|
| 1180 |
st.image("output/forecast.png",
|
| 1181 |
-
caption="Actual vs predicted sales
|
| 1182 |
use_container_width=True)
|
| 1183 |
c1,c2=st.columns(2)
|
| 1184 |
with c1:
|
|
@@ -1191,25 +1189,24 @@ with T5:
|
|
| 1191 |
|
| 1192 |
st.markdown("""
|
| 1193 |
<div class='insight'>
|
| 1194 |
-
Linear Regression won on <b>both accuracy and speed</b>
|
| 1195 |
and a fraction of the training time (LR is a single matrix solve; RF trains 200 trees on
|
| 1196 |
bootstrap samples). The reason LR wins here: once lag_7 (same weekday last week) is in the
|
| 1197 |
features, the demand signal becomes mostly linear. Random Forest's complexity adds noise, not signal.
|
| 1198 |
-
Top predictors: <b>lag_7</b>, <b>lag_14</b>, <b>is_promo</b>
|
| 1199 |
drive demand more than anything else.
|
| 1200 |
</div>""", unsafe_allow_html=True)
|
| 1201 |
|
| 1202 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1203 |
-
# TASK 6
|
| 1204 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1205 |
with T6:
|
| 1206 |
st.markdown("""
|
| 1207 |
<div class='task-card'>
|
| 1208 |
-
<div class='task-icon' style='background:linear-gradient(135deg,#
|
| 1209 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 1210 |
-
<
|
| 1211 |
-
<path d="
|
| 1212 |
-
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
|
| 1213 |
</svg>
|
| 1214 |
</div>
|
| 1215 |
<div>
|
|
@@ -1278,7 +1275,7 @@ with T6:
|
|
| 1278 |
|
| 1279 |
st.markdown(f"""
|
| 1280 |
<div class='warn-box'>
|
| 1281 |
-
<b>Reminder:</b> these are estimates for illustration only
|
| 1282 |
Current inputs: {fleet} vehicles, {daily} deliveries/day, {avg_km} km avg route,
|
| 1283 |
{rt_save}% saving from A* routing, β¬{seg_rev}k rural revenue uplift assumed.
|
| 1284 |
Change the sliders to model your own scenario.
|
|
|
|
| 1 |
"""
|
| 2 |
+
EcoCart AI System - Streamlit App
|
| 3 |
NCI MSCAI | Fundamentals of AI TABA 2026
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 59 |
r = p.add_run("EcoCart AI System"); r.font.name=TNR; r.font.size=Pt(24); r.font.bold=True
|
| 60 |
p.paragraph_format.space_after = Pt(8)
|
| 61 |
p2 = doc.add_paragraph(); p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
| 62 |
+
r2 = p2.add_run("Technical Report - TABA Section II"); r2.font.name=TNR; r2.font.size=Pt(14)
|
| 63 |
p2.paragraph_format.space_after = Pt(20)
|
| 64 |
for line in ["National College of Ireland","MSc Artificial Intelligence",
|
| 65 |
"Fundamentals of Artificial Intelligence","May 2026"]:
|
|
|
|
| 72 |
lr2.font.name=TNR; lr2.font.size=Pt(11); lr2.font.bold=True
|
| 73 |
lr2.font.color.rgb = RGBColor(37,99,235)
|
| 74 |
doc.add_page_break()
|
| 75 |
+
H("Task 2 - Customer Segmentation & Bias Mitigation")
|
| 76 |
P("Running task2_segmentation.py produced the following output:")
|
| 77 |
if t2_text: CODE(t2_text)
|
| 78 |
SP(); IMG("output/bias_before_after.png","Figure 1: Customer clusters before and after bias mitigation")
|
| 79 |
SP(); IMG("output/disparate_impact.png","Figure 2: Disparate Impact and High Value rates before and after fix")
|
| 80 |
SP(); P("Before: DI=0.0 (biased). After: DI=0.847 (fair, above 0.80).")
|
| 81 |
doc.add_page_break()
|
| 82 |
+
H("Tasks 3 & 4 - Route Optimisation and Algorithm Comparison")
|
| 83 |
P("Running task3_4_routing.py produced the following output:")
|
| 84 |
if t3_text: CODE(t3_text)
|
| 85 |
SP(); IMG("output/network_map.png","Figure 3: EcoCart 20-node delivery network")
|
|
|
|
| 87 |
SP(); IMG("output/green_vs_fast.png","Figure 5: Fastest vs lowest CO2 route")
|
| 88 |
SP(); P("A* found the optimal path on every route with fewest nodes expanded.")
|
| 89 |
doc.add_page_break()
|
| 90 |
+
H("Task 5 - Demand Forecasting with Machine Learning")
|
| 91 |
P("Running task5_forecasting.py produced the following output:")
|
| 92 |
if t5_text: CODE(t5_text)
|
| 93 |
SP(); IMG("output/forecast.png","Figure 6: Actual vs predicted daily sales")
|
|
|
|
| 375 |
|
| 376 |
st.markdown("<div class='sb-section'>How to use</div>", unsafe_allow_html=True)
|
| 377 |
for n, t in [("1","Pick a task tab above"),
|
| 378 |
+
("2","Tasks 2, 3, 5 - press Run"),
|
| 379 |
+
("3","Tasks 1 & 3 - press Play"),
|
| 380 |
+
("4","Task 6 - adjust the sliders")]:
|
| 381 |
st.markdown(f"""<div class='sb-step'>
|
| 382 |
<div class='sb-num'>{n}</div>
|
| 383 |
<span class='sb-step-txt'>{t}</span></div>""", unsafe_allow_html=True)
|
|
|
|
| 387 |
t3_done = st.session_state.get("t3_done", False)
|
| 388 |
t5_done = st.session_state.get("t5_done", False)
|
| 389 |
for lbl, icon, done in [
|
| 390 |
+
("Task 2 - Bias", "βοΈ", t2_done),
|
| 391 |
+
("Task 3 - Routes", "πΊοΈ", t3_done),
|
| 392 |
+
("Task 5 - Forecast", "π", t5_done),
|
| 393 |
]:
|
| 394 |
cls = "sb-done" if done else "sb-pending"
|
| 395 |
mark = "β" if done else "Β·"
|
|
|
|
| 403 |
st.markdown("""
|
| 404 |
<div class='hero'>
|
| 405 |
<div class='hero-title'>EcoCart AI System</div>
|
| 406 |
+
<div class='hero-sub'>Six AI tasks built to solve one real logistics problem - every chart and number runs from actual Python scripts</div>
|
| 407 |
+
<div style='display:flex;gap:10px;flex-wrap:wrap;margin-top:4px;'>
|
| 408 |
+
<div style='background:rgba(96,165,250,.18);border:1.5px solid rgba(96,165,250,.4);border-radius:14px;padding:14px 22px;text-align:center;min-width:86px;backdrop-filter:blur(8px);'>
|
| 409 |
+
<div style='color:#93c5fd;font-size:1.7rem;font-weight:900;letter-spacing:-.04em;line-height:1;'>6</div>
|
| 410 |
+
<div style='color:#7dd3fc;font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;'>Tasks</div>
|
| 411 |
</div>
|
| 412 |
+
<div style='background:rgba(167,139,250,.18);border:1.5px solid rgba(167,139,250,.4);border-radius:14px;padding:14px 22px;text-align:center;min-width:86px;backdrop-filter:blur(8px);'>
|
| 413 |
+
<div style='color:#c4b5fd;font-size:1.7rem;font-weight:900;letter-spacing:-.04em;line-height:1;'>4</div>
|
| 414 |
+
<div style='color:#c4b5fd;font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;'>Algorithms</div>
|
| 415 |
</div>
|
| 416 |
+
<div style='background:rgba(251,191,36,.18);border:1.5px solid rgba(251,191,36,.4);border-radius:14px;padding:14px 22px;text-align:center;min-width:86px;backdrop-filter:blur(8px);'>
|
| 417 |
+
<div style='color:#fde68a;font-size:1.7rem;font-weight:900;letter-spacing:-.04em;line-height:1;'>730</div>
|
| 418 |
+
<div style='color:#fde68a;font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;'>Days Data</div>
|
| 419 |
</div>
|
| 420 |
+
<div style='background:rgba(52,211,153,.18);border:1.5px solid rgba(52,211,153,.4);border-radius:14px;padding:14px 22px;text-align:center;min-width:86px;backdrop-filter:blur(8px);'>
|
| 421 |
+
<div style='color:#6ee7b7;font-size:1.7rem;font-weight:900;letter-spacing:-.04em;line-height:1;'>20</div>
|
| 422 |
+
<div style='color:#6ee7b7;font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;'>Node Network</div>
|
| 423 |
</div>
|
| 424 |
+
<div style='background:rgba(34,211,238,.18);border:1.5px solid rgba(34,211,238,.4);border-radius:14px;padding:14px 22px;text-align:center;min-width:86px;backdrop-filter:blur(8px);'>
|
| 425 |
+
<div style='color:#67e8f9;font-size:1.7rem;font-weight:900;letter-spacing:-.04em;line-height:1;'>0.847</div>
|
| 426 |
+
<div style='color:#67e8f9;font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;'>DI Score</div>
|
| 427 |
</div>
|
| 428 |
</div>
|
| 429 |
</div>""", unsafe_allow_html=True)
|
| 430 |
|
| 431 |
T1, T2, T3, T4, T5, T6 = st.tabs([
|
| 432 |
+
"π€ Task 1 - AI Agents",
|
| 433 |
+
"βοΈ Task 2 - Bias",
|
| 434 |
+
"πΊοΈ Task 3 - Routes",
|
| 435 |
+
"π Task 4 - A* vs IDA*",
|
| 436 |
+
"π Task 5 - Forecast",
|
| 437 |
+
"πΌ Task 6 - Business",
|
| 438 |
])
|
| 439 |
|
| 440 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 441 |
+
# TASK 1 - AI AGENTS (step-by-step animated map)
|
| 442 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 443 |
with T1:
|
| 444 |
st.markdown("""
|
| 445 |
<div class='task-card'>
|
| 446 |
+
<div class='task-icon' style='background:linear-gradient(135deg,#4f46e5,#818cf8);box-shadow:0 6px 20px rgba(99,102,241,.4);'>
|
| 447 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 448 |
+
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
|
|
|
| 449 |
</svg>
|
| 450 |
</div>
|
| 451 |
<div>
|
|
|
|
| 496 |
ROUTES = _get_routes()
|
| 497 |
RCOLS = {"Reactive Agent":BLUE, "Goal-Based Agent":GREEN, "Utility-Based Agent":AMBER}
|
| 498 |
RDESC = {
|
| 499 |
+
"Reactive Agent": "No planning - just go to the nearest stop. Fast to decide, but the total route is often longer.",
|
| 500 |
"Goal-Based Agent": "Plans the full route before moving using 2-opt optimisation. Always finds the shortest total distance.",
|
| 501 |
"Utility-Based Agent":"Scores every stop by priority Γ· distance. Gets to the most urgent β
stops first, not just the closest.",
|
| 502 |
}
|
|
|
|
| 568 |
ag_speed = cb5.slider("Speed", 1, 8, 3, format="%dx",
|
| 569 |
label_visibility="collapsed", key="ag_speed")
|
| 570 |
|
| 571 |
+
# step slider - use value= so auto-play can write to ag_stp freely
|
| 572 |
new_stp = st.slider("Step", 0, mx,
|
| 573 |
value=st.session_state["ag_stp"],
|
| 574 |
format="Stop %d",
|
|
|
|
| 605 |
fig.add_trace(go.Scatter(x=[x1,x2,None],y=[y1,y2,None],mode="lines",
|
| 606 |
line=dict(color="#dde6f0",width=1.5),showlegend=False,hoverinfo="skip"))
|
| 607 |
|
| 608 |
+
# traveled path - drawn so far (thick animated line)
|
| 609 |
if len(path_sf) > 1:
|
| 610 |
px=[STOPS[n][0] for n in path_sf]; py=[STOPS[n][1] for n in path_sf]
|
| 611 |
fig.add_trace(go.Scatter(x=px,y=py,mode="lines",
|
|
|
|
| 620 |
text=[star+name.replace("Shop","").strip()],
|
| 621 |
textposition="top center",textfont=dict(size=9,color="#94a3b8"),
|
| 622 |
showlegend=False,
|
| 623 |
+
hovertemplate=f"<b>{name}</b><br>Priority {pri}/5 - not visited yet<extra></extra>"))
|
| 624 |
|
| 625 |
+
# visited nodes - show visit order number inside circle
|
| 626 |
for i,name in enumerate(path_sf):
|
| 627 |
if name=="Depot" or name==route[stp]: continue
|
| 628 |
nx,ny,pri=STOPS[name]
|
|
|
|
| 631 |
text=[str(i)],textposition="middle center",
|
| 632 |
textfont=dict(size=10,color="#fff",family="monospace"),
|
| 633 |
showlegend=False,
|
| 634 |
+
hovertemplate=f"<b>{name}</b><br>Stop #{i} - delivered β<extra></extra>"))
|
| 635 |
|
| 636 |
+
# current node - large, distinct, clearly highlighted
|
| 637 |
cn=route[stp]; cx,cy,cpri=STOPS[cn]
|
| 638 |
if cn!="Depot":
|
| 639 |
star="β
" if cpri>=4 else ""
|
|
|
|
| 643 |
textposition="top center",
|
| 644 |
textfont=dict(size=10,color=SLATE,family="system-ui",weight=700),
|
| 645 |
showlegend=False,
|
| 646 |
+
hovertemplate=f"<b>{cn}</b><br>Delivering here now - Priority {cpri}/5<extra></extra>"))
|
| 647 |
|
| 648 |
# depot (always on top)
|
| 649 |
dx,dy,_=STOPS["Depot"]
|
|
|
|
| 683 |
st.session_state["ag_play"] = False
|
| 684 |
|
| 685 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 686 |
+
# TASK 2 - BIAS
|
| 687 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 688 |
with T2:
|
| 689 |
st.markdown("""
|
| 690 |
<div class='task-card'>
|
| 691 |
+
<div class='task-icon' style='background:linear-gradient(135deg,#b45309,#f59e0b);box-shadow:0 6px 20px rgba(180,83,9,.4);'>
|
| 692 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 693 |
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
| 694 |
+
<circle cx="12" cy="12" r="3"/>
|
| 695 |
</svg>
|
| 696 |
</div>
|
| 697 |
<div>
|
|
|
|
| 703 |
</div>
|
| 704 |
</div>""", unsafe_allow_html=True)
|
| 705 |
|
| 706 |
+
run_t2 = st.button("βΆ Run Task 2 - Segmentation & Bias Fix",
|
| 707 |
type="primary", use_container_width=True, key="run_t2")
|
| 708 |
if run_t2 or st.session_state.get("t2_done"):
|
| 709 |
st.session_state["t2_done"] = True
|
|
|
|
| 737 |
with c2:
|
| 738 |
if os.path.exists("output/disparate_impact.png"):
|
| 739 |
st.image("output/disparate_impact.png",
|
| 740 |
+
caption="Fairness metrics - before vs after",
|
| 741 |
use_container_width=True)
|
| 742 |
|
| 743 |
st.markdown("""
|
| 744 |
<div class='insight'>
|
| 745 |
+
Before the fix, 0% of rural customers reached High Value - Disparate Impact was 0.0, a complete fairness failure.
|
| 746 |
After oversampling rural customers to match urban count, adjusting spend for the delivery cost premium (+β¬12),
|
| 747 |
+
and correcting frequency for order batching (Γ1.5), the Disparate Impact rose to <b>0.847</b> - above the 0.80 threshold.
|
| 748 |
The model now treats both groups fairly.
|
| 749 |
</div>""", unsafe_allow_html=True)
|
| 750 |
|
| 751 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 752 |
+
# TASK 3 - ROUTES (run + animated exploration replay)
|
| 753 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 754 |
with T3:
|
| 755 |
st.markdown("""
|
| 756 |
<div class='task-card'>
|
| 757 |
+
<div class='task-icon' style='background:linear-gradient(135deg,#0369a1,#38bdf8);box-shadow:0 6px 20px rgba(3,105,161,.4);'>
|
| 758 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 759 |
+
<circle cx="6" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/>
|
| 760 |
+
<path d="M6 9v6"/><path d="M9 6h6"/><path d="M9 6a9 9 0 009 9"/>
|
| 761 |
</svg>
|
| 762 |
</div>
|
| 763 |
<div>
|
|
|
|
| 769 |
</div>
|
| 770 |
</div>""", unsafe_allow_html=True)
|
| 771 |
|
| 772 |
+
run_t3 = st.button("βΆ Run Task 3 - Route Optimisation",
|
| 773 |
type="primary", use_container_width=True, key="run_t3")
|
| 774 |
if run_t3 or st.session_state.get("t3_done"):
|
| 775 |
st.session_state["t3_done"] = True
|
|
|
|
| 809 |
|
| 810 |
st.markdown("""
|
| 811 |
<div class='insight'>
|
| 812 |
+
A* found the shortest path (5.69 km) using only 7 node expansions - the most efficient result.
|
| 813 |
BFS found the same optimal path but needed 11 expansions. DFS was the only algorithm that got
|
| 814 |
it wrong, returning a 6.84 km suboptimal route because it dives deep without comparing alternatives.
|
| 815 |
+
IDA* also found 5.69 km but needed 43 expansions - its advantage is near-zero memory use,
|
| 816 |
which matters at national scale but not here.
|
| 817 |
</div>""", unsafe_allow_html=True)
|
| 818 |
|
| 819 |
# ββ interactive route replay ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 820 |
st.markdown("<br>", unsafe_allow_html=True)
|
| 821 |
+
st.markdown("<div class='sec-head'>Live search replay - pick start, end and algorithm, watch it think</div>",
|
| 822 |
unsafe_allow_html=True)
|
| 823 |
|
| 824 |
NODES_R = {
|
|
|
|
| 905 |
bound=t
|
| 906 |
|
| 907 |
ALGO_DESC = {
|
| 908 |
+
"A*": "Guided heuristic - expands fewest nodes, always optimal",
|
| 909 |
+
"BFS": "Level-by-level - optimal shortest hops, explores broadly",
|
| 910 |
+
"DFS": "Deep dive - fast but not guaranteed to find shortest path",
|
| 911 |
+
"IDA*": "Iterative A* - optimal like A*, uses almost no memory",
|
| 912 |
}
|
| 913 |
|
| 914 |
# config row
|
|
|
|
| 918 |
en = cfg2.selectbox("End", all_n, index=19, key="r_en")
|
| 919 |
algo = cfg3.radio("Algorithm", ["A*","BFS","DFS","IDA*"], key="r_algo", horizontal=True)
|
| 920 |
rp_speed = cfg4.slider("Speed", 1, 8, 3, format="%dx", key="rp_spd")
|
| 921 |
+
st.caption(f"**{algo}** - {ALGO_DESC[algo]}")
|
| 922 |
|
| 923 |
fn = {"A*":_astar, "BFS":_bfs, "DFS":_dfs, "IDA*":_idastar}[algo]
|
| 924 |
if sn != en:
|
|
|
|
| 954 |
if st.session_state["rp"] >= max_rp: st.session_state["rp"]=0
|
| 955 |
st.session_state["rp_pl"] = not st.session_state["rp_pl"]; st.rerun()
|
| 956 |
|
| 957 |
+
# slider - use value= so auto-play can write to rp freely
|
| 958 |
new_rp = st.slider("Nodes explored", 0, max_rp,
|
| 959 |
value=st.session_state["rp"],
|
| 960 |
help="Drag to replay how the algorithm searches node by node")
|
|
|
|
| 1054 |
st.session_state["rp_pl"] = False
|
| 1055 |
|
| 1056 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1057 |
+
# TASK 4 - A* vs IDA*
|
| 1058 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1059 |
with T4:
|
| 1060 |
st.markdown("""
|
| 1061 |
<div class='task-card'>
|
| 1062 |
+
<div class='task-icon' style='background:linear-gradient(135deg,#6d28d9,#a78bfa);box-shadow:0 6px 20px rgba(109,40,217,.4);'>
|
| 1063 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 1064 |
+
<circle cx="12" cy="12" r="10"/>
|
| 1065 |
+
<polyline points="12 6 12 12 16 14"/>
|
| 1066 |
</svg>
|
| 1067 |
</div>
|
| 1068 |
<div>
|
| 1069 |
<div class='task-title'>Same shortest path, completely different strategies</div>
|
| 1070 |
+
<div class='task-desc'>A* remembers every node it visits - fast, but memory grows with the network.
|
| 1071 |
+
IDA* forgets and re-searches from scratch each pass, tightening its cost bound each time - slower
|
| 1072 |
but uses almost no memory. This benchmark runs <b>10 routes Γ 20 timing runs</b> across urban
|
| 1073 |
+
and rural pairs to find out which algorithm is right for EcoCart - and at what scale that answer changes.</div>
|
| 1074 |
</div>
|
| 1075 |
</div>""", unsafe_allow_html=True)
|
| 1076 |
|
|
|
|
| 1118 |
|
| 1119 |
st.markdown("""
|
| 1120 |
<div class='insight'>
|
| 1121 |
+
Both algorithms found <b>identical optimal paths</b> on every single route - path costs match exactly.
|
| 1122 |
But A* was faster and expanded fewer nodes every time. The starkest example: R4βR9, where
|
| 1123 |
A* needed 7 node expansions in 0.130 ms while IDA* needed 50 in 0.642 ms.
|
| 1124 |
For EcoCart's current network, A* is the clear winner. IDA*'s value shows up at national scale β
|
|
|
|
| 1126 |
</div>""", unsafe_allow_html=True)
|
| 1127 |
|
| 1128 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1129 |
+
# TASK 5 - FORECASTING
|
| 1130 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1131 |
with T5:
|
| 1132 |
st.markdown("""
|
| 1133 |
<div class='task-card'>
|
| 1134 |
+
<div class='task-icon' style='background:linear-gradient(135deg,#047857,#34d399);box-shadow:0 6px 20px rgba(4,120,87,.4);'>
|
| 1135 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 1136 |
+
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
|
|
|
| 1137 |
</svg>
|
| 1138 |
</div>
|
| 1139 |
<div>
|
|
|
|
| 1141 |
<div class='task-desc'>Linear Regression (fast, transparent) goes head-to-head against
|
| 1142 |
Random Forest (200 trees, non-linear patterns). Both train on <b>730 days</b> of EcoCart
|
| 1143 |
sales history and are tested blind on <b>140 days they have never seen</b>.
|
| 1144 |
+
Press <b>Run</b> to see which model wins on MAE, RMSE, RΒ², and MAPE - and why the result is surprising.</div>
|
| 1145 |
</div>
|
| 1146 |
</div>""", unsafe_allow_html=True)
|
| 1147 |
|
| 1148 |
+
run_t5 = st.button("βΆ Run Task 5 - Demand Forecasting",
|
| 1149 |
type="primary", use_container_width=True, key="run_t5")
|
| 1150 |
if run_t5 or st.session_state.get("t5_done"):
|
| 1151 |
st.session_state["t5_done"] = True
|
|
|
|
| 1171 |
</div><div class='term-body'>{t5_out}</div></div>""", unsafe_allow_html=True)
|
| 1172 |
|
| 1173 |
m1,m2,m3,m4=st.columns(4)
|
| 1174 |
+
m1.metric("LR - MAE","9.62 units"); m2.metric("LR - RΒ²","0.762")
|
| 1175 |
+
m3.metric("RF - MAE","9.75 units"); m4.metric("RF - RΒ²","0.716")
|
| 1176 |
|
| 1177 |
if os.path.exists("output/forecast.png"):
|
| 1178 |
st.image("output/forecast.png",
|
| 1179 |
+
caption="Actual vs predicted sales - 140 test days",
|
| 1180 |
use_container_width=True)
|
| 1181 |
c1,c2=st.columns(2)
|
| 1182 |
with c1:
|
|
|
|
| 1189 |
|
| 1190 |
st.markdown("""
|
| 1191 |
<div class='insight'>
|
| 1192 |
+
Linear Regression won on <b>both accuracy and speed</b> - RΒ²=0.762 vs Random Forest's 0.716,
|
| 1193 |
and a fraction of the training time (LR is a single matrix solve; RF trains 200 trees on
|
| 1194 |
bootstrap samples). The reason LR wins here: once lag_7 (same weekday last week) is in the
|
| 1195 |
features, the demand signal becomes mostly linear. Random Forest's complexity adds noise, not signal.
|
| 1196 |
+
Top predictors: <b>lag_7</b>, <b>lag_14</b>, <b>is_promo</b> - weekly rhythm and promotions
|
| 1197 |
drive demand more than anything else.
|
| 1198 |
</div>""", unsafe_allow_html=True)
|
| 1199 |
|
| 1200 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1201 |
+
# TASK 6 - BUSINESS CASE
|
| 1202 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1203 |
with T6:
|
| 1204 |
st.markdown("""
|
| 1205 |
<div class='task-card'>
|
| 1206 |
+
<div class='task-icon' style='background:linear-gradient(135deg,#c2410c,#fb923c);box-shadow:0 6px 20px rgba(194,65,12,.4);'>
|
| 1207 |
<svg width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
| 1208 |
+
<path d="M21.21 15.89A10 10 0 118 2.83"/>
|
| 1209 |
+
<path d="M22 12A10 10 0 0012 2v10z"/>
|
|
|
|
| 1210 |
</svg>
|
| 1211 |
</div>
|
| 1212 |
<div>
|
|
|
|
| 1275 |
|
| 1276 |
st.markdown(f"""
|
| 1277 |
<div class='warn-box'>
|
| 1278 |
+
<b>Reminder:</b> these are estimates for illustration only - not measured values.
|
| 1279 |
Current inputs: {fleet} vehicles, {daily} deliveries/day, {avg_km} km avg route,
|
| 1280 |
{rt_save}% saving from A* routing, β¬{seg_rev}k rural revenue uplift assumed.
|
| 1281 |
Change the sliders to model your own scenario.
|