""" 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}{label}
{value}{unit_html}
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}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}%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.* """)