mathiassandnes Claude Opus 4.5 commited on
Commit
cbed23f
·
1 Parent(s): 2c64567

Redesign Strategy view layout and apply LiiS brand theme

Browse files

- Remove teal gradient header, use plain title
- Replace dark hero banner with clean card rows (Break Even, Margin, Revenue, Cost)
- Remove status badge pill
- Stack revenue/cost cards vertically with per-product and portfolio levels
- Replace Fleet Efficiency bar chart with line chart (customers served vs products manufactured)
- Apply LiiS light palette (COLORS dict, liis Plotly template) across all views
- Fix undefined margin_color reference in Revenue & Cost chart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +356 -242
app.py CHANGED
@@ -11,10 +11,46 @@ import plotly.graph_objects as go
11
  import pandas as pd
12
  from pathlib import Path
13
  import yaml
14
- import plotly.io as pio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- # Set Plotly to dark theme
17
- pio.templates.default = "plotly_dark"
 
 
 
 
18
 
19
  # Page config
20
  st.set_page_config(
@@ -23,34 +59,20 @@ st.set_page_config(
23
  layout="wide",
24
  )
25
 
26
- # Custom CSS
27
- st.markdown("""
28
  <style>
29
- .main-header {
30
- background: linear-gradient(135deg, #1a472a 0%, #2ecc71 100%);
31
- padding: 1.5rem 2rem;
32
- border-radius: 12px;
33
- margin-bottom: 1.5rem;
34
- box-shadow: 0 4px 20px rgba(46, 204, 113, 0.3);
35
- }
36
- .main-header h1 { color: white; margin: 0; font-size: 2rem; }
37
- .main-header p { color: rgba(255,255,255,0.9); margin: 0.5rem 0 0 0; }
38
- [data-testid="stMetricValue"] { font-size: 1.8rem; }
39
- .stMarkdown h2 { border-bottom: 2px solid #2ecc71; padding-bottom: 0.5rem; }
40
- [data-testid="stImage"] { border-radius: 10px; background: #1a1f2e; padding: 10px; }
41
- .stSuccess, .stInfo { border-radius: 8px; }
42
- .stDataFrame { border-radius: 8px; }
43
- .streamlit-expanderHeader { border-radius: 8px; }
44
  </style>
45
  """, unsafe_allow_html=True)
46
 
47
  # Header
48
- st.markdown("""
49
- <div class="main-header">
50
- <h1>♻️ LiiS Circular Economy Simulator</h1>
51
- <p>Explore how product configurations affect CTI metrics and profitability</p>
52
- </div>
53
- """, unsafe_allow_html=True)
54
 
55
 
56
  # Load product configs
@@ -78,6 +100,9 @@ selected_product = st.sidebar.selectbox(
78
  )
79
  config = products[selected_product]
80
 
 
 
 
81
  st.sidebar.markdown("---")
82
  st.sidebar.header("View")
83
  selected_view = st.sidebar.radio(
@@ -125,11 +150,11 @@ if "max_cycles_override" not in st.session_state:
125
 
126
  # Strategy view session state (simplified C-Level view)
127
  if "strategy_portfolio" not in st.session_state:
128
- st.session_state.strategy_portfolio = 300
129
  if "strategy_monthly_lease" not in st.session_state:
130
- st.session_state.strategy_monthly_lease = 250
131
  if "strategy_mfg_cost" not in st.session_state:
132
- st.session_state.strategy_mfg_cost = 2500
133
  if "strategy_reuse_cycles" not in st.session_state:
134
  st.session_state.strategy_reuse_cycles = 3
135
  if "strategy_customer_base" not in st.session_state:
@@ -140,21 +165,21 @@ if selected_view == "Strategy":
140
  st.sidebar.header("💼 Portfolio Parameters")
141
  st.sidebar.caption("Adjust to see impact on profitability")
142
 
143
- st.session_state.strategy_portfolio = st.sidebar.slider(
144
- "Product Portfolio", 50, 1000, st.session_state.strategy_portfolio, step=50,
145
- help="Number of units in your fleet"
146
  )
147
- st.session_state.strategy_monthly_lease = st.sidebar.slider(
148
- "Monthly Lease Price", 250, 650, st.session_state.strategy_monthly_lease, step=25,
149
- format="%d NOK", help="What customers pay per month"
150
  )
151
- st.session_state.strategy_mfg_cost = st.sidebar.slider(
152
- "Manufacturing Cost", 2500, 7500, st.session_state.strategy_mfg_cost, step=250,
153
- format="%d NOK", help="Cost to produce one unit"
154
  )
155
- st.session_state.strategy_reuse_cycles = st.sidebar.slider(
156
- "Reuse Cycles", 2, 10, st.session_state.strategy_reuse_cycles,
157
- help="How many families use each product"
158
  )
159
 
160
  # Keep EUR values in sync for other views
@@ -233,13 +258,13 @@ operating_margin = (lifecycle_profit / lifecycle_revenue) * 100 if lifecycle_rev
233
 
234
  # Model status thresholds
235
  if operating_margin >= 30:
236
- model_status, status_color = "Profitable", "#2ecc71"
237
  elif operating_margin >= 15:
238
- model_status, status_color = "Healthy", "#f1c40f"
239
  elif operating_margin >= 0:
240
- model_status, status_color = "Break-even", "#e67e22"
241
  else:
242
- model_status, status_color = "Loss", "#e74c3c"
243
 
244
 
245
  # ============================================================
@@ -266,7 +291,7 @@ if selected_view != "Strategy":
266
  <span style="background-color: {status_color}; color: white; padding: 0.3rem 0.8rem;
267
  border-radius: 20px; font-weight: 600; font-size: 0.85rem;">
268
  Model: {model_status}</span>
269
- <span style="color: #888; margin-left: 1rem; font-size: 0.85rem;">
270
  {operating_margin:.1f}% margin across {max_cycles} cycles</span>
271
  ''', unsafe_allow_html=True)
272
 
@@ -293,12 +318,32 @@ if selected_view != "Strategy":
293
 
294
  st.markdown("---")
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  # ============================================================
297
  # VIEW: STRATEGY (C-Level)
298
  # ============================================================
299
  if selected_view == "Strategy":
300
  st.header("Strategy: Portfolio Economics")
301
- st.markdown("*C-Level View — All values in NOK*")
302
 
303
  # Get Strategy slider values
304
  portfolio_size = st.session_state.strategy_portfolio
@@ -316,236 +361,305 @@ if selected_view == "Strategy":
316
  # Per-product economics
317
  revenue_per_product = monthly_lease * lease_term_months * reuse_cycles
318
  total_cost_per_product = s_mfg_cost + (logistics_per_cycle + repair_per_cycle) * reuse_cycles
319
- blended_cost_per_product = total_cost_per_product / reuse_cycles
320
  profit_per_product = revenue_per_product - total_cost_per_product
321
  operating_margin_strat = (profit_per_product / revenue_per_product) * 100 if revenue_per_product > 0 else 0
322
 
323
  # Portfolio level
324
  revenue_portfolio = portfolio_size * revenue_per_product
325
  profit_portfolio = portfolio_size * profit_per_product
326
- blended_cost_portfolio = portfolio_size * blended_cost_per_product
327
 
328
- # Break-even calculation (months)
329
- monthly_revenue_portfolio = portfolio_size * monthly_lease
330
- monthly_cost_portfolio = portfolio_size * (repair_per_cycle / lease_term_months)
331
- initial_investment = portfolio_size * s_mfg_cost
332
-
333
- breakeven_month = None
334
- cumulative = -initial_investment
335
- for month in range(1, 121):
336
- if month % lease_term_months == 0:
337
- cumulative -= portfolio_size * logistics_per_cycle
338
- cumulative += monthly_revenue_portfolio - monthly_cost_portfolio
339
- if cumulative >= 0 and breakeven_month is None:
340
- breakeven_month = month
341
- break
342
-
343
- # Linear model comparison
344
- linear_margin = 0.20
345
- linear_breakeven_months = 24 # Typical retail payback
346
 
347
  # ============================================================
348
- # TIER 1: PRIMARY METRICS (Hero)
349
  # ============================================================
350
- st.markdown('<p style="color: #888; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px;">Primary Metrics</p>', unsafe_allow_html=True)
351
 
352
- hero_col1, hero_col2 = st.columns(2)
 
 
 
 
 
 
353
 
354
- with hero_col1:
355
- be_display = f"{breakeven_month}" if breakeven_month else "> 120"
356
  st.markdown(f"""
357
- <div style="background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); padding: 1.5rem; border-radius: 12px; border-left: 4px solid #2ecc71;">
358
- <p style="color: #888; margin: 0; font-size: 0.9rem;">BREAK EVEN</p>
359
- <h2 style="color: #2ecc71; margin: 0.3rem 0; font-size: 2.5rem;">{be_display} MONTHS</h2>
360
- <p style="color: #666; margin: 0; font-size: 0.85rem;">For entire portfolio of {portfolio_size:,} units</p>
361
  </div>
362
  """, unsafe_allow_html=True)
363
-
364
- with hero_col2:
365
- profit_color = "#2ecc71" if profit_portfolio > 0 else "#e74c3c"
366
  st.markdown(f"""
367
- <div style="background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); padding: 1.5rem; border-radius: 12px; border-left: 4px solid {profit_color};">
368
- <p style="color: #888; margin: 0; font-size: 0.9rem;">TOTAL PORTFOLIO PROFIT</p>
369
- <h2 style="color: {profit_color}; margin: 0.3rem 0; font-size: 2.5rem;">{profit_portfolio:,.0f} NOK</h2>
370
- <p style="color: #666; margin: 0; font-size: 0.85rem;">Net revenue across {portfolio_size:,} units over {reuse_cycles} cycles</p>
371
  </div>
372
  """, unsafe_allow_html=True)
373
 
374
- st.markdown("---")
375
 
376
  # ============================================================
377
- # TIER 2: SUPPORTING METRICS
378
  # ============================================================
379
- st.markdown('<p style="color: #888; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px;">Unit Economics</p>', unsafe_allow_html=True)
380
-
381
- metric_col1, metric_col2, metric_col3 = st.columns(3)
382
-
383
- with metric_col1:
384
- st.metric("Revenue per Product", f"{revenue_per_product:,.0f} NOK",
385
- delta=f"{monthly_lease} × {lease_term_months}mo × {reuse_cycles} cycles")
386
- st.metric("Revenue per Portfolio", f"{revenue_portfolio:,.0f} NOK",
387
- delta=f"{portfolio_size:,} units")
388
 
389
- with metric_col2:
390
- st.metric("Blended Cost per Product", f"{blended_cost_per_product:,.0f} NOK",
391
- delta=f"Total {total_cost_per_product:,.0f} ÷ {reuse_cycles} cycles")
392
- st.metric("Blended Cost per Portfolio", f"{blended_cost_portfolio:,.0f} NOK",
393
- delta=f"{portfolio_size:,} units")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
- with metric_col3:
396
- margin_delta = "healthy" if operating_margin_strat >= 50 else "review pricing"
397
- st.metric("Operating Margin", f"{operating_margin_strat:.0f}%", delta=margin_delta)
398
- st.metric("Profit per Product", f"{profit_per_product:,.0f} NOK", delta="lifetime profit")
399
 
400
  st.markdown("---")
401
 
402
  # ============================================================
403
- # CHART 1: Break Even Comparison
404
  # ============================================================
405
- st.markdown('<p style="color: #888; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px;">Model Comparison</p>', unsafe_allow_html=True)
406
- st.subheader("Break Even: Circular vs Linear Model")
407
 
408
- comp_col1, comp_col2 = st.columns(2)
 
 
 
 
 
 
409
 
410
- with comp_col1:
411
- st.markdown(f"""
412
- <div style="background: #2c3e50; padding: 1.5rem; border-radius: 12px; text-align: center;">
413
- <p style="color: #888; margin: 0;">LINEAR MODEL</p>
414
- <h2 style="color: #e74c3c; margin: 0.5rem 0;">{linear_breakeven_months} MONTHS</h2>
415
- <p style="color: #888; margin: 0;">{linear_margin*100:.0f}% margin</p>
416
- <p style="color: #666; font-size: 0.8rem; margin-top: 0.5rem;">Sell once, done</p>
417
- </div>
418
- """, unsafe_allow_html=True)
419
 
420
- with comp_col2:
421
- be_text = f"{breakeven_month}" if breakeven_month else "> 120"
422
- st.markdown(f"""
423
- <div style="background: #2c3e50; padding: 1.5rem; border-radius: 12px; text-align: center; border: 2px solid #2ecc71;">
424
- <p style="color: #888; margin: 0;">CIRCULAR MODEL</p>
425
- <h2 style="color: #2ecc71; margin: 0.5rem 0;">{be_text} MONTHS</h2>
426
- <p style="color: #2ecc71; margin: 0;">{operating_margin_strat:.0f}% margin</p>
427
- <p style="color: #666; font-size: 0.8rem; margin-top: 0.5rem;">Reuse {reuse_cycles}× per product</p>
428
- </div>
429
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
 
431
  st.markdown("---")
432
 
433
  # ============================================================
434
- # CHART 2: Blended Cost vs Revenue Across Cycles
435
  # ============================================================
436
- st.subheader("Cost & Revenue Trajectory by Reuse Cycles")
437
-
438
- cycles_range = list(range(2, 11))
439
- chart_data = []
440
- for c in cycles_range:
441
- rev = monthly_lease * lease_term_months * c
442
- total_cost = s_mfg_cost + (logistics_per_cycle + repair_per_cycle) * c
443
- blended = total_cost / c
444
- chart_data.append({"Cycles": c, "Revenue": rev, "Total Cost": total_cost, "Blended Cost": blended})
445
-
446
- chart_df = pd.DataFrame(chart_data)
447
-
448
- fig_cycles = go.Figure()
449
- fig_cycles.add_trace(go.Scatter(
450
- x=chart_df["Cycles"], y=chart_df["Revenue"],
451
- mode="lines+markers", name="Revenue per Product",
452
- line=dict(color="#2ecc71", width=3), marker=dict(size=8),
453
- ))
454
- fig_cycles.add_trace(go.Scatter(
455
- x=chart_df["Cycles"], y=chart_df["Blended Cost"],
456
- mode="lines+markers", name="Blended Cost per Product",
457
- line=dict(color="#e74c3c", width=3), marker=dict(size=8),
458
- ))
459
- fig_cycles.add_trace(go.Scatter(
460
- x=chart_df["Cycles"], y=chart_df["Total Cost"],
461
- mode="lines+markers", name="Total Cost per Product",
462
- line=dict(color="#e74c3c", width=2, dash="dot"), marker=dict(size=6), opacity=0.6,
463
  ))
464
- fig_cycles.add_vline(x=reuse_cycles, line_dash="dash", line_color="#f1c40f",
465
- annotation_text=f"Current: {reuse_cycles} cycles", annotation_position="top")
466
- fig_cycles.update_layout(
467
- xaxis_title="Reuse Cycles", yaxis_title="NOK per Product",
468
- legend=dict(orientation="h", yanchor="bottom", y=1.02), hovermode="x unified",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  )
470
- st.plotly_chart(fig_cycles, use_container_width=True)
 
 
 
 
471
 
472
- st.info(f"**Key insight:** More cycles = lower blended cost = higher profit. At {reuse_cycles} cycles, blended cost is {blended_cost_per_product:,.0f} NOK vs {s_mfg_cost:,} NOK manufacturing cost.")
 
473
 
474
  st.markdown("---")
475
 
476
  # ============================================================
477
- # TIER 3: AT SCALE
478
  # ============================================================
479
- st.markdown('<p style="color: #888; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px;">Scale Projection</p>', unsafe_allow_html=True)
480
- st.subheader("At Scale: Customer Base Projection")
481
-
482
- scale_col1, scale_col2 = st.columns([1, 2])
483
-
484
- with scale_col1:
485
- customer_base = st.slider("Customer Base", 500, 10000, st.session_state.strategy_customer_base, step=100,
486
- help="Target number of active customers", key="customer_base_slider")
487
- st.session_state.strategy_customer_base = customer_base
488
-
489
- utilization_rate = 0.80
490
- fleet_needed = int(customer_base / utilization_rate)
491
- annual_revenue_scale = customer_base * monthly_lease * lease_term_months
492
- total_cost_scale = fleet_needed * blended_cost_per_product
493
- annual_profit_scale = annual_revenue_scale - total_cost_scale
494
-
495
- st.metric("Fleet Size Needed", f"{fleet_needed:,} units", delta=f"{utilization_rate*100:.0f}% utilization")
496
- st.metric("Annual Revenue", f"{annual_revenue_scale:,.0f} NOK", delta=f"{customer_base:,} customers")
497
- st.metric("Total Blended Cost", f"{total_cost_scale:,.0f} NOK", delta=f"{fleet_needed:,} units")
498
- st.metric("Annual Profit", f"{annual_profit_scale:,.0f} NOK",
499
- delta="profit" if annual_profit_scale > 0 else "loss")
500
-
501
- with scale_col2:
502
- st.markdown("**5-Year Cumulative Profit Trajectory**")
503
-
504
- months = list(range(0, 61))
505
- cumulative_profit = []
506
- cumulative = -fleet_needed * s_mfg_cost
507
- current_util = utilization_rate
508
-
509
- for month in months:
510
- if month == 0:
 
 
 
 
 
 
 
 
 
 
 
 
511
  cumulative_profit.append(cumulative)
512
- continue
513
- if month > 0 and month % lease_term_months == 0:
514
- current_util = max(0.5, current_util - 0.10)
515
- cumulative -= fleet_needed * logistics_per_cycle
516
- active_customers = int(fleet_needed * current_util)
517
- monthly_rev = active_customers * monthly_lease
518
- monthly_cost = active_customers * (repair_per_cycle / lease_term_months)
519
- cumulative += monthly_rev - monthly_cost
520
- cumulative_profit.append(cumulative)
521
-
522
- fig_trajectory = go.Figure()
523
- fig_trajectory.add_trace(go.Scatter(
524
- x=months, y=cumulative_profit, mode="lines", name="Cumulative Profit",
525
- line=dict(color="#2ecc71", width=3), fill="tozeroy", fillcolor="rgba(46, 204, 113, 0.2)",
526
- ))
527
- fig_trajectory.add_hline(y=0, line_color="rgba(255,255,255,0.5)", line_width=1)
528
 
529
- for cycle_month in range(lease_term_months, 61, lease_term_months):
530
- fig_trajectory.add_vline(x=cycle_month, line_dash="dot", line_color="rgba(255,255,255,0.3)")
 
 
 
 
531
 
532
- for i, cp in enumerate(cumulative_profit):
533
- if cp >= 0 and i > 0:
534
- fig_trajectory.add_trace(go.Scatter(
535
- x=[i], y=[cp], mode="markers+text", name="Breakeven",
536
- marker=dict(size=12, color="#f1c40f", symbol="star"),
537
- text=["Breakeven"], textposition="top center",
538
- ))
539
- break
540
 
541
- fig_trajectory.update_layout(
542
- xaxis_title="Months", yaxis_title="Cumulative Profit (NOK)",
543
- showlegend=False, yaxis=dict(tickformat=","),
544
- )
545
- st.plotly_chart(fig_trajectory, use_container_width=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
 
547
- final_profit = cumulative_profit[-1]
548
- st.caption(f"5-year projection with {fleet_needed:,} unit fleet. Final cumulative profit: **{final_profit:,.0f} NOK**")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
 
550
  # ============================================================
551
  # VIEW: DESIGN
@@ -581,7 +695,7 @@ elif selected_view == "Design":
581
 
582
  fig_grades = px.bar(
583
  grades_df, x="Grade", y="Percentage", color="Grade",
584
- color_discrete_map={"A (Clean only)": "#2ecc71", "B (Repair needed)": "#f1c40f", "C (Scrap)": "#e74c3c"},
585
  title="Return Grade Distribution", text="Percentage",
586
  )
587
  fig_grades.update_traces(texttemplate='%{text:.0f}%', textposition='outside')
@@ -627,13 +741,13 @@ elif selected_view == "Design":
627
  fig_materials.add_trace(go.Scatter(
628
  x=lifecycle_df["Cycle"], y=lifecycle_df["Original Material %"],
629
  name="Original Parts", mode="lines", fill="tozeroy",
630
- line=dict(color="#3498db", width=0), fillcolor="rgba(52, 152, 219, 0.7)",
631
  ))
632
  fig_materials.add_trace(go.Scatter(
633
  x=lifecycle_df["Cycle"],
634
  y=lifecycle_df["Original Material %"] + lifecycle_df["New Parts Added %"],
635
  name="Replacement Parts", mode="lines", fill="tonexty",
636
- line=dict(color="#2ecc71", width=0), fillcolor="rgba(46, 204, 113, 0.7)",
637
  ))
638
  fig_materials.update_layout(
639
  yaxis_title="Material Composition (%)", yaxis_range=[0, 120],
@@ -681,7 +795,7 @@ elif selected_view == "Logistics":
681
 
682
  fig_grades = px.bar(
683
  grades_df, x="Grade", y="Percentage", color="Grade",
684
- color_discrete_map={"A (Reusable)": "#2ecc71", "B (Refurbish)": "#f1c40f", "C (Scrap)": "#e74c3c"},
685
  title="Return Grade Distribution", text="Percentage",
686
  )
687
  fig_grades.update_traces(texttemplate='%{text:.0f}%', textposition='outside')
@@ -702,11 +816,11 @@ elif selected_view == "Logistics":
702
 
703
  fig_cycle = go.Figure()
704
  fig_cycle.add_trace(go.Bar(name="Manufacturing", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Manufacturing"],
705
- marker_color="#3498db", text=[f"€{v:.0f}" if v > 0 else "" for v in df_cycle_costs["Manufacturing"]], textposition="inside"))
706
  fig_cycle.add_trace(go.Bar(name="Refurbishment", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Refurbishment"],
707
- marker_color="#2ecc71", text=[f"€{v:.0f}" if v > 0 else "" for v in df_cycle_costs["Refurbishment"]], textposition="inside"))
708
  fig_cycle.add_trace(go.Bar(name="Logistics", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Logistics"],
709
- marker_color="#9b59b6", text=[f"€{v:.0f}" for v in df_cycle_costs["Logistics"]], textposition="inside"))
710
  fig_cycle.update_layout(barmode="stack", yaxis_title="Cost (EUR)", legend=dict(orientation="h", yanchor="bottom", y=1.02))
711
  st.plotly_chart(fig_cycle, use_container_width=True)
712
 
@@ -741,12 +855,12 @@ elif selected_view == "Logistics":
741
  fig_products = go.Figure()
742
  fig_products.add_trace(go.Bar(
743
  x=lifecycle_df["Cycle"], y=lifecycle_df["Products in Service"],
744
- name="In Service", marker_color="#2ecc71",
745
  text=[f"{v:.0f}" for v in lifecycle_df["Products in Service"]], textposition="outside",
746
  ))
747
  fig_products.add_trace(go.Bar(
748
  x=lifecycle_df["Cycle"], y=lifecycle_df["Products Lost"],
749
- name="Lost (non-returns + scrap)", marker_color="#e74c3c",
750
  text=[f"{v:.0f}" if v > 0 else "" for v in lifecycle_df["Products Lost"]], textposition="outside",
751
  ))
752
  fig_products.update_layout(
@@ -850,9 +964,9 @@ elif selected_view == "Sourcing":
850
  text=[f"€{total_material_cost:.0f}", f"€{labor_cost:.0f}",
851
  f"€{overhead_cost:.0f}", f"€{total_material_cost + labor_cost + overhead_cost:.0f}"],
852
  textposition="outside",
853
- connector={"line": {"color": "rgb(63, 63, 63)"}},
854
- increasing={"marker": {"color": "#3498db"}},
855
- totals={"marker": {"color": "#2ecc71"}},
856
  ))
857
  fig_waterfall.update_layout(title=f"Manufacturing Cost: €{mfg_cost:.0f}", yaxis_title="EUR", showlegend=False)
858
  st.plotly_chart(fig_waterfall, use_container_width=True)
@@ -960,16 +1074,16 @@ elif selected_view == "Market":
960
  fig_customer = go.Figure()
961
  fig_customer.add_trace(go.Bar(
962
  x=pricing_df["Monthly Price"], y=pricing_df["Total Lease Cost"],
963
- name="Lease Cost (1 term)", marker_color="#3498db",
964
  ))
965
 
966
  if retail_price > 0:
967
- fig_customer.add_hline(y=retail_price, line_dash="dash", line_color="#e74c3c", line_width=2,
968
  annotation_text=f"Retail: €{retail_price}", annotation_position="right")
969
 
970
  fig_customer.add_trace(go.Scatter(
971
  x=[lease_price], y=[lease_price * lease_term], mode="markers",
972
- name=f"Current (€{lease_price}/mo)", marker=dict(size=14, color="#e74c3c", symbol="star"),
973
  ))
974
  fig_customer.update_layout(title="Customer Cost vs Retail Price", xaxis_title="Monthly Lease Price (EUR)",
975
  yaxis_title="Total Customer Cost (EUR)")
 
11
  import pandas as pd
12
  from pathlib import Path
13
  import yaml
14
+ def sfmt(n, decimals=0):
15
+ """Format number with space as thousands separator."""
16
+ return f"{n:,.{decimals}f}".replace(",", " ")
17
+
18
+ # ── LiiS Brand Palette ──
19
+ COLORS = {
20
+ "bg": "#f9fcf3",
21
+ "surface": "#ffffff",
22
+ "surface_alt": "#eef5e6",
23
+ "primary": "#124b5f",
24
+ "secondary": "#785964",
25
+ "text": "#1a1a2e",
26
+ "text_muted": "#4a5568",
27
+ "text_subtle": "#718096",
28
+ "border": "#d1ddc8",
29
+ "positive": "#124b5f",
30
+ "negative": "#a0444a",
31
+ "warning": "#c87941",
32
+ "info": "#4a7c9b",
33
+ }
34
+ CHART_COLORS = ["#124b5f", "#785964", "#2a7f62", "#c87941", "#4a7c9b", "#a0444a"]
35
+
36
+ # LiiS light Plotly theme
37
+ LIIS_LAYOUT = dict(
38
+ paper_bgcolor=COLORS["bg"],
39
+ plot_bgcolor=COLORS["surface"],
40
+ font=dict(family="Futura, Century Gothic, Arial, sans-serif", color=COLORS["text"], size=14),
41
+ xaxis=dict(gridcolor="#e2e8f0", zerolinecolor=COLORS["border"]),
42
+ yaxis=dict(gridcolor="#e2e8f0", zerolinecolor=COLORS["border"]),
43
+ colorway=CHART_COLORS,
44
+ legend=dict(orientation="h", yanchor="bottom", y=1.02),
45
+ hovermode="x unified",
46
+ )
47
 
48
+ import plotly.io as pio
49
+ liis_template = pio.templates["plotly"]
50
+ liis_template.layout.update(LIIS_LAYOUT)
51
+ liis_template.layout.separators = ", " # decimal=comma-free, thousands=space
52
+ pio.templates["liis"] = liis_template
53
+ pio.templates.default = "liis"
54
 
55
  # Page config
56
  st.set_page_config(
 
59
  layout="wide",
60
  )
61
 
62
+ # Custom CSS — LiiS brand
63
+ st.markdown(f"""
64
  <style>
65
+ [data-testid="stMetricValue"] {{ font-size: 1.8rem; color: {COLORS['primary']}; }}
66
+ .stMarkdown h2 {{ border-bottom: 2px solid {COLORS['primary']}; padding-bottom: 0.5rem; }}
67
+ [data-testid="stImage"] {{ border-radius: 10px; }}
68
+ .stSuccess, .stInfo {{ border-radius: 8px; }}
69
+ .stDataFrame {{ border-radius: 8px; }}
70
+ .streamlit-expanderHeader {{ border-radius: 8px; }}
 
 
 
 
 
 
 
 
 
71
  </style>
72
  """, unsafe_allow_html=True)
73
 
74
  # Header
75
+ st.title("LiiS Circular Economy Simulator")
 
 
 
 
 
76
 
77
 
78
  # Load product configs
 
100
  )
101
  config = products[selected_product]
102
 
103
+ if "image_url" in products[selected_product].get("product", {}):
104
+ st.sidebar.image(products[selected_product]["product"]["image_url"], use_container_width=True)
105
+
106
  st.sidebar.markdown("---")
107
  st.sidebar.header("View")
108
  selected_view = st.sidebar.radio(
 
150
 
151
  # Strategy view session state (simplified C-Level view)
152
  if "strategy_portfolio" not in st.session_state:
153
+ st.session_state.strategy_portfolio = 100
154
  if "strategy_monthly_lease" not in st.session_state:
155
+ st.session_state.strategy_monthly_lease = 100
156
  if "strategy_mfg_cost" not in st.session_state:
157
+ st.session_state.strategy_mfg_cost = 1500
158
  if "strategy_reuse_cycles" not in st.session_state:
159
  st.session_state.strategy_reuse_cycles = 3
160
  if "strategy_customer_base" not in st.session_state:
 
165
  st.sidebar.header("💼 Portfolio Parameters")
166
  st.sidebar.caption("Adjust to see impact on profitability")
167
 
168
+ st.sidebar.slider(
169
+ "Product Portfolio", 100, 10000, step=50,
170
+ key="strategy_portfolio", help="Number of units in your fleet"
171
  )
172
+ st.sidebar.slider(
173
+ "Monthly Lease Price", 0, 500, step=25,
174
+ format="%d NOK", key="strategy_monthly_lease", help="What customers pay per month"
175
  )
176
+ st.sidebar.slider(
177
+ "Manufacturing Cost", 0, 7500, step=250,
178
+ format="%d NOK", key="strategy_mfg_cost", help="Cost to produce one unit"
179
  )
180
+ st.sidebar.slider(
181
+ "Reuse Cycles", 1, 10,
182
+ key="strategy_reuse_cycles", help="How many families use each product"
183
  )
184
 
185
  # Keep EUR values in sync for other views
 
258
 
259
  # Model status thresholds
260
  if operating_margin >= 30:
261
+ model_status, status_color = "Profitable", COLORS["positive"]
262
  elif operating_margin >= 15:
263
+ model_status, status_color = "Healthy", COLORS["warning"]
264
  elif operating_margin >= 0:
265
+ model_status, status_color = "Break-even", COLORS["secondary"]
266
  else:
267
+ model_status, status_color = "Loss", COLORS["negative"]
268
 
269
 
270
  # ============================================================
 
291
  <span style="background-color: {status_color}; color: white; padding: 0.3rem 0.8rem;
292
  border-radius: 20px; font-weight: 600; font-size: 0.85rem;">
293
  Model: {model_status}</span>
294
+ <span style="color: {COLORS['text_subtle']}; margin-left: 1rem; font-size: 0.85rem;">
295
  {operating_margin:.1f}% margin across {max_cycles} cycles</span>
296
  ''', unsafe_allow_html=True)
297
 
 
318
 
319
  st.markdown("---")
320
 
321
+ # ============================================================
322
+ # HELPER: Break-even calculation
323
+ # ============================================================
324
+ def calculate_breakeven_months(portfolio, monthly_lease, mfg_cost_nok, lease_term_mo=12, logistics_nok=150, repair_nok=200, max_months=120):
325
+ """Return (breakeven_month, cumulative_list) for given parameters."""
326
+ monthly_revenue = portfolio * monthly_lease
327
+ monthly_repair = portfolio * (repair_nok / lease_term_mo)
328
+ initial = -portfolio * mfg_cost_nok
329
+ cumulative = initial
330
+ cumulative_list = [cumulative]
331
+ breakeven = None
332
+ for month in range(1, max_months + 1):
333
+ if month % lease_term_mo == 0:
334
+ cumulative -= portfolio * logistics_nok
335
+ cumulative += monthly_revenue - monthly_repair
336
+ cumulative_list.append(cumulative)
337
+ if cumulative >= 0 and breakeven is None:
338
+ breakeven = month
339
+ return breakeven, cumulative_list
340
+
341
+
342
  # ============================================================
343
  # VIEW: STRATEGY (C-Level)
344
  # ============================================================
345
  if selected_view == "Strategy":
346
  st.header("Strategy: Portfolio Economics")
 
347
 
348
  # Get Strategy slider values
349
  portfolio_size = st.session_state.strategy_portfolio
 
361
  # Per-product economics
362
  revenue_per_product = monthly_lease * lease_term_months * reuse_cycles
363
  total_cost_per_product = s_mfg_cost + (logistics_per_cycle + repair_per_cycle) * reuse_cycles
364
+ blended_cost_per_product = total_cost_per_product / reuse_cycles if reuse_cycles > 0 else total_cost_per_product
365
  profit_per_product = revenue_per_product - total_cost_per_product
366
  operating_margin_strat = (profit_per_product / revenue_per_product) * 100 if revenue_per_product > 0 else 0
367
 
368
  # Portfolio level
369
  revenue_portfolio = portfolio_size * revenue_per_product
370
  profit_portfolio = portfolio_size * profit_per_product
 
371
 
372
+ # Break-even using helper
373
+ breakeven_month, breakeven_cumulative = calculate_breakeven_months(
374
+ portfolio_size, monthly_lease, s_mfg_cost,
375
+ lease_term_months, logistics_per_cycle, repair_per_cycle,
376
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
  # ============================================================
379
+ # TIER 1: Break Even + Operating Margin
380
  # ============================================================
381
+ be_display = f"{breakeven_month}" if breakeven_month else "> 120"
382
 
383
+ # Color-code margin card border
384
+ if operating_margin_strat >= 30:
385
+ margin_border = COLORS["positive"]
386
+ elif operating_margin_strat >= 15:
387
+ margin_border = COLORS["warning"]
388
+ else:
389
+ margin_border = COLORS["negative"]
390
 
391
+ t1_left, t1_right = st.columns(2)
392
+ with t1_left:
393
  st.markdown(f"""
394
+ <div style="text-align: center; padding: 1.2rem; background: {COLORS['surface']}; border-radius: 10px; border: 1px solid {COLORS['border']}; border-top: 3px solid {COLORS['warning']};">
395
+ <p style="color: {COLORS['text_muted']}; margin: 0; font-size: 0.95rem;">Break Even Period</p>
396
+ <p style="color: {COLORS['warning']}; margin: 0; font-size: 2.2rem; font-weight: 700;">{be_display} <span style="font-size: 1rem; font-weight: 400; color: {COLORS['text_subtle']};">months</span></p>
 
397
  </div>
398
  """, unsafe_allow_html=True)
399
+ with t1_right:
 
 
400
  st.markdown(f"""
401
+ <div style="text-align: center; padding: 1.2rem; background: {COLORS['surface']}; border-radius: 10px; border: 1px solid {COLORS['border']}; border-top: 3px solid {margin_border};">
402
+ <p style="color: {COLORS['text_muted']}; margin: 0; font-size: 0.95rem;">Operating Margin</p>
403
+ <p style="color: {margin_border}; margin: 0; font-size: 2.2rem; font-weight: 700;">{operating_margin_strat:.0f}%</p>
 
404
  </div>
405
  """, unsafe_allow_html=True)
406
 
 
407
 
408
  # ============================================================
409
+ # TIER 2: Revenue + Blended Cost (per product & portfolio)
410
  # ============================================================
411
+ blended_cost_portfolio = portfolio_size * blended_cost_per_product
 
 
 
 
 
 
 
 
412
 
413
+ t2_left, t2_right = st.columns(2)
414
+ with t2_left:
415
+ st.markdown(f"""
416
+ <div style="text-align: center; padding: 1.2rem; background: {COLORS['surface']}; border-radius: 10px; border: 1px solid {COLORS['border']}; border-top: 3px solid {COLORS['primary']}; margin-bottom: 0.5rem;">
417
+ <p style="color: {COLORS['text_muted']}; margin: 0; font-size: 0.85rem;">Revenue / Product</p>
418
+ <p style="color: {COLORS['primary']}; margin: 0; font-size: 1.8rem; font-weight: 600;">{sfmt(revenue_per_product)} <span style="font-size: 0.85rem; font-weight: 400; color: {COLORS['text_subtle']};">NOK</span></p>
419
+ </div>
420
+ <div style="text-align: center; padding: 1.2rem; background: {COLORS['surface']}; border-radius: 10px; border: 1px solid {COLORS['border']}; border-top: 3px solid {COLORS['primary']};">
421
+ <p style="color: {COLORS['text_muted']}; margin: 0; font-size: 0.85rem;">Portfolio Revenue</p>
422
+ <p style="color: {COLORS['primary']}; margin: 0; font-size: 1.8rem; font-weight: 600;">{sfmt(revenue_portfolio)} <span style="font-size: 0.85rem; font-weight: 400; color: {COLORS['text_subtle']};">NOK</span></p>
423
+ </div>
424
+ """, unsafe_allow_html=True)
425
+ with t2_right:
426
+ st.markdown(f"""
427
+ <div style="text-align: center; padding: 1.2rem; background: {COLORS['surface']}; border-radius: 10px; border: 1px solid {COLORS['border']}; border-top: 3px solid {COLORS['secondary']}; margin-bottom: 0.5rem;">
428
+ <p style="color: {COLORS['text_muted']}; margin: 0; font-size: 0.85rem;">Blended Cost / Product</p>
429
+ <p style="color: {COLORS['secondary']}; margin: 0; font-size: 1.8rem; font-weight: 600;">{sfmt(blended_cost_per_product)} <span style="font-size: 0.85rem; font-weight: 400; color: {COLORS['text_subtle']};">NOK</span></p>
430
+ </div>
431
+ <div style="text-align: center; padding: 1.2rem; background: {COLORS['surface']}; border-radius: 10px; border: 1px solid {COLORS['border']}; border-top: 3px solid {COLORS['secondary']};">
432
+ <p style="color: {COLORS['text_muted']}; margin: 0; font-size: 0.85rem;">Portfolio Blended Cost</p>
433
+ <p style="color: {COLORS['secondary']}; margin: 0; font-size: 1.8rem; font-weight: 600;">{sfmt(blended_cost_portfolio)} <span style="font-size: 0.85rem; font-weight: 400; color: {COLORS['text_subtle']};">NOK</span></p>
434
+ </div>
435
+ """, unsafe_allow_html=True)
436
 
437
+ st.markdown(f'<p style="text-align: center; color: {COLORS["text_muted"]}; font-size: 0.95rem; margin-top: 1rem;">Assuming {lease_term_months}-month cycle length.</p>', unsafe_allow_html=True)
 
 
 
438
 
439
  st.markdown("---")
440
 
441
  # ============================================================
442
+ # CHART 1: Break Even Timeline
443
  # ============================================================
444
+ st.subheader("Break Even Timeline")
 
445
 
446
+ if monthly_lease == 0:
447
+ st.warning("Set a lease price above 0 NOK to see the break-even timeline.")
448
+ else:
449
+ # Use first 36 months of cumulative data for this chart
450
+ chart_months = min(36, len(breakeven_cumulative) - 1)
451
+ be_months = list(range(chart_months + 1))
452
+ be_values = breakeven_cumulative[:chart_months + 1]
453
 
454
+ fig_be = go.Figure()
 
 
 
 
 
 
 
 
455
 
456
+ # Split into loss (red) and profit (green) fill zones
457
+ loss_y = [v if v <= 0 else 0 for v in be_values]
458
+ profit_y = [v if v >= 0 else 0 for v in be_values]
459
+
460
+ fig_be.add_trace(go.Scatter(
461
+ x=be_months, y=loss_y, mode="none", name="Loss period",
462
+ fill="tozeroy", fillcolor="rgba(160, 68, 74, 0.15)",
463
+ showlegend=False,
464
+ ))
465
+ fig_be.add_trace(go.Scatter(
466
+ x=be_months, y=profit_y, mode="none", name="Profit zone",
467
+ fill="tozeroy", fillcolor="rgba(18, 75, 95, 0.15)",
468
+ showlegend=False,
469
+ ))
470
+ fig_be.add_trace(go.Scatter(
471
+ x=be_months, y=be_values, mode="lines", name="Cumulative Profit",
472
+ line=dict(color=COLORS["primary"], width=3),
473
+ ))
474
+ fig_be.add_hline(y=0, line_color=COLORS["border"], line_width=1)
475
+
476
+ if breakeven_month and breakeven_month <= chart_months:
477
+ fig_be.add_trace(go.Scatter(
478
+ x=[breakeven_month], y=[breakeven_cumulative[breakeven_month]],
479
+ mode="markers+text", name="Break-even",
480
+ marker=dict(size=14, color=COLORS["warning"], symbol="diamond"),
481
+ text=[f"Month {breakeven_month}"], textposition="top center",
482
+ ))
483
+
484
+ fig_be.update_layout(
485
+ xaxis_title="Months", yaxis_title="Cumulative Profit (NOK)",
486
+ showlegend=False, yaxis=dict(separatethousands=True),
487
+ )
488
+ st.plotly_chart(fig_be, use_container_width=True)
489
 
490
  st.markdown("---")
491
 
492
  # ============================================================
493
+ # CHART 2: Revenue & Cost Distribution
494
  # ============================================================
495
+ st.subheader("Revenue & Cost Distribution")
496
+
497
+ rev_per = monthly_lease * lease_term_months # per cycle
498
+ cost_per = blended_cost_per_product # already blended across cycles
499
+ margin_pct = operating_margin_strat
500
+
501
+ profit_gap = rev_per - cost_per
502
+
503
+ fig_rc = go.Figure()
504
+ fig_rc.add_trace(go.Bar(
505
+ x=["Revenue / Product / Cycle", "Blended Cost / Product / Cycle"],
506
+ y=[rev_per, cost_per],
507
+ marker_color=[COLORS["positive"], COLORS["negative"]],
508
+ text=[sfmt(rev_per), sfmt(cost_per)],
509
+ textposition="outside",
 
 
 
 
 
 
 
 
 
 
 
 
510
  ))
511
+
512
+ # Profit gap annotation — bracket between the two bars
513
+ gap_mid = (rev_per + cost_per) / 2
514
+ gap_color = COLORS["positive"] if profit_gap >= 0 else COLORS["negative"]
515
+ gap_label = f"+{sfmt(profit_gap)} NOK" if profit_gap >= 0 else f"{sfmt(profit_gap)} NOK"
516
+
517
+ # Horizontal bracket line at gap midpoint
518
+ fig_rc.add_shape(
519
+ type="line", x0=0, x1=1, y0=rev_per, y1=rev_per,
520
+ line=dict(color=gap_color, width=1, dash="dot"),
521
+ )
522
+ fig_rc.add_shape(
523
+ type="line", x0=1, x1=1, y0=rev_per, y1=cost_per,
524
+ line=dict(color=gap_color, width=2),
525
+ )
526
+ fig_rc.add_annotation(
527
+ x=1, y=gap_mid,
528
+ text=f" <b>{gap_label}</b><br> <span style='font-size:11px'>profit gap</span>",
529
+ showarrow=False, xanchor="left",
530
+ font=dict(size=14, color=gap_color),
531
+ )
532
+ fig_rc.add_annotation(
533
+ x=0.5, y=max(rev_per, cost_per) * 1.18, xref="paper",
534
+ text=f"Margin: {margin_pct:.0f}%", showarrow=False,
535
+ font=dict(size=18, color=margin_border),
536
  )
537
+ fig_rc.update_layout(
538
+ yaxis_title="NOK", showlegend=False,
539
+ yaxis=dict(separatethousands=True),
540
+ )
541
+ st.plotly_chart(fig_rc, use_container_width=True)
542
 
543
+ mfg_share = s_mfg_cost / reuse_cycles if reuse_cycles > 0 else s_mfg_cost
544
+ st.caption(f"Blended cost breakdown: Mfg share {sfmt(mfg_share)} + logistics {sfmt(logistics_per_cycle)} + repair {sfmt(repair_per_cycle)} = **{sfmt(blended_cost_per_product)} NOK** per product per cycle")
545
 
546
  st.markdown("---")
547
 
548
  # ============================================================
549
+ # AT SCALE (hidden — set show_at_scale=True to re-enable)
550
  # ============================================================
551
+ show_at_scale = False
552
+ if show_at_scale:
553
+ st.markdown(f'<p style="color: {COLORS["text_subtle"]}; font-size: 1rem; text-transform: uppercase; letter-spacing: 1px;">Scale Projection</p>', unsafe_allow_html=True)
554
+ st.subheader("At Scale: Customer Base Projection")
555
+
556
+ scale_col1, scale_col2 = st.columns([1, 2])
557
+
558
+ with scale_col1:
559
+ st.slider("Customer Base", 500, 10000, step=100,
560
+ help="Target number of active customers", key="strategy_customer_base")
561
+ customer_base = st.session_state.strategy_customer_base
562
+
563
+ utilization_rate = 0.80
564
+ fleet_needed = int(customer_base / utilization_rate)
565
+ annual_revenue_scale = customer_base * monthly_lease * lease_term_months
566
+ total_cost_scale = fleet_needed * blended_cost_per_product
567
+ annual_profit_scale = annual_revenue_scale - total_cost_scale
568
+ five_year_profit = annual_profit_scale * 5
569
+
570
+ st.metric("Fleet Size Needed", f"{sfmt(fleet_needed)} units")
571
+ st.metric("Annual Revenue", f"{sfmt(annual_revenue_scale)} NOK")
572
+ st.metric("Total Blended Cost", f"{sfmt(total_cost_scale)} NOK")
573
+ st.metric("Annual Profit", f"{sfmt(annual_profit_scale)} NOK")
574
+ st.metric("5-Year Cumulative Profit", f"{sfmt(five_year_profit)} NOK")
575
+
576
+ with scale_col2:
577
+ st.markdown("**5-Year Cumulative Profit Trajectory**")
578
+
579
+ months_range = list(range(0, 61))
580
+ cumulative_profit = []
581
+ cumulative = -fleet_needed * s_mfg_cost
582
+ current_util = utilization_rate
583
+
584
+ for month in months_range:
585
+ if month == 0:
586
+ cumulative_profit.append(cumulative)
587
+ continue
588
+ if month > 0 and month % lease_term_months == 0:
589
+ current_util = max(0.5, current_util - 0.10)
590
+ cumulative -= fleet_needed * logistics_per_cycle
591
+ active_customers = int(fleet_needed * current_util)
592
+ monthly_rev = active_customers * monthly_lease
593
+ monthly_cost = active_customers * (repair_per_cycle / lease_term_months)
594
+ cumulative += monthly_rev - monthly_cost
595
  cumulative_profit.append(cumulative)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
 
597
+ fig_trajectory = go.Figure()
598
+ fig_trajectory.add_trace(go.Scatter(
599
+ x=months_range, y=cumulative_profit, mode="lines", name="Cumulative Profit",
600
+ line=dict(color=COLORS["positive"], width=3), fill="tozeroy", fillcolor="rgba(18, 75, 95, 0.15)",
601
+ ))
602
+ fig_trajectory.add_hline(y=0, line_color=COLORS["border"], line_width=1)
603
 
604
+ for cycle_month in range(lease_term_months, 61, lease_term_months):
605
+ fig_trajectory.add_vline(x=cycle_month, line_dash="dot", line_color=COLORS["border"])
 
 
 
 
 
 
606
 
607
+ for i, cp in enumerate(cumulative_profit):
608
+ if cp >= 0 and i > 0:
609
+ fig_trajectory.add_trace(go.Scatter(
610
+ x=[i], y=[cp], mode="markers+text", name="Breakeven",
611
+ marker=dict(size=12, color=COLORS["warning"], symbol="diamond"),
612
+ text=["Breakeven"], textposition="top center",
613
+ ))
614
+ break
615
+
616
+ fig_trajectory.update_layout(
617
+ xaxis_title="Months", yaxis_title="Cumulative Profit (NOK)",
618
+ showlegend=False, yaxis=dict(separatethousands=True),
619
+ )
620
+ st.plotly_chart(fig_trajectory, use_container_width=True)
621
+
622
+ final_profit = cumulative_profit[-1]
623
+ st.caption(f"5-year projection with {sfmt(fleet_needed)} unit fleet. Final cumulative profit: **{sfmt(final_profit)} NOK**")
624
+
625
+ st.markdown("---")
626
 
627
+ # ============================================================
628
+ # Fleet Efficiency: Customers Served vs Products Manufactured
629
+ # ============================================================
630
+ st.subheader("Fleet Efficiency: Circular vs Linear")
631
+
632
+ cycles_range = list(range(1, reuse_cycles + 1))
633
+ customers_served = [portfolio_size * c for c in cycles_range]
634
+ products_manufactured_circular = [portfolio_size] * len(cycles_range)
635
+ products_manufactured_linear = customers_served # 1 product per customer
636
+
637
+ fig_fleet = go.Figure()
638
+ fig_fleet.add_trace(go.Scatter(
639
+ x=cycles_range, y=customers_served, mode="lines+markers",
640
+ name="Customers Served",
641
+ line=dict(color=COLORS["primary"], width=3),
642
+ marker=dict(size=8),
643
+ ))
644
+ fig_fleet.add_trace(go.Scatter(
645
+ x=cycles_range, y=products_manufactured_linear, mode="lines",
646
+ name="Products Manufactured (Linear)",
647
+ line=dict(color=COLORS["negative"], width=2, dash="dot"),
648
+ ))
649
+ fig_fleet.add_trace(go.Scatter(
650
+ x=cycles_range, y=products_manufactured_circular, mode="lines",
651
+ name="Products Manufactured (Circular)",
652
+ line=dict(color=COLORS["positive"], width=2, dash="dot"),
653
+ ))
654
+ fig_fleet.update_layout(
655
+ xaxis_title="Cycle", yaxis_title="Units",
656
+ xaxis=dict(dtick=1),
657
+ yaxis=dict(separatethousands=True, rangemode="tozero"),
658
+ legend=dict(orientation="h", yanchor="bottom", y=1.02),
659
+ )
660
+ st.plotly_chart(fig_fleet, use_container_width=True)
661
+ savings = customers_served[-1] - portfolio_size
662
+ st.caption(f"{sfmt(portfolio_size)} products serve **{sfmt(customers_served[-1])} families** over {reuse_cycles} cycles — **{sfmt(savings)} fewer products** manufactured vs linear model.")
663
 
664
  # ============================================================
665
  # VIEW: DESIGN
 
695
 
696
  fig_grades = px.bar(
697
  grades_df, x="Grade", y="Percentage", color="Grade",
698
+ color_discrete_map={"A (Clean only)": COLORS["positive"], "B (Repair needed)": COLORS["warning"], "C (Scrap)": COLORS["negative"]},
699
  title="Return Grade Distribution", text="Percentage",
700
  )
701
  fig_grades.update_traces(texttemplate='%{text:.0f}%', textposition='outside')
 
741
  fig_materials.add_trace(go.Scatter(
742
  x=lifecycle_df["Cycle"], y=lifecycle_df["Original Material %"],
743
  name="Original Parts", mode="lines", fill="tozeroy",
744
+ line=dict(color=COLORS["primary"], width=0), fillcolor="rgba(18, 75, 95, 0.5)",
745
  ))
746
  fig_materials.add_trace(go.Scatter(
747
  x=lifecycle_df["Cycle"],
748
  y=lifecycle_df["Original Material %"] + lifecycle_df["New Parts Added %"],
749
  name="Replacement Parts", mode="lines", fill="tonexty",
750
+ line=dict(color=COLORS["secondary"], width=0), fillcolor="rgba(120, 89, 100, 0.5)",
751
  ))
752
  fig_materials.update_layout(
753
  yaxis_title="Material Composition (%)", yaxis_range=[0, 120],
 
795
 
796
  fig_grades = px.bar(
797
  grades_df, x="Grade", y="Percentage", color="Grade",
798
+ color_discrete_map={"A (Reusable)": COLORS["positive"], "B (Refurbish)": COLORS["warning"], "C (Scrap)": COLORS["negative"]},
799
  title="Return Grade Distribution", text="Percentage",
800
  )
801
  fig_grades.update_traces(texttemplate='%{text:.0f}%', textposition='outside')
 
816
 
817
  fig_cycle = go.Figure()
818
  fig_cycle.add_trace(go.Bar(name="Manufacturing", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Manufacturing"],
819
+ marker_color=COLORS["primary"], text=[f"€{v:.0f}" if v > 0 else "" for v in df_cycle_costs["Manufacturing"]], textposition="inside"))
820
  fig_cycle.add_trace(go.Bar(name="Refurbishment", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Refurbishment"],
821
+ marker_color=COLORS["info"], text=[f"€{v:.0f}" if v > 0 else "" for v in df_cycle_costs["Refurbishment"]], textposition="inside"))
822
  fig_cycle.add_trace(go.Bar(name="Logistics", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Logistics"],
823
+ marker_color=COLORS["secondary"], text=[f"€{v:.0f}" for v in df_cycle_costs["Logistics"]], textposition="inside"))
824
  fig_cycle.update_layout(barmode="stack", yaxis_title="Cost (EUR)", legend=dict(orientation="h", yanchor="bottom", y=1.02))
825
  st.plotly_chart(fig_cycle, use_container_width=True)
826
 
 
855
  fig_products = go.Figure()
856
  fig_products.add_trace(go.Bar(
857
  x=lifecycle_df["Cycle"], y=lifecycle_df["Products in Service"],
858
+ name="In Service", marker_color=COLORS["positive"],
859
  text=[f"{v:.0f}" for v in lifecycle_df["Products in Service"]], textposition="outside",
860
  ))
861
  fig_products.add_trace(go.Bar(
862
  x=lifecycle_df["Cycle"], y=lifecycle_df["Products Lost"],
863
+ name="Lost (non-returns + scrap)", marker_color=COLORS["negative"],
864
  text=[f"{v:.0f}" if v > 0 else "" for v in lifecycle_df["Products Lost"]], textposition="outside",
865
  ))
866
  fig_products.update_layout(
 
964
  text=[f"€{total_material_cost:.0f}", f"€{labor_cost:.0f}",
965
  f"€{overhead_cost:.0f}", f"€{total_material_cost + labor_cost + overhead_cost:.0f}"],
966
  textposition="outside",
967
+ connector={"line": {"color": COLORS["border"]}},
968
+ increasing={"marker": {"color": COLORS["primary"]}},
969
+ totals={"marker": {"color": COLORS["info"]}},
970
  ))
971
  fig_waterfall.update_layout(title=f"Manufacturing Cost: €{mfg_cost:.0f}", yaxis_title="EUR", showlegend=False)
972
  st.plotly_chart(fig_waterfall, use_container_width=True)
 
1074
  fig_customer = go.Figure()
1075
  fig_customer.add_trace(go.Bar(
1076
  x=pricing_df["Monthly Price"], y=pricing_df["Total Lease Cost"],
1077
+ name="Lease Cost (1 term)", marker_color=COLORS["primary"],
1078
  ))
1079
 
1080
  if retail_price > 0:
1081
+ fig_customer.add_hline(y=retail_price, line_dash="dash", line_color=COLORS["negative"], line_width=2,
1082
  annotation_text=f"Retail: €{retail_price}", annotation_position="right")
1083
 
1084
  fig_customer.add_trace(go.Scatter(
1085
  x=[lease_price], y=[lease_price * lease_term], mode="markers",
1086
+ name=f"Current (€{lease_price}/mo)", marker=dict(size=14, color=COLORS["warning"], symbol="star"),
1087
  ))
1088
  fig_customer.update_layout(title="Customer Cost vs Retail Price", xaxis_title="Monthly Lease Price (EUR)",
1089
  yaxis_title="Total Customer Cost (EUR)")