demeter

Autonomous Hydroponic Intelligence
commit 39bd6d6d45ca9cb6ff6637e21117e0f558600ff5
parent ec1d78dc599b6918c5e5202b6d54eed116595a22
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat, 21 Mar 2026 20:19:57 +0530

Add simulator

Diffstat:
Magent/Qdrant/Client.py | 11+++++++----
Magent/main_agent.py | 66++++++++++++++++++++++++++++++++++++------------------------------
Magent/sub_agents/fetching_agent.py | 65+++++++++++++++++++++++++++++++++++++++++++++--------------------
Magent/sub_agents/water_and_atmospheric_dependencies/physics_engine.py | 30++++++++++++++++--------------
Magent/tools/db_tools.py | 34++++++++++++++++------------------
Mbackend/node_server/package.json | 1+
Mbackend/server/main.py | 6++++++
Mfrontend/package-lock.json | 47+++++++++++++++++++++++++++++++++++++++++++----
Mfrontend/package.json | 7++++---
Mfrontend/src/api/agentApi.js | 2+-
Mfrontend/src/api/farmApi.jsx | 3++-
Mfrontend/src/pages/Settings.jsx | 4++--
Mreadme.md | 123++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Asimulator/main.py | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulator/requirements.txt | 9+++++++++
15 files changed, 547 insertions(+), 142 deletions(-)

