vikash-nuvai commited on
Commit
bbc1784
·
0 Parent(s):

feat: complete tiffin packing OpenEnv environment with 3 tasks, VLM, grader, and inference

Browse files
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .git/
8
+ .gitignore
9
+ .env
10
+ outputs/
11
+ *.md
12
+ !README.md
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
9
+ outputs/logs/
10
+ outputs/evals/
11
+ test_*.py
12
+ *.egg
13
+ .eggs/
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile for Tiffin Packer — HF Spaces Compatible
2
+ # Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
3
+
4
+ FROM python:3.10-slim
5
+
6
+ # Create non-root user (HF Spaces requirement)
7
+ RUN useradd -m -u 1000 user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+
10
+ # System dependencies for PyBullet (headless OpenGL)
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ libgl1-mesa-glx \
13
+ libglib2.0-0 \
14
+ libsm6 \
15
+ libxrender1 \
16
+ libxext6 \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Switch to non-root user
20
+ USER user
21
+ WORKDIR /app
22
+
23
+ # Install Python dependencies
24
+ COPY --chown=user requirements.txt .
25
+ RUN pip install --no-cache-dir --upgrade pip && \
26
+ pip install --no-cache-dir -r requirements.txt
27
+
28
+ # Copy application code
29
+ COPY --chown=user . /app
30
+
31
+ # Expose port (HF Spaces default for Docker)
32
+ EXPOSE 7860
33
+
34
+ # Health check
35
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
36
+ CMD python -c "import requests; r=requests.get('http://localhost:7860/health'); r.raise_for_status()" || exit 1
37
+
38
+ # Run the server
39
+ CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smart Tiffin Packing Environment 🍱🤖
2
+
3
+ > **Semantic-aware constrained packing under real-world constraints**
4
+ >
5
+ > An OpenEnv-compliant RL environment where an LLM agent controls a robotic arm
6
+ > to pack an Indian tiffin meal. The agent uses VLM-derived food classification
7
+ > to reason about container compatibility, volume constraints, temperature zones,
8
+ > and fragility — then physically executes packing decisions.
9
+
10
+ ## 🎯 What is this?
11
+
12
+ This environment simulates the real-world task of **packing an Indian meal into tiffin containers**. An AI agent must:
13
+
14
+ 1. **Identify** food items using a Vision-Language Model (VLM)
15
+ 2. **Reason** about which container each item should go into
16
+ 3. **Execute** packing commands via a robotic arm
17
+ 4. **Satisfy** multiple constraints simultaneously
18
+
19
+ ### Why Tiffin Packing?
20
+
21
+ Every day, millions of people in India pack tiffin boxes for lunch. It's a genuine spatial-reasoning task with real constraints:
22
+ - Liquids (sambar, dal) must go in sealed containers
23
+ - Fragile items (papad, chapati) shouldn't be crushed
24
+ - Hot and cold foods should be separated
25
+ - Volume limits mean you can't just stuff everything in one box
26
+
27
+ ## 🏗️ Architecture
28
+
29
+ ```
30
+ LLM Agent (via OpenAI API)
31
+
32
+ ├── observe → See scene description
33
+ ├── identify → VLM classifies food item
34
+ ├── pick → Robotic arm picks up food
35
+ ├── place → Place item in container
36
+ └── pour → Pour liquid into container
37
+
38
+
39
+ OpenEnv Server (FastAPI)
40
+
41
+ ├── Simulation Engine (logic + PyBullet physics)
42
+ ├── VLM Classifier (cached food_db.json)
43
+ ├── Task Manager (easy/medium/hard)
44
+ └── Deterministic Grader (0.0-1.0)
45
+ ```
46
+
47
+ ## 🎮 Tasks
48
+
49
+ | Task | Items | Containers | Constraints | Difficulty |
50
+ |------|-------|-----------|-------------|------------|
51
+ | 🟢 Easy | rice, sambar (2) | sealed, flat (2) | Type matching | Straightforward |
52
+ | 🟡 Medium | rice, sambar, chapati, pickle (4) | sealed, flat, deep (3) | Types + overflow + temperature | Requires reasoning |
53
+ | 🔴 Hard | rice, sambar, curd, chapati, papad, curry (6) | sealed, flat, deep, small_sealed (4) | All constraints active | Genuinely challenging |
54
+
55
+ ## 📊 Scoring (0.0 – 1.0)
56
+
57
+ | Component | Weight | Description |
58
+ |-----------|--------|-------------|
59
+ | Validity | 40% | Food placed in type-compatible container? |
60
+ | Efficiency | 30% | Space utilization vs capacity used |
61
+ | Constraints | 20% | Temperature, fragility, flavor isolation |
62
+ | Neatness | 10% | All items packed? Nothing dropped? |
63
+
64
+ ## 🚀 Quick Start
65
+
66
+ ### Run locally
67
+ ```bash
68
+ pip install -r requirements.txt
69
+ uvicorn server.app:app --host 0.0.0.0 --port 7860
70
+ ```
71
+
72
+ ### Run inference
73
+ ```bash
74
+ export API_BASE_URL=https://api.openai.com/v1
75
+ export MODEL_NAME=gpt-4o
76
+ export HF_TOKEN=your-api-key
77
+ export ENV_URL=http://localhost:7860
78
+ python inference.py
79
+ ```
80
+
81
+ ### Docker
82
+ ```bash
83
+ docker build -t tiffin-packer .
84
+ docker run -p 7860:7860 tiffin-packer
85
+ ```
86
+
87
+ ## 🔧 Action Space
88
+
89
+ ```json
90
+ {
91
+ "command": "identify | pick | place | pour | observe",
92
+ "target_id": 1
93
+ }
94
+ ```
95
+
96
+ ## 👁️ Observation Space
97
+
98
+ ```json
99
+ {
100
+ "scene_description": "Natural language scene state",
101
+ "food_items": [{"id": 1, "name": "rice", "status": "on_table", ...}],
102
+ "containers": [{"id": 1, "type": "sealed_round", "capacity_ml": 300, ...}],
103
+ "held_item": null,
104
+ "vlm_result": {"type": "solid", "fragility": 0.1, ...},
105
+ "available_commands": ["observe", "identify", "pick"],
106
+ "step_feedback": "Successfully picked up rice"
107
+ }
108
+ ```
109
+
110
+ ## 📁 Project Structure
111
+
112
+ ```
113
+ tiffen-packer/
114
+ ├── openenv.yaml # OpenEnv manifest
115
+ ├── inference.py # LLM inference script (OpenAI Client)
116
+ ├── Dockerfile # HF Spaces deployment
117
+ ├── tiffin_packer/ # Core package
118
+ │ ├── models.py # Pydantic Action/Observation/State
119
+ │ ├── simulation/
120
+ │ │ ├── engine.py # Logic simulation engine
121
+ │ │ └── pybullet_renderer.py # Physics visualization
122
+ │ ├── vlm/
123
+ │ │ ├── classifier.py # VLM food classifier
124
+ │ │ └── food_db.json # 15 Indian food items
125
+ │ ├── tasks.py # Easy/Medium/Hard task configs
126
+ │ └── grader.py # Deterministic scoring
127
+ └── server/
128
+ ├── tiffin_environment.py # OpenEnv Environment
129
+ └── app.py # FastAPI server
130
+ ```
131
+
132
+ ## 🏆 OpenEnv Compliance
133
+
134
+ - ✅ Typed Pydantic models (Action, Observation, State)
135
+ - ✅ `step()` / `reset()` / `state()` API
136
+ - ✅ `openenv.yaml` manifest
137
+ - ✅ 3 tasks with deterministic graders (0.0–1.0)
138
+ - ✅ Dense reward function with partial progress signals
139
+ - ✅ Baseline inference script using OpenAI Client
140
+ - ✅ Docker deployment for HF Spaces
141
+
142
+ ## 👥 Team
143
+
144
+ **CtrlAltWin** — Meta PyTorch OpenEnv Hackathon 2026
145
+
146
+ ## 📄 License
147
+
148
+ MIT
inference.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 CtrlAltWin Team
3
+ """
4
+ Tiffin Packer — OpenEnv Inference Script.
5
+
6
+ Runs an LLM agent against the tiffin packing environment using the
7
+ OpenAI Client API with environment variables:
8
+ API_BASE_URL — The API endpoint for the LLM
9
+ MODEL_NAME — The model identifier for inference
10
+ HF_TOKEN — Hugging Face / API key
11
+
12
+ Usage:
13
+ API_BASE_URL=https://api.openai.com/v1 \
14
+ MODEL_NAME=gpt-4o \
15
+ HF_TOKEN=your-key \
16
+ python inference.py
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import sys
22
+ import time
23
+
24
+ import requests
25
+ from openai import OpenAI
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Required environment variables
29
+ # ---------------------------------------------------------------------------
30
+ API_BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
31
+ MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o")
32
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
33
+ ENV_URL = os.environ.get("ENV_URL", "http://localhost:7860")
34
+
35
+ if not HF_TOKEN:
36
+ print("WARNING: HF_TOKEN not set. LLM calls will fail.")
37
+
38
+ client = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # System prompt
42
+ # ---------------------------------------------------------------------------
43
+ SYSTEM_PROMPT = """You are a tiffin packing assistant that controls a robotic arm.
44
+ Your goal: pack Indian meal items into the correct tiffin containers.
45
+
46
+ COMMANDS — respond with ONLY a JSON object, no other text:
47
+ {"command": "observe"} — See the full scene
48
+ {"command": "identify", "target_id": N} — Classify food item N using VLM
49
+ {"command": "pick", "target_id": N} — Pick up food item N
50
+ {"command": "place", "target_id": N} — Place held item into container N
51
+ {"command": "pour", "target_id": N} — Pour held liquid into container N
52
+
53
+ PACKING RULES:
54
+ 1. ALWAYS identify items before packing (you cannot see food properties otherwise)
55
+ 2. Liquids (sambar, dal, rasam, curry) → sealed containers only
56
+ 3. Solids (rice, chapati, idli) → any container type
57
+ 4. Semi-solids (curd, pickle, chutney) → sealed containers preferred
58
+ 5. FRAGILE items (papad=0.9, chapati=0.7) → don't crush under heavy items
59
+ 6. HOT and COLD food must NOT share a container
60
+ 7. Don't overflow containers — check volume math!
61
+ 8. Strong-flavor items (pickle, chutney) should be isolated
62
+
63
+ STRATEGY:
64
+ 1. First: observe the scene
65
+ 2. Then: identify ALL food items (one by one)
66
+ 3. Then: plan which food goes where based on constraints
67
+ 4. Finally: pick and place/pour each item
68
+
69
+ Respond with ONLY valid JSON. No explanation, no markdown, no extra text."""
70
+
71
+
72
+ def parse_action(text: str) -> dict:
73
+ """Parse LLM output into an action dict."""
74
+ text = text.strip()
75
+
76
+ # Try to extract JSON from the text
77
+ if text.startswith("```"):
78
+ # Handle markdown code blocks
79
+ lines = text.split("\n")
80
+ json_lines = [l for l in lines if not l.startswith("```")]
81
+ text = "\n".join(json_lines).strip()
82
+
83
+ # Try direct JSON parse
84
+ try:
85
+ action = json.loads(text)
86
+ if "command" in action:
87
+ return action
88
+ except json.JSONDecodeError:
89
+ pass
90
+
91
+ # Try to find JSON in the text
92
+ for i in range(len(text)):
93
+ if text[i] == "{":
94
+ for j in range(len(text) - 1, i, -1):
95
+ if text[j] == "}":
96
+ try:
97
+ action = json.loads(text[i : j + 1])
98
+ if "command" in action:
99
+ return action
100
+ except json.JSONDecodeError:
101
+ continue
102
+
103
+ # Fallback
104
+ print(f" [WARN] Could not parse action: {text[:100]}")
105
+ return {"command": "observe"}
106
+
107
+
108
+ def run_episode(task_id: str) -> dict:
109
+ """Run one episode of the tiffin packing task."""
110
+ print(f"\n{'='*60}")
111
+ print(f" TASK: {task_id.upper()}")
112
+ print(f"{'='*60}")
113
+
114
+ # Reset the environment
115
+ try:
116
+ resp = requests.post(
117
+ f"{ENV_URL}/reset",
118
+ json={"task_id": task_id, "seed": 42},
119
+ timeout=30,
120
+ )
121
+ resp.raise_for_status()
122
+ result = resp.json()
123
+ obs = result.get("observation", result)
124
+ except Exception as e:
125
+ print(f" ERROR: Failed to reset environment: {e}")
126
+ return {"task_id": task_id, "reward": 0.0, "score": 0.0, "error": str(e)}
127
+
128
+ # Initialize conversation
129
+ init_scene = obs.get("scene_description", "")
130
+ init_feedback = obs.get("step_feedback", "")
131
+ messages = [
132
+ {"role": "system", "content": SYSTEM_PROMPT},
133
+ {
134
+ "role": "user",
135
+ "content": (
136
+ f"Task: {task_id}\n\n"
137
+ f"{init_feedback}\n\n"
138
+ f"Scene:\n{init_scene}\n\n"
139
+ f"Available commands: {obs.get('available_commands', [])}\n\n"
140
+ f"What is your first action? Respond with JSON only."
141
+ ),
142
+ },
143
+ ]
144
+
145
+ total_reward = 0.0
146
+ step = 0
147
+ max_steps = 35 # safety limit
148
+
149
+ while not obs.get("done", False) and step < max_steps:
150
+ step += 1
151
+
152
+ # Get LLM decision
153
+ try:
154
+ response = client.chat.completions.create(
155
+ model=MODEL_NAME,
156
+ messages=messages,
157
+ temperature=0.0,
158
+ max_tokens=200,
159
+ )
160
+ action_text = response.choices[0].message.content.strip()
161
+ except Exception as e:
162
+ print(f" [Step {step}] LLM error: {e}")
163
+ action_text = '{"command": "observe"}'
164
+
165
+ action = parse_action(action_text)
166
+ print(f" [Step {step}] Action: {json.dumps(action)}")
167
+
168
+ # Execute step
169
+ try:
170
+ resp = requests.post(
171
+ f"{ENV_URL}/step",
172
+ json={"action": action},
173
+ timeout=30,
174
+ )
175
+ resp.raise_for_status()
176
+ result = resp.json()
177
+ obs = result.get("observation", result)
178
+ reward = result.get("reward", obs.get("reward", 0.0))
179
+ total_reward += reward or 0
180
+ except Exception as e:
181
+ print(f" [Step {step}] Step error: {e}")
182
+ break
183
+
184
+ # Print feedback
185
+ feedback = obs.get("step_feedback", "")[:200]
186
+ print(f" Reward: {reward:+.2f} | Feedback: {feedback}")
187
+
188
+ # Update conversation with assistant response and new observation
189
+ messages.append({"role": "assistant", "content": action_text})
190
+
191
+ # Build concise next observation for LLM
192
+ held = obs.get("held_item")
193
+ held_str = (
194
+ f"Holding: {held.get('name', 'unknown')}" if held else "Arm: idle"
195
+ )
196
+ items_status = [
197
+ f"[{i['id']}] {i.get('name', '?')} ({i['status']})"
198
+ for i in obs.get("food_items", [])
199
+ ]
200
+ containers_status = [
201
+ f"[{c['id']}] {c['name']} {c.get('fill_percentage',0):.0f}% full"
202
+ for c in obs.get("containers", [])
203
+ ]
204
+
205
+ messages.append(
206
+ {
207
+ "role": "user",
208
+ "content": (
209
+ f"Step {step} result (reward={reward:+.2f}):\n"
210
+ f"Feedback: {obs.get('step_feedback', '')}\n\n"
211
+ f"{held_str}\n"
212
+ f"Items: {', '.join(items_status)}\n"
213
+ f"Containers: {', '.join(containers_status)}\n"
214
+ f"Available: {obs.get('available_commands', [])}\n\n"
215
+ f"{'VLM Result: ' + json.dumps(obs.get('vlm_result')) if obs.get('vlm_result') else ''}\n\n"
216
+ f"Next action? JSON only."
217
+ ),
218
+ },
219
+ )
220
+
221
+ # Extract final score
222
+ final_score = obs.get("metadata", {}).get("final_score", 0.0)
223
+ grade_breakdown = obs.get("metadata", {}).get("grade_breakdown", {})
224
+
225
+ print(f"\n {'─'*40}")
226
+ print(f" Steps taken: {step}")
227
+ print(f" Total reward: {total_reward:+.2f}")
228
+ print(f" Final score: {final_score:.4f}")
229
+ if grade_breakdown:
230
+ print(f" Breakdown:")
231
+ print(f" Validity: {grade_breakdown.get('validity', 0):.4f} (x0.4)")
232
+ print(f" Efficiency: {grade_breakdown.get('efficiency', 0):.4f} (x0.3)")
233
+ print(f" Constraints: {grade_breakdown.get('constraints', 0):.4f} (x0.2)")
234
+ print(f" Neatness: {grade_breakdown.get('neatness', 0):.4f} (x0.1)")
235
+
236
+ return {
237
+ "task_id": task_id,
238
+ "steps": step,
239
+ "total_reward": round(total_reward, 4),
240
+ "score": final_score,
241
+ "grade_breakdown": grade_breakdown,
242
+ }
243
+
244
+
245
+ def main():
246
+ """Run all 3 tasks and report results."""
247
+ print("=" * 60)
248
+ print(" TIFFIN PACKER — INFERENCE SCRIPT")
249
+ print(f" Model: {MODEL_NAME}")
250
+ print(f" API: {API_BASE_URL}")
251
+ print(f" Env: {ENV_URL}")
252
+ print("=" * 60)
253
+
254
+ start_time = time.time()
255
+ results = {}
256
+
257
+ for task_id in ["easy", "medium", "hard"]:
258
+ result = run_episode(task_id)
259
+ results[task_id] = result
260
+
261
+ elapsed = time.time() - start_time
262
+
263
+ # Summary
264
+ print("\n" + "=" * 60)
265
+ print(" FINAL RESULTS")
266
+ print("=" * 60)
267
+ for task_id, r in results.items():
268
+ print(f" {task_id:8s}: score={r['score']:.4f} reward={r['total_reward']:+.2f} steps={r.get('steps', '?')}")
269
+
270
+ avg_score = sum(r["score"] for r in results.values()) / max(len(results), 1)
271
+ print(f"\n Average score: {avg_score:.4f}")
272
+ print(f" Total time: {elapsed:.1f}s")
273
+
274
+ # Save results
275
+ os.makedirs("outputs/evals", exist_ok=True)
276
+ with open("outputs/evals/results.json", "w") as f:
277
+ json.dump(
278
+ {
279
+ "model": MODEL_NAME,
280
+ "api_base_url": API_BASE_URL,
281
+ "results": results,
282
+ "average_score": avg_score,
283
+ "elapsed_seconds": round(elapsed, 1),
284
+ },
285
+ f,
286
+ indent=2,
287
+ )
288
+ print(f"\n Results saved to outputs/evals/results.json")
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: tiffin_packer
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 7860
pyproject.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "tiffin-packer"
3
+ version = "1.0.0"
4
+ description = "Smart Tiffin Packing Environment — OpenEnv compliant RL environment for semantic-aware constrained packing"
5
+ authors = [{name = "CtrlAltWin", email = "team@ctrlaltwin.dev"}]
6
+ license = {text = "MIT"}
7
+ readme = "README.md"
8
+ requires-python = ">=3.9"
9
+ dependencies = [
10
+ "openenv-core>=0.1.0",
11
+ "fastapi>=0.104.0",
12
+ "uvicorn[standard]>=0.24.0",
13
+ "pydantic>=2.0.0",
14
+ "numpy>=1.24.0",
15
+ "requests>=2.28.0",
16
+ "openai>=1.0.0",
17
+ "pybullet>=3.2.5",
18
+ ]
19
+
20
+ [project.scripts]
21
+ tiffin-packer = "server.app:main"
22
+
23
+ [build-system]
24
+ requires = ["setuptools>=68.0", "wheel"]
25
+ build-backend = "setuptools.build_meta"
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["tiffin_packer*", "server*"]
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ openenv-core>=0.1.0
2
+ fastapi>=0.104.0
3
+ uvicorn[standard]>=0.24.0
4
+ pydantic>=2.0.0
5
+ numpy>=1.24.0
6
+ requests>=2.28.0
7
+ openai>=1.0.0
8
+ pybullet>=3.2.5
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Server package
server/app.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ FastAPI application for the Tiffin Packing Environment.
4
+
5
+ Creates an HTTP + WebSocket server exposing the TiffinPackingEnvironment
6
+ via the OpenEnv interface.
7
+
8
+ Usage:
9
+ uvicorn server.app:app --host 0.0.0.0 --port 7860
10
+ """
11
+
12
+ try:
13
+ from openenv.core.env_server.http_server import create_app
14
+ except ImportError:
15
+ from openenv.core.env_server import create_app
16
+
17
+ from tiffin_packer.models import TiffinAction, TiffinObservation
18
+ from server.tiffin_environment import TiffinPackingEnvironment
19
+
20
+ # Create the FastAPI app
21
+ # Pass the class (factory) for WebSocket session support
22
+ app = create_app(
23
+ TiffinPackingEnvironment,
24
+ TiffinAction,
25
+ TiffinObservation,
26
+ env_name="tiffin_packer",
27
+ )
28
+
29
+
30
+ def main():
31
+ """Entry point for direct execution."""
32
+ import uvicorn
33
+
34
+ uvicorn.run(app, host="0.0.0.0", port=7860)
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
server/tiffin_environment.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ Tiffin Packing Environment — OpenEnv Server Implementation.
4
+
5
+ Wraps the packing simulation into the OpenEnv Environment base class,
6
+ exposing step(), reset(), and state() for LLM agent interaction.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+ from uuid import uuid4
13
+
14
+ try:
15
+ from openenv.core.env_server import Environment
16
+ except ImportError:
17
+ # Fallback for local testing without openenv installed
18
+ class Environment:
19
+ def __init__(self, **kwargs): pass
20
+ def reset(self, **kwargs): raise NotImplementedError
21
+ def step(self, action, **kwargs): raise NotImplementedError
22
+ @property
23
+ def state(self): raise NotImplementedError
24
+
25
+ from tiffin_packer.models import TiffinAction, TiffinObservation, TiffinState
26
+ from tiffin_packer.simulation.engine import PackingSimulation
27
+ from tiffin_packer.vlm.classifier import FoodClassifier
28
+ from tiffin_packer.tasks import get_task_config, list_tasks
29
+ from tiffin_packer.grader import grade, grade_detailed
30
+
31
+
32
+ class TiffinPackingEnvironment(Environment):
33
+ """
34
+ OpenEnv-compliant tiffin packing environment.
35
+
36
+ An LLM agent controls a robotic arm to identify food items using VLM
37
+ and pack them into the correct tiffin containers under real-world
38
+ constraints (type compatibility, volume, temperature, fragility).
39
+
40
+ Supports 3 tasks: easy, medium, hard.
41
+ """
42
+
43
+ def __init__(self):
44
+ super().__init__()
45
+ self.sim = PackingSimulation()
46
+ self.vlm = FoodClassifier()
47
+ self._state = TiffinState()
48
+ self._identified_items: set = set()
49
+ self._task_config = None
50
+
51
+ def reset(
52
+ self,
53
+ seed: Optional[int] = None,
54
+ episode_id: Optional[str] = None,
55
+ **kwargs: Any,
56
+ ) -> TiffinObservation:
57
+ """
58
+ Reset the environment for a new episode.
59
+
60
+ Args:
61
+ seed: Optional random seed for reproducibility.
62
+ episode_id: Optional custom episode ID.
63
+ **kwargs: Must include 'task_id' (easy/medium/hard).
64
+
65
+ Returns:
66
+ Initial TiffinObservation with scene description.
67
+ """
68
+ task_id = kwargs.get("task_id", "easy")
69
+
70
+ # Load task configuration
71
+ self._task_config = get_task_config(task_id, seed=seed)
72
+
73
+ # Reset simulation
74
+ self.sim.reset(
75
+ food_items=self._task_config.food_items,
76
+ containers=self._task_config.containers,
77
+ seed=seed,
78
+ )
79
+
80
+ # Reset state
81
+ self._state = TiffinState(
82
+ episode_id=episode_id or str(uuid4()),
83
+ step_count=0,
84
+ task_id=task_id,
85
+ items_packed=0,
86
+ total_items=len(self._task_config.food_items),
87
+ items_identified=0,
88
+ packing_log=[],
89
+ constraints_violated=[],
90
+ )
91
+ self._identified_items = set()
92
+
93
+ # Build initial observation
94
+ return self._build_observation(
95
+ reward=0.0,
96
+ done=False,
97
+ feedback=(
98
+ f"Episode started! Task: {task_id.upper()}\n\n"
99
+ f"{self._task_config.description}\n\n"
100
+ f"You have {self._task_config.max_steps} steps to pack "
101
+ f"{len(self._task_config.food_items)} food items into "
102
+ f"{len(self._task_config.containers)} containers.\n\n"
103
+ f"Start by using 'observe' to see the scene, then 'identify' "
104
+ f"each food item before packing."
105
+ ),
106
+ )
107
+
108
+ def step(
109
+ self,
110
+ action: TiffinAction,
111
+ timeout_s: Optional[float] = None,
112
+ **kwargs: Any,
113
+ ) -> TiffinObservation:
114
+ """
115
+ Execute one step in the environment.
116
+
117
+ Args:
118
+ action: TiffinAction with command and optional target_id.
119
+ timeout_s: Optional timeout (unused).
120
+
121
+ Returns:
122
+ TiffinObservation with updated scene state.
123
+ """
124
+ self._state.step_count += 1
125
+ reward = 0.0
126
+ done = False
127
+ vlm_result = None
128
+ feedback = ""
129
+
130
+ command = action.command.lower().strip()
131
+ target_id = action.target_id
132
+
133
+ # --- Dispatch command ---
134
+ if command == "observe":
135
+ _, feedback, reward = self.sim.observe()
136
+
137
+ elif command == "identify":
138
+ if target_id is None:
139
+ feedback = "Error: 'identify' requires a target_id (food item ID)."
140
+ reward = -0.1
141
+ else:
142
+ success, feedback, reward, vlm_result = self.sim.identify(target_id)
143
+ if success and vlm_result and vlm_result.get("name"):
144
+ self._identified_items.add(target_id)
145
+ self._state.items_identified = len(self._identified_items)
146
+
147
+ elif command == "pick":
148
+ if target_id is None:
149
+ feedback = "Error: 'pick' requires a target_id (food item ID)."
150
+ reward = -0.1
151
+ else:
152
+ success, feedback, reward = self.sim.pick(target_id)
153
+
154
+ elif command == "place":
155
+ if target_id is None:
156
+ feedback = "Error: 'place' requires a target_id (container ID)."
157
+ reward = -0.1
158
+ else:
159
+ success, feedback, reward = self.sim.place(target_id)
160
+ if success:
161
+ self._state.items_packed = sum(
162
+ 1
163
+ for i in self.sim.food_items
164
+ if i.status == "packed"
165
+ )
166
+ self._state.packing_log = list(self.sim.packing_log)
167
+
168
+ elif command == "pour":
169
+ if target_id is None:
170
+ feedback = "Error: 'pour' requires a target_id (container ID)."
171
+ reward = -0.1
172
+ else:
173
+ success, feedback, reward = self.sim.pour(target_id)
174
+ if success:
175
+ self._state.items_packed = sum(
176
+ 1
177
+ for i in self.sim.food_items
178
+ if i.status == "packed"
179
+ )
180
+ self._state.packing_log = list(self.sim.packing_log)
181
+
182
+ else:
183
+ feedback = (
184
+ f"Unknown command: '{command}'. "
185
+ f"Available commands: {self.sim.get_available_commands()}"
186
+ )
187
+ reward = -0.1
188
+
189
+ # --- Time penalty ---
190
+ reward -= 0.02
191
+
192
+ # --- Check termination ---
193
+ done = (
194
+ self.sim.all_packed
195
+ or self._state.step_count >= self._task_config.max_steps
196
+ )
197
+
198
+ # --- Final grading ---
199
+ final_score = None
200
+ grade_breakdown = None
201
+ if done:
202
+ grade_breakdown = grade_detailed(
203
+ self._state.packing_log, self._task_config
204
+ )
205
+ final_score = grade_breakdown["final_score"]
206
+ reward += final_score # bonus = final grade
207
+
208
+ if self.sim.all_packed:
209
+ feedback += f"\n\n🎉 All items packed! Final score: {final_score:.4f}"
210
+ else:
211
+ feedback += (
212
+ f"\n\n⏰ Time's up! {self.sim.unpacked_count} items remaining. "
213
+ f"Final score: {final_score:.4f}"
214
+ )
215
+
216
+ return self._build_observation(
217
+ reward=reward,
218
+ done=done,
219
+ feedback=feedback,
220
+ vlm_result=vlm_result,
221
+ final_score=final_score,
222
+ grade_breakdown=grade_breakdown,
223
+ )
224
+
225
+ @property
226
+ def state(self) -> TiffinState:
227
+ """Return the current episode state."""
228
+ return self._state
229
+
230
+ # -------------------------------------------------------------------
231
+ # Helpers
232
+ # -------------------------------------------------------------------
233
+
234
+ def _build_observation(
235
+ self,
236
+ reward: float = 0.0,
237
+ done: bool = False,
238
+ feedback: str = "",
239
+ vlm_result: dict = None,
240
+ final_score: float = None,
241
+ grade_breakdown: dict = None,
242
+ ) -> TiffinObservation:
243
+ """Build a TiffinObservation from current state."""
244
+ metadata = {}
245
+ if final_score is not None:
246
+ metadata["final_score"] = final_score
247
+ if grade_breakdown is not None:
248
+ metadata["grade_breakdown"] = grade_breakdown
249
+
250
+ return TiffinObservation(
251
+ done=done,
252
+ reward=round(reward, 4),
253
+ metadata=metadata,
254
+ scene_description=self.sim.get_scene_description(),
255
+ food_items=[
256
+ item.to_dict(hide_unidentified=True)
257
+ for item in self.sim.food_items
258
+ ],
259
+ containers=[c.to_dict() for c in self.sim.containers],
260
+ held_item=(
261
+ self.sim.held_item.to_dict(hide_unidentified=False)
262
+ if self.sim.held_item
263
+ else None
264
+ ),
265
+ vlm_result=vlm_result,
266
+ available_commands=self.sim.get_available_commands(),
267
+ step_feedback=feedback,
268
+ )
tiffin_packer/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ # Smart Tiffin Packing Environment for OpenEnv
3
+
4
+ """
5
+ Tiffin Packer — A multimodal RL environment for semantic-aware
6
+ constrained packing tasks inspired by real-world Indian meal organization.
7
+
8
+ An LLM agent controls a robotic arm to identify food items (via VLM),
9
+ reason about container compatibility, and pack a complete Indian meal
10
+ into tiffin containers.
11
+ """
12
+
13
+ from .models import TiffinAction, TiffinObservation, TiffinState
14
+
15
+ try:
16
+ from .client import TiffinEnv
17
+ except ImportError:
18
+ TiffinEnv = None # Client requires openenv-core
19
+
20
+ __all__ = ["TiffinAction", "TiffinObservation", "TiffinState", "TiffinEnv"]
tiffin_packer/client.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ Tiffin Packer Environment Client.
4
+
5
+ Provides the client for connecting to a running TiffinPackingEnvironment server.
6
+ """
7
+
8
+ try:
9
+ from openenv.core.env_client import EnvClient
10
+ except ImportError:
11
+ # Fallback if openenv not installed
12
+ EnvClient = object
13
+
14
+ from .models import TiffinAction, TiffinObservation
15
+
16
+
17
+ class TiffinEnv(EnvClient):
18
+ """
19
+ Client for the Tiffin Packing Environment.
20
+
21
+ Example:
22
+ >>> with TiffinEnv(base_url="http://localhost:7860").sync() as env:
23
+ ... obs = env.reset(task_id="easy")
24
+ ... obs = env.step(TiffinAction(command="observe"))
25
+ ... print(obs.scene_description)
26
+ """
27
+
28
+ pass # EnvClient provides all needed functionality
tiffin_packer/grader.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ Deterministic Grader — Scores packing quality from 0.0 to 1.0.
4
+
5
+ Scoring formula:
6
+ score = 0.4 * validity + 0.3 * efficiency + 0.2 * constraints + 0.1 * neatness
7
+
8
+ Each component:
9
+ validity — food placed in type-compatible container?
10
+ efficiency — space utilization vs total capacity used
11
+ constraints — temperature separation, fragility, flavor isolation
12
+ neatness — all items packed? nothing dropped?
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from .tasks import TaskConfig
20
+ from .simulation.engine import is_type_compatible
21
+
22
+
23
+ def grade(
24
+ packing_log: List[Dict[str, Any]],
25
+ task_config: TaskConfig,
26
+ ) -> float:
27
+ """
28
+ Grade a packing episode. Returns score between 0.0 and 1.0.
29
+
30
+ Args:
31
+ packing_log: List of placement records from the simulation.
32
+ task_config: The task configuration used for this episode.
33
+
34
+ Returns:
35
+ Final score (0.0 to 1.0), rounded to 4 decimal places.
36
+ """
37
+ total_items = len(task_config.food_items)
38
+
39
+ if total_items == 0:
40
+ return 0.0
41
+
42
+ # ---- Validity (40%) ----
43
+ validity = _score_validity(packing_log, total_items)
44
+
45
+ # ---- Efficiency (30%) ----
46
+ efficiency = _score_efficiency(packing_log, task_config)
47
+
48
+ # ---- Constraint Satisfaction (20%) ----
49
+ constraints = _score_constraints(packing_log, task_config)
50
+
51
+ # ---- Neatness (10%) ----
52
+ neatness = _score_neatness(packing_log, total_items)
53
+
54
+ # ---- Final score ----
55
+ score = 0.4 * validity + 0.3 * efficiency + 0.2 * constraints + 0.1 * neatness
56
+ return round(max(0.0, min(1.0, score)), 4)
57
+
58
+
59
+ def grade_detailed(
60
+ packing_log: List[Dict[str, Any]],
61
+ task_config: TaskConfig,
62
+ ) -> Dict[str, Any]:
63
+ """Grade with full breakdown for debugging."""
64
+ total_items = len(task_config.food_items)
65
+
66
+ validity = _score_validity(packing_log, total_items)
67
+ efficiency = _score_efficiency(packing_log, task_config)
68
+ constraints = _score_constraints(packing_log, task_config)
69
+ neatness = _score_neatness(packing_log, total_items)
70
+
71
+ score = 0.4 * validity + 0.3 * efficiency + 0.2 * constraints + 0.1 * neatness
72
+ score = round(max(0.0, min(1.0, score)), 4)
73
+
74
+ return {
75
+ "final_score": score,
76
+ "validity": round(validity, 4),
77
+ "efficiency": round(efficiency, 4),
78
+ "constraints": round(constraints, 4),
79
+ "neatness": round(neatness, 4),
80
+ "items_packed": len(packing_log),
81
+ "total_items": total_items,
82
+ "weights": {
83
+ "validity": 0.4,
84
+ "efficiency": 0.3,
85
+ "constraints": 0.2,
86
+ "neatness": 0.1,
87
+ },
88
+ }
89
+
90
+
91
+ # -----------------------------------------------------------------------
92
+ # Component scorers
93
+ # -----------------------------------------------------------------------
94
+
95
+
96
+ def _score_validity(packing_log: List[Dict], total_items: int) -> float:
97
+ """Score: food placed in type-compatible container? (0-1)"""
98
+ if not packing_log:
99
+ return 0.0
100
+
101
+ correct = sum(1 for entry in packing_log if entry.get("type_compatible", False))
102
+ return correct / max(total_items, 1)
103
+
104
+
105
+ def _score_efficiency(packing_log: List[Dict], task_config: TaskConfig) -> float:
106
+ """Score: how well is container space utilized? (0-1)"""
107
+ if not packing_log:
108
+ return 0.0
109
+
110
+ total_food_vol = sum(entry.get("food_volume", 0) for entry in packing_log)
111
+
112
+ # Find which containers were used
113
+ used_container_ids = set(entry.get("container_id") for entry in packing_log)
114
+ total_capacity = sum(
115
+ c.capacity_ml
116
+ for c in task_config.containers
117
+ if c.id in used_container_ids
118
+ )
119
+
120
+ if total_capacity == 0:
121
+ return 0.0
122
+
123
+ utilization = total_food_vol / total_capacity
124
+
125
+ # Penalize overflow
126
+ overflow_count = sum(1 for entry in packing_log if entry.get("overflow", False))
127
+ if overflow_count > 0:
128
+ utilization *= max(0.3, 1.0 - 0.2 * overflow_count)
129
+
130
+ return min(1.0, utilization)
131
+
132
+
133
+ def _score_constraints(packing_log: List[Dict], task_config: TaskConfig) -> float:
134
+ """Score: task-specific constraints satisfied? (0-1)"""
135
+ if not packing_log:
136
+ return 0.0
137
+
138
+ scores = []
139
+ active = set(task_config.constraints)
140
+
141
+ if "temperature_separation" in active:
142
+ scores.append(_check_temperature(packing_log))
143
+
144
+ if "fragility_ordering" in active:
145
+ scores.append(_check_fragility(packing_log))
146
+
147
+ if "flavor_isolation" in active:
148
+ scores.append(_check_flavor_isolation(packing_log))
149
+
150
+ if "no_overflow" in active:
151
+ overflow_count = sum(1 for e in packing_log if e.get("overflow", False))
152
+ scores.append(1.0 if overflow_count == 0 else max(0.0, 1.0 - 0.3 * overflow_count))
153
+
154
+ if "type_match" in active:
155
+ correct = sum(1 for e in packing_log if e.get("type_compatible", False))
156
+ scores.append(correct / max(len(packing_log), 1))
157
+
158
+ if not scores:
159
+ return 1.0 # no constraints to violate
160
+
161
+ return sum(scores) / len(scores)
162
+
163
+
164
+ def _check_temperature(packing_log: List[Dict]) -> float:
165
+ """Check if hot and cold items are kept separate."""
166
+ # Group items by container
167
+ container_temps: Dict[int, List[str]] = {}
168
+ for entry in packing_log:
169
+ cid = entry.get("container_id")
170
+ temp = entry.get("food_temperature", "room")
171
+ container_temps.setdefault(cid, []).append(temp)
172
+
173
+ violations = 0
174
+ total_containers = len(container_temps)
175
+ for temps in container_temps.values():
176
+ if "hot" in temps and "cold" in temps:
177
+ violations += 1
178
+
179
+ if total_containers == 0:
180
+ return 1.0
181
+ return max(0.0, 1.0 - violations / total_containers)
182
+
183
+
184
+ def _check_fragility(packing_log: List[Dict]) -> float:
185
+ """Check if fragile items are not crushed by heavy items placed after them."""
186
+ # Group by container, check placement order
187
+ container_order: Dict[int, List[float]] = {}
188
+ for entry in packing_log:
189
+ cid = entry.get("container_id")
190
+ frag = entry.get("food_fragility", 0.5)
191
+ container_order.setdefault(cid, []).append(frag)
192
+
193
+ violations = 0
194
+ checks = 0
195
+ for fragilites in container_order.values():
196
+ for i in range(1, len(fragilites)):
197
+ checks += 1
198
+ # If a less fragile (heavy) item is placed AFTER a more fragile item
199
+ if fragilites[i] < 0.4 and fragilites[i - 1] > 0.6:
200
+ violations += 1
201
+
202
+ if checks == 0:
203
+ return 1.0
204
+ return max(0.0, 1.0 - violations / max(checks, 1))
205
+
206
+
207
+ def _check_flavor_isolation(packing_log: List[Dict]) -> float:
208
+ """Check that strong-flavor items (pickle, chutney) are isolated."""
209
+ strong_flavors = {"pickle", "chutney"}
210
+ # Group by container
211
+ container_contents: Dict[int, List[str]] = {}
212
+ for entry in packing_log:
213
+ cid = entry.get("container_id")
214
+ name = entry.get("food_name", "")
215
+ container_contents.setdefault(cid, []).append(name)
216
+
217
+ violations = 0
218
+ total = 0
219
+ for contents in container_contents.values():
220
+ has_strong = any(c in strong_flavors for c in contents)
221
+ has_others = any(c not in strong_flavors for c in contents)
222
+ if has_strong and has_others and len(contents) > 1:
223
+ violations += 1
224
+ total += 1
225
+ elif has_strong:
226
+ total += 1
227
+
228
+ if total == 0:
229
+ return 1.0
230
+ return max(0.0, 1.0 - violations / max(total, 1))
231
+
232
+
233
+ def _score_neatness(packing_log: List[Dict], total_items: int) -> float:
234
+ """Score: fraction of items successfully packed. (0-1)"""
235
+ if total_items == 0:
236
+ return 0.0
237
+ return len(packing_log) / total_items
tiffin_packer/models.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ # Smart Tiffin Packing Environment — Pydantic Models
3
+
4
+ """
5
+ Typed data models for the Tiffin Packing OpenEnv environment.
6
+ Follows the OpenEnv specification with Action, Observation, and State base classes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from pydantic import Field
14
+
15
+ try:
16
+ from openenv.core.env_server import Action, Observation, State
17
+ except ImportError:
18
+ try:
19
+ from openenv.core.env_server.types import Action, Observation, State
20
+ except ImportError:
21
+ # Fallback: define compatible base classes when openenv is not installed
22
+ from pydantic import BaseModel, ConfigDict
23
+
24
+ class Action(BaseModel):
25
+ model_config = ConfigDict(extra="forbid", validate_assignment=True, arbitrary_types_allowed=True)
26
+ metadata: Dict[str, Any] = Field(default_factory=dict)
27
+
28
+ class Observation(BaseModel):
29
+ model_config = ConfigDict(extra="forbid", validate_assignment=True, arbitrary_types_allowed=True)
30
+ done: bool = Field(default=False)
31
+ reward: Optional[float] = Field(default=None)
32
+ metadata: Dict[str, Any] = Field(default_factory=dict)
33
+
34
+ class State(BaseModel):
35
+ model_config = ConfigDict(extra="allow", validate_assignment=True, arbitrary_types_allowed=True)
36
+ episode_id: Optional[str] = Field(default=None)
37
+ step_count: int = Field(default=0)
38
+
39
+
40
+ class TiffinAction(Action):
41
+ """
42
+ High-level command the LLM agent issues to the robotic arm.
43
+
44
+ Available commands:
45
+ - "observe" : Get a full scene description (no target_id needed)
46
+ - "identify" : Use VLM to classify a food item (target_id = food item ID)
47
+ - "pick" : Pick up a food item with the robotic arm (target_id = food item ID)
48
+ - "place" : Place the currently held item into a container (target_id = container ID)
49
+ - "pour" : Pour liquid from held bowl into a container (target_id = container ID)
50
+
51
+ Attributes:
52
+ command: The action command string.
53
+ target_id: The ID of the food item or container to act on.
54
+ """
55
+
56
+ command: str = Field(
57
+ description="One of: 'observe', 'identify', 'pick', 'place', 'pour'"
58
+ )
59
+ target_id: Optional[int] = Field(
60
+ default=None,
61
+ description="ID of food item (for identify/pick) or container (for place/pour)",
62
+ )
63
+
64
+
65
+ class TiffinObservation(Observation):
66
+ """
67
+ Observation returned after each action.
68
+
69
+ Contains a natural-language scene description, structured data about
70
+ food items and containers, and feedback on the last action.
71
+
72
+ Attributes:
73
+ scene_description: Human-readable text describing the current scene.
74
+ food_items: List of food item dicts with id, name, status, etc.
75
+ containers: List of container dicts with id, type, capacity, contents.
76
+ held_item: The food item currently held by the robotic arm, if any.
77
+ vlm_result: VLM classification result after an 'identify' command.
78
+ available_commands: Commands the agent can issue right now.
79
+ step_feedback: Text feedback on the outcome of the last action.
80
+ """
81
+
82
+ scene_description: str = Field(
83
+ default="", description="Natural language description of current scene state"
84
+ )
85
+ food_items: List[Dict[str, Any]] = Field(
86
+ default_factory=list,
87
+ description="List of food items: [{id, name, status, position}]",
88
+ )
89
+ containers: List[Dict[str, Any]] = Field(
90
+ default_factory=list,
91
+ description="List of containers: [{id, type, capacity_ml, filled_ml, contents}]",
92
+ )
93
+ held_item: Optional[Dict[str, Any]] = Field(
94
+ default=None,
95
+ description="Currently held food item, or None if gripper is empty",
96
+ )
97
+ vlm_result: Optional[Dict[str, Any]] = Field(
98
+ default=None,
99
+ description="VLM classification result after 'identify' command",
100
+ )
101
+ available_commands: List[str] = Field(
102
+ default_factory=list,
103
+ description="Valid commands the agent can issue right now",
104
+ )
105
+ step_feedback: str = Field(
106
+ default="", description="Feedback on the last action (success/failure reason)"
107
+ )
108
+
109
+
110
+ class TiffinState(State):
111
+ """
112
+ Internal episode state for tracking progress.
113
+
114
+ Attributes:
115
+ task_id: Which task is active (easy/medium/hard).
116
+ items_packed: Number of items successfully packed.
117
+ total_items: Total items that need to be packed.
118
+ items_identified: Number of items that have been VLM-classified.
119
+ packing_log: Record of each placement decision.
120
+ constraints_violated: List of constraint violations.
121
+ """
122
+
123
+ task_id: str = Field(default="easy", description="Active task ID")
124
+ items_packed: int = Field(default=0, description="Items successfully packed")
125
+ total_items: int = Field(default=0, description="Total items to pack")
126
+ items_identified: int = Field(default=0, description="Items VLM-classified")
127
+ packing_log: List[Dict[str, Any]] = Field(
128
+ default_factory=list, description="Record of placement decisions"
129
+ )
130
+ constraints_violated: List[str] = Field(
131
+ default_factory=list, description="Constraint violations"
132
+ )
tiffin_packer/simulation/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .engine import PackingSimulation
2
+
3
+ __all__ = ["PackingSimulation"]
tiffin_packer/simulation/engine.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ Tiffin Packing Simulation Engine — Pure Logic + PyBullet Physics.
4
+
5
+ This module implements the core packing simulation. It operates in two modes:
6
+
7
+ 1. **Logic mode** (default): Pure Python state tracking — fast, lightweight,
8
+ guaranteed to run on 2 vCPU / 8 GB RAM. Used for all OpenEnv interactions.
9
+
10
+ 2. **Physics mode** (optional): PyBullet simulation with real URDF models
11
+ (Kuka arm, table, containers, food cubes/spheres). Used for rendering
12
+ and visual validation.
13
+
14
+ The LLM agent issues high-level commands (pick, place, pour, identify),
15
+ and this engine validates and executes them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ import random
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Data structures
28
+ # ---------------------------------------------------------------------------
29
+
30
+ @dataclass
31
+ class FoodItem:
32
+ """Represents a food item on the table."""
33
+
34
+ id: int
35
+ name: str
36
+ food_type: str # "solid" | "liquid" | "semi-solid"
37
+ volume_ml: float
38
+ temperature: str # "hot" | "cold" | "room"
39
+ fragility: float # 0.0 (sturdy) to 1.0 (very fragile)
40
+ preferred_container: str # "sealed" | "flat" | "deep"
41
+ color: str = "unknown"
42
+ special_notes: str = ""
43
+ status: str = "on_table" # "on_table" | "held" | "packed" | "dropped"
44
+ identified: bool = False
45
+ position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
46
+
47
+ def to_dict(self, hide_unidentified: bool = True) -> Dict[str, Any]:
48
+ """Convert to observation dict. Hides properties if not yet identified."""
49
+ base = {"id": self.id, "status": self.status}
50
+ if self.identified or not hide_unidentified:
51
+ base.update(
52
+ {
53
+ "name": self.name,
54
+ "food_type": self.food_type,
55
+ "volume_ml": self.volume_ml,
56
+ "temperature": self.temperature,
57
+ "fragility": self.fragility,
58
+ "preferred_container": self.preferred_container,
59
+ "color": self.color,
60
+ }
61
+ )
62
+ else:
63
+ base["name"] = f"Unknown food item #{self.id}"
64
+ base["food_type"] = "unknown"
65
+ base["hint"] = "Use 'identify' command to classify this item"
66
+ return base
67
+
68
+
69
+ @dataclass
70
+ class Container:
71
+ """Represents a tiffin container."""
72
+
73
+ id: int
74
+ name: str
75
+ container_type: str # "sealed_round" | "flat_open" | "deep_box" | "small_sealed"
76
+ capacity_ml: float
77
+ filled_ml: float = 0.0
78
+ contents: List[str] = field(default_factory=list)
79
+ content_types: List[str] = field(default_factory=list) # food types inside
80
+ content_temperatures: List[str] = field(default_factory=list)
81
+ content_fragilites: List[float] = field(default_factory=list)
82
+ position: Tuple[float, float, float] = (0.0, 0.0, 0.0)
83
+
84
+ @property
85
+ def remaining_ml(self) -> float:
86
+ return max(0, self.capacity_ml - self.filled_ml)
87
+
88
+ @property
89
+ def fill_percentage(self) -> float:
90
+ return (self.filled_ml / self.capacity_ml) * 100 if self.capacity_ml > 0 else 0
91
+
92
+ @property
93
+ def accepts_liquid(self) -> bool:
94
+ """Sealed containers can hold liquids."""
95
+ return "sealed" in self.container_type
96
+
97
+ @property
98
+ def is_flat(self) -> bool:
99
+ return "flat" in self.container_type
100
+
101
+ def to_dict(self) -> Dict[str, Any]:
102
+ return {
103
+ "id": self.id,
104
+ "name": self.name,
105
+ "type": self.container_type,
106
+ "capacity_ml": self.capacity_ml,
107
+ "filled_ml": round(self.filled_ml, 1),
108
+ "remaining_ml": round(self.remaining_ml, 1),
109
+ "fill_percentage": round(self.fill_percentage, 1),
110
+ "contents": self.contents,
111
+ "accepts_liquid": self.accepts_liquid,
112
+ }
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Compatibility rules
117
+ # ---------------------------------------------------------------------------
118
+
119
+ CONTAINER_TYPE_COMPATIBILITY = {
120
+ # food_type -> set of compatible container_types
121
+ "liquid": {"sealed_round", "small_sealed"},
122
+ "semi-solid": {"sealed_round", "small_sealed", "deep_box"},
123
+ "solid": {"sealed_round", "flat_open", "deep_box", "small_sealed"},
124
+ }
125
+
126
+
127
+ def is_type_compatible(food_type: str, container_type: str) -> bool:
128
+ """Check if a food type is compatible with a container type."""
129
+ compatible = CONTAINER_TYPE_COMPATIBILITY.get(food_type, set())
130
+ return container_type in compatible
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Main simulation engine
135
+ # ---------------------------------------------------------------------------
136
+
137
+ class PackingSimulation:
138
+ """
139
+ Pure-logic tiffin packing simulation.
140
+
141
+ Models a robotic arm, food items, and containers as data structures.
142
+ Validates all actions against physical constraints (volume, type
143
+ compatibility, temperature zones, fragility ordering).
144
+ """
145
+
146
+ def __init__(self):
147
+ self.arm_state: str = "idle" # "idle" | "holding"
148
+ self.held_item: Optional[FoodItem] = None
149
+ self.food_items: List[FoodItem] = []
150
+ self.containers: List[Container] = []
151
+ self.packing_log: List[Dict[str, Any]] = []
152
+ self._step_count: int = 0
153
+
154
+ def reset(
155
+ self,
156
+ food_items: List[FoodItem],
157
+ containers: List[Container],
158
+ seed: Optional[int] = None,
159
+ ):
160
+ """Initialize simulation with food items and containers."""
161
+ if seed is not None:
162
+ random.seed(seed)
163
+
164
+ self.arm_state = "idle"
165
+ self.held_item = None
166
+ self.food_items = food_items
167
+ self.containers = containers
168
+ self.packing_log = []
169
+ self._step_count = 0
170
+
171
+ # Randomize positions on table
172
+ for i, item in enumerate(self.food_items):
173
+ angle = (2 * math.pi * i) / max(len(self.food_items), 1)
174
+ item.position = (
175
+ 0.3 * math.cos(angle),
176
+ 0.3 * math.sin(angle),
177
+ 0.65, # table height
178
+ )
179
+
180
+ for i, container in enumerate(self.containers):
181
+ container.position = (
182
+ -0.4 + 0.25 * i,
183
+ 0.5,
184
+ 0.65,
185
+ )
186
+
187
+ # -------------------------------------------------------------------
188
+ # Actions
189
+ # -------------------------------------------------------------------
190
+
191
+ def observe(self) -> Tuple[bool, str, float]:
192
+ """Get detailed scene description. Returns (success, feedback, reward)."""
193
+ desc = self.get_scene_description()
194
+ return True, desc, 0.05 # small reward for observing
195
+
196
+ def identify(self, item_id: int) -> Tuple[bool, str, float, Optional[Dict]]:
197
+ """
198
+ Classify a food item using VLM.
199
+ Returns (success, feedback, reward, vlm_result_or_None).
200
+ """
201
+ item = self._find_item(item_id)
202
+ if item is None:
203
+ return False, f"No food item with ID {item_id} found.", -0.1, None
204
+
205
+ if item.status == "packed":
206
+ return (
207
+ False,
208
+ f"Item #{item_id} ({item.name}) is already packed.",
209
+ -0.05,
210
+ None,
211
+ )
212
+
213
+ if item.identified:
214
+ # Re-identifying is allowed but gives no reward
215
+ vlm_result = {
216
+ "name": item.name,
217
+ "type": item.food_type,
218
+ "fragility": item.fragility,
219
+ "preferred_container": item.preferred_container,
220
+ "volume_ml": item.volume_ml,
221
+ "temperature": item.temperature,
222
+ "color": item.color,
223
+ "special_notes": item.special_notes,
224
+ }
225
+ return (
226
+ True,
227
+ f"Item #{item_id} already identified as '{item.name}'. {item.special_notes}",
228
+ 0.0,
229
+ vlm_result,
230
+ )
231
+
232
+ # First-time identification
233
+ item.identified = True
234
+ vlm_result = {
235
+ "name": item.name,
236
+ "type": item.food_type,
237
+ "fragility": item.fragility,
238
+ "preferred_container": item.preferred_container,
239
+ "volume_ml": item.volume_ml,
240
+ "temperature": item.temperature,
241
+ "color": item.color,
242
+ "special_notes": item.special_notes,
243
+ }
244
+ return (
245
+ True,
246
+ f"VLM identified item #{item_id}: '{item.name}' — "
247
+ f"type={item.food_type}, volume={item.volume_ml}ml, "
248
+ f"temperature={item.temperature}, fragility={item.fragility:.1f}, "
249
+ f"preferred container={item.preferred_container}. "
250
+ f"Note: {item.special_notes}",
251
+ 0.1, # reward for gathering information
252
+ vlm_result,
253
+ )
254
+
255
+ def pick(self, item_id: int) -> Tuple[bool, str, float]:
256
+ """Pick up a food item. Returns (success, feedback, reward)."""
257
+ if self.arm_state == "holding":
258
+ return (
259
+ False,
260
+ f"Arm is already holding '{self.held_item.name}'. "
261
+ f"Place or pour it first before picking another item.",
262
+ -0.1,
263
+ )
264
+
265
+ item = self._find_item(item_id)
266
+ if item is None:
267
+ return False, f"No food item with ID {item_id} found.", -0.1
268
+
269
+ if item.status != "on_table":
270
+ return (
271
+ False,
272
+ f"Item #{item_id} ({item.name}) cannot be picked — status is '{item.status}'.",
273
+ -0.1,
274
+ )
275
+
276
+ # Success — pick up the item
277
+ item.status = "held"
278
+ self.held_item = item
279
+ self.arm_state = "holding"
280
+ return (
281
+ True,
282
+ f"Successfully picked up item #{item_id} "
283
+ f"({'identified as ' + item.name if item.identified else 'unidentified'}).",
284
+ 0.3,
285
+ )
286
+
287
+ def place(self, container_id: int) -> Tuple[bool, str, float]:
288
+ """Place held item into container. Returns (success, feedback, reward)."""
289
+ if self.arm_state != "holding" or self.held_item is None:
290
+ return (
291
+ False,
292
+ "Arm is not holding any item. Use 'pick' first.",
293
+ -0.1,
294
+ )
295
+
296
+ container = self._find_container(container_id)
297
+ if container is None:
298
+ return False, f"No container with ID {container_id} found.", -0.1
299
+
300
+ item = self.held_item
301
+ reward = 0.0
302
+ feedback_parts = []
303
+
304
+ # --- Check type compatibility ---
305
+ type_ok = is_type_compatible(item.food_type, container.container_type)
306
+ if not type_ok:
307
+ reward -= 1.5
308
+ feedback_parts.append(
309
+ f"WARNING: {item.food_type} food in {container.container_type} "
310
+ f"container is incompatible! (e.g. liquid will spill from open container)"
311
+ )
312
+
313
+ # --- Check volume overflow ---
314
+ if item.volume_ml > container.remaining_ml:
315
+ overflow = item.volume_ml - container.remaining_ml
316
+ reward -= 1.0
317
+ feedback_parts.append(
318
+ f"WARNING: Overflow! Item needs {item.volume_ml}ml but container "
319
+ f"only has {container.remaining_ml:.0f}ml remaining. "
320
+ f"Overflow of {overflow:.0f}ml!"
321
+ )
322
+
323
+ # --- Check temperature mixing ---
324
+ if container.content_temperatures:
325
+ existing_temps = set(container.content_temperatures)
326
+ if item.temperature == "hot" and "cold" in existing_temps:
327
+ reward -= 0.5
328
+ feedback_parts.append(
329
+ "WARNING: Placing hot food with cold items! "
330
+ "Temperature contamination will occur."
331
+ )
332
+ elif item.temperature == "cold" and "hot" in existing_temps:
333
+ reward -= 0.5
334
+ feedback_parts.append(
335
+ "WARNING: Placing cold food with hot items! "
336
+ "Temperature contamination will occur."
337
+ )
338
+
339
+ # --- Check fragility ---
340
+ if container.content_fragilites and item.fragility < 0.5:
341
+ # Placing heavy/sturdy item — check if fragile items are under
342
+ max_existing_fragility = max(container.content_fragilites)
343
+ if max_existing_fragility > 0.6:
344
+ reward -= 0.3
345
+ feedback_parts.append(
346
+ f"WARNING: Placing sturdy item on top of fragile item "
347
+ f"(fragility {max_existing_fragility:.1f}) — may crush it!"
348
+ )
349
+
350
+ # --- Positive rewards ---
351
+ if type_ok:
352
+ reward += 1.5 # correct container type
353
+ if container.container_type == item.preferred_container or (
354
+ item.preferred_container in container.container_type
355
+ ):
356
+ reward += 0.5 # preferred container bonus
357
+ feedback_parts.append("Great choice — matches preferred container type!")
358
+
359
+ if item.volume_ml <= container.remaining_ml:
360
+ # Good volume fit
361
+ utilization = item.volume_ml / container.capacity_ml
362
+ reward += 0.3 * utilization # reward proportional to space usage
363
+
364
+ # --- Execute placement ---
365
+ container.filled_ml += item.volume_ml
366
+ container.contents.append(item.name)
367
+ container.content_types.append(item.food_type)
368
+ container.content_temperatures.append(item.temperature)
369
+ container.content_fragilites.append(item.fragility)
370
+ item.status = "packed"
371
+ self.held_item = None
372
+ self.arm_state = "idle"
373
+
374
+ # Log the placement
375
+ self.packing_log.append(
376
+ {
377
+ "food_name": item.name,
378
+ "food_id": item.id,
379
+ "food_type": item.food_type,
380
+ "food_volume": item.volume_ml,
381
+ "food_temperature": item.temperature,
382
+ "food_fragility": item.fragility,
383
+ "food_preferred_container": item.preferred_container,
384
+ "container_id": container.id,
385
+ "container_type": container.container_type,
386
+ "container_name": container.name,
387
+ "type_compatible": type_ok,
388
+ "overflow": item.volume_ml > container.remaining_ml + item.volume_ml,
389
+ }
390
+ )
391
+
392
+ if feedback_parts:
393
+ feedback = f"Placed '{item.name}' in '{container.name}'. " + " ".join(
394
+ feedback_parts
395
+ )
396
+ else:
397
+ feedback = (
398
+ f"Placed '{item.name}' in '{container.name}'. "
399
+ f"Container now {container.fill_percentage:.0f}% full."
400
+ )
401
+
402
+ return True, feedback, round(reward, 2)
403
+
404
+ def pour(self, container_id: int) -> Tuple[bool, str, float]:
405
+ """Pour liquid from held item into container. Returns (success, feedback, reward)."""
406
+ if self.arm_state != "holding" or self.held_item is None:
407
+ return False, "Arm is not holding any item. Use 'pick' first.", -0.1
408
+
409
+ item = self.held_item
410
+
411
+ # Only liquids or semi-solids can be poured
412
+ if item.food_type not in ("liquid", "semi-solid"):
413
+ return (
414
+ False,
415
+ f"Cannot pour '{item.name}' — it is '{item.food_type}', not a pourable item. "
416
+ f"Use 'place' instead.",
417
+ -0.1,
418
+ )
419
+
420
+ # Pour is functionally same as place but gives extra reward for liquids
421
+ success, feedback, reward = self.place(container_id)
422
+ if success:
423
+ reward += 0.2 # bonus for correctly using pour for liquids
424
+ feedback = feedback.replace("Placed", "Poured")
425
+ return success, feedback, reward
426
+
427
+ # -------------------------------------------------------------------
428
+ # Scene description
429
+ # -------------------------------------------------------------------
430
+
431
+ def get_scene_description(self) -> str:
432
+ """Generate natural language description of the current scene."""
433
+ lines = []
434
+ lines.append("=" * 60)
435
+ lines.append("TIFFIN PACKING SCENE")
436
+ lines.append("=" * 60)
437
+
438
+ # Arm state
439
+ lines.append("")
440
+ lines.append("🤖 ROBOTIC ARM STATUS:")
441
+ if self.arm_state == "holding" and self.held_item:
442
+ item = self.held_item
443
+ if item.identified:
444
+ lines.append(
445
+ f" Currently holding: {item.name} "
446
+ f"(type={item.food_type}, volume={item.volume_ml}ml)"
447
+ )
448
+ else:
449
+ lines.append(f" Currently holding: Unknown food item #{item.id}")
450
+ else:
451
+ lines.append(" Arm is idle — ready to pick up an item")
452
+
453
+ # Food items on table
454
+ on_table = [i for i in self.food_items if i.status == "on_table"]
455
+ packed = [i for i in self.food_items if i.status == "packed"]
456
+ lines.append("")
457
+ lines.append(f"🍛 FOOD ITEMS ON TABLE ({len(on_table)} remaining, {len(packed)} packed):")
458
+ for item in self.food_items:
459
+ status_icon = {"on_table": "⬜", "held": "🤏", "packed": "✅", "dropped": "❌"}.get(
460
+ item.status, "?"
461
+ )
462
+ if item.identified:
463
+ lines.append(
464
+ f" {status_icon} [{item.id}] {item.name} — "
465
+ f"type={item.food_type}, volume={item.volume_ml}ml, "
466
+ f"temp={item.temperature}, fragility={item.fragility:.1f}, "
467
+ f"preferred={item.preferred_container}"
468
+ )
469
+ else:
470
+ lines.append(
471
+ f" {status_icon} [{item.id}] Unknown food item "
472
+ f"(use 'identify' to classify)"
473
+ )
474
+
475
+ # Containers
476
+ lines.append("")
477
+ lines.append("🍱 TIFFIN CONTAINERS:")
478
+ for c in self.containers:
479
+ bar_len = 20
480
+ filled_bars = int((c.fill_percentage / 100) * bar_len)
481
+ bar = "█" * filled_bars + "░" * (bar_len - filled_bars)
482
+ lines.append(
483
+ f" [{c.id}] {c.name} ({c.container_type}) — "
484
+ f"[{bar}] {c.fill_percentage:.0f}% "
485
+ f"({c.filled_ml:.0f}/{c.capacity_ml:.0f}ml)"
486
+ )
487
+ if c.contents:
488
+ lines.append(f" Contains: {', '.join(c.contents)}")
489
+ liquid_note = "✅ Can hold liquids" if c.accepts_liquid else "⚠️ Open — no liquids"
490
+ lines.append(f" {liquid_note}")
491
+
492
+ lines.append("")
493
+ lines.append("=" * 60)
494
+ return "\n".join(lines)
495
+
496
+ def get_available_commands(self) -> List[str]:
497
+ """Return list of valid commands given current state."""
498
+ commands = ["observe"]
499
+
500
+ unpacked = [i for i in self.food_items if i.status == "on_table"]
501
+ unidentified = [i for i in self.food_items if not i.identified and i.status != "packed"]
502
+
503
+ if unidentified:
504
+ commands.append("identify")
505
+
506
+ if self.arm_state == "idle" and unpacked:
507
+ commands.append("pick")
508
+
509
+ if self.arm_state == "holding" and self.held_item:
510
+ commands.append("place")
511
+ if self.held_item.food_type in ("liquid", "semi-solid"):
512
+ commands.append("pour")
513
+
514
+ return commands
515
+
516
+ # -------------------------------------------------------------------
517
+ # Helpers
518
+ # -------------------------------------------------------------------
519
+
520
+ def _find_item(self, item_id: int) -> Optional[FoodItem]:
521
+ for item in self.food_items:
522
+ if item.id == item_id:
523
+ return item
524
+ return None
525
+
526
+ def _find_container(self, container_id: int) -> Optional[Container]:
527
+ for c in self.containers:
528
+ if c.id == container_id:
529
+ return c
530
+ return None
531
+
532
+ @property
533
+ def all_packed(self) -> bool:
534
+ return all(i.status == "packed" for i in self.food_items)
535
+
536
+ @property
537
+ def unpacked_count(self) -> int:
538
+ return sum(1 for i in self.food_items if i.status != "packed")
tiffin_packer/simulation/pybullet_renderer.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ PyBullet Rendering Module — Real physics visualization using URDF models.
4
+
5
+ Provides an optional physics-backed renderer that loads real URDF models
6
+ (Kuka robot arm, table, containers, food items) and renders frames.
7
+
8
+ This module is used for:
9
+ 1. Generating visual frames for the frontend viewer
10
+ 2. Physics validation of placements
11
+ 3. Demo/presentation screenshots
12
+
13
+ The simulation engine (engine.py) handles all logic — this module only
14
+ provides visualization and optional physics validation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import io
21
+ import math
22
+ import os
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ import numpy as np
26
+
27
+ # PyBullet may not be available in all environments
28
+ try:
29
+ import pybullet as p
30
+ import pybullet_data
31
+
32
+ PYBULLET_AVAILABLE = True
33
+ except ImportError:
34
+ PYBULLET_AVAILABLE = False
35
+
36
+
37
+ # Color presets for food items
38
+ FOOD_COLORS = {
39
+ "rice": [1.0, 1.0, 0.9, 1.0], # white
40
+ "sambar": [0.9, 0.5, 0.1, 1.0], # orange
41
+ "curd": [1.0, 1.0, 0.95, 1.0], # off-white
42
+ "chapati": [0.8, 0.6, 0.3, 1.0], # brown
43
+ "pickle": [0.8, 0.1, 0.1, 1.0], # red
44
+ "dal": [0.9, 0.8, 0.2, 1.0], # yellow
45
+ "rasam": [0.6, 0.1, 0.05, 1.0], # dark red
46
+ "poriyal": [0.2, 0.7, 0.2, 1.0], # green
47
+ "papad": [0.9, 0.8, 0.4, 1.0], # golden
48
+ "raita": [0.8, 0.9, 0.8, 1.0], # pale green
49
+ "idli": [1.0, 1.0, 0.95, 1.0], # white
50
+ "chutney": [0.1, 0.6, 0.1, 1.0], # green
51
+ "biryani": [0.9, 0.7, 0.2, 1.0], # saffron
52
+ "curry": [0.6, 0.3, 0.1, 1.0], # brown
53
+ "salad": [0.3, 0.8, 0.3, 1.0], # mixed green
54
+ }
55
+
56
+ # Container colors
57
+ CONTAINER_COLORS = {
58
+ "sealed_round": [0.7, 0.7, 0.8, 0.7], # steel blue
59
+ "flat_open": [0.8, 0.6, 0.3, 0.8], # bronze
60
+ "deep_box": [0.6, 0.6, 0.7, 0.7], # grey steel
61
+ "small_sealed": [0.9, 0.9, 0.95, 0.7], # silver
62
+ }
63
+
64
+
65
+ class PyBulletRenderer:
66
+ """
67
+ Optional PyBullet-based renderer for the tiffin packing scene.
68
+
69
+ Creates a physics simulation with:
70
+ - Kuka IIWA robot arm (from pybullet_data)
71
+ - Table (box primitive)
72
+ - Food items (colored cubes/spheres on table)
73
+ - Tiffin containers (open-top box composites)
74
+ """
75
+
76
+ def __init__(self, gui: bool = False):
77
+ if not PYBULLET_AVAILABLE:
78
+ raise ImportError(
79
+ "pybullet is not installed. Install with: pip install pybullet"
80
+ )
81
+
82
+ self._gui = gui
83
+ self._physics_client = None
84
+ self._robot_id = None
85
+ self._table_id = None
86
+ self._food_ids: Dict[int, int] = {} # food_item_id -> bullet_body_id
87
+ self._container_ids: Dict[int, int] = {} # container_id -> bullet_body_id
88
+ self._initialized = False
89
+
90
+ def initialize(self):
91
+ """Start the PyBullet physics server."""
92
+ if self._initialized:
93
+ return
94
+
95
+ if self._gui:
96
+ self._physics_client = p.connect(p.GUI)
97
+ else:
98
+ self._physics_client = p.connect(p.DIRECT)
99
+
100
+ p.setAdditionalSearchPath(pybullet_data.getDataPath())
101
+ p.setGravity(0, 0, -9.81)
102
+
103
+ # Load ground plane
104
+ p.loadURDF("plane.urdf")
105
+
106
+ self._initialized = True
107
+
108
+ def setup_scene(
109
+ self,
110
+ food_items: list,
111
+ containers: list,
112
+ ):
113
+ """
114
+ Set up the full PyBullet scene with robot, table, food, containers.
115
+
116
+ Args:
117
+ food_items: List of FoodItem dataclasses
118
+ containers: List of Container dataclasses
119
+ """
120
+ self.initialize()
121
+
122
+ # Clear previous objects
123
+ self._clear_objects()
124
+
125
+ # --- Table ---
126
+ table_half_extents = [0.4, 0.6, 0.02]
127
+ table_col = p.createCollisionShape(p.GEOM_BOX, halfExtents=table_half_extents)
128
+ table_vis = p.createVisualShape(
129
+ p.GEOM_BOX,
130
+ halfExtents=table_half_extents,
131
+ rgbaColor=[0.6, 0.4, 0.2, 1.0],
132
+ )
133
+ self._table_id = p.createMultiBody(
134
+ baseMass=0,
135
+ baseCollisionShapeIndex=table_col,
136
+ baseVisualShapeIndex=table_vis,
137
+ basePosition=[0, 0, 0.6],
138
+ )
139
+
140
+ # Table legs
141
+ for lx, ly in [(-0.35, -0.55), (-0.35, 0.55), (0.35, -0.55), (0.35, 0.55)]:
142
+ leg_col = p.createCollisionShape(
143
+ p.GEOM_BOX, halfExtents=[0.02, 0.02, 0.3]
144
+ )
145
+ leg_vis = p.createVisualShape(
146
+ p.GEOM_BOX,
147
+ halfExtents=[0.02, 0.02, 0.3],
148
+ rgbaColor=[0.5, 0.3, 0.15, 1.0],
149
+ )
150
+ p.createMultiBody(
151
+ baseMass=0,
152
+ baseCollisionShapeIndex=leg_col,
153
+ baseVisualShapeIndex=leg_vis,
154
+ basePosition=[lx, ly, 0.3],
155
+ )
156
+
157
+ # --- Robot arm (Kuka IIWA) ---
158
+ self._robot_id = p.loadURDF(
159
+ "kuka_iiwa/model.urdf",
160
+ basePosition=[-0.5, 0, 0.62],
161
+ useFixedBase=True,
162
+ )
163
+
164
+ # --- Food items ---
165
+ for item in food_items:
166
+ color = FOOD_COLORS.get(item.name, [0.5, 0.5, 0.5, 1.0])
167
+
168
+ if item.food_type == "liquid":
169
+ # Sphere for liquids
170
+ shape_col = p.createCollisionShape(p.GEOM_SPHERE, radius=0.03)
171
+ shape_vis = p.createVisualShape(
172
+ p.GEOM_SPHERE, radius=0.03, rgbaColor=color
173
+ )
174
+ elif item.fragility > 0.6:
175
+ # Flat disc for fragile items (papad, chapati)
176
+ shape_col = p.createCollisionShape(
177
+ p.GEOM_CYLINDER, radius=0.04, height=0.01
178
+ )
179
+ shape_vis = p.createVisualShape(
180
+ p.GEOM_CYLINDER,
181
+ radius=0.04,
182
+ length=0.01,
183
+ rgbaColor=color,
184
+ )
185
+ else:
186
+ # Cube for solid foods
187
+ sz = 0.025
188
+ shape_col = p.createCollisionShape(
189
+ p.GEOM_BOX, halfExtents=[sz, sz, sz]
190
+ )
191
+ shape_vis = p.createVisualShape(
192
+ p.GEOM_BOX, halfExtents=[sz, sz, sz], rgbaColor=color
193
+ )
194
+
195
+ body_id = p.createMultiBody(
196
+ baseMass=0.1,
197
+ baseCollisionShapeIndex=shape_col,
198
+ baseVisualShapeIndex=shape_vis,
199
+ basePosition=[
200
+ item.position[0],
201
+ item.position[1],
202
+ item.position[2] + 0.03,
203
+ ],
204
+ )
205
+ self._food_ids[item.id] = body_id
206
+
207
+ # --- Containers (open-top boxes) ---
208
+ for container in containers:
209
+ color = CONTAINER_COLORS.get(
210
+ container.container_type, [0.5, 0.5, 0.5, 0.7]
211
+ )
212
+ # Scale container size based on capacity
213
+ scale = (container.capacity_ml / 300) ** 0.33
214
+ w, d, h = 0.05 * scale, 0.05 * scale, 0.06 * scale
215
+
216
+ # Bottom
217
+ bottom_col = p.createCollisionShape(
218
+ p.GEOM_BOX, halfExtents=[w, d, 0.002]
219
+ )
220
+ bottom_vis = p.createVisualShape(
221
+ p.GEOM_BOX, halfExtents=[w, d, 0.002], rgbaColor=color
222
+ )
223
+ cx, cy, cz = container.position
224
+ body_id = p.createMultiBody(
225
+ baseMass=0,
226
+ baseCollisionShapeIndex=bottom_col,
227
+ baseVisualShapeIndex=bottom_vis,
228
+ basePosition=[cx, cy, cz],
229
+ )
230
+ self._container_ids[container.id] = body_id
231
+
232
+ # Walls (4 sides)
233
+ wall_thickness = 0.003
234
+ walls = [
235
+ ([w, wall_thickness, h / 2], [cx, cy + d, cz + h / 2]),
236
+ ([w, wall_thickness, h / 2], [cx, cy - d, cz + h / 2]),
237
+ ([wall_thickness, d, h / 2], [cx + w, cy, cz + h / 2]),
238
+ ([wall_thickness, d, h / 2], [cx - w, cy, cz + h / 2]),
239
+ ]
240
+ for wall_ext, wall_pos in walls:
241
+ wall_col = p.createCollisionShape(
242
+ p.GEOM_BOX, halfExtents=wall_ext
243
+ )
244
+ wall_vis = p.createVisualShape(
245
+ p.GEOM_BOX, halfExtents=wall_ext, rgbaColor=color
246
+ )
247
+ p.createMultiBody(
248
+ baseMass=0,
249
+ baseCollisionShapeIndex=wall_col,
250
+ baseVisualShapeIndex=wall_vis,
251
+ basePosition=wall_pos,
252
+ )
253
+
254
+ # Set up camera
255
+ p.resetDebugVisualizerCamera(
256
+ cameraDistance=1.2,
257
+ cameraYaw=45,
258
+ cameraPitch=-30,
259
+ cameraTargetPosition=[0, 0, 0.6],
260
+ )
261
+
262
+ def render(
263
+ self,
264
+ width: int = 640,
265
+ height: int = 480,
266
+ camera_distance: float = 1.2,
267
+ camera_yaw: float = 45,
268
+ camera_pitch: float = -30,
269
+ ) -> np.ndarray:
270
+ """
271
+ Render the current scene as an RGB image.
272
+
273
+ Returns:
274
+ numpy array of shape (height, width, 3) with RGB values.
275
+ """
276
+ if not self._initialized:
277
+ raise RuntimeError("Renderer not initialized. Call setup_scene() first.")
278
+
279
+ view_matrix = p.computeViewMatrixFromYawPitchRoll(
280
+ cameraTargetPosition=[0, 0, 0.6],
281
+ distance=camera_distance,
282
+ yaw=camera_yaw,
283
+ pitch=camera_pitch,
284
+ roll=0,
285
+ upAxisIndex=2,
286
+ )
287
+ proj_matrix = p.computeProjectionMatrixFOV(
288
+ fov=60,
289
+ aspect=width / height,
290
+ nearVal=0.1,
291
+ farVal=3.0,
292
+ )
293
+
294
+ _, _, rgba, _, _ = p.getCameraImage(
295
+ width=width,
296
+ height=height,
297
+ viewMatrix=view_matrix,
298
+ projectionMatrix=proj_matrix,
299
+ renderer=p.ER_TINY_RENDERER,
300
+ )
301
+
302
+ rgb = np.array(rgba, dtype=np.uint8).reshape(height, width, 4)[:, :, :3]
303
+ return rgb
304
+
305
+ def render_base64(self, **kwargs) -> str:
306
+ """Render scene and return as base64-encoded PNG string."""
307
+ rgb = self.render(**kwargs)
308
+
309
+ from PIL import Image
310
+
311
+ img = Image.fromarray(rgb)
312
+ buffer = io.BytesIO()
313
+ img.save(buffer, format="PNG")
314
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
315
+
316
+ def move_food_to_container(self, food_item_id: int, container_id: int):
317
+ """Visually move a food item into a container (for animation)."""
318
+ if food_item_id not in self._food_ids or container_id not in self._container_ids:
319
+ return
320
+
321
+ food_body = self._food_ids[food_item_id]
322
+ container_body = self._container_ids[container_id]
323
+
324
+ # Get container position
325
+ pos, _ = p.getBasePositionAndOrientation(container_body)
326
+ # Place food slightly above container center
327
+ new_pos = [pos[0], pos[1], pos[2] + 0.05]
328
+ p.resetBasePositionAndOrientation(
329
+ food_body, new_pos, [0, 0, 0, 1]
330
+ )
331
+
332
+ def close(self):
333
+ """Disconnect from PyBullet."""
334
+ if self._initialized:
335
+ p.disconnect(self._physics_client)
336
+ self._initialized = False
337
+
338
+ def _clear_objects(self):
339
+ """Remove all food and container objects."""
340
+ for body_id in self._food_ids.values():
341
+ try:
342
+ p.removeBody(body_id)
343
+ except Exception:
344
+ pass
345
+ for body_id in self._container_ids.values():
346
+ try:
347
+ p.removeBody(body_id)
348
+ except Exception:
349
+ pass
350
+ self._food_ids.clear()
351
+ self._container_ids.clear()
352
+
353
+ def __del__(self):
354
+ self.close()
tiffin_packer/tasks.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ Task Definitions — Easy, Medium, Hard difficulty levels.
4
+
5
+ Each task defines what food items are on the table, what containers are
6
+ available, what constraints are active, and how many steps the agent gets.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import List, Optional
13
+
14
+ from .simulation.engine import Container, FoodItem
15
+ from .vlm.classifier import FoodClassifier
16
+
17
+
18
+ @dataclass
19
+ class TaskConfig:
20
+ """Configuration for a single task."""
21
+
22
+ task_id: str
23
+ description: str
24
+ food_items: List[FoodItem]
25
+ containers: List[Container]
26
+ constraints: List[str]
27
+ max_steps: int
28
+ seed: Optional[int] = None
29
+
30
+
31
+ _vlm = FoodClassifier()
32
+
33
+
34
+ def _make_food(id: int, name: str) -> FoodItem:
35
+ """Create a FoodItem from the VLM database."""
36
+ attrs = _vlm.classify(name)
37
+ return FoodItem(
38
+ id=id,
39
+ name=name,
40
+ food_type=attrs["type"],
41
+ volume_ml=attrs["volume_ml"],
42
+ temperature=attrs["temperature"],
43
+ fragility=attrs["fragility"],
44
+ preferred_container=attrs["preferred_container"],
45
+ color=attrs.get("color", "unknown"),
46
+ special_notes=attrs.get("special_notes", ""),
47
+ )
48
+
49
+
50
+ def get_task_config(task_id: str, seed: Optional[int] = None) -> TaskConfig:
51
+ """Get task configuration by ID."""
52
+ tasks = {
53
+ "easy": _task_easy,
54
+ "medium": _task_medium,
55
+ "hard": _task_hard,
56
+ }
57
+ if task_id not in tasks:
58
+ raise ValueError(
59
+ f"Unknown task_id '{task_id}'. Available: {list(tasks.keys())}"
60
+ )
61
+ config = tasks[task_id](seed)
62
+ return config
63
+
64
+
65
+ def _task_easy(seed: Optional[int] = None) -> TaskConfig:
66
+ """
67
+ Task 1 — Basic Packing (Easy)
68
+
69
+ 2 food items, 2 containers. Just match food type to container type.
70
+ Rice (solid) → open/deep container, Sambar (liquid) → sealed container.
71
+ """
72
+ return TaskConfig(
73
+ task_id="easy",
74
+ description=(
75
+ "Basic Packing: You have 2 food items (rice and sambar) and "
76
+ "2 containers (one sealed, one open). Place each food item in "
77
+ "a compatible container. Liquids must go in sealed containers."
78
+ ),
79
+ food_items=[
80
+ _make_food(1, "rice"),
81
+ _make_food(2, "sambar"),
82
+ ],
83
+ containers=[
84
+ Container(
85
+ id=1,
86
+ name="Sealed Round Container",
87
+ container_type="sealed_round",
88
+ capacity_ml=300,
89
+ ),
90
+ Container(
91
+ id=2,
92
+ name="Flat Open Container",
93
+ container_type="flat_open",
94
+ capacity_ml=400,
95
+ ),
96
+ ],
97
+ constraints=["type_match"],
98
+ max_steps=12,
99
+ seed=seed,
100
+ )
101
+
102
+
103
+ def _task_medium(seed: Optional[int] = None) -> TaskConfig:
104
+ """
105
+ Task 2 — Efficient Packing (Medium)
106
+
107
+ 4 food items, 3 containers. Must match types AND avoid overflow.
108
+ Hot/cold separation matters.
109
+ """
110
+ return TaskConfig(
111
+ task_id="medium",
112
+ description=(
113
+ "Efficient Packing: You have 4 food items (rice, sambar, chapati, "
114
+ "pickle) and 3 containers. Place each item correctly:\n"
115
+ "- Match food type to container type (liquids → sealed)\n"
116
+ "- Don't overflow containers (check volumes!)\n"
117
+ "- Keep hot and cold items separate"
118
+ ),
119
+ food_items=[
120
+ _make_food(1, "rice"),
121
+ _make_food(2, "sambar"),
122
+ _make_food(3, "chapati"),
123
+ _make_food(4, "pickle"),
124
+ ],
125
+ containers=[
126
+ Container(
127
+ id=1,
128
+ name="Sealed Round Container",
129
+ container_type="sealed_round",
130
+ capacity_ml=200,
131
+ ),
132
+ Container(
133
+ id=2,
134
+ name="Flat Open Container",
135
+ container_type="flat_open",
136
+ capacity_ml=300,
137
+ ),
138
+ Container(
139
+ id=3,
140
+ name="Deep Box Container",
141
+ container_type="deep_box",
142
+ capacity_ml=350,
143
+ ),
144
+ ],
145
+ constraints=["type_match", "no_overflow", "temperature_separation"],
146
+ max_steps=20,
147
+ seed=seed,
148
+ )
149
+
150
+
151
+ def _task_hard(seed: Optional[int] = None) -> TaskConfig:
152
+ """
153
+ Task 3 — Smart Packing (Hard)
154
+
155
+ 6 food items, 4 containers. Full constraint set:
156
+ type match, overflow, temperature, fragility, flavor mixing.
157
+
158
+ Key challenges:
159
+ - Curd (cold) ≠ hot items in same container
160
+ - Papad (fragility=0.9) must not be crushed
161
+ - Curry + sambar both liquid+hot → total 300ml but sealed_round only 250ml!
162
+ - Must split liquids across containers
163
+ """
164
+ return TaskConfig(
165
+ task_id="hard",
166
+ description=(
167
+ "Smart Packing: You have 6 food items and 4 containers. This is a "
168
+ "complex meal with many constraints:\n"
169
+ "- Match food type to container type\n"
170
+ "- Don't overflow (watch the math!)\n"
171
+ "- Separate hot and cold items\n"
172
+ "- Don't crush fragile items (papad, chapati)\n"
173
+ "- Consider flavor isolation (pickle, chutney)\n"
174
+ "\nItems: rice, sambar, curd, chapati, papad, curry\n"
175
+ "Containers: sealed_round (250ml), flat_open (200ml), "
176
+ "deep_box (400ml), small_sealed (100ml)"
177
+ ),
178
+ food_items=[
179
+ _make_food(1, "rice"),
180
+ _make_food(2, "sambar"),
181
+ _make_food(3, "curd"),
182
+ _make_food(4, "chapati"),
183
+ _make_food(5, "papad"),
184
+ _make_food(6, "curry"),
185
+ ],
186
+ containers=[
187
+ Container(
188
+ id=1,
189
+ name="Sealed Round Container",
190
+ container_type="sealed_round",
191
+ capacity_ml=250,
192
+ ),
193
+ Container(
194
+ id=2,
195
+ name="Flat Open Container",
196
+ container_type="flat_open",
197
+ capacity_ml=200,
198
+ ),
199
+ Container(
200
+ id=3,
201
+ name="Deep Box Container",
202
+ container_type="deep_box",
203
+ capacity_ml=400,
204
+ ),
205
+ Container(
206
+ id=4,
207
+ name="Small Sealed Container",
208
+ container_type="small_sealed",
209
+ capacity_ml=100,
210
+ ),
211
+ ],
212
+ constraints=[
213
+ "type_match",
214
+ "no_overflow",
215
+ "temperature_separation",
216
+ "fragility_ordering",
217
+ "flavor_isolation",
218
+ ],
219
+ max_steps=30,
220
+ seed=seed,
221
+ )
222
+
223
+
224
+ def list_tasks() -> List[str]:
225
+ """Return list of available task IDs."""
226
+ return ["easy", "medium", "hard"]
tiffin_packer/vlm/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .classifier import FoodClassifier
2
+
3
+ __all__ = ["FoodClassifier"]
tiffin_packer/vlm/classifier.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2026 CtrlAltWin Team
2
+ """
3
+ VLM Food Classifier — Simulates Vision-Language Model food classification.
4
+
5
+ In production, this would call LLaVA / GPT-4V on a rendered PyBullet frame.
6
+ For the hackathon, uses pre-computed attributes from food_db.json.
7
+
8
+ The agent MUST call 'identify' before it knows a food item's properties.
9
+ Without identification, items appear as generic "Unknown food item".
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from typing import Any, Dict, Optional
15
+
16
+
17
+ class FoodClassifier:
18
+ """Cached VLM food classifier.
19
+
20
+ Loads pre-computed food attributes from food_db.json.
21
+ In a production system, replace ``classify()`` with a real
22
+ VLM API call (e.g. LLaVA, GPT-4V) on a rendered scene frame.
23
+ """
24
+
25
+ def __init__(self, db_path: Optional[str] = None):
26
+ if db_path is None:
27
+ db_path = os.path.join(os.path.dirname(__file__), "food_db.json")
28
+ with open(db_path, "r") as f:
29
+ self.food_db: Dict[str, Dict[str, Any]] = json.load(f)
30
+
31
+ def classify(self, food_name: str) -> Dict[str, Any]:
32
+ """Classify a food item and return its attributes.
33
+
34
+ Args:
35
+ food_name: Name of the food item (e.g. "sambar", "rice").
36
+
37
+ Returns:
38
+ Dict with keys: type, fragility, preferred_container,
39
+ volume_ml, temperature, color, special_notes.
40
+ """
41
+ key = food_name.lower().strip()
42
+ if key in self.food_db:
43
+ return {**self.food_db[key], "name": key, "classified": True}
44
+ return self._unknown_default(food_name)
45
+
46
+ def _unknown_default(self, food_name: str) -> Dict[str, Any]:
47
+ """Fallback for foods not in the database."""
48
+ return {
49
+ "name": food_name,
50
+ "type": "solid",
51
+ "fragility": 0.5,
52
+ "preferred_container": "deep",
53
+ "volume_ml": 100,
54
+ "temperature": "room",
55
+ "color": "unknown",
56
+ "special_notes": "Unknown food item — classification uncertain",
57
+ "classified": False,
58
+ }
59
+
60
+ def get_all_foods(self) -> list:
61
+ """Return list of all known food names."""
62
+ return list(self.food_db.keys())
tiffin_packer/vlm/food_db.json ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rice": {
3
+ "type": "solid",
4
+ "fragility": 0.1,
5
+ "preferred_container": "deep",
6
+ "volume_ml": 200,
7
+ "temperature": "hot",
8
+ "color": "white",
9
+ "special_notes": "Staple grain, can be packed densely"
10
+ },
11
+ "sambar": {
12
+ "type": "liquid",
13
+ "fragility": 0.0,
14
+ "preferred_container": "sealed",
15
+ "volume_ml": 150,
16
+ "temperature": "hot",
17
+ "color": "orange",
18
+ "special_notes": "Lentil-based stew, will spill if container not sealed"
19
+ },
20
+ "curd": {
21
+ "type": "semi-solid",
22
+ "fragility": 0.3,
23
+ "preferred_container": "sealed",
24
+ "volume_ml": 100,
25
+ "temperature": "cold",
26
+ "color": "white",
27
+ "special_notes": "Dairy product, must be kept cold and away from hot items"
28
+ },
29
+ "chapati": {
30
+ "type": "solid",
31
+ "fragility": 0.7,
32
+ "preferred_container": "flat",
33
+ "volume_ml": 80,
34
+ "temperature": "room",
35
+ "color": "brown",
36
+ "special_notes": "Flatbread, fragile when stacked under heavy items"
37
+ },
38
+ "pickle": {
39
+ "type": "semi-solid",
40
+ "fragility": 0.2,
41
+ "preferred_container": "sealed",
42
+ "volume_ml": 30,
43
+ "temperature": "room",
44
+ "color": "red",
45
+ "special_notes": "Strong flavor, should not contaminate other items"
46
+ },
47
+ "dal": {
48
+ "type": "liquid",
49
+ "fragility": 0.0,
50
+ "preferred_container": "sealed",
51
+ "volume_ml": 120,
52
+ "temperature": "hot",
53
+ "color": "yellow",
54
+ "special_notes": "Lentil soup, needs sealed container"
55
+ },
56
+ "rasam": {
57
+ "type": "liquid",
58
+ "fragility": 0.0,
59
+ "preferred_container": "sealed",
60
+ "volume_ml": 100,
61
+ "temperature": "hot",
62
+ "color": "dark_red",
63
+ "special_notes": "Thin spicy soup, will leak easily"
64
+ },
65
+ "poriyal": {
66
+ "type": "solid",
67
+ "fragility": 0.5,
68
+ "preferred_container": "flat",
69
+ "volume_ml": 80,
70
+ "temperature": "hot",
71
+ "color": "green",
72
+ "special_notes": "Stir-fried vegetables, moderately fragile"
73
+ },
74
+ "papad": {
75
+ "type": "solid",
76
+ "fragility": 0.9,
77
+ "preferred_container": "flat",
78
+ "volume_ml": 20,
79
+ "temperature": "room",
80
+ "color": "golden",
81
+ "special_notes": "Very fragile crispy disc, breaks easily under pressure"
82
+ },
83
+ "raita": {
84
+ "type": "semi-solid",
85
+ "fragility": 0.2,
86
+ "preferred_container": "sealed",
87
+ "volume_ml": 80,
88
+ "temperature": "cold",
89
+ "color": "pale_green",
90
+ "special_notes": "Yogurt-based, must be kept cold"
91
+ },
92
+ "idli": {
93
+ "type": "solid",
94
+ "fragility": 0.4,
95
+ "preferred_container": "deep",
96
+ "volume_ml": 120,
97
+ "temperature": "hot",
98
+ "color": "white",
99
+ "special_notes": "Steamed rice cake, soft but holds shape"
100
+ },
101
+ "chutney": {
102
+ "type": "semi-solid",
103
+ "fragility": 0.1,
104
+ "preferred_container": "sealed",
105
+ "volume_ml": 50,
106
+ "temperature": "room",
107
+ "color": "green",
108
+ "special_notes": "Condiment, strong flavor, needs isolation"
109
+ },
110
+ "biryani": {
111
+ "type": "solid",
112
+ "fragility": 0.3,
113
+ "preferred_container": "deep",
114
+ "volume_ml": 250,
115
+ "temperature": "hot",
116
+ "color": "saffron",
117
+ "special_notes": "Fragrant rice dish, needs larger container"
118
+ },
119
+ "curry": {
120
+ "type": "liquid",
121
+ "fragility": 0.0,
122
+ "preferred_container": "sealed",
123
+ "volume_ml": 150,
124
+ "temperature": "hot",
125
+ "color": "brown",
126
+ "special_notes": "Gravy-based dish, will spill without sealed container"
127
+ },
128
+ "salad": {
129
+ "type": "solid",
130
+ "fragility": 0.6,
131
+ "preferred_container": "flat",
132
+ "volume_ml": 60,
133
+ "temperature": "cold",
134
+ "color": "mixed",
135
+ "special_notes": "Fresh vegetables, keep away from hot items"
136
+ }
137
+ }