update replace cost equipment logic

main
MrWaradana 2 weeks ago
parent 07e2d75cb5
commit f0142044cd

@ -0,0 +1,59 @@
# Updated Equipment Acquisition & Simulation Algorithm
This document outlines the refactored logic for equipment acquisition cost calculation and simulation forecasting, implemented in February 2026.
## 1. Timeline Definitions
The simulation follows a strict temporal alignment to ensure consistency across the fleet:
| Parameter | Value | Description |
| :--- | :--- | :--- |
| **Base Year** | `2015` | The target year for all "Value of Money" (Net Present Value) calculations. |
| **Forecasting Start** | `2015` | The year from which future predictions and Economic Life reports begin. |
| **Calculation Start** | `2014` | The technical sequence start ($seq = 0$) used to establish an initial state. |
---
## 2. Capital Cost Adjustment (Value of Money)
To account for the time value of money, both the **Initial Acquisition Cost** and the **Replacement Cost** are normalized to the **2015 Base Year** using the project's inflation rate.
### 2.1 Adjustment Formula
The value of any cost $V$ at a specific $Year$ is adjusted to its equivalent value in $2015$ using the following formula:
$$V_{2015} = \frac{V_{Year}}{(1 + r)^{(Year - 2015)}}$$
Where:
- $V_{2015}$ = Adjusted value in 2015 terms.
- $V_{Year}$ = Raw cost recorded in the database or Maximo.
- $r$ = Inflation rate (from `lcc_ms_master`, defaults to $0.05$ if undefined).
- $Year$ = The year the cost was recorded ($Y_{acq}$ or $Y_{replace}$).
### 2.2 Total Acquisition Cost
The total capital cost $C_{total}$ stored in the master data is the sum of the adjusted initial cost and the adjusted first detected replacement cost:
$$C_{total} = \frac{C_{initial}}{(1+r)^{(Y_{acq} - 2015)}} + \frac{C_{replace}}{(1+r)^{(Y_{replace} - 2015)}}$$
---
## 3. Maintenance Cost Suppression Logic
A specific business rule is applied to prevent "double counting" or distorted maintenance records during major equipment replacement years:
### 3.1 Replacement Year Rule
In the **first year** where a `replace_cost > 0` is detected in Maximo ($Y_{replace}$):
- All **Material Costs** are set to $0.0$.
- All **Labor Costs** (and labor hours) are set to $0.0$.
### 3.2 Logic Rationale
The replacement cost is treated as a capital expenditure (CAPEX) that restarts the equipment's life cycle. Standard maintenance (OPEX) for that specific year is ignored because the replacement action supersedes regular repair tasks.
---
## 4. Implementation Reference
The logic is primarily contained in:
- `src/equipment/service.py`: `check_and_update_acquisition_data()` (Cost adjustments).
- `src/modules/equipment/insert_actual_data.py`: `query_data()` (Timeline and cost suppression).

