simulator / app.py
mathiassandnes's picture
Claude Sonnet 5
Charge transport + maintenance as a lump sum on product return
6cbd5b9
Raw History Blame Contribute Delete
73.1 kB
"""
LiiS Circular Economy Simulator
Interactive dashboard with stakeholder-specific views.
Run with: streamlit run app.py
"""
import streamlit as st
import plotly.graph_objects as go
import pandas as pd
from pathlib import Path
import yaml
def sfmt(n, decimals=0):
"""Format number with space as thousands separator."""
return f"{n:,.{decimals}f}".replace(",", " ")
# ── LiiS Brand Palette ──
COLORS = {
"bg": "#f9fcf3",
"surface": "#ffffff",
"surface_alt": "#eef5e6",
"primary": "#124b5f",
"secondary": "#785964",
"text": "#1a1a2e",
"text_muted": "#4a5568",
"text_subtle": "#718096",
"border": "#d1ddc8",
"positive": "#124b5f",
"negative": "#a0444a",
"warning": "#c87941",
"info": "#4a7c9b",
}
CHART_COLORS = ["#124b5f", "#785964", "#2a7f62", "#c87941", "#4a7c9b", "#a0444a"]
# LiiS light Plotly theme
LIIS_LAYOUT = dict(
paper_bgcolor=COLORS["bg"],
plot_bgcolor=COLORS["surface"],
font=dict(family="Futura, Century Gothic, Arial, sans-serif", color=COLORS["text"], size=14),
xaxis=dict(gridcolor="#e2e8f0", zerolinecolor=COLORS["border"]),
yaxis=dict(gridcolor="#e2e8f0", zerolinecolor=COLORS["border"]),
colorway=CHART_COLORS,
legend=dict(orientation="h", yanchor="bottom", y=1.02),
hovermode="x unified",
)
import plotly.io as pio
liis_template = pio.templates["plotly"]
liis_template.layout.update(LIIS_LAYOUT)
liis_template.layout.separators = ", " # decimal=comma-free, thousands=space
pio.templates["liis"] = liis_template
pio.templates.default = "liis"
# ── Helper Functions ──
def hero_card(label, value, unit="", subtitle=""):
"""Large centered hero metric card."""
unit_html = f' <span style="font-size: 1.2rem; font-weight: 400; color: {COLORS["text_subtle"]};">{unit}</span>' if unit else ""
subtitle_html = f'<p style="color: {COLORS["text_subtle"]}; margin: 0.5rem 0 0 0; font-size: 0.95rem;">{subtitle}</p>' if subtitle else ""
return f"""
<div style="text-align: center; padding: 2rem 1.5rem; background: {COLORS['surface']};
border-radius: 10px; border: 1px solid {COLORS['border']};">
<p style="color: {COLORS['text_muted']}; margin: 0 0 0.5rem 0;
font-size: 1.3rem; font-weight: 700;">{label}</p>
<p style="color: {COLORS['primary']}; margin: 0;
font-size: 3rem; font-weight: 700;">{value}{unit_html}</p>
{subtitle_html}
</div>
"""
def metric_card(label, value, unit="", color=None):
"""Smaller supporting metric card."""
c = color or COLORS["primary"]
unit_html = f' <span style="font-size: 1.1rem; font-weight: 400; color: {COLORS["text_subtle"]};">{unit}</span>' if unit else ""
return f"""
<div style="text-align: center; padding: 1.5rem; background: {COLORS['surface']};
border-radius: 10px; border: 1px solid {COLORS['border']};">
<p style="color: {COLORS['text_muted']}; margin: 0 0 0.5rem 0;
font-size: 1.2rem; font-weight: 700;">{label}</p>
<p style="color: {c}; margin: 0;
font-size: 2.4rem; font-weight: 600;">{value}{unit_html}</p>
</div>
"""
def slider_with_input(label, min_value, max_value, key, step=1, format=None, help=None):
"""Sidebar slider paired with a number input, so exact values can be typed in."""
num_key = f"_{key}_num"
if key not in st.session_state:
st.session_state[key] = min_value
if num_key not in st.session_state:
st.session_state[num_key] = st.session_state[key]
def _from_slider():
st.session_state[num_key] = st.session_state[key]
def _from_number():
v = min(max_value, max(min_value, st.session_state[num_key]))
st.session_state[num_key] = v
st.session_state[key] = v
col_slider, col_input = st.sidebar.columns([2.3, 1])
with col_slider:
st.slider(label, min_value, max_value, step=step, format=format, key=key, help=help, on_change=_from_slider)
with col_input:
st.markdown("<div style='height: 1.6rem'></div>", unsafe_allow_html=True)
st.number_input(
label, min_value=min_value, max_value=max_value, step=step,
key=num_key, on_change=_from_number, label_visibility="collapsed",
)
def foundation_banner():
"""Read-only bar showing Strategy foundation values."""
portfolio = st.session_state.strategy_portfolio
lease = st.session_state.strategy_monthly_lease
mfg = st.session_state.strategy_mfg_cost
cycles = st.session_state.strategy_reuse_cycles
st.markdown(f"""
<div style="display: flex; justify-content: space-between; padding: 0.75rem 1.5rem;
background: {COLORS['surface_alt']}; border-radius: 8px; border: 1px solid {COLORS['border']};
margin-bottom: 1.5rem;">
<span style="color: {COLORS['text_muted']}; font-size: 0.9rem;">
<b>Strategy:</b> Portfolio {sfmt(portfolio)} units &nbsp;|&nbsp;
Lease {sfmt(lease)} NOK/mo &nbsp;|&nbsp;
Mfg cost {sfmt(mfg)} NOK &nbsp;|&nbsp;
{cycles} reuse cycles
</span>
</div>
""", unsafe_allow_html=True)
# Page config
st.set_page_config(
page_title="LiiS Simulator",
page_icon="♻️",
layout="wide",
)
# Custom CSS — LiiS brand
st.markdown(f"""
<style>
[data-testid="stMetricValue"] {{ font-size: 1.8rem; color: {COLORS['primary']}; }}
.stMarkdown h2 {{ border-bottom: 2px solid {COLORS['primary']}; padding-bottom: 0.5rem; }}
[data-testid="stImage"] {{ border-radius: 10px; }}
.stSuccess, .stInfo {{ border-radius: 8px; }}
.stDataFrame {{ border-radius: 8px; }}
.streamlit-expanderHeader {{ border-radius: 8px; }}
/* Sidebar: kill all top spacing */
[data-testid="stSidebar"] > div {{ padding-top: 0 !important; margin-top: 0 !important; }}
[data-testid="stSidebar"] [data-testid="stSidebarContent"] {{ padding-top: 0 !important; }}
[data-testid="stSidebar"] [data-testid="stVerticalBlock"] {{ gap: 0.5rem; }}
[data-testid="stSidebar"] .stElementContainer:first-child {{ margin-top: 0 !important; }}
section[data-testid="stSidebar"] {{ padding-top: 0 !important; }}
[data-testid="stSidebarCollapsedControl"] {{ top: 0.5rem; }}
/* Dropdowns: non-editable */
[data-testid="stSidebar"] [data-testid="stSelectbox"] input {{ pointer-events: none; caret-color: transparent; }}
</style>
""", unsafe_allow_html=True)
# Header
import base64
st.title("Circular Economy Transition")
# Load product configs
def load_products():
products = {}
products_dir = Path("products")
for product_dir in products_dir.iterdir():
if product_dir.is_dir():
config_path = product_dir / "config.yaml"
if config_path.exists():
products[product_dir.name] = yaml.safe_load(config_path.read_text())
return products
products = load_products()
# ============================================================
# SIDEBAR - Product Selection & View Selection
# ============================================================
with open("branding/image.png", "rb") as f:
logo_b64 = base64.b64encode(f.read()).decode()
st.sidebar.markdown(f'<img src="data:image/png;base64,{logo_b64}" style="height: 44px; margin: -50px 0 1rem 0; padding: 0; display: block;">', unsafe_allow_html=True)
selected_view = st.sidebar.selectbox(
"View",
options=["Strategy", "Design", "Operations", "Logistics", "Sourcing", "Market", "Sustainability"],
index=0,
)
_product_keys = list(products.keys())
_default_product_index = _product_keys.index("parkdress") if "parkdress" in _product_keys else 0
selected_product = st.sidebar.selectbox(
"Product",
options=_product_keys,
format_func=lambda x: products[x]["product"]["name"],
index=_default_product_index,
)
config = products[selected_product]
product_image_path = Path("products") / selected_product / "image.png"
if not product_image_path.exists():
product_image_path = Path("products") / selected_product / "image.webp"
if product_image_path.exists():
st.sidebar.image(str(product_image_path), use_container_width=True)
# ============================================================
# TAB-SPECIFIC SIDEBAR CONTROLS
# ============================================================
# Initialize all values with defaults from config
product_info = config["product"]
retail_price = product_info.get("retail_price", 0)
lease_term = config["pricing"]["lease_term_months"]
max_cycles = config["design"]["max_cycles"]
# Default values (all NOK)
default_lease_price = config["pricing"]["lease_monthly"]
default_return_rate = config["return_assumptions"]["return_rate"]
default_refurb_pct = config["return_assumptions"]["grade_distribution"]["b"] + config["return_assumptions"]["grade_distribution"].get("c", 0.05)
default_mfg_cost = config["costs"]["manufacturing_unit_cost"]
default_avg_refurb = (config["costs"]["refurbishment"]["grade_a"] * 0.7 + config["costs"]["refurbishment"]["grade_b"] * 0.3)
default_logistics = config["costs"]["logistics"]["delivery"] + config["costs"]["logistics"]["return"]
# Streamlit discards a widget's state on runs where it isn't drawn; re-assigning keeps
# slider values when switching between views.
for _key in list(st.session_state.keys()):
if _key.startswith(("strategy_", "design_", "ops_", "sourcing_", "market_", "sust_")):
st.session_state[_key] = st.session_state[_key]
# True only on the very first run of a session, before any defaults are set
_first_load = "strategy_portfolio" not in st.session_state
# Product-specific values reset to the selected product's defaults
if st.session_state.get("active_product") != selected_product:
st.session_state.active_product = selected_product
st.session_state.lease_price = default_lease_price
st.session_state.mfg_cost = default_mfg_cost
st.session_state.return_rate = default_return_rate
st.session_state.refurb_needed_pct = default_refurb_pct
st.session_state.avg_refurb_cost = int(default_avg_refurb)
st.session_state.logistics = default_logistics
st.session_state.max_cycles_override = max_cycles
st.session_state.strategy_monthly_lease = default_lease_price
st.session_state.strategy_mfg_cost = default_mfg_cost
st.session_state.logistics_cost_per_cycle = max(100, min(1000, default_logistics))
# Strategy view session state
if "strategy_portfolio" not in st.session_state:
st.session_state.strategy_portfolio = 7000
if "strategy_reuse_cycles" not in st.session_state:
st.session_state.strategy_reuse_cycles = 4
if "strategy_cycle_length" not in st.session_state:
st.session_state.strategy_cycle_length = 8
if "strategy_customer_base" not in st.session_state:
st.session_state.strategy_customer_base = 1000
# App-wide starting point (overrides the selected product's config defaults once)
if _first_load:
st.session_state.strategy_monthly_lease = 100
st.session_state.strategy_mfg_cost = 750
st.session_state.lease_price = 100
st.session_state.mfg_cost = 750
# Render sidebar based on selected view
if selected_view == "Strategy":
slider_with_input(
"Product Portfolio", 100, 10000, step=50,
key="strategy_portfolio", help="Number of units in your fleet"
)
slider_with_input(
"Monthly Lease Price", 0, 1000, step=25,
format="%d NOK", key="strategy_monthly_lease", help="What customers pay per month"
)
slider_with_input(
"Manufacturing Cost", 0, 7500, step=250,
format="%d NOK", key="strategy_mfg_cost", help="Cost to produce one unit"
)
slider_with_input(
"Reuse Cycles", 1, 10,
key="strategy_reuse_cycles", help="How many families use each product"
)
slider_with_input(
"Cycle Length", 1, 36, step=1,
format="%d mo", key="strategy_cycle_length",
help="How many months each lease cycle lasts"
)
slider_with_input(
"Cycle Cost", 100, 1000, step=25,
format="%d NOK", key="logistics_cost_per_cycle",
help="Total logistics cost per cycle (delivery + return + processing)"
)
# Keep shared state in sync (pure NOK now)
st.session_state.lease_price = st.session_state.strategy_monthly_lease
st.session_state.mfg_cost = st.session_state.strategy_mfg_cost
elif selected_view == "Design":
if "design_damage_rate" not in st.session_state:
st.session_state.design_damage_rate = 10
if "design_repairability" not in st.session_state:
st.session_state.design_repairability = 7
if "design_spare_parts" not in st.session_state:
st.session_state.design_spare_parts = 75
slider_with_input(
"Expected Damage Rate", 0, 50,
key="design_damage_rate", format="%d%%",
help="% of products needing repair per cycle"
)
slider_with_input(
"Repairability Score", 0, 10,
key="design_repairability",
help="0 = everything scrapped, 10 = everything can be repaired"
)
slider_with_input(
"Spare Parts Availability", 0, 100,
key="design_spare_parts", format="%d%%",
help="% of components available in supply chain"
)
# Sync back to shared state
_damage = st.session_state.design_damage_rate / 100
_repair_factor = st.session_state.design_repairability / 10
_spare_factor = st.session_state.design_spare_parts / 100
st.session_state.refurb_needed_pct = _damage * _repair_factor * _spare_factor + (_damage - _damage * _repair_factor * _spare_factor)
elif selected_view == "Operations":
if "ops_batch_size" not in st.session_state:
st.session_state.ops_batch_size = 500
if "ops_qa_level" not in st.session_state:
st.session_state.ops_qa_level = 3
if "ops_repair_new_pct" not in st.session_state:
st.session_state.ops_repair_new_pct = 50
slider_with_input(
"Batch Size", 100, 10000, step=100,
key="ops_batch_size", help="Units processed per batch"
)
slider_with_input(
"QA Inspection Level", 1, 6,
key="ops_qa_level", help="1 = basic visual, 6 = full functional test"
)
slider_with_input(
"Repair Components % New", 0, 100, step=5,
key="ops_repair_new_pct", format="%d%%",
help="% of replacement parts that are new (vs refurbished)"
)
elif selected_view == "Logistics":
if "logistics_collection_freq" not in st.session_state:
st.session_state.logistics_collection_freq = "Bi-weekly"
if "logistics_cost_per_cycle" not in st.session_state:
st.session_state.logistics_cost_per_cycle = max(100, min(1000, default_logistics))
st.session_state.logistics_collection_freq = st.sidebar.selectbox(
"Collection Frequency",
options=["Weekly", "Bi-weekly", "Monthly"],
index=["Weekly", "Bi-weekly", "Monthly"].index(st.session_state.logistics_collection_freq),
help="How often products are collected from customers"
)
slider_with_input(
"Logistics Cost per Cycle", 100, 1000, step=25,
format="%d NOK", key="logistics_cost_per_cycle",
help="Total logistics cost (delivery + return + processing)"
)
elif selected_view == "Sourcing":
if "sourcing_recycled_target" not in st.session_state:
st.session_state.sourcing_recycled_target = 20
if "sourcing_closed_loop" not in st.session_state:
st.session_state.sourcing_closed_loop = 10
if "sourcing_production_eff" not in st.session_state:
st.session_state.sourcing_production_eff = 85
slider_with_input(
"Recycled Content Target", 0, 100, step=5,
key="sourcing_recycled_target", format="%d%%",
help="Target % of recycled material in new products"
)
slider_with_input(
"Closed-Loop Recovery", 0, 100, step=5,
key="sourcing_closed_loop", format="%d%%",
help="% of materials recovered from own product returns"
)
slider_with_input(
"Production Efficiency", 0, 100, step=5,
key="sourcing_production_eff", format="%d%%",
help="% of raw material that ends up in finished product"
)
elif selected_view == "Market":
if "market_quality_level" not in st.session_state:
st.session_state.market_quality_level = 4
if "market_pricing_discount" not in st.session_state:
st.session_state.market_pricing_discount = 0
if "market_subscription_tier" not in st.session_state:
st.session_state.market_subscription_tier = "Standard"
slider_with_input(
"Service Quality Level", 1, 6,
key="market_quality_level",
help="1 = basic, 6 = premium white-glove service"
)
slider_with_input(
"Pricing Discount", 0, 100, step=5,
key="market_pricing_discount", format="%d%%",
help="Discount from standard lease price"
)
st.session_state.market_subscription_tier = st.sidebar.selectbox(
"Subscription Tier",
options=["Basic", "Standard", "Premium"],
index=["Basic", "Standard", "Premium"].index(st.session_state.market_subscription_tier),
help="Tier affects service level and pricing"
)
elif selected_view == "Sustainability":
if "sust_reuse_cycles" not in st.session_state:
st.session_state.sust_reuse_cycles = 3
if "sust_recycled_content" not in st.session_state:
st.session_state.sust_recycled_content = 20
if "sust_production_eff" not in st.session_state:
st.session_state.sust_production_eff = 85
slider_with_input(
"Reuse Cycles (Slowing)", 1, 6,
key="sust_reuse_cycles",
help="Number of reuse cycles — slowing the loop"
)
slider_with_input(
"Recycled Content (Closing)", 0, 100, step=5,
key="sust_recycled_content", format="%d%%",
help="% recycled material — closing the loop"
)
slider_with_input(
"Production Efficiency (Narrowing)", 0, 100, step=5,
key="sust_production_eff", format="%d%%",
help="Material efficiency — narrowing the loop"
)
# ============================================================
# DERIVED VALUES (used across all views)
# ============================================================
lease_price = st.session_state.lease_price
mfg_cost = st.session_state.mfg_cost
return_rate = st.session_state.return_rate
refurb_needed_pct = st.session_state.refurb_needed_pct
avg_refurb_cost = st.session_state.avg_refurb_cost
logistics = st.session_state.logistics
max_cycles = st.session_state.max_cycles_override
grade_a_pct = 1.0 - refurb_needed_pct - 0.05
grade_b_pct = refurb_needed_pct
grade_c_pct = 0.05
eolrr = return_rate * (grade_a_pct + grade_b_pct)
new_unit_cost = mfg_cost + logistics
refurb_unit_cost = avg_refurb_cost + logistics
revenue_per_customer = lease_price * lease_term
total_lease_cost = lease_price * lease_term
refurb_a = int(avg_refurb_cost * 0.4)
refurb_b = int(avg_refurb_cost * 1.8)
# Executive Summary calculations
lifecycle_revenue = max_cycles * revenue_per_customer
lifecycle_cost = new_unit_cost + (max_cycles - 1) * refurb_unit_cost
lifecycle_profit = lifecycle_revenue - lifecycle_cost
operating_margin = (lifecycle_profit / lifecycle_revenue) * 100 if lifecycle_revenue > 0 else 0
# Model status thresholds
if operating_margin >= 30:
model_status, status_color = "Profitable", COLORS["positive"]
elif operating_margin >= 15:
model_status, status_color = "Healthy", COLORS["warning"]
elif operating_margin >= 0:
model_status, status_color = "Break-even", COLORS["secondary"]
else:
model_status, status_color = "Loss", COLORS["negative"]
# ============================================================
# HELPER: Break-even calculation
# ============================================================
def calculate_breakeven_months(portfolio, monthly_lease, mfg_cost_nok, lease_term_mo=12, logistics_nok=150, repair_nok=200, max_months=120):
"""Return (breakeven_month, cumulative_list) for given parameters.
A lease cycle shorter than 12 months (e.g. a seasonal product) is assumed to
sit idle — no revenue — for the rest of the calendar year before the next
cycle begins. Transport + maintenance cost is paid as a lump sum at the end
of each active period, when the product is collected back and refurbished.
"""
real_cycle_mo = max(lease_term_mo, 12)
monthly_revenue = portfolio * monthly_lease
cycle_cost = portfolio * (logistics_nok + repair_nok)
initial = -portfolio * mfg_cost_nok
cumulative = initial
cumulative_list = [cumulative]
breakeven = None
for month in range(1, max_months + 1):
position_in_cycle = (month - 1) % real_cycle_mo
active = position_in_cycle < lease_term_mo
if position_in_cycle == lease_term_mo - 1:
cumulative -= cycle_cost
if active:
cumulative += monthly_revenue
cumulative_list.append(cumulative)
if cumulative >= 0 and breakeven is None:
breakeven = month
return breakeven, cumulative_list
# ============================================================
# VIEW: STRATEGY (C-Level)
# ============================================================
if selected_view == "Strategy":
st.header("Strategy")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For C-level executives and business owners</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">Portfolio economics, break-even timeline, and operating margin</p>', unsafe_allow_html=True)
st.subheader("Portfolio Economics")
# Get Strategy slider values
portfolio_size = st.session_state.strategy_portfolio
monthly_lease = st.session_state.strategy_monthly_lease
s_mfg_cost = st.session_state.strategy_mfg_cost
reuse_cycles = st.session_state.strategy_reuse_cycles
# ============================================================
# CALCULATIONS
# ============================================================
lease_term_months = st.session_state.strategy_cycle_length
logistics_per_cycle = st.session_state.logistics_cost_per_cycle # NOK - pickup + delivery per cycle
repair_per_cycle = 200 # NOK - average repair/refurb per cycle
# A cycle shorter than a year (e.g. a seasonal product) sits idle — no
# revenue, no wear — for the rest of the calendar year before the next
# cycle's logistics cost is paid and it goes back into active service.
idle_months = max(0, 12 - lease_term_months)
real_cycle_months = lease_term_months + idle_months
# Per-product economics
revenue_per_product = monthly_lease * lease_term_months * reuse_cycles
total_cost_per_product = s_mfg_cost + (logistics_per_cycle + repair_per_cycle) * reuse_cycles
blended_cost_per_product = total_cost_per_product / reuse_cycles if reuse_cycles > 0 else total_cost_per_product
profit_per_product = revenue_per_product - total_cost_per_product
operating_margin_strat = (profit_per_product / revenue_per_product) * 100 if revenue_per_product > 0 else 0
# Portfolio level
revenue_portfolio = portfolio_size * revenue_per_product
profit_portfolio = portfolio_size * profit_per_product
# Break-even using helper
breakeven_month, breakeven_cumulative = calculate_breakeven_months(
portfolio_size, monthly_lease, s_mfg_cost,
lease_term_months, logistics_per_cycle, repair_per_cycle,
)
# ============================================================
# TIER 1: Break Even + Operating Margin
# ============================================================
be_display = f"{breakeven_month}" if breakeven_month else "> 120"
t1_left, t1_right = st.columns(2)
with t1_left:
st.markdown(hero_card("Break Even Period", be_display, "months"), unsafe_allow_html=True)
with t1_right:
st.markdown(hero_card("Operating Margin", f"{operating_margin_strat:.0f}%"), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ============================================================
# TIER 2: Revenue + Blended Cost (per product & portfolio)
# ============================================================
st.subheader("Operating Efficiency")
blended_cost_portfolio = portfolio_size * blended_cost_per_product
# Row: Revenue
r1_left, r1_right = st.columns(2)
with r1_left:
st.markdown(metric_card("Revenue / Product", sfmt(revenue_per_product), "NOK"), unsafe_allow_html=True)
with r1_right:
st.markdown(metric_card("Portfolio Revenue", sfmt(revenue_portfolio), "NOK"), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 0.75rem;"></div>', unsafe_allow_html=True)
# Row: Blended Cost
r2_left, r2_right = st.columns(2)
with r2_left:
st.markdown(metric_card("Blended Cost / Product", sfmt(blended_cost_per_product), "NOK", COLORS["secondary"]), unsafe_allow_html=True)
with r2_right:
st.markdown(metric_card("Portfolio Blended Cost", sfmt(blended_cost_portfolio), "NOK", COLORS["secondary"]), unsafe_allow_html=True)
cycle_length_note = f"Assuming {lease_term_months}-month cycle length."
if idle_months > 0:
cycle_length_note = f"Assuming {lease_term_months} active months + {idle_months} idle (off-season) months per {real_cycle_months}-month cycle."
st.markdown(f'<p style="text-align: center; color: {COLORS["text_muted"]}; font-size: 0.95rem; margin-top: 1rem;">{cycle_length_note}</p>', unsafe_allow_html=True)
st.markdown("---")
# ============================================================
# CHART 1: Break Even Timeline
# ============================================================
st.subheader("Break Even Timeline")
if monthly_lease == 0:
st.warning("Set a lease price above 0 NOK to see the break-even timeline.")
else:
# Scale chart length with reuse cycles (each real cycle = active + idle months)
chart_months = min(reuse_cycles * real_cycle_months, len(breakeven_cumulative) - 1)
be_months = list(range(chart_months + 1))
# Revenue builds steadily during each cycle's active months, then flatlines
# for its idle months (e.g. a seasonal product with no one renting it).
# Cost starts at the upfront manufacturing spend and is otherwise flat —
# it only steps up as a lump sum at the end of each active period, when
# the product is collected back for transport and maintenance.
monthly_revenue_val = portfolio_size * monthly_lease
initial_investment = portfolio_size * s_mfg_cost
cycle_cost_val = portfolio_size * (logistics_per_cycle + repair_per_cycle)
def _active_months_through(m):
full_cycles, remainder = divmod(m, real_cycle_months)
return full_cycles * lease_term_months + min(remainder, lease_term_months)
def _cycle_events_through(m):
full_cycles, remainder = divmod(m, real_cycle_months)
return full_cycles + (1 if remainder >= lease_term_months else 0)
revenue_cum = [monthly_revenue_val * _active_months_through(m) for m in be_months]
cost_cum = [
initial_investment + cycle_cost_val * _cycle_events_through(m)
for m in be_months
]
be_lead = f'Cost starts at <b>{sfmt(initial_investment)} NOK</b> — the upfront manufacturing spend — then steps up by <b>{sfmt(cycle_cost_val)} NOK</b> each time the portfolio is collected back for transport and maintenance. Revenue builds during each cycle\'s active months'
be_lead += f' and flatlines for its {idle_months} idle months.' if idle_months > 0 else '.'
if breakeven_month and breakeven_month <= chart_months:
lead_gap = revenue_cum[-1] - cost_cum[-1]
be_lead += f' Revenue overtakes cost in <b>month {breakeven_month}</b>, and the portfolio is <b>{sfmt(lead_gap)} NOK</b> ahead by month {chart_months}.'
else:
be_lead += f' Cost still leads revenue after {chart_months} months at these settings.'
st.markdown(f'<p style="color: {COLORS["text_muted"]}; font-size: 0.95rem; margin: 0 0 1rem 0;">{be_lead}</p>', unsafe_allow_html=True)
# Pre-formatted hover text (matches the rest of the app's number style,
# instead of Plotly's default SI-suffix auto-formatting)
rev_hover = [f"+{sfmt(v)} NOK" for v in revenue_cum]
cost_hover = [f"-{sfmt(v)} NOK" for v in cost_cum]
fig_be = go.Figure()
# Shaded gap between the lines — red while cost leads, teal once revenue overtakes it.
# hoverinfo is skipped so these don't clutter the tooltip.
crossing = breakeven_month if (breakeven_month and breakeven_month <= chart_months) else chart_months
if crossing > 0:
fig_be.add_trace(go.Scatter(
x=be_months[:crossing + 1] + be_months[:crossing + 1][::-1],
y=cost_cum[:crossing + 1] + revenue_cum[:crossing + 1][::-1],
mode="none", fill="toself", fillcolor="rgba(160, 68, 74, 0.16)",
hoverinfo="skip", showlegend=False,
))
if crossing < chart_months:
fig_be.add_trace(go.Scatter(
x=be_months[crossing:] + be_months[crossing:][::-1],
y=revenue_cum[crossing:] + cost_cum[crossing:][::-1],
mode="none", fill="toself", fillcolor="rgba(18, 75, 95, 0.12)",
hoverinfo="skip", showlegend=False,
))
fig_be.add_trace(go.Scatter(
x=be_months, y=revenue_cum, mode="lines", name="Cumulative revenue",
line=dict(color=COLORS["primary"], width=3),
customdata=rev_hover,
hovertemplate="Month %{x}<br>Revenue: <b>%{customdata}</b><extra></extra>",
))
fig_be.add_trace(go.Scatter(
x=be_months, y=cost_cum, mode="lines", name="Cumulative cost",
line=dict(color=COLORS["negative"], width=3),
customdata=cost_hover,
hovertemplate="Month %{x}<br>Cost: <b>%{customdata}</b><extra></extra>",
))
if breakeven_month and breakeven_month <= chart_months:
cross_y = revenue_cum[breakeven_month]
fig_be.add_trace(go.Scatter(
x=[breakeven_month], y=[cross_y], mode="markers",
marker=dict(size=14, color=COLORS["warning"], symbol="diamond",
line=dict(color="white", width=1.5)),
hoverinfo="skip", showlegend=False,
))
fig_be.add_annotation(
x=breakeven_month, y=cross_y,
text=f"<b>Break-even — month {breakeven_month}</b>",
showarrow=True, arrowhead=2, arrowcolor=COLORS["warning"], ax=0, ay=-45,
bgcolor="white", bordercolor=COLORS["warning"], borderwidth=1, borderpad=4,
font=dict(size=12, color=COLORS["text"]),
)
fig_be.update_layout(
xaxis_title="Months", yaxis_title="Cumulative NOK",
showlegend=True, yaxis=dict(separatethousands=True),
margin=dict(t=30),
)
st.plotly_chart(fig_be, use_container_width=True)
caption = f"Cost steps up once per {lease_term_months}-month active period — transport + maintenance paid when the product is collected back; revenue accrues from the lease price during those same active months"
caption += f" and pauses for the {idle_months} idle months." if idle_months > 0 else "."
st.caption(caption)
st.markdown("---")
# ============================================================
# CHART 2: Revenue & Cost Distribution
# ============================================================
st.subheader("Revenue & Cost Distribution")
rev_per = monthly_lease * lease_term_months # per cycle
cost_per = blended_cost_per_product # already blended across cycles
margin_pct = operating_margin_strat
profit_gap = rev_per - cost_per
fig_rc = go.Figure()
fig_rc.add_trace(go.Bar(
x=["Revenue / Product / Cycle", "Blended Cost / Product / Cycle"],
y=[rev_per, cost_per],
marker_color=[COLORS["positive"], COLORS["negative"]],
text=[sfmt(rev_per), sfmt(cost_per)],
textposition="outside",
))
# Profit gap annotation
gap_mid = (rev_per + cost_per) / 2
gap_color = COLORS["positive"] if profit_gap >= 0 else COLORS["negative"]
gap_label = f"+{sfmt(profit_gap)} NOK" if profit_gap >= 0 else f"{sfmt(profit_gap)} NOK"
fig_rc.add_shape(
type="line", x0=0, x1=1, y0=rev_per, y1=rev_per,
line=dict(color=gap_color, width=1, dash="dot"),
)
fig_rc.add_shape(
type="line", x0=1, x1=1, y0=rev_per, y1=cost_per,
line=dict(color=gap_color, width=2),
)
fig_rc.add_annotation(
x=1, y=gap_mid,
text=f" <b>{gap_label}</b><br> <span style='font-size:11px'>profit gap</span>",
showarrow=False, xanchor="left",
font=dict(size=14, color=gap_color),
)
fig_rc.add_annotation(
x=0.5, y=max(rev_per, cost_per) * 1.18, xref="paper",
text=f"Margin: {margin_pct:.0f}%", showarrow=False,
font=dict(size=18, color=COLORS["primary"]),
)
fig_rc.update_layout(
yaxis_title="NOK", showlegend=False,
yaxis=dict(separatethousands=True),
)
st.plotly_chart(fig_rc, use_container_width=True)
mfg_share = s_mfg_cost / reuse_cycles if reuse_cycles > 0 else s_mfg_cost
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")
# ============================================================
# VIEW: DESIGN
# ============================================================
elif selected_view == "Design":
st.header("Design")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For product designers and sustainability officers</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">Recovery rate, repairability impact, and component failure analysis</p>', unsafe_allow_html=True)
# ── Design-specific calculations from 3 sliders ──
d_damage_rate = st.session_state.design_damage_rate / 100
d_repairability_factor = st.session_state.design_repairability / 10
d_spare_parts_factor = st.session_state.design_spare_parts / 100
d_grade_a_pct = 1 - d_damage_rate
d_grade_b_pct = d_damage_rate * d_repairability_factor * d_spare_parts_factor
d_grade_c_pct = d_damage_rate - d_grade_b_pct
d_recovery_rate = return_rate * (d_grade_a_pct + d_grade_b_pct)
# Repair cost affected by repairability (easier repair = cheaper)
base_repair = config["costs"]["refurbishment"]["grade_b"]
d_repair_cost = base_repair * (1.4 - d_repairability_factor * 0.8)
# Strategy foundation values
s_cycles = st.session_state.strategy_reuse_cycles
# ── Hero metric: Reuse Rate ──
st.markdown(hero_card("Reuse Rate", f"{d_recovery_rate:.1%}", subtitle="% of returned products that can be reused"), unsafe_allow_html=True)
st.markdown(f'<p style="text-align: center; color: {COLORS["text_subtle"]}; font-size: 0.85rem; margin-top: 0.5rem;">'
f'Affected by: Expected Damage Rate, Repairability Score, Spare Parts Availability</p>',
unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ── Supporting metrics (row) ──
sup_left, sup_right = st.columns(2)
with sup_left:
st.markdown(metric_card("Grade A Rate", f"{d_grade_a_pct:.1%}", color=COLORS["positive"]), unsafe_allow_html=True)
with sup_right:
st.markdown(metric_card("Repair Cost per Unit", f"{sfmt(int(d_repair_cost))}", "NOK", COLORS["secondary"]), unsafe_allow_html=True)
st.markdown("---")
# ── Chart 1: Design Choices -> Return Grades ──
st.subheader("Design Choices → Return Grades")
st.markdown("Better design for durability and repairability reduces the damage rate and increases Grade A returns.")
grade_labels = ["C (Scrap)", "B (Repair needed)", "A (Clean only)"]
grade_values = [d_grade_c_pct * 100, d_grade_b_pct * 100, d_grade_a_pct * 100]
grade_colors = [COLORS["negative"], COLORS["warning"], COLORS["positive"]]
fig_grades = go.Figure()
fig_grades.add_trace(go.Bar(
y=grade_labels,
x=grade_values,
orientation="h",
marker_color=grade_colors,
text=[f"{v:.1f}%" for v in grade_values],
textposition="outside",
))
fig_grades.update_layout(
title="Return Grade Distribution", showlegend=False,
xaxis=dict(range=[0, 105], title="% of Returns"),
yaxis=dict(type="category"),
height=350, hovermode="closest",
)
st.plotly_chart(fig_grades, use_container_width=True, config={"displayModeBar": False})
st.info(f"**Design insight:** At {st.session_state.design_damage_rate}% damage rate with repairability score "
f"{st.session_state.design_repairability}/10, "
f"**{d_grade_b_pct:.1%}** of products can be repaired while **{d_grade_c_pct:.1%}** are scrapped.")
st.markdown("---")
# ── Chart 2: Part Failure Distribution ──
st.subheader("Part Failure Distribution")
st.markdown("Which components fail most frequently, driving repair costs.")
has_bom = "materials" in config and "bom" in config.get("materials", {})
if has_bom:
bom_keys = list(config["materials"]["bom"].keys())
bom_names = [config["materials"]["bom"][k].get("name", k) for k in bom_keys]
failure_map = {
"wheels": 40, "tire": 40, "wheel": 40,
"fabric": 20, "canopy": 20, "cover": 20, "mesh": 20,
"hinge": 20, "joint": 20, "hitch": 15, "steel": 10,
"fastener": 10, "hardware": 10,
"foam": 8, "padding": 8,
"frame": 5, "aluminum": 5, "shell": 10, "plastic": 10,
"harness": 15,
}
raw_failures = []
for k in bom_keys:
name_lower = k.lower()
rate = 5
for keyword, r in failure_map.items():
if keyword in name_lower:
rate = r
break
raw_failures.append(rate)
total_raw = sum(raw_failures)
failure_pcts = [r / total_raw * 100 for r in raw_failures]
failure_df = pd.DataFrame({"Component": bom_names, "Failure %": failure_pcts})
else:
failure_df = pd.DataFrame({
"Component": ["Wheels", "Fabric/Cover", "Joints/Hinges", "Fasteners", "Frame", "Other"],
"Failure %": [40, 20, 20, 10, 5, 5],
})
failure_df = failure_df.sort_values("Failure %", ascending=True)
fig_failure = go.Figure(go.Bar(
x=failure_df["Failure %"],
y=failure_df["Component"],
orientation="h",
marker_color=COLORS["primary"],
text=[f"{v:.0f}%" for v in failure_df["Failure %"]],
textposition="outside",
))
fig_failure.update_layout(
xaxis_title="% of Total Failures",
yaxis_title="",
showlegend=False,
)
st.plotly_chart(fig_failure, use_container_width=True)
st.caption("Prioritize design improvements on high-failure components to reduce damage rate and repair costs.")
st.markdown("---")
# ── Chart 3: Product Condition Degradation ──
st.subheader("Product Condition Degradation")
st.markdown("Estimated functional condition after each reuse cycle.")
gauge_cols = st.columns(s_cycles)
for i, col in enumerate(gauge_cols):
cycle_num = i + 1
functional_pct = (1 - d_damage_rate) ** cycle_num * 100
if functional_pct >= 80:
gauge_color = COLORS["positive"]
elif functional_pct >= 60:
gauge_color = COLORS["warning"]
else:
gauge_color = COLORS["negative"]
fig_gauge = go.Figure(go.Indicator(
mode="gauge+number",
value=functional_pct,
number={"suffix": "%", "font": {"size": 22}, "valueformat": ".0f"},
title={"text": f"Cycle {cycle_num}", "font": {"size": 14}},
domain={"x": [0, 1], "y": [0, 1]},
gauge={
"axis": {"range": [0, 100], "tickwidth": 1},
"bar": {"color": gauge_color},
"bgcolor": COLORS["surface"],
"borderwidth": 1,
"bordercolor": COLORS["border"],
"steps": [
{"range": [0, 60], "color": "rgba(160, 68, 74, 0.1)"},
{"range": [60, 80], "color": "rgba(200, 121, 65, 0.1)"},
{"range": [80, 100], "color": "rgba(18, 75, 95, 0.1)"},
],
},
))
fig_gauge.update_layout(
height=220,
margin=dict(t=50, b=0, l=20, r=20),
)
with col:
st.plotly_chart(fig_gauge, use_container_width=True)
st.caption(f"Acceptable for recovery rate of {d_recovery_rate:.0%}")
# ============================================================
# VIEW: OPERATIONS (NEW)
# ============================================================
elif selected_view == "Operations":
st.header("Operations")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For operations managers and warehouse teams</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">Turnaround time, throughput capacity, and material waste analysis</p>', unsafe_allow_html=True)
foundation_banner()
# ── Operations calculations ──
batch_size = st.session_state.ops_batch_size
qa_level = st.session_state.ops_qa_level
repair_new_pct = st.session_state.ops_repair_new_pct / 100
queue_days = max(1, batch_size / 50)
qa_days = 0.5 + (qa_level - 1) * 0.5
repair_days = 2.0 * (1 + (1 - repair_new_pct) * 0.5)
turnaround_days = queue_days + qa_days + repair_days
# Repair cost scales with new parts %
base_repair_cost = config["costs"]["refurbishment"]["grade_b"]
ops_repair_cost = base_repair_cost * (0.6 + repair_new_pct * 0.8)
# Portfolio values from strategy
portfolio_size = st.session_state.strategy_portfolio
reuse_cycles = st.session_state.strategy_reuse_cycles
# Throughput
working_days_per_month = 22
throughput_per_month = int(working_days_per_month / turnaround_days * batch_size) if turnaround_days > 0 else 0
fleet_coverage = min(100, throughput_per_month / max(1, portfolio_size) * 100)
# ── Hero: Turnaround Time ──
st.markdown(hero_card(
"Turnaround Time",
f"{turnaround_days:.1f}",
"days",
f"Queue ({queue_days:.1f}d) + QA ({qa_days:.1f}d) + Repair ({repair_days:.1f}d)"
), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ── Supporting metrics ──
s1, s2, s3, s4 = st.columns(4)
with s1:
st.markdown(metric_card("New Components", f"{repair_new_pct:.0%}"), unsafe_allow_html=True)
with s2:
st.markdown(metric_card("Avg Repair Cost", sfmt(int(ops_repair_cost)), "NOK", COLORS["secondary"]), unsafe_allow_html=True)
with s3:
st.markdown(metric_card("Queue + QA Time", f"{queue_days + qa_days:.1f}", "days", COLORS["info"]), unsafe_allow_html=True)
with s4:
st.markdown(metric_card("Repair Time", f"{repair_days:.1f}", "days", COLORS["warning"]), unsafe_allow_html=True)
st.markdown("---")
# ── Unit / Portfolio Economics ──
st.subheader("Throughput Capacity")
tp1, tp2 = st.columns(2)
with tp1:
st.markdown(metric_card("Throughput / Month", sfmt(throughput_per_month), "units"), unsafe_allow_html=True)
with tp2:
st.markdown(metric_card("Fleet Coverage", f"{fleet_coverage:.0f}%", color=COLORS["positive"] if fleet_coverage >= 80 else COLORS["warning"]), unsafe_allow_html=True)
st.markdown("---")
# ── Chart 1: Turnaround Timeline Breakdown ──
st.subheader("Turnaround Timeline Breakdown")
fig_timeline = go.Figure()
phases = ["Queue", "QA Inspection", "Repair"]
durations = [queue_days, qa_days, repair_days]
phase_colors = [COLORS["info"], COLORS["primary"], COLORS["warning"]]
for i, (phase, dur, color) in enumerate(zip(phases, durations, phase_colors)):
fig_timeline.add_trace(go.Bar(
y=["Turnaround"],
x=[dur],
name=phase,
orientation="h",
marker_color=color,
text=[f"{phase}: {dur:.1f}d"],
textposition="inside",
insidetextanchor="middle",
))
fig_timeline.update_layout(
barmode="stack",
xaxis_title="Days",
showlegend=True,
height=200,
yaxis=dict(showticklabels=False),
)
st.plotly_chart(fig_timeline, use_container_width=True)
st.markdown("---")
# ── Chart 2: Material Waste Composition ──
st.subheader("Material Waste Composition")
st.markdown("Estimated waste by material type from BOM (% of total product weight).")
has_bom = "materials" in config and "bom" in config.get("materials", {})
if has_bom:
bom = config["materials"]["bom"]
waste_data = []
waste_map = {
"foam": 15, "padding": 15,
"fabric": 12, "canopy": 12, "cover": 12,
"plastic": 8, "shell": 8,
"rubber": 10, "tire": 10, "wheel": 10,
"steel": 3, "aluminum": 2, "frame": 2,
"harness": 7, "hardware": 5, "fastener": 5,
}
for k, comp in bom.items():
name = comp.get("name", k)
weight = comp.get("weight_kg", 0)
recyclable = comp.get("recyclable", False)
waste_pct = 2 if recyclable else 10
for keyword, wp in waste_map.items():
if keyword in k.lower():
waste_pct = wp
break
waste_data.append({"Material": name, "Waste %": waste_pct, "Weight": weight})
waste_df = pd.DataFrame(waste_data).sort_values("Waste %", ascending=True)
fig_waste = go.Figure(go.Bar(
x=waste_df["Waste %"],
y=waste_df["Material"],
orientation="h",
marker_color=[COLORS["negative"] if w > 10 else COLORS["warning"] if w > 5 else COLORS["positive"] for w in waste_df["Waste %"]],
text=[f"{v}%" for v in waste_df["Waste %"]],
textposition="outside",
))
fig_waste.update_layout(
xaxis_title="Waste Rate (%)",
showlegend=False,
)
st.plotly_chart(fig_waste, use_container_width=True)
else:
st.info("Add BOM data to product config for detailed waste analysis.")
st.caption("Waste rates are simulated estimates based on material type and recyclability.")
# ============================================================
# VIEW: LOGISTICS (REDESIGN)
# ============================================================
elif selected_view == "Logistics":
st.header("Logistics")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For logistics managers and warehouse operations</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">Collection timing, exchange efficiency, and logistics cost breakdown</p>', unsafe_allow_html=True)
foundation_banner()
# ── Logistics calculations ──
collection_freq = st.session_state.logistics_collection_freq
logistics_cost = st.session_state.logistics_cost_per_cycle
collection_time_map = {"Weekly": 3, "Bi-weekly": 7, "Monthly": 14}
collection_days = collection_time_map[collection_freq]
# Exchange time improves with frequency
avg_exchange_days = collection_days * 0.7 + 1.5
products_per_route = max(5, 30 - collection_days)
cost_per_product = logistics_cost / max(1, products_per_route) * 3
on_time_rate = max(70, 98 - collection_days * 1.5)
portfolio_size = st.session_state.strategy_portfolio
reuse_cycles = st.session_state.strategy_reuse_cycles
delivery_cost = config["costs"]["logistics"]["delivery"]
return_cost = config["costs"]["logistics"]["return"]
# ── Hero: Collection Time ──
st.markdown(hero_card(
"Collection Time",
f"{collection_days}",
"days",
f"{collection_freq} collection schedule"
), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ── Supporting metrics ──
s1, s2, s3, s4 = st.columns(4)
with s1:
st.markdown(metric_card("Avg Exchange Time", f"{avg_exchange_days:.1f}", "days"), unsafe_allow_html=True)
with s2:
st.markdown(metric_card("Products / Route", f"{products_per_route}"), unsafe_allow_html=True)
with s3:
st.markdown(metric_card("Collection Cost", f"{sfmt(int(cost_per_product))}", "NOK", COLORS["secondary"]), unsafe_allow_html=True)
with s4:
st.markdown(metric_card("On-time Rate", f"{on_time_rate:.0f}%", color=COLORS["positive"] if on_time_rate >= 90 else COLORS["warning"]), unsafe_allow_html=True)
st.markdown("---")
# ── Chart 1: Exchange Time Over Cycles ──
st.subheader("Exchange Time Over Cycles")
st.markdown("How exchange time evolves across reuse cycles as processes improve.")
cycles_range = list(range(1, reuse_cycles + 1))
exchange_times = [avg_exchange_days * (1.1 - 0.05 * i) for i in range(reuse_cycles)]
fig_exchange = go.Figure()
fig_exchange.add_trace(go.Scatter(
x=cycles_range, y=exchange_times,
mode="lines+markers",
line=dict(color=COLORS["primary"], width=3),
marker=dict(size=10),
name="Exchange Time",
text=[f"{t:.1f} days" for t in exchange_times],
textposition="top center",
))
fig_exchange.update_layout(
xaxis_title="Reuse Cycle",
yaxis_title="Exchange Time (days)",
showlegend=False,
xaxis=dict(dtick=1),
)
st.plotly_chart(fig_exchange, use_container_width=True)
st.markdown("---")
# ── Chart 2: Logistics Cost Breakdown per Cycle ──
st.subheader("Logistics Cost Breakdown per Cycle")
processing_cost = logistics_cost - delivery_cost - return_cost
processing_cost = max(0, processing_cost)
cycle_data = []
for cycle in range(1, reuse_cycles + 1):
cycle_data.append({
"Cycle": f"Cycle {cycle}",
"Delivery": delivery_cost,
"Return": return_cost,
"Processing": processing_cost if cycle > 1 else 0,
})
df_logistics = pd.DataFrame(cycle_data)
fig_logistics = go.Figure()
fig_logistics.add_trace(go.Bar(
name="Delivery", x=df_logistics["Cycle"], y=df_logistics["Delivery"],
marker_color=COLORS["primary"],
text=[f"{sfmt(v)} NOK" for v in df_logistics["Delivery"]], textposition="inside",
))
fig_logistics.add_trace(go.Bar(
name="Return", x=df_logistics["Cycle"], y=df_logistics["Return"],
marker_color=COLORS["secondary"],
text=[f"{sfmt(v)} NOK" for v in df_logistics["Return"]], textposition="inside",
))
fig_logistics.add_trace(go.Bar(
name="Processing", x=df_logistics["Cycle"], y=df_logistics["Processing"],
marker_color=COLORS["info"],
text=[f"{sfmt(v)} NOK" if v > 0 else "" for v in df_logistics["Processing"]], textposition="inside",
))
fig_logistics.update_layout(
barmode="stack",
yaxis_title="Cost (NOK)",
yaxis=dict(separatethousands=True),
)
st.plotly_chart(fig_logistics, use_container_width=True)
total_logistics_lifecycle = sum(df_logistics["Delivery"]) + sum(df_logistics["Return"]) + sum(df_logistics["Processing"])
st.caption(f"Total logistics cost over {reuse_cycles} cycles: **{sfmt(int(total_logistics_lifecycle))} NOK** per product")
# ============================================================
# VIEW: SOURCING (REDESIGN)
# ============================================================
elif selected_view == "Sourcing":
st.header("Sourcing")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For procurement, supply chain, and finance</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">Material cost optimization, recycled content impact, and waste reduction</p>', unsafe_allow_html=True)
foundation_banner()
# ── Sourcing calculations ──
recycled_target = st.session_state.sourcing_recycled_target / 100
closed_loop = st.session_state.sourcing_closed_loop / 100
production_eff = st.session_state.sourcing_production_eff / 100
has_bom = "materials" in config and "bom" in config.get("materials", {})
if has_bom:
bom = config["materials"]["bom"]
# Calculate material costs with recycled target applied
material_data = []
total_cost_current = 0
total_cost_virgin = 0
total_weight = 0
total_recycled_weight = 0
for comp_key, comp in bom.items():
weight = comp.get("weight_kg", 0)
total_weight += weight
if "virgin_cost_per_kg" in comp:
virgin_cpk = comp["virgin_cost_per_kg"]
recycled_cpk = comp.get("recycled_cost_per_kg", virgin_cpk)
# Apply the slider target to recyclable materials
effective_recycled = recycled_target if comp.get("recyclable", False) else comp.get("recycled_content_pct", 0)
blended_cpk = virgin_cpk * (1 - effective_recycled) + recycled_cpk * effective_recycled
cost = weight * blended_cpk
cost_virgin = weight * virgin_cpk
total_recycled_weight += weight * effective_recycled
else:
cpk = comp.get("cost_per_kg", 0)
cost = weight * cpk
cost_virgin = cost
blended_cpk = cpk
effective_recycled = 0
total_cost_current += cost
total_cost_virgin += cost_virgin
material_data.append({
"Component": comp.get("name", comp_key),
"Weight (kg)": weight,
"Cost/kg (NOK)": blended_cpk,
"Total (NOK)": cost,
"Recycled %": effective_recycled * 100,
"Recyclable": comp.get("recyclable", False),
})
combined_recycled_pct = (total_recycled_weight / total_weight * 100) if total_weight > 0 else 0
waste_rate = (1 - production_eff) * 100
cost_per_kg = total_cost_current / total_weight if total_weight > 0 else 0
# ── Hero: Material Cost per Unit ──
st.markdown(hero_card(
"Material Cost per Unit",
sfmt(int(total_cost_current)),
"NOK",
f"Based on {combined_recycled_pct:.0f}% recycled content target"
), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ── Supporting metrics ──
s1, s2, s3, s4 = st.columns(4)
with s1:
st.markdown(metric_card("Combined Recycled", f"{combined_recycled_pct:.0f}%", color=COLORS["positive"]), unsafe_allow_html=True)
with s2:
st.markdown(metric_card("Virgin Material Cost", sfmt(int(total_cost_virgin)), "NOK", COLORS["negative"]), unsafe_allow_html=True)
with s3:
st.markdown(metric_card("Waste Rate", f"{waste_rate:.0f}%", color=COLORS["warning"]), unsafe_allow_html=True)
with s4:
st.markdown(metric_card("Cost / kg", f"{sfmt(cost_per_kg, 1)}", "NOK"), unsafe_allow_html=True)
savings = total_cost_virgin - total_cost_current
if savings > 0:
st.success(f"Using {combined_recycled_pct:.0f}% recycled content saves **{sfmt(int(savings))} NOK** per unit vs 100% virgin materials ({savings/total_cost_virgin*100:.1f}% reduction)")
st.markdown("---")
# ── Chart 1: Recycled vs Virgin Cost ──
st.subheader("Recycled vs Virgin Cost Comparison")
fig_compare = go.Figure()
fig_compare.add_trace(go.Bar(
x=["100% Virgin", "Current Mix"],
y=[total_cost_virgin, total_cost_current],
marker_color=[COLORS["negative"], COLORS["positive"]],
text=[f"{sfmt(int(total_cost_virgin))} NOK", f"{sfmt(int(total_cost_current))} NOK"],
textposition="outside",
))
if savings > 0:
fig_compare.add_annotation(
x=1, y=total_cost_current + (total_cost_virgin - total_cost_current) / 2,
text=f"Saves {sfmt(int(savings))} NOK",
showarrow=True, arrowhead=2,
font=dict(size=14, color=COLORS["positive"]),
)
fig_compare.update_layout(
yaxis_title="Material Cost (NOK)",
showlegend=False,
yaxis=dict(separatethousands=True),
)
st.plotly_chart(fig_compare, use_container_width=True)
st.markdown("---")
# ── Chart 2: Recycled Content Impact by Material ──
st.subheader("Recycled Content by Material Type")
mat_df = pd.DataFrame(material_data)
recyclable_df = mat_df[mat_df["Recyclable"]].sort_values("Weight (kg)", ascending=True)
if not recyclable_df.empty:
fig_materials = go.Figure()
fig_materials.add_trace(go.Bar(
y=recyclable_df["Component"],
x=recyclable_df["Recycled %"],
orientation="h",
marker_color=COLORS["primary"],
text=[f"{v:.0f}%" for v in recyclable_df["Recycled %"]],
textposition="outside",
name="Recycled Content",
))
fig_materials.update_layout(
xaxis=dict(range=[0, 110], title="Recycled Content (%)"),
showlegend=False,
)
st.plotly_chart(fig_materials, use_container_width=True)
# BOM table
with st.expander("Full Bill of Materials"):
display_df = mat_df.copy()
display_df["Recyclable"] = display_df["Recyclable"].map({True: "Yes", False: "No"})
st.dataframe(display_df.style.format({
"Weight (kg)": "{:.1f}",
"Cost/kg (NOK)": "{:.1f}",
"Total (NOK)": "{:.0f}",
"Recycled %": "{:.0f}%",
}), hide_index=True)
else:
st.info("Add BOM data to product config for detailed sourcing analysis.")
# ============================================================
# VIEW: MARKET (REDESIGN)
# ============================================================
elif selected_view == "Market":
st.header("Market")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For marketing, sales, and business development</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">Customer retention, satisfaction, and subscription tier analysis</p>', unsafe_allow_html=True)
foundation_banner()
# ── Market calculations ──
quality_level = st.session_state.market_quality_level
pricing_discount = st.session_state.market_pricing_discount / 100
subscription_tier = st.session_state.market_subscription_tier
# Customer retention = 50% + (quality - 1) * 9%
retention_rate = min(100, 50 + (quality_level - 1) * 9)
satisfaction = min(10, 3 + quality_level * 1.1)
# Tier multipliers
tier_config = {
"Basic": {"price_mult": 0.75, "service_mult": 0.7, "share": 40},
"Standard": {"price_mult": 1.0, "service_mult": 1.0, "share": 45},
"Premium": {"price_mult": 1.35, "service_mult": 1.3, "share": 15},
}
tier = tier_config[subscription_tier]
effective_price = lease_price * tier["price_mult"] * (1 - pricing_discount)
revenue_per_customer_yr = effective_price * 12
market_penetration = min(25, 5 + quality_level * 2 + pricing_discount * 10)
exchange_rate = retention_rate * 0.85 # % who actually return product
# ── Hero: Customer Retention Rate ──
st.markdown(hero_card(
"Customer Retention Rate",
f"{retention_rate:.0f}%",
subtitle=f"Based on service quality level {quality_level}/6"
), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ── Supporting metrics ──
s1, s2, s3, s4 = st.columns(4)
with s1:
st.markdown(metric_card("Satisfaction", f"{satisfaction:.1f}", "/10"), unsafe_allow_html=True)
with s2:
st.markdown(metric_card("Revenue / Customer", sfmt(int(revenue_per_customer_yr)), "NOK/yr", COLORS["positive"]), unsafe_allow_html=True)
with s3:
st.markdown(metric_card("Market Penetration", f"{market_penetration:.0f}%", color=COLORS["info"]), unsafe_allow_html=True)
with s4:
st.markdown(metric_card("Exchange Rate", f"{exchange_rate:.0f}%", color=COLORS["secondary"]), unsafe_allow_html=True)
st.markdown("---")
# ── Chart 1: Quality Impact on Retention ──
st.subheader("Quality Impact on Retention")
quality_levels = list(range(1, 7))
retention_rates = [min(100, 50 + (q - 1) * 9) for q in quality_levels]
fig_retention = go.Figure()
fig_retention.add_trace(go.Scatter(
x=quality_levels, y=retention_rates,
mode="lines+markers",
line=dict(color=COLORS["primary"], width=3),
marker=dict(size=10),
name="Retention Rate",
))
# Highlight current level
fig_retention.add_trace(go.Scatter(
x=[quality_level], y=[retention_rate],
mode="markers+text",
marker=dict(size=16, color=COLORS["warning"], symbol="star"),
text=[f"{retention_rate:.0f}%"],
textposition="top center",
name="Current",
))
fig_retention.update_layout(
xaxis_title="Service Quality Level",
yaxis_title="Customer Retention (%)",
xaxis=dict(dtick=1),
yaxis=dict(range=[40, 105]),
showlegend=False,
)
st.plotly_chart(fig_retention, use_container_width=True)
st.markdown("---")
# ── Chart 2: Subscription Tier Distribution ──
st.subheader("Subscription Tier Distribution")
tier_names = ["Basic", "Standard", "Premium"]
tier_shares = [tier_config[t]["share"] for t in tier_names]
tier_prices = [lease_price * tier_config[t]["price_mult"] for t in tier_names]
tier_colors = [COLORS["info"], COLORS["primary"], COLORS["warning"]]
fig_tiers = go.Figure()
fig_tiers.add_trace(go.Bar(
x=tier_names,
y=tier_shares,
marker_color=tier_colors,
text=[f"{s}%<br>{sfmt(int(p))} NOK/mo" for s, p in zip(tier_shares, tier_prices)],
textposition="outside",
))
# Highlight selected tier
selected_idx = tier_names.index(subscription_tier)
fig_tiers.add_trace(go.Scatter(
x=[subscription_tier], y=[tier_shares[selected_idx]],
mode="markers",
marker=dict(size=14, color=COLORS["warning"], symbol="diamond"),
name="Selected",
showlegend=False,
))
fig_tiers.update_layout(
yaxis_title="Market Share (%)",
showlegend=False,
yaxis=dict(range=[0, 65]),
)
st.plotly_chart(fig_tiers, use_container_width=True)
# Customer value comparison
st.markdown("---")
st.subheader("Customer Value Proposition")
effective_annual = effective_price * 12
retail = config["product"].get("retail_price", 0)
use_years = config["design"].get("use_cycle_years", 3)
annual_ownership = retail / use_years if use_years > 0 else 0
v1, v2, v3 = st.columns(3)
with v1:
st.markdown(metric_card("Retail Price", sfmt(retail), "NOK", COLORS["negative"]), unsafe_allow_html=True)
with v2:
st.markdown(metric_card("LiiS Annual Cost", sfmt(int(effective_annual)), "NOK", COLORS["positive"]), unsafe_allow_html=True)
with v3:
saving = annual_ownership - effective_annual
if saving > 0:
st.markdown(metric_card("Annual Saving", sfmt(int(saving)), "NOK", COLORS["positive"]), unsafe_allow_html=True)
else:
st.markdown(metric_card("Premium", sfmt(int(-saving)), "NOK", COLORS["warning"]), unsafe_allow_html=True)
# ============================================================
# VIEW: SUSTAINABILITY (NEW)
# ============================================================
elif selected_view == "Sustainability":
st.header("Sustainability")
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0; font-size: 1.15rem;">For sustainability officers, ESG reporting, and regulatory compliance</p>', unsafe_allow_html=True)
st.markdown(f'<p style="color: {COLORS["text_muted"]}; margin: 0 0 1.5rem 0; font-size: 1.15rem;">CO2 savings, circular loop strategies, and regulatory mapping</p>', unsafe_allow_html=True)
foundation_banner()
# ── Sustainability calculations ──
sust_cycles = st.session_state.sust_reuse_cycles
sust_recycled = st.session_state.sust_recycled_content / 100
sust_efficiency = st.session_state.sust_production_eff / 100
portfolio_size = st.session_state.strategy_portfolio
# CO2 data from config
co2_mfg = config["environmental"]["co2_manufacturing_kg"]
co2_refurb = config["environmental"]["co2_refurbishment_kg"]
co2_logistics = config["environmental"]["co2_logistics_kg"]
# Linear model: N products, each manufactured new
linear_co2_total = portfolio_size * sust_cycles * co2_mfg
linear_cost_total = portfolio_size * sust_cycles * mfg_cost
# Circular model: 1st life = 100%, reuse = 20%, remanufacture = 40%, recycle = 70%
# Simplified: first cycle full mfg CO2, subsequent cycles much lower
circular_co2_per_product = co2_mfg # first life
for cycle in range(2, sust_cycles + 1):
circular_co2_per_product += co2_refurb + co2_logistics # reuse cycles
circular_co2_total = portfolio_size * circular_co2_per_product
circular_cost_total = portfolio_size * (mfg_cost + (sust_cycles - 1) * (avg_refurb_cost + logistics))
co2_savings_pct = ((linear_co2_total - circular_co2_total) / linear_co2_total * 100) if linear_co2_total > 0 else 0
cost_savings_pct = ((linear_cost_total - circular_cost_total) / linear_cost_total * 100) if linear_cost_total > 0 else 0
products_avoided = portfolio_size * (sust_cycles - 1)
material_recovery = return_rate * (grade_a_pct + grade_b_pct) * 100
co2_per_product = circular_co2_per_product
# Loop strategy breakdown
slowing_savings = co2_mfg * (sust_cycles - 1) * 0.80 # reuse avoids most mfg CO2
closing_savings = co2_mfg * sust_recycled * 0.30 # recycled content reduces virgin extraction
narrowing_savings = co2_mfg * sust_efficiency * 0.10 # efficiency reduces waste in production
# ── Hero: CO2 Savings vs Linear ──
st.markdown(hero_card(
"CO2 Savings vs Linear",
f"{co2_savings_pct:.0f}%",
subtitle=f"Circular model saves {sfmt(int(linear_co2_total - circular_co2_total))} kg CO2 across portfolio"
), unsafe_allow_html=True)
st.markdown('<div style="margin-top: 1.5rem;"></div>', unsafe_allow_html=True)
# ── Supporting metrics ──
s1, s2, s3, s4 = st.columns(4)
with s1:
st.markdown(metric_card("Cost Savings vs Linear", f"{cost_savings_pct:.0f}%", color=COLORS["positive"]), unsafe_allow_html=True)
with s2:
st.markdown(metric_card("Products Avoided", sfmt(products_avoided), "units", COLORS["positive"]), unsafe_allow_html=True)
with s3:
st.markdown(metric_card("Material Recovery", f"{material_recovery:.0f}%", color=COLORS["info"]), unsafe_allow_html=True)
with s4:
st.markdown(metric_card("CO2 / Product", f"{co2_per_product:.1f}", "kg", COLORS["secondary"]), unsafe_allow_html=True)
st.markdown("---")
# ── Chart 1: Linear vs Circular ──
st.subheader("Linear vs Circular Model Comparison")
fig_compare = go.Figure()
categories = ["CO2 Emissions (kg)", "Total Cost (NOK)"]
fig_compare.add_trace(go.Bar(
x=categories,
y=[linear_co2_total, linear_cost_total],
name="Linear (buy new each time)",
marker_color=COLORS["negative"],
text=[sfmt(int(linear_co2_total)), sfmt(int(linear_cost_total))],
textposition="outside",
))
fig_compare.add_trace(go.Bar(
x=categories,
y=[circular_co2_total, circular_cost_total],
name="Circular (reuse model)",
marker_color=COLORS["positive"],
text=[sfmt(int(circular_co2_total)), sfmt(int(circular_cost_total))],
textposition="outside",
))
fig_compare.update_layout(
barmode="group",
yaxis_title="Value",
yaxis=dict(separatethousands=True),
)
st.plotly_chart(fig_compare, use_container_width=True)
st.markdown("---")
# ── Chart 2: CO2 Savings by Loop Strategy (Waterfall) ──
st.subheader("CO2 Savings by Loop Strategy")
st.markdown("The three circular strategies: **Slowing** (reuse), **Closing** (recycled content), **Narrowing** (efficiency).")
fig_waterfall = go.Figure(go.Waterfall(
orientation="v",
measure=["absolute", "relative", "relative", "relative", "total"],
x=["Linear Baseline", "Slowing (Reuse)", "Closing (Recycling)", "Narrowing (Efficiency)", "Circular Total"],
y=[co2_mfg * sust_cycles, -slowing_savings, -closing_savings, -narrowing_savings, 0],
text=[f"{co2_mfg * sust_cycles:.1f}", f"-{slowing_savings:.1f}", f"-{closing_savings:.1f}", f"-{narrowing_savings:.1f}",
f"{co2_mfg * sust_cycles - slowing_savings - closing_savings - narrowing_savings:.1f}"],
textposition="outside",
connector={"line": {"color": COLORS["border"]}},
decreasing={"marker": {"color": COLORS["positive"]}},
increasing={"marker": {"color": COLORS["negative"]}},
totals={"marker": {"color": COLORS["info"]}},
))
fig_waterfall.update_layout(
yaxis_title="CO2 per Product (kg)",
showlegend=False,
)
st.plotly_chart(fig_waterfall, use_container_width=True)
st.caption(f"Per-product CO2: Linear = {co2_mfg * sust_cycles:.1f} kg, Circular = {co2_per_product:.1f} kg")
st.markdown("---")
# ── ESPR / EPR Regulatory Mapping ──
st.subheader("ESPR / EPR Regulatory Mapping")
st.markdown("How LiiS circular model maps to upcoming EU regulations.")
st.markdown(f"""
| Regulation | Requirement | LiiS Circular Model |
|------------|-------------|---------------------|
| **ESPR** (Ecodesign) | Product durability & repairability | {sust_cycles} reuse cycles, modular design |
| **ESPR** (Digital Product Passport) | Material traceability | BOM tracking, {sust_recycled*100:.0f}% recycled content |
| **EPR** (Extended Producer Responsibility) | End-of-life collection | {return_rate*100:.0f}% return rate, grade-based processing |
| **EU Taxonomy** | Circular economy contribution | {co2_savings_pct:.0f}% CO2 reduction vs linear |
| **CSRD** (Sustainability Reporting) | Environmental impact disclosure | Full lifecycle tracking per product |
""")
# Assumptions log
with st.expander("Simulation Assumptions"):
st.markdown(f"""
**CO2 Model:**
- Manufacturing: {co2_mfg} kg CO2 per new product
- Refurbishment: {co2_refurb} kg CO2 per cycle
- Logistics: {co2_logistics} kg CO2 per cycle
- Reuse cycles avoid ~80% of manufacturing CO2
- Recycled content reduces virgin extraction CO2 by ~30%
- Production efficiency reduces waste CO2 by ~10%
**Cost Model:**
- Linear: {sust_cycles} x manufacturing cost ({sfmt(mfg_cost)} NOK)
- Circular: 1x manufacturing + {sust_cycles - 1}x refurbishment ({sfmt(avg_refurb_cost)} NOK) + logistics ({sfmt(logistics)} NOK)
*These are simulated estimates for decision support, not audited figures.*
""")