clouds-everywhere 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- clouds_everywhere-0.1.0/PKG-INFO +138 -0
- clouds_everywhere-0.1.0/README.md +104 -0
- clouds_everywhere-0.1.0/clouds_everywhere/__init__.py +18 -0
- clouds_everywhere-0.1.0/clouds_everywhere/aoi.py +161 -0
- clouds_everywhere-0.1.0/clouds_everywhere/coverage.py +89 -0
- clouds_everywhere-0.1.0/clouds_everywhere/models.py +224 -0
- clouds_everywhere-0.1.0/clouds_everywhere/providers/__init__.py +1 -0
- clouds_everywhere-0.1.0/clouds_everywhere/providers/landsat.py +51 -0
- clouds_everywhere-0.1.0/clouds_everywhere/providers/modis.py +43 -0
- clouds_everywhere-0.1.0/clouds_everywhere/providers/sentinel2.py +50 -0
- clouds_everywhere-0.1.0/clouds_everywhere/providers/utils.py +43 -0
- clouds_everywhere-0.1.0/clouds_everywhere/query.py +179 -0
- clouds_everywhere-0.1.0/clouds_everywhere/search.py +35 -0
- clouds_everywhere-0.1.0/clouds_everywhere/viz.py +374 -0
- clouds_everywhere-0.1.0/clouds_everywhere.egg-info/PKG-INFO +138 -0
- clouds_everywhere-0.1.0/clouds_everywhere.egg-info/SOURCES.txt +23 -0
- clouds_everywhere-0.1.0/clouds_everywhere.egg-info/dependency_links.txt +1 -0
- clouds_everywhere-0.1.0/clouds_everywhere.egg-info/requires.txt +14 -0
- clouds_everywhere-0.1.0/clouds_everywhere.egg-info/top_level.txt +1 -0
- clouds_everywhere-0.1.0/pyproject.toml +52 -0
- clouds_everywhere-0.1.0/setup.cfg +4 -0
- clouds_everywhere-0.1.0/tests/test_aoi.py +105 -0
- clouds_everywhere-0.1.0/tests/test_query.py +182 -0
- clouds_everywhere-0.1.0/tests/test_search_coverage.py +69 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mo_Anwar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clouds-everywhere
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check satellite imagery availability over any AOI by cloud cover — and find the data gaps
|
|
5
|
+
Author-email: Mohammad Anwar <mohammadanwarx99@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mohammadanwarx/Clouds-Everywhere
|
|
8
|
+
Project-URL: Repository, https://github.com/mohammadanwarx/Clouds-Everywhere
|
|
9
|
+
Project-URL: Issues, https://github.com/mohammadanwarx/Clouds-Everywhere/issues
|
|
10
|
+
Keywords: satellite,remote-sensing,sentinel-2,landsat,modis,cloud-cover,stac,earth-observation,gis
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: pandas
|
|
22
|
+
Requires-Dist: matplotlib
|
|
23
|
+
Requires-Dist: seaborn
|
|
24
|
+
Requires-Dist: folium
|
|
25
|
+
Requires-Dist: geopandas
|
|
26
|
+
Requires-Dist: pyproj
|
|
27
|
+
Requires-Dist: shapely
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: jupyter; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Requires-Dist: build; extra == "dev"
|
|
32
|
+
Requires-Dist: twine; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# Clouds-Everywhere
|
|
36
|
+
|
|
37
|
+
Find out when cloud-free satellite imagery is available over your study area —
|
|
38
|
+
and where the data gaps are.
|
|
39
|
+
|
|
40
|
+
You give it an area, a date range, and a cloud limit. It answers in plain
|
|
41
|
+
language: which days, weeks, or months have usable imagery, and which tiles
|
|
42
|
+
are missing.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install clouds-everywhere
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or from source:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/mohammadanwarx/Clouds-Everywhere.git
|
|
54
|
+
cd Clouds-Everywhere
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from clouds_everywhere import query
|
|
62
|
+
|
|
63
|
+
report = query(
|
|
64
|
+
aoi = "my_area.geojson", # bbox, polygon, GeoJSON, or shapefile
|
|
65
|
+
start_date = "2024-01-01",
|
|
66
|
+
end_date = "2024-02-29",
|
|
67
|
+
max_cloud = 20, # max cloud cover in %
|
|
68
|
+
group_by = "week", # "day" | "week" | "month"
|
|
69
|
+
satellites = ["sentinel2", "landsat"],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
print(report)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Sentinel-2 - needs 9 tiles to fully cover your area
|
|
77
|
+
[OK] 8-14 Jan 2024 All tiles have usable imagery (15 images across 9 tiles)
|
|
78
|
+
[GAP] 15-21 Jan 2024 Data gap - some tiles missing (3/9 tiles; missing: 30SVJ, ...)
|
|
79
|
+
[--] 5-11 Feb 2024 No usable imagery
|
|
80
|
+
|
|
81
|
+
Summary: 3 fully-covered weeks, 5 with gaps, 1 empty (of 9 weeks)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each period gets one of three statuses:
|
|
85
|
+
|
|
86
|
+
| Status | Meaning |
|
|
87
|
+
|---|---|
|
|
88
|
+
| **available** | every tile has at least one usable image |
|
|
89
|
+
| **gap** | some tiles have imagery, but at least one is missing |
|
|
90
|
+
| **missing** | no usable imagery at all |
|
|
91
|
+
|
|
92
|
+
*Usable* means cloud cover is at or below your threshold.
|
|
93
|
+
|
|
94
|
+
## Your area, any format
|
|
95
|
+
|
|
96
|
+
The AOI can be any of these — the CRS is handled for you (everything is
|
|
97
|
+
reprojected to WGS84 automatically):
|
|
98
|
+
|
|
99
|
+
- a bbox: `[minX, minY, maxX, maxY]`
|
|
100
|
+
- polygon coordinates: `[[lon, lat], ...]`
|
|
101
|
+
- a GeoJSON dict (`Feature`, `FeatureCollection`, or geometry)
|
|
102
|
+
- a file: `.geojson`, `.json`, `.shp`, or `.zip` (zipped shapefile)
|
|
103
|
+
|
|
104
|
+
## Tables and plots
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
report.to_dataframe() # one row per period per satellite
|
|
108
|
+
report.tile_dataframe() # one row per tile per period
|
|
109
|
+
report.available_periods() # fully covered periods
|
|
110
|
+
report.gap_periods() # periods with holes
|
|
111
|
+
|
|
112
|
+
from clouds_everywhere.viz import plot_availability_calendar
|
|
113
|
+
plot_availability_calendar(report) # green / amber / red calendar
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Lower-level functions
|
|
117
|
+
|
|
118
|
+
- `search_images(...)` — flat list of matching scenes
|
|
119
|
+
- `check_coverage(...)` — tile coverage per date
|
|
120
|
+
- `viz.plot_coverage_heatmap`, `plot_cloud_timeline`, `plot_satellite_comparison`
|
|
121
|
+
|
|
122
|
+
## Demos
|
|
123
|
+
|
|
124
|
+
- [`demo.ipynb`](demo.ipynb) — search and coverage basics
|
|
125
|
+
- [`demo2.ipynb`](demo2.ipynb) — the `query()` workflow and all plots
|
|
126
|
+
|
|
127
|
+
## Tests
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pytest
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Fast and offline — all API calls are mocked.
|
|
134
|
+
|
|
135
|
+
## Data sources
|
|
136
|
+
|
|
137
|
+
Sentinel-2 and Landsat from the [Element84 Earth Search](https://earth-search.aws.element84.com)
|
|
138
|
+
STAC API. MODIS from NASA CMR STAC.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Clouds-Everywhere
|
|
2
|
+
|
|
3
|
+
Find out when cloud-free satellite imagery is available over your study area —
|
|
4
|
+
and where the data gaps are.
|
|
5
|
+
|
|
6
|
+
You give it an area, a date range, and a cloud limit. It answers in plain
|
|
7
|
+
language: which days, weeks, or months have usable imagery, and which tiles
|
|
8
|
+
are missing.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install clouds-everywhere
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or from source:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
git clone https://github.com/mohammadanwarx/Clouds-Everywhere.git
|
|
20
|
+
cd Clouds-Everywhere
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from clouds_everywhere import query
|
|
28
|
+
|
|
29
|
+
report = query(
|
|
30
|
+
aoi = "my_area.geojson", # bbox, polygon, GeoJSON, or shapefile
|
|
31
|
+
start_date = "2024-01-01",
|
|
32
|
+
end_date = "2024-02-29",
|
|
33
|
+
max_cloud = 20, # max cloud cover in %
|
|
34
|
+
group_by = "week", # "day" | "week" | "month"
|
|
35
|
+
satellites = ["sentinel2", "landsat"],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
print(report)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Sentinel-2 - needs 9 tiles to fully cover your area
|
|
43
|
+
[OK] 8-14 Jan 2024 All tiles have usable imagery (15 images across 9 tiles)
|
|
44
|
+
[GAP] 15-21 Jan 2024 Data gap - some tiles missing (3/9 tiles; missing: 30SVJ, ...)
|
|
45
|
+
[--] 5-11 Feb 2024 No usable imagery
|
|
46
|
+
|
|
47
|
+
Summary: 3 fully-covered weeks, 5 with gaps, 1 empty (of 9 weeks)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Each period gets one of three statuses:
|
|
51
|
+
|
|
52
|
+
| Status | Meaning |
|
|
53
|
+
|---|---|
|
|
54
|
+
| **available** | every tile has at least one usable image |
|
|
55
|
+
| **gap** | some tiles have imagery, but at least one is missing |
|
|
56
|
+
| **missing** | no usable imagery at all |
|
|
57
|
+
|
|
58
|
+
*Usable* means cloud cover is at or below your threshold.
|
|
59
|
+
|
|
60
|
+
## Your area, any format
|
|
61
|
+
|
|
62
|
+
The AOI can be any of these — the CRS is handled for you (everything is
|
|
63
|
+
reprojected to WGS84 automatically):
|
|
64
|
+
|
|
65
|
+
- a bbox: `[minX, minY, maxX, maxY]`
|
|
66
|
+
- polygon coordinates: `[[lon, lat], ...]`
|
|
67
|
+
- a GeoJSON dict (`Feature`, `FeatureCollection`, or geometry)
|
|
68
|
+
- a file: `.geojson`, `.json`, `.shp`, or `.zip` (zipped shapefile)
|
|
69
|
+
|
|
70
|
+
## Tables and plots
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
report.to_dataframe() # one row per period per satellite
|
|
74
|
+
report.tile_dataframe() # one row per tile per period
|
|
75
|
+
report.available_periods() # fully covered periods
|
|
76
|
+
report.gap_periods() # periods with holes
|
|
77
|
+
|
|
78
|
+
from clouds_everywhere.viz import plot_availability_calendar
|
|
79
|
+
plot_availability_calendar(report) # green / amber / red calendar
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Lower-level functions
|
|
83
|
+
|
|
84
|
+
- `search_images(...)` — flat list of matching scenes
|
|
85
|
+
- `check_coverage(...)` — tile coverage per date
|
|
86
|
+
- `viz.plot_coverage_heatmap`, `plot_cloud_timeline`, `plot_satellite_comparison`
|
|
87
|
+
|
|
88
|
+
## Demos
|
|
89
|
+
|
|
90
|
+
- [`demo.ipynb`](demo.ipynb) — search and coverage basics
|
|
91
|
+
- [`demo2.ipynb`](demo2.ipynb) — the `query()` workflow and all plots
|
|
92
|
+
|
|
93
|
+
## Tests
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
pytest
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Fast and offline — all API calls are mocked.
|
|
100
|
+
|
|
101
|
+
## Data sources
|
|
102
|
+
|
|
103
|
+
Sentinel-2 and Landsat from the [Element84 Earth Search](https://earth-search.aws.element84.com)
|
|
104
|
+
STAC API. MODIS from NASA CMR STAC.
|
|
@@ -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))
|