From 4e81c366302705bcd670fb638078151586a5becb Mon Sep 17 00:00:00 2001 From: Harmon Date: Tue, 28 Apr 2026 10:44:45 +0800 Subject: [PATCH 1/3] Initial project implementation --- python/s1-orbit-downloader/.gitignore | 23 + python/s1-orbit-downloader/.python-version | 1 + python/s1-orbit-downloader/README.md | 401 ++++++++ .../s1-orbit-downloader/Start-SentinelUI.cmd | 5 + .../s1-orbit-downloader/Start-SentinelUI.ps1 | 233 +++++ .../s1-orbit-downloader/Stop-SentinelUI.cmd | 5 + .../s1-orbit-downloader/Stop-SentinelUI.ps1 | 43 + .../docs/PROJECT_DESIGN.md | 818 +++++++++++++++++ python/s1-orbit-downloader/pyproject.toml | 25 + .../src/sentinel_orbit_downloader/__init__.py | 3 + .../src/sentinel_orbit_downloader/__main__.py | 5 + .../sentinel_orbit_downloader/api/__init__.py | 1 + .../src/sentinel_orbit_downloader/api/app.py | 57 ++ .../src/sentinel_orbit_downloader/api/deps.py | 9 + .../api/routes_downloads.py | 67 ++ .../api/routes_health.py | 14 + .../api/routes_jobs.py | 90 ++ .../api/routes_projects.py | 45 + .../api/routes_regions.py | 60 ++ .../api/routes_search.py | 121 +++ .../api/routes_settings.py | 52 ++ .../sentinel_orbit_downloader/api/schemas.py | 195 ++++ .../api/serializers.py | 45 + .../src/sentinel_orbit_downloader/cli.py | 815 +++++++++++++++++ .../core/__init__.py | 1 + .../sentinel_orbit_downloader/core/config.py | 27 + .../core/provinces.py | 40 + .../services/__init__.py | 1 + .../sentinel_orbit_downloader/services/aoi.py | 75 ++ .../services/asf_client.py | 151 +++ .../services/download_manager.py | 370 ++++++++ .../services/orbit_client.py | 91 ++ .../services/project_store.py | 545 +++++++++++ .../services/region_catalog.py | 285 ++++++ .../services/settings_store.py | 145 +++ .../storage/__init__.py | 1 + .../sentinel_orbit_downloader/storage/db.py | 193 ++++ .../src/sentinel_orbit_downloader/web/app.css | 573 ++++++++++++ .../src/sentinel_orbit_downloader/web/app.js | 864 ++++++++++++++++++ .../sentinel_orbit_downloader/web/index.html | 271 ++++++ python/s1-orbit-downloader/uv.lock | 612 +++++++++++++ 41 files changed, 7378 insertions(+) create mode 100644 python/s1-orbit-downloader/.gitignore create mode 100644 python/s1-orbit-downloader/.python-version create mode 100644 python/s1-orbit-downloader/README.md create mode 100644 python/s1-orbit-downloader/Start-SentinelUI.cmd create mode 100644 python/s1-orbit-downloader/Start-SentinelUI.ps1 create mode 100644 python/s1-orbit-downloader/Stop-SentinelUI.cmd create mode 100644 python/s1-orbit-downloader/Stop-SentinelUI.ps1 create mode 100644 python/s1-orbit-downloader/docs/PROJECT_DESIGN.md create mode 100644 python/s1-orbit-downloader/pyproject.toml create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/__init__.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/__main__.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/__init__.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/deps.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_downloads.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_jobs.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_projects.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_regions.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_settings.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/serializers.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/__init__.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/provinces.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/__init__.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/asf_client.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/project_store.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/region_catalog.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/settings_store.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/__init__.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/db.py create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.css create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js create mode 100644 python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html create mode 100644 python/s1-orbit-downloader/uv.lock diff --git a/python/s1-orbit-downloader/.gitignore b/python/s1-orbit-downloader/.gitignore new file mode 100644 index 0000000..e7f3d6c --- /dev/null +++ b/python/s1-orbit-downloader/.gitignore @@ -0,0 +1,23 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Local data sources and runtime state +全国行政区.geojson +.s1dl/ +.local-data/ +*.db +*.sqlite +*.sqlite3 +*.log +downloads/ +data/ +orbits/ +projects/ diff --git a/python/s1-orbit-downloader/.python-version b/python/s1-orbit-downloader/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/python/s1-orbit-downloader/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/python/s1-orbit-downloader/README.md b/python/s1-orbit-downloader/README.md new file mode 100644 index 0000000..d564ed6 --- /dev/null +++ b/python/s1-orbit-downloader/README.md @@ -0,0 +1,401 @@ +# Sentinel Orbit Downloader + +一个本地可用的 Sentinel-1 数据与精密轨道下载工具,支持: + +- 按行政区和时间范围检索 Sentinel-1 +- 下载原始 Sentinel-1 产品 +- 下载匹配的精密轨道/恢复轨道 `.EOF` +- 用 SQLite 管理项目、任务、场景和下载记录 +- 通过本地 Web 页面完成日常操作 + +当前项目面向 Windows 本机使用场景,已经包含一键启动脚本。 + +## 当前能力 + +已实现: + +- 行政区 GeoJSON 导入 SQLite +- 按行政区检索 Sentinel-1 场景 +- 原始 Sentinel-1 产品下载 +- 精密轨道 `.EOF` 下载 +- 项目管理 +- 任务列表、任务详情、事件日志 +- 任务删除与清空 +- Earthdata 凭据本地保存 + +暂未实现: + +- GeoTIFF / `.tif` 处理与导出 +- 后处理流水线 + +也就是说,现在项目专注于“检索 + 下载原始数据 + 下载轨道”。 + +## 目录 + +- 项目根目录:`C:\Users\16216\Desktop\Code\sentinel-orbit-downloader` +- 默认数据库:`.s1dl\app.db` +- 默认本地数据目录:`.local-data\` +- 行政区源文件:`全国行政区.geojson` + +## 环境要求 + +- Windows +- Python 3.11 - 3.13 +- `uv` +- 网络可访问 ASF / Earthdata + +## 快速开始 + +### 1. 一键启动 + +直接双击: + +- `Start-SentinelUI.cmd` + +它会自动: + +1. 检查 `uv` +2. 执行 `uv sync` +3. 启动本地服务 +4. 打开浏览器 + +停止服务时双击: + +- `Stop-SentinelUI.cmd` + +启动脚本优先尝试这些端口: + +- `8011` +- `8010` +- `8012` +- `8013` +- `8014` + +如果端口已被占用,会自动换到下一个可用端口。 + +### 2. 手动启动 + +```powershell +cd C:\Users\16216\Desktop\Code\sentinel-orbit-downloader +uv sync +$env:S1DL_API_PORT="8011" +uv run s1-web +``` + +打开页面: + +```text +http://127.0.0.1:8011/ +``` + +Swagger 文档: + +```text +http://127.0.0.1:8011/docs +``` + +## 首次使用流程 + +### 1. 导入行政区 + +项目根目录下已经放了: + +- `全国行政区.geojson` + +在页面中点击: + +- `导入行政区` + +也可以用命令行: + +```powershell +uv run s1-dl regions import +``` + +### 2. 创建项目 + +建议先建一个本地项目,例如: + +- 项目名称:`牡丹江 Sentinel-1 数据` +- 数据根目录:`.local-data\mudanjiang` + +### 3. 配置 Earthdata + +下载原始 Sentinel-1 产品必须有 NASA Earthdata 账号。 + +页面中可填写: + +- `Earthdata 用户名` +- `Earthdata 密码` + +然后点击: + +- `保存凭据` + +保存后: + +- 用户名和密码会写入本地 SQLite +- 密码不是明文保存 +- 密码使用当前 Windows 用户的 DPAPI 加密 + +如果不想保存,也可以每次手填。 + +### 4. Earthdata 首次授权 ASF + +很多第一次下载失败,不是用户名密码错,而是没有授权 ASF 应用访问 Earthdata。 + +如果任务日志出现类似错误: + +```text +Pre authorization required for this application +``` + +请先在浏览器登录 Earthdata,然后打开下面链接完成授权: + +```text +https://urs.earthdata.nasa.gov/approve_app?client_id=BO_n7nTIlMljdvU6kRRB3g +``` + +这是一次性的关键步骤。 + +### 5. 检索并下载 + +页面典型流程: + +1. 搜索行政区,例如 `牡丹江` +2. 选择时间范围 +3. 选择产品级别,如 `SLC` +4. 点击 `发起检索` +5. 选中任务 +6. 选择下载参数 +7. 点击 `启动下载` + +如果只想补精密轨道,不下载原始产品: + +- 把 `跳过产品` 设为 `是` + +## 页面功能说明 + +### 环境配置 + +- 导入行政区 GeoJSON +- 查看服务状态 + +### 项目 + +- 创建项目 +- 刷新项目列表 + +### 行政区选择 + +- 按名称或 `adcode` 搜索 +- 查看行政区范围框和中心点 + +### 检索参数 + +支持: + +- 开始日期 / 结束日期 +- 产品级别 +- 波束模式 +- 轨道方向 +- 极化 +- 相对轨道号 +- 最大结果数 +- 使用原始行政区边界 + +### 下载参数 + +支持: + +- 数据目录 +- 轨道目录 +- 跳过产品 +- 跳过轨道 +- 并发数 +- 忽略轨道失败 +- Earthdata 凭据保存 / 清除 + +### 任务区 + +支持: + +- 刷新任务 +- 刷新详情 +- 删除选中任务 +- 清空当前项目的非运行任务 + +## 输出结构 + +典型输出目录如下: + +```text +.local-data\mudanjiang\ + data\ # Sentinel-1 原始产品 + orbits\ # AUX_POEORB / AUX_RESORB EOF 文件 + manifest.csv +``` + +数据库中还会保存: + +- 项目信息 +- 搜索任务 +- 场景清单 +- 下载记录 +- 任务事件日志 + +## 命令行用法 + +### 同步环境 + +```powershell +uv sync +``` + +### 导入行政区 + +```powershell +uv run s1-dl regions import +``` + +### 搜索行政区 + +```powershell +uv run s1-dl regions search 牡丹江 +``` + +### 创建项目 + +```powershell +uv run s1-dl projects create ` + --name "牡丹江 Sentinel-1 数据" ` + --data-root ".local-data\mudanjiang" ` + --description "Mudanjiang Sentinel-1 scenes and orbits" +``` + +### 按行政区检索 + +牡丹江市 `adcode`: + +- `231000` + +```powershell +uv run s1-dl search ` + --project-id 1 ` + --region-adcode 231000 ` + --start 2024-01-01 ` + --end 2024-03-01 ` + --processing-level SLC ` + --max-results 20 ` + --manifest ".local-data\mudanjiang\manifest.csv" +``` + +### 直接下载 + +```powershell +uv run s1-dl download ` + --project-id 1 ` + --region-adcode 231000 ` + --start 2024-01-01 ` + --end 2024-03-01 ` + --processing-level SLC ` + --out ".local-data\mudanjiang-run" ` + --processes 2 +``` + +### 只补轨道 + +```powershell +uv run s1-dl orbits ` + --data-dir ".local-data\mudanjiang\data" ` + --out ".local-data\mudanjiang\orbits" +``` + +## 常见问题 + +### 1. 页面里任务全是 failed + +先看任务事件日志。 + +常见原因有两个: + +#### 原因 A:没填 Earthdata 用户名和密码 + +如果下载原始 Sentinel-1 产品,必须提供 Earthdata 凭据。 + +解决: + +- 页面填写用户名密码 +- 或保存到本地配置 +- 或设置环境变量 `EARTHDATA_USER` / `EARTHDATA_PASS` + +#### 原因 B:Earthdata 没授权 ASF + +如果日志里有: + +```text +Pre authorization required for this application +``` + +按前文的授权链接先批准 ASF 应用。 + +### 2. 为什么检索成功,但下载失败 + +因为检索 ASF 元数据不一定要求完整的下载授权;真正下载原始产品时才会触发 Earthdata 登录和授权校验。 + +### 3. 只想下载轨道,不想下载原始产品 + +页面里把: + +- `跳过产品` + +设成: + +- `是` + +### 4. 保存 Earthdata 凭据是否明文 + +不是。 + +当前实现: + +- 用户名保存在本地 SQLite +- 密码经过 Windows DPAPI 加密后保存 + +### 5. 为什么不支持 `.tif` + +当前项目只管原始 Sentinel-1 产品和轨道文件。 + +还没有做: + +- 解压 +- 预处理 +- GeoTIFF 导出 + +## 开发与维护 + +### 主要入口 + +- Web API: `src/sentinel_orbit_downloader/api/app.py` +- Web 页面: `src/sentinel_orbit_downloader/web/` +- 下载管理: `src/sentinel_orbit_downloader/services/download_manager.py` +- 项目/任务存储: `src/sentinel_orbit_downloader/services/project_store.py` +- Earthdata 本地设置: `src/sentinel_orbit_downloader/services/settings_store.py` + +### 本地脚本 + +- `Start-SentinelUI.cmd` +- `Start-SentinelUI.ps1` +- `Stop-SentinelUI.cmd` +- `Stop-SentinelUI.ps1` + +## 说明 + +这个项目当前阶段的目标很明确: + +- 把“按区域检索 Sentinel-1” +- “下载原始数据” +- “下载精密轨道” +- “管理任务记录” + +这四件事先稳定做好。 diff --git a/python/s1-orbit-downloader/Start-SentinelUI.cmd b/python/s1-orbit-downloader/Start-SentinelUI.cmd new file mode 100644 index 0000000..929675c --- /dev/null +++ b/python/s1-orbit-downloader/Start-SentinelUI.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Start-SentinelUI.ps1" +if errorlevel 1 pause +endlocal diff --git a/python/s1-orbit-downloader/Start-SentinelUI.ps1 b/python/s1-orbit-downloader/Start-SentinelUI.ps1 new file mode 100644 index 0000000..f39c1b1 --- /dev/null +++ b/python/s1-orbit-downloader/Start-SentinelUI.ps1 @@ -0,0 +1,233 @@ +param( + [int[]]$PreferredPorts = @(8011, 8010, 8012, 8013, 8014), + [switch]$NoBrowser, + [switch]$ForceRestart +) + +$ErrorActionPreference = "Stop" + +function Write-Info { + param([string]$Message) + Write-Host "[launcher] $Message" +} + +function Get-UvPath { + $command = Get-Command uv -ErrorAction SilentlyContinue + if ($command -and $command.Source) { + return $command.Source + } + + $fallback = Join-Path $HOME ".local\bin\uv.exe" + if (Test-Path -LiteralPath $fallback) { + return $fallback + } + + throw "uv.exe was not found. Install uv first or add it to PATH." +} + +function Test-Health { + param([int]$Port) + + try { + $null = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 2 + return $true + } catch { + return $false + } +} + +function Get-FreePort { + param([int[]]$Candidates) + + foreach ($port in $Candidates) { + $listener = $null + try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $port) + $listener.Start() + return $port + } catch { + continue + } finally { + if ($listener) { + $listener.Stop() + } + } + } + + throw "No free port was found in: $($Candidates -join ', ')" +} + +function Get-RunningProcess { + param([int]$Id) + + if ($Id -le 0) { + return $null + } + + try { + return Get-Process -Id $Id -ErrorAction Stop + } catch { + return $null + } +} + +function Load-State { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + try { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } catch { + return $null + } +} + +function Save-State { + param( + [string]$Path, + [int]$ProcessId, + [int]$Port, + [string]$Url + ) + + $payload = [ordered]@{ + pid = $ProcessId + port = $Port + url = $Url + started_at = (Get-Date).ToString("s") + } + + $payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +function Remove-State { + param([string]$Path) + + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Force + } +} + +$projectRoot = Split-Path -Parent $PSCommandPath +$runtimeDir = Join-Path $projectRoot ".s1dl" +$statePath = Join-Path $runtimeDir "web-ui-state.json" +$stdoutPath = Join-Path $runtimeDir "web-ui.stdout.log" +$stderrPath = Join-Path $runtimeDir "web-ui.stderr.log" + +New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null + +$state = Load-State -Path $statePath + +if ($state -and -not $ForceRestart) { + $existingProcess = Get-RunningProcess -Id ([int]$state.pid) + if ($existingProcess -and (Test-Health -Port ([int]$state.port))) { + $existingUrl = "http://127.0.0.1:$($state.port)/" + Write-Info "UI is already running at $existingUrl" + if (-not $NoBrowser) { + Start-Process $existingUrl | Out-Null + } + exit 0 + } +} + +foreach ($port in $PreferredPorts) { + if (Test-Health -Port $port) { + $url = "http://127.0.0.1:$port/" + Write-Info "Found an existing running UI at $url" + Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url + if (-not $NoBrowser) { + Start-Process $url | Out-Null + } + exit 0 + } +} + +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" +& $uvPath sync --directory $projectRoot +if ($LASTEXITCODE -ne 0) { + throw "uv sync failed with exit code $LASTEXITCODE" +} + +$pythonPath = Join-Path $projectRoot ".venv\Scripts\python.exe" +if (-not (Test-Path -LiteralPath $pythonPath)) { + throw "Python executable was not found at $pythonPath" +} + +$portToUse = Get-FreePort -Candidates $PreferredPorts +$url = "http://127.0.0.1:$portToUse/" + +if (Test-Path -LiteralPath $stdoutPath) { + Remove-Item -LiteralPath $stdoutPath -Force +} +if (Test-Path -LiteralPath $stderrPath) { + Remove-Item -LiteralPath $stderrPath -Force +} + +$arguments = @( + "-m", + "uvicorn", + "sentinel_orbit_downloader.api.app:app", + "--host", + "127.0.0.1", + "--port", + "$portToUse" +) + +Write-Info "Starting UI at $url" +$process = Start-Process ` + -FilePath $pythonPath ` + -ArgumentList $arguments ` + -WorkingDirectory $projectRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru + +$started = $false +for ($i = 0; $i -lt 30; $i++) { + Start-Sleep -Seconds 1 + if (Test-Health -Port $portToUse) { + $started = $true + break + } +} + +if (-not $started) { + $running = Get-RunningProcess -Id $process.Id + if ($running) { + Stop-Process -Id $running.Id -Force + } + + $stderrTail = "" + if (Test-Path -LiteralPath $stderrPath) { + $stderrTail = (Get-Content -LiteralPath $stderrPath -Tail 20) -join [Environment]::NewLine + } + + if ($stderrTail) { + throw "UI failed to start.`n$stderrTail" + } + + throw "UI failed to start." +} + +Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url +Write-Info "UI is ready: $url" + +if (-not $NoBrowser) { + Start-Process $url | Out-Null +} diff --git a/python/s1-orbit-downloader/Stop-SentinelUI.cmd b/python/s1-orbit-downloader/Stop-SentinelUI.cmd new file mode 100644 index 0000000..905591d --- /dev/null +++ b/python/s1-orbit-downloader/Stop-SentinelUI.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Stop-SentinelUI.ps1" +if errorlevel 1 pause +endlocal diff --git a/python/s1-orbit-downloader/Stop-SentinelUI.ps1 b/python/s1-orbit-downloader/Stop-SentinelUI.ps1 new file mode 100644 index 0000000..b2a845b --- /dev/null +++ b/python/s1-orbit-downloader/Stop-SentinelUI.ps1 @@ -0,0 +1,43 @@ +param() + +$ErrorActionPreference = "Stop" + +function Write-Info { + param([string]$Message) + Write-Host "[launcher] $Message" +} + +function Get-RunningProcess { + param([int]$Id) + + if ($Id -le 0) { + return $null + } + + try { + return Get-Process -Id $Id -ErrorAction Stop + } catch { + return $null + } +} + +$projectRoot = Split-Path -Parent $PSCommandPath +$statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json" + +if (-not (Test-Path -LiteralPath $statePath)) { + Write-Info "No tracked UI process was found." + exit 0 +} + +$state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json +$process = Get-RunningProcess -Id ([int]$state.pid) + +if ($process) { + Write-Info "Stopping UI process $($process.Id)" + Stop-Process -Id $process.Id -Force +} else { + Write-Info "Tracked process is not running." +} + +Remove-Item -LiteralPath $statePath -Force +Write-Info "Done." diff --git a/python/s1-orbit-downloader/docs/PROJECT_DESIGN.md b/python/s1-orbit-downloader/docs/PROJECT_DESIGN.md new file mode 100644 index 0000000..17a74ac --- /dev/null +++ b/python/s1-orbit-downloader/docs/PROJECT_DESIGN.md @@ -0,0 +1,818 @@ +# Sentinel 数据与精密轨道下载管理系统设计 + +## 目标 + +把当前命令行下载器扩展成一个本地化的 Sentinel-1 数据管理系统。系统需要支持按行政区、手绘范围、bbox、GeoJSON/WKT 查询影像,选择下载任务,自动下载 Sentinel-1 数据和对应精密轨道文件,并长期管理任务、文件、状态和日志。 + +第一阶段重点服务 Sentinel-1 SLC/GRD 数据和 `AUX_POEORB` 精密轨道;后续架构保留接入 Sentinel-2/3、Copernicus Data Space、批量处理流程的空间。 + +## 当前基础 + +已有能力: + +- `s1-dl search`:通过 ASF Search 按 AOI 和时间查询 Sentinel-1。 +- `s1-dl download`:查询后下载 Sentinel-1 产品,并下载匹配轨道。 +- `s1-dl orbits`:为已有场景名、`.zip`、`.SAFE` 补下载轨道。 +- Python 环境由 `uv` 管理。 + +项目下已有 `全国行政区.geojson`: + +- 文件大小约 53MB。 +- 顶层类型为 `FeatureCollection`。 +- 要素数:477。 +- 常见属性:`adcode`、`name`、`center`、`centroid`、`childrenNum`、`level`、`parent`、`acroutes`、`treeID`。 +- `level` 分布:多数为 `city`,少量 `district`,省级要素不完整。 +- 牡丹江市要素:`adcode=231000`、`name=牡丹江市`、`level=city`、`parent.adcode=230000`。 +- 牡丹江市边界范围:`128.232144,43.425798,131.310629,45.9875`。 + +设计结论:不能每次页面加载都直接解析 53MB GeoJSON。应在首次启动或数据更新时导入 SQLite,建立行政区索引、bbox、简化 WKT,并用内置省份 adcode 表补齐省级树。 + +## 产品形态 + +推荐形态:本地 Web 管理器 + 保留 CLI。 + +```text +浏览器页面 + 区域选择、地图查看、查询条件、结果表格、任务监控 + +FastAPI 后端 + 行政区查询、ASF 查询、下载任务、轨道匹配、文件管理 + +SQLite 数据库 + 项目、AOI、搜索任务、场景、下载记录、日志、设置 + +文件仓库 + Sentinel-1 zip、轨道 EOF、manifest、任务日志 + +CLI + 保留自动化和批处理入口,复用后端 service 层 +``` + +前端建议使用 Vite + React + TypeScript + Leaflet/MapLibre;后端使用 FastAPI + SQLAlchemy/SQLModel + SQLite。这样后端仍是 Python,能复用现有 ASF 和轨道下载逻辑;前端只负责交互和展示。 + +如果要先快速交付,也可以用 Streamlit 做 MVP。但作为长期项目,FastAPI + 独立前端更适合任务状态、日志、地图交互和后续扩展。 + +## 核心流程 + +### 1. 首次启动 + +1. 检查 `app.db` 是否存在。 +2. 检查行政区索引版本。 +3. 如果未导入,读取 `全国行政区.geojson`。 +4. 为每个要素计算: + - 原始 GeoJSON 几何。 + - bbox。 + - 中心点。 + - 原始 WKT。 + - 查询用简化 WKT。 + - 几何 hash。 +5. 根据 `parent.adcode` 和内置省份表生成省-市-区县树。 +6. 写入 `regions` 表。 + +### 2. 创建项目 + +用户创建一个项目,例如: + +```text +项目名:牡丹江积雪/形变数据 +数据根目录:D:\Sentinel\Mudanjiang +默认产品:SLC +默认波束:IW +默认下载并发:4 +``` + +项目用于隔离任务、下载目录和 manifest。 + +### 3. 选择 AOI + +支持四种来源: + +- 行政区:省/市/区县选择,例如 `黑龙江省 -> 牡丹江市`。 +- bbox:输入 `min_lon,min_lat,max_lon,max_lat`。 +- 上传文件:GeoJSON/WKT。 +- 地图绘制:画矩形或多边形。 + +对于行政区 AOI,系统默认使用行政区边界的简化 WKT 查询 ASF。页面允许切换: + +- 精确边界:减少周边误匹配,但 WKT 更长。 +- 简化边界:默认推荐。 +- bbox:速度快,但会包含边界外数据。 + +### 4. 查询 Sentinel-1 + +用户设置: + +- 时间范围。 +- 产品级别:`SLC`、`GRD_HD`、`GRD_MD`、`OCN`。 +- 波束:默认 `IW`。 +- 升降轨:`ASCENDING` / `DESCENDING` / 不限。 +- 极化:`VV`、`VH`、`VV+VH` 等。 +- 相对轨道号:可选。 +- 最大结果数。 + +后端调用 ASF Search,结果写入: + +- `search_jobs` +- `scenes` +- `job_scenes` + +前端展示可筛选表格: + +- 场景名。 +- 开始/结束时间。 +- 卫星:S1A/S1B/S1C/S1D。 +- 产品级别。 +- 升降轨。 +- 相对轨道。 +- 极化。 +- 文件大小。 +- 是否已有本地产品。 +- 是否已有轨道。 + +### 5. 创建下载任务 + +用户勾选场景后创建下载任务。任务分三段: + +1. 产品下载。 +2. 轨道下载。 +3. 文件核查和状态归档。 + +下载任务需要支持: + +- 暂停。 +- 取消。 +- 失败重试。 +- 跳过已存在文件。 +- 单独补轨道。 +- 只查询不下载。 +- 只下载轨道。 + +### 6. 下载数据 + +产品下载通过 ASF Session 使用 NASA Earthdata 账号。账号密码不写入数据库明文,优先使用: + +- 环境变量:`EARTHDATA_USER`、`EARTHDATA_PASS`。 +- Windows Credential Manager/keyring。 +- 页面临时输入,只保存在内存 session。 + +下载目录按项目和任务组织: + +```text +\ + projects\ + \ + jobs\ + ___\ + data\ + S1A_....zip + orbits\ + S1A_OPER_AUX_POEORB_....EOF + manifest.csv + manifest.json + logs\ + job.log +``` + +同一个场景如果已在仓库里存在,系统通过 `scene_id` 和文件路径复用,不重复下载。 + +### 7. 下载精密轨道 + +轨道不是按位置查询,而是按 Sentinel-1 场景名匹配: + +```text +场景名 -> 卫星平台 + 成像时间 -> AUX_POEORB EOF +``` + +系统行为: + +- 默认优先 `AUX_POEORB`。 +- 如果精密轨道暂不可用,可允许回退 `AUX_RESORB`,并在 UI 中明确标记。 +- 轨道下载失败不应阻断已下载产品,但任务状态应显示 `产品完成 / 轨道失败`。 +- 支持后续“一键补齐精密轨道”。 + +## 系统架构 + +### 后端模块 + +建议目录: + +```text +src/sentinel_orbit_downloader/ + api/ + app.py # FastAPI app + routes_regions.py + routes_projects.py + routes_search.py + routes_downloads.py + schemas.py # API DTO + core/ + config.py # 路径、并发、凭据配置 + paths.py # 文件目录规范 + logging.py + domain/ + enums.py + models.py # 领域对象,不绑定数据库 + services/ + region_catalog.py # 行政区导入和查询 + aoi.py # AOI 转换、简化、bbox + asf_client.py # ASF 查询和下载适配 + orbit_client.py # s1-orbits 适配 + download_manager.py # 下载编排 + job_runner.py # 后台任务执行 + inventory.py # 本地文件扫描和去重 + storage/ + db.py + orm.py + repositories.py + migrations/ + cli.py # 继续保留 CLI,调用 services +``` + +前端建议目录: + +```text +frontend/ + package.json + src/ + api/ + components/ + pages/ + styles/ +``` + +### 数据库模型 + +#### settings + +保存本地配置。 + +| 字段 | 说明 | +| --- | --- | +| key | 配置名 | +| value | 配置值 | +| updated_at | 更新时间 | + +#### regions + +行政区索引。 + +| 字段 | 说明 | +| --- | --- | +| adcode | 行政区代码 | +| name | 名称 | +| level | `country/province/city/district/custom` | +| parent_adcode | 父级代码 | +| province_adcode | 省级代码 | +| center_lon/center_lat | 中心点 | +| min_lon/min_lat/max_lon/max_lat | bbox | +| geometry_json | 原始几何 | +| wkt | 原始 WKT | +| simplified_wkt | ASF 查询用简化 WKT | +| geometry_hash | 几何 hash | + +#### projects + +用户项目。 + +| 字段 | 说明 | +| --- | --- | +| id | 项目 ID | +| name | 项目名 | +| slug | 目录友好名称 | +| data_root | 数据根目录 | +| description | 说明 | +| created_at/updated_at | 时间 | + +#### aois + +项目 AOI。 + +| 字段 | 说明 | +| --- | --- | +| id | AOI ID | +| project_id | 项目 ID | +| name | AOI 名称 | +| source_type | `region/bbox/upload/drawn` | +| region_adcode | 可选行政区代码 | +| wkt | 查询 WKT | +| bbox | bbox | +| geometry_json | 几何 | +| simplification_tolerance | 简化参数 | + +#### search_jobs + +一次查询任务。 + +| 字段 | 说明 | +| --- | --- | +| id | 查询 ID | +| project_id | 项目 ID | +| aoi_id | AOI ID | +| start_time/end_time | 查询时间范围 | +| processing_level | 产品级别 | +| beam_mode | 波束 | +| flight_direction | 升降轨 | +| polarization | 极化 | +| relative_orbit | 相对轨道 | +| max_results | 最大结果 | +| status | `pending/running/succeeded/failed/canceled` | +| result_count | 结果数 | +| error_message | 错误 | +| created_at/finished_at | 时间 | + +#### scenes + +ASF 场景主表。 + +| 字段 | 说明 | +| --- | --- | +| scene_id | Sentinel-1 场景名 | +| platform | S1A/S1B/S1C/S1D | +| start_time/stop_time | 成像时间 | +| processing_level | 产品级别 | +| beam_mode | 波束 | +| flight_direction | 升降轨 | +| polarization | 极化 | +| relative_orbit | 相对轨道 | +| size_bytes | 文件大小 | +| download_url | ASF 下载 URL | +| geometry_json | 场景 footprint | +| properties_json | ASF 原始属性 | + +#### job_scenes + +查询任务和场景的关系。 + +| 字段 | 说明 | +| --- | --- | +| job_id | 查询 ID | +| scene_id | 场景 ID | +| selected | 用户是否选择下载 | +| product_status | 产品下载状态 | +| orbit_status | 轨道下载状态 | +| product_path | 本地产品路径 | +| orbit_path | 本地轨道路径 | + +#### downloads + +下载记录。 + +| 字段 | 说明 | +| --- | --- | +| id | 下载 ID | +| job_id | 任务 ID | +| scene_id | 场景 ID | +| kind | `product/orbit` | +| status | `queued/running/succeeded/failed/skipped/canceled` | +| url | 下载 URL | +| path | 本地路径 | +| bytes_total/bytes_done | 进度 | +| attempt_count | 尝试次数 | +| error_message | 错误 | +| started_at/finished_at | 时间 | + +#### job_events + +任务日志。 + +| 字段 | 说明 | +| --- | --- | +| id | 日志 ID | +| job_id | 任务 ID | +| level | `info/warning/error` | +| message | 日志内容 | +| created_at | 时间 | + +## API 设计 + +### 行政区 + +```text +GET /api/regions/tree +GET /api/regions/search?q=牡丹江 +GET /api/regions/{adcode} +GET /api/regions/{adcode}/geometry?simplified=true +POST /api/regions/reindex +``` + +### 项目和 AOI + +```text +GET /api/projects +POST /api/projects +GET /api/projects/{id} +PATCH /api/projects/{id} +DELETE /api/projects/{id} + +GET /api/projects/{id}/aois +POST /api/projects/{id}/aois +``` + +### 查询 + +```text +POST /api/search-jobs +GET /api/search-jobs/{id} +GET /api/search-jobs/{id}/scenes +POST /api/search-jobs/{id}/select-scenes +``` + +### 下载 + +```text +POST /api/download-jobs +GET /api/download-jobs/{id} +GET /api/download-jobs/{id}/events +POST /api/download-jobs/{id}/pause +POST /api/download-jobs/{id}/resume +POST /api/download-jobs/{id}/cancel +POST /api/download-jobs/{id}/retry +POST /api/scenes/{scene_id}/download-orbit +``` + +### 系统状态 + +```text +GET /api/health +GET /api/settings +PUT /api/settings +GET /api/inventory/scan +``` + +## 前端页面设计 + +### 页面 1:首页 / 仪表盘 + +显示: + +- 项目列表。 +- 最近查询任务。 +- 最近下载任务。 +- 下载成功/失败数量。 +- 数据目录占用。 +- 凭据状态和 ASF 连通性。 + +### 页面 2:项目详情 + +显示: + +- 项目信息。 +- AOI 列表。 +- 历史任务。 +- 本地数据清单。 +- 一键新建查询。 + +### 页面 3:新建查询 + +布局: + +```text +左侧:区域选择和查询参数 +右侧:地图 +底部:查询按钮和参数摘要 +``` + +区域选择: + +- 省份下拉。 +- 城市下拉。 +- 搜索框:输入 `牡丹江` 可以直接定位。 +- 上传 GeoJSON。 +- bbox 输入。 +- 地图绘制。 + +查询参数: + +- 时间范围。 +- 产品类型。 +- 波束。 +- 升降轨。 +- 极化。 +- 相对轨道。 +- 最大结果数。 + +### 页面 4:查询结果 + +功能: + +- 表格排序、筛选、勾选。 +- 地图显示选中场景 footprint。 +- 显示本地文件存在状态。 +- 下载选中产品。 +- 只下载轨道。 +- 导出 manifest。 + +### 页面 5:下载任务 + +功能: + +- 进度条。 +- 当前下载文件。 +- 产品状态。 +- 轨道状态。 +- 失败原因。 +- 暂停/取消/重试。 +- 打开本地目录。 + +### 页面 6:数据仓库 + +功能: + +- 按项目、区域、时间、产品类型筛选本地数据。 +- 查看每景产品和轨道是否匹配。 +- 补下载缺失轨道。 +- 删除任务记录但保留文件。 +- 扫描外部已有数据入库。 + +## 文件和命名规范 + +项目目录: + +```text +\ + app.db + app.log + regions\ + regions_index_version.json + projects\ + \ + project.json + aois\ + jobs\ + __\ + data\ + orbits\ + manifests\ + logs\ +``` + +任务目录名示例: + +```text +000001_mudanjiang_20240101_20240301_s1_slc +``` + +manifest: + +- `manifest.csv`:给用户打开查看。 +- `manifest.json`:完整机器可读版本。 + +## 状态机 + +查询任务: + +```text +pending -> running -> succeeded + -> failed + -> canceled +``` + +下载任务: + +```text +queued -> running -> succeeded + -> partial_failed + -> failed + -> paused + -> canceled +``` + +单文件下载: + +```text +queued -> running -> succeeded + -> skipped + -> failed + -> canceled +``` + +产品和轨道状态分开记录。一个任务可以是: + +```text +产品全部成功,轨道部分失败 +``` + +这种情况应显示为 `partial_failed`,并允许单独补轨道。 + +## 关键工程决策 + +### 行政区边界处理 + +`全国行政区.geojson` 不作为运行时主数据源,而是导入源。导入后使用 SQLite 查询。 + +对于 ASF 查询,默认使用 `simplified_wkt`。原因: + +- 原始边界可能很长。 +- ASF 对复杂 WKT 查询更慢。 +- Sentinel-1 景幅较大,过细边界通常没有必要。 + +简化参数建议: + +- 默认 tolerance:`0.01` 度。 +- 牡丹江市原始 WKT 约 10755 字符,`0.01` 简化后约 5694 字符。 +- 用户可在高级设置中切换为原始边界或 bbox。 + +### 下载幂等 + +任何下载开始前先检查: + +- 数据库中是否已有该 `scene_id`。 +- 本地目标文件是否存在。 +- 文件大小是否合理。 +- 已有轨道是否覆盖该场景时间。 + +存在则标记 `skipped`,避免重复下载。 + +### 凭据 + +不把 Earthdata 密码明文写入 SQLite。 + +第一版: + +- 支持环境变量。 +- 支持页面临时输入。 + +第二版: + +- 接入 `keyring`,在 Windows 上使用凭据管理器。 + +### 后台任务 + +本地单用户应用可以先用进程内任务队列: + +- `asyncio.Queue` 或 `ThreadPoolExecutor`。 +- 下载任务串行调度,单任务内部并发下载。 +- 任务状态写 SQLite。 + +以后如果需要更强任务管理,再接入 RQ/Celery,但第一版不需要。 + +## 实现阶段 + +### 阶段 1:服务层重构 + +目标:把现有 CLI 逻辑拆成可复用 service。 + +任务: + +- 新增 `services/asf_client.py`。 +- 新增 `services/orbit_client.py`。 +- 新增 `services/aoi.py`。 +- CLI 改为调用 service。 +- 保持现有 CLI 行为不变。 +- 增加基础单元测试。 + +验收: + +- `uv run s1-dl search` 仍可用。 +- `uv run s1-dl download` 仍可用。 +- `uv run s1-dl orbits` 仍可用。 + +### 阶段 2:行政区索引 + +目标:支持从 `全国行政区.geojson` 快速检索牡丹江等行政区。 + +任务: + +- 新增 SQLite。 +- 新增 `regions` 表。 +- 新增导入命令:`s1-dl regions import 全国行政区.geojson`。 +- 新增搜索命令:`s1-dl regions search 牡丹江`。 +- 新增导出 AOI 命令:`s1-dl regions export 231000 --format wkt`。 + +验收: + +- 能查到 `牡丹江市 adcode=231000`。 +- 能用 `231000` 直接发起查询。 + +### 阶段 3:项目和任务数据库 + +目标:所有查询和下载有记录可追踪。 + +任务: + +- 新增 `projects`、`aois`、`search_jobs`、`scenes`、`job_scenes`、`downloads`、`job_events`。 +- 查询结果入库。 +- 下载结果入库。 +- 生成规范目录和 manifest。 + +验收: + +- 查询牡丹江后,数据库可看到任务和场景。 +- 下载后,数据库记录产品和轨道路径。 + +### 阶段 4:FastAPI 后端 + +目标:提供本地 Web API。 + +任务: + +- 新增 `api/app.py`。 +- 实现行政区、项目、查询、下载接口。 +- 提供 Swagger 文档。 +- 提供 `uv run s1-web` 启动命令。 + +验收: + +- `http://127.0.0.1:8000/docs` 可访问。 +- API 能返回牡丹江边界和查询结果。 + +### 阶段 5:前端 MVP + +目标:页面完成主要流程。 + +任务: + +- 项目列表。 +- 行政区选择。 +- 地图显示边界。 +- 查询表单。 +- 查询结果表格。 +- 创建下载任务。 +- 下载任务进度页。 + +验收: + +- 用户能在页面选择 `黑龙江省 -> 牡丹江市`。 +- 设置时间范围。 +- 查询 Sentinel-1。 +- 勾选场景并下载数据和轨道。 + +### 阶段 6:管理和增强 + +目标:长期可用。 + +任务: + +- 文件仓库扫描。 +- 缺失轨道补齐。 +- 失败重试。 +- 任务日志查看。 +- keyring 凭据管理。 +- 导出任务报告。 +- 支持 Copernicus Data Space 作为备用数据源。 + +## 牡丹江市示例流程 + +1. 用户打开本地页面。 +2. 进入项目 `牡丹江 Sentinel-1 数据`。 +3. 选择行政区: + +```text +黑龙江省 -> 牡丹江市 +``` + +4. 设置: + +```text +时间:2024-01-01 至 2024-03-01 +产品:SLC +波束:IW +升降轨:不限 +极化:不限 +``` + +5. 点击查询。 +6. 页面展示 ASF 返回的 Sentinel-1 场景。 +7. 用户勾选需要的场景。 +8. 点击下载。 +9. 系统下载: + +```text +data\S1A_....zip +orbits\S1A_OPER_AUX_POEORB_....EOF +manifest.csv +``` + +10. 下载任务页显示每景产品和轨道状态。 +11. 如果某景轨道失败,用户点击“补轨道”。 + +## 风险和处理 + +### ASF 下载需要 Earthdata 账号 + +处理:页面启动时做凭据检查,查询可以不登录,下载时必须登录。 + +### 精密轨道可能有延迟 + +处理:允许暂时使用 `AUX_RESORB`,并提供后续补 `AUX_POEORB`。 + +### 行政区 GeoJSON 层级不完整 + +处理:用 `parent.adcode` 和内置省级 adcode 表补层级,不依赖文件里存在完整省级 Feature。 + +### 大范围长时间查询结果过多 + +处理:限制最大结果数,页面提醒分批查询;任务层支持按月份自动拆分。 + +### 文件重复和路径混乱 + +处理:以 `scene_id` 为唯一键,所有任务引用同一场景,文件仓库扫描可修复数据库状态。 + +## 后续扩展 + +- Sentinel-2 数据下载。 +- Copernicus Data Space 数据源。 +- 自动按月份/季度拆分任务。 +- InSAR 处理链对接,例如 ISCE、SNAP、MintPy。 +- 场景覆盖图层和轨道号统计。 +- 下载完成后的数据质量检查。 diff --git a/python/s1-orbit-downloader/pyproject.toml b/python/s1-orbit-downloader/pyproject.toml new file mode 100644 index 0000000..1897c28 --- /dev/null +++ b/python/s1-orbit-downloader/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "sentinel-orbit-downloader" +version = "0.1.0" +description = "Query and download Sentinel-1 scenes and precise orbit files" +readme = "README.md" +authors = [ + { name = "Harmon", email = "rs_bhm@163.com" } +] +requires-python = ">=3.11,<3.14" +dependencies = [ + "asf-search>=12.0.7", + "fastapi>=0.116.1", + "s1-orbits>=0.2.0", + "shapely>=2.1.2", + "uvicorn>=0.35.0", +] + +[project.scripts] +sentinel-orbit-downloader = "sentinel_orbit_downloader:main" +s1-dl = "sentinel_orbit_downloader:main" +s1-web = "sentinel_orbit_downloader.api.app:main" + +[build-system] +requires = ["uv_build>=0.11.8,<0.12.0"] +build-backend = "uv_build" diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/__init__.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/__init__.py new file mode 100644 index 0000000..ed32c05 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/__init__.py @@ -0,0 +1,3 @@ +from .cli import main + +__all__ = ["main"] diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/__main__.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/__main__.py new file mode 100644 index 0000000..a049ad7 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/__init__.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/__init__.py new file mode 100644 index 0000000..d9c5b70 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI application package.""" diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py new file mode 100644 index 0000000..3e65c8a --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from sentinel_orbit_downloader.api.routes_downloads import router as downloads_router +from sentinel_orbit_downloader.api.routes_health import router as health_router +from sentinel_orbit_downloader.api.routes_jobs import router as jobs_router +from sentinel_orbit_downloader.api.routes_projects import router as projects_router +from sentinel_orbit_downloader.api.routes_regions import router as regions_router +from sentinel_orbit_downloader.api.routes_search import router as search_router +from sentinel_orbit_downloader.api.routes_settings import router as settings_router +from sentinel_orbit_downloader.core.config import configured_api_host, configured_api_port + + +def create_app() -> FastAPI: + app = FastAPI( + title="Sentinel Orbit Downloader API", + version="0.1.0", + description="Local API for Sentinel-1 search, orbit matching, and task management.", + ) + + web_root = Path(__file__).resolve().parents[1] / "web" + app.mount("/static", StaticFiles(directory=web_root), name="static") + + @app.get("/", include_in_schema=False) + def index() -> FileResponse: + return FileResponse(web_root / "index.html") + + @app.get("/app", include_in_schema=False) + def app_index() -> FileResponse: + return FileResponse(web_root / "index.html") + + app.include_router(health_router, prefix="/api") + app.include_router(regions_router, prefix="/api") + app.include_router(projects_router, prefix="/api") + app.include_router(jobs_router, prefix="/api") + app.include_router(search_router, prefix="/api") + app.include_router(downloads_router, prefix="/api") + app.include_router(settings_router, prefix="/api") + return app + + +app = create_app() + + +def main() -> None: + uvicorn.run( + "sentinel_orbit_downloader.api.app:app", + host=configured_api_host(), + port=configured_api_port(), + reload=False, + ) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/deps.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/deps.py new file mode 100644 index 0000000..7ffb4db --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/deps.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pathlib import Path + +from sentinel_orbit_downloader.core.config import configured_db_path + + +def get_db_path() -> Path: + return configured_db_path() diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_downloads.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_downloads.py new file mode 100644 index 0000000..6addca0 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_downloads.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import DownloadJobCreateRequest, DownloadJobCreateResponse, JobSummary +from sentinel_orbit_downloader.services.asf_client import credentials_available +from sentinel_orbit_downloader.services.download_manager import DownloadJobRequest, execute_download_job, queue_download_job +from sentinel_orbit_downloader.services.project_store import get_job +from sentinel_orbit_downloader.services.settings_store import get_saved_earthdata_credentials + + +router = APIRouter(prefix="/download-jobs", tags=["download-jobs"]) + + +@router.post("", response_model=DownloadJobCreateResponse, status_code=status.HTTP_202_ACCEPTED) +def create_download_job_endpoint( + request: DownloadJobCreateRequest, + background_tasks: BackgroundTasks, + db_path=Depends(get_db_path), +) -> DownloadJobCreateResponse: + job = get_job(db_path, request.job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job not found: {request.job_id}") + + saved_username, saved_password = get_saved_earthdata_credentials(db_path) + resolved_username = request.username or saved_username + resolved_password = request.password or saved_password + + if not request.skip_data and not credentials_available(resolved_username, resolved_password): + raise HTTPException( + status_code=400, + detail=( + "下载 Sentinel-1 原始产品需要 Earthdata 用户名和密码。" + "请在页面填写 Earthdata 用户名/密码,或先保存到本地配置,或先设置 EARTHDATA_USER 和 EARTHDATA_PASS。" + "如果这次只想补轨道,请把“跳过产品”设为“是”。" + ), + ) + + manager_request = DownloadJobRequest( + job_id=request.job_id, + data_dir=Path(request.data_dir) if request.data_dir else None, + orbit_dir=Path(request.orbit_dir) if request.orbit_dir else None, + manifest_path=Path(request.manifest_path) if request.manifest_path else None, + skip_data=request.skip_data, + skip_orbits=request.skip_orbits, + processes=request.processes, + ignore_orbit_errors=request.ignore_orbit_errors, + username=resolved_username, + password=resolved_password, + ) + + try: + queue_download_job(db_path, manager_request) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + background_tasks.add_task(execute_download_job, db_path, manager_request) + queued_job = get_job(db_path, request.job_id) + if queued_job is None: + raise HTTPException(status_code=500, detail=f"Job disappeared after queueing: {request.job_id}") + return DownloadJobCreateResponse( + message="Download job queued", + job=JobSummary.model_validate(queued_job), + ) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py new file mode 100644 index 0000000..99cbdb7 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import HealthResponse + + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +def health(db_path=Depends(get_db_path)) -> HealthResponse: + return HealthResponse(status="ok", db_path=str(db_path)) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_jobs.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_jobs.py new file mode 100644 index 0000000..7b82042 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_jobs.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ( + JobDeleteResponse, + JobDetailResponse, + JobEventResponse, + JobSceneResponse, + JobSummary, +) +from sentinel_orbit_downloader.services.project_store import ( + clear_jobs, + delete_job, + get_job, + list_job_events, + list_job_scenes, + list_jobs, +) + + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.get("", response_model=list[JobSummary]) +def jobs( + project_id: int | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + db_path=Depends(get_db_path), +) -> list[JobSummary]: + return [JobSummary.model_validate(job) for job in list_jobs(db_path, project_id=project_id, limit=limit)] + + +@router.delete("", response_model=JobDeleteResponse) +def clear_jobs_endpoint( + project_id: int | None = Query(default=None), + force: bool = Query(default=False), + db_path=Depends(get_db_path), +) -> JobDeleteResponse: + deleted_job_ids = clear_jobs(db_path, project_id=project_id, include_running=force) + return JobDeleteResponse( + message="任务已清空" if deleted_job_ids else "没有可清空的任务", + deleted_count=len(deleted_job_ids), + deleted_job_ids=deleted_job_ids, + ) + + +@router.get("/{job_id}", response_model=JobDetailResponse) +def job_detail( + job_id: int, + include_events: bool = Query(default=False), + db_path=Depends(get_db_path), +) -> JobDetailResponse: + job = get_job(db_path, job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + scenes = [JobSceneResponse(**scene) for scene in list_job_scenes(db_path, job_id)] + events = [] + if include_events: + events = [JobEventResponse(**event) for event in list_job_events(db_path, job_id)] + return JobDetailResponse( + job=JobSummary.model_validate(job), + scenes=scenes, + events=events, + ) + + +@router.delete("/{job_id}", response_model=JobDeleteResponse, status_code=status.HTTP_200_OK) +def delete_job_endpoint( + job_id: int, + force: bool = Query(default=False), + db_path=Depends(get_db_path), +) -> JobDeleteResponse: + job = get_job(db_path, job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + if job.status == "running" and not force: + raise HTTPException( + status_code=409, + detail="任务仍在运行。请先停止服务后再删,或使用强制删除。", + ) + deleted = delete_job(db_path, job_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"Job not found: {job_id}") + return JobDeleteResponse( + message="任务已删除", + deleted_count=1, + deleted_job_ids=[job_id], + ) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_projects.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_projects.py new file mode 100644 index 0000000..95b1a80 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_projects.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ProjectCreateRequest, ProjectResponse +from sentinel_orbit_downloader.services.project_store import create_project, get_project, list_projects + + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.get("", response_model=list[ProjectResponse]) +def projects(db_path=Depends(get_db_path)) -> list[ProjectResponse]: + return [ProjectResponse.model_validate(project) for project in list_projects(db_path)] + + +@router.post("", response_model=ProjectResponse) +def create_project_endpoint( + request: ProjectCreateRequest, + db_path=Depends(get_db_path), +) -> ProjectResponse: + try: + project = create_project( + db_path, + name=request.name, + data_root=Path(request.data_root), + description=request.description, + slug=request.slug, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return ProjectResponse.model_validate(project) + + +@router.get("/{project_id}", response_model=ProjectResponse) +def project_detail(project_id: int, db_path=Depends(get_db_path)) -> ProjectResponse: + project = get_project(db_path, project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project not found: {project_id}") + return ProjectResponse.model_validate(project) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_regions.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_regions.py new file mode 100644 index 0000000..35b6e8e --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_regions.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Query + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ( + RegionDetail, + RegionImportRequest, + RegionImportResponse, + RegionSummary, +) +from sentinel_orbit_downloader.api.serializers import region_detail, region_summary +from sentinel_orbit_downloader.services.region_catalog import ( + get_region, + import_regions, + search_regions, +) + + +router = APIRouter(prefix="/regions", tags=["regions"]) + + +@router.post("/import", response_model=RegionImportResponse) +def import_region_catalog( + request: RegionImportRequest, + db_path=Depends(get_db_path), +) -> RegionImportResponse: + source_path = Path(request.source_path) + if not source_path.exists(): + raise HTTPException(status_code=404, detail=f"GeoJSON file not found: {source_path}") + stats = import_regions(db_path, source_path, simplify_tolerance=request.simplify_tolerance) + return RegionImportResponse( + source_path=str(stats.source_path), + simplify_tolerance=stats.simplify_tolerance, + total_features=stats.total_features, + imported=stats.imported, + ) + + +@router.get("/search", response_model=list[RegionSummary]) +def search_region_catalog( + q: str = Query(..., min_length=1), + level: str | None = Query(default=None), + limit: int = Query(default=20, ge=1, le=200), + db_path=Depends(get_db_path), +) -> list[RegionSummary]: + return [ + region_summary(region) + for region in search_regions(db_path, q, limit=limit, level=level) + ] + + +@router.get("/{adcode}", response_model=RegionDetail) +def get_region_detail(adcode: str, db_path=Depends(get_db_path)) -> RegionDetail: + region = get_region(db_path, adcode) + if region is None: + raise HTTPException(status_code=404, detail=f"Region not found: {adcode}") + return region_detail(region) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py new file mode 100644 index 0000000..7f62197 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +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.services.asf_client import SearchParams, query_scenes +from sentinel_orbit_downloader.services.project_store import ( + create_search_job, + get_job, + get_project, + list_job_scenes, + record_job_event, + save_query_results, + update_search_job, +) +from sentinel_orbit_downloader.services.region_catalog import get_region, region_wkt + + +router = APIRouter(prefix="/search-jobs", tags=["search-jobs"]) + + +@router.post("", response_model=SearchJobCreateResponse) +def create_search_job_endpoint( + request: SearchJobCreateRequest, + db_path=Depends(get_db_path), +) -> SearchJobCreateResponse: + if request.project_id is not None and get_project(db_path, request.project_id) is None: + raise HTTPException(status_code=404, detail=f"Project not found: {request.project_id}") + + try: + aoi_meta = resolve_search_aoi(request, db_path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + job = create_search_job( + db_path, + project_id=request.project_id, + source_type=aoi_meta["source_type"], + source_value=aoi_meta["source_value"], + region_adcode=aoi_meta["region_adcode"], + region_name=aoi_meta["region_name"], + start_time=request.start, + end_time=request.end, + processing_level=request.processing_level, + beam_mode=request.beam_mode, + flight_direction=request.flight_direction.upper() if request.flight_direction else None, + polarization=request.polarization, + relative_orbit=request.relative_orbit, + max_results=request.max_results, + manifest_path=request.manifest_path, + ) + record_job_event(db_path, job.id, "info", "API search job created") + + try: + params = SearchParams( + aoi_wkt=aoi_meta["aoi_wkt"], + start=request.start, + end=request.end, + processing_level=request.processing_level, + beam_mode=request.beam_mode, + flight_direction=request.flight_direction, + polarization=request.polarization, + relative_orbit=request.relative_orbit, + max_results=request.max_results, + ) + results = query_scenes(params) + save_query_results(db_path, job.id, results) + update_search_job( + db_path, + job.id, + status="succeeded", + result_count=len(results), + manifest_path=request.manifest_path, + ) + record_job_event(db_path, job.id, "info", f"API query returned {len(results)} scene(s)") + except Exception as exc: + update_search_job(db_path, job.id, status="failed", error_message=str(exc)) + record_job_event(db_path, job.id, "error", f"API search failed: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + final_job = get_job(db_path, job.id) + if final_job is None: + raise HTTPException(status_code=500, detail=f"Job disappeared after creation: {job.id}") + scenes = [JobSceneResponse(**scene) for scene in list_job_scenes(db_path, job.id)] + return SearchJobCreateResponse( + job=JobSummary.model_validate(final_job), + scenes=scenes, + ) + + +def resolve_search_aoi(request: SearchJobCreateRequest, db_path) -> dict[str, str | None]: + if request.bbox: + return { + "aoi_wkt": bbox_to_wkt(request.bbox), + "source_type": "bbox", + "source_value": request.bbox, + "region_adcode": None, + "region_name": None, + } + if request.wkt: + return { + "aoi_wkt": request.wkt.strip(), + "source_type": "wkt", + "source_value": request.wkt.strip(), + "region_adcode": None, + "region_name": None, + } + if request.region_adcode: + region = get_region(db_path, request.region_adcode) + if region is None: + raise ValueError(f"Region not found: {request.region_adcode}") + return { + "aoi_wkt": region_wkt(db_path, region.adcode, simplified=not request.raw_region), + "source_type": "region", + "source_value": region.label, + "region_adcode": region.adcode, + "region_name": region.label, + } + raise ValueError("one AOI input is required") diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_settings.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_settings.py new file mode 100644 index 0000000..c8124c2 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_settings.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status + +from sentinel_orbit_downloader.api.deps import get_db_path +from sentinel_orbit_downloader.api.schemas import ( + EarthdataSettingsRequest, + EarthdataSettingsResponse, + MessageResponse, +) +from sentinel_orbit_downloader.services.settings_store import ( + clear_earthdata_credentials, + get_earthdata_settings, + save_earthdata_credentials, +) + + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("/earthdata", response_model=EarthdataSettingsResponse) +def earthdata_settings(db_path=Depends(get_db_path)) -> EarthdataSettingsResponse: + settings = get_earthdata_settings(db_path) + return EarthdataSettingsResponse( + username=settings.username, + has_password=settings.has_password, + ) + + +@router.put("/earthdata", response_model=EarthdataSettingsResponse, status_code=status.HTTP_200_OK) +def save_earthdata_settings( + request: EarthdataSettingsRequest, + db_path=Depends(get_db_path), +) -> EarthdataSettingsResponse: + try: + settings = save_earthdata_credentials( + db_path, + username=request.username, + password=request.password, + ) + except (ValueError, RuntimeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return EarthdataSettingsResponse( + username=settings.username, + has_password=settings.has_password, + ) + + +@router.delete("/earthdata", response_model=MessageResponse) +def delete_earthdata_settings(db_path=Depends(get_db_path)) -> MessageResponse: + clear_earthdata_credentials(db_path) + return MessageResponse(message="Earthdata 凭据已清除") diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py new file mode 100644 index 0000000..67ab8bc --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class HealthResponse(BaseModel): + status: str + db_path: str + + +class RegionSummary(BaseModel): + adcode: str + name: str + level: str + parent_adcode: str | None = None + parent_name: str | None = None + province_adcode: str | None = None + province_name: str | None = None + label: str + bbox: list[float] | None = None + simplify_tolerance: float + + +class RegionDetail(RegionSummary): + center: list[float] | None = None + centroid: list[float] | None = None + geometry_json: dict[str, Any] + wkt: str + simplified_wkt: str + children_num: int | None = None + tree_id: str | None = None + + +class RegionImportRequest(BaseModel): + source_path: str = Field(default="\u5168\u56fd\u884c\u653f\u533a.geojson") + simplify_tolerance: float = Field(default=0.01, gt=0) + + +class RegionImportResponse(BaseModel): + source_path: str + simplify_tolerance: float + total_features: int + imported: int + + +class ProjectCreateRequest(BaseModel): + name: str + data_root: str + description: str | None = None + slug: str | None = None + + +class ProjectResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + slug: str + data_root: str + description: str | None = None + created_at: str + updated_at: str + + +class JobSummary(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int | None = None + source_type: str + source_value: str | None = None + region_adcode: str | None = None + region_name: str | None = None + start_time: str + end_time: str + processing_level: str + beam_mode: str | None = None + flight_direction: str | None = None + polarization: str | None = None + relative_orbit: int | None = None + max_results: int + status: str + result_count: int | None = None + data_dir: str | None = None + orbit_dir: str | None = None + manifest_path: str | None = None + error_message: str | None = None + created_at: str + finished_at: str | None = None + + +class JobSceneResponse(BaseModel): + job_id: int + scene_id: str + selected: int + product_status: str + orbit_status: str + product_path: str | None = None + orbit_path: str | None = None + platform: str | None = None + start_time: str | None = None + stop_time: str | None = None + processing_level: str | None = None + flight_direction: str | None = None + relative_orbit: int | None = None + size_mb: float | None = None + + +class JobEventResponse(BaseModel): + id: int + level: str + message: str + created_at: str + + +class JobDetailResponse(BaseModel): + job: JobSummary + scenes: list[JobSceneResponse] + events: list[JobEventResponse] = Field(default_factory=list) + + +class SearchJobCreateRequest(BaseModel): + project_id: int | None = None + bbox: str | None = None + wkt: str | None = None + region_adcode: str | None = None + start: str + end: str + processing_level: str = "SLC" + beam_mode: str | None = "IW" + flight_direction: str | None = None + polarization: str | None = None + relative_orbit: int | None = None + max_results: int = Field(default=50, ge=1) + manifest_path: str | None = None + raw_region: bool = False + + @model_validator(mode="after") + def validate_aoi(self) -> "SearchJobCreateRequest": + provided = [self.bbox, self.wkt, self.region_adcode] + count = sum(1 for value in provided if value) + if count != 1: + raise ValueError("provide exactly one of bbox, wkt, or region_adcode") + return self + + +class SearchJobCreateResponse(BaseModel): + job: JobSummary + scenes: list[JobSceneResponse] + + +class DownloadJobCreateRequest(BaseModel): + job_id: int = Field(ge=1) + data_dir: str | None = None + orbit_dir: str | None = None + manifest_path: str | None = None + skip_data: bool = False + skip_orbits: bool = False + processes: int = Field(default=2, ge=1) + ignore_orbit_errors: bool = False + username: str | None = None + password: str | None = None + + @model_validator(mode="after") + def validate_download_flags(self) -> "DownloadJobCreateRequest": + if self.skip_data and self.skip_orbits: + raise ValueError("skip_data and skip_orbits cannot both be true") + return self + + +class DownloadJobCreateResponse(BaseModel): + message: str + job: JobSummary + + +class MessageResponse(BaseModel): + message: str + + +class EarthdataSettingsRequest(BaseModel): + username: str + password: str + + +class EarthdataSettingsResponse(BaseModel): + username: str | None = None + has_password: bool = False + + +class JobDeleteResponse(BaseModel): + message: str + deleted_count: int + deleted_job_ids: list[int] = Field(default_factory=list) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/serializers.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/serializers.py new file mode 100644 index 0000000..97d26ae --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/serializers.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json + +from sentinel_orbit_downloader.api.schemas import RegionDetail, RegionSummary + + +def region_summary(region) -> RegionSummary: + bbox = list(region.bbox) if region.bbox is not None else None + return RegionSummary( + adcode=region.adcode, + name=region.name, + level=region.level, + parent_adcode=region.parent_adcode, + parent_name=region.parent_name, + province_adcode=region.province_adcode, + province_name=region.province_name, + label=region.label, + bbox=bbox, + simplify_tolerance=region.simplify_tolerance, + ) + + +def region_detail(region) -> RegionDetail: + summary = region_summary(region) + center = ( + [region.center_lon, region.center_lat] + if region.center_lon is not None and region.center_lat is not None + else None + ) + centroid = ( + [region.centroid_lon, region.centroid_lat] + if region.centroid_lon is not None and region.centroid_lat is not None + else None + ) + return RegionDetail( + **summary.model_dump(), + center=center, + centroid=centroid, + geometry_json=json.loads(region.geometry_json), + wkt=region.wkt, + simplified_wkt=region.simplified_wkt, + children_num=region.children_num, + tree_id=region.tree_id, + ) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py new file mode 100644 index 0000000..9c626d7 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py @@ -0,0 +1,815 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Sequence + +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, + write_manifest, +) +from sentinel_orbit_downloader.services.orbit_client import ( + collect_orbit_scenes, + download_orbits, + scene_names_from_results, +) +from sentinel_orbit_downloader.services.project_store import ( + create_project, + create_search_job, + get_job, + get_project, + list_job_events, + list_job_scenes, + list_jobs, + list_projects, + record_download_result, + record_job_event, + save_query_results, + update_search_job, +) +from sentinel_orbit_downloader.services.region_catalog import ( + get_region, + import_regions, + region_wkt, + search_regions, +) + + +DEFAULT_REGION_FILE = Path("\u5168\u56fd\u884c\u653f\u533a.geojson") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except KeyboardInterrupt: + print("Interrupted.", file=sys.stderr) + return 130 + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="s1-dl", + description="Query and download Sentinel-1 scenes plus matching orbit files.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + search_parser = subparsers.add_parser("search", help="query Sentinel-1 scenes") + add_database_arg(search_parser) + add_project_arg(search_parser) + add_query_args(search_parser) + add_manifest_arg(search_parser) + search_parser.set_defaults(func=cmd_search) + + download_parser = subparsers.add_parser( + "download", + help="query scenes, download products, and fetch matching orbit files", + ) + add_database_arg(download_parser) + add_project_arg(download_parser) + add_query_args(download_parser) + download_parser.add_argument( + "--out", + type=Path, + default=Path("downloads"), + help="output root directory; data and orbits subdirectories are created here", + ) + download_parser.add_argument( + "--data-dir", + type=Path, + help="override product download directory; default is /data", + ) + download_parser.add_argument( + "--orbit-dir", + type=Path, + help="override orbit download directory; default is /orbits", + ) + download_parser.add_argument( + "--username", + help="NASA Earthdata username; defaults to EARTHDATA_USER or EARTHDATA_USERNAME", + ) + download_parser.add_argument( + "--password", + help="NASA Earthdata password; defaults to EARTHDATA_PASS or EARTHDATA_PASSWORD", + ) + download_parser.add_argument( + "--ask-password", + action="store_true", + help="prompt for the Earthdata password instead of reading an argument/env var", + ) + download_parser.add_argument( + "--processes", + type=positive_int, + default=2, + help="parallel ASF download processes", + ) + download_parser.add_argument( + "--skip-data", + action="store_true", + help="do not download product zip files; useful for orbit-only runs from query results", + ) + download_parser.add_argument( + "--skip-orbits", + action="store_true", + help="do not download matching orbit files", + ) + download_parser.add_argument( + "--ignore-orbit-errors", + action="store_true", + help="return success even if some orbit files cannot be found", + ) + add_manifest_arg(download_parser) + download_parser.set_defaults(func=cmd_download) + + orbit_parser = subparsers.add_parser( + "orbits", + help="download orbit files for existing scenes or local .zip/.SAFE products", + ) + scene_group = orbit_parser.add_mutually_exclusive_group(required=True) + scene_group.add_argument( + "--scene", + action="append", + help="Sentinel-1 scene name; may be repeated", + ) + scene_group.add_argument( + "--scene-file", + type=Path, + help="text file containing one scene name per line", + ) + scene_group.add_argument( + "--data-dir", + type=Path, + help="directory containing Sentinel-1 .zip files or .SAFE folders", + ) + orbit_parser.add_argument( + "--out", + type=Path, + default=Path("orbits"), + help="orbit output directory", + ) + orbit_parser.add_argument( + "--ignore-errors", + action="store_true", + help="return success even if some orbit files cannot be found", + ) + orbit_parser.set_defaults(func=cmd_orbits) + + regions_parser = subparsers.add_parser( + "regions", + help="import and query administrative boundary regions", + ) + add_database_arg(regions_parser) + region_subparsers = regions_parser.add_subparsers(dest="regions_command", required=True) + + regions_import = region_subparsers.add_parser("import", help="import a GeoJSON region catalog") + add_database_arg(regions_import) + regions_import.add_argument( + "source", + nargs="?", + type=Path, + default=DEFAULT_REGION_FILE, + help=f"GeoJSON source file; default is .\\{DEFAULT_REGION_FILE.name}", + ) + regions_import.add_argument( + "--simplify-tolerance", + type=float, + default=0.01, + help="Shapely simplify tolerance used to derive query WKT", + ) + regions_import.set_defaults(func=cmd_regions_import) + + regions_search_parser = region_subparsers.add_parser("search", help="search regions by name or adcode") + add_database_arg(regions_search_parser) + regions_search_parser.add_argument("query", help="query text, for example mudanjiang or 231000") + regions_search_parser.add_argument( + "--limit", + type=positive_int, + default=20, + help="maximum number of matches to return", + ) + regions_search_parser.add_argument( + "--level", + choices=("country", "province", "city", "district"), + help="optional level filter", + ) + regions_search_parser.set_defaults(func=cmd_regions_search) + + regions_export_parser = region_subparsers.add_parser("export", help="export region geometry or metadata") + add_database_arg(regions_export_parser) + regions_export_parser.add_argument("adcode", help="region adcode") + regions_export_parser.add_argument( + "--format", + choices=("wkt", "geojson", "bbox", "json"), + default="wkt", + help="output format", + ) + regions_export_parser.add_argument( + "--raw", + action="store_true", + help="use the original geometry instead of simplified WKT/json", + ) + regions_export_parser.add_argument( + "--out", + type=Path, + help="optional file to write the exported value to", + ) + regions_export_parser.set_defaults(func=cmd_regions_export) + + projects_parser = subparsers.add_parser("projects", help="manage local projects") + add_database_arg(projects_parser) + project_subparsers = projects_parser.add_subparsers(dest="projects_command", required=True) + + projects_create = project_subparsers.add_parser("create", help="create a project") + add_database_arg(projects_create) + projects_create.add_argument("--name", required=True, help="project name") + projects_create.add_argument("--data-root", type=Path, required=True, help="project data root") + projects_create.add_argument("--description", help="optional description") + projects_create.add_argument("--slug", help="optional directory-friendly slug") + projects_create.set_defaults(func=cmd_projects_create) + + projects_list_parser = project_subparsers.add_parser("list", help="list projects") + add_database_arg(projects_list_parser) + projects_list_parser.set_defaults(func=cmd_projects_list) + + projects_show_parser = project_subparsers.add_parser("show", help="show one project") + add_database_arg(projects_show_parser) + projects_show_parser.add_argument("project_id", type=positive_int, help="project id") + projects_show_parser.set_defaults(func=cmd_projects_show) + + jobs_parser = subparsers.add_parser("jobs", help="inspect saved query and download jobs") + add_database_arg(jobs_parser) + jobs_subparsers = jobs_parser.add_subparsers(dest="jobs_command", required=True) + + jobs_list_parser = jobs_subparsers.add_parser("list", help="list recent jobs") + add_database_arg(jobs_list_parser) + jobs_list_parser.add_argument("--project-id", type=positive_int, help="optional project filter") + jobs_list_parser.add_argument("--limit", type=positive_int, default=20, help="maximum jobs to list") + jobs_list_parser.set_defaults(func=cmd_jobs_list) + + jobs_show_parser = jobs_subparsers.add_parser("show", help="show one job and its scenes") + add_database_arg(jobs_show_parser) + jobs_show_parser.add_argument("job_id", type=positive_int, help="job id") + jobs_show_parser.add_argument( + "--events", + action="store_true", + help="include stored job events", + ) + jobs_show_parser.set_defaults(func=cmd_jobs_show) + + return parser + + +def add_database_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--db", + type=Path, + default=DEFAULT_DB_PATH, + help=f"SQLite database path; default is {DEFAULT_DB_PATH}", + ) + + +def add_project_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--project-id", + type=positive_int, + help="optional project id used to group saved jobs", + ) + + +def add_query_args(parser: argparse.ArgumentParser) -> None: + aoi = parser.add_mutually_exclusive_group(required=True) + aoi.add_argument("--bbox", help="AOI bounding box as min_lon,min_lat,max_lon,max_lat") + aoi.add_argument("--wkt", help="AOI as WKT, for example POLYGON((...))") + aoi.add_argument( + "--aoi-file", + type=Path, + help="AOI file containing WKT, GeoJSON Feature, or GeoJSON geometry", + ) + aoi.add_argument( + "--region-adcode", + help="AOI from imported region catalog; use `s1-dl regions search` first", + ) + parser.add_argument( + "--raw-region", + action="store_true", + help="when using --region-adcode, use the original geometry instead of simplified WKT", + ) + parser.add_argument("--start", required=True, help="start date/time, e.g. 2024-01-01") + parser.add_argument("--end", required=True, help="end date/time, e.g. 2024-02-01") + parser.add_argument( + "--processing-level", + default="SLC", + help="ASF processing level, e.g. SLC, GRD_HD, GRD_MD, OCN", + ) + parser.add_argument("--beam-mode", default="IW", help="beam mode, e.g. IW, EW, WV") + parser.add_argument( + "--flight-direction", + choices=("ASCENDING", "DESCENDING", "ascending", "descending"), + help="optional orbit direction filter", + ) + parser.add_argument("--polarization", help="optional polarization filter, e.g. VV+VH") + parser.add_argument( + "--relative-orbit", + type=positive_int, + help="optional Sentinel-1 relative orbit/track number", + ) + parser.add_argument( + "--max-results", + type=positive_int, + default=50, + help="maximum scenes to return", + ) + + +def add_manifest_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--manifest", type=Path, help="write search results to .csv or .json") + + +def cmd_search(args: argparse.Namespace) -> int: + ensure_project_exists(args.db, args.project_id) + aoi_meta = resolve_aoi(args) + job = create_search_job( + args.db, + project_id=args.project_id, + source_type=aoi_meta["source_type"], + source_value=aoi_meta["source_value"], + region_adcode=aoi_meta["region_adcode"], + region_name=aoi_meta["region_name"], + start_time=args.start, + end_time=args.end, + processing_level=args.processing_level, + beam_mode=args.beam_mode, + flight_direction=args.flight_direction.upper() if args.flight_direction else None, + polarization=args.polarization, + relative_orbit=args.relative_orbit, + max_results=args.max_results, + manifest_path=str(args.manifest) if args.manifest else None, + ) + record_job_event(args.db, job.id, "info", "Query created") + + try: + results = perform_query(args, aoi_meta["aoi_wkt"]) + save_query_results(args.db, job.id, results) + print_results(results) + if args.manifest: + write_manifest(results, args.manifest) + print(f"Wrote manifest: {args.manifest}") + update_search_job( + args.db, + job.id, + status="succeeded", + result_count=len(results), + manifest_path=str(args.manifest) if args.manifest else None, + ) + record_job_event(args.db, job.id, "info", f"Query returned {len(results)} scene(s)") + except Exception as exc: + update_search_job(args.db, job.id, status="failed", error_message=str(exc)) + record_job_event(args.db, job.id, "error", f"Query failed: {exc}") + raise + + print(f"Saved job: {job.id}") + return 0 + + +def cmd_download(args: argparse.Namespace) -> int: + ensure_project_exists(args.db, args.project_id) + aoi_meta = resolve_aoi(args) + + out_root = args.out + data_dir = args.data_dir or out_root / "data" + orbit_dir = args.orbit_dir or out_root / "orbits" + out_root.mkdir(parents=True, exist_ok=True) + manifest = args.manifest or out_root / "manifest.csv" + + job = create_search_job( + args.db, + project_id=args.project_id, + source_type=aoi_meta["source_type"], + source_value=aoi_meta["source_value"], + region_adcode=aoi_meta["region_adcode"], + region_name=aoi_meta["region_name"], + start_time=args.start, + end_time=args.end, + processing_level=args.processing_level, + beam_mode=args.beam_mode, + flight_direction=args.flight_direction.upper() if args.flight_direction else None, + polarization=args.polarization, + relative_orbit=args.relative_orbit, + max_results=args.max_results, + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + ) + record_job_event(args.db, job.id, "info", "Download job created") + + try: + 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)") + + print_results(results) + write_manifest(results, manifest) + print(f"Wrote manifest: {manifest}") + + if not results: + update_search_job( + args.db, + job.id, + status="succeeded", + result_count=0, + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + ) + record_job_event(args.db, job.id, "info", "No scenes matched the query") + print(f"Saved job: {job.id}") + return 0 + + if not args.skip_data: + data_dir.mkdir(parents=True, exist_ok=True) + session = build_session( + 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), + ) + + print(f"Saved job: {job.id}") + if orbit_failures and not args.ignore_orbit_errors: + return 1 + return 0 + except Exception as exc: + update_search_job( + args.db, + job.id, + status="failed", + result_count=None, + data_dir=str(data_dir), + orbit_dir=str(orbit_dir), + manifest_path=str(manifest), + error_message=str(exc), + ) + record_job_event(args.db, job.id, "error", f"Download job failed: {exc}") + raise + + +def cmd_orbits(args: argparse.Namespace) -> int: + scenes = collect_orbit_scenes( + scenes=args.scene, + scene_file=args.scene_file, + data_dir=args.data_dir, + ) + if not scenes: + print("No Sentinel-1 scenes found.", file=sys.stderr) + return 1 + + print(f"Downloading orbit files for {len(scenes)} scene(s) to {args.out} ...") + results = download_orbits(scenes, args.out) + failures = summarize_orbit_results(results) + if failures and not args.ignore_errors: + return 1 + return 0 + + +def cmd_regions_import(args: argparse.Namespace) -> int: + if not args.source.exists(): + raise ValueError(f"GeoJSON file does not exist: {args.source}") + stats = import_regions(args.db, args.source, simplify_tolerance=args.simplify_tolerance) + print( + "Imported region catalog: " + f"{stats.imported}/{stats.total_features} features " + f"from {stats.source_path} with simplify_tolerance={stats.simplify_tolerance}" + ) + return 0 + + +def cmd_regions_search(args: argparse.Namespace) -> int: + matches = search_regions(args.db, args.query, limit=args.limit, level=args.level) + if not matches: + print("No regions matched.") + return 0 + + for region in matches: + bbox_text = "" + if region.bbox is not None: + bbox_text = ",".join(f"{value:.6f}" for value in region.bbox) + print(f"{region.adcode} | {region.level:<8} | {region.label} | {bbox_text}") + return 0 + + +def cmd_regions_export(args: argparse.Namespace) -> int: + region = get_region(args.db, args.adcode) + if region is None: + raise ValueError(f"region not found: {args.adcode}") + + if args.format == "wkt": + output = region.wkt if args.raw else region.simplified_wkt + elif args.format == "geojson": + output = region.geometry_json + elif args.format == "bbox": + if region.bbox is None: + raise ValueError(f"region has no bbox: {args.adcode}") + output = ",".join(str(value) for value in region.bbox) + else: + payload: dict[str, Any] = { + "adcode": region.adcode, + "name": region.name, + "level": region.level, + "parent_adcode": region.parent_adcode, + "parent_name": region.parent_name, + "province_adcode": region.province_adcode, + "province_name": region.province_name, + "bbox": region.bbox, + "simplify_tolerance": region.simplify_tolerance, + "label": region.label, + } + output = json.dumps(payload, ensure_ascii=False, indent=2) + + if args.out: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(output, encoding="utf-8") + print(f"Wrote region export: {args.out}") + else: + print(output) + return 0 + + +def cmd_projects_create(args: argparse.Namespace) -> int: + project = create_project( + args.db, + name=args.name, + data_root=args.data_root, + description=args.description, + slug=args.slug, + ) + print(f"Created project: {project.id} | {project.slug} | {project.data_root}") + return 0 + + +def cmd_projects_list(args: argparse.Namespace) -> int: + projects = list_projects(args.db) + if not projects: + print("No projects found.") + return 0 + for project in projects: + desc = f" | {project.description}" if project.description else "" + print(f"{project.id} | {project.slug} | {project.name} | {project.data_root}{desc}") + return 0 + + +def cmd_projects_show(args: argparse.Namespace) -> int: + project = get_project(args.db, args.project_id) + if project is None: + raise ValueError(f"project not found: {args.project_id}") + print(json.dumps(project.__dict__, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_jobs_list(args: argparse.Namespace) -> int: + jobs = list_jobs(args.db, project_id=args.project_id, limit=args.limit) + if not jobs: + print("No jobs found.") + return 0 + for job in jobs: + project_text = job.project_id if job.project_id is not None else "-" + target = job.region_name or job.source_value or job.source_type + print( + f"{job.id} | project={project_text} | {job.status:<14} | " + f"{job.start_time}..{job.end_time} | {target} | count={job.result_count}" + ) + return 0 + + +def cmd_jobs_show(args: argparse.Namespace) -> int: + job = get_job(args.db, args.job_id) + if job is None: + raise ValueError(f"job not found: {args.job_id}") + + print(json.dumps(job.__dict__, ensure_ascii=False, indent=2)) + scenes = list_job_scenes(args.db, args.job_id) + if scenes: + print("\nScenes:") + for scene in scenes: + size_text = f"{scene['size_mb']:.1f} MB" if scene["size_mb"] is not None else "" + print( + f" {scene['scene_id']} | {scene['start_time']} | {scene['processing_level']} | " + f"product={scene['product_status']} | orbit={scene['orbit_status']} | {size_text}" + ) + if args.events: + events = list_job_events(args.db, args.job_id) + if events: + print("\nEvents:") + for event in events: + print(f" [{event['created_at']}] {event['level']}: {event['message']}") + return 0 + + +def perform_query(args: argparse.Namespace, aoi_wkt: str): + params = SearchParams( + aoi_wkt=aoi_wkt, + start=args.start, + end=args.end, + processing_level=args.processing_level, + beam_mode=args.beam_mode, + flight_direction=args.flight_direction, + polarization=args.polarization, + relative_orbit=args.relative_orbit, + max_results=args.max_results, + ) + print("Querying ASF Search ...") + return query_scenes(params) + + +def resolve_aoi(args: argparse.Namespace) -> dict[str, Any]: + if args.bbox: + return { + "aoi_wkt": bbox_to_wkt(args.bbox), + "source_type": "bbox", + "source_value": args.bbox, + "region_adcode": None, + "region_name": None, + } + if args.wkt: + return { + "aoi_wkt": args.wkt.strip(), + "source_type": "wkt", + "source_value": args.wkt.strip(), + "region_adcode": None, + "region_name": None, + } + if args.aoi_file: + return { + "aoi_wkt": aoi_file_to_wkt(args.aoi_file), + "source_type": "aoi_file", + "source_value": str(args.aoi_file), + "region_adcode": None, + "region_name": None, + } + if args.region_adcode: + region = get_region(args.db, args.region_adcode) + if region is None: + raise ValueError(f"region not found: {args.region_adcode}") + return { + "aoi_wkt": region_wkt(args.db, args.region_adcode, simplified=not args.raw_region), + "source_type": "region", + "source_value": region.label, + "region_adcode": region.adcode, + "region_name": region.label, + } + raise ValueError("one AOI input is required") + + +def print_results(results) -> None: + count = len(results) + print(f"Found {count} scene(s).") + if not count: + return + + for index, product in enumerate(results, start=1): + props = product.properties + scene = scene_name_from_product(product) or props.get("fileID") or "" + start = props.get("startTime") or "" + level = props.get("processingLevel") or "" + direction = props.get("flightDirection") or "" + rel_orbit = props.get("pathNumber") or "" + size = product_size_mb(props) + size_text = f"{size:.1f} MB" if size is not None else "" + print(f"{index:>3}. {scene} | {start} | {level} | {direction} | rel={rel_orbit} | {size_text}") + + +def record_product_downloads(db_path: Path, job_id: int, results, data_dir: Path | None) -> None: + for product in results: + scene_id = scene_name_from_product(product) + if scene_id is None: + continue + props = product.properties + if data_dir is None: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="skipped", + path=None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=0, + ) + continue + + expected = expected_product_path(product, data_dir) + status = "succeeded" if expected.exists() else "failed" + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status=status, + path=str(expected) if expected.exists() else None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=float(props["bytes"]) if expected.exists() and props.get("bytes") is not None else None, + error_message=None if expected.exists() else "Downloaded file not found at expected path", + ) + + +def record_orbit_downloads(db_path: Path, job_id: int, orbit_results) -> None: + for result in orbit_results: + record_download_result( + db_path, + job_id=job_id, + scene_id=result.scene, + kind="orbit", + status="succeeded" if result.error is None else "failed", + path=str(result.path) if result.path is not None else None, + error_message=result.error, + ) + + +def expected_product_path(product, data_dir: Path) -> Path: + props = product.properties + file_name = props.get("fileName") + if file_name: + return data_dir / str(file_name) + url = props.get("url") + if url: + return data_dir / Path(str(url)).name + scene = scene_name_from_product(product) or "unknown_scene" + return data_dir / f"{scene}.zip" + + +def summarize_orbit_results(results) -> int: + failures = [result for result in results if result.error] + for result in results: + if result.error: + print(f"Orbit failed: {result.scene}: {result.error}", file=sys.stderr) + else: + print(f"Orbit: {result.scene} -> {result.path.name}") + print(f"Orbit download finished: {len(results) - len(failures)} ok, {len(failures)} failed.") + return len(failures) + + +def ensure_project_exists(db_path: Path, project_id: int | None) -> None: + if project_id is None: + return + if get_project(db_path, project_id) is None: + raise ValueError(f"project not found: {project_id}") + + +def string_value(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def positive_int(value: str) -> int: + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError("must be >= 1") + return number diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/__init__.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/__init__.py new file mode 100644 index 0000000..7c16cea --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration and constants.""" diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py new file mode 100644 index 0000000..aaf6659 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +DEFAULT_DB_PATH = Path(".s1dl/app.db") +DEFAULT_API_HOST = "127.0.0.1" +DEFAULT_API_PORT = 8010 + + +def configured_db_path() -> Path: + raw = os.getenv("S1DL_DB_PATH") + if not raw: + return DEFAULT_DB_PATH + return Path(raw) + + +def configured_api_host() -> str: + return os.getenv("S1DL_API_HOST", DEFAULT_API_HOST) + + +def configured_api_port() -> int: + raw = os.getenv("S1DL_API_PORT") + if not raw: + return DEFAULT_API_PORT + return int(raw) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/provinces.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/provinces.py new file mode 100644 index 0000000..225e19f --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/provinces.py @@ -0,0 +1,40 @@ +from __future__ import annotations + + +PROVINCES: dict[str, str] = { + "110000": "北京市", + "120000": "天津市", + "130000": "河北省", + "140000": "山西省", + "150000": "内蒙古自治区", + "210000": "辽宁省", + "220000": "吉林省", + "230000": "黑龙江省", + "310000": "上海市", + "320000": "江苏省", + "330000": "浙江省", + "340000": "安徽省", + "350000": "福建省", + "360000": "江西省", + "370000": "山东省", + "410000": "河南省", + "420000": "湖北省", + "430000": "湖南省", + "440000": "广东省", + "450000": "广西壮族自治区", + "460000": "海南省", + "500000": "重庆市", + "510000": "四川省", + "520000": "贵州省", + "530000": "云南省", + "540000": "西藏自治区", + "610000": "陕西省", + "620000": "甘肃省", + "630000": "青海省", + "640000": "宁夏回族自治区", + "650000": "新疆维吾尔自治区", + "710000": "台湾省", + "810000": "香港特别行政区", + "820000": "澳门特别行政区", +} + diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/__init__.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/__init__.py new file mode 100644 index 0000000..a69ee20 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/__init__.py @@ -0,0 +1 @@ +"""Application services.""" diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py new file mode 100644 index 0000000..6edf331 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterable, Sequence + + +def bbox_to_wkt(value: str) -> str: + parts = [part.strip() for part in value.split(",")] + if len(parts) != 4: + raise ValueError("--bbox must be min_lon,min_lat,max_lon,max_lat") + try: + min_lon, min_lat, max_lon, max_lat = (float(part) for part in parts) + except ValueError as exc: + raise ValueError("--bbox values must be numbers") from exc + if min_lon >= max_lon or min_lat >= max_lat: + raise ValueError("--bbox min values must be smaller than max values") + return ( + f"POLYGON(({min_lon} {min_lat},{max_lon} {min_lat}," + f"{max_lon} {max_lat},{min_lon} {max_lat},{min_lon} {min_lat}))" + ) + + +def aoi_file_to_wkt(path: Path) -> str: + text = path.read_text(encoding="utf-8").strip() + if not text: + raise ValueError(f"AOI file is empty: {path}") + if text[0] != "{": + return text + + data = json.loads(text) + geometry = geojson_geometry(data) + return geometry_to_wkt(geometry) + + +def geojson_geometry(data: dict[str, Any]) -> dict[str, Any]: + 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]) + if kind == "Feature": + geometry = data.get("geometry") + if not geometry: + raise ValueError("GeoJSON Feature has no geometry") + 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 coords_to_text(coords: Iterable[Sequence[float]]) -> str: + return ",".join(f"{lon} {lat}" for lon, lat, *_ in coords) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/asf_client.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/asf_client.py new file mode 100644 index 0000000..5a7a3eb --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/asf_client.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import csv +import getpass +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +import asf_search as asf + + +DEFAULT_FIELDS = ( + "scene", + "startTime", + "stopTime", + "processingLevel", + "beamModeType", + "flightDirection", + "polarization", + "pathNumber", + "platform", + "sizeMB", +) + + +@dataclass(frozen=True) +class SearchParams: + aoi_wkt: str + start: str + end: str + processing_level: str = "SLC" + beam_mode: str | None = "IW" + flight_direction: str | None = None + polarization: str | None = None + relative_orbit: int | None = None + max_results: int = 50 + + +def query_scenes(params: SearchParams): + kwargs: dict[str, Any] = { + "platform": [asf.PLATFORM.SENTINEL1], + "processingLevel": params.processing_level, + "intersectsWith": params.aoi_wkt, + "start": params.start, + "end": params.end, + "maxResults": params.max_results, + } + if params.beam_mode: + kwargs["beamMode"] = params.beam_mode + if params.flight_direction: + kwargs["flightDirection"] = params.flight_direction.upper() + if params.polarization: + kwargs["polarization"] = params.polarization + if params.relative_orbit: + kwargs["relativeOrbit"] = params.relative_orbit + return asf.geo_search(**kwargs) + + +def resolve_credentials( + username: str | None = None, + password: str | None = None, +) -> tuple[str | None, str | None]: + resolved_username = username or os.getenv("EARTHDATA_USER") or os.getenv("EARTHDATA_USERNAME") + resolved_password = password or os.getenv("EARTHDATA_PASS") or os.getenv("EARTHDATA_PASSWORD") + return resolved_username, resolved_password + + +def credentials_available( + username: str | None = None, + password: str | None = None, +) -> bool: + resolved_username, resolved_password = resolve_credentials(username, password) + return bool(resolved_username and resolved_password) + + +def build_session( + username: str | None = None, + password: str | None = None, + ask_password: bool = False, +) -> asf.ASFSession: + username, password = resolve_credentials(username, password) + if ask_password: + password = getpass.getpass("Earthdata password: ") + + if not username or not password: + raise ValueError( + "ASF product downloads require NASA Earthdata credentials. " + "Set EARTHDATA_USER/EARTHDATA_PASS or pass --username/--ask-password." + ) + + return asf.ASFSession().auth_with_creds(username, password) + + +def product_size_mb(props: dict[str, Any]) -> float | None: + if props.get("sizeMB") is not None: + return float(props["sizeMB"]) + if props.get("bytes") is not None: + return float(props["bytes"]) / (1024 * 1024) + return None + + +def scene_name_from_product(product) -> str | None: + from .orbit_client import normalize_scene_name + + props = getattr(product, "properties", {}) + candidates: Sequence[Any] = ( + props.get("sceneName"), + props.get("fileID"), + props.get("fileName"), + props.get("url"), + ) + for candidate in candidates: + if not candidate: + continue + scene = normalize_scene_name(str(candidate)) + if scene: + return scene + return None + + +def manifest_row(product) -> dict[str, Any]: + props = product.properties + return { + "scene": scene_name_from_product(product), + "startTime": props.get("startTime"), + "stopTime": props.get("stopTime"), + "processingLevel": props.get("processingLevel"), + "beamModeType": props.get("beamModeType"), + "flightDirection": props.get("flightDirection"), + "polarization": props.get("polarization"), + "pathNumber": props.get("pathNumber"), + "platform": props.get("platform"), + "sizeMB": product_size_mb(props), + } + + +def write_manifest(results, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + suffix = path.suffix.lower() + rows = [manifest_row(product) for product in results] + if suffix == ".json": + path.write_text(json.dumps(rows, indent=2, ensure_ascii=False, default=str), encoding="utf-8") + return + if suffix not in {"", ".csv"}: + raise ValueError("manifest path must end with .csv or .json") + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=DEFAULT_FIELDS) + writer.writeheader() + writer.writerows(rows) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py new file mode 100644 index 0000000..79bbfb5 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +import asf_search as asf + +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 +from sentinel_orbit_downloader.services.project_store import ( + get_job, + get_project, + list_job_scenes, + record_download_result, + record_job_event, + update_search_job, +) + + +@dataclass(frozen=True) +class DownloadJobRequest: + job_id: int + data_dir: Path | None = None + orbit_dir: Path | None = None + manifest_path: Path | None = None + skip_data: bool = False + skip_orbits: bool = False + processes: int = 2 + ignore_orbit_errors: bool = False + username: str | None = None + password: str | None = None + + +@dataclass(frozen=True) +class DownloadJobPaths: + root: Path + data_dir: Path + orbit_dir: Path + manifest_path: Path + + +def resolve_download_paths(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths: + job = get_job(db_path, request.job_id) + if job is None: + raise ValueError(f"Job not found: {request.job_id}") + + root = None + if request.data_dir is not None: + root = request.data_dir.parent if request.orbit_dir is None else request.data_dir.parent + elif request.orbit_dir is not None: + root = request.orbit_dir.parent + elif job.data_dir: + root = Path(job.data_dir).parent + elif job.project_id is not None: + project = get_project(db_path, job.project_id) + if project is not None: + root = Path(project.data_root) / "jobs" / f"job-{job.id:06d}" + if root is None: + root = Path(".s1dl") / "downloads" / f"job-{job.id:06d}" + + data_dir = request.data_dir or (Path(job.data_dir) if job.data_dir else root / "data") + orbit_dir = request.orbit_dir or (Path(job.orbit_dir) if job.orbit_dir else root / "orbits") + manifest_path = request.manifest_path or ( + Path(job.manifest_path) if job.manifest_path else root / "manifest.csv" + ) + return DownloadJobPaths(root=root, data_dir=data_dir, orbit_dir=orbit_dir, manifest_path=manifest_path) + + +def queue_download_job(db_path: Path, request: DownloadJobRequest) -> DownloadJobPaths: + job = get_job(db_path, request.job_id) + if job is None: + raise ValueError(f"Job not found: {request.job_id}") + scenes = list_job_scenes(db_path, request.job_id) + if not scenes: + raise ValueError(f"Job has no scenes to download: {request.job_id}") + + paths = resolve_download_paths(db_path, request) + paths.root.mkdir(parents=True, exist_ok=True) + update_search_job( + db_path, + request.job_id, + status="running", + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=None, + ) + record_job_event( + db_path, + request.job_id, + "info", + f"Download queued for {len(scenes)} scene(s) into {paths.root}", + ) + return paths + + +def execute_download_job(db_path: Path, request: DownloadJobRequest) -> None: + job = get_job(db_path, request.job_id) + if job is None: + return + + paths = resolve_download_paths(db_path, request) + paths.root.mkdir(parents=True, exist_ok=True) + + try: + scenes = list_job_scenes(db_path, request.job_id) + scene_ids = [scene["scene_id"] for scene in scenes] + if not scene_ids: + update_search_job( + db_path, + request.job_id, + status="failed", + result_count=0, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message="No scenes available for download", + ) + record_job_event(db_path, request.job_id, "error", "Download job failed: no scenes available") + return + + if not request.skip_data and not credentials_available(request.username, request.password): + message = ( + "Download failed: Sentinel-1 product downloads require NASA Earthdata credentials. " + "Fill in Earthdata username/password, or set EARTHDATA_USER and EARTHDATA_PASS first." + ) + for scene_id in scene_ids: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="product", + status="failed", + path=None, + error_message=message, + ) + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="orbit", + status="skipped", + path=None, + ) + update_search_job( + db_path, + request.job_id, + status="failed", + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=message, + ) + record_job_event(db_path, request.job_id, "error", message) + return + + product_error: str | None = None + if request.skip_data: + record_job_event(db_path, request.job_id, "info", "Product download skipped by request") + 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} + 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: + record_job_event( + db_path, + request.job_id, + "warning", + f"{len(missing_scene_ids)} scene(s) could not be reloaded from ASF search", + ) + for scene_id in missing_scene_ids: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="product", + status="failed", + path=None, + error_message="Scene was not returned by ASF granule_search", + ) + if request.skip_orbits: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=scene_id, + kind="orbit", + status="skipped", + 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) + record_job_event( + db_path, + request.job_id, + "info", + f"Product download finished into {paths.data_dir}", + ) + 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: + record_job_event(db_path, request.job_id, "info", "Orbit download skipped by request") + record_orbit_skips(db_path, request.job_id, scene_ids) + else: + paths.orbit_dir.mkdir(parents=True, exist_ok=True) + orbit_results = download_orbits(scene_ids, paths.orbit_dir) + orbit_failures = sum(1 for result in orbit_results if result.error) + for result in orbit_results: + record_download_result( + db_path, + job_id=request.job_id, + scene_id=result.scene, + kind="orbit", + status="succeeded" if result.error is None else "failed", + path=str(result.path) if result.path is not None else None, + error_message=result.error, + ) + record_job_event( + db_path, + request.job_id, + "info", + f"Orbit download finished: {len(orbit_results) - orbit_failures} ok, {orbit_failures} failed", + ) + + product_failures = count_download_failures(db_path, request.job_id, "product") + 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)): + final_status = "failed" + + update_search_job( + db_path, + request.job_id, + status=final_status, + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=product_error, + ) + except Exception as exc: + update_search_job( + db_path, + request.job_id, + status="failed", + result_count=job.result_count, + data_dir=str(paths.data_dir), + orbit_dir=str(paths.orbit_dir), + manifest_path=str(paths.manifest_path), + error_message=str(exc), + ) + record_job_event(db_path, request.job_id, "error", f"Download execution failed: {exc}") + + +def record_product_downloads( + db_path: Path, + job_id: int, + products: Sequence, + data_dir: Path | None, + fallback_error: str | None = None, +) -> None: + for product in products: + scene_id = scene_name_from_product(product) + if scene_id is None: + continue + props = product.properties + if data_dir is None: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="skipped", + path=None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=0, + ) + continue + + expected = expected_product_path(product, data_dir) + exists = expected.exists() + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="succeeded" if exists else "failed", + path=str(expected) if exists else None, + url=string_value(props.get("url")), + bytes_total=float(props["bytes"]) if props.get("bytes") is not None else None, + bytes_done=float(props["bytes"]) if exists and props.get("bytes") is not None else None, + error_message=None if exists else (fallback_error or "Downloaded file not found at expected path"), + ) + + +def record_product_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None: + for scene_id in scene_ids: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="product", + status="skipped", + path=None, + ) + + +def record_orbit_skips(db_path: Path, job_id: int, scene_ids: Sequence[str]) -> None: + for scene_id in scene_ids: + record_download_result( + db_path, + job_id=job_id, + scene_id=scene_id, + kind="orbit", + status="skipped", + path=None, + ) + + +def expected_product_path(product, data_dir: Path) -> Path: + props = product.properties + file_name = props.get("fileName") + if file_name: + return data_dir / str(file_name) + url = props.get("url") + if url: + return data_dir / Path(str(url)).name + scene = scene_name_from_product(product) or "unknown_scene" + return data_dir / f"{scene}.zip" + + +def string_value(value) -> str | None: + if value is None: + return None + return str(value) + + +def count_download_failures(db_path: Path, job_id: int, kind: str) -> int: + from sentinel_orbit_downloader.storage.db import connect + + if kind == "product": + column = "product_status" + elif kind == "orbit": + column = "orbit_status" + else: + raise ValueError(f"unsupported download kind: {kind}") + + with connect(db_path) as connection: + row = connection.execute( + f""" + SELECT COUNT(*) AS failure_count + FROM job_scenes + WHERE job_id = ? AND {column} = 'failed' + """, + (job_id,), + ).fetchone() + return int(row["failure_count"]) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py new file mode 100644 index 0000000..a07c621 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +import s1_orbits +from s1_orbits.exceptions import InvalidSceneError, OrbitNotFoundError + +SCENE_RE = re.compile(r"^(S1[A-D]_.+?)(?:\.SAFE|\.zip)?$", re.IGNORECASE) + + +@dataclass(frozen=True) +class OrbitResult: + scene: str + path: Path | None + error: str | None = None + + +def normalize_scene_name(value: str) -> str | None: + name = Path(value).name.strip() + for suffix in (".SAFE", ".safe", ".ZIP", ".zip"): + if name.endswith(suffix): + name = name[: -len(suffix)] + break + match = SCENE_RE.match(name) + if not match: + return None + return match.group(1) + + +def unique(values: Iterable[str]) -> list[str]: + seen = set() + output = [] + for value in values: + if value in seen: + continue + seen.add(value) + output.append(value) + return output + + +def collect_orbit_scenes( + scenes: Sequence[str] | None = None, + scene_file: Path | None = None, + data_dir: Path | None = None, +) -> list[str]: + if scenes: + return unique(filter(None, (normalize_scene_name(scene) for scene in scenes))) + + if scene_file: + scene_names = [] + for line in scene_file.read_text(encoding="utf-8").splitlines(): + clean = line.strip() + if not clean or clean.startswith("#"): + continue + scene_names.append(normalize_scene_name(clean)) + return unique(filter(None, scene_names)) + + if not data_dir: + return [] + + found = [] + for path in data_dir.iterdir(): + if not path.name.upper().startswith(("S1A_", "S1B_", "S1C_", "S1D_")): + continue + scene = normalize_scene_name(path.name) + if scene: + found.append(scene) + return unique(found) + + +def scene_names_from_results(results) -> list[str]: + from .asf_client import scene_name_from_product + + scenes = [scene_name_from_product(product) for product in results] + return unique(scene for scene in scenes if scene) + + +def download_orbits(scenes: Sequence[str], orbit_dir: Path) -> list[OrbitResult]: + orbit_dir.mkdir(parents=True, exist_ok=True) + results: list[OrbitResult] = [] + for scene in scenes: + try: + path = s1_orbits.fetch_for_scene(scene, dir=orbit_dir) + results.append(OrbitResult(scene=scene, path=path)) + except (InvalidSceneError, OrbitNotFoundError) as exc: + message = str(exc) or exc.__class__.__name__ + results.append(OrbitResult(scene=scene, path=None, error=message)) + return results diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/project_store.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/project_store.py new file mode 100644 index 0000000..dfde788 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/project_store.py @@ -0,0 +1,545 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from sentinel_orbit_downloader.storage.db import connect + + +NON_SLUG_RE = re.compile(r"[^\w\u4e00-\u9fff-]+", re.UNICODE) + + +@dataclass(frozen=True) +class ProjectRecord: + id: int + name: str + slug: str + data_root: str + description: str | None + created_at: str + updated_at: str + + +def create_project( + db_path: Path, + name: str, + data_root: Path, + description: str | None = None, + slug: str | None = None, +) -> ProjectRecord: + final_slug = slug or slugify(name) + if not final_slug: + raise ValueError("project slug is empty; choose a clearer project name") + + data_root.mkdir(parents=True, exist_ok=True) + + with connect(db_path) as connection: + exists = connection.execute("SELECT id FROM projects WHERE slug = ?", (final_slug,)).fetchone() + if exists is not None: + raise ValueError(f"project slug already exists: {final_slug}") + + cursor = connection.execute( + """ + INSERT INTO projects(name, slug, data_root, description) + VALUES (?, ?, ?, ?) + """, + (name, final_slug, str(data_root), description), + ) + project_id = int(cursor.lastrowid) + row = connection.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + + return row_to_project(row) + + +def list_projects(db_path: Path) -> list[ProjectRecord]: + with connect(db_path) as connection: + rows = connection.execute( + "SELECT * FROM projects ORDER BY created_at DESC, id DESC" + ).fetchall() + return [row_to_project(row) for row in rows] + + +def get_project(db_path: Path, project_id: int) -> ProjectRecord | None: + with connect(db_path) as connection: + row = connection.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + if row is None: + return None + return row_to_project(row) + + +def slugify(value: str) -> str: + text = value.strip().lower().replace(" ", "-") + text = NON_SLUG_RE.sub("-", text) + text = re.sub(r"-{2,}", "-", text) + text = text.strip("-") + return text + + +def row_to_project(row) -> ProjectRecord: + return ProjectRecord( + id=int(row["id"]), + name=str(row["name"]), + slug=str(row["slug"]), + data_root=str(row["data_root"]), + description=row["description"], + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + + +@dataclass(frozen=True) +class SearchJobRecord: + id: int + project_id: int | None + aoi_id: int | None + source_type: str + source_value: str | None + region_adcode: str | None + region_name: str | None + start_time: str + end_time: str + processing_level: str + beam_mode: str | None + flight_direction: str | None + polarization: str | None + relative_orbit: int | None + max_results: int + status: str + result_count: int | None + data_dir: str | None + orbit_dir: str | None + manifest_path: str | None + error_message: str | None + created_at: str + finished_at: str | None + + +def create_search_job( + db_path: Path, + *, + project_id: int | None, + source_type: str, + source_value: str | None, + region_adcode: str | None, + region_name: str | None, + start_time: str, + end_time: str, + processing_level: str, + beam_mode: str | None, + flight_direction: str | None, + polarization: str | None, + relative_orbit: int | None, + max_results: int, + status: str = "running", + data_dir: str | None = None, + orbit_dir: str | None = None, + manifest_path: str | None = None, +) -> SearchJobRecord: + with connect(db_path) as connection: + cursor = connection.execute( + """ + INSERT INTO search_jobs( + project_id, aoi_id, source_type, source_value, region_adcode, region_name, + start_time, end_time, processing_level, beam_mode, flight_direction, + polarization, relative_orbit, max_results, status, data_dir, orbit_dir, manifest_path + ) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + project_id, + source_type, + source_value, + region_adcode, + region_name, + start_time, + end_time, + processing_level, + beam_mode, + flight_direction, + polarization, + relative_orbit, + max_results, + status, + data_dir, + orbit_dir, + manifest_path, + ), + ) + job_id = int(cursor.lastrowid) + row = connection.execute("SELECT * FROM search_jobs WHERE id = ?", (job_id,)).fetchone() + return row_to_search_job(row) + + +def update_search_job( + db_path: Path, + job_id: int, + *, + status: str, + result_count: int | None = None, + data_dir: str | None = None, + orbit_dir: str | None = None, + manifest_path: str | None = None, + error_message: str | None = None, +) -> None: + final_statuses = {"succeeded", "failed", "partial_failed", "canceled"} + with connect(db_path) as connection: + if status in final_statuses: + connection.execute( + """ + UPDATE search_jobs + SET + status = ?, + result_count = COALESCE(?, result_count), + data_dir = COALESCE(?, data_dir), + orbit_dir = COALESCE(?, orbit_dir), + manifest_path = COALESCE(?, manifest_path), + error_message = ?, + finished_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + status, + result_count, + data_dir, + orbit_dir, + manifest_path, + error_message, + job_id, + ), + ) + else: + connection.execute( + """ + UPDATE search_jobs + SET + status = ?, + result_count = COALESCE(?, result_count), + data_dir = COALESCE(?, data_dir), + orbit_dir = COALESCE(?, orbit_dir), + manifest_path = COALESCE(?, manifest_path), + error_message = ?, + finished_at = NULL + WHERE id = ? + """, + ( + status, + result_count, + data_dir, + orbit_dir, + manifest_path, + error_message, + job_id, + ), + ) + + +def record_job_event(db_path: Path, job_id: int, level: str, message: str) -> None: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return + connection.execute( + "INSERT INTO job_events(job_id, level, message) VALUES (?, ?, ?)", + (job_id, level, message), + ) + + +def save_query_results(db_path: Path, job_id: int, results) -> None: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return + for product in results: + scene_id = scene_id_from_product(product) + if scene_id is None: + continue + props = product.properties + geometry_json = json.dumps(product.geometry, ensure_ascii=False, default=str) + properties_json = json.dumps(props, ensure_ascii=False, default=str) + connection.execute( + """ + INSERT INTO scenes( + scene_id, platform, start_time, stop_time, processing_level, beam_mode, + flight_direction, polarization, relative_orbit, size_bytes, size_mb, + download_url, geometry_json, properties_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(scene_id) DO UPDATE SET + platform = excluded.platform, + start_time = excluded.start_time, + stop_time = excluded.stop_time, + processing_level = excluded.processing_level, + beam_mode = excluded.beam_mode, + flight_direction = excluded.flight_direction, + polarization = excluded.polarization, + relative_orbit = excluded.relative_orbit, + size_bytes = excluded.size_bytes, + size_mb = excluded.size_mb, + download_url = excluded.download_url, + geometry_json = excluded.geometry_json, + properties_json = excluded.properties_json + """, + ( + scene_id, + platform_from_scene(scene_id), + string_value(props.get("startTime")), + string_value(props.get("stopTime")), + string_value(props.get("processingLevel")), + string_value(props.get("beamModeType")), + string_value(props.get("flightDirection")), + string_value(props.get("polarization")), + int(props["pathNumber"]) if props.get("pathNumber") is not None else None, + float(props["bytes"]) if props.get("bytes") is not None else None, + product_size_mb(props), + string_value(props.get("url")), + geometry_json, + properties_json, + ), + ) + connection.execute( + """ + INSERT INTO job_scenes(job_id, scene_id, selected, product_status, orbit_status) + VALUES (?, ?, 1, 'pending', 'pending') + ON CONFLICT(job_id, scene_id) DO NOTHING + """, + (job_id, scene_id), + ) + + +def record_download_result( + db_path: Path, + *, + job_id: int, + scene_id: str, + kind: str, + status: str, + path: str | None, + url: str | None = None, + bytes_total: float | None = None, + bytes_done: float | None = None, + error_message: str | None = None, +) -> None: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return + connection.execute( + """ + INSERT INTO downloads( + job_id, scene_id, kind, status, url, path, bytes_total, bytes_done, error_message + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, + scene_id, + kind, + status, + url, + path, + bytes_total, + bytes_done, + error_message, + ), + ) + if kind == "product": + connection.execute( + """ + UPDATE job_scenes + SET product_status = ?, product_path = COALESCE(?, product_path) + WHERE job_id = ? AND scene_id = ? + """, + (status, path, job_id, scene_id), + ) + elif kind == "orbit": + connection.execute( + """ + UPDATE job_scenes + SET orbit_status = ?, orbit_path = COALESCE(?, orbit_path) + WHERE job_id = ? AND scene_id = ? + """, + (status, path, job_id, scene_id), + ) + + +def delete_job(db_path: Path, job_id: int) -> bool: + with connect(db_path) as connection: + if not job_exists(connection, job_id): + return False + delete_job_rows(connection, job_id) + prune_orphan_scenes(connection) + return True + + +def clear_jobs( + db_path: Path, + project_id: int | None = None, + include_running: bool = False, +) -> list[int]: + sql = "SELECT id FROM search_jobs" + conditions: list[str] = [] + params: list[Any] = [] + + if project_id is not None: + conditions.append("project_id = ?") + params.append(project_id) + if not include_running: + conditions.append("status != 'running'") + + if conditions: + sql += " WHERE " + " AND ".join(conditions) + sql += " ORDER BY id" + + with connect(db_path) as connection: + rows = connection.execute(sql, params).fetchall() + job_ids = [int(row["id"]) for row in rows] + for job_id in job_ids: + delete_job_rows(connection, job_id) + prune_orphan_scenes(connection) + return job_ids + + +def list_jobs(db_path: Path, project_id: int | None = None, limit: int = 20) -> list[SearchJobRecord]: + sql = """ + SELECT * + FROM search_jobs + """ + params: list[Any] = [] + if project_id is not None: + sql += " WHERE project_id = ?" + params.append(project_id) + sql += " ORDER BY id DESC LIMIT ?" + params.append(limit) + + with connect(db_path) as connection: + rows = connection.execute(sql, params).fetchall() + return [row_to_search_job(row) for row in rows] + + +def get_job(db_path: Path, job_id: int) -> SearchJobRecord | None: + with connect(db_path) as connection: + row = connection.execute("SELECT * FROM search_jobs WHERE id = ?", (job_id,)).fetchone() + if row is None: + return None + return row_to_search_job(row) + + +def list_job_scenes(db_path: Path, job_id: int) -> list[dict[str, Any]]: + with connect(db_path) as connection: + rows = connection.execute( + """ + SELECT + js.job_id, + js.scene_id, + js.selected, + js.product_status, + js.orbit_status, + js.product_path, + js.orbit_path, + s.platform, + s.start_time, + s.stop_time, + s.processing_level, + s.flight_direction, + s.relative_orbit, + s.size_mb + FROM job_scenes AS js + JOIN scenes AS s ON s.scene_id = js.scene_id + WHERE js.job_id = ? + ORDER BY s.start_time, js.scene_id + """, + (job_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def list_job_events(db_path: Path, job_id: int) -> list[dict[str, Any]]: + with connect(db_path) as connection: + rows = connection.execute( + "SELECT id, level, message, created_at FROM job_events WHERE job_id = ? ORDER BY id", + (job_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def row_to_search_job(row) -> SearchJobRecord: + return SearchJobRecord( + id=int(row["id"]), + project_id=int(row["project_id"]) if row["project_id"] is not None else None, + aoi_id=int(row["aoi_id"]) if row["aoi_id"] is not None else None, + source_type=str(row["source_type"]), + source_value=row["source_value"], + region_adcode=row["region_adcode"], + region_name=row["region_name"], + start_time=str(row["start_time"]), + end_time=str(row["end_time"]), + processing_level=str(row["processing_level"]), + beam_mode=row["beam_mode"], + flight_direction=row["flight_direction"], + polarization=row["polarization"], + relative_orbit=int(row["relative_orbit"]) if row["relative_orbit"] is not None else None, + max_results=int(row["max_results"]), + status=str(row["status"]), + result_count=int(row["result_count"]) if row["result_count"] is not None else None, + data_dir=row["data_dir"], + orbit_dir=row["orbit_dir"], + manifest_path=row["manifest_path"], + error_message=row["error_message"], + created_at=str(row["created_at"]), + finished_at=row["finished_at"], + ) + + +def scene_id_from_product(product) -> str | None: + from sentinel_orbit_downloader.services.asf_client import scene_name_from_product + + return scene_name_from_product(product) + + +def platform_from_scene(scene_id: str) -> str | None: + parts = scene_id.split("_", 1) + if not parts: + return None + return parts[0] + + +def product_size_mb(props: dict[str, Any]) -> float | None: + if props.get("sizeMB") is not None: + return float(props["sizeMB"]) + if props.get("bytes") is not None: + return float(props["bytes"]) / (1024 * 1024) + return None + + +def string_value(value: Any) -> str | None: + if value is None: + return None + return str(value) + + +def job_exists(connection, job_id: int) -> bool: + row = connection.execute( + "SELECT 1 FROM search_jobs WHERE id = ?", + (job_id,), + ).fetchone() + return row is not None + + +def delete_job_rows(connection, job_id: int) -> None: + connection.execute("DELETE FROM job_events WHERE job_id = ?", (job_id,)) + connection.execute("DELETE FROM downloads WHERE job_id = ?", (job_id,)) + connection.execute("DELETE FROM job_scenes WHERE job_id = ?", (job_id,)) + connection.execute("DELETE FROM search_jobs WHERE id = ?", (job_id,)) + + +def prune_orphan_scenes(connection) -> None: + connection.execute( + """ + DELETE FROM scenes + WHERE NOT EXISTS ( + SELECT 1 + FROM job_scenes + WHERE job_scenes.scene_id = scenes.scene_id + ) + """ + ) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/region_catalog.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/region_catalog.py new file mode 100644 index 0000000..33ffe00 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/region_catalog.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from shapely.geometry import shape + +from sentinel_orbit_downloader.core.provinces import PROVINCES +from sentinel_orbit_downloader.storage.db import connect, set_setting + + +@dataclass(frozen=True) +class RegionImportStats: + source_path: Path + simplify_tolerance: float + total_features: int + imported: int + + +@dataclass(frozen=True) +class RegionRecord: + adcode: str + name: str + level: str + parent_adcode: str | None + parent_name: str | None + province_adcode: str | None + province_name: str | None + center_lon: float | None + center_lat: float | None + centroid_lon: float | None + centroid_lat: float | None + min_lon: float | None + min_lat: float | None + max_lon: float | None + max_lat: float | None + children_num: int | None + tree_id: str | None + source_path: str | None + geometry_json: str + wkt: str + simplified_wkt: str + simplify_tolerance: float + geometry_hash: str + + @property + def bbox(self) -> tuple[float, float, float, float] | None: + values = (self.min_lon, self.min_lat, self.max_lon, self.max_lat) + if any(value is None for value in values): + return None + return values # type: ignore[return-value] + + @property + def label(self) -> str: + parts = [] + if self.province_name and self.province_name != self.name: + parts.append(self.province_name) + if self.parent_name and self.parent_name not in parts and self.parent_name != self.name: + parts.append(self.parent_name) + parts.append(self.name) + return " / ".join(parts) + + +def import_regions( + db_path: Path, + source_path: Path, + simplify_tolerance: float = 0.01, +) -> RegionImportStats: + data = json.loads(source_path.read_text(encoding="utf-8")) + features = data.get("features") or [] + rows = [feature_to_row(feature, source_path, simplify_tolerance) for feature in features] + + with connect(db_path) as connection: + connection.execute("DELETE FROM regions") + connection.executemany( + """ + INSERT INTO regions ( + adcode, name, level, parent_adcode, province_adcode, province_name, + center_lon, center_lat, centroid_lon, centroid_lat, + min_lon, min_lat, max_lon, max_lat, children_num, tree_id, + source_path, geometry_json, wkt, simplified_wkt, simplify_tolerance, geometry_hash + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + set_setting(connection, "regions.source_path", str(source_path.resolve())) + set_setting(connection, "regions.simplify_tolerance", str(simplify_tolerance)) + set_setting(connection, "regions.feature_count", str(len(features))) + + return RegionImportStats( + source_path=source_path, + simplify_tolerance=simplify_tolerance, + total_features=len(features), + imported=len(rows), + ) + + +def search_regions( + db_path: Path, + query: str, + limit: int = 20, + level: str | None = None, +) -> list[RegionRecord]: + sql = """ + SELECT + r.*, + parent.name AS parent_name + FROM regions AS r + LEFT JOIN regions AS parent ON parent.adcode = r.parent_adcode + WHERE ( + r.name LIKE :like_query + OR r.adcode LIKE :like_query + OR IFNULL(r.province_name, '') LIKE :like_query + ) + """ + params: dict[str, Any] = { + "like_query": f"%{query}%", + "exact_query": query, + "prefix_query": f"{query}%", + "limit": limit, + } + if level: + sql += " AND r.level = :level" + params["level"] = level + + sql += """ + ORDER BY + CASE + WHEN r.name = :exact_query THEN 0 + WHEN r.name LIKE :prefix_query THEN 1 + ELSE 2 + END, + LENGTH(r.name), + r.level, + r.adcode + LIMIT :limit + """ + + with connect(db_path) as connection: + rows = connection.execute(sql, params).fetchall() + return [row_to_region(row) for row in rows] + + +def get_region(db_path: Path, adcode: str) -> RegionRecord | None: + with connect(db_path) as connection: + row = connection.execute( + """ + SELECT + r.*, + parent.name AS parent_name + FROM regions AS r + LEFT JOIN regions AS parent ON parent.adcode = r.parent_adcode + WHERE r.adcode = ? + """, + (str(adcode),), + ).fetchone() + if row is None: + return None + return row_to_region(row) + + +def region_wkt(db_path: Path, adcode: str, simplified: bool = True) -> str: + region = get_region(db_path, adcode) + if region is None: + raise ValueError(f"region not found: {adcode}") + return region.simplified_wkt if simplified else region.wkt + + +def feature_to_row(feature: dict[str, Any], source_path: Path, simplify_tolerance: float) -> tuple[Any, ...]: + properties = feature.get("properties") or {} + geometry = feature.get("geometry") + if geometry is None: + raise ValueError("feature geometry is missing") + + geometry_text = json.dumps(geometry, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + geometry_hash = hashlib.sha256(geometry_text.encode("utf-8")).hexdigest() + geom = shape(geometry) + simplified = geom.simplify(simplify_tolerance, preserve_topology=True) + if simplified.is_empty: + simplified = geom + + adcode = str(properties.get("adcode")) + name = str(properties.get("name")) + level = str(properties.get("level") or "unknown") + parent_adcode = normalize_adcode((properties.get("parent") or {}).get("adcode")) + province_adcode = derive_province_adcode(adcode, level, parent_adcode, properties.get("acroutes")) + province_name = PROVINCES.get(province_adcode) if province_adcode else None + + center = normalize_point(properties.get("center")) + centroid = normalize_point(properties.get("centroid")) + min_lon, min_lat, max_lon, max_lat = geom.bounds + + return ( + adcode, + name, + level, + parent_adcode, + province_adcode, + province_name, + center[0] if center else None, + center[1] if center else None, + centroid[0] if centroid else None, + centroid[1] if centroid else None, + min_lon, + min_lat, + max_lon, + max_lat, + int(properties["childrenNum"]) if properties.get("childrenNum") is not None else None, + properties.get("treeID"), + str(source_path), + geometry_text, + geom.wkt, + simplified.wkt, + simplify_tolerance, + geometry_hash, + ) + + +def derive_province_adcode( + adcode: str, + level: str, + parent_adcode: str | None, + acroutes: Any, +) -> str | None: + if level == "province" and adcode in PROVINCES: + return adcode + + routes = [normalize_adcode(value) for value in acroutes or []] + for route in routes: + if route in PROVINCES: + return route + + if parent_adcode in PROVINCES: + return parent_adcode + + if adcode.isdigit() and len(adcode) == 6: + candidate = f"{adcode[:2]}0000" + if candidate in PROVINCES: + return candidate + return None + + +def normalize_adcode(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text else None + + +def normalize_point(value: Any) -> tuple[float, float] | None: + if not isinstance(value, list) or len(value) < 2: + return None + return (float(value[0]), float(value[1])) + + +def row_to_region(row) -> RegionRecord: + return RegionRecord( + adcode=str(row["adcode"]), + name=str(row["name"]), + level=str(row["level"]), + parent_adcode=row["parent_adcode"], + parent_name=row["parent_name"], + province_adcode=row["province_adcode"], + province_name=row["province_name"], + center_lon=row["center_lon"], + center_lat=row["center_lat"], + centroid_lon=row["centroid_lon"], + centroid_lat=row["centroid_lat"], + min_lon=row["min_lon"], + min_lat=row["min_lat"], + max_lon=row["max_lon"], + max_lat=row["max_lat"], + children_num=row["children_num"], + tree_id=row["tree_id"], + source_path=row["source_path"], + geometry_json=row["geometry_json"], + wkt=row["wkt"], + simplified_wkt=row["simplified_wkt"], + simplify_tolerance=float(row["simplify_tolerance"]), + geometry_hash=str(row["geometry_hash"]), + ) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/settings_store.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/settings_store.py new file mode 100644 index 0000000..30ebbf1 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/settings_store.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import base64 +import ctypes +from dataclasses import dataclass +from pathlib import Path + +from sentinel_orbit_downloader.storage.db import connect, get_setting, set_setting + + +EARTHDATA_USERNAME_KEY = "earthdata.username" +EARTHDATA_PASSWORD_KEY = "earthdata.password_dpapi" + + +@dataclass(frozen=True) +class EarthdataSettings: + username: str | None + has_password: bool + + +def get_earthdata_settings(db_path: Path) -> EarthdataSettings: + with connect(db_path) as connection: + username = get_setting(connection, EARTHDATA_USERNAME_KEY) + encrypted_password = get_setting(connection, EARTHDATA_PASSWORD_KEY) + return EarthdataSettings( + username=username, + has_password=bool(encrypted_password), + ) + + +def get_saved_earthdata_credentials(db_path: Path) -> tuple[str | None, str | None]: + with connect(db_path) as connection: + username = get_setting(connection, EARTHDATA_USERNAME_KEY) + encrypted_password = get_setting(connection, EARTHDATA_PASSWORD_KEY) + if not encrypted_password: + return username, None + return username, dpapi_decrypt(encrypted_password) + + +def save_earthdata_credentials(db_path: Path, username: str, password: str) -> EarthdataSettings: + final_username = username.strip() + if not final_username: + raise ValueError("Earthdata username cannot be empty") + if not password: + raise ValueError("Earthdata password cannot be empty") + + encrypted_password = dpapi_encrypt(password) + with connect(db_path) as connection: + set_setting(connection, EARTHDATA_USERNAME_KEY, final_username) + set_setting(connection, EARTHDATA_PASSWORD_KEY, encrypted_password) + return EarthdataSettings(username=final_username, has_password=True) + + +def clear_earthdata_credentials(db_path: Path) -> None: + with connect(db_path) as connection: + connection.execute( + "DELETE FROM settings WHERE key IN (?, ?)", + (EARTHDATA_USERNAME_KEY, EARTHDATA_PASSWORD_KEY), + ) + + +class DATA_BLOB(ctypes.Structure): + _fields_ = [ + ("cbData", ctypes.c_uint32), + ("pbData", ctypes.POINTER(ctypes.c_ubyte)), + ] + + +try: + kernel32 = ctypes.windll.kernel32 + crypt32 = ctypes.windll.crypt32 +except AttributeError: + kernel32 = None + crypt32 = None + + +CRYPTPROTECT_UI_FORBIDDEN = 0x01 + + +def dpapi_encrypt(value: str) -> str: + ensure_windows_dpapi() + raw = value.encode("utf-8") + in_blob = bytes_to_blob(raw) + out_blob = DATA_BLOB() + if not crypt32.CryptProtectData( + ctypes.byref(in_blob), + "sentinel-orbit-downloader", + None, + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(out_blob), + ): + raise ctypes.WinError() + try: + encrypted = blob_to_bytes(out_blob) + finally: + free_blob(out_blob) + return base64.b64encode(encrypted).decode("ascii") + + +def dpapi_decrypt(encoded_value: str) -> str: + ensure_windows_dpapi() + encrypted = base64.b64decode(encoded_value.encode("ascii")) + in_blob = bytes_to_blob(encrypted) + out_blob = DATA_BLOB() + if not crypt32.CryptUnprotectData( + ctypes.byref(in_blob), + None, + None, + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + ctypes.byref(out_blob), + ): + raise ctypes.WinError() + try: + decrypted = blob_to_bytes(out_blob) + finally: + free_blob(out_blob) + return decrypted.decode("utf-8") + + +def ensure_windows_dpapi() -> None: + if crypt32 is None or kernel32 is None: + raise RuntimeError("Earthdata local credential storage currently requires Windows") + + +def bytes_to_blob(value: bytes) -> DATA_BLOB: + if not value: + buffer = (ctypes.c_ubyte * 1)() + return DATA_BLOB(0, ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))) + buffer = (ctypes.c_ubyte * len(value)).from_buffer_copy(value) + return DATA_BLOB(len(value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))) + + +def blob_to_bytes(blob: DATA_BLOB) -> bytes: + if not blob.cbData or not blob.pbData: + return b"" + return bytes(ctypes.string_at(blob.pbData, blob.cbData)) + + +def free_blob(blob: DATA_BLOB) -> None: + if blob.pbData: + kernel32.LocalFree(blob.pbData) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/__init__.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/__init__.py new file mode 100644 index 0000000..0da8c4f --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/__init__.py @@ -0,0 +1 @@ +"""Storage layer.""" diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/db.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/db.py new file mode 100644 index 0000000..8acdead --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/storage/db.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS regions ( + adcode TEXT PRIMARY KEY, + name TEXT NOT NULL, + level TEXT NOT NULL, + parent_adcode TEXT, + province_adcode TEXT, + province_name TEXT, + center_lon REAL, + center_lat REAL, + centroid_lon REAL, + centroid_lat REAL, + min_lon REAL, + min_lat REAL, + max_lon REAL, + max_lat REAL, + children_num INTEGER, + tree_id TEXT, + source_path TEXT, + geometry_json TEXT NOT NULL, + wkt TEXT NOT NULL, + simplified_wkt TEXT NOT NULL, + simplify_tolerance REAL NOT NULL, + geometry_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_regions_name ON regions(name); +CREATE INDEX IF NOT EXISTS idx_regions_level ON regions(level); +CREATE INDEX IF NOT EXISTS idx_regions_parent ON regions(parent_adcode); +CREATE INDEX IF NOT EXISTS idx_regions_province ON regions(province_adcode); + +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + data_root TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS aois ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER, + name TEXT NOT NULL, + source_type TEXT NOT NULL, + region_adcode TEXT, + wkt TEXT NOT NULL, + bbox_json TEXT, + geometry_json TEXT, + simplification_tolerance REAL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(project_id) REFERENCES projects(id) +); + +CREATE TABLE IF NOT EXISTS search_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER, + aoi_id INTEGER, + source_type TEXT NOT NULL, + source_value TEXT, + region_adcode TEXT, + region_name TEXT, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + processing_level TEXT NOT NULL, + beam_mode TEXT, + flight_direction TEXT, + polarization TEXT, + relative_orbit INTEGER, + max_results INTEGER NOT NULL, + status TEXT NOT NULL, + result_count INTEGER, + data_dir TEXT, + orbit_dir TEXT, + manifest_path TEXT, + error_message TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT, + FOREIGN KEY(project_id) REFERENCES projects(id), + FOREIGN KEY(aoi_id) REFERENCES aois(id) +); + +CREATE INDEX IF NOT EXISTS idx_search_jobs_project ON search_jobs(project_id); +CREATE INDEX IF NOT EXISTS idx_search_jobs_created ON search_jobs(created_at); +CREATE INDEX IF NOT EXISTS idx_search_jobs_region ON search_jobs(region_adcode); + +CREATE TABLE IF NOT EXISTS scenes ( + scene_id TEXT PRIMARY KEY, + platform TEXT, + start_time TEXT, + stop_time TEXT, + processing_level TEXT, + beam_mode TEXT, + flight_direction TEXT, + polarization TEXT, + relative_orbit INTEGER, + size_bytes REAL, + size_mb REAL, + download_url TEXT, + geometry_json TEXT, + properties_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS job_scenes ( + job_id INTEGER NOT NULL, + scene_id TEXT NOT NULL, + selected INTEGER NOT NULL DEFAULT 1, + product_status TEXT NOT NULL DEFAULT 'pending', + orbit_status TEXT NOT NULL DEFAULT 'pending', + product_path TEXT, + orbit_path TEXT, + PRIMARY KEY(job_id, scene_id), + FOREIGN KEY(job_id) REFERENCES search_jobs(id), + FOREIGN KEY(scene_id) REFERENCES scenes(scene_id) +); + +CREATE INDEX IF NOT EXISTS idx_job_scenes_scene ON job_scenes(scene_id); + +CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + scene_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + url TEXT, + path TEXT, + bytes_total REAL, + bytes_done REAL, + attempt_count INTEGER NOT NULL DEFAULT 1, + error_message TEXT, + started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(job_id) REFERENCES search_jobs(id), + FOREIGN KEY(scene_id) REFERENCES scenes(scene_id) +); + +CREATE INDEX IF NOT EXISTS idx_downloads_job ON downloads(job_id); +CREATE INDEX IF NOT EXISTS idx_downloads_scene ON downloads(scene_id); + +CREATE TABLE IF NOT EXISTS job_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(job_id) REFERENCES search_jobs(id) +); + +CREATE INDEX IF NOT EXISTS idx_job_events_job ON job_events(job_id); +""" + + +def connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(str(db_path)) + connection.row_factory = sqlite3.Row + connection.executescript(SCHEMA) + return connection + + +def set_setting(connection: sqlite3.Connection, key: str, value: str) -> None: + connection.execute( + """ + INSERT INTO settings(key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = CURRENT_TIMESTAMP + """, + (key, value), + ) + + +def get_setting(connection: sqlite3.Connection, key: str) -> str | None: + row = connection.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + if row is None: + return None + return str(row["value"]) diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.css b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.css new file mode 100644 index 0000000..cb2d2aa --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.css @@ -0,0 +1,573 @@ +:root { + color-scheme: light; + --bg: #f3f5f9; + --panel: #ffffff; + --panel-soft: #f8fafc; + --line: #d8dee8; + --text: #15253b; + --muted: #5e6c80; + --accent: #2563eb; + --accent-soft: #e7efff; + --success: #157347; + --success-soft: #dbf5e7; + --warning: #9a6700; + --warning-soft: #fff0cf; + --danger: #b42318; + --danger-soft: #ffe3df; + --shadow: 0 1px 2px rgba(16, 24, 40, 0.05); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: "Segoe UI", system-ui, sans-serif; + background: var(--bg); + color: var(--text); +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select, +textarea, +.item, +.chip, +.status-pill { + transition: border-color 120ms ease, background 120ms ease, color 120ms ease; +} + +.shell { + min-height: 100vh; + display: grid; + grid-template-columns: 320px minmax(0, 1fr); +} + +.sidebar { + padding: 20px; + border-right: 1px solid var(--line); + background: var(--panel); + display: flex; + flex-direction: column; + gap: 16px; +} + +.brand h1, +.topbar h2, +.panel h2 { + margin: 0; +} + +.brand p, +.topbar p { + margin: 4px 0 0; + color: var(--muted); + font-size: 13px; +} + +.content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.message { + min-height: 38px; + display: inline-flex; + align-items: center; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel); + box-shadow: var(--shadow); + font-size: 13px; + line-height: 1.4; +} + +.message.info { + color: var(--text); +} + +.message.success { + border-color: #b8e3c9; + background: var(--success-soft); + color: var(--success); +} + +.message.warning { + border-color: #f0d48a; + background: var(--warning-soft); + color: var(--warning); +} + +.message.error { + border-color: #f3b8b1; + background: var(--danger-soft); + color: var(--danger); +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 16px; + min-width: 0; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.section-head h2 { + font-size: 15px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.layout.two-col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.grid { + display: grid; + gap: 12px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.field label, +.field span { + font-size: 12px; + color: var(--muted); +} + +.field input, +.field select, +.field textarea { + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); +} + +.field textarea { + min-height: 84px; + resize: vertical; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + outline: none; + border-color: #94b5ff; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} + +.inline-field input { + max-width: 160px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +button { + min-height: 38px; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--line); + background: #fff; + color: var(--text); + cursor: pointer; +} + +button:hover { + border-color: #bcc8d9; +} + +button.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +button.primary:hover { + background: #1d4ed8; + border-color: #1d4ed8; +} + +button.ghost { + background: var(--panel-soft); +} + +button.danger { + border-color: #f0c3bc; + background: #fff6f4; + color: var(--danger); +} + +button.danger:hover { + border-color: #e5a59c; + background: #ffeceb; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.status-block { + display: flex; + align-items: center; + gap: 10px; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #98a2b3; + flex: none; +} + +.status-dot.ok { + background: var(--success); +} + +.status-dot.error { + background: var(--danger); +} + +.status-title { + font-size: 13px; + font-weight: 600; +} + +.status-subtle { + margin-top: 2px; + color: var(--muted); + font-size: 12px; +} + +.mono { + font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; +} + +.list.tall { + max-height: 320px; + overflow: auto; +} + +.item { + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + text-align: left; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.item:hover { + border-color: #c1d2fa; +} + +.item.active { + border-color: var(--accent); + background: #f5f8ff; +} + +.item-title { + font-size: 13px; + font-weight: 600; + line-height: 1.45; +} + +.item-subtle { + font-size: 12px; + color: var(--muted); + line-height: 1.45; +} + +.detail-card, +.events { + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-soft); + padding: 12px; +} + +.detail-card { + min-height: 150px; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.detail-row { + display: flex; + flex-direction: column; + gap: 3px; +} + +.detail-label { + font-size: 12px; + color: var(--muted); +} + +.detail-value { + font-size: 13px; + line-height: 1.4; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 6px 10px; + border-radius: 999px; + background: var(--panel-soft); + border: 1px solid var(--line); + font-size: 12px; + line-height: 1.4; +} + +.status-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + line-height: 1.4; + background: var(--panel-soft); + color: var(--muted); +} + +.status-pill.pending, +.status-pill.queued, +.status-pill.skipped { + background: #edf1f6; + color: var(--muted); +} + +.status-pill.running { + background: var(--accent-soft); + color: var(--accent); +} + +.status-pill.succeeded { + background: var(--success-soft); + color: var(--success); +} + +.status-pill.partial_failed { + background: var(--warning-soft); + color: var(--warning); +} + +.status-pill.failed, +.status-pill.error { + background: var(--danger-soft); + color: var(--danger); +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 0; + color: var(--text); +} + +.toggle input { + width: 16px; + height: 16px; +} + +.note { + font-size: 12px; + line-height: 1.5; + color: var(--muted); + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 12px; +} + +.detail-layout { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr); + gap: 16px; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; +} + +thead { + background: #fafbfd; +} + +th, +td { + border-top: 1px solid var(--line); + text-align: left; + vertical-align: top; + padding: 9px 8px; + word-break: break-word; +} + +thead th { + border-top: 0; + color: var(--muted); + font-weight: 600; +} + +.events { + min-height: 280px; + max-height: 420px; + overflow: auto; +} + +.event { + padding: 8px 0; + border-top: 1px solid #e8edf4; +} + +.event:first-child { + border-top: 0; + padding-top: 0; +} + +.event-title { + font-size: 12px; + font-weight: 600; +} + +.event-meta { + margin-top: 2px; + font-size: 12px; + color: var(--muted); +} + +.event-message { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; +} + +.empty { + color: var(--muted); + font-size: 12px; + padding: 8px 0; +} + +@media (max-width: 1200px) { + .shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--line); + } +} + +@media (max-width: 900px) { + .layout.two-col, + .grid.two, + .grid.three, + .detail-layout, + .detail-grid { + grid-template-columns: 1fr; + } + + .topbar { + flex-direction: column; + align-items: stretch; + } +} diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js new file mode 100644 index 0000000..354c28c --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js @@ -0,0 +1,864 @@ +const DEFAULT_CATALOG_PATH = "\u5168\u56fd\u884c\u653f\u533a.geojson"; +const FINAL_STATUSES = new Set(["succeeded", "failed", "partial_failed", "canceled"]); + +const api = { + async request(path, options = {}) { + const init = { ...options }; + init.headers = { + ...(options.body ? { "Content-Type": "application/json" } : {}), + ...(options.headers || {}), + }; + const response = await fetch(path, init); + if (!response.ok) { + let detail = response.statusText; + try { + const payload = await response.json(); + detail = payload.detail || JSON.stringify(payload); + } catch (_) {} + throw new Error(detail); + } + if (response.status === 204) { + return null; + } + return response.json(); + }, + health() { + return this.request("/api/health"); + }, + earthdataSettings() { + return this.request("/api/settings/earthdata"); + }, + saveEarthdataSettings(payload) { + return this.request("/api/settings/earthdata", { + method: "PUT", + body: JSON.stringify(payload), + }); + }, + clearEarthdataSettings() { + return this.request("/api/settings/earthdata", { + method: "DELETE", + }); + }, + importRegions(payload) { + return this.request("/api/regions/import", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + searchRegions(query) { + return this.request(`/api/regions/search?q=${encodeURIComponent(query)}`); + }, + regionDetail(adcode) { + return this.request(`/api/regions/${encodeURIComponent(adcode)}`); + }, + projects() { + return this.request("/api/projects"); + }, + createProject(payload) { + return this.request("/api/projects", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + jobs(projectId) { + const suffix = projectId ? `?project_id=${projectId}&limit=50` : "?limit=50"; + return this.request(`/api/jobs${suffix}`); + }, + job(jobId) { + return this.request(`/api/jobs/${jobId}?include_events=true`); + }, + createSearchJob(payload) { + return this.request("/api/search-jobs", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + createDownloadJob(payload) { + return this.request("/api/download-jobs", { + method: "POST", + body: JSON.stringify(payload), + }); + }, + deleteJob(jobId, force = false) { + return this.request(`/api/jobs/${jobId}?force=${force ? "true" : "false"}`, { + method: "DELETE", + }); + }, + clearJobs(projectId, force = false) { + const projectPart = projectId ? `project_id=${projectId}&` : ""; + return this.request(`/api/jobs?${projectPart}force=${force ? "true" : "false"}`, { + method: "DELETE", + }); + }, +}; + +const state = { + projects: [], + selectedProjectId: null, + regions: [], + selectedRegion: null, + selectedRegionDetail: null, + earthdataSettings: null, + jobs: [], + selectedJobId: null, + selectedJobDetail: null, + pollTimer: null, +}; + +const el = { + healthDot: document.getElementById("healthDot"), + healthLabel: document.getElementById("healthLabel"), + healthDetail: document.getElementById("healthDetail"), + catalogPath: document.getElementById("catalogPath"), + simplifyTolerance: document.getElementById("simplifyTolerance"), + projectName: document.getElementById("projectName"), + projectPath: document.getElementById("projectPath"), + projectDesc: document.getElementById("projectDesc"), + projectList: document.getElementById("projectList"), + messageBar: document.getElementById("messageBar"), + regionKeyword: document.getElementById("regionKeyword"), + regionList: document.getElementById("regionList"), + regionDetailCard: document.getElementById("regionDetailCard"), + startDate: document.getElementById("startDate"), + endDate: document.getElementById("endDate"), + maxResults: document.getElementById("maxResults"), + processingLevel: document.getElementById("processingLevel"), + beamMode: document.getElementById("beamMode"), + flightDirection: document.getElementById("flightDirection"), + polarization: document.getElementById("polarization"), + relativeOrbit: document.getElementById("relativeOrbit"), + manifestPath: document.getElementById("manifestPath"), + rawRegion: document.getElementById("rawRegion"), + selectionSummary: document.getElementById("selectionSummary"), + dataDir: document.getElementById("dataDir"), + orbitDir: document.getElementById("orbitDir"), + skipData: document.getElementById("skipData"), + skipOrbits: document.getElementById("skipOrbits"), + downloadProcesses: document.getElementById("downloadProcesses"), + ignoreOrbitErrors: document.getElementById("ignoreOrbitErrors"), + earthdataUser: document.getElementById("earthdataUser"), + earthdataPass: document.getElementById("earthdataPass"), + earthdataSummary: document.getElementById("earthdataSummary"), + jobFilterSummary: document.getElementById("jobFilterSummary"), + jobList: document.getElementById("jobList"), + deleteJobBtn: document.getElementById("deleteJobBtn"), + clearJobsBtn: document.getElementById("clearJobsBtn"), + jobMeta: document.getElementById("jobMeta"), + sceneTableBody: document.getElementById("sceneTableBody"), + eventList: document.getElementById("eventList"), +}; + +function setMessage(text, tone = "info") { + el.messageBar.textContent = text; + el.messageBar.className = `message ${tone}`; +} + +function setHealth(ok, label, detail) { + el.healthDot.className = `status-dot ${ok ? "ok" : "error"}`; + el.healthLabel.textContent = label; + el.healthDetail.textContent = detail; +} + +function makeChip(text) { + const span = document.createElement("span"); + span.className = "chip"; + span.textContent = text; + return span; +} + +function makeStatusPill(value) { + const span = document.createElement("span"); + span.className = `status-pill ${value || ""}`.trim(); + span.textContent = value || "-"; + return span; +} + +function renderEmpty(container, text) { + container.innerHTML = `
${escapeHtml(text)}
`; +} + +function formatMb(value) { + if (value == null || Number.isNaN(Number(value))) { + return ""; + } + return `${Number(value).toFixed(1)} MB`; +} + +function formatRelativeOrbit(value) { + return value == null ? "" : String(value); +} + +function formatBBox(bbox) { + if (!Array.isArray(bbox) || bbox.length !== 4) { + return "-"; + } + return bbox.map((value) => Number(value).toFixed(4)).join(", "); +} + +function formatPoint(point) { + if (!Array.isArray(point) || point.length < 2) { + return "-"; + } + return point.slice(0, 2).map((value) => Number(value).toFixed(4)).join(", "); +} + +function emptyToNull(value) { + const text = (value || "").trim(); + return text ? text : null; +} + +function numberOrNull(value) { + const text = (value || "").trim(); + if (!text) { + return null; + } + const number = Number(text); + return Number.isFinite(number) ? number : null; +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text || ""; + return div.innerHTML; +} + +function statusCounts(scenes) { + const product = {}; + const orbit = {}; + for (const scene of scenes) { + product[scene.product_status] = (product[scene.product_status] || 0) + 1; + orbit[scene.orbit_status] = (orbit[scene.orbit_status] || 0) + 1; + } + return { product, orbit }; +} + +function currentProject() { + return state.projects.find((project) => project.id === state.selectedProjectId) || null; +} + +function autoFillProjectPaths(project) { + if (!project || !project.data_root) { + return; + } + el.projectPath.value = project.data_root; + el.dataDir.value = `${project.data_root}\\data`; + el.orbitDir.value = `${project.data_root}\\orbits`; + if (!el.manifestPath.value) { + el.manifestPath.value = `${project.data_root}\\manifest.csv`; + } +} + +function renderProjects() { + el.projectList.innerHTML = ""; + if (!state.projects.length) { + renderEmpty(el.projectList, "暂无项目"); + return; + } + + for (const project of state.projects) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `item ${state.selectedProjectId === project.id ? "active" : ""}`; + button.innerHTML = ` +
${escapeHtml(project.name)}
+
${escapeHtml(project.data_root)}
+
${escapeHtml(project.slug)}
+ `; + button.addEventListener("click", async () => { + state.selectedProjectId = project.id; + autoFillProjectPaths(project); + renderProjects(); + updateSelectionSummary(); + await loadJobs(); + await loadSelectedJobDetail(); + }); + el.projectList.appendChild(button); + } +} + +function renderRegions() { + el.regionList.innerHTML = ""; + if (!state.regions.length) { + renderEmpty(el.regionList, "暂无行政区结果"); + return; + } + + for (const region of state.regions) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `item ${state.selectedRegion && state.selectedRegion.adcode === region.adcode ? "active" : ""}`; + button.innerHTML = ` +
${escapeHtml(region.label)}
+
${escapeHtml(region.adcode)}
+
${escapeHtml(region.level)} | ${escapeHtml(formatBBox(region.bbox))}
+ `; + button.addEventListener("click", async () => { + state.selectedRegion = region; + renderRegions(); + updateSelectionSummary(); + await loadRegionDetail(region.adcode); + }); + el.regionList.appendChild(button); + } +} + +function renderRegionDetail() { + const region = state.selectedRegionDetail; + if (!region) { + el.regionDetailCard.innerHTML = '
未选择行政区
'; + return; + } + + el.regionDetailCard.innerHTML = ` +
+
+ ${escapeHtml(region.level)} + ${escapeHtml(region.adcode)} +
+
+
+
行政区
+
${escapeHtml(region.label)}
+
+
+
简化容差
+
${escapeHtml(String(region.simplify_tolerance))}
+
+
+
范围框
+
${escapeHtml(formatBBox(region.bbox))}
+
+
+
中心点
+
${escapeHtml(formatPoint(region.center))}
+
+
+
+ `; +} + +function renderEarthdataSummary() { + el.earthdataSummary.innerHTML = ""; + const settings = state.earthdataSettings; + if (!settings || (!settings.username && !settings.has_password)) { + el.earthdataSummary.appendChild(makeChip("Earthdata 本地凭据:未保存")); + return; + } + if (settings.username) { + el.earthdataSummary.appendChild(makeChip(`Earthdata 用户:${settings.username}`)); + } + el.earthdataSummary.appendChild( + makeChip(settings.has_password ? "Earthdata 密码:已保存" : "Earthdata 密码:未保存"), + ); +} + +function renderJobFilterSummary() { + el.jobFilterSummary.innerHTML = ""; + const project = currentProject(); + if (project) { + el.jobFilterSummary.appendChild(makeChip(`项目 ${project.name}`)); + } else { + el.jobFilterSummary.appendChild(makeChip("全部项目")); + } + el.jobFilterSummary.appendChild(makeChip(`任务数 ${state.jobs.length}`)); +} + +function renderJobs() { + el.jobList.innerHTML = ""; + if (!state.jobs.length) { + renderEmpty(el.jobList, "暂无任务"); + renderJobFilterSummary(); + return; + } + + for (const job of state.jobs) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `item ${state.selectedJobId === job.id ? "active" : ""}`; + button.appendChild(makeStatusPill(job.status)); + + const title = document.createElement("div"); + title.className = "item-title"; + title.textContent = `#${job.id} ${job.region_name || job.source_value || job.source_type}`; + button.appendChild(title); + + const subtles = [ + `${job.start_time} - ${job.end_time}`, + `${job.processing_level}${job.beam_mode ? ` | ${job.beam_mode}` : ""}`, + `结果 ${job.result_count ?? "-"}`, + ]; + for (const text of subtles) { + const div = document.createElement("div"); + div.className = "item-subtle"; + div.textContent = text; + button.appendChild(div); + } + + button.addEventListener("click", async () => { + state.selectedJobId = job.id; + renderJobs(); + updateSelectionSummary(); + await loadSelectedJobDetail(); + }); + el.jobList.appendChild(button); + } + + renderJobFilterSummary(); +} + +function renderJobDetail() { + const payload = state.selectedJobDetail; + el.jobMeta.innerHTML = ""; + el.sceneTableBody.innerHTML = ""; + el.eventList.innerHTML = ""; + + if (!payload) { + renderEmpty(el.jobMeta, "未选择任务"); + el.sceneTableBody.innerHTML = '暂无场景'; + renderEmpty(el.eventList, "暂无事件"); + return; + } + + const { job, scenes, events } = payload; + const counts = statusCounts(scenes); + const chips = [ + `Job #${job.id}`, + `状态 ${job.status}`, + `时间 ${job.start_time} - ${job.end_time}`, + `产品 ${job.processing_level}`, + `结果 ${job.result_count ?? scenes.length}`, + ]; + + if (job.region_name) { + chips.push(`区域 ${job.region_name}`); + } + if (job.data_dir) { + chips.push(`数据目录 ${job.data_dir}`); + } + if (job.orbit_dir) { + chips.push(`轨道目录 ${job.orbit_dir}`); + } + if (job.error_message) { + chips.push(`错误 ${job.error_message}`); + } + + for (const text of chips) { + el.jobMeta.appendChild(makeChip(text)); + } + for (const [status, count] of Object.entries(counts.product)) { + el.jobMeta.appendChild(makeChip(`产品 ${status} ${count}`)); + } + for (const [status, count] of Object.entries(counts.orbit)) { + el.jobMeta.appendChild(makeChip(`轨道 ${status} ${count}`)); + } + + if (!scenes.length) { + el.sceneTableBody.innerHTML = '暂无场景'; + } else { + for (const scene of scenes) { + const row = document.createElement("tr"); + + const sceneCell = document.createElement("td"); + sceneCell.className = "mono"; + sceneCell.textContent = scene.scene_id; + row.appendChild(sceneCell); + + const startCell = document.createElement("td"); + startCell.textContent = scene.start_time || ""; + row.appendChild(startCell); + + const levelCell = document.createElement("td"); + levelCell.textContent = scene.processing_level || ""; + row.appendChild(levelCell); + + const orbitCell = document.createElement("td"); + orbitCell.textContent = formatRelativeOrbit(scene.relative_orbit); + row.appendChild(orbitCell); + + const productCell = document.createElement("td"); + productCell.appendChild(makeStatusPill(scene.product_status)); + row.appendChild(productCell); + + const eofCell = document.createElement("td"); + eofCell.appendChild(makeStatusPill(scene.orbit_status)); + row.appendChild(eofCell); + + const sizeCell = document.createElement("td"); + sizeCell.textContent = formatMb(scene.size_mb); + row.appendChild(sizeCell); + + el.sceneTableBody.appendChild(row); + } + } + + if (!events.length) { + renderEmpty(el.eventList, "暂无事件"); + } else { + for (const event of events) { + const div = document.createElement("div"); + div.className = "event"; + div.innerHTML = ` +
${escapeHtml(event.level)}
+
${escapeHtml(event.created_at)}
+
${escapeHtml(event.message)}
+ `; + el.eventList.appendChild(div); + } + } + + syncPolling(); +} + +function updateSelectionSummary() { + el.selectionSummary.innerHTML = ""; + const project = currentProject(); + if (project) { + el.selectionSummary.appendChild(makeChip(`项目 ${project.name}`)); + } + 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) { + el.selectionSummary.appendChild(makeChip(`波束 ${el.beamMode.value}`)); + } + if (state.selectedJobId) { + el.selectionSummary.appendChild(makeChip(`当前任务 #${state.selectedJobId}`)); + } +} + +async function loadHealth() { + try { + const health = await api.health(); + setHealth(true, "服务已连接", health.db_path); + } catch (error) { + setHealth(false, "服务不可用", error.message || String(error)); + throw error; + } +} + +async function loadEarthdataSettings() { + state.earthdataSettings = await api.earthdataSettings(); + if (state.earthdataSettings && state.earthdataSettings.username && !el.earthdataUser.value) { + el.earthdataUser.value = state.earthdataSettings.username; + } + renderEarthdataSummary(); +} + +async function loadProjects() { + state.projects = await api.projects(); + if (!state.selectedProjectId && state.projects.length) { + state.selectedProjectId = state.projects[0].id; + autoFillProjectPaths(state.projects[0]); + } else { + const project = currentProject(); + if (project) { + autoFillProjectPaths(project); + } + } + renderProjects(); + updateSelectionSummary(); +} + +async function loadRegions() { + const keyword = el.regionKeyword.value.trim(); + if (!keyword) { + state.regions = []; + state.selectedRegion = null; + state.selectedRegionDetail = null; + renderRegions(); + renderRegionDetail(); + updateSelectionSummary(); + return; + } + + state.regions = await api.searchRegions(keyword); + state.selectedRegion = state.regions[0] || null; + renderRegions(); + if (state.selectedRegion) { + await loadRegionDetail(state.selectedRegion.adcode); + } else { + state.selectedRegionDetail = null; + renderRegionDetail(); + } + updateSelectionSummary(); +} + +async function loadRegionDetail(adcode) { + state.selectedRegionDetail = await api.regionDetail(adcode); + renderRegionDetail(); +} + +async function loadJobs() { + state.jobs = await api.jobs(state.selectedProjectId); + if (state.selectedJobId && !state.jobs.some((job) => job.id === state.selectedJobId)) { + state.selectedJobId = null; + state.selectedJobDetail = null; + } + renderJobs(); + updateSelectionSummary(); +} + +async function loadSelectedJobDetail() { + if (!state.selectedJobId) { + state.selectedJobDetail = null; + renderJobDetail(); + return; + } + state.selectedJobDetail = await api.job(state.selectedJobId); + const updatedJob = state.selectedJobDetail.job; + state.jobs = state.jobs.map((job) => (job.id === updatedJob.id ? updatedJob : job)); + renderJobs(); + renderJobDetail(); + updateSelectionSummary(); +} + +async function importCatalog() { + const payload = { + source_path: el.catalogPath.value.trim() || DEFAULT_CATALOG_PATH, + simplify_tolerance: Number(el.simplifyTolerance.value || 0.01), + }; + const result = await api.importRegions(payload); + setMessage(`行政区已导入:${result.imported}/${result.total_features}`, "success"); + await loadRegions(); +} + +async function saveEarthdataSettings() { + const username = el.earthdataUser.value.trim(); + const password = el.earthdataPass.value; + if (!username || !password) { + throw new Error("保存 Earthdata 凭据时必须填写用户名和密码"); + } + const response = await api.saveEarthdataSettings({ username, password }); + state.earthdataSettings = response; + el.earthdataPass.value = ""; + renderEarthdataSummary(); + setMessage("Earthdata 凭据已保存到本地配置", "success"); +} + +async function clearEarthdataSettings() { + if (!window.confirm("确定清除本地保存的 Earthdata 用户名和密码吗?")) { + return; + } + await api.clearEarthdataSettings(); + state.earthdataSettings = { username: null, has_password: false }; + el.earthdataUser.value = ""; + el.earthdataPass.value = ""; + renderEarthdataSummary(); + setMessage("Earthdata 本地凭据已清除", "success"); +} + +async function createProject() { + const payload = { + name: el.projectName.value.trim(), + data_root: el.projectPath.value.trim(), + description: emptyToNull(el.projectDesc.value), + }; + if (!payload.name || !payload.data_root) { + throw new Error("项目名称和数据根目录不能为空"); + } + const project = await api.createProject(payload); + state.selectedProjectId = project.id; + setMessage(`项目已创建:${project.name}`, "success"); + await loadProjects(); + await loadJobs(); +} + +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, + beam_mode: emptyToNull(el.beamMode.value), + flight_direction: emptyToNull(el.flightDirection.value), + polarization: emptyToNull(el.polarization.value), + relative_orbit: numberOrNull(el.relativeOrbit.value), + max_results: Number(el.maxResults.value || 20), + manifest_path: emptyToNull(el.manifestPath.value), + raw_region: el.rawRegion.checked, + }; + + const response = await api.createSearchJob(payload); + state.selectedJobId = response.job.id; + state.selectedJobDetail = { + job: response.job, + scenes: response.scenes, + events: [], + }; + setMessage(`检索完成:job #${response.job.id},场景 ${response.scenes.length}`, "success"); + await loadJobs(); + await loadSelectedJobDetail(); +} + +async function queueDownloadJob() { + if (!state.selectedJobId) { + throw new Error("请先选择任务"); + } + + const payload = { + job_id: state.selectedJobId, + data_dir: emptyToNull(el.dataDir.value), + orbit_dir: emptyToNull(el.orbitDir.value), + skip_data: el.skipData.value === "true", + skip_orbits: el.skipOrbits.value === "true", + processes: Number(el.downloadProcesses.value || 2), + ignore_orbit_errors: el.ignoreOrbitErrors.checked, + username: emptyToNull(el.earthdataUser.value), + password: emptyToNull(el.earthdataPass.value), + }; + + const response = await api.createDownloadJob(payload); + setMessage(`下载任务已排队:job #${response.job.id}`, "success"); + await loadSelectedJobDetail(); +} + +async function deleteSelectedJob() { + if (!state.selectedJobId) { + throw new Error("请先选择任务"); + } + + const selectedJob = + (state.selectedJobDetail && state.selectedJobDetail.job) || + state.jobs.find((job) => job.id === state.selectedJobId) || + null; + const isRunning = Boolean(selectedJob && selectedJob.status === "running"); + const message = isRunning + ? "当前任务仍显示为运行中。强制删除只会清理记录,不能保证后台下载立刻停止。确定继续删除吗?" + : `确定删除任务 #${state.selectedJobId} 吗?`; + + if (!window.confirm(message)) { + return; + } + + const deletedJobId = state.selectedJobId; + const response = await api.deleteJob(deletedJobId, isRunning); + state.selectedJobId = null; + state.selectedJobDetail = null; + state.jobs = state.jobs.filter((job) => job.id !== deletedJobId); + renderJobs(); + renderJobDetail(); + updateSelectionSummary(); + setMessage(`已删除 ${response.deleted_count} 个任务`, "success"); +} + +async function clearCurrentJobs() { + if (!state.selectedProjectId) { + throw new Error("请先选择项目"); + } + + const project = currentProject(); + const projectName = project ? project.name : `#${state.selectedProjectId}`; + if (!window.confirm(`确定清空项目“${projectName}”下的非运行任务吗?`)) { + return; + } + + const response = await api.clearJobs(state.selectedProjectId, false); + const deletedIds = new Set(response.deleted_job_ids || []); + if (state.selectedJobId && deletedIds.has(state.selectedJobId)) { + state.selectedJobId = null; + state.selectedJobDetail = null; + } + state.jobs = state.jobs.filter((job) => !deletedIds.has(job.id)); + renderJobs(); + renderJobDetail(); + updateSelectionSummary(); + setMessage(`已清空 ${response.deleted_count} 个任务`, "success"); +} + +function stopPolling() { + if (state.pollTimer) { + window.clearInterval(state.pollTimer); + state.pollTimer = null; + } +} + +function syncPolling() { + const activeJob = state.selectedJobDetail && state.selectedJobDetail.job; + if (!activeJob || FINAL_STATUSES.has(activeJob.status)) { + stopPolling(); + return; + } + if (state.pollTimer) { + return; + } + state.pollTimer = window.setInterval(async () => { + if (!state.selectedJobId) { + stopPolling(); + return; + } + try { + await loadSelectedJobDetail(); + } catch (_) {} + }, 5000); +} + +function runAction(fn, pendingText) { + return async () => { + try { + if (pendingText) { + setMessage(pendingText, "info"); + } + await fn(); + } catch (error) { + setMessage(error.message || String(error), "error"); + } + }; +} + +function bindEvents() { + document.getElementById("saveEarthdataBtn").addEventListener("click", runAction(saveEarthdataSettings, "正在保存 Earthdata 凭据")); + document.getElementById("clearEarthdataBtn").addEventListener("click", runAction(clearEarthdataSettings, "正在清除 Earthdata 凭据")); + document.getElementById("importCatalogBtn").addEventListener("click", runAction(importCatalog, "正在导入行政区")); + document.getElementById("createProjectBtn").addEventListener("click", runAction(createProject, "正在创建项目")); + document.getElementById("refreshProjectsBtn").addEventListener("click", runAction(loadProjects, "正在刷新项目")); + document.getElementById("searchRegionsBtn").addEventListener("click", runAction(loadRegions, "正在查询行政区")); + document.getElementById("clearRegionsBtn").addEventListener("click", () => { + state.regions = []; + state.selectedRegion = null; + state.selectedRegionDetail = null; + el.regionKeyword.value = ""; + renderRegions(); + renderRegionDetail(); + updateSelectionSummary(); + setMessage("行政区查询已清空", "info"); + }); + document.getElementById("runSearchBtn").addEventListener("click", runAction(createSearchJob, "正在检索 Sentinel-1")); + document.getElementById("queueDownloadBtn").addEventListener("click", runAction(queueDownloadJob, "正在排队下载任务")); + document.getElementById("deleteJobBtn").addEventListener("click", runAction(deleteSelectedJob, "正在删除任务")); + document.getElementById("clearJobsBtn").addEventListener("click", runAction(clearCurrentJobs, "正在清空任务")); + document.getElementById("refreshJobsBtn").addEventListener("click", runAction(async () => { + await loadJobs(); + await loadSelectedJobDetail(); + }, "正在刷新任务")); + document.getElementById("refreshDetailBtn").addEventListener("click", runAction(loadSelectedJobDetail, "正在刷新任务详情")); +} + +async function bootstrap() { + bindEvents(); + if (!el.catalogPath.value) { + el.catalogPath.value = DEFAULT_CATALOG_PATH; + } + el.healthDetail.textContent = window.location.origin; + setMessage("正在初始化工作台", "info"); + await loadHealth(); + await loadEarthdataSettings(); + await loadProjects(); + await loadRegions(); + await loadJobs(); + await loadSelectedJobDetail(); + setMessage("工作台已就绪", "success"); +} + +bootstrap().catch((error) => { + setMessage(error.message || String(error), "error"); +}); diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html new file mode 100644 index 0000000..4e17097 --- /dev/null +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html @@ -0,0 +1,271 @@ + + + + + + Sentinel Orbit Downloader + + + +
+ + +
+
+
+

