commit b95745bce5d0630447be1bfc6de73ca00a884ae8
parent f734d5ffd2193be4befaa6781481afe53d03ece4
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date: Mon, 2 Mar 2026 23:04:24 +0000
judge done
Diffstat:
8 files changed, 342 insertions(+), 184 deletions(-)
diff --git a/agent/main_agent.py b/agent/main_agent.py
@@ -11,26 +11,28 @@ sys.path.append(current_dir)
# Import Agents
from sub_agents.fetching_agent import FetchingAgent
+from sub_agents.judge_agent import JudgeAgent # š Import Judge
from sub_agents.atmospheric_agent import AtmosphericAgent
from sub_agents.water_agent import WaterAgent
from sub_agents.Researcher import ResearcherAgent
from sub_agents.Supervisor import SupervisorAgent
# Simulator Action URL
+# Updated to localhost to match the simulator we just created
SIMULATOR_ACTION_URL = "https://unexhumed-melaine-bouncingly.ngrok-free.dev/simulation/action"
def main():
- print("š Initializing Demeter Orchestrator...")
+ print("š Initializing Demeter Orchestrator (Judge-Review -> Supervisor-Store)...")
- # 1. Instantiate All Agents
try:
fetcher = FetchingAgent()
+ judge = JudgeAgent()
researcher = ResearcherAgent()
atmos_agent = AtmosphericAgent()
water_agent = WaterAgent()
supervisor = SupervisorAgent()
except Exception as e:
- print(f"ā Error initializing agents: {e}")
+ print(f"ā Init Error: {e}")
return
while True:
@@ -38,66 +40,48 @@ def main():
print("ā±ļø STARTING NEW CYCLE")
print("="*50)
- # 2. Fetch Reality (Current State + History)
- # fetcher returns: (FMU object, sensors dict, history list)
+ # 1. Fetch Reality (Seq N)
fmu, sensor_snapshot, history = fetcher.fetch_and_process()
if not fmu:
- print("ā Fetch failed or Simulator offline. Retrying in 10s...")
+ print("ā Fetch failed. Retrying in 10s...")
time.sleep(10)
continue
- # Extract Context
+ # 2. Judge Reviews History (Updates Seq N-1)
+ # Does NOT store current FMU yet
+ judge.review_previous_cycle(fmu)
+
+ # 3. Research & Reasoning
crop = fmu.metadata.get("crop", "unknown")
stage = fmu.metadata.get("stage", "unknown")
+ query = f"optimal hydroponic conditions for {crop} in {stage} stage"
- print(f"\n[Context] Crop: {crop} | Stage: {stage}")
- print(f"[Context] Current Sensors: {sensor_snapshot}")
-
- # 3. Get Research Knowledge
- # FIX: Adapted to use the 'search' method from your Researcher.py
- search_query = f"optimal hydroponic conditions for {crop} in {stage} stage"
- print(f"\n[Researcher] š Searching knowledge base for: '{search_query}'...")
-
- research_context = researcher.search(search_query)
+ research_context = researcher.search(query)
- # 4. Domain Agent Reasoning
- # They analyze the SENSORS against the RESEARCH
print("\nš§ Domain Agents Deliberating...")
-
- # Atmospheric Agent (Controls CO2, Light, Air Temp, Humidity)
atmos_plan = atmos_agent.reason(sensor_snapshot, research_context)
- print(f" š Atmospheric Plan: {atmos_plan}")
-
- # Water Agent (Controls pH, EC, Water Temp)
water_plan = water_agent.reason(sensor_snapshot, research_context)
- print(f" š Water Plan: {water_plan}")
- # 5. Supervisor Synthesis & Validation
- # Merges plans and checks against HISTORY for safety
- print("\nš® Supervisor Validating...")
+ # 4. Supervisor Decides & STORES Reality (Seq N)
+ # Now passes the full 'fmu' object so Supervisor can save it
+ print("\nš® Supervisor Validating & Storing...")
final_action = supervisor.synthesize_plan(
atmos_plan,
water_plan,
- fmu.metadata,
+ fmu, # <--- Passing full FMU object
history
)
print(f"šÆ FINAL COMMAND: {final_action}")
- # 6. Execute (Send to Simulator)
+ # 5. Execute
try:
- print(f"š” Sending command to Simulator...")
- resp = requests.post(SIMULATOR_ACTION_URL, json=final_action)
-
- if resp.status_code == 200:
- print("ā
Action accepted by Simulator.")
- else:
- print(f"ā ļø Simulator rejected action: {resp.status_code} - {resp.text}")
+ requests.post(SIMULATOR_ACTION_URL, json=final_action)
+ print("ā
Action sent to Simulator.")
except Exception as e:
print(f"ā Connection error: {e}")
- # Wait for next cycle
print("\nzzz Sleeping 15s...")
time.sleep(15)
diff --git a/agent/sub_agents/Supervisor.py b/agent/sub_agents/Supervisor.py
@@ -1,7 +1,9 @@
import os
+import ast
from openai import OpenAI
+from Qdrant.Store import store_fmu # Import storage function
-MODEL_ID = "llama3-70b-8192"
+MODEL_ID = "openai/gpt-oss-120b"
API_KEY = os.environ.get("GROQ_API_KEY")
class SupervisorAgent:
@@ -12,40 +14,50 @@ class SupervisorAgent:
else:
self.client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=API_KEY)
- def synthesize_plan(self, atmos_plan: dict, water_plan: dict, current_meta: dict, history: list) -> dict:
+ def synthesize_plan(self, atmos_plan: dict, water_plan: dict, fmu, history: list) -> dict:
"""
- Takes plans from sub-agents and creates the final JSON payload for the Simulator.
+ 1. Validates and merges plans.
+ 2. UPDATES the FMU with the final action.
+ 3. UPLOADS the FMU to Qdrant.
"""
- print(f"[{self.name}] š® Validating and merging plans...")
+ print(f"[{self.name}] š® Validating, Merging & Storing...")
- # 1. Merge the plans
- # We start with the sub-agent recommendations
+ # 1. Merge & Reason
combined_action = {**atmos_plan, **water_plan}
+ current_meta = fmu.metadata
- # 2. Reasoning (Optional: Check for conflicts)
prompt = (
f"You are the Farm Supervisor.\n"
f"Proposed Atmospheric Actions: {atmos_plan}\n"
f"Proposed Water Actions: {water_plan}\n"
f"Current Crop: {current_meta.get('crop')} ({current_meta.get('stage')})\n"
- f"Similar History: {history}\n\n"
- f"TASK: Review these actions. If they are safe, merge them into a single JSON object.\n"
- f"If there is a conflict (e.g., high Temp but low CO2), adjust them.\n"
- f"OUTPUT: A clean JSON object strictly matching the simulator's expected action keys.\n"
- f"Do not add markdown."
+ f"History Context: {history}\n\n"
+ f"TASK: Output a single valid JSON object for the simulator controls.\n"
+ f"Strict JSON only."
)
+ final_action = combined_action # Default
try:
- # For speed, we might just return the combined dict,
- # but here we ask the LLM to validate/format it.
- response = self.client.chat.completions.create(
- model=MODEL_ID,
- messages=[{"role": "user", "content": prompt}]
- )
- content = response.choices[0].message.content.replace("```json", "").replace("```", "").strip()
- import ast
- final_json = ast.literal_eval(content)
- return final_json
- except Exception:
- # Fallback: Just return the merged dicts
- return combined_action
-\ No newline at end of file
+ if self.client:
+ response = self.client.chat.completions.create(
+ model=MODEL_ID,
+ messages=[{"role": "user", "content": prompt}]
+ )
+ content = response.choices[0].message.content.replace("```json", "").replace("```", "").strip()
+ final_action = ast.literal_eval(content)
+ except Exception as e:
+ print(f"[{self.name}] ā ļø Reasoning failed, using defaults: {e}")
+
+ # 2. Update FMU Metadata
+ print(f"[{self.name}] š Recording Action: {final_action}")
+ fmu.metadata["action_taken"] = str(final_action) # Store as string or dict depending on your DB preference
+
+ # 3. Clean & Store FMU
+ # Critical: Remove large image blob before uploading to Qdrant
+ if "image_b64" in fmu.metadata:
+ del fmu.metadata["image_b64"]
+
+ store_fmu(fmu)
+ print(f"[{self.name}] š¾ State (Seq #{fmu.metadata.get('sequence_number')}) saved to DB.")
+
+ return final_action
+\ No newline at end of file
diff --git a/agent/sub_agents/atmospheric_agent.py b/agent/sub_agents/atmospheric_agent.py
@@ -2,7 +2,7 @@ import os
from openai import OpenAI
# Configuration
-MODEL_ID = "llama3-70b-8192"
+MODEL_ID = "openai/gpt-oss-120b"
API_KEY = os.environ.get("GROQ_API_KEY")
class AtmosphericAgent:
diff --git a/agent/sub_agents/base_agent.py b/agent/sub_agents/base_agent.py
@@ -1,9 +1,13 @@
import os
from openai import OpenAI
+from dotenv import load_dotenv # š Import this
+
+# š Load environment variables from .env file
+load_dotenv()
# --- GROQ CONFIGURATION ---
# Common Groq Models: "llama3-70b-8192", "mixtral-8x7b-32768"
-MODEL_ID = "llama3-70b-8192"
+MODEL_ID = "openai/gpt-oss-120b"
API_KEY = os.environ.get("GROQ_API_KEY")
class BaseReasoningAgent:
diff --git a/agent/sub_agents/fetching_agent.py b/agent/sub_agents/fetching_agent.py
@@ -1,18 +1,17 @@
import sys
import os
import requests
-import json
from pathlib import Path
# --- PATH FIX ---
current_file = Path(__file__).resolve()
-project_root = current_file.parent.parent.parent # Adjusted for sub_agents nesting
+project_root = current_file.parent.parent.parent
sys.path.append(str(project_root))
# ----------------
from qdrant_client import models
from Sentinel.agent import FMUBuilder
-from Qdrant.Store import store_fmu, COLLECTION_NAME
+from Qdrant.Store import COLLECTION_NAME
from Qdrant.Client import client
class FetchingAgent:
@@ -29,50 +28,43 @@ class FetchingAgent:
if response.status_code == 200:
data = response.json()
- # --- 1. EXTRACT RAW DATA ---
+ # 1. Extract Data
window_data = data.get("sensor_window", {})
image_b64 = data.get("image", "")
raw_meta = data.get("metadata", {})
- # --- 2. FILTER SENSORS (Strictly the 4 requested) ---
- wanted_keys = {
- "ph": "pH",
- "ec": "EC",
- "humidity": "humidity",
- "temp": "temp",
- "air_temp": "temp"
- }
-
+ # 2. Filter Sensors
+ 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
- if out_name not in sensor_snapshot:
- sensor_snapshot[out_name] = val
+ sensor_snapshot[out_name] = val
+
+ # 3. Calculate Sequence & Prepare Metadata
+ crop_id = raw_meta.get("crop_id", "UNKNOWN_CROP")
+ next_seq = self._get_next_sequence(crop_id)
+ print(f"[Fetcher] š¢ Sequence for {crop_id}: {next_seq}")
- # --- 3. FILTER METADATA (Crop & Stage) ---
filtered_metadata = {
"crop": raw_meta.get("crop", "unknown"),
- "stage": raw_meta.get("stage", "unknown")
+ "stage": raw_meta.get("stage", "unknown"),
+ "crop_id": crop_id,
+ "sequence_number": next_seq,
+ # Store raw image for JudgeAgent (since we don't save to disk)
+ "image_b64": image_b64
}
- # --- 4. CREATE FMU ---
+ # 4. Create FMU (BUT DO NOT STORE)
fmu = self.builder.create_fmu(image_b64, sensor_snapshot, filtered_metadata)
- print(f"\n[Fetcher] š§ FMU Created (ID: {fmu.id})")
- print(f"[Fetcher] Sensors: {sensor_snapshot}")
+ print(f"[Fetcher] š§ FMU Created (ID: {fmu.id}) - Handing off to Judge.")
- # --- 5. STORE IN QDRANT ---
- store_fmu(fmu)
-
- # --- 6. HISTORIAN SEARCH (Integrated) ---
+ # 5. Historian Search (Optional context for Researcher)
search_results = self.find_similar_instances(fmu)
- print(f"[Historian] š Found {len(search_results)} similar past events.")
-
- # RETURN EVERYTHING THE ORCHESTRATOR NEEDS
return fmu, sensor_snapshot, search_results
else:
print(f"[Fetcher] ā Error: Simulator returned {response.status_code}")
@@ -80,40 +72,28 @@ class FetchingAgent:
except Exception as e:
print(f"[Fetcher] ā Critical Error: {e}")
- import traceback
- traceback.print_exc()
return None, None, None
- def find_similar_instances(self, current_fmu):
- """
- Uses the current FMU's vector and metadata to filter and search Qdrant.
- """
- target_crop = current_fmu.metadata.get("crop")
- target_stage = current_fmu.metadata.get("stage")
-
- # Create Filter
- context_filter = models.Filter(
- must=[
- models.FieldCondition(key="crop", match=models.MatchValue(value=target_crop)),
- models.FieldCondition(key="stage", match=models.MatchValue(value=target_stage))
- ]
- )
+ def _get_next_sequence(self, crop_id):
+ """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))]
+ )
+ count_result = client.count(collection_name=COLLECTION_NAME, count_filter=count_filter)
+ return count_result.count + 1
+ except Exception:
+ return 1
+ def find_similar_instances(self, current_fmu):
try:
- # Use the vector we just generated
- response = client.search(
+ # Simple similarity search (excluding current crop to avoid bias if needed)
+ hits = client.search(
collection_name=COLLECTION_NAME,
query_vector=current_fmu.vector,
- query_filter=context_filter,
- limit=5,
+ limit=3,
with_payload=True
)
- return [{"id": hit.id, "score": hit.score, "payload": hit.payload} for hit in response]
-
- except Exception as e:
- print(f"ā ļø Search Warning: {e}. Returning empty history.")
- return []
-
-if __name__ == "__main__":
- agent = FetchingAgent()
- agent.fetch_and_process()
-\ No newline at end of file
+ return [{"payload": hit.payload} for hit in hits]
+ except Exception:
+ return []
+\ No newline at end of file
diff --git a/agent/sub_agents/judge_agent.py b/agent/sub_agents/judge_agent.py
@@ -0,0 +1,99 @@
+import os
+import json
+from qdrant_client import models
+from Sentinel.fmu import FMU
+from sub_agents.base_agent import BaseReasoningAgent
+from Qdrant.Client import client
+from Qdrant.Store import store_fmu
+
+COLLECTION_NAME = "Farm_Memory"
+
+class JudgeAgent(BaseReasoningAgent):
+ def __init__(self):
+ super().__init__(name="Judge Agent")
+ self.qdrant = client
+ # Using Llama 3.2 Vision (11B) for analysis
+ self.vision_model = "meta-llama/llama-4-scout-17b-16e-instruct"
+
+ def review_previous_cycle(self, current_fmu: FMU):
+ """
+ Only looks back at Sequence N-1 to judge its outcome based on N.
+ Does NOT store the current state (N).
+ """
+ print(f"[{self.name}] šØāāļø Reviewing previous cycle results...")
+
+ crop_id = current_fmu.metadata.get("crop_id")
+ current_seq = current_fmu.metadata.get("sequence_number", 1)
+ image_b64 = current_fmu.metadata.get("image_b64") # Raw image for vision analysis
+
+ if current_seq > 1:
+ prev_seq = current_seq - 1
+ print(f"[{self.name}] š Looking up history (Seq #{prev_seq})...")
+
+ prev_point = self._find_specific_sequence(crop_id, prev_seq)
+
+ if prev_point:
+ # Judge: Did the plant improve?
+ health_analysis = self._analyze_visual_health(
+ image_b64,
+ current_fmu.metadata.get("sensors")
+ )
+
+ # Update N-1 with the verdict
+ self._update_outcome(prev_point.id, health_analysis)
+ else:
+ print(f"[{self.name}] ā ļø History record (Seq #{prev_seq}) not found.")
+ else:
+ print(f"[{self.name}] š First cycle. No history to review.")
+
+ def _find_specific_sequence(self, crop_id, sequence_number):
+ try:
+ s_filter = models.Filter(
+ must=[
+ models.FieldCondition(key="crop_id", match=models.MatchValue(value=crop_id)),
+ models.FieldCondition(key="sequence_number", match=models.MatchValue(value=sequence_number))
+ ]
+ )
+ res, _ = self.qdrant.scroll(collection_name=COLLECTION_NAME, scroll_filter=s_filter, limit=1)
+ return res[0] if res else None
+ except Exception as e:
+ print(f"[{self.name}] ā ļø DB Error: {e}")
+ return None
+
+ def _analyze_visual_health(self, image_b64, sensors):
+ if not image_b64:
+ return {"outcome": "NO_IMAGE", "health_score": 0}
+
+ prompt = (
+ f"Sensors: {sensors}\n"
+ f"Task: Assess and find out the current condition of the plant.\n"
+ f"Output JSON: {{'health_score': 0-100, 'outcome': 'IMPROVED'|'DETERIORATED'|'STABLE', 'notes': '...' }}"
+ )
+
+ try:
+ image_url = f"data:image/png;base64,{image_b64}"
+ completion = self.client.chat.completions.create(
+ model=self.vision_model,
+ messages=[
+ {"role": "user", "content": [
+ {"type": "text", "text": prompt},
+ {"type": "image_url", "image_url": {"url": image_url}}
+ ]}
+ ],
+ response_format={"type": "json_object"},
+ temperature=0.1
+ )
+ return json.loads(completion.choices[0].message.content)
+ except Exception as e:
+ print(f"[{self.name}] ā ļø Vision Error: {e}")
+ return {"outcome": "ERROR", "health_score": 0}
+
+ def _update_outcome(self, point_id, analysis):
+ self.qdrant.set_payload(
+ collection_name=COLLECTION_NAME,
+ payload={
+ "outcome": "condition_assessed" + analysis.get("outcome", "UNKNOWN") + "| health_score:" + str(analysis.get("health_score", 0)) + " | notes:" + analysis.get("notes", "")
+ },
+ points=[point_id]
+ )
+ print(f"[{self.name}] ā
Outcome Updated for ID {point_id}: {analysis.get('outcome')}")
+\ No newline at end of file
diff --git a/agent/sub_agents/water_agent.py b/agent/sub_agents/water_agent.py
@@ -1,7 +1,7 @@
import os
from openai import OpenAI
-MODEL_ID = "llama3-70b-8192"
+MODEL_ID = "openai/gpt-oss-120b"
API_KEY = os.environ.get("GROQ_API_KEY")
class WaterAgent:
diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx
@@ -2,14 +2,62 @@ import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { fetchCropDetails } from '../api/farmApi';
import {
- ArrowLeft, Thermometer, Droplet, Sun, Wind, FlaskConical, Sparkles
+ ArrowLeft, Thermometer, Droplet, Sun, FlaskConical, Sparkles
} from 'lucide-react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from 'recharts';
+// --- HELPER 1: Parse Python-style Dict Strings ---
+const parsePythonString = (str) => {
+ if (!str) return null;
+ if (typeof str === 'object') return str;
+
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ try {
+ // Fix Python single quotes and Booleans
+ const fixedStr = str
+ .replace(/'/g, '"')
+ .replace(/\bNone\b/g, 'null')
+ .replace(/\bFalse\b/g, 'false')
+ .replace(/\bTrue\b/g, 'true');
+ return JSON.parse(fixedStr);
+ } catch (e2) {
+ return null;
+ }
+ }
+};
+
+// --- HELPER 2: Extract Sensor Data Safely ---
+const extractSensors = (payload) => {
+ if (!payload) return { temp: 0, ph: 0, lux: 0, humidity: 0 };
+
+ // 1. Check for standard "sensors" object
+ if (payload.sensors && payload.sensors.ph) {
+ return payload.sensors;
+ }
+
+ // 2. If missing, look inside "action_taken"
+ const actionData = parsePythonString(payload.action_taken);
+
+ if (actionData) {
+ // Handle nested structures like 'atmospheric_actions' or flat structures
+ return {
+ temp: actionData.atmospheric_actions?.air_temp || actionData.air_temp || 0,
+ ph: actionData.water_actions?.ph || actionData.ph || 0,
+ lux: actionData.atmospheric_actions?.light_intensity || 0,
+ humidity: actionData.atmospheric_actions?.humidity || 0,
+ };
+ }
+
+ // 3. Fallback
+ return { temp: 0, ph: 0, lux: 0, humidity: 0 };
+};
+
const CropDetails = () => {
- const { cropId } = useParams(); // Get ID from URL
+ const { cropId } = useParams();
const navigate = useNavigate();
const [history, setHistory] = useState([]);
@@ -18,41 +66,62 @@ const CropDetails = () => {
useEffect(() => {
const getData = async () => {
- // 1. Fetch all points for this crop
- const data = await fetchCropDetails(cropId);
-
- if (data && data.length > 0) {
- // 2. Sort by sequence number (Ascending for Chart)
- const sorted = [...data].sort((a, b) =>
- (a.payload.sequence_number || 0) - (b.payload.sequence_number || 0)
- );
-
- setHistory(sorted);
- setLatest(sorted[sorted.length - 1].payload); // The last one is the current state
+ try {
+ const data = await fetchCropDetails(cropId);
+
+ if (data && Array.isArray(data) && data.length > 0) {
+ // Sort by sequence number
+ const sorted = [...data].sort((a, b) =>
+ (a.payload?.sequence_number || 0) - (b.payload?.sequence_number || 0)
+ );
+
+ // Process history with safety checks
+ const processedHistory = sorted.map(item => {
+ const safePayload = item.payload || {};
+ const sensors = extractSensors(safePayload);
+ return {
+ ...item,
+ cleanSensors: sensors,
+ parsedAction: parsePythonString(safePayload.action_taken)
+ };
+ });
+
+ setHistory(processedHistory);
+ setLatest(processedHistory[processedHistory.length - 1]);
+ } else {
+ setHistory([]);
+ setLatest(null);
+ }
+ } catch (err) {
+ console.error("Error processing crop details:", err);
+ } finally {
+ setLoading(false);
}
- setLoading(false);
};
getData();
}, [cropId]);
- if (loading) return <div className="h-screen flex items-center justify-center">Loading Crop Data...</div>;
- if (!latest) return <div className="h-screen flex items-center justify-center">Crop not found</div>;
+ if (loading) return <div className="h-screen flex items-center justify-center text-gray-500">Loading Crop Data...</div>;
+ if (!latest) return <div className="h-screen flex items-center justify-center text-gray-500">Crop data not found.</div>;
- // --- DERIVED DATA FOR UI ---
-
- // 1. Chart Data: Map history to time/temp/ph
+ const latestPayload = latest.payload || {};
+ // Safety: Ensure latestSensors is never undefined
+ const latestSensors = latest.cleanSensors || { temp: 0, ph: 0, lux: 0, humidity: 0 };
+
+ // --- CHART DATA (With Safety Checks) ---
const chartData = history.map(h => ({
- time: new Date(h.payload.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
- temp: h.payload.sensors?.temp || 0,
- ph: h.payload.sensors?.ph || 0
+ time: h.payload?.timestamp ? new Date(h.payload.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '--:--',
+ // FIX: Use optional chaining (?.) and fallback (|| 0)
+ temp: h.cleanSensors?.temp || 0,
+ ph: h.cleanSensors?.ph || 0
}));
- // 2. Vitals: Take from 'latest' state
+ // --- VITALS DATA ---
const vitals = [
- { label: "Air Temp", value: `${latest.sensors?.temp || 0}°C`, status: "Optimal", icon: <Thermometer size={18} className="text-orange-500" />, color: "bg-orange-100" },
- { label: "Water pH", value: latest.sensors?.ph || 7.0, status: latest.action_taken !== "PENDING_ACTION" ? "Adjusting" : "Stable", icon: <FlaskConical size={18} className="text-purple-500" />, color: "bg-purple-100" },
- { label: "Humidity", value: "58%", status: "Optimal", icon: <Droplet size={18} className="text-blue-500" />, color: "bg-blue-100" }, // Mock if missing
- { label: "Light", value: `${latest.sensors?.lux || 0}k`, status: "Optimal", icon: <Sun size={18} className="text-yellow-500" />, color: "bg-yellow-100" },
+ { label: "Air Temp", value: `${latestSensors.temp || 0}°C`, status: "Optimal", icon: <Thermometer size={18} className="text-orange-500" />, color: "bg-orange-100" },
+ { label: "Water pH", value: latestSensors.ph || "N/A", status: "Stable", icon: <FlaskConical size={18} className="text-purple-500" />, color: "bg-purple-100" },
+ { label: "Humidity", value: `${latestSensors.humidity || 0}%`, status: "Optimal", icon: <Droplet size={18} className="text-blue-500" />, color: "bg-blue-100" },
+ { label: "Light", value: `${latestSensors.lux || 0}`, status: "Optimal", icon: <Sun size={18} className="text-yellow-500" />, color: "bg-yellow-100" },
];
return (
@@ -65,7 +134,7 @@ const CropDetails = () => {
</button>
<div>
<div className="text-xs text-gray-500">Back to Dashboard</div>
- <h1 className="font-bold text-xl text-gray-900">{latest.crop} <span className="text-gray-400">#{latest.sequence_number}</span></h1>
+ <h1 className="font-bold text-xl text-gray-900">{latestPayload.crop || "Unknown Crop"} <span className="text-gray-400">#{latestPayload.sequence_number || 0}</span></h1>
</div>
<div className="ml-auto flex items-center gap-2 bg-emerald-50 text-emerald-700 px-3 py-1 rounded-full text-xs font-semibold">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span> System Online
@@ -75,82 +144,91 @@ const CropDetails = () => {
{/* MAIN CONTENT */}
<main className="flex-1 max-w-7xl mx-auto w-full p-6 space-y-6">
- {/* TOP ROW: Image & Vitals */}
+ {/* TOP ROW: Vitals */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
-
- {/* Image */}
- <div className="bg-white rounded-2xl p-3 shadow-sm border border-gray-100">
- <div className="relative h-48 rounded-xl overflow-hidden bg-gray-100">
- <img src="https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=2000" className="w-full h-full object-cover" />
- <div className="absolute top-3 right-3 bg-red-500 text-white text-[10px] font-bold px-2 py-0.5 rounded">LIVE</div>
- </div>
- <div className="mt-3 text-center">
- <div className="text-xs uppercase text-gray-400 font-bold">Growth Stage</div>
- <div className="text-xl font-bold text-emerald-600">{latest.stage}</div>
- </div>
+ <div className="bg-white rounded-2xl p-3 shadow-sm border border-gray-100 flex flex-col items-center justify-center text-center">
+ <div className="relative w-full h-32 rounded-lg overflow-hidden bg-gray-100 mb-2">
+ <img src="https://images.unsplash.com/photo-1622206151226-18ca2c9ab4a1?q=80&w=2000" className="w-full h-full object-cover" alt="crop" />
+ </div>
+ <div className="text-xs uppercase text-gray-400 font-bold">Current Stage</div>
+ <div className="text-lg font-bold text-emerald-600">{latestPayload.stage || "Unknown"}</div>
</div>
- {/* Vitals */}
<div className="lg:col-span-2 bg-white rounded-2xl p-5 shadow-sm border border-gray-100 grid grid-cols-2 md:grid-cols-4 gap-4">
{vitals.map((v, i) => (
<div key={i} className="flex flex-col items-center justify-center p-4 rounded-xl hover:bg-gray-50 transition border border-transparent hover:border-gray-100">
<div className={`w-12 h-12 rounded-full ${v.color} flex items-center justify-center mb-3`}>{v.icon}</div>
<div className="text-sm text-gray-500">{v.label}</div>
<div className="text-2xl font-bold text-gray-900">{v.value}</div>
- <span className="text-xs font-bold text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded mt-1">{v.status}</span>
</div>
))}
</div>
</div>
- {/* MIDDLE ROW: Chart & Insights */}
+ {/* MIDDLE ROW: Chart & Analysis */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* AI Insights (Derived from latest outcome) */}
+ {/* LATEST AI ANALYSIS */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-emerald-100 relative overflow-hidden">
- <div className="absolute top-0 right-0 w-32 h-32 bg-emerald-50 rounded-full blur-2xl -translate-y-1/2 translate-x-1/2"></div>
<div className="flex gap-4 relative z-10">
<div className="flex-none bg-emerald-500 text-white w-10 h-10 rounded-lg flex items-center justify-center"><Sparkles size={20} /></div>
- <div>
+ <div className="overflow-hidden w-full">
<h3 className="font-bold text-gray-900 mb-2">Latest AI Analysis</h3>
- <p className="text-sm text-gray-600 leading-relaxed">
- <strong>Observation:</strong> {latest.outcome === 'PENDING_OBSERVATION' ? 'System Monitoring...' : latest.outcome} <br/>
- <strong>Action:</strong> {latest.action_taken === 'PENDING_ACTION' ? 'No intervention needed.' : latest.action_taken}
- </p>
+
+ <div className="text-sm text-gray-600 leading-relaxed">
+ <p className="mb-2"><strong>Observation:</strong> {latestPayload.outcome || "Monitoring..."}</p>
+
+ <p className="font-bold text-xs text-gray-400 uppercase tracking-wide mb-1">Active Parameters:</p>
+ <div className="flex flex-wrap gap-2">
+ {latest.parsedAction ? (
+ <>
+ <span className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded border border-blue-100">pH: {latestSensors.ph}</span>
+ <span className="px-2 py-1 bg-orange-50 text-orange-700 text-xs rounded border border-orange-100">Temp: {latestSensors.temp}°C</span>
+ </>
+ ) : (
+ <span className="text-gray-400 italic">No automated actions active.</span>
+ )}
+ </div>
+ </div>
</div>
</div>
</div>
- {/* Chart */}
+ {/* CHART */}
<div className="lg:col-span-2 bg-white rounded-2xl p-6 shadow-sm border border-gray-100 h-80">
<h3 className="font-bold text-gray-900 mb-4">Environmental Trend</h3>
<ResponsiveContainer width="100%" height="90%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#eee" />
<XAxis dataKey="time" tick={{fontSize: 10, fill: '#aaa'}} axisLine={false} tickLine={false} />
- <YAxis yAxisId="left" domain={['auto', 'auto']} tick={{fontSize: 10}} axisLine={false} tickLine={false} />
- <YAxis yAxisId="right" orientation="right" domain={['auto', 'auto']} tick={{fontSize: 10}} axisLine={false} tickLine={false} />
+ <YAxis yAxisId="left" domain={['auto', 'auto']} tick={{fontSize: 10}} axisLine={false} tickLine={false} label={{ value: 'Temp (°C)', angle: -90, position: 'insideLeft', fontSize: 10 }} />
+ <YAxis yAxisId="right" orientation="right" domain={[4, 8]} tick={{fontSize: 10}} axisLine={false} tickLine={false} label={{ value: 'pH', angle: 90, position: 'insideRight', fontSize: 10 }} />
<Tooltip contentStyle={{borderRadius: '8px', border:'none', boxShadow:'0 4px 12px rgba(0,0,0,0.1)'}} />
- <Line yAxisId="left" type="monotone" dataKey="temp" stroke="#10B981" strokeWidth={3} dot={false} />
- <Line yAxisId="right" type="monotone" dataKey="ph" stroke="#3B82F6" strokeWidth={2} strokeDasharray="5 5" dot={false} />
+ <Line yAxisId="left" type="monotone" dataKey="temp" stroke="#10B981" strokeWidth={3} dot={false} name="Temp" />
+ <Line yAxisId="right" type="monotone" dataKey="ph" stroke="#3B82F6" strokeWidth={2} strokeDasharray="5 5" dot={false} name="pH" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
- {/* BOTTOM: Logs Table */}
+ {/* BOTTOM: Event Log */}
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
<h3 className="font-bold text-gray-900 mb-4">Historical Event Log</h3>
<div className="space-y-4">
- {/* Reverse history to show newest first, take top 5 */}
- {[...history].reverse().slice(0, 5).map((h, i) => (
+ {[...history].reverse().slice(0, 10).map((h, i) => (
<div key={i} className="flex gap-4 items-start pb-4 border-b border-gray-50 last:border-0">
<div className="w-16 text-xs text-gray-400 font-mono pt-1">
- {new Date(h.payload.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
+ {h.payload?.timestamp ? new Date(h.payload.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}) : '-'}
</div>
<div>
- <div className="text-sm font-bold text-gray-800">{h.payload.action_taken}</div>
- <div className="text-xs text-gray-500">{h.payload.outcome}</div>
+ <div className="text-sm font-bold text-gray-800">
+ {h.parsedAction
+ ? `Adjusted pH to ${h.cleanSensors?.ph || 0} ⢠Temp to ${h.cleanSensors?.temp || 0}°C`
+ : h.payload?.action_taken || "Routine Check"}
+ </div>
+ <div className="text-xs text-gray-500 mt-1">
+ {h.payload?.outcome || "Monitoring"}
+ </div>
</div>
</div>
))}