diff --git a/docs/updated_acquisition_algorithm.md b/docs/updated_acquisition_algorithm.md new file mode 100644 index 0000000..72006b1 --- /dev/null +++ b/docs/updated_acquisition_algorithm.md @@ -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). diff --git a/src/database/service.py b/src/database/service.py index b797248..baa61e7 100644 --- a/src/database/service.py +++ b/src/database/service.py @@ -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 { diff --git a/src/equipment/__pycache__/service.cpython-311.pyc b/src/equipment/__pycache__/service.cpython-311.pyc index 158ac2f..190e00c 100644 Binary files a/src/equipment/__pycache__/service.cpython-311.pyc and b/src/equipment/__pycache__/service.cpython-311.pyc differ diff --git a/src/equipment/service.py b/src/equipment/service.py index e3080c9..0651595 100644 --- a/src/equipment/service.py +++ b/src/equipment/service.py @@ -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}" + # 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}") - # --- 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 + # 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 diff --git a/src/modules/equipment/__pycache__/insert_actual_data.cpython-311.pyc b/src/modules/equipment/__pycache__/insert_actual_data.cpython-311.pyc index 11b06a3..ca6f66e 100644 Binary files a/src/modules/equipment/__pycache__/insert_actual_data.cpython-311.pyc and b/src/modules/equipment/__pycache__/insert_actual_data.cpython-311.pyc differ diff --git a/src/modules/equipment/__pycache__/run.cpython-311.pyc b/src/modules/equipment/__pycache__/run.cpython-311.pyc index 9b0cd2d..485be65 100644 Binary files a/src/modules/equipment/__pycache__/run.cpython-311.pyc and b/src/modules/equipment/__pycache__/run.cpython-311.pyc differ diff --git a/src/modules/equipment/insert_actual_data.py b/src/modules/equipment/insert_actual_data.py index 88f1764..d26b38a 100644 --- a/src/modules/equipment/insert_actual_data.py +++ b/src/modules/equipment/insert_actual_data.py @@ -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, diff --git a/src/modules/equipment/run.py b/src/modules/equipment/run.py index 4c7637d..6795c94 100644 --- a/src/modules/equipment/run.py +++ b/src/modules/equipment/run.py @@ -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))