| """ |
| Simulation engine for the Retail Inventory & Expiry Management environment. |
| |
| Handles: |
| - Stochastic demand generation with: |
| * Day-of-week patterns (weekend uplift) |
| * Monthly seasonal patterns (Christmas peak, summer produce surge etc.) |
| * Category-specific seasonal boosts (strawberries in summer, dairy in winter) |
| * Expiry urgency decay |
| * Discount elasticity |
| - Daily sales resolution |
| - Expiry checking and waste accounting |
| - Reorder fulfilment |
| """ |
|
|
| from __future__ import annotations |
|
|
| import copy |
| import random |
| from typing import Dict, List, Tuple |
|
|
| from models import Action, ActionType, Product, Reward |
|
|
|
|
| |
| |
| |
|
|
| def build_product_catalogue( |
| num_products: int, |
| episode_length: int, |
| rng: random.Random, |
| start_month: int = 1, |
| ) -> List[Product]: |
| templates = [ |
| ("Fresh Milk", "dairy", 1.20, 0.70, 12.0, 7), |
| ("Greek Yoghurt", "dairy", 0.90, 0.50, 8.0, 10), |
| ("Cheddar Cheese", "dairy", 2.50, 1.40, 5.0, 21), |
| ("Sliced Bread", "bakery", 1.10, 0.55, 15.0, 5), |
| ("Croissants x4", "bakery", 1.80, 0.90, 6.0, 3), |
| ("Chicken Breast", "meat", 3.50, 2.10, 7.0, 4), |
| ("Pork Sausages", "meat", 2.20, 1.30, 6.0, 6), |
| ("Salmon Fillet", "fish", 4.00, 2.60, 4.0, 3), |
| ("Baby Spinach", "produce", 1.00, 0.55, 10.0, 5), |
| ("Strawberries 400g", "produce", 1.50, 0.85, 9.0, 4), |
| ("Orange Juice 1L", "drinks", 1.40, 0.75, 8.0, 14), |
| ("Hummus", "deli", 1.20, 0.65, 5.0, 12), |
| ("Ready Meal Pasta", "ready", 2.80, 1.60, 6.0, 5), |
| ("Eggs x12", "dairy", 1.80, 1.00, 11.0, 21), |
| ("Butter 250g", "dairy", 1.50, 0.90, 7.0, 30), |
| ("Broccoli", "produce", 0.80, 0.45, 8.0, 6), |
| ("Mixed Salad", "produce", 1.30, 0.70, 7.0, 4), |
| ("Cod Fillets", "fish", 3.80, 2.40, 3.0, 3), |
| ("Lamb Chops", "meat", 5.50, 3.50, 3.0, 5), |
| ("Cream Cheese", "dairy", 1.60, 0.90, 4.0, 14), |
| ] |
|
|
| num_products = min(num_products, len(templates)) |
| chosen = rng.sample(templates, num_products) |
| products: List[Product] = [] |
|
|
| for idx, (name, category, price, cost, demand, shelf_life) in enumerate(chosen): |
| pid = f"P{idx+1:02d}" |
| init_stock = int(demand * rng.uniform(2.0, 5.0)) |
| lo = max(2, shelf_life // 2) |
| hi = min(shelf_life, episode_length - 1) |
| if lo >= hi: |
| lo = max(1, hi - 1) |
| expiry_day = rng.randint(lo, hi) |
| noisy_demand = demand * rng.uniform(0.8, 1.2) |
| products.append(Product( |
| product_id = pid, |
| name = name, |
| category = category, |
| stock = init_stock, |
| price = round(price * rng.uniform(0.9, 1.1), 2), |
| cost = round(cost, 2), |
| expiry_day = expiry_day, |
| base_demand = round(noisy_demand, 2), |
| )) |
|
|
| return products |
|
|
|
|
| |
| |
| |
|
|
| |
| MONTHLY_MULTIPLIERS = [ |
| 0.85, |
| 0.88, |
| 0.92, |
| 0.95, |
| 1.00, |
| 1.05, |
| 1.10, |
| 1.08, |
| 0.98, |
| 1.02, |
| 1.15, |
| 1.30, |
| ] |
|
|
| |
| CATEGORY_SEASONAL = { |
| "produce": { |
| 5: 1.20, 6: 1.35, 7: 1.35, 8: 1.25, |
| 9: 1.10, 10: 1.05, |
| 0: 0.80, 1: 0.80, |
| }, |
| "fish": { |
| 2: 1.25, |
| 5: 1.15, 6: 1.20, 7: 1.15, |
| 11: 1.20, |
| }, |
| "meat": { |
| 5: 1.25, 6: 1.30, 7: 1.25, |
| 11: 1.35, |
| 0: 0.90, |
| }, |
| "dairy": { |
| 11: 1.20, 10: 1.10, |
| 6: 0.90, 7: 0.90, |
| }, |
| "drinks": { |
| 5: 1.30, 6: 1.40, 7: 1.40, 8: 1.25, |
| 11: 1.25, |
| 0: 0.80, 1: 0.80, |
| }, |
| "bakery": { |
| 11: 1.30, |
| 0: 0.85, |
| }, |
| "deli": { |
| 11: 1.25, |
| 5: 1.15, 6: 1.15, |
| }, |
| } |
|
|
| |
| DOW_MULTIPLIERS = [0.85, 0.85, 0.90, 0.95, 1.15, 1.25, 1.20] |
|
|
|
|
| def get_seasonal_factor(product: Product, current_day: int, start_month: int = 1) -> float: |
| """Return the combined seasonal multiplier for a product on a given day.""" |
| month_idx = ((start_month - 1) + (current_day - 1) // 30) % 12 |
|
|
| base = MONTHLY_MULTIPLIERS[month_idx] |
| category_boosts = CATEGORY_SEASONAL.get(product.category, {}) |
| category_boost = category_boosts.get(month_idx, 1.0) |
|
|
| |
| product_boost = 1.0 |
| if "Strawberr" in product.name: |
| if month_idx in (5, 6, 7): product_boost = 1.50 |
| elif month_idx in (0, 1, 11): product_boost = 0.50 |
| elif "Lamb" in product.name: |
| if month_idx in (2, 3): product_boost = 1.40 |
| elif month_idx == 11: product_boost = 1.30 |
| elif "Salmon" in product.name or "Cod" in product.name: |
| if month_idx == 2: product_boost = 1.35 |
| elif "Orange Juice" in product.name: |
| if month_idx == 0: product_boost = 1.30 |
|
|
| return base * category_boost * product_boost |
|
|
|
|
| def compute_demand( |
| product: Product, |
| current_day: int, |
| rng: random.Random, |
| start_month: int = 1, |
| ) -> float: |
| """ |
| Return units sold this day for *product*. |
| |
| Demand drivers: |
| 1. Base demand (product mean) |
| 2. Monthly seasonal pattern |
| 3. Category-specific seasonal boost |
| 4. Product-specific seasonal boost |
| 5. Day-of-week pattern |
| 6. Discount elasticity (each 10% off -> +15% demand) |
| 7. Expiry urgency decay |
| 8. Gaussian noise (sigma = 20%) |
| """ |
| if product.stock == 0: |
| return 0.0 |
|
|
| dte = product.days_to_expiry(current_day) |
| if dte == -1: expiry_factor = 1.0 |
| elif dte <= 1: expiry_factor = 0.3 |
| elif dte <= 2: expiry_factor = 0.6 |
| else: expiry_factor = 1.0 |
|
|
| discount_factor = 1.0 + (product.current_discount / 10.0) * 0.15 |
| dow_factor = DOW_MULTIPLIERS[(current_day - 1) % 7] |
| seasonal_factor = get_seasonal_factor(product, current_day, start_month) |
|
|
| adjusted = ( |
| product.base_demand |
| * expiry_factor |
| * discount_factor |
| * dow_factor |
| * seasonal_factor |
| ) |
|
|
| noisy = rng.gauss(adjusted, adjusted * 0.2) |
| return max(0.0, min(float(product.stock), noisy)) |
|
|
|
|
| def estimate_demand(product: Product, current_day: int, start_month: int = 1) -> float: |
| """Deterministic demand estimate shown in Observation (no noise).""" |
| dte = product.days_to_expiry(current_day) |
| expiry_factor = 1.0 if dte == -1 else (0.3 if dte <= 1 else 0.6 if dte <= 2 else 1.0) |
| discount_factor = 1.0 + (product.current_discount / 10.0) * 0.15 |
| dow_factor = DOW_MULTIPLIERS[(current_day - 1) % 7] |
| seasonal_factor = get_seasonal_factor(product, current_day, start_month) |
| return round( |
| product.base_demand * expiry_factor * discount_factor * dow_factor * seasonal_factor, 2 |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def apply_action( |
| product: Product, |
| action: Action, |
| current_day: int, |
| budget: float, |
| ) -> Tuple[Product, float, float]: |
| p = copy.deepcopy(product) |
| reward_delta = 0.0 |
|
|
| if action.action_type == ActionType.DISCOUNT: |
| p.current_discount = action.discount_pct |
|
|
| elif action.action_type == ActionType.REORDER: |
| reorder_cost = p.cost * action.reorder_qty |
| if reorder_cost <= budget: |
| p.pending_reorder += action.reorder_qty |
| budget -= reorder_cost |
| reward_delta -= reorder_cost |
|
|
| elif action.action_type == ActionType.REMOVE: |
| removed = p.stock |
| p.stock = 0 |
| reward_delta -= p.cost * removed * 0.5 |
|
|
| return p, reward_delta, budget |
|
|
|
|
| def resolve_day( |
| products: List[Product], |
| current_day: int, |
| rng: random.Random, |
| start_month: int = 1, |
| ) -> Tuple[List[Product], float, float, int]: |
| """Simulate one day of trading after actions have been applied.""" |
| updated: List[Product] = [] |
| total_revenue = 0.0 |
| total_waste = 0.0 |
| stockouts = 0 |
|
|
| for p in products: |
| p = copy.deepcopy(p) |
|
|
| p.stock += p.pending_reorder |
| p.pending_reorder = 0 |
|
|
| units_float = compute_demand(p, current_day, rng, start_month) |
| units_sold = min(int(units_float), p.stock) |
| effective_price = p.price * (1 - p.current_discount / 100.0) |
| total_revenue += units_sold * effective_price |
| p.stock -= units_sold |
|
|
| if units_float > p.stock + units_sold: |
| stockouts += 1 |
|
|
| dte = p.days_to_expiry(current_day) |
| if dte == 0 and p.expiry_day != -1 and p.stock > 0: |
| total_waste += p.cost * p.stock |
| p.stock = 0 |
|
|
| p.current_discount = 0.0 |
| updated.append(p) |
|
|
| return updated, total_revenue, total_waste, stockouts |
|
|