Commit 5af55e81 authored by Barthelet Thibault's avatar Barthelet Thibault
Browse files

potentially working

parent 2fbcc1ab
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -2,3 +2,4 @@
/archives
/supply_chain_sim.egg-info
/venv
*.pyc
 No newline at end of file
+66 −91
Original line number Diff line number Diff line
@@ -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
    
@@ -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:
@@ -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
    
@@ -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)
@@ -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
@@ -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()
@@ -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
@@ -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}")
        
+73 −16
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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."""
@@ -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:
@@ -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
        
@@ -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
+21 −2
Original line number Diff line number Diff line
@@ -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 ==========
@@ -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 ==========
@@ -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
@@ -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%}")
@@ -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."""
+38 −19
Original line number Diff line number Diff line
@@ -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
@@ -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),
@@ -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
    
@@ -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
    
@@ -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)
            ))
        
@@ -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