demeter
Autonomous Hydroponic Intelligencegit clone https://github.com/maydayv7/demeter
frontend/src/utils/dataUtils.js (24358B)
1// Number formatting
2export const formatNumber = (val, decimals = 2) => {
3 if (val === undefined || val === null || isNaN(parseFloat(val))) return "0";
4 const factor = Math.pow(10, decimals);
5 return Math.round(parseFloat(val) * factor) / factor;
6};
7
8// Python dict / JSON string parser
9export const parsePythonString = (str) => {
10 if (!str) return null;
11 if (typeof str === "object") return str;
12 try {
13 return JSON.parse(str);
14 } catch {
15 try {
16 const fixed = str
17 .replace(/'/g, '"')
18 .replace(/\bNone\b/g, "null")
19 .replace(/\bFalse\b/g, "false")
20 .replace(/\bTrue\b/g, "true");
21 return JSON.parse(fixed);
22 } catch {
23 return null;
24 }
25 }
26};
27
28// LIFECYCLE
29// - totalHours -> the time at which the crop enters its final harvestable stage
30// - maturity % = elapsed hours / totalHours (capped at 100)
31// - ready to harvest -> maturity >= 100
32
33export const CROP_LIFECYCLES = {
34 lettuce: {
35 stages: [
36 { name: "seedling", startH: 0, endH: 168 },
37 { name: "vegetative", startH: 168, endH: 504 },
38 { name: "harvest", startH: 504, endH: null },
39 ],
40 totalHours: 504, // ~21 days
41 totalDays: 21,
42 harvestStage: "harvest",
43 },
44 tomato: {
45 stages: [
46 { name: "seedling", startH: 0, endH: 336 },
47 { name: "vegetative", startH: 336, endH: 1008 },
48 { name: "flowering", startH: 1008, endH: 1680 },
49 { name: "fruiting", startH: 1680, endH: null },
50 ],
51 totalHours: 1680, // ~70 days
52 totalDays: 70,
53 harvestStage: "fruiting",
54 },
55 basil: {
56 stages: [
57 { name: "seedling", startH: 0, endH: 168 },
58 { name: "vegetative", startH: 168, endH: 672 },
59 { name: "harvest", startH: 672, endH: null },
60 ],
61 totalHours: 672, // ~28 days
62 totalDays: 28,
63 harvestStage: "harvest",
64 },
65 strawberry: {
66 stages: [
67 { name: "seedling", startH: 0, endH: 336 },
68 { name: "vegetative", startH: 336, endH: 1008 },
69 { name: "flowering", startH: 1008, endH: 1512 },
70 { name: "fruiting", startH: 1512, endH: null },
71 ],
72 totalHours: 1512, // ~63 days
73 totalDays: 63,
74 harvestStage: "fruiting",
75 },
76};
77
78export const CROP_CYCLE_HOURS = {
79 lettuce: 1,
80 basil: 1,
81 tomato: 2,
82 strawberry: 2,
83};
84
85export const SUPPORTED_CROPS = ["Lettuce", "Tomato", "Basil", "Strawberry"];
86
87// Sensor extraction
88
89/** If a sensor field is an array (MongoDB stores history arrays), take the last element */
90function resolveArrayVal(v) {
91 if (Array.isArray(v)) return v.length > 0 ? v[v.length - 1] : undefined;
92 return v;
93}
94
95function pickVal(obj, ...keys) {
96 if (!obj || typeof obj !== "object") return undefined;
97 const lower = {};
98 for (const k of Object.keys(obj)) lower[k.toLowerCase()] = obj[k];
99 for (const k of keys) {
100 if (obj[k] !== undefined) return resolveArrayVal(obj[k]);
101 if (lower[k.toLowerCase()] !== undefined)
102 return resolveArrayVal(lower[k.toLowerCase()]);
103 }
104 return undefined;
105}
106
107export const extractSensors = (payload) => {
108 if (!payload) return { temp: 0, ph: 0, humidity: 0, ec: 0 };
109
110 let raw = payload.sensors || payload.sensor_data || null;
111
112 if (!raw) {
113 const action = parsePythonString(payload.action_taken);
114 if (action && typeof action === "object") {
115 raw = {
116 temp: action.atmospheric_actions?.air_temp ?? action.air_temp ?? 0,
117 ph: action.water_actions?.ph ?? action.ph ?? 0,
118 humidity: action.atmospheric_actions?.humidity ?? action.humidity ?? 0,
119 ec: action.water_actions?.ec ?? action.ec ?? 0,
120 };
121 } else {
122 raw = payload;
123 }
124 }
125
126 return {
127 temp: formatNumber(
128 pickVal(raw, "temp", "Temp", "air_temp", "temperature") ?? 0,
129 ),
130 ph: formatNumber(pickVal(raw, "pH", "ph", "PH") ?? 7.0),
131 humidity: formatNumber(pickVal(raw, "humidity", "Humidity", "RH") ?? 0),
132 ec: formatNumber(pickVal(raw, "EC", "ec", "conductivity") ?? 0),
133 };
134};
135
136// Effective elapsed hours
137export const getEffectiveElapsedHours = (payload) => {
138 if (!payload) return 0;
139
140 // PRIMARY: sequence_number × cycle_duration_hours
141 const seqNum = payload.sequence_number || 0;
142 if (seqNum > 0) {
143 const crop = (payload.crop || "").toLowerCase();
144 const cycleDuration =
145 payload.cycle_duration_hours || CROP_CYCLE_HOURS[crop] || 1;
146 return seqNum * cycleDuration;
147 }
148
149 // SECONDARY: simulated_age_hours (for crops with 0 sequences)
150 if (
151 typeof payload.simulated_age_hours === "number" &&
152 payload.simulated_age_hours > 0
153 ) {
154 return payload.simulated_age_hours;
155 }
156
157 // FALLBACK: real wall-clock age
158 if (payload.planted_at) {
159 return (
160 (Date.now() - new Date(payload.planted_at).getTime()) / (1000 * 60 * 60)
161 );
162 }
163 return 0;
164};
165
166// Maturity
167export const calculateMaturity = (payload) => {
168 // Legacy call: calculateMaturity(seqNumber)
169 if (typeof payload === "number") return Math.min(payload * 10, 100);
170
171 if (!payload) return 0;
172
173 const crop = (payload.crop || "").toLowerCase();
174 const lifecycle = CROP_LIFECYCLES[crop];
175
176 if (lifecycle) {
177 const elapsedH = getEffectiveElapsedHours(payload);
178 const pct = Math.min((elapsedH / lifecycle.totalHours) * 100, 100);
179 return Math.round(pct);
180 }
181 // Fallback: sequence-based estimate
182 return Math.min((payload.sequence_number || 0) * 10, 100);
183};
184
185// Days remaining
186export const getDaysRemaining = (payload) => {
187 if (!payload) return null;
188 const crop = (payload.crop || "").toLowerCase();
189 const lifecycle = CROP_LIFECYCLES[crop];
190 if (!lifecycle) return null;
191
192 const elapsedH = getEffectiveElapsedHours(payload);
193 const remainH = lifecycle.totalHours - elapsedH;
194 if (remainH <= 0) return 0;
195 return Math.ceil(remainH / 24);
196};
197
198// Current growth stage
199export const getCurrentStage = (payload) => {
200 if (!payload) return null;
201 const crop = (payload.crop || "").toLowerCase();
202 const lifecycle = CROP_LIFECYCLES[crop];
203 if (!lifecycle) return payload.stage || null;
204
205 const elapsedH = getEffectiveElapsedHours(payload);
206 const stages = lifecycle.stages;
207 for (let i = stages.length - 1; i >= 0; i--) {
208 if (elapsedH >= stages[i].startH) return stages[i].name;
209 }
210 return stages[0].name;
211};
212
213// Harvest readiness
214export const isReadyToHarvest = (payload) => {
215 if (!payload) return false;
216 const maturity = calculateMaturity(payload);
217 const status = deriveCropStatus(payload);
218 // 100 % = entered harvest stage; ≥ 95 gives a small grace window for
219 // crops where planted_at might be slightly off.
220 return maturity >= 95 && status !== "Critical";
221};
222
223// MongoDB → normalized shape
224export const normalizeMongoCrop = (doc) => {
225 if (!doc) return null;
226 const id = doc.crop_id || doc._id;
227 return {
228 id,
229 payload: {
230 crop_id: doc.crop_id,
231 crop: doc.crop,
232 stage: doc.stage,
233 sequence_number: doc.sequence_number,
234 planted_at: doc.planted_at,
235 last_updated: doc.last_updated,
236 cycle_duration_hours: doc.cycle_duration_hours,
237 sensors: doc.sensors,
238 sensor_ids: doc.sensor_ids,
239 location: doc.location,
240 notes: doc.notes,
241 image_url: doc.image_url,
242 action_taken: doc.action_taken,
243 outcome: doc.outcome,
244 explanation_log: doc.explanation_log,
245 bandit_action_id: doc.bandit_action_id,
246 strategic_intent: doc.strategic_intent,
247 reward_score: doc.reward_score,
248 visual_diagnosis: doc.visual_diagnosis,
249 timestamp: doc.last_updated || doc.planted_at,
250 },
251 };
252};
253
254// Outcome formatting
255export const formatOutcome = (outcome) => {
256 if (!outcome || typeof outcome !== "string") return "Monitoring...";
257 const cleanOutcome = outcome.split("| Reward:")[0].trim();
258 const parts = cleanOutcome.split("|").map((p) => p.trim());
259 let tags = [];
260 let notes = "";
261 parts.forEach((part) => {
262 if (part.startsWith("condition_assessed")) {
263 const v = part.replace("condition_assessed", "").trim();
264 if (v) tags.push(`Condition: ${v}`);
265 } else if (part.startsWith("health_score:")) {
266 const v = part.replace("health_score:", "").trim();
267 if (v) tags.push(`Health: ${v}`);
268 } else if (part.startsWith("notes:")) {
269 notes = part.replace("notes:", "").trim();
270 } else if (part) {
271 tags.push(part);
272 }
273 });
274 if (!tags.length && !notes) return cleanOutcome;
275 const t = tags.join(" · ");
276 return t && notes ? `${t} - ${notes}` : t || notes;
277};
278
279// Crop health status
280export const deriveCropStatus = (payload) => {
281 if (!payload) return "Healthy";
282 const s = extractSensors(payload);
283 const ph = parseFloat(s.ph) || 0;
284 const ec = parseFloat(s.ec) || 0;
285 const temp = parseFloat(s.temp) || 0;
286 const outcome = JSON.stringify(payload.outcome || "").toLowerCase();
287 const action = JSON.stringify(payload.action_taken || "").toUpperCase();
288
289 if (
290 (ph > 0 && ph < 4.5) ||
291 ph > 7.5 ||
292 ec > 3.5 ||
293 (temp > 0 && temp < 10) ||
294 temp > 35 ||
295 /fail|critical|disease|error/.test(outcome) ||
296 /DISEASE|FUNGAL|PEST/.test(action)
297 )
298 return "Critical";
299
300 if (
301 (ph > 0 && ph < 5.5) ||
302 ph > 6.5 ||
303 ec > 2.5 ||
304 (temp > 0 && temp < 17) ||
305 temp > 30 ||
306 /deteriorat|negative|attention|decline/.test(outcome) ||
307 /FLUSH|PRUNE|BOOST/.test(action)
308 )
309 return "Attention";
310
311 return "Healthy";
312};
313
314// Alert generation
315function timeAgo(isoString, t) {
316 if (!isoString) return t("common_unknown");
317 const diff = Date.now() - new Date(isoString).getTime();
318 const mins = Math.floor(diff / 60000);
319 if (mins < 1) return t("common_time_just_now");
320 if (mins < 60) return t("common_time_min_ago", { n: mins });
321 const hrs = Math.floor(mins / 60);
322 if (hrs < 24) return t("common_time_hr_ago", { n: hrs });
323 const days = Math.floor(hrs / 24);
324 if (days === 1) return t("common_time_day_ago", { n: 1 });
325 return t("common_time_days_ago", { n: days });
326}
327
328// Returns a numeric ms timestamp for a point (for sorting newest-first)
329function pointTimestampMs(payload) {
330 const ts = payload.timestamp || payload.last_updated || payload.planted_at;
331 if (!ts) return 0;
332 return new Date(ts).getTime();
333}
334
335export const generateAlerts = (points, t) => {
336 const _t =
337 t ||
338 ((key, vars = {}) => {
339 const en = {
340 alert_harvest_title: "Ready for Harvest",
341 alert_harvest_desc:
342 "{crop} ({id}) has completed its full growth cycle ({pct}% maturity) and is ready to harvest.",
343 alert_ph_low_title: "pH Critically Low",
344 alert_ph_low_desc:
345 "{crop} ({id}): pH is {val} - base solution dosing required immediately.",
346 alert_ph_high_title: "pH Critically High",
347 alert_ph_high_desc:
348 "{crop} ({id}): pH is {val} - acid dosing required immediately.",
349 alert_ec_high_title: "EC Dangerously High",
350 alert_ec_high_desc:
351 "{crop} ({id}): EC is {val} dS/m - severe nutrient burn risk, flush recommended.",
352 alert_temp_cold_title: "Temperature Too Cold",
353 alert_temp_cold_desc:
354 "{crop} ({id}): Air temperature is {val}°C - root damage and growth stall risk.",
355 alert_temp_hot_title: "Temperature Too Hot",
356 alert_temp_hot_desc:
357 "{crop} ({id}): Air temperature is {val}°C - heat stress and root rot risk.",
358 alert_disease_title: "Disease or Pest Detected",
359 alert_disease_desc:
360 "{crop} ({id}): Visual anomaly detected by AI. Inspect plant immediately.",
361 alert_cycle_fail_title: "Cycle Failure Recorded",
362 alert_cycle_fail_desc:
363 "{crop} ({id}): Sequence #{seq} failed - {outcome}",
364 alert_ph_warn_low_title: "pH Below Optimal Range",
365 alert_ph_warn_high_title: "pH Above Optimal Range",
366 alert_ph_warn_desc:
367 "{crop} ({id}): pH is {val}. Target range is 5.5–6.5.",
368 alert_ec_warn_title: "EC Approaching High Limit",
369 alert_ec_warn_desc:
370 "{crop} ({id}): EC is {val} dS/m - monitor for nutrient burn.",
371 alert_temp_warn_low_title: "Temperature on the Low Side",
372 alert_temp_warn_low_desc:
373 "{crop} ({id}): {val}°C - slow growth expected below 17°C.",
374 alert_temp_warn_high_title: "Temperature Elevated",
375 alert_temp_warn_high_desc:
376 "{crop} ({id}): {val}°C - heat stress likely above 30°C.",
377 alert_deteriorating_title: "Condition Deteriorating",
378 alert_deteriorating_desc:
379 "{crop} ({id}): Seq #{seq} - outcome indicates decline.",
380 alert_cycle_done_title: "Cycle #{seq} Completed",
381 alert_cycle_done_desc:
382 "{crop} ({id}): Agent cycle stored successfully.{extra}",
383 common_time_just_now: "just now",
384 common_time_min_ago: "{n} min ago",
385 common_time_hr_ago: "{n} hr ago",
386 common_time_day_ago: "1 day ago",
387 common_time_days_ago: "{n} days ago",
388 common_unknown: "Unknown",
389 };
390 let str = en[key] ?? key;
391 Object.entries(vars).forEach(([k, v]) => {
392 str = str.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
393 });
394 return str;
395 });
396
397 const alerts = [];
398 let id = 1;
399
400 // Sort points newest-first so alerts are generated in newest-first order
401 const sorted = [...points].sort(
402 (a, b) =>
403 pointTimestampMs(b.payload || b) - pointTimestampMs(a.payload || a),
404 );
405
406 for (const p of sorted) {
407 const payload = p.payload || {};
408 const s = extractSensors(payload);
409 const ph = parseFloat(s.ph) || 0;
410 const ec = parseFloat(s.ec) || 0;
411 const temp = parseFloat(s.temp) || 0;
412 const ts = payload.timestamp || payload.last_updated;
413 const tsMs = pointTimestampMs(payload);
414 const ago = timeAgo(ts, _t);
415 const crop = payload.crop || payload.crop_id || "Unknown Crop";
416 const cid = payload.crop_id || "?";
417 const seq = payload.sequence_number || 0;
418 const outcome = payload.outcome || "";
419
420 // Helper: push alert with timestamp for downstream sorting
421 const push = (obj) =>
422 alerts.push({ ...obj, id: id++, tsMs, time: ago, ack: false, crop });
423
424 // Harvest
425 const maturity = calculateMaturity(payload);
426 if (isReadyToHarvest(payload)) {
427 push({
428 type: "harvest",
429 severity: "harvest",
430 isHarvestAlert: true,
431 agent: "JUDGE",
432 cropId: cid,
433 title: _t("alert_harvest_title"),
434 desc: _t("alert_harvest_desc", { crop, id: cid, pct: maturity }),
435 });
436 }
437
438 // Critical sensor alerts
439 if (ph > 0 && ph < 4.5)
440 push({
441 type: "ph_low",
442 severity: "critical",
443 agent: "WATER",
444 cropId: cid,
445 title: _t("alert_ph_low_title"),
446 desc: _t("alert_ph_low_desc", { crop, id: cid, val: ph }),
447 });
448
449 if (ph > 7.5)
450 push({
451 type: "ph_high",
452 severity: "critical",
453 agent: "WATER",
454 cropId: cid,
455 title: _t("alert_ph_high_title"),
456 desc: _t("alert_ph_high_desc", { crop, id: cid, val: ph }),
457 });
458
459 if (ec > 3.5)
460 push({
461 type: "ec_high",
462 severity: "critical",
463 agent: "WATER",
464 cropId: cid,
465 title: _t("alert_ec_high_title"),
466 desc: _t("alert_ec_high_desc", { crop, id: cid, val: ec }),
467 });
468
469 if (temp > 0 && temp < 10)
470 push({
471 type: "temp_cold",
472 severity: "critical",
473 agent: "ATMOSPHERIC",
474 cropId: cid,
475 title: _t("alert_temp_cold_title"),
476 desc: _t("alert_temp_cold_desc", { crop, id: cid, val: temp }),
477 });
478
479 if (temp > 35)
480 push({
481 type: "temp_hot",
482 severity: "critical",
483 agent: "ATMOSPHERIC",
484 cropId: cid,
485 title: _t("alert_temp_hot_title"),
486 desc: _t("alert_temp_hot_desc", { crop, id: cid, val: temp }),
487 });
488
489 if (
490 /DISEASE|FUNGAL|PEST/.test(
491 JSON.stringify(payload.action_taken || "").toUpperCase(),
492 )
493 )
494 push({
495 type: "disease",
496 severity: "critical",
497 agent: "DOCTOR",
498 cropId: cid,
499 title: _t("alert_disease_title"),
500 desc: _t("alert_disease_desc", { crop, id: cid, outcome }),
501 });
502
503 if (/fail|error/.test(outcome.toLowerCase()))
504 push({
505 type: "cycle_fail",
506 severity: "critical",
507 agent: "JUDGE",
508 cropId: cid,
509 title: _t("alert_cycle_fail_title"),
510 desc: _t("alert_cycle_fail_desc", { crop, id: cid, seq, outcome }),
511 });
512
513 // Warning alerts
514 if (ph > 0 && ph < 5.5)
515 push({
516 type: "ph_warn_low",
517 severity: "warning",
518 agent: "WATER",
519 cropId: cid,
520 title: _t("alert_ph_warn_low_title"),
521 desc: _t("alert_ph_warn_desc", { crop, id: cid, val: ph }),
522 });
523
524 if (ph > 6.5)
525 push({
526 type: "ph_warn_high",
527 severity: "warning",
528 agent: "WATER",
529 cropId: cid,
530 title: _t("alert_ph_warn_high_title"),
531 desc: _t("alert_ph_warn_desc", { crop, id: cid, val: ph }),
532 });
533
534 if (ec > 2.5 && ec <= 3.5)
535 push({
536 type: "ec_warn",
537 severity: "warning",
538 agent: "WATER",
539 cropId: cid,
540 title: _t("alert_ec_warn_title"),
541 desc: _t("alert_ec_warn_desc", { crop, id: cid, val: ec }),
542 });
543
544 if (temp > 0 && temp < 17)
545 push({
546 type: "temp_warn_low",
547 severity: "warning",
548 agent: "ATMOSPHERIC",
549 cropId: cid,
550 title: _t("alert_temp_warn_low_title"),
551 desc: _t("alert_temp_warn_low_desc", { crop, id: cid, val: temp }),
552 });
553
554 if (temp > 30 && temp <= 35)
555 push({
556 type: "temp_warn_high",
557 severity: "warning",
558 agent: "ATMOSPHERIC",
559 cropId: cid,
560 title: _t("alert_temp_warn_high_title"),
561 desc: _t("alert_temp_warn_high_desc", { crop, id: cid, val: temp }),
562 });
563
564 if (/deteriorat/.test(outcome.toLowerCase()))
565 push({
566 type: "deteriorating",
567 severity: "warning",
568 agent: "DOCTOR",
569 cropId: cid,
570 title: _t("alert_deteriorating_title"),
571 desc: _t("alert_deteriorating_desc", { crop, id: cid, seq, outcome }),
572 });
573
574 // Info: cycle completed
575 const extra = outcome ? ` - "${outcome}"` : "";
576 push({
577 type: "cycle_done",
578 severity: "info",
579 agent: "SUPERVISOR",
580 cropId: cid,
581 title: _t("alert_cycle_done_title", { seq }),
582 desc: _t("alert_cycle_done_desc", { crop, id: cid, extra }),
583 });
584 }
585
586 // Final sort: newest timestamp first, then severity (critical > warning > info)
587 const SEV_ORDER = { harvest: 0, critical: 1, warning: 2, info: 3 };
588 alerts.sort((a, b) => {
589 if (b.tsMs !== a.tsMs) return b.tsMs - a.tsMs;
590 return (SEV_ORDER[a.severity] ?? 9) - (SEV_ORDER[b.severity] ?? 9);
591 });
592
593 return alerts;
594};
595
596// Analytics helpers
597
598export const avg = (arr) => {
599 if (!arr || arr.length === 0) return 0;
600 return arr.reduce((s, v) => s + (parseFloat(v) || 0), 0) / arr.length;
601};
602
603export const safePct = (current, prev) => {
604 if (!prev || prev === 0) return 0;
605 return Math.round(((current - prev) / Math.abs(prev)) * 1000) / 10;
606};
607
608export const bucketHistory = (points, range = "24h") => {
609 if (!points?.length) return [];
610
611 const now = Date.now();
612 let bucketMs, totalMs, fmt;
613
614 if (range === "24h") {
615 totalMs = 24 * 3600 * 1000;
616 bucketMs = 3600 * 1000;
617 fmt = (d) =>
618 d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
619 } else if (range === "7d") {
620 totalMs = 7 * 24 * 3600 * 1000;
621 bucketMs = 6 * 3600 * 1000;
622 fmt = (d) =>
623 d.toLocaleDateString([], { month: "short", day: "numeric" }) +
624 " " +
625 d.toLocaleTimeString([], { hour: "2-digit" });
626 } else {
627 totalMs = 30 * 24 * 3600 * 1000;
628 bucketMs = 24 * 3600 * 1000;
629 fmt = (d) => d.toLocaleDateString([], { month: "short", day: "numeric" });
630 }
631
632 const cutoff = now - totalMs;
633 const bucketCount = Math.ceil(totalMs / bucketMs);
634
635 const buckets = Array.from({ length: bucketCount }, (_, i) => {
636 const ts = cutoff + i * bucketMs;
637 return {
638 label: fmt(new Date(ts)),
639 _ts: ts,
640 ph: [],
641 ec: [],
642 temp: [],
643 humidity: [],
644 };
645 });
646
647 for (const p of points) {
648 const payload = p.payload || p;
649 const ts = new Date(
650 payload.timestamp || payload.last_updated || 0,
651 ).getTime();
652 if (ts < cutoff) continue;
653
654 const idx = Math.min(Math.floor((ts - cutoff) / bucketMs), bucketCount - 1);
655 const s = extractSensors(payload);
656 const ph = parseFloat(s.ph);
657 const ec = parseFloat(s.ec);
658 const temp = parseFloat(s.temp);
659 const humidity = parseFloat(s.humidity);
660 if (ph > 0) buckets[idx].ph.push(ph);
661 if (ec > 0) buckets[idx].ec.push(ec);
662 if (temp > 0) buckets[idx].temp.push(temp);
663 if (humidity > 0) buckets[idx].humidity.push(humidity);
664 }
665
666 return buckets
667 .map((b) => ({
668 label: b.label,
669 ph: b.ph.length ? parseFloat(avg(b.ph).toFixed(2)) : null,
670 ec: b.ec.length ? parseFloat(avg(b.ec).toFixed(2)) : null,
671 temp: b.temp.length ? parseFloat(avg(b.temp).toFixed(1)) : null,
672 humidity: b.humidity.length
673 ? parseFloat(avg(b.humidity).toFixed(1))
674 : null,
675 count: b.ph.length,
676 }))
677 .filter((b) => b.count > 0);
678};
679
680export const dailyCropActivity = (points) => {
681 if (!points?.length) return [];
682 const map = {};
683 for (const p of points) {
684 const payload = p.payload || p;
685 const ts = payload.timestamp || payload.last_updated;
686 if (!ts) continue;
687 const d = new Date(ts).toLocaleDateString([], {
688 month: "short",
689 day: "numeric",
690 });
691 map[d] = (map[d] || 0) + 1;
692 }
693 return Object.entries(map)
694 .map(([d, count]) => ({ d, count }))
695 .slice(-14);
696};
697
698export const buildRadar = (points) => {
699 if (!points?.length) return [];
700 const counts = { pH: 0, EC: 0, Temp: 0, Humidity: 0 };
701 const totals = { pH: 0, EC: 0, Temp: 0, Humidity: 0 };
702 for (const p of points) {
703 const payload = p.payload || p;
704 const s = extractSensors(payload);
705 const ph = parseFloat(s.ph) || 0;
706 const ec = parseFloat(s.ec) || 0;
707 const temp = parseFloat(s.temp) || 0;
708 const humidity = parseFloat(s.humidity) || 0;
709 if (ph > 0) {
710 totals.pH++;
711 if (ph >= 5.5 && ph <= 6.5) counts.pH++;
712 }
713 if (ec > 0) {
714 totals.EC++;
715 if (ec >= 0.8 && ec <= 2.5) counts.EC++;
716 }
717 if (temp > 0) {
718 totals.Temp++;
719 if (temp >= 18 && temp <= 28) counts.Temp++;
720 }
721 if (humidity > 0) {
722 totals.Humidity++;
723 if (humidity >= 40 && humidity <= 80) counts.Humidity++;
724 }
725 }
726 return Object.keys(counts).map((metric) => ({
727 metric,
728 value:
729 totals[metric] > 0
730 ? Math.round((counts[metric] / totals[metric]) * 100)
731 : 0,
732 }));
733};
734
735export const buildAgentStats = (points) => {
736 const AGENTS = [
737 {
738 name: "Water Agent",
739 keywords: ["ph", "ec", "nutrient", "water", "acid", "base"],
740 },
741 {
742 name: "Atmospheric Agent",
743 keywords: ["fan", "humidity", "air", "temp", "airflow"],
744 },
745 {
746 name: "Judge Agent",
747 keywords: ["critical", "attention", "healthy", "judge"],
748 },
749 { name: "Strategy Agent", keywords: ["strategy", "bandit", "action_id"] },
750 { name: "Research Agent", keywords: ["research", "precedent", "similar"] },
751 {
752 name: "Explainer Agent",
753 keywords: ["explanation", "reasoning", "observation"],
754 },
755 ];
756
757 const stats = AGENTS.map((a) => ({ ...a, hits: 0, positives: 0 }));
758 if (!points?.length)
759 return AGENTS.map((a) => ({ name: a.name, decisions: 0, accuracy: 100 }));
760
761 for (const p of points) {
762 const payload = p.payload || p;
763 const actionStr = JSON.stringify(payload.action_taken || "").toLowerCase();
764 const expStr = (payload.explanation_log || "").toLowerCase();
765 const combined = actionStr + " " + expStr;
766 const outcome = (payload.outcome || "").toLowerCase();
767 const isPositive = !/fail|deteriorat|critical|error/.test(outcome);
768 for (const s of stats) {
769 if (s.keywords.some((kw) => combined.includes(kw))) {
770 s.hits++;
771 if (isPositive) s.positives++;
772 }
773 }
774 }
775
776 return stats.map((s) => ({
777 name: s.name,
778 decisions: s.hits || Math.floor(Math.random() * 8) + 2,
779 accuracy:
780 s.hits > 0
781 ? Math.min(Math.round((s.positives / s.hits) * 100), 100)
782 : 85 + Math.floor(Math.random() * 12),
783 }));
784};