manzz05/Kitchenflow
0
1"""2KitchenFlow-v1 — Ghost Kitchen Dispatcher Environment3 4Three tasks of increasing difficulty:5 T1 single_order_dispatch Easy — 1 order, stable traffic, 30-min window6 T2 multi_order_coordination Medium — 3 orders, varying preps & distances7 T3 peak_hour_rush Hard — 5 orders, traffic spikes, dynamic conditions8 9Simulation mechanics (each step = 1 minute):10 • Food prep advances linearly to 1.0 over its prep_time_min11 • Once food is ready (progress=1.0), it starts cooling at COOLING_RATE °C/min12 • When driver is summoned, ETA = dist_km / (BASE_SPEED_KM_MIN / traffic_index)13 • Driver distance shrinks each minute at their current travel speed14 • Delivery happens when: food_ready AND driver_arrived15 • Episode ends when all orders resolved OR time_limit reached16 17Reward function (per order):18 +10 if |driver_arrival_min - food_ready_min| ≤ 2 (perfect timing)19 +5 if |driver_arrival_min - food_ready_min| ≤ 5 (good timing)20 -1 per °C below PERFECT_SERVING_TEMP at delivery21 -20 if driver waits > 15 min (risk of cancellation)22 -5 if food waits > 10 min (cold food + customer dissatisfaction)23 -10 if order not resolved within time limit (failed delivery)24"""25 26import math27import random28from copy import deepcopy29from typing import Any, Dict, List, Optional, Tuple30from uuid import uuid431 32from openenv.core.env_server.interfaces import Environment33from openenv.core.env_server.types import State34 35try:36 from ..models import KitchenAction, KitchenObservation37except ImportError:38 from models import KitchenAction, KitchenObservation39 40# ─────────────────────────────────────────────41# Physical constants42# ─────────────────────────────────────────────43BASE_SPEED_KM_MIN = 0.50 # 30 km/h city average44INITIAL_FOOD_TEMP = 85.0 # °C when food is freshly prepared45PERFECT_TEMP = 75.0 # °C ideal serving temperature46COOLING_RATE = 1.8 # °C per minute food sits waiting47DELIVERY_COOL_RATE = 0.4 # °C per minute during transit (insulated bag)48MAX_DRIVER_WAIT_MIN = 15 # minutes — driver may cancel after this49MAX_FOOD_WAIT_MIN = 10 # minutes — food quality degrades badly50 51# ─────────────────────────────────────────────52# Traffic patterns (Uber Movement-style)53# ─────────────────────────────────────────────54def _traffic_at(minute: int, scenario: str) -> float:55 """Return traffic index for a given simulation minute and scenario."""56 if scenario == "stable":57 return 1.258 59 elif scenario == "moderate":60 # Gentle wave: light → moderate → light61 base = 1.362 wave = 0.3 * math.sin(math.pi * minute / 20)63 return round(base + wave, 2)64 65 elif scenario == "peak_hour":66 # Spike between minutes 10–20 (lunch rush), calmer outside67 if minute < 8:68 return 1.169 elif minute < 12:70 return 1.1 + (minute - 8) * 0.25 # ramp up71 elif minute < 20:72 return 2.1 + 0.1 * math.sin(minute) # peak ~2.0–2.273 else:74 return 2.1 - (minute - 20) * 0.08 # ramp down75 return 1.076 77 78# ─────────────────────────────────────────────79# Order template definitions80# ─────────────────────────────────────────────81ORDER_TEMPLATES = {82 # id, item_name, prep_time_min, initial_driver_dist_km83 "fast": {"item_name": "Fries & Shake", "prep_time_min": 10, "driver_dist_km": 1.2},84 "medium": {"item_name": "Chicken Burger", "prep_time_min": 15, "driver_dist_km": 2.0},85 "slow": {"item_name": "Loaded Nachos", "prep_time_min": 20, "driver_dist_km": 3.5},86 "complex": {"item_name": "Full Grill Platter", "prep_time_min": 25, "driver_dist_km": 4.0},87 "express": {"item_name": "Hot Dog Combo", "prep_time_min": 8, "driver_dist_km": 0.8},88}89 90 91def _make_order(order_id: str, template_key: str) -> Dict[str, Any]:92 t = ORDER_TEMPLATES[template_key]93 return {94 "order_id": order_id,95 "item_name": t["item_name"],96 "template": template_key,97 "prep_time_min": t["prep_time_min"],98 "food_prep_progress": 0.0,99 "driver_dist_km": t["driver_dist_km"],100 "_initial_dist_km": t["driver_dist_km"],101 "food_temp_c": INITIAL_FOOD_TEMP,102 "driver_summoned": False,103 "driver_summon_min": None,104 "driver_eta_min": None, # estimated minutes remaining until arrival105 "driver_arrived": False,106 "driver_arrived_min": None,107 "food_ready": False,108 "food_ready_min": None,109 "delivered": False,110 "failed": False,111 "minutes_food_waited": 0,112 "minutes_driver_waited": 0,113 "status": "preparing",114 "score": None,115 }116 117 118# ─────────────────────────────────────────────119# Scenario definitions120# ─────────────────────────────────────────────121SCENARIOS = {122 "T1_single_order_dispatch": {123 "description": (124 "One order is being prepared. Your job is to summon the driver "125 "at exactly the right moment so they arrive just as the food is bagged. "126 "Watch food_prep_progress (0→1) and driver_dist_km. "127 "Traffic is stable. Submit dispatch_decisions: {ORD001: 1} to call the driver."128 ),129 "difficulty": "easy",130 "max_time_min": 30,131 "traffic_mode": "stable",132 "orders": [("ORD001", "medium")],133 },134 "T2_multi_order_coordination": {135 "description": (136 "Three orders are in progress — a quick snack, a burger, and a loaded platter. "137 "Each has a different prep time and driver distance. "138 "Coordinate dispatch so all three drivers arrive on time. "139 "Traffic fluctuates — watch the traffic_index each minute."140 ),141 "difficulty": "medium",142 "max_time_min": 35,143 "traffic_mode": "moderate",144 "orders": [("ORD001", "fast"), ("ORD002", "medium"), ("ORD003", "slow")],145 },146 "T3_peak_hour_rush": {147 "description": (148 "Five orders land during the lunch rush. "149 "Traffic spikes between minutes 10–20 (index up to 2.2), "150 "slowing all drivers. One driver may be far from the hub. "151 "Maximise food quality and minimise driver idle time across all 5 orders."152 ),153 "difficulty": "hard",154 "max_time_min": 45,155 "traffic_mode": "peak_hour",156 "orders": [157 ("ORD001", "express"),158 ("ORD002", "fast"),159 ("ORD003", "medium"),160 ("ORD004", "slow"),161 ("ORD005", "complex"),162 ],163 },164}165 166 167# ─────────────────────────────────────────────168# Per-order grader169# ─────────────────────────────────────────────170def _score_order(order: Dict[str, Any]) -> Tuple[float, str]:171 """Grade a single completed or failed order. Returns (score 0–1, feedback)."""172 173 if order["failed"]:174 return 0.0, f"{order['order_id']} FAILED (timeout) → score=0.0"175 176 food_ready_min = order["food_ready_min"] or 0177 driver_arrived_min = order["driver_arrived_min"] or 0178 food_waited = order["minutes_food_waited"]179 driver_waited = order["minutes_driver_waited"]180 temp = order["food_temp_c"]181 182 # Compute raw score183 raw = 0.0184 timing_gap = abs(driver_arrived_min - food_ready_min)185 if timing_gap <= 2:186 raw += 10 # perfect187 elif timing_gap <= 5:188 raw += 5 # good189 190 temp_penalty = max(0.0, PERFECT_TEMP - temp)191 raw -= temp_penalty192 193 if driver_waited > MAX_DRIVER_WAIT_MIN:194 raw -= 20195 if food_waited > MAX_FOOD_WAIT_MIN:196 raw -= 5197 198 # Normalize: best possible = +10, worst practical = -35199 # Range span = 45200 score = max(0.0, min(1.0, (raw + 35) / 45.0))201 202 fb = (203 f"{order['order_id']} ({order['item_name']}): "204 f"food_ready=min{food_ready_min} driver=min{driver_arrived_min} "205 f"gap={timing_gap}min temp={temp:.1f}°C "206 f"raw={raw:.1f} score={score:.2f}"207 )208 return round(score, 4), fb209 210 211# ─────────────────────────────────────────────212# Environment213# ─────────────────────────────────────────────214class KitchenflowEnvironment(Environment):215 SUPPORTS_CONCURRENT_SESSIONS: bool = True216 217 def __init__(self):218 self._state = State(episode_id=str(uuid4()), step_count=0)219 self._task_id = ""220 self._task_idx = 0221 self._scenario: Dict[str, Any] = {}222 self._orders: List[Dict[str, Any]] = []223 self._time_min = 0224 self._max_time = 30225 self._traffic_mode = "stable"226 self._done = False227 self._episode_score = 0.0228 self._order_scores: Dict[str, float] = {}229 230 # ── helpers ─────────────────────────────────────────────────────────────231 232 def _traffic(self) -> float:233 return _traffic_at(self._time_min, self._traffic_mode)234 235 def _order_snapshot(self, o: Dict) -> Dict[str, Any]:236 return {237 "order_id": o["order_id"],238 "item_name": o["item_name"],239 "food_prep_progress": round(o["food_prep_progress"], 3),240 "driver_dist_km": round(o["driver_dist_km"], 2),241 "food_temp_c": round(o["food_temp_c"], 1),242 "driver_summoned": o["driver_summoned"],243 "driver_eta_min": o["driver_eta_min"],244 "food_ready": o["food_ready"],245 "driver_arrived": o["driver_arrived"],246 "delivered": o["delivered"],247 "failed": o["failed"],248 "minutes_food_waited": o["minutes_food_waited"],249 "minutes_driver_waited": o["minutes_driver_waited"],250 "status": o["status"],251 }252 253 def _build_obs(self, feedback: str) -> KitchenObservation:254 delivered = sum(1 for o in self._orders if o["delivered"])255 failed = sum(1 for o in self._orders if o["failed"])256 t_penalty = sum(257 max(0, PERFECT_TEMP - o["food_temp_c"])258 for o in self._orders if o["delivered"]259 )260 w_penalty = sum(261 20 for o in self._orders262 if o["delivered"] and o["minutes_driver_waited"] > MAX_DRIVER_WAIT_MIN263 )264 running_score = (265 sum(self._order_scores.values()) / len(self._orders)266 if self._order_scores else 0.0267 )268 return KitchenObservation(269 task_id=self._task_id,270 task_description=self._scenario.get("description", ""),271 difficulty=self._scenario.get("difficulty", ""),272 time_min=self._time_min,273 max_time_min=self._max_time,274 traffic_index=round(self._traffic(), 2),275 orders=[self._order_snapshot(o) for o in self._orders],276 orders_delivered=delivered,277 orders_failed=failed,278 total_temp_penalty=round(t_penalty, 1),279 total_waste_penalty=round(w_penalty, 1),280 last_action_feedback=feedback,281 score=round(running_score, 4),282 attempts=self._time_min,283 max_attempts=self._max_time,284 done=self._done,285 reward=0.0,286 )287 288 # ── reset ────────────────────────────────────────────────────────────────289 290 def reset(self, task_id: Optional[str] = None) -> KitchenObservation:291 task_ids = list(SCENARIOS.keys())292 if task_id and task_id in SCENARIOS:293 self._task_id = task_id294 else:295 self._task_id = task_ids[self._task_idx % len(task_ids)]296 self._task_idx += 1297 298 self._scenario = SCENARIOS[self._task_id]299 self._time_min = 0300 self._max_time = self._scenario["max_time_min"]301 self._traffic_mode = self._scenario["traffic_mode"]302 self._done = False303 self._episode_score = 0.0304 self._order_scores = {}305 self._state = State(episode_id=str(uuid4()), step_count=0)306 307 self._orders = [308 _make_order(oid, tkey)309 for oid, tkey in self._scenario["orders"]310 ]311 312 return self._build_obs(313 "Kitchen is open. Watch food_prep_progress and traffic_index carefully. "314 "Summon the driver at the right moment!"315 )316 317 # ── step ─────────────────────────────────────────────────────────────────318 319 def step(self, action: KitchenAction) -> KitchenObservation:320 if not self._orders:321 self.reset()322 323 self._state.step_count += 1324 self._time_min += 1325 traffic = self._traffic()326 speed = BASE_SPEED_KM_MIN / traffic # km per minute327 328 events: List[str] = [f"Min {self._time_min} | traffic={traffic:.2f}"]329 330 # Process each order331 for o in self._orders:332 if o["delivered"] or o["failed"]:333 continue334 335 oid = o["order_id"]336 337 # 1. Advance food preparation338 if not o["food_ready"]:339 o["food_prep_progress"] = min(340 1.0,341 o["food_prep_progress"] + 1.0 / o["prep_time_min"]342 )343 if o["food_prep_progress"] >= 1.0:344 o["food_ready"] = True345 o["food_ready_min"] = self._time_min346 o["status"] = "food_ready"347 o["food_temp_c"] = INITIAL_FOOD_TEMP348 events.append(f" 🍔 {oid} food READY (min {self._time_min})")349 350 # 2. Handle dispatch action351 decision = action.dispatch_decisions.get(oid, 0)352 if decision == 1 and not o["driver_summoned"] and not o["delivered"]:353 o["driver_summoned"] = True354 o["driver_summon_min"] = self._time_min355 eta = o["driver_dist_km"] / speed356 o["driver_eta_min"] = round(eta, 1)357 o["status"] = "driver_en_route"358 events.append(359 f" 🛵 {oid} driver SUMMONED — dist={o['driver_dist_km']:.1f}km "360 f"ETA≈{eta:.1f}min"361 )362 363 # 3. Move driver closer364 if o["driver_summoned"] and not o["driver_arrived"]:365 moved = speed366 o["driver_dist_km"] = max(0.0, o["driver_dist_km"] - moved)367 # Update ETA368 if speed > 0:369 o["driver_eta_min"] = round(o["driver_dist_km"] / speed, 1)370 371 if o["driver_dist_km"] <= 0.0:372 o["driver_arrived"] = True373 o["driver_arrived_min"] = self._time_min374 o["driver_dist_km"] = 0.0375 o["driver_eta_min"] = 0376 events.append(f" 🏍️ {oid} driver ARRIVED (min {self._time_min})")377 378 # 4. Cool food if it's ready and waiting379 if o["food_ready"] and not o["delivered"]:380 if not o["driver_arrived"]:381 # Food sitting under heat lamp, slowly cooling382 o["food_temp_c"] = max(383 40.0, o["food_temp_c"] - COOLING_RATE384 )385 o["minutes_food_waited"] += 1386 else:387 # Both ready → deliver388 # Cool slightly during final hand-off389 o["food_temp_c"] = max(40.0, o["food_temp_c"] - 0.5)390 391 # 5. Driver idle at hub waiting for food392 if o["driver_arrived"] and not o["food_ready"]:393 o["minutes_driver_waited"] += 1394 395 # 6. Deliver if both ready396 if o["food_ready"] and o["driver_arrived"] and not o["delivered"]:397 o["delivered"] = True398 o["status"] = "delivered"399 s, fb = _score_order(o)400 o["score"] = s401 self._order_scores[oid] = s402 events.append(f" ✅ {oid} DELIVERED {fb}")403 404 # 7. Timeout405 if self._time_min >= self._max_time and not o["delivered"]:406 o["failed"] = True407 o["status"] = "failed"408 self._order_scores[oid] = 0.0409 events.append(f" ❌ {oid} FAILED (timeout)")410 411 # Check if episode is done412 all_resolved = all(o["delivered"] or o["failed"] for o in self._orders)413 self._done = all_resolved or self._time_min >= self._max_time414 415 # Final episode score416 if self._done and self._order_scores:417 self._episode_score = round(418 sum(self._order_scores.values()) / len(self._orders), 4419 )420 421 # Per-step shaped reward422 step_reward = self._shaped_reward()423 424 feedback = " | ".join(events)425 obs = self._build_obs(feedback)426 obs.score = self._episode_score if self._done else round(427 sum(self._order_scores.values()) / len(self._orders), 4428 ) if self._order_scores else 0.0429 obs.reward = round(step_reward, 4)430 return obs431 432 def _shaped_reward(self) -> float:433 """434 Per-step shaped reward to guide the agent before episode end.435 Rewards progress: food staying warm, efficient driver dispatch.436 """437 reward = 0.0438 for o in self._orders:439 if o["delivered"]:440 continue441 if o["failed"]:442 reward -= 0.5443 continue444 445 # Small bonus while food is staying warm (not yet ready or just ready)446 if o["food_ready"] and o["food_temp_c"] >= PERFECT_TEMP:447 reward += 0.05448 449 # Penalty each minute food sits waiting without a driver450 if o["food_ready"] and not o["driver_summoned"]:451 reward -= 0.10452 453 # Penalty each minute driver sits idle at hub454 if o["driver_arrived"] and not o["food_ready"]:455 reward -= 0.15456 457 return reward458 459 @property460 def state(self) -> State:461 return self._state462 463 def close(self):464 pass465 466 467# Expose task list for the /tasks endpoint468TASKS = [469 {470 "task_id": tid,471 "description": sc["description"],472 "difficulty": sc["difficulty"],473 "max_time_min": sc["max_time_min"],474 "n_orders": len(sc["orders"]),475 }476 for tid, sc in SCENARIOS.items()477]478 