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.
- clouds_everywhere/__init__.py +18 -0
- clouds_everywhere/aoi.py +161 -0
- clouds_everywhere/coverage.py +89 -0
- clouds_everywhere/models.py +224 -0
- clouds_everywhere/providers/__init__.py +1 -0
- clouds_everywhere/providers/landsat.py +51 -0
- clouds_everywhere/providers/modis.py +43 -0
- clouds_everywhere/providers/sentinel2.py +50 -0
- clouds_everywhere/providers/utils.py +43 -0
- clouds_everywhere/query.py +179 -0
- clouds_everywhere/search.py +35 -0
- clouds_everywhere/viz.py +374 -0
- clouds_everywhere-0.1.0.dist-info/METADATA +138 -0
- clouds_everywhere-0.1.0.dist-info/RECORD +17 -0
- clouds_everywhere-0.1.0.dist-info/WHEEL +5 -0
- clouds_everywhere-0.1.0.dist-info/licenses/LICENSE +21 -0
- clouds_everywhere-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
clouds_everywhere/aoi.py
ADDED
|
@@ -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
|