Spaces:
Running
Running
Commit ·
bcf0c50
1
Parent(s): 406a69d
Overhaul Strategy page with new economic model
Browse files- Add 8 new sliders: linear price, lease price, mfg cost, fleet size,
utilization rate, logistics cost, repair cost/month, cycle length
- Add cumulative earnings over time chart (0-120 months)
- Add scenario comparison: save/clear scenarios feature
- Add breakeven analysis with marker on chart
- Add cycle transition markers (vertical dashed lines)
- Update hero metrics: breakeven month, monthly net revenue, 10yr profit
- Update Fleet Scale Economics to use new model
- Replace cycle sensitivity with cycle length analysis
- Switch Strategy view to NOK currency (other views remain EUR)
- Add model parameters summary expander
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
app.py
CHANGED
|
@@ -123,19 +123,73 @@ if "logistics" not in st.session_state:
|
|
| 123 |
if "max_cycles_override" not in st.session_state:
|
| 124 |
st.session_state.max_cycles_override = max_cycles
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
# Render sidebar based on selected view
|
| 127 |
if selected_view == "Strategy":
|
| 128 |
-
st.sidebar.header("💼 Strategy Controls")
|
| 129 |
-
st.
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
)
|
| 132 |
-
st.session_state.
|
| 133 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
)
|
| 135 |
-
st.session_state.
|
| 136 |
-
"
|
|
|
|
| 137 |
)
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
elif selected_view == "Design":
|
| 140 |
st.sidebar.header("🎨 Design Controls")
|
| 141 |
refurb_pct = st.sidebar.slider(
|
|
@@ -216,6 +270,68 @@ elif operating_margin >= 0:
|
|
| 216 |
else:
|
| 217 |
model_status, status_color = "Loss", "#e74c3c"
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
# ============================================================
|
| 220 |
# SHARED PRODUCT HEADER (compact)
|
| 221 |
# ============================================================
|
|
@@ -275,56 +391,218 @@ st.markdown("---")
|
|
| 275 |
# ============================================================
|
| 276 |
if selected_view == "Strategy":
|
| 277 |
st.header("Strategy: Business Model at Scale")
|
| 278 |
-
st.markdown("*For executives, investors, and board members*")
|
| 279 |
-
|
| 280 |
-
#
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
hero_cols = st.columns(4)
|
| 293 |
with hero_cols[0]:
|
| 294 |
-
st.metric(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
with hero_cols[1]:
|
| 296 |
-
st.metric(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
with hero_cols[2]:
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
with hero_cols[3]:
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
st.markdown("---")
|
| 303 |
|
| 304 |
# Fleet Scale Economics
|
| 305 |
st.subheader("Fleet Scale Economics: Volume Advantage")
|
| 306 |
|
|
|
|
| 307 |
fleet_sizes = [500, 1000, 2000, 5000]
|
| 308 |
scale_data = []
|
|
|
|
|
|
|
| 309 |
for fleet in fleet_sizes:
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
products_saved = customers_served - fleet
|
| 318 |
co2_saved = products_saved * co2_per_unit
|
| 319 |
-
profit_advantage = circular_profit - linear_profit
|
| 320 |
|
| 321 |
scale_data.append({
|
| 322 |
"Fleet Size": fleet,
|
| 323 |
"Fleet Label": f"{fleet:,}",
|
| 324 |
"Customers Served": customers_served,
|
| 325 |
-
"Circular Profit":
|
| 326 |
"Linear Profit": linear_profit,
|
| 327 |
-
"Profit Advantage":
|
| 328 |
"Products Saved": products_saved,
|
| 329 |
"CO2 Saved (kg)": co2_saved,
|
| 330 |
"Linear Products Needed": customers_served,
|
|
@@ -335,22 +613,20 @@ if selected_view == "Strategy":
|
|
| 335 |
scale_col1, scale_col2 = st.columns(2)
|
| 336 |
|
| 337 |
with scale_col1:
|
| 338 |
-
# Fixed chart: Show Profit Advantage as the primary metric
|
| 339 |
fig_advantage = go.Figure()
|
| 340 |
fig_advantage.add_trace(go.Bar(
|
| 341 |
x=scale_df["Fleet Label"],
|
| 342 |
y=scale_df["Profit Advantage"],
|
| 343 |
name="Circular Profit Advantage",
|
| 344 |
-
marker_color="#2ecc71",
|
| 345 |
-
text=[f"
|
| 346 |
textposition="outside",
|
| 347 |
))
|
| 348 |
fig_advantage.update_layout(
|
| 349 |
-
title="Profit Advantage: Circular vs Linear",
|
| 350 |
xaxis_title="Fleet Size",
|
| 351 |
-
yaxis_title="Extra Profit from Circular Model (
|
| 352 |
showlegend=False,
|
| 353 |
-
yaxis=dict(rangemode='tozero'),
|
| 354 |
)
|
| 355 |
st.plotly_chart(fig_advantage, use_container_width=True)
|
| 356 |
|
|
@@ -367,41 +643,86 @@ if selected_view == "Strategy":
|
|
| 367 |
text=[f"{v:,}" for v in scale_df["Linear Products Needed"]], textposition="outside",
|
| 368 |
))
|
| 369 |
fig_products.update_layout(
|
| 370 |
-
title="Products Needed to Serve Same Customers",
|
| 371 |
xaxis_title="Fleet Size Target", yaxis_title="Products to Manufacture",
|
| 372 |
barmode="group", legend=dict(orientation="h", yanchor="bottom", y=1.02),
|
| 373 |
yaxis=dict(rangemode='tozero'),
|
| 374 |
)
|
| 375 |
st.plotly_chart(fig_products, use_container_width=True)
|
| 376 |
|
| 377 |
-
# Business case summary
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
st.success(f"""
|
| 380 |
-
**The Business Case:** With **{
|
| 381 |
-
|
| 382 |
|
| 383 |
-
|
| 384 |
|
| 385 |
-
Environmental impact: **{
|
| 386 |
""")
|
| 387 |
|
| 388 |
st.markdown("---")
|
| 389 |
|
| 390 |
# Cycle Sensitivity
|
| 391 |
-
st.subheader("Sensitivity: Impact of
|
| 392 |
|
|
|
|
|
|
|
| 393 |
sensitivity_data = []
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
sensitivity_data.append({
|
| 402 |
-
"
|
| 403 |
-
"
|
| 404 |
-
"
|
|
|
|
|
|
|
|
|
|
| 405 |
})
|
| 406 |
|
| 407 |
sens_df = pd.DataFrame(sensitivity_data)
|
|
@@ -411,44 +732,67 @@ if selected_view == "Strategy":
|
|
| 411 |
with sens_col1:
|
| 412 |
fig_sens = go.Figure()
|
| 413 |
fig_sens.add_trace(go.Bar(
|
| 414 |
-
x=sens_df["
|
| 415 |
-
marker_color=["#
|
| 416 |
-
text=[f"
|
| 417 |
))
|
| 418 |
fig_sens.update_layout(
|
| 419 |
-
title="Profit
|
| 420 |
-
xaxis_title="
|
| 421 |
-
yaxis=dict(rangemode='tozero'),
|
| 422 |
)
|
| 423 |
-
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
st.plotly_chart(fig_sens, use_container_width=True)
|
| 426 |
|
| 427 |
with sens_col2:
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
x=sens_df["
|
| 431 |
marker_color="#3498db",
|
| 432 |
-
text=[f"
|
| 433 |
))
|
| 434 |
-
|
| 435 |
-
title="
|
| 436 |
-
xaxis_title="
|
| 437 |
yaxis=dict(rangemode='tozero'),
|
| 438 |
)
|
| 439 |
-
st.plotly_chart(
|
| 440 |
|
| 441 |
# Summary table
|
| 442 |
with st.expander("Full Scale Comparison Table"):
|
| 443 |
-
display_df = scale_df[["Fleet Label", "Customers Served", "Circular Profit", "Linear Profit",
|
| 444 |
"Profit Advantage", "Products Saved", "CO2 Saved (kg)"]].copy()
|
| 445 |
-
display_df.columns = ["Fleet", "Customers", "Circular Profit", "Linear Profit",
|
| 446 |
"Advantage", "Products Saved", "CO2 Saved"]
|
| 447 |
st.dataframe(display_df.style.format({
|
| 448 |
-
"Customers": "{:,}", "Circular Profit": "
|
| 449 |
-
"Advantage": "
|
| 450 |
}), use_container_width=True, hide_index=True)
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
# ============================================================
|
| 453 |
# VIEW: DESIGN
|
| 454 |
# ============================================================
|
|
|
|
| 123 |
if "max_cycles_override" not in st.session_state:
|
| 124 |
st.session_state.max_cycles_override = max_cycles
|
| 125 |
|
| 126 |
+
# Strategy view session state (NOK currency - approximately EUR × 10)
|
| 127 |
+
if "saved_scenarios" not in st.session_state:
|
| 128 |
+
st.session_state.saved_scenarios = []
|
| 129 |
+
if "strategy_linear_price" not in st.session_state:
|
| 130 |
+
st.session_state.strategy_linear_price = int(retail_price * 10) # EUR to NOK
|
| 131 |
+
if "strategy_lease_price" not in st.session_state:
|
| 132 |
+
st.session_state.strategy_lease_price = int(default_lease_price * 10) # EUR to NOK
|
| 133 |
+
if "strategy_mfg_cost" not in st.session_state:
|
| 134 |
+
st.session_state.strategy_mfg_cost = int(default_mfg_cost * 10) # EUR to NOK
|
| 135 |
+
if "strategy_fleet_size" not in st.session_state:
|
| 136 |
+
st.session_state.strategy_fleet_size = 1000
|
| 137 |
+
if "strategy_utilization" not in st.session_state:
|
| 138 |
+
st.session_state.strategy_utilization = 85
|
| 139 |
+
if "strategy_logistics_cost" not in st.session_state:
|
| 140 |
+
st.session_state.strategy_logistics_cost = int(default_logistics * 10) # EUR to NOK
|
| 141 |
+
if "strategy_repair_cost" not in st.session_state:
|
| 142 |
+
st.session_state.strategy_repair_cost = 50
|
| 143 |
+
if "strategy_cycle_length" not in st.session_state:
|
| 144 |
+
st.session_state.strategy_cycle_length = 24
|
| 145 |
+
|
| 146 |
# Render sidebar based on selected view
|
| 147 |
if selected_view == "Strategy":
|
| 148 |
+
st.sidebar.header("💼 Strategy Controls (NOK)")
|
| 149 |
+
st.sidebar.caption("Economic model for product lifespan profitability")
|
| 150 |
+
|
| 151 |
+
st.sidebar.subheader("Pricing")
|
| 152 |
+
st.session_state.strategy_linear_price = st.sidebar.slider(
|
| 153 |
+
"Linear Model Price", 500, 2000, st.session_state.strategy_linear_price, step=50, format="%d NOK",
|
| 154 |
+
help="What customers pay to buy outright"
|
| 155 |
)
|
| 156 |
+
st.session_state.strategy_lease_price = st.sidebar.slider(
|
| 157 |
+
"Monthly Lease Price", 100, 600, st.session_state.strategy_lease_price, step=10, format="%d NOK",
|
| 158 |
+
help="Monthly revenue per leased product"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
st.sidebar.subheader("Costs")
|
| 162 |
+
st.session_state.strategy_mfg_cost = st.sidebar.slider(
|
| 163 |
+
"Manufacturing Cost", 500, 2500, st.session_state.strategy_mfg_cost, step=50, format="%d NOK",
|
| 164 |
+
help="Cost to produce one unit"
|
| 165 |
+
)
|
| 166 |
+
st.session_state.strategy_logistics_cost = st.sidebar.slider(
|
| 167 |
+
"Logistics Cost", 50, 400, st.session_state.strategy_logistics_cost, step=10, format="%d NOK",
|
| 168 |
+
help="Cost between cycles (pickup + delivery)"
|
| 169 |
)
|
| 170 |
+
st.session_state.strategy_repair_cost = st.sidebar.slider(
|
| 171 |
+
"Repair Cost/Month", 10, 100, st.session_state.strategy_repair_cost, step=5, format="%d NOK",
|
| 172 |
+
help="Monthly degradation cost (wear & tear)"
|
| 173 |
)
|
| 174 |
|
| 175 |
+
st.sidebar.subheader("Fleet & Operations")
|
| 176 |
+
st.session_state.strategy_fleet_size = st.sidebar.slider(
|
| 177 |
+
"Fleet Size", 100, 10000, st.session_state.strategy_fleet_size, step=100,
|
| 178 |
+
help="Total products in fleet"
|
| 179 |
+
)
|
| 180 |
+
st.session_state.strategy_utilization = st.sidebar.slider(
|
| 181 |
+
"Utilization Rate", 50, 100, st.session_state.strategy_utilization, format="%d%%",
|
| 182 |
+
help="Percentage of fleet currently leased out"
|
| 183 |
+
)
|
| 184 |
+
st.session_state.strategy_cycle_length = st.sidebar.slider(
|
| 185 |
+
"Cycle Length", 12, 60, st.session_state.strategy_cycle_length, step=6, format="%d months",
|
| 186 |
+
help="Duration of one lease cycle"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# Keep EUR values in sync for other views (convert NOK back to EUR)
|
| 190 |
+
st.session_state.lease_price = st.session_state.strategy_lease_price // 10
|
| 191 |
+
st.session_state.mfg_cost = st.session_state.strategy_mfg_cost // 10
|
| 192 |
+
|
| 193 |
elif selected_view == "Design":
|
| 194 |
st.sidebar.header("🎨 Design Controls")
|
| 195 |
refurb_pct = st.sidebar.slider(
|
|
|
|
| 270 |
else:
|
| 271 |
model_status, status_color = "Loss", "#e74c3c"
|
| 272 |
|
| 273 |
+
|
| 274 |
+
# ============================================================
|
| 275 |
+
# STRATEGY: EARNINGS CALCULATION FUNCTION
|
| 276 |
+
# ============================================================
|
| 277 |
+
def calculate_cumulative_earnings(
|
| 278 |
+
fleet_size: int,
|
| 279 |
+
utilization_rate: float,
|
| 280 |
+
lease_price: float,
|
| 281 |
+
mfg_cost: float,
|
| 282 |
+
logistics_cost: float,
|
| 283 |
+
repair_cost_per_month: float,
|
| 284 |
+
cycle_length_months: int,
|
| 285 |
+
max_months: int = 120,
|
| 286 |
+
) -> pd.DataFrame:
|
| 287 |
+
"""
|
| 288 |
+
Calculate cumulative earnings over time for a circular lease model.
|
| 289 |
+
|
| 290 |
+
Returns DataFrame with columns: month, cumulative_profit, cycle_transitions
|
| 291 |
+
"""
|
| 292 |
+
products_in_use = fleet_size * utilization_rate
|
| 293 |
+
|
| 294 |
+
# Initial investment (manufacturing cost for entire fleet)
|
| 295 |
+
initial_investment = fleet_size * mfg_cost
|
| 296 |
+
|
| 297 |
+
data = []
|
| 298 |
+
cumulative_profit = -initial_investment # Start negative (investment)
|
| 299 |
+
cycle_transitions = 0
|
| 300 |
+
|
| 301 |
+
for month in range(max_months + 1):
|
| 302 |
+
if month == 0:
|
| 303 |
+
data.append({
|
| 304 |
+
"month": month,
|
| 305 |
+
"cumulative_profit": cumulative_profit,
|
| 306 |
+
"is_cycle_transition": False,
|
| 307 |
+
})
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
# Monthly revenue from products in use
|
| 311 |
+
monthly_revenue = products_in_use * lease_price
|
| 312 |
+
|
| 313 |
+
# Monthly degradation/repair cost
|
| 314 |
+
monthly_degradation = products_in_use * repair_cost_per_month
|
| 315 |
+
|
| 316 |
+
# Check for cycle transition (every cycle_length_months)
|
| 317 |
+
is_transition = (month % cycle_length_months == 0) and (month > 0)
|
| 318 |
+
transition_cost = 0
|
| 319 |
+
if is_transition:
|
| 320 |
+
# Logistics cost for the entire fleet at cycle end
|
| 321 |
+
transition_cost = fleet_size * logistics_cost
|
| 322 |
+
cycle_transitions += 1
|
| 323 |
+
|
| 324 |
+
# Update cumulative profit
|
| 325 |
+
cumulative_profit += monthly_revenue - monthly_degradation - transition_cost
|
| 326 |
+
|
| 327 |
+
data.append({
|
| 328 |
+
"month": month,
|
| 329 |
+
"cumulative_profit": cumulative_profit,
|
| 330 |
+
"is_cycle_transition": is_transition,
|
| 331 |
+
})
|
| 332 |
+
|
| 333 |
+
return pd.DataFrame(data)
|
| 334 |
+
|
| 335 |
# ============================================================
|
| 336 |
# SHARED PRODUCT HEADER (compact)
|
| 337 |
# ============================================================
|
|
|
|
| 391 |
# ============================================================
|
| 392 |
if selected_view == "Strategy":
|
| 393 |
st.header("Strategy: Business Model at Scale")
|
| 394 |
+
st.markdown("*For executives, investors, and board members — All values in NOK*")
|
| 395 |
+
|
| 396 |
+
# Get Strategy slider values
|
| 397 |
+
s_fleet_size = st.session_state.strategy_fleet_size
|
| 398 |
+
s_utilization = st.session_state.strategy_utilization / 100 # Convert to decimal
|
| 399 |
+
s_lease_price = st.session_state.strategy_lease_price
|
| 400 |
+
s_mfg_cost = st.session_state.strategy_mfg_cost
|
| 401 |
+
s_logistics = st.session_state.strategy_logistics_cost
|
| 402 |
+
s_repair_cost = st.session_state.strategy_repair_cost
|
| 403 |
+
s_cycle_length = st.session_state.strategy_cycle_length
|
| 404 |
+
s_linear_price = st.session_state.strategy_linear_price
|
| 405 |
+
|
| 406 |
+
# ============================================================
|
| 407 |
+
# EARNINGS OVER TIME CHART
|
| 408 |
+
# ============================================================
|
| 409 |
+
st.subheader("Cumulative Earnings Over Time")
|
| 410 |
+
|
| 411 |
+
# Calculate current scenario
|
| 412 |
+
max_display_months = 120
|
| 413 |
+
current_earnings = calculate_cumulative_earnings(
|
| 414 |
+
fleet_size=s_fleet_size,
|
| 415 |
+
utilization_rate=s_utilization,
|
| 416 |
+
lease_price=s_lease_price,
|
| 417 |
+
mfg_cost=s_mfg_cost,
|
| 418 |
+
logistics_cost=s_logistics,
|
| 419 |
+
repair_cost_per_month=s_repair_cost,
|
| 420 |
+
cycle_length_months=s_cycle_length,
|
| 421 |
+
max_months=max_display_months,
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
# Find breakeven month
|
| 425 |
+
breakeven_month = None
|
| 426 |
+
for idx, row in current_earnings.iterrows():
|
| 427 |
+
if row["cumulative_profit"] >= 0 and idx > 0:
|
| 428 |
+
breakeven_month = row["month"]
|
| 429 |
+
break
|
| 430 |
+
|
| 431 |
+
# Create the chart
|
| 432 |
+
fig_earnings = go.Figure()
|
| 433 |
+
|
| 434 |
+
# Add saved scenarios first (so current is on top)
|
| 435 |
+
scenario_colors = ["#9b59b6", "#e67e22", "#1abc9c", "#e74c3c", "#3498db"]
|
| 436 |
+
for i, scenario in enumerate(st.session_state.saved_scenarios):
|
| 437 |
+
color = scenario_colors[i % len(scenario_colors)]
|
| 438 |
+
fig_earnings.add_trace(go.Scatter(
|
| 439 |
+
x=scenario["data"]["month"],
|
| 440 |
+
y=scenario["data"]["cumulative_profit"],
|
| 441 |
+
mode="lines",
|
| 442 |
+
name=scenario["name"],
|
| 443 |
+
line=dict(color=color, width=2, dash="dot"),
|
| 444 |
+
opacity=0.7,
|
| 445 |
+
))
|
| 446 |
+
|
| 447 |
+
# Add current scenario
|
| 448 |
+
fig_earnings.add_trace(go.Scatter(
|
| 449 |
+
x=current_earnings["month"],
|
| 450 |
+
y=current_earnings["cumulative_profit"],
|
| 451 |
+
mode="lines",
|
| 452 |
+
name="Current Scenario",
|
| 453 |
+
line=dict(color="#2ecc71", width=3),
|
| 454 |
+
))
|
| 455 |
+
|
| 456 |
+
# Add breakeven marker
|
| 457 |
+
if breakeven_month:
|
| 458 |
+
breakeven_profit = current_earnings[current_earnings["month"] == breakeven_month]["cumulative_profit"].values[0]
|
| 459 |
+
fig_earnings.add_trace(go.Scatter(
|
| 460 |
+
x=[breakeven_month],
|
| 461 |
+
y=[breakeven_profit],
|
| 462 |
+
mode="markers+text",
|
| 463 |
+
name=f"Breakeven (Month {breakeven_month})",
|
| 464 |
+
marker=dict(size=12, color="#f1c40f", symbol="star"),
|
| 465 |
+
text=[f"Month {breakeven_month}"],
|
| 466 |
+
textposition="top center",
|
| 467 |
+
))
|
| 468 |
+
|
| 469 |
+
# Add cycle transition markers (vertical dashed lines)
|
| 470 |
+
for month in range(s_cycle_length, max_display_months + 1, s_cycle_length):
|
| 471 |
+
fig_earnings.add_vline(
|
| 472 |
+
x=month, line_dash="dash", line_color="rgba(255,255,255,0.3)",
|
| 473 |
+
annotation_text=f"Cycle {month // s_cycle_length}" if month <= s_cycle_length * 3 else None,
|
| 474 |
+
annotation_position="top",
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
# Add zero line (breakeven threshold)
|
| 478 |
+
fig_earnings.add_hline(y=0, line_color="rgba(255,255,255,0.5)", line_width=1)
|
| 479 |
+
|
| 480 |
+
fig_earnings.update_layout(
|
| 481 |
+
xaxis_title="Months",
|
| 482 |
+
yaxis_title="Cumulative Profit (NOK)",
|
| 483 |
+
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
|
| 484 |
+
hovermode="x unified",
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
st.plotly_chart(fig_earnings, use_container_width=True)
|
| 488 |
+
|
| 489 |
+
# Save/Clear scenario buttons
|
| 490 |
+
btn_col1, btn_col2, btn_col3 = st.columns([1, 1, 3])
|
| 491 |
+
with btn_col1:
|
| 492 |
+
if st.button("💾 Save Current Scenario", use_container_width=True):
|
| 493 |
+
scenario_num = len(st.session_state.saved_scenarios) + 1
|
| 494 |
+
st.session_state.saved_scenarios.append({
|
| 495 |
+
"name": f"Scenario {scenario_num}",
|
| 496 |
+
"params": {
|
| 497 |
+
"fleet_size": s_fleet_size,
|
| 498 |
+
"utilization": s_utilization,
|
| 499 |
+
"lease_price": s_lease_price,
|
| 500 |
+
"mfg_cost": s_mfg_cost,
|
| 501 |
+
"logistics": s_logistics,
|
| 502 |
+
"repair_cost": s_repair_cost,
|
| 503 |
+
"cycle_length": s_cycle_length,
|
| 504 |
+
},
|
| 505 |
+
"data": current_earnings.to_dict(),
|
| 506 |
+
})
|
| 507 |
+
st.rerun()
|
| 508 |
+
with btn_col2:
|
| 509 |
+
if st.button("🗑️ Clear All Scenarios", use_container_width=True):
|
| 510 |
+
st.session_state.saved_scenarios = []
|
| 511 |
+
st.rerun()
|
| 512 |
+
|
| 513 |
+
st.markdown("---")
|
| 514 |
+
|
| 515 |
+
# ============================================================
|
| 516 |
+
# HERO METRICS (updated for new model)
|
| 517 |
+
# ============================================================
|
| 518 |
+
# Calculate key metrics
|
| 519 |
+
products_in_use = s_fleet_size * s_utilization
|
| 520 |
+
monthly_net_revenue = products_in_use * (s_lease_price - s_repair_cost)
|
| 521 |
+
annual_profit = monthly_net_revenue * 12 - (s_fleet_size * s_logistics) # One cycle transition per year approx
|
| 522 |
+
|
| 523 |
+
# Linear model comparison: selling s_fleet_size products
|
| 524 |
+
linear_profit = s_fleet_size * (s_linear_price - s_mfg_cost)
|
| 525 |
+
|
| 526 |
+
# Minimum lifespan calculation (months to breakeven)
|
| 527 |
+
min_lifespan = breakeven_month if breakeven_month else "> 120"
|
| 528 |
|
| 529 |
hero_cols = st.columns(4)
|
| 530 |
with hero_cols[0]:
|
| 531 |
+
st.metric(
|
| 532 |
+
"Breakeven",
|
| 533 |
+
f"Month {breakeven_month}" if breakeven_month else "Not reached",
|
| 534 |
+
delta="profitable after" if breakeven_month else "needs adjustment",
|
| 535 |
+
help="When cumulative profit crosses zero"
|
| 536 |
+
)
|
| 537 |
with hero_cols[1]:
|
| 538 |
+
st.metric(
|
| 539 |
+
"Monthly Net Revenue",
|
| 540 |
+
f"{monthly_net_revenue:,.0f} NOK",
|
| 541 |
+
delta=f"{products_in_use:,.0f} products in use",
|
| 542 |
+
help="(Lease price - repair cost) × products in use"
|
| 543 |
+
)
|
| 544 |
with hero_cols[2]:
|
| 545 |
+
profit_at_120 = current_earnings[current_earnings["month"] == 120]["cumulative_profit"].values[0]
|
| 546 |
+
st.metric(
|
| 547 |
+
"Profit at Year 10",
|
| 548 |
+
f"{profit_at_120:,.0f} NOK",
|
| 549 |
+
delta="positive" if profit_at_120 > 0 else "loss",
|
| 550 |
+
help="Cumulative profit after 120 months"
|
| 551 |
+
)
|
| 552 |
with hero_cols[3]:
|
| 553 |
+
# Compare circular vs linear (10-year horizon)
|
| 554 |
+
circular_10yr = profit_at_120
|
| 555 |
+
linear_10yr = linear_profit # One-time sale
|
| 556 |
+
advantage = circular_10yr - linear_10yr
|
| 557 |
+
st.metric(
|
| 558 |
+
"vs Linear (10yr)",
|
| 559 |
+
f"{advantage:+,.0f} NOK",
|
| 560 |
+
delta="circular wins" if advantage > 0 else "linear wins",
|
| 561 |
+
help="Circular 10yr profit minus one-time linear sale profit"
|
| 562 |
+
)
|
| 563 |
|
| 564 |
st.markdown("---")
|
| 565 |
|
| 566 |
# Fleet Scale Economics
|
| 567 |
st.subheader("Fleet Scale Economics: Volume Advantage")
|
| 568 |
|
| 569 |
+
# Calculate 10-year profit for different fleet sizes using the new model
|
| 570 |
fleet_sizes = [500, 1000, 2000, 5000]
|
| 571 |
scale_data = []
|
| 572 |
+
co2_per_unit = config["environmental"]["co2_manufacturing_kg"]
|
| 573 |
+
|
| 574 |
for fleet in fleet_sizes:
|
| 575 |
+
# Calculate 10-year (120 month) earnings for this fleet size
|
| 576 |
+
earnings_df = calculate_cumulative_earnings(
|
| 577 |
+
fleet_size=fleet,
|
| 578 |
+
utilization_rate=s_utilization,
|
| 579 |
+
lease_price=s_lease_price,
|
| 580 |
+
mfg_cost=s_mfg_cost,
|
| 581 |
+
logistics_cost=s_logistics,
|
| 582 |
+
repair_cost_per_month=s_repair_cost,
|
| 583 |
+
cycle_length_months=s_cycle_length,
|
| 584 |
+
max_months=120,
|
| 585 |
+
)
|
| 586 |
+
circular_profit_10yr = earnings_df[earnings_df["month"] == 120]["cumulative_profit"].values[0]
|
| 587 |
+
|
| 588 |
+
# Linear model: one-time sale
|
| 589 |
+
linear_profit = fleet * (s_linear_price - s_mfg_cost)
|
| 590 |
+
|
| 591 |
+
# Number of cycles in 10 years
|
| 592 |
+
num_cycles = 120 // s_cycle_length
|
| 593 |
+
customers_served = fleet * num_cycles
|
| 594 |
+
|
| 595 |
+
# Products saved (circular reuses, linear needs one per customer)
|
| 596 |
products_saved = customers_served - fleet
|
| 597 |
co2_saved = products_saved * co2_per_unit
|
|
|
|
| 598 |
|
| 599 |
scale_data.append({
|
| 600 |
"Fleet Size": fleet,
|
| 601 |
"Fleet Label": f"{fleet:,}",
|
| 602 |
"Customers Served": customers_served,
|
| 603 |
+
"Circular Profit (10yr)": circular_profit_10yr,
|
| 604 |
"Linear Profit": linear_profit,
|
| 605 |
+
"Profit Advantage": circular_profit_10yr - linear_profit,
|
| 606 |
"Products Saved": products_saved,
|
| 607 |
"CO2 Saved (kg)": co2_saved,
|
| 608 |
"Linear Products Needed": customers_served,
|
|
|
|
| 613 |
scale_col1, scale_col2 = st.columns(2)
|
| 614 |
|
| 615 |
with scale_col1:
|
|
|
|
| 616 |
fig_advantage = go.Figure()
|
| 617 |
fig_advantage.add_trace(go.Bar(
|
| 618 |
x=scale_df["Fleet Label"],
|
| 619 |
y=scale_df["Profit Advantage"],
|
| 620 |
name="Circular Profit Advantage",
|
| 621 |
+
marker_color=["#2ecc71" if v > 0 else "#e74c3c" for v in scale_df["Profit Advantage"]],
|
| 622 |
+
text=[f"{v:,.0f} NOK" for v in scale_df["Profit Advantage"]],
|
| 623 |
textposition="outside",
|
| 624 |
))
|
| 625 |
fig_advantage.update_layout(
|
| 626 |
+
title="10-Year Profit Advantage: Circular vs Linear",
|
| 627 |
xaxis_title="Fleet Size",
|
| 628 |
+
yaxis_title="Extra Profit from Circular Model (NOK)",
|
| 629 |
showlegend=False,
|
|
|
|
| 630 |
)
|
| 631 |
st.plotly_chart(fig_advantage, use_container_width=True)
|
| 632 |
|
|
|
|
| 643 |
text=[f"{v:,}" for v in scale_df["Linear Products Needed"]], textposition="outside",
|
| 644 |
))
|
| 645 |
fig_products.update_layout(
|
| 646 |
+
title="Products Needed to Serve Same Customers (10yr)",
|
| 647 |
xaxis_title="Fleet Size Target", yaxis_title="Products to Manufacture",
|
| 648 |
barmode="group", legend=dict(orientation="h", yanchor="bottom", y=1.02),
|
| 649 |
yaxis=dict(rangemode='tozero'),
|
| 650 |
)
|
| 651 |
st.plotly_chart(fig_products, use_container_width=True)
|
| 652 |
|
| 653 |
+
# Business case summary using current fleet size
|
| 654 |
+
current_fleet_data = None
|
| 655 |
+
for row in scale_data:
|
| 656 |
+
if row["Fleet Size"] == s_fleet_size or row == scale_data[-1]:
|
| 657 |
+
current_fleet_data = row
|
| 658 |
+
if row["Fleet Size"] == s_fleet_size:
|
| 659 |
+
break
|
| 660 |
+
|
| 661 |
+
# If current fleet size not in list, calculate it
|
| 662 |
+
if current_fleet_data["Fleet Size"] != s_fleet_size:
|
| 663 |
+
current_10yr = current_earnings[current_earnings["month"] == 120]["cumulative_profit"].values[0]
|
| 664 |
+
num_cycles = 120 // s_cycle_length
|
| 665 |
+
customers_served = s_fleet_size * num_cycles
|
| 666 |
+
products_saved = customers_served - s_fleet_size
|
| 667 |
+
current_fleet_data = {
|
| 668 |
+
"Fleet Size": s_fleet_size,
|
| 669 |
+
"Customers Served": customers_served,
|
| 670 |
+
"Circular Profit (10yr)": current_10yr,
|
| 671 |
+
"Linear Profit": s_fleet_size * (s_linear_price - s_mfg_cost),
|
| 672 |
+
"Profit Advantage": current_10yr - s_fleet_size * (s_linear_price - s_mfg_cost),
|
| 673 |
+
"Products Saved": products_saved,
|
| 674 |
+
"CO2 Saved (kg)": products_saved * co2_per_unit,
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
st.success(f"""
|
| 678 |
+
**The Business Case:** With **{s_fleet_size:,} products** in our fleet at **{s_utilization*100:.0f}% utilization**,
|
| 679 |
+
we serve **{current_fleet_data['Customers Served']:,} families** over 10 years.
|
| 680 |
|
| 681 |
+
Circular model generates **{current_fleet_data['Profit Advantage']:+,.0f} NOK** compared to linear model.
|
| 682 |
|
| 683 |
+
Environmental impact: **{current_fleet_data['CO2 Saved (kg)']:,.0f} kg CO2 saved** ({current_fleet_data['Products Saved']:,} fewer products manufactured).
|
| 684 |
""")
|
| 685 |
|
| 686 |
st.markdown("---")
|
| 687 |
|
| 688 |
# Cycle Sensitivity
|
| 689 |
+
st.subheader("Sensitivity: Impact of Cycle Length")
|
| 690 |
|
| 691 |
+
# Test different cycle lengths
|
| 692 |
+
cycle_lengths = [12, 18, 24, 36, 48, 60]
|
| 693 |
sensitivity_data = []
|
| 694 |
+
|
| 695 |
+
for test_cycle in cycle_lengths:
|
| 696 |
+
# Calculate 10-year profit for this cycle length
|
| 697 |
+
earnings_df = calculate_cumulative_earnings(
|
| 698 |
+
fleet_size=s_fleet_size,
|
| 699 |
+
utilization_rate=s_utilization,
|
| 700 |
+
lease_price=s_lease_price,
|
| 701 |
+
mfg_cost=s_mfg_cost,
|
| 702 |
+
logistics_cost=s_logistics,
|
| 703 |
+
repair_cost_per_month=s_repair_cost,
|
| 704 |
+
cycle_length_months=test_cycle,
|
| 705 |
+
max_months=120,
|
| 706 |
+
)
|
| 707 |
+
profit_10yr = earnings_df[earnings_df["month"] == 120]["cumulative_profit"].values[0]
|
| 708 |
+
|
| 709 |
+
# Find breakeven
|
| 710 |
+
be_month = None
|
| 711 |
+
for idx, row in earnings_df.iterrows():
|
| 712 |
+
if row["cumulative_profit"] >= 0 and idx > 0:
|
| 713 |
+
be_month = row["month"]
|
| 714 |
+
break
|
| 715 |
+
|
| 716 |
+
num_cycles = 120 // test_cycle
|
| 717 |
+
num_transitions = num_cycles # Logistics cost at each cycle end
|
| 718 |
|
| 719 |
sensitivity_data.append({
|
| 720 |
+
"Cycle Length": f"{test_cycle}mo",
|
| 721 |
+
"Cycle Months": test_cycle,
|
| 722 |
+
"10yr Profit": profit_10yr,
|
| 723 |
+
"Breakeven Month": be_month if be_month else 120,
|
| 724 |
+
"Num Cycles": num_cycles,
|
| 725 |
+
"Total Logistics": num_transitions * s_fleet_size * s_logistics,
|
| 726 |
})
|
| 727 |
|
| 728 |
sens_df = pd.DataFrame(sensitivity_data)
|
|
|
|
| 732 |
with sens_col1:
|
| 733 |
fig_sens = go.Figure()
|
| 734 |
fig_sens.add_trace(go.Bar(
|
| 735 |
+
x=sens_df["Cycle Length"], y=sens_df["10yr Profit"],
|
| 736 |
+
marker_color=["#2ecc71" if x > 0 else "#e74c3c" for x in sens_df["10yr Profit"]],
|
| 737 |
+
text=[f"{v:,.0f} NOK" for v in sens_df["10yr Profit"]], textposition="outside",
|
| 738 |
))
|
| 739 |
fig_sens.update_layout(
|
| 740 |
+
title="10-Year Profit by Cycle Length",
|
| 741 |
+
xaxis_title="Cycle Length", yaxis_title="Cumulative Profit (NOK)",
|
|
|
|
| 742 |
)
|
| 743 |
+
# Mark current cycle length
|
| 744 |
+
current_cycle_label = f"{s_cycle_length}mo"
|
| 745 |
+
if current_cycle_label in sens_df["Cycle Length"].values:
|
| 746 |
+
fig_sens.add_vline(
|
| 747 |
+
x=sens_df[sens_df["Cycle Length"] == current_cycle_label].index[0],
|
| 748 |
+
line_dash="dash", line_color="#f1c40f",
|
| 749 |
+
annotation_text=f"Current: {s_cycle_length}mo"
|
| 750 |
+
)
|
| 751 |
st.plotly_chart(fig_sens, use_container_width=True)
|
| 752 |
|
| 753 |
with sens_col2:
|
| 754 |
+
fig_be = go.Figure()
|
| 755 |
+
fig_be.add_trace(go.Bar(
|
| 756 |
+
x=sens_df["Cycle Length"], y=sens_df["Breakeven Month"],
|
| 757 |
marker_color="#3498db",
|
| 758 |
+
text=[f"{v:.0f}mo" for v in sens_df["Breakeven Month"]], textposition="outside",
|
| 759 |
))
|
| 760 |
+
fig_be.update_layout(
|
| 761 |
+
title="Breakeven Month by Cycle Length",
|
| 762 |
+
xaxis_title="Cycle Length", yaxis_title="Months to Breakeven",
|
| 763 |
yaxis=dict(rangemode='tozero'),
|
| 764 |
)
|
| 765 |
+
st.plotly_chart(fig_be, use_container_width=True)
|
| 766 |
|
| 767 |
# Summary table
|
| 768 |
with st.expander("Full Scale Comparison Table"):
|
| 769 |
+
display_df = scale_df[["Fleet Label", "Customers Served", "Circular Profit (10yr)", "Linear Profit",
|
| 770 |
"Profit Advantage", "Products Saved", "CO2 Saved (kg)"]].copy()
|
| 771 |
+
display_df.columns = ["Fleet", "Customers (10yr)", "Circular Profit", "Linear Profit",
|
| 772 |
"Advantage", "Products Saved", "CO2 Saved"]
|
| 773 |
st.dataframe(display_df.style.format({
|
| 774 |
+
"Customers (10yr)": "{:,}", "Circular Profit": "{:,.0f} NOK", "Linear Profit": "{:,.0f} NOK",
|
| 775 |
+
"Advantage": "{:+,.0f} NOK", "Products Saved": "{:,}", "CO2 Saved": "{:,.0f} kg",
|
| 776 |
}), use_container_width=True, hide_index=True)
|
| 777 |
|
| 778 |
+
# Model parameters summary
|
| 779 |
+
with st.expander("Current Model Parameters"):
|
| 780 |
+
params_col1, params_col2, params_col3 = st.columns(3)
|
| 781 |
+
with params_col1:
|
| 782 |
+
st.markdown("**Pricing**")
|
| 783 |
+
st.write(f"- Linear Price: {s_linear_price:,} NOK")
|
| 784 |
+
st.write(f"- Monthly Lease: {s_lease_price:,} NOK")
|
| 785 |
+
with params_col2:
|
| 786 |
+
st.markdown("**Costs**")
|
| 787 |
+
st.write(f"- Manufacturing: {s_mfg_cost:,} NOK")
|
| 788 |
+
st.write(f"- Logistics/cycle: {s_logistics:,} NOK")
|
| 789 |
+
st.write(f"- Repair/month: {s_repair_cost:,} NOK")
|
| 790 |
+
with params_col3:
|
| 791 |
+
st.markdown("**Operations**")
|
| 792 |
+
st.write(f"- Fleet Size: {s_fleet_size:,}")
|
| 793 |
+
st.write(f"- Utilization: {s_utilization*100:.0f}%")
|
| 794 |
+
st.write(f"- Cycle Length: {s_cycle_length} months")
|
| 795 |
+
|
| 796 |
# ============================================================
|
| 797 |
# VIEW: DESIGN
|
| 798 |
# ============================================================
|