Add AOI upload search and resilient downloads

This commit is contained in:
2026-04-28 15:47:25 +08:00
parent 60527e935f
commit a4fd1cff8e
12 changed files with 970 additions and 121 deletions
+52 -10
View File
@@ -71,6 +71,29 @@ function Get-RunningProcess {
}
}
function Get-ListeningProcessId {
param([int]$Port)
try {
$connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
Select-Object -First 1
if ($connection) {
return [int]$connection.OwningProcess
}
} catch {
}
$lines = netstat -ano -p tcp | Select-String "LISTENING"
foreach ($line in $lines) {
$parts = ($line.Line.Trim() -split "\s+")
if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) {
return [int]$parts[4]
}
}
return 0
}
function Load-State {
param([string]$Path)
@@ -133,6 +156,29 @@ if ($state -and -not $ForceRestart) {
}
}
if ($ForceRestart -and $state) {
$oldProcess = Get-RunningProcess -Id ([int]$state.pid)
if ($oldProcess) {
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
Stop-Process -Id $oldProcess.Id -Force
Start-Sleep -Seconds 1
}
Remove-State -Path $statePath
}
if ($ForceRestart) {
foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$listenerProcessId = Get-ListeningProcessId -Port $port
if ($listenerProcessId -gt 0) {
Write-Info "Stopping existing UI listener $listenerProcessId on port $port"
Stop-Process -Id $listenerProcessId -Force
Start-Sleep -Seconds 1
}
}
}
}
foreach ($port in $PreferredPorts) {
if (Test-Health -Port $port) {
$url = "http://127.0.0.1:$port/"
@@ -145,16 +191,6 @@ foreach ($port in $PreferredPorts) {
}
}
if ($ForceRestart -and $state) {
$oldProcess = Get-RunningProcess -Id ([int]$state.pid)
if ($oldProcess) {
Write-Info "Stopping previous tracked process $($oldProcess.Id)"
Stop-Process -Id $oldProcess.Id -Force
Start-Sleep -Seconds 1
}
Remove-State -Path $statePath
}
$uvPath = Get-UvPath
Write-Info "Using uv at $uvPath"
Write-Info "Syncing project environment"
@@ -226,6 +262,12 @@ if (-not $started) {
}
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url
$listenerProcessId = Get-ListeningProcessId -Port $portToUse
if ($listenerProcessId -gt 0) {
Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url
} else {
Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url
}
Write-Info "UI is ready: $url"
if (-not $NoBrowser) {
+32
View File
@@ -21,6 +21,29 @@ function Get-RunningProcess {
}
}
function Get-ListeningProcessId {
param([int]$Port)
try {
$connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
Select-Object -First 1
if ($connection) {
return [int]$connection.OwningProcess
}
} catch {
}
$lines = netstat -ano -p tcp | Select-String "LISTENING"
foreach ($line in $lines) {
$parts = ($line.Line.Trim() -split "\s+")
if ($parts.Count -ge 5 -and $parts[1].EndsWith(":$Port")) {
return [int]$parts[4]
}
}
return 0
}
$projectRoot = Split-Path -Parent $PSCommandPath
$statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json"
@@ -35,9 +58,18 @@ $process = Get-RunningProcess -Id ([int]$state.pid)
if ($process) {
Write-Info "Stopping UI process $($process.Id)"
Stop-Process -Id $process.Id -Force
} else {
$listenerProcessId = 0
if ($state.port) {
$listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port)
}
if ($listenerProcessId -gt 0) {
Write-Info "Stopping listener process $listenerProcessId"
Stop-Process -Id $listenerProcessId -Force
} else {
Write-Info "Tracked process is not running."
}
}
Remove-Item -LiteralPath $statePath -Force
Write-Info "Done."
+3
View File
@@ -10,6 +10,9 @@ requires-python = ">=3.11,<3.14"
dependencies = [
"asf-search>=12.0.7",
"fastapi>=0.116.1",
"pyproj>=3.7.0",
"python-multipart>=0.0.20",
"pyshp>=2.3.1",
"s1-orbits>=0.2.0",
"shapely>=2.1.2",
"uvicorn>=0.35.0",
@@ -1,10 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sentinel_orbit_downloader.api.deps import get_db_path
from sentinel_orbit_downloader.api.schemas import JobSceneResponse, JobSummary, SearchJobCreateRequest, SearchJobCreateResponse
from sentinel_orbit_downloader.services.aoi import bbox_to_wkt
from sentinel_orbit_downloader.api.schemas import (
JobSceneResponse,
JobSummary,
SearchJobCreateRequest,
SearchJobCreateResponse,
UploadedAoiResponse,
)
from sentinel_orbit_downloader.services.aoi import bbox_to_wkt, parse_uploaded_aoi
from sentinel_orbit_downloader.services.asf_client import SearchParams, query_scenes
from sentinel_orbit_downloader.services.project_store import (
create_search_job,
@@ -21,6 +27,32 @@ from sentinel_orbit_downloader.services.region_catalog import get_region, region
router = APIRouter(prefix="/search-jobs", tags=["search-jobs"])
@router.post("/upload-aoi", response_model=UploadedAoiResponse)
async def upload_search_aoi_endpoint(
files: list[UploadFile] = File(...),
) -> UploadedAoiResponse:
uploads: list[tuple[str, bytes]] = []
for file in files:
if not file.filename:
continue
content = await file.read()
uploads.append((file.filename, content))
try:
parsed = parse_uploaded_aoi(uploads)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return UploadedAoiResponse(
name=parsed.name,
source_format=parsed.source_format,
geometry_type=parsed.geometry_type,
feature_count=parsed.feature_count,
bbox=parsed.bbox,
wkt=parsed.wkt,
)
@router.post("", response_model=SearchJobCreateResponse)
def create_search_job_endpoint(
request: SearchJobCreateRequest,
@@ -102,8 +134,8 @@ def resolve_search_aoi(request: SearchJobCreateRequest, db_path) -> dict[str, st
if request.wkt:
return {
"aoi_wkt": request.wkt.strip(),
"source_type": "wkt",
"source_value": request.wkt.strip(),
"source_type": "aoi_file" if request.source_name else "wkt",
"source_value": request.source_name.strip() if request.source_name else request.wkt.strip(),
"region_adcode": None,
"region_name": None,
}
+11 -1
View File
@@ -121,11 +121,21 @@ class JobDetailResponse(BaseModel):
events: list[JobEventResponse] = Field(default_factory=list)
class UploadedAoiResponse(BaseModel):
name: str
source_format: str
geometry_type: str
feature_count: int
bbox: list[float] = Field(default_factory=list)
wkt: str
class SearchJobCreateRequest(BaseModel):
project_id: int | None = None
bbox: str | None = None
wkt: str | None = None
region_adcode: str | None = None
source_name: str | None = None
start: str
end: str
processing_level: str = "SLC"
@@ -158,7 +168,7 @@ class DownloadJobCreateRequest(BaseModel):
manifest_path: str | None = None
skip_data: bool = False
skip_orbits: bool = False
processes: int = Field(default=2, ge=1)
processes: int = Field(default=3, ge=1)
ignore_orbit_errors: bool = False
username: str | None = None
password: str | None = None
+38 -45
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import getpass
import json
import sys
from pathlib import Path
@@ -10,7 +11,6 @@ from sentinel_orbit_downloader.core.config import DEFAULT_DB_PATH
from sentinel_orbit_downloader.services.aoi import aoi_file_to_wkt, bbox_to_wkt
from sentinel_orbit_downloader.services.asf_client import (
SearchParams,
build_session,
product_size_mb,
query_scenes,
scene_name_from_product,
@@ -19,7 +19,11 @@ from sentinel_orbit_downloader.services.asf_client import (
from sentinel_orbit_downloader.services.orbit_client import (
collect_orbit_scenes,
download_orbits,
scene_names_from_results,
)
from sentinel_orbit_downloader.services.download_manager import (
DownloadJobRequest,
execute_download_job,
queue_download_job,
)
from sentinel_orbit_downloader.services.project_store import (
create_project,
@@ -112,8 +116,8 @@ def build_parser() -> argparse.ArgumentParser:
download_parser.add_argument(
"--processes",
type=positive_int,
default=2,
help="parallel ASF download processes",
default=3,
help="retry attempts for each product download",
)
download_parser.add_argument(
"--skip-data",
@@ -418,6 +422,15 @@ def cmd_download(args: argparse.Namespace) -> int:
results = perform_query(args, aoi_meta["aoi_wkt"])
save_query_results(args.db, job.id, results)
record_job_event(args.db, job.id, "info", f"Query returned {len(results)} scene(s)")
update_search_job(
args.db,
job.id,
status="running",
result_count=len(results),
data_dir=str(data_dir),
orbit_dir=str(orbit_dir),
manifest_path=str(manifest),
)
print_results(results)
write_manifest(results, manifest)
@@ -437,51 +450,31 @@ def cmd_download(args: argparse.Namespace) -> int:
print(f"Saved job: {job.id}")
return 0
if not args.skip_data:
data_dir.mkdir(parents=True, exist_ok=True)
session = build_session(
password = args.password
if args.ask_password and not args.skip_data:
password = getpass.getpass("Earthdata password: ")
manager_request = DownloadJobRequest(
job_id=job.id,
data_dir=data_dir,
orbit_dir=orbit_dir,
manifest_path=manifest,
skip_data=args.skip_data,
skip_orbits=args.skip_orbits,
processes=args.processes,
ignore_orbit_errors=args.ignore_orbit_errors,
username=args.username,
password=args.password,
ask_password=args.ask_password,
)
print(f"Downloading {len(results)} product(s) to {data_dir} ...")
results.download(path=str(data_dir), session=session, processes=args.processes)
print("Product download finished.")
record_job_event(args.db, job.id, "info", f"Product download finished into {data_dir}")
record_product_downloads(args.db, job.id, results, data_dir)
else:
record_job_event(args.db, job.id, "info", "Product download skipped by request")
record_product_downloads(args.db, job.id, results, None)
orbit_failures = 0
if not args.skip_orbits:
scenes = scene_names_from_results(results)
print(f"Downloading orbit files for {len(scenes)} scene(s) to {orbit_dir} ...")
orbit_results = download_orbits(scenes, orbit_dir)
orbit_failures = summarize_orbit_results(orbit_results)
record_orbit_downloads(args.db, job.id, orbit_results)
record_job_event(
args.db,
job.id,
"info",
f"Orbit download finished: {len(orbit_results) - orbit_failures} ok, {orbit_failures} failed",
)
else:
record_job_event(args.db, job.id, "info", "Orbit download skipped by request")
final_status = "partial_failed" if orbit_failures else "succeeded"
update_search_job(
args.db,
job.id,
status=final_status,
result_count=len(results),
data_dir=str(data_dir),
orbit_dir=str(orbit_dir),
manifest_path=str(manifest),
password=password,
)
queue_download_job(args.db, manager_request)
print(f"Downloading {len(results)} scene(s) with resume and validation support ...")
execute_download_job(args.db, manager_request)
finished_job = get_job(args.db, job.id)
final_status = finished_job.status if finished_job is not None else "failed"
print(f"Download job finished: {final_status}")
print(f"Saved job: {job.id}")
if orbit_failures and not args.ignore_orbit_errors:
if final_status in {"failed", "partial_failed"}:
return 1
return 0
except Exception as exc:
+290 -33
View File
@@ -1,8 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
from io import BytesIO
import json
from pathlib import Path
from typing import Any, Iterable, Sequence
import zipfile
import shapefile
from pyproj import CRS, Transformer
from shapely import make_valid
from shapely.geometry import shape as shapely_shape
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from shapely.ops import transform as shapely_transform
GEOJSON_SUFFIXES = {".geojson", ".json"}
TEXT_WKT_SUFFIXES = {".wkt", ".txt"}
SHAPEFILE_PART_SUFFIXES = {".shp", ".shx", ".dbf", ".prj", ".cpg"}
@dataclass(frozen=True)
class ParsedAOI:
name: str
wkt: str
source_format: str
geometry_type: str
feature_count: int
bbox: list[float]
def bbox_to_wkt(value: str) -> str:
@@ -22,54 +48,285 @@ def bbox_to_wkt(value: str) -> str:
def aoi_file_to_wkt(path: Path) -> str:
text = path.read_text(encoding="utf-8").strip()
suffix = path.suffix.lower()
if suffix in GEOJSON_SUFFIXES:
return parse_geojson_bytes(path.name, path.read_bytes()).wkt
if suffix == ".zip":
return parse_zip_bytes(path.name, path.read_bytes()).wkt
if suffix == ".shp":
return parse_shapefile_paths(path).wkt
text = decode_text_bytes(path.read_bytes()).strip()
if not text:
raise ValueError(f"AOI file is empty: {path}")
if text[0] != "{":
if suffix in TEXT_WKT_SUFFIXES:
return text
if text[0] in "{[":
return parse_geojson_text(path.name, text).wkt
return text
data = json.loads(text)
geometry = geojson_geometry(data)
return geometry_to_wkt(geometry)
def parse_uploaded_aoi(files: Sequence[tuple[str, bytes]]) -> ParsedAOI:
cleaned = [(Path(name).name or name, content) for name, content in files if content]
if not cleaned:
raise ValueError("No AOI files were provided")
suffixes = {Path(name).suffix.lower() for name, _ in cleaned}
if len(cleaned) == 1:
name, content = cleaned[0]
suffix = Path(name).suffix.lower()
if suffix in GEOJSON_SUFFIXES:
return parse_geojson_bytes(name, content)
if suffix == ".zip":
return parse_zip_bytes(name, content)
if suffix == ".shp":
return parse_shapefile_uploads(cleaned)
if suffix in TEXT_WKT_SUFFIXES:
return parse_wkt_text(name, content)
if suffixes & SHAPEFILE_PART_SUFFIXES:
return parse_shapefile_uploads(cleaned)
raise ValueError(
"Unsupported AOI upload. Use .geojson/.json, .zip shapefile, "
"or shapefile parts (.shp/.shx/.dbf)."
)
def geojson_geometry(data: dict[str, Any]) -> dict[str, Any]:
def parse_wkt_text(name: str, content: bytes) -> ParsedAOI:
text = decode_text_bytes(content).strip()
if not text:
raise ValueError(f"AOI file is empty: {name}")
return ParsedAOI(
name=Path(name).stem or name,
wkt=text,
source_format="wkt",
geometry_type="WKT",
feature_count=1,
bbox=[],
)
def parse_geojson_bytes(name: str, content: bytes) -> ParsedAOI:
text = decode_text_bytes(content)
return parse_geojson_text(name, text)
def parse_geojson_text(name: str, text: str) -> ParsedAOI:
stripped = text.strip()
if not stripped:
raise ValueError(f"AOI file is empty: {name}")
try:
data = json.loads(stripped)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid GeoJSON: {exc}") from exc
geometry, feature_count = geojson_to_geometry(data)
return build_parsed_aoi(
name=Path(name).stem or name,
source_format="geojson",
geometry=geometry,
feature_count=feature_count,
)
def parse_zip_bytes(name: str, content: bytes) -> ParsedAOI:
try:
archive = zipfile.ZipFile(BytesIO(content))
except zipfile.BadZipFile as exc:
raise ValueError(f"Invalid zip file: {name}") from exc
with archive:
file_names = [member for member in archive.namelist() if not member.endswith("/")]
geojson_members = [member for member in file_names if Path(member).suffix.lower() in GEOJSON_SUFFIXES]
if geojson_members:
member = geojson_members[0]
parsed = parse_geojson_bytes(Path(member).name, archive.read(member))
return ParsedAOI(
name=Path(name).stem or parsed.name,
wkt=parsed.wkt,
source_format="geojson-zip",
geometry_type=parsed.geometry_type,
feature_count=parsed.feature_count,
bbox=parsed.bbox,
)
shape_members = [
(Path(member).name, archive.read(member))
for member in file_names
if Path(member).suffix.lower() in SHAPEFILE_PART_SUFFIXES
]
if shape_members:
parsed = parse_shapefile_uploads(shape_members)
return ParsedAOI(
name=Path(name).stem or parsed.name,
wkt=parsed.wkt,
source_format="shapefile-zip",
geometry_type=parsed.geometry_type,
feature_count=parsed.feature_count,
bbox=parsed.bbox,
)
raise ValueError("Zip file does not contain GeoJSON or shapefile content")
def parse_shapefile_paths(shp_path: Path) -> ParsedAOI:
base = shp_path.with_suffix("")
uploads: list[tuple[str, bytes]] = [(shp_path.name, shp_path.read_bytes())]
for suffix in (".shx", ".dbf", ".prj", ".cpg"):
candidate = Path(f"{base}{suffix}")
if candidate.exists():
uploads.append((candidate.name, candidate.read_bytes()))
return parse_shapefile_uploads(uploads)
def parse_shapefile_uploads(files: Sequence[tuple[str, bytes]]) -> ParsedAOI:
groups: dict[str, dict[str, bytes]] = {}
display_names: dict[str, str] = {}
for name, content in files:
suffix = Path(name).suffix.lower()
if suffix not in SHAPEFILE_PART_SUFFIXES:
continue
stem = Path(name).stem
key = stem.lower()
groups.setdefault(key, {})[suffix] = content
display_names[key] = stem
shp_groups = [key for key, parts in groups.items() if ".shp" in parts]
if not shp_groups:
raise ValueError("Shapefile upload requires at least a .shp file")
if len(shp_groups) > 1:
names = ", ".join(display_names[key] for key in shp_groups)
raise ValueError(f"Multiple shapefiles were uploaded at once: {names}")
key = shp_groups[0]
parts = groups[key]
reader = shapefile.Reader(
shp=BytesIO(parts[".shp"]),
shx=BytesIO(parts[".shx"]) if ".shx" in parts else None,
dbf=BytesIO(parts[".dbf"]) if ".dbf" in parts else None,
)
try:
geometries = [shapely_shape(shape.__geo_interface__) for shape in reader.shapes()]
finally:
close = getattr(reader, "close", None)
if callable(close):
close()
if not geometries:
raise ValueError("Shapefile has no geometry records")
geometry = combine_geometries(geometries)
geometry = reproject_shapefile_geometry(geometry, parts.get(".prj"))
return build_parsed_aoi(
name=display_names[key],
source_format="shapefile",
geometry=geometry,
feature_count=len(geometries),
)
def geojson_to_geometry(data: dict[str, Any]) -> tuple[BaseGeometry, int]:
kind = data.get("type")
if kind == "FeatureCollection":
features = data.get("features") or []
if not features:
raise ValueError("GeoJSON FeatureCollection has no features")
return geojson_geometry(features[0])
geometries = []
for feature in features:
geometry = feature.get("geometry") if isinstance(feature, dict) else None
if geometry:
geometries.append(shapely_shape(geometry))
if not geometries:
raise ValueError("GeoJSON FeatureCollection has no geometry")
return combine_geometries(geometries), len(geometries)
if kind == "Feature":
geometry = data.get("geometry")
if not geometry:
raise ValueError("GeoJSON Feature has no geometry")
return normalize_geometry(shapely_shape(geometry)), 1
if kind:
return normalize_geometry(shapely_shape(data)), 1
raise ValueError("Invalid GeoJSON: missing type")
def combine_geometries(geometries: Iterable[BaseGeometry]) -> BaseGeometry:
items = [normalize_geometry(geometry) for geometry in geometries if not geometry.is_empty]
if not items:
raise ValueError("AOI has no valid geometry")
geometry = items[0] if len(items) == 1 else unary_union(items)
return normalize_geometry(geometry)
def normalize_geometry(geometry: BaseGeometry) -> BaseGeometry:
if geometry.is_empty:
raise ValueError("AOI geometry is empty")
if not geometry.is_valid:
repaired = make_valid(geometry)
if not repaired.is_empty:
geometry = repaired
if geometry.is_empty:
raise ValueError("AOI geometry is empty")
return geometry
if kind in {"Polygon", "MultiPolygon", "Point", "MultiPoint", "LineString"}:
return data
raise ValueError(f"unsupported GeoJSON type: {kind}")
def geometry_to_wkt(geometry: dict[str, Any]) -> str:
kind = geometry.get("type")
coordinates = geometry.get("coordinates")
if kind == "Point":
lon, lat = coordinates
return f"POINT({lon} {lat})"
if kind == "MultiPoint":
return "MULTIPOINT(" + ",".join(f"({lon} {lat})" for lon, lat in coordinates) + ")"
if kind == "LineString":
return "LINESTRING(" + coords_to_text(coordinates) + ")"
if kind == "Polygon":
return "POLYGON(" + ",".join(f"({coords_to_text(ring)})" for ring in coordinates) + ")"
if kind == "MultiPolygon":
polygons = []
for polygon in coordinates:
rings = ",".join(f"({coords_to_text(ring)})" for ring in polygon)
polygons.append(f"({rings})")
return "MULTIPOLYGON(" + ",".join(polygons) + ")"
raise ValueError(f"unsupported GeoJSON geometry type: {kind}")
def reproject_shapefile_geometry(geometry: BaseGeometry, prj_content: bytes | None) -> BaseGeometry:
if not prj_content:
validate_lon_lat_bounds(geometry)
return geometry
prj_text = decode_text_bytes(prj_content).strip()
if not prj_text:
validate_lon_lat_bounds(geometry)
return geometry
try:
source_crs = CRS.from_wkt(prj_text)
except Exception as exc:
raise ValueError(f"Unable to read shapefile .prj CRS: {exc}") from exc
target_crs = CRS.from_epsg(4326)
if source_crs.equals(target_crs):
validate_lon_lat_bounds(geometry)
return geometry
transformer = Transformer.from_crs(source_crs, target_crs, always_xy=True)
transformed = shapely_transform(transformer.transform, geometry)
validate_lon_lat_bounds(transformed)
return normalize_geometry(transformed)
def coords_to_text(coords: Iterable[Sequence[float]]) -> str:
return ",".join(f"{lon} {lat}" for lon, lat, *_ in coords)
def validate_lon_lat_bounds(geometry: BaseGeometry) -> None:
min_x, min_y, max_x, max_y = geometry.bounds
if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90:
raise ValueError(
"AOI coordinates are outside longitude/latitude bounds. "
"For shapefiles, include a valid .prj file or convert the data to WGS84/EPSG:4326 first."
)
def build_parsed_aoi(
*,
name: str,
source_format: str,
geometry: BaseGeometry,
feature_count: int,
) -> ParsedAOI:
geometry = normalize_geometry(geometry)
bounds = list(geometry.bounds) if geometry.bounds else []
return ParsedAOI(
name=name,
wkt=geometry.wkt,
source_format=source_format,
geometry_type=geometry.geom_type,
feature_count=feature_count,
bbox=[float(value) for value in bounds],
)
def decode_text_bytes(content: bytes) -> str:
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return content.decode(encoding)
except UnicodeDecodeError:
continue
return content.decode("latin-1")
@@ -2,9 +2,12 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import time
from typing import Sequence
import zipfile
import asf_search as asf
from asf_search.download.download import strip_auth_if_aws
from sentinel_orbit_downloader.services.asf_client import build_session, credentials_available, scene_name_from_product
from sentinel_orbit_downloader.services.orbit_client import download_orbits
@@ -40,6 +43,17 @@ class DownloadJobPaths:
manifest_path: Path
@dataclass(frozen=True)
class ProductDownloadResult:
scene_id: str
status: str
path: Path | None
url: str | None
bytes_total: float | None
bytes_done: float | None
error_message: str | None = None
def resolve_download_paths(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths:
job = get_job(db_path, request.job_id)
if job is None:
@@ -163,7 +177,7 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None:
record_product_skips(db_path, request.job_id, scene_ids)
else:
results = asf.granule_search(scene_ids)
products = {scene_name_from_product(product): product for product in results}
products = select_data_products(results, scene_ids, job.processing_level)
ordered_products = [products[scene_id] for scene_id in scene_ids if scene_id in products]
missing_scene_ids = [scene_id for scene_id in scene_ids if scene_id not in products]
if missing_scene_ids:
@@ -193,19 +207,52 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None:
path=None,
)
paths.data_dir.mkdir(parents=True, exist_ok=True)
try:
session = build_session(username=request.username, password=request.password)
results.download(path=str(paths.data_dir), session=session, processes=request.processes)
product_attempts = max(1, min(5, request.processes))
product_results: list[ProductDownloadResult] = []
for index, product in enumerate(ordered_products, start=1):
scene_id = scene_name_from_product(product) or f"scene #{index}"
record_job_event(
db_path,
request.job_id,
"info",
f"Product download finished into {paths.data_dir}",
f"Product download {index}/{len(ordered_products)} started: {scene_id}",
)
result = download_product_with_retries(
product,
paths.data_dir,
session,
attempts=product_attempts,
)
product_results.append(result)
record_product_download_result(db_path, request.job_id, result)
if result.status == "succeeded":
record_job_event(
db_path,
request.job_id,
"info",
f"Product download {index}/{len(ordered_products)} succeeded: {scene_id}",
)
else:
record_job_event(
db_path,
request.job_id,
"warning",
f"Product download {index}/{len(ordered_products)} failed: {scene_id}; {result.error_message}",
)
product_failures = sum(1 for result in product_results if result.status == "failed") + len(
missing_scene_ids
)
product_successes = sum(1 for result in product_results if result.status == "succeeded")
if product_failures:
product_error = f"Product download failed for {product_failures} of {len(scene_ids)} scene(s)"
record_job_event(
db_path,
request.job_id,
"info",
f"Product download finished: {product_successes} ok, {product_failures} failed",
)
except Exception as exc:
product_error = str(exc)
record_job_event(db_path, request.job_id, "error", f"Product download error: {exc}")
record_product_downloads(db_path, request.job_id, ordered_products, paths.data_dir, product_error)
orbit_failures = 0
if request.skip_orbits:
@@ -233,12 +280,25 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None:
)
product_failures = count_download_failures(db_path, request.job_id, "product")
product_successes = count_download_status(db_path, request.job_id, "product", "succeeded")
effective_orbit_failures = 0 if request.ignore_orbit_errors else orbit_failures
final_status = "succeeded"
if product_failures or effective_orbit_failures:
final_status = "partial_failed"
if product_failures and (request.skip_orbits or effective_orbit_failures == len(scene_ids)):
if not request.skip_data and product_failures and product_successes == 0:
final_status = "failed"
elif request.skip_data and not request.skip_orbits and effective_orbit_failures == len(scene_ids):
final_status = "failed"
error_message = None
if final_status != "succeeded":
error_message = summarize_failure(
product_failures=product_failures if not request.skip_data else 0,
orbit_failures=effective_orbit_failures if not request.skip_orbits else 0,
scene_count=len(scene_ids),
fallback=product_error,
)
update_search_job(
db_path,
@@ -248,7 +308,7 @@ def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None:
data_dir=str(paths.data_dir),
orbit_dir=str(paths.orbit_dir),
manifest_path=str(paths.manifest_path),
error_message=product_error,
error_message=error_message,
)
except Exception as exc:
update_search_job(
@@ -306,6 +366,21 @@ def record_product_downloads(
)
def record_product_download_result(db_path: Path, job_id: int, result: ProductDownloadResult) -> None:
record_download_result(
db_path,
job_id=job_id,
scene_id=result.scene_id,
kind="product",
status=result.status,
path=str(result.path) if result.path is not None else None,
url=result.url,
bytes_total=result.bytes_total,
bytes_done=result.bytes_done,
error_message=result.error_message,
)
def record_product_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None:
for scene_id in scene_ids:
record_download_result(
@@ -342,6 +417,209 @@ def expected_product_path(product, data_dir: Path) -> Path:
return data_dir / f"{scene}.zip"
def select_data_products(results, scene_ids: Sequence[str], processing_level: str) -> dict[str, object]:
wanted = set(scene_ids)
selected: dict[str, object] = {}
selected_rank: dict[str, int] = {}
for product in results:
scene_id = scene_name_from_product(product)
if scene_id not in wanted:
continue
rank = data_product_rank(product, processing_level)
if rank is None:
continue
current_rank = selected_rank.get(scene_id)
if current_rank is None or rank < current_rank:
selected[scene_id] = product
selected_rank[scene_id] = rank
return selected
def data_product_rank(product, processing_level: str) -> int | None:
props = product.properties
product_level = string_value(props.get("processingLevel"))
file_name = string_value(props.get("fileName")) or ""
url = string_value(props.get("url")) or ""
lower_name = file_name.lower()
lower_url = url.lower()
if product_level and product_level.upper().startswith("METADATA"):
return None
if lower_name.endswith(".iso.xml") or lower_url.endswith(".iso.xml"):
return None
if processing_level and product_level and product_level.upper() == processing_level.upper():
return 0
if lower_name.endswith(".zip") or lower_url.endswith(".zip"):
return 1
return None
def download_product_with_retries(product, data_dir: Path, session, attempts: int = 3) -> ProductDownloadResult:
scene_id = scene_name_from_product(product) or "unknown_scene"
props = product.properties
expected = expected_product_path(product, data_dir)
bytes_total = float(props["bytes"]) if props.get("bytes") is not None else None
expected_size = int(props["bytes"]) if props.get("bytes") is not None else None
url = product_download_url(product)
if file_complete(expected, expected_size):
return ProductDownloadResult(
scene_id=scene_id,
status="succeeded",
path=expected,
url=url,
bytes_total=bytes_total,
bytes_done=float(expected.stat().st_size),
)
last_error = "Downloaded file not found at expected path"
for attempt in range(1, max(1, attempts) + 1):
try:
if url is None:
raise ValueError("Product has no downloadable URL")
download_product_file(url, expected, session, expected_size)
if file_complete(expected, expected_size):
return ProductDownloadResult(
scene_id=scene_id,
status="succeeded",
path=expected,
url=url,
bytes_total=bytes_total,
bytes_done=float(expected.stat().st_size),
)
if expected.exists():
last_error = (
f"Downloaded file size mismatch: got {expected.stat().st_size}, expected {expected_size}"
)
else:
last_error = "Downloaded file not found at expected path"
except Exception as exc:
last_error = str(exc) or exc.__class__.__name__
if attempt < attempts:
time.sleep(min(15, attempt * 3))
part_path = partial_product_path(expected)
return ProductDownloadResult(
scene_id=scene_id,
status="failed",
path=None,
url=url,
bytes_total=bytes_total,
bytes_done=partial_size(expected, part_path),
error_message=last_error,
)
def product_download_url(product) -> str | None:
try:
urls = product.get_urls()
except Exception:
urls = []
if urls:
return str(urls[0])
return string_value(product.properties.get("url"))
def download_product_file(url: str, final_path: Path, session, expected_size: int | None) -> None:
final_path.parent.mkdir(parents=True, exist_ok=True)
if file_complete(final_path, expected_size):
return
part_path = prepare_partial_file(final_path, expected_size)
if validate_product_file(part_path, expected_size) is None:
part_path.replace(final_path)
return
resume_from = part_path.stat().st_size if part_path.exists() else 0
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
response = session.get(
url,
stream=True,
headers=headers,
hooks={"response": strip_auth_if_aws},
timeout=(30, 120),
)
try:
response.raise_for_status()
if resume_from and response.status_code != 206:
part_path.unlink(missing_ok=True)
resume_from = 0
elif resume_from:
validate_content_range(response.headers.get("Content-Range"), resume_from)
mode = "ab" if resume_from else "wb"
with part_path.open(mode) as handle:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
handle.write(chunk)
finally:
response.close()
validation_error = validate_product_file(part_path, expected_size)
if validation_error is not None:
raise IOError(validation_error)
part_path.replace(final_path)
def prepare_partial_file(final_path: Path, expected_size: int | None) -> Path:
part_path = partial_product_path(final_path)
if final_path.exists() and not file_complete(final_path, expected_size):
final_size = final_path.stat().st_size
if expected_size is not None and final_size < expected_size and not part_path.exists():
final_path.replace(part_path)
else:
final_path.unlink()
if part_path.exists() and expected_size is not None:
part_size = part_path.stat().st_size
if part_size > expected_size or (
part_size == expected_size and validate_product_file(part_path, expected_size)
):
part_path.unlink()
return part_path
def partial_product_path(final_path: Path) -> Path:
return final_path.with_name(f"{final_path.name}.part")
def file_complete(path: Path, expected_size: int | None) -> bool:
return validate_product_file(path, expected_size) is None
def validate_product_file(path: Path, expected_size: int | None) -> str | None:
if not path.exists() or not path.is_file():
return "Downloaded file not found at expected path"
actual_size = path.stat().st_size
if expected_size is not None and actual_size != expected_size:
return f"Downloaded file size mismatch: got {actual_size}, expected {expected_size}"
lower_name = path.name.lower()
if (lower_name.endswith(".zip") or lower_name.endswith(".zip.part")) and not zipfile.is_zipfile(path):
return "Downloaded ZIP failed integrity check"
return None
def validate_content_range(content_range: str | None, expected_start: int) -> None:
if not content_range:
return
prefix = f"bytes {expected_start}-"
if not content_range.startswith(prefix):
raise IOError(f"Unexpected resume range from server: {content_range}")
def partial_size(final_path: Path, part_path: Path) -> float | None:
if final_path.exists():
return float(final_path.stat().st_size)
if part_path.exists():
return float(part_path.stat().st_size)
return None
def string_value(value) -> str | None:
if value is None:
return None
@@ -349,6 +627,10 @@ def string_value(value) -> str | None:
def count_download_failures(db_path: Path, job_id: int, kind: str) -> int:
return count_download_status(db_path, job_id, kind, "failed")
def count_download_status(db_path: Path, job_id: int, kind: str, status: str) -> int:
from sentinel_orbit_downloader.storage.db import connect
if kind == "product":
@@ -361,10 +643,27 @@ def count_download_failures(db_path: Path, job_id: int, kind: str) -> int:
with connect(db_path) as connection:
row = connection.execute(
f"""
SELECT COUNT(*) AS failure_count
SELECT COUNT(*) AS status_count
FROM job_scenes
WHERE job_id = ? AND {column} = 'failed'
WHERE job_id = ? AND {column} = ?
""",
(job_id,),
(job_id, status),
).fetchone()
return int(row["failure_count"])
return int(row["status_count"])
def summarize_failure(
*,
product_failures: int,
orbit_failures: int,
scene_count: int,
fallback: str | None,
) -> str:
parts = []
if product_failures:
parts.append(f"Product failed for {product_failures}/{scene_count} scene(s)")
if orbit_failures:
parts.append(f"Orbit failed for {orbit_failures}/{scene_count} scene(s)")
if parts:
return "; ".join(parts)
return fallback or "Download finished with failures"
@@ -88,4 +88,7 @@ def download_orbits(scenes: Sequence[str], orbit_dir: Path) -> list[OrbitResult]
except (InvalidSceneError, OrbitNotFoundError) as exc:
message = str(exc) or exc.__class__.__name__
results.append(OrbitResult(scene=scene, path=None, error=message))
except Exception as exc:
message = str(exc) or exc.__class__.__name__
results.append(OrbitResult(scene=scene, path=None, error=message))
return results
+91 -5
View File
@@ -73,6 +73,25 @@ const api = {
body: JSON.stringify(payload),
});
},
async uploadSearchAoi(files) {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const response = await fetch("/api/search-jobs/upload-aoi", {
method: "POST",
body: formData,
});
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
detail = payload.detail || JSON.stringify(payload);
} catch (_) {}
throw new Error(detail);
}
return response.json();
},
createDownloadJob(payload) {
return this.request("/api/download-jobs", {
method: "POST",
@@ -98,6 +117,7 @@ const state = {
regions: [],
selectedRegion: null,
selectedRegionDetail: null,
uploadedAoi: null,
earthdataSettings: null,
jobs: [],
selectedJobId: null,
@@ -119,6 +139,9 @@ const el = {
regionKeyword: document.getElementById("regionKeyword"),
regionList: document.getElementById("regionList"),
regionDetailCard: document.getElementById("regionDetailCard"),
aoiMode: document.getElementById("aoiMode"),
uploadAoiFiles: document.getElementById("uploadAoiFiles"),
uploadAoiSummary: document.getElementById("uploadAoiSummary"),
startDate: document.getElementById("startDate"),
endDate: document.getElementById("endDate"),
maxResults: document.getElementById("maxResults"),
@@ -236,6 +259,10 @@ function currentProject() {
return state.projects.find((project) => project.id === state.selectedProjectId) || null;
}
function currentAoiMode() {
return el.aoiMode.value || "region";
}
function autoFillProjectPaths(project) {
if (!project || !project.data_root) {
return;
@@ -352,6 +379,22 @@ function renderEarthdataSummary() {
);
}
function renderUploadedAoiSummary() {
el.uploadAoiSummary.innerHTML = "";
const uploadedAoi = state.uploadedAoi;
if (!uploadedAoi) {
el.uploadAoiSummary.appendChild(makeChip("上传 AOI:未解析"));
return;
}
el.uploadAoiSummary.appendChild(makeChip(`AOI ${uploadedAoi.name}`));
el.uploadAoiSummary.appendChild(makeChip(`格式 ${uploadedAoi.source_format}`));
el.uploadAoiSummary.appendChild(makeChip(`几何 ${uploadedAoi.geometry_type}`));
el.uploadAoiSummary.appendChild(makeChip(`要素 ${uploadedAoi.feature_count}`));
if (Array.isArray(uploadedAoi.bbox) && uploadedAoi.bbox.length === 4) {
el.uploadAoiSummary.appendChild(makeChip(`范围 ${formatBBox(uploadedAoi.bbox)}`));
}
}
function renderJobFilterSummary() {
el.jobFilterSummary.innerHTML = "";
const project = currentProject();
@@ -515,9 +558,17 @@ function updateSelectionSummary() {
if (project) {
el.selectionSummary.appendChild(makeChip(`项目 ${project.name}`));
}
if (currentAoiMode() === "upload") {
el.selectionSummary.appendChild(makeChip("AOI 来源 上传文件"));
if (state.uploadedAoi) {
el.selectionSummary.appendChild(makeChip(`上传 AOI ${state.uploadedAoi.name}`));
}
} else {
el.selectionSummary.appendChild(makeChip("AOI 来源 行政区"));
if (state.selectedRegion) {
el.selectionSummary.appendChild(makeChip(`行政区 ${state.selectedRegion.label}`));
}
}
el.selectionSummary.appendChild(makeChip(`时间 ${el.startDate.value} - ${el.endDate.value}`));
el.selectionSummary.appendChild(makeChip(`产品 ${el.processingLevel.value}`));
if (el.beamMode.value) {
@@ -614,6 +665,25 @@ async function loadSelectedJobDetail() {
updateSelectionSummary();
}
async function uploadAoi() {
const files = Array.from(el.uploadAoiFiles.files || []);
if (!files.length) {
throw new Error("请先选择 AOI 文件");
}
state.uploadedAoi = await api.uploadSearchAoi(files);
renderUploadedAoiSummary();
updateSelectionSummary();
setMessage(`AOI 已解析:${state.uploadedAoi.name}`, "success");
}
function clearUploadedAoi() {
state.uploadedAoi = null;
el.uploadAoiFiles.value = "";
renderUploadedAoiSummary();
updateSelectionSummary();
setMessage("上传 AOI 已清空", "info");
}
async function importCatalog() {
const payload = {
source_path: el.catalogPath.value.trim() || DEFAULT_CATALOG_PATH,
@@ -669,13 +739,9 @@ async function createSearchJob() {
if (!state.selectedProjectId) {
throw new Error("请先选择项目");
}
if (!state.selectedRegion) {
throw new Error("请先选择行政区");
}
const payload = {
project_id: state.selectedProjectId,
region_adcode: state.selectedRegion.adcode,
start: el.startDate.value,
end: el.endDate.value,
processing_level: el.processingLevel.value,
@@ -685,9 +751,23 @@ async function createSearchJob() {
relative_orbit: numberOrNull(el.relativeOrbit.value),
max_results: Number(el.maxResults.value || 20),
manifest_path: emptyToNull(el.manifestPath.value),
raw_region: el.rawRegion.checked,
};
if (currentAoiMode() === "upload") {
if (!state.uploadedAoi) {
throw new Error("请先上传并解析 AOI 文件");
}
payload.wkt = state.uploadedAoi.wkt;
payload.source_name = state.uploadedAoi.name;
payload.raw_region = false;
} else {
if (!state.selectedRegion) {
throw new Error("请先选择行政区");
}
payload.region_adcode = state.selectedRegion.adcode;
payload.raw_region = el.rawRegion.checked;
}
const response = await api.createSearchJob(payload);
state.selectedJobId = response.job.id;
state.selectedJobDetail = {
@@ -822,6 +902,11 @@ function bindEvents() {
document.getElementById("createProjectBtn").addEventListener("click", runAction(createProject, "正在创建项目"));
document.getElementById("refreshProjectsBtn").addEventListener("click", runAction(loadProjects, "正在刷新项目"));
document.getElementById("searchRegionsBtn").addEventListener("click", runAction(loadRegions, "正在查询行政区"));
document.getElementById("uploadAoiBtn").addEventListener("click", runAction(uploadAoi, "正在解析上传 AOI"));
document.getElementById("clearAoiBtn").addEventListener("click", clearUploadedAoi);
document.getElementById("aoiMode").addEventListener("change", () => {
updateSelectionSummary();
});
document.getElementById("clearRegionsBtn").addEventListener("click", () => {
state.regions = [];
state.selectedRegion = null;
@@ -848,6 +933,7 @@ async function bootstrap() {
if (!el.catalogPath.value) {
el.catalogPath.value = DEFAULT_CATALOG_PATH;
}
renderUploadedAoiSummary();
el.healthDetail.textContent = window.location.origin;
setMessage("正在初始化工作台", "info");
await loadHealth();
+24 -3
View File
@@ -95,6 +95,27 @@
<h2>检索参数</h2>
<button class="primary" id="runSearchBtn" type="button">发起检索</button>
</div>
<div class="grid two">
<div class="field">
<label for="aoiMode">AOI 来源</label>
<select id="aoiMode">
<option value="region" selected>行政区</option>
<option value="upload">上传 AOI 文件</option>
</select>
</div>
<div class="field">
<label for="uploadAoiFiles">AOI 文件</label>
<input id="uploadAoiFiles" type="file" multiple accept=".geojson,.json,.zip,.shp,.shx,.dbf,.prj,.wkt,.txt">
</div>
</div>
<div class="toolbar">
<button class="ghost" id="uploadAoiBtn" type="button">解析上传 AOI</button>
<button class="ghost" id="clearAoiBtn" type="button">清除上传 AOI</button>
</div>
<div class="chip-row" id="uploadAoiSummary"></div>
<div class="note">
支持 `.geojson`、`.json`、`.wkt`、`.txt`、zip shapefile,或同时选择 `.shp`、`.shx`、`.dbf`、`.prj` 等文件。带 `.prj` 的 shapefile 会自动转为 WGS84,经纬度 GeoJSON 会自动合并成一个 AOI 范围。
</div>
<div class="grid three">
<div class="field">
<label for="startDate">开始日期</label>
@@ -193,8 +214,8 @@
</select>
</label>
<div class="field">
<label for="downloadProcesses">并发</label>
<input id="downloadProcesses" type="number" min="1" value="2">
<label for="downloadProcesses">重试次</label>
<input id="downloadProcesses" type="number" min="1" max="5" value="3">
</div>
</div>
<div class="toggle-row">
@@ -219,7 +240,7 @@
</div>
<div class="chip-row" id="earthdataSummary"></div>
<div class="note">
下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。
下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。产品下载支持完整文件跳过、.part 断点续传和下载后校验。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。
</div>
</section>
Generated
+71
View File
@@ -332,6 +332,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" },
]
[[package]]
name = "pyproj"
version = "3.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/bd/f205552cd1713b08f93b09e39a3ec99edef0b3ebbbca67b486fdf1abe2de/pyproj-3.7.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:2514d61f24c4e0bb9913e2c51487ecdaeca5f8748d8313c933693416ca41d4d5", size = 6227022, upload-time = "2025-08-14T12:03:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/75/4c/9a937e659b8b418ab573c6d340d27e68716928953273e0837e7922fcac34/pyproj-3.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8693ca3892d82e70de077701ee76dd13d7bca4ae1c9d1e739d72004df015923a", size = 4625810, upload-time = "2025-08-14T12:03:53.808Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/a9f41e814dc4d1dc54e95b2ccaf0b3ebe3eb18b1740df05fe334724c3d89/pyproj-3.7.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5e26484d80fea56273ed1555abaea161e9661d81a6c07815d54b8e883d4ceb25", size = 9638694, upload-time = "2025-08-14T12:03:55.669Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ab/9bdb4a6216b712a1f9aab1c0fcbee5d3726f34a366f29c3e8c08a78d6b70/pyproj-3.7.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:281cb92847814e8018010c48b4069ff858a30236638631c1a91dd7bfa68f8a8a", size = 9493977, upload-time = "2025-08-14T12:03:57.937Z" },
{ url = "https://files.pythonhosted.org/packages/c9/db/2db75b1b6190f1137b1c4e8ef6a22e1c338e46320f6329bfac819143e063/pyproj-3.7.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9c8577f0b7bb09118ec2e57e3babdc977127dd66326d6c5d755c76b063e6d9dc", size = 10841151, upload-time = "2025-08-14T12:04:00.271Z" },
{ url = "https://files.pythonhosted.org/packages/89/f7/989643394ba23a286e9b7b3f09981496172f9e0d4512457ffea7dc47ffc7/pyproj-3.7.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a23f59904fac3a5e7364b3aa44d288234af267ca041adb2c2b14a903cd5d3ac5", size = 10751585, upload-time = "2025-08-14T12:04:02.228Z" },
{ url = "https://files.pythonhosted.org/packages/53/6d/ad928fe975a6c14a093c92e6a319ca18f479f3336bb353a740bdba335681/pyproj-3.7.2-cp311-cp311-win32.whl", hash = "sha256:f2af4ed34b2cf3e031a2d85b067a3ecbd38df073c567e04b52fa7a0202afde8a", size = 5908533, upload-time = "2025-08-14T12:04:04.821Z" },
{ url = "https://files.pythonhosted.org/packages/79/e0/b95584605cec9ed50b7ebaf7975d1c4ddeec5a86b7a20554ed8b60042bd7/pyproj-3.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:0b7cb633565129677b2a183c4d807c727d1c736fcb0568a12299383056e67433", size = 6320742, upload-time = "2025-08-14T12:04:06.357Z" },
{ url = "https://files.pythonhosted.org/packages/b7/4d/536e8f93bca808175c2d0a5ac9fdf69b960d8ab6b14f25030dccb07464d7/pyproj-3.7.2-cp311-cp311-win_arm64.whl", hash = "sha256:38b08d85e3a38e455625b80e9eb9f78027c8e2649a21dec4df1f9c3525460c71", size = 6245772, upload-time = "2025-08-14T12:04:08.365Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" },
{ url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" },
{ url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" },
{ url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" },
{ url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" },
{ url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" },
{ url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" },
{ url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" },
{ url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" },
{ url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" },
{ url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" },
{ url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" },
{ url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" },
{ url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" },
{ url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" },
]
[[package]]
name = "pyshp"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/20/8b07bae73aaa0c3f5a2683ba6e23b46e977e2d33a88126d56bbcc2d135cd/pyshp-3.0.3.tar.gz", hash = "sha256:bf4678b13dd53578ed87669676a2fffeccbcded1ec8ff9cafb36d1b660f4b305", size = 2192568, upload-time = "2025-11-28T17:47:31.616Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/06/cad54e8ce758bd836ee5411691cbd49efeb9cc611b374670fce299519334/pyshp-3.0.3-py3-none-any.whl", hash = "sha256:28c8fac8c0c25bb0fecbbfd10ead7f319c2ff2f3b0b44a94f22bd2c93510ad42", size = 58465, upload-time = "2025-11-28T17:47:30.328Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -344,6 +400,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
name = "pytz"
version = "2026.1.post1"
@@ -459,6 +524,9 @@ source = { editable = "." }
dependencies = [
{ name = "asf-search" },
{ name = "fastapi" },
{ name = "pyproj" },
{ name = "pyshp" },
{ name = "python-multipart" },
{ name = "s1-orbits" },
{ name = "shapely" },
{ name = "uvicorn" },
@@ -468,6 +536,9 @@ dependencies = [
requires-dist = [
{ name = "asf-search", specifier = ">=12.0.7" },
{ name = "fastapi", specifier = ">=0.116.1" },
{ name = "pyproj", specifier = ">=3.7.0" },
{ name = "pyshp", specifier = ">=2.3.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "s1-orbits", specifier = ">=0.2.0" },
{ name = "shapely", specifier = ">=2.1.2" },
{ name = "uvicorn", specifier = ">=0.35.0" },