mathiassandnes Claude Opus 4.5 commited on
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>

Files changed (1) hide show
  1. app.py +245 -323
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", "Operations", "Product", "Growth"],
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
- st.session_state.mfg_cost = st.sidebar.slider(
142
- "Manufacturing Cost", 50, 250, st.session_state.mfg_cost, step=10, format="€%d"
143
  )
144
- # Recycled content sliders will be in main content area
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 == "Product":
166
- st.sidebar.header("📊 Product Controls")
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 == "Growth":
181
- st.sidebar.header("📈 Growth Controls")
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"€{circ_profit_5k - lin_profit_5k:,.0f}", delta="vs linear model")
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": circular_profit - linear_profit,
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
- fig_scale = go.Figure()
289
- fig_scale.add_trace(go.Bar(
290
- x=scale_df["Fleet Label"], y=scale_df["Circular Profit"],
291
- name=f"Circular ({max_cycles} cycles)", marker_color="#2ecc71",
292
- text=[f"€{v:,.0f}" for v in scale_df["Circular Profit"]], textposition="outside",
 
 
 
 
293
  ))
294
- fig_scale.add_trace(go.Bar(
295
- x=scale_df["Fleet Label"], y=scale_df["Linear Profit"],
296
- name="Linear (sell once)", marker_color="#e74c3c",
297
- text=[f"€{v:,.0f}" for v in scale_df["Linear Profit"]], textposition="outside",
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(fig_scale, use_container_width=True)
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['Circular Profit']:,.0f}** in profit.
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: Material & Manufacturing Impact")
404
  st.markdown("*For product designers and sustainability officers*")
405
 
406
- has_materials = "materials" in config and "bom" in config.get("materials", {})
407
- has_labor = "labor" in config
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
- material_data.append({
461
- "Component": component.get("name", component_key),
462
- "Weight (kg)": weight,
463
- "Cost/kg": cost_per_kg,
464
- "Total Cost": cost,
465
- "Recycled %": recycled_pct * 100,
466
- "Recyclable": "Yes" if recyclable else "No",
467
- })
 
468
 
469
- labor_cost = config["labor"]["manufacturing"].get("total",
470
- config["labor"]["manufacturing"]["hours_per_unit"] *
471
- config["labor"]["manufacturing"]["hourly_rate"])
472
- overhead_cost = config["overhead"]["manufacturing_per_unit"]
473
 
474
- cost_col1, cost_col2 = st.columns(2)
 
 
475
 
476
- with cost_col1:
477
- st.subheader("Cost Waterfall")
 
 
 
478
 
479
- fig_waterfall = go.Figure(go.Waterfall(
480
- orientation="v",
481
- measure=["relative", "relative", "relative", "total"],
482
- x=["Materials", "Labor", "Overhead", "Total"],
483
- y=[total_material_cost, labor_cost, overhead_cost, 0],
484
- text=[f"€{total_material_cost:.0f}", f"€{labor_cost:.0f}",
485
- f"€{overhead_cost:.0f}", f"€{total_material_cost + labor_cost + overhead_cost:.0f}"],
486
- textposition="outside",
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
- with cost_col2:
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
- recyclable_weight = sum(m["Weight (kg)"] for m in material_data if m["Recyclable"] == "Yes")
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
- st.markdown(f"""
507
- **Summary:** Total Weight: **{total_weight:.1f} kg** | Material Cost: **€{total_material_cost:.2f}** | Recyclable: **{recyclable_pct:.0f}%**
508
- """)
509
 
510
- if cost_savings > 0:
511
- st.success(f"Recycled content saves **€{cost_savings:.2f}** vs 100% virgin ({cost_savings/total_material_cost_virgin*100:.1f}% reduction)")
 
 
 
512
 
513
- st.markdown("---")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
 
515
- # Material Composition Over Time
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
- final_original = lifecycle_df.iloc[-1]["Original Material %"]
564
- final_new = lifecycle_df.iloc[-1]["New Parts Added %"]
565
- st.info(f"After {max_cycles} cycles: **{final_original:.0f}%** original parts, **{final_new:.0f}%** replacement parts")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
- else:
568
- st.info("Detailed cost breakdown not available. Add `materials`, `labor`, and `overhead` sections to config.")
 
569
 
570
  # ============================================================
571
- # VIEW: OPERATIONS
572
  # ============================================================
573
- elif selected_view == "Operations":
574
- st.header("Operations: Logistics & Refurbishment Planning")
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: PRODUCT
683
  # ============================================================
684
- elif selected_view == "Product":
685
- st.header("Product: Lifecycle Economics & Pricing")
686
- st.markdown("*For product managers and pricing analysts*")
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
- # Per-Product Economics
705
- st.subheader("Per-Product Economics: Single Unit Over Multiple Cycles")
 
706
 
707
- product_data = []
708
- circular_cum_cost = 0
709
- circular_cum_revenue = 0
710
- linear_cum_cost = 0
711
- linear_cum_revenue = 0
712
 
713
- for cycle in range(1, max_cycles + 1):
714
- if cycle == 1:
715
- cycle_cost = new_unit_cost
716
- else:
717
- cycle_cost = refurb_unit_cost
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
- df_product = pd.DataFrame(product_data)
 
 
 
 
 
 
 
 
 
 
737
 
738
- chart_col1, chart_col2 = st.columns(2)
 
 
 
 
739
 
740
- with chart_col1:
741
- fig_profit = go.Figure()
742
- fig_profit.add_trace(go.Scatter(
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
- with chart_col2:
757
- fig_cost = go.Figure()
758
- fig_cost.add_trace(go.Bar(
759
- x=df_product["Cycle Label"], y=df_product["Circular: Cost per Use"],
760
- name="Circular (amortized)", marker_color="#2ecc71",
761
- text=[f"€{c:.0f}" for c in df_product["Circular: Cost per Use"]], textposition="outside",
762
- ))
763
- fig_cost.add_trace(go.Bar(
764
- x=df_product["Cycle Label"], y=df_product["Linear: Cost per Use"],
765
- name="Linear (always new)", marker_color="#e74c3c",
766
- text=[f"€{c:.0f}" for c in df_product["Linear: Cost per Use"]], textposition="outside",
767
- ))
768
- fig_cost.update_layout(title="Cost per Use Cycle (Amortized)", xaxis_title="Use Cycle", yaxis_title="Cost per Use (EUR)",
769
- barmode="group", legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01))
770
- st.plotly_chart(fig_cost, use_container_width=True)
771
 
772
- # Profit summary
773
- profit_advantage = df_product.iloc[-1]["Circular: Cumulative Profit"] - df_product.iloc[-1]["Linear: Cumulative Profit"]
774
- cost_savings = (max_cycles * new_unit_cost) - circular_cum_cost
775
 
776
- if profit_advantage > 0:
777
- st.success(f"**Circular advantage:** Save **€{cost_savings:.0f}** in costs, earn **€{profit_advantage:.0f}** more profit per product lifecycle")
 
 
 
 
 
 
778
 
779
- st.markdown("---")
 
 
 
780
 
781
- # Pricing Analysis
782
- st.subheader("Pricing Analysis: Finding the Sweet Spot")
783
 
784
- price_range = list(range(15, 51, 5))
785
- pricing_data = []
786
 
787
- for test_price in price_range:
788
- test_revenue = test_price * lease_term
789
- test_total_revenue = test_revenue * max_cycles
790
- circ_cost = new_unit_cost + (max_cycles - 1) * refurb_unit_cost
791
- circ_profit = test_total_revenue - circ_cost
 
 
 
 
 
 
 
 
 
792
 
793
- pricing_data.append({
794
- "Monthly Price": test_price,
795
- "Revenue per Cycle": test_revenue,
796
- "Profit per Product": circ_profit,
797
- })
 
 
798
 
799
- pricing_df = pd.DataFrame(pricing_data)
 
 
 
800
 
801
- fig_price_profit = go.Figure()
802
- fig_price_profit.add_trace(go.Scatter(
803
- x=pricing_df["Monthly Price"], y=pricing_df["Profit per Product"],
804
- mode="lines+markers", name="Profit per Product",
805
- line=dict(color="#2ecc71", width=3), marker=dict(size=10),
806
- ))
 
 
 
 
 
 
 
 
 
807
 
808
- circ_cost_current = new_unit_cost + (max_cycles - 1) * refurb_unit_cost
809
- current_profit = (lease_price * lease_term * max_cycles) - circ_cost_current
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
- break_even_price = circ_cost_current / (lease_term * max_cycles)
820
- st.info(f"**Break-even price:** €{break_even_price:.0f}/month (over {max_cycles} cycles)")
821
 
822
  # ============================================================
823
- # VIEW: GROWTH
824
  # ============================================================
825
- elif selected_view == "Growth":
826
- st.header("Growth: Customer Value & Marketing Story")
827
- st.markdown("*For marketing, sales, and customer success*")
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) # Approximate savings per customer
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
- # Customer Value vs Retail
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
- # Marketing messages
909
  st.markdown("---")
910
- st.subheader("Key Marketing Messages")
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
  # ============================================================