Update main.py
Browse files
main.py
CHANGED
|
@@ -1,313 +1,313 @@
|
|
| 1 |
-
import pandas as pd
|
| 2 |
-
import numpy as np
|
| 3 |
-
import random
|
| 4 |
-
import copy
|
| 5 |
-
import optuna
|
| 6 |
-
import matplotlib.pyplot as plt
|
| 7 |
-
import seaborn as sns
|
| 8 |
-
from skopt import gp_minimize
|
| 9 |
-
from skopt.space import Real, Categorical
|
| 10 |
-
from skopt.utils import use_named_args
|
| 11 |
-
from statsmodels.tsa.arima.model import ARIMA
|
| 12 |
-
import numpy_financial as npf
|
| 13 |
-
|
| 14 |
-
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
| 15 |
-
|
| 16 |
-
PROJECT_YEARS = 15
|
| 17 |
-
BASE_CAPACITY_KTA = 300
|
| 18 |
-
INFLATION_RATE = 0.015
|
| 19 |
-
TAX_RATE = 0.10
|
| 20 |
-
DEPRECIATION_YEARS = 15
|
| 21 |
-
|
| 22 |
-
TECHNOLOGY_DATA = {
|
| 23 |
-
"JNC": {"capex_base_M": 180.0, "opex_base_cents_kg": 150.0},
|
| 24 |
-
"Hoechst_AG": {"capex_base_M": 230.0, "opex_base_cents_kg": 155.0},
|
| 25 |
-
"BF_Goodrich": {"capex_base_M": 280.0, "opex_base_cents_kg": 160.0},
|
| 26 |
-
"Shin_Etsu_1991": {"capex_base_M": 260.0, "opex_base_cents_kg": 155.0},
|
| 27 |
-
"Shin_Etsu_2004": {"capex_base_M": 1500.0, "opex_base_cents_kg": 150.0},
|
| 28 |
-
"Vinnolit": {"capex_base_M": 240.0, "opex_base_cents_kg": 155.0},
|
| 29 |
-
"QVC_Qatar": {"capex_base_M": 200.0, "opex_base_cents_kg": 145.0},
|
| 30 |
-
"SP_Chemicals": {"capex_base_M": 250.0, "opex_base_cents_kg": 145.0},
|
| 31 |
-
"Engro_Pakistan": {"capex_base_M": 1400.0, "opex_base_cents_kg": 155.0},
|
| 32 |
-
"Formosa_BR_USA": {"capex_base_M": 380.0, "opex_base_cents_kg": 150.0},
|
| 33 |
-
"Shintech_USA_Exp": {"capex_base_M": 1700.0, "opex_base_cents_kg": 160.0},
|
| 34 |
-
"Zhongtai_China": {"capex_base_M": 1100.0, "opex_base_cents_kg": 155.0},
|
| 35 |
-
"Shintech_USA_2021": {"capex_base_M": 1700.0, "opex_base_cents_kg": 160.0},
|
| 36 |
-
"Reliance_India_2024": {"capex_base_M": 2200.0, "opex_base_cents_kg": 155.0},
|
| 37 |
-
"Orbia_Germany_2023": {"capex_base_M": 180.0, "opex_base_cents_kg": 155.0},
|
| 38 |
-
"Westlake_USA_2022": {"capex_base_M": 850.0, "opex_base_cents_kg": 160.0}
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
STRATEGY_DATA = {
|
| 42 |
-
'Integrated_Production': {'sourcing_cost_per_ton_pvc': 450.0, 'byproducts': {'caustic_soda_ton': 1.1, 'surplus_edc_ton': 0.523}},
|
| 43 |
-
'Purchase_VCM': {'sourcing_cost_per_ton_pvc': 650.0, 'byproducts': {'caustic_soda_ton': 0, 'surplus_edc_ton': 0}}
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
PRODUCT_PRICES_USD_PER_TON = {
|
| 47 |
-
'pvc_s65_export': 1100, 'pvc_s70_domestic': 950,
|
| 48 |
-
'byproduct_caustic_soda': 450, 'byproduct_surplus_edc': 170
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
OPTIMIZATION_SPACE = {
|
| 52 |
-
'capacity_kta': (500, 600),
|
| 53 |
-
'technology': ["Engro_Pakistan"], #, "Shin_Etsu_2004"],
|
| 54 |
-
'sourcing_strategy': ['Integrated_Production'],
|
| 55 |
-
'export_market_mix': (0.6, 0.8),
|
| 56 |
-
'sell_byproducts': [True]
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
def forecast_prices(base_price, years=PROJECT_YEARS, cagr=0.04):
|
| 60 |
-
historical = [base_price * (1 + cagr + random.uniform(-0.03, 0.03)) ** i for i in range(-5, 0)]
|
| 61 |
-
model = ARIMA(historical, order=(1, 1, 0))
|
| 62 |
-
fit = model.fit()
|
| 63 |
-
forecast = fit.forecast(steps=years)
|
| 64 |
-
return [p * (1 + INFLATION_RATE) ** i for i, p in enumerate(forecast)]
|
| 65 |
-
|
| 66 |
-
def calculate_project_kpis(**params):
|
| 67 |
-
tech_data = TECHNOLOGY_DATA[params['technology']]
|
| 68 |
-
scaling_factor = (params['capacity_kta'] / BASE_CAPACITY_KTA) ** 0.65
|
| 69 |
-
total_capex = tech_data['capex_base_M'] * scaling_factor * 1_000_000
|
| 70 |
-
capacity_tons = params['capacity_kta'] * 1000
|
| 71 |
-
|
| 72 |
-
price_s65_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['pvc_s65_export'])
|
| 73 |
-
price_s70_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['pvc_s70_domestic'])
|
| 74 |
-
byproduct_caustic_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['byproduct_caustic_soda'])
|
| 75 |
-
byproduct_edc_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['byproduct_surplus_edc'])
|
| 76 |
-
|
| 77 |
-
cash_flows = [-total_capex]
|
| 78 |
-
depreciation_annual = total_capex / DEPRECIATION_YEARS
|
| 79 |
-
|
| 80 |
-
for year in range(1, PROJECT_YEARS + 1):
|
| 81 |
-
infl_factor = (1 + INFLATION_RATE) ** (year - 1)
|
| 82 |
-
price_s65 = price_s65_forecast[year - 1]
|
| 83 |
-
price_s70 = price_s70_forecast[year - 1] * 0.95 if params['capacity_kta'] >= 550 else price_s70_forecast[year - 1]
|
| 84 |
-
revenue_export = (capacity_tons * params['export_market_mix']) * price_s65
|
| 85 |
-
revenue_domestic = (capacity_tons * (1 - params['export_market_mix'])) * price_s70
|
| 86 |
-
pvc_revenue = revenue_export + revenue_domestic
|
| 87 |
-
|
| 88 |
-
byproduct_revenue = 0
|
| 89 |
-
if params['sourcing_strategy'] == 'Integrated_Production' and params['sell_byproducts']:
|
| 90 |
-
byproducts = STRATEGY_DATA['Integrated_Production']['byproducts']
|
| 91 |
-
byproduct_revenue += (byproducts['caustic_soda_ton'] * capacity_tons * byproduct_caustic_forecast[year - 1])
|
| 92 |
-
byproduct_revenue += (byproducts['surplus_edc_ton'] * capacity_tons * byproduct_edc_forecast[year - 1])
|
| 93 |
-
|
| 94 |
-
total_revenue = pvc_revenue + byproduct_revenue
|
| 95 |
-
|
| 96 |
-
opex_sourcing = STRATEGY_DATA['Integrated_Production']['sourcing_cost_per_ton_pvc'] * capacity_tons * infl_factor
|
| 97 |
-
opex_base = (tech_data['opex_base_cents_kg'] / 100) * capacity_tons * infl_factor
|
| 98 |
-
total_opex = opex_sourcing + opex_base
|
| 99 |
-
|
| 100 |
-
ebitda = total_revenue - total_opex
|
| 101 |
-
taxable_income = ebitda - depreciation_annual
|
| 102 |
-
taxes = max(taxable_income * TAX_RATE, 0)
|
| 103 |
-
net_income = taxable_income - taxes
|
| 104 |
-
free_cash_flow = net_income + depreciation_annual
|
| 105 |
-
cash_flows.append(free_cash_flow)
|
| 106 |
-
|
| 107 |
-
irr = npf.irr(cash_flows) * 100 if npf.irr(cash_flows) > 0 else -1.0
|
| 108 |
-
cumulative_cash_flow = np.cumsum(cash_flows)
|
| 109 |
-
payback_period_years = np.where(cumulative_cash_flow > 0)[0]
|
| 110 |
-
payback_period = payback_period_years[0] + 1 if len(payback_period_years) > 0 else float('inf')
|
| 111 |
-
annual_profit = np.mean([cf for cf in cash_flows[1:] if cf > 0])
|
| 112 |
-
|
| 113 |
-
return {"irr": irr, "annual_profit": annual_profit, "total_capex": total_capex, "payback_period": payback_period}
|
| 114 |
-
|
| 115 |
-
def run_optimizations_without_ml():
|
| 116 |
-
print("\n--- Running Optimization Algorithms ---")
|
| 117 |
-
results = []
|
| 118 |
-
results.append(run_bayesian_optimization())
|
| 119 |
-
results.append(run_genetic_algorithm())
|
| 120 |
-
results.append(run_optuna_direct())
|
| 121 |
-
return results
|
| 122 |
-
|
| 123 |
-
def run_genetic_algorithm():
|
| 124 |
-
print("Running Genetic Algorithm (GA)...")
|
| 125 |
-
population = [{k: random.choice(v) if isinstance(v, list) else random.uniform(*v) for k,v in OPTIMIZATION_SPACE.items()} for _ in range(50)]
|
| 126 |
-
best_overall_individual = None
|
| 127 |
-
best_overall_fitness = -float('inf')
|
| 128 |
-
for _ in range(
|
| 129 |
-
fitnesses = [calculate_project_kpis(**ind)['irr'] for ind in population]
|
| 130 |
-
if max(fitnesses) > best_overall_fitness:
|
| 131 |
-
best_overall_fitness = max(fitnesses)
|
| 132 |
-
best_overall_individual = population[np.argmax(fitnesses)]
|
| 133 |
-
selected = [max(random.sample(list(zip(population, fitnesses)), 5), key=lambda i: i[1])[0] for _ in range(50)]
|
| 134 |
-
next_gen = []
|
| 135 |
-
for i in range(0, 50, 2):
|
| 136 |
-
p1, p2 = selected[i], selected[i+1]
|
| 137 |
-
c1, c2 = copy.deepcopy(p1), copy.deepcopy(p2)
|
| 138 |
-
if random.random() < 0.9: c1['technology'], c2['technology'] = p2['technology'], c1['technology']
|
| 139 |
-
if random.random() < 0.2: c1['export_market_mix'] = random.uniform(*OPTIMIZATION_SPACE['export_market_mix'])
|
| 140 |
-
next_gen.extend([c1, c2])
|
| 141 |
-
population = next_gen
|
| 142 |
-
kpis = calculate_project_kpis(**best_overall_individual)
|
| 143 |
-
return {"Method": "Genetic Algorithm", **kpis, "Params": best_overall_individual}
|
| 144 |
-
|
| 145 |
-
def run_bayesian_optimization():
|
| 146 |
-
print("Running Bayesian Optimization...")
|
| 147 |
-
skopt_space = [
|
| 148 |
-
Real(OPTIMIZATION_SPACE['capacity_kta'][0], OPTIMIZATION_SPACE['capacity_kta'][1], name='capacity_kta'),
|
| 149 |
-
Categorical(OPTIMIZATION_SPACE['technology'], name='technology'),
|
| 150 |
-
Categorical(OPTIMIZATION_SPACE['sourcing_strategy'], name='sourcing_strategy'),
|
| 151 |
-
Real(OPTIMIZATION_SPACE['export_market_mix'][0], OPTIMIZATION_SPACE['export_market_mix'][1], name='export_market_mix'),
|
| 152 |
-
Categorical(OPTIMIZATION_SPACE['sell_byproducts'], name='sell_byproducts')
|
| 153 |
-
]
|
| 154 |
-
@use_named_args(skopt_space)
|
| 155 |
-
def objective(**params):
|
| 156 |
-
return -calculate_project_kpis(**params)['irr']
|
| 157 |
-
res = gp_minimize(objective, skopt_space, n_calls=100, random_state=42, n_initial_points=20)
|
| 158 |
-
best_params = {space.name: val for space, val in zip(skopt_space, res.x)}
|
| 159 |
-
kpis = calculate_project_kpis(**best_params)
|
| 160 |
-
return {"Method": "Bayesian Opt", **kpis, "Params": best_params}
|
| 161 |
-
|
| 162 |
-
def run_optuna_direct():
|
| 163 |
-
print("Running Optuna (TPE)...")
|
| 164 |
-
def objective(trial):
|
| 165 |
-
params = {
|
| 166 |
-
"capacity_kta": trial.suggest_float("capacity_kta", *OPTIMIZATION_SPACE['capacity_kta']),
|
| 167 |
-
"technology": trial.suggest_categorical("technology", OPTIMIZATION_SPACE['technology']),
|
| 168 |
-
"sourcing_strategy": trial.suggest_categorical("sourcing_strategy", OPTIMIZATION_SPACE['sourcing_strategy']),
|
| 169 |
-
"export_market_mix": trial.suggest_float("export_market_mix", *OPTIMIZATION_SPACE['export_market_mix']),
|
| 170 |
-
"sell_byproducts": trial.suggest_categorical("sell_byproducts", OPTIMIZATION_SPACE['sell_byproducts'])
|
| 171 |
-
}
|
| 172 |
-
return calculate_project_kpis(**params)['irr']
|
| 173 |
-
study = optuna.create_study(direction="maximize")
|
| 174 |
-
study.optimize(objective, n_trials=
|
| 175 |
-
kpis = calculate_project_kpis(**study.best_params)
|
| 176 |
-
return {"Method": "Optuna (TPE - Direct)", **kpis, "Params": study.best_params}
|
| 177 |
-
|
| 178 |
-
def display_and_save_results(df_results):
|
| 179 |
-
print("\n--- Final Results and Comparison ---")
|
| 180 |
-
df_display = pd.DataFrame()
|
| 181 |
-
df_display['Method'] = df_results['Method']
|
| 182 |
-
df_display['Optimal IRR (%)'] = df_results['irr'].map('{:,.2f}%'.format)
|
| 183 |
-
df_display['Annual Profit ($M)'] = (df_results['annual_profit'] / 1_000_000).map('{:,.1f}'.format)
|
| 184 |
-
df_display['CAPEX ($M)'] = (df_results['total_capex'] / 1_000_000).map('{:,.1f}'.format)
|
| 185 |
-
df_display['Payback (Yrs)'] = df_results['payback_period'].map('{:,.1f}'.format)
|
| 186 |
-
param_df = pd.DataFrame(df_results['Params'].tolist())
|
| 187 |
-
param_df['capacity_kta'] = param_df['capacity_kta'].round(1)
|
| 188 |
-
param_df['export_market_mix'] = (param_df['export_market_mix'] * 100).round(1).astype(str) + '%'
|
| 189 |
-
df_display = pd.concat([df_display, param_df.rename(columns={
|
| 190 |
-
'capacity_kta': 'Capacity (KTA)', 'technology': 'Technology', 'sourcing_strategy': 'Sourcing',
|
| 191 |
-
'export_market_mix': 'Export Mix', 'sell_byproducts': 'Sell Byproducts'
|
| 192 |
-
})], axis=1)
|
| 193 |
-
|
| 194 |
-
print("\n✅ **Final Comparison of Optimal Scenarios (Sorted by Best IRR)**")
|
| 195 |
-
print("="*120)
|
| 196 |
-
print(df_display.to_string(index=False))
|
| 197 |
-
print("="*120)
|
| 198 |
-
df_display.to_csv("results.csv", index=False, encoding='utf-8-sig')
|
| 199 |
-
|
| 200 |
-
def create_kpi_comparison_dashboard(df_results):
|
| 201 |
-
print("\n--- Generating KPI Comparison Dashboard ---")
|
| 202 |
-
df_plot = df_results.sort_values(by='irr', ascending=False)
|
| 203 |
-
df_plot['annual_profit_M'] = df_plot['annual_profit'] / 1_000_000
|
| 204 |
-
df_plot['total_capex_M'] = df_plot['total_capex'] / 1_000_000
|
| 205 |
-
|
| 206 |
-
fig, axes = plt.subplots(2, 2, figsize=(20, 14))
|
| 207 |
-
fig.suptitle('Dashboard: Comprehensive Comparison of Optimization Methods', fontsize=22, weight='bold')
|
| 208 |
-
palettes = ['viridis', 'plasma', 'magma', 'cividis']
|
| 209 |
-
metrics = [
|
| 210 |
-
('irr', 'Optimal IRR (%)', axes[0, 0]),
|
| 211 |
-
('annual_profit_M', 'Annual Profit ($M)', axes[0, 1]),
|
| 212 |
-
('total_capex_M', 'Total CAPEX ($M)', axes[1, 0]),
|
| 213 |
-
('payback_period', 'Payback Period (Years)', axes[1, 1])
|
| 214 |
-
]
|
| 215 |
-
|
| 216 |
-
for i, (metric, title, ax) in enumerate(metrics):
|
| 217 |
-
sns.barplot(x=metric, y='Method', data=df_plot, ax=ax, palette=palettes[i])
|
| 218 |
-
ax.set_title(title, fontsize=16, weight='bold')
|
| 219 |
-
ax.set_xlabel('')
|
| 220 |
-
ax.set_ylabel('')
|
| 221 |
-
for p in ax.patches:
|
| 222 |
-
width = p.get_width()
|
| 223 |
-
ax.text(width * 1.01, p.get_y() + p.get_height() / 2,
|
| 224 |
-
f'{width:,.2f}',
|
| 225 |
-
va='center', fontsize=11)
|
| 226 |
-
|
| 227 |
-
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
|
| 228 |
-
plt.savefig("static/images/kpi_dashboard.png", bbox_inches='tight')
|
| 229 |
-
print("\n✅ A KPI dashboard graph has been saved as 'kpi_dashboard.png'")
|
| 230 |
-
|
| 231 |
-
def run_sensitivity_analysis(best_params, base_irr):
|
| 232 |
-
global PRODUCT_PRICES_USD_PER_TON, STRATEGY_DATA
|
| 233 |
-
print("\n--- Sensitivity Analysis on Best Scenario ---")
|
| 234 |
-
print(f"Analyzing sensitivity around the base IRR of {base_irr:,.2f}%")
|
| 235 |
-
|
| 236 |
-
sensitivity_vars = {
|
| 237 |
-
'Byproduct Caustic Soda Price': ('price', 'byproduct_caustic_soda'),
|
| 238 |
-
'Sourcing Cost Integrated': ('strategy', 'Integrated_Production', 'sourcing_cost_per_ton_pvc'),
|
| 239 |
-
'Domestic PVC S70 Price': ('price', 'pvc_s70_domestic')
|
| 240 |
-
}
|
| 241 |
-
variations = [-0.20, -0.10, 0.10, 0.20]
|
| 242 |
-
results = []
|
| 243 |
-
original_prices = copy.deepcopy(PRODUCT_PRICES_USD_PER_TON)
|
| 244 |
-
original_strategies = copy.deepcopy(STRATEGY_DATA)
|
| 245 |
-
|
| 246 |
-
for key, path in sensitivity_vars.items():
|
| 247 |
-
for var in variations:
|
| 248 |
-
PRODUCT_PRICES_USD_PER_TON = copy.deepcopy(original_prices)
|
| 249 |
-
STRATEGY_DATA = copy.deepcopy(original_strategies)
|
| 250 |
-
if path[0] == 'price':
|
| 251 |
-
base_value = PRODUCT_PRICES_USD_PER_TON[path[1]]
|
| 252 |
-
PRODUCT_PRICES_USD_PER_TON[path[1]] = base_value * (1 + var)
|
| 253 |
-
else:
|
| 254 |
-
base_value = STRATEGY_DATA[path[1]][path[2]]
|
| 255 |
-
STRATEGY_DATA[path[1]][path[2]] = base_value * (1 + var)
|
| 256 |
-
|
| 257 |
-
kpis = calculate_project_kpis(**best_params)
|
| 258 |
-
results.append({
|
| 259 |
-
'Variable': key, 'Change': f'{var:+.0%}',
|
| 260 |
-
'New IRR (%)': kpis['irr'], 'IRR Delta (%)': kpis['irr'] - base_irr
|
| 261 |
-
})
|
| 262 |
-
|
| 263 |
-
PRODUCT_PRICES_USD_PER_TON = original_prices
|
| 264 |
-
STRATEGY_DATA = original_strategies
|
| 265 |
-
|
| 266 |
-
df_sensitivity = pd.DataFrame(results)
|
| 267 |
-
print("\n✅ **Sensitivity Analysis Results**")
|
| 268 |
-
print("="*60)
|
| 269 |
-
print(df_sensitivity.to_string(index=False))
|
| 270 |
-
print("="*60)
|
| 271 |
-
|
| 272 |
-
tornado_data = []
|
| 273 |
-
for var_name in df_sensitivity['Variable'].unique():
|
| 274 |
-
subset = df_sensitivity[df_sensitivity['Variable'] == var_name]
|
| 275 |
-
min_delta = subset['IRR Delta (%)'].min()
|
| 276 |
-
max_delta = subset['IRR Delta (%)'].max()
|
| 277 |
-
tornado_data.append({
|
| 278 |
-
'Variable': var_name,
|
| 279 |
-
'Min_Delta': min_delta,
|
| 280 |
-
'Max_Delta': max_delta,
|
| 281 |
-
'Range': max_delta - min_delta
|
| 282 |
-
})
|
| 283 |
-
df_tornado = pd.DataFrame(tornado_data).sort_values('Range', ascending=True)
|
| 284 |
-
|
| 285 |
-
fig, ax = plt.subplots(figsize=(12, 8))
|
| 286 |
-
y = np.arange(len(df_tornado))
|
| 287 |
-
ax.barh(y, df_tornado['Max_Delta'], color='mediumseagreen', label='Positive Impact')
|
| 288 |
-
ax.barh(y, df_tornado['Min_Delta'], color='lightcoral', label='Negative Impact')
|
| 289 |
-
ax.set_yticks(y)
|
| 290 |
-
ax.set_yticklabels(df_tornado['Variable'], fontsize=12)
|
| 291 |
-
ax.axvline(0, color='black', linewidth=0.8, linestyle='--')
|
| 292 |
-
ax.set_title('Tornado Chart: IRR Sensitivity to Key Variables', fontsize=18, pad=20, weight='bold')
|
| 293 |
-
ax.set_xlabel(f'Change in IRR (%) from Base IRR ({base_irr:.2f}%)', fontsize=14)
|
| 294 |
-
ax.set_ylabel('Variable', fontsize=14)
|
| 295 |
-
ax.legend()
|
| 296 |
-
ax.grid(axis='x', linestyle='--', alpha=0.7)
|
| 297 |
-
for i, (p, n) in enumerate(zip(df_tornado['Max_Delta'], df_tornado['Min_Delta'])):
|
| 298 |
-
ax.text(p, i, f' +{p:.2f}%', va='center', ha='left', color='darkgreen')
|
| 299 |
-
ax.text(n, i, f' {n:.2f}%', va='center', ha='right', color='darkred')
|
| 300 |
-
plt.tight_layout()
|
| 301 |
-
plt.savefig("static/images/sensitivity_analysis_tornado.png", bbox_inches='tight')
|
| 302 |
-
print("\n✅ A sensitivity analysis Tornado chart has been saved as 'sensitivity_analysis_tornado.png'")
|
| 303 |
-
|
| 304 |
-
if __name__ == "__main__":
|
| 305 |
-
optimization_results = run_optimizations_without_ml()
|
| 306 |
-
df_results = pd.DataFrame(optimization_results).sort_values(by="irr", ascending=False).reset_index(drop=True)
|
| 307 |
-
df_results.round(2)
|
| 308 |
-
display_and_save_results(df_results)
|
| 309 |
-
create_kpi_comparison_dashboard(df_results)
|
| 310 |
-
|
| 311 |
-
if not df_results.empty:
|
| 312 |
-
best_scenario = df_results.iloc[0]
|
| 313 |
run_sensitivity_analysis(best_scenario['Params'], best_scenario['irr'])
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import random
|
| 4 |
+
import copy
|
| 5 |
+
import optuna
|
| 6 |
+
import matplotlib.pyplot as plt
|
| 7 |
+
import seaborn as sns
|
| 8 |
+
from skopt import gp_minimize
|
| 9 |
+
from skopt.space import Real, Categorical
|
| 10 |
+
from skopt.utils import use_named_args
|
| 11 |
+
from statsmodels.tsa.arima.model import ARIMA
|
| 12 |
+
import numpy_financial as npf
|
| 13 |
+
|
| 14 |
+
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
| 15 |
+
|
| 16 |
+
PROJECT_YEARS = 15
|
| 17 |
+
BASE_CAPACITY_KTA = 300
|
| 18 |
+
INFLATION_RATE = 0.015
|
| 19 |
+
TAX_RATE = 0.10
|
| 20 |
+
DEPRECIATION_YEARS = 15
|
| 21 |
+
|
| 22 |
+
TECHNOLOGY_DATA = {
|
| 23 |
+
"JNC": {"capex_base_M": 180.0, "opex_base_cents_kg": 150.0},
|
| 24 |
+
# "Hoechst_AG": {"capex_base_M": 230.0, "opex_base_cents_kg": 155.0},
|
| 25 |
+
# "BF_Goodrich": {"capex_base_M": 280.0, "opex_base_cents_kg": 160.0},
|
| 26 |
+
# "Shin_Etsu_1991": {"capex_base_M": 260.0, "opex_base_cents_kg": 155.0},
|
| 27 |
+
# "Shin_Etsu_2004": {"capex_base_M": 1500.0, "opex_base_cents_kg": 150.0},
|
| 28 |
+
# "Vinnolit": {"capex_base_M": 240.0, "opex_base_cents_kg": 155.0},
|
| 29 |
+
# "QVC_Qatar": {"capex_base_M": 200.0, "opex_base_cents_kg": 145.0},
|
| 30 |
+
# "SP_Chemicals": {"capex_base_M": 250.0, "opex_base_cents_kg": 145.0},
|
| 31 |
+
# "Engro_Pakistan": {"capex_base_M": 1400.0, "opex_base_cents_kg": 155.0},
|
| 32 |
+
# "Formosa_BR_USA": {"capex_base_M": 380.0, "opex_base_cents_kg": 150.0},
|
| 33 |
+
# "Shintech_USA_Exp": {"capex_base_M": 1700.0, "opex_base_cents_kg": 160.0},
|
| 34 |
+
# "Zhongtai_China": {"capex_base_M": 1100.0, "opex_base_cents_kg": 155.0},
|
| 35 |
+
# "Shintech_USA_2021": {"capex_base_M": 1700.0, "opex_base_cents_kg": 160.0},
|
| 36 |
+
# "Reliance_India_2024": {"capex_base_M": 2200.0, "opex_base_cents_kg": 155.0},
|
| 37 |
+
# "Orbia_Germany_2023": {"capex_base_M": 180.0, "opex_base_cents_kg": 155.0},
|
| 38 |
+
# "Westlake_USA_2022": {"capex_base_M": 850.0, "opex_base_cents_kg": 160.0}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
STRATEGY_DATA = {
|
| 42 |
+
'Integrated_Production': {'sourcing_cost_per_ton_pvc': 450.0, 'byproducts': {'caustic_soda_ton': 1.1, 'surplus_edc_ton': 0.523}},
|
| 43 |
+
'Purchase_VCM': {'sourcing_cost_per_ton_pvc': 650.0, 'byproducts': {'caustic_soda_ton': 0, 'surplus_edc_ton': 0}}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
PRODUCT_PRICES_USD_PER_TON = {
|
| 47 |
+
'pvc_s65_export': 1100, 'pvc_s70_domestic': 950,
|
| 48 |
+
'byproduct_caustic_soda': 450, 'byproduct_surplus_edc': 170
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
OPTIMIZATION_SPACE = {
|
| 52 |
+
'capacity_kta': (500, 600),
|
| 53 |
+
'technology': ["Engro_Pakistan"], #, "Shin_Etsu_2004"],
|
| 54 |
+
'sourcing_strategy': ['Integrated_Production'],
|
| 55 |
+
'export_market_mix': (0.6, 0.8),
|
| 56 |
+
'sell_byproducts': [True]
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
def forecast_prices(base_price, years=PROJECT_YEARS, cagr=0.04):
|
| 60 |
+
historical = [base_price * (1 + cagr + random.uniform(-0.03, 0.03)) ** i for i in range(-5, 0)]
|
| 61 |
+
model = ARIMA(historical, order=(1, 1, 0))
|
| 62 |
+
fit = model.fit()
|
| 63 |
+
forecast = fit.forecast(steps=years)
|
| 64 |
+
return [p * (1 + INFLATION_RATE) ** i for i, p in enumerate(forecast)]
|
| 65 |
+
|
| 66 |
+
def calculate_project_kpis(**params):
|
| 67 |
+
tech_data = TECHNOLOGY_DATA[params['technology']]
|
| 68 |
+
scaling_factor = (params['capacity_kta'] / BASE_CAPACITY_KTA) ** 0.65
|
| 69 |
+
total_capex = tech_data['capex_base_M'] * scaling_factor * 1_000_000
|
| 70 |
+
capacity_tons = params['capacity_kta'] * 1000
|
| 71 |
+
|
| 72 |
+
price_s65_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['pvc_s65_export'])
|
| 73 |
+
price_s70_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['pvc_s70_domestic'])
|
| 74 |
+
byproduct_caustic_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['byproduct_caustic_soda'])
|
| 75 |
+
byproduct_edc_forecast = forecast_prices(PRODUCT_PRICES_USD_PER_TON['byproduct_surplus_edc'])
|
| 76 |
+
|
| 77 |
+
cash_flows = [-total_capex]
|
| 78 |
+
depreciation_annual = total_capex / DEPRECIATION_YEARS
|
| 79 |
+
|
| 80 |
+
for year in range(1, PROJECT_YEARS + 1):
|
| 81 |
+
infl_factor = (1 + INFLATION_RATE) ** (year - 1)
|
| 82 |
+
price_s65 = price_s65_forecast[year - 1]
|
| 83 |
+
price_s70 = price_s70_forecast[year - 1] * 0.95 if params['capacity_kta'] >= 550 else price_s70_forecast[year - 1]
|
| 84 |
+
revenue_export = (capacity_tons * params['export_market_mix']) * price_s65
|
| 85 |
+
revenue_domestic = (capacity_tons * (1 - params['export_market_mix'])) * price_s70
|
| 86 |
+
pvc_revenue = revenue_export + revenue_domestic
|
| 87 |
+
|
| 88 |
+
byproduct_revenue = 0
|
| 89 |
+
if params['sourcing_strategy'] == 'Integrated_Production' and params['sell_byproducts']:
|
| 90 |
+
byproducts = STRATEGY_DATA['Integrated_Production']['byproducts']
|
| 91 |
+
byproduct_revenue += (byproducts['caustic_soda_ton'] * capacity_tons * byproduct_caustic_forecast[year - 1])
|
| 92 |
+
byproduct_revenue += (byproducts['surplus_edc_ton'] * capacity_tons * byproduct_edc_forecast[year - 1])
|
| 93 |
+
|
| 94 |
+
total_revenue = pvc_revenue + byproduct_revenue
|
| 95 |
+
|
| 96 |
+
opex_sourcing = STRATEGY_DATA['Integrated_Production']['sourcing_cost_per_ton_pvc'] * capacity_tons * infl_factor
|
| 97 |
+
opex_base = (tech_data['opex_base_cents_kg'] / 100) * capacity_tons * infl_factor
|
| 98 |
+
total_opex = opex_sourcing + opex_base
|
| 99 |
+
|
| 100 |
+
ebitda = total_revenue - total_opex
|
| 101 |
+
taxable_income = ebitda - depreciation_annual
|
| 102 |
+
taxes = max(taxable_income * TAX_RATE, 0)
|
| 103 |
+
net_income = taxable_income - taxes
|
| 104 |
+
free_cash_flow = net_income + depreciation_annual
|
| 105 |
+
cash_flows.append(free_cash_flow)
|
| 106 |
+
|
| 107 |
+
irr = npf.irr(cash_flows) * 100 if npf.irr(cash_flows) > 0 else -1.0
|
| 108 |
+
cumulative_cash_flow = np.cumsum(cash_flows)
|
| 109 |
+
payback_period_years = np.where(cumulative_cash_flow > 0)[0]
|
| 110 |
+
payback_period = payback_period_years[0] + 1 if len(payback_period_years) > 0 else float('inf')
|
| 111 |
+
annual_profit = np.mean([cf for cf in cash_flows[1:] if cf > 0])
|
| 112 |
+
|
| 113 |
+
return {"irr": irr, "annual_profit": annual_profit, "total_capex": total_capex, "payback_period": payback_period}
|
| 114 |
+
|
| 115 |
+
def run_optimizations_without_ml():
|
| 116 |
+
print("\n--- Running Optimization Algorithms ---")
|
| 117 |
+
results = []
|
| 118 |
+
results.append(run_bayesian_optimization())
|
| 119 |
+
results.append(run_genetic_algorithm())
|
| 120 |
+
results.append(run_optuna_direct())
|
| 121 |
+
return results
|
| 122 |
+
|
| 123 |
+
def run_genetic_algorithm():
|
| 124 |
+
print("Running Genetic Algorithm (GA)...")
|
| 125 |
+
population = [{k: random.choice(v) if isinstance(v, list) else random.uniform(*v) for k,v in OPTIMIZATION_SPACE.items()} for _ in range(50)]
|
| 126 |
+
best_overall_individual = None
|
| 127 |
+
best_overall_fitness = -float('inf')
|
| 128 |
+
for _ in range(10):
|
| 129 |
+
fitnesses = [calculate_project_kpis(**ind)['irr'] for ind in population]
|
| 130 |
+
if max(fitnesses) > best_overall_fitness:
|
| 131 |
+
best_overall_fitness = max(fitnesses)
|
| 132 |
+
best_overall_individual = population[np.argmax(fitnesses)]
|
| 133 |
+
selected = [max(random.sample(list(zip(population, fitnesses)), 5), key=lambda i: i[1])[0] for _ in range(50)]
|
| 134 |
+
next_gen = []
|
| 135 |
+
for i in range(0, 50, 2):
|
| 136 |
+
p1, p2 = selected[i], selected[i+1]
|
| 137 |
+
c1, c2 = copy.deepcopy(p1), copy.deepcopy(p2)
|
| 138 |
+
if random.random() < 0.9: c1['technology'], c2['technology'] = p2['technology'], c1['technology']
|
| 139 |
+
if random.random() < 0.2: c1['export_market_mix'] = random.uniform(*OPTIMIZATION_SPACE['export_market_mix'])
|
| 140 |
+
next_gen.extend([c1, c2])
|
| 141 |
+
population = next_gen
|
| 142 |
+
kpis = calculate_project_kpis(**best_overall_individual)
|
| 143 |
+
return {"Method": "Genetic Algorithm", **kpis, "Params": best_overall_individual}
|
| 144 |
+
|
| 145 |
+
def run_bayesian_optimization():
|
| 146 |
+
print("Running Bayesian Optimization...")
|
| 147 |
+
skopt_space = [
|
| 148 |
+
Real(OPTIMIZATION_SPACE['capacity_kta'][0], OPTIMIZATION_SPACE['capacity_kta'][1], name='capacity_kta'),
|
| 149 |
+
Categorical(OPTIMIZATION_SPACE['technology'], name='technology'),
|
| 150 |
+
Categorical(OPTIMIZATION_SPACE['sourcing_strategy'], name='sourcing_strategy'),
|
| 151 |
+
Real(OPTIMIZATION_SPACE['export_market_mix'][0], OPTIMIZATION_SPACE['export_market_mix'][1], name='export_market_mix'),
|
| 152 |
+
Categorical(OPTIMIZATION_SPACE['sell_byproducts'], name='sell_byproducts')
|
| 153 |
+
]
|
| 154 |
+
@use_named_args(skopt_space)
|
| 155 |
+
def objective(**params):
|
| 156 |
+
return -calculate_project_kpis(**params)['irr']
|
| 157 |
+
res = gp_minimize(objective, skopt_space, n_calls=100, random_state=42, n_initial_points=20)
|
| 158 |
+
best_params = {space.name: val for space, val in zip(skopt_space, res.x)}
|
| 159 |
+
kpis = calculate_project_kpis(**best_params)
|
| 160 |
+
return {"Method": "Bayesian Opt", **kpis, "Params": best_params}
|
| 161 |
+
|
| 162 |
+
def run_optuna_direct():
|
| 163 |
+
print("Running Optuna (TPE)...")
|
| 164 |
+
def objective(trial):
|
| 165 |
+
params = {
|
| 166 |
+
"capacity_kta": trial.suggest_float("capacity_kta", *OPTIMIZATION_SPACE['capacity_kta']),
|
| 167 |
+
"technology": trial.suggest_categorical("technology", OPTIMIZATION_SPACE['technology']),
|
| 168 |
+
"sourcing_strategy": trial.suggest_categorical("sourcing_strategy", OPTIMIZATION_SPACE['sourcing_strategy']),
|
| 169 |
+
"export_market_mix": trial.suggest_float("export_market_mix", *OPTIMIZATION_SPACE['export_market_mix']),
|
| 170 |
+
"sell_byproducts": trial.suggest_categorical("sell_byproducts", OPTIMIZATION_SPACE['sell_byproducts'])
|
| 171 |
+
}
|
| 172 |
+
return calculate_project_kpis(**params)['irr']
|
| 173 |
+
study = optuna.create_study(direction="maximize")
|
| 174 |
+
study.optimize(objective, n_trials=2, n_jobs=-1)
|
| 175 |
+
kpis = calculate_project_kpis(**study.best_params)
|
| 176 |
+
return {"Method": "Optuna (TPE - Direct)", **kpis, "Params": study.best_params}
|
| 177 |
+
|
| 178 |
+
def display_and_save_results(df_results):
|
| 179 |
+
print("\n--- Final Results and Comparison ---")
|
| 180 |
+
df_display = pd.DataFrame()
|
| 181 |
+
df_display['Method'] = df_results['Method']
|
| 182 |
+
df_display['Optimal IRR (%)'] = df_results['irr'].map('{:,.2f}%'.format)
|
| 183 |
+
df_display['Annual Profit ($M)'] = (df_results['annual_profit'] / 1_000_000).map('{:,.1f}'.format)
|
| 184 |
+
df_display['CAPEX ($M)'] = (df_results['total_capex'] / 1_000_000).map('{:,.1f}'.format)
|
| 185 |
+
df_display['Payback (Yrs)'] = df_results['payback_period'].map('{:,.1f}'.format)
|
| 186 |
+
param_df = pd.DataFrame(df_results['Params'].tolist())
|
| 187 |
+
param_df['capacity_kta'] = param_df['capacity_kta'].round(1)
|
| 188 |
+
param_df['export_market_mix'] = (param_df['export_market_mix'] * 100).round(1).astype(str) + '%'
|
| 189 |
+
df_display = pd.concat([df_display, param_df.rename(columns={
|
| 190 |
+
'capacity_kta': 'Capacity (KTA)', 'technology': 'Technology', 'sourcing_strategy': 'Sourcing',
|
| 191 |
+
'export_market_mix': 'Export Mix', 'sell_byproducts': 'Sell Byproducts'
|
| 192 |
+
})], axis=1)
|
| 193 |
+
|
| 194 |
+
print("\n✅ **Final Comparison of Optimal Scenarios (Sorted by Best IRR)**")
|
| 195 |
+
print("="*120)
|
| 196 |
+
print(df_display.to_string(index=False))
|
| 197 |
+
print("="*120)
|
| 198 |
+
df_display.to_csv("results.csv", index=False, encoding='utf-8-sig')
|
| 199 |
+
|
| 200 |
+
def create_kpi_comparison_dashboard(df_results):
|
| 201 |
+
print("\n--- Generating KPI Comparison Dashboard ---")
|
| 202 |
+
df_plot = df_results.sort_values(by='irr', ascending=False)
|
| 203 |
+
df_plot['annual_profit_M'] = df_plot['annual_profit'] / 1_000_000
|
| 204 |
+
df_plot['total_capex_M'] = df_plot['total_capex'] / 1_000_000
|
| 205 |
+
|
| 206 |
+
fig, axes = plt.subplots(2, 2, figsize=(20, 14))
|
| 207 |
+
fig.suptitle('Dashboard: Comprehensive Comparison of Optimization Methods', fontsize=22, weight='bold')
|
| 208 |
+
palettes = ['viridis', 'plasma', 'magma', 'cividis']
|
| 209 |
+
metrics = [
|
| 210 |
+
('irr', 'Optimal IRR (%)', axes[0, 0]),
|
| 211 |
+
('annual_profit_M', 'Annual Profit ($M)', axes[0, 1]),
|
| 212 |
+
('total_capex_M', 'Total CAPEX ($M)', axes[1, 0]),
|
| 213 |
+
('payback_period', 'Payback Period (Years)', axes[1, 1])
|
| 214 |
+
]
|
| 215 |
+
|
| 216 |
+
for i, (metric, title, ax) in enumerate(metrics):
|
| 217 |
+
sns.barplot(x=metric, y='Method', data=df_plot, ax=ax, palette=palettes[i])
|
| 218 |
+
ax.set_title(title, fontsize=16, weight='bold')
|
| 219 |
+
ax.set_xlabel('')
|
| 220 |
+
ax.set_ylabel('')
|
| 221 |
+
for p in ax.patches:
|
| 222 |
+
width = p.get_width()
|
| 223 |
+
ax.text(width * 1.01, p.get_y() + p.get_height() / 2,
|
| 224 |
+
f'{width:,.2f}',
|
| 225 |
+
va='center', fontsize=11)
|
| 226 |
+
|
| 227 |
+
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
|
| 228 |
+
plt.savefig("static/images/kpi_dashboard.png", bbox_inches='tight')
|
| 229 |
+
print("\n✅ A KPI dashboard graph has been saved as 'kpi_dashboard.png'")
|
| 230 |
+
|
| 231 |
+
def run_sensitivity_analysis(best_params, base_irr):
|
| 232 |
+
global PRODUCT_PRICES_USD_PER_TON, STRATEGY_DATA
|
| 233 |
+
print("\n--- Sensitivity Analysis on Best Scenario ---")
|
| 234 |
+
print(f"Analyzing sensitivity around the base IRR of {base_irr:,.2f}%")
|
| 235 |
+
|
| 236 |
+
sensitivity_vars = {
|
| 237 |
+
'Byproduct Caustic Soda Price': ('price', 'byproduct_caustic_soda'),
|
| 238 |
+
'Sourcing Cost Integrated': ('strategy', 'Integrated_Production', 'sourcing_cost_per_ton_pvc'),
|
| 239 |
+
'Domestic PVC S70 Price': ('price', 'pvc_s70_domestic')
|
| 240 |
+
}
|
| 241 |
+
variations = [-0.20, -0.10, 0.10, 0.20]
|
| 242 |
+
results = []
|
| 243 |
+
original_prices = copy.deepcopy(PRODUCT_PRICES_USD_PER_TON)
|
| 244 |
+
original_strategies = copy.deepcopy(STRATEGY_DATA)
|
| 245 |
+
|
| 246 |
+
for key, path in sensitivity_vars.items():
|
| 247 |
+
for var in variations:
|
| 248 |
+
PRODUCT_PRICES_USD_PER_TON = copy.deepcopy(original_prices)
|
| 249 |
+
STRATEGY_DATA = copy.deepcopy(original_strategies)
|
| 250 |
+
if path[0] == 'price':
|
| 251 |
+
base_value = PRODUCT_PRICES_USD_PER_TON[path[1]]
|
| 252 |
+
PRODUCT_PRICES_USD_PER_TON[path[1]] = base_value * (1 + var)
|
| 253 |
+
else:
|
| 254 |
+
base_value = STRATEGY_DATA[path[1]][path[2]]
|
| 255 |
+
STRATEGY_DATA[path[1]][path[2]] = base_value * (1 + var)
|
| 256 |
+
|
| 257 |
+
kpis = calculate_project_kpis(**best_params)
|
| 258 |
+
results.append({
|
| 259 |
+
'Variable': key, 'Change': f'{var:+.0%}',
|
| 260 |
+
'New IRR (%)': kpis['irr'], 'IRR Delta (%)': kpis['irr'] - base_irr
|
| 261 |
+
})
|
| 262 |
+
|
| 263 |
+
PRODUCT_PRICES_USD_PER_TON = original_prices
|
| 264 |
+
STRATEGY_DATA = original_strategies
|
| 265 |
+
|
| 266 |
+
df_sensitivity = pd.DataFrame(results)
|
| 267 |
+
print("\n✅ **Sensitivity Analysis Results**")
|
| 268 |
+
print("="*60)
|
| 269 |
+
print(df_sensitivity.to_string(index=False))
|
| 270 |
+
print("="*60)
|
| 271 |
+
|
| 272 |
+
tornado_data = []
|
| 273 |
+
for var_name in df_sensitivity['Variable'].unique():
|
| 274 |
+
subset = df_sensitivity[df_sensitivity['Variable'] == var_name]
|
| 275 |
+
min_delta = subset['IRR Delta (%)'].min()
|
| 276 |
+
max_delta = subset['IRR Delta (%)'].max()
|
| 277 |
+
tornado_data.append({
|
| 278 |
+
'Variable': var_name,
|
| 279 |
+
'Min_Delta': min_delta,
|
| 280 |
+
'Max_Delta': max_delta,
|
| 281 |
+
'Range': max_delta - min_delta
|
| 282 |
+
})
|
| 283 |
+
df_tornado = pd.DataFrame(tornado_data).sort_values('Range', ascending=True)
|
| 284 |
+
|
| 285 |
+
fig, ax = plt.subplots(figsize=(12, 8))
|
| 286 |
+
y = np.arange(len(df_tornado))
|
| 287 |
+
ax.barh(y, df_tornado['Max_Delta'], color='mediumseagreen', label='Positive Impact')
|
| 288 |
+
ax.barh(y, df_tornado['Min_Delta'], color='lightcoral', label='Negative Impact')
|
| 289 |
+
ax.set_yticks(y)
|
| 290 |
+
ax.set_yticklabels(df_tornado['Variable'], fontsize=12)
|
| 291 |
+
ax.axvline(0, color='black', linewidth=0.8, linestyle='--')
|
| 292 |
+
ax.set_title('Tornado Chart: IRR Sensitivity to Key Variables', fontsize=18, pad=20, weight='bold')
|
| 293 |
+
ax.set_xlabel(f'Change in IRR (%) from Base IRR ({base_irr:.2f}%)', fontsize=14)
|
| 294 |
+
ax.set_ylabel('Variable', fontsize=14)
|
| 295 |
+
ax.legend()
|
| 296 |
+
ax.grid(axis='x', linestyle='--', alpha=0.7)
|
| 297 |
+
for i, (p, n) in enumerate(zip(df_tornado['Max_Delta'], df_tornado['Min_Delta'])):
|
| 298 |
+
ax.text(p, i, f' +{p:.2f}%', va='center', ha='left', color='darkgreen')
|
| 299 |
+
ax.text(n, i, f' {n:.2f}%', va='center', ha='right', color='darkred')
|
| 300 |
+
plt.tight_layout()
|
| 301 |
+
plt.savefig("static/images/sensitivity_analysis_tornado.png", bbox_inches='tight')
|
| 302 |
+
print("\n✅ A sensitivity analysis Tornado chart has been saved as 'sensitivity_analysis_tornado.png'")
|
| 303 |
+
|
| 304 |
+
if __name__ == "__main__":
|
| 305 |
+
optimization_results = run_optimizations_without_ml()
|
| 306 |
+
df_results = pd.DataFrame(optimization_results).sort_values(by="irr", ascending=False).reset_index(drop=True)
|
| 307 |
+
df_results.round(2)
|
| 308 |
+
display_and_save_results(df_results)
|
| 309 |
+
create_kpi_comparison_dashboard(df_results)
|
| 310 |
+
|
| 311 |
+
if not df_results.empty:
|
| 312 |
+
best_scenario = df_results.iloc[0]
|
| 313 |
run_sensitivity_analysis(best_scenario['Params'], best_scenario['irr'])
|