diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-pairs.png index 8b1d63d..339a668 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-projection.png index 544cef2..5ee1dbc 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-1-effective-1/location-1__run-1__farm__k-1__effective-1__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-map.png index 5bfa8e3..1390024 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-sizes.png index 8932e9b..96522fe 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-pairs.png index f99b8a1..838757b 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-projection.png index db8d780..f229ddf 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-10-effective-10/location-1__run-1__farm__k-10__effective-10__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-map.png index 6848735..c6f6e2c 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-sizes.png index 09a2166..424d828 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-pairs.png index 86da49e..a171647 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-projection.png index 8dba4c6..eaa9dda 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-2-effective-2/location-1__run-1__farm__k-2__effective-2__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-map.png index 0262e88..2de9cb5 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-sizes.png index 0917f83..a08ee5c 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-pairs.png index f8ee15b..28b7d4b 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-projection.png index e7d7e82..bae774b 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-3-effective-3/location-1__run-1__farm__k-3__effective-3__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-map.png index 5978b64..9478864 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-sizes.png index 909bb07..3c366e0 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-pairs.png index da370ef..4e7f6af 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-projection.png index d2026fc..8997b7c 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-4-effective-4/location-1__run-1__farm__k-4__effective-4__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-map.png index b7e9a2a..b1a1bb7 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-sizes.png index 76eee68..bb8edf3 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-pairs.png index 04b7fa4..460def5 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-projection.png index ac588e8..9270a95 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-5-effective-5/location-1__run-1__farm__k-5__effective-5__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-map.png index ef25e99..19b3857 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-sizes.png index 82439f6..654933c 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-pairs.png index 1321428..cfe054d 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-projection.png index 53a35b8..84e2666 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-6-effective-6/location-1__run-1__farm__k-6__effective-6__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-map.png index 5d0edbc..444a020 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-sizes.png index ec4570a..fd42e65 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-pairs.png index 88d9d88..6879ed5 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-projection.png index 720800d..cacdfc7 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-7-effective-7/location-1__run-1__farm__k-7__effective-7__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-map.png index e293876..13f8b66 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-sizes.png index 834f7e2..35e1273 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-pairs.png index 79964a3..7fdf928 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-projection.png index 10ffcfc..d88cbcb 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-8-effective-8/location-1__run-1__farm__k-8__effective-8__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-map.png index faea4ad..254f89f 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-sizes.png index d78f8f0..19021a9 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-pairs.png index 85ec61e..b2b2797 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-projection.png index 756daf4..d63d732 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/k-9-effective-9/location-1__run-1__farm__k-9__effective-9__feature-projection.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-map.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-map.png index 6848735..c6f6e2c 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-map.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-map.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-sizes.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-sizes.png index 09a2166..424d828 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-sizes.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__cluster-sizes.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__elbow.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__elbow.png index a51a107..cd466fd 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__elbow.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__elbow.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-pairs.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-pairs.png index 86da49e..a171647 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-pairs.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-pairs.png differ diff --git a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-projection.png b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-projection.png index 8dba4c6..eaa9dda 100644 Binary files a/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-projection.png and b/artifacts/remote_sensing_charts/location-1/run-1-farm/location-1__run-1__farm__feature-projection.png differ diff --git a/crop_simulation/recommendation_optimizer.py b/crop_simulation/recommendation_optimizer.py index 031ba5c..8b89d88 100644 --- a/crop_simulation/recommendation_optimizer.py +++ b/crop_simulation/recommendation_optimizer.py @@ -6,7 +6,7 @@ from statistics import mean from typing import Any from django.apps import apps -from location_data.satellite_snapshot import build_location_satellite_snapshot +from farm_data.services import get_ai_snapshot_metric from crop_simulation.services import CropSimulationService @@ -47,20 +47,9 @@ def _first_not_none(*values: Any) -> Any: return None -def _sensor_metric(sensor: Any, metric: str) -> float | None: - if sensor is None: - return None - if hasattr(sensor, metric): - value = getattr(sensor, metric) - return _safe_float(value, default=0.0) if value is not None else None - - payload = getattr(sensor, "sensor_payload", None) or {} - if not isinstance(payload, dict): - return None - for block in payload.values(): - if isinstance(block, dict) and block.get(metric) is not None: - return _safe_float(block.get(metric), default=0.0) - return None +def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None: + value = get_ai_snapshot_metric(ai_snapshot, metric) + return _safe_float(value, default=0.0) if value is not None else None def _parse_temperature_range(plant: Any) -> tuple[float, float]: @@ -140,15 +129,9 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude: return records -def _build_soil_parameters(sensor: Any) -> tuple[dict[str, Any], dict[str, Any]]: - moisture_pct = _sensor_metric(sensor, "soil_moisture") - center_location = getattr(sensor, "center_location", None) - satellite_metrics = ( - build_location_satellite_snapshot(center_location).get("resolved_metrics") or {} - if center_location is not None - else {} - ) - ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.34) +def _build_soil_parameters(sensor: Any, ai_snapshot: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]: + moisture_pct = _aggregated_metric(ai_snapshot, "soil_moisture") + ndwi = _safe_float(_aggregated_metric(ai_snapshot, "ndwi"), 0.34) wv0033 = ndwi if ndwi > 0 else 0.34 wv1500 = max(round(wv0033 * 0.45, 3), 0.14) @@ -288,6 +271,7 @@ class SimulationRecommendationOptimizer: daily_water_needs: list[dict[str, Any]], growth_stage: str | None, irrigation_method: Any | None, + ai_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if sensor is None or plant is None or not forecasts: return None @@ -301,6 +285,7 @@ class SimulationRecommendationOptimizer: daily_water_needs=daily_water_needs, growth_stage=growth_stage, crop_blueprint=crop_blueprint, + ai_snapshot=ai_snapshot, ) if pcse_result is not None: return pcse_result @@ -312,6 +297,7 @@ class SimulationRecommendationOptimizer: daily_water_needs=daily_water_needs, growth_stage=growth_stage, irrigation_method=irrigation_method, + ai_snapshot=ai_snapshot, ) def optimize_fertilization( @@ -321,6 +307,7 @@ class SimulationRecommendationOptimizer: plant: Any, forecasts: list[Any], growth_stage: str | None, + ai_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if sensor is None or plant is None: return None @@ -333,6 +320,7 @@ class SimulationRecommendationOptimizer: forecasts=forecasts, growth_stage=growth_stage, crop_blueprint=crop_blueprint, + ai_snapshot=ai_snapshot, ) if pcse_result is not None: return pcse_result @@ -342,6 +330,7 @@ class SimulationRecommendationOptimizer: plant=plant, forecasts=forecasts, growth_stage=growth_stage, + ai_snapshot=ai_snapshot, ) def _optimize_irrigation_with_pcse( @@ -353,10 +342,11 @@ class SimulationRecommendationOptimizer: daily_water_needs: list[dict[str, Any]], growth_stage: str | None, crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]], + ai_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any] | None: defaults = apps.get_app_config("irrigation").get_optimizer_defaults() crop_parameters, agromanagement = crop_blueprint - soil, site = _build_soil_parameters(sensor) + soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot) weather = _build_weather_records( forecasts, latitude=_safe_float(sensor.center_location.latitude), @@ -572,10 +562,11 @@ class SimulationRecommendationOptimizer: forecasts: list[Any], growth_stage: str | None, crop_blueprint: tuple[dict[str, Any], list[dict[str, Any]]], + ai_snapshot: dict[str, Any] | None = None, ) -> dict[str, Any] | None: defaults = apps.get_app_config("fertilization").get_optimizer_defaults() crop_parameters, agromanagement = crop_blueprint - soil, site = _build_soil_parameters(sensor) + soil, site = _build_soil_parameters(sensor, ai_snapshot=ai_snapshot) weather = _build_weather_records( forecasts, latitude=_safe_float(sensor.center_location.latitude), diff --git a/crop_simulation/services.py b/crop_simulation/services.py index 1b8d7e2..a803570 100644 --- a/crop_simulation/services.py +++ b/crop_simulation/services.py @@ -7,7 +7,8 @@ from datetime import date, datetime, timedelta from typing import Any from django.db import transaction -from location_data.satellite_snapshot import build_location_satellite_snapshot + +from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_weather from .models import SimulationRun, SimulationScenario @@ -303,23 +304,22 @@ def _clamp(value: float, lower: float, upper: float) -> float: return max(lower, min(value, upper)) -def _sensor_metric(sensor: Any, metric_name: str) -> float | None: - if sensor is None: +def _snapshot_metric(snapshot: dict[str, Any] | None, metric_name: str) -> float | None: + if not isinstance(snapshot, dict): return None - - if hasattr(sensor, metric_name): - value = getattr(sensor, metric_name) - if value is not None: - return _safe_float(value) - - payload = getattr(sensor, "sensor_payload", None) or {} - if not isinstance(payload, dict): + farm_metrics = snapshot.get("farm_metrics") or {} + resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {} + if not isinstance(resolved_metrics, dict): return None + return _safe_float(resolved_metrics.get(metric_name)) - for block in payload.values(): - if isinstance(block, dict) and block.get(metric_name) is not None: - return _safe_float(block.get(metric_name)) - return None + + +def _snapshot_source_metadata(snapshot: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(snapshot, dict): + return {} + source_metadata = snapshot.get("source_metadata") or {} + return source_metadata if isinstance(source_metadata, dict) else {} def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None: @@ -458,6 +458,9 @@ def build_simulation_payload_from_farm( raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.") plant = get_runtime_plant_for_farm(farm, plant_name=plant_name) + ai_snapshot = build_ai_farm_snapshot(str(farm_uuid)) + if ai_snapshot is None: + raise CropSimulationError(f"Canonical AI snapshot for farm uuid={farm_uuid} is missing.") if weather is not None: resolved_weather = _normalize_weather_records(weather) @@ -476,8 +479,7 @@ def build_simulation_payload_from_farm( longitude=float(farm.center_location.longitude), ) - satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {} - ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28) + ndwi = _snapshot_metric(ai_snapshot, "ndwi") smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55) smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06)) sm0 = _clamp( @@ -485,17 +487,17 @@ def build_simulation_payload_from_farm( max(smfcf + 0.02, smw + 0.05), 0.8, ) - soil_moisture = _sensor_metric(farm, "soil_moisture") + soil_moisture = _snapshot_metric(ai_snapshot, "soil_moisture") wav = ( round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3) if soil_moisture is not None else DEFAULT_WAV ) - nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db")) - phosphorus = _sensor_metric(farm, "phosphorus") - potassium = _sensor_metric(farm, "potassium") - soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None) - ec = _sensor_metric(farm, "electrical_conductivity") + nitrogen = _pick_first_not_none(_snapshot_metric(ai_snapshot, "nitrogen"), _snapshot_metric(ai_snapshot, "soil_vv_db")) + phosphorus = _snapshot_metric(ai_snapshot, "phosphorus") + potassium = _snapshot_metric(ai_snapshot, "potassium") + soil_ph = _pick_first_not_none(_snapshot_metric(ai_snapshot, "soil_ph"), None) + ec = _snapshot_metric(ai_snapshot, "electrical_conductivity") resolved_soil = { "SMFCF": round(smfcf, 3), @@ -569,6 +571,13 @@ def build_simulation_payload_from_farm( "site_parameters": resolved_site, "crop_parameters": resolved_crop, "agromanagement": resolved_agromanagement, + "source_metadata": { + "farm_metrics": _snapshot_source_metadata(ai_snapshot).get("farm_metrics", {}), + "weather": { + "source": "center_location_forecast", + "policy": "center_location_latest_forecast", + }, + }, } @@ -914,6 +923,7 @@ class CropSimulationService: "fertilization_recommendation": fertilization_recommendation or {}, "farm_uuid": farm_uuid, "plant_name": plant_name, + "source_metadata": resolved.get("source_metadata") or {}, } ), ) @@ -937,6 +947,7 @@ class CropSimulationService: "crop_parameters": resolved["crop_parameters"], "site_parameters": resolved["site_parameters"], "agromanagement": merged_agromanagement, + "source_metadata": resolved.get("source_metadata") or {}, } ], ) @@ -1205,6 +1216,7 @@ class CropSimulationService: "crop_parameters": resolved["crop_parameters"], "site_parameters": resolved["site_parameters"], "agromanagement": merged_agromanagement, + "source_metadata": resolved.get("source_metadata") or {}, } ) return self._execute_scenario(scenario=scenario, run_specs=run_specs) @@ -1258,7 +1270,7 @@ class CropSimulationService: site_parameters=spec["site_parameters"], ) run.status = SimulationScenario.Status.SUCCESS - run.result_payload = result + run.result_payload = {**result, "source_metadata": spec.get("source_metadata") or {}} run.save(update_fields=["status", "result_payload", "updated_at"]) results.append( { diff --git a/crop_simulation/tests.py b/crop_simulation/tests.py index 01a2631..cad6d02 100644 --- a/crop_simulation/tests.py +++ b/crop_simulation/tests.py @@ -11,6 +11,10 @@ from django.test import TestCase from rest_framework.test import APIRequestFactory from .models import SimulationRun, SimulationScenario +from farm_data.models import PlantCatalogSnapshot, SensorData +from irrigation.models import IrrigationMethod +from location_data.models import SoilLocation +from weather.models import WeatherForecast from .services import CropSimulationService, CropSimulationError, PcseSimulationManager from .views import PlantGrowthSimulationView @@ -366,3 +370,97 @@ class CropSimulationPcseIntegrationTests(TestCase): self.assertEqual(result["result"]["engine"], "pcse") self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"]) self.assertIsNotNone(result["result"]["metrics"]["biomass"]) + + +class CropSimulationCanonicalSnapshotTests(TestCase): + def setUp(self): + self.location = SoilLocation.objects.create(latitude="35.700000", longitude="51.400000") + self.weather = WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 10), + temperature_min=12.0, + temperature_max=24.0, + temperature_mean=18.0, + humidity_mean=55.0, + precipitation=1.0, + et0=3.5, + ) + self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=401, name="wheat") + self.irrigation_method = IrrigationMethod.objects.create(name="drip") + self.farm = SensorData.objects.create( + farm_uuid="550e8400-e29b-41d4-a716-446655440000", + center_location=self.location, + weather_forecast=self.weather, + irrigation_method=self.irrigation_method, + ) + self.farm.plants.add(self.plant) + + @patch("crop_simulation.services.build_ai_farm_snapshot") + def test_build_simulation_payload_from_farm_uses_aggregated_metrics(self, mock_snapshot): + from crop_simulation.services import build_simulation_payload_from_farm + + mock_snapshot.return_value = { + "farm_uuid": str(self.farm.farm_uuid), + "farm_metrics": { + "resolved_metrics": { + "soil_moisture": 36.0, + "ndwi": 0.31, + "nitrogen": 21.0, + "phosphorus": 11.0, + "potassium": 17.0, + "soil_ph": 6.8, + "electrical_conductivity": 1.4, + } + }, + "source_metadata": { + "farm_metrics": { + "canonical_source": "farmer_block_aggregated_snapshot", + "aggregation_strategy": "farmer_block_mean", + } + }, + } + + payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat") + + self.assertEqual(payload["soil"]["soil_moisture"], 36.0) + self.assertEqual(payload["site_parameters"]["NAVAILI"], 21.0) + self.assertEqual(payload["soil"]["phosphorus"], 11.0) + self.assertEqual(payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot") + + @patch("crop_simulation.services.build_ai_farm_snapshot") + def test_build_simulation_payload_from_farm_handles_missing_block_metrics(self, mock_snapshot): + from crop_simulation.services import build_simulation_payload_from_farm + + mock_snapshot.return_value = { + "farm_uuid": str(self.farm.farm_uuid), + "farm_metrics": {"resolved_metrics": {}}, + "source_metadata": {"farm_metrics": {"status": "missing"}}, + } + + payload = build_simulation_payload_from_farm(farm_uuid=str(self.farm.farm_uuid), plant_name="wheat") + + self.assertEqual(payload["site_parameters"]["WAV"], 40.0) + self.assertEqual(payload["source_metadata"]["farm_metrics"]["status"], "missing") + + @patch("crop_simulation.services.build_ai_farm_snapshot") + def test_run_single_simulation_stores_weather_provenance(self, mock_snapshot): + mock_snapshot.return_value = { + "farm_uuid": str(self.farm.farm_uuid), + "farm_metrics": {"resolved_metrics": {"soil_moisture": 35.0, "ndwi": 0.3}}, + "source_metadata": { + "farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}, + "weather": {"policy": "center_location_latest_forecast"}, + }, + } + service = CropSimulationService() + + with patch.object(service.manager, "run_simulation", return_value={"engine": "pcse", "metrics": {}, "daily_output": [], "summary_output": [], "terminal_output": []}): + result = service.run_single_simulation( + farm_uuid=str(self.farm.farm_uuid), + plant_name="wheat", + agromanagement=build_agromanagement(), + ) + + self.assertEqual(result["result"]["source_metadata"]["weather"]["policy"], "center_location_latest_forecast") + run = SimulationRun.objects.get() + self.assertEqual(run.result_payload["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot") diff --git a/crop_simulation/yield_harvest_summary.py b/crop_simulation/yield_harvest_summary.py index 9077275..43c9bf7 100644 --- a/crop_simulation/yield_harvest_summary.py +++ b/crop_simulation/yield_harvest_summary.py @@ -10,7 +10,7 @@ from django.apps import apps from django.conf import settings from farm_data.models import SensorData -from farm_data.services import get_farm_details +from farm_data.services import build_ai_farm_snapshot from location_data.models import NdviObservation, SoilLocation from rag.failure_contract import RAGServiceError @@ -720,13 +720,13 @@ class YieldHarvestSummaryService: "recent_sensor_averages": {}, } - farm_details = get_farm_details(str(farm_uuid)) or {} + farm_details = build_ai_farm_snapshot(str(farm_uuid)) or {} center_location = farm.center_location - soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {} - weather_details = farm_details.get("weather") or {} + soil_details = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {} + weather_details = ((farm_details.get("weather") or {}).get("forecast") or {}) recent_sensor_averages = { - "soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None), - "soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None), + "soil_moisture": self._safe_float(soil_details.get("soil_moisture"), None), + "soil_temperature": self._safe_float(soil_details.get("soil_temperature"), None), "air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None), } @@ -744,7 +744,7 @@ class YieldHarvestSummaryService: "lat": float(center_location.latitude), "lon": float(center_location.longitude), }, - "farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"), + "farm_boundary": getattr(center_location, "farm_boundary", None), "soil": { "provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"), "soil_type": self._infer_soil_type(soil_details), @@ -760,6 +760,10 @@ class YieldHarvestSummaryService: "sensor_data": SensorData.__name__, "soil_location": SoilLocation.__name__, }, + "source_metadata": { + "farm_metrics": (farm_details.get("source_metadata") or {}).get("farm_metrics", {}), + "weather": ((farm_details.get("weather") or {}).get("source_metadata") or {}), + }, } def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float: diff --git a/docs/ai_api_cluster_data_policy.md b/docs/ai_api_cluster_data_policy.md new file mode 100644 index 0000000..bda34b2 --- /dev/null +++ b/docs/ai_api_cluster_data_policy.md @@ -0,0 +1,677 @@ +# سیاست منبع داده و تجمیع کلاستر برای APIهای AI + +این سند مشخص می‌کند هر API از کجا داده می‌گیرد، آیا الان خروجی آن بر اساس میانگین داده سنسور کلاسترها و میانگین داده ماهواره‌ای کلاسترهای `location_data` هست یا نه، و برای یکپارچه‌سازی چه policyای باید رعایت شود. + +این سند بر اساس کد فعلی پروژه نوشته شده و مخصوص APIهای زیر است: + +- `POST /api/rag/chat/` +- `POST /api/soile/anomaly-detection/` +- `POST /api/soile/health-summary/` +- `POST /api/soile/moisture-heatmap/` +- `POST /api/weather/farm-card/` +- `POST /api/weather/water-need-prediction/` +- `POST /api/pest-disease/detect/` +- `POST /api/pest-disease/risk/` +- `POST /api/irrigation/plan-from-text/` +- `POST /api/irrigation/recommend/` +- `POST /api/farm-data/parameters/` +- `POST /api/fertilization/plan-from-text/` +- `POST /api/fertilization/recommend/` +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/growth/` +- `GET /api/crop-simulation/growth/{task_id}/status/` +- `POST /api/crop-simulation/harvest-prediction/` +- `GET /api/crop-simulation/yield-harvest-summary/` +- `POST /api/crop-simulation/yield-prediction/` +- `POST /api/economy/overview/` +- `POST /api/farm-alerts/tracker/` + +--- + +## 1) قانون هدف کسب‌وکاری + +قانون مطلوبی که باید برای محاسبه‌های عمومی AI رعایت شود این است: + +1. برای محاسبه‌های عمومی AI مثل `RAG`، `crop_simulation`، `irrigation`، `fertilization`، `farm_alerts` و سرویس‌های تحلیلی خاک، مبنای داده باید `میانگین داده سنسورهای کلاسترها` و `میانگین داده ماهواره‌ای کلاسترهای location_data` باشد. +2. این تجمیع باید در سطح `بلوک‌های بزرگ کشاورز` انجام شود؛ یعنی اول هر block اصلی جداگانه محاسبه شود، بعد در صورت نیاز یک خلاصه کل مزرعه از روی blockهای اصلی ساخته شود. +3. اگر کشاورز هنوز block تعریف نکرده باشد، حالت پیش‌فرض دامنه باید این باشد: + - `1 بلوک بزرگ` برای کل مزرعه + - `1 بلوک کوچک` داخل همان بلوک بزرگ +4. هر API که فقط `farm_uuid` می‌گیرد و رفتار عمومی مزرعه را تحلیل می‌کند، باید از snapshot تجمیع‌شده‌ی مبتنی بر block/sub-block استفاده کند، نه از یک سنسور خام یا یک location خام. + +--- + +## 2) وضعیت فعلی مدل داده + +### 2.1) لایه مزرعه + +رکورد canonical مزرعه در `farm_data.SensorData` نگه‌داری می‌شود: + +- شناسه اصلی: `farm_uuid` +- اتصال مکانی: `center_location -> location_data.SoilLocation` +- سنسورها: `sensor_payload` +- آب‌وهوا: `weather_forecast` +- گیاه: `plant_assignments` و `plant_snapshots` +- روش آبیاری: `irrigation_method` + +### 2.2) لایه مکانی + +رکورد canonical مکانی در `location_data.SoilLocation` است: + +- `farm_boundary` +- `input_block_count` +- `block_layout` +- `block_subdivisions` +- `remote_sensing_runs` + +### 2.3) لایه کلاستر و سنجش از دور + +در `location_data` عملاً دو سطح تجمیع وجود دارد: + +- `block` = بلوک اصلی که کشاورز تعریف کرده +- `sub-block / cluster` = بخش‌های کوچک‌تر داخل هر block + +منطق فعلی تجمیع در `location_data/satellite_snapshot.py` پیاده شده است: + +- `build_block_sensor_summary(...)` + - داده سنسورهای منتسب به sub-blockها را جمع می‌کند + - ابتدا برای هر sub-block میانگین می‌سازد + - سپس از روی sub-blockها میانگین block را می‌سازد +- `summarize_block_satellite_metrics(...)` + - داده‌های grid/cell ماهواره‌ای را برای cluster blockها خلاصه می‌کند + - سپس میانگین block را می‌سازد +- `build_location_block_satellite_snapshots(...)` + - برای هر block اصلی یک snapshot می‌سازد +- `build_farmer_block_aggregated_snapshot(...)` + - از روی blockهای اصلی یک خلاصه کل مزرعه می‌سازد + +پس زیرساخت فنی برای policy موردنظر تا حد زیادی در پروژه وجود دارد. + +--- + +## 3) رفتار پیش‌فرض blockها + +### رفتار فعلی در کد + +تابع `build_block_layout()` در `location_data/models.py` وقتی blockی داده نشود، این رفتار را دارد: + +- `input_block_count = 1` +- فقط `1 بلوک اصلی` با `block-1` ساخته می‌شود +- `sub_blocks = []` + +یعنی الان بخش دوم rule شما کامل اعمال نشده، چون: + +- `1 بلوک بزرگ` وجود دارد +- ولی `1 بلوک کوچک داخل آن` به‌صورت default ساخته نمی‌شود + +### رفتار مطلوب پیشنهادی + +برای سازگاری با خواسته شما، policy پیشنهادی این است: + +- اگر هیچ block و هیچ subdivisionی تعریف نشده بود: + - `block_layout.blocks = [block-1]` + - داخل `block-1.sub_blocks` یک sub-block پیش‌فرض ساخته شود +- این sub-block پیش‌فرض می‌تواند چیزی شبیه این داشته باشد: + +```json +{ + "sub_block_code": "block-1-sub-1", + "cluster_label": 0, + "source": "default", + "boundary": {}, + "cluster_uuid": null +} +``` + +این policy باعث می‌شود تمام APIهای downstream همیشه حداقل یک سطح `sub-block` داشته باشند و منطق aggregation یکنواخت بماند. + +--- + +## 4) منبع canonical پیشنهادی برای همه APIهای AI + +برای APIهای عمومی AI، منبع canonical باید این ترتیب باشد: + +### 4.1) سنسورها + +منبع اصلی: + +- `farm_data.SensorData.sensor_payload` + +اما نه به صورت خام. باید از مسیر زیر مصرف شود: + +- assign سنسورها به `cluster/sub-block` +- میانگین‌گیری در سطح sub-block +- میانگین‌گیری مجدد در سطح block اصلی +- در صورت نیاز میانگین‌گیری در سطح کل مزرعه + +### 4.2) ماهواره و remote sensing + +منبع اصلی: + +- `location_data.RemoteSensingRun` +- `location_data.AnalysisGridObservation` +- `location_data.RemoteSensingSubdivisionResult` +- `location_data.RemoteSensingClusterBlock` +- `location_data.RemoteSensingClusterAssignment` + +و باز هم باید از مسیر زیر مصرف شود: + +- میانگین متریک هر cluster/sub-block +- میانگین متریک هر block اصلی +- در صورت نیاز میانگین کل مزرعه + +### 4.3) آب‌وهوا + +منبع اصلی آب‌وهوا فعلاً cluster-based نیست و از اینجا می‌آید: + +- `farm.weather_forecast` +- یا آخرین `center_location.weather_forecasts` + +یعنی weather فعلاً location-center based است، نه cluster based. + +### 4.4) source of truth نهایی برای APIهای عمومی + +برای APIهایی که `farm_uuid` می‌گیرند، بهتر است `source of truth` نهایی یکی از این دو باشد: + +- `get_farm_details(farm_uuid)` بعد از ارتقا به policy جدید +- یا یک service جدید مثل `build_ai_farm_snapshot(farm_uuid)` + +این snapshot باید شامل این بخش‌ها باشد: + +- `farm_level_aggregated_metrics` +- `block_level_metrics` +- `sub_block_level_metrics` +- `weather` +- `plants` +- `irrigation_method` +- `source_metadata` + +--- + +## 5) وضعیت فعلی هر API + +در این بخش برای هر API مشخص شده: + +- آیا الان مبتنی بر میانگین سنسور کلاسترها و میانگین ماهواره‌ای کلاسترها هست یا نه +- اگر نیست، الان داده را از کجا می‌گیرد +- وضعیت انطباق آن با policy جدید + +--- + +## 5.1) RAG + +### `POST /api/rag/chat/` + +وضعیت فعلی: `نیمه‌منطبق` + +منبع داده فعلی: + +- متن خاک کاربر در `rag/user_data.py -> build_user_soil_text()` +- از `SensorData` و `center_location` می‌خواند +- از `build_farmer_block_aggregated_snapshot(...)` استفاده می‌کند +- از `build_location_block_satellite_snapshots(...)` هم برای blockهای اصلی استفاده می‌کند + +نتیجه: + +- برای summary کلی مزرعه، از aggregation مبتنی بر block استفاده می‌کند +- برای satellite و sensor summary تا حدی با policy شما سازگار است +- اما RAG chat هنوز یک contract رسمی و واحد برای `cluster mean only` ندارد و متن embed شده ممکن است ترکیبی از داده‌های سطح farm و block باشد + +نیاز به اصلاح: + +- canonical snapshot باید صریحاً از `block mean` و `sub-block mean` ساخته شود +- prompt/context باید روشن بگوید که اعداد از `cluster averages` آمده‌اند + +### `POST /api/soile/anomaly-detection/` + +وضعیت فعلی: `غیرمنطبق مستقیم، منطبق غیرمستقیم` + +منبع داده فعلی: + +- anomaly payload از request می‌آید +- اطلاعات مزرعه از `farm_data.services.get_farm_details()` +- سپس به `rag/services/soil_anomaly.py` داده می‌شود + +نتیجه: + +- خود anomaly ممکن است از هر منبعی آمده باشد و API آن را enforce نمی‌کند +- اما farm context از `get_farm_details()` می‌آید که فعلاً `latest_satellite + sensor_payload` را ترکیب می‌کند +- `get_farm_details()` هنوز خلاصه soil را از `build_location_satellite_snapshot(center_location)` می‌گیرد، نه از `build_farmer_block_aggregated_snapshot()` + +نیاز به اصلاح: + +- `get_farm_details()` باید خلاصه soil اصلی را از `farmer-level aggregated snapshot` بگیرد +- anomaly input هم اگر داخلی تولید می‌شود، باید بر پایه cluster mean باشد + +--- + +## 5.2) Soile + +### `POST /api/soile/health-summary/` + +وضعیت فعلی: `احتمالاً نیمه‌منطبق` + +منبع داده فعلی: + +- از `farm_data.context.load_farm_context()` و سرویس‌های soil استفاده می‌کند +- `load_farm_context()` از `build_location_block_satellite_snapshots(location)` استفاده می‌کند +- بخشی از context block-based است + +اما: + +- اگر در summary نهایی از metrics خام سنسور یا latest weather/location استفاده شود، خروجی کاملاً cluster-mean-only نیست + +نیاز به اصلاح: + +- health summary باید صریحاً از `block_level` و `farm_level` aggregated metrics استفاده کند +- هیچ metric خام سنسور بدون عبور از منطق sub-block mean وارد پاسخ نشود + +### `POST /api/soile/moisture-heatmap/` + +وضعیت فعلی: `منطبق نیست` + +منبع داده فعلی: + +- heatmap ذاتاً spatial است و معمولاً از grid/cell یا location-level moisture data ساخته می‌شود +- برای heatmap، میانگین‌گیری کامل روی clusterها کافی نیست چون heatmap نیاز به توزیع مکانی دارد + +نتیجه: + +- این API نباید به خروجی تک‌عددی farm average تقلیل داده شود +- بلکه باید داده خام grid/cell یا cluster-level map را نگه دارد + +policy صحیح: + +- برای کارت summary یا average moisture بله، از cluster mean استفاده شود +- اما برای heatmap خودِ خروجی باید `cluster map` یا `grid map` باشد، نه صرفاً میانگین کل مزرعه + +--- + +## 5.3) Weather + +### `POST /api/weather/farm-card/` + +وضعیت فعلی: `غیرمنطبق` + +منبع داده فعلی: + +- از `weather_forecast` مرتبط با `center_location` +- یا آخرین forecast همان location + +نتیجه: + +- weather فعلاً بر اساس clusterهای `location_data` نیست +- چون مدل weather cluster-based ندارد + +نیاز به اصلاح: + +- اگر قرار است cluster-based شود، باید weather در سطح block/sub-block تعریف شود +- در غیر این صورت، این API باید در سند به‌عنوان `location-centered weather` ثبت شود + +### `POST /api/weather/water-need-prediction/` + +وضعیت فعلی: `نیمه‌منطبق` + +منبع داده فعلی: + +- در `rag/services/water_need_prediction.py` از `get_farm_details(farm_uuid)` استفاده می‌شود +- همچنین از weather forecast مزرعه استفاده می‌کند + +نتیجه: + +- بخش خاک/سنسور می‌تواند از snapshot مزرعه بیاید +- اما چون `get_farm_details()` هنوز fully farmer-block-aggregated نیست، خروجی کامل منطبق نیست +- بخش weather هم location-level است + +نیاز به اصلاح: + +- خاک و ماهواره از farmer aggregated snapshot +- weather تا زمان توسعه مدل cluster-weather، به‌صورت location-level باقی بماند ولی source آن شفاف اعلام شود + +--- + +## 5.4) Pest & Disease + +### `POST /api/pest-disease/detect/` + +وضعیت فعلی: `غیرمنطبق مفهومی` + +منبع داده فعلی: + +- ورودی اصلی: تصویر +- context کمکی: `farm_uuid` و داده مزرعه از `get_farm_details()` + +نتیجه: + +- هسته این API image-based است، نه cluster average based +- context مزرعه می‌تواند cluster-based شود، اما خروجی نهایی وابسته به تصویر است + +policy صحیح: + +- context مزرعه برای کمک به تشخیص از snapshot تجمیعی بیاید +- اما این API ذاتاً APIِ میانگین‌محور نیست + +### `POST /api/pest-disease/risk/` + +وضعیت فعلی: `نیمه‌منطبق` + +منبع داده فعلی: + +- `rag/services/pest_disease.py` +- اطلاعات مزرعه از `get_farm_details()` +- داده RAG و پایگاه دانش تخصصی + +نتیجه: + +- risk API باید از context مزرعه استفاده کند +- اگر `get_farm_details()` اصلاح شود، این API هم خودبه‌خود نزدیک به policy مطلوب می‌شود + +--- + +## 5.5) Irrigation + +### `POST /api/irrigation/plan-from-text/` + +وضعیت فعلی: `غیرمنطبق ذاتی` + +منبع داده فعلی: + +- ورودی اصلی: متن آزاد +- مدل parser متنی + +نتیجه: + +- این API parser است، نه تحلیل agronomic مبتنی بر sensor/satellite +- بنابراین لازم نیست خروجی‌اش بر پایه cluster average باشد + +policy صحیح: + +- فقط اگر farm context کمکی به parser اضافه شود، آن context باید از snapshot تجمیعی بیاید + +### `POST /api/irrigation/recommend/` + +وضعیت فعلی: `نیمه‌منطبق` + +منبع داده فعلی: + +- از farm context و سرویس RAG irrigation استفاده می‌کند +- بخشی از سنسور را ممکن است مستقیماً از `sensor.sensor_payload` بخواند + +نتیجه: + +- اگر بعضی metricها مستقیم از payload خام خوانده شوند، با policy شما ناسازگار است + +نیاز به اصلاح: + +- recommendation فقط باید از aggregated block/sub-block metrics تغذیه شود +- fallback مستقیم به اولین سنسور یا payload خام باید حذف یا محدود شود + +--- + +## 5.6) Farm Parameters + +### `POST /api/farm-data/parameters/` + +وضعیت فعلی: `خارج از دامنه این policy` + +منبع داده فعلی: + +- این API داده را ایجاد/ویرایش می‌کند +- `farm_boundary` را می‌گیرد +- `center_location` را resolve می‌کند +- `sensor_payload` را ذخیره می‌کند +- weather/location sync را trigger می‌کند + +نتیجه: + +- این API تولیدکننده داده است، نه consumer تحلیلی +- بنابراین قرار نیست خروجی analytic آن بر پایه cluster mean باشد + +اما نقش مهم: + +- باید sensorها را طوری ذخیره کند که assignment به `cluster/sub-block` ممکن باشد +- اگر cluster_uuid یا sub_block_code در payload نیاید، aggregation دقیق محدود می‌شود + +--- + +## 5.7) Fertilization + +### `POST /api/fertilization/plan-from-text/` + +وضعیت فعلی: `غیرمنطبق ذاتی` + +منبع داده فعلی: + +- parser متن آزاد + +نتیجه: + +- مانند irrigation text parser، این API ماهیتاً cluster-average consumer نیست + +### `POST /api/fertilization/recommend/` + +وضعیت فعلی: `نیمه‌منطبق` + +منبع داده فعلی: + +- context مزرعه و RAG fertilization +- معمولاً از `get_farm_details()` و contextهای وابسته استفاده می‌کند + +نتیجه: + +- با اصلاح منبع canonical مزرعه، این API هم می‌تواند کاملاً منطبق شود + +--- + +## 5.8) Crop Simulation + +### `POST /api/crop-simulation/current-farm-chart/` +### `POST /api/crop-simulation/growth/` +### `POST /api/crop-simulation/harvest-prediction/` +### `GET /api/crop-simulation/yield-harvest-summary/` +### `POST /api/crop-simulation/yield-prediction/` + +وضعیت فعلی: `غیرمنطبق تا نیمه‌منطبق` + +منبع داده فعلی: + +- سرویس‌های `crop_simulation/services.py` +- بخشی از داده‌ها از farm context و بخشی از سنسور یا weather فعلی می‌آیند +- در بعضی helperها متریک‌ها ممکن است مستقیماً از `sensor_payload` یا propertyهای سنسور resolve شوند + +نتیجه: + +- با rule شما که گفته بودی برای `crop_simulation` باید از داده کل بلوک‌های بزرگ استفاده شود، وضعیت فعلی هنوز کامل نیست +- چون منبع canonical شبیه‌سازی هنوز به‌صورت صریح farmer-block aggregation enforced نشده + +نیاز به اصلاح: + +- ورودی تمام simulation endpointها باید از farm aggregated snapshot بیاید +- یعنی متریک‌های soil moisture, temperature, ndvi, ndwi, slope, nitrogen, phosphorus, potassium از `cluster mean -> block mean -> farm mean` ساخته شوند +- weather فعلاً جداگانه location-level می‌ماند مگر بعداً block-weather اضافه شود + +### `GET /api/crop-simulation/growth/{task_id}/status/` + +وضعیت فعلی: `وابسته به منبع run اولیه` + +نتیجه: + +- این endpoint فقط status/result job را برمی‌گرداند +- اگر `growth` هنگام شروع از داده غیرمنطبق استفاده کرده باشد، status endpoint هم همان خروجی را reflect می‌کند + +--- + +## 5.9) Economy + +### `POST /api/economy/overview/` + +وضعیت فعلی: `منطبق نیست و حتی منبع واقعی ندارد` + +منبع داده فعلی: + +- `economy/services.py` +- پیام صریح دارد که منبع واقعی تنظیم نشده است + +نتیجه: + +- این API فعلاً نه cluster-based است و نه حتی data-backed + +نیاز به اصلاح: + +- اگر قرار است فعال شود، باید از snapshot مزرعه + هزینه‌ها + yield prediction + irrigation/fertilization plan استفاده کند + +--- + +## 5.10) Farm Alerts + +### `POST /api/farm-alerts/tracker/` + +وضعیت فعلی: `نیمه‌منطبق` + +منبع داده فعلی: + +- `farm_alerts/services.py` +- از `load_farm_context(farm_uuid)` و `get_farm_details(farm_uuid)` استفاده می‌کند + +نتیجه: + +- چون farm context بخشی از snapshotهای block-based را می‌خواند، تا حدی با policy سازگار است +- اما اگر context نهایی هنوز metric خام یا latest non-aggregated را ترجیح بدهد، کامل منطبق نیست + +نیاز به اصلاح: + +- همه alert ruleها باید روی block/farm aggregated metrics سوار شوند +- در صورت نیاز، هشدارهای spatial باید روی cluster-level breakdown هم نمایش داده شوند + +--- + +## 6) جمع‌بندی سریع انطباق APIها + +### APIهایی که ماهیتاً باید cluster-aggregated باشند + +این‌ها باید به policy جدید مهاجرت کنند: + +- `POST /api/rag/chat/` +- `POST /api/soile/anomaly-detection/` +- `POST /api/soile/health-summary/` +- `POST /api/weather/water-need-prediction/` +- `POST /api/pest-disease/risk/` +- `POST /api/irrigation/recommend/` +- `POST /api/fertilization/recommend/` +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/growth/` +- `POST /api/crop-simulation/harvest-prediction/` +- `GET /api/crop-simulation/yield-harvest-summary/` +- `POST /api/crop-simulation/yield-prediction/` +- `POST /api/farm-alerts/tracker/` + +### APIهایی که ماهیتاً parser/input/image/weather هستند و کامل cluster-based نیستند + +- `POST /api/pest-disease/detect/` -> image-first +- `POST /api/irrigation/plan-from-text/` -> text parser +- `POST /api/fertilization/plan-from-text/` -> text parser +- `POST /api/farm-data/parameters/` -> data ingestion/upsert +- `POST /api/weather/farm-card/` -> location-centered weather +- `POST /api/economy/overview/` -> فعلاً منبع واقعی ندارد + +### APIهایی که spatial map هستند و نباید صرفاً average شوند + +- `POST /api/soile/moisture-heatmap/` + +این API باید cluster/grid-aware بماند، نه فقط average-based. + +--- + +## 7) مهم‌ترین mismatch فعلی در کد + +مهم‌ترین mismatch فعلی این است که `farm_data.services.get_farm_details()` برای `soil.resolved_metrics` این کار را می‌کند: + +- `latest_satellite = build_location_satellite_snapshot(center_location)` +- سپس `latest_satellite.resolved_metrics` را به عنوان soil_metrics می‌گیرد + +این یعنی: + +- خلاصه اصلی soil بر اساس یک snapshot location/block واحد ساخته می‌شود +- نه بر اساس `build_farmer_block_aggregated_snapshot(center_location, sensor_payload=...)` + +در حالی که برای policy موردنظر شما بهتر است این کار انجام شود: + +- `farm_level_snapshot = build_farmer_block_aggregated_snapshot(...)` +- `soil.resolved_metrics = farm_level_snapshot.resolved_metrics` +- `soil.satellite_snapshots = block-level snapshots` +- در صورت نیاز `sub_block snapshots` هم در context بمانند + +--- + +## 8) پیشنهاد ساختار استاندارد پاسخ داده برای AI + +برای همه سرویس‌های AI بهتر است یک snapshot واحد با این ساختار وجود داشته باشد: + +```json +{ + "farm_uuid": "...", + "aggregation_policy": { + "sensor": "cluster_mean_then_block_mean_then_farm_mean", + "satellite": "cluster_mean_then_block_mean_then_farm_mean", + "weather": "center_location_latest_forecast", + "default_block_policy": "1_main_block + 1_default_sub_block_when_missing" + }, + "farm_metrics": {}, + "block_metrics": [], + "sub_block_metrics": [], + "weather": {}, + "plants": [], + "irrigation_method": {} +} +``` + +مزیت این طراحی: + +- همه APIها source of truth یکسان دارند +- اختلاف بین RAG، crop simulation، irrigation و alerts کم می‌شود +- تست‌پذیری ساده‌تر می‌شود + +--- + +## 9) نتیجه نهایی + +### الان کدام APIها دقیقاً مطابق خواسته شما نیستند؟ + +تقریباً همه APIهای تحلیلی هنوز `کاملاً` مطابق policy شما نیستند، چون منبع canonical یکپارچه‌ای که صریحاً بر پایه `cluster mean sensor + cluster mean satellite` باشد هنوز همه‌جا enforce نشده است. + +بیشترین فاصله با policy مطلوب در این بخش‌هاست: + +- `crop_simulation` +- `irrigation/recommend` +- `fertilization/recommend` +- `weather/farm-card` +- `economy/overview` +- بخشی از `soile` و `farm_alerts` + +### الان کدام APIها از همین حالا تا حدی نزدیک هستند؟ + +- `rag/chat` +- `farm_alerts/tracker` +- `soile/health-summary` +- `pest-disease/risk` + +چون به‌صورت مستقیم یا غیرمستقیم از snapshotهای block-based و `get_farm_details()` استفاده می‌کنند. + +### الان کدام APIها ماهیتاً نباید صرفاً average-based شوند؟ + +- `soile/moisture-heatmap` +- `pest-disease/detect` +- parserهای `plan-from-text` + +--- + +## 10) پیشنهاد اجرای فنی در کد + +ترتیب پیشنهادی برای پیاده‌سازی: + +1. `get_farm_details()` را به `farmer aggregated snapshot` مهاجرت بده. +2. یک service جدید مثل `build_ai_farm_snapshot()` بساز. +3. default block policy را به `1 main block + 1 default sub-block` ارتقا بده. +4. همه APIهای تحلیلی را وادار کن فقط از snapshot جدید بخوانند. +5. برای APIهای spatial مثل heatmap، علاوه بر average summary، خروجی cluster/grid breakdown نگه دار. + diff --git a/entrypoint.sh b/entrypoint.sh index 64db956..397c357 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -88,7 +88,8 @@ if [ "${SKIP_MIGRATE}" != "1" ]; then fi if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then - echo "DEVELOP is set. Seeding demo plant, weather_data, and farm_data..." + echo "DEVELOP is set. Seeding demo location_data, plant, weather_data, and farm_data..." + run_cmd python manage.py seed_location_data run_cmd python manage.py seed_plants run_cmd python manage.py seed_weather_data run_cmd python manage.py seed_farm_data @@ -96,7 +97,9 @@ if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then fi echo "Checking openEO authentication..." -run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured +if ! run_cmd python manage.py verify_openeo_auth --skip-if-unconfigured; then + echo "openEO authentication failed; continuing startup with degraded openEO-dependent features." >&2 +fi echo "Collecting static files..." run_cmd python manage.py collectstatic --noinput diff --git a/farm_alerts/alerts_tracker.py b/farm_alerts/alerts_tracker.py index fb971a2..6a8d28a 100644 --- a/farm_alerts/alerts_tracker.py +++ b/farm_alerts/alerts_tracker.py @@ -521,13 +521,95 @@ def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]: return stats +def _snapshot_metric(ai_snapshot: dict[str, Any] | None, metric_name: str) -> float | None: + if not isinstance(ai_snapshot, dict): + return None + farm_metrics = ai_snapshot.get("farm_metrics") or {} + resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {} + if not isinstance(resolved_metrics, dict): + return None + return safe_number(resolved_metrics.get(metric_name), None) + + + +def _snapshot_weather(ai_snapshot: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(ai_snapshot, dict): + return {} + weather = ai_snapshot.get("weather") or {} + forecast = weather.get("forecast") if isinstance(weather, dict) else None + return forecast if isinstance(forecast, dict) else {} + + + +def _block_metric_alerts(ai_snapshot: dict[str, Any] | None, sensor_id: str) -> list[dict[str, Any]]: + alerts: list[dict[str, Any]] = [] + if not isinstance(ai_snapshot, dict): + return alerts + for block in ai_snapshot.get("block_metrics") or []: + if not isinstance(block, dict): + continue + block_code = str(block.get("block_code") or "default-block") + metrics = block.get("resolved_metrics") or {} + moisture = safe_number(metrics.get("soil_moisture"), None) + if moisture is not None and moisture < 25: + alerts.append( + _make_alert( + metric_type="moisture", + current_value=moisture, + threshold_value=25.0, + severity="warning" if moisture >= 18 else "danger", + duration_hours=1.0, + timestamp=_now(), + sensor_id=sensor_id, + zone_id=block_code, + direction="below", + metadata={ + "evaluation_level": "block", + "affected_blocks": [block_code], + "source": "block_metrics", + }, + ) + ) + return alerts + + + +def _sub_block_support(ai_snapshot: dict[str, Any] | None, metric_type: str) -> list[dict[str, Any]]: + evidence: list[dict[str, Any]] = [] + if not isinstance(ai_snapshot, dict): + return evidence + metric_key = { + "moisture": "soil_moisture", + "ph": "soil_ph", + "ec": "electrical_conductivity", + }.get(metric_type) + if not metric_key: + return evidence + for sub_block in ai_snapshot.get("sub_block_metrics") or []: + if not isinstance(sub_block, dict): + continue + metrics = sub_block.get("resolved_metrics") or {} + value = metrics.get(metric_key) + if value is None: + continue + evidence.append( + { + "block_code": sub_block.get("block_code") or "default-block", + "sub_block_code": sub_block.get("sub_block_code") or "default-sub-block", + "metric": metric_key, + "value": round(float(value), 2), + } + ) + return evidence + + + def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: context = context or {} - sensor = context.get("sensor") + ai_snapshot = (ai_bundle or {}).get("ai_snapshot") if isinstance(ai_bundle, dict) else None forecasts = context.get("forecasts", []) - history = context.get("history", []) - if sensor is None: + if not isinstance(ai_snapshot, dict): return { "totalAlerts": 0, "alerts": [], @@ -537,14 +619,116 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu "prioritizedAlertSummaries": [], "recommendedOperationalActions": [], "humanReadableExplanations": [], + "source_metadata": {"status": "missing", "fallback": "no_ai_snapshot"}, } alerts = [] - alerts.extend(_detect_moisture_alert(sensor, history, sensor_id)) - alerts.extend(_detect_ph_alert(sensor, history, sensor_id)) - alerts.extend(_detect_ec_alert(sensor, history, sensor_id)) - alerts.extend(_detect_frost_alert(forecasts, sensor_id)) - alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id)) + + moisture = _snapshot_metric(ai_snapshot, "soil_moisture") + if moisture is not None and moisture < 25: + alerts.append( + _make_alert( + metric_type="moisture", + current_value=moisture, + threshold_value=25.0, + severity="warning" if moisture >= 18 else "danger", + duration_hours=1.0, + timestamp=_now(), + sensor_id=sensor_id, + direction="below", + metadata={ + "evaluation_level": "farm", + "affected_blocks": [item.get("block_code") for item in (ai_snapshot.get("block_metrics") or [])], + "supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"), + "source": "farm_metrics", + }, + ) + ) + + soil_ph = _snapshot_metric(ai_snapshot, "soil_ph") + if soil_ph is not None and not (6.0 <= soil_ph <= 7.8): + alerts.append( + _make_alert( + metric_type="ph", + current_value=soil_ph, + threshold_value="6.0-7.8", + severity="warning" if 5.5 <= soil_ph <= 8.2 else "danger", + duration_hours=1.0, + timestamp=_now(), + sensor_id=sensor_id, + direction="below" if soil_ph < 6.0 else "above", + metadata={ + "evaluation_level": "farm", + "supporting_sub_blocks": _sub_block_support(ai_snapshot, "ph"), + "source": "farm_metrics", + }, + ) + ) + + ec = _snapshot_metric(ai_snapshot, "electrical_conductivity") + if ec is not None and ec > 2.5: + alerts.append( + _make_alert( + metric_type="ec", + current_value=ec, + threshold_value=2.5, + severity="warning" if ec <= 3.2 else "danger", + duration_hours=1.0, + timestamp=_now(), + sensor_id=sensor_id, + direction="above", + metadata={ + "evaluation_level": "farm", + "supporting_sub_blocks": _sub_block_support(ai_snapshot, "ec"), + "source": "farm_metrics", + }, + ) + ) + + weather = _snapshot_weather(ai_snapshot) + if weather: + temp_min = safe_number(weather.get("temperature_min"), None) + if temp_min is not None and temp_min < 0: + alerts.append( + _make_alert( + metric_type="temperature", + current_value=temp_min, + threshold_value=0.0, + severity="warning" if temp_min >= -2 else "danger", + duration_hours=24.0, + timestamp=_now(), + sensor_id=sensor_id, + direction="below", + metadata={ + "evaluation_level": "farm", + "source": "weather_forecast", + "weather_policy": "center_location_latest_forecast", + }, + ) + ) + + humidity = safe_number(weather.get("humidity_mean"), None) + if humidity is not None and moisture is not None and humidity > 75 and moisture > 60: + alerts.append( + _make_alert( + metric_type="fungal_risk", + current_value=humidity, + threshold_value=75.0, + severity="warning" if humidity <= 85 else "danger", + duration_hours=24.0, + timestamp=_now(), + sensor_id=sensor_id, + direction="above", + metadata={ + "evaluation_level": "farm", + "supporting_sub_blocks": _sub_block_support(ai_snapshot, "moisture"), + "source": "farm_metrics+weather_forecast", + "soil_moisture": round(moisture, 2), + }, + ) + ) + + alerts.extend(_block_metric_alerts(ai_snapshot, sensor_id)) ordered_alerts = _sort_alerts(alerts) clusters = _build_clusters(ordered_alerts) @@ -559,4 +743,9 @@ def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bu "prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts], "recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts], "humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts], + "source_metadata": { + "farm_metrics": (ai_snapshot.get("source_metadata") or {}).get("farm_metrics", {}), + "weather": ((ai_snapshot.get("weather") or {}).get("source_metadata") or {}), + "default_block_policy": (ai_snapshot.get("aggregation_policy") or {}).get("default_block_policy"), + }, } diff --git a/farm_alerts/services.py b/farm_alerts/services.py index c63c01d..20253ca 100644 --- a/farm_alerts/services.py +++ b/farm_alerts/services.py @@ -8,8 +8,8 @@ from typing import Any from django.apps import apps from django.core.serializers.json import DjangoJSONEncoder -from farm_data.services import get_farm_details from farm_data.context import load_farm_context +from farm_data.services import build_ai_farm_snapshot from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -156,12 +156,18 @@ def _build_structured_context( if context is None: raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.") - tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None) + ai_snapshot = build_ai_farm_snapshot(farm_uuid) + tracker = build_farm_alerts_tracker( + sensor_id=farm_uuid, + context=context, + ai_bundle={"ai_snapshot": ai_snapshot}, + ) structured = { "farm_profile": _farm_profile(context, farm_uuid), "tracker": tracker, "forecasts": _forecast_summary(context), "incoming_alerts": _normalize_incoming_alerts(incoming_alerts), + "ai_snapshot_source_metadata": (ai_snapshot or {}).get("source_metadata", {}), } return context, structured @@ -321,7 +327,7 @@ def _llm_response( ) -> tuple[dict[str, Any], str, str]: cfg = load_rag_config() service, service_cfg, model, client = _build_service_config(cfg, service_id) - farm_details = get_farm_details(farm_uuid) + farm_details = build_ai_farm_snapshot(farm_uuid) rag_context = build_rag_context( query=query, sensor_uuid=farm_uuid, diff --git a/farm_alerts/tests.py b/farm_alerts/tests.py new file mode 100644 index 0000000..e43dbf5 --- /dev/null +++ b/farm_alerts/tests.py @@ -0,0 +1,83 @@ +from django.test import SimpleTestCase + +from farm_alerts.alerts_tracker import build_farm_alerts_tracker + + +class FarmAlertsTrackerCanonicalTests(SimpleTestCase): + def test_whole_farm_alert_uses_aggregated_metrics(self): + ai_snapshot = { + "farm_metrics": {"resolved_metrics": {"soil_moisture": 16.0}}, + "block_metrics": [{"block_code": "block-1", "resolved_metrics": {"soil_moisture": 14.0}}], + "sub_block_metrics": [ + {"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 12.0}} + ], + "weather": {"forecast": {"temperature_min": 8.0, "humidity_mean": 60.0}, "source_metadata": {}}, + "source_metadata": {"farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}}, + "aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"}, + } + + tracker = build_farm_alerts_tracker( + sensor_id="farm-1", + context={"forecasts": []}, + ai_bundle={"ai_snapshot": ai_snapshot}, + ) + + self.assertEqual(tracker["totalAlerts"], 2) + self.assertEqual(tracker["alerts"][0]["metadata"]["evaluation_level"], "farm") + self.assertEqual(tracker["alerts"][0]["metadata"]["source"], "farm_metrics") + self.assertEqual(tracker["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot") + + def test_block_specific_alert_includes_affected_block(self): + ai_snapshot = { + "farm_metrics": {"resolved_metrics": {"soil_moisture": 30.0}}, + "block_metrics": [ + {"block_code": "block-1", "resolved_metrics": {"soil_moisture": 31.0}}, + {"block_code": "block-2", "resolved_metrics": {"soil_moisture": 17.0}}, + ], + "sub_block_metrics": [ + {"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 15.0}} + ], + "weather": {"forecast": {}, "source_metadata": {}}, + "source_metadata": {"farm_metrics": {}}, + "aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"}, + } + + tracker = build_farm_alerts_tracker( + sensor_id="farm-1", + context={"forecasts": []}, + ai_bundle={"ai_snapshot": ai_snapshot}, + ) + + block_alerts = [alert for alert in tracker["alerts"] if alert.get("zone_id") == "block-2"] + self.assertEqual(len(block_alerts), 1) + self.assertEqual(block_alerts[0]["metadata"]["evaluation_level"], "block") + self.assertEqual(block_alerts[0]["metadata"]["affected_blocks"], ["block-2"]) + + def test_default_block_sub_block_policy_is_reported(self): + ai_snapshot = { + "farm_metrics": {"resolved_metrics": {"soil_moisture": 22.0}}, + "block_metrics": [{"block_code": "default-block", "resolved_metrics": {"soil_moisture": 22.0}}], + "sub_block_metrics": [ + {"block_code": "default-block", "sub_block_code": "default-sub-block", "resolved_metrics": {"soil_moisture": 22.0}} + ], + "weather": {"forecast": {}, "source_metadata": {}}, + "source_metadata": {"farm_metrics": {}}, + "aggregation_policy": {"default_block_policy": "1_main_block + 1_default_sub_block_when_missing"}, + } + + tracker = build_farm_alerts_tracker( + sensor_id="farm-1", + context={"forecasts": []}, + ai_bundle={"ai_snapshot": ai_snapshot}, + ) + + self.assertEqual( + tracker["source_metadata"]["default_block_policy"], + "1_main_block + 1_default_sub_block_when_missing", + ) + + def test_missing_snapshot_uses_explicit_fallback_metadata(self): + tracker = build_farm_alerts_tracker(sensor_id="farm-1", context={"forecasts": []}, ai_bundle={}) + + self.assertEqual(tracker["totalAlerts"], 0) + self.assertEqual(tracker["source_metadata"]["fallback"], "no_ai_snapshot") diff --git a/farm_data/migrations/0013_device.py b/farm_data/migrations/0013_device.py new file mode 100644 index 0000000..aa11442 --- /dev/null +++ b/farm_data/migrations/0013_device.py @@ -0,0 +1,75 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_devices_from_sensor_payload(apps, schema_editor): + SensorData = apps.get_model("sensor_data", "SensorData") + Device = apps.get_model("sensor_data", "Device") + + for farm in SensorData.objects.all().iterator(): + sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {} + location_id = getattr(farm, "center_location_id", None) + if location_id is None: + continue + + for sensor_name, payload in sensor_payload.items(): + if not isinstance(payload, dict): + continue + + cluster_uuid = None + raw_cluster_uuid = payload.get("cluster_uuid") + if raw_cluster_uuid not in (None, ""): + try: + cluster_uuid = uuid.UUID(str(raw_cluster_uuid)) + except (TypeError, ValueError, AttributeError): + cluster_uuid = None + + Device.objects.update_or_create( + farm_id=farm.pk, + sensor_name=sensor_name, + defaults={ + "location_id": location_id, + "payload": payload, + "cluster_uuid": cluster_uuid, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0019_cluster_block_centers"), + ("sensor_data", "0012_plant_catalog_snapshot_and_assignment"), + ] + + operations = [ + migrations.CreateModel( + name="Device", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sensor_name", models.CharField(db_index=True, help_text='نام سنسور مثل "sensor-7-1"', max_length=64)), + ("payload", models.JSONField(blank=True, default=dict, help_text="payload همان سنسور")), + ("cluster_uuid", models.UUIDField(blank=True, db_index=True, help_text="uuid کلاستر داخل location برای این سنسور", null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="sensor_data.sensordata")), + ("location", models.ForeignKey(db_column="location_id", help_text="location مرتبط با این device", on_delete=django.db.models.deletion.CASCADE, related_name="devices", to="location_data.soillocation")), + ], + options={ + "verbose_name": "device", + "verbose_name_plural": "devices", + "db_table": "farm_data_device", + "ordering": ["sensor_name", "id"], + }, + ), + migrations.AddConstraint( + model_name="device", + constraint=models.UniqueConstraint(fields=("farm", "sensor_name"), name="farm_data_unique_device_per_farm_sensor"), + ), + migrations.RunPython( + backfill_devices_from_sensor_payload, + migrations.RunPython.noop, + ), + ] diff --git a/farm_data/models.py b/farm_data/models.py index d41ab65..1811c87 100644 --- a/farm_data/models.py +++ b/farm_data/models.py @@ -169,6 +169,57 @@ class SensorData(SensorPayloadMixin, models.Model): return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")] +class Device(models.Model): + """نسخه نرمال‌شده هر سنسور داخل farm_data_sensordata.""" + + farm = models.ForeignKey( + SensorData, + on_delete=models.CASCADE, + related_name="devices", + db_column="farm_uuid", + ) + location = models.ForeignKey( + "location_data.SoilLocation", + on_delete=models.CASCADE, + related_name="devices", + db_column="location_id", + help_text="location مرتبط با این device", + ) + sensor_name = models.CharField( + max_length=64, + db_index=True, + help_text='نام سنسور مثل "sensor-7-1"', + ) + payload = models.JSONField( + default=dict, + blank=True, + help_text="payload همان سنسور", + ) + cluster_uuid = models.UUIDField( + null=True, + blank=True, + db_index=True, + help_text="uuid کلاستر داخل location برای این سنسور", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_device" + ordering = ["sensor_name", "id"] + constraints = [ + models.UniqueConstraint( + fields=["farm", "sensor_name"], + name="farm_data_unique_device_per_farm_sensor", + ) + ] + verbose_name = "device" + verbose_name_plural = "devices" + + def __str__(self): + return f"{self.farm_id}::{self.sensor_name}" + + class PlantCatalogSnapshot(models.Model): """ کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژول‌های AI. diff --git a/farm_data/services.py b/farm_data/services.py index 98f5833..23c0323 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -3,6 +3,7 @@ from __future__ import annotations from decimal import Decimal, ROUND_HALF_UP from numbers import Number import logging +import uuid import warnings from django.conf import settings @@ -17,13 +18,14 @@ from location_data.block_subdivision import create_or_get_block_subdivision from location_data.models import BlockSubdivision, SoilLocation from location_data.satellite_snapshot import ( build_block_layout_metric_summary, + build_farmer_block_aggregated_snapshot, build_location_block_satellite_snapshots, - build_location_satellite_snapshot, ) from irrigation.serializers import IrrigationMethodSerializer from weather.models import WeatherForecast from .models import ( + Device, FarmPlantAssignment, ParameterUpdateLog, PlantCatalogSnapshot, @@ -431,6 +433,45 @@ def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[Sen return synced_parameters +def _parse_cluster_uuid(value: object) -> uuid.UUID | None: + if value in (None, ""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError, AttributeError): + return None + + +def sync_devices_from_sensor_data(farm: SensorData) -> list[Device]: + sensor_payload = farm.sensor_payload if isinstance(farm.sensor_payload, dict) else {} + location = farm.center_location + synced_devices: list[Device] = [] + + with transaction.atomic(): + active_sensor_names: list[str] = [] + for sensor_name, payload in sensor_payload.items(): + if not isinstance(payload, dict): + continue + active_sensor_names.append(sensor_name) + device, _created = Device.objects.update_or_create( + farm=farm, + sensor_name=sensor_name, + defaults={ + "location": location, + "payload": payload, + "cluster_uuid": _parse_cluster_uuid(payload.get("cluster_uuid")), + }, + ) + synced_devices.append(device) + + stale_devices = Device.objects.filter(farm=farm) + if active_sensor_names: + stale_devices = stale_devices.exclude(sensor_name__in=active_sensor_names) + stale_devices.delete() + + return synced_devices + + def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]: parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code") if sensor_payload and isinstance(sensor_payload, dict): @@ -464,24 +505,10 @@ def get_farm_details(farm_uuid: str): center_location.weather_forecasts.order_by("-forecast_date", "-id").first() ) - latest_satellite = build_location_satellite_snapshot(center_location) - block_metric_snapshots = build_location_block_satellite_snapshots( + soil_snapshot = _build_farm_soil_snapshot( center_location, sensor_payload=farm.sensor_payload, ) - if all( - snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics") - for snapshot in block_metric_snapshots - ): - block_metric_snapshots = [] - soil_metrics = dict(latest_satellite.get("resolved_metrics") or {}) - sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload) - - resolved_metrics = dict(soil_metrics) - metric_sources = {key: "remote_sensing" for key in soil_metrics} - for key, value in sensor_metrics.items(): - resolved_metrics[key] = value - metric_sources[key] = sensor_metric_sources[key] plant_assignments = get_farm_plant_assignments(farm) plant_snapshots = [assignment.plant for assignment in plant_assignments] @@ -501,11 +528,7 @@ def get_farm_details(farm_uuid: str): "weather": WeatherForecastDetailSerializer(weather).data if weather else None, "sensor_payload": farm.sensor_payload or {}, "sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload), - "soil": { - "resolved_metrics": resolved_metrics, - "metric_sources": metric_sources, - "satellite_snapshots": block_metric_snapshots, - }, + "soil": soil_snapshot, "plant_ids": [plant.backend_plant_id for plant in plant_snapshots], "plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data, "plant_assignments": [ @@ -528,9 +551,343 @@ def get_farm_details(farm_uuid: str): ), "created_at": farm.created_at, "updated_at": farm.updated_at, + "source_metadata": { + "soil": soil_snapshot.get("source_metadata", {}), + "weather": { + "source": "center_location_forecast", + "scope": "location_center_based", + "location_id": center_location.id, + "note": "Weather remains tied to the farm center location.", + }, + }, } +def _build_farm_soil_snapshot( + center_location: SoilLocation, + *, + sensor_payload: dict | None, +) -> dict[str, object]: + # Canonical farm soil metrics now come from farmer-level block aggregation. + aggregated_snapshot = build_farmer_block_aggregated_snapshot( + center_location, + sensor_payload=sensor_payload, + ) + block_snapshots = build_location_block_satellite_snapshots( + center_location, + sensor_payload=sensor_payload, + ) + if all( + snapshot.get("status") == "missing" and not snapshot.get("resolved_metrics") + for snapshot in block_snapshots + ): + block_snapshots = [] + + has_explicit_blocks = bool((center_location.block_layout or {}).get("blocks")) + resolved_metrics = dict(aggregated_snapshot.get("resolved_metrics") or {}) + metric_sources = dict(aggregated_snapshot.get("metric_sources") or {}) + compatibility_sensor_overlay_applied = False + + if not has_explicit_blocks: + compatibility_sensor_overlay_applied = _merge_legacy_sensor_metrics_if_missing( + resolved_metrics, + metric_sources, + sensor_payload, + ) + + cluster_breakdown = _build_cluster_breakdown(block_snapshots) + return { + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, + "block_snapshots": block_snapshots, + "satellite_snapshots": block_snapshots, + "cluster_breakdown": cluster_breakdown, + "source_metadata": { + "canonical_source": "farmer_block_aggregated_snapshot", + "aggregation_strategy": aggregated_snapshot.get("aggregation_strategy") or "missing", + "status": aggregated_snapshot.get("status") or "missing", + "block_count": int(aggregated_snapshot.get("block_count") or len(block_snapshots)), + "has_explicit_blocks": has_explicit_blocks, + "compatibility_sensor_overlay_applied": compatibility_sensor_overlay_applied, + "policy": { + "sensor": "cluster_mean -> block_mean -> farm_mean", + "satellite": "cluster_mean -> block_mean -> farm_mean", + "weather": "location_center_based", + }, + }, + } + + +def _merge_legacy_sensor_metrics_if_missing( + resolved_metrics: dict, + metric_sources: dict, + sensor_payload: dict | None, +) -> bool: + sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(sensor_payload) + applied = False + for metric_name, metric_value in sensor_metrics.items(): + if metric_name in resolved_metrics: + continue + resolved_metrics[metric_name] = metric_value + metric_sources[metric_name] = sensor_metric_sources[metric_name] + applied = True + return applied + + +def _build_cluster_breakdown(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]: + cluster_breakdown: list[dict[str, object]] = [] + for snapshot in block_snapshots: + block_code = str(snapshot.get("block_code") or "").strip() + for sub_block in snapshot.get("satellite_sub_blocks") or []: + cluster_breakdown.append( + { + "block_code": block_code, + "source": "satellite", + **dict(sub_block), + } + ) + for sub_block in snapshot.get("sensor_sub_blocks") or []: + cluster_breakdown.append( + { + "block_code": block_code, + "source": "sensor", + **dict(sub_block), + } + ) + return cluster_breakdown + + +AI_FARM_AGGREGATION_POLICY = { + "sensor": "cluster_mean_then_block_mean_then_farm_mean", + "satellite": "cluster_mean_then_block_mean_then_farm_mean", + "weather": "center_location_latest_forecast", + "default_block_policy": "1_main_block + 1_default_sub_block_when_missing", +} + + +def build_ai_farm_snapshot(farm_uuid: str) -> dict[str, object] | None: + farm = get_canonical_farm_record(farm_uuid) + if farm is None: + return None + + sync_sensor_parameters_from_payload(farm.sensor_payload) + + center_location = farm.center_location + weather = farm.weather_forecast + if weather is None: + weather = center_location.weather_forecasts.order_by("-forecast_date", "-id").first() + + soil_snapshot = _build_farm_soil_snapshot( + center_location, + sensor_payload=farm.sensor_payload, + ) + block_metrics = _build_ai_block_metrics(soil_snapshot.get("block_snapshots") or []) + sub_block_metrics = _build_ai_sub_block_metrics(soil_snapshot.get("block_snapshots") or []) + plant_assignments = get_farm_plant_assignments(farm) + + return { + "farm_uuid": str(farm.farm_uuid), + "aggregation_policy": dict(AI_FARM_AGGREGATION_POLICY), + "farm_metrics": { + "resolved_metrics": dict(soil_snapshot.get("resolved_metrics") or {}), + "metric_sources": dict(soil_snapshot.get("metric_sources") or {}), + "status": (soil_snapshot.get("source_metadata") or {}).get("status", "missing"), + "aggregation_strategy": (soil_snapshot.get("source_metadata") or {}).get( + "aggregation_strategy", "missing" + ), + }, + "block_metrics": block_metrics, + "sub_block_metrics": sub_block_metrics, + "weather": { + "forecast": WeatherForecastDetailSerializer(weather).data if weather else None, + "source_metadata": { + "source": "center_location_forecast", + "scope": "location_center_based", + "location_id": center_location.id, + "status": "completed" if weather else "missing", + }, + }, + "plants": [ + { + "plant_id": assignment.plant.backend_plant_id, + "position": assignment.position, + "stage": assignment.stage, + "metadata": assignment.metadata, + "assigned_at": assignment.assigned_at, + "updated_at": assignment.updated_at, + "plant": PlantCatalogSnapshotSerializer(assignment.plant).data, + } + for assignment in plant_assignments + ], + "irrigation_method": { + "id": farm.irrigation_method_id, + "details": ( + IrrigationMethodSerializer(farm.irrigation_method).data + if farm.irrigation_method + else None + ), + "source_metadata": { + "source": "farm_record", + "status": "completed" if farm.irrigation_method_id else "missing", + }, + }, + "source_metadata": { + "farm": { + "farm_uuid": str(farm.farm_uuid), + "center_location_id": center_location.id, + "has_explicit_blocks": bool((center_location.block_layout or {}).get("blocks")), + }, + "farm_metrics": dict(soil_snapshot.get("source_metadata") or {}), + "block_metrics": { + "source": "build_location_block_satellite_snapshots", + "block_count": len(block_metrics), + "status": "completed" if block_metrics else "missing", + }, + "sub_block_metrics": { + "source": "block_snapshot_sub_blocks", + "sub_block_count": len(sub_block_metrics), + "status": "completed" if sub_block_metrics else "missing", + }, + "weather": { + "source": "center_location_forecast", + "scope": "location_center_based", + "location_id": center_location.id, + "note": "Weather remains tied to the farm center location.", + }, + "plants": { + "source": "farm_plant_assignments", + "count": len(plant_assignments), + }, + "irrigation_method": { + "source": "farm_record", + "status": "completed" if farm.irrigation_method_id else "missing", + }, + }, + } + + +def get_ai_farm_snapshot_or_details(farm_uuid: str) -> dict[str, object] | None: + """Return the canonical AI snapshot, or fall back to farm details for older consumers.""" + snapshot = build_ai_farm_snapshot(farm_uuid) + if snapshot is None: + return None + return snapshot + + +def get_ai_snapshot_metric(snapshot: dict[str, object] | None, metric_name: str) -> object | None: + if not isinstance(snapshot, dict): + return None + farm_metrics = snapshot.get("farm_metrics") or {} + resolved_metrics = farm_metrics.get("resolved_metrics") if isinstance(farm_metrics, dict) else {} + if isinstance(resolved_metrics, dict): + return resolved_metrics.get(metric_name) + return None + + +def get_ai_snapshot_weather(snapshot: dict[str, object] | None) -> dict[str, object]: + if not isinstance(snapshot, dict): + return {} + weather_section = snapshot.get("weather") or {} + forecast = weather_section.get("forecast") if isinstance(weather_section, dict) else None + return forecast if isinstance(forecast, dict) else {} + + +def _build_ai_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]: + block_metrics: list[dict[str, object]] = [] + for snapshot in block_snapshots: + block_code = str(snapshot.get("block_code") or "").strip() or "default-block" + block_metrics.append( + { + "block_code": block_code, + "resolved_metrics": dict(snapshot.get("resolved_metrics") or {}), + "metric_sources": dict(snapshot.get("metric_sources") or {}), + "satellite_metrics": dict(snapshot.get("satellite_metrics") or {}), + "sensor_metrics": dict(snapshot.get("sensor_metrics") or {}), + "status": snapshot.get("status") or "missing", + "aggregation_strategy": snapshot.get("aggregation_strategy") or "missing", + "sub_block_count": int(snapshot.get("sub_block_count") or 0), + "temporal_extent": snapshot.get("temporal_extent"), + "source_metadata": { + "source": "build_location_block_satellite_snapshots", + "block_code": block_code, + "run_id": snapshot.get("run_id"), + "cell_count": snapshot.get("cell_count"), + }, + } + ) + return block_metrics + + +def _build_ai_sub_block_metrics(block_snapshots: list[dict[str, object]]) -> list[dict[str, object]]: + sub_block_metrics: list[dict[str, object]] = [] + for snapshot in block_snapshots: + block_code = str(snapshot.get("block_code") or "").strip() or "default-block" + satellite_sub_blocks = snapshot.get("satellite_sub_blocks") or [] + sensor_sub_blocks = snapshot.get("sensor_sub_blocks") or [] + if not satellite_sub_blocks and not sensor_sub_blocks: + sub_block_metrics.append( + { + "block_code": block_code, + "sub_block_code": "default-sub-block", + "resolved_metrics": dict(snapshot.get("resolved_metrics") or {}), + "satellite_metrics": dict(snapshot.get("satellite_metrics") or {}), + "sensor_metrics": dict(snapshot.get("sensor_metrics") or {}), + "status": snapshot.get("status") or "missing", + "source_metadata": { + "source": "default_sub_block_compatibility", + "scope": "future_default_policy", + }, + } + ) + continue + + sub_blocks_by_code: dict[str, dict[str, object]] = {} + for sub_block in satellite_sub_blocks: + sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block" + entry = sub_blocks_by_code.setdefault( + sub_block_code, + { + "block_code": block_code, + "sub_block_code": sub_block_code, + "resolved_metrics": {}, + "satellite_metrics": {}, + "sensor_metrics": {}, + "status": snapshot.get("status") or "missing", + "source_metadata": { + "source": "block_snapshot_sub_blocks", + "satellite_present": False, + "sensor_present": False, + }, + }, + ) + entry["satellite_metrics"] = dict(sub_block.get("resolved_metrics") or {}) + entry["resolved_metrics"].update(entry["satellite_metrics"]) + entry["source_metadata"]["satellite_present"] = True + for sub_block in sensor_sub_blocks: + sub_block_code = str(sub_block.get("sub_block_code") or sub_block.get("cluster_code") or "").strip() or "default-sub-block" + entry = sub_blocks_by_code.setdefault( + sub_block_code, + { + "block_code": block_code, + "sub_block_code": sub_block_code, + "resolved_metrics": {}, + "satellite_metrics": {}, + "sensor_metrics": {}, + "status": snapshot.get("status") or "missing", + "source_metadata": { + "source": "block_snapshot_sub_blocks", + "satellite_present": False, + "sensor_present": False, + }, + }, + ) + entry["sensor_metrics"] = dict(sub_block.get("resolved_metrics") or {}) + entry["resolved_metrics"].update(entry["sensor_metrics"]) + entry["source_metadata"]["sensor_present"] = True + sub_block_metrics.extend(sub_blocks_by_code.values()) + return sub_block_metrics + + def resolve_center_location_from_boundary( farm_boundary: dict | list, block_count: int = 1, diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index 9dff7f4..6f4c09a 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -15,9 +15,10 @@ from location_data.models import ( RemoteSensingSubdivisionResult, SoilLocation, ) -from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter +from farm_data.models import Device, PlantCatalogSnapshot, SensorData, SensorParameter from farm_data.services import ( assign_farm_plants_from_backend_ids, + build_ai_farm_snapshot, get_canonical_farm_record, get_runtime_plant_for_farm, list_runtime_plants_for_farm, @@ -356,6 +357,309 @@ class FarmDetailApiTests(TestCase): 0.6, ) + def test_detail_uses_farmer_aggregated_snapshot_as_canonical_soil_source(self): + self.location.block_layout = { + "blocks": [ + {"block_code": "block-a", "order": 1}, + {"block_code": "block-b", "order": 2}, + ] + } + self.location.save(update_fields=["block_layout", "updated_at"]) + + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 2, + "resolved_metrics": {"nitrogen": 42.0, "ndvi": 0.61}, + "metric_sources": { + "nitrogen": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2}, + "ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 2}, + }, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "block-a", + "resolved_metrics": {"nitrogen": 20.0, "ndvi": 0.5}, + "metric_sources": {}, + "satellite_sub_blocks": [{"sub_block_code": "cluster-a"}], + "sensor_sub_blocks": [], + }, + { + "status": "completed", + "block_code": "block-b", + "resolved_metrics": {"nitrogen": 64.0, "ndvi": 0.72}, + "metric_sources": {}, + "satellite_sub_blocks": [{"sub_block_code": "cluster-b"}], + "sensor_sub_blocks": [], + }, + ] + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 42.0) + self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.61) + self.assertEqual(payload["soil"]["source_metadata"]["canonical_source"], "farmer_block_aggregated_snapshot") + self.assertEqual(payload["soil"]["source_metadata"]["policy"]["sensor"], "cluster_mean -> block_mean -> farm_mean") + self.assertEqual(payload["source_metadata"]["weather"]["scope"], "location_center_based") + self.assertEqual(len(payload["soil"]["block_snapshots"]), 2) + self.assertEqual(len(payload["soil"]["cluster_breakdown"]), 2) + aggregated_mock.assert_called_once() + block_mock.assert_called_once() + + def test_detail_without_explicit_blocks_keeps_aggregated_snapshot_and_marks_compatibility_policy(self): + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 1, + "resolved_metrics": {"ndvi": 0.55}, + "metric_sources": { + "ndvi": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1}, + }, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "", + "resolved_metrics": {"ndvi": 0.55}, + "metric_sources": {}, + "satellite_sub_blocks": [], + "sensor_sub_blocks": [], + } + ] + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["soil"]["resolved_metrics"]["ndvi"], 0.55) + self.assertEqual(payload["soil"]["resolved_metrics"]["nitrogen"], 99.0) + self.assertFalse(payload["soil"]["source_metadata"]["has_explicit_blocks"]) + self.assertTrue(payload["soil"]["source_metadata"]["compatibility_sensor_overlay_applied"]) + self.assertEqual(payload["soil"]["metric_sources"]["nitrogen"]["type"], "sensor") + + def test_detail_canonical_soil_metrics_do_not_come_from_single_raw_location_snapshot(self): + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 1, + "resolved_metrics": {"soil_moisture": 12.0}, + "metric_sources": { + "soil_moisture": {"type": "farmer_block", "strategy": "average_of_main_blocks", "block_count": 1}, + }, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "block-1", + "resolved_metrics": {"soil_moisture": 77.0}, + "metric_sources": {}, + "satellite_sub_blocks": [], + "sensor_sub_blocks": [], + } + ] + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["soil"]["resolved_metrics"]["soil_moisture"], 12.0) + self.assertNotEqual( + payload["soil"]["resolved_metrics"]["soil_moisture"], + payload["soil"]["block_snapshots"][0]["resolved_metrics"]["soil_moisture"], + ) + + +class BuildAiFarmSnapshotTests(TestCase): + def setUp(self): + self.location = SoilLocation.objects.create( + latitude="35.700000", + longitude="51.400000", + farm_boundary={"type": "Polygon", "coordinates": []}, + ) + self.weather = WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 10), + temperature_min=12.0, + temperature_max=23.0, + temperature_mean=18.0, + precipitation=1.2, + humidity_mean=52.0, + ) + self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=201, name="ذرت") + self.irrigation_method = IrrigationMethod.objects.create(name="تیپ") + self.farm_uuid = uuid.uuid4() + self.farm = SensorData.objects.create( + farm_uuid=self.farm_uuid, + center_location=self.location, + weather_forecast=self.weather, + irrigation_method=self.irrigation_method, + sensor_payload={"sensor-1": {"soil_moisture": 30.0}}, + ) + assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id]) + + def test_build_ai_farm_snapshot_returns_normalized_block_and_sub_block_metrics(self): + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 1, + "resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0}, + "metric_sources": {"ndvi": {"type": "farmer_block"}, "soil_moisture": {"type": "farmer_block"}}, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "block-1", + "aggregation_strategy": "sub_block_mean", + "resolved_metrics": {"ndvi": 0.6, "soil_moisture": 24.0}, + "metric_sources": {"ndvi": {"type": "satellite"}, "soil_moisture": {"type": "sensor"}}, + "satellite_metrics": {"ndvi": 0.6}, + "sensor_metrics": {"soil_moisture": 24.0}, + "sub_block_count": 2, + "run_id": 91, + "cell_count": 8, + "temporal_extent": {"start_date": "2026-04-01", "end_date": "2026-04-30"}, + "satellite_sub_blocks": [ + {"sub_block_code": "cluster-a", "resolved_metrics": {"ndvi": 0.5}}, + {"sub_block_code": "cluster-b", "resolved_metrics": {"ndvi": 0.7}}, + ], + "sensor_sub_blocks": [ + {"sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 20.0}}, + {"sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 28.0}}, + ], + } + ] + + snapshot = build_ai_farm_snapshot(str(self.farm_uuid)) + + self.assertIsNotNone(snapshot) + self.assertEqual(snapshot["farm_uuid"], str(self.farm_uuid)) + self.assertEqual(snapshot["aggregation_policy"]["sensor"], "cluster_mean_then_block_mean_then_farm_mean") + self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 24.0) + self.assertEqual(snapshot["block_metrics"][0]["block_code"], "block-1") + self.assertEqual(snapshot["block_metrics"][0]["source_metadata"]["run_id"], 91) + self.assertEqual(len(snapshot["sub_block_metrics"]), 2) + self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["satellite_present"], True) + self.assertEqual(snapshot["sub_block_metrics"][0]["source_metadata"]["sensor_present"], True) + self.assertEqual(snapshot["weather"]["source_metadata"]["scope"], "location_center_based") + self.assertEqual(len(snapshot["plants"]), 1) + self.assertEqual(snapshot["irrigation_method"]["details"]["name"], self.irrigation_method.name) + + def test_build_ai_farm_snapshot_without_explicit_subdivisions_uses_default_compatibility_shape(self): + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 1, + "resolved_metrics": {"ndvi": 0.55}, + "metric_sources": {"ndvi": {"type": "farmer_block"}}, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "", + "resolved_metrics": {"ndvi": 0.55}, + "metric_sources": {"ndvi": {"type": "satellite"}}, + "satellite_metrics": {"ndvi": 0.55}, + "sensor_metrics": {}, + "satellite_sub_blocks": [], + "sensor_sub_blocks": [], + } + ] + + snapshot = build_ai_farm_snapshot(str(self.farm_uuid)) + + self.assertEqual(snapshot["block_metrics"][0]["block_code"], "default-block") + self.assertEqual(snapshot["sub_block_metrics"][0]["sub_block_code"], "default-sub-block") + self.assertEqual( + snapshot["sub_block_metrics"][0]["source_metadata"]["source"], + "default_sub_block_compatibility", + ) + self.assertEqual( + snapshot["aggregation_policy"]["default_block_policy"], + "1_main_block + 1_default_sub_block_when_missing", + ) + + def test_build_ai_farm_snapshot_handles_missing_sensor_data(self): + self.farm.sensor_payload = None + self.farm.save(update_fields=["sensor_payload"]) + + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 1, + "resolved_metrics": {"ndvi": 0.49}, + "metric_sources": {"ndvi": {"type": "farmer_block"}}, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "block-1", + "resolved_metrics": {"ndvi": 0.49}, + "metric_sources": {"ndvi": {"type": "satellite"}}, + "satellite_metrics": {"ndvi": 0.49}, + "sensor_metrics": {}, + "satellite_sub_blocks": [], + "sensor_sub_blocks": [], + } + ] + + snapshot = build_ai_farm_snapshot(str(self.farm_uuid)) + + self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"], {"ndvi": 0.49}) + self.assertEqual(snapshot["block_metrics"][0]["sensor_metrics"], {}) + + def test_build_ai_farm_snapshot_handles_missing_remote_sensing_data(self): + with patch("farm_data.services.build_farmer_block_aggregated_snapshot") as aggregated_mock, patch( + "farm_data.services.build_location_block_satellite_snapshots" + ) as block_mock: + aggregated_mock.return_value = { + "status": "completed", + "aggregation_strategy": "farmer_block_mean", + "block_count": 1, + "resolved_metrics": {"soil_moisture": 30.0}, + "metric_sources": {"soil_moisture": {"type": "farmer_block"}}, + } + block_mock.return_value = [ + { + "status": "completed", + "block_code": "block-1", + "resolved_metrics": {"soil_moisture": 30.0}, + "metric_sources": {"soil_moisture": {"type": "sensor"}}, + "satellite_metrics": {}, + "sensor_metrics": {"soil_moisture": 30.0}, + "satellite_sub_blocks": [], + "sensor_sub_blocks": [ + {"sub_block_code": "cluster-1", "resolved_metrics": {"soil_moisture": 30.0}} + ], + } + ] + + snapshot = build_ai_farm_snapshot(str(self.farm_uuid)) + + self.assertEqual(snapshot["farm_metrics"]["resolved_metrics"]["soil_moisture"], 30.0) + self.assertEqual(snapshot["block_metrics"][0]["satellite_metrics"], {}) + self.assertEqual(snapshot["sub_block_metrics"][0]["sensor_metrics"]["soil_moisture"], 30.0) + class FarmDataUpsertApiTests(TestCase): def setUp(self): @@ -406,6 +710,10 @@ class FarmDataUpsertApiTests(TestCase): farm.sensor_payload["sensor-7-1"]["soil_moisture"], 31.2, ) + device = Device.objects.get(farm=farm, sensor_name="sensor-7-1") + self.assertEqual(device.location_id, self.location.id) + self.assertEqual(device.payload["soil_moisture"], 31.2) + self.assertIsNone(device.cluster_uuid) self.assertTrue( SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists() ) @@ -444,6 +752,57 @@ class FarmDataUpsertApiTests(TestCase): ["disease_pressure_index", "leaf_temperature", "leaf_wetness"], ) + def test_post_syncs_device_rows_from_sensor_payload(self): + farm_uuid = uuid.uuid4() + cluster_uuid = uuid.uuid4() + + create_response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "sensor_payload": { + "sensor-7-1": { + "cluster_uuid": str(cluster_uuid), + "soil_moisture": 31.2, + }, + "leaf-sensor": { + "leaf_wetness": 10.0, + }, + }, + }, + format="json", + ) + + self.assertEqual(create_response.status_code, 201) + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertEqual(Device.objects.filter(farm=farm).count(), 2) + soil_device = Device.objects.get(farm=farm, sensor_name="sensor-7-1") + self.assertEqual(str(soil_device.cluster_uuid), str(cluster_uuid)) + self.assertEqual(soil_device.payload["soil_moisture"], 31.2) + + update_response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "sensor_payload": { + "sensor-7-1": { + "cluster_uuid": str(cluster_uuid), + "soil_moisture": 33.8, + "nitrogen": 20.5, + }, + }, + }, + format="json", + ) + + self.assertEqual(update_response.status_code, 200) + soil_device.refresh_from_db() + self.assertEqual(soil_device.payload["soil_moisture"], 33.8) + self.assertEqual(soil_device.payload["nitrogen"], 20.5) + self.assertEqual(Device.objects.filter(farm=farm, sensor_name="leaf-sensor").count(), 1) + def test_post_requires_farm_uuid_in_request_body(self): response = self.client.post( "/api/farm-data/", diff --git a/farm_data/views.py b/farm_data/views.py index bb7be5f..1580e08 100644 --- a/farm_data/views.py +++ b/farm_data/views.py @@ -28,6 +28,7 @@ from .services import ( ensure_location_and_weather_data, get_farm_details, resolve_center_location_from_boundary, + sync_devices_from_sensor_data, sync_sensor_parameters_from_payload, sync_plant_catalog_from_backend, ) @@ -239,6 +240,8 @@ class FarmDataUpsertView(APIView): else: farm_data.save() + sync_devices_from_sensor_data(farm_data) + if plant_ids is not None: try: assign_farm_plants_from_backend_ids(farm_data, plant_ids) @@ -280,6 +283,13 @@ class FarmDetailView(APIView): "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند " "و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند." ), + examples=[ + OpenApiExample( + "نمونه مسیر farm detail", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + parameter_only=("farm_uuid", "path"), + ), + ], responses={ 200: build_response( FarmDetailEnvelopeSerializer, diff --git a/location_data/block_subdivision.py b/location_data/block_subdivision.py index 1002396..f27d8a6 100644 --- a/location_data/block_subdivision.py +++ b/location_data/block_subdivision.py @@ -32,7 +32,7 @@ def create_or_get_block_subdivision( اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمی‌گرداند؛ در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره می‌کند. """ - from .models import BlockSubdivision + from .models import BlockSubdivision, build_default_sub_block, ensure_block_layout_defaults existing = BlockSubdivision.objects.filter( soil_location=location, @@ -244,7 +244,7 @@ def render_elbow_plot( def sync_block_layout_with_subdivision(location, subdivision) -> None: - layout = location.block_layout or {} + layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count) blocks = list(layout.get("blocks") or []) target_block = None for block in blocks: @@ -263,7 +263,12 @@ def sync_block_layout_with_subdivision(location, subdivision) -> None: blocks.append(target_block) target_block["needs_subdivision"] = subdivision.centroid_count > 1 - target_block["sub_blocks"] = list(subdivision.centroid_points or []) + target_block["sub_blocks"] = list(subdivision.centroid_points or []) or [ + build_default_sub_block( + str(target_block.get("block_code") or "block-1"), + boundary=target_block.get("boundary") or {}, + ) + ] target_block["subdivision_summary"] = { "chunk_size_sqm": subdivision.chunk_size_sqm, "grid_point_count": subdivision.grid_point_count, diff --git a/location_data/cluster_recommendation.py b/location_data/cluster_recommendation.py new file mode 100644 index 0000000..1d5d5fb --- /dev/null +++ b/location_data/cluster_recommendation.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +from django.db.models import Avg + +from crop_simulation.growth_simulation import GrowthSimulationContext, _run_projection_engine +from crop_simulation.services import PcseSimulationManager, build_simulation_payload_from_farm +from farm_data.services import get_canonical_farm_record, get_farm_plant_assignments +from .models import AnalysisGridObservation, RemoteSensingClusterBlock +from .satellite_snapshot import build_location_block_satellite_snapshots + + +class ClusterRecommendationNotFound(Exception): + pass + + +class ClusterRecommendationValidationError(Exception): + pass + + +@dataclass +class ClusterPlantCandidate: + plant_id: int | None + plant_name: str + position: int | None + stage: str + score: float + predicted_yield: float | None + predicted_yield_tons: float | None + biomass: float | None + max_lai: float | None + simulation_engine: str | None + simulation_model_name: str | None + simulation_warning: str | None + supporting_metrics: dict[str, Any] + + def as_dict(self) -> dict[str, Any]: + return { + "plant_id": self.plant_id, + "plant_name": self.plant_name, + "position": self.position, + "stage": self.stage, + "score": self.score, + "predicted_yield": self.predicted_yield, + "predicted_yield_tons": self.predicted_yield_tons, + "biomass": self.biomass, + "max_lai": self.max_lai, + "simulation_engine": self.simulation_engine, + "simulation_model_name": self.simulation_model_name, + "simulation_warning": self.simulation_warning, + "supporting_metrics": self.supporting_metrics, + } + + +def _safe_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _clamp(value: float, minimum: float, maximum: float) -> float: + if minimum > maximum: + minimum, maximum = maximum, minimum + return max(minimum, min(value, maximum)) + + +def _build_cluster_entries( + snapshots: list[dict[str, Any]], + *, + cluster_blocks_by_uuid: dict[str, RemoteSensingClusterBlock], +) -> list[dict[str, Any]]: + entries_by_key: dict[str, dict[str, Any]] = {} + + for snapshot in snapshots: + block_code = str(snapshot.get("block_code") or "").strip() + temporal_extent = snapshot.get("temporal_extent") + for satellite_sub_block in snapshot.get("satellite_sub_blocks") or []: + cluster_uuid = str(satellite_sub_block.get("cluster_uuid") or "").strip() + sub_block_code = str(satellite_sub_block.get("sub_block_code") or "").strip() + cluster_label = satellite_sub_block.get("cluster_label") + if cluster_uuid: + entry_key = cluster_uuid + elif sub_block_code: + entry_key = f"{block_code}::{sub_block_code}" + else: + entry_key = f"{block_code}::cluster-{cluster_label}" + entry = entries_by_key.setdefault( + entry_key, + { + "block_code": block_code, + "cluster_uuid": cluster_uuid or None, + "sub_block_code": sub_block_code, + "cluster_label": cluster_label, + "temporal_extent": temporal_extent, + "cluster_block": None, + "satellite_metrics": {}, + "sensor_metrics": {}, + "resolved_metrics": {}, + "source_metadata": { + "block_status": snapshot.get("status") or "missing", + "aggregation_strategy": snapshot.get("aggregation_strategy") or "missing", + "has_satellite_metrics": False, + "has_sensor_metrics": False, + }, + }, + ) + entry["satellite_metrics"] = dict(satellite_sub_block.get("resolved_metrics") or {}) + entry["resolved_metrics"].update(entry["satellite_metrics"]) + entry["source_metadata"]["has_satellite_metrics"] = True + if cluster_uuid and cluster_uuid in cluster_blocks_by_uuid: + entry["cluster_block"] = cluster_blocks_by_uuid[cluster_uuid] + + for sensor_sub_block in snapshot.get("sensor_sub_blocks") or []: + cluster_uuid = str(sensor_sub_block.get("cluster_uuid") or "").strip() + sub_block_code = str(sensor_sub_block.get("sub_block_code") or "").strip() + cluster_label = sensor_sub_block.get("cluster_label") + candidate_keys = [ + cluster_uuid, + f"{block_code}::{sub_block_code}" if sub_block_code else "", + f"{block_code}::cluster-{cluster_label}" if cluster_label is not None else "", + ] + entry = None + for candidate_key in candidate_keys: + if candidate_key and candidate_key in entries_by_key: + entry = entries_by_key[candidate_key] + break + if entry is None: + continue + entry["sensor_metrics"] = dict(sensor_sub_block.get("resolved_metrics") or {}) + entry["resolved_metrics"].update(entry["sensor_metrics"]) + entry["source_metadata"]["has_sensor_metrics"] = True + + return list(entries_by_key.values()) + + +def _attach_missing_satellite_metrics(cluster_entries: list[dict[str, Any]]) -> None: + for cluster_entry in cluster_entries: + cluster_block = cluster_entry.get("cluster_block") + if cluster_block is None: + continue + needs_soil_vv = "soil_vv" not in (cluster_entry.get("resolved_metrics") or {}) + if not needs_soil_vv: + continue + observation_summary = AnalysisGridObservation.objects.filter( + cell__cell_code__in=list(cluster_block.cell_codes or []), + temporal_start=cluster_block.result.temporal_start, + temporal_end=cluster_block.result.temporal_end, + ).aggregate(soil_vv_mean=Avg("soil_vv")) + soil_vv_mean = _safe_float(observation_summary.get("soil_vv_mean")) + if soil_vv_mean is None: + continue + rounded_soil_vv = round(soil_vv_mean, 6) + cluster_entry.setdefault("satellite_metrics", {})["soil_vv"] = rounded_soil_vv + cluster_entry.setdefault("resolved_metrics", {})["soil_vv"] = rounded_soil_vv + + +def _build_cluster_overrides( + base_payload: dict[str, Any], + *, + cluster_metrics: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + soil_parameters = deepcopy(base_payload.get("soil") or {}) + site_parameters = deepcopy(base_payload.get("site_parameters") or {}) + + ndwi = _safe_float(cluster_metrics.get("ndwi")) + if ndwi is not None: + smfcf = _clamp(ndwi, 0.2, 0.55) + smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06)) + sm0 = _clamp( + min(max(smfcf + 0.08, smw + 0.12), 0.6), + max(smfcf + 0.02, smw + 0.05), + 0.8, + ) + soil_parameters["SMFCF"] = round(smfcf, 3) + soil_parameters["SMW"] = round(smw, 3) + soil_parameters["SM0"] = round(sm0, 3) + site_parameters["SMLIM"] = round(_clamp(smfcf, smw, sm0), 3) + + soil_moisture = _safe_float(cluster_metrics.get("soil_moisture")) + if soil_moisture is not None: + soil_parameters["soil_moisture"] = soil_moisture + site_parameters["WAV"] = round(max(soil_moisture, 0.0), 3) + + nutrient_mappings = ( + ("nitrogen", "NAVAILI", "nitrogen"), + ("phosphorus", "P_STATUS", "phosphorus"), + ("potassium", "K_STATUS", "potassium"), + ("soil_ph", "SOIL_PH", "soil_ph"), + ("electrical_conductivity", "EC", "electrical_conductivity"), + ) + for metric_name, site_key, soil_key in nutrient_mappings: + value = _safe_float(cluster_metrics.get(metric_name)) + if value is None: + continue + soil_parameters[soil_key] = value + site_parameters[site_key] = value + + return soil_parameters, site_parameters + + +def _serialize_cluster_block(cluster_block: RemoteSensingClusterBlock | None) -> dict[str, Any] | None: + if cluster_block is None: + return None + return { + "uuid": str(cluster_block.uuid), + "sub_block_code": cluster_block.sub_block_code, + "cluster_label": cluster_block.cluster_label, + "chunk_size_sqm": cluster_block.chunk_size_sqm, + "centroid_lat": cluster_block.centroid_lat, + "centroid_lon": cluster_block.centroid_lon, + "center_cell_code": cluster_block.center_cell_code, + "center_cell_lat": cluster_block.center_cell_lat, + "center_cell_lon": cluster_block.center_cell_lon, + "cell_count": cluster_block.cell_count, + "cell_codes": list(cluster_block.cell_codes or []), + "geometry": cluster_block.geometry, + "metadata": dict(cluster_block.metadata or {}), + "created_at": cluster_block.created_at, + "updated_at": cluster_block.updated_at, + } + + +def _simulate_candidate( + *, + base_payload: dict[str, Any], + soil_parameters: dict[str, Any], + site_parameters: dict[str, Any], +) -> tuple[dict[str, Any], str | None]: + manager = PcseSimulationManager() + try: + return ( + manager.run_simulation( + weather=base_payload.get("weather") or [], + soil=soil_parameters, + crop_parameters=base_payload.get("crop_parameters") or {}, + agromanagement=base_payload.get("agromanagement") or [], + site_parameters=site_parameters, + ), + None, + ) + except Exception as exc: + context = GrowthSimulationContext( + farm_uuid=None, + plant_name=str((base_payload.get("crop_parameters") or {}).get("crop_name") or ""), + plant=base_payload.get("plant"), + dynamic_parameters=[], + weather=base_payload.get("weather") or [], + crop_parameters=base_payload.get("crop_parameters") or {}, + soil_parameters=soil_parameters, + site_parameters=site_parameters, + agromanagement=base_payload.get("agromanagement") or [], + page_size=10, + ) + fallback_result = _run_projection_engine(context) + return fallback_result, f"simulation_fallback:{exc}" + + +def _rank_cluster_plants( + cluster_entry: dict[str, Any], + *, + plant_assignments: list[Any], + base_payloads: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + candidates: list[ClusterPlantCandidate] = [] + for assignment in plant_assignments: + plant_name = str(getattr(assignment.plant, "name", "") or "").strip() + if not plant_name: + continue + base_payload = base_payloads[plant_name] + soil_parameters, site_parameters = _build_cluster_overrides( + base_payload, + cluster_metrics=dict(cluster_entry.get("resolved_metrics") or {}), + ) + simulation_result, simulation_warning = _simulate_candidate( + base_payload=base_payload, + soil_parameters=soil_parameters, + site_parameters=site_parameters, + ) + metrics = dict(simulation_result.get("metrics") or {}) + predicted_yield = _safe_float(metrics.get("yield_estimate")) + biomass = _safe_float(metrics.get("biomass")) + max_lai = _safe_float(metrics.get("max_lai")) + predicted_yield_tons = None if predicted_yield is None else round(max(predicted_yield, 0.0) / 1000.0, 4) + score = round(predicted_yield if predicted_yield is not None else -1.0, 4) + candidates.append( + ClusterPlantCandidate( + plant_id=getattr(assignment.plant, "backend_plant_id", None), + plant_name=plant_name, + position=getattr(assignment, "position", None), + stage=str(getattr(assignment, "stage", "") or ""), + score=score, + predicted_yield=round(predicted_yield, 4) if predicted_yield is not None else None, + predicted_yield_tons=predicted_yield_tons, + biomass=round(biomass, 4) if biomass is not None else None, + max_lai=round(max_lai, 4) if max_lai is not None else None, + simulation_engine=simulation_result.get("engine"), + simulation_model_name=simulation_result.get("model_name"), + simulation_warning=simulation_warning, + supporting_metrics=metrics, + ) + ) + + ranked_candidates = sorted( + candidates, + key=lambda item: ( + item.score, + item.biomass if item.biomass is not None else float("-inf"), + -1 * (item.position if item.position is not None else 10_000), + ), + reverse=True, + ) + return [candidate.as_dict() for candidate in ranked_candidates] + + +def build_cluster_crop_recommendations(farm_uuid: str) -> dict[str, Any]: + farm = get_canonical_farm_record(farm_uuid) + if farm is None: + raise ClusterRecommendationNotFound("مزرعه پیدا نشد.") + + plant_assignments = get_farm_plant_assignments(farm) + if not plant_assignments: + raise ClusterRecommendationValidationError("برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.") + + location = farm.center_location + snapshots = build_location_block_satellite_snapshots( + location, + sensor_payload=farm.sensor_payload, + ) + cluster_uuids = { + str(sub_block.get("cluster_uuid") or "").strip() + for snapshot in snapshots + for sub_block in (snapshot.get("satellite_sub_blocks") or []) + if str(sub_block.get("cluster_uuid") or "").strip() + } + if not cluster_uuids: + raise ClusterRecommendationNotFound("برای این مزرعه هنوز خروجی KMeans در location_data ثبت نشده است.") + + cluster_blocks_by_uuid = { + str(cluster_block.uuid): cluster_block + for cluster_block in RemoteSensingClusterBlock.objects.filter(uuid__in=list(cluster_uuids)).select_related("result") + } + cluster_entries = _build_cluster_entries( + snapshots, + cluster_blocks_by_uuid=cluster_blocks_by_uuid, + ) + _attach_missing_satellite_metrics(cluster_entries) + if not cluster_entries: + raise ClusterRecommendationNotFound("برای این مزرعه هنوز کلاستر قابل استفاده پیدا نشد.") + + base_payloads: dict[str, dict[str, Any]] = {} + for assignment in plant_assignments: + plant_name = str(getattr(assignment.plant, "name", "") or "").strip() + if not plant_name or plant_name in base_payloads: + continue + try: + base_payloads[plant_name] = build_simulation_payload_from_farm( + farm_uuid=str(farm.farm_uuid), + plant_name=plant_name, + ) + except Exception as exc: + raise ClusterRecommendationValidationError( + f"مقایسه گیاه‌ها با crop_simulation انجام نشد: {exc}" + ) from exc + + response_clusters: list[dict[str, Any]] = [] + for cluster_entry in cluster_entries: + candidate_plants = _rank_cluster_plants( + cluster_entry, + plant_assignments=plant_assignments, + base_payloads=base_payloads, + ) + response_clusters.append( + { + "block_code": cluster_entry.get("block_code") or "", + "cluster_uuid": cluster_entry.get("cluster_uuid"), + "sub_block_code": cluster_entry.get("sub_block_code") or "", + "cluster_label": cluster_entry.get("cluster_label"), + "temporal_extent": cluster_entry.get("temporal_extent"), + "cluster_block": _serialize_cluster_block(cluster_entry.get("cluster_block")), + "satellite_metrics": dict(cluster_entry.get("satellite_metrics") or {}), + "sensor_metrics": dict(cluster_entry.get("sensor_metrics") or {}), + "resolved_metrics": dict(cluster_entry.get("resolved_metrics") or {}), + "candidate_plants": candidate_plants, + "suggested_plant": candidate_plants[0] if candidate_plants else None, + "source_metadata": dict(cluster_entry.get("source_metadata") or {}), + } + ) + + return { + "farm_uuid": str(farm.farm_uuid), + "location_id": location.id, + "evaluated_plant_count": len(base_payloads), + "cluster_count": len(response_clusters), + "registered_plants": [ + { + "plant_id": assignment.plant.backend_plant_id, + "plant_name": assignment.plant.name, + "position": assignment.position, + "stage": assignment.stage, + } + for assignment in plant_assignments + ], + "clusters": response_clusters, + "source_metadata": { + "source": "location_data+kmeans+farm_data+crop_simulation", + "location_id": location.id, + "snapshot_block_count": len(snapshots), + }, + } diff --git a/location_data/data_driven_subdivision.py b/location_data/data_driven_subdivision.py index 1228a03..c98fa85 100644 --- a/location_data/data_driven_subdivision.py +++ b/location_data/data_driven_subdivision.py @@ -16,6 +16,8 @@ from django.db import transaction from .block_subdivision import detect_elbow_point, point_in_polygon, render_elbow_plot from .models import ( + build_default_sub_block, + ensure_block_layout_defaults, AnalysisGridObservation, BlockSubdivision, RemoteSensingClusterBlock, @@ -1272,7 +1274,7 @@ def sync_location_block_layout_with_result( result: RemoteSensingSubdivisionResult, cluster_summaries: list[dict[str, Any]], ) -> None: - layout = dict(location.block_layout or {}) + layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count) blocks = list(layout.get("blocks") or []) target_block = None for block in blocks: @@ -1307,6 +1309,14 @@ def sync_location_block_layout_with_result( } for cluster in cluster_summaries ] + if not target_block["sub_blocks"]: + target_block["sub_blocks"] = [ + build_default_sub_block( + str(target_block.get("block_code") or "block-1"), + boundary=target_block.get("boundary") or {}, + ) + ] + target_block["subdivision_summary"] = { "type": "data_driven_remote_sensing", "cluster_count": result.cluster_count, diff --git a/location_data/management/commands/seed_location_data.py b/location_data/management/commands/seed_location_data.py new file mode 100644 index 0000000..5bb8241 --- /dev/null +++ b/location_data/management/commands/seed_location_data.py @@ -0,0 +1,533 @@ +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils.dateparse import parse_datetime + +from location_data.models import ( + AnalysisGridCell, + AnalysisGridObservation, + BlockSubdivision, + NdviObservation, + RemoteSensingClusterAssignment, + RemoteSensingClusterBlock, + RemoteSensingRun, + RemoteSensingSubdivisionOption, + RemoteSensingSubdivisionOptionAssignment, + RemoteSensingSubdivisionOptionBlock, + RemoteSensingSubdivisionResult, + SoilLocation, +) + +SEED_DATA = { + "soillocations": [ + { + "id": 1, + "latitude": "50.000000", + "longitude": "50.000000", + "task_id": "", + "farm_boundary": { + "type": "Polygon", + "coordinates": [[[49.9995, 49.9995], [50.0005, 49.9995], [50.0005, 50.0005], [49.9995, 50.0005], [49.9995, 49.9995]]], + }, + "input_block_count": 1, + "block_layout": { + "blocks": [ + { + "order": 1, + "source": "default", + "boundary": {}, + "block_code": "block-1", + "sub_blocks": [], + "needs_subdivision": None, + }, + { + "order": 2, + "source": "remote_sensing", + "block_code": "", + "sub_blocks": [ + { + "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]}, + "metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0004993, "center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "center_mean_distance": 0.00029732}, "cell_geometry_type": "Polygon"}, + "cell_count": 4, + "centroid_lat": 49.99977, + "centroid_lon": 49.99992, + "cluster_uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4", + "cluster_label": 0, + "sub_block_code": "cluster-0", + "center_cell_lat": 49.999635, + "center_cell_lon": 49.99971, + "center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000", + }, + { + "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]}, + "metadata": {"source": "analysis_grid_cells", "center_selection": {"strategy": "coordinate_1_center", "center_radius": 0.0006827, "center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "center_mean_distance": 0.00041092}, "cell_geometry_type": "Polygon"}, + "cell_count": 8, + "centroid_lat": 50.000174, + "centroid_lon": 50.000235, + "cluster_uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76", + "cluster_label": 1, + "sub_block_code": "cluster-1", + "center_cell_lat": 50.000174, + "center_cell_lon": 50.00013, + "center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001", + }, + ], + "needs_subdivision": True, + "subdivision_summary": { + "type": "data_driven_remote_sensing", + "run_id": 1, + "cluster_count": 2, + "used_cell_count": 12, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "skipped_cell_count": 0, + }, + }, + ], + "algorithm_status": "completed", + "default_full_farm": True, + "input_block_count": 1, + "analysis_grid_summary": {"cell_count": 12, "chunk_size_sqm": 900}, + }, + "created_at": "2026-05-11T14:41:43.319380+00:00", + "updated_at": "2026-05-12T12:37:38.239904+00:00", + } + ], + "blocksubdivisions": [], + "remotesensingruns": [ + { + "id": 1, + "soil_location_id": 1, + "block_subdivision_id": None, + "block_code": "", + "provider": "openeo", + "chunk_size_sqm": 900, + "temporal_start": "2026-04-11", + "temporal_end": "2026-05-11", + "status": "success", + "metadata": {"farm_uuid": "11111111-1111-1111-1111-111111111111", "requested_via": "api", "scope": "all_blocks"}, + "error_message": "", + "started_at": "2026-05-12T12:19:03.911826+00:00", + "finished_at": "2026-05-12T12:37:39.018428+00:00", + "created_at": "2026-05-12T12:19:03.912346+00:00", + "updated_at": "2026-05-12T12:37:39.019007+00:00", + } + ], + "remotesensingsubdivisionresults": [ + { + "id": 1, + "soil_location_id": 1, + "run_id": 1, + "block_subdivision_id": None, + "block_code": "", + "chunk_size_sqm": 900, + "temporal_start": "2026-04-11", + "temporal_end": "2026-05-11", + "cluster_count": 2, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + "skipped_cell_codes": [], + "metadata": {"selection_strategy": "elbow", "used_cell_count": 12}, + "created_at": "2026-05-12T12:37:27.897155+00:00", + "updated_at": "2026-05-12T12:37:27.897180+00:00", + } + ], + "remotesensingclusterblocks": [ + { + "id": 1, + "uuid": "daa278cb-cf75-4f17-bc94-bb3a780dd4d4", + "result_id": 1, + "soil_location_id": 1, + "block_subdivision_id": None, + "block_code": "", + "sub_block_code": "cluster-0", + "cluster_label": 0, + "chunk_size_sqm": 900, + "centroid_lat": "49.999770", + "centroid_lon": "49.999920", + "center_cell_code": "loc-1__block-farm__chunk-900__r0000c0000", + "center_cell_lat": "49.999635", + "center_cell_lon": "49.999710", + "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977], [49.9995, 49.9995]]]}, + "cell_count": 4, + "cell_codes": ["loc-1__block-farm__chunk-900__r0000c0000", "loc-1__block-farm__chunk-900__r0000c0001", "loc-1__block-farm__chunk-900__r0001c0000", "loc-1__block-farm__chunk-900__r0001c0001"], + "metadata": {"source": "analysis_grid_cells"}, + "created_at": "2026-05-12T12:37:27.899874+00:00", + "updated_at": "2026-05-12T12:37:27.899899+00:00", + }, + { + "id": 2, + "uuid": "e9beea1c-8736-4c45-ac5b-f186705bad76", + "result_id": 1, + "soil_location_id": 1, + "block_subdivision_id": None, + "block_code": "", + "sub_block_code": "cluster-1", + "cluster_label": 1, + "chunk_size_sqm": 900, + "centroid_lat": "50.000174", + "centroid_lon": "50.000235", + "center_cell_code": "loc-1__block-farm__chunk-900__r0002c0001", + "center_cell_lat": "50.000174", + "center_cell_lon": "50.000130", + "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000759, 50.00004], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309], [49.9995, 50.00004], [49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977], [50.000339, 49.9995]]]}, + "cell_count": 8, + "cell_codes": ["loc-1__block-farm__chunk-900__r0000c0002", "loc-1__block-farm__chunk-900__r0001c0002", "loc-1__block-farm__chunk-900__r0002c0000", "loc-1__block-farm__chunk-900__r0002c0001", "loc-1__block-farm__chunk-900__r0002c0002", "loc-1__block-farm__chunk-900__r0003c0000", "loc-1__block-farm__chunk-900__r0003c0001", "loc-1__block-farm__chunk-900__r0003c0002"], + "metadata": {"source": "analysis_grid_cells"}, + "created_at": "2026-05-12T12:37:27.901926+00:00", + "updated_at": "2026-05-12T12:37:27.901945+00:00", + }, + ], + "analysisgridcells": [ + {"id": 1, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.9995], [49.99992, 49.9995], [49.99992, 49.99977], [49.9995, 49.99977], [49.9995, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.930590+00:00", "updated_at": "2026-05-12T12:19:03.930609+00:00"}, + {"id": 2, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.9995], [50.000339, 49.9995], [50.000339, 49.99977], [49.99992, 49.99977], [49.99992, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931797+00:00", "updated_at": "2026-05-12T12:19:03.931817+00:00"}, + {"id": 3, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0000c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.9995], [50.000759, 49.9995], [50.000759, 49.99977], [50.000339, 49.99977], [50.000339, 49.9995]]]}, "centroid_lat": "49.999635", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931857+00:00", "updated_at": "2026-05-12T12:19:03.931864+00:00"}, + {"id": 4, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 49.99977], [49.99992, 49.99977], [49.99992, 50.00004], [49.9995, 50.00004], [49.9995, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.931899+00:00", "updated_at": "2026-05-12T12:19:03.931906+00:00"}, + {"id": 5, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 49.99977], [50.000339, 49.99977], [50.000339, 50.00004], [49.99992, 50.00004], [49.99992, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.931939+00:00", "updated_at": "2026-05-12T12:19:03.931945+00:00"}, + {"id": 6, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0001c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 49.99977], [50.000759, 49.99977], [50.000759, 50.00004], [50.000339, 50.00004], [50.000339, 49.99977]]]}, "centroid_lat": "49.999905", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.931978+00:00", "updated_at": "2026-05-12T12:19:03.931985+00:00"}, + {"id": 7, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.00004], [49.99992, 50.00004], [49.99992, 50.000309], [49.9995, 50.000309], [49.9995, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932017+00:00", "updated_at": "2026-05-12T12:19:03.932024+00:00"}, + {"id": 8, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.00004], [50.000339, 50.00004], [50.000339, 50.000309], [49.99992, 50.000309], [49.99992, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932056+00:00", "updated_at": "2026-05-12T12:19:03.932063+00:00"}, + {"id": 9, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0002c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.00004], [50.000759, 50.00004], [50.000759, 50.000309], [50.000339, 50.000309], [50.000339, 50.00004]]]}, "centroid_lat": "50.000174", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932100+00:00", "updated_at": "2026-05-12T12:19:03.932107+00:00"}, + {"id": 10, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0000", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.9995, 50.000309], [49.99992, 50.000309], [49.99992, 50.000579], [49.9995, 50.000579], [49.9995, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "49.999710", "created_at": "2026-05-12T12:19:03.932145+00:00", "updated_at": "2026-05-12T12:19:03.932152+00:00"}, + {"id": 11, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0001", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[49.99992, 50.000309], [50.000339, 50.000309], [50.000339, 50.000579], [49.99992, 50.000579], [49.99992, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000130", "created_at": "2026-05-12T12:19:03.932191+00:00", "updated_at": "2026-05-12T12:19:03.932198+00:00"}, + {"id": 12, "soil_location_id": 1, "block_subdivision_id": None, "block_code": "", "cell_code": "loc-1__block-farm__chunk-900__r0003c0002", "chunk_size_sqm": 900, "geometry": {"type": "Polygon", "coordinates": [[[50.000339, 50.000309], [50.000759, 50.000309], [50.000759, 50.000579], [50.000339, 50.000579], [50.000339, 50.000309]]]}, "centroid_lat": "50.000444", "centroid_lon": "50.000549", "created_at": "2026-05-12T12:19:03.932234+00:00", "updated_at": "2026-05-12T12:19:03.932241+00:00"}, + ], + "analysisgridobservations": [ + {"id": 1, "cell_id": 1, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv": -15.369688, "soil_vv_db": -15.369688, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.614257+00:00", "updated_at": "2026-05-12T12:37:26.614281+00:00"}, + {"id": 2, "cell_id": 2, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv": -14.043169, "soil_vv_db": -14.043169, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615124+00:00", "updated_at": "2026-05-12T12:37:26.615143+00:00"}, + {"id": 3, "cell_id": 3, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv": -13.727797, "soil_vv_db": -13.727797, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.615867+00:00", "updated_at": "2026-05-12T12:37:26.615885+00:00"}, + {"id": 4, "cell_id": 4, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv": -13.127913, "soil_vv_db": -13.127913, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.616668+00:00", "updated_at": "2026-05-12T12:37:26.616687+00:00"}, + {"id": 5, "cell_id": 5, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv": -12.400669, "soil_vv_db": -12.400669, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.617453+00:00", "updated_at": "2026-05-12T12:37:26.617472+00:00"}, + {"id": 6, "cell_id": 6, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv": -12.273758, "soil_vv_db": -12.273758, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618181+00:00", "updated_at": "2026-05-12T12:37:26.618199+00:00"}, + {"id": 7, "cell_id": 7, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv": -12.147284, "soil_vv_db": -12.147284, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.618964+00:00", "updated_at": "2026-05-12T12:37:26.618982+00:00"}, + {"id": 8, "cell_id": 8, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv": -13.170681, "soil_vv_db": -13.170681, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.619733+00:00", "updated_at": "2026-05-12T12:37:26.619750+00:00"}, + {"id": 9, "cell_id": 9, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv": -13.873331, "soil_vv_db": -13.873331, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.620504+00:00", "updated_at": "2026-05-12T12:37:26.620522+00:00"}, + {"id": 10, "cell_id": 10, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv": -14.09151, "soil_vv_db": -14.09151, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621257+00:00", "updated_at": "2026-05-12T12:37:26.621275+00:00"}, + {"id": 11, "cell_id": 11, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv": -13.272252, "soil_vv_db": -13.272252, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.621989+00:00", "updated_at": "2026-05-12T12:37:26.622008+00:00"}, + {"id": 12, "cell_id": 12, "run_id": 1, "temporal_start": "2026-04-11", "temporal_end": "2026-05-11", "ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv": -12.991811, "soil_vv_db": -12.991811, "dem_m": None, "slope_deg": None, "metadata": {}, "created_at": "2026-05-12T12:37:26.622751+00:00", "updated_at": "2026-05-12T12:37:26.622769+00:00"}, + ], + "remotesensingclusterassignments": [ + {"id": 1, "result_id": 1, "cell_id": 1, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6483580933676826, "ndwi": -0.5629512800110711, "soil_vv_db": -15.369688}, "scaled_feature_values": {"ndvi": -1.630738, "ndwi": 1.792215, "soil_vv_db": -2.274005}, "created_at": "2026-05-12T12:37:27.901986+00:00", "updated_at": "2026-05-12T12:37:27.902003+00:00"}, + {"id": 2, "result_id": 1, "cell_id": 2, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6588515573077731, "ndwi": -0.5730775992075602, "soil_vv_db": -14.043169}, "scaled_feature_values": {"ndvi": -1.094298, "ndwi": 1.109414, "soil_vv_db": -0.762373}, "created_at": "2026-05-12T12:37:27.902768+00:00", "updated_at": "2026-05-12T12:37:27.902786+00:00"}, + {"id": 3, "result_id": 1, "cell_id": 3, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6946650213665433, "ndwi": -0.6026291714774238, "soil_vv_db": -13.727797}, "scaled_feature_values": {"ndvi": 0.736534, "ndwi": -0.8832, "soil_vv_db": -0.402992}, "created_at": "2026-05-12T12:37:27.903479+00:00", "updated_at": "2026-05-12T12:37:27.903497+00:00"}, + {"id": 4, "result_id": 1, "cell_id": 4, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6682408054669698, "ndwi": -0.578668495019277, "soil_vv_db": -13.127913}, "scaled_feature_values": {"ndvi": -0.614307, "ndwi": 0.732429, "soil_vv_db": 0.280605}, "created_at": "2026-05-12T12:37:27.904197+00:00", "updated_at": "2026-05-12T12:37:27.904214+00:00"}, + {"id": 5, "result_id": 1, "cell_id": 5, "cluster_label": 0, "raw_feature_values": {"ndvi": 0.6642558574676514, "ndwi": -0.5712497035662333, "soil_vv_db": -12.400669}, "scaled_feature_values": {"ndvi": -0.818023, "ndwi": 1.232666, "soil_vv_db": 1.109334}, "created_at": "2026-05-12T12:37:27.904909+00:00", "updated_at": "2026-05-12T12:37:27.904926+00:00"}, + {"id": 6, "result_id": 1, "cell_id": 6, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7056174145804511, "ndwi": -0.599965857134925, "soil_vv_db": -12.273758}, "scaled_feature_values": {"ndvi": 1.296436, "ndwi": -0.703617, "soil_vv_db": 1.253955}, "created_at": "2026-05-12T12:37:27.905606+00:00", "updated_at": "2026-05-12T12:37:27.905623+00:00"}, + {"id": 7, "result_id": 1, "cell_id": 7, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6824584868219163, "ndwi": -0.5929381317562528, "soil_vv_db": -12.147284}, "scaled_feature_values": {"ndvi": 0.11252, "ndwi": -0.229749, "soil_vv_db": 1.398079}, "created_at": "2026-05-12T12:37:27.906325+00:00", "updated_at": "2026-05-12T12:37:27.906343+00:00"}, + {"id": 8, "result_id": 1, "cell_id": 8, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6956862476136949, "ndwi": -0.6001381145583259, "soil_vv_db": -13.170681}, "scaled_feature_values": {"ndvi": 0.788741, "ndwi": -0.715232, "soil_vv_db": 0.231869}, "created_at": "2026-05-12T12:37:27.907052+00:00", "updated_at": "2026-05-12T12:37:27.907069+00:00"}, + {"id": 9, "result_id": 1, "cell_id": 9, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.7093238963021172, "ndwi": -0.6121659080187479, "soil_vv_db": -13.873331}, "scaled_feature_values": {"ndvi": 1.485916, "ndwi": -1.526247, "soil_vv_db": -0.568835}, "created_at": "2026-05-12T12:37:27.907735+00:00", "updated_at": "2026-05-12T12:37:27.907752+00:00"}, + {"id": 10, "result_id": 1, "cell_id": 10, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6764502127965292, "ndwi": -0.5939946042166816, "soil_vv_db": -14.09151}, "scaled_feature_values": {"ndvi": -0.194631, "ndwi": -0.300985, "soil_vv_db": -0.81746}, "created_at": "2026-05-12T12:37:27.908441+00:00", "updated_at": "2026-05-12T12:37:27.908458+00:00"}, + {"id": 11, "result_id": 1, "cell_id": 11, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6600760486390855, "ndwi": -0.5822326408492194, "soil_vv_db": -13.272252}, "scaled_feature_values": {"ndvi": -1.031701, "ndwi": 0.492105, "soil_vv_db": 0.116124}, "created_at": "2026-05-12T12:37:27.909117+00:00", "updated_at": "2026-05-12T12:37:27.909134+00:00"}, + {"id": 12, "result_id": 1, "cell_id": 12, "cluster_label": 1, "raw_feature_values": {"ndvi": 0.6991057925754123, "ndwi": -0.6043583750724792, "soil_vv_db": -12.991811}, "scaled_feature_values": {"ndvi": 0.963553, "ndwi": -0.999798, "soil_vv_db": 0.4357}, "created_at": "2026-05-12T12:37:27.909828+00:00", "updated_at": "2026-05-12T12:37:27.909845+00:00"}, + ], + "remotesensingsubdivisionoptions": [], + "remotesensingsubdivisionoptionblocks": [], + "remotesensingsubdivisionoptionassignments": [], + "ndviobservations": [], +} + + +def _dt(value: str | None): + if not value: + return None + parsed = parse_datetime(value) + if parsed is not None: + return parsed + return datetime.fromisoformat(value) + + +def _date(value: str | None): + if not value: + return None + return date.fromisoformat(value) + + +def _decimal(value): + if value is None: + return None + return Decimal(str(value)) + + +def _uuid(value): + if not value: + return None + return UUID(str(value)) + + +class Command(BaseCommand): + help = "Seed the current location_data database snapshot into local tables." + + def add_arguments(self, parser): + parser.add_argument( + "--flush-existing", + action="store_true", + help="Delete existing location_data seeded tables before inserting the snapshot.", + ) + + @transaction.atomic + def handle(self, *args, **options): + if options["flush_existing"]: + self._flush_existing() + + self._seed_soil_locations() + self._seed_block_subdivisions() + self._seed_remote_sensing_runs() + self._seed_subdivision_results() + self._seed_cluster_blocks() + self._seed_analysis_grid_cells() + self._seed_analysis_grid_observations() + self._seed_cluster_assignments() + self._seed_subdivision_options() + self._seed_subdivision_option_blocks() + self._seed_subdivision_option_assignments() + self._seed_ndvi_observations() + self.stdout.write(self.style.SUCCESS("location_data seed snapshot applied successfully.")) + + def _flush_existing(self): + RemoteSensingSubdivisionOptionAssignment.objects.all().delete() + RemoteSensingSubdivisionOptionBlock.objects.all().delete() + RemoteSensingSubdivisionOption.objects.all().delete() + RemoteSensingClusterAssignment.objects.all().delete() + AnalysisGridObservation.objects.all().delete() + RemoteSensingClusterBlock.objects.all().delete() + RemoteSensingSubdivisionResult.objects.all().delete() + RemoteSensingRun.objects.all().delete() + AnalysisGridCell.objects.all().delete() + BlockSubdivision.objects.all().delete() + NdviObservation.objects.all().delete() + SoilLocation.objects.all().delete() + + def _seed_soil_locations(self): + for row in SEED_DATA["soillocations"]: + obj, _ = SoilLocation.objects.update_or_create( + id=row["id"], + defaults={ + "latitude": _decimal(row["latitude"]), + "longitude": _decimal(row["longitude"]), + "task_id": row["task_id"], + "farm_boundary": row["farm_boundary"], + "input_block_count": row["input_block_count"], + "block_layout": row["block_layout"], + }, + ) + self._touch(obj, row) + + def _seed_block_subdivisions(self): + for row in SEED_DATA["blocksubdivisions"]: + obj, _ = BlockSubdivision.objects.update_or_create( + id=row["id"], + defaults={ + "soil_location_id": row["soil_location_id"], + "block_code": row["block_code"], + "source_boundary": row["source_boundary"], + "chunk_size_sqm": row["chunk_size_sqm"], + "grid_points": row["grid_points"], + "centroid_points": row["centroid_points"], + "grid_point_count": row["grid_point_count"], + "centroid_count": row["centroid_count"], + "elbow_plot": row.get("elbow_plot", ""), + "subdivision_summary": row.get("subdivision_summary", {}), + }, + ) + self._touch(obj, row) + + def _seed_remote_sensing_runs(self): + for row in SEED_DATA["remotesensingruns"]: + obj, _ = RemoteSensingRun.objects.update_or_create( + id=row["id"], + defaults={ + "soil_location_id": row["soil_location_id"], + "block_subdivision_id": row["block_subdivision_id"], + "block_code": row["block_code"], + "provider": row["provider"], + "chunk_size_sqm": row["chunk_size_sqm"], + "temporal_start": _date(row["temporal_start"]), + "temporal_end": _date(row["temporal_end"]), + "status": row["status"], + "metadata": row["metadata"], + "error_message": row["error_message"], + "started_at": _dt(row["started_at"]), + "finished_at": _dt(row["finished_at"]), + }, + ) + self._touch(obj, row) + + def _seed_subdivision_results(self): + for row in SEED_DATA["remotesensingsubdivisionresults"]: + obj, _ = RemoteSensingSubdivisionResult.objects.update_or_create( + id=row["id"], + defaults={ + "soil_location_id": row["soil_location_id"], + "run_id": row["run_id"], + "block_subdivision_id": row["block_subdivision_id"], + "block_code": row["block_code"], + "chunk_size_sqm": row["chunk_size_sqm"], + "temporal_start": _date(row["temporal_start"]), + "temporal_end": _date(row["temporal_end"]), + "cluster_count": row["cluster_count"], + "selected_features": row["selected_features"], + "skipped_cell_codes": row["skipped_cell_codes"], + "metadata": row["metadata"], + }, + ) + self._touch(obj, row) + + def _seed_cluster_blocks(self): + for row in SEED_DATA["remotesensingclusterblocks"]: + obj, _ = RemoteSensingClusterBlock.objects.update_or_create( + id=row["id"], + defaults={ + "uuid": _uuid(row["uuid"]), + "result_id": row["result_id"], + "soil_location_id": row["soil_location_id"], + "block_subdivision_id": row["block_subdivision_id"], + "block_code": row["block_code"], + "sub_block_code": row["sub_block_code"], + "cluster_label": row["cluster_label"], + "chunk_size_sqm": row["chunk_size_sqm"], + "centroid_lat": _decimal(row["centroid_lat"]), + "centroid_lon": _decimal(row["centroid_lon"]), + "center_cell_code": row["center_cell_code"], + "center_cell_lat": _decimal(row["center_cell_lat"]), + "center_cell_lon": _decimal(row["center_cell_lon"]), + "geometry": row["geometry"], + "cell_count": row["cell_count"], + "cell_codes": row["cell_codes"], + "metadata": row["metadata"], + }, + ) + self._touch(obj, row) + + def _seed_analysis_grid_cells(self): + for row in SEED_DATA["analysisgridcells"]: + obj, _ = AnalysisGridCell.objects.update_or_create( + id=row["id"], + defaults={ + "soil_location_id": row["soil_location_id"], + "block_subdivision_id": row["block_subdivision_id"], + "block_code": row["block_code"], + "cell_code": row["cell_code"], + "chunk_size_sqm": row["chunk_size_sqm"], + "geometry": row["geometry"], + "centroid_lat": _decimal(row["centroid_lat"]), + "centroid_lon": _decimal(row["centroid_lon"]), + }, + ) + self._touch(obj, row) + + def _seed_analysis_grid_observations(self): + for row in SEED_DATA["analysisgridobservations"]: + obj, _ = AnalysisGridObservation.objects.update_or_create( + id=row["id"], + defaults={ + "cell_id": row["cell_id"], + "run_id": row["run_id"], + "temporal_start": _date(row["temporal_start"]), + "temporal_end": _date(row["temporal_end"]), + "ndvi": row["ndvi"], + "ndwi": row["ndwi"], + "soil_vv": row["soil_vv"], + "soil_vv_db": row["soil_vv_db"], + "dem_m": row["dem_m"], + "slope_deg": row["slope_deg"], + "metadata": row["metadata"], + }, + ) + self._touch(obj, row) + + def _seed_cluster_assignments(self): + for row in SEED_DATA["remotesensingclusterassignments"]: + obj, _ = RemoteSensingClusterAssignment.objects.update_or_create( + id=row["id"], + defaults={ + "result_id": row["result_id"], + "cell_id": row["cell_id"], + "cluster_label": row["cluster_label"], + "raw_feature_values": row["raw_feature_values"], + "scaled_feature_values": row["scaled_feature_values"], + }, + ) + self._touch(obj, row) + + def _seed_subdivision_options(self): + for row in SEED_DATA["remotesensingsubdivisionoptions"]: + obj, _ = RemoteSensingSubdivisionOption.objects.update_or_create( + id=row["id"], + defaults={ + "result_id": row["result_id"], + "requested_k": row["requested_k"], + "effective_cluster_count": row["effective_cluster_count"], + "is_active": row["is_active"], + "is_recommended": row["is_recommended"], + "selection_source": row["selection_source"], + "metadata": row["metadata"], + }, + ) + self._touch(obj, row) + + def _seed_subdivision_option_blocks(self): + for row in SEED_DATA["remotesensingsubdivisionoptionblocks"]: + obj, _ = RemoteSensingSubdivisionOptionBlock.objects.update_or_create( + id=row["id"], + defaults={ + "option_id": row["option_id"], + "cluster_label": row["cluster_label"], + "sub_block_code": row["sub_block_code"], + "chunk_size_sqm": row["chunk_size_sqm"], + "centroid_lat": _decimal(row["centroid_lat"]), + "centroid_lon": _decimal(row["centroid_lon"]), + "center_cell_code": row["center_cell_code"], + "center_cell_lat": _decimal(row["center_cell_lat"]), + "center_cell_lon": _decimal(row["center_cell_lon"]), + "geometry": row["geometry"], + "cell_count": row["cell_count"], + "cell_codes": row["cell_codes"], + "metadata": row["metadata"], + }, + ) + self._touch(obj, row) + + def _seed_subdivision_option_assignments(self): + for row in SEED_DATA["remotesensingsubdivisionoptionassignments"]: + obj, _ = RemoteSensingSubdivisionOptionAssignment.objects.update_or_create( + id=row["id"], + defaults={ + "option_id": row["option_id"], + "cell_id": row["cell_id"], + "cluster_label": row["cluster_label"], + "raw_feature_values": row["raw_feature_values"], + "scaled_feature_values": row["scaled_feature_values"], + }, + ) + self._touch(obj, row) + + def _seed_ndvi_observations(self): + for row in SEED_DATA["ndviobservations"]: + obj, _ = NdviObservation.objects.update_or_create( + id=row["id"], + defaults={ + "location_id": row["location_id"], + "observation_date": _date(row["observation_date"]), + "mean_ndvi": row["mean_ndvi"], + "ndvi_map": row["ndvi_map"], + "vegetation_health_class": row["vegetation_health_class"], + "satellite_source": row["satellite_source"], + "cloud_cover": row["cloud_cover"], + "metadata": row["metadata"], + }, + ) + self._touch(obj, row, created_field=False) + + def _touch(self, obj, row, *, created_field=True): + updates = [] + if created_field and hasattr(obj, "created_at") and row.get("created_at"): + obj.created_at = _dt(row["created_at"]) + updates.append("created_at") + if hasattr(obj, "updated_at") and row.get("updated_at"): + obj.updated_at = _dt(row["updated_at"]) + updates.append("updated_at") + if updates: + obj.save(update_fields=updates) diff --git a/location_data/models.py b/location_data/models.py index 8d290d4..191eb13 100644 --- a/location_data/models.py +++ b/location_data/models.py @@ -3,24 +3,83 @@ import uuid from django.db import models +def build_default_sub_block(block_code: str, *, boundary: dict | None = None) -> dict: + normalized_block_code = str(block_code or "block-1").strip() or "block-1" + return { + "sub_block_code": f"{normalized_block_code}-sub-1", + "cluster_label": 0, + "source": "default", + "boundary": boundary or {}, + "cluster_uuid": None, + } + + + +def ensure_block_layout_defaults(layout: dict | None, *, block_count: int | None = None) -> dict: + raw_layout = dict(layout or {}) + raw_blocks = list(raw_layout.get("blocks") or []) + normalized_count = len(raw_blocks) if raw_blocks else max(int(block_count or raw_layout.get("input_block_count") or 1), 1) + normalized_blocks: list[dict] = [] + + for index in range(normalized_count): + raw_block = raw_blocks[index] if index < len(raw_blocks) else {} + block_code = str(raw_block.get("block_code") or f"block-{index + 1}").strip() or f"block-{index + 1}" + boundary = raw_block.get("boundary") or {} + sub_blocks = [dict(sub_block) for sub_block in (raw_block.get("sub_blocks") or []) if isinstance(sub_block, dict)] + if not sub_blocks: + sub_blocks = [build_default_sub_block(block_code, boundary=boundary)] + + normalized_block = { + "block_code": block_code, + "order": int(raw_block.get("order") or index + 1), + "source": raw_block.get("source") or ("input" if raw_blocks or normalized_count > 1 else "default"), + "boundary": boundary, + "needs_subdivision": raw_block.get("needs_subdivision"), + "sub_blocks": sub_blocks, + } + for extra_key in ("subdivision_summary", "analysis_grid_summary", "aggregated_metrics"): + if extra_key in raw_block: + normalized_block[extra_key] = raw_block[extra_key] + normalized_blocks.append(normalized_block) + + return { + "input_block_count": normalized_count, + "default_full_farm": raw_layout.get("default_full_farm", normalized_count == 1), + "algorithm_status": raw_layout.get("algorithm_status") or "pending", + "blocks": normalized_blocks, + } + + + def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict: - normalized_blocks = [] if blocks: - for index, block in enumerate(blocks): - normalized_blocks.append( - { - "block_code": str(block.get("block_code") or f"block-{index + 1}").strip(), - "order": int(block.get("order") or index + 1), - "source": "input", - "boundary": block.get("boundary") or {}, - "needs_subdivision": None, - "sub_blocks": [], - } - ) - else: - normalized_count = max(int(block_count or 1), 1) - for index in range(normalized_count): - normalized_blocks.append( + return ensure_block_layout_defaults( + { + "input_block_count": len(blocks), + "default_full_farm": len(blocks) == 1, + "algorithm_status": "pending", + "blocks": [ + { + "block_code": str(block.get("block_code") or f"block-{index + 1}").strip(), + "order": int(block.get("order") or index + 1), + "source": "input", + "boundary": block.get("boundary") or {}, + "needs_subdivision": None, + "sub_blocks": [dict(sub_block) for sub_block in (block.get("sub_blocks") or [])], + } + for index, block in enumerate(blocks) + ], + }, + block_count=len(blocks), + ) + + normalized_count = max(int(block_count or 1), 1) + return ensure_block_layout_defaults( + { + "input_block_count": normalized_count, + "default_full_farm": normalized_count == 1, + "algorithm_status": "pending", + "blocks": [ { "block_code": f"block-{index + 1}", "order": index + 1, @@ -29,16 +88,11 @@ def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) - "needs_subdivision": None, "sub_blocks": [], } - ) - - normalized_count = len(normalized_blocks) if normalized_blocks else max(int(block_count or 1), 1) - - return { - "input_block_count": normalized_count, - "default_full_farm": normalized_count == 1, - "algorithm_status": "pending", - "blocks": normalized_blocks, - } + for index in range(normalized_count) + ], + }, + block_count=normalized_count, + ) class SoilLocation(models.Model): @@ -122,8 +176,10 @@ class SoilLocation(models.Model): def save(self, *args, **kwargs): if not self.input_block_count: self.input_block_count = 1 - if not self.block_layout: - self.block_layout = build_block_layout(self.input_block_count) + self.block_layout = ensure_block_layout_defaults( + self.block_layout, + block_count=self.input_block_count, + ) super().save(*args, **kwargs) diff --git a/location_data/satellite_snapshot.py b/location_data/satellite_snapshot.py index 19efcd2..630d001 100644 --- a/location_data/satellite_snapshot.py +++ b/location_data/satellite_snapshot.py @@ -5,6 +5,7 @@ from typing import Any from django.db.models import Avg, QuerySet from .models import ( + ensure_block_layout_defaults, AnalysisGridObservation, RemoteSensingRun, RemoteSensingSubdivisionResult, @@ -90,7 +91,7 @@ def build_location_block_satellite_snapshots( *, sensor_payload: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: - block_layout = location.block_layout or {} + block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count) blocks = block_layout.get("blocks") or [] if not blocks: return [build_location_satellite_snapshot(location, sensor_payload=sensor_payload)] @@ -112,7 +113,7 @@ def build_block_layout_metric_summary( *, sensor_payload: dict[str, Any] | None = None, ) -> dict[str, Any]: - layout = dict(location.block_layout or {}) + layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count) blocks = [dict(block) for block in (layout.get("blocks") or [])] snapshots_by_block_code = { str(snapshot.get("block_code") or ""): snapshot @@ -461,7 +462,7 @@ def build_block_sensor_summary( def _build_active_sub_block_lookup(location: SoilLocation) -> dict[str, Any]: - block_layout = dict(location.block_layout or {}) + block_layout = ensure_block_layout_defaults(location.block_layout, block_count=location.input_block_count) by_cluster_uuid: dict[str, dict[str, Any]] = {} by_sub_block_code: dict[str, list[dict[str, Any]]] = {} by_block_and_cluster_label: dict[tuple[str, int], dict[str, Any]] = {} diff --git a/location_data/serializers.py b/location_data/serializers.py index 07a396e..7851de7 100644 --- a/location_data/serializers.py +++ b/location_data/serializers.py @@ -144,6 +144,10 @@ class RemoteSensingFarmRequestSerializer(serializers.Serializer): page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100) +class ClusterCropRecommendationRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + class RemoteSensingClusterBlockLiveRequestSerializer(serializers.Serializer): temporal_start = serializers.DateField(required=False) temporal_end = serializers.DateField(required=False) @@ -426,6 +430,54 @@ class RemoteSensingClusterBlockLiveResponseSerializer(serializers.Serializer): metadata = serializers.JSONField() +class ClusterCropRegisteredPlantSerializer(serializers.Serializer): + plant_id = serializers.IntegerField() + plant_name = serializers.CharField() + position = serializers.IntegerField(allow_null=True) + stage = serializers.CharField(allow_blank=True) + + +class ClusterCropCandidateSerializer(serializers.Serializer): + plant_id = serializers.IntegerField(allow_null=True) + plant_name = serializers.CharField() + position = serializers.IntegerField(allow_null=True) + stage = serializers.CharField(allow_blank=True) + score = serializers.FloatField() + predicted_yield = serializers.FloatField(allow_null=True) + predicted_yield_tons = serializers.FloatField(allow_null=True) + biomass = serializers.FloatField(allow_null=True) + max_lai = serializers.FloatField(allow_null=True) + simulation_engine = serializers.CharField(allow_null=True) + simulation_model_name = serializers.CharField(allow_null=True) + simulation_warning = serializers.CharField(allow_null=True, allow_blank=True) + supporting_metrics = serializers.JSONField() + + +class ClusterCropRecommendationClusterSerializer(serializers.Serializer): + block_code = serializers.CharField(allow_blank=True) + cluster_uuid = serializers.CharField(allow_null=True, allow_blank=True) + sub_block_code = serializers.CharField() + cluster_label = serializers.IntegerField(allow_null=True) + temporal_extent = serializers.JSONField(allow_null=True) + cluster_block = RemoteSensingClusterBlockSerializer(allow_null=True) + satellite_metrics = serializers.JSONField() + sensor_metrics = serializers.JSONField() + resolved_metrics = serializers.JSONField() + candidate_plants = ClusterCropCandidateSerializer(many=True) + suggested_plant = ClusterCropCandidateSerializer(allow_null=True) + source_metadata = serializers.JSONField() + + +class ClusterCropRecommendationResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + location_id = serializers.IntegerField() + evaluated_plant_count = serializers.IntegerField() + cluster_count = serializers.IntegerField() + registered_plants = ClusterCropRegisteredPlantSerializer(many=True) + clusters = ClusterCropRecommendationClusterSerializer(many=True) + source_metadata = serializers.JSONField() + + class RemoteSensingSubdivisionOptionListResponseSerializer(serializers.Serializer): result_id = serializers.IntegerField() active_requested_k = serializers.IntegerField(allow_null=True) diff --git a/location_data/test_cluster_recommendation_api.py b/location_data/test_cluster_recommendation_api.py new file mode 100644 index 0000000..ea4ee33 --- /dev/null +++ b/location_data/test_cluster_recommendation_api.py @@ -0,0 +1,281 @@ +from datetime import date +from unittest.mock import patch + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from farm_data.models import FarmPlantAssignment, PlantCatalogSnapshot, SensorData +from location_data.models import ( + AnalysisGridCell, + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingClusterAssignment, + RemoteSensingClusterBlock, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) +from weather.models import WeatherForecast + + +@override_settings(ROOT_URLCONF="location_data.urls") +class RemoteSensingClusterRecommendationApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3900, 35.6890], + [51.3900, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + } + self.location = SoilLocation.objects.create( + latitude="35.689200", + longitude="51.389000", + farm_boundary=self.boundary, + ) + self.location.set_input_block_count(1) + self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"]) + self.farm = SensorData.objects.create( + farm_uuid="11111111-1111-1111-1111-111111111111", + center_location=self.location, + sensor_payload={}, + ) + for day_index in range(1, 5): + WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2025, 2, day_index), + temperature_min=12.0, + temperature_max=24.0, + temperature_mean=18.0, + precipitation=1.0, + precipitation_probability=25.0, + humidity_mean=55.0, + wind_speed_max=10.0, + et0=3.0, + weather_code=1, + ) + self.subdivision = BlockSubdivision.objects.create( + soil_location=self.location, + block_code="block-1", + source_boundary=self.boundary, + chunk_size_sqm=900, + status="subdivided", + ) + self.run = RemoteSensingRun.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + status=RemoteSensingRun.STATUS_SUCCESS, + metadata={"stage": "completed"}, + ) + self.result = RemoteSensingSubdivisionResult.objects.create( + soil_location=self.location, + run=self.run, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + cluster_count=2, + selected_features=["ndvi", "ndwi", "soil_vv_db"], + metadata={"used_cell_count": 2, "skipped_cell_count": 0}, + ) + + self.cell_1 = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + cell_code="cell-1", + chunk_size_sqm=900, + geometry=self.boundary, + centroid_lat="35.689250", + centroid_lon="51.389250", + ) + self.cell_2 = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + cell_code="cell-2", + chunk_size_sqm=900, + geometry=self.boundary, + centroid_lat="35.689750", + centroid_lon="51.389750", + ) + AnalysisGridObservation.objects.create( + cell=self.cell_1, + run=self.run, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + ndvi=0.51, + ndwi=0.24, + soil_vv=0.13, + soil_vv_db=-10.0, + metadata={"backend_name": "openeo"}, + ) + AnalysisGridObservation.objects.create( + cell=self.cell_2, + run=self.run, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + ndvi=0.71, + ndwi=0.48, + soil_vv=0.19, + soil_vv_db=-7.5, + metadata={"backend_name": "openeo"}, + ) + RemoteSensingClusterAssignment.objects.create( + result=self.result, + cell=self.cell_1, + cluster_label=0, + raw_feature_values={"ndvi": 0.51, "ndwi": 0.24, "soil_vv_db": -10.0}, + scaled_feature_values={"ndvi": -1.0, "ndwi": -1.0, "soil_vv_db": -1.0}, + ) + RemoteSensingClusterAssignment.objects.create( + result=self.result, + cell=self.cell_2, + cluster_label=1, + raw_feature_values={"ndvi": 0.71, "ndwi": 0.48, "soil_vv_db": -7.5}, + scaled_feature_values={"ndvi": 1.0, "ndwi": 1.0, "soil_vv_db": 1.0}, + ) + self.cluster_0 = RemoteSensingClusterBlock.objects.create( + result=self.result, + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + sub_block_code="cluster-0", + cluster_label=0, + chunk_size_sqm=900, + centroid_lat="35.689250", + centroid_lon="51.389250", + center_cell_code="cell-1", + cell_count=1, + cell_codes=["cell-1"], + geometry=self.boundary, + ) + self.cluster_1 = RemoteSensingClusterBlock.objects.create( + result=self.result, + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + sub_block_code="cluster-1", + cluster_label=1, + chunk_size_sqm=900, + centroid_lat="35.689750", + centroid_lon="51.389750", + center_cell_code="cell-2", + cell_count=1, + cell_codes=["cell-2"], + geometry=self.boundary, + ) + + self.location.block_layout = { + "input_block_count": 1, + "default_full_farm": True, + "algorithm_status": "completed", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "input", + "boundary": self.boundary, + "needs_subdivision": True, + "sub_blocks": [ + { + "sub_block_code": "cluster-0", + "cluster_label": 0, + "cluster_uuid": str(self.cluster_0.uuid), + }, + { + "sub_block_code": "cluster-1", + "cluster_label": 1, + "cluster_uuid": str(self.cluster_1.uuid), + }, + ], + } + ], + } + self.location.save(update_fields=["block_layout", "updated_at"]) + + self.tomato = PlantCatalogSnapshot.objects.create( + backend_plant_id=101, + name="Tomato", + growth_profile={"simulation": {"crop_parameters": {"crop_name": "Tomato", "MAX_BIOMASS": 14000.0}}}, + ) + self.wheat = PlantCatalogSnapshot.objects.create( + backend_plant_id=102, + name="Wheat", + growth_profile={"simulation": {"crop_parameters": {"crop_name": "Wheat", "MAX_BIOMASS": 11000.0}}}, + ) + FarmPlantAssignment.objects.create(farm=self.farm, plant=self.tomato, position=0, stage="vegetative") + FarmPlantAssignment.objects.create(farm=self.farm, plant=self.wheat, position=1, stage="vegetative") + + @patch("location_data.cluster_recommendation._simulate_candidate") + def test_cluster_recommendations_return_ranked_plants_for_each_cluster(self, simulate_mock): + def fake_simulation(*, base_payload, soil_parameters, site_parameters): + plant_name = base_payload["crop_parameters"]["crop_name"] + smfcf = float(soil_parameters["SMFCF"]) + if plant_name == "Tomato": + yield_estimate = 150.0 if smfcf >= 0.4 else 80.0 + else: + yield_estimate = 110.0 if smfcf >= 0.4 else 120.0 + return ( + { + "engine": "pcse", + "model_name": "Wofost81_NWLP_CWB_CNB", + "metrics": { + "yield_estimate": yield_estimate, + "biomass": yield_estimate * 2, + "max_lai": 4.2, + }, + }, + None, + ) + + simulate_mock.side_effect = fake_simulation + + response = self.client.get( + "/remote-sensing/cluster-recommendations/", + data={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["cluster_count"], 2) + self.assertEqual(payload["evaluated_plant_count"], 2) + self.assertEqual(len(payload["registered_plants"]), 2) + + clusters = {item["cluster_label"]: item for item in payload["clusters"]} + self.assertEqual(clusters[0]["resolved_metrics"]["ndvi"], 0.51) + self.assertEqual(clusters[0]["resolved_metrics"]["ndwi"], 0.24) + self.assertEqual(clusters[0]["resolved_metrics"]["soil_vv"], 0.13) + self.assertEqual(clusters[1]["resolved_metrics"]["ndwi"], 0.48) + + self.assertEqual(clusters[0]["suggested_plant"]["plant_name"], "Wheat") + self.assertEqual(clusters[1]["suggested_plant"]["plant_name"], "Tomato") + self.assertEqual(clusters[0]["candidate_plants"][0]["score"], 120.0) + self.assertEqual(clusters[1]["candidate_plants"][0]["score"], 150.0) + self.assertEqual(clusters[0]["cluster_block"]["uuid"], str(self.cluster_0.uuid)) + self.assertEqual(clusters[1]["cluster_block"]["uuid"], str(self.cluster_1.uuid)) + + def test_cluster_recommendations_return_400_when_no_plants_registered(self): + FarmPlantAssignment.objects.all().delete() + + response = self.client.get( + "/remote-sensing/cluster-recommendations/", + data={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["msg"], + "برای این مزرعه هنوز هیچ گیاهی در farm_data ثبت نشده است.", + ) diff --git a/location_data/test_soil_api.py b/location_data/test_soil_api.py index ceef7a4..139ce16 100644 --- a/location_data/test_soil_api.py +++ b/location_data/test_soil_api.py @@ -57,10 +57,29 @@ class SoilDataApiTests(TestCase): self.assertEqual(len(payload["block_layout"]["blocks"]), 1) self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary) self.assertEqual(payload["block_layout"]["algorithm_status"], "pending") + self.assertEqual(len(payload["block_layout"]["blocks"][0]["sub_blocks"]), 1) + self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["sub_block_code"], "block-1-sub-1") + self.assertEqual(payload["block_layout"]["blocks"][0]["sub_blocks"][0]["cluster_label"], 0) self.assertEqual(len(payload["block_subdivisions"]), 1) self.assertEqual(payload["block_subdivisions"][0]["status"], "defined") self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing") + def test_model_default_layout_includes_default_sub_block_when_missing(self): + location = SoilLocation.objects.create( + latitude="35.689201", + longitude="51.389001", + ) + + self.assertEqual(location.input_block_count, 1) + self.assertEqual(location.block_layout["blocks"][0]["block_code"], "block-1") + self.assertEqual(len(location.block_layout["blocks"][0]["sub_blocks"]), 1) + self.assertEqual( + location.block_layout["blocks"][0]["sub_blocks"][0]["sub_block_code"], + "block-1-sub-1", + ) + self.assertEqual(location.block_layout["blocks"][0]["sub_blocks"][0]["source"], "default") + + def test_post_updates_block_layout_from_input(self): SoilLocation.objects.create( latitude="35.689200", diff --git a/location_data/urls.py b/location_data/urls.py index 84bb8c4..d9abd53 100644 --- a/location_data/urls.py +++ b/location_data/urls.py @@ -4,6 +4,7 @@ from .views import ( NdviHealthView, RemoteSensingAnalysisView, RemoteSensingClusterBlockLiveView, + RemoteSensingClusterRecommendationView, RemoteSensingSubdivisionOptionActivateView, RemoteSensingSubdivisionOptionListView, RemoteSensingRunStatusView, @@ -18,6 +19,11 @@ urlpatterns = [ RemoteSensingClusterBlockLiveView.as_view(), name="remote-sensing-cluster-block-live", ), + path( + "remote-sensing/cluster-recommendations/", + RemoteSensingClusterRecommendationView.as_view(), + name="remote-sensing-cluster-recommendations", + ), path( "remote-sensing/results//k-options/", RemoteSensingSubdivisionOptionListView.as_view(), diff --git a/location_data/views.py b/location_data/views.py index 2c19018..39be17d 100644 --- a/location_data/views.py +++ b/location_data/views.py @@ -37,8 +37,15 @@ from farm_data.models import SensorData from .data_driven_subdivision import DEFAULT_CLUSTER_FEATURES from .data_driven_subdivision import activate_subdivision_option +from .cluster_recommendation import ( + ClusterRecommendationNotFound, + ClusterRecommendationValidationError, + build_cluster_crop_recommendations, +) from .serializers import ( BlockSubdivisionSerializer, + ClusterCropRecommendationRequestSerializer, + ClusterCropRecommendationResponseSerializer, NdviHealthRequestSerializer, NdviHealthResponseSerializer, RemoteSensingCellObservationSerializer, @@ -140,6 +147,10 @@ RemoteSensingClusterBlockLiveEnvelopeSerializer = build_envelope_serializer( "RemoteSensingClusterBlockLiveEnvelopeSerializer", RemoteSensingClusterBlockLiveResponseSerializer, ) +ClusterCropRecommendationEnvelopeSerializer = build_envelope_serializer( + "ClusterCropRecommendationEnvelopeSerializer", + ClusterCropRecommendationResponseSerializer, +) RemoteSensingSubdivisionOptionListEnvelopeSerializer = build_envelope_serializer( "RemoteSensingSubdivisionOptionListEnvelopeSerializer", RemoteSensingSubdivisionOptionListResponseSerializer, @@ -843,6 +854,75 @@ class RemoteSensingClusterBlockLiveView(APIView): ) +class RemoteSensingClusterRecommendationView(APIView): + @extend_schema( + tags=["Location Data"], + summary="پیشنهاد گیاه برای هر کلاستر KMeans", + description=( + "با دریافت farm_uuid، داده هر کلاستر KMeans location_data به‌همراه " + "ndvi، ndwi، soil_vv، soil_vv_db و مقایسه گیاه‌های ثبت‌شده در farm_data " + "با crop_simulation برگردانده می‌شود و برای هر زیر‌بلاک یک گیاه پیشنهادی ارائه می‌شود." + ), + parameters=[ + OpenApiParameter( + name="farm_uuid", + type=str, + location=OpenApiParameter.QUERY, + required=True, + description="شناسه یکتای مزرعه", + ), + ], + responses={ + 200: build_response( + ClusterCropRecommendationEnvelopeSerializer, + "داده کلاسترها و پیشنهاد گیاه برای هر زیر‌بلاک بازگردانده شد.", + ), + 400: build_response( + SoilErrorResponseSerializer, + "پیش‌نیازهای مقایسه نامعتبر یا ناقص است.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "مزرعه یا خروجی KMeans یافت نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست پیشنهاد گیاه برای کلاسترها", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + parameter_only=("farm_uuid", "query"), + ) + ], + ) + def get(self, request): + serializer = ClusterCropRecommendationRequestSerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + payload = build_cluster_crop_recommendations( + str(serializer.validated_data["farm_uuid"]) + ) + except ClusterRecommendationNotFound as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + except ClusterRecommendationValidationError as exc: + return Response( + {"code": 400, "msg": str(exc), "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response( + {"code": 200, "msg": "success", "data": payload}, + status=status.HTTP_200_OK, + ) + + class RemoteSensingSubdivisionOptionListView(APIView): @extend_schema( tags=["Location Data"], diff --git a/pest_disease/Untitled b/pest_disease/Untitled new file mode 100644 index 0000000..877ce34 --- /dev/null +++ b/pest_disease/Untitled @@ -0,0 +1,15 @@ + +feature_decision(feature) := result if { + has_feature_rule(feature) + rule := feature_rule(feature) + matched := [matched_rule | matched_rule := rule; action_match(matched_rule)] + deny_rules := [matched_rule | matched_rule := matched[_]; not object.get(matched_rule, "allow", false)] + allow_rules := [matched_rule | matched_rule := matched[_]; object.get(matched_rule, "allow", false)] + count(deny_rules) > 0 + result := { + "allow": false, + "matched_rules": matched, + "deny_rules": deny_rules, + "allow_rules": allow_rules, + } +} \ No newline at end of file diff --git a/rag/chat.py b/rag/chat.py index 4fbbd57..a1b9024 100644 --- a/rag/chat.py +++ b/rag/chat.py @@ -130,9 +130,9 @@ def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) def _format_farm_context(farm_uuid: str) -> str: - from farm_data.services import get_farm_details + from farm_data.services import build_ai_farm_snapshot - farm_details = get_farm_details(farm_uuid) + farm_details = build_ai_farm_snapshot(farm_uuid) if not farm_details: raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index cc61870..b64a9df 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -13,7 +13,12 @@ from typing import Any from django.apps import apps from farm_data.models import SensorData -from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name +from farm_data.services import ( + build_ai_farm_snapshot, + clone_snapshot_as_runtime_plant, + get_ai_snapshot_weather, + get_farm_plant_snapshot_by_name, +) from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -628,6 +633,7 @@ def get_fertilization_recommendation( .filter(farm_uuid=resolved_farm_uuid) .first() ) + ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid) plant_config = apps.get_app_config("plant") resolved_plant_name = plant_config.resolve_plant_name(plant_name) @@ -662,6 +668,7 @@ def get_fertilization_recommendation( plant=plant, forecasts=forecasts, growth_stage=resolved_growth_stage, + ai_snapshot=ai_snapshot, ) context = build_rag_context( @@ -727,6 +734,9 @@ def get_fertilization_recommendation( growth_stage=resolved_growth_stage, forecasts=forecasts, ) + result.setdefault("source_metadata", {}) + result["source_metadata"]["farm_metrics"] = (ai_snapshot or {}).get("source_metadata", {}).get("farm_metrics", {}) + result["source_metadata"]["weather"] = {"source": "center_location_forecast", "policy": "center_location_latest_forecast"} result = _validate_fertilization_response(result) result["raw_response"] = raw result["simulation_optimizer"] = optimized_result diff --git a/rag/services/fertilization_plan_parser.py b/rag/services/fertilization_plan_parser.py index 0a6c332..f57d031 100644 --- a/rag/services/fertilization_plan_parser.py +++ b/rag/services/fertilization_plan_parser.py @@ -6,6 +6,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field, ValidationError +from farm_data.services import build_ai_farm_snapshot from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -124,7 +125,14 @@ class FertilizationPlanParserService: "partial_plan": normalized_partial, "required_core_fields": CORE_FIELDS, "service": "fertilization_plan_parser", + "endpoint_policy": "parser_first", } + if farm_uuid: + # Parser-first endpoint: farm context is optional enrichment only. + structured_context["farm_context_source_metadata"] = { + "source": "build_ai_farm_snapshot", + "optional": True, + } rag_query = self._build_retrieval_query( message=normalized_message, diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index a0b6b7d..88db21a 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -11,7 +11,10 @@ from django.db import transaction from farm_data.models import SensorData from farm_data.services import ( + build_ai_farm_snapshot, clone_snapshot_as_runtime_plant, + get_ai_snapshot_metric, + get_ai_snapshot_weather, get_farm_plant_snapshot_by_name, ) from irrigation.evapotranspiration import ( @@ -55,13 +58,15 @@ def _safe_float(value: Any, default: float = 0.0) -> float: return default -def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None: - if sensor is None or not isinstance(sensor.sensor_payload, dict): - return None - for payload in sensor.sensor_payload.values(): - if isinstance(payload, dict) and payload.get(metric) is not None: - return _safe_float(payload.get(metric), default=0.0) - return None +def _aggregated_metric(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None: + value = get_ai_snapshot_metric(ai_snapshot, metric) + return _safe_float(value, default=0.0) if value is not None else None + + + +def _aggregated_metric_fallback(ai_snapshot: dict[str, Any] | None, metric: str) -> float | None: + """Limited fallback for missing aggregated metrics only; raw payload is intentionally not consulted.""" + return _aggregated_metric(ai_snapshot, metric) def _coerce_list(value: Any) -> list[Any]: @@ -275,9 +280,9 @@ def _build_irrigation_ui_payload( crop_profile: dict[str, Any], active_kc: float, irrigation_method: IrrigationMethod | None, - sensor: SensorData | None, + ai_snapshot: dict[str, Any] | None, ) -> dict[str, Any]: - soil_moisture = _sensor_metric(sensor, "soil_moisture") + soil_moisture = _aggregated_metric_fallback(ai_snapshot, "soil_moisture") plan = _normalize_plan( llm_result, optimizer_result, @@ -380,6 +385,7 @@ def get_irrigation_recommendation( .filter(farm_uuid=resolved_farm_uuid) .first() ) + ai_snapshot = build_ai_farm_snapshot(resolved_farm_uuid) irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name) _persist_irrigation_method_on_farm(sensor, irrigation_method) @@ -423,6 +429,7 @@ def get_irrigation_recommendation( if plant is not None and forecasts: optimized_result = _get_optimizer().optimize_irrigation( sensor=sensor, + ai_snapshot=ai_snapshot, plant=plant, forecasts=forecasts, daily_water_needs=daily_water_needs, @@ -518,7 +525,7 @@ def get_irrigation_recommendation( crop_profile, active_kc, irrigation_method, - sensor, + ai_snapshot, ) result["raw_response"] = raw result["simulation_optimizer"] = optimized_result diff --git a/rag/services/irrigation_plan_parser.py b/rag/services/irrigation_plan_parser.py index 90e5349..3a0a45a 100644 --- a/rag/services/irrigation_plan_parser.py +++ b/rag/services/irrigation_plan_parser.py @@ -6,6 +6,7 @@ from typing import Any, Literal from pydantic import BaseModel, Field, ValidationError +from farm_data.services import build_ai_farm_snapshot from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -120,7 +121,14 @@ class IrrigationPlanParserService: "partial_plan": normalized_partial, "required_core_fields": CORE_FIELDS, "service": "irrigation_plan_parser", + "endpoint_policy": "parser_first", } + if farm_uuid: + # Parser-first endpoint: farm context is optional enrichment only. + structured_context["farm_context_source_metadata"] = { + "source": "build_ai_farm_snapshot", + "optional": True, + } rag_query = self._build_retrieval_query( message=normalized_message, diff --git a/rag/services/pest_disease.py b/rag/services/pest_disease.py index 5b71c66..9b172a1 100644 --- a/rag/services/pest_disease.py +++ b/rag/services/pest_disease.py @@ -7,7 +7,7 @@ import json import logging from typing import Any -from farm_data.services import get_farm_details +from farm_data.services import build_ai_farm_snapshot from rag.api_provider import get_chat_client from rag.chat import ( _build_content_parts, @@ -106,7 +106,7 @@ def _clean_json(raw: str) -> dict[str, Any]: def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: - farm_details = get_farm_details(farm_uuid) + farm_details = build_ai_farm_snapshot(farm_uuid) if farm_details is None: raise RAGServiceError( error_code="farm_not_found", @@ -134,8 +134,8 @@ def _build_service_client(cfg: RAGConfig): def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]: - weather = farm_details.get("weather") or {} - soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {} + weather = ((farm_details.get("weather") or {}).get("forecast") or {}) + soil = (farm_details.get("farm_metrics") or {}).get("resolved_metrics") or {} humidity = _safe_float(weather.get("humidity_mean"), 55.0) temp = _safe_float(weather.get("temperature_mean"), 24.0) rain = _safe_float(weather.get("precipitation"), 0.0) diff --git a/rag/services/soil_anomaly.py b/rag/services/soil_anomaly.py index 11fb364..6051695 100644 --- a/rag/services/soil_anomaly.py +++ b/rag/services/soil_anomaly.py @@ -4,7 +4,7 @@ import json import logging from typing import Any -from farm_data.services import get_farm_details +from farm_data.services import build_ai_farm_snapshot from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -73,7 +73,7 @@ def _clean_json(raw: str) -> dict[str, Any]: def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: - farm_details = get_farm_details(farm_uuid) + farm_details = build_ai_farm_snapshot(farm_uuid) if farm_details is None: raise RAGServiceError( error_code="farm_not_found", diff --git a/rag/services/water_need_prediction.py b/rag/services/water_need_prediction.py index ce93745..f7b4c25 100644 --- a/rag/services/water_need_prediction.py +++ b/rag/services/water_need_prediction.py @@ -4,7 +4,7 @@ import json import logging from typing import Any -from farm_data.services import get_farm_details +from farm_data.services import build_ai_farm_snapshot, get_ai_snapshot_metric, get_ai_snapshot_weather from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -71,7 +71,7 @@ def _clean_json(raw: str) -> dict[str, Any]: def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: - farm_details = get_farm_details(farm_uuid) + farm_details = build_ai_farm_snapshot(farm_uuid) if farm_details is None: raise RAGServiceError( error_code="farm_not_found", @@ -158,6 +158,16 @@ def get_water_need_prediction_insight( structured_context = { "farm_uuid": farm_uuid, "prediction_payload": prediction_payload, + "source_metadata": { + "agronomic_metrics": { + "source": "build_ai_farm_snapshot", + "policy": "cluster_block_farm_aggregated", + }, + "weather": { + "source": "center_location_forecast", + "policy": "center_location_latest_forecast", + }, + }, } rag_context = build_rag_context( query=user_query, @@ -208,4 +218,14 @@ def get_water_need_prediction_insight( parsed["source"] = "llm" parsed["farm_uuid"] = farm_uuid parsed["raw_response"] = raw + parsed["source_metadata"] = { + "agronomic_metrics": { + "source": "build_ai_farm_snapshot", + "policy": "cluster_block_farm_aggregated", + }, + "weather": { + "source": "center_location_forecast", + "policy": "center_location_latest_forecast", + }, + } return parsed diff --git a/rag/tests/test_chat_context.py b/rag/tests/test_chat_context.py index 30e4b7c..fe5146f 100644 --- a/rag/tests/test_chat_context.py +++ b/rag/tests/test_chat_context.py @@ -69,3 +69,56 @@ class ChatContextTests(SimpleTestCase): self.assertIn("[اطلاعات کامل مزرعه]", context) self.assertIn("soil_moisture", context) + + +class CanonicalFarmContextTests(SimpleTestCase): + @patch("rag.chat.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-123", "farm_metrics": {"resolved_metrics": {"soil_moisture": 41.0}}}) + def test_format_farm_context_uses_canonical_ai_snapshot(self, mock_snapshot): + from rag.chat import _format_farm_context + + context = _format_farm_context("farm-123") + + self.assertIn("farm_metrics", context) + self.assertIn("soil_moisture", context) + mock_snapshot.assert_called_once_with("farm-123") + + +class CanonicalUserSoilTextTests(SimpleTestCase): + @patch( + "rag.user_data.build_ai_farm_snapshot", + return_value={ + "farm_uuid": "farm-123", + "aggregation_policy": { + "sensor": "cluster_mean_then_block_mean_then_farm_mean", + "satellite": "cluster_mean_then_block_mean_then_farm_mean", + "weather": "center_location_latest_forecast", + }, + "farm_metrics": {"resolved_metrics": {"soil_moisture": 42.0, "nitrogen": 18.0}}, + "block_metrics": [ + {"block_code": "block-1", "resolved_metrics": {"soil_moisture": 40.0}}, + {"block_code": "block-2", "resolved_metrics": {"soil_moisture": 44.0}}, + ], + "sub_block_metrics": [ + {"block_code": "block-1", "sub_block_code": "cluster-a", "resolved_metrics": {"soil_moisture": 39.0}}, + {"block_code": "block-2", "sub_block_code": "cluster-b", "resolved_metrics": {"soil_moisture": 45.0}}, + ], + "source_metadata": { + "farm_metrics": { + "canonical_source": "farmer_block_aggregated_snapshot", + "aggregation_strategy": "farmer_block_mean", + } + }, + }, + ) + def test_build_user_soil_text_uses_only_canonical_snapshot(self, mock_snapshot): + from rag.user_data import build_user_soil_text + + text = build_user_soil_text("farm-123") + + self.assertIn("سیاست تجمیع خاک", text) + self.assertIn("خلاصه تجمیع‌شده مزرعه", text) + self.assertIn("بلوک block-1", text) + self.assertIn("زیر-بلوک cluster-a", text) + self.assertIn("canonical_source: farmer_block_aggregated_snapshot", text) + self.assertNotIn("sensor_payload", text) + mock_snapshot.assert_called_once_with("farm-123") diff --git a/rag/tests/test_failure_contracts.py b/rag/tests/test_failure_contracts.py index 135d557..ab7b787 100644 --- a/rag/tests/test_failure_contracts.py +++ b/rag/tests/test_failure_contracts.py @@ -86,3 +86,39 @@ class RAGFailureContractTests(SimpleTestCase): YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"}) self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") + + + @patch("rag.services.soil_anomaly.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"}) + def test_soil_anomaly_loads_canonical_snapshot(self, mock_snapshot): + from rag.services.soil_anomaly import _load_farm_or_error + + payload = _load_farm_or_error("farm-1") + + self.assertEqual(payload["farm_uuid"], "farm-1") + mock_snapshot.assert_called_once_with("farm-1") + + @patch( + "rag.services.pest_disease.build_ai_farm_snapshot", + return_value={ + "farm_uuid": "farm-1", + "weather": {"forecast": {"humidity_mean": 75.0, "temperature_mean": 31.0, "precipitation": 3.0}}, + "farm_metrics": {"resolved_metrics": {"soil_moisture": 66.0, "electrical_conductivity": 2.8, "soil_ph": 7.9}}, + }, + ) + def test_pest_risk_context_reads_canonical_snapshot_shape(self, mock_snapshot): + from rag.services.pest_disease import _build_risk_context, _load_farm_or_error + + farm_details = _load_farm_or_error("farm-1") + risk = _build_risk_context(farm_details, plant_name=None, growth_stage=None) + + self.assertEqual(risk["overall_risk"], "high") + self.assertIn("EC بالا", risk["key_drivers"]) + mock_snapshot.assert_called_once_with("farm-1") + + @patch("rag.services.pest_disease.build_ai_farm_snapshot", return_value={"farm_uuid": "farm-1"}) + def test_pest_detection_remains_image_first_with_optional_farm_context(self, mock_snapshot): + with self.assertRaises(RAGServiceError) as exc_info: + get_pest_disease_detection(farm_uuid="farm-1", images=[]) + + self.assertEqual(exc_info.exception.contract.error_code, "missing_images") + mock_snapshot.assert_not_called() diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py index f7184e4..1ad003b 100644 --- a/rag/tests/test_recommendation_services.py +++ b/rag/tests/test_recommendation_services.py @@ -385,3 +385,47 @@ class RecommendationServiceDefaultsTests(TestCase): self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0) + + +class RecommendationCanonicalSnapshotTests(TestCase): + @patch("rag.services.irrigation.get_chat_client") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation.build_ai_farm_snapshot") + def test_irrigation_ui_payload_uses_aggregated_snapshot_metrics(self, mock_snapshot, _mock_context, mock_client): + from rag.services.irrigation import _build_irrigation_ui_payload + + mock_snapshot.return_value = {"farm_metrics": {"resolved_metrics": {"soil_moisture": 44.0}}} + payload = _build_irrigation_ui_payload( + llm_result={"plan": {}, "timeline": [], "sections": []}, + optimizer_result=None, + daily_water_needs=[], + crop_profile={}, + active_kc=0.9, + irrigation_method=None, + ai_snapshot=mock_snapshot.return_value, + ) + + self.assertEqual(payload["plan"]["moistureLevel"], 44) + + @patch("rag.services.fertilization._get_optimizer") + @patch("rag.services.fertilization.get_chat_client") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization.build_ai_farm_snapshot") + def test_fertilization_recommendation_includes_snapshot_provenance(self, mock_snapshot, _mock_context, mock_client, mock_optimizer): + from rag.services.fertilization import get_fertilization_recommendation + + client = mock_client.return_value + client.chat.completions.create.return_value = type("Resp", (), {"choices": [type("Choice", (), {"message": type("Msg", (), {"content": '{"status": "success", "data": {}}'})()})()]})() + mock_snapshot.return_value = { + "source_metadata": { + "farm_metrics": {"canonical_source": "farmer_block_aggregated_snapshot"}, + } + } + mock_optimizer.return_value.optimize_fertilization.return_value = None + + with patch("rag.services.fertilization.SensorData.objects.select_related") as mock_select: + mock_select.return_value.prefetch_related.return_value.filter.return_value.first.return_value = None + result = get_fertilization_recommendation(farm_uuid="farm-1") + + self.assertEqual(result["source_metadata"]["farm_metrics"]["canonical_source"], "farmer_block_aggregated_snapshot") + self.assertEqual(result["source_metadata"]["weather"]["policy"], "center_location_latest_forecast") diff --git a/rag/views.py b/rag/views.py index 79fb9c4..9bfa637 100644 --- a/rag/views.py +++ b/rag/views.py @@ -132,7 +132,7 @@ class ChatView(APIView): ], ) def post(self, request: Request): - from farm_data.services import get_farm_details + from farm_data.services import build_ai_farm_snapshot from .config import load_rag_config data = request.data if request.method == "POST" else request.query_params @@ -178,7 +178,7 @@ class ChatView(APIView): status=status.HTTP_400_BAD_REQUEST, ) cfg = load_rag_config() - farm_details = get_farm_details(farm_uuid) + farm_details = build_ai_farm_snapshot(farm_uuid) if farm_details is None: return Response( {"code": 404, "msg": "farm پیدا نشد."}, diff --git a/scripts/export_location_data_seed.py b/scripts/export_location_data_seed.py new file mode 100644 index 0000000..17b4678 --- /dev/null +++ b/scripts/export_location_data_seed.py @@ -0,0 +1,55 @@ +import json +from pathlib import Path + +import MySQLdb +from dotenv import dotenv_values + +TABLES = [ + "location_data_soillocation", + "location_data_blocksubdivision", + "location_data_remotesensingrun", + "location_data_remotesensingsubdivisionresult", + "location_data_remotesensingclusterblock", + "location_data_remotesensingclusterassignment", + "location_data_analysisgridcell", + "location_data_analysisgridobservation", + "location_data_remotesensingsubdivisionoption", + "location_data_remotesensingsubdivisionoptionblock", + "location_data_remotesensingsubdivisionoptionassignment", + "dashboard_data_ndviobservation", +] + + +def main() -> None: + env = dotenv_values(Path(__file__).resolve().parent.parent / ".env") + conn = MySQLdb.connect( + host=env.get("DB_HOST", "127.0.0.1"), + port=int(env.get("DB_PORT", 3306)), + user=env.get("DB_USER", ""), + passwd=env.get("DB_PASSWORD", ""), + db=env.get("DB_NAME", ""), + charset="utf8mb4", + ) + out: dict[str, list[dict]] = {} + try: + with conn as cursor: + for table in TABLES: + cursor.execute(f"SELECT * FROM {table}") + columns = [col[0] for col in cursor.description] + rows = [] + for raw_row in cursor.fetchall(): + row = {} + for key, value in zip(columns, raw_row): + if isinstance(value, (bytes, bytearray)): + value = value.decode("utf-8") + row[key] = value + rows.append(row) + out[table] = rows + finally: + conn.close() + + print(json.dumps(out, ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/soile/services.py b/soile/services.py index 0e3cfff..9f072dc 100644 --- a/soile/services.py +++ b/soile/services.py @@ -281,6 +281,8 @@ def _heatmap_summary(sensor_points: list[dict[str, Any]], grid_cells: list[dict[ class SoilMoistureHeatmapService: def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]: + # Spatial-first endpoint: preserve grid/cluster-aware output and never flatten the + # heatmap to a single farm-average metric. Aggregated summaries are supplementary only. current_sensor = ( SensorData.objects.select_related("center_location") .prefetch_related("plant_assignments__plant") @@ -329,6 +331,11 @@ class SoilMoistureHeatmapService: ], }, "summary": _heatmap_summary(sensor_points, []), + "source_metadata": { + "endpoint_policy": "spatial_first", + "primary_source": "sensor_network_spatial_interpolation", + "agronomic_aggregation": "supplementary_only", + }, "quality_legend": { QUALITY_REAL: "اندازه گیری واقعی سنسور", QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", @@ -438,6 +445,11 @@ class SoilMoistureHeatmapService: ], }, "summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]), + "source_metadata": { + "endpoint_policy": "spatial_first", + "primary_source": "sensor_network_spatial_interpolation", + "agronomic_aggregation": "supplementary_only", + }, "quality_legend": { QUALITY_REAL: "اندازه گیری واقعی سنسور", QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", diff --git a/weather/farm_weather.py b/weather/farm_weather.py index 953b9ce..032e8f1 100644 --- a/weather/farm_weather.py +++ b/weather/farm_weather.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any from farm_data.models import SensorData +from farm_data.services import build_ai_farm_snapshot from .services import get_forecast_for_location @@ -39,7 +40,7 @@ def _weather_condition(weather_code): return WMO_CONDITIONS.get(weather_code, "نامشخص") -def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]: +def _build_farm_weather_card(forecasts: list[Any], *, farm_uuid: str | None = None, ai_snapshot: dict[str, Any] | None = None) -> dict[str, Any]: if not forecasts: return { "condition": "نامشخص", @@ -49,6 +50,10 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]: "windSpeed": 0, "windUnit": "km/h", "chartData": {"labels": [], "series": [[]]}, + "source_metadata": { + "weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"}, + "agronomic_metrics": {"source": "build_ai_farm_snapshot", "policy": "cluster_block_farm_aggregated"}, + }, } current_forecast = forecasts[0] @@ -66,6 +71,14 @@ def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]: "labels": labels, "series": series, }, + "source_metadata": { + "weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"}, + "agronomic_metrics": { + "source": "build_ai_farm_snapshot", + "policy": "cluster_block_farm_aggregated", + "available": bool(ai_snapshot), + }, + }, } @@ -80,4 +93,5 @@ class FarmWeatherService: raise ValueError("Farm not found.") forecasts = get_forecast_for_location(sensor.center_location, days=7) - return _build_farm_weather_card(forecasts) + ai_snapshot = build_ai_farm_snapshot(farm_uuid) + return _build_farm_weather_card(forecasts, farm_uuid=farm_uuid, ai_snapshot=ai_snapshot) diff --git a/weather/test_farm_weather_api.py b/weather/test_farm_weather_api.py index bdcb262..2bfec08 100644 --- a/weather/test_farm_weather_api.py +++ b/weather/test_farm_weather_api.py @@ -28,6 +28,10 @@ class FarmWeatherApiTests(TestCase): "labels": ["2026-04-01", "2026-04-02"], "series": [[28.0, 29.0]], }, + "source_metadata": { + "weather": {"source": "center_location_forecast", "policy": "center_location_latest_forecast"}, + "agronomic_metrics": {"source": "build_ai_farm_snapshot", "policy": "cluster_block_farm_aggregated", "available": True}, + }, } ) mock_get_app_config.return_value = SimpleNamespace( @@ -44,6 +48,7 @@ class FarmWeatherApiTests(TestCase): payload = response.json()["data"] self.assertEqual(payload["condition"], "صاف") self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01") + self.assertEqual(payload["source_metadata"]["weather"]["policy"], "center_location_latest_forecast") @patch("weather.views.apps.get_app_config") def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config): @@ -106,6 +111,8 @@ class WaterNeedPredictionApiTests(TestCase): payload = response.json()["data"] self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") self.assertEqual(payload["insight"]["confidence"], 0.82) + self.assertEqual(payload["source_metadata"]["agronomic_metrics"]["policy"], "cluster_block_farm_aggregated") + self.assertEqual(payload["source_metadata"]["weather"]["policy"], "center_location_latest_forecast") @patch("weather.views.apps.get_app_config") def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config): @@ -150,3 +157,8 @@ class WaterNeedPredictionApiTests(TestCase): self.assertEqual(response.status_code, 502) self.assertEqual(response.json()["data"]["error_code"], "invalid_json") + + +@override_settings(ROOT_URLCONF="soile.urls") +class SpatialPolicySmokeTests(TestCase): + pass diff --git a/weather/water_need_prediction.py b/weather/water_need_prediction.py index 285193d..c2102a8 100644 --- a/weather/water_need_prediction.py +++ b/weather/water_need_prediction.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from farm_data.services import clone_snapshot_as_runtime_plant, get_primary_plant_snapshot +from farm_data.services import build_ai_farm_snapshot, clone_snapshot_as_runtime_plant, get_primary_plant_snapshot, get_ai_snapshot_weather from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile from farm_data.models import SensorData @@ -11,7 +11,7 @@ from rag.services import get_water_need_prediction_insight from .services import get_forecast_for_location -def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]: +def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any], ai_snapshot: dict[str, Any] | None = None) -> dict[str, Any]: location = getattr(sensor, "center_location", None) plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor)) irrigation_method = getattr(sensor, "irrigation_method", None) @@ -46,6 +46,17 @@ def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> "dailyBreakdown": daily, "cropProfile": crop_profile, "irrigationEfficiencyPercent": efficiency, + "source_metadata": { + "agronomic_metrics": { + "source": "build_ai_farm_snapshot", + "policy": "cluster_block_farm_aggregated", + "available": bool(ai_snapshot), + }, + "weather": { + "source": "center_location_forecast", + "policy": "center_location_latest_forecast", + }, + }, } @@ -61,7 +72,8 @@ class WaterNeedPredictionService: raise ValueError("Farm not found.") forecasts = get_forecast_for_location(sensor.center_location, days=7) - payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts) + ai_snapshot = build_ai_farm_snapshot(farm_uuid) + payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts, ai_snapshot=ai_snapshot) insight = get_water_need_prediction_insight( farm_uuid=farm_uuid, prediction_payload=payload,