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