Files
bakery-ia/services/forecasting/app/ml/scenario_planner.py
2025-11-05 13:34:56 +01:00

386 lines
12 KiB
Python

"""
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