Spaces:
Sleeping
Sleeping
Commit Β·
1e44df5
0
Parent(s):
v1.0.0 TimeSeries Service with AutoNHITS
Browse files- Dockerfile +25 -0
- README.md +57 -0
- app.py +276 -0
- auto_tuning_predictor.py +441 -0
- prediction_pipeline.py +145 -0
- requirements.txt +17 -0
- satellite_pipeline.py +311 -0
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
gcc \
|
| 8 |
+
libgdal-dev \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Copy requirements first for caching
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
+
|
| 15 |
+
# Copy application files
|
| 16 |
+
COPY app.py .
|
| 17 |
+
COPY satellite_pipeline.py .
|
| 18 |
+
COPY auto_tuning_predictor.py .
|
| 19 |
+
COPY prediction_pipeline.py .
|
| 20 |
+
|
| 21 |
+
# Expose port
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
# Run the application
|
| 25 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AGROW TimeSeries
|
| 3 |
+
emoji: π
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# AGROW Time Series Service
|
| 12 |
+
|
| 13 |
+
Time series forecasting for satellite indices using AutoNHITS.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- Historical data from Sentinel-1 (SAR) and Sentinel-2 (Optical)
|
| 18 |
+
- AutoNHITS forecasting with weather integration
|
| 19 |
+
- Returns JSON data for interactive Flutter charts
|
| 20 |
+
|
| 21 |
+
## API Endpoints
|
| 22 |
+
|
| 23 |
+
### POST /timeseries
|
| 24 |
+
|
| 25 |
+
Generate time series with historical data and forecasts.
|
| 26 |
+
|
| 27 |
+
**Request:**
|
| 28 |
+
```json
|
| 29 |
+
{
|
| 30 |
+
"center_lat": 26.1885,
|
| 31 |
+
"center_lon": 91.6894,
|
| 32 |
+
"field_size_hectares": 10.0,
|
| 33 |
+
"metric": "VV"
|
| 34 |
+
}
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**Response:**
|
| 38 |
+
```json
|
| 39 |
+
{
|
| 40 |
+
"success": true,
|
| 41 |
+
"metric": "VV",
|
| 42 |
+
"historical": [{"date": "2024-01-01", "value": -12.5}, ...],
|
| 43 |
+
"forecast": [{"date": "2024-12-01", "value": -11.8, "confidence_low": -12.5, "confidence_high": -11.0}],
|
| 44 |
+
"trend": "improving",
|
| 45 |
+
"stats": {"min": -15.2, "max": -8.5, "mean": -11.5}
|
| 46 |
+
}
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
## Supported Metrics
|
| 50 |
+
|
| 51 |
+
- **SAR:** VV, VH (Sentinel-1)
|
| 52 |
+
- **Optical:** B02-B12 (Sentinel-2)
|
| 53 |
+
|
| 54 |
+
## Required Secrets
|
| 55 |
+
|
| 56 |
+
- `SH_CLIENT_ID`
|
| 57 |
+
- `SH_CLIENT_SECRET`
|
app.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AGROW Time Series Service
|
| 3 |
+
=========================
|
| 4 |
+
Time series forecasting for satellite indices using AutoNHITS.
|
| 5 |
+
|
| 6 |
+
Features:
|
| 7 |
+
- Historical data from Sentinel-1 (SAR) and Sentinel-2 (Optical)
|
| 8 |
+
- AutoNHITS forecasting with weather integration
|
| 9 |
+
- Returns JSON data points for interactive Flutter charts
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import io
|
| 14 |
+
import json
|
| 15 |
+
import base64
|
| 16 |
+
import logging
|
| 17 |
+
import traceback
|
| 18 |
+
from datetime import datetime, timedelta
|
| 19 |
+
from typing import List, Optional, Dict, Any
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
from fastapi import FastAPI, HTTPException
|
| 24 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 25 |
+
from pydantic import BaseModel
|
| 26 |
+
|
| 27 |
+
# Import our modules
|
| 28 |
+
from satellite_pipeline import SatelliteFetcher
|
| 29 |
+
from auto_tuning_predictor import AutoTimeSeriesPredictor
|
| 30 |
+
|
| 31 |
+
# ============================================================================
|
| 32 |
+
# LOGGING
|
| 33 |
+
# ============================================================================
|
| 34 |
+
logging.basicConfig(
|
| 35 |
+
level=logging.INFO,
|
| 36 |
+
format='[%(asctime)s] %(levelname)s: %(message)s',
|
| 37 |
+
datefmt='%H:%M:%S'
|
| 38 |
+
)
|
| 39 |
+
logger = logging.getLogger("TimeSeriesService")
|
| 40 |
+
|
| 41 |
+
# ============================================================================
|
| 42 |
+
# FASTAPI
|
| 43 |
+
# ============================================================================
|
| 44 |
+
app = FastAPI(
|
| 45 |
+
title="AGROW Time Series Service",
|
| 46 |
+
description="Time series forecasting for satellite indices",
|
| 47 |
+
version="1.0.0"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
app.add_middleware(
|
| 51 |
+
CORSMiddleware,
|
| 52 |
+
allow_origins=["*"],
|
| 53 |
+
allow_credentials=True,
|
| 54 |
+
allow_methods=["*"],
|
| 55 |
+
allow_headers=["*"],
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# ============================================================================
|
| 59 |
+
# REQUEST/RESPONSE MODELS
|
| 60 |
+
# ============================================================================
|
| 61 |
+
class DataPoint(BaseModel):
|
| 62 |
+
date: str
|
| 63 |
+
value: float
|
| 64 |
+
|
| 65 |
+
class ForecastPoint(BaseModel):
|
| 66 |
+
date: str
|
| 67 |
+
value: float
|
| 68 |
+
confidence_low: Optional[float] = None
|
| 69 |
+
confidence_high: Optional[float] = None
|
| 70 |
+
|
| 71 |
+
class TimeSeriesRequest(BaseModel):
|
| 72 |
+
center_lat: float
|
| 73 |
+
center_lon: float
|
| 74 |
+
field_size_hectares: float = 10.0
|
| 75 |
+
metric: str = "VV" # VV, VH, B04, B08, NDVI, etc.
|
| 76 |
+
days_history: int = 365 # How many days of history
|
| 77 |
+
days_forecast: int = 30 # How many days to forecast
|
| 78 |
+
|
| 79 |
+
class TimeSeriesResponse(BaseModel):
|
| 80 |
+
success: bool
|
| 81 |
+
metric: str
|
| 82 |
+
historical: List[DataPoint]
|
| 83 |
+
forecast: List[ForecastPoint]
|
| 84 |
+
trend: str # "improving", "stable", "declining"
|
| 85 |
+
stats: Dict[str, float]
|
| 86 |
+
timestamp: str
|
| 87 |
+
|
| 88 |
+
# ============================================================================
|
| 89 |
+
# HELPERS
|
| 90 |
+
# ============================================================================
|
| 91 |
+
def coords_to_polygon(center_lat: float, center_lon: float, size_ha: float):
|
| 92 |
+
"""Convert center point and size to polygon coordinates."""
|
| 93 |
+
# Approximate conversion: 1 hectare = 0.01 kmΒ²
|
| 94 |
+
radius_km = np.sqrt(size_ha / 100) / 2
|
| 95 |
+
lat_off = radius_km / 111
|
| 96 |
+
lon_off = radius_km / (111 * np.cos(np.radians(center_lat)))
|
| 97 |
+
|
| 98 |
+
return [
|
| 99 |
+
(center_lon - lon_off, center_lat - lat_off),
|
| 100 |
+
(center_lon + lon_off, center_lat - lat_off),
|
| 101 |
+
(center_lon + lon_off, center_lat + lat_off),
|
| 102 |
+
(center_lon - lon_off, center_lat + lat_off),
|
| 103 |
+
(center_lon - lon_off, center_lat - lat_off), # Close polygon
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
def calculate_trend(values: List[float]) -> str:
|
| 107 |
+
"""Determine trend from values."""
|
| 108 |
+
if len(values) < 5:
|
| 109 |
+
return "stable"
|
| 110 |
+
|
| 111 |
+
# Simple linear regression slope
|
| 112 |
+
x = np.arange(len(values))
|
| 113 |
+
slope = np.polyfit(x, values, 1)[0]
|
| 114 |
+
|
| 115 |
+
if slope > 0.01:
|
| 116 |
+
return "improving"
|
| 117 |
+
elif slope < -0.01:
|
| 118 |
+
return "declining"
|
| 119 |
+
return "stable"
|
| 120 |
+
|
| 121 |
+
# ============================================================================
|
| 122 |
+
# API ENDPOINTS
|
| 123 |
+
# ============================================================================
|
| 124 |
+
@app.get("/")
|
| 125 |
+
async def root():
|
| 126 |
+
return {
|
| 127 |
+
"service": "AGROW Time Series Service",
|
| 128 |
+
"version": "1.0.0",
|
| 129 |
+
"metrics": ["VV", "VH", "B02", "B03", "B04", "B08", "B8A", "B11", "B12"],
|
| 130 |
+
"description": "Time series forecasting with AutoNHITS"
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
@app.get("/health")
|
| 134 |
+
async def health():
|
| 135 |
+
return {"status": "healthy"}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.post("/timeseries", response_model=TimeSeriesResponse)
|
| 139 |
+
async def get_timeseries(request: TimeSeriesRequest):
|
| 140 |
+
"""
|
| 141 |
+
Fetch historical satellite data and generate forecasts.
|
| 142 |
+
|
| 143 |
+
This uses the full AutoNHITS model for accurate predictions.
|
| 144 |
+
Note: First request may take 2-5 minutes for data fetching and model training.
|
| 145 |
+
"""
|
| 146 |
+
req_id = datetime.now().strftime("%H%M%S")
|
| 147 |
+
logger.info(f"[{req_id}] Time Series Request: {request.metric}")
|
| 148 |
+
logger.info(f"[{req_id}] Location: ({request.center_lat}, {request.center_lon})")
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
# Step 1: Create polygon from center point
|
| 152 |
+
polygon = coords_to_polygon(
|
| 153 |
+
request.center_lat,
|
| 154 |
+
request.center_lon,
|
| 155 |
+
request.field_size_hectares
|
| 156 |
+
)
|
| 157 |
+
logger.info(f"[{req_id}] Polygon created: {polygon[:2]}...")
|
| 158 |
+
|
| 159 |
+
# Step 2: Fetch historical satellite data
|
| 160 |
+
logger.info(f"[{req_id}] Fetching satellite data...")
|
| 161 |
+
fetcher = SatelliteFetcher(polygon)
|
| 162 |
+
|
| 163 |
+
# Determine which data to fetch based on metric
|
| 164 |
+
if request.metric in ['VV', 'VH']:
|
| 165 |
+
fetcher.fetch_sar_data('sar_data.csv')
|
| 166 |
+
csv_file = 'sar_data.csv'
|
| 167 |
+
target_col = f'{request.metric}_mean_dB'
|
| 168 |
+
else:
|
| 169 |
+
fetcher.fetch_sentinel2_data('sentinel2_data.csv')
|
| 170 |
+
csv_file = 'sentinel2_data.csv'
|
| 171 |
+
target_col = request.metric
|
| 172 |
+
|
| 173 |
+
# Read historical data
|
| 174 |
+
if not os.path.exists(csv_file):
|
| 175 |
+
raise HTTPException(404, "No satellite data available for this location")
|
| 176 |
+
|
| 177 |
+
df = pd.read_csv(csv_file)
|
| 178 |
+
if 'ds' not in df.columns:
|
| 179 |
+
raise HTTPException(500, "Invalid data format")
|
| 180 |
+
|
| 181 |
+
# Convert to DataPoint list
|
| 182 |
+
historical = []
|
| 183 |
+
for _, row in df.iterrows():
|
| 184 |
+
if target_col in row and not pd.isna(row[target_col]):
|
| 185 |
+
historical.append(DataPoint(
|
| 186 |
+
date=str(row['ds']),
|
| 187 |
+
value=round(float(row[target_col]), 4)
|
| 188 |
+
))
|
| 189 |
+
|
| 190 |
+
if len(historical) < 10:
|
| 191 |
+
raise HTTPException(400, f"Insufficient data points ({len(historical)}) for forecasting")
|
| 192 |
+
|
| 193 |
+
logger.info(f"[{req_id}] Historical data: {len(historical)} points")
|
| 194 |
+
|
| 195 |
+
# Step 3: Run AutoNHITS prediction
|
| 196 |
+
logger.info(f"[{req_id}] Running AutoNHITS prediction...")
|
| 197 |
+
predictor = AutoTimeSeriesPredictor()
|
| 198 |
+
|
| 199 |
+
predictions = predictor.tune_and_predict(
|
| 200 |
+
csv_path=csv_file,
|
| 201 |
+
field_coords=polygon,
|
| 202 |
+
target_col=target_col,
|
| 203 |
+
output_file='predictions.csv',
|
| 204 |
+
num_samples=5 # Quick tuning for API
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Convert predictions to ForecastPoint list
|
| 208 |
+
forecast = []
|
| 209 |
+
for _, row in predictions.iterrows():
|
| 210 |
+
value = float(row['predicted_y'])
|
| 211 |
+
forecast.append(ForecastPoint(
|
| 212 |
+
date=str(row['ds'].date()) if hasattr(row['ds'], 'date') else str(row['ds']),
|
| 213 |
+
value=round(value, 4),
|
| 214 |
+
confidence_low=round(value * 0.9, 4), # Simple confidence band
|
| 215 |
+
confidence_high=round(value * 1.1, 4)
|
| 216 |
+
))
|
| 217 |
+
|
| 218 |
+
logger.info(f"[{req_id}] Forecast: {len(forecast)} points")
|
| 219 |
+
|
| 220 |
+
# Calculate stats
|
| 221 |
+
all_values = [p.value for p in historical]
|
| 222 |
+
stats = {
|
| 223 |
+
"min": round(min(all_values), 4),
|
| 224 |
+
"max": round(max(all_values), 4),
|
| 225 |
+
"mean": round(sum(all_values) / len(all_values), 4),
|
| 226 |
+
"count": len(all_values),
|
| 227 |
+
"forecast_count": len(forecast)
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
trend = calculate_trend(all_values[-20:] if len(all_values) > 20 else all_values)
|
| 231 |
+
|
| 232 |
+
# Cleanup temp files
|
| 233 |
+
for f in ['sar_data.csv', 'sentinel2_data.csv', 'predictions.csv']:
|
| 234 |
+
if os.path.exists(f):
|
| 235 |
+
os.remove(f)
|
| 236 |
+
|
| 237 |
+
logger.info(f"[{req_id}] SUCCESS - Trend: {trend}")
|
| 238 |
+
|
| 239 |
+
return TimeSeriesResponse(
|
| 240 |
+
success=True,
|
| 241 |
+
metric=request.metric,
|
| 242 |
+
historical=historical,
|
| 243 |
+
forecast=forecast,
|
| 244 |
+
trend=trend,
|
| 245 |
+
stats=stats,
|
| 246 |
+
timestamp=datetime.now().isoformat()
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
except HTTPException:
|
| 250 |
+
raise
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.error(f"[{req_id}] ERROR: {str(e)}")
|
| 253 |
+
logger.error(traceback.format_exc())
|
| 254 |
+
raise HTTPException(500, str(e))
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@app.get("/timeseries/quick")
|
| 258 |
+
async def quick_timeseries(
|
| 259 |
+
center_lat: float,
|
| 260 |
+
center_lon: float,
|
| 261 |
+
metric: str = "VV",
|
| 262 |
+
field_size_hectares: float = 10.0
|
| 263 |
+
):
|
| 264 |
+
"""Quick endpoint for simple GET requests."""
|
| 265 |
+
request = TimeSeriesRequest(
|
| 266 |
+
center_lat=center_lat,
|
| 267 |
+
center_lon=center_lon,
|
| 268 |
+
field_size_hectares=field_size_hectares,
|
| 269 |
+
metric=metric
|
| 270 |
+
)
|
| 271 |
+
return await get_timeseries(request)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
import uvicorn
|
| 276 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
auto_tuning_predictor.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
from shapely.geometry import Polygon
|
| 4 |
+
from sklearn.preprocessing import StandardScaler
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
|
| 7 |
+
from neuralforecast import NeuralForecast
|
| 8 |
+
from neuralforecast.auto import AutoNHITS
|
| 9 |
+
from neuralforecast.losses.pytorch import MAE
|
| 10 |
+
|
| 11 |
+
import openmeteo_requests
|
| 12 |
+
import requests_cache
|
| 13 |
+
from retry_requests import retry
|
| 14 |
+
from openmeteo_sdk.Variable import Variable
|
| 15 |
+
from openmeteo_sdk.Aggregation import Aggregation
|
| 16 |
+
import warnings
|
| 17 |
+
import os
|
| 18 |
+
import datetime
|
| 19 |
+
|
| 20 |
+
# Suppress warnings
|
| 21 |
+
warnings.filterwarnings("ignore")
|
| 22 |
+
|
| 23 |
+
class AutoTimeSeriesPredictor:
|
| 24 |
+
def __init__(self):
|
| 25 |
+
# Coordinates will be set in tune_and_predict
|
| 26 |
+
self.field_coords = None
|
| 27 |
+
self.latitude = None
|
| 28 |
+
self.longitude = None
|
| 29 |
+
|
| 30 |
+
# OpenMeteo Client Setup
|
| 31 |
+
cache_session = requests_cache.CachedSession(".cache", expire_after=-1)
|
| 32 |
+
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
|
| 33 |
+
self.openmeteo = openmeteo_requests.Client(session=retry_session)
|
| 34 |
+
|
| 35 |
+
# Feature Configuration
|
| 36 |
+
self.hist_exog_list = ["lag1", "diff"]
|
| 37 |
+
self.futr_exog_list = ["temp", "rainfall", "humidity"]
|
| 38 |
+
|
| 39 |
+
# Scalers
|
| 40 |
+
self.y_scaler = StandardScaler()
|
| 41 |
+
self.exog_scaler = StandardScaler()
|
| 42 |
+
|
| 43 |
+
def set_coordinates(self, coords):
|
| 44 |
+
"""
|
| 45 |
+
Sets the field coordinates and calculates the centroid latitude and longitude.
|
| 46 |
+
"""
|
| 47 |
+
self.field_coords = coords
|
| 48 |
+
field_polygon = Polygon(self.field_coords)
|
| 49 |
+
self.latitude = field_polygon.centroid.y
|
| 50 |
+
self.longitude = field_polygon.centroid.x
|
| 51 |
+
|
| 52 |
+
def fetch_weather_data(self, start_date, end_date):
|
| 53 |
+
"""
|
| 54 |
+
Fetches weather data, automatically switching between Historical and Ensemble Forecast APIs.
|
| 55 |
+
"""
|
| 56 |
+
# Ensure dates are date objects
|
| 57 |
+
if isinstance(start_date, pd.Timestamp):
|
| 58 |
+
start_date = start_date.date()
|
| 59 |
+
if isinstance(end_date, pd.Timestamp):
|
| 60 |
+
end_date = end_date.date()
|
| 61 |
+
|
| 62 |
+
today = datetime.date.today()
|
| 63 |
+
|
| 64 |
+
dfs = []
|
| 65 |
+
|
| 66 |
+
# 1. Historical Data (if start_date < today)
|
| 67 |
+
if start_date < today:
|
| 68 |
+
hist_end = min(end_date, today - datetime.timedelta(days=1))
|
| 69 |
+
if start_date <= hist_end:
|
| 70 |
+
print(f"Fetching historical data from {start_date} to {hist_end}...")
|
| 71 |
+
try:
|
| 72 |
+
hist_df = self._fetch_historical_api(start_date, hist_end)
|
| 73 |
+
dfs.append(hist_df)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"Error fetching historical data: {e}")
|
| 76 |
+
|
| 77 |
+
# 2. Forecast Data (if end_date >= today)
|
| 78 |
+
if end_date >= today:
|
| 79 |
+
print(f"Fetching forecast data from {today} to {end_date}...")
|
| 80 |
+
try:
|
| 81 |
+
# Calculate needed forecast days
|
| 82 |
+
days_needed = (end_date - today).days + 1
|
| 83 |
+
# API supports up to 35 days for ensemble
|
| 84 |
+
forecast_days = min(max(days_needed, 1), 35)
|
| 85 |
+
|
| 86 |
+
forecast_df = self._fetch_ensemble_forecast_api(forecast_days)
|
| 87 |
+
|
| 88 |
+
# Filter for requested range
|
| 89 |
+
forecast_df = forecast_df[
|
| 90 |
+
(forecast_df["ds"].dt.date >= today) &
|
| 91 |
+
(forecast_df["ds"].dt.date <= end_date)
|
| 92 |
+
]
|
| 93 |
+
dfs.append(forecast_df)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Error fetching forecast data: {e}")
|
| 96 |
+
|
| 97 |
+
if not dfs:
|
| 98 |
+
print("Warning: No weather data fetched.")
|
| 99 |
+
return pd.DataFrame(columns=["ds", "temp", "humidity", "rainfall"])
|
| 100 |
+
|
| 101 |
+
final_df = pd.concat(dfs, ignore_index=True)
|
| 102 |
+
final_df = final_df.sort_values("ds").reset_index(drop=True)
|
| 103 |
+
|
| 104 |
+
# Aggregate to daily if not already (The helpers return daily)
|
| 105 |
+
# But we need to ensure unique dates in case of overlap
|
| 106 |
+
final_df = final_df.drop_duplicates(subset=["ds"], keep="last")
|
| 107 |
+
|
| 108 |
+
# Filter to ensure exact range (handling timezone spillover)
|
| 109 |
+
final_df = final_df[
|
| 110 |
+
(final_df["ds"].dt.date >= start_date) &
|
| 111 |
+
(final_df["ds"].dt.date <= end_date)
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
return final_df.reset_index(drop=True)
|
| 115 |
+
|
| 116 |
+
def _fetch_historical_api(self, start_date, end_date):
|
| 117 |
+
url = "https://archive-api.open-meteo.com/v1/archive"
|
| 118 |
+
params = {
|
| 119 |
+
"latitude": self.latitude,
|
| 120 |
+
"longitude": self.longitude,
|
| 121 |
+
"start_date": start_date.strftime("%Y-%m-%d"),
|
| 122 |
+
"end_date": end_date.strftime("%Y-%m-%d"),
|
| 123 |
+
"daily": ["temperature_2m_mean", "rain_sum"], # We need these for consistency check, but we use hourly aggregated
|
| 124 |
+
"hourly": ["temperature_2m", "relative_humidity_2m", "rain"],
|
| 125 |
+
"timezone": "auto",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
responses = self.openmeteo.weather_api(url, params=params)
|
| 129 |
+
response = responses[0]
|
| 130 |
+
|
| 131 |
+
hourly = response.Hourly()
|
| 132 |
+
hourly_data = {
|
| 133 |
+
"date": pd.date_range(
|
| 134 |
+
start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
|
| 135 |
+
end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
|
| 136 |
+
freq=pd.Timedelta(seconds=hourly.Interval()),
|
| 137 |
+
inclusive="left",
|
| 138 |
+
),
|
| 139 |
+
"temperature_2m": hourly.Variables(0).ValuesAsNumpy(),
|
| 140 |
+
"relative_humidity_2m": hourly.Variables(1).ValuesAsNumpy(),
|
| 141 |
+
"rain": hourly.Variables(2).ValuesAsNumpy(),
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
hourly_df = pd.DataFrame(data=hourly_data)
|
| 145 |
+
|
| 146 |
+
# Daily aggregation
|
| 147 |
+
hourly_df["ds"] = hourly_df["date"].dt.floor("D").dt.tz_convert(None)
|
| 148 |
+
daily_weather = (
|
| 149 |
+
hourly_df.groupby("ds")
|
| 150 |
+
.agg(
|
| 151 |
+
temp=("temperature_2m", "mean"),
|
| 152 |
+
humidity=("relative_humidity_2m", "mean"),
|
| 153 |
+
rainfall=("rain", "sum"),
|
| 154 |
+
)
|
| 155 |
+
.reset_index()
|
| 156 |
+
)
|
| 157 |
+
return daily_weather
|
| 158 |
+
|
| 159 |
+
def _fetch_ensemble_forecast_api(self, forecast_days):
|
| 160 |
+
url = "https://ensemble-api.open-meteo.com/v1/ensemble"
|
| 161 |
+
params = {
|
| 162 |
+
"latitude": self.latitude,
|
| 163 |
+
"longitude": self.longitude,
|
| 164 |
+
"hourly": ["temperature_2m", "relative_humidity_2m", "rain"],
|
| 165 |
+
"models": ["ecmwf_ifs025", "gfs025", "icon_global", "icon_seamless", "gem_global", "bom_access_global_ensemble"],
|
| 166 |
+
"timezone": "auto",
|
| 167 |
+
"forecast_days": forecast_days,
|
| 168 |
+
}
|
| 169 |
+
responses = self.openmeteo.weather_api(url, params=params)
|
| 170 |
+
|
| 171 |
+
# We will aggregate all models and members into a single mean
|
| 172 |
+
all_hourly_dfs = []
|
| 173 |
+
|
| 174 |
+
for response in responses:
|
| 175 |
+
hourly = response.Hourly()
|
| 176 |
+
|
| 177 |
+
# Helper to extract all members for a variable
|
| 178 |
+
def get_members(variable_type):
|
| 179 |
+
# variable_type is an enum from Variable class?
|
| 180 |
+
# The snippet uses filter on hourly.Variables()
|
| 181 |
+
# We need to map the snippet logic here.
|
| 182 |
+
vars_list = [hourly.Variables(i) for i in range(hourly.VariablesLength())]
|
| 183 |
+
return [v for v in vars_list if v.Variable() == variable_type]
|
| 184 |
+
|
| 185 |
+
# We need Variable enum.
|
| 186 |
+
# Note: The snippet imports Variable.
|
| 187 |
+
# We need to check if Variable.temperature is correct mapping for "temperature_2m"
|
| 188 |
+
# In snippet: Variable.temperature and Altitude() == 2
|
| 189 |
+
|
| 190 |
+
temp_vars = [v for v in [hourly.Variables(i) for i in range(hourly.VariablesLength())]
|
| 191 |
+
if v.Variable() == Variable.temperature and v.Altitude() == 2]
|
| 192 |
+
|
| 193 |
+
rh_vars = [v for v in [hourly.Variables(i) for i in range(hourly.VariablesLength())]
|
| 194 |
+
if v.Variable() == Variable.relative_humidity and v.Altitude() == 2]
|
| 195 |
+
|
| 196 |
+
rain_vars = [v for v in [hourly.Variables(i) for i in range(hourly.VariablesLength())]
|
| 197 |
+
if v.Variable() == Variable.rain]
|
| 198 |
+
|
| 199 |
+
# Create a DF for this model
|
| 200 |
+
dates = pd.date_range(
|
| 201 |
+
start=pd.to_datetime(hourly.Time(), unit="s", utc=True),
|
| 202 |
+
end=pd.to_datetime(hourly.TimeEnd(), unit="s", utc=True),
|
| 203 |
+
freq=pd.Timedelta(seconds=hourly.Interval()),
|
| 204 |
+
inclusive="left",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
model_df = pd.DataFrame({"date": dates})
|
| 208 |
+
|
| 209 |
+
# Average members for this model
|
| 210 |
+
if temp_vars:
|
| 211 |
+
temps = np.stack([v.ValuesAsNumpy() for v in temp_vars])
|
| 212 |
+
model_df["temp"] = np.mean(temps, axis=0)
|
| 213 |
+
else:
|
| 214 |
+
model_df["temp"] = np.nan
|
| 215 |
+
|
| 216 |
+
if rh_vars:
|
| 217 |
+
rhs = np.stack([v.ValuesAsNumpy() for v in rh_vars])
|
| 218 |
+
model_df["humidity"] = np.mean(rhs, axis=0)
|
| 219 |
+
else:
|
| 220 |
+
model_df["humidity"] = np.nan
|
| 221 |
+
|
| 222 |
+
if rain_vars:
|
| 223 |
+
rains = np.stack([v.ValuesAsNumpy() for v in rain_vars])
|
| 224 |
+
model_df["rainfall"] = np.mean(rains, axis=0)
|
| 225 |
+
else:
|
| 226 |
+
model_df["rainfall"] = 0
|
| 227 |
+
|
| 228 |
+
all_hourly_dfs.append(model_df)
|
| 229 |
+
|
| 230 |
+
# Concatenate all models
|
| 231 |
+
full_hourly = pd.concat(all_hourly_dfs, ignore_index=True)
|
| 232 |
+
|
| 233 |
+
# Group by date and take mean across all models
|
| 234 |
+
full_hourly = full_hourly.groupby("date").mean().reset_index()
|
| 235 |
+
|
| 236 |
+
# Daily aggregation
|
| 237 |
+
full_hourly["ds"] = full_hourly["date"].dt.floor("D").dt.tz_convert(None)
|
| 238 |
+
daily_weather = (
|
| 239 |
+
full_hourly.groupby("ds")
|
| 240 |
+
.agg(
|
| 241 |
+
temp=("temp", "mean"),
|
| 242 |
+
humidity=("humidity", "mean"),
|
| 243 |
+
rainfall=("rainfall", "sum"),
|
| 244 |
+
)
|
| 245 |
+
.reset_index()
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
return daily_weather
|
| 249 |
+
|
| 250 |
+
def preprocess_data(self, df):
|
| 251 |
+
"""
|
| 252 |
+
Preprocesses the input dataframe:
|
| 253 |
+
1. Fetches historical weather
|
| 254 |
+
2. Creates lag/diff features
|
| 255 |
+
3. Scales data
|
| 256 |
+
"""
|
| 257 |
+
df = df.copy()
|
| 258 |
+
df["ds"] = pd.to_datetime(df["ds"])
|
| 259 |
+
df = df.sort_values("ds").reset_index(drop=True)
|
| 260 |
+
df["unique_id"] = "VV"
|
| 261 |
+
|
| 262 |
+
# 1. Fetch Historical Weather
|
| 263 |
+
start_date = df["ds"].min().date()
|
| 264 |
+
end_date = df["ds"].max().date()
|
| 265 |
+
print(f"Fetching historical weather from {start_date} to {end_date}...")
|
| 266 |
+
weather_df = self.fetch_weather_data(start_date, end_date)
|
| 267 |
+
|
| 268 |
+
df = df.merge(weather_df, on="ds", how="left")
|
| 269 |
+
df[self.futr_exog_list] = df[self.futr_exog_list].ffill().bfill()
|
| 270 |
+
|
| 271 |
+
# 2. Feature Engineering (Lags/Diffs)
|
| 272 |
+
df["lag1"] = df["y"].shift(1)
|
| 273 |
+
df["diff"] = df["y"].diff()
|
| 274 |
+
|
| 275 |
+
df = df.dropna().reset_index(drop=True)
|
| 276 |
+
|
| 277 |
+
# 3. Scaling
|
| 278 |
+
df["y"] = self.y_scaler.fit_transform(df[["y"]])
|
| 279 |
+
|
| 280 |
+
all_exog = self.hist_exog_list + self.futr_exog_list
|
| 281 |
+
df[all_exog] = self.exog_scaler.fit_transform(df[all_exog])
|
| 282 |
+
|
| 283 |
+
return df
|
| 284 |
+
|
| 285 |
+
def tune_and_predict(self, csv_path, field_coords, target_col="y", output_file="auto_tuned_predictions.csv", num_samples=10):
|
| 286 |
+
"""
|
| 287 |
+
Runs AutoNHITS tuning and predicts the next 20 days.
|
| 288 |
+
"""
|
| 289 |
+
print(f"Setting coordinates to: {field_coords}")
|
| 290 |
+
self.set_coordinates(field_coords)
|
| 291 |
+
|
| 292 |
+
# 1. Load Data
|
| 293 |
+
print("Loading data...")
|
| 294 |
+
df = pd.read_csv(csv_path)
|
| 295 |
+
|
| 296 |
+
# Rename target column to 'y' if it exists
|
| 297 |
+
if target_col in df.columns:
|
| 298 |
+
df = df.rename(columns={target_col: "y"})
|
| 299 |
+
|
| 300 |
+
if "ds" not in df.columns or "y" not in df.columns:
|
| 301 |
+
raise ValueError(f"CSV must contain 'ds' and '{target_col}' (mapped to 'y') columns.")
|
| 302 |
+
|
| 303 |
+
# 2. Preprocess
|
| 304 |
+
print("Preprocessing data...")
|
| 305 |
+
train_df = self.preprocess_data(df)
|
| 306 |
+
|
| 307 |
+
# 3. Prepare Future Dataframe
|
| 308 |
+
last_date = train_df["ds"].max()
|
| 309 |
+
# Ensure we predict for at least 30 days as per user request "next 1 month"
|
| 310 |
+
# Data frequency is 5 Days, so 30 days / 5 = 6 periods
|
| 311 |
+
prediction_days = 6
|
| 312 |
+
# Start 5 days after the last training date
|
| 313 |
+
future_dates = pd.date_range(start=last_date + pd.Timedelta(days=5), periods=prediction_days, freq="5D")
|
| 314 |
+
future_df = pd.DataFrame({"ds": future_dates, "unique_id": "VV"})
|
| 315 |
+
|
| 316 |
+
# 4. Fetch Future Weather
|
| 317 |
+
print("Fetching future weather forecast...")
|
| 318 |
+
# The new fetch_weather_data handles the logic automatically
|
| 319 |
+
weather_future = self.fetch_weather_data(future_dates[0].date(), future_dates[-1].date())
|
| 320 |
+
|
| 321 |
+
future_df = future_df.merge(weather_future, on="ds", how="left")
|
| 322 |
+
future_df[self.futr_exog_list] = future_df[self.futr_exog_list].fillna(0)
|
| 323 |
+
|
| 324 |
+
# 5. Auto Model Definition
|
| 325 |
+
import ray.tune as tune
|
| 326 |
+
print(f"Initializing AutoNHITS model (tuning with {num_samples} samples)...")
|
| 327 |
+
|
| 328 |
+
# Define a custom search space (same as auto_tuning_testing.py)
|
| 329 |
+
config = {
|
| 330 |
+
"input_size": tune.choice([60, 90, 120]), # Lookback window
|
| 331 |
+
"learning_rate": tune.loguniform(1e-4, 1e-2), # Learning rate
|
| 332 |
+
"n_blocks": tune.choice([[1, 1, 1], [3, 3, 3]]), # Depth
|
| 333 |
+
"mlp_units": tune.choice([ # Width
|
| 334 |
+
[[64, 64], [64, 64], [64, 64]],
|
| 335 |
+
[[512, 512], [512, 512], [512, 512]]
|
| 336 |
+
]),
|
| 337 |
+
"n_pool_kernel_size": tune.choice([ # Pooling
|
| 338 |
+
[2, 2, 1],
|
| 339 |
+
[4, 4, 2],
|
| 340 |
+
[8, 4, 1]
|
| 341 |
+
]),
|
| 342 |
+
"n_freq_downsample": tune.choice([ # Downsampling
|
| 343 |
+
[2, 1, 1],
|
| 344 |
+
[4, 2, 1],
|
| 345 |
+
[8, 4, 1]
|
| 346 |
+
])
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
# AutoNHITS configuration
|
| 350 |
+
auto_nhits = AutoNHITS(
|
| 351 |
+
h=prediction_days, # Horizon (6 steps = 30 days)
|
| 352 |
+
loss=MAE(),
|
| 353 |
+
config=config,
|
| 354 |
+
search_alg=None, # Use default search algorithm (HyperOpt)
|
| 355 |
+
num_samples=num_samples, # Number of trials
|
| 356 |
+
cpus=1,
|
| 357 |
+
gpus=0, # Set to 1 if GPU available
|
| 358 |
+
verbose=True,
|
| 359 |
+
alias="AutoNHITS"
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
nf = NeuralForecast(models=[auto_nhits], freq="5D")
|
| 363 |
+
|
| 364 |
+
# 6. Train (Tune) and Predict
|
| 365 |
+
print("Tuning and Training model...")
|
| 366 |
+
nf.fit(df=train_df)
|
| 367 |
+
|
| 368 |
+
# Get best config
|
| 369 |
+
# The model inside nf.models[0] is the trained AutoNHITS
|
| 370 |
+
# It should have 'results' or 'best_config' attribute after fitting?
|
| 371 |
+
# Actually, AutoNHITS replaces itself with the best model found or wraps it.
|
| 372 |
+
|
| 373 |
+
print("Predicting...")
|
| 374 |
+
# Prepare future exogenous features
|
| 375 |
+
# Note: We need to scale them!
|
| 376 |
+
X_futr = future_df[self.futr_exog_list].values
|
| 377 |
+
|
| 378 |
+
# Get indices of futr_exog in the scaler
|
| 379 |
+
# self.exog_scaler was fitted on [hist_exog + futr_exog]
|
| 380 |
+
# futr_exog are the last columns, starting after hist_exog
|
| 381 |
+
start_idx = len(self.hist_exog_list)
|
| 382 |
+
futr_indices = [start_idx + i for i in range(len(self.futr_exog_list))]
|
| 383 |
+
|
| 384 |
+
means = self.exog_scaler.mean_[futr_indices]
|
| 385 |
+
scales = self.exog_scaler.scale_[futr_indices]
|
| 386 |
+
|
| 387 |
+
X_futr_scaled = (X_futr - means) / scales
|
| 388 |
+
future_df[self.futr_exog_list] = X_futr_scaled
|
| 389 |
+
|
| 390 |
+
preds_df = nf.predict(futr_df=future_df)
|
| 391 |
+
|
| 392 |
+
# 7. Inverse Scale Predictions
|
| 393 |
+
y_pred_scaled = preds_df["AutoNHITS"].values.reshape(-1, 1)
|
| 394 |
+
y_pred = self.y_scaler.inverse_transform(y_pred_scaled).flatten()
|
| 395 |
+
|
| 396 |
+
result = pd.DataFrame({
|
| 397 |
+
"ds": preds_df["ds"].values,
|
| 398 |
+
"predicted_y": y_pred
|
| 399 |
+
})
|
| 400 |
+
|
| 401 |
+
# 8. Plot results
|
| 402 |
+
result.to_csv(output_file, index=False)
|
| 403 |
+
print(f"\nPredictions saved to {output_file}")
|
| 404 |
+
|
| 405 |
+
return result
|
| 406 |
+
|
| 407 |
+
try:
|
| 408 |
+
from plot_predictions import plot_predictions
|
| 409 |
+
print("\nGenerating plot...")
|
| 410 |
+
plot_predictions(csv_path, output_file)
|
| 411 |
+
except Exception as e:
|
| 412 |
+
print(f"Warning: Could not generate plot: {e}")
|
| 413 |
+
|
| 414 |
+
return result
|
| 415 |
+
|
| 416 |
+
if __name__ == "__main__":
|
| 417 |
+
# Define the input file path here
|
| 418 |
+
target_file = "vh_data_structured.csv"
|
| 419 |
+
|
| 420 |
+
print(f"Using input file: {target_file}")
|
| 421 |
+
|
| 422 |
+
if os.path.exists(target_file):
|
| 423 |
+
predictor = AutoTimeSeriesPredictor()
|
| 424 |
+
|
| 425 |
+
# Example coordinates
|
| 426 |
+
coords = [
|
| 427 |
+
(77.2090, 28.6139),
|
| 428 |
+
(77.2100, 28.6139),
|
| 429 |
+
(77.2100, 28.6149),
|
| 430 |
+
(77.2090, 28.6149),
|
| 431 |
+
(77.2090, 28.6139),
|
| 432 |
+
]
|
| 433 |
+
|
| 434 |
+
try:
|
| 435 |
+
predictions = predictor.tune_and_predict(target_file, field_coords=coords, num_samples=30) # 30 samples for better results
|
| 436 |
+
print("\nPredictions for the next 20 days:")
|
| 437 |
+
print(predictions)
|
| 438 |
+
except Exception as e:
|
| 439 |
+
print(f"An error occurred: {e}")
|
| 440 |
+
else:
|
| 441 |
+
print(f"File not found: {target_file}")
|
prediction_pipeline.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prediction Pipeline
|
| 3 |
+
Orchestrates satellite data fetching and time series prediction.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from satellite_pipeline import SatelliteFetcher
|
| 9 |
+
from auto_tuning_predictor import AutoTimeSeriesPredictor
|
| 10 |
+
|
| 11 |
+
class PredictionOrchestrator:
|
| 12 |
+
"""
|
| 13 |
+
Orchestrates the entire pipeline:
|
| 14 |
+
1. Fetch Satellite Data (SAR & Sentinel-2)
|
| 15 |
+
2. Generate Predictions for ALL bands using AutoNHITS
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, polygon_coords):
|
| 19 |
+
"""
|
| 20 |
+
Initialize the orchestrator.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
polygon_coords (list): List of (lon, lat) tuples defining the polygon.
|
| 24 |
+
"""
|
| 25 |
+
self.polygon_coords = polygon_coords
|
| 26 |
+
self.sar_csv = 'sar_data.csv'
|
| 27 |
+
self.sentinel2_csv = 'sentinel2_data.csv'
|
| 28 |
+
|
| 29 |
+
self.sar_pred_csv = 'sar_predictions.csv'
|
| 30 |
+
self.sentinel2_pred_csv = 'sentinel2_predictions.csv'
|
| 31 |
+
|
| 32 |
+
def run(self):
|
| 33 |
+
"""Run the full pipeline."""
|
| 34 |
+
print("="*60)
|
| 35 |
+
print("STARTING PREDICTION PIPELINE")
|
| 36 |
+
print("="*60)
|
| 37 |
+
|
| 38 |
+
# 1. Fetch Satellite Data
|
| 39 |
+
print("\n[1/2] Fetching Satellite Data...")
|
| 40 |
+
fetcher = SatelliteFetcher(self.polygon_coords)
|
| 41 |
+
fetcher.run_all()
|
| 42 |
+
# print("Skipping fetch for verification (using existing CSVs)...")
|
| 43 |
+
|
| 44 |
+
# 2. Generate Predictions
|
| 45 |
+
print("\n[2/2] Generating Predictions...")
|
| 46 |
+
predictor = AutoTimeSeriesPredictor()
|
| 47 |
+
|
| 48 |
+
# --- SAR Prediction ---
|
| 49 |
+
print("\n" + "-"*30)
|
| 50 |
+
print("Predicting SAR Data (All Bands)")
|
| 51 |
+
print("-"*30)
|
| 52 |
+
self._predict_all_bands(
|
| 53 |
+
predictor,
|
| 54 |
+
input_csv=self.sar_csv,
|
| 55 |
+
output_csv=self.sar_pred_csv
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# --- Sentinel-2 Prediction ---
|
| 59 |
+
print("\n" + "-"*30)
|
| 60 |
+
print("Predicting Sentinel-2 Data (All Bands)")
|
| 61 |
+
print("-"*30)
|
| 62 |
+
self._predict_all_bands(
|
| 63 |
+
predictor,
|
| 64 |
+
input_csv=self.sentinel2_csv,
|
| 65 |
+
output_csv=self.sentinel2_pred_csv
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
print("\n" + "="*60)
|
| 69 |
+
print("PIPELINE COMPLETE")
|
| 70 |
+
print(f"SAR Predictions: {self.sar_pred_csv}")
|
| 71 |
+
print(f"Sentinel-2 Predictions: {self.sentinel2_pred_csv}")
|
| 72 |
+
print("="*60)
|
| 73 |
+
|
| 74 |
+
def _predict_all_bands(self, predictor, input_csv, output_csv):
|
| 75 |
+
"""
|
| 76 |
+
Helper to predict all numerical columns in a CSV.
|
| 77 |
+
"""
|
| 78 |
+
if not os.path.exists(input_csv):
|
| 79 |
+
print(f"β Input file not found: {input_csv}")
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
df = pd.read_csv(input_csv)
|
| 83 |
+
if 'ds' not in df.columns:
|
| 84 |
+
print(f"β 'ds' column missing in {input_csv}")
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
# Identify target columns (all except 'ds')
|
| 88 |
+
target_cols = [c for c in df.columns if c != 'ds']
|
| 89 |
+
print(f"Found {len(target_cols)} targets: {target_cols}")
|
| 90 |
+
|
| 91 |
+
all_preds = []
|
| 92 |
+
|
| 93 |
+
for col in target_cols:
|
| 94 |
+
print(f"\n>> Predicting {col}...")
|
| 95 |
+
try:
|
| 96 |
+
# We use a temporary output file for individual band predictions
|
| 97 |
+
temp_out = f"temp_pred_{col}.csv"
|
| 98 |
+
|
| 99 |
+
pred_df = predictor.tune_and_predict(
|
| 100 |
+
csv_path=input_csv,
|
| 101 |
+
field_coords=self.polygon_coords,
|
| 102 |
+
target_col=col,
|
| 103 |
+
output_file=temp_out,
|
| 104 |
+
num_samples=5
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Rename predicted column to the band name
|
| 108 |
+
pred_df = pred_df.rename(columns={'predicted_y': col})
|
| 109 |
+
|
| 110 |
+
if not all_preds:
|
| 111 |
+
all_preds.append(pred_df)
|
| 112 |
+
else:
|
| 113 |
+
# Merge on 'ds'
|
| 114 |
+
all_preds.append(pred_df[['ds', col]])
|
| 115 |
+
|
| 116 |
+
# Clean up temp file
|
| 117 |
+
if os.path.exists(temp_out):
|
| 118 |
+
os.remove(temp_out)
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f"β Failed to predict {col}: {e}")
|
| 122 |
+
|
| 123 |
+
# Merge all predictions
|
| 124 |
+
if all_preds:
|
| 125 |
+
final_df = all_preds[0]
|
| 126 |
+
for i in range(1, len(all_preds)):
|
| 127 |
+
final_df = final_df.merge(all_preds[i], on='ds', how='outer')
|
| 128 |
+
|
| 129 |
+
final_df.to_csv(output_csv, index=False)
|
| 130 |
+
print(f"β Saved combined predictions to {output_csv}")
|
| 131 |
+
else:
|
| 132 |
+
print("β No predictions generated.")
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
# Example Usage
|
| 136 |
+
POLYGON = [
|
| 137 |
+
(75.829, 30.229),
|
| 138 |
+
(75.831, 30.229),
|
| 139 |
+
(75.831, 30.231),
|
| 140 |
+
(75.829, 30.231),
|
| 141 |
+
(75.829, 30.229)
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
pipeline = PredictionOrchestrator(POLYGON)
|
| 145 |
+
pipeline.run()
|
requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
pydantic==2.5.2
|
| 4 |
+
numpy>=1.24.0
|
| 5 |
+
pandas>=2.0.0
|
| 6 |
+
shapely>=2.0.0
|
| 7 |
+
python-dotenv>=1.0.0
|
| 8 |
+
sentinelhub>=3.9.0
|
| 9 |
+
scikit-learn>=1.3.0
|
| 10 |
+
matplotlib>=3.7.0
|
| 11 |
+
neuralforecast>=1.6.0
|
| 12 |
+
openmeteo-requests>=1.2.0
|
| 13 |
+
requests-cache>=1.1.0
|
| 14 |
+
retry-requests>=2.0.0
|
| 15 |
+
openmeteo-sdk>=1.7.0
|
| 16 |
+
ray[tune]>=2.7.0
|
| 17 |
+
torch>=2.0.0
|
satellite_pipeline.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Satellite Data Pipeline
|
| 3 |
+
Fetches both Sentinel-1 (SAR) and Sentinel-2 (Optical) data for a given polygon.
|
| 4 |
+
Outputs two separate CSV files: `sar_data.csv` and `sentinel2_data.csv`.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import datetime
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from shapely.geometry import Polygon
|
| 12 |
+
from dotenv import load_dotenv
|
| 13 |
+
|
| 14 |
+
from sentinelhub import (
|
| 15 |
+
SHConfig,
|
| 16 |
+
SentinelHubRequest,
|
| 17 |
+
SentinelHubCatalog,
|
| 18 |
+
DataCollection,
|
| 19 |
+
MimeType,
|
| 20 |
+
BBox,
|
| 21 |
+
CRS,
|
| 22 |
+
bbox_to_dimensions,
|
| 23 |
+
Geometry
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Load environment variables
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
class SatelliteFetcher:
|
| 30 |
+
"""
|
| 31 |
+
A class to fetch and process satellite data (SAR and Optical) for a specific area of interest.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(self, polygon_coords):
|
| 35 |
+
"""
|
| 36 |
+
Initialize the fetcher.
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
polygon_coords (list): List of (lon, lat) tuples defining the polygon.
|
| 40 |
+
"""
|
| 41 |
+
self.polygon_coords = polygon_coords
|
| 42 |
+
self.start_date = '2020-01-01'
|
| 43 |
+
self.end_date = datetime.date.today().strftime('%Y-%m-%d')
|
| 44 |
+
|
| 45 |
+
# Configuration
|
| 46 |
+
self.resolution = 10
|
| 47 |
+
self.max_cloud_cover = 20.0
|
| 48 |
+
|
| 49 |
+
# Setup Sentinel Hub
|
| 50 |
+
self.config = self._setup_config()
|
| 51 |
+
self.geometry, self.bbox, self.size = self._setup_geometry()
|
| 52 |
+
|
| 53 |
+
def _setup_config(self):
|
| 54 |
+
"""Configure Sentinel Hub credentials."""
|
| 55 |
+
config = SHConfig()
|
| 56 |
+
config.sh_client_id = os.environ.get('SH_CLIENT_ID')
|
| 57 |
+
config.sh_client_secret = os.environ.get('SH_CLIENT_SECRET')
|
| 58 |
+
|
| 59 |
+
if not config.sh_client_id:
|
| 60 |
+
config.sh_client_id = "sh-4c23abb9-6263-4a2c-bdba-a4ff6b84bdfb"
|
| 61 |
+
if not config.sh_client_secret:
|
| 62 |
+
config.sh_client_secret = "iZ4gexrfiWQpopHAGYlBeEMj9J8DAZtD"
|
| 63 |
+
|
| 64 |
+
config.sh_base_url = 'https://sh.dataspace.copernicus.eu'
|
| 65 |
+
config.sh_token_url = 'https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token'
|
| 66 |
+
config.save("cdse")
|
| 67 |
+
|
| 68 |
+
return config
|
| 69 |
+
|
| 70 |
+
def _setup_geometry(self):
|
| 71 |
+
"""Setup geometry, bbox, and size from polygon coordinates."""
|
| 72 |
+
shapely_poly = Polygon(self.polygon_coords)
|
| 73 |
+
geometry = Geometry(shapely_poly, crs=CRS.WGS84)
|
| 74 |
+
bbox = geometry.bbox
|
| 75 |
+
size = bbox_to_dimensions(bbox, resolution=self.resolution)
|
| 76 |
+
|
| 77 |
+
print(f"AOI Setup:")
|
| 78 |
+
print(f" Polygon: {self.polygon_coords}")
|
| 79 |
+
print(f" BBox: {bbox}")
|
| 80 |
+
print(f" Size: {size}")
|
| 81 |
+
print(f" Time Range: {self.start_date} to {self.end_date}")
|
| 82 |
+
|
| 83 |
+
return geometry, bbox, size
|
| 84 |
+
|
| 85 |
+
def fetch_sar_data(self, output_csv='sar_data.csv'):
|
| 86 |
+
"""
|
| 87 |
+
Fetch Sentinel-1 SAR data (VV, VH) and save to CSV.
|
| 88 |
+
"""
|
| 89 |
+
print("\n" + "="*40)
|
| 90 |
+
print("FETCHING SENTINEL-1 SAR DATA")
|
| 91 |
+
print("="*40)
|
| 92 |
+
|
| 93 |
+
S1 = DataCollection.define(
|
| 94 |
+
name="SENTINEL1_IW_CDSE",
|
| 95 |
+
api_id="sentinel-1-grd",
|
| 96 |
+
service_url="https://sh.dataspace.copernicus.eu"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
evalscript = """
|
| 100 |
+
//VERSION=3
|
| 101 |
+
function setup() {
|
| 102 |
+
return {
|
| 103 |
+
input: ["VV", "VH", "dataMask"],
|
| 104 |
+
output: [
|
| 105 |
+
{ id: "VV", bands: 1, sampleType: "FLOAT32" },
|
| 106 |
+
{ id: "VH", bands: 1, sampleType: "FLOAT32" }
|
| 107 |
+
]
|
| 108 |
+
};
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function evaluatePixel(sample) {
|
| 112 |
+
let vv_db = (sample.VV > 0) ? 10 * Math.log(sample.VV) / Math.LN10 : -9999;
|
| 113 |
+
let vh_db = (sample.VH > 0) ? 10 * Math.log(sample.VH) / Math.LN10 : -9999;
|
| 114 |
+
return { VV: [vv_db], VH: [vh_db] };
|
| 115 |
+
}
|
| 116 |
+
"""
|
| 117 |
+
|
| 118 |
+
print("π Searching catalog...")
|
| 119 |
+
catalog = SentinelHubCatalog(config=self.config)
|
| 120 |
+
results = catalog.search(
|
| 121 |
+
collection=S1,
|
| 122 |
+
geometry=self.geometry,
|
| 123 |
+
time=(self.start_date, self.end_date),
|
| 124 |
+
filter="sar:instrument_mode = 'IW'"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
scenes = list(results)
|
| 128 |
+
dates = sorted(list(set([scene['properties']['datetime'].split('T')[0] for scene in scenes])))
|
| 129 |
+
print(f"β Found {len(dates)} unique dates.")
|
| 130 |
+
|
| 131 |
+
all_records = []
|
| 132 |
+
|
| 133 |
+
print("π₯ Downloading and Processing...")
|
| 134 |
+
for date_str in dates:
|
| 135 |
+
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d")
|
| 136 |
+
next_day = (dt + datetime.timedelta(days=1)).strftime("%Y-%m-%d")
|
| 137 |
+
|
| 138 |
+
request = SentinelHubRequest(
|
| 139 |
+
evalscript=evalscript,
|
| 140 |
+
input_data=[
|
| 141 |
+
SentinelHubRequest.input_data(
|
| 142 |
+
data_collection=S1,
|
| 143 |
+
time_interval=(date_str, next_day),
|
| 144 |
+
mosaicking_order="leastRecent",
|
| 145 |
+
other_args={"processing": {"backCoeff": "GAMMA0_TERRAIN", "orthorectify": True}}
|
| 146 |
+
)
|
| 147 |
+
],
|
| 148 |
+
responses=[
|
| 149 |
+
SentinelHubRequest.output_response('VV', MimeType.TIFF),
|
| 150 |
+
SentinelHubRequest.output_response('VH', MimeType.TIFF)
|
| 151 |
+
],
|
| 152 |
+
geometry=self.geometry,
|
| 153 |
+
bbox=self.bbox,
|
| 154 |
+
size=self.size,
|
| 155 |
+
config=self.config
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
data = request.get_data()
|
| 160 |
+
if data and len(data) > 0:
|
| 161 |
+
data_dict = data[0]
|
| 162 |
+
if 'VV.tif' in data_dict and 'VH.tif' in data_dict:
|
| 163 |
+
vv_arr = data_dict['VV.tif']
|
| 164 |
+
vh_arr = data_dict['VH.tif']
|
| 165 |
+
|
| 166 |
+
valid_mask = (vv_arr > -9999) & (vh_arr > -9999)
|
| 167 |
+
|
| 168 |
+
if np.any(valid_mask):
|
| 169 |
+
vv_mean = np.mean(vv_arr[valid_mask])
|
| 170 |
+
vh_mean = np.mean(vh_arr[valid_mask])
|
| 171 |
+
|
| 172 |
+
all_records.append({
|
| 173 |
+
'ds': date_str,
|
| 174 |
+
'VV_mean_dB': round(vv_mean, 4),
|
| 175 |
+
'VH_mean_dB': round(vh_mean, 4)
|
| 176 |
+
})
|
| 177 |
+
print(f" β {date_str}: VV={vv_mean:.2f}, VH={vh_mean:.2f}")
|
| 178 |
+
else:
|
| 179 |
+
print(f" β {date_str}: No valid pixels")
|
| 180 |
+
except Exception as e:
|
| 181 |
+
print(f" β Error {date_str}: {e}")
|
| 182 |
+
|
| 183 |
+
df = pd.DataFrame(all_records)
|
| 184 |
+
if not df.empty:
|
| 185 |
+
df = df.sort_values('ds').reset_index(drop=True)
|
| 186 |
+
df.to_csv(output_csv, index=False)
|
| 187 |
+
print(f"\nπΎ Saved SAR data to: {output_csv}")
|
| 188 |
+
else:
|
| 189 |
+
print("\nβ No SAR data fetched.")
|
| 190 |
+
|
| 191 |
+
def fetch_sentinel2_data(self, output_csv='sentinel2_data.csv'):
|
| 192 |
+
"""
|
| 193 |
+
Fetch Sentinel-2 L2A data (Bands + SCL) and save to CSV.
|
| 194 |
+
"""
|
| 195 |
+
print("\n" + "="*40)
|
| 196 |
+
print("FETCHING SENTINEL-2 OPTICAL DATA")
|
| 197 |
+
print("="*40)
|
| 198 |
+
|
| 199 |
+
S2 = DataCollection.define(
|
| 200 |
+
name="SENTINEL2_L2A_CDSE",
|
| 201 |
+
api_id="sentinel-2-l2a",
|
| 202 |
+
service_url="https://sh.dataspace.copernicus.eu",
|
| 203 |
+
collection_type="Sentinel-2",
|
| 204 |
+
is_timeless=False
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
evalscript = """
|
| 208 |
+
//VERSION=3
|
| 209 |
+
function setup() {
|
| 210 |
+
return {
|
| 211 |
+
input: [{
|
| 212 |
+
bands: ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12", "SCL", "dataMask"],
|
| 213 |
+
units: "DN"
|
| 214 |
+
}],
|
| 215 |
+
output: {
|
| 216 |
+
bands: 13,
|
| 217 |
+
sampleType: "FLOAT32"
|
| 218 |
+
}
|
| 219 |
+
};
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function evaluatePixel(sample) {
|
| 223 |
+
if (sample.dataMask == 0) {
|
| 224 |
+
return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
| 225 |
+
}
|
| 226 |
+
return [
|
| 227 |
+
sample.B01 / 10000, sample.B02 / 10000, sample.B03 / 10000, sample.B04 / 10000,
|
| 228 |
+
sample.B05 / 10000, sample.B06 / 10000, sample.B07 / 10000, sample.B08 / 10000,
|
| 229 |
+
sample.B8A / 10000, sample.B09 / 10000, sample.B11 / 10000, sample.B12 / 10000,
|
| 230 |
+
sample.SCL
|
| 231 |
+
];
|
| 232 |
+
}
|
| 233 |
+
"""
|
| 234 |
+
|
| 235 |
+
print("π Searching catalog...")
|
| 236 |
+
catalog = SentinelHubCatalog(config=self.config)
|
| 237 |
+
search_iterator = catalog.search(
|
| 238 |
+
S2,
|
| 239 |
+
geometry=self.geometry,
|
| 240 |
+
time=(self.start_date, self.end_date),
|
| 241 |
+
filter=f'eo:cloud_cover < {self.max_cloud_cover}'
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
dates = sorted(list(set([item['properties']['datetime'] for item in search_iterator])))
|
| 245 |
+
print(f"β Found {len(dates)} available scenes.")
|
| 246 |
+
|
| 247 |
+
results = []
|
| 248 |
+
band_names = ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12"]
|
| 249 |
+
|
| 250 |
+
print("π₯ Downloading and Processing...")
|
| 251 |
+
for i, date_str in enumerate(dates):
|
| 252 |
+
request = SentinelHubRequest(
|
| 253 |
+
evalscript=evalscript,
|
| 254 |
+
input_data=[SentinelHubRequest.input_data(
|
| 255 |
+
data_collection=S2,
|
| 256 |
+
time_interval=(date_str, date_str)
|
| 257 |
+
)],
|
| 258 |
+
responses=[SentinelHubRequest.output_response('default', MimeType.TIFF)],
|
| 259 |
+
geometry=self.geometry,
|
| 260 |
+
bbox=self.bbox,
|
| 261 |
+
size=self.size,
|
| 262 |
+
config=self.config
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
try:
|
| 266 |
+
data = request.get_data()[0]
|
| 267 |
+
scl = data[:, :, 12]
|
| 268 |
+
valid_mask = (scl != 0)
|
| 269 |
+
|
| 270 |
+
if np.any(valid_mask):
|
| 271 |
+
means = {}
|
| 272 |
+
for b_idx, b_name in enumerate(band_names):
|
| 273 |
+
means[b_name] = np.mean(data[:, :, b_idx][valid_mask])
|
| 274 |
+
|
| 275 |
+
means['ds'] = date_str.split('T')[0]
|
| 276 |
+
results.append(means)
|
| 277 |
+
print(f" β {date_str[:10]}")
|
| 278 |
+
else:
|
| 279 |
+
print(f" β {date_str[:10]} (No valid pixels)")
|
| 280 |
+
except Exception as e:
|
| 281 |
+
print(f" β Error {date_str[:10]}: {e}")
|
| 282 |
+
|
| 283 |
+
df = pd.DataFrame(results)
|
| 284 |
+
if not df.empty:
|
| 285 |
+
cols = ['ds'] + [c for c in df.columns if c != 'ds']
|
| 286 |
+
df = df[cols]
|
| 287 |
+
df.to_csv(output_csv, index=False)
|
| 288 |
+
print(f"\nπΎ Saved Sentinel-2 data to: {output_csv}")
|
| 289 |
+
else:
|
| 290 |
+
print("\nβ No Sentinel-2 data fetched.")
|
| 291 |
+
|
| 292 |
+
def run_all(self):
|
| 293 |
+
"""Run both pipelines."""
|
| 294 |
+
self.fetch_sar_data()
|
| 295 |
+
self.fetch_sentinel2_data()
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
if __name__ == "__main__":
|
| 299 |
+
# Example Usage
|
| 300 |
+
# PAU Experimental Farm, Punjab, India
|
| 301 |
+
POLYGON = [
|
| 302 |
+
(75.829, 30.229),
|
| 303 |
+
(75.831, 30.229),
|
| 304 |
+
(75.831, 30.231),
|
| 305 |
+
(75.829, 30.231),
|
| 306 |
+
(75.829, 30.229)
|
| 307 |
+
]
|
| 308 |
+
|
| 309 |
+
# Initialize and Run
|
| 310 |
+
fetcher = SatelliteFetcher(POLYGON)
|
| 311 |
+
fetcher.run_all()
|