feat: Introduce linear projection for plant cost breakdown and decouple plant simulation from simulation_id.

main
MrWaradana 3 weeks ago
parent 8c01702c7a
commit 39a53c477a

@ -158,7 +158,7 @@ async def _trigger_masterdata_recalculation(
directory_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../modules/plant")
)
script_path = os.path.join(directory_path, "run2.py")
script_path = os.path.join(directory_path, "run_plant_simulation.py")
process = await asyncio.create_subprocess_exec(
"python",

@ -155,7 +155,7 @@ async def _trigger_masterdata_simulation_recalculation(
directory_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../modules/plant")
)
script_path = os.path.join(directory_path, "run2.py")
script_path = os.path.join(directory_path, "run_plant_simulation.py")
process = await asyncio.create_subprocess_exec(
"python",

@ -11,21 +11,15 @@ import math
import uuid
SIMULATION_ID = os.getenv("PLANT_SIMULATION_ID")
MASTER_TABLE = "lcc_ms_master_simulations"
PLANT_TABLE = "lcc_plant_tr_data_simulations"
def validate_number(n):
return n if n is not None else 0
def normalize_db_value(value):
"""Convert numpy scalars to native Python types for psycopg2."""
if isinstance(value, np.generic):
return value.item()
return value
def validate_number(n):
return n if n is not None else 0
def cumulative_npv(values, rate, initial_cf0=0.0):
"""
@ -76,12 +70,69 @@ def hitung_pv(rate, nper, fv):
def hitung_irr(cashflows: list):
return npf.irr(cashflows)
def getproyeksilinier(years, values, iterations=30, target_years=None):
"""
Jika target_years diberikan (list[int]), fungsi mengembalikan prediksi untuk tahun-tahun tersebut.
Jika target_years None, perilaku lama tetap: memproyeksikan dari max(years)+1 sepanjang (iterations - len(years)).
Catatan:
- Jika data historis < 2 titik, fallback pakai nilai terakhir (atau 0).
- Jika semua year sama (degenerate), fallback juga.
"""
if len(years) != len(values):
raise ValueError("Panjang years dan values harus sama")
# bersihkan pasangan (year, value) yang year-nya None
pairs = [(int(y), float(v)) for y, v in zip(years, values) if y is not None]
if not pairs:
# tidak ada data sama sekali
if target_years is None:
return {}
return {int(y): 0.0 for y in target_years}
years_clean = [p[0] for p in pairs]
values_clean = [p[1] for p in pairs]
# fallback kalau data tidak cukup untuk regresi
if len(set(years_clean)) < 2 or len(values_clean) < 2:
last_val = float(values_clean[-1]) if values_clean else 0.0
if target_years is None:
# perilaku lama
n_hist = len(years_clean)
if iterations <= n_hist:
return {}
start_year = max(years_clean) + 1
n_projection = iterations - n_hist
return {start_year + i: last_val for i in range(n_projection)}
return {int(y): last_val for y in target_years}
# regresi linier y = a*x + b
x = np.array(years_clean, dtype=float)
y = np.array(values_clean, dtype=float)
a, b = np.polyfit(x, y, 1)
def _predict(yr: int) -> float:
v = float(a * yr + b)
# optional: kalau tidak boleh negatif, clamp
return max(0.0, v)
# mode target_years: prediksi untuk tahun tertentu
if target_years is not None:
return {int(yr): _predict(int(yr)) for yr in target_years}
# mode lama: generate dari tahun setelah histori
n_hist = len(years_clean)
if iterations <= n_hist:
raise ValueError(
f"iterations ({iterations}) harus lebih besar dari jumlah data historis ({n_hist})"
)
start_year = max(years_clean) + 1
n_projection = iterations - n_hist
return {start_year + i: _predict(start_year + i) for i in range(n_projection)}
def main():
if not SIMULATION_ID:
print("Environment variable PLANT_SIMULATION_ID is required for simulations.")
sys.exit(1)
def main():
connections = get_connection()
conn = connections[0] if isinstance(connections, tuple) else connections
if conn is None:
@ -98,22 +149,18 @@ def main():
cur = conn.cursor()
# ### LOCKING: kunci tabel simulasi
# ### LOCKING: kunci tabel lcc_plant_tr_data
# Mode SHARE ROW EXCLUSIVE:
# - Menghalangi INSERT/UPDATE/DELETE di tabel ini
# - Menghalangi lock SHARE ROW EXCLUSIVE lain → script ngantri satu per satu
cur.execute(f"LOCK TABLE {PLANT_TABLE} IN SHARE ROW EXCLUSIVE MODE")
cur.execute("LOCK TABLE lcc_plant_tr_data IN SHARE ROW EXCLUSIVE MODE")
# 0 Mendapatkan master parameter dari tabel lcc_ms_master
cur.execute(
f"""
SELECT name,
value_num AS value
FROM {MASTER_TABLE}
WHERE simulation_id = %s
""",
(SIMULATION_ID,),
)
cur.execute("""
SELECT name,
value_num AS value
FROM lcc_ms_master
""")
param_rows = cur.fetchall()
param_map = {name: val for (name, val) in param_rows}
@ -124,43 +171,29 @@ def main():
# 0-1 Generate New data Projection (is_actual=0) if not exist
# Hapus data projection lama (is_actual = 0)
cur.execute(
f"""
DELETE
FROM {PLANT_TABLE}
WHERE is_actual = 0
AND simulation_id = %s
""",
(SIMULATION_ID,),
)
cur.execute("""
DELETE
FROM lcc_plant_tr_data
WHERE is_actual = 0
""")
# Hitung kebutuhan jumlah baris projection baru agar total (actual + projection)
# sama dengan parameter umur_teknis
cur.execute(
f"""
SELECT COALESCE(COUNT(*), 0)
FROM {PLANT_TABLE}
WHERE is_actual = 1
AND simulation_id = %s
""",
(SIMULATION_ID,),
)
cur.execute("""
SELECT COALESCE(COUNT(*), 0)
FROM lcc_plant_tr_data
WHERE is_actual = 1
""")
count_actual = cur.fetchone()[0] if cur.rowcount != -1 else 0
umur_teknis = int(get_param("umur_teknis"))
proj_needed = max(0, umur_teknis - int(count_actual))
# Ambil seq dan tahun terakhir sebagai titik awal penomoran berikutnya
cur.execute(
f"SELECT COALESCE(MAX(seq), 0) FROM {PLANT_TABLE} WHERE simulation_id = %s",
(SIMULATION_ID,),
)
cur.execute("SELECT COALESCE(MAX(seq), 0) FROM lcc_plant_tr_data")
last_seq = int(cur.fetchone()[0])
cur.execute(
f"SELECT COALESCE(MAX(tahun), 0) FROM {PLANT_TABLE} WHERE simulation_id = %s",
(SIMULATION_ID,),
)
cur.execute("SELECT COALESCE(MAX(tahun), 0) FROM lcc_plant_tr_data")
last_year = int(cur.fetchone()[0])
# Jika belum ada tahun sama sekali, gunakan tahun_cod-1 sebagai dasar
@ -181,29 +214,62 @@ def main():
next_year += 1
insert_sql = (
f"INSERT INTO {PLANT_TABLE} (id, seq, tahun, is_actual, simulation_id, created_at, created_by) "
"VALUES (%s, %s, %s, 0, %s, CURRENT_TIMESTAMP, 'SYS')"
"INSERT INTO lcc_plant_tr_data (id, seq, tahun, is_actual, created_at, created_by) "
"VALUES (%s, %s, %s, 0, CURRENT_TIMESTAMP, 'SYS')"
)
sim_values = [(*val, SIMULATION_ID) for val in values]
cur.executemany(insert_sql, sim_values)
cur.executemany(insert_sql, values)
# 1. Ambil data awal
select_sql = f"""
SELECT *
FROM {PLANT_TABLE}
WHERE simulation_id = %s
ORDER BY seq \
"""
cur.execute(select_sql, (SIMULATION_ID,))
select_sql = """
SELECT *
FROM lcc_plant_tr_data
ORDER BY seq \
"""
cur.execute(select_sql)
col_names = [desc[0] for desc in cur.description]
rows = cur.fetchall()
# ============================================================
# PROYEKSI LINIER untuk COST BD berdasarkan histori (is_actual=1)
# ============================================================
hist_years_om, hist_vals_om = [], []
hist_years_pm, hist_vals_pm = [], []
hist_years_bd, hist_vals_bd = [], []
projection_years = []
for r in rows:
d = dict(zip(col_names, r))
yr = d.get("tahun")
if yr is None:
continue
yr = int(yr)
if d.get("is_actual") == 1:
# ambil histori (boleh 0 kalau null)
hist_years_om.append(yr)
hist_vals_om.append(validate_number(d.get("cost_bd_om")))
hist_years_pm.append(yr)
hist_vals_pm.append(validate_number(d.get("cost_bd_pm_nonmi")))
hist_years_bd.append(yr)
hist_vals_bd.append(validate_number(d.get("cost_bd_bd")))
else:
# tahun-tahun projection yang ingin diprediksi
projection_years.append(yr)
# buat mapping prediksi per tahun untuk masing-masing komponen cost BD
proj_cost_bd_om = getproyeksilinier(hist_years_om, hist_vals_om, target_years=projection_years)
proj_cost_bd_pm = getproyeksilinier(hist_years_pm, hist_vals_pm, target_years=projection_years)
proj_cost_bd_bd = getproyeksilinier(hist_years_bd, hist_vals_bd, target_years=projection_years)
print(f"Jumlah baris yang akan di-update: {len(rows)}")
# 2. Siapkan data untuk bulk UPDATE
update_sql = f"""
UPDATE {PLANT_TABLE}
update_sql = """
UPDATE lcc_plant_tr_data
SET net_capacity_factor = %s,
eaf = %s,
production_bruto = %s,
@ -267,12 +333,12 @@ def main():
chart_oem_periodic_maintenance_cost = %s,
chart_oem_annualized = %s,
chart_capex_component_a = %s,
chart_capex_biaya_investasi_tambahan = %s,
chart_capex_acquisition_cost = %s,
chart_capex_annualized = %s
WHERE seq = %s
AND simulation_id = %s \
"""
chart_capex_biaya_investasi_tambahan = %s,
chart_capex_acquisition_cost = %s,
chart_capex_annualized = %s,
cost_disposal_cost = %s
WHERE seq = %s \
"""
# Ambil parameter dari tabel (fungsi get_param sudah kamu buat sebelumnya)
discount_rate = get_param("discount_rate") / 100
@ -333,32 +399,25 @@ def main():
net_capacity_factor_v = 0
eaf_v = 0
# Prefetch CF/EAF master data once to avoid repeated queries per row
cur.execute(
"""
SELECT year as tahun, cf, eaf
FROM lcc_ms_year_data
"""
)
# Prefetch master data CF dan EAF sekali saja di luar loop
cur.execute("""
SELECT year as tahun, cf, eaf
FROM lcc_ms_year_data
order by year asc
""")
year_rows = cur.fetchall()
year_data_map = {
int(t): (validate_number(cf), validate_number(eaf))
for (t, cf, eaf) in year_rows
if t is not None
}
year_data_map = {int(t): (validate_number(cf), validate_number(eaf)) for (t, cf, eaf) in year_rows if
t is not None}
for row in rows:
# row adalah tuple sesuai urutan select_sql
data = dict(zip(col_names, row))
seq = data["seq"] # primary key / unique key untuk WHERE
yr = int(data["tahun"]) if data.get("tahun") is not None else None
# Ambil net_capacity_factor dan eaf dari year-data cache berdasarkan tahun
cf_eaf = (
year_data_map.get(int(data["tahun"]))
if data.get("tahun") is not None
else None
)
# Ambil net_capacity_factor dan eaf dari cache berdasarkan tahun
cf_eaf = year_data_map.get(int(data["tahun"])) if data.get("tahun") is not None else None
if cf_eaf:
net_capacity_factor_v, eaf_v = cf_eaf
else:
@ -366,8 +425,6 @@ def main():
eaf_v = 0
if data["is_actual"] == 1:
net_capacity_factor = validate_number(data["net_capacity_factor"])
eaf = validate_number(data["eaf"])
production_bruto = validate_number(data["production_bruto"])
production_netto = validate_number(data["production_netto"])
energy_sales = production_netto
@ -381,8 +438,6 @@ def main():
cost_bd_pm_nonmi = validate_number(data["cost_bd_pm_nonmi"])
cost_bd_bd = validate_number(data["cost_bd_bd"])
else:
net_capacity_factor = net_capacity_factor # last value
eaf = eaf # last value
production_netto = net_capacity_factor * 8760 * daya_mampu_netto / 100
production_bruto = production_netto / (100 - (auxiliary + susut_trafo)) * 100
energy_sales = production_netto
@ -392,9 +447,15 @@ def main():
revenue_c = price_c * production_netto * 1000 / 1000000
revenue_d = price_d * production_netto * 1000 / 1000000
cost_c_fuel = fuel_consumption * harga_bahan_bakar / 1000000
cost_bd_om = cost_bd_om # last value
cost_bd_pm_nonmi = cost_bd_pm_nonmi # last value
cost_bd_bd = cost_bd_bd # last value
# default fallback tetap pakai last value kalau tahun kosong / prediksi tidak ada
if yr is not None:
cost_bd_om = proj_cost_bd_om.get(yr, cost_bd_om)
cost_bd_pm_nonmi = proj_cost_bd_pm.get(yr, cost_bd_pm_nonmi)
cost_bd_bd = proj_cost_bd_bd.get(yr, cost_bd_bd)
else:
cost_bd_om = cost_bd_om
cost_bd_pm_nonmi = cost_bd_pm_nonmi
cost_bd_bd = cost_bd_bd
net_capacity_factor = net_capacity_factor_v
eaf = eaf_v
@ -440,6 +501,7 @@ def main():
# + cost_a_pinjaman
# + cost_a_depreciation
)
else:
cost_a_replacement = 0
cost_a_pm = 0
@ -455,11 +517,15 @@ def main():
chart_capex_component_a = cost_a_acquisition
chart_capex_annualized = cost_a_annualized
cost_disposal_cost = -npf.pmt(discount_rate, seq, 0, 0.05 * total_project_cost)
else:
chart_capex_component_a = total_project_cost
chart_capex_annualized = 0
cost_a_pv = 0
cost_a_annualized = 0
cost_disposal_cost = 0
chart_capex_biaya_investasi_tambahan = 0
chart_capex_acquisition_cost = 0
@ -544,6 +610,8 @@ def main():
calc4_free_cash_flow_on_equity_array.append(calc4_free_cash_flow_on_equity)
calc4_discounted_fcf_on_equity = hitung_pv(wacc_on_equity, seq, calc4_free_cash_flow_on_equity)
row_params = (
net_capacity_factor,
eaf,
@ -611,8 +679,8 @@ def main():
chart_capex_biaya_investasi_tambahan,
chart_capex_acquisition_cost,
chart_capex_annualized,
seq,
SIMULATION_ID,
cost_disposal_cost,
seq # <-- penting: ini untuk WHERE
)
params.append(tuple(normalize_db_value(v) for v in row_params))
@ -639,12 +707,11 @@ def main():
ROA_TO_L = sum(calc2_earning_after_tax_array_sampai_sekarang) / sum(
total_residual_value_array_sampai_sekarang) * 100 # dalam %
update_kpi_sql = f"""
UPDATE {MASTER_TABLE}
SET value_num = %s
WHERE name = %s
AND simulation_id = %s \
"""
update_kpi_sql = """
UPDATE lcc_ms_master
SET value_num = %s
WHERE name = %s \
"""
kpi_params_raw = [
(IRR_ON_EQUITY * 100, "calc_on_equity_irr"),
@ -656,11 +723,7 @@ def main():
]
kpi_params = [
(
None if (value is None or isinstance(value, float) and math.isnan(value)) else value,
key,
SIMULATION_ID,
)
(None if (value is None or isinstance(value, float) and math.isnan(value)) else value, key)
for value, key in kpi_params_raw
]

@ -8,12 +8,15 @@ from src.database.service import CommonParameters
from src.models import StandardResponse
from .schema import (
PlantFSTransactionDataCreate,
PlantFSTransactionDataPagination,
PlantFSTransactionDataRead,
PlantFSTransactionDataUpdate,
PlantFSTransactionDataCreate,
PlantFSTransactionDataImport,
PlantFSTransactionDataPagination,
PlantFSTransactionDataRead,
PlantFSTransactionDataUpdate,
)
from .service import create, delete, get, get_all, update
from .service import create, delete, get, get_all, update, update_fs_charts_from_matrix
from typing import List
router = APIRouter()
@ -119,3 +122,26 @@ async def delete_fs_transaction(
return StandardResponse(data=record, message="Data deleted successfully")
@router.post(
"/import/charts",
response_model=StandardResponse[List[PlantFSTransactionDataRead]],
)
async def import_fs_charts(
db_session: DbSession,
payload: PlantFSTransactionDataImport,
current_user: CurrentUser,
):
updated, missing = await update_fs_charts_from_matrix(
db_session=db_session,
payload=payload,
updated_by=getattr(current_user, "user_id", None) if current_user else None,
)
msg = "Data imported successfully."
if missing:
msg += f" Note: Years {missing} were not found."
return StandardResponse(data=updated, message=msg)

@ -1,5 +1,5 @@
from datetime import datetime
from typing import List, Optional
from typing import Any, List, Optional
from uuid import UUID
from pydantic import Field
@ -84,3 +84,8 @@ class PlantFSTransactionDataRead(PlantFSTransactionDataBase):
class PlantFSTransactionDataPagination(Pagination):
items: List[PlantFSTransactionDataRead] = []
class PlantFSTransactionDataImport(DefaultBase):
data: List[List[Any]]
seq: Optional[int] = None

@ -1,5 +1,5 @@
import logging
from typing import Optional
from typing import Any, Dict, List, Optional
from sqlalchemy import Delete, Select, String, cast
@ -9,6 +9,7 @@ from src.database.service import search_filter_sort_paginate
from src.plant_fs_transaction_data.model import PlantFSTransactionData
from src.plant_fs_transaction_data.schema import (
PlantFSTransactionDataCreate,
PlantFSTransactionDataImport,
PlantFSTransactionDataUpdate,
)
@ -116,3 +117,130 @@ async def delete(*, db_session: DbSession, fs_transaction_id: str) -> None:
)
await db_session.execute(query)
await db_session.commit()
def _safe_float(x: object) -> float:
"""Safely convert `x` to float, returning 0.0 for None or invalid values."""
try:
if x is None:
return 0.0
return float(x)
except Exception:
return 0.0
_FS_LABEL_FIELD_MAP: Dict[str, str] = {
"Total Revenue": "fs_chart_total_revenue",
"Revenue A": "fs_chart_revenue_a",
"Revenue B": "fs_chart_revenue_b",
"Revenue C": "fs_chart_revenue_c",
"Revenue D": "fs_chart_revenue_d",
"Revenue Annualized": "fs_chart_revenue_annualized",
"Fuel Cost (Component C)": "fs_chart_fuel_cost_component_c",
"Fuel Cost": "fs_chart_fuel_cost",
"Fuel Cost Annualized": "fs_chart_fuel_cost_annualized",
"O and M Cost (Component B and D)": "fs_chart_oem_component_bd",
"O and M Cost": "fs_chart_oem_bd_cost",
"Periodic Maintenance Cost (NonMI)": "fs_chart_oem_periodic_maintenance_cost",
"O and M Cost Annualized": "fs_chart_oem_annualized",
"Capex (Component A)": "fs_chart_capex_component_a",
"Biaya Investasi Tambahan": "fs_chart_capex_biaya_investasi_tambahan",
"Acquisition Cost": "fs_chart_capex_acquisition_cost",
"Capex Annualized": "fs_chart_capex_annualized",
}
def _extract_years(header_row: List[Any]) -> List[int]:
years: List[int] = []
for cell in header_row[2:]:
if cell is None:
continue
try:
years.append(int(float(cell)))
except Exception:
continue
return years
def _resolve_label(row: List[Any]) -> Optional[str]:
for candidate in row[:2]:
if isinstance(candidate, str):
label = candidate.strip()
if label:
return label
return None
def _build_fs_year_value_map(matrix: List[List[Any]]) -> Dict[int, Dict[str, float]]:
if not matrix:
return {}
header = matrix[0]
years = _extract_years(header)
if not years:
return {}
year_map: Dict[int, Dict[str, float]] = {year: {} for year in years}
for row in matrix[1:]:
label = _resolve_label(row)
if not label:
continue
field_name = _FS_LABEL_FIELD_MAP.get(label)
if not field_name:
continue
for idx, year in enumerate(years):
col_idx = idx + 2
if col_idx >= len(row):
continue
value = row[col_idx]
if value is None:
continue
try:
year_map[year][field_name] = _safe_float(value)
except Exception:
continue
return year_map
async def update_fs_charts_from_matrix(
*,
db_session: DbSession,
payload: PlantFSTransactionDataImport,
updated_by: Optional[str] = None,
):
"""Update fs_* chart columns based on a transposed matrix payload."""
year_value_map = _build_fs_year_value_map(payload.data)
if not year_value_map:
return [], []
updated_records: List[PlantFSTransactionData] = []
missing_years: List[int] = []
for year, field_values in year_value_map.items():
if not field_values:
continue
query = Select(PlantFSTransactionData).where(PlantFSTransactionData.tahun == year)
if payload.seq is not None:
query = query.where(PlantFSTransactionData.seq == payload.seq)
result = await db_session.execute(query)
records = result.scalars().all()
if not records:
missing_years.append(year)
continue
for record in records:
for field_name, value in field_values.items():
setattr(record, field_name, value)
if updated_by:
record.updated_by = updated_by
updated_records.append(record)
await db_session.commit()
return updated_records, missing_years

@ -218,7 +218,7 @@ async def create(
)
# Construct path to the script
script_path = os.path.join(directory_path, "run2.py")
script_path = os.path.join(directory_path, "run_plant_simulation.py")
try:
process = await asyncio.create_subprocess_exec(
@ -261,7 +261,7 @@ async def update(
)
# Construct path to the script
script_path = os.path.join(directory_path, "run2.py")
script_path = os.path.join(directory_path, "run_plant_simulation.py")
try:
process = await asyncio.create_subprocess_exec(

@ -229,7 +229,7 @@ async def create(
)
# Construct path to the script
script_path = os.path.join(directory_path, "run2.py")
script_path = os.path.join(directory_path, "run_plant_simulation.py")
try:
process = await asyncio.create_subprocess_exec(
@ -272,7 +272,7 @@ async def update(
)
# Construct path to the script
script_path = os.path.join(directory_path, "run2.py")
script_path = os.path.join(directory_path, "run_plant_simulation.py")
try:
process = await asyncio.create_subprocess_exec(

Loading…
Cancel
Save