demeter

Autonomous Hydroponic Intelligence
commit f294d8a2ce498bccf5e8bb516883ab553c0cf2b4
parent 6bd43d625c709b1856cc8f8008fb85785f5e608d
Author: maydayv7 <maydayv7@gmail.com>
Date:   Sat, 28 Mar 2026 13:51:59 +0530

Fix crop lifecycle and FarmIntelligence

Diffstat:
Mbackend/node_server/schema/cropSchema.js | 1+
Mbackend/server/functions.py | 169+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mfrontend/src/pages/CropDetails.jsx | 7++-----
Mfrontend/src/pages/FarmIntelligence.jsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mfrontend/src/pages/RunCycle.jsx | 4++--
Mfrontend/src/utils/dataUtils.js | 49++++++++++++++++++++++++++++++++++++-------------
Msimulator/main.py | 12+++++++++---
7 files changed, 236 insertions(+), 135 deletions(-)

diff --git a/backend/node_server/schema/cropSchema.js b/backend/node_server/schema/cropSchema.js @@ -27,6 +27,7 @@ const cropStateSchema = new mongoose.Schema({ sequence_number: { type: Number, default: 0 }, cycle_duration_hours: { type: Number, default: 1 }, total_crop_lifetime_days: { type: Number, default: 0 }, + simulated_age_hours: { type: Number, default: 0 }, planted_at: { type: Date, default: Date.now }, last_updated: { type: Date, default: Date.now }, diff --git a/backend/server/functions.py b/backend/server/functions.py @@ -139,7 +139,8 @@ async def process_search(file: UploadFile, sensors_str: str, builder): # Metadata construction metadata = { "crop": target_crop, - "stage": raw_sensor_data.get("stage", "Unknown"), + "stage": raw_sensor_data.get("stage") + or raw_sensor_data.get("metadata", {}).get("stage", "seedling"), "crop_id": target_crop_id, "sequence_number": seq_num, "sensors": clean_sensors, @@ -298,7 +299,8 @@ async def process_cycle_stream(file: UploadFile, sensors_str: str, builder): metadata = { "crop": target_crop, - "stage": raw_sensor_data.get("stage", "Unknown"), + "stage": raw_sensor_data.get("stage") + or raw_sensor_data.get("metadata", {}).get("stage", "seedling"), "crop_id": target_crop_id, "sequence_number": seq_num, "sensors": clean_sensors, @@ -619,9 +621,8 @@ async def process_audio_search(file: UploadFile): async def process_ask_query(query: str, context: str, language: str): """ - Directly answers user questions using farm data context. - The context string already contains similar-crop data pre-built by the - frontend; this function just passes it through to the LLM. + Answers natural language questions about the farm using pre-built context + from the frontend. """ try: lang_instr = ( @@ -629,19 +630,23 @@ async def process_ask_query(query: str, context: str, language: str): if language == "hi" else "Respond entirely in English." ) - system_prompt = f""" - You are Demeter Intelligence, an expert AI agronomist for a hydroponic farm. - Use the FARM DATA below to answer the user's question accurately and concisely. - {context} + system_prompt = f"""You are Demeter Intelligence — an expert AI agronomist embedded in a hydroponic farm management system. - Instructions: - - Wrap your internal reasoning in <thinking>...</thinking> tags. - - After </thinking>, give a clear direct answer. - - When referencing specific crops, mention their crop_id in parentheses. - - If the question requires comparing multiple crops, address each one. - - CRITICAL: {lang_instr} - """ +ROLE: +- Answer questions about crop health, sensor readings, agent decisions, and farm trends +- Compare crops when asked, citing their crop_id +- Give actionable recommendations grounded in the data +- Be concise: lead with the direct answer, then explain + +REASONING: +Wrap your internal reasoning in <thinking>...</thinking> before your answer. +Keep thinking brief — focus on which crops are relevant and what the data says. + +FARM DATA: +{context} + +LANGUAGE: {lang_instr}""" response = supervisor.model.invoke( [SystemMessage(content=system_prompt), HumanMessage(content=query)] @@ -651,129 +656,121 @@ async def process_ask_query(query: str, context: str, language: str): thinking = "" answer = raw_text - think_match = re.search(r"<thinking>(.*?)</thinking>", raw_text, re.DOTALL) + # Support both <thinking> and <think> tags + think_match = re.search( + r"<think(?:ing)?>(.*?)</think(?:ing)?>", raw_text, re.DOTALL + ) if think_match: thinking = think_match.group(1).strip() answer = re.sub( - r"<thinking>.*?</thinking>", "", raw_text, flags=re.DOTALL + r"<think(?:ing)?>(.*?)</think(?:ing)?>", "", raw_text, flags=re.DOTALL ).strip() return {"status": "success", "thinking": thinking, "answer": answer} - except Exception as e: - import traceback + except Exception as e: traceback.print_exc() return {"status": "error", "message": str(e)} async def process_similar_crops(crop_id: str, crop_name: str, payload_json: str): """ - Find cosine-similar crops using the ACTUAL stored vector for crop_id + Find cosine-similar crops using the latest stored vector for crop_id. + Falls back to a zero-padded sensor vector if no stored vector is found. """ import json as _json import numpy as np try: - # Find the latest point for this crop - filter_latest = models.Filter( - must=[ - models.FieldCondition( - key="crop_id", - match=models.MatchValue(value=crop_id), - ) - ] - ) - + # Step 1: Scroll all points for this crop, requesting vectors points, _ = client.scroll( collection_name=COLLECTION_NAME, - scroll_filter=filter_latest, + scroll_filter=models.Filter( + must=[ + models.FieldCondition( + key="crop_id", + match=models.MatchValue(value=crop_id), + ) + ] + ), limit=100, with_payload=True, - with_vectors=True, # <-- we need the actual stored vector + with_vectors=True, ) - query = None + query_vector = None if points: # Pick the point with the highest sequence_number best = max(points, key=lambda p: p.payload.get("sequence_number", 0)) v = best.vector if v is not None: - # v may be a list or a dict (named vectors); handle both + # Handle named-vector collections (dict) vs plain list if isinstance(v, dict): - # Named vector collections — grab the default/first key v = next(iter(v.values())) - query = list(v) + if len(v) == 516: + query_vector = list(v) + else: + print( + f"[SimilarCrops] Unexpected vector length {len(v)} for {crop_id}" + ) - # Fallback: build sensor vector from payload JSON - if query is None: + # Step 2: Sensor-only fallback — build a 516-dim vector + # Vision dims (0–511) stay zero; sensor dims (512–515) are filled in + if query_vector is None: print( - f"[SimilarCrops] No stored vector for {crop_id}, falling back to sensor encoding" + f"[SimilarCrops] No usable stored vector for {crop_id}, using sensor fallback" ) - try: - from Sentinel.Encoders.TimeSeries import SensorEncoder - - payload = _json.loads(payload_json) if payload_json else {} - raw_sensors = payload.get("sensor_data") or payload.get("sensors") or {} - # Keep only the four numeric sensors - clean = {} - for k, v in raw_sensors.items(): - if k in {"pH", "EC", "temp", "humidity"}: - try: - clean[k] = float(v) - except (TypeError, ValueError): - pass + payload = _json.loads(payload_json) if payload_json else {} + raw_sensors = payload.get("sensor_data") or payload.get("sensors") or {} + + SENSOR_ORDER = ["pH", "EC", "temp", "humidity"] + SENSOR_DEFAULTS = {"pH": 6.0, "EC": 1.5, "temp": 23.0, "humidity": 65.0} + + full_vec = np.zeros(516, dtype=np.float32) + for i, key in enumerate(SENSOR_ORDER): + raw = raw_sensors.get(key) + val = raw[-1] if isinstance(raw, list) and raw else raw + try: + full_vec[512 + i] = ( + float(val) if val is not None else SENSOR_DEFAULTS[key] + ) + except (TypeError, ValueError): + full_vec[512 + i] = SENSOR_DEFAULTS[key] - if clean: - encoder = SensorEncoder() - sensor_vec = encoder.encode(clean) # shape: (4,) or (12,) - # Pad to COLLECTION vector size (516) with zeros - full_vec = np.zeros(516, dtype=np.float32) - full_vec[-len(sensor_vec) :] = sensor_vec - query = full_vec.tolist() - except Exception as enc_err: - print(f"[SimilarCrops] Sensor encoding fallback failed: {enc_err}") - - if query is None: - return { - "status": "error", - "message": f"Could not build a query vector for crop_id={crop_id}", - "results": [], - } + query_vector = full_vec.tolist() - # Cector search, excluding this crop - exclude_filter = models.Filter( - must_not=[ - models.FieldCondition( - key="crop_id", - match=models.MatchValue(value=crop_id), - ) - ] - ) - - search_results = client.query_points( + # Step 3: Vector search, excluding the source crop + results = client.query_points( collection_name=COLLECTION_NAME, - query=query, - query_filter=exclude_filter, + query=query_vector, + query_filter=models.Filter( + must_not=[ + models.FieldCondition( + key="crop_id", + match=models.MatchValue(value=crop_id), + ) + ] + ), limit=6, with_payload=True, with_vectors=False, ) + hits = results.points if hasattr(results, "points") else results + return { "status": "success", "results": [ { "id": str(r.id), - "score": float(r.score), + "score": round(float(r.score), 4), "payload": r.payload, } - for r in search_results + for r in hits ], } except Exception as e: - import traceback - traceback.print_exc() return {"status": "error", "message": str(e), "results": []} diff --git a/frontend/src/pages/CropDetails.jsx b/frontend/src/pages/CropDetails.jsx @@ -38,6 +38,7 @@ import { calculateMaturity, getDaysRemaining, getCurrentStage, + getEffectiveElapsedHours, CROP_LIFECYCLES, CROP_CYCLE_HOURS, } from "../utils/dataUtils"; @@ -447,11 +448,7 @@ function InfoTab({ cropDoc, cropId, navigate, t }) { day: "numeric", }) : "-"; - const daysSince = cropDoc.planted_at - ? Math.floor( - (Date.now() - new Date(cropDoc.planted_at).getTime()) / 86400000, - ) - : null; + const daysSince = Math.floor(getEffectiveElapsedHours(cropDoc) / 24) || null; const sensorIds = cropDoc.sensor_ids || {}; const SENSORS_DISPLAY = [ diff --git a/frontend/src/pages/FarmIntelligence.jsx b/frontend/src/pages/FarmIntelligence.jsx @@ -23,7 +23,17 @@ import { ExternalLink, } from "lucide-react"; import { agentService } from "../api/agentApi"; -import { extractSensors, deriveCropStatus } from "../utils/dataUtils"; +import { fetchCropDetails } from "../api/farmApi"; +import { + parsePythonString, + extractSensors, + deriveCropStatus, + calculateMaturity, + getEffectiveElapsedHours, + getCurrentStage, + getDaysRemaining, + isReadyToHarvest, +} from "../utils/dataUtils"; import { AgentActionWidget, AgentOutcomeWidget, @@ -955,7 +965,7 @@ export default function FarmIntelligence() { // Build rich context for LLM const buildLLMContext = useCallback( - (cropCtx, similarCrops = []) => { + (cropCtx, similarCrops = [], cropHistory = []) => { const allCrops = dashboard || []; if (cropCtx) { @@ -975,24 +985,79 @@ export default function FarmIntelligence() { .join("\n") : ""; + const historySection = + cropHistory.length > 1 + ? `\nACTION HISTORY (last ${cropHistory.length} cycles, newest first):\n` + + cropHistory + .map((h) => { + const p = h.payload || {}; + const rawAction = p.action_taken; + const actionStr = + !rawAction || rawAction === "PENDING_ACTION" + ? "None" + : typeof rawAction === "object" + ? JSON.stringify(rawAction) + : (() => { + const parsed = parsePythonString(rawAction); + return parsed && typeof parsed === "object" + ? JSON.stringify(parsed) + : rawAction; + })(); + const explanation = + p.explanation_log && + p.explanation_log !== "PENDING_ANALYSIS" + ? p.explanation_log.trim() + : null; + const stratIntent = p.strategic_intent || null; + return [ + ` Seq #${p.sequence_number ?? "?"} | Stage: ${p.stage || "?"} | Reward: ${p.reward_score ?? "N/A"}`, + ` Action: ${actionStr}`, + ` Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"}`, + stratIntent ? ` Intent: ${stratIntent}` : null, + explanation ? ` Explain: ${explanation}` : null, + ] + .filter(Boolean) + .join("\n"); + }) + .join("\n\n") + : ""; + + const elapsedH = getEffectiveElapsedHours(p); + const maturity = calculateMaturity(p); + const currentStage = getCurrentStage(p) || p.stage || "Unknown"; + const daysRemaining = getDaysRemaining(p); + const readyToHarvest = isReadyToHarvest(p); + return `CROP CONTEXT: - Crop: ${p.crop || cropCtx.crop} - Batch ID: ${p.crop_id || cropCtx.cropId} -- Growth Stage: ${p.stage || "Unknown"} +- Growth Stage: ${currentStage} +- Maturity: ${maturity}%${readyToHarvest ? " ⚠️ READY TO HARVEST" : ""} +- Simulated Age: ${Math.round(elapsedH)} hours (${Math.floor(elapsedH / 24)} days) +- Days Until Harvest: ${daysRemaining !== null ? daysRemaining : "N/A"} - Sequence Number: ${p.sequence_number || "-"} -- Last Updated: ${p.timestamp ? new Date(p.timestamp).toLocaleString() : "Unknown"} +- Last Updated: ${p.last_updated ? new Date(p.last_updated).toLocaleString() : "Unknown"} LATEST SENSOR READINGS: - pH: ${sensors.ph} - EC: ${sensors.ec} dS/m - Temperature: ${sensors.temp}°C - Humidity: ${sensors.humidity}% LATEST AGENT DECISION: -- Action Taken: ${p.action_taken && p.action_taken !== "PENDING_ACTION" ? p.action_taken : "None recorded"} +- Action Taken: ${(() => { + const a = p.action_taken; + if (!a || a === "PENDING_ACTION") return "None recorded"; + if (typeof a === "object") return JSON.stringify(a, null, 2); + const parsed = parsePythonString(a); + return parsed && typeof parsed === "object" + ? JSON.stringify(parsed, null, 2) + : a; + })()} - Outcome: ${p.outcome && p.outcome !== "PENDING_OBSERVATION" ? p.outcome : "Pending"} - Reward Score: ${p.reward_score ?? "N/A"} - Strategic Intent: ${p.strategic_intent || "N/A"} EXPLANATION LOG: -${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."}${similarSection} +${p.explanation_log && p.explanation_log !== "PENDING_ANALYSIS" ? p.explanation_log : "Not yet generated."} +${similarSection}${historySection} FLEET OVERVIEW (for comparison): - Total crops: ${fleetStats.total} - Healthy: ${fleetStats.healthy}, Needs Attention: ${fleetStats.attention}, Critical: ${fleetStats.critical}`.trim(); @@ -1003,7 +1068,10 @@ FLEET OVERVIEW (for comparison): .map((d) => { const p = d.payload || {}; const s = extractSensors(p); - return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${p.stage}, pH=${s.ph}, EC=${s.ec}, T=${s.temp}°, H=${s.humidity}%, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}, Action=${p.action_taken || "Pending"}`; + const maturity = calculateMaturity(p); + const stage = getCurrentStage(p) || p.stage || "Unknown"; + const ready = isReadyToHarvest(p); + return ` - ${p.crop || "?"} (${p.crop_id || d.id}): Stage=${stage}, Maturity=${maturity}%${ready ? " [HARVEST READY]" : ""}, pH=${s.ph}, EC=${s.ec}, T=${s.temp}°, H=${s.humidity}%, Status=${deriveCropStatus(p)}, Outcome=${p.outcome || "Pending"}`; }) .join("\n"); return `FLEET OVERVIEW: @@ -1043,15 +1111,12 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); selectedCrop.crop, selectedCrop.payload, ); - if (searchData.results) { - similarCrops = searchData.results - .filter((r) => r.payload?.crop_id !== selectedCrop.cropId) - .slice(0, 5) - .map((r) => ({ - id: r.id, - score: r.score || 0, - payload: r.payload, - })); + if (searchData.status === "success" && searchData.results?.length) { + similarCrops = searchData.results.slice(0, 5).map((r) => ({ + id: r.id, + score: r.score || 0, + payload: r.payload, + })); setRelatedCrops(similarCrops.slice(0, 3)); } } catch (e) { @@ -1059,7 +1124,22 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); } } - const context = buildLLMContext(selectedCrop, similarCrops); + let cropHistory = []; + if (selectedCrop) { + try { + const hist = await fetchCropDetails(selectedCrop.cropId); + // hist is sorted desc by sequence_number, take last 10 for context + cropHistory = (hist || []).slice(0, 10); + } catch (e) { + console.warn("History fetch failed:", e); + } + } + + const context = buildLLMContext( + selectedCrop, + similarCrops, + cropHistory, + ); const data = await agentService.askDemeter(query, context, lang); setLlmThinking(data.thinking || ""); @@ -1129,16 +1209,13 @@ SYSTEM: Hydroponic multi-crop farm management system (Demeter).`.trim(); selectedCrop.crop, selectedCrop.payload, ); - if (simData.results) { + if (simData.status === "success" && simData.results?.length) { setRelatedCrops( - simData.results - .filter((r) => r.payload?.crop_id !== selectedCrop.cropId) - .slice(0, 3) - .map((r) => ({ - id: r.id, - score: r.score || 0, - payload: r.payload, - })), + simData.results.slice(0, 3).map((r) => ({ + id: r.id, + score: r.score || 0, + payload: r.payload, + })), ); } } catch (e) { diff --git a/frontend/src/pages/RunCycle.jsx b/frontend/src/pages/RunCycle.jsx @@ -469,7 +469,7 @@ export default function RunCycle() { alignItems: "center", justifyContent: "center", gap: 8, - padding: "8px 16px", + padding: "8px 0px", borderBottom: "1px solid rgba(74,222,128,0.15)", background: "rgba(74,222,128,0.06)", }} @@ -659,7 +659,7 @@ export default function RunCycle() { <div key={s.name} style={{ width: `${w}%`, textAlign: "center" }} - title={`${s.name}: ${Math.round((s.endH ?? lifecycle.totalHours - s.startH) / 24)}d`} + title={`${s.name}: ${Math.round(((s.endH ?? lifecycle.totalHours) - s.startH) / 24)}d`} > <div style={{ diff --git a/frontend/src/utils/dataUtils.js b/frontend/src/utils/dataUtils.js @@ -133,6 +133,36 @@ export const extractSensors = (payload) => { }; }; +// Effective elapsed hours +export const getEffectiveElapsedHours = (payload) => { + if (!payload) return 0; + + // PRIMARY: sequence_number × cycle_duration_hours + const seqNum = payload.sequence_number || 0; + if (seqNum > 0) { + const crop = (payload.crop || "").toLowerCase(); + const cycleDuration = + payload.cycle_duration_hours || CROP_CYCLE_HOURS[crop] || 1; + return seqNum * cycleDuration; + } + + // SECONDARY: simulated_age_hours (for crops with 0 sequences) + if ( + typeof payload.simulated_age_hours === "number" && + payload.simulated_age_hours > 0 + ) { + return payload.simulated_age_hours; + } + + // FALLBACK: real wall-clock age + if (payload.planted_at) { + return ( + (Date.now() - new Date(payload.planted_at).getTime()) / (1000 * 60 * 60) + ); + } + return 0; +}; + // Maturity export const calculateMaturity = (payload) => { // Legacy call: calculateMaturity(seqNumber) @@ -142,15 +172,12 @@ export const calculateMaturity = (payload) => { const crop = (payload.crop || "").toLowerCase(); const lifecycle = CROP_LIFECYCLES[crop]; - const plantedAt = payload.planted_at; - if (lifecycle && plantedAt) { - const elapsedH = - (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60); + if (lifecycle) { + const elapsedH = getEffectiveElapsedHours(payload); const pct = Math.min((elapsedH / lifecycle.totalHours) * 100, 100); return Math.round(pct); } - // Fallback: sequence-based estimate return Math.min((payload.sequence_number || 0) * 10, 100); }; @@ -160,11 +187,9 @@ export const getDaysRemaining = (payload) => { if (!payload) return null; const crop = (payload.crop || "").toLowerCase(); const lifecycle = CROP_LIFECYCLES[crop]; - const plantedAt = payload.planted_at; - if (!lifecycle || !plantedAt) return null; + if (!lifecycle) return null; - const elapsedH = - (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60); + const elapsedH = getEffectiveElapsedHours(payload); const remainH = lifecycle.totalHours - elapsedH; if (remainH <= 0) return 0; return Math.ceil(remainH / 24); @@ -175,11 +200,9 @@ export const getCurrentStage = (payload) => { if (!payload) return null; const crop = (payload.crop || "").toLowerCase(); const lifecycle = CROP_LIFECYCLES[crop]; - const plantedAt = payload.planted_at; - if (!lifecycle || !plantedAt) return payload.stage || null; + if (!lifecycle) return payload.stage || null; - const elapsedH = - (Date.now() - new Date(plantedAt).getTime()) / (1000 * 60 * 60); + const elapsedH = getEffectiveElapsedHours(payload); const stages = lifecycle.stages; for (let i = stages.length - 1; i >= 0; i--) { if (elapsedH >= stages[i].startH) return stages[i].name; diff --git a/simulator/main.py b/simulator/main.py @@ -20,10 +20,13 @@ load_dotenv(env_path) MONGO_URI = os.environ.get("MONGODB_URI") mongo_client = MongoClient(MONGO_URI) -db = mongo_client["test"] +db = mongo_client.get_default_database() crops_collection = db["cropstates"] sim_state_collection = db["simulator_state"] +print(f"[DEBUG] MongoDB connected to DB: '{db.name}'") +print(f"[DEBUG] MONGO_URI = {MONGO_URI[:40] if MONGO_URI else 'NOT SET'}...") + MODEL_PATH = "models/PPO/lettuce_brain_v1.zip" HISTORY_LEN = 20 @@ -234,7 +237,10 @@ async def get_all_states(): for crop in db_crops: cid = crop.get("crop_id") - if not cid or cid not in simulators: + if not cid: + continue + if cid not in simulators: + print(f"⚠️ Crop {cid} not in simulators after sync — skipping") continue crop_type = crop.get("crop", "lettuce").lower() @@ -259,7 +265,7 @@ async def get_all_states(): f" current_tick: {current_tick} | crop_id: {cid} | age_hours: {age_hours} | stage: {new_stage} | cycle_duration: {cycle_duration}" ) - if current_tick % cycle_duration != 0: + if current_tick % cycle_duration != 0 and current_tick != 1: continue sim = simulators[cid]