Loading .gitignore +2 −1 Original line number Diff line number Diff line Loading @@ -2,3 +2,4 @@ /archives /supply_chain_sim.egg-info /venv *.pyc No newline at end of file supply_chain_sim/customer_behavior.py +66 −91 Original line number Diff line number Diff line Loading @@ -18,10 +18,10 @@ class CustomerBehavior: def sample_customers_for_timestep(self, n_customers: int = None) -> List[Customer]: """Sample customers who will shop in this timestep.""" if n_customers is None: # Sample 5-10% of customers per timestep n_customers = int(len(self.setup.customers) * np.random.uniform(0.05, 0.1)) # Sample 10-20% of customers per timestep (increased from 5-10%) n_customers = max(10, int(len(self.setup.customers) * np.random.uniform(0.1, 0.2))) sampled = np.random.choice(self.setup.customers, n_customers, replace=False) sampled = np.random.choice(self.setup.customers, min(n_customers, len(self.setup.customers)), replace=False) logger.debug(f"Sampled {len(sampled)} customers for shopping") return sampled Loading @@ -31,37 +31,15 @@ class CustomerBehavior: if not watch: return 0 # Create watch profile (simplified - could be more sophisticated) watch_profile = self._get_watch_profile(watch) # Dot product of taste vectors match_score = np.dot(customer.taste_profile, watch_profile) # Normalize to 0-1 match_score = 1 / (1 + np.exp(-match_score)) return match_score def _get_watch_profile(self, watch) -> np.ndarray: """Generate a taste profile for a watch.""" np.random.seed(watch.id) # Consistent profile per watch profile = np.zeros(5) if watch.category == 'luxury': profile[0] = 2.0 # Prestige profile[1] = 1.5 # Quality elif watch.category == 'sport': profile[2] = 2.0 # Functionality profile[3] = 1.5 # Durability else: # casual profile[4] = 2.0 # Style profile[1] = 1.0 # Quality # Add some randomness profile += np.random.normal(0, 0.2, 5) return profile # Simplified preference match - just use category preference if watch.category == 'luxury' and customer.wealth_class >= 7: return 0.8 elif watch.category == 'sport' and customer.wealth_class >= 4: return 0.7 elif watch.category == 'casual': return 0.6 else: return 0.4 def evaluate_purchase_probability(self, customer: Customer, watch_id: int, event_modifiers: dict) -> float: Loading @@ -70,31 +48,30 @@ class CustomerBehavior: if not watch: return 0 # Components of purchase decision # Simplified purchase probability calculation match_score = self.compute_preference_match(customer, watch_id) # Brand awareness factor brand_awareness = customer.brand_awareness # Budget fit - much more lenient budget_fit = 1.0 if watch.base_price <= customer.budget else max(0.1, customer.budget / watch.base_price) # Budget fit (afford ability) budget_fit = 1.0 if watch.base_price <= customer.budget else 0.3 # Brand awareness factor brand_awareness = min(1.0, customer.brand_awareness + 0.3) # Base awareness boost # Event influence event_influence = event_modifiers.get('demand_multiplier', 1.0) - 1.0 event_influence *= customer.event_responsiveness event_boost = event_modifiers.get('demand_multiplier', 1.0) - 1.0 event_influence = event_boost * customer.event_responsiveness # Combine factors using logistic function α, β, γ, δ = 0.3, 0.2, 0.4, 0.1 # Weights # Much simpler combination - base probability of 0.3 base_prob = 0.3 linear_combination = ( α * match_score + β * brand_awareness + γ * budget_fit + δ * event_influence ) # Multiply by factors probability = base_prob * match_score * budget_fit * brand_awareness # Add event influence probability += event_influence * 0.1 # Apply sigmoid probability = 1 / (1 + np.exp(-3 * (linear_combination - 0.5))) # Cap at reasonable maximum probability = min(0.8, max(0.0, probability)) return probability Loading @@ -112,7 +89,8 @@ class CustomerBehavior: region_retailers = [r for r in self.setup.retailers if r.region == customer.region] if not region_retailers: continue # If no regional retailers, use any retailer region_retailers = self.setup.retailers # Choose a retailer to visit retailer = np.random.choice(region_retailers) Loading @@ -125,20 +103,16 @@ class CustomerBehavior: if not available_watches: continue # Evaluate each available watch best_watch = None best_prob = 0 # Try to buy something - check each available watch for watch_id, quantity in available_watches: if quantity <= 0: continue for watch_id, _ in available_watches: prob = self.evaluate_purchase_probability(customer, watch_id, event_modifiers) if prob > best_prob: best_prob = prob best_watch = watch_id # Make purchase decision if best_watch and np.random.random() < best_prob: watch = next((w for w in self.setup.watches if w.id == best_watch), None) if np.random.random() < prob: watch = next((w for w in self.setup.watches if w.id == watch_id), None) if watch: # Create sale Loading @@ -146,7 +120,7 @@ class CustomerBehavior: id=self.sale_id_counter, customer_id=customer.id, retailer_id=retailer.id, watch_id=best_watch, watch_id=watch_id, quantity=1, total_price=watch.base_price * retailer.markup_policy, date=datetime.now() Loading @@ -157,11 +131,12 @@ class CustomerBehavior: self.sale_id_counter += 1 # Deduct from inventory key = (retailer.id, best_watch) key = (retailer.id, watch_id) if key in inv_map: inv_map[key].quantity -= 1 logger.debug(f"Customer {customer.id} purchased watch {best_watch}") logger.debug(f"Customer {customer.id} purchased watch {watch_id} for ${sale.total_price:.0f}") break # Customer only buys one watch per visit logger.info(f"Processed {len(new_sales)} sales") return new_sales, retailer_inventories Loading @@ -175,12 +150,12 @@ class CustomerBehavior: inv_map = {(inv.retailer_id, inv.watch_id): inv for inv in retailer_inventories} for sale in recent_sales: # Get return rate for this retailer ret_inv = inv_map.get((sale.retailer_id, sale.watch_id)) if ret_inv and np.random.random() < ret_inv.return_rate: # Use a simple 5% return rate if np.random.random() < 0.05: # Process return ret_inv.quantity += sale.quantity key = (sale.retailer_id, sale.watch_id) if key in inv_map: inv_map[key].quantity += sale.quantity returns.append(sale) logger.debug(f"Return processed for sale {sale.id}") Loading supply_chain_sim/forecasting.py +73 −16 Original line number Diff line number Diff line Loading @@ -32,22 +32,19 @@ class Forecasting: for watch in self.setup.watches: for region in regions: # Generate synthetic forecast (will use ML when enough data) if self.is_trained and len(self.historical_sales) > 100: # Generate realistic forecast based on watch category and price if self.is_trained and len(self.historical_sales) > 50: demand = self._ml_predict(watch.id, region, horizon_days) else: # Initial synthetic demand based on watch category and price base_demand = 100 / (1 + watch.base_price / 1000) seasonality = np.sin(datetime.now().timetuple().tm_yday / 365 * 2 * np.pi) noise = np.random.normal(0, 5) demand = max(0, base_demand + seasonality * 10 + noise) # More realistic initial demand based on watch characteristics base_demand = self._calculate_realistic_demand(watch, region) forecasts.append(Forecast( id=forecast_id, brand_id=watch.brand_id, watch_id=watch.id, region=region, forecasted_demand=demand, forecasted_demand=max(0, base_demand), date=datetime.now() )) forecast_id += 1 Loading @@ -56,6 +53,37 @@ class Forecasting: logger.info(f"Generated {len(forecasts)} demand forecasts") return forecasts def _calculate_realistic_demand(self, watch, region: str) -> float: """Calculate realistic demand based on watch characteristics.""" # Base demand starts lower and more realistic if watch.category == 'luxury': base_demand = np.random.uniform(5, 15) # Luxury has lower volume elif watch.category == 'sport': base_demand = np.random.uniform(10, 25) # Sport has medium volume else: # casual base_demand = np.random.uniform(15, 30) # Casual has higher volume # Price sensitivity - higher prices reduce demand price_factor = max(0.3, 1.0 - (watch.base_price - 200) / 2000) base_demand *= price_factor # Regional variation regional_factors = { 'North': 1.2, 'South': 0.9, 'East': 1.1, 'West': 1.0 } base_demand *= regional_factors.get(region, 1.0) # Add some seasonality and randomness seasonality = 1 + 0.2 * np.sin(datetime.now().timetuple().tm_yday / 365 * 2 * np.pi) noise = np.random.normal(0, 2) final_demand = base_demand * seasonality + noise return max(1, final_demand) # Minimum demand of 1 def _ml_predict(self, watch_id: int, region: str, horizon: int) -> float: """Use ML model for prediction when enough data exists.""" # Create features from historical data Loading @@ -63,9 +91,14 @@ class Forecasting: if features is not None: prediction = self.model.predict(features.reshape(1, -1))[0] return max(0, prediction) return max(1, prediction) # Fallback to watch characteristics watch = next((w for w in self.setup.watches if w.id == watch_id), None) if watch: return self._calculate_realistic_demand(watch, region) return np.random.uniform(10, 50) # Fallback return np.random.uniform(5, 20) def _extract_features(self, watch_id: int, region: str) -> Optional[np.ndarray]: """Extract features for ML prediction.""" Loading @@ -73,7 +106,7 @@ class Forecasting: return None # Simple feature extraction (can be enhanced) recent_sales = [s for s in self.historical_sales[-100:] recent_sales = [s for s in self.historical_sales[-50:] if s.watch_id == watch_id] if not recent_sales: Loading @@ -94,7 +127,7 @@ class Forecasting: def train_model(self): """Train the forecasting model with historical data.""" if len(self.historical_sales) < 50: if len(self.historical_sales) < 20: logger.warning("Not enough data to train model") return Loading @@ -114,21 +147,45 @@ class Forecasting: # Aggregate sales by watch and region df = pd.DataFrame([{ 'watch_id': s.watch_id, 'region': self.setup.retailers[s.retailer_id % len(self.setup.retailers)].region, 'region': self._get_region_for_sale(s), 'quantity': s.quantity, 'price': s.total_price, 'date': s.date } for s in self.historical_sales]) # This is simplified - real implementation would be more sophisticated return None, None # Placeholder # Group by watch_id and region, sum quantities grouped = df.groupby(['watch_id', 'region'])['quantity'].sum().reset_index() if len(grouped) < 5: return None, None X = [] y = [] for _, row in grouped.iterrows(): features = [ row['watch_id'], row['quantity'], 1 if row['region'] == 'North' else 0, 1 if row['region'] == 'South' else 0, 1 if row['region'] == 'East' else 0, 1 if row['region'] == 'West' else 0, ] X.append(features) y.append(row['quantity']) return np.array(X), np.array(y) def _get_region_for_sale(self, sale: Sale) -> str: """Get region for a sale based on retailer.""" retailer = next((r for r in self.setup.retailers if r.id == sale.retailer_id), None) return retailer.region if retailer else 'North' def get_uncertainty_intervals(self) -> Dict: """Estimate uncertainty intervals for forecasts.""" intervals = {} for forecast in self.forecasts: # Simple uncertainty estimation std_dev = forecast.forecasted_demand * 0.2 std_dev = forecast.forecasted_demand * 0.3 intervals[forecast.id] = { 'lower': max(0, forecast.forecasted_demand - 1.96 * std_dev), 'upper': forecast.forecasted_demand + 1.96 * std_dev Loading supply_chain_sim/simulation.py +21 −2 Original line number Diff line number Diff line Loading @@ -118,6 +118,7 @@ class SupplyChainSimulation: # ========== DEMAND FORECASTING ========== print("→ Forecasting demand...") forecasts = self.forecasting.predict_demand(horizon_days=30) self.all_forecasts.extend(forecasts) # Collect for final report print(f" ✓ Generated {len(forecasts)} demand forecasts") # ========== SUPPLY CHAIN PLANNING ========== Loading @@ -127,6 +128,7 @@ class SupplyChainSimulation: self.supply_order = SupplyOrderLogic(self.setup, forecasts) supply_orders = self.supply_order.generate_orders(self.warehouse_inventories) self.all_orders.extend(supply_orders) # Collect for final report print(f" ✓ Generated {len(supply_orders)} supply orders") # ========== EVENT & CAMPAIGN ENGINE ========== Loading Loading @@ -161,6 +163,7 @@ class SupplyChainSimulation: self.warehouse_inventories, assembled_count = self.assembly.assemble_watches( self.warehouse_inventories, assembly_targets ) self.total_assembled += assembled_count # Collect for final report print(f" ✓ Assembled {assembled_count} watches") # Update inventory age Loading @@ -185,6 +188,7 @@ class SupplyChainSimulation: new_sales, self.retailer_inventories ) self.all_sales.extend(new_sales) # Collect for final report conversion_rate = self.customer_behavior.calculate_conversion_rate(len(sampled_customers)) sales_summary = self.customer_behavior.get_sales_summary() print(f" ✓ {len(new_sales)} sales, conversion rate: {conversion_rate:.1%}") Loading Loading @@ -225,8 +229,23 @@ class SupplyChainSimulation: print(f" Customer segments: {model_summary['customer_segments']}") print(f" Forecast accuracy: {model_summary['forecast_accuracy']['accuracy']:.1%}") # Research logging final report self.research_logging.print_final_report() # Prepare simulation data for detailed report simulation_data = { 'all_forecasts': self.all_forecasts, 'all_sales': self.all_sales, 'all_orders': self.all_orders, 'total_assembled': self.total_assembled, 'warehouse_inventories': self.warehouse_inventories, 'retailer_inventories': self.retailer_inventories, 'distribution_history': self.distribution.distribution_history, 'returns': self.customer_behavior.returns, 'watches': self.setup.watches, 'customers': self.setup.customers, 'components': self.setup.components } # Research logging final report with data self.research_logging.print_final_report(simulation_data) def run_oneshot_simulation(config: Dict = None): """Run a single simulation with the given configuration.""" Loading supply_chain_sim/synthetic_setup.py +38 −19 Original line number Diff line number Diff line Loading @@ -79,7 +79,7 @@ class SyntheticEntitySetup: id=i, name=f"Supplier_{i}", reliability_score=np.random.beta(5, 2), # Skewed towards reliable lead_time=np.random.normal(14, 3), # 2 weeks avg lead_time=max(1, np.random.normal(7, 2)), # 1 week avg, minimum 1 day capacity=np.random.randint(1000, 10000) )) return suppliers Loading @@ -92,7 +92,7 @@ class SyntheticEntitySetup: id=i, name=f"Component_{i}", type=types[i % len(types)], cost=np.random.uniform(10, 500), cost=np.random.uniform(10, 100), # Reduced component costs storage_rate=np.random.uniform(0.01, 0.1), shelf_life=np.random.randint(180, 720), quality_tier=np.random.randint(1, 6), Loading Loading @@ -134,8 +134,11 @@ class SyntheticEntitySetup: total_cost += comp.cost * quantity bom_id += 1 # Set watch price based on components + markup watch.base_price = total_cost * np.random.uniform(2.5, 4.0) # Set watch price based on components + markup (more reasonable prices) markup = np.random.uniform(2.0, 3.0) if watch.category == 'luxury': markup *= 2.0 # Luxury watches have higher markup watch.base_price = total_cost * markup return boms Loading @@ -160,8 +163,8 @@ class SyntheticEntitySetup: id=i, name=f"Retailer_{i}", region=regions[i % len(regions)], capacity=np.random.randint(100, 1000), markup_policy=np.random.uniform(1.2, 1.8) capacity=np.random.randint(200, 1000), # Increased capacity markup_policy=np.random.uniform(1.1, 1.3) # Reduced markup )) return retailers Loading @@ -170,22 +173,25 @@ class SyntheticEntitySetup: regions = ['North', 'South', 'East', 'West'] for i in range(n): # Wealth follows Pareto distribution wealth_class = min(10, int(np.random.pareto(2.5) + 1)) # Wealth follows Pareto distribution but more balanced wealth_class = min(10, max(1, int(np.random.pareto(1.5) + 1))) # Taste profile: 5D normal distribution taste_profile = np.random.normal(0, 1, 5).tolist() # Budget scaled by wealth budget = wealth_class * np.random.uniform(500, 5000) # Budget scaled by wealth - more generous budgets base_budget = wealth_class * np.random.uniform(200, 2000) # Add extra budget for higher wealth classes if wealth_class >= 7: base_budget *= np.random.uniform(2, 5) customers.append(Customer( id=i, wealth_class=wealth_class, taste_profile=taste_profile, region=regions[i % len(regions)], brand_awareness=np.random.uniform(0, 0.3), budget=budget, brand_awareness=np.random.uniform(0.1, 0.5), # Higher base awareness budget=base_budget, event_responsiveness=np.random.uniform(0.2, 1.0) )) Loading @@ -197,27 +203,40 @@ class SyntheticEntitySetup: retailer_inventories = [] inv_id = 0 # Initialize warehouse inventories with components # Initialize warehouse inventories with MORE components for warehouse in self.warehouses: for component in self.components[:10]: # Start with some components for component in self.components: # Start with ALL components warehouse_inventories.append(WarehouseInventory( id=inv_id, warehouse_id=warehouse.id, item_id=component.id, item_type="component", quantity=np.random.randint(50, 200) quantity=np.random.randint(100, 500) # More components )) inv_id += 1 # Initialize warehouse with some pre-assembled watches for warehouse in self.warehouses: for watch in self.watches: warehouse_inventories.append(WarehouseInventory( id=inv_id, warehouse_id=warehouse.id, item_id=watch.id, item_type="watch", quantity=np.random.randint(20, 100) # Start with assembled watches )) inv_id += 1 inv_id = 0 # Initialize retailer inventories with watches # Initialize retailer inventories with MORE watches for retailer in self.retailers: for watch in self.watches[:5]: # Start with some watches for watch in self.watches: # All watches available at all retailers retailer_inventories.append(RetailerInventory( id=inv_id, retailer_id=retailer.id, watch_id=watch.id, quantity=np.random.randint(5, 20) quantity=np.random.randint(10, 50), # More watches per retailer return_rate=0.05 )) inv_id += 1 Loading Loading
.gitignore +2 −1 Original line number Diff line number Diff line Loading @@ -2,3 +2,4 @@ /archives /supply_chain_sim.egg-info /venv *.pyc No newline at end of file
supply_chain_sim/customer_behavior.py +66 −91 Original line number Diff line number Diff line Loading @@ -18,10 +18,10 @@ class CustomerBehavior: def sample_customers_for_timestep(self, n_customers: int = None) -> List[Customer]: """Sample customers who will shop in this timestep.""" if n_customers is None: # Sample 5-10% of customers per timestep n_customers = int(len(self.setup.customers) * np.random.uniform(0.05, 0.1)) # Sample 10-20% of customers per timestep (increased from 5-10%) n_customers = max(10, int(len(self.setup.customers) * np.random.uniform(0.1, 0.2))) sampled = np.random.choice(self.setup.customers, n_customers, replace=False) sampled = np.random.choice(self.setup.customers, min(n_customers, len(self.setup.customers)), replace=False) logger.debug(f"Sampled {len(sampled)} customers for shopping") return sampled Loading @@ -31,37 +31,15 @@ class CustomerBehavior: if not watch: return 0 # Create watch profile (simplified - could be more sophisticated) watch_profile = self._get_watch_profile(watch) # Dot product of taste vectors match_score = np.dot(customer.taste_profile, watch_profile) # Normalize to 0-1 match_score = 1 / (1 + np.exp(-match_score)) return match_score def _get_watch_profile(self, watch) -> np.ndarray: """Generate a taste profile for a watch.""" np.random.seed(watch.id) # Consistent profile per watch profile = np.zeros(5) if watch.category == 'luxury': profile[0] = 2.0 # Prestige profile[1] = 1.5 # Quality elif watch.category == 'sport': profile[2] = 2.0 # Functionality profile[3] = 1.5 # Durability else: # casual profile[4] = 2.0 # Style profile[1] = 1.0 # Quality # Add some randomness profile += np.random.normal(0, 0.2, 5) return profile # Simplified preference match - just use category preference if watch.category == 'luxury' and customer.wealth_class >= 7: return 0.8 elif watch.category == 'sport' and customer.wealth_class >= 4: return 0.7 elif watch.category == 'casual': return 0.6 else: return 0.4 def evaluate_purchase_probability(self, customer: Customer, watch_id: int, event_modifiers: dict) -> float: Loading @@ -70,31 +48,30 @@ class CustomerBehavior: if not watch: return 0 # Components of purchase decision # Simplified purchase probability calculation match_score = self.compute_preference_match(customer, watch_id) # Brand awareness factor brand_awareness = customer.brand_awareness # Budget fit - much more lenient budget_fit = 1.0 if watch.base_price <= customer.budget else max(0.1, customer.budget / watch.base_price) # Budget fit (afford ability) budget_fit = 1.0 if watch.base_price <= customer.budget else 0.3 # Brand awareness factor brand_awareness = min(1.0, customer.brand_awareness + 0.3) # Base awareness boost # Event influence event_influence = event_modifiers.get('demand_multiplier', 1.0) - 1.0 event_influence *= customer.event_responsiveness event_boost = event_modifiers.get('demand_multiplier', 1.0) - 1.0 event_influence = event_boost * customer.event_responsiveness # Combine factors using logistic function α, β, γ, δ = 0.3, 0.2, 0.4, 0.1 # Weights # Much simpler combination - base probability of 0.3 base_prob = 0.3 linear_combination = ( α * match_score + β * brand_awareness + γ * budget_fit + δ * event_influence ) # Multiply by factors probability = base_prob * match_score * budget_fit * brand_awareness # Add event influence probability += event_influence * 0.1 # Apply sigmoid probability = 1 / (1 + np.exp(-3 * (linear_combination - 0.5))) # Cap at reasonable maximum probability = min(0.8, max(0.0, probability)) return probability Loading @@ -112,7 +89,8 @@ class CustomerBehavior: region_retailers = [r for r in self.setup.retailers if r.region == customer.region] if not region_retailers: continue # If no regional retailers, use any retailer region_retailers = self.setup.retailers # Choose a retailer to visit retailer = np.random.choice(region_retailers) Loading @@ -125,20 +103,16 @@ class CustomerBehavior: if not available_watches: continue # Evaluate each available watch best_watch = None best_prob = 0 # Try to buy something - check each available watch for watch_id, quantity in available_watches: if quantity <= 0: continue for watch_id, _ in available_watches: prob = self.evaluate_purchase_probability(customer, watch_id, event_modifiers) if prob > best_prob: best_prob = prob best_watch = watch_id # Make purchase decision if best_watch and np.random.random() < best_prob: watch = next((w for w in self.setup.watches if w.id == best_watch), None) if np.random.random() < prob: watch = next((w for w in self.setup.watches if w.id == watch_id), None) if watch: # Create sale Loading @@ -146,7 +120,7 @@ class CustomerBehavior: id=self.sale_id_counter, customer_id=customer.id, retailer_id=retailer.id, watch_id=best_watch, watch_id=watch_id, quantity=1, total_price=watch.base_price * retailer.markup_policy, date=datetime.now() Loading @@ -157,11 +131,12 @@ class CustomerBehavior: self.sale_id_counter += 1 # Deduct from inventory key = (retailer.id, best_watch) key = (retailer.id, watch_id) if key in inv_map: inv_map[key].quantity -= 1 logger.debug(f"Customer {customer.id} purchased watch {best_watch}") logger.debug(f"Customer {customer.id} purchased watch {watch_id} for ${sale.total_price:.0f}") break # Customer only buys one watch per visit logger.info(f"Processed {len(new_sales)} sales") return new_sales, retailer_inventories Loading @@ -175,12 +150,12 @@ class CustomerBehavior: inv_map = {(inv.retailer_id, inv.watch_id): inv for inv in retailer_inventories} for sale in recent_sales: # Get return rate for this retailer ret_inv = inv_map.get((sale.retailer_id, sale.watch_id)) if ret_inv and np.random.random() < ret_inv.return_rate: # Use a simple 5% return rate if np.random.random() < 0.05: # Process return ret_inv.quantity += sale.quantity key = (sale.retailer_id, sale.watch_id) if key in inv_map: inv_map[key].quantity += sale.quantity returns.append(sale) logger.debug(f"Return processed for sale {sale.id}") Loading
supply_chain_sim/forecasting.py +73 −16 Original line number Diff line number Diff line Loading @@ -32,22 +32,19 @@ class Forecasting: for watch in self.setup.watches: for region in regions: # Generate synthetic forecast (will use ML when enough data) if self.is_trained and len(self.historical_sales) > 100: # Generate realistic forecast based on watch category and price if self.is_trained and len(self.historical_sales) > 50: demand = self._ml_predict(watch.id, region, horizon_days) else: # Initial synthetic demand based on watch category and price base_demand = 100 / (1 + watch.base_price / 1000) seasonality = np.sin(datetime.now().timetuple().tm_yday / 365 * 2 * np.pi) noise = np.random.normal(0, 5) demand = max(0, base_demand + seasonality * 10 + noise) # More realistic initial demand based on watch characteristics base_demand = self._calculate_realistic_demand(watch, region) forecasts.append(Forecast( id=forecast_id, brand_id=watch.brand_id, watch_id=watch.id, region=region, forecasted_demand=demand, forecasted_demand=max(0, base_demand), date=datetime.now() )) forecast_id += 1 Loading @@ -56,6 +53,37 @@ class Forecasting: logger.info(f"Generated {len(forecasts)} demand forecasts") return forecasts def _calculate_realistic_demand(self, watch, region: str) -> float: """Calculate realistic demand based on watch characteristics.""" # Base demand starts lower and more realistic if watch.category == 'luxury': base_demand = np.random.uniform(5, 15) # Luxury has lower volume elif watch.category == 'sport': base_demand = np.random.uniform(10, 25) # Sport has medium volume else: # casual base_demand = np.random.uniform(15, 30) # Casual has higher volume # Price sensitivity - higher prices reduce demand price_factor = max(0.3, 1.0 - (watch.base_price - 200) / 2000) base_demand *= price_factor # Regional variation regional_factors = { 'North': 1.2, 'South': 0.9, 'East': 1.1, 'West': 1.0 } base_demand *= regional_factors.get(region, 1.0) # Add some seasonality and randomness seasonality = 1 + 0.2 * np.sin(datetime.now().timetuple().tm_yday / 365 * 2 * np.pi) noise = np.random.normal(0, 2) final_demand = base_demand * seasonality + noise return max(1, final_demand) # Minimum demand of 1 def _ml_predict(self, watch_id: int, region: str, horizon: int) -> float: """Use ML model for prediction when enough data exists.""" # Create features from historical data Loading @@ -63,9 +91,14 @@ class Forecasting: if features is not None: prediction = self.model.predict(features.reshape(1, -1))[0] return max(0, prediction) return max(1, prediction) # Fallback to watch characteristics watch = next((w for w in self.setup.watches if w.id == watch_id), None) if watch: return self._calculate_realistic_demand(watch, region) return np.random.uniform(10, 50) # Fallback return np.random.uniform(5, 20) def _extract_features(self, watch_id: int, region: str) -> Optional[np.ndarray]: """Extract features for ML prediction.""" Loading @@ -73,7 +106,7 @@ class Forecasting: return None # Simple feature extraction (can be enhanced) recent_sales = [s for s in self.historical_sales[-100:] recent_sales = [s for s in self.historical_sales[-50:] if s.watch_id == watch_id] if not recent_sales: Loading @@ -94,7 +127,7 @@ class Forecasting: def train_model(self): """Train the forecasting model with historical data.""" if len(self.historical_sales) < 50: if len(self.historical_sales) < 20: logger.warning("Not enough data to train model") return Loading @@ -114,21 +147,45 @@ class Forecasting: # Aggregate sales by watch and region df = pd.DataFrame([{ 'watch_id': s.watch_id, 'region': self.setup.retailers[s.retailer_id % len(self.setup.retailers)].region, 'region': self._get_region_for_sale(s), 'quantity': s.quantity, 'price': s.total_price, 'date': s.date } for s in self.historical_sales]) # This is simplified - real implementation would be more sophisticated return None, None # Placeholder # Group by watch_id and region, sum quantities grouped = df.groupby(['watch_id', 'region'])['quantity'].sum().reset_index() if len(grouped) < 5: return None, None X = [] y = [] for _, row in grouped.iterrows(): features = [ row['watch_id'], row['quantity'], 1 if row['region'] == 'North' else 0, 1 if row['region'] == 'South' else 0, 1 if row['region'] == 'East' else 0, 1 if row['region'] == 'West' else 0, ] X.append(features) y.append(row['quantity']) return np.array(X), np.array(y) def _get_region_for_sale(self, sale: Sale) -> str: """Get region for a sale based on retailer.""" retailer = next((r for r in self.setup.retailers if r.id == sale.retailer_id), None) return retailer.region if retailer else 'North' def get_uncertainty_intervals(self) -> Dict: """Estimate uncertainty intervals for forecasts.""" intervals = {} for forecast in self.forecasts: # Simple uncertainty estimation std_dev = forecast.forecasted_demand * 0.2 std_dev = forecast.forecasted_demand * 0.3 intervals[forecast.id] = { 'lower': max(0, forecast.forecasted_demand - 1.96 * std_dev), 'upper': forecast.forecasted_demand + 1.96 * std_dev Loading
supply_chain_sim/simulation.py +21 −2 Original line number Diff line number Diff line Loading @@ -118,6 +118,7 @@ class SupplyChainSimulation: # ========== DEMAND FORECASTING ========== print("→ Forecasting demand...") forecasts = self.forecasting.predict_demand(horizon_days=30) self.all_forecasts.extend(forecasts) # Collect for final report print(f" ✓ Generated {len(forecasts)} demand forecasts") # ========== SUPPLY CHAIN PLANNING ========== Loading @@ -127,6 +128,7 @@ class SupplyChainSimulation: self.supply_order = SupplyOrderLogic(self.setup, forecasts) supply_orders = self.supply_order.generate_orders(self.warehouse_inventories) self.all_orders.extend(supply_orders) # Collect for final report print(f" ✓ Generated {len(supply_orders)} supply orders") # ========== EVENT & CAMPAIGN ENGINE ========== Loading Loading @@ -161,6 +163,7 @@ class SupplyChainSimulation: self.warehouse_inventories, assembled_count = self.assembly.assemble_watches( self.warehouse_inventories, assembly_targets ) self.total_assembled += assembled_count # Collect for final report print(f" ✓ Assembled {assembled_count} watches") # Update inventory age Loading @@ -185,6 +188,7 @@ class SupplyChainSimulation: new_sales, self.retailer_inventories ) self.all_sales.extend(new_sales) # Collect for final report conversion_rate = self.customer_behavior.calculate_conversion_rate(len(sampled_customers)) sales_summary = self.customer_behavior.get_sales_summary() print(f" ✓ {len(new_sales)} sales, conversion rate: {conversion_rate:.1%}") Loading Loading @@ -225,8 +229,23 @@ class SupplyChainSimulation: print(f" Customer segments: {model_summary['customer_segments']}") print(f" Forecast accuracy: {model_summary['forecast_accuracy']['accuracy']:.1%}") # Research logging final report self.research_logging.print_final_report() # Prepare simulation data for detailed report simulation_data = { 'all_forecasts': self.all_forecasts, 'all_sales': self.all_sales, 'all_orders': self.all_orders, 'total_assembled': self.total_assembled, 'warehouse_inventories': self.warehouse_inventories, 'retailer_inventories': self.retailer_inventories, 'distribution_history': self.distribution.distribution_history, 'returns': self.customer_behavior.returns, 'watches': self.setup.watches, 'customers': self.setup.customers, 'components': self.setup.components } # Research logging final report with data self.research_logging.print_final_report(simulation_data) def run_oneshot_simulation(config: Dict = None): """Run a single simulation with the given configuration.""" Loading
supply_chain_sim/synthetic_setup.py +38 −19 Original line number Diff line number Diff line Loading @@ -79,7 +79,7 @@ class SyntheticEntitySetup: id=i, name=f"Supplier_{i}", reliability_score=np.random.beta(5, 2), # Skewed towards reliable lead_time=np.random.normal(14, 3), # 2 weeks avg lead_time=max(1, np.random.normal(7, 2)), # 1 week avg, minimum 1 day capacity=np.random.randint(1000, 10000) )) return suppliers Loading @@ -92,7 +92,7 @@ class SyntheticEntitySetup: id=i, name=f"Component_{i}", type=types[i % len(types)], cost=np.random.uniform(10, 500), cost=np.random.uniform(10, 100), # Reduced component costs storage_rate=np.random.uniform(0.01, 0.1), shelf_life=np.random.randint(180, 720), quality_tier=np.random.randint(1, 6), Loading Loading @@ -134,8 +134,11 @@ class SyntheticEntitySetup: total_cost += comp.cost * quantity bom_id += 1 # Set watch price based on components + markup watch.base_price = total_cost * np.random.uniform(2.5, 4.0) # Set watch price based on components + markup (more reasonable prices) markup = np.random.uniform(2.0, 3.0) if watch.category == 'luxury': markup *= 2.0 # Luxury watches have higher markup watch.base_price = total_cost * markup return boms Loading @@ -160,8 +163,8 @@ class SyntheticEntitySetup: id=i, name=f"Retailer_{i}", region=regions[i % len(regions)], capacity=np.random.randint(100, 1000), markup_policy=np.random.uniform(1.2, 1.8) capacity=np.random.randint(200, 1000), # Increased capacity markup_policy=np.random.uniform(1.1, 1.3) # Reduced markup )) return retailers Loading @@ -170,22 +173,25 @@ class SyntheticEntitySetup: regions = ['North', 'South', 'East', 'West'] for i in range(n): # Wealth follows Pareto distribution wealth_class = min(10, int(np.random.pareto(2.5) + 1)) # Wealth follows Pareto distribution but more balanced wealth_class = min(10, max(1, int(np.random.pareto(1.5) + 1))) # Taste profile: 5D normal distribution taste_profile = np.random.normal(0, 1, 5).tolist() # Budget scaled by wealth budget = wealth_class * np.random.uniform(500, 5000) # Budget scaled by wealth - more generous budgets base_budget = wealth_class * np.random.uniform(200, 2000) # Add extra budget for higher wealth classes if wealth_class >= 7: base_budget *= np.random.uniform(2, 5) customers.append(Customer( id=i, wealth_class=wealth_class, taste_profile=taste_profile, region=regions[i % len(regions)], brand_awareness=np.random.uniform(0, 0.3), budget=budget, brand_awareness=np.random.uniform(0.1, 0.5), # Higher base awareness budget=base_budget, event_responsiveness=np.random.uniform(0.2, 1.0) )) Loading @@ -197,27 +203,40 @@ class SyntheticEntitySetup: retailer_inventories = [] inv_id = 0 # Initialize warehouse inventories with components # Initialize warehouse inventories with MORE components for warehouse in self.warehouses: for component in self.components[:10]: # Start with some components for component in self.components: # Start with ALL components warehouse_inventories.append(WarehouseInventory( id=inv_id, warehouse_id=warehouse.id, item_id=component.id, item_type="component", quantity=np.random.randint(50, 200) quantity=np.random.randint(100, 500) # More components )) inv_id += 1 # Initialize warehouse with some pre-assembled watches for warehouse in self.warehouses: for watch in self.watches: warehouse_inventories.append(WarehouseInventory( id=inv_id, warehouse_id=warehouse.id, item_id=watch.id, item_type="watch", quantity=np.random.randint(20, 100) # Start with assembled watches )) inv_id += 1 inv_id = 0 # Initialize retailer inventories with watches # Initialize retailer inventories with MORE watches for retailer in self.retailers: for watch in self.watches[:5]: # Start with some watches for watch in self.watches: # All watches available at all retailers retailer_inventories.append(RetailerInventory( id=inv_id, retailer_id=retailer.id, watch_id=watch.id, quantity=np.random.randint(5, 20) quantity=np.random.randint(10, 50), # More watches per retailer return_rate=0.05 )) inv_id += 1 Loading