commit 39bd6d6d45ca9cb6ff6637e21117e0f558600ff5
parent ec1d78dc599b6918c5e5202b6d54eed116595a22
Author: maydayv7 <maydayv7@gmail.com>
Date: Sat, 21 Mar 2026 20:19:57 +0530
Add simulator
Diffstat:
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">
-
-
-
-
+<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