demeter

Autonomous Hydroponic Intelligence
commit b95745bce5d0630447be1bfc6de73ca00a884ae8
parent f734d5ffd2193be4befaa6781481afe53d03ece4
Author: AbhinavRai01 <abhinavrai004@gmail.com>
Date:   Mon,  2 Mar 2026 23:04:24 +0000

judge done

Diffstat:
Magent/main_agent.py | 58+++++++++++++++++++++-------------------------------------
Magent/sub_agents/Supervisor.py | 64++++++++++++++++++++++++++++++++++++++--------------------------
Magent/sub_agents/atmospheric_agent.py | 2+-
Magent/sub_agents/base_agent.py | 6+++++-
Magent/sub_agents/fetching_agent.py | 94+++++++++++++++++++++++++++++++------------------------------------------------
Aagent/sub_agents/judge_agent.py | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Magent/sub_agents/water_agent.py | 2+-
Mfrontend/src/pages/CropDetails.jsx | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
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> ))}