feat: update sparepart procurement logic, refine data loading, and add documentation for optimization workflows

main
Cizz22 2 months ago
parent 7c36b7d276
commit a0fedd83f1

15
.env

@ -9,13 +9,20 @@ DATABASE_CREDENTIAL_USER=postgres
DATABASE_CREDENTIAL_PASSWORD=postgres DATABASE_CREDENTIAL_PASSWORD=postgres
DATABASE_NAME=digital_twin DATABASE_NAME=digital_twin
COLLECTOR_HOSTNAME=192.168.1.82 COLLECTOR_HOSTNAME=192.168.1.86
COLLECTOR_PORT=1111 COLLECTOR_PORT=5432
COLLECTOR_CREDENTIAL_USER=digital_twin COLLECTOR_CREDENTIAL_USER=postgres
COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN COLLECTOR_CREDENTIAL_PASSWORD=postgres
COLLECTOR_NAME=digital_twin COLLECTOR_NAME=digital_twin
# COLLECTOR_PORT=1111
# COLLECTOR_CREDENTIAL_USER=digital_twin
# COLLECTOR_CREDENTIAL_PASSWORD=Pr0jec7@D!g!tTwiN
# COLLECTOR_NAME=digital_twin
# COLLECTOR_HOSTNAME=192.168.1.86 # COLLECTOR_HOSTNAME=192.168.1.86
# COLLECTOR_PORT=5432 # COLLECTOR_PORT=5432
# COLLECTOR_CREDENTIAL_USER=postgres # COLLECTOR_CREDENTIAL_USER=postgres

@ -0,0 +1,41 @@
# Equipment Level Overhaul Optimization
This document explains the mathematical theory and implementation used to determine the optimal overhaul interval for individual assets.
## 1. Theoretical Foundation
The optimization model is based on the **Expected Total Cost per Unit Time (CPUT)** theory, a standard approach in reliability engineering for age-replacement and block-replacement policies.
### Objective Function
The goal is to find an overhaul interval ($T$) that minimizes the total expected cost amortized over time:
$$C(T) = \frac{C_{Preventive} + C_{Corrective} \cdot E[N(T)]}{T}$$
Where:
* **$T$**: The overhaul interval (e.g., month 12, 24, etc.).
* **$C_{Preventive}$**: The cost of a planned overhaul (materials, labor, and procurement).
* **$C_{Corrective}$**: The cost of an unplanned failure (repairs + risk cost of downtime).
* **$E[N(T)]$**: The expected number of failures occurring in the interval $(0, T]$. This is derived from the **NHPP (Non-Homogeneous Poisson Process)** reliability model.
## 2. The Cost Balance (The "U-Curve")
Optimization works by balancing two competing costs:
1. **Overhaul Cost ($C_{PM}/T$)**: As the interval $T$ increases, the amortized cost of the overhaul decreases (economy of waiting).
2. **Failure Risk ($C_{CM} \cdot E[N(T)]/T$)**: As the interval $T$ increases, the probability and expected frequency of failure increase (cost of wear-out).
The point where these two lines intersect typically represents the **Global Minimum** of the total cost curve, known as the **Optimum Overhaul Month**.
## 3. Implementation Logic
In the `service.py` engine, the search is performed as follows:
1. **Failure Projection**: The system fetches reliability prediction data (cumulative failures) for the analysis window.
2. **Sparepart Simulation**: For every potential month $T$, the system simulates the procurement process to calculate the real $C_{Preventive}$ (including any shortage penalties).
3. **Cost Amortization**:
```python
total_cycle_cost = total_expected_failure_cost + total_preventive_cost
cput = total_cycle_cost / month_index
```
4. **Grid Search**: The system iterates through all months in the analysis window and identifies the index with the lowest `cput` value.
## 4. Interpretation in UI
* **Analysis Window Card**: Shows the CPUT value at your currently selected month.
* **Optimum Target Card**: Shows the month $T$ where the curve is at its lowest point.
* **Potential Benefit**: Calculated as $CPUT(current) - CPUT(optimum)$. This represents the real monthly cash-flow saving if the maintenance interval is adjusted.

@ -0,0 +1,47 @@
# Plant Level Overhaul Optimization
This document explains how individual asset optimizations are aggregated to find the best economic strategy for the entire fleet or plant.
## 1. Fleet Aggregation Theory
At the plant level, the objective is to minimize the **Total Fleet Cost per Unit Time**. This assumes a **Uniform Interval Policy** or a **Synchronized Overhaul Strategy** where the plant looks for a common maintenance rhythm.
### Plant Objective Function
The plant-level cost function is the summation of individual equipment cost functions ($C_i$):
$$C_{Plant}(T) = \sum_{i=1}^{N} C_i(T) = \frac{\sum_{i=1}^{N} (C_{p,i} + C_{f,i} \cdot E[N_i(T)])}{T}$$
Where:
* **$N$**: Total number of equipments in the scope.
* **$C_{p,i}$**: Preventive cost for equipment $i$.
* **$C_{f,i}$**: Failure cost for equipment $i$.
* **$E[N_i(T)]$**: Expected failures for equipment $i$ until time $T$.
## 2. Searching for the Fleet Optimum
The "actual theory" used by the engine involves a two-phase search:
### Phase 1: Unconstrained Summation
The system calculates the CPUT curve for every piece of equipment independently. It then sums these curves to create a "Fleet U-Curve." The minimum of this sum represents the **Theoretical Fleet Optimum**.
### Phase 2: Sparepart Interaction & Constraints
Unlike a simple sum, the real plant optimum must account for **shared resources** (e.g., a limited budget or limited spare parts).
1. **Sparepart Conflicts**: If multiple equipments reach their optimal interval at the same time, the `SparepartManager` checks if there are enough parts in the warehouse.
2. **Constraint Penalty**: If parts are missing, a "Procurement Penalty" is added to the $C_{p,i}$ for that specific month, effectively shifting the "U-curve" to the right (delaying) or left (earlier) depending on availability.
3. **Final Selection**: The system chooses the Month $T$ that minimizes the *constrained* total fleet cost.
## 3. Implementation in `service.py`
The code uses `numpy` to perform vector addition of the cost curves:
```python
# Aggregate amortized costs for fleet analysis
total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs)
total_costs += np.array(total_costs_equipment)
# Find the month T that minimizes the sum
fleet_optimal_index = np.argmin(total_costs)
```
## 4. Key Metrics for Decision Makers
* **Fleet CPUT**: The average monthly budget required to maintain the plant at the chosen interval.
* **Accumulation Control**: By using amortized costs (Rp/Month) instead of total cumulative costs, the plant chart remains stable and allows for direct comparison between intervals regardless of the number of assets.
* **Risk vs. Cost**: The plant chart shows the trade-off between the *Fleet Failure Risk* (increasing line) and *Fixed Overhaul Costs* (decreasing line).

