Spaces:
Running
Running
Commit ·
b2744d8
1
Parent(s): 4811e6a
Restructure tabs: Strategy, Design, Logistics, Sourcing, Market
Browse files- Fix Fleet Scale Economics chart (show Profit Advantage instead of comparing similar-sized profits)
- Rename Operations → Logistics
- Move BOM/materials from Design → new Sourcing tab
- Design tab now focuses on circularity impact (grades, material composition)
- Expand Market tab with competitive positioning tables
- Update sidebar controls per tab
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
app.py
CHANGED
|
@@ -82,7 +82,7 @@ st.sidebar.markdown("---")
|
|
| 82 |
st.sidebar.header("View")
|
| 83 |
selected_view = st.sidebar.radio(
|
| 84 |
"Select stakeholder view",
|
| 85 |
-
options=["Strategy", "Design", "
|
| 86 |
index=0,
|
| 87 |
help="Each view shows relevant controls and insights"
|
| 88 |
)
|
|
@@ -138,23 +138,25 @@ if selected_view == "Strategy":
|
|
| 138 |
|
| 139 |
elif selected_view == "Design":
|
| 140 |
st.sidebar.header("🎨 Design Controls")
|
| 141 |
-
|
| 142 |
-
"
|
| 143 |
)
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
elif selected_view == "Operations":
|
| 147 |
-
st.sidebar.header("🔧 Operations Controls")
|
| 148 |
return_rate_pct = st.sidebar.slider(
|
| 149 |
"Return Rate", 50, 100, int(st.session_state.return_rate * 100), format="%d%%"
|
| 150 |
)
|
| 151 |
st.session_state.return_rate = return_rate_pct / 100
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
refurb_pct = st.sidebar.slider(
|
| 154 |
"Products Needing Repair", 5, 50, int(st.session_state.refurb_needed_pct * 100), format="%d%%"
|
| 155 |
)
|
| 156 |
st.session_state.refurb_needed_pct = refurb_pct / 100
|
| 157 |
-
|
| 158 |
st.session_state.logistics = st.sidebar.slider(
|
| 159 |
"Logistics (per cycle)", 5, 40, st.session_state.logistics, format="€%d"
|
| 160 |
)
|
|
@@ -162,23 +164,14 @@ elif selected_view == "Operations":
|
|
| 162 |
"Avg. Refurbishment Cost", 10, 80, st.session_state.avg_refurb_cost, format="€%d"
|
| 163 |
)
|
| 164 |
|
| 165 |
-
elif selected_view == "
|
| 166 |
-
st.sidebar.header("
|
| 167 |
-
st.session_state.lease_price = st.sidebar.slider(
|
| 168 |
-
"Monthly Lease Price", 10, 60, st.session_state.lease_price, format="€%d"
|
| 169 |
-
)
|
| 170 |
st.session_state.mfg_cost = st.sidebar.slider(
|
| 171 |
"Manufacturing Cost", 50, 250, st.session_state.mfg_cost, step=10, format="€%d"
|
| 172 |
)
|
| 173 |
-
st.session_state.avg_refurb_cost = st.sidebar.slider(
|
| 174 |
-
"Avg. Refurbishment Cost", 10, 80, st.session_state.avg_refurb_cost, format="€%d"
|
| 175 |
-
)
|
| 176 |
-
st.session_state.logistics = st.sidebar.slider(
|
| 177 |
-
"Logistics (per cycle)", 5, 40, st.session_state.logistics, format="€%d"
|
| 178 |
-
)
|
| 179 |
|
| 180 |
-
elif selected_view == "
|
| 181 |
-
st.sidebar.header("📈
|
| 182 |
st.session_state.lease_price = st.sidebar.slider(
|
| 183 |
"Monthly Lease Price", 10, 60, st.session_state.lease_price, format="€%d"
|
| 184 |
)
|
|
@@ -236,13 +229,14 @@ if selected_view == "Strategy":
|
|
| 236 |
circ_profit_5k = customers_5k * revenue_per_customer - circ_cost_5k
|
| 237 |
lin_cost_5k = customers_5k * new_unit_cost
|
| 238 |
lin_profit_5k = customers_5k * revenue_per_customer - lin_cost_5k
|
|
|
|
| 239 |
products_saved_5k = customers_5k - fleet_5k
|
| 240 |
co2_per_unit = config["environmental"]["co2_manufacturing_kg"]
|
| 241 |
co2_saved_5k = products_saved_5k * co2_per_unit
|
| 242 |
|
| 243 |
hero_cols = st.columns(4)
|
| 244 |
with hero_cols[0]:
|
| 245 |
-
st.metric("Profit Advantage", f"€{
|
| 246 |
with hero_cols[1]:
|
| 247 |
st.metric("Products Saved", f"{products_saved_5k:,}", delta=f"at {fleet_5k:,} fleet")
|
| 248 |
with hero_cols[2]:
|
|
@@ -267,6 +261,7 @@ if selected_view == "Strategy":
|
|
| 267 |
linear_profit = linear_revenue - linear_cost
|
| 268 |
products_saved = customers_served - fleet
|
| 269 |
co2_saved = products_saved * co2_per_unit
|
|
|
|
| 270 |
|
| 271 |
scale_data.append({
|
| 272 |
"Fleet Size": fleet,
|
|
@@ -274,7 +269,7 @@ if selected_view == "Strategy":
|
|
| 274 |
"Customers Served": customers_served,
|
| 275 |
"Circular Profit": circular_profit,
|
| 276 |
"Linear Profit": linear_profit,
|
| 277 |
-
"Profit Advantage":
|
| 278 |
"Products Saved": products_saved,
|
| 279 |
"CO2 Saved (kg)": co2_saved,
|
| 280 |
"Linear Products Needed": customers_served,
|
|
@@ -285,23 +280,23 @@ if selected_view == "Strategy":
|
|
| 285 |
scale_col1, scale_col2 = st.columns(2)
|
| 286 |
|
| 287 |
with scale_col1:
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
))
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
fig_scale.update_layout(
|
| 300 |
-
title="Profit at Scale: Circular vs Linear",
|
| 301 |
-
xaxis_title="Fleet Size", yaxis_title="Total Profit (EUR)",
|
| 302 |
-
barmode="group", legend=dict(orientation="h", yanchor="bottom", y=1.02),
|
| 303 |
)
|
| 304 |
-
st.plotly_chart(
|
| 305 |
|
| 306 |
with scale_col2:
|
| 307 |
fig_products = go.Figure()
|
|
@@ -326,10 +321,9 @@ if selected_view == "Strategy":
|
|
| 326 |
key_data = scale_df[scale_df["Fleet Size"] == 5000].iloc[0]
|
| 327 |
st.success(f"""
|
| 328 |
**The Business Case:** With **{5000:,} products** in our fleet, we serve **{key_data['Customers Served']:,} families**
|
| 329 |
-
and generate **€{key_data['
|
| 330 |
|
| 331 |
-
A linear model would need **{key_data['Linear Products Needed']:,} products** to serve the same customers
|
| 332 |
-
generating **€{key_data['Profit Advantage']:,.0f} less profit**.
|
| 333 |
|
| 334 |
Environmental impact: **{key_data['CO2 Saved (kg)']:,.0f} kg CO2 saved**.
|
| 335 |
""")
|
|
@@ -400,178 +394,105 @@ if selected_view == "Strategy":
|
|
| 400 |
# VIEW: DESIGN
|
| 401 |
# ============================================================
|
| 402 |
elif selected_view == "Design":
|
| 403 |
-
st.header("Design:
|
| 404 |
st.markdown("*For product designers and sustainability officers*")
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
has_overhead = "overhead" in config
|
| 409 |
-
|
| 410 |
-
if has_materials and has_labor and has_overhead:
|
| 411 |
-
bom = config["materials"]["bom"]
|
| 412 |
-
|
| 413 |
-
# Recycled content sliders
|
| 414 |
-
with st.expander("Adjust Recycled Content", expanded=True):
|
| 415 |
-
st.markdown("**Adjust recycled material percentages to see cost impact:**")
|
| 416 |
-
recycled_sliders = {}
|
| 417 |
-
slider_cols = st.columns(3)
|
| 418 |
-
col_idx = 0
|
| 419 |
-
|
| 420 |
-
for component_key, component in bom.items():
|
| 421 |
-
if "virgin_cost_per_kg" in component:
|
| 422 |
-
default_pct = component.get("recycled_content_pct", 0)
|
| 423 |
-
with slider_cols[col_idx % 3]:
|
| 424 |
-
recycled_sliders[component_key] = st.slider(
|
| 425 |
-
f"{component.get('name', component_key)}",
|
| 426 |
-
0, 100, int(default_pct * 100), step=5,
|
| 427 |
-
key=f"recycled_{selected_product}_{component_key}",
|
| 428 |
-
help=f"Virgin: €{component['virgin_cost_per_kg']:.2f}/kg, Recycled: €{component.get('recycled_cost_per_kg', component['virgin_cost_per_kg']):.2f}/kg"
|
| 429 |
-
)
|
| 430 |
-
col_idx += 1
|
| 431 |
-
|
| 432 |
-
# Calculate material costs
|
| 433 |
-
material_data = []
|
| 434 |
-
total_material_cost = 0
|
| 435 |
-
total_material_cost_virgin = 0
|
| 436 |
-
total_weight = 0
|
| 437 |
-
|
| 438 |
-
for component_key, component in bom.items():
|
| 439 |
-
weight = component.get("weight_kg", 0)
|
| 440 |
-
total_weight += weight
|
| 441 |
-
|
| 442 |
-
if "virgin_cost_per_kg" in component:
|
| 443 |
-
recycled_pct = recycled_sliders.get(component_key, component.get("recycled_content_pct", 0) * 100) / 100
|
| 444 |
-
virgin_cost = component["virgin_cost_per_kg"]
|
| 445 |
-
recycled_cost = component.get("recycled_cost_per_kg", virgin_cost)
|
| 446 |
-
cost_per_kg = (virgin_cost * (1 - recycled_pct)) + (recycled_cost * recycled_pct)
|
| 447 |
-
cost = weight * cost_per_kg
|
| 448 |
-
cost_virgin = weight * virgin_cost
|
| 449 |
-
recyclable = component.get("recyclable", False)
|
| 450 |
-
else:
|
| 451 |
-
cost_per_kg = component.get("cost_per_kg", 0)
|
| 452 |
-
cost = weight * cost_per_kg
|
| 453 |
-
cost_virgin = cost
|
| 454 |
-
recycled_pct = 0
|
| 455 |
-
recyclable = component.get("recyclable", False)
|
| 456 |
-
|
| 457 |
-
total_material_cost += cost
|
| 458 |
-
total_material_cost_virgin += cost_virgin
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
|
|
|
| 468 |
|
| 469 |
-
|
| 470 |
-
config["labor"]["manufacturing"]["hours_per_unit"] *
|
| 471 |
-
config["labor"]["manufacturing"]["hourly_rate"])
|
| 472 |
-
overhead_cost = config["overhead"]["manufacturing_per_unit"]
|
| 473 |
|
| 474 |
-
|
|
|
|
|
|
|
| 475 |
|
| 476 |
-
|
| 477 |
-
|
|
|
|
|
|
|
|
|
|
| 478 |
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
connector={"line": {"color": "rgb(63, 63, 63)"}},
|
| 488 |
-
increasing={"marker": {"color": "#3498db"}},
|
| 489 |
-
totals={"marker": {"color": "#2ecc71"}},
|
| 490 |
-
))
|
| 491 |
-
fig_waterfall.update_layout(title=f"Manufacturing Cost: €{mfg_cost:.0f}", yaxis_title="EUR", showlegend=False)
|
| 492 |
-
st.plotly_chart(fig_waterfall, use_container_width=True)
|
| 493 |
|
| 494 |
-
|
| 495 |
-
st.subheader("Bill of Materials")
|
| 496 |
-
mat_df = pd.DataFrame(material_data)
|
| 497 |
-
st.dataframe(mat_df.style.format({
|
| 498 |
-
"Weight (kg)": "{:.1f}", "Cost/kg": "€{:.2f}",
|
| 499 |
-
"Total Cost": "€{:.2f}", "Recycled %": "{:.0f}%",
|
| 500 |
-
}), use_container_width=True, hide_index=True)
|
| 501 |
|
| 502 |
-
|
| 503 |
-
recyclable_pct = recyclable_weight / total_weight * 100 if total_weight > 0 else 0
|
| 504 |
-
cost_savings = total_material_cost_virgin - total_material_cost
|
| 505 |
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
|
| 510 |
-
|
| 511 |
-
|
|
|
|
|
|
|
|
|
|
| 512 |
|
| 513 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 514 |
|
| 515 |
-
|
| 516 |
-
st.subheader("Material Composition Over Time")
|
| 517 |
-
|
| 518 |
-
lifecycle_data = []
|
| 519 |
-
original_material_pct = 100
|
| 520 |
-
new_parts_added_pct = 0
|
| 521 |
-
parts_replaced_grade_a = 0.05
|
| 522 |
-
parts_replaced_grade_b = 0.20
|
| 523 |
-
|
| 524 |
-
for cycle in range(0, max_cycles + 1):
|
| 525 |
-
if cycle == 0:
|
| 526 |
-
lifecycle_data.append({
|
| 527 |
-
"Cycle": "New",
|
| 528 |
-
"Original Material %": original_material_pct,
|
| 529 |
-
"New Parts Added %": new_parts_added_pct,
|
| 530 |
-
})
|
| 531 |
-
else:
|
| 532 |
-
parts_from_a = grade_a_pct * parts_replaced_grade_a * 100
|
| 533 |
-
parts_from_b = grade_b_pct * parts_replaced_grade_b * 100
|
| 534 |
-
new_parts_added_pct += parts_from_a + parts_from_b
|
| 535 |
-
original_material_pct = max(0, 100 - new_parts_added_pct)
|
| 536 |
-
|
| 537 |
-
lifecycle_data.append({
|
| 538 |
-
"Cycle": f"Cycle {cycle}",
|
| 539 |
-
"Original Material %": original_material_pct,
|
| 540 |
-
"New Parts Added %": new_parts_added_pct,
|
| 541 |
-
})
|
| 542 |
-
|
| 543 |
-
lifecycle_df = pd.DataFrame(lifecycle_data)
|
| 544 |
-
|
| 545 |
-
fig_materials = go.Figure()
|
| 546 |
-
fig_materials.add_trace(go.Scatter(
|
| 547 |
-
x=lifecycle_df["Cycle"], y=lifecycle_df["Original Material %"],
|
| 548 |
-
name="Original Parts", mode="lines", fill="tozeroy",
|
| 549 |
-
line=dict(color="#3498db", width=0), fillcolor="rgba(52, 152, 219, 0.7)",
|
| 550 |
-
))
|
| 551 |
-
fig_materials.add_trace(go.Scatter(
|
| 552 |
-
x=lifecycle_df["Cycle"],
|
| 553 |
-
y=lifecycle_df["Original Material %"] + lifecycle_df["New Parts Added %"],
|
| 554 |
-
name="Replacement Parts", mode="lines", fill="tonexty",
|
| 555 |
-
line=dict(color="#2ecc71", width=0), fillcolor="rgba(46, 204, 113, 0.7)",
|
| 556 |
-
))
|
| 557 |
-
fig_materials.update_layout(
|
| 558 |
-
yaxis_title="Material Composition (%)", yaxis_range=[0, 120],
|
| 559 |
-
legend=dict(orientation="h", yanchor="bottom", y=1.02),
|
| 560 |
-
)
|
| 561 |
-
st.plotly_chart(fig_materials, use_container_width=True)
|
| 562 |
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
-
|
| 568 |
-
|
|
|
|
| 569 |
|
| 570 |
# ============================================================
|
| 571 |
-
# VIEW:
|
| 572 |
# ============================================================
|
| 573 |
-
elif selected_view == "
|
| 574 |
-
st.header("
|
| 575 |
st.markdown("*For logistics managers and warehouse operations*")
|
| 576 |
|
| 577 |
# Return assumptions summary
|
|
@@ -679,152 +600,141 @@ elif selected_view == "Operations":
|
|
| 679 |
st.info(f"Per 100 products: After {max_cycles} cycles, **{final_in_service:.0f}** still serviceable, **{final_lost:.0f}** lost")
|
| 680 |
|
| 681 |
# ============================================================
|
| 682 |
-
# VIEW:
|
| 683 |
# ============================================================
|
| 684 |
-
elif selected_view == "
|
| 685 |
-
st.header("
|
| 686 |
-
st.markdown("*For
|
| 687 |
-
|
| 688 |
-
# Key Metrics
|
| 689 |
-
st.subheader("Key Metrics")
|
| 690 |
-
|
| 691 |
-
col1, col2, col3, col4 = st.columns(4)
|
| 692 |
-
with col1:
|
| 693 |
-
st.metric("EoLRR", f"{eolrr:.1%}", delta=f"{(eolrr - config['cti_parameters']['target_eolrr']):.1%}",
|
| 694 |
-
help="End-of-Life Recovery Rate = Return Rate × (Grade A + B)")
|
| 695 |
-
with col2:
|
| 696 |
-
st.metric("New Unit Cost", f"€{new_unit_cost:.0f}", help="Manufacturing + Logistics")
|
| 697 |
-
with col3:
|
| 698 |
-
st.metric("Refurb Unit Cost", f"€{refurb_unit_cost:.0f}", delta=f"-€{new_unit_cost - refurb_unit_cost:.0f} vs new")
|
| 699 |
-
with col4:
|
| 700 |
-
st.metric("Revenue per Customer", f"€{revenue_per_customer:.0f}", help=f"€{lease_price}/month × {lease_term} months")
|
| 701 |
-
|
| 702 |
-
st.markdown("---")
|
| 703 |
|
| 704 |
-
|
| 705 |
-
|
|
|
|
| 706 |
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
circular_cum_revenue = 0
|
| 710 |
-
linear_cum_cost = 0
|
| 711 |
-
linear_cum_revenue = 0
|
| 712 |
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
circular_cum_cost += cycle_cost
|
| 720 |
-
circular_cum_revenue += revenue_per_customer
|
| 721 |
-
circular_cum_profit = circular_cum_revenue - circular_cum_cost
|
| 722 |
-
|
| 723 |
-
linear_cum_cost += new_unit_cost
|
| 724 |
-
linear_cum_revenue += revenue_per_customer
|
| 725 |
-
linear_cum_profit = linear_cum_revenue - linear_cum_cost
|
| 726 |
-
|
| 727 |
-
product_data.append({
|
| 728 |
-
"Cycle": cycle,
|
| 729 |
-
"Cycle Label": f"Cycle {cycle}",
|
| 730 |
-
"Circular: Cumulative Profit": circular_cum_profit,
|
| 731 |
-
"Circular: Cost per Use": circular_cum_cost / cycle,
|
| 732 |
-
"Linear: Cumulative Profit": linear_cum_profit,
|
| 733 |
-
"Linear: Cost per Use": new_unit_cost,
|
| 734 |
-
})
|
| 735 |
|
| 736 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 737 |
|
| 738 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
x=df_product["Cycle"], y=df_product["Circular: Cumulative Profit"],
|
| 744 |
-
name="Circular (1 product reused)", mode="lines+markers",
|
| 745 |
-
line=dict(color="#2ecc71", width=3), marker=dict(size=12),
|
| 746 |
-
))
|
| 747 |
-
fig_profit.add_trace(go.Scatter(
|
| 748 |
-
x=df_product["Cycle"], y=df_product["Linear: Cumulative Profit"],
|
| 749 |
-
name="Linear (N new products)", mode="lines+markers",
|
| 750 |
-
line=dict(color="#e74c3c", width=3), marker=dict(size=12),
|
| 751 |
-
))
|
| 752 |
-
fig_profit.update_layout(title="Cumulative Profit per Product", xaxis_title="Use Cycle", yaxis_title="Cumulative Profit (EUR)",
|
| 753 |
-
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01))
|
| 754 |
-
st.plotly_chart(fig_profit, use_container_width=True)
|
| 755 |
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
st.plotly_chart(fig_cost, use_container_width=True)
|
| 771 |
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
cost_savings = (max_cycles * new_unit_cost) - circular_cum_cost
|
| 775 |
|
| 776 |
-
|
| 777 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 778 |
|
| 779 |
-
|
|
|
|
|
|
|
|
|
|
| 780 |
|
| 781 |
-
|
| 782 |
-
st.subheader("Pricing Analysis: Finding the Sweet Spot")
|
| 783 |
|
| 784 |
-
|
| 785 |
-
|
| 786 |
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
|
| 793 |
-
|
| 794 |
-
"
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
|
|
|
|
|
|
| 798 |
|
| 799 |
-
|
|
|
|
|
|
|
|
|
|
| 800 |
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 807 |
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
fig_price_profit.add_trace(go.Scatter(
|
| 811 |
-
x=[lease_price], y=[current_profit], mode="markers",
|
| 812 |
-
name=f"Current (€{lease_price}/mo)", marker=dict(size=16, color="#e74c3c", symbol="star"),
|
| 813 |
-
))
|
| 814 |
-
fig_price_profit.add_hline(y=0, line_dash="dash", line_color="gray", annotation_text="Break-even")
|
| 815 |
-
fig_price_profit.update_layout(title="Profit vs Monthly Price", xaxis_title="Monthly Lease Price (EUR)",
|
| 816 |
-
yaxis_title="Profit per Product Lifecycle (EUR)")
|
| 817 |
-
st.plotly_chart(fig_price_profit, use_container_width=True)
|
| 818 |
|
| 819 |
-
|
| 820 |
-
|
| 821 |
|
| 822 |
# ============================================================
|
| 823 |
-
# VIEW:
|
| 824 |
# ============================================================
|
| 825 |
-
elif selected_view == "
|
| 826 |
-
st.header("
|
| 827 |
-
st.markdown("*For marketing, sales, and
|
| 828 |
|
| 829 |
# Customer Value Proposition
|
| 830 |
st.subheader("Customer Value Proposition")
|
|
@@ -847,7 +757,7 @@ elif selected_view == "Growth":
|
|
| 847 |
st.subheader("Environmental Impact Story")
|
| 848 |
|
| 849 |
co2_per_unit = config["environmental"]["co2_manufacturing_kg"]
|
| 850 |
-
co2_saved_per_customer = co2_per_unit * (1 - 1/max_cycles)
|
| 851 |
|
| 852 |
env_cols = st.columns(3)
|
| 853 |
with env_cols[0]:
|
|
@@ -866,7 +776,7 @@ elif selected_view == "Growth":
|
|
| 866 |
|
| 867 |
st.markdown("---")
|
| 868 |
|
| 869 |
-
#
|
| 870 |
st.subheader("Price vs Value Analysis")
|
| 871 |
|
| 872 |
price_range = list(range(15, 51, 5))
|
|
@@ -905,9 +815,9 @@ elif selected_view == "Growth":
|
|
| 905 |
if retail_price > 0 and total_lease_cost < retail_price:
|
| 906 |
st.success(f"At €{lease_price}/mo, customers **save €{retail_price - total_lease_cost}** ({(retail_price - total_lease_cost)/retail_price*100:.0f}% less than buying)")
|
| 907 |
|
| 908 |
-
#
|
| 909 |
st.markdown("---")
|
| 910 |
-
st.subheader("
|
| 911 |
|
| 912 |
st.markdown(f"""
|
| 913 |
| Message | Proof Point |
|
|
@@ -919,6 +829,18 @@ elif selected_view == "Growth":
|
|
| 919 |
| **Circular economy** | One product serves {max_cycles} families |
|
| 920 |
""")
|
| 921 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
# ============================================================
|
| 923 |
# FOOTER
|
| 924 |
# ============================================================
|
|
|
|
| 82 |
st.sidebar.header("View")
|
| 83 |
selected_view = st.sidebar.radio(
|
| 84 |
"Select stakeholder view",
|
| 85 |
+
options=["Strategy", "Design", "Logistics", "Sourcing", "Market"],
|
| 86 |
index=0,
|
| 87 |
help="Each view shows relevant controls and insights"
|
| 88 |
)
|
|
|
|
| 138 |
|
| 139 |
elif selected_view == "Design":
|
| 140 |
st.sidebar.header("🎨 Design Controls")
|
| 141 |
+
refurb_pct = st.sidebar.slider(
|
| 142 |
+
"Products Needing Repair", 5, 50, int(st.session_state.refurb_needed_pct * 100), format="%d%%"
|
| 143 |
)
|
| 144 |
+
st.session_state.refurb_needed_pct = refurb_pct / 100
|
|
|
|
|
|
|
|
|
|
| 145 |
return_rate_pct = st.sidebar.slider(
|
| 146 |
"Return Rate", 50, 100, int(st.session_state.return_rate * 100), format="%d%%"
|
| 147 |
)
|
| 148 |
st.session_state.return_rate = return_rate_pct / 100
|
| 149 |
|
| 150 |
+
elif selected_view == "Logistics":
|
| 151 |
+
st.sidebar.header("🚚 Logistics Controls")
|
| 152 |
+
return_rate_pct = st.sidebar.slider(
|
| 153 |
+
"Return Rate", 50, 100, int(st.session_state.return_rate * 100), format="%d%%"
|
| 154 |
+
)
|
| 155 |
+
st.session_state.return_rate = return_rate_pct / 100
|
| 156 |
refurb_pct = st.sidebar.slider(
|
| 157 |
"Products Needing Repair", 5, 50, int(st.session_state.refurb_needed_pct * 100), format="%d%%"
|
| 158 |
)
|
| 159 |
st.session_state.refurb_needed_pct = refurb_pct / 100
|
|
|
|
| 160 |
st.session_state.logistics = st.sidebar.slider(
|
| 161 |
"Logistics (per cycle)", 5, 40, st.session_state.logistics, format="€%d"
|
| 162 |
)
|
|
|
|
| 164 |
"Avg. Refurbishment Cost", 10, 80, st.session_state.avg_refurb_cost, format="€%d"
|
| 165 |
)
|
| 166 |
|
| 167 |
+
elif selected_view == "Sourcing":
|
| 168 |
+
st.sidebar.header("📦 Sourcing Controls")
|
|
|
|
|
|
|
|
|
|
| 169 |
st.session_state.mfg_cost = st.sidebar.slider(
|
| 170 |
"Manufacturing Cost", 50, 250, st.session_state.mfg_cost, step=10, format="€%d"
|
| 171 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
+
elif selected_view == "Market":
|
| 174 |
+
st.sidebar.header("📈 Market Controls")
|
| 175 |
st.session_state.lease_price = st.sidebar.slider(
|
| 176 |
"Monthly Lease Price", 10, 60, st.session_state.lease_price, format="€%d"
|
| 177 |
)
|
|
|
|
| 229 |
circ_profit_5k = customers_5k * revenue_per_customer - circ_cost_5k
|
| 230 |
lin_cost_5k = customers_5k * new_unit_cost
|
| 231 |
lin_profit_5k = customers_5k * revenue_per_customer - lin_cost_5k
|
| 232 |
+
profit_advantage_5k = circ_profit_5k - lin_profit_5k
|
| 233 |
products_saved_5k = customers_5k - fleet_5k
|
| 234 |
co2_per_unit = config["environmental"]["co2_manufacturing_kg"]
|
| 235 |
co2_saved_5k = products_saved_5k * co2_per_unit
|
| 236 |
|
| 237 |
hero_cols = st.columns(4)
|
| 238 |
with hero_cols[0]:
|
| 239 |
+
st.metric("Profit Advantage", f"€{profit_advantage_5k:,.0f}", delta="vs linear model")
|
| 240 |
with hero_cols[1]:
|
| 241 |
st.metric("Products Saved", f"{products_saved_5k:,}", delta=f"at {fleet_5k:,} fleet")
|
| 242 |
with hero_cols[2]:
|
|
|
|
| 261 |
linear_profit = linear_revenue - linear_cost
|
| 262 |
products_saved = customers_served - fleet
|
| 263 |
co2_saved = products_saved * co2_per_unit
|
| 264 |
+
profit_advantage = circular_profit - linear_profit
|
| 265 |
|
| 266 |
scale_data.append({
|
| 267 |
"Fleet Size": fleet,
|
|
|
|
| 269 |
"Customers Served": customers_served,
|
| 270 |
"Circular Profit": circular_profit,
|
| 271 |
"Linear Profit": linear_profit,
|
| 272 |
+
"Profit Advantage": profit_advantage,
|
| 273 |
"Products Saved": products_saved,
|
| 274 |
"CO2 Saved (kg)": co2_saved,
|
| 275 |
"Linear Products Needed": customers_served,
|
|
|
|
| 280 |
scale_col1, scale_col2 = st.columns(2)
|
| 281 |
|
| 282 |
with scale_col1:
|
| 283 |
+
# Fixed chart: Show Profit Advantage as the primary metric
|
| 284 |
+
fig_advantage = go.Figure()
|
| 285 |
+
fig_advantage.add_trace(go.Bar(
|
| 286 |
+
x=scale_df["Fleet Label"],
|
| 287 |
+
y=scale_df["Profit Advantage"],
|
| 288 |
+
name="Circular Profit Advantage",
|
| 289 |
+
marker_color="#2ecc71",
|
| 290 |
+
text=[f"€{v:,.0f}" for v in scale_df["Profit Advantage"]],
|
| 291 |
+
textposition="outside",
|
| 292 |
))
|
| 293 |
+
fig_advantage.update_layout(
|
| 294 |
+
title="Profit Advantage: Circular vs Linear",
|
| 295 |
+
xaxis_title="Fleet Size",
|
| 296 |
+
yaxis_title="Extra Profit from Circular Model (EUR)",
|
| 297 |
+
showlegend=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
)
|
| 299 |
+
st.plotly_chart(fig_advantage, use_container_width=True)
|
| 300 |
|
| 301 |
with scale_col2:
|
| 302 |
fig_products = go.Figure()
|
|
|
|
| 321 |
key_data = scale_df[scale_df["Fleet Size"] == 5000].iloc[0]
|
| 322 |
st.success(f"""
|
| 323 |
**The Business Case:** With **{5000:,} products** in our fleet, we serve **{key_data['Customers Served']:,} families**
|
| 324 |
+
and generate **€{key_data['Profit Advantage']:,.0f} more profit** than a linear model.
|
| 325 |
|
| 326 |
+
A linear model would need **{key_data['Linear Products Needed']:,} products** to serve the same customers.
|
|
|
|
| 327 |
|
| 328 |
Environmental impact: **{key_data['CO2 Saved (kg)']:,.0f} kg CO2 saved**.
|
| 329 |
""")
|
|
|
|
| 394 |
# VIEW: DESIGN
|
| 395 |
# ============================================================
|
| 396 |
elif selected_view == "Design":
|
| 397 |
+
st.header("Design: Product Design for Circularity")
|
| 398 |
st.markdown("*For product designers and sustainability officers*")
|
| 399 |
|
| 400 |
+
# Design for Circularity metrics
|
| 401 |
+
st.subheader("Design Impact on Circular Economics")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
+
design_cols = st.columns(4)
|
| 404 |
+
with design_cols[0]:
|
| 405 |
+
st.metric("EoLRR", f"{eolrr:.1%}", help="End-of-Life Recovery Rate")
|
| 406 |
+
with design_cols[1]:
|
| 407 |
+
st.metric("Grade A Rate", f"{grade_a_pct:.0%}", help="Products needing only cleaning")
|
| 408 |
+
with design_cols[2]:
|
| 409 |
+
st.metric("Repair Rate", f"{grade_b_pct:.0%}", help="Products needing parts replacement")
|
| 410 |
+
with design_cols[3]:
|
| 411 |
+
st.metric("Scrap Rate", f"{grade_c_pct:.0%}", help="Products that can't be reused")
|
| 412 |
|
| 413 |
+
st.markdown("---")
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
+
# Grade Distribution - Design Impact framing
|
| 416 |
+
st.subheader("Design Choices → Return Grades")
|
| 417 |
+
st.markdown("Better design for durability and repairability reduces the repair rate and increases Grade A returns.")
|
| 418 |
|
| 419 |
+
grades_df = pd.DataFrame({
|
| 420 |
+
"Grade": ["A (Clean only)", "B (Repair needed)", "C (Scrap)"],
|
| 421 |
+
"Percentage": [grade_a_pct * 100, grade_b_pct * 100, grade_c_pct * 100],
|
| 422 |
+
"Refurb Cost": [refurb_a, refurb_b, config["costs"]["refurbishment"]["grade_c"]],
|
| 423 |
+
})
|
| 424 |
|
| 425 |
+
fig_grades = px.bar(
|
| 426 |
+
grades_df, x="Grade", y="Percentage", color="Grade",
|
| 427 |
+
color_discrete_map={"A (Clean only)": "#2ecc71", "B (Repair needed)": "#f1c40f", "C (Scrap)": "#e74c3c"},
|
| 428 |
+
title="Return Grade Distribution", text="Percentage",
|
| 429 |
+
)
|
| 430 |
+
fig_grades.update_traces(texttemplate='%{text:.0f}%', textposition='outside')
|
| 431 |
+
fig_grades.update_layout(showlegend=False, yaxis_title="% of Returns")
|
| 432 |
+
st.plotly_chart(fig_grades, use_container_width=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
|
| 434 |
+
st.info(f"**Design insight:** Reducing repair rate from {grade_b_pct:.0%} to {max(5, grade_b_pct*100-10):.0f}% would save €{(grade_b_pct - max(0.05, grade_b_pct-0.10)) * refurb_b:.0f} per returned product.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
+
st.markdown("---")
|
|
|
|
|
|
|
| 437 |
|
| 438 |
+
# Material Composition Over Time
|
| 439 |
+
st.subheader("Material Composition Over Time")
|
| 440 |
+
st.markdown("How the product's material composition changes through repair cycles.")
|
| 441 |
|
| 442 |
+
lifecycle_data = []
|
| 443 |
+
original_material_pct = 100
|
| 444 |
+
new_parts_added_pct = 0
|
| 445 |
+
parts_replaced_grade_a = 0.05
|
| 446 |
+
parts_replaced_grade_b = 0.20
|
| 447 |
|
| 448 |
+
for cycle in range(0, max_cycles + 1):
|
| 449 |
+
if cycle == 0:
|
| 450 |
+
lifecycle_data.append({
|
| 451 |
+
"Cycle": "New",
|
| 452 |
+
"Original Material %": original_material_pct,
|
| 453 |
+
"New Parts Added %": new_parts_added_pct,
|
| 454 |
+
})
|
| 455 |
+
else:
|
| 456 |
+
parts_from_a = grade_a_pct * parts_replaced_grade_a * 100
|
| 457 |
+
parts_from_b = grade_b_pct * parts_replaced_grade_b * 100
|
| 458 |
+
new_parts_added_pct += parts_from_a + parts_from_b
|
| 459 |
+
original_material_pct = max(0, 100 - new_parts_added_pct)
|
| 460 |
+
|
| 461 |
+
lifecycle_data.append({
|
| 462 |
+
"Cycle": f"Cycle {cycle}",
|
| 463 |
+
"Original Material %": original_material_pct,
|
| 464 |
+
"New Parts Added %": new_parts_added_pct,
|
| 465 |
+
})
|
| 466 |
|
| 467 |
+
lifecycle_df = pd.DataFrame(lifecycle_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
|
| 469 |
+
fig_materials = go.Figure()
|
| 470 |
+
fig_materials.add_trace(go.Scatter(
|
| 471 |
+
x=lifecycle_df["Cycle"], y=lifecycle_df["Original Material %"],
|
| 472 |
+
name="Original Parts", mode="lines", fill="tozeroy",
|
| 473 |
+
line=dict(color="#3498db", width=0), fillcolor="rgba(52, 152, 219, 0.7)",
|
| 474 |
+
))
|
| 475 |
+
fig_materials.add_trace(go.Scatter(
|
| 476 |
+
x=lifecycle_df["Cycle"],
|
| 477 |
+
y=lifecycle_df["Original Material %"] + lifecycle_df["New Parts Added %"],
|
| 478 |
+
name="Replacement Parts", mode="lines", fill="tonexty",
|
| 479 |
+
line=dict(color="#2ecc71", width=0), fillcolor="rgba(46, 204, 113, 0.7)",
|
| 480 |
+
))
|
| 481 |
+
fig_materials.update_layout(
|
| 482 |
+
yaxis_title="Material Composition (%)", yaxis_range=[0, 120],
|
| 483 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02),
|
| 484 |
+
)
|
| 485 |
+
st.plotly_chart(fig_materials, use_container_width=True)
|
| 486 |
|
| 487 |
+
final_original = lifecycle_df.iloc[-1]["Original Material %"]
|
| 488 |
+
final_new = lifecycle_df.iloc[-1]["New Parts Added %"]
|
| 489 |
+
st.info(f"After {max_cycles} cycles: **{final_original:.0f}%** original parts remain, **{final_new:.0f}%** replaced through repairs.")
|
| 490 |
|
| 491 |
# ============================================================
|
| 492 |
+
# VIEW: LOGISTICS
|
| 493 |
# ============================================================
|
| 494 |
+
elif selected_view == "Logistics":
|
| 495 |
+
st.header("Logistics: Operations & Warehouse Planning")
|
| 496 |
st.markdown("*For logistics managers and warehouse operations*")
|
| 497 |
|
| 498 |
# Return assumptions summary
|
|
|
|
| 600 |
st.info(f"Per 100 products: After {max_cycles} cycles, **{final_in_service:.0f}** still serviceable, **{final_lost:.0f}** lost")
|
| 601 |
|
| 602 |
# ============================================================
|
| 603 |
+
# VIEW: SOURCING
|
| 604 |
# ============================================================
|
| 605 |
+
elif selected_view == "Sourcing":
|
| 606 |
+
st.header("Sourcing: Material Costs & Supply")
|
| 607 |
+
st.markdown("*For procurement, supply chain, and finance*")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
|
| 609 |
+
has_materials = "materials" in config and "bom" in config.get("materials", {})
|
| 610 |
+
has_labor = "labor" in config
|
| 611 |
+
has_overhead = "overhead" in config
|
| 612 |
|
| 613 |
+
if has_materials and has_labor and has_overhead:
|
| 614 |
+
bom = config["materials"]["bom"]
|
|
|
|
|
|
|
|
|
|
| 615 |
|
| 616 |
+
# Recycled content sliders
|
| 617 |
+
with st.expander("Adjust Recycled Content", expanded=True):
|
| 618 |
+
st.markdown("**Adjust recycled material percentages to see cost impact:**")
|
| 619 |
+
recycled_sliders = {}
|
| 620 |
+
slider_cols = st.columns(3)
|
| 621 |
+
col_idx = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
|
| 623 |
+
for component_key, component in bom.items():
|
| 624 |
+
if "virgin_cost_per_kg" in component:
|
| 625 |
+
default_pct = component.get("recycled_content_pct", 0)
|
| 626 |
+
with slider_cols[col_idx % 3]:
|
| 627 |
+
recycled_sliders[component_key] = st.slider(
|
| 628 |
+
f"{component.get('name', component_key)}",
|
| 629 |
+
0, 100, int(default_pct * 100), step=5,
|
| 630 |
+
key=f"recycled_{selected_product}_{component_key}",
|
| 631 |
+
help=f"Virgin: €{component['virgin_cost_per_kg']:.2f}/kg, Recycled: €{component.get('recycled_cost_per_kg', component['virgin_cost_per_kg']):.2f}/kg"
|
| 632 |
+
)
|
| 633 |
+
col_idx += 1
|
| 634 |
|
| 635 |
+
# Calculate material costs
|
| 636 |
+
material_data = []
|
| 637 |
+
total_material_cost = 0
|
| 638 |
+
total_material_cost_virgin = 0
|
| 639 |
+
total_weight = 0
|
| 640 |
|
| 641 |
+
for component_key, component in bom.items():
|
| 642 |
+
weight = component.get("weight_kg", 0)
|
| 643 |
+
total_weight += weight
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
| 645 |
+
if "virgin_cost_per_kg" in component:
|
| 646 |
+
recycled_pct = recycled_sliders.get(component_key, component.get("recycled_content_pct", 0) * 100) / 100
|
| 647 |
+
virgin_cost = component["virgin_cost_per_kg"]
|
| 648 |
+
recycled_cost = component.get("recycled_cost_per_kg", virgin_cost)
|
| 649 |
+
cost_per_kg = (virgin_cost * (1 - recycled_pct)) + (recycled_cost * recycled_pct)
|
| 650 |
+
cost = weight * cost_per_kg
|
| 651 |
+
cost_virgin = weight * virgin_cost
|
| 652 |
+
recyclable = component.get("recyclable", False)
|
| 653 |
+
else:
|
| 654 |
+
cost_per_kg = component.get("cost_per_kg", 0)
|
| 655 |
+
cost = weight * cost_per_kg
|
| 656 |
+
cost_virgin = cost
|
| 657 |
+
recycled_pct = 0
|
| 658 |
+
recyclable = component.get("recyclable", False)
|
|
|
|
| 659 |
|
| 660 |
+
total_material_cost += cost
|
| 661 |
+
total_material_cost_virgin += cost_virgin
|
|
|
|
| 662 |
|
| 663 |
+
material_data.append({
|
| 664 |
+
"Component": component.get("name", component_key),
|
| 665 |
+
"Weight (kg)": weight,
|
| 666 |
+
"Cost/kg": cost_per_kg,
|
| 667 |
+
"Total Cost": cost,
|
| 668 |
+
"Recycled %": recycled_pct * 100,
|
| 669 |
+
"Recyclable": "Yes" if recyclable else "No",
|
| 670 |
+
})
|
| 671 |
|
| 672 |
+
labor_cost = config["labor"]["manufacturing"].get("total",
|
| 673 |
+
config["labor"]["manufacturing"]["hours_per_unit"] *
|
| 674 |
+
config["labor"]["manufacturing"]["hourly_rate"])
|
| 675 |
+
overhead_cost = config["overhead"]["manufacturing_per_unit"]
|
| 676 |
|
| 677 |
+
cost_col1, cost_col2 = st.columns(2)
|
|
|
|
| 678 |
|
| 679 |
+
with cost_col1:
|
| 680 |
+
st.subheader("Manufacturing Cost Waterfall")
|
| 681 |
|
| 682 |
+
fig_waterfall = go.Figure(go.Waterfall(
|
| 683 |
+
orientation="v",
|
| 684 |
+
measure=["relative", "relative", "relative", "total"],
|
| 685 |
+
x=["Materials", "Labor", "Overhead", "Total"],
|
| 686 |
+
y=[total_material_cost, labor_cost, overhead_cost, 0],
|
| 687 |
+
text=[f"€{total_material_cost:.0f}", f"€{labor_cost:.0f}",
|
| 688 |
+
f"€{overhead_cost:.0f}", f"€{total_material_cost + labor_cost + overhead_cost:.0f}"],
|
| 689 |
+
textposition="outside",
|
| 690 |
+
connector={"line": {"color": "rgb(63, 63, 63)"}},
|
| 691 |
+
increasing={"marker": {"color": "#3498db"}},
|
| 692 |
+
totals={"marker": {"color": "#2ecc71"}},
|
| 693 |
+
))
|
| 694 |
+
fig_waterfall.update_layout(title=f"Manufacturing Cost: €{mfg_cost:.0f}", yaxis_title="EUR", showlegend=False)
|
| 695 |
+
st.plotly_chart(fig_waterfall, use_container_width=True)
|
| 696 |
|
| 697 |
+
with cost_col2:
|
| 698 |
+
st.subheader("Bill of Materials")
|
| 699 |
+
mat_df = pd.DataFrame(material_data)
|
| 700 |
+
st.dataframe(mat_df.style.format({
|
| 701 |
+
"Weight (kg)": "{:.1f}", "Cost/kg": "€{:.2f}",
|
| 702 |
+
"Total Cost": "€{:.2f}", "Recycled %": "{:.0f}%",
|
| 703 |
+
}), use_container_width=True, hide_index=True)
|
| 704 |
|
| 705 |
+
# Summary
|
| 706 |
+
recyclable_weight = sum(m["Weight (kg)"] for m in material_data if m["Recyclable"] == "Yes")
|
| 707 |
+
recyclable_pct = recyclable_weight / total_weight * 100 if total_weight > 0 else 0
|
| 708 |
+
cost_savings = total_material_cost_virgin - total_material_cost
|
| 709 |
|
| 710 |
+
st.markdown("---")
|
| 711 |
+
st.subheader("Sourcing Summary")
|
| 712 |
+
|
| 713 |
+
summary_cols = st.columns(4)
|
| 714 |
+
with summary_cols[0]:
|
| 715 |
+
st.metric("Total Weight", f"{total_weight:.1f} kg")
|
| 716 |
+
with summary_cols[1]:
|
| 717 |
+
st.metric("Material Cost", f"€{total_material_cost:.2f}")
|
| 718 |
+
with summary_cols[2]:
|
| 719 |
+
st.metric("Recyclable", f"{recyclable_pct:.0f}%", help="By weight")
|
| 720 |
+
with summary_cols[3]:
|
| 721 |
+
if cost_savings > 0:
|
| 722 |
+
st.metric("Recycled Savings", f"€{cost_savings:.2f}", delta=f"{cost_savings/total_material_cost_virgin*100:.1f}% vs virgin")
|
| 723 |
+
else:
|
| 724 |
+
st.metric("Recycled Savings", "€0", help="Adjust sliders above")
|
| 725 |
|
| 726 |
+
if cost_savings > 0:
|
| 727 |
+
st.success(f"Using recycled content saves **€{cost_savings:.2f}** vs 100% virgin materials")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
|
| 729 |
+
else:
|
| 730 |
+
st.info("Detailed cost breakdown not available. Add `materials`, `labor`, and `overhead` sections to config.")
|
| 731 |
|
| 732 |
# ============================================================
|
| 733 |
+
# VIEW: MARKET
|
| 734 |
# ============================================================
|
| 735 |
+
elif selected_view == "Market":
|
| 736 |
+
st.header("Market: Customer Value & Positioning")
|
| 737 |
+
st.markdown("*For marketing, sales, and business development*")
|
| 738 |
|
| 739 |
# Customer Value Proposition
|
| 740 |
st.subheader("Customer Value Proposition")
|
|
|
|
| 757 |
st.subheader("Environmental Impact Story")
|
| 758 |
|
| 759 |
co2_per_unit = config["environmental"]["co2_manufacturing_kg"]
|
| 760 |
+
co2_saved_per_customer = co2_per_unit * (1 - 1/max_cycles)
|
| 761 |
|
| 762 |
env_cols = st.columns(3)
|
| 763 |
with env_cols[0]:
|
|
|
|
| 776 |
|
| 777 |
st.markdown("---")
|
| 778 |
|
| 779 |
+
# Price vs Value Analysis
|
| 780 |
st.subheader("Price vs Value Analysis")
|
| 781 |
|
| 782 |
price_range = list(range(15, 51, 5))
|
|
|
|
| 815 |
if retail_price > 0 and total_lease_cost < retail_price:
|
| 816 |
st.success(f"At €{lease_price}/mo, customers **save €{retail_price - total_lease_cost}** ({(retail_price - total_lease_cost)/retail_price*100:.0f}% less than buying)")
|
| 817 |
|
| 818 |
+
# Market Positioning
|
| 819 |
st.markdown("---")
|
| 820 |
+
st.subheader("Market Positioning")
|
| 821 |
|
| 822 |
st.markdown(f"""
|
| 823 |
| Message | Proof Point |
|
|
|
|
| 829 |
| **Circular economy** | One product serves {max_cycles} families |
|
| 830 |
""")
|
| 831 |
|
| 832 |
+
st.markdown("---")
|
| 833 |
+
st.subheader("Competitive Advantages")
|
| 834 |
+
|
| 835 |
+
st.markdown("""
|
| 836 |
+
| vs Traditional Retail | vs Other Rental |
|
| 837 |
+
|----------------------|-----------------|
|
| 838 |
+
| Lower total cost | Professional refurbishment |
|
| 839 |
+
| No resale hassle | Quality guarantee |
|
| 840 |
+
| Sustainable choice | Circular model transparency |
|
| 841 |
+
| Flexibility to upgrade | CTI-certified process |
|
| 842 |
+
""")
|
| 843 |
+
|
| 844 |
# ============================================================
|
| 845 |
# FOOTER
|
| 846 |
# ============================================================
|