Upload 9 files
Browse files- .gitattributes +1 -0
- app.py +155 -0
- main.py +313 -0
- static/css/style.css +204 -0
- static/images/kpi_dashboard.png +0 -0
- static/images/logo.png +0 -0
- static/images/sensitivity_analysis_tornado.png +3 -0
- static/js/charts.js +72 -0
- templates/index.html +156 -0
- templates/layout.html +59 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
static/images/sensitivity_analysis_tornado.png filter=lfs diff=lfs merge=lfs -text
|
app.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, render_template, request, redirect, url_for, send_file, flash, jsonify
|
| 2 |
+
import os
|
| 3 |
+
import traceback
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import json
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
app = Flask(__name__)
|
| 9 |
+
app.secret_key = os.urandom(24)
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
import main as project_main
|
| 13 |
+
except Exception as e:
|
| 14 |
+
project_main = None
|
| 15 |
+
import_error = traceback.format_exc()
|
| 16 |
+
else:
|
| 17 |
+
import_error = None
|
| 18 |
+
|
| 19 |
+
RESULTS_CSV = Path("results_flask.csv")
|
| 20 |
+
|
| 21 |
+
def safe_update_dict_from_json(orig_dict, json_text):
|
| 22 |
+
if not json_text or not json_text.strip():
|
| 23 |
+
return orig_dict
|
| 24 |
+
try:
|
| 25 |
+
new = json.loads(json_text)
|
| 26 |
+
if not isinstance(new, dict):
|
| 27 |
+
return orig_dict
|
| 28 |
+
d = orig_dict.copy()
|
| 29 |
+
d.update(new)
|
| 30 |
+
return d
|
| 31 |
+
except Exception:
|
| 32 |
+
return orig_dict
|
| 33 |
+
|
| 34 |
+
@app.route("/", methods=["GET"])
|
| 35 |
+
def index():
|
| 36 |
+
if project_main is None:
|
| 37 |
+
return render_template("index.html", import_error=import_error, project=None)
|
| 38 |
+
techs = list(project_main.TECHNOLOGY_DATA.keys())
|
| 39 |
+
defaults = {
|
| 40 |
+
"inflation": project_main.INFLATION_RATE,
|
| 41 |
+
"tax": project_main.TAX_RATE,
|
| 42 |
+
"years": project_main.PROJECT_YEARS,
|
| 43 |
+
"cap_min": project_main.OPTIMIZATION_SPACE['capacity_kta'][0],
|
| 44 |
+
"cap_max": project_main.OPTIMIZATION_SPACE['capacity_kta'][1],
|
| 45 |
+
"export_mix": project_main.OPTIMIZATION_SPACE['export_market_mix'][0],
|
| 46 |
+
}
|
| 47 |
+
return render_template("index.html", import_error=None, project=project_main, techs=techs, defaults=defaults)
|
| 48 |
+
|
| 49 |
+
@app.route("/run", methods=["POST"])
|
| 50 |
+
def run():
|
| 51 |
+
if project_main is None:
|
| 52 |
+
flash("خطا: فایل main.py بارگزاری نشد. لطفاً لاگ را بررسی کنید.", "danger")
|
| 53 |
+
return redirect(url_for("index"))
|
| 54 |
+
try:
|
| 55 |
+
inflation_rate = float(request.form.get("inflation", project_main.INFLATION_RATE))
|
| 56 |
+
tax_rate = float(request.form.get("tax", project_main.TAX_RATE))
|
| 57 |
+
project_years = int(request.form.get("years", project_main.PROJECT_YEARS))
|
| 58 |
+
capacity_min = float(request.form.get("cap_min", project_main.OPTIMIZATION_SPACE['capacity_kta'][0]))
|
| 59 |
+
capacity_max = float(request.form.get("cap_max", project_main.OPTIMIZATION_SPACE['capacity_kta'][1]))
|
| 60 |
+
technology = request.form.get("technology", list(project_main.TECHNOLOGY_DATA.keys())[0])
|
| 61 |
+
export_mix = float(request.form.get("export_mix", project_main.OPTIMIZATION_SPACE['export_market_mix'][0]))
|
| 62 |
+
sell_byproducts = request.form.get("sell_byproducts") == "on"
|
| 63 |
+
tech_json = request.form.get("tech_json", "")
|
| 64 |
+
prices_json = request.form.get("prices_json", "")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
flash("خطا در خواندن ورودیها: " + str(e), "danger")
|
| 67 |
+
return redirect(url_for("index"))
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
project_main.INFLATION_RATE = inflation_rate
|
| 71 |
+
project_main.TAX_RATE = tax_rate
|
| 72 |
+
project_main.PROJECT_YEARS = project_years
|
| 73 |
+
|
| 74 |
+
project_main.OPTIMIZATION_SPACE['capacity_kta'] = (capacity_min, capacity_max)
|
| 75 |
+
if technology not in project_main.OPTIMIZATION_SPACE.get('technology', []):
|
| 76 |
+
project_main.OPTIMIZATION_SPACE['technology'] = [technology]
|
| 77 |
+
low = max(0.0, export_mix - 0.05)
|
| 78 |
+
high = min(1.0, export_mix + 0.05)
|
| 79 |
+
if low == high:
|
| 80 |
+
high = min(1.0, high + 0.01)
|
| 81 |
+
project_main.OPTIMIZATION_SPACE['export_market_mix'] = (low, high)
|
| 82 |
+
project_main.OPTIMIZATION_SPACE['sell_byproducts'] = [bool(sell_byproducts)]
|
| 83 |
+
|
| 84 |
+
project_main.TECHNOLOGY_DATA = safe_update_dict_from_json(project_main.TECHNOLOGY_DATA, tech_json)
|
| 85 |
+
project_main.PRODUCT_PRICES_USD_PER_TON = safe_update_dict_from_json(project_main.PRODUCT_PRICES_USD_PER_TON, prices_json)
|
| 86 |
+
except Exception as e:
|
| 87 |
+
flash("خطا در اعمال پارامترها: " + str(e), "danger")
|
| 88 |
+
return redirect(url_for("index"))
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
flash("محاسبات شروع شد — صبر کنید تا عملیات به پایان برسد...", "info")
|
| 92 |
+
results = project_main.run_optimizations_without_ml()
|
| 93 |
+
df_results = pd.DataFrame(results).sort_values(by="irr", ascending=False).reset_index(drop=True)
|
| 94 |
+
df_results = df_results.round(2)
|
| 95 |
+
|
| 96 |
+
df_results.to_csv(RESULTS_CSV, index=False, encoding='utf-8-sig')
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
project_main.display_and_save_results(df_results)
|
| 100 |
+
project_main.create_kpi_comparison_dashboard(df_results)
|
| 101 |
+
except Exception:
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
if not df_results.empty:
|
| 105 |
+
best = df_results.iloc[0]
|
| 106 |
+
top_kpis = {
|
| 107 |
+
"irr": round(float(best['irr']), 2),
|
| 108 |
+
"annual_profit_M": round(float(best['annual_profit'])/1_000_000, 2),
|
| 109 |
+
"capex_M": round(float(best['total_capex'])/1_000_000, 2),
|
| 110 |
+
"payback": round(float(best['payback_period']), 2)
|
| 111 |
+
}
|
| 112 |
+
else:
|
| 113 |
+
top_kpis = None
|
| 114 |
+
|
| 115 |
+
charts_data = df_results.to_dict(orient="records")
|
| 116 |
+
|
| 117 |
+
flash("محاسبات با موفقیت تکمیل شد.", "success")
|
| 118 |
+
|
| 119 |
+
kpi_img = Path("static/images/kpi_dashboard.png") if Path("static/images/kpi_dashboard.png").exists() else None
|
| 120 |
+
tornado_img = Path("static/images/sensitivity_analysis_tornado.png") if Path("static/images/sensitivity_analysis_tornado.png").exists() else None
|
| 121 |
+
|
| 122 |
+
return render_template(
|
| 123 |
+
"index.html",
|
| 124 |
+
project=project_main,
|
| 125 |
+
techs=list(project_main.TECHNOLOGY_DATA.keys()),
|
| 126 |
+
defaults={"inflation": project_main.INFLATION_RATE, "tax": project_main.TAX_RATE},
|
| 127 |
+
table_html = df_results.to_html(
|
| 128 |
+
classes='table table-striped table-dark',
|
| 129 |
+
index=False,
|
| 130 |
+
justify='center',
|
| 131 |
+
float_format=lambda x: f"{x:.2f}"
|
| 132 |
+
),
|
| 133 |
+
top_kpis=top_kpis,
|
| 134 |
+
charts_json=json.dumps(charts_data, default=str),
|
| 135 |
+
kpi_img="images/kpi_dashboard.png" if kpi_img else None,
|
| 136 |
+
tornado_img="images/sensitivity_analysis_tornado.png" if tornado_img else None
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
tb = traceback.format_exc()
|
| 141 |
+
flash("خطا در اجرای الگوریتمها. لاگ: " + str(e), "danger")
|
| 142 |
+
return render_template("index.html", import_error=tb, project=project_main)
|
| 143 |
+
|
| 144 |
+
@app.route("/download")
|
| 145 |
+
def download_results():
|
| 146 |
+
if RESULTS_CSV.exists():
|
| 147 |
+
return send_file(str(RESULTS_CSV), as_attachment=True)
|
| 148 |
+
flash("فایل نتایج آماده نیست.", "warning")
|
| 149 |
+
return redirect(url_for("index"))
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
app.run(debug=True)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
|
main.py
ADDED
|
@@ -0,0 +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(100):
|
| 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=200, 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'])
|
static/css/style.css
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg-main: #121212;
|
| 3 |
+
--bg-surface: #1E1E1E;
|
| 4 |
+
--border: #2C2C2C;
|
| 5 |
+
--text-primary: #E0E0E0;
|
| 6 |
+
--text-secondary: #A0A0A0;
|
| 7 |
+
--accent: #2DECBF;
|
| 8 |
+
--accent-hover: #58FFCF;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
html, body {
|
| 12 |
+
background-color: var(--bg-main);
|
| 13 |
+
color: var(--text-primary);
|
| 14 |
+
margin: 0;
|
| 15 |
+
padding: 0;
|
| 16 |
+
font-family: "Vazirmatn", sans-serif;
|
| 17 |
+
height: 100%;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
body, .container, .container-fluid, .row, .col, main {
|
| 21 |
+
background-color: var(--bg-main) !important;
|
| 22 |
+
color: var(--text-primary) !important;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.navbar {
|
| 26 |
+
background-color: var(--bg-surface) !important;
|
| 27 |
+
border-bottom: 1px solid var(--border);
|
| 28 |
+
}
|
| 29 |
+
.navbar-brand {
|
| 30 |
+
color: var(--accent) !important;
|
| 31 |
+
font-weight: bold;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.card {
|
| 35 |
+
background-color: var(--bg-surface) !important;
|
| 36 |
+
border: 1px solid var(--border);
|
| 37 |
+
border-radius: 10px;
|
| 38 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
|
| 39 |
+
color: var(--text-primary);
|
| 40 |
+
}
|
| 41 |
+
.card-header {
|
| 42 |
+
background-color: var(--bg-surface) !important;
|
| 43 |
+
border-bottom: 1px solid var(--border);
|
| 44 |
+
font-weight: 600;
|
| 45 |
+
color: var(--text-primary);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.btn-primary {
|
| 49 |
+
background-color: var(--accent);
|
| 50 |
+
border: none;
|
| 51 |
+
color: #000000;
|
| 52 |
+
font-weight: 600;
|
| 53 |
+
border-radius: 6px;
|
| 54 |
+
transition: background-color 0.2s, transform 0.1s;
|
| 55 |
+
}
|
| 56 |
+
.btn-primary:hover {
|
| 57 |
+
background-color: var(--accent-hover);
|
| 58 |
+
transform: translateY(-1px);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.btn-outline-light {
|
| 62 |
+
background-color: transparent;
|
| 63 |
+
border: 1px solid var(--accent);
|
| 64 |
+
color: var(--accent);
|
| 65 |
+
}
|
| 66 |
+
.btn-outline-light:hover {
|
| 67 |
+
background-color: var(--accent);
|
| 68 |
+
color: #000000;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
.form-control, .form-select, textarea {
|
| 73 |
+
background-color: var(--bg-main) !important;
|
| 74 |
+
color: var(--text-primary) !important;
|
| 75 |
+
border: 1px solid var(--border);
|
| 76 |
+
border-radius: 6px;
|
| 77 |
+
}
|
| 78 |
+
.form-control:focus, .form-select:focus {
|
| 79 |
+
border-color: var(--accent);
|
| 80 |
+
box-shadow: 0 0 0 0.2rem rgba(45, 236, 191, 0.25);
|
| 81 |
+
background-color: var(--bg-surface);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
.table {
|
| 86 |
+
background-color: var(--bg-surface) !important;
|
| 87 |
+
color: var(--text-primary);
|
| 88 |
+
border-color: var(--border);
|
| 89 |
+
}
|
| 90 |
+
.table-striped>tbody>tr:nth-of-type(odd)>* {
|
| 91 |
+
background-color: var(--bg-main);
|
| 92 |
+
}
|
| 93 |
+
.table-striped>tbody>tr:hover>* {
|
| 94 |
+
background-color: #2A2A2A;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.table td, .table th {
|
| 98 |
+
color: #FFFFFF !important;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.card-body, .alert, .form-label, .form-check-label, .card-header {
|
| 102 |
+
color: #FFFFFF !important;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.alert, .custom-alert {
|
| 106 |
+
background-color: #1E1E1E !important;
|
| 107 |
+
color: #FFFFFF !important;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.custom-alert, .alert {
|
| 111 |
+
background-color: var(--bg-surface) !important;
|
| 112 |
+
border-left: 4px solid var(--accent);
|
| 113 |
+
color: var(--text-primary);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
canvas, img {
|
| 117 |
+
background-color: var(--bg-surface);
|
| 118 |
+
border: 1px solid var(--border);
|
| 119 |
+
border-radius: 8px;
|
| 120 |
+
padding: 8px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
footer {
|
| 124 |
+
background-color: var(--bg-surface);
|
| 125 |
+
border-top: 1px solid var(--border);
|
| 126 |
+
color: var(--text-secondary);
|
| 127 |
+
padding-top: 10px;
|
| 128 |
+
padding-bottom: 10px;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
::-webkit-scrollbar {
|
| 132 |
+
width: 10px;
|
| 133 |
+
}
|
| 134 |
+
::-webkit-scrollbar-track {
|
| 135 |
+
background: var(--bg-main);
|
| 136 |
+
}
|
| 137 |
+
::-webkit-scrollbar-thumb {
|
| 138 |
+
background: var(--border);
|
| 139 |
+
border-radius: 8px;
|
| 140 |
+
}
|
| 141 |
+
::-webkit-scrollbar-thumb:hover {
|
| 142 |
+
background: var(--accent);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.table>:not(caption)>*>* {
|
| 146 |
+
background-color: transparent !important;
|
| 147 |
+
}
|
| 148 |
+
.modal-content, .dropdown-menu, .popover, .tooltip {
|
| 149 |
+
background-color: var(--bg-surface) !important;
|
| 150 |
+
color: var(--text-primary) !important;
|
| 151 |
+
border: 1px solid var(--border);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
::placeholder {
|
| 155 |
+
color: var(--text-secondary);
|
| 156 |
+
opacity: 1;
|
| 157 |
+
}
|
| 158 |
+
::-webkit-input-placeholder {
|
| 159 |
+
color: var(--text-secondary);
|
| 160 |
+
opacity: 1;
|
| 161 |
+
}
|
| 162 |
+
:-ms-input-placeholder {
|
| 163 |
+
color: var(--text-secondary);
|
| 164 |
+
opacity: 1;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
#loading-overlay {
|
| 168 |
+
position: fixed;
|
| 169 |
+
top: 0;
|
| 170 |
+
left: 0;
|
| 171 |
+
width: 100%;
|
| 172 |
+
height: 100%;
|
| 173 |
+
background-color: rgba(18,18,18,0.95);
|
| 174 |
+
display: flex;
|
| 175 |
+
flex-direction: column;
|
| 176 |
+
align-items: center;
|
| 177 |
+
justify-content: center;
|
| 178 |
+
z-index: 9999;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.loading-spinner {
|
| 182 |
+
width: 60px;
|
| 183 |
+
height: 60px;
|
| 184 |
+
border: 6px solid #2C2C2C;
|
| 185 |
+
border-top-color: #2DECBF;
|
| 186 |
+
border-radius: 50%;
|
| 187 |
+
animation: spin 1s linear infinite;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.text-accent {
|
| 191 |
+
color: var(--accent);
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.loading-text {
|
| 195 |
+
margin-top: 15px;
|
| 196 |
+
font-size: 1.1rem;
|
| 197 |
+
color: #E0E0E0;
|
| 198 |
+
text-align: center;
|
| 199 |
+
letter-spacing: 0.5px;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
@keyframes spin {
|
| 203 |
+
to { transform: rotate(360deg); }
|
| 204 |
+
}
|
static/images/kpi_dashboard.png
ADDED
|
static/images/logo.png
ADDED
|
static/images/sensitivity_analysis_tornado.png
ADDED
|
Git LFS Details
|
static/js/charts.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function renderKPIChart(labels, data) {
|
| 2 |
+
const ctx = document.getElementById('kpiChart').getContext('2d');
|
| 3 |
+
if (window.kpiChart) window.kpiChart.destroy();
|
| 4 |
+
|
| 5 |
+
window.kpiChart = new Chart(ctx, {
|
| 6 |
+
type: 'bar',
|
| 7 |
+
data: {
|
| 8 |
+
labels: labels,
|
| 9 |
+
datasets: [{
|
| 10 |
+
label: 'IRR (%)',
|
| 11 |
+
data: data,
|
| 12 |
+
backgroundColor: '#00C3A3',
|
| 13 |
+
borderColor: '#2DECBF',
|
| 14 |
+
borderWidth: 1,
|
| 15 |
+
borderRadius: 6,
|
| 16 |
+
}]
|
| 17 |
+
},
|
| 18 |
+
options: {
|
| 19 |
+
responsive: true,
|
| 20 |
+
plugins: {
|
| 21 |
+
legend: { display: false },
|
| 22 |
+
tooltip: { mode: 'index', intersect: false, bodyColor: '#FFFFFF', titleColor: '#FFFFFF' }
|
| 23 |
+
},
|
| 24 |
+
scales: {
|
| 25 |
+
x: {
|
| 26 |
+
ticks: { color: '#FFFFFF' },
|
| 27 |
+
grid: { color: 'rgba(255,255,255,0.1)' }
|
| 28 |
+
},
|
| 29 |
+
y: {
|
| 30 |
+
ticks: { color: '#FFFFFF' },
|
| 31 |
+
grid: { color: 'rgba(255,255,255,0.1)' }
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function renderTornadoChart(labels, data) {
|
| 39 |
+
const ctx = document.getElementById('tornadoChart').getContext('2d');
|
| 40 |
+
if (window.tornadoChart) window.tornadoChart.destroy();
|
| 41 |
+
|
| 42 |
+
window.tornadoChart = new Chart(ctx, {
|
| 43 |
+
type: 'bar',
|
| 44 |
+
data: {
|
| 45 |
+
labels: labels,
|
| 46 |
+
datasets: [{
|
| 47 |
+
label: 'ΔIRR',
|
| 48 |
+
data: data,
|
| 49 |
+
backgroundColor: '#2DECBF',
|
| 50 |
+
borderRadius: 5
|
| 51 |
+
}]
|
| 52 |
+
},
|
| 53 |
+
options: {
|
| 54 |
+
indexAxis: 'y',
|
| 55 |
+
responsive: true,
|
| 56 |
+
plugins: {
|
| 57 |
+
legend: { display: false },
|
| 58 |
+
tooltip: { bodyColor: '#FFFFFF', titleColor: '#FFFFFF' }
|
| 59 |
+
},
|
| 60 |
+
scales: {
|
| 61 |
+
x: {
|
| 62 |
+
ticks: { color: '#FFFFFF' },
|
| 63 |
+
grid: { color: 'rgba(255,255,255,0.1)' }
|
| 64 |
+
},
|
| 65 |
+
y: {
|
| 66 |
+
ticks: { color: '#FFFFFF' },
|
| 67 |
+
grid: { color: 'rgba(255,255,255,0.1)' }
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
}
|
templates/index.html
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "layout.html" %}
|
| 2 |
+
{% block content %}
|
| 3 |
+
|
| 4 |
+
<div class="row">
|
| 5 |
+
<div class="col-lg-4">
|
| 6 |
+
<div class="card bg-secondary mb-3">
|
| 7 |
+
<div class="card-header">پارامترهای مدل</div>
|
| 8 |
+
<div class="card-body">
|
| 9 |
+
{% if import_error %}
|
| 10 |
+
<div class="alert alert-danger">خطا در بارگذاری main.py — لطفاً فایل و وابستگیها را بررسی کنید.<pre style="white-space:pre-wrap">{{ import_error }}</pre></div>
|
| 11 |
+
{% endif %}
|
| 12 |
+
<form method="post" action="{{ url_for('run') }}">
|
| 13 |
+
<div class="mb-2">
|
| 14 |
+
<label class="form-label">نرخ تورم (مثلاً 0.015)</label>
|
| 15 |
+
<input name="inflation" type="text" class="form-control" value="{{ defaults.inflation if defaults else '0.015' }}">
|
| 16 |
+
</div>
|
| 17 |
+
<div class="mb-2">
|
| 18 |
+
<label class="form-label">نرخ مالیات (مثلاً 0.10)</label>
|
| 19 |
+
<input name="tax" type="text" class="form-control" value="{{ defaults.tax if defaults else '0.10' }}">
|
| 20 |
+
</div>
|
| 21 |
+
<div class="mb-2">
|
| 22 |
+
<label class="form-label">طول پروژه (سال)</label>
|
| 23 |
+
<input name="years" type="number" class="form-control" value="{{ defaults.years if defaults else 15 }}">
|
| 24 |
+
</div>
|
| 25 |
+
<div class="mb-2">
|
| 26 |
+
<label class="form-label">ظرفیت (KTA) - حداقل</label>
|
| 27 |
+
<input name="cap_min" type="number" step="1" class="form-control" value="{{ defaults.cap_min if defaults else 500 }}">
|
| 28 |
+
</div>
|
| 29 |
+
<div class="mb-2">
|
| 30 |
+
<label class="form-label">ظرفیت (KTA) - حداکثر</label>
|
| 31 |
+
<input name="cap_max" type="number" step="1" class="form-control" value="{{ defaults.cap_max if defaults else 600 }}">
|
| 32 |
+
</div>
|
| 33 |
+
<div class="mb-2">
|
| 34 |
+
<label class="form-label">Technology</label>
|
| 35 |
+
<select name="technology" class="form-select">
|
| 36 |
+
{% for t in techs %}
|
| 37 |
+
<option {{ 'selected' if loop.first }}>{{ t }}</option>
|
| 38 |
+
{% endfor %}
|
| 39 |
+
</select>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="mb-2">
|
| 42 |
+
<label class="form-label">Export market mix (0-1)</label>
|
| 43 |
+
<input name="export_mix" type="number" step="0.01" class="form-control" value="{{ defaults.export_mix if defaults else 0.7 }}">
|
| 44 |
+
</div>
|
| 45 |
+
<div class="form-check mb-2">
|
| 46 |
+
<input class="form-check-input" type="checkbox" id="sell_byproducts" name="sell_byproducts" checked>
|
| 47 |
+
<label class="form-check-label" for="sell_byproducts">فروش محصولات جانبی (Sell byproducts)</label>
|
| 48 |
+
</div>
|
| 49 |
+
|
| 50 |
+
<hr class="my-3">
|
| 51 |
+
<label class="form-label">اعمال تغییرات پیشرفته (JSON)</label>
|
| 52 |
+
<small class="text-muted d-block mb-2">برای ویرایش / اضافه کردن تکنولوژی یا قیمتها از JSON استفاده کنید (اختیاری)</small>
|
| 53 |
+
<div class="mb-2">
|
| 54 |
+
<textarea name="tech_json" rows="6" class="form-control" placeholder='مثال: {"MyTech": {"capex_base_M": 200, "opex_base_cents_kg": 150}}'></textarea>
|
| 55 |
+
</div>
|
| 56 |
+
<div class="mb-2">
|
| 57 |
+
<textarea name="prices_json" rows="4" class="form-control" placeholder='مثال: {"pvc_s65_export": 1200}'></textarea>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<button class="btn btn-primary w-100" type="submit">اجرا و محاسبه</button>
|
| 61 |
+
</form>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="card bg-secondary">
|
| 66 |
+
<div class="card-header">فایل خروجی</div>
|
| 67 |
+
<div class="card-body">
|
| 68 |
+
<a class="btn btn-outline-light w-100" href="{{ url_for('download_results') }}">دانلود CSV نتایج</a>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div class="col-lg-8">
|
| 74 |
+
{% if top_kpis %}
|
| 75 |
+
<div class="row mb-3">
|
| 76 |
+
<div class="col-md-3">
|
| 77 |
+
<div class="card text-dark bg-light mb-2">
|
| 78 |
+
<div class="card-body text-center">
|
| 79 |
+
<h6>بهترین IRR (%)</h6>
|
| 80 |
+
<h4>{{ top_kpis.irr }}</h4>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="col-md-3">
|
| 85 |
+
<div class="card text-dark bg-light mb-2">
|
| 86 |
+
<div class="card-body text-center">
|
| 87 |
+
<h6>سود سالانه ($M)</h6>
|
| 88 |
+
<h4>{{ top_kpis.annual_profit_M }}</h4>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="col-md-3">
|
| 93 |
+
<div class="card text-dark bg-light mb-2">
|
| 94 |
+
<div class="card-body text-center">
|
| 95 |
+
<h6>CAPEX ($M)</h6>
|
| 96 |
+
<h4>{{ top_kpis.capex_M }}</h4>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="col-md-3">
|
| 101 |
+
<div class="card text-dark bg-light mb-2">
|
| 102 |
+
<div class="card-body text-center">
|
| 103 |
+
<h6>Payback (yrs)</h6>
|
| 104 |
+
<h4>{{ top_kpis.payback }}</h4>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
{% endif %}
|
| 110 |
+
|
| 111 |
+
<!-- Charts -->
|
| 112 |
+
<div class="card bg-secondary mb-3">
|
| 113 |
+
<div class="card-header">نمودارها و تحلیل حساسیت</div>
|
| 114 |
+
<div class="card-body text-center">
|
| 115 |
+
|
| 116 |
+
{% if kpi_img %}
|
| 117 |
+
<h6 class="text-light mb-2">KPI Dashboard</h6>
|
| 118 |
+
<img src="{{ url_for('static', filename=kpi_img) }}" alt="KPI Dashboard" class="img-fluid rounded shadow border border-secondary mb-3">
|
| 119 |
+
{% else %}
|
| 120 |
+
<div class="row">
|
| 121 |
+
<div class="col-md-6">
|
| 122 |
+
<canvas id="kpiChart" height="200"></canvas>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="col-md-6">
|
| 125 |
+
<canvas id="tornadoChart" height="200"></canvas>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
{% endif %}
|
| 129 |
+
|
| 130 |
+
{% if tornado_img %}
|
| 131 |
+
<h6 class="text-light mt-4 mb-2">Sensitivity Analysis</h6>
|
| 132 |
+
<img src="{{ url_for('static', filename=tornado_img) }}" alt="Tornado Chart" class="img-fluid rounded shadow border border-secondary">
|
| 133 |
+
{% endif %}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
<script>
|
| 142 |
+
const chartsPayload = {{ charts_json|safe if charts_json else 'null' }};
|
| 143 |
+
if (chartsPayload) {
|
| 144 |
+
const labels = chartsPayload.map((r, i) => r.Method ? r.Method : `M${i+1}`);
|
| 145 |
+
const irrs = chartsPayload.map(r => Number(r.irr) || 0);
|
| 146 |
+
|
| 147 |
+
renderKPIChart(labels, irrs);
|
| 148 |
+
|
| 149 |
+
const ranges = chartsPayload.map(r => {
|
| 150 |
+
const low = r.irr - 5; const high = r.irr + 5; return high - low;
|
| 151 |
+
});
|
| 152 |
+
renderTornadoChart(labels, ranges);
|
| 153 |
+
}
|
| 154 |
+
</script>
|
| 155 |
+
|
| 156 |
+
{% endblock %}
|
templates/layout.html
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="fa" dir="rtl">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>داشبورد بهینهسازی پروژه PVC</title>
|
| 7 |
+
|
| 8 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.rtl.min.css" rel="stylesheet">
|
| 9 |
+
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
| 11 |
+
|
| 12 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
| 13 |
+
</head>
|
| 14 |
+
|
| 15 |
+
<body>
|
| 16 |
+
<nav class="navbar navbar-dark bg-dark border-bottom border-secondary shadow-sm">
|
| 17 |
+
<div class="container-fluid d-flex align-items-center">
|
| 18 |
+
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo" height="120" class="me-2 rounded">
|
| 19 |
+
<span class="navbar-brand fw-bold text-accent">🏭 Petro Optima</span>
|
| 20 |
+
</div>
|
| 21 |
+
</nav>
|
| 22 |
+
|
| 23 |
+
<main class="px-4 py-3">
|
| 24 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 25 |
+
{% if messages %}
|
| 26 |
+
{% for category, msg in messages %}
|
| 27 |
+
<div class="alert alert-{{ 'info' if category=='info' else ('danger' if category=='danger' else ('success' if category=='success' else 'warning')) }} alert-dismissible fade show custom-alert" role="alert">
|
| 28 |
+
{{ msg }}
|
| 29 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 30 |
+
</div>
|
| 31 |
+
{% endfor %}
|
| 32 |
+
{% endif %}
|
| 33 |
+
{% endwith %}
|
| 34 |
+
{% block content %}{% endblock %}
|
| 35 |
+
</main>
|
| 36 |
+
|
| 37 |
+
<footer class="text-center text-muted py-3 border-top border-secondary">
|
| 38 |
+
</footer>
|
| 39 |
+
|
| 40 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
| 41 |
+
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
| 42 |
+
<div id="loading-overlay" class="d-none">
|
| 43 |
+
<div class="loading-spinner"></div>
|
| 44 |
+
<div class="loading-text">در حال انجام محاسبات، لطفاً صبر کنید...</div>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<script>
|
| 48 |
+
document.addEventListener("DOMContentLoaded", function() {
|
| 49 |
+
const forms = document.querySelectorAll("form");
|
| 50 |
+
forms.forEach(form => {
|
| 51 |
+
form.addEventListener("submit", function() {
|
| 52 |
+
const overlay = document.getElementById("loading-overlay");
|
| 53 |
+
overlay.classList.remove("d-none");
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
+
});
|
| 57 |
+
</script>
|
| 58 |
+
</body>
|
| 59 |
+
</html>
|