@ -134,7 +134,7 @@ async def search_filter_sort_paginate(
# Get total count # Get total count
count_query = Select(func.count()).select_from(query.subquery()) count_query = Select(func.count()).select_from(query.subquery())
total = await db_session.scalar(count_query) total = await db_session.scalar(count_query)
if all: if all or items_per_page == -1:
result = await db_session.execute(query) result = await db_session.execute(query)
items = _extract_result_items(result) items = _extract_result_items(result)
return { return {

@ -670,9 +670,9 @@ async def delete(*, db_session: DbSession, equipment_id: str):
async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str) -> bool: async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str) -> bool:
""" """
Check if acquisition year/cost in Maximo differs from local DB. Check if acquisition cost in Maximo differs from local DB.
If changed, archive history, delete transaction data, update master, and return True. Updates master acquisition_cost (initial + replacement) and sets forecasting_start_year to 2015.
Otherwise return False. Returns True if master record was updated, False otherwise.
""" """
conn = get_production_connection() conn = get_production_connection()
first_year = None first_year = None
@ -680,7 +680,7 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
if conn: if conn:
try: try:
cursor = conn.cursor() cursor = conn.cursor()
# Query the oldest year from wo_maximo to detect the original acquisition # Query the oldest year from wo_maximo to detect the original replacement cost
query = """ query = """
select DATE_PART('year', a.reportdate) AS year, a.asset_replacecost AS cost select DATE_PART('year', a.reportdate) AS year, a.asset_replacecost AS cost
from wo_maximo a from wo_maximo a
@ -697,7 +697,7 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
cursor.close() cursor.close()
conn.close() conn.close()
except Exception as e: except Exception as e:
print(f"Error fetching acquisition year for {assetnum}: {e}") print(f"Error fetching replacement data for {assetnum}: {e}")
if conn: if conn:
try: try:
conn.close() conn.close()
@ -706,7 +706,6 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
updates_performed = False updates_performed = False
if first_year:
# Fetch equipment to update # Fetch equipment to update
eq = await get_by_assetnum(db_session=db_session, assetnum=assetnum) eq = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
if eq: if eq:
@ -715,111 +714,64 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
current_acq = eq.acquisition_year current_acq = eq.acquisition_year
current_life = eq.design_life current_life = eq.design_life
current_target = eq.forecasting_target_year current_target = eq.forecasting_target_year
current_acq_cost = eq.acquisition_cost
# If current_target is logically "default", we update it.
# If user changed it to something else, we might want to preserve it
# if it currently holds the default value (based on old acq year).
is_valid_default = False is_valid_default = False
if current_acq and current_life and current_target: if current_acq and current_life and current_target:
is_valid_default = current_target == (current_acq + current_life) is_valid_default = current_target == (current_acq + current_life)
# Check for changes # Fetch inflation rate from lcc_ms_master for value-of-money adjustment
change_year = (eq.acquisition_year != first_year) inflation_rate = 0.05 # Default fallback
change_cost = (first_cost is not None and eq.acquisition_cost != first_cost) try:
rate_query = text("SELECT value_num / 100.0 FROM lcc_ms_master WHERE name = 'inflation_rate'")
# We only archive transaction history if the acquisition year itself changed. rate_result = (await db_session.execute(rate_query)).scalar()
# This prevents redundant history entries for cost-only updates. if rate_result is not None:
if change_year: inflation_rate = float(rate_result)
print(f"Acquisition year change detected for {assetnum}: {current_acq}->{first_year}. Archiving history.") except Exception as e:
print(f"Warning: Could not fetch inflation_rate for {assetnum}: {e}")
acq_year_ref = f"{current_acq}_{current_target}"
# Calculate initial cost from category/proportion (base acquisition cost)
# --- ARCHIVE HISTORICAL DATA --- initial_cost = 0.0
if eq.category_no and eq.proportion:
# Check for existing identical archive to prevent duplicates (after calculation failures/retries) _, aggregated_cost = await fetch_acquisition_cost_with_rollup(
check_hist_query = text("SELECT 1 FROM lcc_ms_equipment_historical_data WHERE assetnum = :assetnum AND acquisition_year_ref = :acq_year_ref LIMIT 1") db_session=db_session, base_category_no=eq.category_no
hist_exists = (await db_session.execute(check_hist_query, {"assetnum": assetnum, "acq_year_ref": acq_year_ref})).fetchone() )
if aggregated_cost:
if not hist_exists: initial_cost = (eq.proportion * 0.01) * aggregated_cost
# 1. Copy old equipment master data to history
history_ms_query = text(""" # Adjust initial cost to 2015 value (Base Year)
INSERT INTO lcc_ms_equipment_historical_data ( # Formula: Value_2015 = Value_Year / (1 + rate)^(Year - 2015)
id, assetnum, acquisition_year, acquisition_cost, capital_cost_record_time, design_life, adj_initial_cost = initial_cost
forecasting_start_year, forecasting_target_year, manhours_rate, created_at, created_by, if current_acq and current_acq != 2015:
updated_at, updated_by, min_eac_info, harga_saat_ini, minimum_eac_seq, minimum_eac_year, adj_initial_cost = initial_cost / ((1 + inflation_rate) ** (current_acq - 2015))
minimum_eac, minimum_npv, minimum_pmt, minimum_pmt_aq_cost, minimum_is_actual,
efdh_equivalent_forced_derated_hours, foh_forced_outage_hours, category_no, proportion, # Adjust replace cost to 2015 value (Base Year)
acquisition_year_ref adj_replace_cost = (first_cost or 0.0)
) if first_year and first_year != 2015:
SELECT adj_replace_cost = (first_cost or 0.0) / ((1 + inflation_rate) ** (first_year - 2015))
uuid_generate_v4(), assetnum, acquisition_year, acquisition_cost, capital_cost_record_time, design_life,
forecasting_start_year, forecasting_target_year, manhours_rate, created_at, created_by, # Total cost is adjusted initial cost plus the adjusted replacement cost
updated_at, updated_by, min_eac_info, harga_saat_ini, minimum_eac_seq, minimum_eac_year, total_cost = adj_initial_cost + adj_replace_cost
minimum_eac, minimum_npv, minimum_pmt, minimum_pmt_aq_cost, minimum_is_actual,
efdh_equivalent_forced_derated_hours, foh_forced_outage_hours, category_no, proportion, change_cost = (eq.acquisition_cost != total_cost)
:acq_year_ref # Requirement: forecasting_start_year always starts from 2015
FROM lcc_ms_equipment_data change_start = (eq.forecasting_start_year != 2015)
WHERE assetnum = :assetnum
""") # Note: acquisition_year itself is no longer updated as per requirements.
await db_session.execute(history_ms_query, {"acq_year_ref": acq_year_ref, "assetnum": assetnum})
if change_cost or change_start:
# 2. Copy old transaction data to lcc_equipment_historical_tr_data if change_cost:
history_tr_query = text(""" print(
INSERT INTO lcc_equipment_historical_tr_data ( f"Acquisition cost update for {assetnum}: {eq.acquisition_cost} -> {total_cost} "
id, assetnum, tahun, seq, is_actual, f"(Adj. Initial: {adj_initial_cost} + Adj. Replacement: {adj_replace_cost} | Rate: {inflation_rate})"
raw_cm_interval, raw_cm_material_cost, raw_cm_labor_time, raw_cm_labor_human, )
raw_pm_interval, raw_pm_material_cost, raw_pm_labor_time, raw_pm_labor_human, eq.acquisition_cost = total_cost
raw_oh_interval, raw_oh_material_cost, raw_oh_labor_time, raw_oh_labor_human,
raw_predictive_interval, raw_predictive_material_cost, raw_predictive_labor_time, raw_predictive_labor_human, if change_start:
raw_project_task_material_cost, "raw_loss_output_MW", raw_loss_output_price, print(f"Aligning forecasting_start_year to 2015 for {assetnum}")
raw_operational_cost, raw_maintenance_cost, eq.forecasting_start_year = 2015
rc_cm_material_cost, rc_cm_labor_cost, # If target was default, we update it to 2015 + design_life
rc_pm_material_cost, rc_pm_labor_cost,
rc_oh_material_cost, rc_oh_labor_cost,
rc_predictive_labor_cost,
rc_project_material_cost, rc_lost_cost, rc_operation_cost, rc_maintenance_cost,
rc_total_cost,
eac_npv, eac_annual_mnt_cost, eac_annual_acq_cost, eac_disposal_cost, eac_eac,
efdh_equivalent_forced_derated_hours, foh_forced_outage_hours,
created_by, created_at, acquisition_year_ref
)
SELECT
uuid_generate_v4(), assetnum, tahun, seq, is_actual,
raw_cm_interval, raw_cm_material_cost, raw_cm_labor_time, raw_cm_labor_human,
raw_pm_interval, raw_pm_material_cost, raw_pm_labor_time, raw_pm_labor_human,
raw_oh_interval, raw_oh_material_cost, raw_oh_labor_time, raw_oh_labor_human,
raw_predictive_interval, raw_predictive_material_cost, raw_predictive_labor_time, raw_predictive_labor_human,
raw_project_task_material_cost, "raw_loss_output_MW", raw_loss_output_price,
raw_operational_cost, raw_maintenance_cost,
rc_cm_material_cost, rc_cm_labor_cost,
rc_pm_material_cost, rc_pm_labor_cost,
rc_oh_material_cost, rc_oh_labor_cost,
rc_predictive_labor_cost,
rc_project_material_cost, rc_lost_cost, rc_operation_cost, rc_maintenance_cost,
rc_total_cost,
eac_npv, eac_annual_mnt_cost, eac_annual_acq_cost, eac_disposal_cost, eac_eac,
efdh_equivalent_forced_derated_hours, foh_forced_outage_hours,
created_by, NOW(), :acq_year_ref
FROM lcc_equipment_tr_data
WHERE assetnum = :assetnum
""")
await db_session.execute(history_tr_query, {"acq_year_ref": acq_year_ref, "assetnum": assetnum})
# 3. Delete old data
del_query = text("DELETE FROM lcc_equipment_tr_data WHERE assetnum = :assetnum")
await db_session.execute(del_query, {"assetnum": assetnum})
# Update Equipment Master regardless of if archive was needed/skipped
if change_year or change_cost:
if first_cost is not None and eq.acquisition_cost != first_cost:
eq.acquisition_cost = first_cost
if eq.acquisition_year != first_year:
eq.acquisition_year = first_year
eq.forecasting_start_year = first_year # Align start with acquisition
if is_valid_default and current_life: if is_valid_default and current_life:
eq.forecasting_target_year = first_year + current_life eq.forecasting_target_year = 2015 + current_life
await db_session.commit() await db_session.commit()
updates_performed = True updates_performed = True

