From 61646de64715f97625332a11e4716a27b826147b Mon Sep 17 00:00:00 2001 From: Victor Phan Date: Mon, 19 Jan 2026 12:31:40 +0700 Subject: [PATCH] =?UTF-8?q?b=E1=BB=95=20sung=20th=C3=AAm=20h=C3=A0m=20t?= =?UTF-8?q?=E1=BB=B1=20resign=20token=20SAS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api_server.py | 757 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 565 insertions(+), 192 deletions(-) diff --git a/api_server.py b/api_server.py index 6bba65e..b1bd909 100644 --- a/api_server.py +++ b/api_server.py @@ -22,6 +22,8 @@ from rasterio.transform import from_bounds import asyncio import hashlib import traceback +import socket +import urllib.request # Import report generator from report_generator import generate_training_report, generate_prediction_report @@ -107,6 +109,58 @@ DEFAULT_LABEL_NAMES = { } +# ============ NETWORK CONNECTIVITY HELPERS ============ +def check_internet_connectivity(timeout=5): + """ + Check if we have internet connectivity to common hosts + Returns: (is_connected: bool, error_message: str) + """ + test_hosts = [ + ("8.8.8.8", 53), # Google DNS + ("1.1.1.1", 53), # Cloudflare DNS + ] + + for host, port in test_hosts: + try: + socket.setdefaulttimeout(timeout) + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) + return True, None + except (socket.timeout, socket.error): + continue + + return False, "No internet connectivity detected. Please check your network connection." + + +def check_planetary_computer_access(timeout=10): + """ + Check if we can access Microsoft Planetary Computer + Returns: (is_accessible: bool, error_message: str) + """ + test_urls = [ + "https://planetarycomputer.microsoft.com/api/stac/v1", + ] + + for url in test_urls: + try: + req = urllib.request.Request(url) + response = urllib.request.urlopen(req, timeout=timeout) + if response.status == 200: + return True, None + except urllib.error.HTTPError as e: + # 405 Method Not Allowed is OK - means the service is reachable + if e.code in [200, 405]: + return True, None + return False, f"HTTP Error {e.code}: {e.reason}" + except urllib.error.URLError as e: + if "Could not resolve host" in str(e): + return False, f"Cannot resolve Planetary Computer hosts. DNS issue detected: {str(e)}" + return False, f"URL Error: {str(e)}" + except Exception as e: + return False, f"Unexpected error: {str(e)}" + + return False, "Cannot access Microsoft Planetary Computer. Service may be down or blocked by firewall." + + class TrainingConfig(BaseModel): """Cấu hình training - Tất cả bắt buộc nhập từ giao diện""" # Khu vực (bbox) @@ -233,6 +287,44 @@ async def change_detection_page(): return HTMLResponse("

Change Detection Interface not found.

