""" 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' {unit}' if unit else "" subtitle_html = f'

{subtitle}

' if subtitle else "" return f"""

{label}

{value}{unit_html}

{subtitle_html}
""" def metric_card(label, value, unit="", color=None): """Smaller supporting metric card.""" c = color or COLORS["primary"] unit_html = f' {unit}' if unit else "" return f"""

{label}

{value}{unit_html}

""" 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("
", 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"""
Strategy: Portfolio {sfmt(portfolio)} units  |  Lease {sfmt(lease)} NOK/mo  |  Mfg cost {sfmt(mfg)} NOK  |  {cycles} reuse cycles
""", 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""" """, 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'', 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'

For C-level executives and business owners

', unsafe_allow_html=True) st.markdown(f'

Portfolio economics, break-even timeline, and operating margin

', 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('
', 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('
', 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'

{cycle_length_note}

', 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 {sfmt(initial_investment)} NOK — the upfront manufacturing spend — then steps up by {sfmt(cycle_cost_val)} NOK 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 month {breakeven_month}, and the portfolio is {sfmt(lead_gap)} NOK ahead by month {chart_months}.' else: be_lead += f' Cost still leads revenue after {chart_months} months at these settings.' st.markdown(f'

{be_lead}

', 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}
Revenue: %{customdata}", )) 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}
Cost: %{customdata}", )) 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"Break-even — month {breakeven_month}", 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" {gap_label}
profit gap", 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'

For product designers and sustainability officers

', unsafe_allow_html=True) st.markdown(f'

Recovery rate, repairability impact, and component failure analysis

', 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'

' f'Affected by: Expected Damage Rate, Repairability Score, Spare Parts Availability

', unsafe_allow_html=True) st.markdown('
', 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'

For operations managers and warehouse teams

', unsafe_allow_html=True) st.markdown(f'

Turnaround time, throughput capacity, and material waste analysis

', 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('
', 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'

For logistics managers and warehouse operations

', unsafe_allow_html=True) st.markdown(f'

Collection timing, exchange efficiency, and logistics cost breakdown

', 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('
', 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'

For procurement, supply chain, and finance

', unsafe_allow_html=True) st.markdown(f'

Material cost optimization, recycled content impact, and waste reduction

', 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('
', 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'

For marketing, sales, and business development

', unsafe_allow_html=True) st.markdown(f'

Customer retention, satisfaction, and subscription tier analysis

', 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('
', 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}%
{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'

For sustainability officers, ESG reporting, and regulatory compliance

', unsafe_allow_html=True) st.markdown(f'

CO2 savings, circular loop strategies, and regulatory mapping

', 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('
', 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.* """)