Files
insar-management-system-v2/backend/app/services/spatial_service.py
T

2916 lines
124 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
空间计算服务 - 纯 PostGIS 实现
将所有空间计算下放到数据库层,利用 PostGIS 的高效空间索引。
"""
import hashlib
import json
import logging
import math
import re
import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from itertools import combinations
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import and_, case, cast, func, or_, text
from sqlalchemy.orm import aliased
from geoalchemy2 import Geography
from geoalchemy2.shape import to_shape
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers
from shapely.geometry import Polygon, mapping, shape
from shapely.ops import unary_union
from ..models import (
DinsarProductionRunItemORM,
DinsarProductionRunORM,
HazardPoint,
HazardPointORM,
PairingNetworkEdgeORM,
PairingNetworkRunORM,
PairingMetricCacheORM,
PairingRequest,
PsRequest,
RadarData,
RadarDataORM,
RadarPair,
ResultProductORM,
TimeseriesStackPlanEdgeORM,
TimeseriesStackPlanItemORM,
TimeseriesStackPlanORM,
)
from .dinsar_naming import build_pair_key, build_task_alias, ensure_unique_task_aliases
from .pairing_state_service import pairing_state_service
PAIRING_POLICY_VERSION = "2026.06.dinsar-production.v1"
PAIRING_WARNING_CANDIDATE_THRESHOLD = 3000
PAIRING_ALL_STRATEGY_HARD_LIMIT = 20000
logger = logging.getLogger(__name__)
def _normalized_satellite_family_expr(alias):
compact_satellite = func.upper(
func.replace(
func.replace(
func.replace(func.coalesce(alias.satellite, ""), "-", ""),
"_",
"",
),
" ",
"",
)
)
inferred_family = case(
(
compact_satellite.in_(
["LT1", "LT1A", "LT1B", "LUTAN1", "LUTAN1A", "LUTAN1B"]
),
"LT1",
),
(
compact_satellite.in_(
[
"S1",
"S1A",
"S1B",
"S1C",
"SENTINEL1",
"SENTINEL1A",
"SENTINEL1B",
"SENTINEL1C",
]
),
"S1",
),
else_=func.upper(alias.satellite),
)
return func.coalesce(func.nullif(func.upper(alias.satellite_family), ""), inferred_family)
def _same_relative_orbit_expr(left_alias, right_alias):
left_relative_orbit = func.upper(func.trim(func.coalesce(left_alias.relative_orbit, "")))
right_relative_orbit = func.upper(func.trim(func.coalesce(right_alias.relative_orbit, "")))
return and_(
left_relative_orbit != "",
right_relative_orbit != "",
left_relative_orbit == right_relative_orbit,
)
class SpatialService:
"""
纯 PostGIS 空间计算服务
利用 PostGIS 空间索引执行高效的地理计算,
比 Python Shapely 快 10-100 倍。
"""
async def find_dinsar_pairs(
self,
db: AsyncSession,
params: PairingRequest,
aoi_wkt: Optional[str] = None,
require_orbit_data: bool = True,
) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]:
"""
基于 pairing_metric_cache 的统一配对入口。
该路径不再静默回退到另一套 Python/SQL 语义,而是只使用候选缓存层。
当缓存处于 DIRTY/DEGRADED 状态时允许返回降级结果,但会明确告警。
"""
warnings: List[str] = []
effective_params = self._normalize_pairing_request(params)
pairing_status = await pairing_state_service.get_pairing_system_status(db)
cache_status = str(pairing_status.get("status") or "UNINITIALIZED")
scene_count = int(pairing_status.get("scene_count") or 0)
pair_count = int(pairing_status.get("pair_count") or 0)
degraded = bool(pairing_status.get("needs_rebuild"))
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
raise RuntimeError(
"配对候选缓存当前不可用,请先在生产规划页执行“修复配对基础”或“强制全量重建”。"
)
if degraded:
warnings.append(
f"配对候选缓存当前状态为 {cache_status},本次结果基于现有缓存生成,建议尽快在生产规划页执行缓存修复。"
)
candidate_pool = await self._query_pairing_metric_cache(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
if len(candidate_pool) > PAIRING_ALL_STRATEGY_HARD_LIMIT:
raise RuntimeError(
f"全部配对命中 {len(candidate_pool)} 条候选边,超过系统一次性返回上限 "
f"{PAIRING_ALL_STRATEGY_HARD_LIMIT}。请收紧 AOI、日期范围、重叠率或中心距离阈值。"
)
if len(candidate_pool) > PAIRING_WARNING_CANDIDATE_THRESHOLD:
warnings.append(
f"候选配对数超过 {PAIRING_WARNING_CANDIDATE_THRESHOLD}(当前: {len(candidate_pool)}),建议收紧参数或缩小 AOI。"
)
selected_candidates, strategy_warnings = self._apply_dinsar_production_strategy(candidate_pool, effective_params)
warnings.extend(strategy_warnings)
if not selected_candidates:
warnings.extend(
await self._build_empty_pairing_diagnostics(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
)
for candidate in selected_candidates:
candidate["selection_strategy"] = "dinsar_production"
self._ensure_candidate_identity(candidate)
network_run_id = await self._persist_network_run(
db,
params=effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
warnings=warnings,
candidate_pool=candidate_pool,
selected_candidates=selected_candidates,
)
await self._attach_dinsar_production_summaries(db, selected_candidates)
result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates))
metadata = {
"fallback_used": False,
"degraded": degraded,
"policy_version": PAIRING_POLICY_VERSION,
"network_run_id": network_run_id,
"candidate_count": len(candidate_pool),
"selected_edge_count": len(result_pairs),
}
return result_pairs, warnings, metadata
async def find_dinsar_coverage_pairs(
self,
db: AsyncSession,
params: PairingRequest,
*,
target_date_from: str,
target_date_to: str,
extension_days: int = 15,
max_pairs: int = 200,
target_coverage_ratio: float = 0.98,
min_new_coverage_ratio: float = 0.0005,
aoi_wkt: Optional[str] = None,
require_orbit_data: bool = True,
) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]:
warnings: List[str] = []
target_start = self._parse_yyyymmdd(target_date_from, field_name="target_date_from")
target_end = self._parse_yyyymmdd(target_date_to, field_name="target_date_to")
if target_end < target_start:
raise ValueError("target_date_to must be greater than or equal to target_date_from.")
safe_extension_days = max(0, min(180, int(extension_days or 0)))
safe_max_pairs = max(1, min(5000, int(max_pairs or 200)))
safe_target_coverage_ratio = max(0.0, min(1.0, float(target_coverage_ratio or 0.98)))
safe_min_new_coverage_ratio = max(0.0, min(1.0, float(min_new_coverage_ratio or 0.0)))
query_start = target_start - timedelta(days=safe_extension_days)
query_end = target_end + timedelta(days=safe_extension_days)
effective_params = self._normalize_pairing_request(params).model_copy(
update={
"master_date_from": self._format_yyyymmdd(query_start),
"master_date_to": self._format_yyyymmdd(query_end),
"slave_date_from": self._format_yyyymmdd(query_start),
"slave_date_to": self._format_yyyymmdd(query_end),
"strategy": "dinsar_province_coverage",
}
)
pairing_status = await pairing_state_service.get_pairing_system_status(db)
cache_status = str(pairing_status.get("status") or "UNINITIALIZED")
scene_count = int(pairing_status.get("scene_count") or 0)
pair_count = int(pairing_status.get("pair_count") or 0)
degraded = bool(pairing_status.get("needs_rebuild"))
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
raise RuntimeError(
"Pairing candidate cache is not available. Repair or rebuild the pairing foundation first."
)
if degraded:
warnings.append(
f"Pairing foundation status is {cache_status}; coverage plan uses current cached candidates."
)
candidate_pool = await self._query_pairing_metric_cache(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
selected_candidates, coverage_meta, strategy_warnings = self._apply_province_coverage_strategy(
candidate_pool,
target_start=target_start,
target_end=target_end,
query_start=query_start,
query_end=query_end,
max_pairs=safe_max_pairs,
target_coverage_ratio=safe_target_coverage_ratio,
min_new_coverage_ratio=safe_min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
)
warnings.extend(strategy_warnings)
if not selected_candidates:
warnings.extend(
await self._build_empty_pairing_diagnostics(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
)
for candidate in selected_candidates:
candidate["selection_strategy"] = "dinsar_province_coverage"
self._ensure_candidate_identity(candidate)
network_run_id = await self._persist_network_run(
db,
params=effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
warnings=warnings,
candidate_pool=candidate_pool,
selected_candidates=selected_candidates,
)
await self._attach_dinsar_production_summaries(db, selected_candidates)
result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates))
metadata = {
"fallback_used": False,
"degraded": degraded,
"policy_version": PAIRING_POLICY_VERSION,
"network_run_id": network_run_id,
"candidate_count": len(candidate_pool),
"selected_edge_count": len(result_pairs),
"coverage": coverage_meta,
}
return result_pairs, warnings, metadata
def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest:
updates: Dict[str, Any] = {}
if params.aoi_overlap_threshold is not None and float(params.aoi_overlap_threshold) <= 0:
updates["aoi_overlap_threshold"] = None
if params.start_date:
if not params.master_date_from:
updates["master_date_from"] = params.start_date
if not params.slave_date_from:
updates["slave_date_from"] = params.start_date
return params.model_copy(update=updates) if updates else params
async def _query_pairing_metric_cache(
self,
db: AsyncSession,
params: PairingRequest,
*,
aoi_wkt: Optional[str],
require_orbit_data: bool,
) -> List[dict]:
master_alias = aliased(RadarDataORM)
slave_alias = aliased(RadarDataORM)
master_family_expr = _normalized_satellite_family_expr(master_alias)
slave_family_expr = _normalized_satellite_family_expr(slave_alias)
center_distance_expr = func.coalesce(
PairingMetricCacheORM.scene_center_distance_meters,
PairingMetricCacheORM.spatial_baseline_meters,
)
stmt = (
select(PairingMetricCacheORM, master_alias, slave_alias)
.join(master_alias, master_alias.id == PairingMetricCacheORM.master_scene_ref_id)
.join(slave_alias, slave_alias.id == PairingMetricCacheORM.slave_scene_ref_id)
.where(
PairingMetricCacheORM.metric_version == pairing_state_service.metric_version,
PairingMetricCacheORM.status == "READY",
PairingMetricCacheORM.time_baseline_days >= params.time_baseline_min,
PairingMetricCacheORM.time_baseline_days <= params.time_baseline_max,
PairingMetricCacheORM.master_imaging_date < PairingMetricCacheORM.slave_imaging_date,
PairingMetricCacheORM.scene_overlap_ratio >= params.overlap_threshold,
PairingMetricCacheORM.same_look_direction.is_(True),
PairingMetricCacheORM.dinsar_readiness.in_(["RECOMMENDED", "CANDIDATE"]),
)
)
stmt = stmt.where(center_distance_expr <= params.spatial_baseline_max_meters)
stmt = stmt.where(
or_(
and_(master_family_expr == "LT1", slave_family_expr == "LT1"),
and_(master_family_expr == "S1", slave_family_expr == "S1"),
)
)
if require_orbit_data:
stmt = stmt.where(
master_alias.has_orbit_data.is_(True),
slave_alias.has_orbit_data.is_(True),
)
stmt = stmt.where(PairingMetricCacheORM.same_satellite_family.is_(True))
if params.require_same_imaging_mode:
stmt = stmt.where(PairingMetricCacheORM.same_imaging_mode.is_(True))
if params.require_same_polarization:
stmt = stmt.where(PairingMetricCacheORM.same_polarization.is_(True))
stmt = stmt.where(
or_(
master_family_expr != "S1",
slave_family_expr != "S1",
_same_relative_orbit_expr(master_alias, slave_alias),
)
)
if params.allowed_satellites:
allowed_satellites = []
for item in params.allowed_satellites:
compact = str(item).strip().upper().replace("-", "").replace("_", "").replace(" ", "")
if compact in {"LT1", "LT1A", "LT1B", "LUTAN1", "LUTAN1A", "LUTAN1B"}:
allowed_satellites.append("LT1")
elif compact in {"S1", "S1A", "S1B", "S1C", "SENTINEL1", "SENTINEL1A", "SENTINEL1B", "SENTINEL1C"}:
allowed_satellites.append("S1")
allowed_satellites = list(dict.fromkeys(allowed_satellites))
if not allowed_satellites:
return []
stmt = stmt.where(
master_family_expr.in_(allowed_satellites),
slave_family_expr.in_(allowed_satellites),
)
if params.master_date_from:
stmt = stmt.where(PairingMetricCacheORM.master_imaging_date >= params.master_date_from)
if params.master_date_to:
stmt = stmt.where(PairingMetricCacheORM.master_imaging_date <= params.master_date_to)
if params.slave_date_from:
stmt = stmt.where(PairingMetricCacheORM.slave_imaging_date >= params.slave_date_from)
if params.slave_date_to:
stmt = stmt.where(PairingMetricCacheORM.slave_imaging_date <= params.slave_date_to)
if aoi_wkt:
aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326)
aoi_geog = cast(aoi_geom, Geography)
aoi_area = func.nullif(ST_Area(aoi_geog), 0)
pair_overlap_geom = ST_Intersection(master_alias.geom, slave_alias.geom)
pair_aoi_geom = ST_Intersection(pair_overlap_geom, aoi_geom)
pair_aoi_overlap_expr = (ST_Area(cast(pair_aoi_geom, Geography)) / aoi_area).label("pair_aoi_overlap_ratio")
stmt = stmt.add_columns(pair_aoi_overlap_expr)
stmt = stmt.where(
ST_Intersects(pair_overlap_geom, aoi_geom),
)
if params.aoi_overlap_threshold is not None:
stmt = stmt.where(pair_aoi_overlap_expr >= params.aoi_overlap_threshold)
stmt = stmt.order_by(
PairingMetricCacheORM.master_imaging_date.asc(),
PairingMetricCacheORM.slave_imaging_date.asc(),
PairingMetricCacheORM.dinsar_quality_tier.asc(),
func.coalesce(PairingMetricCacheORM.dinsar_quality_score, 0).desc(),
func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(),
center_distance_expr.asc(),
PairingMetricCacheORM.pair_uid.asc(),
)
result = await db.execute(stmt)
candidate_pool: List[dict] = []
for row in result.all():
metric_row, master_row, slave_row = row[0], row[1], row[2]
pair_aoi_overlap_ratio = row[3] if len(row) > 3 else None
center_distance = float(
metric_row.scene_center_distance_meters
if metric_row.scene_center_distance_meters is not None
else (metric_row.spatial_baseline_meters or 0)
)
candidate_pool.append(
{
"metric_cache_ref_id": int(metric_row.id),
"pair_uid": metric_row.pair_uid,
"master_scene_uid": metric_row.master_scene_uid,
"slave_scene_uid": metric_row.slave_scene_uid,
"master": RadarData.model_validate(master_row),
"slave": RadarData.model_validate(slave_row),
"days": int(metric_row.time_baseline_days or 0),
"dist": center_distance,
"scene_center_distance_meters": center_distance,
"overlap_ratio": float(metric_row.scene_overlap_ratio or 0),
"dinsar_quality_tier": metric_row.dinsar_quality_tier,
"dinsar_quality_score": (
float(metric_row.dinsar_quality_score)
if metric_row.dinsar_quality_score is not None
else None
),
"dinsar_readiness": metric_row.dinsar_readiness,
"dinsar_reasons": [
str(item)
for item in (metric_row.dinsar_reasons_json or [])
if isinstance(item, str) and item.strip()
],
"same_relative_orbit": bool(metric_row.same_relative_orbit),
"master_relative_orbit": metric_row.master_relative_orbit,
"slave_relative_orbit": metric_row.slave_relative_orbit,
"pair_aoi_overlap_ratio": (
float(pair_aoi_overlap_ratio)
if pair_aoi_overlap_ratio is not None
else None
),
}
)
return candidate_pool
def _ensure_candidate_identity(self, candidate: dict) -> Tuple[str, str]:
master = candidate["master"]
slave = candidate["slave"]
task_alias = str(candidate.get("task_alias") or "").strip() or build_task_alias(
master.imaging_date,
slave.imaging_date,
)
pair_key = str(candidate.get("pair_key") or "").strip() or build_pair_key(
master.file_path,
slave.file_path,
master.imaging_date,
slave.imaging_date,
master.satellite_family or slave.satellite_family or master.satellite or slave.satellite,
)
candidate["task_alias"] = task_alias
candidate["pair_key"] = pair_key
return task_alias, pair_key
def _build_radar_pairs(self, selected_candidates: List[dict]) -> List[RadarPair]:
result_pairs: List[RadarPair] = []
for candidate in selected_candidates:
master = candidate["master"]
slave = candidate["slave"]
task_alias, pair_key = self._ensure_candidate_identity(candidate)
selection_score = candidate.get("selection_score")
result_pairs.append(
RadarPair(
master=master,
slave=slave,
task_name=task_alias,
task_alias=task_alias,
pair_key=pair_key,
pair_uid=candidate.get("pair_uid"),
metric_cache_ref_id=candidate.get("metric_cache_ref_id"),
network_run_id=candidate.get("network_run_id"),
network_edge_id=candidate.get("network_edge_id"),
policy_version=candidate.get("policy_version"),
selection_strategy=candidate.get("selection_strategy"),
selection_score=float(selection_score) if selection_score is not None else None,
selection_reason=candidate.get("selection_reason"),
coverage_rank=(
int(candidate["coverage_rank"])
if candidate.get("coverage_rank") is not None
else None
),
aoi_new_area_ratio=(
float(candidate["aoi_new_area_ratio"])
if candidate.get("aoi_new_area_ratio") is not None
else None
),
aoi_pair_area_ratio=(
float(candidate["aoi_pair_area_ratio"])
if candidate.get("aoi_pair_area_ratio") is not None
else None
),
aoi_coverage_ratio_after=(
float(candidate["aoi_coverage_ratio_after"])
if candidate.get("aoi_coverage_ratio_after") is not None
else None
),
effective_coverage_geojson=candidate.get("effective_coverage_geojson"),
time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]),
scene_center_distance_meters=float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0
),
scene_overlap_ratio=float(candidate.get("overlap_ratio") or 0.0),
pair_aoi_overlap_ratio=(
float(candidate["pair_aoi_overlap_ratio"])
if candidate.get("pair_aoi_overlap_ratio") is not None
else None
),
dinsar_quality_tier=candidate.get("dinsar_quality_tier"),
dinsar_quality_score=(
float(candidate["dinsar_quality_score"])
if candidate.get("dinsar_quality_score") is not None
else None
),
dinsar_readiness=candidate.get("dinsar_readiness"),
dinsar_reasons=candidate.get("dinsar_reasons") or [],
same_relative_orbit=bool(candidate.get("same_relative_orbit")),
master_relative_orbit=candidate.get("master_relative_orbit"),
slave_relative_orbit=candidate.get("slave_relative_orbit"),
production_summary=candidate.get("production_summary"),
)
)
return result_pairs
async def _attach_dinsar_production_summaries(
self,
db: AsyncSession,
selected_candidates: List[dict],
) -> None:
if not selected_candidates:
return
pair_uids: set[str] = set()
pair_keys: set[str] = set()
aliases: set[str] = set()
for candidate in selected_candidates:
task_alias, pair_key = self._ensure_candidate_identity(candidate)
pair_uid = str(candidate.get("pair_uid") or "").strip()
if pair_uid:
pair_uids.add(pair_uid)
if pair_key:
pair_keys.add(pair_key)
if task_alias:
aliases.add(task_alias)
run_conditions = []
if pair_uids:
run_conditions.append(DinsarProductionRunItemORM.pair_uid.in_(pair_uids))
if pair_keys:
run_conditions.append(DinsarProductionRunItemORM.pair_key.in_(pair_keys))
if aliases:
run_conditions.append(DinsarProductionRunItemORM.task_alias.in_(aliases))
run_conditions.append(DinsarProductionRunItemORM.task_name.in_(aliases))
product_conditions = []
if pair_uids:
product_conditions.append(ResultProductORM.pair_uid.in_(pair_uids))
if pair_keys:
product_conditions.append(ResultProductORM.pair_key.in_(pair_keys))
if aliases:
product_conditions.append(ResultProductORM.task_alias.in_(aliases))
product_conditions.append(ResultProductORM.task_name.in_(aliases))
run_rows = []
if run_conditions:
result = await db.execute(
select(DinsarProductionRunItemORM, DinsarProductionRunORM)
.join(DinsarProductionRunORM, DinsarProductionRunItemORM.run_id == DinsarProductionRunORM.run_id)
.where(or_(*run_conditions))
.order_by(
DinsarProductionRunItemORM.updated_at.desc().nullslast(),
DinsarProductionRunItemORM.id.desc(),
)
)
run_rows = result.all()
products = []
if product_conditions:
result = await db.execute(
select(ResultProductORM)
.where(ResultProductORM.catalog_name == "dinsar")
.where(or_(*product_conditions))
.order_by(
ResultProductORM.published_at.desc().nullslast(),
ResultProductORM.id.desc(),
)
)
products = result.scalars().all()
run_by_uid: Dict[str, List[Tuple[DinsarProductionRunItemORM, DinsarProductionRunORM]]] = defaultdict(list)
run_by_key: Dict[str, List[Tuple[DinsarProductionRunItemORM, DinsarProductionRunORM]]] = defaultdict(list)
run_by_alias: Dict[str, List[Tuple[DinsarProductionRunItemORM, DinsarProductionRunORM]]] = defaultdict(list)
for item, run in run_rows:
pair_uid = str(item.pair_uid or "").strip()
pair_key = str(item.pair_key or "").strip()
if pair_uid:
run_by_uid[pair_uid].append((item, run))
if pair_key:
run_by_key[pair_key].append((item, run))
for alias in {str(item.task_alias or "").strip(), str(item.task_name or "").strip()}:
if alias:
run_by_alias[alias].append((item, run))
products_by_uid: Dict[str, List[ResultProductORM]] = defaultdict(list)
products_by_key: Dict[str, List[ResultProductORM]] = defaultdict(list)
products_by_alias: Dict[str, List[ResultProductORM]] = defaultdict(list)
for product in products:
pair_uid = str(product.pair_uid or "").strip()
pair_key = str(product.pair_key or "").strip()
if pair_uid:
products_by_uid[pair_uid].append(product)
if pair_key:
products_by_key[pair_key].append(product)
for alias in {str(product.task_alias or "").strip(), str(product.task_name or "").strip()}:
if alias:
products_by_alias[alias].append(product)
for candidate in selected_candidates:
task_alias, pair_key = self._ensure_candidate_identity(candidate)
pair_uid = str(candidate.get("pair_uid") or "").strip()
exact_runs = self._dedupe_by_object_id(
[*run_by_uid.get(pair_uid, []), *run_by_key.get(pair_key, [])],
key=lambda row: getattr(row[0], "id", None),
)
alias_runs = self._dedupe_by_object_id(
run_by_alias.get(task_alias, []),
key=lambda row: getattr(row[0], "id", None),
)
exact_products = self._dedupe_by_object_id(
[*products_by_uid.get(pair_uid, []), *products_by_key.get(pair_key, [])],
key=lambda product: getattr(product, "id", None),
)
alias_products = self._dedupe_by_object_id(
products_by_alias.get(task_alias, []),
key=lambda product: getattr(product, "id", None),
)
matched_runs = exact_runs or alias_runs
matched_products = exact_products or alias_products
candidate["production_summary"] = self._summarize_dinsar_production(
matched_runs,
matched_products,
match_level="identity" if (exact_runs or exact_products) else ("task_alias" if (alias_runs or alias_products) else "none"),
)
def _summarize_dinsar_production(
self,
run_rows: List[Tuple[DinsarProductionRunItemORM, DinsarProductionRunORM]],
products: List[ResultProductORM],
*,
match_level: str = "none",
) -> Dict[str, Any]:
latest_run_row = max(
run_rows,
key=lambda row: self._datetime_sort_key(
row[0].updated_at,
row[0].ended_at,
row[0].started_at,
row[0].created_at,
),
default=None,
)
latest_product = max(
products,
key=lambda product: self._datetime_sort_key(
product.published_at,
product.produced_at,
product.updated_at,
product.registered_at,
),
default=None,
)
ready_products = [product for product in products if self._is_ready_result_product(product)]
completed_statuses = {"COMPLETED", "READY", "SUCCESS", "PUBLISHED"}
failed_statuses = {"FAILED", "ERROR", "CANCELLED", "CANCELED"}
completed_run_count = sum(
1
for item, run in run_rows
if str(item.status or "").strip().upper() in completed_statuses
or str(run.status or "").strip().upper() in completed_statuses
)
failed_run_count = sum(
1
for item, run in run_rows
if str(item.status or "").strip().upper() in failed_statuses
or str(run.status or "").strip().upper() in failed_statuses
)
latest_item = latest_run_row[0] if latest_run_row else None
latest_run = latest_run_row[1] if latest_run_row else None
if ready_products and latest_product is not None:
latest_status = str(latest_product.status or "").strip().upper()
elif latest_item is not None or latest_run is not None:
latest_status = str(
(latest_item.status if latest_item is not None else None)
or (latest_run.status if latest_run is not None else None)
or ""
).strip().upper()
elif latest_product is not None:
latest_status = str(latest_product.status or "").strip().upper()
else:
latest_status = ""
if ready_products:
status = "READY"
elif completed_run_count > 0:
status = "COMPLETED"
elif latest_status:
status = latest_status
else:
status = "MISSING"
engine_codes = sorted(
{
str(value or "").strip().lower()
for value in [
*(product.engine_code for product in products),
*(run.engine_code for _, run in run_rows),
]
if str(value or "").strip()
}
)
return {
"has_record": bool(run_rows or products),
"is_produced": bool(ready_products or completed_run_count > 0),
"status": status,
"match_level": match_level,
"run_item_count": len(run_rows),
"completed_run_count": completed_run_count,
"failed_run_count": failed_run_count,
"product_count": len(products),
"ready_product_count": len(ready_products),
"engine_codes": engine_codes,
"latest_engine_code": (
str(latest_product.engine_code or "").strip().lower()
if latest_product is not None and latest_product.engine_code
else (
str(latest_run.engine_code or "").strip().lower()
if latest_run is not None and latest_run.engine_code
else None
)
),
"latest_run_id": latest_run.run_id if latest_run is not None else None,
"latest_run_status": latest_run.status if latest_run is not None else None,
"latest_item_status": latest_item.status if latest_item is not None else None,
"latest_output_dir": latest_item.latest_output_dir if latest_item is not None else None,
"latest_product_id": latest_product.id if latest_product is not None else None,
"latest_product_identifier": latest_product.product_id if latest_product is not None else None,
"latest_product_status": latest_product.status if latest_product is not None else None,
"latest_product_health": latest_product.health_status if latest_product is not None else None,
"latest_product_published_at": latest_product.published_at if latest_product is not None else None,
"updated_at": (
latest_item.updated_at
if latest_item is not None
else (
latest_product.updated_at
if latest_product is not None
else None
)
),
}
def _is_ready_result_product(self, product: ResultProductORM) -> bool:
status = str(product.status or "").strip().upper()
health = str(product.health_status or "").strip().upper()
return status in {"READY", "COMPLETED", "SUCCESS"} and health not in {"ERROR", "FAILED"}
def _datetime_sort_key(self, *values: Any) -> float:
for value in values:
if value is None:
continue
try:
return float(value.timestamp())
except Exception:
continue
return 0.0
def _dedupe_by_object_id(self, items: List[Any], *, key) -> List[Any]:
seen: set[Any] = set()
output: List[Any] = []
for item in items:
item_key = key(item)
if item_key is None:
item_key = id(item)
if item_key in seen:
continue
seen.add(item_key)
output.append(item)
return output
def _apply_province_coverage_strategy(
self,
candidate_pool: List[dict],
*,
target_start: datetime,
target_end: datetime,
query_start: datetime,
query_end: datetime,
max_pairs: int,
target_coverage_ratio: float,
min_new_coverage_ratio: float,
aoi_wkt: Optional[str],
) -> Tuple[List[dict], Dict[str, Any], List[str]]:
warnings: List[str] = []
target_days = self._date_set(target_start, target_end)
covered_days: set[datetime] = set()
selected: List[dict] = []
remaining = [candidate for candidate in candidate_pool if self._candidate_date_window(candidate) is not None]
seen_metric_ids: set[int] = set()
aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt)
geometry_cache: Dict[int, Any] = {}
selected_coverage = Polygon()
aoi_area = float(aoi_poly.area or 0.0) if aoi_poly is not None else 0.0
stop_reason = "no_more_gain"
max_pairs_reached = False
while remaining and len(selected) < max_pairs:
best_candidate = None
best_score: Optional[Tuple[float, float, float, float, float, float, str]] = None
current_aoi_coverage_ratio = (
float(selected_coverage.area or 0.0) / aoi_area
if aoi_poly is not None and aoi_area > 0
else None
)
if current_aoi_coverage_ratio is not None and current_aoi_coverage_ratio >= target_coverage_ratio:
stop_reason = "target_coverage_reached"
break
for candidate in remaining:
window = self._candidate_date_window(candidate)
if window is None:
continue
candidate_days = self._date_set(*window) & target_days
new_days = candidate_days - covered_days
new_area = 0.0
candidate_area = 0.0
if aoi_poly is not None:
candidate_geom = self._get_candidate_intersection_geom(
candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
)
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
if aoi_poly is not None:
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if new_area_ratio <= 0:
continue
elif not new_days:
continue
quality_score = float(candidate.get("dinsar_quality_score") or 0.0)
overlap = float(candidate.get("overlap_ratio") or 0.0)
temporal_days = float(candidate.get("days") or 0.0)
distance = float(candidate.get("scene_center_distance_meters") or candidate.get("dist") or 0.0)
if aoi_poly is not None and aoi_area > 0:
score = (
new_area_ratio,
candidate_area / aoi_area,
float(len(new_days)) / max(1, len(target_days)),
overlap,
quality_score,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
else:
score = (
float(len(new_days)),
float(len(candidate_days)),
overlap,
quality_score,
0.0,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
if best_score is None or score > best_score:
best_candidate = candidate
best_score = score
if best_candidate is None:
stop_reason = "no_candidate_adds_coverage"
break
if aoi_poly is None and covered_days == target_days:
stop_reason = "target_time_reached"
break
window = self._candidate_date_window(best_candidate)
candidate_days = self._date_set(*window) & target_days if window else set()
new_days = candidate_days - covered_days
candidate_geom = self._get_candidate_intersection_geom(
best_candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
) if aoi_poly is not None else None
new_area = 0.0
candidate_area = 0.0
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if aoi_poly is not None and len(selected) > 0 and new_area_ratio < min_new_coverage_ratio:
stop_reason = "marginal_gain_below_threshold"
break
best_candidate["selection_reason"] = "province_coverage_new_days"
if aoi_poly is not None and new_area > 0:
best_candidate["selection_reason"] = "province_aoi_new_area"
best_candidate["selection_score"] = float(best_score[0] if best_score else len(new_days))
best_candidate["coverage_rank"] = len(selected) + 1
best_candidate["coverage_new_days"] = len(new_days)
best_candidate["coverage_total_days"] = len(candidate_days)
best_candidate["aoi_new_area_ratio"] = new_area_ratio if aoi_area > 0 else None
best_candidate["aoi_pair_area_ratio"] = (candidate_area / aoi_area) if aoi_area > 0 else None
next_selected_coverage = selected_coverage
if candidate_geom is not None and not candidate_geom.is_empty:
next_selected_coverage = unary_union([selected_coverage, candidate_geom])
best_candidate["aoi_coverage_ratio_after"] = (
float(next_selected_coverage.area or 0.0) / aoi_area
if aoi_area > 0 else None
)
best_candidate["effective_coverage_geojson"] = self._geometry_to_geojson(candidate_geom)
best_candidate["target_coverage_ratio_after"] = (
len(covered_days | candidate_days) / max(1, len(target_days))
)
selected.append(best_candidate)
covered_days |= candidate_days
selected_coverage = next_selected_coverage
seen_metric_ids.add(int(best_candidate.get("metric_cache_ref_id") or 0))
remaining = [
candidate for candidate in remaining
if int(candidate.get("metric_cache_ref_id") or 0) not in seen_metric_ids
]
if remaining and len(selected) >= max_pairs:
max_pairs_reached = True
stop_reason = "max_pairs_reached"
uncovered_ranges = self._date_ranges_from_days(target_days - covered_days)
if uncovered_ranges:
warnings.append(
"Coverage plan did not fully cover the requested time range. "
f"Uncovered ranges: {', '.join(f'{item[0]}~{item[1]}' for item in uncovered_ranges[:6])}"
)
if max_pairs_reached:
warnings.append(
f"Coverage planning reached task limit max_pairs={max_pairs}; increase the limit for better spatial coverage."
)
selected_area = float(selected_coverage.area or 0.0) if aoi_poly is not None else 0.0
aoi_coverage_ratio = (selected_area / aoi_area) if aoi_area > 0 else None
if aoi_coverage_ratio is not None and aoi_coverage_ratio < target_coverage_ratio:
warnings.append(
f"AOI spatial coverage is {aoi_coverage_ratio:.1%}; target is {target_coverage_ratio:.1%}."
)
temporal_ratio = len(covered_days) / max(1, len(target_days))
coverage_meta = {
"strategy": "dinsar_province_coverage",
"optimization_goal": "minimize_pair_count_for_aoi_spatial_coverage",
"greedy_rule": "select_pair_with_largest_new_aoi_intersection_area_each_step",
"stop_reason": stop_reason,
"max_pairs": max_pairs,
"target_coverage_ratio": round(target_coverage_ratio, 6),
"min_new_coverage_ratio": round(min_new_coverage_ratio, 6),
"coverage_basis": "aoi_spatial" if aoi_coverage_ratio is not None else "temporal",
"target_date_from": self._format_yyyymmdd(target_start),
"target_date_to": self._format_yyyymmdd(target_end),
"query_date_from": self._format_yyyymmdd(query_start),
"query_date_to": self._format_yyyymmdd(query_end),
"target_day_count": len(target_days),
"covered_day_count": len(covered_days),
"temporal_coverage_ratio": round(temporal_ratio, 6),
"aoi_coverage_ratio": round(aoi_coverage_ratio, 6) if aoi_coverage_ratio is not None else None,
"coverage_ratio": round(aoi_coverage_ratio if aoi_coverage_ratio is not None else temporal_ratio, 6),
"uncovered_ranges": [
{"date_from": start, "date_to": end}
for start, end in uncovered_ranges
],
"selected_pair_count": len(selected),
"candidate_count": len(candidate_pool),
}
return selected, coverage_meta, warnings
def _candidate_date_window(self, candidate: dict) -> Optional[Tuple[datetime, datetime]]:
master = candidate.get("master")
slave = candidate.get("slave")
master_date = self._try_parse_yyyymmdd(getattr(master, "imaging_date", None))
slave_date = self._try_parse_yyyymmdd(getattr(slave, "imaging_date", None))
if master_date is None or slave_date is None:
return None
return (master_date, slave_date) if master_date <= slave_date else (slave_date, master_date)
def _sort_coverage_candidates(self, candidates: List[dict]) -> List[dict]:
return sorted(
candidates,
key=lambda item: (
str(getattr(item.get("master"), "imaging_date", "") or ""),
str(getattr(item.get("slave"), "imaging_date", "") or ""),
-float(item.get("selection_score") or 0.0),
str(item.get("pair_uid") or ""),
),
)
def _date_set(self, start: datetime, end: datetime) -> set[datetime]:
if end < start:
return set()
return {start + timedelta(days=offset) for offset in range((end - start).days + 1)}
def _date_ranges_from_days(self, days: set[datetime]) -> List[Tuple[str, str]]:
if not days:
return []
ordered = sorted(days)
ranges: List[Tuple[datetime, datetime]] = []
start = previous = ordered[0]
for day in ordered[1:]:
if day == previous + timedelta(days=1):
previous = day
continue
ranges.append((start, previous))
start = previous = day
ranges.append((start, previous))
return [(self._format_yyyymmdd(start), self._format_yyyymmdd(end)) for start, end in ranges]
def _parse_yyyymmdd(self, value: str, *, field_name: str) -> datetime:
parsed = self._try_parse_yyyymmdd(value)
if parsed is None:
raise ValueError(f"{field_name} must be YYYYMMDD.")
return parsed
def _try_parse_yyyymmdd(self, value: Any) -> Optional[datetime]:
text_value = str(value or "").strip()
if not re.match(r"^\d{8}$", text_value):
return None
try:
return datetime.strptime(text_value, "%Y%m%d")
except ValueError:
return None
def _format_yyyymmdd(self, value: datetime) -> str:
return value.strftime("%Y%m%d")
async def _persist_network_run(
self,
db: AsyncSession,
*,
params: PairingRequest,
aoi_wkt: Optional[str],
require_orbit_data: bool,
warnings: List[str],
candidate_pool: List[dict],
selected_candidates: List[dict],
) -> str:
request_payload = params.model_dump(exclude_none=True)
request_payload["require_orbit_data"] = bool(require_orbit_data)
aoi_hash = self._stable_sha1(aoi_wkt) if aoi_wkt else None
request_hash = self._stable_sha1(
{
"params": request_payload,
"aoi_hash": aoi_hash,
}
)
network_run = PairingNetworkRunORM(
network_run_id=f"pnr_{uuid.uuid4().hex[:24]}",
strategy=params.strategy,
policy_version=PAIRING_POLICY_VERSION,
request_hash=request_hash,
request_params_json=request_payload,
aoi_source="wkt" if aoi_wkt else None,
aoi_hash=aoi_hash,
aoi_summary_json=self._build_aoi_summary(aoi_wkt),
candidate_count=len(candidate_pool),
selected_edge_count=len(selected_candidates),
warning_count=len(warnings),
status="READY",
fallback_used=False,
)
db.add(network_run)
await db.flush()
for edge_rank, candidate in enumerate(self._sorted_candidates(selected_candidates), start=1):
edge = PairingNetworkEdgeORM(
network_run_ref_id=network_run.id,
metric_cache_ref_id=int(candidate["metric_cache_ref_id"]),
edge_rank=edge_rank,
selection_reason=candidate.get("selection_reason"),
selection_score=(
float(candidate["selection_score"])
if candidate.get("selection_score") is not None
else None
),
selection_meta_json=self._build_edge_meta(candidate),
is_reference_edge=bool(candidate.get("is_reference_edge")),
)
db.add(edge)
await db.flush()
candidate["network_run_id"] = network_run.network_run_id
candidate["network_edge_id"] = int(edge.id)
candidate["policy_version"] = PAIRING_POLICY_VERSION
await db.commit()
return network_run.network_run_id
def _build_aoi_summary(self, aoi_wkt: Optional[str]) -> Optional[Dict[str, Any]]:
geometry = self._parse_optional_aoi_polygon(aoi_wkt)
if geometry is None:
return None
min_x, min_y, max_x, max_y = geometry.bounds
return {
"geom_type": geometry.geom_type,
"bounds": [float(min_x), float(min_y), float(max_x), float(max_y)],
"area": float(geometry.area or 0.0),
}
def _build_edge_meta(self, candidate: dict) -> Dict[str, Any]:
return {
"selection_strategy": candidate.get("selection_strategy"),
"reference_image_id": candidate.get("reference_image_id"),
"master_scene_uid": candidate.get("master_scene_uid"),
"slave_scene_uid": candidate.get("slave_scene_uid"),
"pair_uid": candidate.get("pair_uid"),
"pair_key": candidate.get("pair_key"),
"task_alias": candidate.get("task_alias"),
"time_baseline_days": int(candidate.get("days") or 0),
"spatial_baseline_meters": float(candidate.get("dist") or 0.0),
"scene_center_distance_meters": float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0.0
),
"legacy_spatial_baseline_field": "scene_center_distance_meters",
"scene_overlap_ratio": float(candidate.get("overlap_ratio") or 0.0),
"pair_aoi_overlap_ratio": (
float(candidate["pair_aoi_overlap_ratio"])
if candidate.get("pair_aoi_overlap_ratio") is not None
else None
),
"coverage_rank": candidate.get("coverage_rank"),
"coverage_new_days": candidate.get("coverage_new_days"),
"coverage_total_days": candidate.get("coverage_total_days"),
"aoi_new_area_ratio": candidate.get("aoi_new_area_ratio"),
"aoi_pair_area_ratio": candidate.get("aoi_pair_area_ratio"),
"aoi_coverage_ratio_after": candidate.get("aoi_coverage_ratio_after"),
"target_coverage_ratio_after": candidate.get("target_coverage_ratio_after"),
}
def _stable_sha1(self, value: Any) -> str:
if isinstance(value, str):
payload = value
else:
payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha1(payload.encode("utf-8", errors="ignore")).hexdigest()
def _build_timeseries_stack_identity(
self,
direction: Optional[str],
scenes: List[RadarDataORM],
) -> Dict[str, Any]:
sorted_scenes = sorted(scenes, key=lambda item: str(item.imaging_date or ""))
first = sorted_scenes[0]
satellite = self._normalize_timeseries_satellite_family(first)
imaging_mode = str(first.imaging_mode or "").strip() or "UNKNOWN"
polarization = str(first.polarization or "").strip() or "UNKNOWN"
orbit_direction = (
str(direction or first.orbit_direction or "").strip().upper() or "UNKNOWN"
)
group_key = "_".join(
part
for part in (satellite, imaging_mode, polarization, orbit_direction)
if str(part).strip()
)
stack_dates = [
str(item.imaging_date or "").strip()
for item in sorted_scenes
if str(item.imaging_date or "").strip()
]
digest = self._stable_sha1(
{
"direction": orbit_direction,
"scene_ids": [int(item.id) for item in sorted_scenes],
"stack_dates": stack_dates,
}
)[:10]
date_start = stack_dates[0] if stack_dates else "NA"
date_end = stack_dates[-1] if stack_dates else "NA"
return {
"direction": orbit_direction,
"group_key": group_key,
"stack_key": f"{group_key}_{date_start}_{date_end}_{digest}",
"stack_dates": stack_dates,
}
async def _build_timeseries_network_edges(
self,
db: AsyncSession,
scenes: List[RadarDataORM],
params: PsRequest,
*,
aoi_wkt: Optional[str],
selection_mode: Optional[str],
) -> Tuple[List[Dict[str, Any]], List[str]]:
scene_ids = [int(item.id) for item in scenes if item.id is not None]
if len(scene_ids) < 2:
return [], []
master_alias = aliased(RadarDataORM)
slave_alias = aliased(RadarDataORM)
center_distance_expr = func.coalesce(
PairingMetricCacheORM.scene_center_distance_meters,
PairingMetricCacheORM.spatial_baseline_meters,
)
stmt = (
select(PairingMetricCacheORM, master_alias, slave_alias)
.join(master_alias, master_alias.id == PairingMetricCacheORM.master_scene_ref_id)
.join(slave_alias, slave_alias.id == PairingMetricCacheORM.slave_scene_ref_id)
.where(
PairingMetricCacheORM.metric_version == pairing_state_service.metric_version,
PairingMetricCacheORM.status == "READY",
PairingMetricCacheORM.master_scene_ref_id.in_(scene_ids),
PairingMetricCacheORM.slave_scene_ref_id.in_(scene_ids),
PairingMetricCacheORM.time_baseline_days >= params.time_baseline_min,
PairingMetricCacheORM.time_baseline_days <= params.time_baseline_max,
center_distance_expr <= params.spatial_baseline_max_meters,
PairingMetricCacheORM.scene_overlap_ratio >= params.network_overlap_threshold,
PairingMetricCacheORM.same_look_direction.is_(True),
)
.order_by(
PairingMetricCacheORM.master_imaging_date.asc(),
PairingMetricCacheORM.slave_imaging_date.asc(),
PairingMetricCacheORM.time_baseline_days.asc(),
center_distance_expr.asc(),
func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(),
PairingMetricCacheORM.id.asc(),
)
)
result = await db.execute(stmt)
candidate_pool: List[dict] = []
for metric_row, master_row, slave_row in result.all():
center_distance = float(
metric_row.scene_center_distance_meters
if metric_row.scene_center_distance_meters is not None
else (metric_row.spatial_baseline_meters or 0.0)
)
candidate_pool.append(
{
"metric_cache_ref_id": int(metric_row.id),
"pair_uid": metric_row.pair_uid,
"master_scene_uid": metric_row.master_scene_uid,
"slave_scene_uid": metric_row.slave_scene_uid,
"master": RadarData.model_validate(master_row),
"slave": RadarData.model_validate(slave_row),
"days": int(metric_row.time_baseline_days or 0),
"dist": center_distance,
"scene_center_distance_meters": center_distance,
"overlap_ratio": float(metric_row.scene_overlap_ratio or 0.0),
}
)
warnings: List[str] = []
if not candidate_pool:
warnings.append(
"No pairing_metric_cache edges matched the time-series SBAS network thresholds."
)
return [], warnings
candidate_scene_ids = {
int(candidate[role].id)
for candidate in candidate_pool
for role in ("master", "slave")
if candidate.get(role) is not None
}
missing_scene_count = len(set(scene_ids) - candidate_scene_ids)
if missing_scene_count > 0:
warnings.append(
f"{missing_scene_count} selected scenes have no metric-cache edge under the current SBAS thresholds."
)
pairing_params = PairingRequest(
time_baseline_min=params.time_baseline_min,
time_baseline_max=params.time_baseline_max,
overlap_threshold=params.network_overlap_threshold,
spatial_baseline_max_meters=params.spatial_baseline_max_meters,
coverage_diversity_penalty=0.3,
require_same_imaging_mode=True,
require_same_polarization=True,
strategy="sbas",
num_connections=params.num_connections,
)
selected_candidates, strategy_warnings = self._apply_sbas_strategy(
candidate_pool,
pairing_params,
aoi_wkt=aoi_wkt,
)
warnings.extend(strategy_warnings)
edges: List[Dict[str, Any]] = []
ordered_candidates = (
selected_candidates
if params.strategy == "dinsar_province_coverage"
else self._sorted_candidates(selected_candidates)
)
for edge_rank, candidate in enumerate(ordered_candidates, start=1):
master = candidate["master"]
slave = candidate["slave"]
edges.append(
{
"edge_rank": edge_rank,
"metric_cache_ref_id": candidate.get("metric_cache_ref_id"),
"master_scene_ref_id": int(master.id),
"slave_scene_ref_id": int(slave.id),
"master_imaging_date": master.imaging_date,
"slave_imaging_date": slave.imaging_date,
"temporal_baseline_days": int(candidate.get("days") or 0),
"spatial_baseline_meters": float(candidate.get("dist") or 0.0),
"scene_center_distance_meters": float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0.0
),
"scene_overlap_ratio": float(candidate.get("overlap_ratio") or 0.0),
"selection_reason": candidate.get("selection_reason"),
"selection_score": (
float(candidate["selection_score"])
if candidate.get("selection_score") is not None
else None
),
"selection_meta_json": {
"source": "pairing_metric_cache",
"selection_mode": selection_mode,
"pair_uid": candidate.get("pair_uid"),
"metric_version": pairing_state_service.metric_version,
"scene_center_distance_meters": float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0.0
),
"time_baseline_min": params.time_baseline_min,
"time_baseline_max": params.time_baseline_max,
"spatial_baseline_max_meters": params.spatial_baseline_max_meters,
"network_overlap_threshold": params.network_overlap_threshold,
"num_connections": params.num_connections,
},
"enabled": True,
}
)
if not edges:
warnings.append("SBAS strategy did not select any network edges for this stack.")
return edges, warnings
async def _persist_timeseries_stack_plan(
self,
db: AsyncSession,
*,
direction: Optional[str],
params: PsRequest,
aoi_wkt: Optional[str],
scenes: List[RadarDataORM],
common_aoi_coverage_ratio: Optional[float] = None,
coverage_consistency_ratio: Optional[float] = None,
threshold_satisfied: Optional[bool] = None,
selection_mode: Optional[str] = None,
network_edges: Optional[List[Dict[str, Any]]] = None,
network_warnings: Optional[List[str]] = None,
) -> Dict[str, Any]:
request_payload = params.model_dump(exclude_none=True)
aoi_hash = self._stable_sha1(aoi_wkt) if aoi_wkt else None
identity = self._build_timeseries_stack_identity(direction, scenes)
plan = TimeseriesStackPlanORM(
plan_id=f"tsp_{uuid.uuid4().hex[:24]}",
strategy="sbas_stack",
request_hash=self._stable_sha1(
{
"params": request_payload,
"aoi_hash": aoi_hash,
"direction": identity.get("direction"),
"scene_ids": [int(item.id) for item in scenes],
}
),
request_params_json=request_payload,
aoi_source="wkt" if aoi_wkt else None,
aoi_hash=aoi_hash,
aoi_summary_json=self._build_aoi_summary(aoi_wkt),
direction=identity.get("direction"),
scene_count=len(scenes),
stack_key=identity.get("stack_key"),
group_key=identity.get("group_key"),
status="READY",
created_by="system:find_ps_timeseries",
)
db.add(plan)
await db.flush()
sorted_scenes = sorted(scenes, key=lambda item: str(item.imaging_date or ""))
scene_payloads: List[RadarData] = []
plan_item_by_scene_id: Dict[int, TimeseriesStackPlanItemORM] = {}
safe_network_edges = list(network_edges or [])
safe_network_warnings = [str(item) for item in (network_warnings or []) if str(item).strip()]
for rank, item in enumerate(sorted_scenes, start=1):
plan_item = TimeseriesStackPlanItemORM(
plan_ref_id=plan.id,
radar_data_ref_id=int(item.id) if item.id is not None else None,
scene_rank=rank,
file_path=item.file_path,
satellite=item.satellite,
imaging_date=item.imaging_date,
imaging_mode=item.imaging_mode,
polarization=item.polarization,
has_orbit_data=bool(item.has_orbit_data),
selection_meta_json={
"source": "find_ps_timeseries",
"direction": identity.get("direction"),
"group_key": identity.get("group_key"),
"stack_key": identity.get("stack_key"),
"initial_overlap_threshold": params.initial_overlap_threshold,
"final_overlap_threshold": params.final_overlap_threshold,
"common_aoi_coverage_ratio": common_aoi_coverage_ratio,
"coverage_consistency_ratio": coverage_consistency_ratio,
"threshold_satisfied": threshold_satisfied,
"selection_mode": selection_mode,
"network_edge_count": len(safe_network_edges),
"network_warnings": safe_network_warnings,
"orbit_direction": item.orbit_direction,
"satellite_family": self._normalize_timeseries_satellite_family(item),
"bbox": [item.min_lon, item.min_lat, item.max_lon, item.max_lat],
"scene_unique_id": item.unique_id,
},
)
db.add(plan_item)
await db.flush()
if item.id is not None:
plan_item_by_scene_id[int(item.id)] = plan_item
scene_payloads.append(
RadarData.model_validate(item).model_copy(
update={
"orbit_direction": identity.get("direction") or item.orbit_direction,
"stack_plan_id": plan.plan_id,
"stack_plan_item_id": int(plan_item.id),
"stack_scene_rank": rank,
"stack_group_key": identity.get("group_key"),
"stack_key": identity.get("stack_key"),
"stack_common_aoi_coverage_ratio": common_aoi_coverage_ratio,
"stack_coverage_consistency_ratio": coverage_consistency_ratio,
"stack_threshold_satisfied": threshold_satisfied,
"stack_selection_mode": selection_mode,
"stack_network_edge_count": len(safe_network_edges),
"stack_network_warnings": safe_network_warnings,
}
)
)
for edge_payload in safe_network_edges:
master_scene_id = edge_payload.get("master_scene_ref_id")
slave_scene_id = edge_payload.get("slave_scene_ref_id")
master_plan_item = (
plan_item_by_scene_id.get(int(master_scene_id))
if master_scene_id is not None
else None
)
slave_plan_item = (
plan_item_by_scene_id.get(int(slave_scene_id))
if slave_scene_id is not None
else None
)
edge = TimeseriesStackPlanEdgeORM(
plan_ref_id=plan.id,
master_plan_item_ref_id=(
int(master_plan_item.id)
if master_plan_item is not None and master_plan_item.id is not None
else None
),
slave_plan_item_ref_id=(
int(slave_plan_item.id)
if slave_plan_item is not None and slave_plan_item.id is not None
else None
),
metric_cache_ref_id=edge_payload.get("metric_cache_ref_id"),
master_scene_ref_id=master_scene_id,
slave_scene_ref_id=slave_scene_id,
edge_rank=int(edge_payload.get("edge_rank") or 0),
master_imaging_date=edge_payload.get("master_imaging_date"),
slave_imaging_date=edge_payload.get("slave_imaging_date"),
temporal_baseline_days=edge_payload.get("temporal_baseline_days"),
spatial_baseline_meters=edge_payload.get("spatial_baseline_meters"),
perpendicular_baseline_meters=edge_payload.get("perpendicular_baseline_meters"),
scene_overlap_ratio=edge_payload.get("scene_overlap_ratio"),
pair_aoi_overlap_ratio=edge_payload.get("pair_aoi_overlap_ratio"),
selection_reason=edge_payload.get("selection_reason"),
selection_score=edge_payload.get("selection_score"),
selection_meta_json=edge_payload.get("selection_meta_json"),
enabled=bool(edge_payload.get("enabled", True)),
)
db.add(edge)
return {
"plan_id": plan.plan_id,
"group_key": identity.get("group_key"),
"stack_key": identity.get("stack_key"),
"edge_count": len(safe_network_edges),
"network_warnings": safe_network_warnings,
"scenes": scene_payloads,
}
def _apply_dinsar_production_strategy(
self,
candidate_pool: List[dict],
params: PairingRequest,
) -> Tuple[List[dict], List[str]]:
if not candidate_pool:
return [], []
warnings: List[str] = []
rejected_count = sum(
1
for candidate in candidate_pool
if str(candidate.get("dinsar_readiness") or "").upper() == "NOT_RECOMMENDED"
)
if rejected_count:
warnings.append(f"{rejected_count}条候选因D-InSAR生产前置条件不足被过滤。")
selected = []
for candidate in candidate_pool:
readiness = str(candidate.get("dinsar_readiness") or "CANDIDATE").upper()
if readiness == "NOT_RECOMMENDED":
continue
tier = str(candidate.get("dinsar_quality_tier") or "C").upper()
quality_score = candidate.get("dinsar_quality_score")
selected.append(
{
**candidate,
"selection_reason": f"dinsar_{readiness.lower()}_{tier.lower()}",
"selection_score": (
float(quality_score)
if quality_score is not None
else self._score_pair_candidate(candidate, params)
),
}
)
selected.sort(
key=lambda item: (
{"A": 0, "B": 1, "C": 2}.get(str(item.get("dinsar_quality_tier") or "C").upper(), 9),
-float(item.get("selection_score") or 0),
int(item.get("days") or 0),
float(item.get("dist") or 0),
str(getattr(item.get("master"), "imaging_date", "") or ""),
str(getattr(item.get("slave"), "imaging_date", "") or ""),
)
)
return selected, warnings
async def _build_empty_pairing_diagnostics(
self,
db: AsyncSession,
params: PairingRequest,
*,
aoi_wkt: Optional[str],
require_orbit_data: bool,
) -> List[str]:
allowed_families: List[str] = []
for item in params.allowed_satellites or []:
compact = str(item).strip().upper().replace("-", "").replace("_", "").replace(" ", "")
if compact in {"LT1", "LT1A", "LT1B", "LUTAN1", "LUTAN1A", "LUTAN1B"}:
allowed_families.append("LT1")
elif compact in {"S1", "S1A", "S1B", "S1C", "SENTINEL1", "SENTINEL1A", "SENTINEL1B", "SENTINEL1C"}:
allowed_families.append("S1")
allowed_families = list(dict.fromkeys(allowed_families))
sql = text(
"""
WITH base AS (
SELECT
pmc.*,
m.satellite AS master_satellite_actual,
s.satellite AS slave_satellite_actual,
m.has_orbit_data AS master_has_orbit,
s.has_orbit_data AS slave_has_orbit,
COALESCE(pmc.scene_center_distance_meters, pmc.spatial_baseline_meters) AS center_m
FROM pairing_metric_cache pmc
JOIN radar_data m ON m.id = pmc.master_scene_ref_id
JOIN radar_data s ON s.id = pmc.slave_scene_ref_id
WHERE pmc.metric_version = :metric_version
AND pmc.status = 'READY'
AND pmc.master_imaging_date < pmc.slave_imaging_date
AND pmc.same_look_direction IS TRUE
AND pmc.same_satellite_family IS TRUE
AND pmc.dinsar_readiness IN ('RECOMMENDED', 'CANDIDATE')
AND (:require_orbit_data IS FALSE OR (m.has_orbit_data IS TRUE AND s.has_orbit_data IS TRUE))
AND (:require_same_imaging_mode IS FALSE OR pmc.same_imaging_mode IS TRUE)
AND (:require_same_polarization IS FALSE OR pmc.same_polarization IS TRUE)
AND (
:allowed_families_is_empty IS TRUE
OR pmc.master_satellite_family = ANY(:allowed_families)
OR pmc.master_satellite = ANY(:allowed_families)
)
AND (CAST(:master_date_from AS text) IS NULL OR pmc.master_imaging_date >= CAST(:master_date_from AS text))
AND (CAST(:master_date_to AS text) IS NULL OR pmc.master_imaging_date <= CAST(:master_date_to AS text))
AND (CAST(:slave_date_from AS text) IS NULL OR pmc.slave_imaging_date >= CAST(:slave_date_from AS text))
AND (CAST(:slave_date_to AS text) IS NULL OR pmc.slave_imaging_date <= CAST(:slave_date_to AS text))
AND (
CAST(:aoi_wkt AS text) IS NULL
OR ST_Intersects(
ST_Intersection(m.geom, s.geom),
ST_GeomFromText(CAST(:aoi_wkt AS text), 4326)
)
)
),
time_ok AS (
SELECT * FROM base
WHERE time_baseline_days BETWEEN :time_baseline_min AND :time_baseline_max
),
overlap_ok AS (
SELECT * FROM time_ok
WHERE scene_overlap_ratio >= :overlap_threshold
),
center_ok AS (
SELECT * FROM overlap_ok
WHERE center_m <= :center_distance_max
)
SELECT
(SELECT count(*) FROM base) AS base_count,
(SELECT count(*) FROM time_ok) AS time_ok_count,
(SELECT count(*) FROM overlap_ok) AS overlap_ok_count,
(SELECT count(*) FROM center_ok) AS center_ok_count,
(
SELECT json_build_object(
'master_date', master_imaging_date,
'slave_date', slave_imaging_date,
'master_satellite', master_satellite_actual,
'slave_satellite', slave_satellite_actual,
'time_baseline_days', time_baseline_days,
'center_meters', center_m,
'overlap_ratio', scene_overlap_ratio,
'quality_tier', dinsar_quality_tier,
'readiness', dinsar_readiness
)
FROM base
ORDER BY
CASE
WHEN time_baseline_days BETWEEN :time_baseline_min AND :time_baseline_max
THEN 0 ELSE 1
END,
CASE WHEN scene_overlap_ratio >= :overlap_threshold THEN 0 ELSE 1 END,
abs(time_baseline_days - :time_baseline_max),
center_m ASC NULLS LAST
LIMIT 1
) AS nearest_candidate;
"""
)
result = await db.execute(
sql,
{
"metric_version": pairing_state_service.metric_version,
"require_orbit_data": require_orbit_data,
"require_same_imaging_mode": bool(params.require_same_imaging_mode),
"require_same_polarization": bool(params.require_same_polarization),
"allowed_families": allowed_families or ["__NONE__"],
"allowed_families_is_empty": not allowed_families,
"master_date_from": params.master_date_from or None,
"master_date_to": params.master_date_to or None,
"slave_date_from": params.slave_date_from or None,
"slave_date_to": params.slave_date_to or None,
"aoi_wkt": aoi_wkt,
"time_baseline_min": int(params.time_baseline_min),
"time_baseline_max": int(params.time_baseline_max),
"overlap_threshold": float(params.overlap_threshold),
"center_distance_max": float(params.spatial_baseline_max_meters),
},
)
row = result.mappings().first()
if not row:
return ["未找到满足条件的 D-InSAR 配对;诊断查询未返回统计结果。"]
base_count = int(row.get("base_count") or 0)
time_ok_count = int(row.get("time_ok_count") or 0)
overlap_ok_count = int(row.get("overlap_ok_count") or 0)
center_ok_count = int(row.get("center_ok_count") or 0)
if base_count <= 0:
family_text = "、".join(allowed_families) if allowed_families else "LT-1/Sentinel-1"
return [
f"未找到 {family_text} 的可生产基础候选边。请检查数据体系、主从日期范围、AOI、精轨绑定和配对缓存状态。"
]
messages = [
(
"当前筛选下基础候选 {base} 条;时间基线 {min_days}-{max_days} 天后剩 {time_ok} 条;"
"重叠率 >= {overlap:.2f} 后剩 {overlap_ok} 条;footprint 中心距离 <= {center:.0f} 米后剩 {center_ok} 条。"
).format(
base=base_count,
min_days=int(params.time_baseline_min),
max_days=int(params.time_baseline_max),
time_ok=time_ok_count,
overlap=float(params.overlap_threshold),
overlap_ok=overlap_ok_count,
center=float(params.spatial_baseline_max_meters),
center_ok=center_ok_count,
)
]
nearest = row.get("nearest_candidate")
if isinstance(nearest, str):
try:
nearest = json.loads(nearest)
except Exception:
nearest = None
if isinstance(nearest, dict):
messages.append(
(
"最接近的一对是 {master_satellite} {master_date} -> {slave_satellite} {slave_date}"
"时间基线 {days} 天,中心距离 {center:.1f} 米,重叠率 {overlap:.3f},质量 {tier}/{readiness}。"
).format(
master_satellite=nearest.get("master_satellite") or "?",
master_date=nearest.get("master_date") or "?",
slave_satellite=nearest.get("slave_satellite") or "?",
slave_date=nearest.get("slave_date") or "?",
days=int(nearest.get("time_baseline_days") or 0),
center=float(nearest.get("center_meters") or 0.0),
overlap=float(nearest.get("overlap_ratio") or 0.0),
tier=nearest.get("quality_tier") or "?",
readiness=nearest.get("readiness") or "?",
)
)
return messages
def _apply_strategy(
self,
candidate_pool: List[dict],
params: PairingRequest,
aoi_wkt: Optional[str] = None,
) -> Tuple[List[dict], List[str]]:
"""根据策略处理候选配对池,并返回策略级告警。"""
if not candidate_pool:
return [], []
if params.strategy == "sbas":
return self._apply_sbas_strategy(candidate_pool, params, aoi_wkt=aoi_wkt)
if params.strategy == "sequential":
return self._apply_sequential_strategy(candidate_pool, params.num_connections, params)
if params.strategy == "star":
return self._apply_star_strategy(candidate_pool, params.reference_image_id, params)
return self._apply_all_strategy(candidate_pool, params)
def _score_pair_candidate(self, candidate: dict, params: PairingRequest) -> float:
max_time = max(float(params.time_baseline_max or 1), 1.0)
time_score = 1.0 - min(float(candidate.get("days") or 0) / max_time, 1.0)
if params.limit_footprint_center_distance:
max_center = max(float(params.spatial_baseline_max_meters or 1), 1.0)
center_score = 1.0 - min(float(candidate.get("dist") or 0) / max_center, 1.0)
else:
center_score = 1.0
overlap_score = min(max(float(candidate.get("overlap_ratio") or 0), 0.0), 1.0)
source_score = 1.0 if (
bool(getattr(candidate.get("master"), "insar_source_ready", False))
and bool(getattr(candidate.get("slave"), "insar_source_ready", False))
) else 0.0
orbit_score = 1.0 if (
bool(getattr(candidate.get("master"), "has_orbit_data", False))
and bool(getattr(candidate.get("slave"), "has_orbit_data", False))
) else 0.0
return (
0.25 * time_score
+ 0.20 * center_score
+ 0.35 * overlap_score
+ 0.15 * source_score
+ 0.05 * orbit_score
)
def _apply_all_strategy(
self,
candidate_pool: List[dict],
params: PairingRequest,
) -> Tuple[List[dict], List[str]]:
return (
[
{
**candidate,
"selection_reason": "all_candidate",
"selection_score": self._score_pair_candidate(candidate, params),
}
for candidate in self._sorted_candidates(candidate_pool)
],
[],
)
def _apply_sequential_strategy(
self,
candidate_pool: List[dict],
num_connections: int,
params: PairingRequest,
) -> Tuple[List[dict], List[str]]:
"""
Sequential: 按稳定时间序列排序,每景连接后续 N 景。
同日多景通过 acquisition_time_utc -> imaging_date -> scene_uid 稳定排序。
"""
scene_entries = self._build_scene_entries(candidate_pool)
if len(scene_entries) < 2:
return [], []
pair_index = self._build_pair_index(candidate_pool)
selected: List[dict] = []
selected_ids = set()
safe_num_connections = max(1, int(num_connections or 1))
for index, scene_entry in enumerate(scene_entries):
picked_count = 0
for next_index in range(index + 1, len(scene_entries)):
if picked_count >= safe_num_connections:
break
if next_index >= len(scene_entries):
break
candidate = pair_index.get(
self._pair_lookup_key(scene_entry["id"], scene_entries[next_index]["id"])
)
if not candidate:
continue
candidate_id = int(candidate.get("metric_cache_ref_id") or 0)
if candidate_id in selected_ids:
continue
selected_ids.add(candidate_id)
selected.append(
{
**candidate,
"selection_reason": "sequential_neighbor",
"selection_score": self._score_pair_candidate(candidate, params),
}
)
picked_count += 1
return selected, []
def _apply_star_strategy(
self,
candidate_pool: List[dict],
reference_image_id: Optional[int],
params: PairingRequest,
) -> Tuple[List[dict], List[str]]:
"""
Star: 参考像固定为 master。
如果未显式指定,自动选择稳定时间序列居中的场景作为参考像。
"""
warnings: List[str] = []
scene_entries = self._build_scene_entries(candidate_pool)
if not scene_entries:
return [], warnings
if reference_image_id is None:
master_side_ids = {int(candidate["master"].id) for candidate in candidate_pool}
center_index = len(scene_entries) // 2
ranked_entries = sorted(
enumerate(scene_entries),
key=lambda item: (
abs(item[0] - center_index),
item[0],
),
)
selected_entry = next(
(entry for _, entry in ranked_entries if int(entry["id"]) in master_side_ids),
None,
)
if selected_entry is None:
warnings.append("当前候选边中不存在可作为 master 的参考像,星型配对无法生成结果。")
return [], warnings
reference_image_id = int(selected_entry["id"])
warnings.append(f"未指定参考像,已自动选择场景 ID {reference_image_id} 作为星型配对参考像。")
available_scene_ids = {int(entry["id"]) for entry in scene_entries}
if reference_image_id not in available_scene_ids:
warnings.append(f"指定的参考像 ID {reference_image_id} 不在当前候选场景集中。")
return [], warnings
master_side_ids = {int(candidate["master"].id) for candidate in candidate_pool}
if reference_image_id not in master_side_ids:
warnings.append(
f"指定的参考像 ID {reference_image_id} 在当前候选边中无法作为 master,按“参考像固定为 master”规则不生成星型结果。"
)
return [], warnings
selected: List[dict] = []
skipped_slave_side = 0
for candidate in self._sorted_candidates(candidate_pool):
if int(candidate["master"].id) == int(reference_image_id):
selected.append(
{
**candidate,
"selection_reason": "star_reference_master",
"selection_score": self._score_pair_candidate(candidate, params),
"is_reference_edge": True,
"reference_image_id": int(reference_image_id),
}
)
elif int(candidate["slave"].id) == int(reference_image_id):
skipped_slave_side += 1
if skipped_slave_side > 0:
warnings.append(
f"参考像 ID {reference_image_id}{skipped_slave_side} 条候选边中位于 slave 侧;按“参考像固定为 master”规则,这些边已被排除。"
)
return selected, warnings
def _apply_sbas_strategy(
self,
candidate_pool: List[dict],
params: PairingRequest,
*,
aoi_wkt: Optional[str] = None,
) -> Tuple[List[dict], List[str]]:
"""
SBAS: 先构造时间骨架,再补齐连通性、低度节点和覆盖多样性。
"""
warnings: List[str] = []
scene_entries = self._build_scene_entries(candidate_pool)
if len(scene_entries) < 2:
return [], warnings
scene_ids = [int(entry["id"]) for entry in scene_entries]
pair_index = self._build_pair_index(candidate_pool)
sorted_candidates = self._sorted_candidates(candidate_pool)
degree: Dict[int, int] = defaultdict(int)
parents = {scene_id: scene_id for scene_id in scene_ids}
selected: List[dict] = []
selected_ids = set()
selected_coverage = Polygon()
geometry_cache: Dict[int, Any] = {}
aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt)
min_degree = min(max(1, int(params.num_connections or 1)), max(1, len(scene_ids) - 1))
max_degree = min(max(min_degree + 2, 3), max(1, len(scene_ids) - 1))
max_edges = min(
len(candidate_pool),
max(len(scene_ids) - 1, len(scene_ids) * min_degree),
)
def select_candidate(candidate: dict, *, reason: str, score: float) -> bool:
nonlocal selected_coverage
candidate_id = int(candidate.get("metric_cache_ref_id") or 0)
if candidate_id in selected_ids:
return False
selected_ids.add(candidate_id)
selected.append(
{
**candidate,
"selection_reason": reason,
"selection_score": float(score),
}
)
master_id = int(candidate["master"].id)
slave_id = int(candidate["slave"].id)
degree[master_id] += 1
degree[slave_id] += 1
self._union_components(parents, master_id, slave_id)
candidate_geom = self._get_candidate_intersection_geom(
candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
)
if candidate_geom is not None and not candidate_geom.is_empty:
selected_coverage = unary_union([selected_coverage, candidate_geom])
return True
for index in range(len(scene_entries) - 1):
candidate = pair_index.get(
self._pair_lookup_key(scene_entries[index]["id"], scene_entries[index + 1]["id"])
)
if not candidate:
continue
score = self._score_sbas_candidate(
candidate,
params,
selected_coverage=selected_coverage,
geometry_cache=geometry_cache,
aoi_poly=aoi_poly,
)
select_candidate(candidate, reason="sbas_time_skeleton", score=score)
for candidate in sorted_candidates:
if len(selected) >= max_edges or self._component_count(parents) <= 1:
break
candidate_id = int(candidate.get("metric_cache_ref_id") or 0)
if candidate_id in selected_ids:
continue
master_id = int(candidate["master"].id)
slave_id = int(candidate["slave"].id)
if self._find_component(parents, master_id) == self._find_component(parents, slave_id):
continue
score = self._score_sbas_candidate(
candidate,
params,
selected_coverage=selected_coverage,
geometry_cache=geometry_cache,
aoi_poly=aoi_poly,
) + 0.35
select_candidate(candidate, reason="sbas_connect_components", score=score)
while len(selected) < max_edges:
low_degree_ids = {scene_id for scene_id in scene_ids if degree[scene_id] < min_degree}
component_count = self._component_count(parents)
best_candidate: Optional[dict] = None
best_reason = "sbas_fill"
best_score: Optional[float] = None
best_tiebreak: Optional[Tuple[Any, ...]] = None
for candidate in sorted_candidates:
candidate_id = int(candidate.get("metric_cache_ref_id") or 0)
if candidate_id in selected_ids:
continue
master_id = int(candidate["master"].id)
slave_id = int(candidate["slave"].id)
if degree[master_id] >= max_degree and degree[slave_id] >= max_degree:
continue
score = self._score_sbas_candidate(
candidate,
params,
selected_coverage=selected_coverage,
geometry_cache=geometry_cache,
aoi_poly=aoi_poly,
)
reason = "sbas_fill"
if component_count > 1 and self._find_component(parents, master_id) != self._find_component(parents, slave_id):
score += 0.35
reason = "sbas_connect_components"
if low_degree_ids and (master_id in low_degree_ids or slave_id in low_degree_ids):
score += 0.20
reason = "sbas_low_degree_fill"
tiebreak = self._candidate_sort_key({**candidate, "selection_score": score})
if (
best_candidate is None
or score > float(best_score)
or (abs(score - float(best_score)) <= 1e-9 and tiebreak < best_tiebreak)
):
best_candidate = candidate
best_reason = reason
best_score = score
best_tiebreak = tiebreak
if best_candidate is None or best_score is None:
break
select_candidate(best_candidate, reason=best_reason, score=best_score)
if self._component_count(parents) == 1 and all(degree[scene_id] >= min_degree for scene_id in scene_ids):
break
if self._component_count(parents) > 1:
warnings.append("SBAS 网络未能构成完整连通图,当前筛选条件下候选边不足。")
zero_degree_count = sum(1 for scene_id in scene_ids if degree[scene_id] == 0)
low_degree_count = sum(1 for scene_id in scene_ids if degree[scene_id] < min_degree)
if zero_degree_count > 0:
warnings.append(f"SBAS 网络仍有 {zero_degree_count} 个场景没有任何连接。")
elif low_degree_count > 0:
warnings.append(f"SBAS 网络未达到目标最小连接数 {min_degree},仍有 {low_degree_count} 个场景连接不足。")
return selected, warnings
def _build_scene_entries(self, candidate_pool: List[dict]) -> List[dict]:
scene_index: Dict[int, dict] = {}
for candidate in candidate_pool:
for role in ("master", "slave"):
scene = candidate[role]
scene_id = int(scene.id)
if scene_id in scene_index:
continue
scene_index[scene_id] = {
"id": scene_id,
"scene": scene,
"scene_uid": str(
candidate.get(f"{role}_scene_uid")
or scene.file_path
or f"scene:{scene_id}"
),
}
return sorted(scene_index.values(), key=self._scene_order_key)
def _build_pair_index(self, candidate_pool: List[dict]) -> Dict[Tuple[int, int], dict]:
pair_index: Dict[Tuple[int, int], dict] = {}
for candidate in candidate_pool:
pair_index.setdefault(
self._pair_lookup_key(candidate["master"].id, candidate["slave"].id),
candidate,
)
return pair_index
def _pair_lookup_key(self, left_id: int, right_id: int) -> Tuple[int, int]:
left_value = int(left_id)
right_value = int(right_id)
return (left_value, right_value) if left_value <= right_value else (right_value, left_value)
def _scene_order_key(self, scene_entry: dict) -> Tuple[str, str, int]:
scene = scene_entry["scene"]
scene_uid = str(scene_entry.get("scene_uid") or "")
return (
self._normalize_scene_time_key(scene),
scene_uid,
int(scene_entry["id"]),
)
def _normalize_scene_time_key(self, scene: RadarData) -> str:
imaging_digits = "".join(ch for ch in str(scene.imaging_date or "") if ch.isdigit())[:8]
imaging_digits = imaging_digits.ljust(8, "0")
acquisition_digits = "".join(ch for ch in str(scene.acquisition_time_utc or "") if ch.isdigit())
if acquisition_digits:
date_part = acquisition_digits[:8].ljust(8, "0")
time_part = acquisition_digits[8:14].ljust(6, "0")
return f"{date_part}{time_part}"
return f"{imaging_digits}000000"
def _candidate_sort_key(self, candidate: dict) -> Tuple[Any, ...]:
selection_score = candidate.get("selection_score")
if selection_score is None:
selection_score = float(candidate.get("overlap_ratio") or 0)
return (
str(candidate["master"].imaging_date or ""),
str(candidate["slave"].imaging_date or ""),
-float(selection_score),
str(candidate.get("pair_uid") or f"{candidate['master'].id}:{candidate['slave'].id}"),
)
def _sorted_candidates(self, candidate_pool: List[dict]) -> List[dict]:
return sorted(candidate_pool, key=self._candidate_sort_key)
def _find_component(self, parents: Dict[int, int], scene_id: int) -> int:
current = int(scene_id)
trail = []
while parents[current] != current:
trail.append(current)
current = parents[current]
for item in trail:
parents[item] = current
return current
def _union_components(self, parents: Dict[int, int], left_id: int, right_id: int) -> None:
left_root = self._find_component(parents, left_id)
right_root = self._find_component(parents, right_id)
if left_root != right_root:
parents[right_root] = left_root
def _component_count(self, parents: Dict[int, int]) -> int:
return len({self._find_component(parents, scene_id) for scene_id in parents})
def _parse_optional_aoi_polygon(self, aoi_wkt: Optional[str]):
if not aoi_wkt:
return None
try:
from shapely import wkt as shapely_wkt
geometry = shapely_wkt.loads(aoi_wkt)
if geometry.is_empty or not geometry.is_valid:
return None
return geometry
except Exception:
return None
def _get_candidate_intersection_geom(
self,
candidate: dict,
*,
aoi_poly,
geometry_cache: Dict[int, Any],
):
cache_key = int(candidate.get("metric_cache_ref_id") or 0)
if cache_key in geometry_cache:
return geometry_cache[cache_key]
try:
master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None))
slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None))
if (
master_poly is None
or slave_poly is None
or master_poly.is_empty
or slave_poly.is_empty
):
geometry_cache[cache_key] = None
return None
pair_geom = master_poly.intersection(slave_poly)
if aoi_poly is not None:
pair_geom = pair_geom.intersection(aoi_poly)
geometry_cache[cache_key] = None if pair_geom.is_empty else pair_geom
return geometry_cache[cache_key]
except Exception:
geometry_cache[cache_key] = None
return None
def _coverage_polygon_to_shape(self, coverage_polygon: Any):
if not coverage_polygon:
return None
try:
if isinstance(coverage_polygon, dict):
if coverage_polygon.get("type") == "Feature":
geometry = coverage_polygon.get("geometry")
if not geometry:
return None
geom = shape(geometry)
else:
geom = shape(coverage_polygon)
elif isinstance(coverage_polygon, list):
points = []
for point in coverage_polygon:
if isinstance(point, (list, tuple)) and len(point) >= 2:
lon = float(point[0])
lat = float(point[1])
points.append((lon, lat))
if len(points) < 3:
return None
geom = Polygon(points)
else:
return None
if geom.is_empty or not geom.is_valid:
return None
return geom
except Exception:
return None
def _geometry_to_geojson(self, geometry: Any) -> Optional[Dict[str, Any]]:
try:
if geometry is None or geometry.is_empty:
return None
return mapping(geometry)
except Exception:
return None
def _score_sbas_candidate(
self,
candidate: dict,
params: PairingRequest,
*,
selected_coverage,
geometry_cache: Dict[int, Any],
aoi_poly,
) -> float:
max_time = max(float(params.time_baseline_max or 1), 1.0)
time_score = 1.0 - min(float(candidate.get("days") or 0) / max_time, 1.0)
if params.limit_footprint_center_distance:
max_space = max(float(params.spatial_baseline_max_meters or 1), 1.0)
spatial_score = 1.0 - min(float(candidate.get("dist") or 0) / max_space, 1.0)
else:
spatial_score = 1.0
overlap_score = min(max(float(candidate.get("overlap_ratio") or 0), 0.0), 1.0)
source_score = 1.0 if (
bool(getattr(candidate.get("master"), "insar_source_ready", False))
and bool(getattr(candidate.get("slave"), "insar_source_ready", False))
) else 0.0
orbit_score = 1.0 if (
bool(getattr(candidate.get("master"), "has_orbit_data", False))
and bool(getattr(candidate.get("slave"), "has_orbit_data", False))
) else 0.0
aoi_gain = 0.0
redundancy_penalty = 0.0
candidate_geom = self._get_candidate_intersection_geom(
candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
)
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
if candidate_area > 0:
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
overlap_area = float(candidate_geom.intersection(selected_coverage).area or 0.0)
aoi_gain = max(0.0, min(new_area / candidate_area, 1.0))
redundancy_penalty = max(0.0, min(overlap_area / candidate_area, 1.0))
return (
0.30 * time_score
+ 0.15 * spatial_score
+ 0.30 * overlap_score
+ 0.10 * aoi_gain
+ 0.10 * source_score
+ 0.05 * orbit_score
- float(params.coverage_diversity_penalty or 0.0) * redundancy_penalty
)
def _generate_task_names(self, pairs: List[RadarPair]) -> List[RadarPair]:
return ensure_unique_task_aliases(pairs)
def _normalize_timeseries_direction(self, image: RadarDataORM) -> str:
raw_direction = str(image.orbit_direction or "").strip().upper()
if raw_direction in {"ASC", "ASCENDING"}:
return "ASC"
if raw_direction in {"DSC", "DESC", "DESCENDING"}:
return "DSC"
if "ASC" in raw_direction:
return "ASC"
if "DSC" in raw_direction or "DESC" in raw_direction:
return "DSC"
return raw_direction or "UNKNOWN"
def _normalize_timeseries_satellite_family(self, image: RadarDataORM) -> str:
raw_satellite = str(image.satellite or "").strip().upper()
compact = raw_satellite.replace("-", "").replace("_", "").replace(" ", "")
if compact in {"LT1", "LT1A", "LT1B", "LUTAN1", "LUTAN1A", "LUTAN1B"}:
return "LT1"
if compact in {"S1", "S1A", "S1B", "S1C", "SENTINEL1", "SENTINEL1A", "SENTINEL1B", "SENTINEL1C"}:
return "S1"
return raw_satellite or "UNKNOWN"
def _timeseries_compatibility_key(self, image: RadarDataORM) -> Tuple[str, str, str, str]:
return (
self._normalize_timeseries_direction(image),
self._normalize_timeseries_satellite_family(image),
str(image.imaging_mode or "UNKNOWN").strip().upper() or "UNKNOWN",
str(image.polarization or "UNKNOWN").strip().upper() or "UNKNOWN",
)
def _format_timeseries_group_label(self, group_key: Tuple[str, str, str, str]) -> str:
return "_".join(part for part in group_key if part and part != "UNKNOWN") or "STACK"
async def _calculate_wkt_area(self, db: AsyncSession, geom_wkt: str) -> float:
geom = func.ST_GeomFromText(geom_wkt, 4326)
result = await db.execute(select(ST_Area(cast(geom, Geography))))
return float(result.scalar() or 0.0)
async def _select_stable_timeseries_stack(
self,
db: AsyncSession,
images: List[RadarDataORM],
params: PsRequest,
*,
aoi_wkt: str,
aoi_area: float,
) -> Tuple[List[RadarDataORM], float, float, bool, str]:
original_images = sorted(images, key=lambda item: (str(item.imaging_date or ""), int(item.id or 0)))
remaining = list(original_images)
best_stack: List[RadarDataORM] = []
best_consistency_ratio = 0.0
best_common_aoi_ratio = 0.0
min_stack_size = 3
target_ratio = float(params.final_overlap_threshold)
scene_aoi_areas: Dict[int, float] = {}
for img in remaining:
if img.id is None:
continue
scene_aoi_areas[int(img.id)] = await self._calculate_overlap_area(db, int(img.id), aoi_wkt)
def _score_stack(stack: List[RadarDataORM], common_area: float) -> Tuple[float, float]:
scene_areas = [
float(scene_aoi_areas.get(int(img.id or 0)) or 0.0)
for img in stack
if img.id is not None
]
min_scene_area = min(scene_areas) if scene_areas else 0.0
consistency_ratio = common_area / min_scene_area if min_scene_area > 0 else 0.0
common_aoi_ratio = common_area / aoi_area if aoi_area > 0 else 0.0
return (
max(0.0, min(consistency_ratio, 1.0)),
max(0.0, min(common_aoi_ratio, 1.0)),
)
while len(remaining) >= min_stack_size:
common_overlap = await self._find_common_overlap(
db,
[int(img.id) for img in remaining if img.id is not None],
clip_wkt=aoi_wkt,
)
common_area = float((common_overlap or {}).get("area") or 0.0)
consistency_ratio, common_aoi_ratio = _score_stack(remaining, common_area)
if (
consistency_ratio > best_consistency_ratio + 1e-9
or (
abs(consistency_ratio - best_consistency_ratio) <= 1e-9
and common_aoi_ratio > best_common_aoi_ratio + 1e-9
)
or (
abs(consistency_ratio - best_consistency_ratio) <= 1e-9
and abs(common_aoi_ratio - best_common_aoi_ratio) <= 1e-9
and len(remaining) > len(best_stack)
)
):
best_stack = list(remaining)
best_consistency_ratio = consistency_ratio
best_common_aoi_ratio = common_aoi_ratio
if consistency_ratio >= target_ratio:
return remaining, consistency_ratio, common_aoi_ratio, True, "common_overlap"
if len(remaining) == min_stack_size:
break
trial_options: List[Tuple[float, float, int, List[RadarDataORM]]] = []
for remove_index, _ in enumerate(remaining):
trial = remaining[:remove_index] + remaining[remove_index + 1:]
trial_overlap = await self._find_common_overlap(
db,
[int(img.id) for img in trial if img.id is not None],
clip_wkt=aoi_wkt,
)
trial_area = float((trial_overlap or {}).get("area") or 0.0)
trial_consistency_ratio, trial_common_aoi_ratio = _score_stack(trial, trial_area)
removed_id = int(remaining[remove_index].id or 0)
trial_options.append((trial_consistency_ratio, trial_common_aoi_ratio, -removed_id, trial))
if not trial_options:
break
_, _, _, remaining = max(trial_options, key=lambda item: (item[0], item[1], item[2]))
if best_consistency_ratio >= target_ratio and len(best_stack) >= min_stack_size:
return best_stack, best_consistency_ratio, best_common_aoi_ratio, True, "common_overlap"
network_stack, network_ratio = await self._select_pairwise_sbas_network_stack(
db,
original_images,
scene_aoi_areas,
target_ratio,
aoi_wkt=aoi_wkt,
)
if len(network_stack) >= min_stack_size:
return network_stack, network_ratio, best_common_aoi_ratio, True, "pairwise_sbas_network"
return [], best_consistency_ratio, best_common_aoi_ratio, False, "none"
async def _select_pairwise_sbas_network_stack(
self,
db: AsyncSession,
images: List[RadarDataORM],
scene_aoi_areas: Dict[int, float],
target_ratio: float,
*,
aoi_wkt: str,
) -> Tuple[List[RadarDataORM], float]:
if len(images) < 3:
return [], 0.0
image_by_id = {int(img.id): img for img in images if img.id is not None}
adjacency: Dict[int, List[Tuple[int, float]]] = {scene_id: [] for scene_id in image_by_id}
aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326)
for left, right in combinations(images, 2):
if left.id is None or right.id is None:
continue
left_id = int(left.id)
right_id = int(right.id)
left_area = float(scene_aoi_areas.get(left_id) or 0.0)
right_area = float(scene_aoi_areas.get(right_id) or 0.0)
min_scene_area = min(left_area, right_area)
if min_scene_area <= 0:
continue
left_geom = select(RadarDataORM.geom).where(RadarDataORM.id == left_id).scalar_subquery()
right_geom = select(RadarDataORM.geom).where(RadarDataORM.id == right_id).scalar_subquery()
pair_geom = ST_Intersection(ST_Intersection(left_geom, right_geom), aoi_geom)
result = await db.execute(select(ST_Area(cast(pair_geom, Geography))))
pair_area = float(result.scalar() or 0.0)
pair_ratio = max(0.0, min(pair_area / min_scene_area, 1.0))
if pair_ratio >= target_ratio:
adjacency[left_id].append((right_id, pair_ratio))
adjacency[right_id].append((left_id, pair_ratio))
visited: set[int] = set()
best_component: List[int] = []
best_component_ratio = 0.0
for scene_id in sorted(adjacency):
if scene_id in visited:
continue
stack = [scene_id]
visited.add(scene_id)
component: List[int] = []
component_edge_ratios: List[float] = []
while stack:
current = stack.pop()
component.append(current)
for neighbor, ratio in adjacency.get(current, []):
component_edge_ratios.append(float(ratio))
if neighbor not in visited:
visited.add(neighbor)
stack.append(neighbor)
if len(component) < 3:
continue
component_ratio = min(component_edge_ratios) if component_edge_ratios else 0.0
if (
len(component) > len(best_component)
or (
len(component) == len(best_component)
and component_ratio > best_component_ratio
)
):
best_component = component
best_component_ratio = component_ratio
if len(best_component) < 3:
return [], 0.0
selected = [image_by_id[scene_id] for scene_id in best_component if scene_id in image_by_id]
selected.sort(key=lambda item: (str(item.imaging_date or ""), int(item.id or 0)))
return selected, best_component_ratio
async def find_ps_timeseries_data(
self,
db: AsyncSession,
params: PsRequest,
aoi_wkt: str
) -> Dict[str, List[RadarData]]:
"""
利用 PostGIS 查找 PS-InSAR 时序影像栈。
Args:
db: 数据库会话
params: PS-InSAR 请求参数
aoi_wkt: 感兴趣区域 WKT 字符串
Returns:
按轨道方向分组的影像字典
"""
# 1. 初始筛选:找到与 AOI 相交且单景覆盖率达标的影像
aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326)
aoi_geog = cast(aoi_geom, Geography)
aoi_area = await self._calculate_wkt_area(db, aoi_wkt)
if aoi_area <= 0:
logger.warning("timeseries stack planning skipped: AOI area is empty")
return {}
intersection_geog = cast(ST_Intersection(RadarDataORM.geom, aoi_geom), Geography)
stmt = select(RadarDataORM).where(
and_(
ST_Intersects(RadarDataORM.geom, aoi_geom),
ST_Area(intersection_geog) / ST_Area(aoi_geog) >= params.initial_overlap_threshold
)
)
result = await db.execute(stmt)
candidates = result.scalars().all()
logger.info(
"timeseries stack planning: candidates_after_aoi_gate=%s initial_threshold=%.3f final_consistency_threshold=%.3f",
len(candidates),
float(params.initial_overlap_threshold),
float(params.final_overlap_threshold),
)
if not candidates:
return {}
# 2. 按轨道方向、卫星、成像模式、极化分组,避免混入不兼容场景。
images_by_group: Dict[Tuple[str, str, str, str], List[RadarDataORM]] = {}
for img in candidates:
images_by_group.setdefault(self._timeseries_compatibility_key(img), []).append(img)
logger.info(
"timeseries stack planning: compatible_groups=%s group_sizes=%s",
len(images_by_group),
{
self._format_timeseries_group_label(group_key): len(items)
for group_key, items in images_by_group.items()
},
)
# 3. 每个兼容组内寻找满足公共 AOI 覆盖阈值的最大稳定候选栈。
final_results: Dict[str, List[RadarData]] = {}
plans_created = False
for group_key, images in images_by_group.items():
if len(images) < 3:
logger.info(
"timeseries stack planning: group=%s skipped because scene_count=%s < 3",
self._format_timeseries_group_label(group_key),
len(images),
)
continue
try:
(
final_stack,
consistency_ratio,
common_aoi_ratio,
threshold_satisfied,
selection_mode,
) = await self._select_stable_timeseries_stack(
db,
images,
params,
aoi_wkt=aoi_wkt,
aoi_area=aoi_area,
)
logger.info(
"timeseries stack planning: group=%s input_scenes=%s selected_scenes=%s consistency=%.4f common_aoi=%.4f threshold_satisfied=%s mode=%s",
self._format_timeseries_group_label(group_key),
len(images),
len(final_stack),
consistency_ratio,
common_aoi_ratio,
threshold_satisfied,
selection_mode,
)
if len(final_stack) >= 3:
final_stack.sort(key=lambda x: str(x.imaging_date or ""))
direction = group_key[0]
network_edges, network_warnings = await self._build_timeseries_network_edges(
db,
final_stack,
params,
aoi_wkt=aoi_wkt,
selection_mode=selection_mode,
)
logger.info(
"timeseries stack planning: group=%s network_edges=%s network_warnings=%s",
self._format_timeseries_group_label(group_key),
len(network_edges),
len(network_warnings),
)
persisted_plan = await self._persist_timeseries_stack_plan(
db,
direction=direction,
params=params,
aoi_wkt=aoi_wkt,
scenes=final_stack,
common_aoi_coverage_ratio=common_aoi_ratio,
coverage_consistency_ratio=consistency_ratio,
threshold_satisfied=threshold_satisfied,
selection_mode=selection_mode,
network_edges=network_edges,
network_warnings=network_warnings,
)
result_key = persisted_plan.get("group_key") or self._format_timeseries_group_label(group_key)
if result_key in final_results:
result_key = f"{result_key}_{len(final_results) + 1}"
final_results[result_key] = persisted_plan["scenes"]
plans_created = True
except Exception as e:
print(f"处理时序候选组 {self._format_timeseries_group_label(group_key)} 时出错: {e}")
continue
if plans_created:
await db.commit()
return final_results
async def find_hazard_points_in_area(
self,
db: AsyncSession,
geom_wkt: str
) -> List[HazardPoint]:
"""
查找指定区域内的灾害点。
Args:
db: 数据库会话
geom_wkt: 区域 WKT 字符串
Returns:
灾害点列表
"""
area_geom = func.ST_GeomFromText(geom_wkt, 4326)
stmt = select(HazardPointORM).where(
ST_Covers(
area_geom,
HazardPointORM.geom
)
)
result = await db.execute(stmt)
points = result.scalars().all()
return [HazardPoint.model_validate(p) for p in points]
async def find_dinsar_results_near_hazard(
self,
db: AsyncSession,
hazard_point_id: int,
buffer_degrees: float = 0.1
) -> List[ResultProductORM]:
"""
查找指定灾害点附近的 D-InSAR 结果。
Args:
db: 数据库会话
hazard_point_id: 灾害点 ID
buffer_degrees: 搜索半径(度)
Returns:
D-InSAR 结果列表
"""
# 获取灾害点位置
stmt = select(HazardPointORM).where(HazardPointORM.id == hazard_point_id)
result = await db.execute(stmt)
hazard = result.scalar_one_or_none()
if not hazard:
return []
# 使用 PostGIS 空间查询
stmt = select(ResultProductORM).where(
ResultProductORM.catalog_name == "dinsar",
ST_Covers(
ResultProductORM.geom,
hazard.geom
)
)
result = await db.execute(stmt)
return result.scalars().all()
async def _calculate_spatial_distance(
self,
db: AsyncSession,
master: RadarDataORM,
slave: RadarDataORM
) -> float:
"""
Calculate footprint center distance in meters using PostGIS sphere distance.
"""
master_alias = RadarDataORM.__table__.alias("master")
slave_alias = RadarDataORM.__table__.alias("slave")
stmt = select(
func.ST_DistanceSphere(
ST_Centroid(master_alias.c.geom),
ST_Centroid(slave_alias.c.geom)
)
).select_from(
master_alias.join(slave_alias, master_alias.c.id != slave_alias.c.id)
).where(
master_alias.c.id == master.id,
slave_alias.c.id == slave.id,
)
result = await db.execute(stmt)
return result.scalar() or 0
async def _calculate_overlap_ratio(
self,
db: AsyncSession,
master: RadarDataORM,
slave: RadarDataORM
) -> float:
"""
Calculate overlap ratio using geography areas for accuracy.
"""
master_alias = RadarDataORM.__table__.alias("master")
slave_alias = RadarDataORM.__table__.alias("slave")
stmt = select(
ST_Area(cast(ST_Intersection(master_alias.c.geom, slave_alias.c.geom), Geography)) /
func.greatest(
ST_Area(cast(master_alias.c.geom, Geography)),
ST_Area(cast(slave_alias.c.geom, Geography))
)
).select_from(
master_alias.join(slave_alias, master_alias.c.id != slave_alias.c.id)
).where(
master_alias.c.id == master.id,
slave_alias.c.id == slave.id,
)
result = await db.execute(stmt)
return result.scalar() or 0
async def _calculate_overlap_area(
self,
db: AsyncSession,
image_id: int,
area_wkt: str
) -> float:
"""
Calculate overlap area against a WKT AOI using geography area.
"""
area_geom = func.ST_GeomFromText(area_wkt, 4326)
inter_geog = cast(ST_Intersection(RadarDataORM.geom, area_geom), Geography)
stmt = select(
ST_Area(inter_geog)
).where(RadarDataORM.id == image_id)
result = await db.execute(stmt)
return result.scalar() or 0
async def _find_common_overlap(
self,
db: AsyncSession,
image_ids: List[int],
clip_wkt: Optional[str] = None,
) -> Optional[dict]:
"""
Compute common overlap geometry and area using DB aggregation.
"""
if not image_ids:
return None
geom_expr = RadarDataORM.geom
if clip_wkt:
clip_geom = func.ST_GeomFromText(clip_wkt, 4326)
geom_expr = ST_Intersection(RadarDataORM.geom, clip_geom)
intersection_expr = func.st_intersection_agg(geom_expr)
stmt = select(
ST_Area(cast(intersection_expr, Geography)).label("common_area"),
intersection_expr.label("common_geom")
).where(RadarDataORM.id.in_(image_ids))
result = await db.execute(stmt)
row = result.first()
if not row or not row.common_geom:
return None
return {"geom": row.common_geom, "area": float(row.common_area or 0)}
def _optimize_coverage_diversity(
self,
candidate_pool: List[dict],
penalty_factor: float,
aoi_wkt: Optional[str] = None,
) -> List[dict]:
"""
优化任务选择,实现空间覆盖多样性。
当提供 AOI 时,只在 AOI 范围内计算覆盖面积,
避免影像全幅覆盖范围干扰优化结果。
Args:
candidate_pool: 候选任务池
penalty_factor: 重复覆盖惩罚因子
aoi_wkt: 可选的 AOI WKT 几何,用于裁剪计算区域
Returns:
优化后的任务列表
"""
if not candidate_pool:
return []
from shapely import wkt as shapely_wkt
# 解析 AOI 几何
aoi_poly = None
if aoi_wkt:
try:
aoi_poly = shapely_wkt.loads(aoi_wkt)
if aoi_poly.is_empty or not aoi_poly.is_valid:
aoi_poly = None
except Exception:
aoi_poly = None
# 按重叠面积降序排序
candidate_pool.sort(key=lambda x: x.get('overlap_ratio', 0), reverse=True)
selected_tasks = []
total_geom = Polygon()
for cand in candidate_pool:
m_poly = Polygon(cand['master'].coverage_polygon)
s_poly = Polygon(cand['slave'].coverage_polygon)
inter_poly = m_poly.intersection(s_poly)
# 如果有 AOI,裁剪到 AOI 范围内再计算
if aoi_poly is not None:
inter_poly = inter_poly.intersection(aoi_poly)
if inter_poly.is_empty:
continue
new_area = inter_poly.difference(total_geom).area
overlap_area = inter_poly.intersection(total_geom).area
score = new_area - (overlap_area * penalty_factor)
if score > 1e-8:
selected_tasks.append(cand)
total_geom = unary_union([total_geom, inter_poly])
return selected_tasks
def _haversine_distance(self, coord1: Tuple[float, float], coord2: Tuple[float, float]) -> float:
"""计算两点之间的大圆距离(米)"""
R = 6371000 # 地球半径(米)
lon1, lat1 = coord1
lon2, lat2 = coord2
phi1, phi2 = map(math.radians, [lat1, lat2])
delta_phi = math.radians(lat2 - lat1)
delta_lambda = math.radians(lon2 - lon1)
a = (math.sin(delta_phi / 2.0) ** 2 +
math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0) ** 2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
# 全局服务实例
spatial_service = SpatialService()