diff --git a/agent/Qdrant/Client.py b/agent/Qdrant/Client.py @@ -1,8 +1,12 @@ +import os +from dotenv import load_dotenv from qdrant_client import QdrantClient +load_dotenv() + client = QdrantClient( - url="https://2a9e6ab0-e572-4bfa-a50f-0a169f9753d3.europe-west3-0.gcp.cloud.qdrant.io:6333", - api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3MiOiJtIn0.RG2XaX6thvBqI6TCtUrFg8znHYbMuFGOvbxoxPgT020", + url=os.getenv("QDRANT_URL", "http://localhost:6333"), + api_key=os.getenv("QDRANT_API_KEY"), ) -# print(qdrant_client.get_collections()) -\ No newline at end of file +# print(client.get_collections()) diff --git a/agent/main_agent.py b/agent/main_agent.py @@ -2,6 +2,9 @@ import sys import os import requests import time +from dotenv import load_dotenv + +load_dotenv() current_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.append(current_dir) @@ -10,15 +13,18 @@ from sub_agents.fetching_agent import FetchingAgent from sub_agents.judge_agent import JudgeAgent from sub_agents.atmospheric_agent import AtmosphericAgent from sub_agents.water_agent import WaterAgent -from sub_agents.Researcher import ResearcherAgent +from sub_agents.Researcher import ResearcherAgent from sub_agents.Supervisor import SupervisorAgent # Update this URL to your running simulator instance -SIMULATOR_ACTION_URL = "https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/action" +SIMULATOR_ACTION_URL = os.getenv( + "SIMULATOR_ACTION_URL", "http://localhost:3001/simulation/action" +) + def main(): print("šŸš€ Initializing Demeter Orchestrator...") - + try: fetcher = FetchingAgent() judge = JudgeAgent() @@ -26,18 +32,18 @@ def main(): atmos_agent = AtmosphericAgent() water_agent = WaterAgent() # Pass researcher so Supervisor can share the RAG tools if needed - supervisor = SupervisorAgent(researcher_agent=researcher) - + supervisor = SupervisorAgent(researcher_agent=researcher) + print("āœ… Agents Online.") except Exception as e: print(f"āŒ Init Error: {e}") return while True: - print("\n" + "="*50) + print("\n" + "=" * 50) print("ā±ļø STARTING NEW CYCLE") - print("="*50) - + print("=" * 50) + # 1. Fetch fmu, sensor_snapshot, history, image_b64 = fetcher.fetch_and_process() if not fmu: @@ -46,12 +52,12 @@ def main(): continue # 2. Judge - time.sleep(2) # Small delay to ensure FMU is fully available before judging + time.sleep(2) # Small delay to ensure FMU is fully available before judging judge.review_previous_cycle(fmu, image_b64) # 3. 🟢 GET BANDIT STRATEGY (The Brain) # The Supervisor consults the Bandit first to set the cycle's goal - time.sleep(2) # Ensure judge's review is complete before strategy retrieval + time.sleep(2) # Ensure judge's review is complete before strategy retrieval strat_name, strat_instr, action_idx = supervisor.get_strategic_goal(fmu) print(f"\nšŸŽ° BANDIT STRATEGY: {strat_name}") print(f"šŸ“ Instruction: {strat_instr}") @@ -61,33 +67,33 @@ def main(): stage = fmu.metadata.get("stage", "unknown") query = f"optimal hydroponic conditions for {crop} in {stage} stage" - time.sleep(2) # Small delay before research + time.sleep(2) # Small delay before research research_context = researcher.search(query) - + # 5. 🟢 DELIBERATION (The Experts) # We pass the strategy instruction and history to the LangGraph agents print("\n🧠 Agents Planning...") - + # Updated call signature to match the new 'reason' method - time.sleep(2) # Ensure research context is ready before reasoning + time.sleep(2) # Ensure research context is ready before reasoning atmos_plan = atmos_agent.reason( - sensors=sensor_snapshot, - research=research_context, - strategy=strat_instr, # Pass the instruction text (e.g. "LOWER pH...") - history=history, # Pass history for context awareness - image_b64=image_b64 # Pass the image data for visual diagnosis + sensors=sensor_snapshot, + research=research_context, + strategy=strat_instr, # Pass the instruction text (e.g. "LOWER pH...") + history=history, # Pass history for context awareness + image_b64=image_b64, # Pass the image data for visual diagnosis ) print(f"\nšŸŒ¬ļø Atmospheric Plan:\n{atmos_plan}") - time.sleep(2) # Small delay between agent calls - + time.sleep(2) # Small delay between agent calls + water_plan = water_agent.reason( - sensors=sensor_snapshot, - research=research_context, + sensors=sensor_snapshot, + research=research_context, strategy=strat_instr, history=history, - image_b64=image_b64 + image_b64=image_b64, ) print(f"\nšŸ’§ Water & Nutrient Plan:\n{water_plan}") @@ -96,11 +102,11 @@ def main(): # Supervisor merges plans, checks conflicts, and ensures safety print("\nšŸ‘® Supervisor Finalizing...") final_action = supervisor.synthesize_plan( - atmos_plan, - water_plan, - fmu, + atmos_plan, + water_plan, + fmu, history, - strategy_info=(strat_name, strat_instr, action_idx) + strategy_info=(strat_name, strat_instr, action_idx), ) print(f"šŸŽÆ FINAL COMMAND: {final_action}") @@ -115,5 +121,6 @@ def main(): print("\nzzz Sleeping 15s...") time.sleep(15) + if __name__ == "__main__": - main() -\ No newline at end of file + main() diff --git a/agent/sub_agents/fetching_agent.py b/agent/sub_agents/fetching_agent.py @@ -4,7 +4,7 @@ import requests from pathlib import Path current_file = Path(__file__).resolve() -project_root = current_file.parent.parent.parent +project_root = current_file.parent.parent.parent sys.path.append(str(project_root)) from qdrant_client import models @@ -12,22 +12,27 @@ from Sentinel.agent import FMUBuilder from Qdrant.Store import COLLECTION_NAME from Qdrant.Client import client + class FetchingAgent: - def __init__(self, simulator_url="https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/state"): + def __init__(self, simulator_url=None): + if simulator_url is None: + simulator_url = os.environ.get( + "SIMULATOR_STATE_URL", "http://localhost:3001/simulation/state" + ) self.sim_url = simulator_url self.builder = FMUBuilder() def fetch_and_process(self): print(f"[Fetcher] šŸ“” Requesting data from {self.sim_url}...") - + try: response = requests.get(self.sim_url) - + if response.status_code == 200: data = response.json() - + window_data = data.get("sensor_window", {}) - image_b64 = data.get("image", "") + image_b64 = data.get("image", "") raw_meta = data.get("metadata", {}) print(data) @@ -38,18 +43,29 @@ class FetchingAgent: from PIL import Image import base64 from io import BytesIO - img = Image.new('RGB', (512, 512), (50, 50, 50)) + + img = Image.new("RGB", (512, 512), (50, 50, 50)) buf = BytesIO() img.save(buf, format="PNG") image_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") - wanted_keys = {"ph": "pH", "ec": "EC", "humidity": "humidity", "temp": "temp", "air_temp": "temp"} + wanted_keys = { + "ph": "pH", + "ec": "EC", + "humidity": "humidity", + "temp": "temp", + "air_temp": "temp", + } sensor_snapshot = {} for key, value_list in window_data.items(): key_lower = key.lower() if key_lower in wanted_keys: out_name = wanted_keys[key_lower] - val = value_list[-1] if isinstance(value_list, list) and value_list else 0.0 + val = ( + value_list[-1] + if isinstance(value_list, list) and value_list + else 0.0 + ) sensor_snapshot[out_name] = val crop_id = raw_meta.get("crop_id", "UNKNOWN_CROP") @@ -61,15 +77,19 @@ class FetchingAgent: "stage": raw_meta.get("stage", "unknown"), "crop_id": crop_id, "sequence_number": next_seq, - "image_b64": image_b64 + "image_b64": image_b64, } - fmu = self.builder.create_fmu(image_b64, sensor_snapshot, filtered_metadata) - - print(f"[Fetcher] 🧠 FMU Created (ID: {fmu.id}) - Handing off to Judge.") - + fmu = self.builder.create_fmu( + image_b64, sensor_snapshot, filtered_metadata + ) + + print( + f"[Fetcher] 🧠 FMU Created (ID: {fmu.id}) - Handing off to Judge." + ) + search_results = self.find_similar_instances(fmu) - + return fmu, sensor_snapshot, search_results, image_b64 else: print(f"[Fetcher] āŒ Error: Simulator returned {response.status_code}") @@ -83,9 +103,15 @@ class FetchingAgent: """Queries Qdrant for count of existing points for this crop_id.""" try: count_filter = models.Filter( - must=[models.FieldCondition(key="crop_id", match=models.MatchValue(value=crop_id))] + must=[ + models.FieldCondition( + key="crop_id", match=models.MatchValue(value=crop_id) + ) + ] + ) + count_result = client.count( + collection_name=COLLECTION_NAME, count_filter=count_filter ) - count_result = client.count(collection_name=COLLECTION_NAME, count_filter=count_filter) return count_result.count + 1 except Exception: return 1 @@ -97,8 +123,8 @@ class FetchingAgent: collection_name=COLLECTION_NAME, query_vector=current_fmu.vector, limit=3, - with_payload=True + with_payload=True, ) return [{"payload": hit.payload} for hit in hits] except Exception: - return [] -\ No newline at end of file + return [] diff --git a/agent/sub_agents/water_and_atmospheric_dependencies/physics_engine.py b/agent/sub_agents/water_and_atmospheric_dependencies/physics_engine.py @@ -4,8 +4,9 @@ from langchain_openai import ChatOpenAI from langchain_core.messages import SystemMessage, HumanMessage # Configuration -API_KEY = os.environ.get("GROQ_API_KEY1") -MODEL_ID = "qwen/qwen3-32b" # Using the latest supported Groq model +API_KEY = os.environ.get("GROQ_API_KEY") +MODEL_ID = "qwen/qwen3-32b" # Using the latest supported Groq model + def predict_outcome(current_state: dict, proposed_action: dict) -> dict: """ @@ -21,10 +22,10 @@ def predict_outcome(current_state: dict, proposed_action: dict) -> dict: base_url="https://api.groq.com/openai/v1", api_key=API_KEY, model=MODEL_ID, - temperature=0.1, # Low temp for consistent physics logic - max_tokens=1024 + temperature=0.1, # Low temp for consistent physics logic + max_tokens=1024, ) - + system_prompt = ( "You are a Hydroponic Physics Engine.\n" "Your task is to simulate the biological and chemical reaction of a plant ecosystem " @@ -43,21 +44,23 @@ def predict_outcome(current_state: dict, proposed_action: dict) -> dict: try: # Invoke Groq - response = llm.invoke([ - SystemMessage(content=system_prompt), - HumanMessage(content=user_prompt) - ]) - + response = llm.invoke( + [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)] + ) + # Clean and Parse JSON content = response.content.replace("```json", "").replace("```", "").strip() result = json.loads(content) - + # Default fallback keys if the LLM misses them return { "predicted_health": result.get("predicted_health", 50.0), - "risk_warning": result.get("risk_warning", "Unknown Risk") + "risk_warning": result.get("risk_warning", "Unknown Risk"), } except Exception as e: print(f" āš ļø Physics Engine Error: {e}") - return {"predicted_health": 70.0, "risk_warning": "Simulation Connection Failed"} -\ No newline at end of file + return { + "predicted_health": 70.0, + "risk_warning": "Simulation Connection Failed", + } diff --git a/agent/tools/db_tools.py b/agent/tools/db_tools.py @@ -1,21 +1,27 @@ # tools/db_tools.py +import os from qdrant_client import QdrantClient, models from qdrant_client.models import PointStruct +from dotenv import load_dotenv + +load_dotenv() + class DBTools: - def __init__(self, host="https://2a9e6ab0-e572-4bfa-a50f-0a169f9753d3.europe-west3-0.gcp.cloud.qdrant.io", collection_name="Farm_Memory"): - self.client = QdrantClient(url=host) + def __init__(self, host=None, collection_name="Farm_Memory"): + if host is None: + host = os.environ.get("QDRANT_URL", "http://localhost:6333") + self.client = QdrantClient(url=host, api_key=os.environ.get("QDRANT_API_KEY")) self.collection_name = collection_name - self.vector_size = 516 # As seen in Qdrant/Setup.py + self.vector_size = 516 # As seen in Qdrant/Setup.py def setup_database(self): """Creates or resets the memory collection.""" self.client.recreate_collection( collection_name=self.collection_name, vectors_config=models.VectorParams( - size=self.vector_size, - distance=models.Distance.COSINE - ) + size=self.vector_size, distance=models.Distance.COSINE + ), ) print(f"[DB] Collection '{self.collection_name}' ready.") @@ -25,14 +31,9 @@ class DBTools: Derived from Qdrant/Store.py """ point = PointStruct( - id=fmu_data.id, - vector=fmu_data.vector, - payload=fmu_data.metadata - ) - self.client.upsert( - collection_name=self.collection_name, - points=[point] + id=fmu_data.id, vector=fmu_data.vector, payload=fmu_data.metadata ) + self.client.upsert(collection_name=self.collection_name, points=[point]) print(f"[DB] Stored FMU ID: {fmu_data.id}") def search_similar(self, vector, limit=5): @@ -41,8 +42,6 @@ class DBTools: Derived from backend/server/main.py search endpoint """ hits = self.client.search( - collection_name=self.collection_name, - query_vector=vector, - limit=limit + collection_name=self.collection_name, query_vector=vector, limit=limit ) - return [{"score": hit.score, "payload": hit.payload} for hit in hits] -\ No newline at end of file + return [{"score": hit.score, "payload": hit.payload} for hit in hits] diff --git a/backend/node_server/package.json b/backend/node_server/package.json @@ -7,6 +7,7 @@ "type": "commonjs", "main": "index.js", "scripts": { + "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { diff --git a/backend/server/main.py b/backend/server/main.py @@ -1,5 +1,6 @@ import sys import os +from dotenv import load_dotenv # --- PATH FIX --- current_dir = os.path.dirname(os.path.abspath(__file__)) @@ -9,6 +10,11 @@ project_root = os.path.abspath(os.path.join(current_dir, "../../")) sys.path.append(project_root) agent_root = os.path.abspath(os.path.join(project_root, "agent")) sys.path.append(agent_root) + +# Load env from root +env_path = os.path.join(project_root, '.env') +if os.path.exists(env_path): + load_dotenv(env_path) # ---------------- from fastapi import FastAPI, UploadFile, File, Form diff --git a/frontend/package-lock.json b/frontend/package-lock.json @@ -22,6 +22,7 @@ }, "devDependencies": { "autoprefixer": "^10.4.23", + "env-cmd": "^11.0.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.19" } @@ -7085,6 +7086,44 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-cmd": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-11.0.0.tgz", + "integrity": "sha512-gnG7H1PlwPqsGhFJNTv68lsDGyQdK+U9DwLVitcj1+wGq7LeOBgUzZd2puZ710bHcH9NfNeGWe2sbw7pdvAqDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commander-js/extra-typings": "^13.1.0", + "commander": "^13.1.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "env-cmd": "bin/env-cmd.js" + }, + "engines": { + "node": ">=20.10.0" + } + }, + "node_modules/env-cmd/node_modules/@commander-js/extra-typings": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-13.1.0.tgz", + "integrity": "sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~13.1.0" + } + }, + "node_modules/env-cmd/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -16503,9 +16542,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "license": "Apache-2.0", "peer": true, "bin": { @@ -16513,7 +16552,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/frontend/package.json b/frontend/package.json @@ -16,9 +16,9 @@ "web-vitals": "^2.1.4" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", + "start": "env-cmd -f ../.env react-scripts start", + "build": "env-cmd -f ../.env react-scripts build", + "test": "env-cmd -f ../.env react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { @@ -41,6 +41,7 @@ }, "devDependencies": { "autoprefixer": "^10.4.23", + "env-cmd": "^11.0.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.19" } diff --git a/frontend/src/api/agentApi.js b/frontend/src/api/agentApi.js @@ -4,7 +4,7 @@ import { MOCK_DASHBOARD, } from "../data/mockData"; -const API_URL = "http://localhost:8000"; +const API_URL = process.env.REACT_APP_AGENT_API_URL || "http://localhost:8000"; export const agentService = { /** diff --git a/frontend/src/api/farmApi.jsx b/frontend/src/api/farmApi.jsx @@ -1,6 +1,7 @@ import { USE_MOCK_DATA, MOCK_DASHBOARD, MOCK_HISTORY } from "../data/mockData"; -const API_BASE_URL = "http://localhost:3001/api"; +const API_BASE_URL = + process.env.REACT_APP_FARM_API_URL || "http://localhost:3001/api"; /** * Fetches the latest state of all unique crops for the Dashboard. diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx @@ -513,8 +513,8 @@ export default function SettingsPage() { }} > {USE_MOCK_DATA - ? "To connect to live data, open src/data/mockData.js and set USE_MOCK_DATA = false" - : "Connected to http://localhost:3001 — real-time data"} + ? "To connect to live data, set USE_MOCK_DATA = false in src/data/mockData.js" + : `Connected to ${process.env.REACT_APP_FARM_API_URL || "http://localhost:3001/api"}`} </div> </div> </div> diff --git a/readme.md b/readme.md @@ -3,12 +3,8 @@ <a href="https://drive.google.com/file/d/1VAN31mXPaQ7r4Fm8dpzjhgGeeQwvlH-Z/view?usp=drive_link"> <img src="https://img.shields.io/badge/Demeter-Hydroponic_AI-4CAF50?style=for-the-badge&logo=robot&logoColor=white" alt="Demeter Logo"> </a> -<div align="center"> -![Python](https://img.shields.io/badge/Python-3.10+-blue?style=flat-square&logo=python) -![React](https://img.shields.io/badge/React-19+-61DAFB?style=flat-square&logo=react) -![FastAPI](https://img.shields.io/badge/FastAPI-009688?style=flat-square&logo=fastapi) -![Qdrant](https://img.shields.io/badge/Qdrant-Vector_DB-FF6B6B?style=flat-square) +<div align="center"> **Industrial-grade Multi-Agent System for autonomous hydroponic farming through AI-driven reasoning** @@ -162,16 +158,6 @@ Before installing Demeter, ensure you have: - **Git** - [Download](https://git-scm.com/) - **Docker Desktop** (for local Qdrant) OR [Qdrant Cloud account](https://cloud.qdrant.io/) -### Required API Keys - -| Service | Environment Variable | Where to Get | -| ----------- | ------------------------------- | --------------------------------------------------------------------- | -| **Groq** | `GROQ_API_KEY` | [console.groq.com/keys](https://console.groq.com/keys) | -| **Qdrant** | `QDRANT_URL` & `QDRANT_API_KEY` | [cloud.qdrant.io](https://cloud.qdrant.io) | -| **SerpAPI** | `SERPAPI_API_KEY` | [serpapi.com](https://serpapi.com) (optional) | -| **OpenAI** | `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com) (optional) | -| **Azure** | See list below | [Azure for Students](https://azure.microsoft.com/en-us/free/students) | - ### 1. Clone and Setup ```bash @@ -183,34 +169,61 @@ cd demeter python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate -# Install Python dependencies +# Install Core Python dependencies +pip install -r requirements.txt + +# Install Simulator dependencies +cd simulator pip install -r requirements.txt +cd .. + +# Install Backend Node dependencies +cd backend/node_server +npm install +cd ../.. + +# Install Frontend dependencies +cd frontend +npm install +cd .. ``` ### 2. Environment Configuration -Create a `.env` file in the project root: +Create a **single** `.env` file in the **project root directory**. All components are configured to read from this top-level file automatically. ```env -# Required: Azure Services -AZURE_API_KEY=your_azure_key_here -AZURE_ENDPOINT=https://msaiunlockedcustomvision-prediction.cognitiveservices.azure.com/ -AZURE_PROJECT_ID=your_azure_project_id_here -DATASET_FOLDER=your_dataset_here -AZURE_PREDICTION_KEY=your_azure_predict_key_here -AZURE_URL=your_azure_url_here -AZURE_ITERATION_NAME=DemeterDoctor-v1 +# Database +QDRANT_URL=http://localhost:6333 +QDRANT_API_KEY=your_qdrant_key_here -# Required: AI Provider +# LLM GROQ_API_KEY=gsk_your_key_here -# Required: Vector Database -QDRANT_URL=http://localhost:6333 -QDRANT_API_KEY=your_qdrant_key_here +# Simulator +SIMULATOR_PORT=8001 +SIMULATOR_ACTION_URL=http://localhost:8001/simulation/action +SIMULATOR_STATE_URL=http://localhost:8001/simulation/state +ADT_URL=simulator.api.krc.digitaltwins.azure.net +AZURE_TENANT_ID=your_azure_tenant_id_here +AZURE_CLIENT_ID=your_service_principal_client_id_here +AZURE_CLIENT_SECRET=your_service_principal_client_secret_here -# Optional: Enhanced features -OPENAI_API_KEY=sk-your_key_here -SERPAPI_API_KEY=your_serpapi_key_here +# Node Backend +PORT=3001 + +# React Frontend +REACT_APP_AGENT_API_URL=http://localhost:8000 +REACT_APP_FARM_API_URL=http://localhost:3001/api + +# Azure Services +AZURE_API_KEY=your_azure_key_here +AZURE_ENDPOINT=azure_endpoint_here +AZURE_PROJECT_ID=your_azure_project_id_here +DATASET_FOLDER="train" +AZURE_PREDICTION_KEY=your_azure_predict_key_here +AZURE_URL=your_azure_url_here +AZURE_ITERATION_NAME=DemeterDoctor ``` ### 3. Start Qdrant Database @@ -228,40 +241,60 @@ docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant ### 4. Initialize Database +Run the following command from the root directory to create the required collections and indexes: + ```bash -# Create required collections and indexes python backend/server/create-index.py ``` -### 5. Start the Agents +### 5. Start the Simulator + +Open a new terminal, activate the virtual environment, and run the Digital Twin Simulator from its directory: + +```bash +cd simulator +python main.py +``` + +### 6. Start the Agents Download [model_bandit_greedy.pkl](https://drive.google.com/file/d/1spuw3TogZRtP0fYZkYA2Kxz1-CiUzBDA/view?usp=drive_link) and [plant_disease_model.pt](https://drive.google.com/file/d/1NkdGt0CFS7tx4vttp8Tod8ksjDLib8dp/view?usp=drive_link) and place them under `agent/Marl` and `agent/Marl/model` respectively. +Open a new terminal, activate the virtual environment, and start the agent orchestrator from the agent directory: + ```bash -# Start the main AI agent system -python agent/main_agent.py +cd agent +python main_agent.py ``` -### 6. Start the Backend +### 7. Start the Backends + +**Python API Server:** +Open a new terminal, activate the virtual environment, and start the FastAPI server from the backend directory: ```bash -# Start the API server -python backend/server/main.py +cd backend/server +python main.py +``` -# In another terminal, start the website backend +**Node.js Database Server:** +Open a new terminal and start the Express server for fetching from the memory database: + +```bash cd backend/node_server -node index.js +npm start ``` -### 7. Run the Frontend +### 8. Run the Frontend + +Open a new terminal, navigate to the frontend directory, and run the development server: ```bash cd frontend -npm install -npm run start +npm start ``` -### 7. Access the Application +### 9. Access the Application - **Frontend**: http://localhost:3000 - **API Documentation**: http://localhost:8000/docs diff --git a/simulator/main.py b/simulator/main.py @@ -0,0 +1,281 @@ +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 PIL import Image +from dotenv import load_dotenv + +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 + + +class FarmAction(BaseModel): + acid_dosage_ml: float = 0.0 + base_dosage_ml: float = 0.0 + nutrient_dosage_ml: float = 0.0 + fan_speed_pct: float = 0.0 + water_refill_l: float = 0.0 + debug_force_ph: float | None = None + + +# --- PHYSICS ENGINE (Research Grade) --- +class ResidualPhysicsNet(torch.nn.Module): + def __init__(self, state_dim, action_dim): + super().__init__() + self.net = torch.nn.Sequential( + torch.nn.Linear(state_dim + action_dim, 64), + torch.nn.Tanh(), + torch.nn.Linear(64, 64), + torch.nn.ReLU(), + torch.nn.Linear(64, state_dim), + ) + + def forward(self, state, action): + 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) + 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), + "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), + } + + def _calculate_vpd(self, temp, hum): + es = 0.61078 * np.exp((17.27 * temp) / (temp + 237.3)) + ea = es * (hum / 100.0) + return max(0.0, es - ea) + + def step(self, action: FarmAction = None): + 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, + ) + + # 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) + if action.debug_force_ph: + self.state[0] = action.debug_force_ph + + 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 + ) + + # 2. Neural Residual + with torch.no_grad(): + 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._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)) + 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() + + +@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)], + }, + "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), + }, + "image": "", + } + except Exception as e: + return {"status": "error", "message": str(e)} + + +@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])}, + } + + +if __name__ == "__main__": + port = int(os.environ.get("SIMULATOR_PORT", 8001)) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/simulator/requirements.txt b/simulator/requirements.txt @@ -0,0 +1,9 @@ +fastapi +uvicorn +pydantic +numpy +torch +Pillow +python-dotenv +azure-identity +azure-digitaltwins-core