demeter

Autonomous Hydroponic Intelligence

simulator/main.py (12026B)


  1import base64
  2import os
  3import uvicorn
  4import numpy as np
  5import torch
  6from io import BytesIO
  7from collections import deque
  8from fastapi import FastAPI
  9from pydantic import BaseModel
 10from typing import List
 11from PIL import Image
 12from dotenv import load_dotenv
 13from pymongo import MongoClient, ReturnDocument
 14from pymongo.errors import ConfigurationError
 15from datetime import datetime
 16
 17# Load env from root
 18current_dir = os.path.dirname(os.path.abspath(__file__))
 19env_path = os.path.join(current_dir, "..", ".env")
 20load_dotenv(env_path)
 21
 22MONGO_URI = os.environ.get("MONGODB_URI")
 23mongo_client = MongoClient(MONGO_URI)
 24try:
 25    db = mongo_client.get_default_database()
 26except ConfigurationError:
 27    db = mongo_client["test"]
 28crops_collection = db["cropstates"]
 29sim_state_collection = db["simulator_state"]
 30
 31print(f"[DEBUG] MongoDB connected to DB: '{db.name}'")
 32print(f"[DEBUG] MONGO_URI = {MONGO_URI[:40] if MONGO_URI else 'NOT SET'}...")
 33
 34MODEL_PATH = "models/PPO/lettuce_brain_v1.zip"
 35HISTORY_LEN = 20
 36
 37CROP_LIFECYCLES = {
 38    "lettuce": {
 39        "stages": [
 40            {"name": "seedling", "end_hour": 168},
 41            {"name": "vegetative", "end_hour": 504},
 42            {"name": "harvest", "end_hour": 999999},
 43        ]
 44    },
 45    "tomato": {
 46        "stages": [
 47            {"name": "seedling", "end_hour": 336},
 48            {"name": "vegetative", "end_hour": 1008},
 49            {"name": "flowering", "end_hour": 1680},
 50            {"name": "fruiting", "end_hour": 999999},
 51        ]
 52    },
 53    "basil": {
 54        "stages": [
 55            {"name": "seedling", "end_hour": 168},
 56            {"name": "vegetative", "end_hour": 672},
 57            {"name": "harvest", "end_hour": 999999},
 58        ]
 59    },
 60    "strawberry": {
 61        "stages": [
 62            {"name": "seedling", "end_hour": 336},
 63            {"name": "vegetative", "end_hour": 1008},
 64            {"name": "flowering", "end_hour": 1512},
 65            {"name": "fruiting", "end_hour": 999999},
 66        ]
 67    },
 68}
 69
 70
 71class FarmAction(BaseModel):
 72    acid_dosage_ml: float = 0.0
 73    base_dosage_ml: float = 0.0
 74    nutrient_dosage_ml: float = 0.0
 75    fan_speed_pct: float = 0.0
 76    water_refill_l: float = 0.0
 77    debug_force_ph: float | None = None
 78
 79
 80class BatchActionRequest(BaseModel):
 81    crop_id: str
 82    action: FarmAction
 83
 84
 85class ResidualPhysicsNet(torch.nn.Module):
 86    def __init__(self, state_dim, action_dim):
 87        super().__init__()
 88        self.net = torch.nn.Sequential(
 89            torch.nn.Linear(state_dim + action_dim, 64),
 90            torch.nn.Tanh(),
 91            torch.nn.Linear(64, 64),
 92            torch.nn.ReLU(),
 93            torch.nn.Linear(64, state_dim),
 94        )
 95
 96    def forward(self, state, action):
 97        x = torch.cat([state, action], dim=-1)
 98        return self.net(x)
 99