任务工作台

+

按行政区和时间检索 Sentinel-1,并统一管理数据与精密轨道下载。

+
+
等待操作
+
+ +
+
+
+

行政区选择

+
+ + +
+
+
+ + +
+
+
+
未选择行政区
+
+
+ +
+
+

检索参数

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+
+
+

下载参数

+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ 下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。 +
+
+ +
+
+

任务

+
+ + + + +
+
+
+
+
+
+ +
+
+

任务详情

+
+
+
+
+ + + + + + + + + + + + + +
Scene开始时间级别相对轨道产品轨道大小
+
+
+
+
+
+
+ + + + diff --git a/python/s1-orbit-downloader/uv.lock b/python/s1-orbit-downloader/uv.lock new file mode 100644 index 0000000..790e06e --- /dev/null +++ b/python/s1-orbit-downloader/uv.lock @@ -0,0 +1,612 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.14" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "asf-search" +version = "12.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dateparser" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "shapely" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/07/874419685c2e79950a8bb2adff2fe079af8a4d77a9a579a291ec6662fd53/asf_search-12.0.7.tar.gz", hash = "sha256:e9ad6e87da7ed000547228308c05653a741eeb7020a3c080d67ae3a91c161e72", size = 1408830, upload-time = "2026-04-13T19:22:34.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f4/c52dad8cbf90b16e7c891df44a14584559e7ee0ddf906bb357cc415b3a26/asf_search-12.0.7-py3-none-any.whl", hash = "sha256:9e0d644f49e36cb5414982ca5363cba49cb9e6e02ea4efbe040f2f4054ae31f4", size = 121238, upload-time = "2026-04-13T19:22:32.769Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dateparser" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, + { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, + { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, + { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, + { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, + { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, + { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, + { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, + { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, + { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, + { 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 = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +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 = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "regex" +version = "2026.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" }, + { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" }, + { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" }, + { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" }, + { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" }, + { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" }, + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "s1-orbits" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/8b/3ba0f9f8d5bf760e8a649516dfde46183b3f4649fabe1d71c1a5596cdc87/s1_orbits-0.2.0.tar.gz", hash = "sha256:9ff7561c7fb76490b6a0c232e61f64226899c8424f49ddd05825eb166d0daeb3", size = 8953, upload-time = "2025-07-22T18:34:12.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/0a/0e7d9741406a044f01a918894b850067c9c7b7e92e17c533cf5fdcac4fb4/s1_orbits-0.2.0-py3-none-any.whl", hash = "sha256:c1d2c42f8d99628efd21b5445fa8d0c9f9d2f575c28d30a29dd42ff305df99c5", size = 4989, upload-time = "2025-07-22T18:34:12.005Z" }, +] + +[[package]] +name = "sentinel-orbit-downloader" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "asf-search" }, + { name = "fastapi" }, + { name = "s1-orbits" }, + { name = "shapely" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "asf-search", specifier = ">=12.0.7" }, + { name = "fastapi", specifier = ">=0.116.1" }, + { name = "s1-orbits", specifier = ">=0.2.0" }, + { name = "shapely", specifier = ">=2.1.2" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8d/1ff672dea9ec6a7b5d422eb6d095ed886e2e523733329f75fdcb14ee1149/shapely-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:91121757b0a36c9aac3427a651a7e6567110a4a67c97edf04f8d55d4765f6618", size = 1820038, upload-time = "2025-09-24T13:50:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ce/28fab8c772ce5db23a0d86bf0adaee0c4c79d5ad1db766055fa3dab442e2/shapely-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16a9c722ba774cf50b5d4541242b4cce05aafd44a015290c82ba8a16931ff63d", size = 1626039, upload-time = "2025-09-24T13:50:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/70/8b/868b7e3f4982f5006e9395c1e12343c66a8155c0374fdc07c0e6a1ab547d/shapely-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cc4f7397459b12c0b196c9efe1f9d7e92463cbba142632b4cc6d8bbbbd3e2b09", size = 3001519, upload-time = "2025-09-24T13:50:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/13/02/58b0b8d9c17c93ab6340edd8b7308c0c5a5b81f94ce65705819b7416dba5/shapely-2.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136ab87b17e733e22f0961504d05e77e7be8c9b5a8184f685b4a91a84efe3c26", size = 3110842, upload-time = "2025-09-24T13:50:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/af/61/8e389c97994d5f331dcffb25e2fa761aeedfb52b3ad9bcdd7b8671f4810a/shapely-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:16c5d0fc45d3aa0a69074979f4f1928ca2734fb2e0dde8af9611e134e46774e7", size = 4021316, upload-time = "2025-09-24T13:50:23.626Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/9b2a9fe6039f9e42ccf2cb3e84f219fd8364b0c3b8e7bbc857b5fbe9c14c/shapely-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ddc759f72b5b2b0f54a7e7cde44acef680a55019eb52ac63a7af2cf17cb9cd2", size = 4178586, upload-time = "2025-09-24T13:50:25.443Z" }, + { url = "https://files.pythonhosted.org/packages/16/f6/9840f6963ed4decf76b08fd6d7fed14f8779fb7a62cb45c5617fa8ac6eab/shapely-2.1.2-cp311-cp311-win32.whl", hash = "sha256:2fa78b49485391224755a856ed3b3bd91c8455f6121fee0db0e71cefb07d0ef6", size = 1543961, upload-time = "2025-09-24T13:50:26.968Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/3f8ea46353c2a33c1669eb7327f9665103aa3a8dfe7f2e4ef714c210b2c2/shapely-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:c64d5c97b2f47e3cd9b712eaced3b061f2b71234b3fc263e0fcf7d889c6559dc", size = 1722856, upload-time = "2025-09-24T13:50:28.497Z" }, + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, +] From 838ae9caa1d003ab0ab6cb96e7540305a1e68e82 Mon Sep 17 00:00:00 2001 From: Harmon Date: Tue, 28 Apr 2026 15:47:25 +0800 Subject: [PATCH 2/3] Add AOI upload search and resilient downloads --- .../s1-orbit-downloader/Start-SentinelUI.ps1 | 62 +++- .../s1-orbit-downloader/Stop-SentinelUI.ps1 | 34 +- python/s1-orbit-downloader/pyproject.toml | 3 + .../api/routes_search.py | 42 ++- .../sentinel_orbit_downloader/api/schemas.py | 12 +- .../src/sentinel_orbit_downloader/cli.py | 83 ++--- .../sentinel_orbit_downloader/services/aoi.py | 325 +++++++++++++++-- .../services/download_manager.py | 329 +++++++++++++++++- .../services/orbit_client.py | 3 + .../src/sentinel_orbit_downloader/web/app.js | 100 +++++- .../sentinel_orbit_downloader/web/index.html | 27 +- python/s1-orbit-downloader/uv.lock | 71 ++++ 12 files changed, 970 insertions(+), 121 deletions(-) diff --git a/python/s1-orbit-downloader/Start-SentinelUI.ps1 b/python/s1-orbit-downloader/Start-SentinelUI.ps1 index f39c1b1..40fd212 100644 --- a/python/s1-orbit-downloader/Start-SentinelUI.ps1 +++ b/python/s1-orbit-downloader/Start-SentinelUI.ps1 @@ -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) { diff --git a/python/s1-orbit-downloader/Stop-SentinelUI.ps1 b/python/s1-orbit-downloader/Stop-SentinelUI.ps1 index b2a845b..6d6c624 100644 --- a/python/s1-orbit-downloader/Stop-SentinelUI.ps1 +++ b/python/s1-orbit-downloader/Stop-SentinelUI.ps1 @@ -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" @@ -36,7 +59,16 @@ if ($process) { Write-Info "Stopping UI process $($process.Id)" Stop-Process -Id $process.Id -Force } else { - Write-Info "Tracked process is not running." + $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 diff --git a/python/s1-orbit-downloader/pyproject.toml b/python/s1-orbit-downloader/pyproject.toml index 1897c28..d92a5e8 100644 --- a/python/s1-orbit-downloader/pyproject.toml +++ b/python/s1-orbit-downloader/pyproject.toml @@ -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", diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py index 7f62197..fdf8284 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_search.py @@ -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, } diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py index 67ab8bc..d014e8a 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/schemas.py @@ -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 diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py index 9c626d7..29ec9f8 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/cli.py @@ -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( - 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) + password = args.password + if args.ask_password and not args.skip_data: + password = getpass.getpass("Earthdata password: ") - 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), + 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=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: diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py index 6edf331..e89c6be 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/aoi.py @@ -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 - - data = json.loads(text) - geometry = geojson_geometry(data) - return geometry_to_wkt(geometry) + if text[0] in "{[": + return parse_geojson_text(path.name, text).wkt + return text -def geojson_geometry(data: dict[str, Any]) -> dict[str, Any]: +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 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 + + +def reproject_shapefile_geometry(geometry: BaseGeometry, prj_content: bytes | None) -> BaseGeometry: + if not prj_content: + validate_lon_lat_bounds(geometry) return geometry - if kind in {"Polygon", "MultiPolygon", "Point", "MultiPoint", "LineString"}: - return data - raise ValueError(f"unsupported GeoJSON type: {kind}") + + 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 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 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 coords_to_text(coords: Iterable[Sequence[float]]) -> str: - return ",".join(f"{lon} {lat}" for lon, lat, *_ in coords) +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") diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py index 79bbfb5..ca3d092 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/download_manager.py @@ -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) + session = build_session(username=request.username, password=request.password) + 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}", ) - 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) + 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", + ) 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" diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py index a07c621..e1f9757 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/services/orbit_client.py @@ -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 diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js index 354c28c..1e2ae66 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/app.js @@ -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,8 +558,16 @@ function updateSelectionSummary() { if (project) { el.selectionSummary.appendChild(makeChip(`项目 ${project.name}`)); } - if (state.selectedRegion) { - el.selectionSummary.appendChild(makeChip(`行政区 ${state.selectedRegion.label}`)); + 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}`)); @@ -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(); diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html index 4e17097..648d685 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/web/index.html @@ -95,6 +95,27 @@

检索参数

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ 支持 `.geojson`、`.json`、`.wkt`、`.txt`、zip shapefile,或同时选择 `.shp`、`.shx`、`.dbf`、`.prj` 等文件。带 `.prj` 的 shapefile 会自动转为 WGS84,经纬度 GeoJSON 会自动合并成一个 AOI 范围。 +
@@ -193,8 +214,8 @@
- - + +
@@ -219,7 +240,7 @@
- 下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。 + 下载原始 Sentinel-1 产品必须提供 Earthdata 用户名和密码。你可以手填后直接下载,也可以先保存到本地配置。产品下载支持完整文件跳过、.part 断点续传和下载后校验。当前阶段只下载原始产品包和匹配的 EOF 轨道文件,暂不包含 GeoTIFF 处理。
diff --git a/python/s1-orbit-downloader/uv.lock b/python/s1-orbit-downloader/uv.lock index 790e06e..3a14d96 100644 --- a/python/s1-orbit-downloader/uv.lock +++ b/python/s1-orbit-downloader/uv.lock @@ -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" }, From 3d2a34a4b34fc5c9b2f1e341598afcc1142f5102 Mon Sep 17 00:00:00 2001 From: Harmon Date: Wed, 29 Apr 2026 00:28:49 +0800 Subject: [PATCH 3/3] Fix Sentinel UI shutdown flow --- .../s1-orbit-downloader/Start-SentinelUI.ps1 | 107 ++++++++++++++---- .../s1-orbit-downloader/Stop-SentinelUI.ps1 | 95 ++++++++++++++-- .../src/sentinel_orbit_downloader/api/app.py | 7 +- .../api/routes_health.py | 32 +++++- .../sentinel_orbit_downloader/core/config.py | 7 ++ 5 files changed, 211 insertions(+), 37 deletions(-) diff --git a/python/s1-orbit-downloader/Start-SentinelUI.ps1 b/python/s1-orbit-downloader/Start-SentinelUI.ps1 index 40fd212..5d36ac1 100644 --- a/python/s1-orbit-downloader/Start-SentinelUI.ps1 +++ b/python/s1-orbit-downloader/Start-SentinelUI.ps1 @@ -94,6 +94,51 @@ function Get-ListeningProcessId { return 0 } +function Invoke-UiShutdown { + param( + [string]$Url, + [string]$ShutdownToken + ) + + if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) { + return $false + } + + try { + $headers = @{ + "X-S1DL-Shutdown-Token" = $ShutdownToken + } + $shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown" + $null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop + return $true + } catch { + return $false + } +} + +function Wait-ForUiShutdown { + param( + [int]$ProcessId, + [int]$Port, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $running = Get-RunningProcess -Id $ProcessId + $listenerProcessId = 0 + if ($Port -gt 0) { + $listenerProcessId = Get-ListeningProcessId -Port $Port + } + if (-not $running -and $listenerProcessId -le 0) { + return $true + } + Start-Sleep -Milliseconds 500 + } + + return $false +} + function Load-State { param([string]$Path) @@ -113,14 +158,16 @@ function Save-State { [string]$Path, [int]$ProcessId, [int]$Port, - [string]$Url + [string]$Url, + [string]$ShutdownToken ) $payload = [ordered]@{ - pid = $ProcessId - port = $Port - url = $Url - started_at = (Get-Date).ToString("s") + pid = $ProcessId + port = $Port + url = $Url + shutdown_token = $ShutdownToken + started_at = (Get-Date).ToString("s") } $payload | ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding UTF8 @@ -157,11 +204,21 @@ 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 + $existingStopped = $false + if ($state.url -and $state.shutdown_token) { + Write-Info "Requesting shutdown for previous tracked UI" + if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) { + $existingStopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port) + } + } + + if (-not $existingStopped) { + $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 } @@ -183,7 +240,7 @@ foreach ($port in $PreferredPorts) { if (Test-Health -Port $port) { $url = "http://127.0.0.1:$port/" Write-Info "Found an existing running UI at $url" - Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url + Save-State -Path $statePath -ProcessId 0 -Port $port -Url $url -ShutdownToken "" if (-not $NoBrowser) { Start-Process $url | Out-Null } @@ -206,6 +263,7 @@ if (-not (Test-Path -LiteralPath $pythonPath)) { $portToUse = Get-FreePort -Candidates $PreferredPorts $url = "http://127.0.0.1:$portToUse/" +$shutdownToken = [guid]::NewGuid().ToString("N") if (Test-Path -LiteralPath $stdoutPath) { Remove-Item -LiteralPath $stdoutPath -Force @@ -225,14 +283,19 @@ $arguments = @( ) Write-Info "Starting UI at $url" -$process = Start-Process ` - -FilePath $pythonPath ` - -ArgumentList $arguments ` - -WorkingDirectory $projectRoot ` - -WindowStyle Hidden ` - -RedirectStandardOutput $stdoutPath ` - -RedirectStandardError $stderrPath ` - -PassThru +try { + $env:S1DL_UI_SHUTDOWN_TOKEN = $shutdownToken + $process = Start-Process ` + -FilePath $pythonPath ` + -ArgumentList $arguments ` + -WorkingDirectory $projectRoot ` + -WindowStyle Hidden ` + -RedirectStandardOutput $stdoutPath ` + -RedirectStandardError $stderrPath ` + -PassThru +} finally { + Remove-Item Env:S1DL_UI_SHUTDOWN_TOKEN -ErrorAction SilentlyContinue +} $started = $false for ($i = 0; $i -lt 30; $i++) { @@ -261,12 +324,12 @@ if (-not $started) { throw "UI failed to start." } -Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url +Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken $listenerProcessId = Get-ListeningProcessId -Port $portToUse if ($listenerProcessId -gt 0) { - Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url + Save-State -Path $statePath -ProcessId $listenerProcessId -Port $portToUse -Url $url -ShutdownToken $shutdownToken } else { - Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url + Save-State -Path $statePath -ProcessId $process.Id -Port $portToUse -Url $url -ShutdownToken $shutdownToken } Write-Info "UI is ready: $url" diff --git a/python/s1-orbit-downloader/Stop-SentinelUI.ps1 b/python/s1-orbit-downloader/Stop-SentinelUI.ps1 index 6d6c624..92d2e50 100644 --- a/python/s1-orbit-downloader/Stop-SentinelUI.ps1 +++ b/python/s1-orbit-downloader/Stop-SentinelUI.ps1 @@ -44,6 +44,51 @@ function Get-ListeningProcessId { return 0 } +function Invoke-UiShutdown { + param( + [string]$Url, + [string]$ShutdownToken + ) + + if ([string]::IsNullOrWhiteSpace($Url) -or [string]::IsNullOrWhiteSpace($ShutdownToken)) { + return $false + } + + try { + $headers = @{ + "X-S1DL-Shutdown-Token" = $ShutdownToken + } + $shutdownUrl = $Url.TrimEnd("/") + "/api/shutdown" + $null = Invoke-RestMethod -Uri $shutdownUrl -Method Post -Headers $headers -TimeoutSec 5 -ErrorAction Stop + return $true + } catch { + return $false + } +} + +function Wait-ForUiShutdown { + param( + [int]$ProcessId, + [int]$Port, + [int]$TimeoutSeconds = 10 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $running = Get-RunningProcess -Id $ProcessId + $listenerProcessId = 0 + if ($Port -gt 0) { + $listenerProcessId = Get-ListeningProcessId -Port $Port + } + if (-not $running -and $listenerProcessId -le 0) { + return $true + } + Start-Sleep -Milliseconds 500 + } + + return $false +} + $projectRoot = Split-Path -Parent $PSCommandPath $statePath = Join-Path $projectRoot ".s1dl\web-ui-state.json" @@ -53,23 +98,49 @@ if (-not (Test-Path -LiteralPath $statePath)) { } $state = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json -$process = Get-RunningProcess -Id ([int]$state.pid) +$stopped = $false -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 ($state.url -and $state.shutdown_token) { + Write-Info "Requesting UI shutdown via API" + if (Invoke-UiShutdown -Url $state.url -ShutdownToken $state.shutdown_token) { + $stopped = Wait-ForUiShutdown -ProcessId ([int]$state.pid) -Port ([int]$state.port) } - if ($listenerProcessId -gt 0) { - Write-Info "Stopping listener process $listenerProcessId" - Stop-Process -Id $listenerProcessId -Force +} + +if (-not $stopped) { + $process = Get-RunningProcess -Id ([int]$state.pid) + + if ($process) { + Write-Info "Stopping UI process $($process.Id)" + try { + Stop-Process -Id $process.Id -Force -ErrorAction Stop + $stopped = $true + } catch { + throw "Failed to stop UI process $($process.Id). It may have been started from an elevated shell. Start or stop it with the same privilege level, or restart it with the updated launcher once so it can use API shutdown. $($_.Exception.Message)" + } } else { - Write-Info "Tracked process is not running." + $listenerProcessId = 0 + if ($state.port) { + $listenerProcessId = Get-ListeningProcessId -Port ([int]$state.port) + } + if ($listenerProcessId -gt 0) { + Write-Info "Stopping listener process $listenerProcessId" + try { + Stop-Process -Id $listenerProcessId -Force -ErrorAction Stop + $stopped = $true + } catch { + throw "Failed to stop UI listener process $listenerProcessId. It may have been started from an elevated shell. Start or stop it with the same privilege level, or restart it with the updated launcher once so it can use API shutdown. $($_.Exception.Message)" + } + } else { + Write-Info "Tracked process is not running." + $stopped = $true + } } } +if (-not $stopped) { + throw "UI shutdown did not complete." +} + Remove-Item -LiteralPath $statePath -Force Write-Info "Done." diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py index 3e65c8a..3a67272 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/app.py @@ -14,7 +14,11 @@ from sentinel_orbit_downloader.api.routes_projects import router as projects_rou from sentinel_orbit_downloader.api.routes_regions import router as regions_router from sentinel_orbit_downloader.api.routes_search import router as search_router from sentinel_orbit_downloader.api.routes_settings import router as settings_router -from sentinel_orbit_downloader.core.config import configured_api_host, configured_api_port +from sentinel_orbit_downloader.core.config import ( + configured_api_host, + configured_api_port, + configured_ui_shutdown_token, +) def create_app() -> FastAPI: @@ -23,6 +27,7 @@ def create_app() -> FastAPI: version="0.1.0", description="Local API for Sentinel-1 search, orbit matching, and task management.", ) + app.state.shutdown_token = configured_ui_shutdown_token() web_root = Path(__file__).resolve().parents[1] / "web" app.mount("/static", StaticFiles(directory=web_root), name="static") diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py index 99cbdb7..d5c9c33 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/api/routes_health.py @@ -1,14 +1,42 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +import os +import signal +import time + +from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status from sentinel_orbit_downloader.api.deps import get_db_path -from sentinel_orbit_downloader.api.schemas import HealthResponse +from sentinel_orbit_downloader.api.schemas import HealthResponse, MessageResponse router = APIRouter(tags=["health"]) +def _terminate_current_process() -> None: + time.sleep(0.2) + try: + os.kill(os.getpid(), signal.SIGTERM) + except Exception: + os._exit(0) + + @router.get("/health", response_model=HealthResponse) def health(db_path=Depends(get_db_path)) -> HealthResponse: return HealthResponse(status="ok", db_path=str(db_path)) + + +@router.post("/shutdown", response_model=MessageResponse, include_in_schema=False) +def shutdown( + background_tasks: BackgroundTasks, + request: Request, + x_s1dl_shutdown_token: str | None = Header(default=None, alias="X-S1DL-Shutdown-Token"), +) -> MessageResponse: + expected_token = getattr(request.app.state, "shutdown_token", None) + if not expected_token: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Shutdown is disabled.") + if x_s1dl_shutdown_token != expected_token: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid shutdown token.") + + background_tasks.add_task(_terminate_current_process) + return MessageResponse(message="Shutdown requested.") diff --git a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py index aaf6659..e25f593 100644 --- a/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py +++ b/python/s1-orbit-downloader/src/sentinel_orbit_downloader/core/config.py @@ -25,3 +25,10 @@ def configured_api_port() -> int: if not raw: return DEFAULT_API_PORT return int(raw) + + +def configured_ui_shutdown_token() -> str | None: + raw = os.getenv("S1DL_UI_SHUTDOWN_TOKEN") + if not raw: + return None + return raw