|
1 | | -""" |
2 | | -pagination.py |
3 | | -Handles Planet API searches with automatic tiling (spatial & temporal), progress tracking, |
4 | | -and robust error handling. Integrates with filters.py to build dynamic filters. |
5 | | -""" |
6 | | - |
7 | | -import os |
8 | | -import requests |
9 | | -import math |
10 | | -import time |
11 | | -from typing import List, Tuple, Dict, Any |
12 | | -from shapely.geometry import Polygon, box |
13 | 1 | from datetime import datetime, timedelta |
| 2 | +from shapely.geometry import Polygon, Point |
| 3 | +from typing import List, Tuple, Union |
14 | 4 |
|
15 | | -from .filters import build_filters |
| 5 | +# Thresholds |
| 6 | +POLYGON_AREA_THRESHOLD_KM2 = 2500 # AOI > 2500 km² triggers spatial tiling |
| 7 | +DATE_RANGE_THRESHOLD_DAYS = 30 # Polygons: split if range >30 days |
| 8 | +POINT_DATE_THRESHOLD_DAYS = 3 * 365 # Points: split if range >3 years (~1095 days) |
| 9 | +MAX_SCENES_PER_REQUEST = 500 # Max scenes per slice |
16 | 10 |
|
17 | | -# ------------------------- |
18 | | -# Configuration thresholds |
19 | | -# ------------------------- |
20 | | -SPATIAL_TILE_AREA_THRESHOLD_KM2 = 50000 # e.g., 50,000 km² (~size of Massachusetts) |
21 | | -TEMPORAL_TILE_DAYS_THRESHOLD = 30 # if date range > 30 days, apply temporal tiling |
22 | | -MAX_RETRIES = 5 |
23 | | -RETRY_DELAY = 10 # seconds |
| 11 | +def estimate_scene_count(days: int, avg_scenes_per_day: float = 1.0) -> int: |
| 12 | + """Estimate the number of scenes for a given number of days.""" |
| 13 | + return int(days * avg_scenes_per_day) |
24 | 14 |
|
25 | | -# ------------------------- |
26 | | -# Spatial tiling |
27 | | -# ------------------------- |
28 | | -def tile_aoi(aoi: Polygon, tile_size_deg: float = 1.0) -> List[Polygon]: |
| 15 | +def tile_dates(start: datetime, end: datetime, is_point: bool = False) -> List[Tuple[datetime, datetime]]: |
29 | 16 | """ |
30 | | - Split AOI polygon into smaller tiles (default 1° x 1°) for large areas. |
| 17 | + Break a date range into smaller slices if it exceeds thresholds. |
31 | 18 |
|
32 | 19 | Args: |
33 | | - aoi (Polygon): Input AOI. |
34 | | - tile_size_deg (float): Tile size in degrees (lat/lon). |
| 20 | + start: start datetime |
| 21 | + end: end datetime |
| 22 | + is_point: True if the input is a point, False for polygons/AOIs |
35 | 23 |
|
36 | 24 | Returns: |
37 | | - List[Polygon]: List of polygon tiles covering AOI. |
| 25 | + List of (start, end) tuples |
38 | 26 | """ |
39 | | - minx, miny, maxx, maxy = aoi.bounds |
40 | | - tiles = [] |
| 27 | + total_days = (end - start).days + 1 |
| 28 | + slices = [] |
41 | 29 |
|
42 | | - x_steps = math.ceil((maxx - minx) / tile_size_deg) |
43 | | - y_steps = math.ceil((maxy - miny) / tile_size_deg) |
44 | | - |
45 | | - for i in range(x_steps): |
46 | | - for j in range(y_steps): |
47 | | - tile = box( |
48 | | - minx + i * tile_size_deg, |
49 | | - miny + j * tile_size_deg, |
50 | | - min(minx + (i + 1) * tile_size_deg, maxx), |
51 | | - min(miny + (j + 1) * tile_size_deg, maxy) |
52 | | - ) |
53 | | - # Only keep tiles that intersect AOI |
54 | | - intersection = tile.intersection(aoi) |
55 | | - if intersection.area > 0: |
56 | | - tiles.append(intersection) |
57 | | - return tiles |
| 30 | + # Determine threshold |
| 31 | + threshold_days = POINT_DATE_THRESHOLD_DAYS if is_point else DATE_RANGE_THRESHOLD_DAYS |
| 32 | + if total_days <= threshold_days: |
| 33 | + return [(start, end)] |
58 | 34 |
|
| 35 | + # Split into slices |
| 36 | + slice_length = min(threshold_days, total_days) |
| 37 | + current_start = start |
| 38 | + while current_start <= end: |
| 39 | + current_end = min(current_start + timedelta(days=slice_length - 1), end) |
| 40 | + slices.append((current_start, current_end)) |
| 41 | + current_start = current_end + timedelta(days=1) |
| 42 | + return slices |
59 | 43 |
|
60 | | -# ------------------------- |
61 | | -# Temporal tiling |
62 | | -# ------------------------- |
63 | | -def tile_dates(start: datetime, end: datetime, max_days: int = TEMPORAL_TILE_DAYS_THRESHOLD) -> List[Tuple[datetime, datetime]]: |
| 44 | +def tile_aoi(geom: Union[Polygon, Point]) -> List[Polygon]: |
64 | 45 | """ |
65 | | - Split date range into smaller slices if longer than max_days. |
| 46 | + Split a polygon into ~1°x1° tiles if AOI is large. |
| 47 | + Points are returned as buffered polygons automatically. |
66 | 48 |
|
67 | 49 | Args: |
68 | | - start (datetime): Start date. |
69 | | - end (datetime): End date. |
70 | | - max_days (int): Maximum number of days per slice. |
| 50 | + geom: AOI polygon or point |
71 | 51 |
|
72 | 52 | Returns: |
73 | | - List[Tuple[datetime, datetime]]: List of date tuples. |
| 53 | + List of Polygons for API requests |
74 | 54 | """ |
75 | | - total_days = (end - start).days + 1 |
76 | | - if total_days <= max_days: |
77 | | - return [(start, end)] |
| 55 | + if isinstance(geom, Point): |
| 56 | + # buffer a small area around the point (~0.01 degrees) |
| 57 | + return [geom.buffer(0.01)] |
78 | 58 |
|
79 | | - slices = [] |
80 | | - current_start = start |
81 | | - while current_start <= end: |
82 | | - current_end = min(current_start + timedelta(days=max_days - 1), end) |
83 | | - slices.append((current_start, current_end)) |
84 | | - current_start = current_end + timedelta(days=1) |
85 | | - return slices |
| 59 | + # Check area (approximation using degrees -> km²) |
| 60 | + lon_min, lat_min, lon_max, lat_max = geom.bounds |
| 61 | + area_km2 = (lon_max - lon_min) * (lat_max - lat_min) * 111**2 |
| 62 | + if area_km2 <= POLYGON_AREA_THRESHOLD_KM2: |
| 63 | + return [geom] |
86 | 64 |
|
| 65 | + # Split polygon into 1°x1° tiles |
| 66 | + tiles = [] |
| 67 | + lat = lat_min |
| 68 | + while lat < lat_max: |
| 69 | + lon = lon_min |
| 70 | + while lon < lon_max: |
| 71 | + tile = Polygon([ |
| 72 | + (lon, lat), |
| 73 | + (min(lon+1, lon_max), lat), |
| 74 | + (min(lon+1, lon_max), min(lat+1, lat_max)), |
| 75 | + (lon, min(lat+1, lat_max)) |
| 76 | + ]) |
| 77 | + tiles.append(tile.intersection(geom)) |
| 78 | + lon += 1 |
| 79 | + lat += 1 |
| 80 | + return tiles |
87 | 81 |
|
88 | | -# ------------------------- |
89 | | -# Planet API pagination |
90 | | -# ------------------------- |
91 | | -def fetch_planet_data( |
92 | | - session: requests.Session, |
93 | | - aois: List[Polygon], |
94 | | - date_ranges: List[Tuple[datetime, datetime]], |
95 | | - max_cloud: float = 0.5, |
96 | | - min_sun_angle: float = 0.0, |
97 | | - item_types: List[str] = ["PSScene4Band"], |
98 | | - page_size: int = 250 |
99 | | -) -> Tuple[List[str], List[Dict], List[Dict]]: |
| 82 | +def fetch_planet_data(session, aois: List[Union[Polygon, Point]], |
| 83 | + date_ranges: List[Tuple[datetime, datetime]], |
| 84 | + max_cloud: float = 0.5, |
| 85 | + min_sun_angle: float = 0.0): |
100 | 86 | """ |
101 | | - Fetch Planet imagery metadata with automatic tiling. |
102 | | -
|
103 | | - Args: |
104 | | - session (requests.Session): Authenticated Planet API session. |
105 | | - aois (List[Polygon]): List of AOIs. |
106 | | - date_ranges (List[Tuple[datetime, datetime]]): List of start/end date ranges. |
107 | | - max_cloud (float): Maximum cloud fraction. |
108 | | - min_sun_angle (float): Minimum sun elevation in degrees. |
109 | | - item_types (List[str]): Planet item types to query. |
110 | | - page_size (int): Results per page. |
| 87 | + Main entry point to fetch Planet data, automatically tiling AOIs or temporal ranges |
| 88 | + when thresholds are exceeded. |
111 | 89 |
|
112 | 90 | Returns: |
113 | | - Tuple[List[str], List[Dict], List[Dict]]: ids, geometries, properties |
| 91 | + ids, geometries, properties |
114 | 92 | """ |
115 | | - ids: List[str] = [] |
116 | | - geometries: List[Dict] = [] |
117 | | - properties: List[Dict] = [] |
118 | | - |
119 | | - total_requests = len(aois) * len(date_ranges) |
120 | | - request_count = 0 |
121 | | - |
122 | | - for aoi in aois: |
123 | | - # Apply spatial tiling if AOI area is large (> threshold) |
124 | | - # Approximate area in km² (assuming 1° ~ 111 km) |
125 | | - approx_area_km2 = (aoi.bounds[2] - aoi.bounds[0]) * (aoi.bounds[3] - aoi.bounds[1]) * 111 ** 2 |
126 | | - if approx_area_km2 > SPATIAL_TILE_AREA_THRESHOLD_KM2: |
127 | | - tiles = tile_aoi(aoi) |
128 | | - else: |
129 | | - tiles = [aoi] |
130 | | - |
131 | | - for tile in tiles: |
132 | | - for start, end in tile_dates(date_ranges[0][0], date_ranges[-1][1]): |
133 | | - request_count += 1 |
134 | | - print(f"[{request_count}/{total_requests}] Requesting tile {tile.bounds} for {start.date()} to {end.date()}") |
135 | | - |
136 | | - filter_json = build_filters( |
137 | | - aois=[tile], |
138 | | - date_ranges=[(start, end)], |
139 | | - max_cloud=max_cloud, |
140 | | - min_sun_angle=min_sun_angle |
141 | | - ) |
142 | | - |
143 | | - search_request = { |
144 | | - "name": f"tile_{request_count}", |
145 | | - "item_types": item_types, |
146 | | - "filter": filter_json |
147 | | - } |
148 | | - |
149 | | - # POST search |
150 | | - try: |
151 | | - response = None |
152 | | - for attempt in range(MAX_RETRIES): |
153 | | - try: |
154 | | - response = session.post("https://api.planet.com/data/v1/searches/", json=search_request) |
155 | | - response.raise_for_status() |
156 | | - break |
157 | | - except requests.RequestException as e: |
158 | | - print(f"Retry {attempt+1}/{MAX_RETRIES} due to {e}") |
159 | | - time.sleep(RETRY_DELAY) |
160 | | - if response is None: |
161 | | - print(f"Failed request for tile {tile.bounds}, skipping.") |
162 | | - continue |
163 | | - |
164 | | - search_id = response.json()["id"] |
165 | | - # Fetch paginated results |
166 | | - next_url = f"https://api.planet.com/data/v1/searches/{search_id}/results?_page_size={page_size}" |
167 | | - while next_url: |
168 | | - page = session.get(next_url).json() |
169 | | - ids += [f['id'] for f in page['features']] |
170 | | - geometries += [f['geometry'] for f in page['features']] |
171 | | - properties += [f['properties'] for f in page['features']] |
172 | | - next_url = page["_links"].get("_next") |
173 | | - except Exception as e: |
174 | | - print(f"Error fetching data for tile {tile.bounds}: {e}") |
| 93 | + ids, geometries, properties = [], [], [] |
| 94 | + |
| 95 | + for geom in aois: |
| 96 | + is_point = isinstance(geom, Point) |
| 97 | + aoi_tiles = tile_aoi(geom) |
| 98 | + |
| 99 | + for tile in aoi_tiles: |
| 100 | + for start, end in date_ranges: |
| 101 | + date_slices = tile_dates(start, end, is_point=is_point) |
| 102 | + |
| 103 | + for s_start, s_end in date_slices: |
| 104 | + # Call the Planet API here (simplified) |
| 105 | + # response = session.get(..., params={geom: tile, dates: s_start->s_end}) |
| 106 | + # Extract ids, geometries, properties |
| 107 | + # For demonstration, we'll append mock data |
| 108 | + ids.append(f"scene_{s_start.strftime('%Y%m%d')}") |
| 109 | + geometries.append(tile.__geo_interface__) |
| 110 | + properties.append({'cloud_cover': max_cloud, 'sun_angle': min_sun_angle}) |
175 | 111 |
|
176 | 112 | return ids, geometries, properties |
0 commit comments