@@ -333,6 +333,51 @@ def _calculate_blooming_start_date_ml(forecast_data: pd.DataFrame, method: str =
333333
334334 return blooming_results
335335
336+ def _average_blooming_start_from_history (history : List [Dict [str , Any ]], target_year : int , threshold : float = 0.65 ) -> Tuple [Optional [datetime ], int ]:
337+ """Estimate blooming start from historical NDVI observations."""
338+ if not history :
339+ return None , 0
340+
341+ yearly_entries : Dict [int , List [Tuple [datetime , float ]]] = {}
342+ for entry in history :
343+ if entry .get ("type" ) == 1 :
344+ continue
345+ raw_date = entry .get ("date" )
346+ ndvi_value = entry .get ("ndvi" )
347+ if raw_date is None or ndvi_value is None :
348+ continue
349+ try :
350+ dt = datetime .fromisoformat (str (raw_date ).replace ("Z" , "+00:00" ))
351+ except Exception :
352+ continue
353+ if dt .tzinfo is None :
354+ dt = dt .replace (tzinfo = timezone .utc )
355+ if dt .year >= target_year :
356+ continue
357+ try :
358+ ndvi = float (ndvi_value )
359+ except (TypeError , ValueError ):
360+ continue
361+ yearly_entries .setdefault (dt .year , []).append ((dt , ndvi ))
362+
363+ blooming_days : List [int ] = []
364+ for year , entries in yearly_entries .items ():
365+ entries .sort (key = lambda item : item [0 ])
366+ for dt , ndvi in entries :
367+ if ndvi >= threshold :
368+ blooming_days .append (dt .timetuple ().tm_yday )
369+ break
370+
371+ if not blooming_days :
372+ return None , 0
373+
374+ avg_day = int (round (sum (blooming_days ) / len (blooming_days )))
375+ max_day = 366 if calendar .isleap (target_year ) else 365
376+ avg_day = max (1 , min (avg_day , max_day ))
377+ avg_date = datetime (target_year , 1 , 1 , tzinfo = timezone .utc ) + timedelta (days = avg_day - 1 )
378+ return avg_date , len (blooming_days )
379+
380+
336381
337382def _predict_ndvi_with_ml (history_data : List [Dict [str , Any ]], weather_df : pd .DataFrame ,
338383 target_year : int ) -> Dict [str , Any ]:
@@ -976,6 +1021,8 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
9761021 }
9771022
9781023 # Add blooming information if available
1024+ heuristic_blooming_date : Optional [datetime ] = None
1025+ heuristic_samples = 0
9791026 if blooming_start_date is not None :
9801027 if isinstance (blooming_start_date , pd .Timestamp ):
9811028 blooming_start_date = blooming_start_date .to_pydatetime ()
@@ -985,6 +1032,13 @@ def _compute_row(item: Tuple[int, Any]) -> Optional[Dict[str, Any]]:
9851032 forecast_dict ["ndviStartAt" ] = _isoformat_utc (blooming_start_date )
9861033 forecast_dict ["ndviStartConfidence" ] = round (blooming_confidence , 2 )
9871034 forecast_dict ["ndviStartMethod" ] = ml_results .get ('blooming_method' )
1035+ else :
1036+ heuristic_blooming_date , heuristic_samples = _average_blooming_start_from_history (history , forecast_year )
1037+ if heuristic_blooming_date is not None :
1038+ forecast_dict ["ndviStartAt" ] = _isoformat_utc (heuristic_blooming_date )
1039+ heuristic_confidence = min (0.8 , 0.4 + 0.1 * heuristic_samples ) if heuristic_samples else 0.0
1040+ forecast_dict ["ndviStartConfidence" ] = round (heuristic_confidence , 2 )
1041+ forecast_dict ["ndviStartMethod" ] = "euristic"
9881042
9891043 anomalies : List [Dict [str , Any ]] = []
9901044 try :
0 commit comments