Skip to content

Commit 9c8e980

Browse files
committed
Merge branch 'master' of https://github.com/rinatmini/DemeterEye
2 parents 20c260f + f087625 commit 9c8e980

10 files changed

Lines changed: 1188 additions & 740 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,7 @@ __pycache__/*
44
*/venv/
55
*/__pycache__
66
chipnik_monitor/on-premise/build.sh
7-
chipnik_monitor/models
7+
!chipnik_monitor/models
8+
chipnik_monitor/models/*
9+
!chipnik_monitor/models/ndvi_prophet_model.pkl
810
.vs

api/handlers_fields.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ func enrichFieldWithLatestReport(ctx context.Context, a *App, f *models.Field) {
8080
return
8181
}
8282

83-
log.Println("doc", doc)
84-
8583
switch doc.Status {
8684
case "processing":
8785
f.Status = models.ReportStatusProcessing
@@ -172,6 +170,21 @@ func enrichFieldWithLatestReport(ctx context.Context, a *App, f *models.Field) {
172170

173171
f.Forecast = ff
174172
}
173+
174+
if doc.Anomalies != nil && len(doc.Anomalies) > 0 {
175+
anomalies := make([]models.Anomaly, len(doc.Anomalies))
176+
for i, a := range doc.Anomalies {
177+
anomalies[i] = models.Anomaly{
178+
Key: a["key"].(string),
179+
Title: a["title"].(string),
180+
Triggered: a["triggered"].(bool),
181+
Severity: a["severity"].(string),
182+
DetectedAt: parseRFC3339Ptr(a["detectedAt"]),
183+
// Details: a["details"].(map[string]any),
184+
}
185+
}
186+
f.Anomalies = anomalies
187+
}
175188
}
176189

177190
func strOrEmpty(v any) string {
@@ -379,7 +392,6 @@ func (a *App) handleUpdateField(w http.ResponseWriter, r *http.Request) {
379392
yt2 = strings.TrimSpace(out.Meta.Crop)
380393
}
381394
if len(req.Geometry) > 0 && yt2 != "" {
382-
log.Println("req", req)
383395
if _, perr := a.PostProcessorReports(ctx, processorReportReq{
384396
FieldID: out.ID.Hex(),
385397
GeoJSON: req.Geometry,

api/models/field.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ type Field struct {
1717
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
1818

1919
// Injected-only (NOT stored in Mongo):
20-
Status ReportStatus `bson:"-" json:"status"`
21-
Forecast *ReportForecast `bson:"-" json:"forecast,omitempty"`
22-
History []ReportDaily `bson:"-" json:"history,omitempty"`
20+
Status ReportStatus `bson:"-" json:"status"`
21+
Forecast *ReportForecast `bson:"-" json:"forecast,omitempty"`
22+
History []ReportDaily `bson:"-" json:"history,omitempty"`
23+
Anomalies []Anomaly `bson:"-" json:"anomalies,omitempty"`
2324

2425
// Visual
2526
Photo string `bson:"photo,omitempty" json:"photo,omitempty"` // URL to field avatar/photo

api/models/report.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Report struct {
2323
GeoJSON map[string]any `bson:"geojson,omitempty" json:"geojson,omitempty"` // processor may store object/string; we normalize to object
2424
History []ReportDaily `bson:"history,omitempty" json:"history,omitempty"`
2525
Forecast *ReportForecast `bson:"forecast,omitempty" json:"forecast,omitempty"`
26+
Anomalies []Anomaly `bson:"anomalies,omitempty" json:"anomalies,omitempty"`
2627
ErrorMessage string `bson:"errorMessage,omitempty" json:"errorMessage,omitempty"`
2728
}
2829

@@ -53,3 +54,13 @@ type ReportForecast struct {
5354
NDVIModel string `bson:"ndviModel,omitempty" json:"ndviModel,omitempty"`
5455
NDVIConfidence *float64 `bson:"ndviConfidence,omitempty" json:"ndviConfidence,omitempty"`
5556
}
57+
58+
// Anomaly mirrors anomaly results from chipnik_monitor/anomalies.py
59+
type Anomaly struct {
60+
Key string `bson:"key" json:"key"`
61+
Title string `bson:"title,omitempty" json:"title,omitempty"`
62+
Triggered bool `bson:"triggered" json:"triggered"`
63+
Severity string `bson:"severity,omitempty" json:"severity,omitempty"`
64+
DetectedAt *time.Time `bson:"detectedAt,omitempty" json:"detectedAt,omitempty"`
65+
Details map[string]any `bson:"details,omitempty" json:"details,omitempty"`
66+
}

api/openapi.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ components:
147147
format: float
148148
nullable: true
149149

150+
Anomaly:
151+
type: object
152+
description: Anomaly signal computed from NDVI and optionally weather.
153+
properties:
154+
key:
155+
type: string
156+
description: Machine-readable key (e.g., ndvi_volatility)
157+
title:
158+
type: string
159+
description: Human-readable title
160+
triggered:
161+
type: boolean
162+
severity:
163+
type: string
164+
description: info | warning | error
165+
detectedAt:
166+
type: string
167+
format: date-time
168+
nullable: true
169+
details:
170+
type: object
171+
additionalProperties: true
172+
150173
Field:
151174
type: object
152175
properties:
@@ -173,6 +196,10 @@ components:
173196
$ref: '#/components/schemas/ReportDaily'
174197
forecast:
175198
$ref: '#/components/schemas/ReportForecast'
199+
anomalies:
200+
type: array
201+
items:
202+
$ref: '#/components/schemas/Anomaly'
176203
errorMessage: { type: string }
177204

178205
CreateFieldReq:

api/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,5 @@ type reportDoc struct {
6262
History []map[string]any `bson:"history"`
6363
Forecast map[string]any `bson:"forecast"`
6464
ErrorMessage *string `bson:"errorMessage"`
65+
Anomalies []map[string]any `bson:"anomalies"`
6566
}

chipnik_monitor/api.py

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -258,50 +258,50 @@ def _prepare_ml_features(history_data: List[Dict[str, Any]], weather_df: pd.Data
258258
'is_northern_region', 'is_growing_season']]
259259

260260

261-
def _calculate_flowering_start_date_ml(forecast_data: pd.DataFrame, method: str = 'ndvi_threshold') -> Dict[str, Any]:
261+
def _calculate_blooming_start_date_ml(forecast_data: pd.DataFrame, method: str = 'ndvi_threshold') -> Dict[str, Any]:
262262
"""
263-
Calculate flowering start date based on ML NDVI predictions
263+
Calculate blooming start date based on ML NDVI predictions
264264
265265
Parameters:
266266
forecast_data (pd.DataFrame): Prophet forecast with 'ds' and 'yhat' columns
267-
method (str): Method for calculating flowering start
267+
method (str): Method for calculating blooming start
268268
269269
Returns:
270-
dict: Flowering prediction results
270+
dict: Blooming prediction results
271271
"""
272272
if forecast_data is None or len(forecast_data) == 0:
273-
return {'flowering_start_date': None, 'method': method, 'confidence': 0}
273+
return {'blooming_start_date': None, 'method': method, 'confidence': 0}
274274

275275
# Create a copy and ensure proper date handling
276276
df = forecast_data.copy()
277277
df['ds'] = pd.to_datetime(df['ds'])
278278
df = df.sort_values('ds')
279279

280-
flowering_results = {}
280+
blooming_results = {}
281281

282282
if method == 'ndvi_threshold':
283-
# Method 1: Flowering starts when NDVI reaches 0.6-0.7
284-
flowering_threshold = 0.65
283+
# Method 1: Blooming starts when NDVI reaches 0.6-0.7
284+
blooming_threshold = 0.65
285285

286286
# Find first date when NDVI exceeds threshold (during spring growth)
287287
spring_mask = (df['ds'].dt.month >= 3) & (df['ds'].dt.month <= 6)
288288
spring_data = df[spring_mask]
289289

290290
if len(spring_data) > 0:
291-
flowering_candidates = spring_data[spring_data['yhat'] >= flowering_threshold]
292-
if len(flowering_candidates) > 0:
293-
flowering_date = flowering_candidates['ds'].iloc[0]
294-
confidence = min(0.9, 0.5 + (flowering_candidates['yhat'].iloc[0] - flowering_threshold) * 2)
295-
flowering_results = {
296-
'flowering_start_date': flowering_date,
291+
blooming_candidates = spring_data[spring_data['yhat'] >= blooming_threshold]
292+
if len(blooming_candidates) > 0:
293+
blooming_date = blooming_candidates['ds'].iloc[0]
294+
confidence = min(0.9, 0.5 + (blooming_candidates['yhat'].iloc[0] - blooming_threshold) * 2)
295+
blooming_results = {
296+
'blooming_start_date': blooming_date,
297297
'method': 'NDVI Threshold (≥0.65)',
298298
'confidence': confidence,
299-
'ndvi_at_flowering': flowering_candidates['yhat'].iloc[0],
300-
'threshold_used': flowering_threshold
299+
'ndvi_at_blooming': blooming_candidates['yhat'].iloc[0],
300+
'threshold_used': blooming_threshold
301301
}
302302

303303
elif method == 'ndvi_acceleration':
304-
# Method 2: Flowering starts when NDVI growth rate accelerates
304+
# Method 2: Blooming starts when NDVI growth rate accelerates
305305
if len(df) >= 3:
306306
df['ndvi_growth'] = df['yhat'].diff()
307307
df['ndvi_acceleration'] = df['ndvi_growth'].diff()
@@ -312,31 +312,31 @@ def _calculate_flowering_start_date_ml(forecast_data: pd.DataFrame, method: str
312312
if len(spring_data) > 0:
313313
max_accel_idx = spring_data['ndvi_acceleration'].idxmax()
314314
if not pd.isna(max_accel_idx):
315-
flowering_date = df.loc[max_accel_idx, 'ds']
315+
blooming_date = df.loc[max_accel_idx, 'ds']
316316
confidence = min(0.85, 0.6 + abs(df.loc[max_accel_idx, 'ndvi_acceleration']) * 10)
317-
flowering_results = {
318-
'flowering_start_date': flowering_date,
317+
blooming_results = {
318+
'blooming_start_date': blooming_date,
319319
'method': 'NDVI Acceleration Peak',
320320
'confidence': confidence,
321-
'ndvi_at_flowering': df.loc[max_accel_idx, 'yhat'],
321+
'ndvi_at_blooming': df.loc[max_accel_idx, 'yhat'],
322322
'acceleration_value': df.loc[max_accel_idx, 'ndvi_acceleration']
323323
}
324324

325325
# Default return if no method worked
326-
if not flowering_results:
327-
flowering_results = {
328-
'flowering_start_date': None,
326+
if not blooming_results:
327+
blooming_results = {
328+
'blooming_start_date': None,
329329
'method': method,
330330
'confidence': 0,
331-
'error': 'Unable to determine flowering date with selected method'
331+
'error': 'Unable to determine blooming date with selected method'
332332
}
333333

334-
return flowering_results
334+
return blooming_results
335335

336336

337337
def _predict_ndvi_with_ml(history_data: List[Dict[str, Any]], weather_df: pd.DataFrame,
338338
target_year: int) -> Dict[str, Any]:
339-
"""Use ML model to predict NDVI and flowering dates."""
339+
"""Use ML model to predict NDVI and blooming dates."""
340340
try:
341341
model = _load_ml_model()
342342
if model is None:
@@ -424,18 +424,18 @@ def _predict_ndvi_with_ml(history_data: List[Dict[str, Any]], weather_df: pd.Dat
424424
# Make predictions
425425
forecast = model.predict(future_df)
426426

427-
# Calculate flowering dates using multiple methods
427+
# Calculate blooming dates using multiple methods
428428
methods = ['ndvi_threshold', 'ndvi_acceleration']
429-
flowering_predictions = {}
429+
blooming_predictions = {}
430430

431431
for method in methods:
432-
result = _calculate_flowering_start_date_ml(forecast, method)
433-
flowering_predictions[method] = result
432+
result = _calculate_blooming_start_date_ml(forecast, method)
433+
blooming_predictions[method] = result
434434

435435
# Select best prediction (highest confidence)
436-
best_method = max(flowering_predictions.keys(),
437-
key=lambda x: flowering_predictions[x]['confidence'])
438-
best_prediction = flowering_predictions[best_method]
436+
best_method = max(blooming_predictions.keys(),
437+
key=lambda x: blooming_predictions[x]['confidence'])
438+
best_prediction = blooming_predictions[best_method]
439439

440440
# Find NDVI peak from predictions
441441
ndvi_values = forecast['yhat'].values
@@ -454,9 +454,9 @@ def _predict_ndvi_with_ml(history_data: List[Dict[str, Any]], weather_df: pd.Dat
454454
return {
455455
'ndvi_peak': float(ndvi_peak) if ndvi_peak is not None else None,
456456
'ndvi_peak_at': ndvi_peak_at,
457-
'flowering_start_date': best_prediction.get('flowering_start_date'),
458-
'flowering_confidence': best_prediction.get('confidence', 0),
459-
'flowering_method': best_prediction.get('method'),
457+
'blooming_start_date': best_prediction.get('blooming_start_date'),
458+
'blooming_confidence': best_prediction.get('confidence', 0),
459+
'blooming_method': best_prediction.get('method'),
460460
'model': 'prophet_ml',
461461
'predictions': forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].to_dict('records')
462462
}
@@ -466,9 +466,9 @@ def _predict_ndvi_with_ml(history_data: List[Dict[str, Any]], weather_df: pd.Dat
466466
return {
467467
'ndvi_peak': None,
468468
'ndvi_peak_at': None,
469-
'flowering_start_date': None,
470-
'flowering_confidence': 0,
471-
'flowering_method': None,
469+
'blooming_start_date': None,
470+
'blooming_confidence': 0,
471+
'blooming_method': None,
472472
'model': 'heuristic_fallback',
473473
'error': str(exc)
474474
}
@@ -863,8 +863,8 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
863863
monitor.logger.info("Forecast NDVI peak %.3f", ml_results.get('ndvi_peak', 0.0))
864864
ndvi_peak = ml_results.get('ndvi_peak')
865865
ndvi_peak_at = ml_results.get('ndvi_peak_at')
866-
flowering_start_date = ml_results.get('flowering_start_date')
867-
flowering_confidence = ml_results.get('flowering_confidence', 0)
866+
blooming_start_date = ml_results.get('blooming_start_date')
867+
blooming_confidence = ml_results.get('blooming_confidence', 0)
868868
model_name = "prophet_ml"
869869

870870
# Add ML predictions to history with type 1
@@ -913,9 +913,9 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
913913
if match_ratio < min_match:
914914
match_ratio = min(min_match, 1.0)
915915

916-
# Boost confidence if we have flowering prediction
917-
if flowering_confidence > match_ratio:
918-
match_ratio = min(1.0, (match_ratio + flowering_confidence) / 2)
916+
# Boost confidence if we have blooming prediction
917+
if blooming_confidence > match_ratio:
918+
match_ratio = min(1.0, (match_ratio + blooming_confidence) / 2)
919919

920920
else:
921921
# Fall back to heuristic method
@@ -924,8 +924,8 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
924924
ndvi_values = [entry["ndvi"] for entry in history if entry["ndvi"] is not None]
925925
ndvi_peak = max(ndvi_values) if ndvi_values else None
926926
ndvi_peak_at = None
927-
flowering_start_date = None
928-
flowering_confidence = 0
927+
blooming_start_date = None
928+
blooming_confidence = 0
929929

930930
if ndvi_peak is not None:
931931
for entry in history:
@@ -964,7 +964,7 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
964964
yield_per_ha = float(yield_profile.get("yield_t_per_ha") or 0.0)
965965
yield_tph = yield_per_ha * match_ratio
966966

967-
# Prepare forecast response with flowering information
967+
# Prepare forecast response with blooming information
968968
forecast_dict = {
969969
"year": forecast_year,
970970
"yieldTph": round(yield_tph, 2),
@@ -975,16 +975,16 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
975975
"ndviModel": model_name,
976976
}
977977

978-
# Add flowering information if available
979-
if flowering_start_date is not None:
980-
if isinstance(flowering_start_date, pd.Timestamp):
981-
flowering_start_date = flowering_start_date.to_pydatetime()
982-
if isinstance(flowering_start_date, datetime):
983-
if flowering_start_date.tzinfo is None:
984-
flowering_start_date = flowering_start_date.replace(tzinfo=timezone.utc)
985-
forecast_dict["ndviStartAt"] = _isoformat_utc(flowering_start_date)
986-
forecast_dict["ndviStartConfidence"] = round(flowering_confidence, 2)
987-
forecast_dict["ndviStartMethod"] = ml_results.get('flowering_method')
978+
# Add blooming information if available
979+
if blooming_start_date is not None:
980+
if isinstance(blooming_start_date, pd.Timestamp):
981+
blooming_start_date = blooming_start_date.to_pydatetime()
982+
if isinstance(blooming_start_date, datetime):
983+
if blooming_start_date.tzinfo is None:
984+
blooming_start_date = blooming_start_date.replace(tzinfo=timezone.utc)
985+
forecast_dict["ndviStartAt"] = _isoformat_utc(blooming_start_date)
986+
forecast_dict["ndviStartConfidence"] = round(blooming_confidence, 2)
987+
forecast_dict["ndviStartMethod"] = ml_results.get('blooming_method')
988988

989989
anomalies: List[Dict[str, Any]] = []
990990
try:
116 KB
Binary file not shown.

0 commit comments

Comments
 (0)