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.
Files changed (25) hide show
  1. clouds_everywhere-0.1.0/LICENSE +21 -0
  2. clouds_everywhere-0.1.0/PKG-INFO +138 -0
  3. clouds_everywhere-0.1.0/README.md +104 -0
  4. clouds_everywhere-0.1.0/clouds_everywhere/__init__.py +18 -0
  5. clouds_everywhere-0.1.0/clouds_everywhere/aoi.py +161 -0
  6. clouds_everywhere-0.1.0/clouds_everywhere/coverage.py +89 -0
  7. clouds_everywhere-0.1.0/clouds_everywhere/models.py +224 -0
  8. clouds_everywhere-0.1.0/clouds_everywhere/providers/__init__.py +1 -0
  9. clouds_everywhere-0.1.0/clouds_everywhere/providers/landsat.py +51 -0
  10. clouds_everywhere-0.1.0/clouds_everywhere/providers/modis.py +43 -0
  11. clouds_everywhere-0.1.0/clouds_everywhere/providers/sentinel2.py +50 -0
  12. clouds_everywhere-0.1.0/clouds_everywhere/providers/utils.py +43 -0
  13. clouds_everywhere-0.1.0/clouds_everywhere/query.py +179 -0
  14. clouds_everywhere-0.1.0/clouds_everywhere/search.py +35 -0
  15. clouds_everywhere-0.1.0/clouds_everywhere/viz.py +374 -0
  16. clouds_everywhere-0.1.0/clouds_everywhere.egg-info/PKG-INFO +138 -0
  17. clouds_everywhere-0.1.0/clouds_everywhere.egg-info/SOURCES.txt +23 -0
  18. clouds_everywhere-0.1.0/clouds_everywhere.egg-info/dependency_links.txt +1 -0
  19. clouds_everywhere-0.1.0/clouds_everywhere.egg-info/requires.txt +14 -0
  20. clouds_everywhere-0.1.0/clouds_everywhere.egg-info/top_level.txt +1 -0
  21. clouds_everywhere-0.1.0/pyproject.toml +52 -0
  22. clouds_everywhere-0.1.0/setup.cfg +4 -0
  23. clouds_everywhere-0.1.0/tests/test_aoi.py +105 -0
  24. clouds_everywhere-0.1.0/tests/test_query.py +182 -0
  25. 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))