100
101class DigitalTwin:
102    def __init__(self, crop_id: str, initial_state: list):
103        self.crop_id = crop_id
104        self.state = np.array(initial_state, dtype=np.float32)
105        self.tank_volume = 100.0
106        self.plant_health = 100.0
107        self.residual_model = ResidualPhysicsNet(7, 4)
108        
109        # Try to load trained weights
110        trained_weights_path = os.path.join(current_dir, "residual_physics.pt")
111        if os.path.exists(trained_weights_path):
112            try:
113                checkpoint = torch.load(trained_weights_path, map_location="cpu")
114                self.residual_model.load_state_dict(checkpoint["model_state_dict"])
115                print(f"[DEBUG] Loaded trained ResidualPhysicsNet weights from {trained_weights_path}")
116            except Exception as e:
117                print(f"[WARNING] Failed to load trained weights: {e}. Using random initialization.")
118        else:
119            print(f"[DEBUG] Trained weights not found at {trained_weights_path}. Using random initialization.")
120
121        self.history = {
122            "ph": deque([float(self.state[0])] * 5, maxlen=HISTORY_LEN),
123            "ec": deque([float(self.state[1])] * 5, maxlen=HISTORY_LEN),
124            "water_temp": deque([float(self.state[2])] * 5, maxlen=HISTORY_LEN),
125            "air_temp": deque([float(self.state[3])] * 5, maxlen=HISTORY_LEN),
126            "humidity": deque([float(self.state[4])] * 5, maxlen=HISTORY_LEN),
127            "co2": deque([400.0] * 5, maxlen=HISTORY_LEN),
128            "light_intensity": deque([0.0] * 5, maxlen=HISTORY_LEN),
129            "vpd": deque([float(self.state[5])] * 5, maxlen=HISTORY_LEN),
130        }
131
132    def _calculate_vpd(self, temp, hum):
133        es = 0.61078 * np.exp((17.27 * temp) / (temp + 237.3))
134        ea = es * (hum / 100.0)
135        return max(0.0, es - ea)
136
137    def step(self, action: FarmAction = None):
138        if action is None:
139            action = FarmAction()
140
141        u = np.array(
142            [
143                action.acid_dosage_ml / 10.0,
144                action.base_dosage_ml / 10.0,
145                action.nutrient_dosage_ml / 20.0,
146                action.fan_speed_pct / 100.0,
147            ],
148            dtype=np.float32,
149        )
150
151        ph, ec, wt, at, hum, vpd, bio = self.state
152
153        d_ph = (u[1] * 0.5) - (u[0] * 0.5) + (0.001 * bio)
154        if action.debug_force_ph:
155            self.state[0] = action.debug_force_ph
156
157        uptake = 0.02 * bio * vpd
158        d_ec = (u[2] * 0.2) - (uptake / self.tank_volume)
159        d_at = 0.1 - (u[3] * 1.5)
160        d_hum = 1.0 - (u[3] * 5.0)
161
162        stress = abs(vpd - 1.0)
163        growth = 0.1 * bio * (1.0 - min(stress, 1.0))
164
165        physics_delta = np.array(
166            [d_ph, d_ec, 0, d_at, d_hum, 0, growth], dtype=np.float32
167        )
168
169        with torch.no_grad():
170            nn_delta = self.residual_model(
171                torch.tensor(self.state), torch.tensor(u)
172            ).numpy()
173
174        self.state += physics_delta + (nn_delta * 0.05)
175        self.state[3] = np.clip(self.state[3], 0, 50)
176        self.state[4] = np.clip(self.state[4], 0, 100)
177        self.state[5] = self._calculate_vpd(self.state[3], self.state[4])
178        self.state[6] = max(0.1, self.state[6])
179
180        ph_score = max(0, 1.0 - abs(self.state[0] - 6.0))
181        vpd_score = max(0, 1.0 - abs(self.state[5] - 1.0))
182        self.plant_health = max(
183            0.0, min(100.0, (float(ph_score) + float(vpd_score)) * 50.0)
184        )
185
186        self._update_history()
187        return self._generate_image()
188
189    def _update_history(self):
190        s = self.state
191        self.history["ph"].append(float(s[0]))
192        self.history["ec"].append(float(s[1]))
193        self.history["water_temp"].append(float(s[2]))
194        self.history["air_temp"].append(float(s[3]))
195        self.history["humidity"].append(float(s[4]))
196        self.history["vpd"].append(float(s[5]))
197
198    def _generate_image(self):
199        bucket = max(0, min(90, int(self.plant_health // 10) * 10))
200        filename = f"{bucket}.png"
201        if os.path.exists(filename):
202            return Image.open(filename)
203        return Image.new("RGB", (512, 512), (50, 50, 50))
204
205
206app = FastAPI()
207simulators = {}
208
209
210def sync_simulators_from_db():
211    db_crops = crops_collection.find({})
212    for crop in db_crops:
213        cid = crop.get("crop_id")
214        if not cid:
215            continue
216
217        if cid not in simulators:
218            sensors = crop.get("sensors", {})
219
220            ph_val = sensors.get("pH", [6.0])
221            ec_val = sensors.get("EC", [1.5])
222            temp_val = sensors.get("temp", [24.0])
223            hum_val = sensors.get("humidity", [60.0])
224
225            state = [
226                ph_val[-1] if isinstance(ph_val, list) else ph_val,
227                ec_val[-1] if isinstance(ec_val, list) else ec_val,
228                20.0,
229                temp_val[-1] if isinstance(temp_val, list) else temp_val,
230                hum_val[-1] if isinstance(hum_val, list) else hum_val,
231                1.0,
232                10.0,
233            ]
234            simulators[cid] = DigitalTwin(cid, state)
235
236
237@app.get("/simulation/state")
238async def get_all_states():
239    clock = sim_state_collection.find_one_and_update(
240        {"_id": "global_clock"},
241        {"$inc": {"tick_hours": 1}},
242        upsert=True,
243        return_document=ReturnDocument.AFTER,
244    )
245    current_tick = clock["tick_hours"]
246
247    crops_collection.update_many({}, {"$inc": {"simulated_age_hours": 1}})
248
249    sync_simulators_from_db()
250
251    db_crops = list(crops_collection.find({}))
252    response = []
253
254    for crop in db_crops:
255        cid = crop.get("crop_id")
256        if not cid:
257            continue
258        if cid not in simulators:
259            print(f"⚠️ Crop {cid} not in simulators after sync — skipping")
260            continue
261
262        crop_type = crop.get("crop", "lettuce").lower()
263        age_hours = crop.get("sequence_number", 0) * crop.get("cycle_duration_hours", 1)
264
265        print(f"Simulating Crop ID: {cid} | Type: {crop_type} | Age (hrs): {age_hours}")
266        cycle_duration = crop.get("cycle_duration_hours", 1)
267
268        new_stage = crop.get("stage", "seedling")
269        if crop_type in CROP_LIFECYCLES:
270            for stage_info in CROP_LIFECYCLES[crop_type]["stages"]:
271                if age_hours <= stage_info["end_hour"]:
272                    new_stage = stage_info["name"]
273                    break
274
275        if new_stage != crop.get("stage"):
276            crops_collection.update_one(
277                {"crop_id": cid}, {"$set": {"stage": new_stage}}
278            )
279
280        print(
281            f" current_tick: {current_tick} | crop_id: {cid} | age_hours: {age_hours} | stage: {new_stage} | cycle_duration: {cycle_duration}"
282        )
283
284        if current_tick % cycle_duration != 0 and current_tick != 1:
285            continue
286
287        sim = simulators[cid]
288        pil_img = sim._generate_image()
289        buf = BytesIO()
290        pil_img.save(buf, format="PNG")
291        img_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
292
293        response.append(
294            {
295                "crop_id": cid,
296                "sensor_window": {k: list(v) for k, v in sim.history.items()},
297                "metadata": {
298                    "crop": crop_type,
299                    "stage": new_stage,
300                    "health": round(float(sim.plant_health), 1),
301                    "biomass_est": round(float(sim.state[6]), 2),
302                    "age_hours": age_hours,
303                    "global_tick": current_tick,
304                },
305                "image": img_b64,
306            }
307        )
308
309    return response
310
311
312@app.post("/simulation/action")
313async def take_batch_actions(payload: List[BatchActionRequest]):
314    sync_simulators_from_db()
315
316    results = []
317    for req in payload:
318        cid = req.crop_id
319        if cid not in simulators:
320            results.append(
321                {"crop_id": cid, "status": "error", "message": "Crop not found"}
322            )
323            continue
324
325        sim = simulators[cid]
326        sim.step(req.action)
327
328        push_payload = {
329            "sensors.pH": {"$each": [float(sim.state[0])], "$slice": -5},
330            "sensors.EC": {"$each": [float(sim.state[1])], "$slice": -5},
331            "sensors.temp": {"$each": [float(sim.state[3])], "$slice": -5},
332            "sensors.humidity": {"$each": [float(sim.state[4])], "$slice": -5},
333        }
334
335        set_payload = {
336            "action_taken": req.action.dict(),
337            "last_updated": datetime.utcnow(),
338        }
339
340        crops_collection.update_one(
341            {"crop_id": cid},
342            {
343                "$push": push_payload,
344                "$set": set_payload,
345                "$inc": {"sequence_number": 1},
346            },
347        )
348
349        results.append(
350            {
351                "crop_id": cid,
352                "status": "success",
353                "new_state": {
354                    "pH": float(sim.state[0]),
355                    "EC": float(sim.state[1]),
356                    "temp": float(sim.state[3]),
357                    "humidity": float(sim.state[4]),
358                },
359            }
360        )
361
362    return {"updated_crops": results}
363
364
365if __name__ == "__main__":
366    port = int(os.environ.get("SIMULATOR_PORT", 8001))
367    uvicorn.run(app, host="0.0.0.0", port=port)