@ -107,6 +107,49 @@ async def create_calculation(
return results return results
async def recalculate_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
simulation_id: Optional[str] = None
):
calculation_data = await get_calculation_data_by_id(
db_session=db_session, calculation_id=calculation_id
)
if not calculation_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Calculation not found",
)
rbd_simulation_id = simulation_id or calculation_data.rbd_simulation_id or TC_RBD_ID
# Delete old results to avoid duplicates
from sqlalchemy import delete
from .model import CalculationEquipmentResult, CalculationResult
await db_session.execute(
delete(CalculationResult).where(CalculationResult.calculation_data_id == calculation_data.id)
)
await db_session.execute(
delete(CalculationEquipmentResult).where(CalculationEquipmentResult.calculation_data_id == calculation_data.id)
)
await db_session.commit()
results = await run_simulation_with_spareparts(
db_session=db_session,
calculation=calculation_data,
token=token,
collector_db_session=collector_db_session,
simulation_id=rbd_simulation_id
)
return results
async def get_or_create_scope_equipment_calculation( async def get_or_create_scope_equipment_calculation(
*, *,
db_session: DbSession, db_session: DbSession,

@ -148,6 +148,7 @@ class CalculationEquipmentResult(Base, DefaultMixin):
corrective_costs = Column(JSON, nullable=False) corrective_costs = Column(JSON, nullable=False)
overhaul_costs = Column(JSON, nullable=False) overhaul_costs = Column(JSON, nullable=False)
daily_failures = Column(JSON, nullable=False) daily_failures = Column(JSON, nullable=False)
is_actual = Column(JSON, nullable=True) # List of booleans
procurement_costs = Column(JSON, nullable=False) procurement_costs = Column(JSON, nullable=False)
location_tag = Column(String(255), nullable=False) location_tag = Column(String(255), nullable=False)
material_cost = Column(Float, nullable=False) material_cost = Column(Float, nullable=False)

@ -186,3 +186,42 @@ async def update_selected_equipment(
data=results, data=results,
message="Data retrieved successfully", message="Data retrieved successfully",
) )
@router.post("/{calculation_id}/refresh-spareparts", response_model=StandardResponse[dict])
async def refresh_spareparts(
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
current_user: CurrentUser,
):
"""Refresh sparepart availability for an existing calculation"""
from .service import refresh_spareparts_service
await refresh_spareparts_service(
db_session=db_session,
collector_db_session=collector_db_session,
calculation_id=calculation_id
)
return StandardResponse(data={}, message="Spareparts refreshed successfully")
@router.post("/{calculation_id}/recalculate", response_model=StandardResponse[CalculationTimeConstrainsRead])
async def recalculate_calculation_api(
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_id: str,
token: Token,
current_user: CurrentUser,
):
"""Recalculate an existing simulation with fresh data"""
from .flows import recalculate_calculation
results = await recalculate_calculation(
token=token,
db_session=db_session,
collector_db_session=collector_db_session,
calculation_id=calculation_id
)
return StandardResponse(data=results, message="Calculation updated with fresh data")

@ -71,6 +71,7 @@ class EquipmentResult(CalculationTimeConstrainsBase):
overhaul_costs: List[float] overhaul_costs: List[float]
procurement_costs: List[float] procurement_costs: List[float]
daily_failures: List[float] daily_failures: List[float]
is_actual: Optional[List[bool]] = None
location_tag: str location_tag: str
material_cost: float material_cost: float
service_cost: float service_cost: float

@ -64,8 +64,8 @@ class OptimumCostModelWithSpareparts:
# Calculate planned overhaul interval in months # Calculate planned overhaul interval in months
self.planned_oh_months = self._get_months_between(last_oh_date, next_oh_date) self.planned_oh_months = self._get_months_between(last_oh_date, next_oh_date)
# Set analysis time window (default: 1.5x planned interval) # Set analysis time window: next OH + 6 months
self.time_window_months = time_window_months or int(self.planned_oh_months * 1.2) self.time_window_months = self.planned_oh_months + 6
# Pre-calculate date range for API calls # Pre-calculate date range for API calls
self.date_range = self._generate_date_range() self.date_range = self._generate_date_range()
@ -112,13 +112,21 @@ class OptimumCostModelWithSpareparts:
await self.session.close() await self.session.close()
self.session = None self.session = None
async def get_failures_prediction(self, simulation_id: str, location_tag: str, birnbaum_importance: float, use_location_tag: int = 1): async def get_failures_prediction(self, location_tag: str):
"""Get failure predictions for equipment from simulation service""" """Get failure predictions for equipment from Reliability Predict service"""
plot_result_url = f"{self.api_base_url}/aeros/simulation/result/plot/{simulation_id}/{location_tag}?use_location_tag={use_location_tag}"
# Calculate start and end dates
start_date = self.last_oh_date.strftime('%Y-%m-%d')
# next_oh + 6 months
end_date_val = self.next_oh_date + timedelta(days=6 * 30)
end_date = end_date_val.strftime('%Y-%m-%d')
# Use RELIABILITY_SERVICE_API instead of RBD_SERVICE_API
predict_url = f"{REALIBILITY_SERVICE_API}/main/predict/{location_tag}/{start_date}/{end_date}"
try: try:
response = requests.get( response = requests.get(
plot_result_url, predict_url,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.token}", "Authorization": f"Bearer {self.token}",
@ -131,14 +139,26 @@ class OptimumCostModelWithSpareparts:
self.logger.error(f"Failed to fetch prediction data for {location_tag}: {e}") self.logger.error(f"Failed to fetch prediction data for {location_tag}: {e}")
return None return None
plot_data = prediction_data.get('data', {}).get('timestamp_outs') if prediction_data.get("data") else None predictions = prediction_data.get('data', {}).get('predictions') if prediction_data.get("data") else None
if not plot_data: if not predictions:
self.logger.warning(f"No plot data available for {location_tag}") self.logger.warning(f"No prediction data available for {location_tag}")
return None return None
time_series = create_time_series_data(plot_data, (self.time_window_months * 24 * 31)) # Convert to dictionary format expected by the model
monthly_data = analyze_monthly_metrics(time_series, self.last_oh_date) monthly_data = {}
cumulative = 0.0
for i, pred in enumerate(predictions):
month_key = pred['month']
monthly_fail = pred['predicted_failures']
source = pred.get('source', 'predicted')
cumulative += monthly_fail
monthly_data[month_key] = {
'cumulative_failures': cumulative,
'monthly_failures': monthly_fail,
'month_index': i + 1,
'source': source
}
return monthly_data return monthly_data
@ -197,44 +217,59 @@ class OptimumCostModelWithSpareparts:
data = failures_prediction[month_key] data = failures_prediction[month_key]
failure_counts.append(data['cumulative_failures']) failure_counts.append(data['cumulative_failures'])
# Calculate costs for each month including sparepart considerations
results = []
for i in range(num_months): for i in range(num_months):
month_index = i + 1 month_index = i + 1
# Use only months within the analysis window
# Basic failure and preventive costs if month_index > self.time_window_months:
failure_cost = (failure_counts[i] * (failure_replacement_cost + monthly_risk_cost_per_failure)) continue
preventive_cost_month = preventive_cost / month_index
# Check sparepart availability for this month # Check sparepart availability for this month
sparepart_analysis = self._analyze_sparepart_availability( sparepart_analysis = self._analyze_sparepart_availability(
location_tag, month_index - 1, planned_overhauls or [] location_tag, month_index - 1, planned_overhauls or []
) )
# THEORY: Total Expected Cost per Unit Time (CPUT)
# Reference: Maintenance Optimization Models (Age/Block Replacement)
# C(T) = [Total Preventive Cost + Total Expected Corrective Cost(T)] / T
# Calculate procurement costs if spareparts are needed # 1. Total Expected Corrective Cost until month_index (Expected number of failures * cost per failure)
# In NHPP model, Expected Failures E[N(T)] = Cumulative Failures
total_expected_failure_cost = failure_counts[i] * (failure_replacement_cost + monthly_risk_cost_per_failure)
# 2. Total Preventive Cost (One-time cost at month_index)
# Includes labor, materials, and procurement delays
procurement_cost = sparepart_analysis['total_procurement_cost'] procurement_cost = sparepart_analysis['total_procurement_cost']
procurement_details = sparepart_analysis total_preventive_cost = preventive_cost + procurement_cost
# Adjust total cost based on sparepart availability # 3. Expected Total Cycle Cost
if not sparepart_analysis['available']: total_cycle_cost = total_expected_failure_cost + total_preventive_cost
total_cost = failure_cost + preventive_cost_month + procurement_cost
else: # 4. Expected Cost Per Unit Time (Optimization Target)
# All spareparts available # This value forms the U-shaped curve
total_cost = failure_cost + preventive_cost_month + procurement_cost cput = total_cycle_cost / month_index
# Store both absolute and amortized components for visualization
results.append({ results.append({
'month': month_index, 'month': month_index,
'number_of_failures': failure_counts[i], 'number_of_failures': failure_counts[i],
'failure_cost': failure_cost, 'is_actual': failures_prediction[months[i]].get('source') == 'actual',
'preventive_cost': preventive_cost_month,
'procurement_cost': procurement_cost, # Amortized components (for the "U" chart)
'total_cost': total_cost, 'failure_cost': total_expected_failure_cost / month_index,
'preventive_cost': preventive_cost / month_index,
'procurement_cost': procurement_cost / month_index,
'total_cost': cput,
# Absolute values (for breakdown analysis)
'absolute_failure_cost': total_expected_failure_cost,
'absolute_overhaul_cost': preventive_cost,
'absolute_procurement_cost': procurement_cost,
'total_cycle_cost': total_cycle_cost,
'is_after_planned_oh': month_index > self.planned_oh_months, 'is_after_planned_oh': month_index > self.planned_oh_months,
'delay_months': max(0, month_index - self.planned_oh_months), 'delay_months': max(0, month_index - self.planned_oh_months),
'procurement_details': procurement_details, 'procurement_details': sparepart_analysis,
'sparepart_available': sparepart_analysis['available'], 'sparepart_available': sparepart_analysis['available'],
'sparepart_status': sparepart_analysis['message'], 'sparepart_status': sparepart_analysis['message'],
'can_proceed': sparepart_analysis['can_proceed_with_delays'] 'can_proceed': sparepart_analysis['can_proceed_with_delays']
@ -329,29 +364,16 @@ class OptimumCostModelWithSpareparts:
'all_monthly_costs': [] # Will be filled by calling function 'all_monthly_costs': [] # Will be filled by calling function
} }
async def calculate_cost_all_equipment_with_spareparts(self, db_session,collector_db_session ,equipments: List, async def calculate_cost_all_equipment_with_spareparts(self, db_session, collector_db_session ,equipments: List,
calculation, preventive_cost: float, calculation, preventive_cost: float,
simulation_id: str = "default"): simulation_id: str = "default"):
""" """
Calculate optimal overhaul timing for entire fleet considering sparepart constraints Calculate optimal overhaul timing for entire fleet considering sparepart constraints
""" """
self.logger.info(f"Starting fleet optimization with sparepart management for {len(equipments)} equipment") self.logger.info(f"Starting fleet optimization with reliability prediction for {len(equipments)} equipment")
max_interval = self.time_window_months max_interval = self.time_window_months
total_simulation_period = max_interval
# Get Birnbaum importance values
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 = {}
# Phase 1: Calculate individual optimal timings without considering interactions # Phase 1: Calculate individual optimal timings without considering interactions
individual_results = {} individual_results = {}
@ -363,30 +385,20 @@ class OptimumCostModelWithSpareparts:
eq["Location"]: eq eq["Location"]: eq
for eq in data for eq in data
} }
plant_monthly_metrics = await self.get_failures_prediction(simulation_id=simulation_id, location_tag="plant", use_location_tag=0, birnbaum_importance=0)
REFERENCE_CAPACITY = 630 # or 550
COST_PER_MWH = 1_000_000 # rupiah
# plant_capacity_loss_money = [metrics['derated_mwh'] * COST_PER_MWH for metrics in plant_monthly_metrics.values()]
# cumulative_loss_money = np.cumsum(plant_capacity_loss_money)
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 * 500_000) * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1) # Loss production estimate (default or from metadata)
# 660 MW * 500,000 IDR/MWh * total_downtime (placeholder if RBD is gone)
# We'll use a per-failure risk cost instead if available
loss_production = 0 # Will be handled per equipment
for equipment in equipments: for equipment in equipments:
location_tag = equipment.location_tag location_tag = equipment.location_tag
contribution_factor = equipment_birnbaum.get(location_tag, 0.0) contribution_factor = 1.0 # Default importance without RBD
ecs = ecs_tags.get(location_tag, None) ecs = ecs_tags.get(location_tag, None)
# try: # Get failure predictions
# # Get failure predictions monthly_data = await self.get_failures_prediction(location_tag)
monthly_data = await self.get_failures_prediction(simulation_id, location_tag, contribution_factor)
if not monthly_data: if not monthly_data:
continue continue
@ -430,14 +442,14 @@ class OptimumCostModelWithSpareparts:
# Iteratively improve the plan considering sparepart constraints # Iteratively improve the plan considering sparepart constraints
improved_plan = self._optimize_fleet_with_sparepart_constraints( improved_plan = self._optimize_fleet_with_sparepart_constraints(
individual_results, equipments, equipment_birnbaum, simulation_id individual_results, equipments, simulation_id
) )
# Phase 3: Generate final results and database objects # Phase 3: Generate final results and database objects
fleet_results = [] fleet_results = []
total_corrective_costs = np.zeros(max_interval) + loss_exp[0:max_interval] total_corrective_costs = np.zeros(max_interval)
total_preventive_costs = np.zeros(max_interval) total_preventive_costs = np.zeros(max_interval)
total_procurement_costs = np.zeros(max_interval) total_procurement_costs = np.zeros(max_interval)
total_costs = np.zeros(max_interval) total_costs = np.zeros(max_interval)
@ -481,12 +493,16 @@ class OptimumCostModelWithSpareparts:
total_costs_equipment = pad_array(total_costs_equipment, max_interval) total_costs_equipment = pad_array(total_costs_equipment, max_interval)
procurement_details = pad_array(procurement_details, max_interval) procurement_details = pad_array(procurement_details, max_interval)
is_actual_list = [r.get("is_actual", False) for r in all_costs]
is_actual_list = pad_array(is_actual_list, max_interval)
# Create database result object # Create database result object
equipment_result = CalculationEquipmentResult( equipment_result = CalculationEquipmentResult(
corrective_costs=corrective_costs, corrective_costs=corrective_costs,
overhaul_costs=preventive_costs, overhaul_costs=preventive_costs,
procurement_costs=procurement_costs, procurement_costs=procurement_costs,
daily_failures=failures, daily_failures=failures,
is_actual=is_actual_list,
location_tag=equipment.location_tag, location_tag=equipment.location_tag,
material_cost=equipment.material_cost, material_cost=equipment.material_cost,
service_cost=equipment.service_cost, service_cost=equipment.service_cost,
@ -509,8 +525,8 @@ class OptimumCostModelWithSpareparts:
self.logger.info(f"Final timing for {location_tag}: Month {equipment_timing} " self.logger.info(f"Final timing for {location_tag}: Month {equipment_timing} "
f"(Procurement cost: ${cost_data['procurement_cost']:,.2f})") f"(Procurement cost: ${cost_data['procurement_cost']:,.2f})")
# Calculate fleet optimal interval # Calculate fleet optimal interval (Month T that minimizes the sum of all equipment CPUTs)
fleet_optimal_index = np.argmin(total_corrective_costs + total_preventive_costs) fleet_optimal_index = np.argmin(total_costs)
fleet_optimal_cost = total_costs[fleet_optimal_index] fleet_optimal_cost = total_costs[fleet_optimal_index]
# Generate procurement optimization report # Generate procurement optimization report
@ -534,7 +550,7 @@ class OptimumCostModelWithSpareparts:
return fleet_optimal_index return fleet_optimal_index
def _optimize_fleet_with_sparepart_constraints(self, individual_results: Dict, equipments: List, def _optimize_fleet_with_sparepart_constraints(self, individual_results: Dict, equipments: List,
equipment_birnbaum: Dict, simulation_id: str) -> List[Tuple[str, int]]: simulation_id: str) -> List[Tuple[str, int]]:
""" """
Optimize fleet overhaul timing considering sparepart sharing constraints Optimize fleet overhaul timing considering sparepart sharing constraints
""" """
@ -677,6 +693,7 @@ async def run_simulation_with_spareparts(*, db_session, calculation, token: str,
collector_db=collector_db_session collector_db=collector_db_session
) )
scope = await get_scope(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) prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
@ -684,11 +701,11 @@ async def run_simulation_with_spareparts(*, db_session, calculation, token: str,
db_session=db_session, calculation_id=calculation.id db_session=db_session, calculation_id=calculation.id
) )
time_window_months = 60 # time window is months between prev oh -> next oh + 6
time_window_months = get_months_between(prev_oh_scope.end_date, scope.start_date) + 6
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) 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)
# Initialize optimization model with sparepart management # Initialize optimization model with sparepart management
optimum_oh_model = OptimumCostModelWithSpareparts( optimum_oh_model = OptimumCostModelWithSpareparts(
token=token, token=token,
@ -824,7 +841,6 @@ async def get_calculation_result(db_session: DbSession, calculation_id: str, tok
# Filter included equipment for performance # Filter included equipment for performance
included_equipment = [eq for eq in scope_calculation.equipment_results if eq.is_included] included_equipment = [eq for eq in scope_calculation.equipment_results if eq.is_included]
all_equipment = scope_calculation.equipment_results all_equipment = scope_calculation.equipment_results
# all_spareparts = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session )
# Pre-calculate aggregated statistics # Pre-calculate aggregated statistics
calculation_results = [] calculation_results = []
@ -838,30 +854,11 @@ async def get_calculation_result(db_session: DbSession, calculation_id: str, tok
'total_spareparts': 745 'total_spareparts': 745
} }
# The code `plant_monthly_metrics` is likely a function or a variable in Python. Without # Process each month
# seeing the implementation of the code, it is not possible to determine exactly what it is
# doing. It could be used to store monthly metrics for a plant, calculate metrics, or perform
# some other operation related to plant data.
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 * 500_000 * include_risk_cost) * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1)
# REFERENCE_CAPACITY = 630 # or 550
# COST_PER_MWH = 1_000_000 # rupiah
# plant_capacity_loss_money = [metrics['derated_mwh'] * COST_PER_MWH for metrics in plant_monthly_metrics.values()]
# cumulative_loss_money = np.cumsum(plant_capacity_loss_money)
# # raise Exception(plant_monthly_metrics)
# Process each monthself
for month_index in range(data_num): for month_index in range(data_num):
month_result = { month_result = {
"overhaul_cost": 0.0, "overhaul_cost": 0.0,
"corrective_cost": float(loss_exp[month_index]), "corrective_cost": 0.0,
"procurement_cost": 0.0, "procurement_cost": 0.0,
"num_failures": 0.0, "num_failures": 0.0,
"day": month_index + 1, "day": month_index + 1,
@ -988,6 +985,14 @@ async def get_calculation_result(db_session: DbSession, calculation_id: str, tok
if detail and isinstance(detail, dict) if detail and isinstance(detail, dict)
]) ])
# Calculate metadata for the chart
analysis_metadata = {
"planned_month": (scope_overhaul.start_date.year - prev_oh_scope.end_date.year) * 12 + (scope_overhaul.start_date.month - prev_oh_scope.end_date.month) if prev_oh_scope and scope_overhaul else 0,
"total_fleet_procurement_cost": sum([eq.procurement_costs[int(scope_calculation.optimum_oh_day)] for eq in all_equipment if eq.procurement_costs]),
"last_oh_date": prev_oh_scope.end_date.isoformat() if prev_oh_scope else None,
"next_oh_date": scope_overhaul.start_date.isoformat() if scope_overhaul else None
}
# Calculate optimal timing analysis # Calculate optimal timing analysis
optimal_analysis = _analyze_optimal_timing( optimal_analysis = _analyze_optimal_timing(
calculation_results, scope_calculation.optimum_oh_day, prev_oh_scope, scope_overhaul calculation_results, scope_calculation.optimum_oh_day, prev_oh_scope, scope_overhaul
@ -1004,15 +1009,7 @@ async def get_calculation_result(db_session: DbSession, calculation_id: str, tok
equipment_results=scope_calculation.equipment_results, equipment_results=scope_calculation.equipment_results,
fleet_statistics=fleet_statistics, fleet_statistics=fleet_statistics,
optimal_analysis=optimal_analysis, optimal_analysis=optimal_analysis,
analysis_metadata={ analysis_metadata=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: except HTTPException:
@ -1490,3 +1487,53 @@ async def bulk_update_equipment(
await db.commit() await db.commit()
return location_tags return location_tags
async def refresh_spareparts_service(db_session: DbSession, collector_db_session: CollectorDbSession, calculation_id: str):
# 1. Fetch calculation data
stmt = select(CalculationData).where(CalculationData.id == calculation_id).options(
joinedload(CalculationData.equipment_results)
)
result = await db_session.execute(stmt)
calculation = result.scalar_one_or_none()
if not calculation:
raise HTTPException(status_code=404, detail="Calculation not found")
# 2. Get prev_oh_scope and scope (needed for load_sparepart_data_from_db)
from src.overhaul_scope.service import get as get_scope, get_prev_oh
scope = await get_scope(db_session=db_session, id=calculation.overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, session_id=calculation.overhaul_session_id)
# 3. Calculate time window
last_oh_date = prev_oh_scope.end_date
next_oh_date = scope.start_date
time_window_months = ((next_oh_date.year - last_oh_date.year) * 12 +
(next_oh_date.month - last_oh_date.month) + 6)
# 4. Load sparepart manager with fresh data from DB
from src.sparepart.service import load_sparepart_data_from_db
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
)
# 5. For each equipment and each month, refresh availability
for eq in calculation.equipment_results:
procurement_details = []
procurement_costs = []
for month_index in range(time_window_months):
status = sparepart_manager.check_sparepart_availability(eq.location_tag, month_index)
procurement_details.append(status)
if not status['available']:
procurement_costs.append(status['total_procurement_cost'])
else:
procurement_costs.append(0.0)
eq.procurement_details = procurement_details
eq.procurement_costs = procurement_costs
await db_session.commit()

@ -437,13 +437,19 @@ class SparepartManager:
for record in self.procurement_records: for record in self.procurement_records:
if record.status in [ProcurementStatus.ORDERED, ProcurementStatus.PLANNED]: if record.status in [ProcurementStatus.ORDERED, ProcurementStatus.PLANNED]:
# Skip records with no expected delivery date (e.g., still PR stage) # Prioritize vendor delivery date, fall back to estimated arrival
if not record.expected_delivery_date: delivery_date = record.po_vendor_delivery_date or record.expected_delivery_date
if not delivery_date:
continue continue
# Ensure we have a date object
if isinstance(delivery_date, datetime):
delivery_date = delivery_date.date()
months_from_start = ( months_from_start = (
(record.expected_delivery_date.year - self.analysis_start_date.year) * 12 + (delivery_date.year - self.analysis_start_date.year) * 12 +
(record.expected_delivery_date.month - self.analysis_start_date.month) (delivery_date.month - self.analysis_start_date.month)
) )
if 0 <= months_from_start < self.analysis_window_months: if 0 <= months_from_start < self.analysis_window_months:
@ -914,22 +920,40 @@ class SparepartManager:
# Integration functions for database operations # Integration functions for database operations
async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_session, analysis_window_months = None) -> SparepartManager: async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_session, analysis_window_months = None) -> SparepartManager:
"""Load sparepart data from database""" """
# You'll need to implement these queries based on your database schema Load sparepart data from database dynamically based on the overhaul scope.
# Get scope dates for analysis window """
# scope = await get_scope(db_session=db_session, overhaul_session_id=overhaul_session_id)
# prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope)
analysis_start_date = prev_oh_scope.end_date analysis_start_date = prev_oh_scope.end_date
analysis_window_months = int(((scope.start_date - prev_oh_scope.end_date).days / 30) * 1.2) if not analysis_window_months else analysis_window_months if not analysis_window_months:
# Default to the interval between OHs + 20% buffer
analysis_window_months = int(((scope.start_date - prev_oh_scope.end_date).days / 30) * 1.2)
# Ensure at least 12 months for small intervals
analysis_window_months = max(12, analysis_window_months)
sparepart_manager = SparepartManager(analysis_start_date, analysis_window_months) sparepart_manager = SparepartManager(analysis_start_date, analysis_window_months)
start_date = prev_oh_scope.end_date
end_date = scope.start_date
# Load sparepart stocks # Extract WO parents for the current OH scope to filter materials
# Example query - adjust based on your schema wo_parents = []
query = text("""SELECT if scope and scope.wo_parent:
if isinstance(scope.wo_parent, list):
wo_parents = scope.wo_parent
elif isinstance(scope.wo_parent, str):
wo_parents = [scope.wo_parent]
if not wo_parents:
log.warning(f"No wo_parent IDs found for scope {scope.id}. Sparepart requirements might be empty.")
# Optionally, we could fallback to all OH workorders if needed, but safer to warn.
# 1. Load Sparepart Remarks from App DB
sparepart_remark_results = (await app_db_session.execute(
select(SparepartRemark)
)).scalars().all()
sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark_results}
# 2. Load Current Sparepart Stocks from Maximo
# We query materials used in OHs to get a relevant set of spareparts
stocks_query = text("""
SELECT
wm.inv_itemnum AS itemnum, wm.inv_itemnum AS itemnum,
wm.inv_itemsetid AS itemsetid, wm.inv_itemsetid AS itemsetid,
wm.inv_location AS location, wm.inv_location AS location,
@ -942,32 +966,21 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
WHERE wm.inv_itemnum IS NOT NULL WHERE wm.inv_itemnum IS NOT NULL
GROUP BY wm.inv_itemnum, wm.inv_itemsetid, wm.inv_location, mspl.description GROUP BY wm.inv_itemnum, wm.inv_itemsetid, wm.inv_location, mspl.description
""") """)
log.info("Fetch sparepart")
sparepart_stocks_query = await db_session.execute(query)
sparepart_remark = (await app_db_session.execute(
select(SparepartRemark)
)).scalars().all()
sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark} log.info(f"Fetching current stocks for scope {scope.id}")
sparepart_stocks_results = await db_session.execute(stocks_query)
for stock_record in sparepart_stocks_query: for row in sparepart_stocks_results:
stock = SparepartStock( stock = SparepartStock(
sparepart_id=stock_record.itemnum, sparepart_id=row.itemnum,
remark=sparepart_remark_dict.get(stock_record.itemnum), sparepart_name=row.description,
sparepart_name=stock_record.description, current_stock=int(row.curbaltotal or 0),
current_stock=stock_record.curbaltotal, unit_cost=float(row.avgcost or 0),
unit_cost=stock_record.avgcost, location=row.location,
location=stock_record.location or "Unknown", remark=sparepart_remark_dict.get(row.itemnum, "")
) )
sparepart_manager.add_sparepart_stock(stock) sparepart_manager.add_sparepart_stock(stock)
# parent_nums = []
# query = text("""
# WITH target_wo AS (
# -- Work orders from the given parent(s)
# SELECT
# wonum, # wonum,
# xx_parent, # xx_parent,
# location_tag AS asset_location # location_tag AS asset_location
@ -1116,22 +1129,15 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
# for equipment_tag, requirements in equipment_requirements.items(): # for equipment_tag, requirements in equipment_requirements.items():
# sparepart_manager.add_equipment_requirements(equipment_tag, requirements) # sparepart_manager.add_equipment_requirements(equipment_tag, requirements)
# Load equipment sparepart requirements # 3. Load Equipment Sparepart Requirements
# You'll need to create this table/relationship # We analyze materials planned for the current OH session
query = text("""WITH oh_workorders AS ( req_query = text("""
-- First, get all OH work orders WITH current_oh as (
SELECT DISTINCT
wonum,
asset_location
FROM public.wo_maximo ma
WHERE worktype = 'OH' AND asset_location IS NOT NULL and asset_unit IN ('3', '00') AND EXTRACT(YEAR FROM reportdate) >= 2019
),
current_oh as (
SELECT DISTINCT wonum, asset_location, asset_unit SELECT DISTINCT wonum, asset_location, asset_unit
FROM public.wo_maximo ma FROM public.wo_maximo ma
WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030') WHERE ma.xx_parent = ANY(:wo_parents)
), ),
sparepart_usage AS ( sparepart_usage AS (
SELECT SELECT
oh.asset_location, oh.asset_location,
mwm.itemnum, mwm.itemnum,
@ -1141,146 +1147,95 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
FROM current_oh oh FROM current_oh oh
INNER JOIN public.wo_maximo_material mwm INNER JOIN public.wo_maximo_material mwm
ON oh.wonum = mwm.wonum ON oh.wonum = mwm.wonum
), ),
location_sparepart_stats AS ( location_sparepart_stats AS (
-- Calculate average usage per sparepart per location SELECT
SELECT asset_location,
asset_location, itemnum,
itemnum, COUNT(DISTINCT wonum) as total_wo_count,
COUNT(DISTINCT wonum) as total_wo_count, SUM(itemqty) as total_qty_used,
SUM(itemqty) as total_qty_used, AVG(itemqty) as avg_qty_per_wo
AVG(itemqty) as avg_qty_per_wo, FROM sparepart_usage
MIN(itemqty) as min_qty_used, GROUP BY asset_location, itemnum
MAX(itemqty) as max_qty_used ),
FROM sparepart_usage pr_po_combined AS (
GROUP BY asset_location, itemnum SELECT
), mspl.item_num,
pr_po_combined AS ( mspl.num,
-- Combine PR and PO data by num to get issue_date and delivery dates mspl.unit_cost,
SELECT mspl.qty_ordered,
mspl.item_num, MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date,
mspl.num, MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate,
mspl.unit_cost, MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date
mspl.qty_ordered, FROM public.maximo_sparepart_pr_po_line mspl
MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date, INNER JOIN public.maximo_sparepart_pr_po mspo
MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate, ON mspl.num = mspo.num
MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date WHERE mspo.type IN ('PR', 'PO')
FROM public.maximo_sparepart_pr_po_line mspl GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered
INNER JOIN public.maximo_sparepart_pr_po mspo ),
ON mspl.num = mspo.num leadtime_stats AS (
WHERE mspo.type IN ('PR', 'PO') SELECT
GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered item_num,
), ROUND(CAST(AVG(
leadtime_stats AS ( EXTRACT(EPOCH FROM (
-- Calculate lead time statistics for each item COALESCE(vendeliverydate, estimated_arrival_date) - issue_date
-- Prioritize vendeliverydate over estimated_arrival_date )) / 86400 / 30.44
SELECT ) AS NUMERIC), 1) as avg_leadtime_months
item_num, FROM pr_po_combined
ROUND(CAST(AVG( WHERE issue_date IS NOT NULL
EXTRACT(EPOCH FROM ( AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL
COALESCE(vendeliverydate, estimated_arrival_date) - issue_date AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date
)) / 86400 / 30.44 GROUP BY item_num
) AS NUMERIC), 1) as avg_leadtime_months, ),
ROUND(CAST(MIN( cost_stats AS (
EXTRACT(EPOCH FROM ( SELECT
COALESCE(vendeliverydate, estimated_arrival_date) - issue_date item_num,
)) / 86400 / 30.44 ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost
) AS NUMERIC), 1) as min_leadtime_months, FROM pr_po_combined
ROUND(CAST(MAX( WHERE unit_cost IS NOT NULL AND unit_cost > 0
EXTRACT(EPOCH FROM ( GROUP BY item_num
COALESCE(vendeliverydate, estimated_arrival_date) - issue_date ),
)) / 86400 / 30.44 item_descriptions AS (
) AS NUMERIC), 1) as max_leadtime_months, SELECT DISTINCT
COUNT(*) as leadtime_sample_size, item_num,
-- Additional metrics for transparency FIRST_VALUE(description) OVER (
COUNT(CASE WHEN vendeliverydate IS NOT NULL THEN 1 END) as vendelivery_count, PARTITION BY item_num
COUNT(CASE WHEN vendeliverydate IS NULL AND estimated_arrival_date IS NOT NULL THEN 1 END) as estimated_only_count ORDER BY created_at DESC NULLS LAST
FROM pr_po_combined ) as description
WHERE issue_date IS NOT NULL FROM public.maximo_sparepart_pr_po_line
AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL WHERE description IS NOT NULL
AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date )
GROUP BY item_num
),
cost_stats AS (
-- Calculate cost statistics for each item
SELECT
item_num,
ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost,
ROUND(CAST(MIN(unit_cost) AS NUMERIC), 2) as min_unit_cost,
ROUND(CAST(MAX(unit_cost) AS NUMERIC), 2) as max_unit_cost,
COUNT(*) as cost_sample_size,
-- Total value statistics
ROUND(CAST(AVG(unit_cost * qty_ordered) AS NUMERIC), 2) as avg_order_value,
ROUND(CAST(SUM(unit_cost * qty_ordered) AS NUMERIC), 2) as total_value_ordered
FROM pr_po_combined
WHERE unit_cost IS NOT NULL AND unit_cost > 0
GROUP BY item_num
),
item_descriptions AS (
-- Get unique descriptions for each item (optimized)
SELECT DISTINCT
item_num,
FIRST_VALUE(description) OVER (
PARTITION BY item_num
ORDER BY created_at DESC NULLS LAST
) as description
FROM public.maximo_sparepart_pr_po_line
WHERE description IS NOT NULL
),
item_inventory as (
SELECT SELECT
itemnum, lss.asset_location,
avgcost lss.itemnum,
FROM public.maximo_inventory COALESCE(id.description, 'No description available') as item_description,
) lss.avg_qty_per_wo,
SELECT iin.inv_avgcost,
lss.asset_location, COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months,
lss.itemnum, COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost
COALESCE(id.description, 'No description available') as item_description, FROM location_sparepart_stats lss
lss.total_wo_count, LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num
lss.total_qty_used, LEFT JOIN leadtime_stats lt ON lss.itemnum = lt.item_num
ROUND(CAST(lss.avg_qty_per_wo AS NUMERIC), 2) as avg_qty_per_wo, LEFT JOIN cost_stats cs ON lss.itemnum = cs.item_num
lss.min_qty_used, LEFT JOIN (SELECT DISTINCT itemnum, inv_avgcost FROM sparepart_usage) iin ON lss.itemnum = iin.itemnum
lss.max_qty_used, ORDER BY lss.asset_location, lss.itemnum;
iin.inv_avgcost, """)
-- Lead time metrics
COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months, log.info(f"Loading requirements for {len(wo_parents)} WO parents")
COALESCE(lt.min_leadtime_months, 0) as min_leadtime_months, req_results = await db_session.execute(req_query, {"wo_parents": wo_parents})
COALESCE(lt.max_leadtime_months, 0) as max_leadtime_months,
COALESCE(lt.leadtime_sample_size, 0) as leadtime_sample_size,
COALESCE(lt.vendelivery_count, 0) as vendelivery_count,
COALESCE(lt.estimated_only_count, 0) as estimated_only_count,
-- Cost metrics
COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost,
COALESCE(cs.min_unit_cost, 0) as min_unit_cost,
COALESCE(cs.max_unit_cost, 0) as max_unit_cost,
COALESCE(cs.cost_sample_size, 0) as cost_sample_size,
COALESCE(cs.avg_order_value, 0) as avg_order_value,
COALESCE(cs.total_value_ordered, 0) as total_value_ordered,
-- Estimated total cost for average OH
ROUND(CAST(COALESCE(lss.avg_qty_per_wo * cs.avg_unit_cost, 0) AS NUMERIC), 2) as estimated_cost_per_oh
FROM location_sparepart_stats lss
LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num
LEFT JOIN leadtime_stats lt ON lss.itemnum = lt.item_num
LEFT JOIN cost_stats cs ON lss.itemnum = cs.item_num
LEFT JOIN sparepart_usage iin ON lss.itemnum = iin.itemnum
ORDER BY lss.asset_location, lss.itemnum;""")
equipment_requirements_query = await db_session.execute(query)
equipment_requirements = defaultdict(list) equipment_requirements = defaultdict(list)
for req_record in equipment_requirements_query: for row in req_results:
requirement = SparepartRequirement( requirement = SparepartRequirement(
sparepart_id=req_record.itemnum, sparepart_id=row.itemnum,
quantity_required=float(req_record.avg_qty_per_wo), quantity_required=float(row.avg_qty_per_wo or 0),
lead_time=float(req_record.avg_leadtime_months), lead_time=int(row.avg_leadtime_months or 0),
sparepart_name=req_record.item_description, sparepart_name=row.item_description,
unit_cost=float(req_record.avg_unit_cost), unit_cost=float(row.avg_unit_cost or 0),
avg_cost=float(req_record.inv_avgcost or 0), avg_cost=float(row.inv_avgcost or 0),
remark=sparepart_remark_dict.get(req_record.itemnum, "") remark=sparepart_remark_dict.get(row.itemnum, "")
) )
equipment_requirements[req_record.asset_location].append(requirement) equipment_requirements[row.asset_location].append(requirement)
for equipment_tag, requirements in equipment_requirements.items(): for equipment_tag, requirements in equipment_requirements.items():
sparepart_manager.add_equipment_requirements(equipment_tag, requirements) sparepart_manager.add_equipment_requirements(equipment_tag, requirements)

Loading…
Cancel
Save