|
1 | 1 | """ |
2 | 2 | pagination.py |
3 | | -Handles Planet API pagination with dynamic spatial/temporal tiling. |
4 | | -Automatically splits large AOIs or long date ranges into smaller requests. |
| 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 | 5 | """ |
6 | 6 |
|
7 | | -import math |
8 | | -import logging |
| 7 | +import os |
9 | 8 | import requests |
10 | | -from shapely.geometry import Polygon |
11 | | -from typing import List, Dict, Tuple |
| 9 | +import math |
| 10 | +import time |
| 11 | +from typing import List, Tuple, Dict, Any |
| 12 | +from shapely.geometry import Polygon, box |
| 13 | +from datetime import datetime, timedelta |
12 | 14 |
|
13 | | -logger = logging.getLogger(__name__) |
| 15 | +from .filters import build_filters |
14 | 16 |
|
15 | | -# Thresholds for auto-tiling |
16 | | -MAX_AOI_DEG2 = 10.0 # Max AOI area in deg² before spatial tiling |
17 | | -MAX_DAYS = 30 # Max days before temporal tiling |
| 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 |
18 | 24 |
|
19 | | -def estimate_scene_count(aoi_area_deg2: float, days: int, density: float = 1.0) -> int: |
| 25 | +# ------------------------- |
| 26 | +# Spatial tiling |
| 27 | +# ------------------------- |
| 28 | +def tile_aoi(aoi: Polygon, tile_size_deg: float = 1.0) -> List[Polygon]: |
20 | 29 | """ |
21 | | - Estimate number of scenes for a given AOI and date range. |
| 30 | + Split AOI polygon into smaller tiles (default 1° x 1°) for large areas. |
| 31 | +
|
22 | 32 | Args: |
23 | | - aoi_area_deg2: AOI area in degrees² |
24 | | - days: Number of days in query |
25 | | - density: Scenes per deg² per day (default 1.0) |
| 33 | + aoi (Polygon): Input AOI. |
| 34 | + tile_size_deg (float): Tile size in degrees (lat/lon). |
| 35 | +
|
26 | 36 | Returns: |
27 | | - Estimated scene count |
| 37 | + List[Polygon]: List of polygon tiles covering AOI. |
28 | 38 | """ |
29 | | - return math.ceil(aoi_area_deg2 * days * density) |
| 39 | + minx, miny, maxx, maxy = aoi.bounds |
| 40 | + tiles = [] |
| 41 | + |
| 42 | + x_steps = math.ceil((maxx - minx) / tile_size_deg) |
| 43 | + y_steps = math.ceil((maxy - miny) / tile_size_deg) |
30 | 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 |
31 | 58 |
|
32 | | -def should_tile(aoi_area_deg2: float, days: int) -> Tuple[bool, bool]: |
| 59 | + |
| 60 | +# ------------------------- |
| 61 | +# Temporal tiling |
| 62 | +# ------------------------- |
| 63 | +def tile_dates(start: datetime, end: datetime, max_days: int = TEMPORAL_TILE_DAYS_THRESHOLD) -> List[Tuple[datetime, datetime]]: |
33 | 64 | """ |
34 | | - Decide whether spatial or temporal tiling is required. |
| 65 | + Split date range into smaller slices if longer than max_days. |
| 66 | +
|
35 | 67 | Args: |
36 | | - aoi_area_deg2: AOI area in deg² |
37 | | - days: Number of days in query |
| 68 | + start (datetime): Start date. |
| 69 | + end (datetime): End date. |
| 70 | + max_days (int): Maximum number of days per slice. |
| 71 | +
|
38 | 72 | Returns: |
39 | | - Tuple[bool, bool]: (spatial_tiling, temporal_tiling) |
| 73 | + List[Tuple[datetime, datetime]]: List of date tuples. |
40 | 74 | """ |
41 | | - spatial = aoi_area_deg2 > MAX_AOI_DEG2 |
42 | | - temporal = days > MAX_DAYS |
43 | | - return spatial, temporal |
| 75 | + total_days = (end - start).days + 1 |
| 76 | + if total_days <= max_days: |
| 77 | + return [(start, end)] |
44 | 78 |
|
| 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 |
45 | 86 |
|
46 | | -def paginate_search(session: requests.Session, search_url: str) -> List[Dict]: |
| 87 | + |
| 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]]: |
47 | 100 | """ |
48 | | - Paginate through Planet API search results. |
| 101 | + Fetch Planet imagery metadata with automatic tiling. |
| 102 | +
|
49 | 103 | Args: |
50 | | - session: Authenticated requests.Session |
51 | | - search_url: URL for the first search page |
| 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. |
| 111 | +
|
52 | 112 | Returns: |
53 | | - List of results (dicts) |
| 113 | + Tuple[List[str], List[Dict], List[Dict]]: ids, geometries, properties |
54 | 114 | """ |
55 | | - results: List[Dict] = [] |
56 | | - next_url = search_url |
57 | | - page_num = 1 |
58 | | - |
59 | | - while next_url: |
60 | | - try: |
61 | | - resp = session.get(next_url) |
62 | | - resp.raise_for_status() |
63 | | - page = resp.json() |
64 | | - except requests.RequestException as e: |
65 | | - logger.error(f"Request failed at page {page_num}: {e}") |
66 | | - break |
67 | | - |
68 | | - features = page.get("features", []) |
69 | | - logger.info(f"Fetched page {page_num} with {len(features)} features") |
70 | | - results.extend(features) |
71 | | - next_url = page.get("_links", {}).get("_next") |
72 | | - page_num += 1 |
73 | | - |
74 | | - return results |
| 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}") |
| 175 | + |
| 176 | + return ids, geometries, properties |
0 commit comments