2025-07-21 19:48:56 +02:00
|
|
|
# ================================================================
|
|
|
|
|
# services/forecasting/app/ml/predictor.py
|
|
|
|
|
# ================================================================
|
|
|
|
|
"""
|
|
|
|
|
Enhanced predictor module with advanced forecasting capabilities
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import structlog
|
|
|
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
|
|
|
import pandas as pd
|
|
|
|
|
import numpy as np
|
|
|
|
|
from datetime import datetime, date, timedelta
|
|
|
|
|
import pickle
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from shared.monitoring.metrics import MetricsCollector
|
2025-08-08 09:08:41 +02:00
|
|
|
from shared.database.base import create_database_manager
|
2025-07-21 19:48:56 +02:00
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
metrics = MetricsCollector("forecasting-service")
|
|
|
|
|
|
|
|
|
|
class BakeryPredictor:
|
|
|
|
|
"""
|
2025-08-08 09:08:41 +02:00
|
|
|
Advanced predictor for bakery demand forecasting with dependency injection
|
2025-07-21 19:48:56 +02:00
|
|
|
Handles Prophet models and business-specific logic
|
|
|
|
|
"""
|
2025-11-05 13:34:56 +01:00
|
|
|
|
|
|
|
|
def __init__(self, database_manager=None, use_dynamic_rules=True):
|
2025-08-08 09:08:41 +02:00
|
|
|
self.database_manager = database_manager or create_database_manager(settings.DATABASE_URL, "forecasting-service")
|
2025-07-21 19:48:56 +02:00
|
|
|
self.model_cache = {}
|
2025-11-05 13:34:56 +01:00
|
|
|
self.use_dynamic_rules = use_dynamic_rules
|
|
|
|
|
|
|
|
|
|
if use_dynamic_rules:
|
2025-12-16 13:32:33 +01:00
|
|
|
try:
|
|
|
|
|
from app.ml.dynamic_rules_engine import DynamicRulesEngine
|
|
|
|
|
from shared.clients.ai_insights_client import AIInsightsClient
|
|
|
|
|
self.rules_engine = DynamicRulesEngine()
|
|
|
|
|
self.ai_insights_client = AIInsightsClient(
|
|
|
|
|
base_url=settings.AI_INSIGHTS_SERVICE_URL or "http://ai-insights-service:8000"
|
|
|
|
|
)
|
|
|
|
|
# Also provide business_rules for consistency
|
|
|
|
|
self.business_rules = BakeryBusinessRules(
|
|
|
|
|
use_dynamic_rules=True,
|
|
|
|
|
ai_insights_client=self.ai_insights_client
|
|
|
|
|
)
|
|
|
|
|
except ImportError as e:
|
|
|
|
|
logger.warning(f"Failed to import dynamic rules engine: {e}. Falling back to basic business rules.")
|
|
|
|
|
self.use_dynamic_rules = False
|
|
|
|
|
self.business_rules = BakeryBusinessRules()
|
2025-11-05 13:34:56 +01:00
|
|
|
else:
|
|
|
|
|
self.business_rules = BakeryBusinessRules()
|
2025-08-08 09:08:41 +02:00
|
|
|
|
|
|
|
|
class BakeryForecaster:
|
|
|
|
|
"""
|
|
|
|
|
Enhanced forecaster that integrates with repository pattern
|
2025-11-05 13:34:56 +01:00
|
|
|
Uses enhanced features from training service for predictions
|
2025-08-08 09:08:41 +02:00
|
|
|
"""
|
2025-11-05 13:34:56 +01:00
|
|
|
|
|
|
|
|
def __init__(self, database_manager=None, use_enhanced_features=True):
|
2025-08-08 09:08:41 +02:00
|
|
|
self.database_manager = database_manager or create_database_manager(settings.DATABASE_URL, "forecasting-service")
|
|
|
|
|
self.predictor = BakeryPredictor(database_manager)
|
2025-11-05 13:34:56 +01:00
|
|
|
self.use_enhanced_features = use_enhanced_features
|
|
|
|
|
|
2025-12-16 13:32:33 +01:00
|
|
|
# Initialize business rules - this was missing! This fixes the AttributeError
|
|
|
|
|
self.business_rules = BakeryBusinessRules(use_dynamic_rules=True, ai_insights_client=self.predictor.ai_insights_client if hasattr(self.predictor, 'ai_insights_client') else None)
|
|
|
|
|
|
2025-11-12 15:34:10 +01:00
|
|
|
# Initialize POI feature service
|
|
|
|
|
from app.services.poi_feature_service import POIFeatureService
|
|
|
|
|
self.poi_feature_service = POIFeatureService()
|
|
|
|
|
|
2025-11-14 07:23:56 +01:00
|
|
|
# Initialize enhanced data processor from shared module
|
2025-11-05 13:34:56 +01:00
|
|
|
if use_enhanced_features:
|
|
|
|
|
try:
|
2025-11-14 07:23:56 +01:00
|
|
|
from shared.ml.data_processor import EnhancedBakeryDataProcessor
|
|
|
|
|
self.data_processor = EnhancedBakeryDataProcessor(region='MD')
|
|
|
|
|
logger.info("Enhanced features enabled using shared data processor")
|
2025-11-05 13:34:56 +01:00
|
|
|
except ImportError as e:
|
2025-11-14 07:23:56 +01:00
|
|
|
logger.warning(
|
|
|
|
|
f"Could not import EnhancedBakeryDataProcessor from shared module: {e}. "
|
|
|
|
|
"Falling back to basic features."
|
|
|
|
|
)
|
2025-11-05 13:34:56 +01:00
|
|
|
self.use_enhanced_features = False
|
|
|
|
|
self.data_processor = None
|
|
|
|
|
else:
|
|
|
|
|
self.data_processor = None
|
2025-08-08 09:08:41 +02:00
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
|
|
|
|
|
async def predict_demand(self, model, features: Dict[str, Any],
|
|
|
|
|
business_type: str = "individual") -> Dict[str, float]:
|
|
|
|
|
"""Generate demand prediction with business rules applied"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Generate base prediction
|
|
|
|
|
base_prediction = await self._generate_base_prediction(model, features)
|
|
|
|
|
|
|
|
|
|
# Apply business rules
|
|
|
|
|
adjusted_prediction = self.business_rules.apply_rules(
|
|
|
|
|
base_prediction, features, business_type
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Add uncertainty estimation
|
|
|
|
|
final_prediction = self._add_uncertainty_bands(adjusted_prediction, features)
|
|
|
|
|
|
|
|
|
|
return final_prediction
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error in demand prediction", error=str(e))
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
async def _generate_base_prediction(self, model, features: Dict[str, Any]) -> Dict[str, float]:
|
|
|
|
|
"""Generate base prediction from Prophet model"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Convert features to Prophet DataFrame
|
|
|
|
|
df = self._prepare_prophet_dataframe(features)
|
|
|
|
|
|
|
|
|
|
# Generate forecast
|
|
|
|
|
forecast = model.predict(df)
|
|
|
|
|
|
|
|
|
|
if len(forecast) > 0:
|
|
|
|
|
row = forecast.iloc[0]
|
|
|
|
|
return {
|
|
|
|
|
"yhat": float(row['yhat']),
|
|
|
|
|
"yhat_lower": float(row['yhat_lower']),
|
|
|
|
|
"yhat_upper": float(row['yhat_upper']),
|
|
|
|
|
"trend": float(row.get('trend', 0)),
|
|
|
|
|
"seasonal": float(row.get('seasonal', 0)),
|
|
|
|
|
"weekly": float(row.get('weekly', 0)),
|
|
|
|
|
"yearly": float(row.get('yearly', 0)),
|
|
|
|
|
"holidays": float(row.get('holidays', 0))
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
raise ValueError("No prediction generated from model")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error generating base prediction", error=str(e))
|
|
|
|
|
raise
|
|
|
|
|
|
2025-11-05 13:34:56 +01:00
|
|
|
async def _prepare_prophet_dataframe(self, features: Dict[str, Any],
|
|
|
|
|
historical_data: pd.DataFrame = None) -> pd.DataFrame:
|
|
|
|
|
"""
|
|
|
|
|
Convert features to Prophet-compatible DataFrame.
|
|
|
|
|
Uses enhanced features when available (60+ features vs basic 10).
|
|
|
|
|
"""
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
try:
|
2025-11-05 13:34:56 +01:00
|
|
|
if self.use_enhanced_features and self.data_processor:
|
|
|
|
|
# Use enhanced data processor from training service
|
|
|
|
|
logger.info("Generating enhanced features for prediction")
|
|
|
|
|
|
|
|
|
|
# Create future date range
|
|
|
|
|
future_dates = pd.DatetimeIndex([pd.to_datetime(features['date'])])
|
|
|
|
|
|
|
|
|
|
# Prepare weather forecast DataFrame
|
|
|
|
|
weather_df = pd.DataFrame({
|
|
|
|
|
'date': [pd.to_datetime(features['date'])],
|
|
|
|
|
'temperature': [features.get('temperature', 15.0)],
|
|
|
|
|
'precipitation': [features.get('precipitation', 0.0)],
|
|
|
|
|
'humidity': [features.get('humidity', 60.0)],
|
|
|
|
|
'wind_speed': [features.get('wind_speed', 5.0)],
|
|
|
|
|
'pressure': [features.get('pressure', 1013.0)]
|
|
|
|
|
})
|
|
|
|
|
|
2025-11-12 15:34:10 +01:00
|
|
|
# Fetch POI features if tenant_id is available
|
|
|
|
|
poi_features = None
|
|
|
|
|
if 'tenant_id' in features:
|
|
|
|
|
poi_features = await self.poi_feature_service.get_poi_features(
|
|
|
|
|
features['tenant_id']
|
|
|
|
|
)
|
|
|
|
|
if poi_features:
|
|
|
|
|
logger.info(
|
|
|
|
|
f"Retrieved {len(poi_features)} POI features for prediction",
|
|
|
|
|
tenant_id=features['tenant_id']
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-05 13:34:56 +01:00
|
|
|
# Use data processor to create ALL enhanced features
|
|
|
|
|
df = await self.data_processor.prepare_prediction_features(
|
|
|
|
|
future_dates=future_dates,
|
|
|
|
|
weather_forecast=weather_df,
|
|
|
|
|
traffic_forecast=None, # Will add when traffic forecasting is implemented
|
2025-11-12 15:34:10 +01:00
|
|
|
poi_features=poi_features, # POI features for location-based forecasting
|
2025-11-05 13:34:56 +01:00
|
|
|
historical_data=historical_data # For lagged features
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(f"Generated {len(df.columns)} enhanced features for prediction")
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
# Fallback to basic features
|
|
|
|
|
logger.info("Using basic features for prediction")
|
|
|
|
|
|
|
|
|
|
# Create base DataFrame
|
|
|
|
|
df = pd.DataFrame({
|
|
|
|
|
'ds': [pd.to_datetime(features['date'])]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Add regressor features
|
|
|
|
|
feature_mapping = {
|
|
|
|
|
'temperature': 'temperature',
|
|
|
|
|
'precipitation': 'precipitation',
|
|
|
|
|
'humidity': 'humidity',
|
|
|
|
|
'wind_speed': 'wind_speed',
|
|
|
|
|
'traffic_volume': 'traffic_volume',
|
|
|
|
|
'pedestrian_count': 'pedestrian_count'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for feature_key, df_column in feature_mapping.items():
|
|
|
|
|
if feature_key in features and features[feature_key] is not None:
|
|
|
|
|
df[df_column] = float(features[feature_key])
|
|
|
|
|
else:
|
|
|
|
|
df[df_column] = 0.0
|
|
|
|
|
|
|
|
|
|
# Add categorical features
|
|
|
|
|
df['day_of_week'] = int(features.get('day_of_week', 0))
|
|
|
|
|
df['is_weekend'] = int(features.get('is_weekend', False))
|
|
|
|
|
df['is_holiday'] = int(features.get('is_holiday', False))
|
|
|
|
|
|
|
|
|
|
# Business type
|
|
|
|
|
business_type = features.get('business_type', 'individual')
|
|
|
|
|
df['is_central_workshop'] = int(business_type == 'central_workshop')
|
|
|
|
|
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Error preparing Prophet dataframe: {e}, falling back to basic features")
|
|
|
|
|
# Fallback to basic implementation on error
|
|
|
|
|
df = pd.DataFrame({'ds': [pd.to_datetime(features['date'])]})
|
|
|
|
|
df['temperature'] = features.get('temperature', 15.0)
|
|
|
|
|
df['precipitation'] = features.get('precipitation', 0.0)
|
2025-07-21 19:48:56 +02:00
|
|
|
df['is_weekend'] = int(features.get('is_weekend', False))
|
|
|
|
|
df['is_holiday'] = int(features.get('is_holiday', False))
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
def _add_uncertainty_bands(self, prediction: Dict[str, float],
|
|
|
|
|
features: Dict[str, Any]) -> Dict[str, float]:
|
|
|
|
|
"""Add uncertainty estimation based on external factors"""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
base_demand = prediction["yhat"]
|
|
|
|
|
base_lower = prediction["yhat_lower"]
|
|
|
|
|
base_upper = prediction["yhat_upper"]
|
|
|
|
|
|
|
|
|
|
# Weather uncertainty
|
|
|
|
|
weather_uncertainty = self._calculate_weather_uncertainty(features)
|
|
|
|
|
|
|
|
|
|
# Holiday uncertainty
|
|
|
|
|
holiday_uncertainty = self._calculate_holiday_uncertainty(features)
|
|
|
|
|
|
|
|
|
|
# Weekend uncertainty
|
|
|
|
|
weekend_uncertainty = self._calculate_weekend_uncertainty(features)
|
|
|
|
|
|
|
|
|
|
# Total uncertainty factor
|
|
|
|
|
total_uncertainty = 1.0 + weather_uncertainty + holiday_uncertainty + weekend_uncertainty
|
|
|
|
|
|
|
|
|
|
# Adjust bounds
|
|
|
|
|
uncertainty_range = (base_upper - base_lower) * total_uncertainty
|
|
|
|
|
center_point = base_demand
|
|
|
|
|
|
|
|
|
|
adjusted_lower = center_point - (uncertainty_range / 2)
|
|
|
|
|
adjusted_upper = center_point + (uncertainty_range / 2)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"demand": max(0, base_demand), # Never predict negative demand
|
|
|
|
|
"lower_bound": max(0, adjusted_lower),
|
|
|
|
|
"upper_bound": adjusted_upper,
|
|
|
|
|
"uncertainty_factor": total_uncertainty,
|
|
|
|
|
"trend": prediction.get("trend", 0),
|
|
|
|
|
"seasonal": prediction.get("seasonal", 0),
|
|
|
|
|
"holiday_effect": prediction.get("holidays", 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error adding uncertainty bands", error=str(e))
|
|
|
|
|
# Return basic prediction if uncertainty calculation fails
|
|
|
|
|
return {
|
|
|
|
|
"demand": max(0, prediction["yhat"]),
|
|
|
|
|
"lower_bound": max(0, prediction["yhat_lower"]),
|
|
|
|
|
"upper_bound": prediction["yhat_upper"],
|
|
|
|
|
"uncertainty_factor": 1.0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _calculate_weather_uncertainty(self, features: Dict[str, Any]) -> float:
|
|
|
|
|
"""Calculate weather-based uncertainty"""
|
|
|
|
|
|
|
|
|
|
uncertainty = 0.0
|
|
|
|
|
|
|
|
|
|
# Temperature extremes add uncertainty
|
|
|
|
|
temp = features.get('temperature')
|
|
|
|
|
if temp is not None:
|
|
|
|
|
if temp < settings.TEMPERATURE_THRESHOLD_COLD or temp > settings.TEMPERATURE_THRESHOLD_HOT:
|
|
|
|
|
uncertainty += 0.1
|
|
|
|
|
|
|
|
|
|
# Rain adds uncertainty
|
|
|
|
|
precipitation = features.get('precipitation')
|
|
|
|
|
if precipitation is not None and precipitation > 0:
|
|
|
|
|
uncertainty += 0.05 * min(precipitation, 10) # Cap at 50mm
|
|
|
|
|
|
|
|
|
|
return uncertainty
|
|
|
|
|
|
|
|
|
|
def _calculate_holiday_uncertainty(self, features: Dict[str, Any]) -> float:
|
|
|
|
|
"""Calculate holiday-based uncertainty"""
|
|
|
|
|
|
|
|
|
|
if features.get('is_holiday', False):
|
|
|
|
|
return 0.2 # 20% additional uncertainty on holidays
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
def _calculate_weekend_uncertainty(self, features: Dict[str, Any]) -> float:
|
|
|
|
|
"""Calculate weekend-based uncertainty"""
|
2025-11-05 13:34:56 +01:00
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
if features.get('is_weekend', False):
|
|
|
|
|
return 0.1 # 10% additional uncertainty on weekends
|
|
|
|
|
return 0.0
|
|
|
|
|
|
2025-12-16 13:32:33 +01:00
|
|
|
async def analyze_demand_patterns(
|
|
|
|
|
self,
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
inventory_product_id: str,
|
|
|
|
|
sales_data: pd.DataFrame,
|
|
|
|
|
forecast_horizon_days: int = 30,
|
|
|
|
|
min_history_days: int = 90
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Analyze demand patterns by delegating to the sales service.
|
|
|
|
|
|
|
|
|
|
NOTE: Sales data analysis is the responsibility of the sales service.
|
|
|
|
|
This method calls the sales service API to get demand pattern analysis.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant identifier
|
|
|
|
|
inventory_product_id: Product identifier
|
|
|
|
|
sales_data: Historical sales DataFrame (not used - kept for backward compatibility)
|
|
|
|
|
forecast_horizon_days: Days to forecast ahead (not used currently)
|
|
|
|
|
min_history_days: Minimum history required
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Analysis results with patterns, trends, and insights from sales service
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from shared.clients.sales_client import SalesServiceClient
|
|
|
|
|
from datetime import date, timedelta
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Requesting demand pattern analysis from sales service",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
inventory_product_id=inventory_product_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Initialize sales client
|
|
|
|
|
sales_client = SalesServiceClient(config=settings, calling_service_name="forecasting")
|
|
|
|
|
|
|
|
|
|
# Calculate date range
|
|
|
|
|
end_date = date.today()
|
|
|
|
|
start_date = end_date - timedelta(days=min_history_days)
|
|
|
|
|
|
|
|
|
|
# Call sales service for pattern analysis
|
|
|
|
|
patterns = await sales_client.get_product_demand_patterns(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
product_id=inventory_product_id,
|
|
|
|
|
start_date=start_date,
|
|
|
|
|
end_date=end_date,
|
|
|
|
|
min_history_days=min_history_days
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Generate insights from patterns
|
|
|
|
|
insights = self._generate_insights_from_patterns(
|
|
|
|
|
patterns,
|
|
|
|
|
tenant_id,
|
|
|
|
|
inventory_product_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Add insights to the result
|
|
|
|
|
patterns['insights'] = insights
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"Demand pattern analysis received from sales service",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
inventory_product_id=inventory_product_id,
|
|
|
|
|
insights_generated=len(insights)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return patterns
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"Error getting demand patterns from sales service",
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
inventory_product_id=inventory_product_id,
|
|
|
|
|
error=str(e),
|
|
|
|
|
exc_info=True
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
'analyzed_at': datetime.utcnow().isoformat(),
|
|
|
|
|
'history_days': 0,
|
|
|
|
|
'insights': [],
|
|
|
|
|
'patterns': {},
|
|
|
|
|
'trend_analysis': {},
|
|
|
|
|
'seasonal_factors': {},
|
|
|
|
|
'statistics': {},
|
|
|
|
|
'error': str(e)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def _generate_insights_from_patterns(
|
|
|
|
|
self,
|
|
|
|
|
patterns: Dict[str, Any],
|
|
|
|
|
tenant_id: str,
|
|
|
|
|
inventory_product_id: str
|
|
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
"""
|
|
|
|
|
Generate actionable insights from demand patterns provided by sales service.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
patterns: Demand patterns from sales service
|
|
|
|
|
tenant_id: Tenant identifier
|
|
|
|
|
inventory_product_id: Product identifier
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
List of insights for AI Insights Service
|
|
|
|
|
"""
|
|
|
|
|
insights = []
|
|
|
|
|
|
|
|
|
|
# Check if there was an error in pattern analysis
|
|
|
|
|
if 'error' in patterns:
|
|
|
|
|
return insights
|
|
|
|
|
|
|
|
|
|
trend = patterns.get('trend_analysis', {})
|
|
|
|
|
stats = patterns.get('statistics', {})
|
|
|
|
|
seasonal = patterns.get('seasonal_factors', {})
|
|
|
|
|
|
|
|
|
|
# Trend insight
|
|
|
|
|
if trend.get('is_increasing'):
|
|
|
|
|
insights.append({
|
|
|
|
|
'type': 'insight',
|
|
|
|
|
'priority': 'medium',
|
|
|
|
|
'category': 'forecasting',
|
|
|
|
|
'title': 'Increasing Demand Trend Detected',
|
|
|
|
|
'description': f"Product shows {trend.get('direction', 'increasing')} demand trend. Consider increasing inventory levels.",
|
|
|
|
|
'impact_type': 'demand_increase',
|
|
|
|
|
'impact_value': abs(trend.get('correlation', 0) * 100),
|
|
|
|
|
'impact_unit': 'percent',
|
|
|
|
|
'confidence': min(int(abs(trend.get('correlation', 0)) * 100), 95),
|
|
|
|
|
'metrics_json': trend,
|
|
|
|
|
'actionable': True,
|
|
|
|
|
'recommendation_actions': [
|
|
|
|
|
{
|
|
|
|
|
'label': 'Increase Safety Stock',
|
|
|
|
|
'action': 'increase_safety_stock',
|
|
|
|
|
'params': {'product_id': inventory_product_id, 'factor': 1.2}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
elif trend.get('is_decreasing'):
|
|
|
|
|
insights.append({
|
|
|
|
|
'type': 'insight',
|
|
|
|
|
'priority': 'low',
|
|
|
|
|
'category': 'forecasting',
|
|
|
|
|
'title': 'Decreasing Demand Trend Detected',
|
|
|
|
|
'description': f"Product shows {trend.get('direction', 'decreasing')} demand trend. Consider reviewing inventory strategy.",
|
|
|
|
|
'impact_type': 'demand_decrease',
|
|
|
|
|
'impact_value': abs(trend.get('correlation', 0) * 100),
|
|
|
|
|
'impact_unit': 'percent',
|
|
|
|
|
'confidence': min(int(abs(trend.get('correlation', 0)) * 100), 95),
|
|
|
|
|
'metrics_json': trend,
|
|
|
|
|
'actionable': True,
|
|
|
|
|
'recommendation_actions': [
|
|
|
|
|
{
|
|
|
|
|
'label': 'Review Inventory Levels',
|
|
|
|
|
'action': 'review_inventory',
|
|
|
|
|
'params': {'product_id': inventory_product_id}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Volatility insight
|
|
|
|
|
cv = stats.get('coefficient_of_variation', 0)
|
|
|
|
|
if cv > 0.5:
|
|
|
|
|
insights.append({
|
|
|
|
|
'type': 'alert',
|
|
|
|
|
'priority': 'medium',
|
|
|
|
|
'category': 'forecasting',
|
|
|
|
|
'title': 'High Demand Variability Detected',
|
|
|
|
|
'description': f'Product has high demand variability (CV: {cv:.2f}). Consider dynamic safety stock levels.',
|
|
|
|
|
'impact_type': 'demand_variability',
|
|
|
|
|
'impact_value': round(cv * 100, 1),
|
|
|
|
|
'impact_unit': 'percent',
|
|
|
|
|
'confidence': 85,
|
|
|
|
|
'metrics_json': stats,
|
|
|
|
|
'actionable': True,
|
|
|
|
|
'recommendation_actions': [
|
|
|
|
|
{
|
|
|
|
|
'label': 'Enable Dynamic Safety Stock',
|
|
|
|
|
'action': 'enable_dynamic_safety_stock',
|
|
|
|
|
'params': {'product_id': inventory_product_id}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Seasonal pattern insight
|
|
|
|
|
peak_ratio = seasonal.get('peak_ratio', 1.0)
|
|
|
|
|
if peak_ratio > 1.5:
|
|
|
|
|
pattern_data = patterns.get('patterns', {})
|
|
|
|
|
peak_day = pattern_data.get('peak_day', 0)
|
|
|
|
|
low_day = pattern_data.get('low_day', 0)
|
|
|
|
|
insights.append({
|
|
|
|
|
'type': 'insight',
|
|
|
|
|
'priority': 'medium',
|
|
|
|
|
'category': 'forecasting',
|
|
|
|
|
'title': 'Strong Weekly Pattern Detected',
|
|
|
|
|
'description': f'Demand is {peak_ratio:.1f}x higher on day {peak_day} compared to day {low_day}. Adjust production schedule accordingly.',
|
|
|
|
|
'impact_type': 'seasonal_pattern',
|
|
|
|
|
'impact_value': round((peak_ratio - 1) * 100, 1),
|
|
|
|
|
'impact_unit': 'percent',
|
|
|
|
|
'confidence': 80,
|
|
|
|
|
'metrics_json': {**seasonal, **pattern_data},
|
|
|
|
|
'actionable': True,
|
|
|
|
|
'recommendation_actions': [
|
|
|
|
|
{
|
|
|
|
|
'label': 'Adjust Production Schedule',
|
|
|
|
|
'action': 'adjust_production',
|
|
|
|
|
'params': {'product_id': inventory_product_id, 'pattern': 'weekly'}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return insights
|
|
|
|
|
|
2025-11-05 13:34:56 +01:00
|
|
|
async def _get_dynamic_rules(self, tenant_id: str, inventory_product_id: str, rule_type: str) -> Dict[str, float]:
|
|
|
|
|
"""
|
|
|
|
|
Fetch learned dynamic rules from AI Insights Service.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant UUID
|
|
|
|
|
inventory_product_id: Product UUID
|
|
|
|
|
rule_type: Type of rules (weather, temporal, holiday, etc.)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary of learned rules with factors
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
# Fetch latest rules insight for this product
|
|
|
|
|
insights = await self.ai_insights_client.get_insights(
|
|
|
|
|
tenant_id=UUID(tenant_id),
|
|
|
|
|
filters={
|
|
|
|
|
'category': 'forecasting',
|
|
|
|
|
'actionable_only': False,
|
|
|
|
|
'page_size': 100
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not insights or 'items' not in insights:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
# Find the most recent rules insight for this product
|
|
|
|
|
for insight in insights['items']:
|
|
|
|
|
if insight.get('source_model') == 'dynamic_rules_engine':
|
|
|
|
|
metrics = insight.get('metrics_json', {})
|
|
|
|
|
if metrics.get('inventory_product_id') == inventory_product_id:
|
|
|
|
|
rules_data = metrics.get('rules', {})
|
|
|
|
|
return rules_data.get(rule_type, {})
|
|
|
|
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to fetch dynamic rules: {e}")
|
|
|
|
|
return {}
|
|
|
|
|
|
2025-12-16 13:32:33 +01:00
|
|
|
async def generate_forecast_with_repository(self, tenant_id: str, inventory_product_id: str,
|
|
|
|
|
forecast_date: date, model_id: str = None) -> Dict[str, Any]:
|
|
|
|
|
"""Generate forecast with repository integration"""
|
|
|
|
|
try:
|
|
|
|
|
# This would integrate with repositories for model loading and caching
|
|
|
|
|
# For now, we'll implement basic forecasting logic using the forecaster's methods
|
|
|
|
|
# This is a simplified approach - in production, this would use repositories
|
|
|
|
|
|
|
|
|
|
# For now, prepare minimal features for prediction
|
|
|
|
|
features = {
|
|
|
|
|
'date': forecast_date.isoformat(),
|
|
|
|
|
'day_of_week': forecast_date.weekday(),
|
|
|
|
|
'is_weekend': 1 if forecast_date.weekday() >= 5 else 0,
|
|
|
|
|
'is_holiday': 0, # Would come from calendar service in real implementation
|
|
|
|
|
# Add default weather values if needed
|
|
|
|
|
'temperature': 20.0,
|
|
|
|
|
'precipitation': 0.0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# This is a placeholder - in a full implementation, we would:
|
|
|
|
|
# 1. Load the appropriate model from repository
|
|
|
|
|
# 2. Use historical data to make prediction
|
|
|
|
|
# 3. Apply business rules
|
|
|
|
|
# For now, return the structure with basic info
|
|
|
|
|
|
|
|
|
|
# For more realistic implementation, we'd use self.predict_demand method
|
|
|
|
|
# but that requires a model object which needs to be loaded
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"tenant_id": tenant_id,
|
|
|
|
|
"inventory_product_id": inventory_product_id,
|
|
|
|
|
"forecast_date": forecast_date.isoformat(),
|
|
|
|
|
"prediction": 10.0, # Placeholder value - in reality would be calculated
|
|
|
|
|
"confidence_interval": {"lower": 8.0, "upper": 12.0}, # Placeholder values
|
|
|
|
|
"status": "completed",
|
|
|
|
|
"repository_integration": True,
|
|
|
|
|
"forecast_method": "placeholder"
|
|
|
|
|
}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Forecast generation failed", error=str(e))
|
|
|
|
|
raise
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
|
|
|
|
|
class BakeryBusinessRules:
|
|
|
|
|
"""
|
|
|
|
|
Business rules for Spanish bakeries
|
|
|
|
|
Applies domain-specific adjustments to predictions
|
2025-11-05 13:34:56 +01:00
|
|
|
Supports both dynamic learned rules and hardcoded fallbacks
|
2025-07-21 19:48:56 +02:00
|
|
|
"""
|
2025-11-05 13:34:56 +01:00
|
|
|
|
|
|
|
|
def __init__(self, use_dynamic_rules=False, ai_insights_client=None):
|
|
|
|
|
self.use_dynamic_rules = use_dynamic_rules
|
|
|
|
|
self.ai_insights_client = ai_insights_client
|
|
|
|
|
self.rules_cache = {}
|
|
|
|
|
|
|
|
|
|
async def apply_rules(self, prediction: Dict[str, float], features: Dict[str, Any],
|
|
|
|
|
business_type: str, tenant_id: str = None, inventory_product_id: str = None) -> Dict[str, float]:
|
|
|
|
|
"""Apply all business rules to prediction (dynamic or hardcoded)"""
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
adjusted_prediction = prediction.copy()
|
2025-11-05 13:34:56 +01:00
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
# Apply weather rules
|
2025-11-05 13:34:56 +01:00
|
|
|
adjusted_prediction = await self._apply_weather_rules(
|
|
|
|
|
adjusted_prediction, features, tenant_id, inventory_product_id
|
|
|
|
|
)
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
# Apply time-based rules
|
2025-11-05 13:34:56 +01:00
|
|
|
adjusted_prediction = await self._apply_time_rules(
|
|
|
|
|
adjusted_prediction, features, tenant_id, inventory_product_id
|
|
|
|
|
)
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
# Apply business type rules
|
|
|
|
|
adjusted_prediction = self._apply_business_type_rules(adjusted_prediction, business_type)
|
2025-11-05 13:34:56 +01:00
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
# Apply Spanish-specific rules
|
|
|
|
|
adjusted_prediction = self._apply_spanish_rules(adjusted_prediction, features)
|
2025-11-05 13:34:56 +01:00
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
return adjusted_prediction
|
2025-11-05 13:34:56 +01:00
|
|
|
|
|
|
|
|
async def _get_dynamic_rules(self, tenant_id: str, inventory_product_id: str, rule_type: str) -> Dict[str, float]:
|
|
|
|
|
"""
|
|
|
|
|
Fetch learned dynamic rules from AI Insights Service.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
tenant_id: Tenant UUID
|
|
|
|
|
inventory_product_id: Product UUID
|
|
|
|
|
rule_type: Type of rules (weather, temporal, holiday, etc.)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary of learned rules with factors
|
|
|
|
|
"""
|
|
|
|
|
# Check cache first
|
|
|
|
|
cache_key = f"{tenant_id}:{inventory_product_id}:{rule_type}"
|
|
|
|
|
if cache_key in self.rules_cache:
|
|
|
|
|
return self.rules_cache[cache_key]
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
if not self.ai_insights_client:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
# Fetch latest rules insight for this product
|
|
|
|
|
insights = await self.ai_insights_client.get_insights(
|
|
|
|
|
tenant_id=UUID(tenant_id),
|
|
|
|
|
filters={
|
|
|
|
|
'category': 'forecasting',
|
|
|
|
|
'actionable_only': False,
|
|
|
|
|
'page_size': 100
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not insights or 'items' not in insights:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
# Find the most recent rules insight for this product
|
|
|
|
|
for insight in insights['items']:
|
|
|
|
|
if insight.get('source_model') == 'dynamic_rules_engine':
|
|
|
|
|
metrics = insight.get('metrics_json', {})
|
|
|
|
|
if metrics.get('inventory_product_id') == inventory_product_id:
|
|
|
|
|
rules_data = metrics.get('rules', {})
|
|
|
|
|
result = rules_data.get(rule_type, {})
|
|
|
|
|
# Cache the result
|
|
|
|
|
self.rules_cache[cache_key] = result
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to fetch dynamic rules: {e}")
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
async def _apply_weather_rules(self, prediction: Dict[str, float],
|
|
|
|
|
features: Dict[str, Any],
|
|
|
|
|
tenant_id: str = None,
|
|
|
|
|
inventory_product_id: str = None) -> Dict[str, float]:
|
|
|
|
|
"""Apply weather-based business rules (dynamic or hardcoded fallback)"""
|
|
|
|
|
|
|
|
|
|
if self.use_dynamic_rules and tenant_id and inventory_product_id:
|
|
|
|
|
try:
|
|
|
|
|
# Fetch dynamic weather rules
|
|
|
|
|
rules = await self._get_dynamic_rules(tenant_id, inventory_product_id, 'weather')
|
|
|
|
|
|
|
|
|
|
# Apply learned rain impact
|
|
|
|
|
precipitation = features.get('precipitation', 0)
|
|
|
|
|
if precipitation > 0:
|
|
|
|
|
rain_factor = rules.get('rain_factor', settings.RAIN_IMPACT_FACTOR)
|
|
|
|
|
prediction["yhat"] *= rain_factor
|
|
|
|
|
prediction["yhat_lower"] *= rain_factor
|
|
|
|
|
prediction["yhat_upper"] *= rain_factor
|
|
|
|
|
|
|
|
|
|
# Apply learned temperature impact
|
|
|
|
|
temperature = features.get('temperature')
|
|
|
|
|
if temperature is not None:
|
|
|
|
|
if temperature > settings.TEMPERATURE_THRESHOLD_HOT:
|
|
|
|
|
hot_factor = rules.get('temperature_hot_factor', 0.9)
|
|
|
|
|
prediction["yhat"] *= hot_factor
|
|
|
|
|
elif temperature < settings.TEMPERATURE_THRESHOLD_COLD:
|
|
|
|
|
cold_factor = rules.get('temperature_cold_factor', 1.1)
|
|
|
|
|
prediction["yhat"] *= cold_factor
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to apply dynamic weather rules, using fallback: {e}")
|
|
|
|
|
# Fallback to hardcoded
|
|
|
|
|
precipitation = features.get('precipitation', 0)
|
|
|
|
|
if precipitation > 0:
|
|
|
|
|
prediction["yhat"] *= settings.RAIN_IMPACT_FACTOR
|
|
|
|
|
prediction["yhat_lower"] *= settings.RAIN_IMPACT_FACTOR
|
|
|
|
|
prediction["yhat_upper"] *= settings.RAIN_IMPACT_FACTOR
|
|
|
|
|
|
|
|
|
|
temperature = features.get('temperature')
|
|
|
|
|
if temperature is not None:
|
|
|
|
|
if temperature > settings.TEMPERATURE_THRESHOLD_HOT:
|
|
|
|
|
prediction["yhat"] *= 0.9
|
|
|
|
|
elif temperature < settings.TEMPERATURE_THRESHOLD_COLD:
|
|
|
|
|
prediction["yhat"] *= 1.1
|
|
|
|
|
else:
|
|
|
|
|
# Use hardcoded rules
|
|
|
|
|
precipitation = features.get('precipitation', 0)
|
|
|
|
|
if precipitation > 0:
|
|
|
|
|
rain_factor = settings.RAIN_IMPACT_FACTOR
|
|
|
|
|
prediction["yhat"] *= rain_factor
|
|
|
|
|
prediction["yhat_lower"] *= rain_factor
|
|
|
|
|
prediction["yhat_upper"] *= rain_factor
|
|
|
|
|
|
|
|
|
|
temperature = features.get('temperature')
|
|
|
|
|
if temperature is not None:
|
|
|
|
|
if temperature > settings.TEMPERATURE_THRESHOLD_HOT:
|
|
|
|
|
prediction["yhat"] *= 0.9
|
|
|
|
|
elif temperature < settings.TEMPERATURE_THRESHOLD_COLD:
|
|
|
|
|
prediction["yhat"] *= 1.1
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
return prediction
|
|
|
|
|
|
2025-11-05 13:34:56 +01:00
|
|
|
async def _apply_time_rules(self, prediction: Dict[str, float],
|
|
|
|
|
features: Dict[str, Any],
|
|
|
|
|
tenant_id: str = None,
|
|
|
|
|
inventory_product_id: str = None) -> Dict[str, float]:
|
|
|
|
|
"""Apply time-based business rules (dynamic or hardcoded fallback)"""
|
|
|
|
|
|
|
|
|
|
if self.use_dynamic_rules and tenant_id and inventory_product_id:
|
|
|
|
|
try:
|
|
|
|
|
# Fetch dynamic temporal rules
|
|
|
|
|
rules = await self._get_dynamic_rules(tenant_id, inventory_product_id, 'temporal')
|
|
|
|
|
|
|
|
|
|
# Apply learned weekend adjustment
|
|
|
|
|
if features.get('is_weekend', False):
|
|
|
|
|
weekend_factor = rules.get('weekend_factor', settings.WEEKEND_ADJUSTMENT_FACTOR)
|
|
|
|
|
prediction["yhat"] *= weekend_factor
|
|
|
|
|
prediction["yhat_lower"] *= weekend_factor
|
|
|
|
|
prediction["yhat_upper"] *= weekend_factor
|
|
|
|
|
|
|
|
|
|
# Apply learned holiday adjustment
|
|
|
|
|
if features.get('is_holiday', False):
|
|
|
|
|
holiday_factor = rules.get('holiday_factor', settings.HOLIDAY_ADJUSTMENT_FACTOR)
|
|
|
|
|
prediction["yhat"] *= holiday_factor
|
|
|
|
|
prediction["yhat_lower"] *= holiday_factor
|
|
|
|
|
prediction["yhat_upper"] *= holiday_factor
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Failed to apply dynamic time rules, using fallback: {e}")
|
|
|
|
|
# Fallback to hardcoded
|
|
|
|
|
if features.get('is_weekend', False):
|
|
|
|
|
prediction["yhat"] *= settings.WEEKEND_ADJUSTMENT_FACTOR
|
|
|
|
|
prediction["yhat_lower"] *= settings.WEEKEND_ADJUSTMENT_FACTOR
|
|
|
|
|
prediction["yhat_upper"] *= settings.WEEKEND_ADJUSTMENT_FACTOR
|
|
|
|
|
|
|
|
|
|
if features.get('is_holiday', False):
|
|
|
|
|
prediction["yhat"] *= settings.HOLIDAY_ADJUSTMENT_FACTOR
|
|
|
|
|
prediction["yhat_lower"] *= settings.HOLIDAY_ADJUSTMENT_FACTOR
|
|
|
|
|
prediction["yhat_upper"] *= settings.HOLIDAY_ADJUSTMENT_FACTOR
|
|
|
|
|
else:
|
|
|
|
|
# Use hardcoded rules
|
|
|
|
|
if features.get('is_weekend', False):
|
|
|
|
|
weekend_factor = settings.WEEKEND_ADJUSTMENT_FACTOR
|
|
|
|
|
prediction["yhat"] *= weekend_factor
|
|
|
|
|
prediction["yhat_lower"] *= weekend_factor
|
|
|
|
|
prediction["yhat_upper"] *= weekend_factor
|
|
|
|
|
|
|
|
|
|
if features.get('is_holiday', False):
|
|
|
|
|
holiday_factor = settings.HOLIDAY_ADJUSTMENT_FACTOR
|
|
|
|
|
prediction["yhat"] *= holiday_factor
|
|
|
|
|
prediction["yhat_lower"] *= holiday_factor
|
|
|
|
|
prediction["yhat_upper"] *= holiday_factor
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
return prediction
|
|
|
|
|
|
|
|
|
|
def _apply_business_type_rules(self, prediction: Dict[str, float],
|
|
|
|
|
business_type: str) -> Dict[str, float]:
|
|
|
|
|
"""Apply business type specific rules"""
|
|
|
|
|
|
|
|
|
|
if business_type == "central_workshop":
|
|
|
|
|
# Central workshops have more stable demand
|
|
|
|
|
uncertainty_reduction = 0.8
|
|
|
|
|
center = prediction["yhat"]
|
|
|
|
|
lower = prediction["yhat_lower"]
|
|
|
|
|
upper = prediction["yhat_upper"]
|
|
|
|
|
|
|
|
|
|
# Reduce uncertainty band
|
|
|
|
|
new_range = (upper - lower) * uncertainty_reduction
|
|
|
|
|
prediction["yhat_lower"] = center - (new_range / 2)
|
|
|
|
|
prediction["yhat_upper"] = center + (new_range / 2)
|
|
|
|
|
|
|
|
|
|
return prediction
|
|
|
|
|
|
2025-12-16 13:32:33 +01:00
|
|
|
def _apply_spanish_rules(self, prediction: Dict[str, float],
|
2025-07-21 19:48:56 +02:00
|
|
|
features: Dict[str, Any]) -> Dict[str, float]:
|
|
|
|
|
"""Apply Spanish bakery specific rules"""
|
2025-12-16 13:32:33 +01:00
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
# Spanish siesta time considerations
|
2025-12-16 13:32:33 +01:00
|
|
|
date_str = features.get('date')
|
|
|
|
|
if date_str:
|
|
|
|
|
try:
|
|
|
|
|
current_date = pd.to_datetime(date_str)
|
|
|
|
|
day_of_week = current_date.weekday()
|
|
|
|
|
|
|
|
|
|
# Reduced activity during typical siesta hours (14:00-17:00)
|
|
|
|
|
# This affects afternoon sales planning
|
|
|
|
|
if day_of_week < 5: # Weekdays
|
|
|
|
|
prediction["yhat"] *= 0.95 # Slight reduction for siesta effect
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Error processing date in spanish rules: {e}")
|
|
|
|
|
else:
|
|
|
|
|
logger.warning("Date not provided in features, skipping Spanish rules")
|
|
|
|
|
|
2025-07-21 19:48:56 +02:00
|
|
|
return prediction
|