@ -39,72 +39,6 @@ def get_recursive_query(cursor, assetnum, worktype="CM"):
Fungsi untuk menjalankan query rekursif berdasarkan assetnum dan worktype. Fungsi untuk menjalankan query rekursif berdasarkan assetnum dan worktype.
worktype memiliki nilai default 'CM'. worktype memiliki nilai default 'CM'.
""" """
# query = f"""
# SELECT
# ROW_NUMBER() OVER (ORDER BY tbl.assetnum, tbl.year, tbl.worktype) AS seq,
# *
# FROM (
# SELECT
# a.worktype,
# a.assetnum,
# EXTRACT(YEAR FROM a.reportdate) AS year,
# COUNT(a.wonum) AS raw_corrective_failure_interval,
# SUM(a.total_cost_max) AS raw_corrective_material_cost,
# ROUND(
# SUM(
# EXTRACT(EPOCH FROM (
# a.actfinish -
# a.actstart
# ))
# ) / 3600
# , 2) AS raw_corrective_labor_time_jam,
# SUM(a.jumlah_labor) AS raw_corrective_labor_technician
# FROM
# public.wo_staging_3 AS a
# WHERE
# a.unit = '3'
# GROUP BY
# a.worktype,
# a.assetnum,
# EXTRACT(YEAR FROM a.reportdate)
# ) AS tbl
# WHERE
# tbl.worktype = '{worktype}'
# AND tbl.assetnum = '{assetnum}'
# ORDER BY
# tbl.assetnum,
# tbl.year,
# tbl.worktype
# """
# query = f"""
# select d.tahun, SUM(d.actmatcost) AS raw_corrective_material_cost, sum(d.man_hour) as man_hour_peryear from
# (
# SELECT
# a.wonum,
# a.actmatcost,
# DATE_PART('year', a.reportdate) AS tahun,
# (
# ROUND(SUM(EXTRACT(EPOCH FROM (a.actfinish - a.actstart)) / 3600), 2)
# ) AS man_hour,
# CASE
# WHEN COUNT(b.laborcode) = 0 THEN 3
# ELSE COUNT(b.laborcode)
# END AS man_count
# FROM public.wo_maximo AS a
# LEFT JOIN public.wo_maximo_labtrans AS b
# ON b.wonum = a.wonum
# WHERE
# a.asset_unit = '3'
# AND a.worktype = '{worktype}'
# AND a.asset_assetnum = '{assetnum}'
# and a.wonum not like 'T%'
# GROUP BY
# a.wonum,
# a.actmatcost,
# DATE_PART('year', a.reportdate)
# ) as d group by d.tahun
# ;
# """
where_query = get_where_query_sql(assetnum, worktype) where_query = get_where_query_sql(assetnum, worktype)
query = f""" query = f"""
@ -360,48 +294,11 @@ def _build_tr_row_values(
) )
rc_cm_material_cost = raw_cm_material_cost_total rc_cm_material_cost = raw_cm_material_cost_total
# rc_cm_labor_cost = (
# data_cm_row.get("raw_cm_labor_time")
# * data_cm_row.get("rc_cm_labor_human")
# * man_hour_value
# if data_cm_row
# and data_cm_row.get("rc_cm_labor_cost")
# and data_cm_row.get("rc_cm_labor_human")
# and man_hour_value is not None
# else 0
# )
rc_pm_material_cost = raw_pm_material_cost rc_pm_material_cost = raw_pm_material_cost
# rc_pm_labor_cost = (
# data_pm_row.get("raw_pm_labor_time")
# * data_pm_row.get("rc_pm_labor_human")
# * man_hour_value
# if data_pm_row
# and data_pm_row.get("rc_pm_labor_cost")
# and data_pm_row.get("rc_pm_labor_human")
# and man_hour_value is not None
# else 0
# )
rc_oh_material_cost = raw_oh_material_cost rc_oh_material_cost = raw_oh_material_cost
# rc_oh_labor_cost = (
# data_oh_row.get("raw_oh_labor_time")
# * data_oh_row.get("rc_oh_labor_human")
# * man_hour_value
# if data_oh_row
# and data_oh_row.get("rc_oh_labor_cost")
# and data_oh_row.get("rc_oh_labor_human")
# and man_hour_value is not None
# else 0
# )
# rc_predictive_labor_cost = (
# data_predictive_row.get("raw_predictive_labor_human") * man_hour_value
# if data_predictive_row
# and data_predictive_row.get("rc_predictive_labor_cost")
# and man_hour_value is not None
# else 0
# )
if labour_cost_lookup and year is not None: if labour_cost_lookup and year is not None:
cm_lookup = labour_cost_lookup.get("CM", {}) cm_lookup = labour_cost_lookup.get("CM", {})
@ -987,18 +884,14 @@ async def query_data(target_assetnum: str = None):
print(f"Error checking acquisition data for {assetnum}: {exc}") print(f"Error checking acquisition data for {assetnum}: {exc}")
forecasting_start_year_db = row.get("forecasting_start_year") # Calculation start is always 2014 (forecasting start is 2015)
acquisition_year = row.get("acquisition_year") # Forecasting and calculation start configuration
loop_start_year = 2014
if acquisition_year: # Delete data before calculation start (2014)
# Remove data before acquisition_year cursor.execute("DELETE FROM lcc_equipment_tr_data WHERE assetnum = %s AND tahun < %s", (assetnum, loop_start_year))
cursor.execute("DELETE FROM lcc_equipment_tr_data WHERE assetnum = %s AND tahun < %s", (assetnum, acquisition_year))
forecasting_start_year = acquisition_year forecasting_start_year = loop_start_year
elif forecasting_start_year_db:
# If no acquisition_year but forecasting_start_year defined in DB
forecasting_start_year = forecasting_start_year_db
else:
forecasting_start_year = 2014
asset_start = datetime.now() asset_start = datetime.now()
processed_assets += 1 processed_assets += 1
@ -1024,6 +917,18 @@ async def query_data(target_assetnum: str = None):
"OH": get_labour_cost_totals(cursor_wo, assetnum, "OH"), "OH": get_labour_cost_totals(cursor_wo, assetnum, "OH"),
} }
# Find first year with replace_cost > 0 in Maximo (Requirement: ignore costs in this year)
cursor_wo.execute("""
select DATE_PART('year', a.reportdate) AS year
from wo_maximo a
where a.asset_replacecost > 0
and a.asset_assetnum = %s
order by a.reportdate asc
limit 1;
""", (assetnum,))
res_rep = cursor_wo.fetchone()
first_rep_year = int(res_rep[0]) if res_rep else None
seq = 0 seq = 0
# Looping untuk setiap tahun # Looping untuk setiap tahun
for year in range(forecasting_start_year, current_year + 1): for year in range(forecasting_start_year, current_year + 1):
@ -1074,6 +979,23 @@ async def query_data(target_assetnum: str = None):
year=year, year=year,
labour_cost_lookup=labour_cost_lookup, labour_cost_lookup=labour_cost_lookup,
) )
# Requirement: At the first year of the replace cost detected > 0,
# The material cost/ labor cost is ignored.
if first_rep_year and year == first_rep_year:
cost_keys = [
"raw_cm_material_cost", "raw_cm_labor_time",
"raw_pm_material_cost", "raw_pm_labor_time",
"raw_oh_material_cost", "raw_oh_labor_time",
"raw_predictive_material_cost", "raw_predictive_labor_time",
"rc_cm_material_cost", "rc_cm_labor_cost",
"rc_pm_material_cost", "rc_pm_labor_cost",
"rc_oh_material_cost", "rc_oh_labor_cost",
"rc_predictive_labor_cost"
]
for k in cost_keys:
if k in row_values:
row_values[k] = 0.0
if not data_exists: if not data_exists:
cursor.execute( cursor.execute(
insert_query, insert_query,

@ -20,12 +20,12 @@ def format_execution_time(execution_time):
return f"{execution_time:.2f} seconds." return f"{execution_time:.2f} seconds."
# Alternative calling function to just predict and calculate eac without inserting actual data # Alternative calling function to just predict and calculate eac without inserting actual data
async def simulate(): async def simulate(assetnum: str = None):
start_time = time.time() start_time = time.time()
print("Starting simulation (predict + eac)...") print(f"Starting simulation (predict + eac) {'for ' + assetnum if assetnum else 'for all assets'}...")
try: try:
prediction_result = await predict_run() prediction_result = await predict_run(assetnum=assetnum)
if prediction_result is False: if prediction_result is False:
print("Prediction step failed or was skipped. Skipping EAC run.") print("Prediction step failed or was skipped. Skipping EAC run.")
return return
@ -34,7 +34,7 @@ async def simulate():
return return
try: try:
result = eac_run() result = eac_run(assetnum=assetnum)
if asyncio.iscoroutine(result): if asyncio.iscoroutine(result):
result = await result result = await result
print("EAC run completed.") print("EAC run completed.")
@ -48,17 +48,18 @@ async def simulate():
return message return message
# Panggil fungsi # Panggil fungsi
async def main(): async def main(assetnum: str = None):
start_time = time.time() start_time = time.time()
print(f"Starting calculation workflow {'for ' + assetnum if assetnum else 'for all assets'}...")
try: try:
await query_data() await query_data(target_assetnum=assetnum)
except Exception as e: except Exception as e:
print(f"Error in query_data: {str(e)}") print(f"Error in query_data: {str(e)}")
return return
try: try:
prediction_result = await predict_run() prediction_result = await predict_run(assetnum=assetnum)
if prediction_result is False: if prediction_result is False:
print("Prediction step failed or was skipped. Skipping EAC run.") print("Prediction step failed or was skipped. Skipping EAC run.")
return return
@ -67,7 +68,7 @@ async def main():
return return
try: try:
result = eac_run() result = eac_run(assetnum=assetnum)
if asyncio.iscoroutine(result): if asyncio.iscoroutine(result):
result = await result result = await result
print("EAC run completed.") print("EAC run completed.")
@ -81,9 +82,14 @@ async def main():
return message return message
if __name__ == "__main__": if __name__ == "__main__":
import sys import argparse
# Use 'simulate' argument to run without query_data parser = argparse.ArgumentParser(description="Run LCCA Simulation")
if len(sys.argv) > 1 and sys.argv[1] == "simulate": parser.add_argument("mode", nargs="?", choices=["main", "simulate"], default="main", help="Mode to run: 'main' (full) or 'simulate' (no data refresh)")
asyncio.run(simulate()) parser.add_argument("--assetnum", type=str, help="Specific asset number to process")
args = parser.parse_args()
if args.mode == "simulate":
asyncio.run(simulate(assetnum=args.assetnum))
else: else:
asyncio.run(main()) asyncio.run(main(assetnum=args.assetnum))

Loading…
Cancel
Save