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
count_query = Select(func.count()).select_from(query.subquery())
total = await db_session.scalar(count_query)
if all:
if all or items_per_page == -1:
result = await db_session.execute(query)
items = _extract_result_items(result)
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:
"""
Check if acquisition year/cost in Maximo differs from local DB.
If changed, archive history, delete transaction data, update master, and return True.
Otherwise return False.
Check if acquisition cost in Maximo differs from local DB.
Updates master acquisition_cost (initial + replacement) and sets forecasting_start_year to 2015.
Returns True if master record was updated, False otherwise.
"""
conn = get_production_connection()
first_year = None
@ -680,7 +680,7 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
if conn:
try:
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 = """
select DATE_PART('year', a.reportdate) AS year, a.asset_replacecost AS cost
from wo_maximo a
@ -697,7 +697,7 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
cursor.close()
conn.close()
except Exception as e:
print(f"Error fetching acquisition year for {assetnum}: {e}")
print(f"Error fetching replacement data for {assetnum}: {e}")
if conn:
try:
conn.close()
@ -706,123 +706,75 @@ async def check_and_update_acquisition_data(db_session: DbSession, assetnum: str
updates_performed = False
if first_year:
# Fetch equipment to update
eq = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
if eq:
# Check if forecasting_target_year matches the "default" logic (acquisition + design_life)
# using the OLD acquisition year.
current_acq = eq.acquisition_year
current_life = eq.design_life
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
if current_acq and current_life and current_target:
is_valid_default = current_target == (current_acq + current_life)
# Check for changes
change_year = (eq.acquisition_year != first_year)
change_cost = (first_cost is not None and eq.acquisition_cost != first_cost)
# We only archive transaction history if the acquisition year itself changed.
# This prevents redundant history entries for cost-only updates.
if change_year:
print(f"Acquisition year change detected for {assetnum}: {current_acq}->{first_year}. Archiving history.")
acq_year_ref = f"{current_acq}_{current_target}"
# --- ARCHIVE HISTORICAL DATA ---
# Check for existing identical archive to prevent duplicates (after calculation failures/retries)
check_hist_query = text("SELECT 1 FROM lcc_ms_equipment_historical_data WHERE assetnum = :assetnum AND acquisition_year_ref = :acq_year_ref LIMIT 1")
hist_exists = (await db_session.execute(check_hist_query, {"assetnum": assetnum, "acq_year_ref": acq_year_ref})).fetchone()
if not hist_exists:
# 1. Copy old equipment master data to history
history_ms_query = text("""
INSERT INTO lcc_ms_equipment_historical_data (
id, assetnum, acquisition_year, acquisition_cost, capital_cost_record_time, design_life,
forecasting_start_year, forecasting_target_year, manhours_rate, created_at, created_by,
updated_at, updated_by, min_eac_info, harga_saat_ini, minimum_eac_seq, minimum_eac_year,
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,
acquisition_year_ref
)
SELECT
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,
updated_at, updated_by, min_eac_info, harga_saat_ini, minimum_eac_seq, minimum_eac_year,
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,
:acq_year_ref
FROM lcc_ms_equipment_data
WHERE assetnum = :assetnum
""")
await db_session.execute(history_ms_query, {"acq_year_ref": acq_year_ref, "assetnum": assetnum})
# 2. Copy old transaction data to lcc_equipment_historical_tr_data
history_tr_query = text("""
INSERT INTO lcc_equipment_historical_tr_data (
id, 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, 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:
eq.forecasting_target_year = first_year + current_life
await db_session.commit()
updates_performed = True
# Fetch equipment to update
eq = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
if eq:
# Check if forecasting_target_year matches the "default" logic (acquisition + design_life)
# using the OLD acquisition year.
current_acq = eq.acquisition_year
current_life = eq.design_life
current_target = eq.forecasting_target_year
is_valid_default = False
if current_acq and current_life and current_target:
is_valid_default = current_target == (current_acq + current_life)
# Fetch inflation rate from lcc_ms_master for value-of-money adjustment
inflation_rate = 0.05 # Default fallback
try:
rate_query = text("SELECT value_num / 100.0 FROM lcc_ms_master WHERE name = 'inflation_rate'")
rate_result = (await db_session.execute(rate_query)).scalar()
if rate_result is not None:
inflation_rate = float(rate_result)
except Exception as e:
print(f"Warning: Could not fetch inflation_rate for {assetnum}: {e}")
# Calculate initial cost from category/proportion (base acquisition cost)
initial_cost = 0.0
if eq.category_no and eq.proportion:
_, aggregated_cost = await fetch_acquisition_cost_with_rollup(
db_session=db_session, base_category_no=eq.category_no
)
if aggregated_cost:
initial_cost = (eq.proportion * 0.01) * aggregated_cost
# Adjust initial cost to 2015 value (Base Year)
# Formula: Value_2015 = Value_Year / (1 + rate)^(Year - 2015)
adj_initial_cost = initial_cost
if current_acq and current_acq != 2015:
adj_initial_cost = initial_cost / ((1 + inflation_rate) ** (current_acq - 2015))
# Adjust replace cost to 2015 value (Base Year)
adj_replace_cost = (first_cost or 0.0)
if first_year and first_year != 2015:
adj_replace_cost = (first_cost or 0.0) / ((1 + inflation_rate) ** (first_year - 2015))
# Total cost is adjusted initial cost plus the adjusted replacement cost
total_cost = adj_initial_cost + adj_replace_cost
change_cost = (eq.acquisition_cost != total_cost)
# Requirement: forecasting_start_year always starts from 2015
change_start = (eq.forecasting_start_year != 2015)
# Note: acquisition_year itself is no longer updated as per requirements.
if change_cost or change_start:
if change_cost:
print(
f"Acquisition cost update for {assetnum}: {eq.acquisition_cost} -> {total_cost} "
f"(Adj. Initial: {adj_initial_cost} + Adj. Replacement: {adj_replace_cost} | Rate: {inflation_rate})"
)
eq.acquisition_cost = total_cost
if change_start:
print(f"Aligning forecasting_start_year to 2015 for {assetnum}")
eq.forecasting_start_year = 2015
# If target was default, we update it to 2015 + design_life
if is_valid_default and current_life:
eq.forecasting_target_year = 2015 + current_life
await db_session.commit()
updates_performed = True
return updates_performed

@ -39,72 +39,6 @@ def get_recursive_query(cursor, assetnum, worktype="CM"):
Fungsi untuk menjalankan query rekursif berdasarkan assetnum dan worktype.
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)
query = f"""
@ -360,48 +294,11 @@ def _build_tr_row_values(
)
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_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_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:
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}")
forecasting_start_year_db = row.get("forecasting_start_year")
acquisition_year = row.get("acquisition_year")
# Calculation start is always 2014 (forecasting start is 2015)
# Forecasting and calculation start configuration
loop_start_year = 2014
# Delete data before calculation start (2014)
cursor.execute("DELETE FROM lcc_equipment_tr_data WHERE assetnum = %s AND tahun < %s", (assetnum, loop_start_year))
if acquisition_year:
# Remove data before acquisition_year
cursor.execute("DELETE FROM lcc_equipment_tr_data WHERE assetnum = %s AND tahun < %s", (assetnum, acquisition_year))
forecasting_start_year = acquisition_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
forecasting_start_year = loop_start_year
asset_start = datetime.now()
processed_assets += 1
@ -1024,6 +917,18 @@ async def query_data(target_assetnum: str = None):
"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
# Looping untuk setiap tahun
for year in range(forecasting_start_year, current_year + 1):
@ -1074,6 +979,23 @@ async def query_data(target_assetnum: str = None):
year=year,
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:
cursor.execute(
insert_query,

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

Loading…
Cancel
Save