You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
be-optimumoh/src/calculation_time_constrains/service.py

609 lines
45 KiB
Python

import datetime
from typing import List, Optional, Tuple, Dict
from uuid import UUID
import calendar
import httpx
from src.config import REALIBILITY_SERVICE_API, RBD_SERVICE_API
import numpy as np
import requests
from fastapi import HTTPException, status
from sqlalchemy import and_, case, func, select, update
from sqlalchemy.orm import joinedload, selectinload
from src.database.core import DbSession
from src.overhaul_activity.service import get_all as get_all_by_session_id
from src.overhaul_scope.service import get as get_scope, get_prev_oh
from src.sparepart.service import load_sparepart_data_from_db
from src.workorder.model import MasterWorkOrder
from .model import CalculationData, CalculationEquipmentResult, CalculationResult
from .schema import CalculationResultsRead, CalculationSelectedEquipmentUpdate, CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsRead, OptimumResult
from .utils import analyze_monthly_metrics, create_time_series_data, get_months_between, plant_simulation_metrics
import math
from src.overhaul_activity.service import get_standard_scope_by_session_id
from datetime import timedelta
import logging
import aiohttp
from datetime import datetime, date
import asyncio
import json
from src.config import BASE_URL, REALIBILITY_SERVICE_API
client = httpx.AsyncClient(timeout=300.0)
log = logging.getLogger(__name__)
class OptimumCostModelWithSpareparts:
def __init__(self, token: str, last_oh_date: date, next_oh_date: date, sparepart_manager, time_window_months: Optional[int]=None, base_url: str=f'{BASE_URL}'):
self.api_base_url = base_url
self.token = token
self.last_oh_date = last_oh_date
self.next_oh_date = next_oh_date
self.session = None
self.sparepart_manager = sparepart_manager
self.planned_oh_months = self._get_months_between(last_oh_date, next_oh_date)
self.time_window_months = time_window_months or int(self.planned_oh_months * 1.2)
self.date_range = self._generate_date_range()
self.logger = log
def _get_months_between(self, start_date: date, end_date: date) -> int:
return (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
def _generate_date_range(self) -> List[datetime]:
dates = []
current_date = datetime.combine(self.last_oh_date, datetime.min.time())
end_date = current_date + timedelta(days=self.time_window_months * 30)
while current_date <= end_date:
dates.append(current_date)
current_date += timedelta(days=31)
return dates
async def _create_session(self):
if self.session is None:
timeout = aiohttp.ClientTimeout(total=300)
connector = aiohttp.TCPConnector(limit=500, limit_per_host=200, ttl_dns_cache=300, use_dns_cache=True, force_close=False, enable_cleanup_closed=True)
self.session = aiohttp.ClientSession(timeout=timeout, connector=connector, headers={'Authorization': f'Bearer {self.token}'})
async def _close_session(self):
if self.session:
await self.session.close()
self.session = None
async def get_failures_prediction(self, simulation_id: str, location_tag: str, birnbaum_importance: float, use_location_tag: int=1):
plot_result_url = f'{self.api_base_url}/aeros/simulation/result/plot/{simulation_id}/{location_tag}?use_location_tag={use_location_tag}'
try:
response = requests.get(plot_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {self.token}'}, timeout=30)
response.raise_for_status()
prediction_data = response.json()
except (requests.RequestException, ValueError) as e:
self.logger.error(f'Failed to fetch prediction data for {location_tag}: {e}')
return None
plot_data = prediction_data.get('data', {}).get('timestamp_outs') if prediction_data.get('data') else None
if not plot_data:
self.logger.warning(f'No plot data available for {location_tag}')
return None
time_series = create_time_series_data(plot_data, self.time_window_months * 24 * 31)
monthly_data = analyze_monthly_metrics(time_series, self.last_oh_date)
return monthly_data
async def get_simulation_results(self, simulation_id: str='default'):
headers = {'Authorization': f'Bearer {self.token}', 'Content-Type': 'application/json'}
calc_result_url = f'{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode'
plant_result_url = f'{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}/plant'
async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers)
plant_task = client.get(plant_result_url, headers=headers)
calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status()
plant_response.raise_for_status()
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data']
return {'calc_result': calc_data, 'plant_result': plant_data}
def _calculate_equipment_costs_with_spareparts(self, failures_prediction: Dict, birnbaum_importance: float, preventive_cost: float, failure_replacement_cost: float, ecs, location_tag: str, planned_overhauls: List=None, loss_production_permonth=0) -> List[Dict]:
if not failures_prediction:
self.logger.warning(f'No failure prediction data for {location_tag}')
return []
months = list(failures_prediction.keys())
num_months = len(months)
failure_counts = []
monthly_risk_cost_per_failure = 0
if ecs:
is_trip = 1 if ecs.get('Diskripsi Operasional Akibat Equip. Failure') == 'Trip' else 0
is_series = 0 if not birnbaum_importance else math.floor(birnbaum_importance)
if is_trip:
downtime = ecs.get('Estimasi Waktu Maint. / Downtime / Gangguan (Jam)')
monthly_risk_cost_per_failure = 660 * 1000000 * is_trip * downtime * is_series
for month_key in months:
data = failures_prediction[month_key]
failure_counts.append(data['cumulative_failures'])
results = []
for i in range(num_months):
month_index = i + 1
failure_cost = failure_counts[i] * (failure_replacement_cost + monthly_risk_cost_per_failure)
preventive_cost_month = preventive_cost / month_index
sparepart_analysis = self._analyze_sparepart_availability(location_tag, month_index - 1, planned_overhauls or [])
procurement_cost = sparepart_analysis['total_procurement_cost']
procurement_details = sparepart_analysis
if not sparepart_analysis['available']:
total_cost = failure_cost + preventive_cost_month + procurement_cost
else:
total_cost = failure_cost + preventive_cost_month + procurement_cost
results.append({'month': month_index, 'number_of_failures': failure_counts[i], 'failure_cost': failure_cost, 'preventive_cost': preventive_cost_month, 'procurement_cost': procurement_cost, 'total_cost': total_cost, 'is_after_planned_oh': month_index > self.planned_oh_months, 'delay_months': max(0, month_index - self.planned_oh_months), 'procurement_details': procurement_details, 'sparepart_available': sparepart_analysis['available'], 'sparepart_status': sparepart_analysis['message'], 'can_proceed': sparepart_analysis['can_proceed_with_delays']})
return results
def _analyze_sparepart_availability(self, equipment_tag: str, target_month: int, planned_overhauls: List) -> Dict:
if not self.sparepart_manager:
return {'available': True, 'message': 'Sparepart manager not initialized', 'total_procurement_cost': 0, 'procurement_needed': [], 'can_proceed_with_delays': True}
other_overhauls = [(eq_tag, month) for eq_tag, month in planned_overhauls if eq_tag != equipment_tag and month <= target_month]
return self.sparepart_manager.check_sparepart_availability(equipment_tag, target_month, other_overhauls)
def _find_optimal_timing_with_spareparts(self, cost_results: List[Dict], location_tag: str) -> Optional[Dict]:
if not cost_results:
return None
feasible_results = [r for r in cost_results if r['can_proceed']]
min_cost = float('inf')
optimal_result = None
for i, result in enumerate(cost_results):
if result in feasible_results and result['total_cost'] < min_cost:
min_cost = result['total_cost']
optimal_result = result
if optimal_result is None:
return None
return self._create_optimal_result(optimal_result, location_tag, 'OPTIMAL')
def _create_optimal_result(self, optimal_result: Dict, location_tag: str, status: str) -> Dict:
planned_cost = None
cost_vs_planned = None
if self.planned_oh_months <= len(optimal_result.get('all_monthly_costs', [])):
pass
return {'location_tag': location_tag, 'optimal_month': optimal_result['month'], 'optimal_index': optimal_result['month'] - 1, 'optimal_cost': optimal_result['total_cost'], 'failure_cost': optimal_result['failure_cost'], 'preventive_cost': optimal_result['preventive_cost'], 'procurement_cost': optimal_result['procurement_cost'], 'number_of_failures': optimal_result['number_of_failures'], 'is_delayed': optimal_result['is_after_planned_oh'], 'delay_months': optimal_result['delay_months'], 'planned_oh_month': self.planned_oh_months, 'planned_cost': planned_cost, 'cost_vs_planned': cost_vs_planned, 'savings_from_delay': 0, 'cost_of_delay': 0, 'sparepart_available': optimal_result['sparepart_available'], 'sparepart_status': optimal_result['sparepart_status'], 'procurement_details': optimal_result['procurement_details'], 'optimization_status': status, 'all_monthly_costs': []}
async def calculate_cost_all_equipment_with_spareparts(self, db_session, collector_db_session, equipments: List, calculation, preventive_cost: float, simulation_id: str='default'):
self.logger.info(f'Starting fleet optimization with sparepart management for {len(equipments)} equipment')
max_interval = self.time_window_months
try:
importance_results = await self.get_simulation_results(simulation_id)
equipment_birnbaum = {imp['aeros_node']['node_name']: imp['contribution_factor'] for imp in importance_results['calc_result']}
loss_production = importance_results['plant_result']['total_downtime'] * 660
except Exception as e:
self.logger.error(f'Failed to get simulation results: {e}')
equipment_birnbaum = {}
individual_results = {}
with open('src/calculation_time_constrains/full_equipment_with_downtime_opdesc.json', 'r') as f:
data = json.load(f)
ecs_tags = {eq['Location']: eq for eq in data}
await self.get_failures_prediction(simulation_id=simulation_id, location_tag='plant', use_location_tag=0, birnbaum_importance=0)
total_simulation_period = int((importance_results['plant_result']['total_downtime'] + importance_results['plant_result']['total_uptime']) / 720)
loss_production_per_month = np.arange(0, total_simulation_period)
k = 5
loss_exp = importance_results['plant_result']['total_downtime'] * 660 * 500000 * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1)
for equipment in equipments:
location_tag = equipment.location_tag
contribution_factor = equipment_birnbaum.get(location_tag, 0.0)
ecs = ecs_tags.get(location_tag, None)
monthly_data = await self.get_failures_prediction(simulation_id, location_tag, contribution_factor)
if not monthly_data:
continue
equipment_preventive_cost = equipment.overhaul_cost + equipment.service_cost
failure_replacement_cost = equipment.material_cost + 3 * 111000 * 3
cost_results = self._calculate_equipment_costs_with_spareparts(failures_prediction=monthly_data, birnbaum_importance=contribution_factor, preventive_cost=equipment_preventive_cost, failure_replacement_cost=failure_replacement_cost, location_tag=location_tag, planned_overhauls=[], ecs=ecs, loss_production_permonth=loss_production)
if not cost_results:
continue
optimal_timing = self._find_optimal_timing_with_spareparts(cost_results, location_tag)
if optimal_timing:
optimal_timing['all_monthly_costs'] = cost_results
individual_results[location_tag] = optimal_timing
self.logger.info(f"Individual optimal for {location_tag}: Month {optimal_timing['optimal_month']}")
self.logger.info('Phase 2: Optimizing with sparepart interactions...')
[(tag, result['optimal_month']) for tag, result in individual_results.items()]
improved_plan = self._optimize_fleet_with_sparepart_constraints(individual_results, equipments, equipment_birnbaum, simulation_id)
fleet_results = []
total_corrective_costs = np.zeros(max_interval) + loss_exp[0:max_interval]
total_preventive_costs = np.zeros(max_interval)
total_procurement_costs = np.zeros(max_interval)
total_costs = np.zeros(max_interval)
total_fleet_procurement_cost = 0
for equipment in equipments:
location_tag = equipment.location_tag
if location_tag not in individual_results:
continue
equipment_timing = next((month for tag, month in improved_plan if tag == location_tag), individual_results[location_tag]['optimal_month'])
cost_data = individual_results[location_tag]['all_monthly_costs'][equipment_timing - 1]
all_costs = individual_results[location_tag]['all_monthly_costs']
corrective_costs = [r['failure_cost'] for r in all_costs]
preventive_costs = [r['preventive_cost'] for r in all_costs]
procurement_costs = [r['procurement_cost'] for r in all_costs]
failures = [r['number_of_failures'] for r in all_costs]
total_costs_equipment = [r['total_cost'] for r in all_costs]
procurement_details = [r['procurement_details'] for r in all_costs]
def pad_array(arr, target_length):
if len(arr) < target_length:
return arr + [arr[-1]] * (target_length - len(arr))
return arr[:target_length]
corrective_costs = pad_array(corrective_costs, max_interval)
preventive_costs = pad_array(preventive_costs, max_interval)
procurement_costs = pad_array(procurement_costs, max_interval)
failures = pad_array(failures, max_interval)
total_costs_equipment = pad_array(total_costs_equipment, max_interval)
procurement_details = pad_array(procurement_details, max_interval)
equipment_result = CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=preventive_costs, procurement_costs=procurement_costs, daily_failures=failures, location_tag=equipment.location_tag, material_cost=equipment.material_cost, service_cost=equipment.service_cost, optimum_day=equipment_timing - 1, calculation_data_id=calculation.id, procurement_details=procurement_details)
fleet_results.append(equipment_result)
total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs)
total_procurement_costs += np.array(procurement_costs)
total_costs += np.array(total_costs_equipment)
total_fleet_procurement_cost += cost_data['procurement_cost']
self.logger.info(f"Final timing for {location_tag}: Month {equipment_timing} (Procurement cost: ${cost_data['procurement_cost']:,.2f})")
fleet_optimal_index = np.argmin(total_corrective_costs + total_preventive_costs)
fleet_optimal_cost = total_costs[fleet_optimal_index]
self.sparepart_manager.optimize_procurement_timing(improved_plan)
calculation.optimum_oh_day = fleet_optimal_index
calculation.max_interval = max_interval
calculation.rbd_simulation_id = simulation_id
db_session.add_all(fleet_results)
await db_session.commit()
self.logger.info(f'Fleet optimization with spareparts completed:')
self.logger.info(f' - Fleet optimal month: {fleet_optimal_index + 1}')
self.logger.info(f' - Fleet optimal cost: ${fleet_optimal_cost:,.2f}')
self.logger.info(f' - Total procurement cost: ${total_fleet_procurement_cost:,.2f}')
self.logger.info(f" - Equipment with sparepart constraints: {len([r for r in individual_results.values() if not r['sparepart_available']])}")
return fleet_optimal_index
def _optimize_fleet_with_sparepart_constraints(self, individual_results: Dict, equipments: List, equipment_birnbaum: Dict, simulation_id: str) -> List[Tuple[str, int]]:
current_plan = [(tag, result['optimal_month']) for tag, result in individual_results.items()]
current_plan.sort(key=lambda x: x[1])
improved_plan = []
processed_equipment = []
for equipment_tag, optimal_month in current_plan:
sparepart_analysis = self.sparepart_manager.check_sparepart_availability(equipment_tag, optimal_month - 1, processed_equipment)
if sparepart_analysis['available'] or sparepart_analysis['can_proceed_with_delays']:
improved_plan.append((equipment_tag, optimal_month))
processed_equipment.append((equipment_tag, optimal_month))
self.logger.info(f'Confirmed optimal timing for {equipment_tag}: Month {optimal_month}')
else:
alternative_month = self._find_alternative_timing(equipment_tag, optimal_month, individual_results[equipment_tag]['all_monthly_costs'], processed_equipment)
if alternative_month:
improved_plan.append((equipment_tag, alternative_month))
processed_equipment.append((equipment_tag, alternative_month))
self.logger.info(f'Alternative timing for {equipment_tag}: Month {alternative_month} (was {optimal_month})')
else:
improved_plan.append((equipment_tag, optimal_month))
processed_equipment.append((equipment_tag, optimal_month))
self.logger.warning(f'Forced timing for {equipment_tag}: Month {optimal_month} (requires emergency procurement)')
return improved_plan
def _find_alternative_timing(self, equipment_tag: str, preferred_month: int, cost_results: List[Dict], processed_equipment: List[Tuple[str, int]]) -> Optional[int]:
search_range = 6
candidates = []
for offset in range(-search_range // 2, search_range // 2 + 1):
candidate_month = preferred_month + offset
if candidate_month <= 0 or candidate_month > len(cost_results):
continue
if candidate_month == preferred_month:
continue
sparepart_analysis = self.sparepart_manager.check_sparepart_availability(equipment_tag, candidate_month - 1, processed_equipment)
if sparepart_analysis['available'] or sparepart_analysis['can_proceed_with_delays']:
cost_data = cost_results[candidate_month - 1]
candidates.append((candidate_month, cost_data['total_cost']))
if not candidates:
return None
candidates.sort(key=lambda x: x[1])
return candidates[0][0]
def generate_sparepart_report(self, results: Dict) -> str:
individual_results = results['individual_results']
procurement_plan = results['procurement_plan']
report = f"\n SPAREPART ANALYSIS REPORT\n {'=' * 50}\n\n FLEET SUMMARY:\n - Total equipment analyzed: {len(individual_results)}\n - Total procurement cost: ${results['total_fleet_procurement_cost']:,.2f}\n - Equipment requiring procurement: {len([r for r in individual_results.values() if r['procurement_cost'] > 0])}\n\n PROCUREMENT SUMMARY:\n - Total procurement items: {(procurement_plan['summary']['total_items'] if 'summary' in procurement_plan else 0)}\n - Critical items: {(procurement_plan['summary']['critical_items'] if 'summary' in procurement_plan else 0)}\n - Unique spareparts: {(procurement_plan['summary']['unique_spareparts'] if 'summary' in procurement_plan else 0)}\n - Suppliers involved: {(procurement_plan['summary']['suppliers_involved'] if 'summary' in procurement_plan else 0)}\n\n EQUIPMENT DETAILS:\n "
for tag, result in individual_results.items():
status = '✓ Available' if result['sparepart_available'] else '⚠ Procurement needed'
report += f"- {tag}: Month {result['optimal_month']} - {status}"
if result['procurement_cost'] > 0:
report += f" (${result['procurement_cost']:,.2f})"
report += '\n'
if procurement_plan.get('procurement_by_month'):
report += '\nPROCUREMENT SCHEDULE:\n'
for month, items in procurement_plan['procurement_by_month'].items():
month_cost = sum((item['total_cost'] for item in items))
report += f'Month {month + 1}: {len(items)} items - ${month_cost:,.2f}\n'
return report
async def __aenter__(self):
await self._create_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._close_session()
async def run_simulation_with_spareparts(*, db_session, calculation, token: str, collector_db_session, time_window_months: Optional[int]=None, simulation_id: str='default') -> Dict:
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id, collector_db=collector_db_session)
scope = await get_scope(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
calculation_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calculation.id)
time_window_months = 60
sparepart_manager = await load_sparepart_data_from_db(scope=scope, prev_oh_scope=prev_oh_scope, db_session=collector_db_session, app_db_session=db_session, analysis_window_months=time_window_months)
optimum_oh_model = OptimumCostModelWithSpareparts(token=token, last_oh_date=prev_oh_scope.end_date, next_oh_date=scope.start_date, base_url=RBD_SERVICE_API, sparepart_manager=sparepart_manager)
simulation_ids = [simulation_id]
try:
optimum_collection = []
for sim_id in simulation_ids:
results = await optimum_oh_model.calculate_cost_all_equipment_with_spareparts(db_session=db_session, collector_db_session=collector_db_session, equipments=equipments, calculation=calculation_data, preventive_cost=calculation_data.parameter.overhaul_cost, simulation_id=sim_id)
optimum_collection.append(results)
finally:
await optimum_oh_model._close_session()
np_optimum = np.array(optimum_collection)
stats = {'min': float(np.min(np_optimum)), 'max': float(np.max(np_optimum)), 'mean': float(np.mean(np_optimum)), 'median': float(np.median(np_optimum)), 'std': float(np.std(np_optimum)), 'variance': float(np.var(np_optimum)), 'range': float(np.ptp(np_optimum))}
calculation_data.optimum_analysis = stats
await db_session.commit()
return {'id': calculation_data.id, 'optimum': stats}
async def create_param_and_data(*, db_session: DbSession, calculation_param_in: CalculationTimeConstrainsParametersCreate, created_by: str, parameter_id: Optional[UUID]=None):
if calculation_param_in.ohSessionId is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='overhaul_session_id is required')
calculationData = await CalculationData.create_with_param(db=db_session, overhaul_session_id=calculation_param_in.ohSessionId, avg_failure_cost=calculation_param_in.costPerFailure, overhaul_cost=calculation_param_in.overhaulCost, created_by=created_by, params_id=parameter_id)
return calculationData
async def get_calculation_result(db_session: DbSession, calculation_id: str, token, include_risk_cost):
try:
calculation_query = await db_session.execute(select(CalculationData).options(selectinload(CalculationData.equipment_results)).where(CalculationData.id == calculation_id))
scope_calculation = calculation_query.scalar_one_or_none()
if not scope_calculation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Calculation with id {calculation_id} does not exist.')
scope_overhaul = await get_scope(db_session=db_session, overhaul_session_id=scope_calculation.overhaul_session_id)
if not scope_overhaul:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Overhaul scope for session {scope_calculation.overhaul_session_id} does not exist.')
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope_overhaul)
data_num = scope_calculation.max_interval
if data_num <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid max_interval in calculation data.')
included_equipment = [eq for eq in scope_calculation.equipment_results if eq.is_included]
all_equipment = scope_calculation.equipment_results
calculation_results = []
fleet_statistics = {'total_equipment': len(all_equipment), 'included_equipment': len(included_equipment), 'excluded_equipment': len(all_equipment) - len(included_equipment), 'equipment_with_sparepart_constraints': 0, 'total_procurement_items': 0, 'critical_procurement_items': 0, 'total_spareparts': 745}
plant_monthly_metrics = await plant_simulation_metrics(simulation_id=scope_calculation.rbd_simulation_id, location_tag='plant', use_location_tag=0, token=token, last_oh_date=prev_oh_scope.end_date, max_interval=scope_calculation.max_interval)
total_simulation_period = int((plant_monthly_metrics['total_downtime'] + plant_monthly_metrics['total_uptime']) / 720)
loss_production_per_month = np.arange(0, total_simulation_period)
k = 5
loss_exp = plant_monthly_metrics['total_downtime'] * 660 * 500000 * include_risk_cost * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1)
for month_index in range(data_num):
month_result = {'overhaul_cost': 0.0, 'corrective_cost': float(loss_exp[month_index]), 'procurement_cost': 0.0, 'num_failures': 0.0, 'day': month_index + 1, 'month': month_index + 1, 'procurement_details': {}, 'sparepart_summary': {'total_procurement_cost': 0.0, 'equipment_requiring_procurement': 0, 'critical_shortages': 0, 'existing_orders_value': 0.0, 'new_orders_required': 0, 'urgent_procurements': 0}}
equipment_requiring_procurement = 0
total_existing_orders_value = 0.0
total_new_orders_value = 0.0
critical_shortages = 0
urgent_procurements = 0
for eq in all_equipment:
if month_index >= len(eq.procurement_details):
continue
procurement_detail = eq.procurement_details[month_index]
if procurement_detail and isinstance(procurement_detail, dict) and procurement_detail.get('procurement_needed'):
equipment_requiring_procurement += 1
pr_po_summary = procurement_detail.get('pr_po_summary', {})
existing_orders_value = pr_po_summary.get('total_existing_value', 0)
total_existing_orders_value += existing_orders_value
new_orders_value = pr_po_summary.get('total_new_orders_value', 0)
total_new_orders_value += new_orders_value
critical_missing = procurement_detail.get('critical_missing_parts', 0)
if critical_missing > 0:
critical_shortages += 1
recommendations = procurement_detail.get('recommendations', [])
urgent_items = [r for r in recommendations if r.get('priority') == 'CRITICAL']
if urgent_items:
urgent_procurements += 1
is_included_eq = False if eq.is_initial else eq.is_included
month_result['procurement_details'][eq.location_tag] = {'is_included': is_included_eq, 'location_tag': eq.location_tag, 'details': procurement_detail.get('procurement_needed', []), 'detailed_message': procurement_detail.get('detailed_message', ''), 'pr_po_summary': pr_po_summary, 'recommendations': recommendations, 'sparepart_available': procurement_detail.get('sparepart_available', True), 'can_proceed': procurement_detail.get('can_proceed_with_delays', True), 'critical_missing_parts': critical_missing, 'existing_orders_value': existing_orders_value, 'new_orders_value': new_orders_value}
if eq.is_included:
if month_index < len(eq.corrective_costs) and month_index < len(eq.overhaul_costs) and (month_index < len(eq.procurement_costs)) and (month_index < len(eq.daily_failures)):
month_result['corrective_cost'] += float(eq.corrective_costs[month_index])
month_result['overhaul_cost'] += float(eq.overhaul_costs[month_index])
month_result['procurement_cost'] += float(eq.procurement_costs[month_index])
month_result['num_failures'] += float(eq.daily_failures[month_index])
month_result['sparepart_summary'].update({'total_procurement_cost': month_result['procurement_cost'], 'equipment_requiring_procurement': equipment_requiring_procurement, 'critical_shortages': critical_shortages, 'existing_orders_value': total_existing_orders_value, 'new_orders_required': len([eq for eq in all_equipment if month_index < len(eq.procurement_details) and eq.procurement_details[month_index] and eq.procurement_details[month_index].get('procurement_needed')]), 'urgent_procurements': urgent_procurements})
month_result['total_cost'] = month_result['corrective_cost'] + month_result['overhaul_cost']
calculation_results.append(CalculationResultsRead(**month_result))
optimum_day = np.argmin([month.total_cost for month in calculation_results])
scope_calculation.optimum_oh_day = int(optimum_day)
await db_session.commit()
fleet_statistics['equipment_with_sparepart_constraints'] = len([eq for eq in all_equipment if any((detail and detail.get('procurement_needed') for detail in eq.procurement_details if detail))])
fleet_statistics['total_procurement_items'] = sum([len(detail.get('procurement_needed', [])) for eq in all_equipment for detail in eq.procurement_details if detail and isinstance(detail, dict)])
optimal_analysis = _analyze_optimal_timing(calculation_results, scope_calculation.optimum_oh_day, prev_oh_scope, scope_overhaul)
return CalculationTimeConstrainsRead(id=scope_calculation.id, reference=scope_calculation.overhaul_session_id, scope=scope_overhaul.maintenance_type.name, results=calculation_results, optimum_oh=scope_calculation.optimum_oh_day, optimum_oh_month=scope_calculation.optimum_oh_day + 1, equipment_results=scope_calculation.equipment_results, fleet_statistics=fleet_statistics, optimal_analysis=optimal_analysis, analysis_metadata={'max_interval_months': data_num, 'last_overhaul_date': prev_oh_scope.end_date.isoformat() if prev_oh_scope else None, 'next_planned_overhaul': scope_overhaul.start_date.isoformat(), 'calculation_type': 'sparepart_optimized' if fleet_statistics['equipment_with_sparepart_constraints'] > 0 else 'standard', 'total_equipment_analyzed': len(all_equipment), 'included_in_optimization': len(included_equipment), 'optimal_stat': scope_calculation.optimum_analysis})
except HTTPException:
raise
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error in get_calculation_result for calculation_id {calculation_id}: {str(e)}')
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal error processing calculation results: {str(e)}')
def _analyze_optimal_timing(calculation_results: List, optimum_oh_day: int, prev_oh_scope, scope_overhaul) -> Dict:
if not calculation_results:
return {}
optimal_result = None
if 0 <= optimum_oh_day < len(calculation_results):
optimal_result = calculation_results[optimum_oh_day]
planned_oh_months = None
if prev_oh_scope and scope_overhaul:
planned_oh_months = (scope_overhaul.start_date.year - prev_oh_scope.end_date.year) * 12 + (scope_overhaul.start_date.month - prev_oh_scope.end_date.month)
min(calculation_results, key=lambda x: x.total_cost)
timing_recommendation = 'OPTIMAL'
if planned_oh_months:
if optimum_oh_day + 1 < planned_oh_months:
timing_recommendation = 'EARLY'
elif optimum_oh_day + 1 > planned_oh_months:
timing_recommendation = 'DELAYED'
else:
timing_recommendation = 'ON_SCHEDULE'
cost_trend = 'STABLE'
if len(calculation_results) > 1:
early_costs = [r.total_cost for r in calculation_results[:len(calculation_results) // 3]]
late_costs = [r.total_cost for r in calculation_results[-len(calculation_results) // 3:]]
avg_early = sum(early_costs) / len(early_costs) if early_costs else 0
avg_late = sum(late_costs) / len(late_costs) if late_costs else 0
if avg_late > avg_early * 1.2:
cost_trend = 'INCREASING'
elif avg_late < avg_early * 0.8:
cost_trend = 'DECREASING'
return {'optimal_month': optimum_oh_day + 1, 'planned_month': planned_oh_months, 'timing_recommendation': timing_recommendation, 'optimal_total_cost': optimal_result.total_cost if optimal_result else 0, 'optimal_breakdown': {'corrective_cost': optimal_result.corrective_cost if optimal_result else 0, 'overhaul_cost': optimal_result.overhaul_cost if optimal_result else 0, 'procurement_cost': optimal_result.procurement_cost if optimal_result else 0, 'num_failures': optimal_result.num_failures if optimal_result else 0}, 'cost_trend': cost_trend, 'months_from_planned': optimum_oh_day + 1 - planned_oh_months if planned_oh_months else None, 'cost_savings_vs_planned': None, 'sparepart_impact': {'equipment_with_constraints': optimal_result.sparepart_summary['equipment_requiring_procurement'] if optimal_result else 0, 'critical_shortages': optimal_result.sparepart_summary['critical_shortages'] if optimal_result else 0, 'procurement_investment': optimal_result.sparepart_summary['total_procurement_cost'] if optimal_result else 0}}
async def get_calculation_data_by_id(db_session: DbSession, calculation_id) -> CalculationData:
stmt = select(CalculationData).filter(CalculationData.id == calculation_id).options(joinedload(CalculationData.equipment_results), joinedload(CalculationData.parameter))
result = await db_session.execute(stmt)
return result.unique().scalar()
async def get_all_calculations(db_session: DbSession) -> List[CalculationData]:
stmt = select(CalculationData).options(selectinload(CalculationData.session)).where(CalculationData.optimum_oh_day.isnot(None), CalculationData.max_interval.isnot(None), CalculationData.optimum_analysis.isnot(None)).order_by(CalculationData.created_at.desc())
result = await db_session.execute(stmt)
return result.scalars().all()
async def get_calculation_by_assetnum(*, db_session: DbSession, assetnum: str, calculation_id: str):
stmt = select(CalculationEquipmentResult).where(CalculationEquipmentResult.assetnum == assetnum).where(CalculationEquipmentResult.calculation_data_id == calculation_id)
result = await db_session.execute(stmt)
return result.scalar()
async def get_number_of_failures(location_tag, start_date, end_date, token, max_interval=24):
url_prediction = f"{REALIBILITY_SERVICE_API}/main/number-of-failures/{location_tag}/{start_date.strftime('%Y-%m-%d')}/{end_date.strftime('%Y-%m-%d')}"
results = {}
try:
response = requests.get(url_prediction, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=10)
response.raise_for_status()
prediction_data = response.json()
except (requests.RequestException, ValueError) as e:
raise Exception(f'Failed to fetch or parse prediction data: {e}')
if not prediction_data or 'data' not in prediction_data or (not isinstance(prediction_data['data'], list)):
raise Exception('Invalid or empty prediction data format.')
last_data = prediction_data['data'][-1]
last_data_date = datetime.strptime(last_data['date'], '%d %b %Y')
results[datetime.date(last_data_date.year, last_data_date.month, last_data_date.day)] = round(last_data['num_fail']) if last_data['num_fail'] is not None else 0
for item in prediction_data['data']:
try:
date = datetime.strptime(item['date'], '%d %b %Y')
last_day = calendar.monthrange(date.year, date.month)[1]
value = item.get('num_fail', 0)
if date.day == last_day:
if date.month == start_date.month and date.year == start_date.year:
results[date.date()] = 0
else:
results[date.date()] = 0 if value <= 0 else int(value)
except (KeyError, ValueError):
continue
current = start_date.replace(day=1)
for _ in range(max_interval):
last_day = calendar.monthrange(current.year, current.month)[1]
last_day_date = datetime.date(current.year, current.month, last_day)
if last_day_date not in results:
results[last_day_date] = 0
if current.month == 12:
current = current.replace(year=current.year + 1, month=1)
else:
current = current.replace(month=current.month + 1)
results = dict(sorted(results.items()))
return results
async def get_equipment_foh(location_tag: str, token: str):
url_mdt = f'{REALIBILITY_SERVICE_API}/asset/mdt/{location_tag}'
try:
response = requests.get(url_mdt, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=10)
response.raise_for_status()
result = response.json()
except (requests.RequestException, ValueError) as e:
raise Exception(f'Failed to fetch or parse mdt data: {e}')
mdt_data = result['data']['hours']
return mdt_data
def simulate_equipment_overhaul(equipment, preventive_cost, predicted_num_failures, interval_months, forced_outage_hours_value, total_months=24):
total_preventive_cost = 0
total_corrective_cost = 0
months_since_overhaul = 0
failures_by_month = {i: val for i, (date, val) in enumerate(sorted(predicted_num_failures.items()))}
cost_per_failure = equipment.material_cost
for month in range(total_months):
if months_since_overhaul >= interval_months:
total_preventive_cost += preventive_cost
months_since_overhaul = 0
if months_since_overhaul == 0:
expected_failures = 0
equivalent_force_derated_hours = 0
failure_cost = expected_failures * cost_per_failure + (forced_outage_hours_value + equivalent_force_derated_hours) * equipment.service_cost
total_corrective_cost += failure_cost
else:
expected_failures = failures_by_month.get(months_since_overhaul, 0)
equivalent_force_derated_hours = 0
failure_cost = expected_failures * cost_per_failure + (forced_outage_hours_value + equivalent_force_derated_hours) * equipment.service_cost
total_corrective_cost += failure_cost
months_since_overhaul += 1
monthly_preventive_cost = total_preventive_cost / total_months
monthly_corrective_cost = total_corrective_cost / total_months
monthly_total_cost = monthly_preventive_cost + monthly_corrective_cost
return {'interval': interval_months, 'preventive_cost': monthly_preventive_cost, 'corrective_cost': monthly_corrective_cost, 'total_cost': monthly_total_cost}
async def create_calculation_result_service(db_session: DbSession, calculation: CalculationData, token: str) -> CalculationTimeConstrainsRead:
equipments = await get_all_by_session_id(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id)
scope = await get_scope(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
calculation_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calculation.id)
if prev_oh_scope:
start_date = datetime.combine(prev_oh_scope.end_date + datetime.timedelta(days=1), datetime.time.min)
end_date = datetime.combine(scope.start_date, datetime.time.min)
else:
start_date = datetime.combine(scope.start_date, datetime.time.min)
end_date = datetime.combine(scope.end_date, datetime.time.min)
max_interval = get_months_between(start_date, end_date)
overhaul_cost = calculation_data.parameter.overhaul_cost / len(equipments)
results = []
total_corrective_costs = np.zeros(max_interval)
total_overhaul_costs = np.zeros(max_interval)
total_daily_failures = np.zeros(max_interval)
total_costs = np.zeros(max_interval)
for eq in equipments:
equipment_results = []
corrective_costs = []
overhaul_costs = []
total = []
predicted_num_failures = await get_number_of_failures(location_tag=eq.location_tag, start_date=start_date, end_date=end_date, token=token)
foh_value = await get_equipment_foh(location_tag=eq.location_tag, token=token)
for interval in range(1, max_interval + 1):
result = simulate_equipment_overhaul(eq, overhaul_cost, predicted_num_failures, interval, foh_value, total_months=max_interval)
corrective_costs.append(result['corrective_cost'])
overhaul_costs.append(result['preventive_cost'])
total.append(result['total_cost'])
equipment_results.append(result)
optimal_result = min(equipment_results, key=lambda x: x['total_cost'])
results.append(CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=overhaul_costs, daily_failures=[failure for _, failure in predicted_num_failures.items()], assetnum=eq.assetnum, material_cost=eq.material_cost, service_cost=eq.service_cost, optimum_day=optimal_result['interval'], calculation_data_id=calculation.id, master_equipment=eq.master_equipment))
if len(predicted_num_failures.values()) < max_interval:
raise Exception(eq.equipment.assetnum)
total_corrective_costs += np.array(corrective_costs)
total_overhaul_costs += np.array(overhaul_costs)
total_daily_failures += np.array([failure for _, failure in predicted_num_failures.items()])
total_costs += np.array(total_costs)
db_session.add_all(results)
total_costs_point = total_corrective_costs + total_overhaul_costs
optimum_oh_index = np.argmin(total_costs_point)
numbers_of_failure = sum(total_daily_failures[:optimum_oh_index])
optimum = OptimumResult(overhaul_cost=float(total_overhaul_costs[optimum_oh_index]), corrective_cost=float(total_corrective_costs[optimum_oh_index]), num_failures=int(numbers_of_failure), days=int(optimum_oh_index + 1))
calculation.optimum_oh_day = optimum.days
await db_session.commit()
return CalculationTimeConstrainsRead(id=calculation.id, reference=calculation.overhaul_session_id, scope=scope.type, results=[], optimum_oh=optimum, equipment_results=results)
async def get_calculation_by_reference_and_parameter(*, db_session: DbSession, calculation_reference_id, parameter_id):
stmt = select(CalculationData).filter(and_(CalculationData.reference_id == calculation_reference_id, CalculationData.parameter_id == parameter_id))
result = await db_session.execute(stmt)
return result.scalar()
async def get_calculation_result_by_day(*, db_session: DbSession, calculation_id, simulation_day):
stmt = select(CalculationResult).filter(and_(CalculationResult.day == simulation_day, CalculationResult.calculation_data_id == calculation_id))
result = await db_session.execute(stmt)
return result.scalar()
async def get_avg_cost_by_asset(*, db_session: DbSession, assetnum: str):
stmt = select(func.avg(MasterWorkOrder.total_cost_max).label('average_cost')).where(MasterWorkOrder.assetnum == assetnum)
result = await db_session.execute(stmt)
return result.scalar_one_or_none()
async def bulk_update_equipment(*, db: DbSession, selected_equipments: List[CalculationSelectedEquipmentUpdate], calculation_data_id: UUID):
case_mappings = {asset.location_tag: asset.is_included for asset in selected_equipments}
location_tags = list(case_mappings.keys())
when_clauses = [(CalculationEquipmentResult.location_tag == location_tag, is_included) for location_tag, is_included in case_mappings.items()]
stmt = update(CalculationEquipmentResult).where(CalculationEquipmentResult.calculation_data_id == calculation_data_id).where(CalculationEquipmentResult.location_tag.in_(location_tags)).values({'is_included': case(*when_clauses), 'is_initial': False})
await db.execute(stmt)
await db.commit()
return location_tags