aereo 1.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.
- aereo/asset_downloader/__init__.py +17 -0
- aereo/asset_downloader/core.py +194 -0
- aereo/cli/__init__.py +0 -0
- aereo/cli/main.py +491 -0
- aereo/client/__init__.py +9 -0
- aereo/client/core.py +484 -0
- aereo/eoids/__init__.py +15 -0
- aereo/eoids/core.py +432 -0
- aereo/execution/__init__.py +20 -0
- aereo/execution/backends.py +195 -0
- aereo/execution/core.py +227 -0
- aereo/grid/__init__.py +16 -0
- aereo/grid/core.py +447 -0
- aereo/interfaces/__init__.py +33 -0
- aereo/interfaces/core.py +637 -0
- aereo/registry/__init__.py +7 -0
- aereo/registry/core.py +278 -0
- aereo/schemas/__init__.py +8 -0
- aereo/schemas/core.py +101 -0
- aereo/serialization/__init__.py +3 -0
- aereo/serialization/core.py +117 -0
- aereo/spatial/__init__.py +15 -0
- aereo/spatial/core.py +0 -0
- aereo/spatial/utils.py +73 -0
- aereo/viz/__init__.py +7 -0
- aereo/viz/core.py +113 -0
- aereo-1.1.0.dist-info/METADATA +48 -0
- aereo-1.1.0.dist-info/RECORD +30 -0
- aereo-1.1.0.dist-info/WHEEL +4 -0
- aereo-1.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safe asset downloading and cleanup routines
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from aereo.asset_downloader.core import (
|
|
6
|
+
cleanup_asset_safely,
|
|
7
|
+
download_asset_safely,
|
|
8
|
+
download_assets_safely,
|
|
9
|
+
extract_asset_safely,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"cleanup_asset_safely",
|
|
14
|
+
"download_asset_safely",
|
|
15
|
+
"download_assets_safely",
|
|
16
|
+
"extract_asset_safely",
|
|
17
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from aereo.interfaces import Downloader
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def download_asset_safely(
|
|
11
|
+
href: str,
|
|
12
|
+
local_path: Path,
|
|
13
|
+
s3_client: Optional[Any] = None,
|
|
14
|
+
http_session: Optional[Any] = None,
|
|
15
|
+
downloader: Optional["Downloader"] = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Download asset with a filelock to avoid corruption in multi-processing.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
href: URL or local path to the asset.
|
|
21
|
+
local_path: Destination path for the downloaded file.
|
|
22
|
+
s3_client: Optional authenticated S3FileSystem for S3 access.
|
|
23
|
+
Note: Only works when running in AWS us-west-2 region.
|
|
24
|
+
http_session: Optional authenticated requests.Session for HTTPS downloads.
|
|
25
|
+
Use earthaccess.get_requests_https_session() to create.
|
|
26
|
+
Works from anywhere (no AWS region requirement).
|
|
27
|
+
downloader: Optional callable that handles the download itself.
|
|
28
|
+
If provided, it is called unconditionally inside the file lock
|
|
29
|
+
and all built-in logic is skipped.
|
|
30
|
+
"""
|
|
31
|
+
import filelock
|
|
32
|
+
import s3fs
|
|
33
|
+
import requests
|
|
34
|
+
|
|
35
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
lock_path = local_path.with_suffix(".lock")
|
|
37
|
+
|
|
38
|
+
with filelock.FileLock(str(lock_path)):
|
|
39
|
+
if not local_path.exists():
|
|
40
|
+
if downloader is not None:
|
|
41
|
+
downloader(href, local_path)
|
|
42
|
+
elif href.startswith("s3://"):
|
|
43
|
+
fs = (
|
|
44
|
+
s3_client if s3_client is not None else s3fs.S3FileSystem(anon=True)
|
|
45
|
+
)
|
|
46
|
+
fs.get(href.replace("s3://", ""), str(local_path))
|
|
47
|
+
elif href.startswith("http://") or href.startswith("https://"):
|
|
48
|
+
http = http_session if http_session is not None else requests
|
|
49
|
+
response = http.get(href, stream=True)
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
with open(local_path, "wb") as f:
|
|
52
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
53
|
+
f.write(chunk)
|
|
54
|
+
elif Path(href).exists():
|
|
55
|
+
if Path(href).absolute() != local_path.absolute():
|
|
56
|
+
shutil.copy(href, local_path)
|
|
57
|
+
else:
|
|
58
|
+
raise FileNotFoundError(f"Source file not found at {href}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def download_assets_safely(
|
|
62
|
+
hrefs: list[str],
|
|
63
|
+
local_paths: list[Path],
|
|
64
|
+
s3_client: Optional[Any] = None,
|
|
65
|
+
http_session: Optional[Any] = None,
|
|
66
|
+
downloader: Optional["Downloader"] = None,
|
|
67
|
+
max_workers: Optional[int] = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Download multiple assets concurrently using a thread pool.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
hrefs: List of URLs or local paths to the assets.
|
|
73
|
+
local_paths: List of destination paths for the downloaded files.
|
|
74
|
+
s3_client: Optional authenticated S3FileSystem for S3 access.
|
|
75
|
+
http_session: Optional authenticated requests.Session for HTTPS downloads.
|
|
76
|
+
downloader: Optional callable that handles the download itself.
|
|
77
|
+
max_workers: The maximum number of threads to use. Defaults to None.
|
|
78
|
+
"""
|
|
79
|
+
if len(hrefs) != len(local_paths):
|
|
80
|
+
raise ValueError("hrefs and local_paths must have the same length")
|
|
81
|
+
|
|
82
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
83
|
+
futures = [
|
|
84
|
+
executor.submit(
|
|
85
|
+
download_asset_safely,
|
|
86
|
+
href=href,
|
|
87
|
+
local_path=local_path,
|
|
88
|
+
s3_client=s3_client,
|
|
89
|
+
http_session=http_session,
|
|
90
|
+
downloader=downloader,
|
|
91
|
+
)
|
|
92
|
+
for href, local_path in zip(hrefs, local_paths, strict=True)
|
|
93
|
+
]
|
|
94
|
+
for future in futures:
|
|
95
|
+
future.result()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_asset_safely(
|
|
99
|
+
archive_path: Path,
|
|
100
|
+
extract_dir: Optional[Path] = None,
|
|
101
|
+
lock_path: Optional[Path] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Extract a zip archive safely with file locking.
|
|
104
|
+
|
|
105
|
+
Uses atomic extraction (temp directory + rename) so that other
|
|
106
|
+
processes never see a partially extracted directory. An
|
|
107
|
+
``.extracted`` marker file is written on success; if the marker
|
|
108
|
+
exists the function returns immediately.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
archive_path: Path to the zip archive.
|
|
112
|
+
extract_dir: Destination directory. Defaults to
|
|
113
|
+
``archive_path.with_suffix("")``.
|
|
114
|
+
lock_path: Path to the lock file. Defaults to
|
|
115
|
+
``extract_dir.with_suffix(".lock")``.
|
|
116
|
+
"""
|
|
117
|
+
import filelock
|
|
118
|
+
import shutil
|
|
119
|
+
import tempfile
|
|
120
|
+
import zipfile
|
|
121
|
+
|
|
122
|
+
archive_path = Path(archive_path)
|
|
123
|
+
if extract_dir is None:
|
|
124
|
+
extract_dir = archive_path.with_suffix("")
|
|
125
|
+
extract_dir = Path(extract_dir)
|
|
126
|
+
|
|
127
|
+
if lock_path is None:
|
|
128
|
+
lock_path = extract_dir.with_suffix(".lock")
|
|
129
|
+
lock_path = Path(lock_path)
|
|
130
|
+
|
|
131
|
+
marker_path = extract_dir.with_suffix(".extracted")
|
|
132
|
+
|
|
133
|
+
with filelock.FileLock(str(lock_path)):
|
|
134
|
+
# Already extracted and marked complete
|
|
135
|
+
if marker_path.exists() and extract_dir.exists():
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Remove any stale partial extraction
|
|
139
|
+
if extract_dir.exists():
|
|
140
|
+
shutil.rmtree(extract_dir, ignore_errors=True)
|
|
141
|
+
|
|
142
|
+
extract_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
# Extract to a temporary directory so other processes never
|
|
145
|
+
# see an incomplete destination.
|
|
146
|
+
temp_dir = tempfile.mkdtemp(
|
|
147
|
+
prefix=extract_dir.name + "_tmp_",
|
|
148
|
+
dir=extract_dir.parent,
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
152
|
+
zf.extractall(temp_dir)
|
|
153
|
+
|
|
154
|
+
Path(temp_dir).rename(extract_dir)
|
|
155
|
+
except Exception:
|
|
156
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
marker_path.touch()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cleanup_asset_safely(
|
|
163
|
+
local_path: Path, chunk_id: Optional[int] = None, total_chunks: int = 1
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Safely clean up the downloaded asset after all chunks are processed."""
|
|
166
|
+
import filelock
|
|
167
|
+
|
|
168
|
+
lock_path = local_path.with_suffix(".lock")
|
|
169
|
+
if total_chunks > 1 and chunk_id is not None:
|
|
170
|
+
done_file = local_path.with_suffix(f".chunk_{chunk_id}.done")
|
|
171
|
+
done_file.touch()
|
|
172
|
+
with filelock.FileLock(str(lock_path)):
|
|
173
|
+
done_files = list(local_path.parent.glob(f"{local_path.stem}.chunk_*.done"))
|
|
174
|
+
if len(done_files) >= total_chunks:
|
|
175
|
+
if local_path.exists():
|
|
176
|
+
try:
|
|
177
|
+
local_path.unlink()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
for df in done_files:
|
|
181
|
+
try:
|
|
182
|
+
df.unlink()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
try:
|
|
186
|
+
lock_path.unlink()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
else:
|
|
190
|
+
if local_path.exists():
|
|
191
|
+
try:
|
|
192
|
+
local_path.unlink()
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
aereo/cli/__init__.py
ADDED
|
File without changes
|
aereo/cli/main.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""AER CLI — command-line interface for the AER satellite data framework."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: E402
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import pickle
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any, Optional
|
|
11
|
+
|
|
12
|
+
import geopandas as gpd
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from aereo.client import AereoClient
|
|
18
|
+
from aereo.execution import LocalProcessBackend
|
|
19
|
+
from aereo.interfaces import AereoProfile, GridConfig
|
|
20
|
+
from aereo.schemas import AssetSchema
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="aereo",
|
|
24
|
+
help="AER — Modular satellite data discovery, extraction, and processing",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Helpers
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_geometry(geojson_path: Path) -> Optional[dict[str, Any]]:
|
|
35
|
+
"""Load a GeoJSON file and return the geometry dict."""
|
|
36
|
+
data = json.loads(geojson_path.read_text())
|
|
37
|
+
if data.get("type") == "FeatureCollection":
|
|
38
|
+
if not data.get("features"):
|
|
39
|
+
raise ValueError("GeoJSON FeatureCollection has no features.")
|
|
40
|
+
return data["features"][0]["geometry"]
|
|
41
|
+
elif data.get("type") == "Feature":
|
|
42
|
+
return data["geometry"]
|
|
43
|
+
elif "type" in data and data["type"] in (
|
|
44
|
+
"Point",
|
|
45
|
+
"MultiPoint",
|
|
46
|
+
"LineString",
|
|
47
|
+
"MultiLineString",
|
|
48
|
+
"Polygon",
|
|
49
|
+
"MultiPolygon",
|
|
50
|
+
"GeometryCollection",
|
|
51
|
+
):
|
|
52
|
+
return data
|
|
53
|
+
raise ValueError("Could not extract geometry from GeoJSON.")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _search_results_to_json(df: gpd.GeoDataFrame) -> list[dict[str, Any]]:
|
|
57
|
+
"""Convert search results GeoDataFrame to JSON-serializable records."""
|
|
58
|
+
# Convert to GeoJSON feature collection then to simple records
|
|
59
|
+
df = df.copy()
|
|
60
|
+
df["geometry"] = df.geometry.apply(
|
|
61
|
+
lambda g: g.__geo_interface__ if g is not None else None
|
|
62
|
+
)
|
|
63
|
+
records = df.to_dict(orient="records")
|
|
64
|
+
# Convert datetime to ISO strings
|
|
65
|
+
for rec in records:
|
|
66
|
+
for key in ("start_time", "end_time"):
|
|
67
|
+
if key in rec and rec[key] is not None:
|
|
68
|
+
rec[key] = (
|
|
69
|
+
rec[key].isoformat() if hasattr(rec[key], "isoformat") else rec[key]
|
|
70
|
+
)
|
|
71
|
+
return records
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _search_results_from_json(records: list[dict[str, Any]]) -> Any:
|
|
75
|
+
"""Reconstruct a GeoDataFrame from JSON records."""
|
|
76
|
+
df = gpd.GeoDataFrame.from_records(records)
|
|
77
|
+
if "geometry" in df.columns:
|
|
78
|
+
from shapely.geometry import shape
|
|
79
|
+
|
|
80
|
+
def _to_geom(g: Any) -> Any:
|
|
81
|
+
if isinstance(g, dict):
|
|
82
|
+
return shape(g)
|
|
83
|
+
return g
|
|
84
|
+
|
|
85
|
+
df["geometry"] = gpd.GeoSeries(df["geometry"].apply(_to_geom))
|
|
86
|
+
df = gpd.GeoDataFrame(df, geometry="geometry")
|
|
87
|
+
df.set_crs(epsg=4326, inplace=True)
|
|
88
|
+
for key in ("start_time", "end_time"):
|
|
89
|
+
if key in df.columns:
|
|
90
|
+
df[key] = gpd.pd.to_datetime(df[key])
|
|
91
|
+
return gpd.GeoDataFrame(AssetSchema.validate(df))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _print_search_table(df: gpd.GeoDataFrame) -> None:
|
|
95
|
+
"""Pretty-print search results as a Rich table."""
|
|
96
|
+
table = Table(title=f"Search Results ({len(df)} scenes)")
|
|
97
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
98
|
+
table.add_column("Collection", style="magenta")
|
|
99
|
+
table.add_column("Start Time", style="green")
|
|
100
|
+
table.add_column("End Time", style="green")
|
|
101
|
+
table.add_column("Href", style="blue", overflow="fold")
|
|
102
|
+
|
|
103
|
+
for _, row in df.head(50).iterrows():
|
|
104
|
+
table.add_row(
|
|
105
|
+
str(row.get("id", "")),
|
|
106
|
+
str(row.get("collection", "")),
|
|
107
|
+
str(row.get("start_time", "")),
|
|
108
|
+
str(row.get("end_time", "")),
|
|
109
|
+
str(row.get("href", ""))[:60] + "...",
|
|
110
|
+
)
|
|
111
|
+
if len(df) > 50:
|
|
112
|
+
table.add_row("...", f"... and {len(df) - 50} more rows", "", "", "")
|
|
113
|
+
console.print(table)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Commands
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def search(
|
|
123
|
+
profile: Annotated[
|
|
124
|
+
list[Path],
|
|
125
|
+
typer.Option("--profile", "-p", help="Path to profile YAML (repeatable)"),
|
|
126
|
+
],
|
|
127
|
+
config: Annotated[
|
|
128
|
+
Optional[Path], typer.Option("--config", "-c", help="Path to grid config YAML")
|
|
129
|
+
] = None,
|
|
130
|
+
geojson: Annotated[
|
|
131
|
+
Optional[Path], typer.Option("--geojson", "-g", help="Path to AOI GeoJSON file")
|
|
132
|
+
] = None,
|
|
133
|
+
start: Annotated[
|
|
134
|
+
Optional[str], typer.Option("--start", "-s", help="Start datetime (ISO 8601)")
|
|
135
|
+
] = None,
|
|
136
|
+
end: Annotated[
|
|
137
|
+
Optional[str], typer.Option("--end", "-e", help="End datetime (ISO 8601)")
|
|
138
|
+
] = None,
|
|
139
|
+
output: Annotated[
|
|
140
|
+
Optional[Path],
|
|
141
|
+
typer.Option("--output", "-o", help="Output JSON file for search results"),
|
|
142
|
+
] = None,
|
|
143
|
+
fmt: Annotated[
|
|
144
|
+
str, typer.Option("--format", help="Output format: table or json")
|
|
145
|
+
] = "table",
|
|
146
|
+
verbose: Annotated[
|
|
147
|
+
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
|
|
148
|
+
] = False,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Search for satellite data across configured profiles."""
|
|
151
|
+
if verbose:
|
|
152
|
+
import structlog
|
|
153
|
+
|
|
154
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
|
|
155
|
+
|
|
156
|
+
# Load profiles
|
|
157
|
+
profiles: list[AereoProfile] = []
|
|
158
|
+
for p in profile:
|
|
159
|
+
if not p.exists():
|
|
160
|
+
console.print(f"[red]Profile not found:[/red] {p}")
|
|
161
|
+
raise typer.Exit(code=1)
|
|
162
|
+
profiles.extend(AereoProfile.from_yaml(p))
|
|
163
|
+
|
|
164
|
+
# Load geometry
|
|
165
|
+
intersects = _load_geometry(geojson) if geojson and geojson.exists() else None
|
|
166
|
+
|
|
167
|
+
# Parse datetimes
|
|
168
|
+
start_dt = datetime.fromisoformat(start) if start else None
|
|
169
|
+
end_dt = datetime.fromisoformat(end) if end else None
|
|
170
|
+
|
|
171
|
+
client = AereoClient()
|
|
172
|
+
try:
|
|
173
|
+
results = client.search(
|
|
174
|
+
profiles=profiles,
|
|
175
|
+
intersects=intersects,
|
|
176
|
+
start_datetime=start_dt,
|
|
177
|
+
end_datetime=end_dt,
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
console.print(f"[red]Search failed:[/red] {exc}")
|
|
181
|
+
raise typer.Exit(code=1)
|
|
182
|
+
|
|
183
|
+
if results is None or len(results) == 0:
|
|
184
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
185
|
+
raise typer.Exit(code=2)
|
|
186
|
+
|
|
187
|
+
if fmt == "json":
|
|
188
|
+
records = _search_results_to_json(results)
|
|
189
|
+
json_out = json.dumps(records, indent=2, default=str)
|
|
190
|
+
if output:
|
|
191
|
+
output.write_text(json_out)
|
|
192
|
+
console.print(f"[green]Wrote {len(records)} results to[/green] {output}")
|
|
193
|
+
else:
|
|
194
|
+
console.print(json_out)
|
|
195
|
+
else:
|
|
196
|
+
_print_search_table(results)
|
|
197
|
+
if output:
|
|
198
|
+
records = _search_results_to_json(results)
|
|
199
|
+
output.write_text(json.dumps(records, indent=2, default=str))
|
|
200
|
+
console.print(f"[green]Wrote results to[/green] {output}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command()
|
|
204
|
+
def prepare(
|
|
205
|
+
search_results: Annotated[Path, typer.Argument(help="Path to search results JSON")],
|
|
206
|
+
profile: Annotated[
|
|
207
|
+
list[Path],
|
|
208
|
+
typer.Option("--profile", "-p", help="Path to profile YAML (repeatable)"),
|
|
209
|
+
],
|
|
210
|
+
config: Annotated[
|
|
211
|
+
Optional[Path], typer.Option("--config", "-c", help="Path to grid config YAML")
|
|
212
|
+
] = None,
|
|
213
|
+
output_dir: Annotated[
|
|
214
|
+
Path, typer.Option("--output-dir", "-d", help="Output directory for extraction")
|
|
215
|
+
] = Path("./out"),
|
|
216
|
+
output: Annotated[
|
|
217
|
+
Optional[Path],
|
|
218
|
+
typer.Option("--output", "-o", help="Output pickle file for tasks"),
|
|
219
|
+
] = None,
|
|
220
|
+
cells_per_chunk: Annotated[
|
|
221
|
+
int, typer.Option("--cells-per-chunk", help="Max grid cells per task")
|
|
222
|
+
] = 50,
|
|
223
|
+
verbose: Annotated[
|
|
224
|
+
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
|
|
225
|
+
] = False,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Prepare search results for extraction."""
|
|
228
|
+
if verbose:
|
|
229
|
+
import structlog
|
|
230
|
+
|
|
231
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
|
|
232
|
+
|
|
233
|
+
if not search_results.exists():
|
|
234
|
+
console.print(f"[red]Search results not found:[/red] {search_results}")
|
|
235
|
+
raise typer.Exit(code=1)
|
|
236
|
+
|
|
237
|
+
records = json.loads(search_results.read_text())
|
|
238
|
+
df = _search_results_from_json(records)
|
|
239
|
+
|
|
240
|
+
profiles: list[AereoProfile] = []
|
|
241
|
+
for p in profile:
|
|
242
|
+
if not p.exists():
|
|
243
|
+
console.print(f"[red]Profile not found:[/red] {p}")
|
|
244
|
+
raise typer.Exit(code=1)
|
|
245
|
+
profiles.extend(AereoProfile.from_yaml(p))
|
|
246
|
+
|
|
247
|
+
grid_config = (
|
|
248
|
+
GridConfig.from_yaml(config) if config and config.exists() else GridConfig()
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
|
|
253
|
+
client = AereoClient()
|
|
254
|
+
try:
|
|
255
|
+
tasks = client.prepare_for_extraction(
|
|
256
|
+
search_results=df, # type: ignore[arg-type]
|
|
257
|
+
grid_config=grid_config,
|
|
258
|
+
profiles=profiles,
|
|
259
|
+
uri=str(output_dir),
|
|
260
|
+
cells_per_chunk=cells_per_chunk,
|
|
261
|
+
)
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
console.print(f"[red]Prepare failed:[/red] {exc}")
|
|
264
|
+
raise typer.Exit(code=1)
|
|
265
|
+
|
|
266
|
+
task_file = output or (output_dir / "tasks.pkl")
|
|
267
|
+
task_file.write_bytes(pickle.dumps(tasks))
|
|
268
|
+
console.print(
|
|
269
|
+
f"[green]✓ Prepared {len(tasks)} tasks (chunk size: {cells_per_chunk}).[/green]"
|
|
270
|
+
)
|
|
271
|
+
console.print(f"[green]Wrote tasks to[/green] {task_file}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.command()
|
|
275
|
+
def extract(
|
|
276
|
+
tasks: Annotated[Path, typer.Argument(help="Path to prepared tasks pickle file")],
|
|
277
|
+
output_dir: Annotated[
|
|
278
|
+
Path, typer.Option("--output-dir", "-d", help="Output directory")
|
|
279
|
+
] = Path("./out"),
|
|
280
|
+
workers: Annotated[
|
|
281
|
+
int, typer.Option("--workers", "-w", help="Max batch workers")
|
|
282
|
+
] = 1,
|
|
283
|
+
verbose: Annotated[
|
|
284
|
+
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
|
|
285
|
+
] = False,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Run extraction on prepared tasks."""
|
|
288
|
+
if verbose:
|
|
289
|
+
import structlog
|
|
290
|
+
|
|
291
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
|
|
292
|
+
|
|
293
|
+
if not tasks.exists():
|
|
294
|
+
console.print(f"[red]Tasks file not found:[/red] {tasks}")
|
|
295
|
+
raise typer.Exit(code=1)
|
|
296
|
+
|
|
297
|
+
task_list = pickle.loads(tasks.read_bytes())
|
|
298
|
+
|
|
299
|
+
backend = LocalProcessBackend(max_workers=workers)
|
|
300
|
+
client = AereoClient()
|
|
301
|
+
try:
|
|
302
|
+
artifacts = client.execute_tasks(task_list, backend=backend)
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
console.print(f"[red]Extraction failed:[/red] {exc}")
|
|
305
|
+
raise typer.Exit(code=1)
|
|
306
|
+
|
|
307
|
+
# Write full GeoDataFrame to parquet
|
|
308
|
+
parquet_path = output_dir / "artifacts.parquet"
|
|
309
|
+
artifacts.to_parquet(parquet_path)
|
|
310
|
+
|
|
311
|
+
console.print(f"[green]✓ Extracted {len(artifacts)} artifacts.[/green]")
|
|
312
|
+
console.print(f"[green]Parquet saved to:[/green] {parquet_path}")
|
|
313
|
+
console.print(f"[green]Output directory:[/green] {output_dir}")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command()
|
|
317
|
+
def run(
|
|
318
|
+
profile: Annotated[
|
|
319
|
+
list[Path],
|
|
320
|
+
typer.Option("--profile", "-p", help="Path to profile YAML (repeatable)"),
|
|
321
|
+
],
|
|
322
|
+
config: Annotated[
|
|
323
|
+
Optional[Path], typer.Option("--config", "-c", help="Path to grid config YAML")
|
|
324
|
+
] = None,
|
|
325
|
+
geojson: Annotated[
|
|
326
|
+
Optional[Path], typer.Option("--geojson", "-g", help="Path to AOI GeoJSON file")
|
|
327
|
+
] = None,
|
|
328
|
+
start: Annotated[
|
|
329
|
+
Optional[str], typer.Option("--start", "-s", help="Start datetime (ISO 8601)")
|
|
330
|
+
] = None,
|
|
331
|
+
end: Annotated[
|
|
332
|
+
Optional[str], typer.Option("--end", "-e", help="End datetime (ISO 8601)")
|
|
333
|
+
] = None,
|
|
334
|
+
output_dir: Annotated[
|
|
335
|
+
Path, typer.Option("--output-dir", "-d", help="Output directory")
|
|
336
|
+
] = Path("./out"),
|
|
337
|
+
workers: Annotated[
|
|
338
|
+
int, typer.Option("--workers", "-w", help="Max batch workers")
|
|
339
|
+
] = 1,
|
|
340
|
+
cells_per_chunk: Annotated[
|
|
341
|
+
int, typer.Option("--cells-per-chunk", help="Max grid cells per task")
|
|
342
|
+
] = 50,
|
|
343
|
+
verbose: Annotated[
|
|
344
|
+
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
|
|
345
|
+
] = False,
|
|
346
|
+
) -> None:
|
|
347
|
+
"""One-shot: search → prepare → extract."""
|
|
348
|
+
if verbose:
|
|
349
|
+
import structlog
|
|
350
|
+
|
|
351
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(10))
|
|
352
|
+
|
|
353
|
+
# Load profiles
|
|
354
|
+
profiles: list[AereoProfile] = []
|
|
355
|
+
for p in profile:
|
|
356
|
+
if not p.exists():
|
|
357
|
+
console.print(f"[red]Profile not found:[/red] {p}")
|
|
358
|
+
raise typer.Exit(code=1)
|
|
359
|
+
profiles.extend(AereoProfile.from_yaml(p))
|
|
360
|
+
|
|
361
|
+
grid_config = (
|
|
362
|
+
GridConfig.from_yaml(config) if config and config.exists() else GridConfig()
|
|
363
|
+
)
|
|
364
|
+
intersects = _load_geometry(geojson) if geojson and geojson.exists() else None
|
|
365
|
+
start_dt = datetime.fromisoformat(start) if start else None
|
|
366
|
+
end_dt = datetime.fromisoformat(end) if end else None
|
|
367
|
+
|
|
368
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
client = AereoClient()
|
|
370
|
+
|
|
371
|
+
# Search
|
|
372
|
+
console.print("[bold blue]🔍 Searching...[/bold blue]")
|
|
373
|
+
try:
|
|
374
|
+
results = client.search(
|
|
375
|
+
profiles=profiles,
|
|
376
|
+
intersects=intersects,
|
|
377
|
+
start_datetime=start_dt,
|
|
378
|
+
end_datetime=end_dt,
|
|
379
|
+
)
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
console.print(f"[red]Search failed:[/red] {exc}")
|
|
382
|
+
raise typer.Exit(code=1)
|
|
383
|
+
|
|
384
|
+
if results is None or len(results) == 0:
|
|
385
|
+
console.print("[yellow]No results found.[/yellow]")
|
|
386
|
+
raise typer.Exit(code=2)
|
|
387
|
+
console.print(f"[green]✓ Found {len(results)} scenes.[/green]")
|
|
388
|
+
|
|
389
|
+
# Prepare
|
|
390
|
+
console.print("[bold blue]📦 Preparing...[/bold blue]")
|
|
391
|
+
try:
|
|
392
|
+
tasks = client.prepare_for_extraction(
|
|
393
|
+
search_results=results,
|
|
394
|
+
grid_config=grid_config,
|
|
395
|
+
profiles=profiles,
|
|
396
|
+
uri=str(output_dir),
|
|
397
|
+
cells_per_chunk=cells_per_chunk,
|
|
398
|
+
)
|
|
399
|
+
except Exception as exc:
|
|
400
|
+
console.print(f"[red]Prepare failed:[/red] {exc}")
|
|
401
|
+
raise typer.Exit(code=1)
|
|
402
|
+
console.print(
|
|
403
|
+
f"[green]✓ Prepared {len(tasks)} tasks (chunk size: {cells_per_chunk}).[/green]"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Extract
|
|
407
|
+
console.print("[bold blue]⛏️ Extracting...[/bold blue]")
|
|
408
|
+
backend = LocalProcessBackend(max_workers=workers)
|
|
409
|
+
try:
|
|
410
|
+
artifacts = client.execute_tasks(tasks, backend=backend)
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
console.print(f"[red]Extraction failed:[/red] {exc}")
|
|
413
|
+
raise typer.Exit(code=1)
|
|
414
|
+
|
|
415
|
+
parquet_path = output_dir / "artifacts.parquet"
|
|
416
|
+
artifacts.to_parquet(parquet_path)
|
|
417
|
+
|
|
418
|
+
console.print(f"[green]✓ Extracted {len(artifacts)} artifacts.[/green]")
|
|
419
|
+
console.print(f"[green]Parquet saved to:[/green] {parquet_path}")
|
|
420
|
+
console.print(f"[green]Output:[/green] {output_dir}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.command()
|
|
424
|
+
def validate(
|
|
425
|
+
config: Annotated[
|
|
426
|
+
Optional[Path],
|
|
427
|
+
typer.Option("--config", "-c", help="Path to config YAML to validate"),
|
|
428
|
+
] = None,
|
|
429
|
+
profile: Annotated[
|
|
430
|
+
Optional[Path],
|
|
431
|
+
typer.Option("--profile", "-p", help="Path to profile YAML to validate"),
|
|
432
|
+
] = None,
|
|
433
|
+
) -> None:
|
|
434
|
+
"""Validate a config or profile YAML against AER schemas."""
|
|
435
|
+
if config:
|
|
436
|
+
if not config.exists():
|
|
437
|
+
console.print(f"[red]Config not found:[/red] {config}")
|
|
438
|
+
raise typer.Exit(code=1)
|
|
439
|
+
try:
|
|
440
|
+
GridConfig.from_yaml(config)
|
|
441
|
+
console.print(f"[green]✓ Config valid:[/green] {config}")
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
console.print(f"[red]✗ Config invalid:[/red] {config}\n{exc}")
|
|
444
|
+
raise typer.Exit(code=1)
|
|
445
|
+
|
|
446
|
+
if profile:
|
|
447
|
+
if not profile.exists():
|
|
448
|
+
console.print(f"[red]Profile not found:[/red] {profile}")
|
|
449
|
+
raise typer.Exit(code=1)
|
|
450
|
+
try:
|
|
451
|
+
AereoProfile.from_yaml(profile)
|
|
452
|
+
console.print(f"[green]✓ Profile valid:[/green] {profile}")
|
|
453
|
+
except Exception as exc:
|
|
454
|
+
console.print(f"[red]✗ Profile invalid:[/red] {profile}\n{exc}")
|
|
455
|
+
raise typer.Exit(code=1)
|
|
456
|
+
|
|
457
|
+
if not config and not profile:
|
|
458
|
+
console.print("[yellow]Provide --config or --profile to validate.[/yellow]")
|
|
459
|
+
raise typer.Exit(code=1)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@app.command()
|
|
463
|
+
def plugins() -> None:
|
|
464
|
+
"""List installed AER plugins."""
|
|
465
|
+
from aereo.registry import AereoRegistry
|
|
466
|
+
|
|
467
|
+
registry = AereoRegistry()
|
|
468
|
+
|
|
469
|
+
table = Table(title="Installed AER Plugins")
|
|
470
|
+
table.add_column("Type", style="cyan")
|
|
471
|
+
table.add_column("Name", style="magenta")
|
|
472
|
+
table.add_column("Collections", style="green")
|
|
473
|
+
|
|
474
|
+
for name, cls in registry._searchers.items():
|
|
475
|
+
cols = ", ".join(cls.supported_collections[:3])
|
|
476
|
+
if len(cls.supported_collections) > 3:
|
|
477
|
+
cols += " ..."
|
|
478
|
+
table.add_row("Searcher", name, cols)
|
|
479
|
+
|
|
480
|
+
for name, cls in registry._extractors.items():
|
|
481
|
+
cols = ", ".join(cls.supported_collections[:3])
|
|
482
|
+
if len(cls.supported_collections) > 3:
|
|
483
|
+
cols += " ..."
|
|
484
|
+
table.add_row("Extractor", name, cols)
|
|
485
|
+
|
|
486
|
+
console.print(table)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# Entry point for `python -m aer.cli`
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
app()
|