Spaces:
Running
Running
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>
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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.
|
| 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 =
|
| 129 |
if "strategy_monthly_lease" not in st.session_state:
|
| 130 |
-
st.session_state.strategy_monthly_lease =
|
| 131 |
if "strategy_mfg_cost" not in st.session_state:
|
| 132 |
-
st.session_state.strategy_mfg_cost =
|
| 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.
|
| 144 |
-
"Product Portfolio",
|
| 145 |
-
help="Number of units in your fleet"
|
| 146 |
)
|
| 147 |
-
st.
|
| 148 |
-
"Monthly Lease Price",
|
| 149 |
-
format="%d NOK", help="What customers pay per month"
|
| 150 |
)
|
| 151 |
-
st.
|
| 152 |
-
"Manufacturing Cost",
|
| 153 |
-
format="%d NOK", help="Cost to produce one unit"
|
| 154 |
)
|
| 155 |
-
st.
|
| 156 |
-
"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", "
|
| 237 |
elif operating_margin >= 15:
|
| 238 |
-
model_status, status_color = "Healthy", "
|
| 239 |
elif operating_margin >= 0:
|
| 240 |
-
model_status, status_color = "Break-even", "
|
| 241 |
else:
|
| 242 |
-
model_status, status_color = "Loss", "
|
| 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:
|
| 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
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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:
|
| 349 |
# ============================================================
|
| 350 |
-
|
| 351 |
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
-
|
| 355 |
-
|
| 356 |
st.markdown(f"""
|
| 357 |
-
<div style="
|
| 358 |
-
<p style="color:
|
| 359 |
-
<
|
| 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="
|
| 368 |
-
<p style="color:
|
| 369 |
-
<
|
| 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:
|
| 378 |
# ============================================================
|
| 379 |
-
|
| 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 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
|
| 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
|
| 404 |
# ============================================================
|
| 405 |
-
st.
|
| 406 |
-
st.subheader("Break Even: Circular vs Linear Model")
|
| 407 |
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
-
|
| 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 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
st.markdown("---")
|
| 432 |
|
| 433 |
# ============================================================
|
| 434 |
-
# CHART 2:
|
| 435 |
# ============================================================
|
| 436 |
-
st.subheader("
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 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 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
)
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
|
| 472 |
-
|
|
|
|
| 473 |
|
| 474 |
st.markdown("---")
|
| 475 |
|
| 476 |
# ============================================================
|
| 477 |
-
#
|
| 478 |
# ============================================================
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 530 |
-
fig_trajectory.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
|
| 532 |
-
|
| 533 |
-
|
| 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 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
-
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)": "
|
| 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="
|
| 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="
|
| 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)": "
|
| 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="
|
| 706 |
fig_cycle.add_trace(go.Bar(name="Refurbishment", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Refurbishment"],
|
| 707 |
-
marker_color="
|
| 708 |
fig_cycle.add_trace(go.Bar(name="Logistics", x=df_cycle_costs["Cycle"], y=df_cycle_costs["Logistics"],
|
| 709 |
-
marker_color="
|
| 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="
|
| 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="
|
| 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": "
|
| 854 |
-
increasing={"marker": {"color": "
|
| 855 |
-
totals={"marker": {"color": "
|
| 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="
|
| 964 |
))
|
| 965 |
|
| 966 |
if retail_price > 0:
|
| 967 |
-
fig_customer.add_hline(y=retail_price, line_dash="dash", line_color="
|
| 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="
|
| 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)")
|