simulator / strategy_v2.py
mathiassandnes's picture
Claude Opus 4.5
Add LiiS brand theme and updated product config
15d717a
Raw History Blame Contribute Delete
16.9 kB
"""
LiiS Circular Economy Simulator - C-Level View (Draft)
Simplified executive dashboard focused on portfolio-level economics.
Run with: streamlit run strategy_v2.py
"""
import streamlit as st
import plotly.graph_objects as go
import pandas as pd
import plotly.io as pio
# ── 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_LAYOUT = dict(
paper_bgcolor=COLORS["bg"],
plot_bgcolor=COLORS["surface"],
font=dict(family="Futura, Century Gothic, Arial, sans-serif", color=COLORS["text"]),
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",
)
liis_template = pio.templates["plotly"]
liis_template.layout.update(LIIS_LAYOUT)
pio.templates["liis"] = liis_template
pio.templates.default = "liis"
# Page config
st.set_page_config(
page_title="LiiS Strategy",
page_icon="💼",
layout="wide",
)
# Custom CSS — LiiS brand
st.markdown(f"""
<style>
.main-header {{
background: linear-gradient(135deg, {COLORS['primary']} 0%, #1a6b7a 100%);
padding: 1.5rem 2rem;
border-radius: 12px;
margin-bottom: 1.5rem;
}}
.main-header h1 {{ color: white; margin: 0; font-size: 2rem; font-family: Futura, Century Gothic, Arial, sans-serif; }}
.main-header p {{ color: rgba(255,255,255,0.9); margin: 0.5rem 0 0 0; }}
[data-testid="stMetricValue"] {{ font-size: 2rem; color: {COLORS['primary']}; }}
.hero-metric {{
background: {COLORS['surface']};
padding: 1.5rem;
border-radius: 12px;
border-left: 4px solid {COLORS['primary']};
border: 1px solid {COLORS['border']};
border-left: 4px solid {COLORS['primary']};
margin-bottom: 1rem;
}}
.hero-metric h2 {{ color: {COLORS['primary']}; margin: 0; font-size: 2.5rem; }}
.hero-metric p {{ color: {COLORS['text_subtle']}; margin: 0.3rem 0 0 0; font-size: 0.9rem; }}
.tier-header {{
color: {COLORS['text_subtle']};
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}}
.comparison-card {{
background: {COLORS['surface']};
padding: 1.5rem;
border-radius: 12px;
text-align: center;
border: 1px solid {COLORS['border']};
}}
.winner {{ border: 2px solid {COLORS['primary']}; }}
</style>
""", unsafe_allow_html=True)
# Header
st.markdown("""
<div class="main-header">
<h1>LiiS Strategy Dashboard</h1>
<p>C-Level View — Portfolio Economics & Break-Even Analysis</p>
</div>
""", unsafe_allow_html=True)
# ============================================================
# SIDEBAR - INPUT CONTROLS (4 Sliders)
# ============================================================
st.sidebar.header("Portfolio Parameters")
st.sidebar.caption("Adjust to see impact on profitability")
portfolio_size = st.sidebar.slider(
"Product Portfolio",
min_value=50, max_value=1000, value=300, step=50,
help="Number of units in your fleet"
)
monthly_lease = st.sidebar.slider(
"Monthly Lease Price",
min_value=250, max_value=650, value=250, step=25,
format="%d NOK",
help="What customers pay per month"
)
mfg_cost = st.sidebar.slider(
"Manufacturing Cost",
min_value=2500, max_value=7500, value=2500, step=250,
format="%d NOK",
help="Cost to produce one unit"
)
reuse_cycles = st.sidebar.slider(
"Reuse Cycles",
min_value=2, max_value=10, value=3, step=1,
help="How many families use each product"
)
# ============================================================
# CALCULATIONS
# ============================================================
# Per-product economics
lease_term_months = 12 # Each cycle is 12 months
revenue_per_product = monthly_lease * lease_term_months * reuse_cycles
# Costs per product (simplified model)
logistics_per_cycle = 150 # NOK - pickup + delivery per cycle
repair_per_cycle = 200 # NOK - average repair/refurb per cycle
total_cost_per_product = mfg_cost + (logistics_per_cycle + repair_per_cycle) * reuse_cycles
blended_cost_per_product = total_cost_per_product / reuse_cycles
# Profit per product
profit_per_product = revenue_per_product - total_cost_per_product
operating_margin = (profit_per_product / revenue_per_product) * 100 if revenue_per_product > 0 else 0
# Portfolio level
revenue_portfolio = portfolio_size * revenue_per_product
cost_portfolio = portfolio_size * total_cost_per_product
blended_cost_portfolio = portfolio_size * blended_cost_per_product
profit_portfolio = portfolio_size * profit_per_product
# Break-even calculation (months)
monthly_revenue_portfolio = portfolio_size * monthly_lease
monthly_cost_portfolio = portfolio_size * (repair_per_cycle / lease_term_months) # Monthly degradation
initial_investment = portfolio_size * mfg_cost
# Cumulative profit over time to find break-even
breakeven_month = None
cumulative = -initial_investment
for month in range(1, 121):
# Add logistics cost at cycle transitions
if month % lease_term_months == 0:
cumulative -= portfolio_size * logistics_per_cycle
cumulative += monthly_revenue_portfolio - monthly_cost_portfolio
if cumulative >= 0 and breakeven_month is None:
breakeven_month = month
break
if breakeven_month is None:
breakeven_month = "> 120"
# Linear model comparison
linear_margin = 0.20 # 20% typical retail margin
linear_profit_per_sale = mfg_cost * linear_margin / (1 - linear_margin) # Selling at cost + margin
linear_revenue_per_product = mfg_cost / (1 - linear_margin)
linear_breakeven_units = int(initial_investment / linear_profit_per_sale) if linear_profit_per_sale > 0 else 999
# Time to sell that many units (assume 1 sale per unit in portfolio per cycle)
linear_breakeven_months = max(24, int(linear_breakeven_units / portfolio_size * lease_term_months))
# ============================================================
# TIER 1: PRIMARY METRICS
# ============================================================
st.markdown('<p class="tier-header">Tier 1 — Primary Metrics</p>', unsafe_allow_html=True)
hero_col1, hero_col2 = st.columns(2)
with hero_col1:
st.markdown(f"""
<div class="hero-metric">
<p>BREAK EVEN</p>
<h2>{breakeven_month} MONTHS</h2>
<p>For entire portfolio of {portfolio_size:,} units</p>
</div>
""", unsafe_allow_html=True)
with hero_col2:
profit_color = COLORS["positive"] if profit_portfolio > 0 else COLORS["negative"]
st.markdown(f"""
<div class="hero-metric">
<p>TOTAL PORTFOLIO PROFIT</p>
<h2 style="color: {profit_color}">{profit_portfolio:,.0f} NOK</h2>
<p>Net revenue across {portfolio_size:,} units over {reuse_cycles} cycles</p>
</div>
""", unsafe_allow_html=True)
st.markdown("---")
# ============================================================
# TIER 2: SUPPORTING METRICS
# ============================================================
st.markdown('<p class="tier-header">Tier 2 — Unit Economics</p>', unsafe_allow_html=True)
metric_col1, metric_col2, metric_col3 = st.columns(3)
with metric_col1:
st.metric(
"Revenue per Product",
f"{revenue_per_product:,.0f} NOK",
delta=f"{monthly_lease} × {lease_term_months}mo × {reuse_cycles} cycles",
help="Total revenue generated by one product over its lifetime"
)
st.metric(
"Revenue per Portfolio",
f"{revenue_portfolio:,.0f} NOK",
delta=f"{portfolio_size:,} units",
help="Total portfolio revenue"
)
with metric_col2:
st.metric(
"Blended Cost per Product",
f"{blended_cost_per_product:,.0f} NOK",
delta=f"Total {total_cost_per_product:,.0f} ÷ {reuse_cycles} cycles",
help="Average cost per cycle (manufacturing + logistics + repair)"
)
st.metric(
"Blended Cost per Portfolio",
f"{blended_cost_portfolio:,.0f} NOK",
delta=f"{portfolio_size:,} units",
help="Total portfolio blended cost"
)
with metric_col3:
margin_color = "normal" if operating_margin >= 50 else "off"
st.metric(
"Operating Margin",
f"{operating_margin:.0f}%",
delta="healthy" if operating_margin >= 50 else "review pricing",
delta_color=margin_color,
help="Profit / Revenue - same for all units"
)
st.metric(
"Profit per Product",
f"{profit_per_product:,.0f} NOK",
delta="lifetime profit",
help="Revenue - Total Cost per product"
)
st.markdown("---")
# ============================================================
# CHART 1: Break Even Comparison
# ============================================================
st.markdown('<p class="tier-header">Tier 2 — Model Comparison</p>', unsafe_allow_html=True)
st.subheader("Break Even: Circular vs Linear Model")
comp_col1, comp_col2 = st.columns(2)
with comp_col1:
st.markdown(f"""
<div class="comparison-card">
<p style="color: {COLORS['text_subtle']}; margin: 0;">LINEAR MODEL</p>
<h2 style="color: {COLORS['negative']}; margin: 0.5rem 0;">{linear_breakeven_months} MONTHS</h2>
<p style="color: {COLORS['text_muted']}; margin: 0;">{linear_margin*100:.0f}% margin</p>
<p style="color: {COLORS['text_subtle']}; font-size: 0.8rem; margin-top: 0.5rem;">Sell once, done</p>
</div>
""", unsafe_allow_html=True)
with comp_col2:
st.markdown(f"""
<div class="comparison-card winner">
<p style="color: {COLORS['text_subtle']}; margin: 0;">CIRCULAR MODEL</p>
<h2 style="color: {COLORS['primary']}; margin: 0.5rem 0;">{breakeven_month} MONTHS</h2>
<p style="color: {COLORS['primary']}; margin: 0;">{operating_margin:.0f}% margin</p>
<p style="color: {COLORS['text_subtle']}; font-size: 0.8rem; margin-top: 0.5rem;">Reuse {reuse_cycles}× per product</p>
</div>
""", unsafe_allow_html=True)
st.markdown("---")
# ============================================================
# CHART 2: Blended Cost vs Revenue Across Cycles
# ============================================================
st.subheader("Cost & Revenue Trajectory by Reuse Cycles")
cycles_range = list(range(2, 11))
chart_data = []
for c in cycles_range:
rev = monthly_lease * lease_term_months * c
total_cost = mfg_cost + (logistics_per_cycle + repair_per_cycle) * c
blended = total_cost / c
profit = rev - total_cost
margin = (profit / rev) * 100 if rev > 0 else 0
chart_data.append({
"Cycles": c,
"Revenue": rev,
"Total Cost": total_cost,
"Blended Cost": blended,
"Profit": profit,
"Margin": margin,
})
chart_df = pd.DataFrame(chart_data)
fig_cycles = go.Figure()
# Revenue line (constant per cycle × cycles)
fig_cycles.add_trace(go.Scatter(
x=chart_df["Cycles"],
y=chart_df["Revenue"],
mode="lines+markers",
name="Revenue per Product",
line=dict(color=COLORS["positive"], width=3),
marker=dict(size=8),
))
# Blended cost line (decreasing)
fig_cycles.add_trace(go.Scatter(
x=chart_df["Cycles"],
y=chart_df["Blended Cost"],
mode="lines+markers",
name="Blended Cost per Product",
line=dict(color=COLORS["negative"], width=3),
marker=dict(size=8),
))
# Total cost line
fig_cycles.add_trace(go.Scatter(
x=chart_df["Cycles"],
y=chart_df["Total Cost"],
mode="lines+markers",
name="Total Cost per Product",
line=dict(color=COLORS["negative"], width=2, dash="dot"),
marker=dict(size=6),
opacity=0.6,
))
# Mark current cycles
fig_cycles.add_vline(
x=reuse_cycles, line_dash="dash", line_color=COLORS["warning"],
annotation_text=f"Current: {reuse_cycles} cycles",
annotation_position="top"
)
fig_cycles.update_layout(
xaxis_title="Reuse Cycles",
yaxis_title="NOK per Product",
legend=dict(orientation="h", yanchor="bottom", y=1.02),
hovermode="x unified",
)
st.plotly_chart(fig_cycles, use_container_width=True)
st.info(f"**Key insight:** More cycles = lower blended cost = higher profit. At {reuse_cycles} cycles, blended cost is {blended_cost_per_product:,.0f} NOK vs {mfg_cost:,} NOK manufacturing cost.")
st.markdown("---")
# ============================================================
# TIER 3: AT SCALE
# ============================================================
st.markdown('<p class="tier-header">Tier 3 — Scale Understanding</p>', unsafe_allow_html=True)
st.subheader("At Scale: Customer Base Projection")
scale_col1, scale_col2 = st.columns([1, 2])
with scale_col1:
customer_base = st.slider(
"Customer Base",
min_value=500, max_value=10000, value=1000, step=100,
help="Target number of active customers"
)
utilization_rate = 0.80 # 80% of fleet in use at any time
fleet_needed = int(customer_base / utilization_rate)
annual_revenue_scale = customer_base * monthly_lease * lease_term_months
total_cost_scale = fleet_needed * blended_cost_per_product
annual_profit_scale = annual_revenue_scale - total_cost_scale
st.metric("Fleet Size Needed", f"{fleet_needed:,} units", delta=f"{utilization_rate*100:.0f}% utilization")
st.metric("Annual Revenue", f"{annual_revenue_scale:,.0f} NOK", delta=f"{customer_base:,} customers")
st.metric("Total Blended Cost", f"{total_cost_scale:,.0f} NOK", delta=f"{fleet_needed:,} units")
profit_delta = "profit" if annual_profit_scale > 0 else "loss"
st.metric("Annual Profit", f"{annual_profit_scale:,.0f} NOK", delta=profit_delta)
with scale_col2:
# Chart 3: 5-Year Cumulative Profit
st.markdown("**5-Year Cumulative Profit Trajectory**")
# Calculate with 10% utilization decline per cycle
months = list(range(0, 61))
cumulative_profit = []
cumulative = -fleet_needed * mfg_cost # Initial investment
current_utilization = utilization_rate
for month in months:
if month == 0:
cumulative_profit.append(cumulative)
continue
# Decline utilization at each cycle end
if month > 0 and month % lease_term_months == 0:
current_utilization = max(0.5, current_utilization - 0.10)
cumulative -= fleet_needed * logistics_per_cycle # Logistics at cycle transition
active_customers = int(fleet_needed * current_utilization)
monthly_rev = active_customers * monthly_lease
monthly_cost = active_customers * (repair_per_cycle / lease_term_months)
cumulative += monthly_rev - monthly_cost
cumulative_profit.append(cumulative)
fig_trajectory = go.Figure()
fig_trajectory.add_trace(go.Scatter(
x=months,
y=cumulative_profit,
mode="lines",
name="Cumulative Profit",
line=dict(color=COLORS["positive"], width=3),
fill="tozeroy",
fillcolor="rgba(18, 75, 95, 0.15)",
))
# Add zero line
fig_trajectory.add_hline(y=0, line_color=COLORS["border"], line_width=1)
# Mark cycle transitions
for cycle_month in range(lease_term_months, 61, lease_term_months):
fig_trajectory.add_vline(
x=cycle_month, line_dash="dot", line_color=COLORS["border"],
)
# Find and mark breakeven
for i, cp in enumerate(cumulative_profit):
if cp >= 0 and i > 0:
fig_trajectory.add_trace(go.Scatter(
x=[i], y=[cp],
mode="markers+text",
name="Breakeven",
marker=dict(size=12, color=COLORS["warning"], symbol="star"),
text=["Breakeven"],
textposition="top center",
))
break
fig_trajectory.update_layout(
xaxis_title="Months",
yaxis_title="Cumulative Profit (NOK)",
showlegend=False,
yaxis=dict(tickformat=","),
)
st.plotly_chart(fig_trajectory, use_container_width=True)
final_profit = cumulative_profit[-1]
st.caption(f"5-year projection with {fleet_needed:,} unit fleet. Final cumulative profit: **{final_profit:,.0f} NOK**")
# ============================================================
# FOOTER
# ============================================================
st.markdown("---")
st.caption(f"Portfolio: {portfolio_size:,} units | Lease: {monthly_lease} NOK/mo | Mfg: {mfg_cost:,} NOK | Cycles: {reuse_cycles}")