Spaces:
Running
Running
Download app.py from Liisdotcom/simulator: direct link, hf CLI and curl.
- Browser
- Download file 73.1 kB
-
https://huggingface.co/spaces/Liisdotcom/simulator/resolve/main/app.py
- Command line
-
hf download hf://spaces/Liisdotcom/simulator/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Liisdotcom/simulator/resolve/main/app.py
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 | | |
| Lease {sfmt(lease)} NOK/mo | | |
| Mfg cost {sfmt(mfg)} NOK | | |
| {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.* | |
| """) | |