demeter

Autonomous Hydroponic Intelligence
commit 19605dea2fe7f0085fc7545fced2ed2f838ba7a8
parent dc5ad1ab3c736894d64d1b44d08638d432d8b992
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date:   Fri, 27 Mar 2026 01:37:23 +0530

simulator done

Diffstat:
Mbackend/node_server/config/db.js | 16++++++++++++++--
Abackend/node_server/controllers/cropController.js | 39+++++++++++++++++++++++++++++++++++++++
Mbackend/node_server/controllers/farmController.js | 2++
Mbackend/node_server/index.js | 5++++-
Mbackend/node_server/package-lock.json | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mbackend/node_server/package.json | 1+
Abackend/node_server/routes/cropRoutes.js | 8++++++++
Abackend/node_server/schema/cropSchema.js | 35+++++++++++++++++++++++++++++++++++
Asimulator/lettuce_brain_v1.zip | 0
Msimulator/main.py | 282++++++++++++++++++++++++++++++++-----------------------------------------------
10 files changed, 432 insertions(+), 179 deletions(-)

diff --git a/backend/node_server/config/db.js b/backend/node_server/config/db.js @@ -1,9 +1,11 @@ require('dotenv').config(); const { QdrantClient } = require('@qdrant/js-client-rest'); +const {mongoose} = require('mongoose'); const COLLECTION_NAME = 'Farm_Memory'; // 1. Initialize Client with the Fix +console.log("🔧 Initializing Qdrant Client...", process.env.QDRANT_URL); const client = new QdrantClient({ url: process.env.QDRANT_URL, apiKey: process.env.QDRANT_API_KEY, @@ -49,4 +51,14 @@ const initDB = async () => { } }; -module.exports = { client, initDB, COLLECTION_NAME }; -\ No newline at end of file +const connectMongoDB = async () => { + try { + await mongoose.connect(process.env.MONGODB_URI, { + }); + console.log("✅ Connected to MongoDB"); + } catch (err) { + console.error("❌ MongoDB Connection Failed:", err.message); + } +}; + +module.exports = { client, initDB, connectMongoDB, COLLECTION_NAME }; +\ No newline at end of file diff --git a/backend/node_server/controllers/cropController.js b/backend/node_server/controllers/cropController.js @@ -0,0 +1,38 @@ +const CropStateSchema = require('../schema/cropSchema'); + +const createCrop = async (req, res) => { + try { + const { crop_id, crop, stage, ...rest } = req.body; + + if (!crop_id) { + return res.status(400).json({ error: 'crop_id is required' }); + } + + const existingCrop = await CropStateSchema.findOne({ crop_id }); + if (existingCrop) { + return res.status(409).json({ error: 'Crop with this ID already exists' }); + } + + const newCrop = new CropStateSchema({ + crop_id, + crop, + stage: stage || 'seedling', + sequence_number: 0, + total_crop_lifetime_days: 0, + planted_at: new Date(), + last_updated: new Date(), + ...rest + }); + + const savedCrop = await newCrop.save(); + + res.status(201).json({ + message: 'Crop created successfully', + data: savedCrop + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +module.exports = { createCrop }; +\ No newline at end of file diff --git a/backend/node_server/controllers/farmController.js b/backend/node_server/controllers/farmController.js @@ -106,4 +106,6 @@ const getCropHistory = async (req, res) => { } }; + + module.exports = { addMemory, getDashboard, getCropHistory }; \ No newline at end of file diff --git a/backend/node_server/index.js b/backend/node_server/index.js @@ -1,7 +1,8 @@ const express = require('express'); const cors = require('cors'); -const { initDB } = require('./config/db'); +const { initDB, connectMongoDB } = require('./config/db'); const farmRoutes = require('./routes/farmRoutes'); +const cropRoutes = require('./routes/cropRoutes'); const app = express(); const PORT = process.env.PORT || 3001; @@ -12,10 +13,12 @@ app.use(cors()); // Initialize Database & Indexes initDB(); +connectMongoDB(); // Mount Routes // All routes inside farmRoutes will be prefixed with /api app.use('/api', farmRoutes); +app.use('/api/crop', cropRoutes); app.listen(PORT, () => { console.log(`🚀 Server running on port ${PORT}`); diff --git a/backend/node_server/package-lock.json b/backend/node_server/package-lock.json @@ -13,10 +13,20 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "mongoose": "^9.3.3", "nodemon": "^3.1.11", "uuid": "^13.0.0" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@qdrant/js-client-rest": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.17.0.tgz", @@ -44,6 +54,21 @@ "pnpm": ">=8" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -116,9 +141,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -139,6 +164,15 @@ "node": ">=8" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -686,6 +720,15 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -704,6 +747,12 @@ "node": ">= 0.8" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -756,6 +805,104 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mongodb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.1.tgz", + "integrity": "sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.3.3.tgz", + "integrity": "sha512-sfv5LOIPWeN5o/281kp4Rx9ZnuXb0g8CtvBTi7trYQs2PYYx8LWXegXxG3ar7VEns1o+d4h9LI/Dtc7dTTyYmA==", + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.1", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -870,9 +1017,9 @@ } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -900,6 +1047,15 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "license": "MIT" }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -1108,6 +1264,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1120,6 +1282,15 @@ "node": ">=10" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1171,6 +1342,18 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -1186,9 +1369,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "license": "Apache-2.0", "peer": true, "bin": { @@ -1245,6 +1428,28 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/node_server/package.json b/backend/node_server/package.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", + "mongoose": "^9.3.3", "nodemon": "^3.1.11", "uuid": "^13.0.0" } diff --git a/backend/node_server/routes/cropRoutes.js b/backend/node_server/routes/cropRoutes.js @@ -0,0 +1,7 @@ +const express = require('express'); +const router = express.Router(); +const { createCrop } = require('../controllers/cropController'); + +router.post('/create', createCrop); + +module.exports = router; +\ No newline at end of file diff --git a/backend/node_server/schema/cropSchema.js b/backend/node_server/schema/cropSchema.js @@ -0,0 +1,34 @@ +const mongoose = require('mongoose'); + +const sensorSchema = new mongoose.Schema({ + pH: [Number], + EC: [Number], + temp: [Number], + humidity: [Number] +}, { _id: false }); + +const cropStateSchema = new mongoose.Schema({ + crop_id: { type: String, required: true, unique: true }, + crop: String, + stage: String, + sequence_number: { type: Number, default: 0 }, + + cycle_duration_hours: { type: Number, default: 1 }, + total_crop_lifetime_days: { type: Number, default: 0 }, + planted_at: { type: Date, default: Date.now }, + last_updated: { type: Date, default: Date.now }, + + sensors: sensorSchema, + + action_taken: mongoose.Schema.Types.Mixed, + outcome: String, + explanation_log: String, + bandit_action_id: Number, + strategic_intent: String, + reward_score: Number, + visual_diagnosis: String, + + schema_version: { type: String, default: "1.1" } +}); + +module.exports = mongoose.model('CropState', cropStateSchema); +\ No newline at end of file diff --git a/simulator/lettuce_brain_v1.zip b/simulator/lettuce_brain_v1.zip Binary files differ. diff --git a/simulator/main.py b/simulator/main.py @@ -1,73 +1,27 @@ import base64 -import time import os -import json -import threading import uvicorn import numpy as np import torch -import torch.nn as nn from io import BytesIO from collections import deque -from dataclasses import dataclass, asdict from fastapi import FastAPI from pydantic import BaseModel +from typing import List from PIL import Image from dotenv import load_dotenv +from pymongo import MongoClient +from typing import Optional load_dotenv() -# --- AZURE DIGITAL TWINS CONFIG --- -try: - from azure.identity import DefaultAzureCredential - from azure.digitaltwins.core import DigitalTwinsClient - - credential = DefaultAzureCredential() - adt_url = os.environ.get("ADT_URL", "simulator.api.krc.digitaltwins.azure.net") - client = DigitalTwinsClient(adt_url, credential) - twin_id = "HydrophonicTank" - AZURE_ENABLED = True -except Exception as e: - print(f"Azure Digital Twins disabled: {e}") - AZURE_ENABLED = False - - -def sync_to_azure(state): - if not AZURE_ENABLED: - return - ph, ec, water_temp, air_temp, humidity, vpd, biomass = state - payload = { - "ph": float(ph), - "ec": float(ec), - "water_temp": float(water_temp), - "air_temp": float(air_temp), - "humidity": float(humidity), - "vpd": float(vpd), - "biomass_g": float(biomass), - } - try: - client.publish_telemetry(twin_id, payload) - except Exception as e: - print(f"Azure Sync Error: {e}") - - -# --- CONFIG --- -MODEL_PATH = "models/PPO/lettuce_brain_v1.zip" -HISTORY_LEN = 20 - - -# --- DATA MODELS --- -@dataclass -class FarmStateData: - ph: float - ec: float - water_temp: float - air_temp: float - humidity: float - vpd: float - biomass_g: float - tank_volume_l: float +MONGO_URI = os.environ.get("MONGO_URI", "mongodb+srv://abhi:lovesv7@demeter.qfvttv1.mongodb.net/?appName=Demeter") +mongo_client = MongoClient(MONGO_URI) +db = mongo_client["test"] +crops_collection = db["cropstates"] +MODEL_PATH = "/lettuce_brain_v1.zip" +HISTORY_LEN = 20 class FarmAction(BaseModel): acid_dosage_ml: float = 0.0 @@ -75,10 +29,12 @@ class FarmAction(BaseModel): nutrient_dosage_ml: float = 0.0 fan_speed_pct: float = 0.0 water_refill_l: float = 0.0 - debug_force_ph: float | None = None + debug_force_ph: Optional[float] = None +class BatchActionRequest(BaseModel): + crop_id: str + action: FarmAction -# --- PHYSICS ENGINE (Research Grade) --- class ResidualPhysicsNet(torch.nn.Module): def __init__(self, state_dim, action_dim): super().__init__() @@ -94,27 +50,23 @@ class ResidualPhysicsNet(torch.nn.Module): x = torch.cat([state, action], dim=-1) return self.net(x) - class DigitalTwin: - def __init__(self): - # Initial State: [pH, EC, WaterT, AirT, Hum, VPD, Biomass] - self.state = np.array([6.0, 1.5, 20.0, 24.0, 60.0, 1.0, 10.0], dtype=np.float32) + def __init__(self, crop_id: str, initial_state: list): + self.crop_id = crop_id + self.state = np.array(initial_state, dtype=np.float32) self.tank_volume = 100.0 self.plant_health = 100.0 - self.crop_id = "BATCH-VERDANT-X1" - self.residual_model = ResidualPhysicsNet(7, 4) - # History self.history = { - "ph": deque([6.0] * 5, maxlen=HISTORY_LEN), - "ec": deque([1.5] * 5, maxlen=HISTORY_LEN), - "water_temp": deque([20.0] * 5, maxlen=HISTORY_LEN), - "air_temp": deque([24.0] * 5, maxlen=HISTORY_LEN), - "humidity": deque([60.0] * 5, maxlen=HISTORY_LEN), + "ph": deque([float(self.state[0])] * 5, maxlen=HISTORY_LEN), + "ec": deque([float(self.state[1])] * 5, maxlen=HISTORY_LEN), + "water_temp": deque([float(self.state[2])] * 5, maxlen=HISTORY_LEN), + "air_temp": deque([float(self.state[3])] * 5, maxlen=HISTORY_LEN), + "humidity": deque([float(self.state[4])] * 5, maxlen=HISTORY_LEN), "co2": deque([400.0] * 5, maxlen=HISTORY_LEN), "light_intensity": deque([0.0] * 5, maxlen=HISTORY_LEN), - "vpd": deque([1.0] * 5, maxlen=HISTORY_LEN), + "vpd": deque([float(self.state[5])] * 5, maxlen=HISTORY_LEN), } def _calculate_vpd(self, temp, hum): @@ -126,17 +78,13 @@ class DigitalTwin: if action is None: action = FarmAction() - u = np.array( - [ - action.acid_dosage_ml / 10.0, - action.base_dosage_ml / 10.0, - action.nutrient_dosage_ml / 20.0, - action.fan_speed_pct / 100.0, - ], - dtype=np.float32, - ) + u = np.array([ + action.acid_dosage_ml / 10.0, + action.base_dosage_ml / 10.0, + action.nutrient_dosage_ml / 20.0, + action.fan_speed_pct / 100.0, + ], dtype=np.float32) - # 1. Physics Calculations ph, ec, wt, at, hum, vpd, bio = self.state d_ph = (u[1] * 0.5) - (u[0] * 0.5) + (0.001 * bio) @@ -145,137 +93,136 @@ class DigitalTwin: uptake = 0.02 * bio * vpd d_ec = (u[2] * 0.2) - (uptake / self.tank_volume) - d_at = 0.1 - (u[3] * 1.5) d_hum = 1.0 - (u[3] * 5.0) stress = abs(vpd - 1.0) growth = 0.1 * bio * (1.0 - min(stress, 1.0)) - physics_delta = np.array( - [d_ph, d_ec, 0, d_at, d_hum, 0, growth], dtype=np.float32 - ) + physics_delta = np.array([d_ph, d_ec, 0, d_at, d_hum, 0, growth], dtype=np.float32) - # 2. Neural Residual with torch.no_grad(): - nn_delta = self.residual_model( - torch.tensor(self.state), torch.tensor(u) - ).numpy() + nn_delta = self.residual_model(torch.tensor(self.state), torch.tensor(u)).numpy() - # 3. Update State self.state += physics_delta + (nn_delta * 0.05) - - # Clip & Recalc self.state[3] = np.clip(self.state[3], 0, 50) self.state[4] = np.clip(self.state[4], 0, 100) self.state[5] = self._calculate_vpd(self.state[3], self.state[4]) self.state[6] = max(0.1, self.state[6]) - # Calculate Health ph_score = max(0, 1.0 - abs(self.state[0] - 6.0)) vpd_score = max(0, 1.0 - abs(self.state[5] - 1.0)) - - # FIX: Ensure calculation results in a standard float - health_calc = (float(ph_score) + float(vpd_score)) * 50.0 - self.plant_health = max(0.0, min(100.0, health_calc)) + self.plant_health = max(0.0, min(100.0, (float(ph_score) + float(vpd_score)) * 50.0)) self._update_history() return self._generate_image() def _update_history(self): s = self.state - # FIX: Explicit float() casting prevents numpy errors in JSON self.history["ph"].append(float(s[0])) self.history["ec"].append(float(s[1])) self.history["water_temp"].append(float(s[2])) self.history["air_temp"].append(float(s[3])) self.history["humidity"].append(float(s[4])) self.history["vpd"].append(float(s[5])) - self.history["co2"].append(400.0) - self.history["light_intensity"].append(0.0) def _generate_image(self): - bucket = int(self.plant_health // 10) * 10 - bucket = max(0, min(90, bucket)) + bucket = max(0, min(90, int(self.plant_health // 10) * 10)) filename = f"{bucket}.png" - if os.path.exists(filename): return Image.open(filename) return Image.new("RGB", (512, 512), (50, 50, 50)) - -# --- SERVER --- app = FastAPI() -sim = DigitalTwin() - +simulators = {} + +def sync_simulators_from_db(): + db_crops = crops_collection.find({}) + for crop in db_crops: + cid = crop.get("crop_id") + if not cid: + continue + + if cid not in simulators: + sensors = crop.get("sensors", {}) + + ph_val = sensors.get("pH", [6.0]) + ec_val = sensors.get("EC", [1.5]) + temp_val = sensors.get("temp", [24.0]) + hum_val = sensors.get("humidity", [60.0]) + + state = [ + ph_val[-1] if isinstance(ph_val, list) else ph_val, + ec_val[-1] if isinstance(ec_val, list) else ec_val, + 20.0, + temp_val[-1] if isinstance(temp_val, list) else temp_val, + hum_val[-1] if isinstance(hum_val, list) else hum_val, + 1.0, + 10.0 + ] + simulators[cid] = DigitalTwin(cid, state) @app.get("/simulation/state") -async def get_state(): - pil_img = sim.step() - - buf = BytesIO() - pil_img.save(buf, format="PNG") - img_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") - - # FIX: Casting numpy values to python types for JSON serialization - return { - "sensor_window": {k: list(v) for k, v in sim.history.items()}, - "metadata": { - "crop": "lettuce", - "stage": "vegetative" if sim.state[6] > 5.0 else "seedling", - # FIX: Convert health to float before rounding - "health": round(float(sim.plant_health), 1), - "crop_id": sim.crop_id, - "biomass_est": round(float(sim.state[6]), 2), - }, - "image": img_b64, - } - - -@app.get("/azure/state") -async def fetch_from_azure(): - if not AZURE_ENABLED: - return {"status": "error", "message": "Azure Digital Twins is disabled."} - - try: - twin = client.get_digital_twin(twin_id) - - biomass = float(twin.get("biomass_g", 0.0)) - - return { - "sensor_window": { - "ph": [twin.get("ph", 0.0)], - "ec": [twin.get("ec", 0.0)], - "water_temp": [twin.get("water_temp", 0.0)], - "air_temp": [twin.get("air_temp", 0.0)], - "humidity": [twin.get("humidity", 0.0)], - "vpd": [twin.get("vpd", 0.0)], - }, +async def get_all_states(): + sync_simulators_from_db() + + response = [] + for cid, sim in simulators.items(): + pil_img = sim._generate_image() + buf = BytesIO() + pil_img.save(buf, format="PNG") + img_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + + response.append({ + "crop_id": cid, + "sensor_window": {k: list(v) for k, v in sim.history.items()}, "metadata": { - "crop": "lettuce", - "stage": "vegetative" if biomass > 5.0 else "seedling", - "health": 100.0, - "crop_id": twin.get("$dtId", "Unknown"), - "biomass_est": round(biomass, 2), + "health": round(float(sim.plant_health), 1), + "biomass_est": round(float(sim.state[6]), 2), }, - "image": "", - } - except Exception as e: - return {"status": "error", "message": str(e)} - + "image": img_b64, + }) + return response @app.post("/simulation/action") -async def take_action(action: FarmAction): - sim.step(action) - - sync_to_azure(sim.state) - - return { - "status": "success", - "new_state": {"ph": float(sim.state[0]), "ec": float(sim.state[1])}, - } +async def take_batch_actions(payload: List[BatchActionRequest]): + sync_simulators_from_db() + + results = [] + for req in payload: + cid = req.crop_id + if cid not in simulators: + continue + + sim = simulators[cid] + sim.step(req.action) + + push_payload = { + "sensors.pH": {"$each": [float(sim.state[0])], "$slice": -5}, + "sensors.EC": {"$each": [float(sim.state[1])], "$slice": -5}, + "sensors.temp": {"$each": [float(sim.state[3])], "$slice": -5}, + "sensors.humidity": {"$each": [float(sim.state[4])], "$slice": -5} + } + + crops_collection.update_one( + {"crop_id": cid}, + { + "$push": push_payload, + "$inc": {"sequence_number": 1} + } + ) + results.append({ + "crop_id": cid, + "status": "success", + "new_state": { + "pH": float(sim.state[0]), + "EC": float(sim.state[1]) + } + }) + + return {"updated_crops": results} if __name__ == "__main__": port = int(os.environ.get("SIMULATOR_PORT", 8001)) - uvicorn.run(app, host="0.0.0.0", port=port) + uvicorn.run(app, host="0.0.0.0", port=port) +\ No newline at end of file