""" Safety Stock Optimizer Replaces hardcoded 95% service level with learned optimal safety stock levels Optimizes based on product characteristics, demand variability, and cost trade-offs """ import pandas as pd import numpy as np from typing import Dict, List, Any, Optional, Tuple import structlog from datetime import datetime, timedelta from scipy import stats from scipy.optimize import minimize_scalar import warnings warnings.filterwarnings('ignore') logger = structlog.get_logger() class SafetyStockOptimizer: """ Optimizes safety stock levels for inventory management. Current problem: Hardcoded 95% service level for all products Solution: Learn optimal service levels based on: - Product characteristics (shelf life, criticality) - Demand variability (coefficient of variation) - Cost trade-offs (holding cost vs stockout cost) - Historical stockout patterns - Supplier reliability Approaches: 1. Statistical approach: Based on demand variability and lead time 2. Cost-based optimization: Minimize total cost (holding + stockout) 3. Service level optimization: Product-specific target service levels 4. Dynamic adjustment: Seasonality and trend awareness """ def __init__(self): self.optimal_stocks = {} self.learned_service_levels = {} async def optimize_safety_stock( self, tenant_id: str, inventory_product_id: str, demand_history: pd.DataFrame, product_characteristics: Dict[str, Any], cost_parameters: Optional[Dict[str, float]] = None, supplier_reliability: Optional[float] = None, min_history_days: int = 90 ) -> Dict[str, Any]: """ Calculate optimal safety stock for a product. Args: tenant_id: Tenant identifier inventory_product_id: Product identifier demand_history: Historical demand data with columns: - date - demand_quantity - stockout (bool, optional) - lead_time_days (optional) product_characteristics: Product properties: - shelf_life_days: int - criticality: str (high, medium, low) - unit_cost: float - avg_daily_demand: float cost_parameters: Optional cost params: - holding_cost_per_unit_per_day: float - stockout_cost_per_unit: float supplier_reliability: Supplier on-time rate (0-1) min_history_days: Minimum days of history required Returns: Dictionary with optimal safety stock and insights """ logger.info( "Optimizing safety stock", tenant_id=tenant_id, inventory_product_id=inventory_product_id, history_days=len(demand_history) ) # Validate input if len(demand_history) < min_history_days: logger.warning( "Insufficient demand history", inventory_product_id=inventory_product_id, days=len(demand_history), required=min_history_days ) return self._insufficient_data_response( tenant_id, inventory_product_id, product_characteristics ) # Calculate demand statistics demand_stats = self._calculate_demand_statistics(demand_history) # Calculate optimal safety stock using multiple methods statistical_result = self._calculate_statistical_safety_stock( demand_stats, product_characteristics, supplier_reliability ) # Cost-based optimization if cost parameters provided if cost_parameters: cost_based_result = self._calculate_cost_optimal_safety_stock( demand_stats, product_characteristics, cost_parameters, demand_history ) else: cost_based_result = None # Service level optimization service_level_result = self._calculate_service_level_optimal_stock( demand_stats, product_characteristics, demand_history ) # Combine methods and select optimal optimal_result = self._select_optimal_safety_stock( statistical_result, cost_based_result, service_level_result, product_characteristics ) # Compare with current hardcoded approach (95% service level) hardcoded_result = self._calculate_hardcoded_safety_stock( demand_stats, service_level=0.95 ) comparison = self._compare_with_hardcoded( optimal_result, hardcoded_result, cost_parameters ) # Generate insights insights = self._generate_safety_stock_insights( tenant_id, inventory_product_id, optimal_result, hardcoded_result, comparison, demand_stats, product_characteristics ) # Store optimal stock self.optimal_stocks[inventory_product_id] = optimal_result['safety_stock'] self.learned_service_levels[inventory_product_id] = optimal_result['service_level'] logger.info( "Safety stock optimization complete", inventory_product_id=inventory_product_id, optimal_stock=optimal_result['safety_stock'], optimal_service_level=optimal_result['service_level'], improvement_vs_hardcoded=comparison.get('cost_savings_pct', 0) ) return { 'tenant_id': tenant_id, 'inventory_product_id': inventory_product_id, 'optimized_at': datetime.utcnow().isoformat(), 'history_days': len(demand_history), 'demand_stats': demand_stats, 'optimal_result': optimal_result, 'hardcoded_result': hardcoded_result, 'comparison': comparison, 'insights': insights } def _calculate_demand_statistics( self, demand_history: pd.DataFrame ) -> Dict[str, float]: """ Calculate comprehensive demand statistics. Args: demand_history: Historical demand data Returns: Dictionary of demand statistics """ # Ensure date column if 'date' not in demand_history.columns: demand_history = demand_history.copy() demand_history['date'] = pd.to_datetime(demand_history.index) demand_history['date'] = pd.to_datetime(demand_history['date']) # Basic statistics mean_demand = demand_history['demand_quantity'].mean() std_demand = demand_history['demand_quantity'].std() cv_demand = std_demand / mean_demand if mean_demand > 0 else 0 # Lead time statistics (if available) if 'lead_time_days' in demand_history.columns: mean_lead_time = demand_history['lead_time_days'].mean() std_lead_time = demand_history['lead_time_days'].std() else: mean_lead_time = 3.0 # Default assumption std_lead_time = 0.5 # Stockout rate (if available) if 'stockout' in demand_history.columns: stockout_rate = demand_history['stockout'].mean() stockout_frequency = demand_history['stockout'].sum() else: stockout_rate = 0.05 # Assume 5% if not tracked stockout_frequency = 0 # Demand distribution characteristics skewness = demand_history['demand_quantity'].skew() kurtosis = demand_history['demand_quantity'].kurtosis() # Recent trend (last 30 days vs overall) if len(demand_history) >= 60: recent_mean = demand_history.tail(30)['demand_quantity'].mean() trend = (recent_mean - mean_demand) / mean_demand if mean_demand > 0 else 0 else: trend = 0 return { 'mean_demand': float(mean_demand), 'std_demand': float(std_demand), 'cv_demand': float(cv_demand), 'min_demand': float(demand_history['demand_quantity'].min()), 'max_demand': float(demand_history['demand_quantity'].max()), 'mean_lead_time': float(mean_lead_time), 'std_lead_time': float(std_lead_time), 'stockout_rate': float(stockout_rate), 'stockout_frequency': int(stockout_frequency), 'skewness': float(skewness), 'kurtosis': float(kurtosis), 'trend': float(trend), 'data_points': int(len(demand_history)) } def _calculate_statistical_safety_stock( self, demand_stats: Dict[str, float], product_characteristics: Dict[str, Any], supplier_reliability: Optional[float] = None ) -> Dict[str, Any]: """ Calculate safety stock using statistical approach (Classic formula). Formula: SS = Z * sqrt(LT * σ_d² + d_avg² * σ_LT²) Where: - Z: Z-score for desired service level - LT: Mean lead time - σ_d: Standard deviation of demand - d_avg: Average demand - σ_LT: Standard deviation of lead time """ # Determine target service level based on product criticality criticality = product_characteristics.get('criticality', 'medium').lower() if criticality == 'high': target_service_level = 0.98 # 98% for critical products elif criticality == 'medium': target_service_level = 0.95 # 95% for medium else: target_service_level = 0.90 # 90% for low criticality # Adjust for supplier reliability if supplier_reliability is not None and supplier_reliability < 0.9: # Less reliable suppliers need higher safety stock target_service_level = min(0.99, target_service_level + 0.03) # Calculate Z-score for target service level z_score = stats.norm.ppf(target_service_level) # Calculate safety stock mean_demand = demand_stats['mean_demand'] std_demand = demand_stats['std_demand'] mean_lead_time = demand_stats['mean_lead_time'] std_lead_time = demand_stats['std_lead_time'] # Safety stock formula variance_component = ( mean_lead_time * (std_demand ** 2) + (mean_demand ** 2) * (std_lead_time ** 2) ) safety_stock = z_score * np.sqrt(variance_component) # Ensure non-negative safety_stock = max(0, safety_stock) return { 'method': 'statistical', 'safety_stock': round(safety_stock, 2), 'service_level': target_service_level, 'z_score': round(z_score, 2), 'rationale': f'Based on {target_service_level*100:.0f}% service level for {criticality} criticality product' } def _calculate_cost_optimal_safety_stock( self, demand_stats: Dict[str, float], product_characteristics: Dict[str, Any], cost_parameters: Dict[str, float], demand_history: pd.DataFrame ) -> Dict[str, Any]: """ Calculate safety stock that minimizes total cost (holding + stockout). Total Cost = (Holding Cost × Safety Stock) + (Stockout Cost × Stockout Frequency) """ holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01) stockout_cost = cost_parameters.get('stockout_cost_per_unit', 10.0) mean_demand = demand_stats['mean_demand'] std_demand = demand_stats['std_demand'] mean_lead_time = demand_stats['mean_lead_time'] def total_cost(safety_stock): """Calculate total cost for given safety stock level.""" # Holding cost (annual) annual_holding_cost = holding_cost * safety_stock * 365 # Stockout probability and expected stockouts # Demand during lead time follows normal distribution demand_during_lt_mean = mean_demand * mean_lead_time demand_during_lt_std = std_demand * np.sqrt(mean_lead_time) # Service level achieved with this safety stock if demand_during_lt_std > 0: z_score = (safety_stock) / demand_during_lt_std service_level = stats.norm.cdf(z_score) else: service_level = 0.99 # Stockout probability stockout_prob = 1 - service_level # Expected annual stockouts (simplified) orders_per_year = 365 / mean_lead_time expected_stockouts = stockout_prob * orders_per_year * mean_demand # Stockout cost (annual) annual_stockout_cost = expected_stockouts * stockout_cost return annual_holding_cost + annual_stockout_cost # Optimize to find minimum total cost # Search range: 0 to 5 * mean demand during lead time max_search = 5 * mean_demand * mean_lead_time result = minimize_scalar( total_cost, bounds=(0, max_search), method='bounded' ) optimal_safety_stock = result.x optimal_cost = result.fun # Calculate achieved service level demand_during_lt_std = std_demand * np.sqrt(mean_lead_time) if demand_during_lt_std > 0: z_score = optimal_safety_stock / demand_during_lt_std achieved_service_level = stats.norm.cdf(z_score) else: achieved_service_level = 0.99 return { 'method': 'cost_optimization', 'safety_stock': round(optimal_safety_stock, 2), 'service_level': round(achieved_service_level, 4), 'annual_total_cost': round(optimal_cost, 2), 'rationale': f'Minimizes total cost (holding + stockout): €{optimal_cost:.2f}/year' } def _calculate_service_level_optimal_stock( self, demand_stats: Dict[str, float], product_characteristics: Dict[str, Any], demand_history: pd.DataFrame ) -> Dict[str, Any]: """ Calculate safety stock based on empirical service level optimization. Uses historical stockout data to find optimal service level. """ # If we have stockout history, learn from it if 'stockout' in demand_history.columns and demand_history['stockout'].sum() > 0: current_stockout_rate = demand_stats['stockout_rate'] # Target: Reduce stockouts by 50% or achieve 95%, whichever is higher target_stockout_rate = min(current_stockout_rate * 0.5, 0.05) target_service_level = 1 - target_stockout_rate else: # No stockout data, use criticality-based default criticality = product_characteristics.get('criticality', 'medium').lower() target_service_level = { 'high': 0.98, 'medium': 0.95, 'low': 0.90 }.get(criticality, 0.95) # Calculate safety stock for target service level z_score = stats.norm.ppf(target_service_level) mean_demand = demand_stats['mean_demand'] std_demand = demand_stats['std_demand'] mean_lead_time = demand_stats['mean_lead_time'] safety_stock = z_score * std_demand * np.sqrt(mean_lead_time) safety_stock = max(0, safety_stock) return { 'method': 'service_level_optimization', 'safety_stock': round(safety_stock, 2), 'service_level': target_service_level, 'rationale': f'Achieves {target_service_level*100:.0f}% service level based on historical performance' } def _select_optimal_safety_stock( self, statistical_result: Dict[str, Any], cost_based_result: Optional[Dict[str, Any]], service_level_result: Dict[str, Any], product_characteristics: Dict[str, Any] ) -> Dict[str, Any]: """ Select optimal safety stock from multiple methods. Priority: 1. Cost-based if available and product value is high 2. Statistical for general case 3. Service level as validation """ # If cost data available and product is valuable, use cost optimization if cost_based_result and product_characteristics.get('unit_cost', 0) > 5: selected = cost_based_result logger.info("Selected cost-based safety stock (high-value product)") # Otherwise use statistical approach else: selected = statistical_result logger.info("Selected statistical safety stock") # Add shelf life constraint shelf_life = product_characteristics.get('shelf_life_days') if shelf_life: max_safe_stock = product_characteristics.get('avg_daily_demand', 0) * (shelf_life * 0.5) if selected['safety_stock'] > max_safe_stock: logger.warning( "Safety stock exceeds shelf life constraint", calculated=selected['safety_stock'], max_allowed=max_safe_stock ) selected['safety_stock'] = round(max_safe_stock, 2) selected['constrained_by'] = 'shelf_life' return selected def _calculate_hardcoded_safety_stock( self, demand_stats: Dict[str, float], service_level: float = 0.95 ) -> Dict[str, Any]: """ Calculate safety stock using current hardcoded 95% service level. Args: demand_stats: Demand statistics service_level: Hardcoded service level (default 0.95) Returns: Safety stock result with hardcoded approach """ z_score = stats.norm.ppf(service_level) mean_demand = demand_stats['mean_demand'] std_demand = demand_stats['std_demand'] mean_lead_time = demand_stats['mean_lead_time'] safety_stock = z_score * std_demand * np.sqrt(mean_lead_time) safety_stock = max(0, safety_stock) return { 'method': 'hardcoded_95_service_level', 'safety_stock': round(safety_stock, 2), 'service_level': service_level, 'rationale': 'Current hardcoded 95% service level for all products' } def _compare_with_hardcoded( self, optimal_result: Dict[str, Any], hardcoded_result: Dict[str, Any], cost_parameters: Optional[Dict[str, float]] ) -> Dict[str, Any]: """ Compare optimal safety stock with hardcoded approach. Args: optimal_result: Optimal safety stock result hardcoded_result: Hardcoded approach result cost_parameters: Optional cost parameters for savings calculation Returns: Comparison metrics """ optimal_stock = optimal_result['safety_stock'] hardcoded_stock = hardcoded_result['safety_stock'] stock_difference = optimal_stock - hardcoded_stock stock_difference_pct = (stock_difference / hardcoded_stock * 100) if hardcoded_stock > 0 else 0 comparison = { 'stock_difference': round(stock_difference, 2), 'stock_difference_pct': round(stock_difference_pct, 2), 'optimal_service_level': optimal_result['service_level'], 'hardcoded_service_level': hardcoded_result['service_level'], 'service_level_difference': round( (optimal_result['service_level'] - hardcoded_result['service_level']) * 100, 2 ) } # Calculate cost savings if cost data available if cost_parameters: holding_cost = cost_parameters.get('holding_cost_per_unit_per_day', 0.01) annual_holding_savings = stock_difference * holding_cost * 365 comparison['annual_holding_cost_savings'] = round(annual_holding_savings, 2) if hardcoded_stock > 0: comparison['cost_savings_pct'] = round( (annual_holding_savings / (hardcoded_stock * holding_cost * 365)) * 100, 2 ) return comparison def _generate_safety_stock_insights( self, tenant_id: str, inventory_product_id: str, optimal_result: Dict[str, Any], hardcoded_result: Dict[str, Any], comparison: Dict[str, Any], demand_stats: Dict[str, float], product_characteristics: Dict[str, Any] ) -> List[Dict[str, Any]]: """ Generate actionable insights from safety stock optimization. Args: tenant_id: Tenant ID inventory_product_id: Product ID optimal_result: Optimal safety stock result hardcoded_result: Hardcoded result comparison: Comparison metrics demand_stats: Demand statistics product_characteristics: Product characteristics Returns: List of insights """ insights = [] stock_diff_pct = comparison['stock_difference_pct'] # Insight 1: Over-stocking reduction opportunity if stock_diff_pct < -10: # Optimal is >10% lower cost_savings = comparison.get('annual_holding_cost_savings', 0) insights.append({ 'type': 'optimization', 'priority': 'high' if abs(stock_diff_pct) > 25 else 'medium', 'category': 'inventory', 'title': f'Reduce Safety Stock by {abs(stock_diff_pct):.0f}%', 'description': f'Product {inventory_product_id} is over-stocked. Optimal safety stock is {optimal_result["safety_stock"]:.1f} units vs current {hardcoded_result["safety_stock"]:.1f}. Reducing to optimal level saves €{abs(cost_savings):.2f}/year in holding costs while maintaining {optimal_result["service_level"]*100:.1f}% service level.', 'impact_type': 'cost_savings', 'impact_value': abs(cost_savings), 'impact_unit': 'euros_per_year', 'confidence': 85, 'metrics_json': { 'inventory_product_id': inventory_product_id, 'current_safety_stock': round(hardcoded_result['safety_stock'], 2), 'optimal_safety_stock': round(optimal_result['safety_stock'], 2), 'reduction_pct': round(abs(stock_diff_pct), 2), 'annual_savings': round(abs(cost_savings), 2), 'optimal_service_level': round(optimal_result['service_level'] * 100, 2) }, 'actionable': True, 'recommendation_actions': [ { 'label': 'Update Safety Stock', 'action': 'update_safety_stock', 'params': { 'inventory_product_id': inventory_product_id, 'new_safety_stock': round(optimal_result['safety_stock'], 2) } } ], 'source_service': 'inventory', 'source_model': 'safety_stock_optimizer' }) # Insight 2: Under-stocking risk elif stock_diff_pct > 10: # Optimal is >10% higher insights.append({ 'type': 'alert', 'priority': 'high' if stock_diff_pct > 25 else 'medium', 'category': 'inventory', 'title': f'Increase Safety Stock by {stock_diff_pct:.0f}%', 'description': f'Product {inventory_product_id} safety stock is too low. Current {hardcoded_result["safety_stock"]:.1f} units provides only {hardcoded_result["service_level"]*100:.0f}% service level. Increase to {optimal_result["safety_stock"]:.1f} for optimal {optimal_result["service_level"]*100:.1f}% service level.', 'impact_type': 'stockout_risk_reduction', 'impact_value': stock_diff_pct, 'impact_unit': 'percentage', 'confidence': 85, 'metrics_json': { 'inventory_product_id': inventory_product_id, 'current_safety_stock': round(hardcoded_result['safety_stock'], 2), 'optimal_safety_stock': round(optimal_result['safety_stock'], 2), 'increase_pct': round(stock_diff_pct, 2), 'current_service_level': round(hardcoded_result['service_level'] * 100, 2), 'optimal_service_level': round(optimal_result['service_level'] * 100, 2), 'historical_stockout_rate': round(demand_stats['stockout_rate'] * 100, 2) }, 'actionable': True, 'recommendation_actions': [ { 'label': 'Update Safety Stock', 'action': 'update_safety_stock', 'params': { 'inventory_product_id': inventory_product_id, 'new_safety_stock': round(optimal_result['safety_stock'], 2) } } ], 'source_service': 'inventory', 'source_model': 'safety_stock_optimizer' }) # Insight 3: High demand variability if demand_stats['cv_demand'] > 0.5: # Coefficient of variation > 0.5 insights.append({ 'type': 'insight', 'priority': 'medium', 'category': 'inventory', 'title': f'High Demand Variability Detected', 'description': f'Product {inventory_product_id} has high demand variability (CV={demand_stats["cv_demand"]:.2f}). This increases safety stock requirements. Consider demand smoothing strategies or more frequent orders.', 'impact_type': 'operational_insight', 'impact_value': demand_stats['cv_demand'], 'impact_unit': 'coefficient_of_variation', 'confidence': 90, 'metrics_json': { 'inventory_product_id': inventory_product_id, 'cv_demand': round(demand_stats['cv_demand'], 2), 'mean_demand': round(demand_stats['mean_demand'], 2), 'std_demand': round(demand_stats['std_demand'], 2) }, 'actionable': True, 'recommendation_actions': [ { 'label': 'Review Demand Patterns', 'action': 'analyze_demand_patterns', 'params': {'inventory_product_id': inventory_product_id} } ], 'source_service': 'inventory', 'source_model': 'safety_stock_optimizer' }) # Insight 4: Frequent stockouts if demand_stats['stockout_rate'] > 0.1: # More than 10% stockout rate insights.append({ 'type': 'alert', 'priority': 'critical' if demand_stats['stockout_rate'] > 0.2 else 'high', 'category': 'inventory', 'title': f'Frequent Stockouts: {demand_stats["stockout_rate"]*100:.1f}%', 'description': f'Product {inventory_product_id} experiences frequent stockouts ({demand_stats["stockout_rate"]*100:.1f}% of days). Optimal safety stock of {optimal_result["safety_stock"]:.1f} units should reduce this significantly.', 'impact_type': 'stockout_frequency', 'impact_value': demand_stats['stockout_rate'] * 100, 'impact_unit': 'percentage', 'confidence': 95, 'metrics_json': { 'inventory_product_id': inventory_product_id, 'stockout_rate': round(demand_stats['stockout_rate'] * 100, 2), 'stockout_frequency': demand_stats['stockout_frequency'], 'optimal_safety_stock': round(optimal_result['safety_stock'], 2) }, 'actionable': True, 'recommendation_actions': [ { 'label': 'URGENT: Update Safety Stock', 'action': 'update_safety_stock', 'params': { 'inventory_product_id': inventory_product_id, 'new_safety_stock': round(optimal_result['safety_stock'], 2) } }, { 'label': 'Review Supplier Reliability', 'action': 'review_supplier', 'params': {'inventory_product_id': inventory_product_id} } ], 'source_service': 'inventory', 'source_model': 'safety_stock_optimizer' }) return insights def _insufficient_data_response( self, tenant_id: str, inventory_product_id: str, product_characteristics: Dict[str, Any] ) -> Dict[str, Any]: """Return response when insufficient data available.""" # Use simple heuristic based on criticality criticality = product_characteristics.get('criticality', 'medium').lower() avg_daily_demand = product_characteristics.get('avg_daily_demand', 10) # Simple rule: 7 days of demand for high, 5 for medium, 3 for low safety_stock_days = {'high': 7, 'medium': 5, 'low': 3}.get(criticality, 5) fallback_safety_stock = avg_daily_demand * safety_stock_days return { 'tenant_id': tenant_id, 'inventory_product_id': inventory_product_id, 'optimized_at': datetime.utcnow().isoformat(), 'history_days': 0, 'demand_stats': {}, 'optimal_result': { 'method': 'fallback_heuristic', 'safety_stock': round(fallback_safety_stock, 2), 'service_level': 0.95, 'rationale': f'Insufficient data. Using {safety_stock_days} days of demand for {criticality} criticality.' }, 'hardcoded_result': None, 'comparison': {}, 'insights': [] } def get_optimal_safety_stock(self, inventory_product_id: str) -> Optional[float]: """Get cached optimal safety stock for a product.""" return self.optimal_stocks.get(inventory_product_id) def get_learned_service_level(self, inventory_product_id: str) -> Optional[float]: """Get learned optimal service level for a product.""" return self.learned_service_levels.get(inventory_product_id)