""" Scenario Planning System What-if analysis for demand forecasting """ import pandas as pd import numpy as np from typing import Dict, List, Any, Optional from datetime import datetime, date, timedelta import structlog from enum import Enum logger = structlog.get_logger() class ScenarioType(str, Enum): """Types of scenarios""" BASELINE = "baseline" OPTIMISTIC = "optimistic" PESSIMISTIC = "pessimistic" CUSTOM = "custom" PROMOTION = "promotion" EVENT = "event" WEATHER = "weather" PRICE_CHANGE = "price_change" class ScenarioPlanner: """ Scenario planning for demand forecasting. Allows testing "what-if" scenarios: - What if we run a promotion? - What if there's a local festival? - What if weather is unusually bad? - What if we change prices? """ def __init__(self, base_forecaster=None): """ Initialize scenario planner. Args: base_forecaster: Base forecaster to use for baseline predictions """ self.base_forecaster = base_forecaster async def create_scenario( self, tenant_id: str, inventory_product_id: str, scenario_name: str, scenario_type: ScenarioType, start_date: date, end_date: date, adjustments: Dict[str, Any] ) -> Dict[str, Any]: """ Create a forecast scenario with adjustments. Args: tenant_id: Tenant identifier inventory_product_id: Product identifier scenario_name: Name for the scenario scenario_type: Type of scenario start_date: Scenario start date end_date: Scenario end date adjustments: Dictionary of adjustments to apply Returns: Scenario forecast results """ logger.info( "Creating forecast scenario", tenant_id=tenant_id, inventory_product_id=inventory_product_id, scenario_name=scenario_name, scenario_type=scenario_type ) # Generate baseline forecast first baseline_forecast = await self._generate_baseline_forecast( tenant_id=tenant_id, inventory_product_id=inventory_product_id, start_date=start_date, end_date=end_date ) # Apply scenario adjustments scenario_forecast = self._apply_scenario_adjustments( baseline_forecast=baseline_forecast, adjustments=adjustments, scenario_type=scenario_type ) # Calculate impact impact_analysis = self._calculate_scenario_impact( baseline_forecast=baseline_forecast, scenario_forecast=scenario_forecast ) return { 'scenario_id': f"scenario_{tenant_id}_{inventory_product_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}", 'scenario_name': scenario_name, 'scenario_type': scenario_type, 'tenant_id': tenant_id, 'inventory_product_id': inventory_product_id, 'date_range': { 'start': start_date.isoformat(), 'end': end_date.isoformat() }, 'baseline_forecast': baseline_forecast, 'scenario_forecast': scenario_forecast, 'impact_analysis': impact_analysis, 'adjustments_applied': adjustments, 'created_at': datetime.now().isoformat() } async def compare_scenarios( self, scenarios: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Compare multiple scenarios side-by-side. Args: scenarios: List of scenario results from create_scenario() Returns: Comparison analysis """ if len(scenarios) < 2: return {'error': 'Need at least 2 scenarios to compare'} comparison = { 'scenarios_compared': len(scenarios), 'scenario_names': [s['scenario_name'] for s in scenarios], 'comparison_metrics': {} } # Extract total demand for each scenario for scenario in scenarios: scenario_name = scenario['scenario_name'] scenario_forecast = scenario['scenario_forecast'] total_demand = sum(f['predicted_demand'] for f in scenario_forecast) comparison['comparison_metrics'][scenario_name] = { 'total_demand': total_demand, 'avg_daily_demand': total_demand / len(scenario_forecast) if scenario_forecast else 0, 'peak_demand': max(f['predicted_demand'] for f in scenario_forecast) if scenario_forecast else 0 } # Determine best and worst scenarios total_demands = { name: metrics['total_demand'] for name, metrics in comparison['comparison_metrics'].items() } comparison['best_scenario'] = max(total_demands, key=total_demands.get) comparison['worst_scenario'] = min(total_demands, key=total_demands.get) comparison['demand_range'] = { 'min': min(total_demands.values()), 'max': max(total_demands.values()), 'spread': max(total_demands.values()) - min(total_demands.values()) } return comparison async def _generate_baseline_forecast( self, tenant_id: str, inventory_product_id: str, start_date: date, end_date: date ) -> List[Dict[str, Any]]: """ Generate baseline forecast without adjustments. Args: tenant_id: Tenant identifier inventory_product_id: Product identifier start_date: Start date end_date: End date Returns: List of daily forecasts """ # Generate date range dates = [] current_date = start_date while current_date <= end_date: dates.append(current_date) current_date += timedelta(days=1) # Placeholder forecast (in real implementation, call forecasting service) baseline = [] for forecast_date in dates: baseline.append({ 'date': forecast_date.isoformat(), 'predicted_demand': 100, # Placeholder 'confidence_lower': 80, 'confidence_upper': 120 }) return baseline def _apply_scenario_adjustments( self, baseline_forecast: List[Dict[str, Any]], adjustments: Dict[str, Any], scenario_type: ScenarioType ) -> List[Dict[str, Any]]: """ Apply adjustments to baseline forecast. Args: baseline_forecast: Baseline forecast data adjustments: Adjustments to apply scenario_type: Type of scenario Returns: Adjusted forecast """ scenario_forecast = [] for day_forecast in baseline_forecast: adjusted_forecast = day_forecast.copy() # Apply different adjustment types if 'demand_multiplier' in adjustments: # Multiply demand by factor multiplier = adjustments['demand_multiplier'] adjusted_forecast['predicted_demand'] *= multiplier adjusted_forecast['confidence_lower'] *= multiplier adjusted_forecast['confidence_upper'] *= multiplier if 'demand_offset' in adjustments: # Add/subtract fixed amount offset = adjustments['demand_offset'] adjusted_forecast['predicted_demand'] += offset adjusted_forecast['confidence_lower'] += offset adjusted_forecast['confidence_upper'] += offset if 'event_impact' in adjustments: # Apply event-specific impact event_multiplier = adjustments['event_impact'] adjusted_forecast['predicted_demand'] *= event_multiplier if 'weather_impact' in adjustments: # Apply weather adjustments weather_factor = adjustments['weather_impact'] adjusted_forecast['predicted_demand'] *= weather_factor if 'price_elasticity' in adjustments and 'price_change_percent' in adjustments: # Apply price elasticity elasticity = adjustments['price_elasticity'] price_change = adjustments['price_change_percent'] demand_change = -elasticity * price_change # Negative correlation adjusted_forecast['predicted_demand'] *= (1 + demand_change) # Ensure non-negative demand adjusted_forecast['predicted_demand'] = max(0, adjusted_forecast['predicted_demand']) adjusted_forecast['confidence_lower'] = max(0, adjusted_forecast['confidence_lower']) scenario_forecast.append(adjusted_forecast) return scenario_forecast def _calculate_scenario_impact( self, baseline_forecast: List[Dict[str, Any]], scenario_forecast: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Calculate impact of scenario vs baseline. Args: baseline_forecast: Baseline forecast scenario_forecast: Scenario forecast Returns: Impact analysis """ baseline_total = sum(f['predicted_demand'] for f in baseline_forecast) scenario_total = sum(f['predicted_demand'] for f in scenario_forecast) difference = scenario_total - baseline_total percent_change = (difference / baseline_total * 100) if baseline_total > 0 else 0 return { 'baseline_total_demand': baseline_total, 'scenario_total_demand': scenario_total, 'absolute_difference': difference, 'percent_change': percent_change, 'impact_category': self._categorize_impact(percent_change), 'days_analyzed': len(baseline_forecast) } def _categorize_impact(self, percent_change: float) -> str: """Categorize impact magnitude""" if abs(percent_change) < 5: return "minimal" elif abs(percent_change) < 15: return "moderate" elif abs(percent_change) < 30: return "significant" else: return "major" def generate_predefined_scenarios( self, base_scenario: Dict[str, Any] ) -> List[Dict[str, Any]]: """ Generate common predefined scenarios for comparison. Args: base_scenario: Base scenario parameters Returns: List of scenario configurations """ scenarios = [] # Baseline scenario scenarios.append({ 'scenario_name': 'Baseline', 'scenario_type': ScenarioType.BASELINE, 'adjustments': {} }) # Optimistic scenario scenarios.append({ 'scenario_name': 'Optimistic', 'scenario_type': ScenarioType.OPTIMISTIC, 'adjustments': { 'demand_multiplier': 1.2, # 20% increase 'description': '+20% demand increase' } }) # Pessimistic scenario scenarios.append({ 'scenario_name': 'Pessimistic', 'scenario_type': ScenarioType.PESSIMISTIC, 'adjustments': { 'demand_multiplier': 0.8, # 20% decrease 'description': '-20% demand decrease' } }) # Promotion scenario scenarios.append({ 'scenario_name': 'Promotion Campaign', 'scenario_type': ScenarioType.PROMOTION, 'adjustments': { 'demand_multiplier': 1.5, # 50% increase 'description': '50% promotion boost' } }) # Bad weather scenario scenarios.append({ 'scenario_name': 'Bad Weather', 'scenario_type': ScenarioType.WEATHER, 'adjustments': { 'weather_impact': 0.7, # 30% decrease 'description': 'Bad weather reduces foot traffic' } }) # Price increase scenario scenarios.append({ 'scenario_name': 'Price Increase 10%', 'scenario_type': ScenarioType.PRICE_CHANGE, 'adjustments': { 'price_elasticity': 1.2, # Elastic demand 'price_change_percent': 0.10, # 10% price increase 'description': '10% price increase with elastic demand' } }) return scenarios