clouds-everywhere 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """Clouds-Everywhere — check satellite imagery availability by cloud cover."""
2
+
3
+ from .search import search_images
4
+ from .coverage import check_coverage
5
+ from .query import query
6
+ from .models import (
7
+ SatelliteImage, TileResult, DateCoverage,
8
+ TilePeriodStat, PeriodCoverage, QueryReport,
9
+ )
10
+ from .aoi import to_bbox
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "query", "search_images", "check_coverage", "to_bbox",
16
+ "SatelliteImage", "TileResult", "DateCoverage",
17
+ "TilePeriodStat", "PeriodCoverage", "QueryReport",
18
+ ]
@@ -0,0 +1,161 @@
1
+ """
2
+ aoi.py — normalize any AOI input to a WGS84 bbox [minX, minY, maxX, maxY].
3
+
4
+ Accepted inputs
5
+ ---------------
6
+ * Plain bbox list/tuple : [minX, minY, maxX, maxY]
7
+ * Polygon coords : [[lon, lat], ...] or shapely Polygon
8
+ * GeoJSON dict : FeatureCollection, Feature, or geometry
9
+ * File path (str/Path) : .geojson, .json, .shp, .zip (zipped shapefile)
10
+
11
+ Every spatial input is reprojected to WGS84 (EPSG:4326) before the bbox is
12
+ returned, so callers never need to think about CRS.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ import geopandas as gpd
20
+ import pyproj
21
+ from shapely.geometry import shape, Polygon
22
+ from shapely.ops import transform, unary_union
23
+
24
+
25
+ WGS84 = pyproj.CRS("EPSG:4326")
26
+
27
+
28
+ # ── public entry point ────────────────────────────────────────────────────────
29
+
30
+ def to_bbox(aoi) -> list[float]:
31
+ """Return [minX, minY, maxX, maxY] in WGS84 for any supported AOI input."""
32
+ geom, crs = _parse(aoi)
33
+ geom = _ensure_wgs84(geom, crs)
34
+ _validate_wgs84_bbox(geom.bounds)
35
+ minx, miny, maxx, maxy = geom.bounds
36
+ return [minx, miny, maxx, maxy]
37
+
38
+
39
+ # ── parsers — all return (shapely_geom, pyproj.CRS | None) ───────────────────
40
+
41
+ def _parse(aoi):
42
+ # shapely geometry passed directly — assume WGS84
43
+ if hasattr(aoi, "geom_type"):
44
+ return aoi, None
45
+
46
+ # file path
47
+ if isinstance(aoi, (str, Path)):
48
+ return _from_file(Path(aoi))
49
+
50
+ # empty containers are never a valid AOI
51
+ if isinstance(aoi, (list, tuple)) and len(aoi) == 0:
52
+ raise ValueError("AOI is empty - provide a bbox, polygon coords, or GeoJSON")
53
+
54
+ # plain bbox [minX, minY, maxX, maxY]
55
+ if (isinstance(aoi, (list, tuple))
56
+ and len(aoi) == 4
57
+ and all(isinstance(v, (int, float)) for v in aoi)):
58
+ return _bbox_to_polygon(aoi), None
59
+
60
+ # polygon as list of coordinate pairs [[x, y], ...]
61
+ if (isinstance(aoi, (list, tuple))
62
+ and all(isinstance(v, (list, tuple)) and len(v) == 2 for v in aoi)):
63
+ if len(aoi) < 3:
64
+ raise ValueError(
65
+ f"Polygon needs at least 3 coordinate pairs, got {len(aoi)}"
66
+ )
67
+ return _coords_to_polygon(aoi), None
68
+
69
+ # GeoJSON dict
70
+ if isinstance(aoi, dict):
71
+ return _from_geojson(aoi)
72
+
73
+ raise TypeError(f"Unsupported AOI type: {type(aoi)}")
74
+
75
+
76
+ def _from_file(path: Path):
77
+ suffix = path.suffix.lower()
78
+
79
+ if suffix in (".geojson", ".json"):
80
+ gdf = gpd.read_file(path)
81
+ elif suffix == ".shp":
82
+ gdf = gpd.read_file(path)
83
+ elif suffix == ".zip":
84
+ gdf = gpd.read_file(f"zip://{path}")
85
+ else:
86
+ raise ValueError(f"Unsupported file format: {suffix!r}. Use .geojson, .json, .shp, or .zip")
87
+
88
+ geom = unary_union(gdf.geometry)
89
+ crs = gdf.crs # pyproj.CRS or None
90
+ return geom, crs
91
+
92
+
93
+ def _from_geojson(d: dict):
94
+ typ = d.get("type")
95
+
96
+ if typ == "FeatureCollection":
97
+ geoms = [shape(f["geometry"]) for f in d["features"] if f.get("geometry")]
98
+ geom = unary_union(geoms)
99
+ elif typ == "Feature":
100
+ geom = shape(d["geometry"])
101
+ elif typ in ("Polygon", "MultiPolygon", "Point", "LineString",
102
+ "MultiPoint", "MultiLineString", "GeometryCollection"):
103
+ geom = shape(d)
104
+ else:
105
+ raise ValueError(f"Unrecognised GeoJSON type: {typ!r}")
106
+
107
+ # Some tools embed a CRS object (older GeoJSON / ArcGIS exports)
108
+ crs = None
109
+ crs_obj = d.get("crs")
110
+ if crs_obj:
111
+ epsg_name = crs_obj.get("properties", {}).get("name", "")
112
+ try:
113
+ crs = pyproj.CRS.from_user_input(epsg_name)
114
+ except Exception:
115
+ pass # can't parse it; fall back to WGS84 assumption
116
+
117
+ return geom, crs
118
+
119
+
120
+ def _bbox_to_polygon(bbox) -> Polygon:
121
+ minx, miny, maxx, maxy = bbox
122
+ return Polygon([
123
+ (minx, miny), (maxx, miny),
124
+ (maxx, maxy), (minx, maxy),
125
+ (minx, miny),
126
+ ])
127
+
128
+
129
+ def _coords_to_polygon(coords) -> Polygon:
130
+ """Accept [[lon, lat], ...] or [[lat, lon], ...] and fix axis order."""
131
+ coords = [tuple(c) for c in coords]
132
+
133
+ xs = [c[0] for c in coords]
134
+ ys = [c[1] for c in coords]
135
+
136
+ # If xs fit in [-90, 90] but ys don't → coords are (lat, lon), swap them
137
+ if all(-90 <= v <= 90 for v in xs) and any(abs(v) > 90 for v in ys):
138
+ coords = [(y, x) for x, y in coords]
139
+
140
+ return Polygon(coords)
141
+
142
+
143
+ # ── CRS reprojection ──────────────────────────────────────────────────────────
144
+
145
+ def _ensure_wgs84(geom, crs):
146
+ if crs is None or crs.equals(WGS84):
147
+ return geom
148
+ transformer = pyproj.Transformer.from_crs(crs, WGS84, always_xy=True)
149
+ return transform(transformer.transform, geom)
150
+
151
+
152
+ def _validate_wgs84_bbox(bounds):
153
+ minx, miny, maxx, maxy = bounds
154
+ if any(v != v for v in bounds): # NaN check (NaN != NaN)
155
+ raise ValueError("AOI produced an empty or invalid geometry")
156
+ if not (-180 <= minx <= 180 and -180 <= maxx <= 180):
157
+ raise ValueError(f"Longitude out of WGS84 range: minX={minx}, maxX={maxx}")
158
+ if not (-90 <= miny <= 90 and -90 <= maxy <= 90):
159
+ raise ValueError(f"Latitude out of WGS84 range: minY={miny}, maxY={maxy}")
160
+ if minx >= maxx or miny >= maxy:
161
+ raise ValueError(f"Degenerate bbox (min >= max): {list(bounds)}")
@@ -0,0 +1,89 @@
1
+ from collections import defaultdict
2
+
3
+ from .providers import sentinel2, landsat
4
+ from .models import DateCoverage
5
+ from .aoi import to_bbox
6
+
7
+
8
+ def check_coverage(aoi, start_date, end_date, max_cloud=20, satellites=("sentinel2", "landsat")):
9
+ """
10
+ For each date in the range, determine whether all tiles covering the bbox
11
+ are available and below the cloud threshold.
12
+
13
+ Returns a list of DateCoverage objects sorted by date, each flagged as:
14
+ "full" — every required tile passes the cloud threshold
15
+ "partial" — some tiles pass, some are missing or too cloudy
16
+ "missing" — no tiles pass at all for that date
17
+ """
18
+ bbox = to_bbox(aoi)
19
+
20
+ fetchers = []
21
+ if "sentinel2" in satellites:
22
+ fetchers.append(("sentinel2", sentinel2.search_tiles))
23
+ if "landsat" in satellites:
24
+ fetchers.append(("landsat", landsat.search_tiles))
25
+
26
+ # One satellite failing (API down, no passes) must not abort coverage.
27
+ all_tile_results = []
28
+ for name, fetch in fetchers:
29
+ try:
30
+ all_tile_results += fetch(bbox, start_date, end_date)
31
+ except Exception as e:
32
+ print(f"[coverage] '{name}' unavailable for this request — skipping ({e})")
33
+
34
+ if not all_tile_results:
35
+ return []
36
+
37
+ # Analyse each satellite independently so Sentinel-2 and Landsat tile
38
+ # grids don't interfere with each other.
39
+ by_satellite = defaultdict(list)
40
+ for tr in all_tile_results:
41
+ by_satellite[tr.satellite].append(tr)
42
+
43
+ coverage_results = []
44
+
45
+ for satellite, tile_results in by_satellite.items():
46
+ required_tiles = set(tr.tile_id for tr in tile_results)
47
+
48
+ by_date = defaultdict(list)
49
+ for tr in tile_results:
50
+ by_date[tr.date].append(tr)
51
+
52
+ for date, date_tiles in sorted(by_date.items()):
53
+ tile_cloud = {tr.tile_id: tr.cloud_cover for tr in date_tiles}
54
+
55
+ covered = {
56
+ t: c for t, c in tile_cloud.items()
57
+ if c == -1 or c <= max_cloud # -1 = cloud unknown, include it
58
+ }
59
+ failed = {
60
+ t: c for t, c in tile_cloud.items()
61
+ if c != -1 and c > max_cloud
62
+ }
63
+ absent = required_tiles - set(tile_cloud.keys())
64
+
65
+ covered_tiles = sorted(covered.keys())
66
+ missing_tiles = sorted(failed.keys()) + sorted(absent)
67
+
68
+ if not missing_tiles:
69
+ status = "full"
70
+ elif covered_tiles:
71
+ status = "partial"
72
+ else:
73
+ status = "missing"
74
+
75
+ valid_clouds = [c for c in covered.values() if c != -1]
76
+ avg_cloud = sum(valid_clouds) / len(valid_clouds) if valid_clouds else -1
77
+
78
+ coverage_results.append(DateCoverage(
79
+ date = date,
80
+ satellite = satellite,
81
+ status = status,
82
+ required_tiles = sorted(required_tiles),
83
+ covered_tiles = covered_tiles,
84
+ missing_tiles = missing_tiles,
85
+ avg_cloud = avg_cloud,
86
+ tile_details = date_tiles,
87
+ ))
88
+
89
+ return sorted(coverage_results, key=lambda x: (x.date, x.satellite))
@@ -0,0 +1,224 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Dict
3
+
4
+ @dataclass
5
+ class SatelliteImage:
6
+ id: str
7
+ date: str
8
+ cloud_cover: float
9
+ satellite: str
10
+ thumbnail_url: str
11
+
12
+ def __repr__(self):
13
+ return f"{self.date} | {self.satellite} | Cloud: {self.cloud_cover}%"
14
+
15
+
16
+ @dataclass
17
+ class TileResult:
18
+ tile_id: str
19
+ date: str
20
+ cloud_cover: float # -1 means unknown
21
+ satellite: str
22
+ item_id: str
23
+ thumbnail_url: str
24
+ geometry: dict | None = None # GeoJSON geometry from the STAC item
25
+
26
+ def __repr__(self):
27
+ cloud = f"{self.cloud_cover:.1f}%" if self.cloud_cover != -1 else "N/A"
28
+ return f"{self.date} | {self.satellite} | Tile {self.tile_id} | Cloud: {cloud}"
29
+
30
+
31
+ @dataclass
32
+ class DateCoverage:
33
+ date: str
34
+ satellite: str
35
+ status: str # "full" | "partial" | "missing"
36
+ required_tiles: List[str]
37
+ covered_tiles: List[str] # tiles that pass the cloud threshold
38
+ missing_tiles: List[str] # absent or too cloudy
39
+ avg_cloud: float # average over covered_tiles; -1 if unknown
40
+ tile_details: List[TileResult] = field(default_factory=list)
41
+
42
+ def __repr__(self):
43
+ cloud = f"{self.avg_cloud:.1f}%" if self.avg_cloud != -1 else "N/A"
44
+ return (
45
+ f"{self.date} | {self.satellite} | {self.status.upper()} "
46
+ f"({len(self.covered_tiles)}/{len(self.required_tiles)} tiles) "
47
+ f"avg cloud: {cloud}"
48
+ )
49
+
50
+
51
+ # ── Period-based availability report ──────────────────────────────────────────
52
+
53
+ @dataclass
54
+ class TilePeriodStat:
55
+ """How much usable imagery exists for a single tile within one time period."""
56
+ tile_id: str
57
+ usable_images: int # scenes at or below the cloud threshold
58
+ total_images: int # all scenes acquired, regardless of cloud
59
+ best_cloud: float # lowest cloud % seen; -1 if none acquired
60
+
61
+ @property
62
+ def covered(self) -> bool:
63
+ return self.usable_images > 0
64
+
65
+ def __repr__(self):
66
+ best = f"{self.best_cloud:.1f}%" if self.best_cloud != -1 else "N/A"
67
+ return f"{self.tile_id}: {self.usable_images} usable / {self.total_images} total (best {best})"
68
+
69
+
70
+ @dataclass
71
+ class PeriodCoverage:
72
+ """Availability of one satellite over the AOI for one time period (day/week/month)."""
73
+ label: str # friendly label, e.g. "13-19 Jan 2024"
74
+ period_start: str # "YYYY-MM-DD"
75
+ period_end: str # "YYYY-MM-DD"
76
+ satellite: str
77
+ status: str # "available" | "gap" | "missing"
78
+ required_tiles: List[str]
79
+ covered_tiles: List[str] # tiles with >=1 usable image this period
80
+ missing_tiles: List[str] # tiles with zero usable images (the holes)
81
+ tile_stats: List[TilePeriodStat] = field(default_factory=list)
82
+
83
+ @property
84
+ def n_required(self) -> int:
85
+ return len(self.required_tiles)
86
+
87
+ @property
88
+ def n_covered(self) -> int:
89
+ return len(self.covered_tiles)
90
+
91
+ @property
92
+ def total_usable_images(self) -> int:
93
+ return sum(t.usable_images for t in self.tile_stats)
94
+
95
+ def __repr__(self):
96
+ return (
97
+ f"{self.label} | {self.satellite} | {self.status.upper()} "
98
+ f"({self.n_covered}/{self.n_required} tiles, {self.total_usable_images} images)"
99
+ )
100
+
101
+
102
+ @dataclass
103
+ class QueryReport:
104
+ """
105
+ Full result of a user query, grouped by time period.
106
+
107
+ Use ``print(report)`` or ``report.summary()`` for a friendly, plain-language
108
+ breakdown, or ``report.to_dataframe()`` for a tabular view.
109
+ """
110
+ aoi_bbox: List[float]
111
+ start_date: str
112
+ end_date: str
113
+ max_cloud: float
114
+ group_by: str # "day" | "week" | "month"
115
+ satellites: List[str]
116
+ periods: List[PeriodCoverage] = field(default_factory=list)
117
+
118
+ # ── convenience views ────────────────────────────────────────────────────
119
+ def by_satellite(self) -> Dict[str, List[PeriodCoverage]]:
120
+ out: Dict[str, List[PeriodCoverage]] = {}
121
+ for p in self.periods:
122
+ out.setdefault(p.satellite, []).append(p)
123
+ return out
124
+
125
+ def available_periods(self, satellite=None) -> List[PeriodCoverage]:
126
+ return [p for p in self.periods
127
+ if p.status == "available" and (satellite is None or p.satellite == satellite)]
128
+
129
+ def gap_periods(self, satellite=None) -> List[PeriodCoverage]:
130
+ return [p for p in self.periods
131
+ if p.status == "gap" and (satellite is None or p.satellite == satellite)]
132
+
133
+ def required_tiles(self, satellite) -> List[str]:
134
+ for p in self.periods:
135
+ if p.satellite == satellite:
136
+ return p.required_tiles
137
+ return []
138
+
139
+ def __repr__(self):
140
+ return self.summary()
141
+
142
+ # ── tabular views ────────────────────────────────────────────────────────
143
+ def to_dataframe(self):
144
+ """One row per period × satellite (requires pandas)."""
145
+ import pandas as pd
146
+ return pd.DataFrame([
147
+ {
148
+ "Period": p.label,
149
+ "Start": p.period_start,
150
+ "Satellite": p.satellite,
151
+ "Status": p.status,
152
+ "Tiles needed": p.n_required,
153
+ "Tiles covered": p.n_covered,
154
+ "Images": p.total_usable_images,
155
+ "Missing tiles": ", ".join(p.missing_tiles) if p.missing_tiles else "-",
156
+ }
157
+ for p in self.periods
158
+ ])
159
+
160
+ def tile_dataframe(self):
161
+ """One row per period × satellite × tile (requires pandas)."""
162
+ import pandas as pd
163
+ rows = []
164
+ for p in self.periods:
165
+ for t in p.tile_stats:
166
+ rows.append({
167
+ "Period": p.label,
168
+ "Start": p.period_start,
169
+ "Satellite": p.satellite,
170
+ "Tile": t.tile_id,
171
+ "Usable": t.usable_images,
172
+ "Total": t.total_images,
173
+ "Best cloud %": round(t.best_cloud, 1) if t.best_cloud != -1 else None,
174
+ })
175
+ return pd.DataFrame(rows)
176
+
177
+ # ── friendly text summary ────────────────────────────────────────────────
178
+ def summary(self) -> str:
179
+ icon = {"available": "[OK] ", "gap": "[GAP]", "missing": "[--] "}
180
+ word = {
181
+ "available": "All tiles have usable imagery",
182
+ "gap": "Data gap - some tiles missing",
183
+ "missing": "No usable imagery",
184
+ }
185
+ unit = {"day": "day", "week": "week", "month": "month"}[self.group_by]
186
+
187
+ lines = []
188
+ lines.append("=" * 68)
189
+ lines.append(" SATELLITE DATA AVAILABILITY REPORT")
190
+ lines.append("=" * 68)
191
+ lines.append(f" Study area (bbox) : {self.aoi_bbox}")
192
+ lines.append(f" Date range : {self.start_date} -> {self.end_date}")
193
+ lines.append(f" Cloud threshold : <= {self.max_cloud:.0f}%")
194
+ lines.append(f" Grouped by : {unit}")
195
+ lines.append("")
196
+
197
+ for satellite, periods in self.by_satellite().items():
198
+ n_req = periods[0].n_required if periods else 0
199
+ n_ok = sum(1 for p in periods if p.status == "available")
200
+ n_gap = sum(1 for p in periods if p.status == "gap")
201
+ n_no = sum(1 for p in periods if p.status == "missing")
202
+
203
+ lines.append("-" * 68)
204
+ lines.append(f" {satellite} - needs {n_req} tiles to fully cover your area")
205
+ lines.append("-" * 68)
206
+
207
+ for p in periods:
208
+ detail = ""
209
+ if p.status == "gap":
210
+ holes = ", ".join(p.missing_tiles[:6])
211
+ if len(p.missing_tiles) > 6:
212
+ holes += f", +{len(p.missing_tiles) - 6} more"
213
+ detail = f" ({p.n_covered}/{p.n_required} tiles; missing: {holes})"
214
+ elif p.status == "available":
215
+ detail = f" ({p.total_usable_images} images across {p.n_required} tiles)"
216
+ lines.append(f" {icon[p.status]} {p.label:<22} {word[p.status]}{detail}")
217
+
218
+ lines.append("")
219
+ lines.append(f" Summary: {n_ok} fully-covered {unit}s, "
220
+ f"{n_gap} with gaps, {n_no} empty (of {len(periods)} {unit}s)")
221
+ lines.append("")
222
+
223
+ lines.append("=" * 68)
224
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ from . import sentinel2, landsat, modis
@@ -0,0 +1,51 @@
1
+ from ..models import SatelliteImage, TileResult
2
+ from .utils import fetch_all
3
+
4
+ URL = "https://earth-search.aws.element84.com/v1/search"
5
+
6
+ def search(bbox, start_date, end_date, max_cloud):
7
+ payload = {
8
+ "collections": ["landsat-c2-l2"],
9
+ "bbox": bbox,
10
+ "datetime": f"{start_date}T00:00:00Z/{end_date}T23:59:59Z",
11
+ "limit": 100,
12
+ }
13
+ results = []
14
+ for item in fetch_all(URL, payload):
15
+ p = item["properties"]
16
+ cloud = p.get("eo:cloud_cover", -1)
17
+ if cloud != -1 and cloud > max_cloud:
18
+ continue
19
+ results.append(SatelliteImage(
20
+ id = item["id"],
21
+ date = p.get("datetime", "")[:10],
22
+ cloud_cover = cloud,
23
+ satellite = p.get("platform", "Landsat"),
24
+ thumbnail_url = item["assets"].get("thumbnail", {}).get("href", "")
25
+ ))
26
+ return results
27
+
28
+
29
+ def search_tiles(bbox, start_date, end_date):
30
+ """Fetch all Landsat tiles in bbox/range with no cloud filter (used for coverage analysis)."""
31
+ payload = {
32
+ "collections": ["landsat-c2-l2"],
33
+ "bbox": bbox,
34
+ "datetime": f"{start_date}T00:00:00Z/{end_date}T23:59:59Z",
35
+ "limit": 100,
36
+ }
37
+ results = []
38
+ for item in fetch_all(URL, payload):
39
+ p = item["properties"]
40
+ path = str(p.get("landsat:wrs_path", "?")).zfill(3)
41
+ row = str(p.get("landsat:wrs_row", "?")).zfill(3)
42
+ results.append(TileResult(
43
+ tile_id = f"P{path}R{row}",
44
+ date = p.get("datetime", "")[:10],
45
+ cloud_cover = p.get("eo:cloud_cover", -1),
46
+ satellite = p.get("platform", "Landsat"),
47
+ item_id = item["id"],
48
+ thumbnail_url = item["assets"].get("thumbnail", {}).get("href", ""),
49
+ geometry = item.get("geometry"),
50
+ ))
51
+ return results
@@ -0,0 +1,43 @@
1
+ import requests
2
+ from ..models import SatelliteImage
3
+
4
+ # NASA CMR STAC — LP DAAC hosts MODIS land surface products
5
+ URL = "https://cmr.earthdata.nasa.gov/stac/LPDAAC_ECS/search"
6
+ COLLECTIONS = ["MOD09GA.061", "MYD09GA.061"] # Terra + Aqua 500 m daily
7
+
8
+ def search(bbox, start_date, end_date, max_cloud):
9
+ payload = {
10
+ "collections": COLLECTIONS,
11
+ "bbox": bbox,
12
+ "datetime": f"{start_date}T00:00:00Z/{end_date}T23:59:59Z",
13
+ "limit": 100,
14
+ }
15
+ try:
16
+ r = requests.post(URL, json=payload, timeout=30)
17
+ r.raise_for_status()
18
+ except requests.RequestException as e:
19
+ print(f"[MODIS] API error: {e}")
20
+ return []
21
+
22
+ results = []
23
+ for item in r.json().get("features", []):
24
+ p = item["properties"]
25
+ cloud = p.get("eo:cloud_cover", -1)
26
+ # -1 means the field is absent; include those (cloud status unknown)
27
+ if cloud != -1 and cloud > max_cloud:
28
+ continue
29
+ item_id = item.get("id", "")
30
+ satellite = "Terra MODIS" if item_id.upper().startswith("MOD") else "Aqua MODIS"
31
+ assets = item.get("assets", {})
32
+ thumbnail = (
33
+ assets.get("browse", {}).get("href", "")
34
+ or assets.get("thumbnail", {}).get("href", "")
35
+ )
36
+ results.append(SatelliteImage(
37
+ id=item_id,
38
+ date=p.get("datetime", "")[:10],
39
+ cloud_cover=cloud,
40
+ satellite=satellite,
41
+ thumbnail_url=thumbnail,
42
+ ))
43
+ return results
@@ -0,0 +1,50 @@
1
+ from ..models import SatelliteImage, TileResult
2
+ from .utils import fetch_all
3
+
4
+ URL = "https://earth-search.aws.element84.com/v1/search"
5
+
6
+ def search(bbox, start_date, end_date, max_cloud):
7
+ payload = {
8
+ "collections": ["sentinel-2-l2a"],
9
+ "bbox": bbox,
10
+ "datetime": f"{start_date}T00:00:00Z/{end_date}T23:59:59Z",
11
+ "limit": 100,
12
+ }
13
+ results = []
14
+ for item in fetch_all(URL, payload):
15
+ p = item["properties"]
16
+ cloud = p.get("eo:cloud_cover", -1)
17
+ if cloud != -1 and cloud > max_cloud:
18
+ continue
19
+ results.append(SatelliteImage(
20
+ id = item["id"],
21
+ date = p.get("datetime", "")[:10],
22
+ cloud_cover = cloud,
23
+ satellite = "Sentinel-2",
24
+ thumbnail_url = item["assets"].get("thumbnail", {}).get("href", "")
25
+ ))
26
+ return results
27
+
28
+
29
+ def search_tiles(bbox, start_date, end_date):
30
+ """Fetch all Sentinel-2 tiles in bbox/range with no cloud filter (used for coverage analysis)."""
31
+ payload = {
32
+ "collections": ["sentinel-2-l2a"],
33
+ "bbox": bbox,
34
+ "datetime": f"{start_date}T00:00:00Z/{end_date}T23:59:59Z",
35
+ "limit": 100,
36
+ }
37
+ results = []
38
+ for item in fetch_all(URL, payload):
39
+ p = item["properties"]
40
+ tile_id = p.get("s2:mgrs_tile") or item["id"].split("_")[1]
41
+ results.append(TileResult(
42
+ tile_id = tile_id,
43
+ date = p.get("datetime", "")[:10],
44
+ cloud_cover = p.get("eo:cloud_cover", -1),
45
+ satellite = "Sentinel-2",
46
+ item_id = item["id"],
47
+ thumbnail_url = item["assets"].get("thumbnail", {}).get("href", ""),
48
+ geometry = item.get("geometry"),
49
+ ))
50
+ return results
@@ -0,0 +1,43 @@
1
+ import time
2
+ import requests
3
+
4
+
5
+ def post_with_retry(url, payload, retries=3, backoff=2, timeout=30):
6
+ """POST with automatic retry on 5xx errors (exponential backoff: 1 s, 2 s, 4 s)."""
7
+ for attempt in range(retries):
8
+ r = requests.post(url, json=payload, timeout=timeout)
9
+ if r.status_code < 500:
10
+ r.raise_for_status() # raises on 4xx, returns cleanly on 2xx
11
+ return r
12
+ if attempt < retries - 1:
13
+ time.sleep(backoff ** attempt)
14
+ r.raise_for_status()
15
+ return r
16
+
17
+
18
+ def fetch_all(url, payload, retries=3, backoff=2, timeout=30):
19
+ """Collect every feature across all pages of a STAC search.
20
+
21
+ The Element84 STAC API caps responses at 100 items per page and signals
22
+ the next page via a ``links[rel=next].body`` object containing a ``next``
23
+ cursor token. Crucially, that body omits any ``filter`` / ``filter-lang``
24
+ fields from the original request, so we must NOT replace the payload
25
+ wholesale — we only inject the cursor token into the original payload.
26
+ """
27
+ features = []
28
+ current_payload = payload.copy()
29
+ while True:
30
+ r = post_with_retry(url, current_payload, retries=retries, backoff=backoff, timeout=timeout)
31
+ data = r.json()
32
+ page = data.get("features", [])
33
+ features.extend(page)
34
+ next_link = next(
35
+ (lnk for lnk in data.get("links", []) if lnk.get("rel") == "next"),
36
+ None,
37
+ )
38
+ if not next_link or not page:
39
+ break
40
+ # Only carry the cursor forward — keep the original filter intact.
41
+ cursor = next_link["body"].get("next")
42
+ current_payload = {**payload, "next": cursor}
43
+ return features