") +@app.get("/api/network/check") +async def check_network(): + """ + Check network connectivity and access to Planetary Computer + Useful for diagnosing connection issues before attempting data downloads + """ + result = { + "timestamp": datetime.now().isoformat(), + "internet_connected": False, + "planetary_computer_accessible": False, + "internet_error": None, + "pc_error": None, + "status": "unknown" + } + + # Check basic internet connectivity + is_connected, conn_error = check_internet_connectivity(timeout=5) + result["internet_connected"] = is_connected + result["internet_error"] = conn_error + + # Check Planetary Computer access + if is_connected: + is_accessible, access_error = check_planetary_computer_access(timeout=10) + result["planetary_computer_accessible"] = is_accessible + result["pc_error"] = access_error + + if is_accessible: + result["status"] = "ready" + else: + result["status"] = "pc_unavailable" + else: + result["status"] = "no_internet" + result["planetary_computer_accessible"] = False + result["pc_error"] = "Cannot check - no internet connection" + + return result + + @app.get("/", response_class=HTMLResponse) async def root(): """Serve main index page with tabs""" @@ -1234,14 +1326,51 @@ async def run_prediction(config: PredictionConfig): else: # temporal or extended bands_to_load = ["B02", "B03", "B04", "B08", "B11", "SCL"] - s2_data = load( - s2_items, - bbox=bbox, - bands=bands_to_load, - chunks={"time": 1, "x": 2048, "y": 2048}, - groupby="solar_day", - resolution=config.resolution - ).compute() + # Retry logic for data loading + max_retries = 3 + s2_data = None + for attempt in range(max_retries): + try: + s2_data = load( + s2_items, + bbox=bbox, + bands=bands_to_load, + chunks={"time": 1, "x": 2048, "y": 2048}, + groupby="solar_day", + resolution=config.resolution + ).compute() + break + except Exception as e: + error_msg = str(e) + + is_auth_error = any(err in error_msg for err in [ + "AuthenticationFailed", "Signature fields not well formed", + "Server failed to authenticate", "403", "401" + ]) + + if is_auth_error: + print(f"⚠️ Authentication error. Re-signing items...") + try: + import planetary_computer + s2_items = [planetary_computer.sign(item) for item in s2_items] + if attempt < max_retries - 1: + import time + time.sleep(1) + continue + except Exception: + pass + + if "does not exist" in error_msg or "RasterioIOError" in error_msg: + if len(s2_items) > 3 and attempt < max_retries - 1: + remove_count = max(1, len(s2_items) // 5) + s2_items = s2_items[:-remove_count] + print(f"⚠️ Access error. Reduced to {len(s2_items)} scenes, retrying...") + continue + if attempt == max_retries - 1: + raise ValueError(f"Failed to load Sentinel-2 data: {error_msg[:200]}") + + if s2_data is None: + raise ValueError("Failed to load Sentinel-2 data after retries") prediction_status["progress"] = "Đã load Sentinel-2 data" @@ -1261,20 +1390,25 @@ async def run_prediction(config: PredictionConfig): if s1_items: s1_items = s1_items[:config.max_scenes] - s1_data = load( - s1_items, - bbox=bbox, - bands=["vh", "vv"], - chunks={"time": 1, "x": 2048, "y": 2048}, - groupby="solar_day", - resolution=config.resolution - ).compute() - - # Convert to dB - vh_data = 10 * np.log10(s1_data['vh'].where(s1_data['vh'] > 0)) - vv_data = 10 * np.log10(s1_data['vv'].where(s1_data['vv'] > 0)) - use_radar = True - prediction_status["progress"] = f"Đã load Sentinel-1 data ({len(s1_items)} scenes)" + # Try to load Sentinel-1 with error handling + try: + s1_data = load( + s1_items, + bbox=bbox, + bands=["vh", "vv"], + chunks={"time": 1, "x": 2048, "y": 2048}, + groupby="solar_day", + resolution=config.resolution + ).compute() + + # Convert to dB + vh_data = 10 * np.log10(s1_data['vh'].where(s1_data['vh'] > 0)) + vv_data = 10 * np.log10(s1_data['vv'].where(s1_data['vv'] > 0)) + use_radar = True + prediction_status["progress"] = f"Đã load Sentinel-1 data ({len(s1_items)} scenes)" + except Exception as s1_error: + print(f"⚠️ Failed to load Sentinel-1 data: {str(s1_error)[:100]}") + prediction_status["progress"] = "Lỗi Sentinel-1, bỏ qua radar features" else: prediction_status["progress"] = "Không có dữ liệu Sentinel-1, bỏ qua radar features" except Exception as e: @@ -2647,15 +2781,55 @@ async def change_detection_predict_workflow( if len(items) == 0: raise HTTPException(status_code=404, detail="No Sentinel-2 data found for the given area and date range") - # Load data + # Load data with error handling signed_items = [planetary_computer.sign(item) for item in items] - data = odc.stac.load( - signed_items, - bbox=bbox, - bands=["B02", "B03", "B04", "B08"], - resolution=resolution, - chunks={"x": 2048, "y": 2048} - ).compute() + + max_retries = 3 + data = None + for attempt in range(max_retries): + try: + data = odc.stac.load( + signed_items, + bbox=bbox, + bands=["B02", "B03", "B04", "B08"], + resolution=resolution, + chunks={"x": 2048, "y": 2048} + ).compute() + break + except Exception as e: + error_msg = str(e) + + is_auth_error = any(err in error_msg for err in [ + "AuthenticationFailed", "Signature fields not well formed", + "Server failed to authenticate", "403", "401" + ]) + + if is_auth_error: + print(f"⚠️ Authentication error. Re-signing items...") + try: + signed_items = [planetary_computer.sign(item) for item in items] + if attempt < max_retries - 1: + import time + time.sleep(1) + continue + except Exception: + pass + + if ("does not exist" in error_msg or "RasterioIOError" in error_msg) and len(signed_items) > 3: + remove_count = max(1, len(signed_items) // 5) + signed_items = signed_items[:-remove_count] + items = items[:-remove_count] + print(f"⚠️ Access error. Reduced to {len(signed_items)} scenes, retrying...") + if attempt < max_retries - 1: + continue + if attempt == max_retries - 1: + raise HTTPException( + status_code=500, + detail=f"Failed to load Sentinel-2 data: {error_msg[:200]}" + ) + + if data is None: + raise HTTPException(status_code=500, detail="Failed to load Sentinel-2 data") # --- STEP 3: CALCULATE NDVI --- print("[CHANGE DETECTION] Calculating NDVI...") @@ -3931,6 +4105,24 @@ async def ndvi_predict_timeseries(config: NDVIPredictionConfig): bbox = [min_lon, min_lat, max_lon, max_lat] time_range = f"{config.start_date}/{config.end_date}" + # Check network connectivity first + print(f"\n🌐 Checking network connectivity...") + is_connected, conn_error = check_internet_connectivity() + if not is_connected: + raise HTTPException( + status_code=503, + detail=f"❌ {conn_error}\n\n" + f"Please check:\n" + f"1. Your internet connection is active\n" + f"2. DNS servers are configured correctly\n" + f"3. Firewall/proxy settings allow outbound connections" + ) + + is_accessible, access_error = check_planetary_computer_access() + if not is_accessible: + print(f"⚠️ {access_error}") + print(f" Will attempt to load data anyway (may fail)...") + # Load Sentinel-2 time series using Planetary Computer print(f"\n📡 Loading Sentinel-2 data from Planetary Computer...") @@ -3984,8 +4176,85 @@ async def ndvi_predict_timeseries(config: NDVIPredictionConfig): except Exception as e: error_msg = str(e) - if "Could not resolve host" in error_msg or "CURL error" in error_msg: + + # Check for various error types that might indicate problematic scenes + is_network_error = any(err in error_msg for err in [ + "Could not resolve host", "CURL error", "Connection", "Timeout" + ]) + is_access_error = any(err in error_msg for err in [ + "does not exist in the file system", "RasterioIOError", + "not recognized as a supported dataset", "Aborting load" + ]) + is_auth_error = any(err in error_msg for err in [ + "AuthenticationFailed", "Signature fields not well formed", + "Server failed to authenticate", "403", "401" + ]) + + if is_auth_error: + print(f"⚠️ Authentication error detected. Re-signing items and retrying...") + # Re-sign all items with fresh tokens + try: + import planetary_computer + s2_items = [planetary_computer.sign(item) for item in s2_items] + print(f" Re-signed {len(s2_items)} items") + + if attempt < max_retries - 1: + import time + time.sleep(1) # Brief pause before retry + continue + else: + raise HTTPException( + status_code=500, + detail=f"❌ Authentication failed after {max_retries} attempts.\n\n" + f"Azure Blob Storage authentication is failing. This may be due to:\n" + f"1. Expired or invalid SAS tokens from Planetary Computer\n" + f"2. Planetary Computer API service issues\n" + f"3. System clock synchronization issues (check date/time)\n\n" + f"Please try again in a few minutes. If the problem persists,\n" + f"Microsoft Planetary Computer may be experiencing issues.\n\n" + f"Error: {error_msg[:300]}" + ) + except Exception as resign_error: + print(f" Re-signing failed: {resign_error}") + if attempt == max_retries - 1: + raise HTTPException( + status_code=500, + detail=f"Failed to re-sign authentication tokens: {str(resign_error)}" + ) + + elif is_access_error and len(s2_items) > 5: + # If we have access errors and multiple scenes, try with fewer scenes + print(f"⚠️ Data access error detected. Reducing scene count and retrying...") + # Remove last 20% of scenes and try again + remove_count = max(1, len(s2_items) // 5) + s2_items = s2_items[:-remove_count] + print(f" Reduced to {len(s2_items)} scenes") + + if attempt < max_retries - 1: + continue + else: + # Final attempt with reduced scenes + print(f" Final attempt with {len(s2_items)} scenes...") + + elif is_network_error: print(f"⚠️ Network error on attempt {attempt + 1}: {error_msg[:100]}") + + # Re-check connectivity on network errors + if "Could not resolve host" in error_msg: + is_conn, conn_msg = check_internet_connectivity() + if not is_conn: + raise HTTPException( + status_code=503, + detail=f"❌ Network connectivity lost!\n\n" + f"{conn_msg}\n\n" + f"Please:\n" + f"1. Check your internet connection\n" + f"2. Verify DNS settings (try 8.8.8.8 or 1.1.1.1)\n" + f"3. Check firewall/proxy settings\n" + f"4. Try again once connection is restored\n\n" + f"Original error: {error_msg[:150]}" + ) + if attempt < max_retries - 1: import time print(f" Retrying in {retry_delay} seconds...") @@ -3994,13 +4263,30 @@ async def ndvi_predict_timeseries(config: NDVIPredictionConfig): else: raise HTTPException( status_code=503, - detail=f"Network error: Unable to download Sentinel-2 data after {max_retries} attempts. " - f"Please check your internet connection or try again later. " + detail=f"❌ Network error persists after {max_retries} attempts.\n\n" + f"Unable to download Sentinel-2 data from Microsoft Planetary Computer.\n\n" + f"Possible causes:\n" + f"1. Unstable internet connection\n" + f"2. DNS resolution issues\n" + f"3. Planetary Computer service temporarily unavailable\n" + f"4. Firewall blocking access to sentinel2l2a01.blob.core.windows.net\n\n" + f"Please check your network and try again.\n\n" f"Error: {error_msg[:200]}" ) else: - # Non-network error, raise immediately - raise + # If still failing after retries, try with minimal data + if attempt == max_retries - 1 and len(s2_items) > 3: + print(f"⚠️ Multiple failures. Attempting with minimal scenes (3)...") + s2_items = s2_items[:3] + continue + else: + # Last resort: provide meaningful error + raise HTTPException( + status_code=500, + detail=f"Failed to load Sentinel-2 data. This may be due to expired access tokens " + f"or unavailable data. Please try a different time range or area. " + f"Error: {error_msg[:300]}" + ) if s2_data is None: raise HTTPException(status_code=500, detail="Failed to load Sentinel-2 data") @@ -4365,17 +4651,104 @@ async def ndvi_forecast(config: NDVIForecastConfig): print(f"✅ Found {len(s2_items)} historical scenes") - # Load data - s2_data = load( - s2_items, - bbox=bbox, - bands=["B02", "B03", "B04", "B05", "B08", "B11", "SCL"], - chunks={"time": 1, "x": 2048, "y": 2048}, - groupby="solar_day", - resolution=config.resolution - ).compute() + # Load data with retry logic for network and access errors + max_retries = 3 + retry_delay = 2 + s2_data = None - print(f"✅ Loaded {len(s2_data.time)} time steps") + for attempt in range(max_retries): + try: + print(f"📥 Loading Sentinel-2 data (attempt {attempt + 1}/{max_retries})...") + s2_data = load( + s2_items, + bbox=bbox, + bands=["B02", "B03", "B04", "B05", "B08", "B11", "SCL"], + chunks={"time": 1, "x": 2048, "y": 2048}, + groupby="solar_day", + resolution=config.resolution + ).compute() + + print(f"✅ Loaded {len(s2_data.time)} time steps") + break + + except Exception as e: + error_msg = str(e) + + # Check for various error types + is_network_error = any(err in error_msg for err in [ + "Could not resolve host", "CURL error", "Connection", "Timeout" + ]) + is_access_error = any(err in error_msg for err in [ + "does not exist in the file system", "RasterioIOError", + "not recognized as a supported dataset", "Aborting load" + ]) + is_auth_error = any(err in error_msg for err in [ + "AuthenticationFailed", "Signature fields not well formed", + "Server failed to authenticate", "403", "401" + ]) + + if is_auth_error: + print(f"⚠️ Authentication error. Re-signing items...") + try: + import planetary_computer + s2_items = [planetary_computer.sign(item) for item in s2_items] + if attempt < max_retries - 1: + import time + time.sleep(1) + continue + else: + raise HTTPException( + status_code=500, + detail=f"Authentication failed: {error_msg[:200]}" + ) + except Exception as resign_error: + if attempt == max_retries - 1: + raise HTTPException( + status_code=500, + detail=f"Failed to re-sign tokens: {str(resign_error)}" + ) + + elif is_access_error and len(s2_items) > 5: + # If we have access errors and multiple scenes, try with fewer scenes + print(f"⚠️ Data access error detected. Reducing scene count and retrying...") + remove_count = max(1, len(s2_items) // 5) + s2_items = s2_items[:-remove_count] + print(f" Reduced to {len(s2_items)} scenes") + + if attempt < max_retries - 1: + continue + else: + print(f" Final attempt with {len(s2_items)} scenes...") + + elif is_network_error: + print(f"⚠️ Network error on attempt {attempt + 1}: {error_msg[:100]}") + if attempt < max_retries - 1: + import time + print(f" Retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + retry_delay *= 2 + else: + raise HTTPException( + status_code=503, + detail=f"Network error: Unable to download Sentinel-2 data after {max_retries} attempts. " + f"Error: {error_msg[:200]}" + ) + else: + # Try with minimal scenes on final attempt + if attempt == max_retries - 1 and len(s2_items) > 3: + print(f"⚠️ Multiple failures. Attempting with minimal scenes (3)...") + s2_items = s2_items[:3] + continue + else: + raise HTTPException( + status_code=500, + detail=f"Failed to load Sentinel-2 data. This may be due to expired access tokens " + f"or unavailable data. Please try a different time range or area. " + f"Error: {error_msg[:300]}" + ) + + if s2_data is None: + raise HTTPException(status_code=500, detail="Failed to load Sentinel-2 data") # Calculate spectral indices print(f"\n📊 Calculating spectral indices...") @@ -4476,158 +4849,158 @@ async def ndvi_forecast(config: NDVIForecastConfig): if len(point_features) == 0: print("[NDVI FORECAST][FALLBACK] No valid classification points. Falling back to simple seasonal forecast.") force_simple_forecast = True - - print(f"✅ Extracted features for {len(point_features)} valid points") - - # Classify points - X = np.array(point_features) - predictions = model.predict(X) - - if label_encoder is not None: - try: - predictions = label_encoder.inverse_transform(predictions.astype(int)) - except: - pass - - # Count land types - unique_types, type_counts = np.unique(predictions, return_counts=True) - print(f"\n📊 Detected land types:") - for land_type, count in zip(unique_types, type_counts): - print(f" Type {land_type}: {count} points ({count/len(predictions)*100:.1f}%)") - - # Calculate land-type-specific seasonal patterns - print(f"\n📈 Calculating land-type-specific seasonal patterns...") - - land_type_patterns = {} - - for time_idx in range(len(s2_data.time)): - time_val = pd.Timestamp(s2_data.time.values[time_idx]) - month = time_val.month - - # Get valid pixels for this time step - if hasattr(cloud_mask, 'isel'): - mask_t = cloud_mask.isel(time=time_idx) - else: - # Wrap numpy mask to xarray with same coords/dims as ndvi slice - ndvi_slice = ndvi.isel(time=time_idx) - mask_t = xr.DataArray(mask_t := cloud_mask[time_idx], coords=ndvi_slice.coords, dims=ndvi_slice.dims) - - ndvi_t = ndvi.isel(time=time_idx).where(mask_t) - ndwi_t = ndwi.isel(time=time_idx).where(mask_t) - ndbi_t = ndbi.isel(time=time_idx).where(mask_t) - evi_t = evi.isel(time=time_idx).where(mask_t) - - # Extract values at classified points - for point_idx, (lat, lon) in enumerate(point_coords): - land_type = predictions[point_idx] - - try: - ndvi_val = float(ndvi_t.sel(y=lat, x=lon, method='nearest').values) - - if not np.isnan(ndvi_val): - ndwi_val = float(ndwi_t.sel(y=lat, x=lon, method='nearest').values) - ndbi_val = float(ndbi_t.sel(y=lat, x=lon, method='nearest').values) - evi_val = float(evi_t.sel(y=lat, x=lon, method='nearest').values) - - # Initialize land type if not exists - if land_type not in land_type_patterns: - land_type_patterns[land_type] = {} - - if month not in land_type_patterns[land_type]: - land_type_patterns[land_type][month] = { - 'ndvi': [], 'ndwi': [], 'ndbi': [], 'evi': [] - } - - # Append values - land_type_patterns[land_type][month]['ndvi'].append(ndvi_val) - land_type_patterns[land_type][month]['ndwi'].append(ndwi_val) - land_type_patterns[land_type][month]['ndbi'].append(ndbi_val) - land_type_patterns[land_type][month]['evi'].append(evi_val) - except: - continue - - # Calculate statistics for each land type and month - land_type_seasonal_stats = {} - - for land_type, month_data in land_type_patterns.items(): - land_type_seasonal_stats[land_type] = {} - - for month, values in month_data.items(): - ndvi_vals = values['ndvi'] - - if len(ndvi_vals) > 0: - land_type_seasonal_stats[land_type][month] = { - 'ndvi_mean': float(np.mean(ndvi_vals)), - 'ndvi_min': float(np.min(ndvi_vals)), - 'ndvi_max': float(np.max(ndvi_vals)), - 'ndvi_std': float(np.std(ndvi_vals)), - 'ndvi_range': float(np.max(ndvi_vals) - np.min(ndvi_vals)), - 'ndwi_mean': float(np.mean(values['ndwi'])), - 'ndbi_mean': float(np.mean(values['ndbi'])), - 'evi_mean': float(np.mean(values['evi'])), - 'n_samples': len(ndvi_vals) - } - - print(f"✅ Calculated patterns for {len(land_type_seasonal_stats)} land types") - - if len(land_type_seasonal_stats) == 0: - print("[NDVI FORECAST][FALLBACK] No seasonal patterns. Falling back to simple seasonal forecast.") - force_simple_forecast = True - - # Generate forecast using land-type-weighted average - if force_simple_forecast: - print("[NDVI FORECAST][FALLBACK] Skipping ML forecast, will use simple seasonal averaging.") else: - print(f"\n🔮 Generating land-type-specific forecast...") + print(f"✅ Extracted features for {len(point_features)} valid points") - forecast_timeseries = [] - current_date = forecast_start + # Classify points + X = np.array(point_features) + predictions = model.predict(X) - # Calculate land type weights - total_points = len(predictions) - if total_points == 0: - print("[NDVI FORECAST][FALLBACK] Zero valid classified points. Switching to simple seasonal forecast.") - force_simple_forecast = True - else: - land_type_weights = {lt: np.sum(predictions == lt) / total_points - for lt in unique_types} + if label_encoder is not None: + try: + predictions = label_encoder.inverse_transform(predictions.astype(int)) + except: + pass - if not force_simple_forecast: - while current_date <= forecast_end: - month = current_date.month - - # Aggregate forecast across all land types (weighted) - weighted_forecast = { - 'ndvi_mean': 0, 'ndvi_min': 0, 'ndvi_max': 0, 'ndvi_std': 0, - 'ndvi_range': 0, 'ndwi_mean': 0, 'ndbi_mean': 0, 'evi_mean': 0 - } - - land_type_contributions = {} - - for land_type, weight in land_type_weights.items(): - if land_type in land_type_seasonal_stats and month in land_type_seasonal_stats[land_type]: - stats = land_type_seasonal_stats[land_type][month] - - land_type_contributions[int(land_type)] = { - **stats, - 'weight': float(weight) - } - - for key in weighted_forecast: - weighted_forecast[key] += stats[key] * weight - - if land_type_contributions: - forecast_data = { - 'date': current_date.strftime('%Y-%m-%d'), - 'is_forecast': True, - 'land_type_specific': land_type_contributions, - **weighted_forecast - } - forecast_timeseries.append(forecast_data) - - current_date += relativedelta(months=1) + # Count land types + unique_types, type_counts = np.unique(predictions, return_counts=True) + print(f"\n📊 Detected land types:") + for land_type, count in zip(unique_types, type_counts): + print(f" Type {land_type}: {count} points ({count/len(predictions)*100:.1f}%)") + + # Calculate land-type-specific seasonal patterns + print(f"\n📈 Calculating land-type-specific seasonal patterns...") + + land_type_patterns = {} + + for time_idx in range(len(s2_data.time)): + time_val = pd.Timestamp(s2_data.time.values[time_idx]) + month = time_val.month - method_used = "Land-Type-Specific Forecasting (ML-Enhanced)" + # Get valid pixels for this time step + if hasattr(cloud_mask, 'isel'): + mask_t = cloud_mask.isel(time=time_idx) + else: + # Wrap numpy mask to xarray with same coords/dims as ndvi slice + ndvi_slice = ndvi.isel(time=time_idx) + mask_t = xr.DataArray(mask_t := cloud_mask[time_idx], coords=ndvi_slice.coords, dims=ndvi_slice.dims) + + ndvi_t = ndvi.isel(time=time_idx).where(mask_t) + ndwi_t = ndwi.isel(time=time_idx).where(mask_t) + ndbi_t = ndbi.isel(time=time_idx).where(mask_t) + evi_t = evi.isel(time=time_idx).where(mask_t) + + # Extract values at classified points + for point_idx, (lat, lon) in enumerate(point_coords): + land_type = predictions[point_idx] + + try: + ndvi_val = float(ndvi_t.sel(y=lat, x=lon, method='nearest').values) + + if not np.isnan(ndvi_val): + ndwi_val = float(ndwi_t.sel(y=lat, x=lon, method='nearest').values) + ndbi_val = float(ndbi_t.sel(y=lat, x=lon, method='nearest').values) + evi_val = float(evi_t.sel(y=lat, x=lon, method='nearest').values) + + # Initialize land type if not exists + if land_type not in land_type_patterns: + land_type_patterns[land_type] = {} + + if month not in land_type_patterns[land_type]: + land_type_patterns[land_type][month] = { + 'ndvi': [], 'ndwi': [], 'ndbi': [], 'evi': [] + } + + # Append values + land_type_patterns[land_type][month]['ndvi'].append(ndvi_val) + land_type_patterns[land_type][month]['ndwi'].append(ndwi_val) + land_type_patterns[land_type][month]['ndbi'].append(ndbi_val) + land_type_patterns[land_type][month]['evi'].append(evi_val) + except: + continue + + # Calculate statistics for each land type and month + land_type_seasonal_stats = {} + + for land_type, month_data in land_type_patterns.items(): + land_type_seasonal_stats[land_type] = {} + + for month, values in month_data.items(): + ndvi_vals = values['ndvi'] + + if len(ndvi_vals) > 0: + land_type_seasonal_stats[land_type][month] = { + 'ndvi_mean': float(np.mean(ndvi_vals)), + 'ndvi_min': float(np.min(ndvi_vals)), + 'ndvi_max': float(np.max(ndvi_vals)), + 'ndvi_std': float(np.std(ndvi_vals)), + 'ndvi_range': float(np.max(ndvi_vals) - np.min(ndvi_vals)), + 'ndwi_mean': float(np.mean(values['ndwi'])), + 'ndbi_mean': float(np.mean(values['ndbi'])), + 'evi_mean': float(np.mean(values['evi'])), + 'n_samples': len(ndvi_vals) + } + + print(f"✅ Calculated patterns for {len(land_type_seasonal_stats)} land types") + + if len(land_type_seasonal_stats) == 0: + print("[NDVI FORECAST][FALLBACK] No seasonal patterns. Falling back to simple seasonal forecast.") + force_simple_forecast = True + + # Generate forecast using land-type-weighted average + if force_simple_forecast: + print("[NDVI FORECAST][FALLBACK] Skipping ML forecast, will use simple seasonal averaging.") + else: + print(f"\n🔮 Generating land-type-specific forecast...") + + forecast_timeseries = [] + current_date = forecast_start + + # Calculate land type weights + total_points = len(predictions) + if total_points == 0: + print("[NDVI FORECAST][FALLBACK] Zero valid classified points. Switching to simple seasonal forecast.") + force_simple_forecast = True + else: + land_type_weights = {lt: np.sum(predictions == lt) / total_points + for lt in unique_types} + + if not force_simple_forecast: + while current_date <= forecast_end: + month = current_date.month + + # Aggregate forecast across all land types (weighted) + weighted_forecast = { + 'ndvi_mean': 0, 'ndvi_min': 0, 'ndvi_max': 0, 'ndvi_std': 0, + 'ndvi_range': 0, 'ndwi_mean': 0, 'ndbi_mean': 0, 'evi_mean': 0 + } + + land_type_contributions = {} + + for land_type, weight in land_type_weights.items(): + if land_type in land_type_seasonal_stats and month in land_type_seasonal_stats[land_type]: + stats = land_type_seasonal_stats[land_type][month] + + land_type_contributions[int(land_type)] = { + **stats, + 'weight': float(weight) + } + + for key in weighted_forecast: + weighted_forecast[key] += stats[key] * weight + + if land_type_contributions: + forecast_data = { + 'date': current_date.strftime('%Y-%m-%d'), + 'is_forecast': True, + 'land_type_specific': land_type_contributions, + **weighted_forecast + } + forecast_timeseries.append(forecast_data) + + current_date += relativedelta(months=1) + + method_used = "Land-Type-Specific Forecasting (ML-Enhanced)" if force_simple_forecast or not use_ml_classification: # Simple seasonal averaging (fallback)