Refactor local InSAR asset and production workflows

This commit is contained in:
2026-06-21 12:30:21 +08:00
parent 65a8cc4eac
commit 71c524967c
88 changed files with 11165 additions and 3017 deletions
+603 -46
View File
@@ -14,7 +14,7 @@ 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_
from sqlalchemy import and_, case, cast, func, or_, text
from sqlalchemy.orm import aliased
from geoalchemy2 import Geography
@@ -24,6 +24,8 @@ from shapely.geometry import Polygon
from shapely.ops import unary_union
from ..models import (
DinsarProductionRunItemORM,
DinsarProductionRunORM,
HazardPoint,
HazardPointORM,
PairingNetworkEdgeORM,
@@ -43,7 +45,7 @@ from .dinsar_naming import build_pair_key, build_task_alias, ensure_unique_task_
from .pairing_state_service import pairing_state_service
PAIRING_POLICY_VERSION = "2026.05.raw-source.v2"
PAIRING_POLICY_VERSION = "2026.06.dinsar-production.v1"
PAIRING_WARNING_CANDIDATE_THRESHOLD = 3000
PAIRING_ALL_STRATEGY_HARD_LIMIT = 20000
logger = logging.getLogger(__name__)
@@ -145,10 +147,10 @@ class SpatialService:
require_orbit_data=require_orbit_data,
)
if effective_params.strategy == "all" and len(candidate_pool) > PAIRING_ALL_STRATEGY_HARD_LIMIT:
if len(candidate_pool) > PAIRING_ALL_STRATEGY_HARD_LIMIT:
raise RuntimeError(
f"全部配对命中 {len(candidate_pool)} 条候选边,超过系统一次性返回上限 "
f"{PAIRING_ALL_STRATEGY_HARD_LIMIT}。请改用 SBAS/Sequential 策略,或收紧 AOI、日期范围、重叠率。"
f"{PAIRING_ALL_STRATEGY_HARD_LIMIT}。请收紧 AOI、日期范围、重叠率或中心距离阈值"
)
if len(candidate_pool) > PAIRING_WARNING_CANDIDATE_THRESHOLD:
@@ -156,15 +158,21 @@ class SpatialService:
f"候选配对数超过 {PAIRING_WARNING_CANDIDATE_THRESHOLD}(当前: {len(candidate_pool)}),建议收紧参数或缩小 AOI。"
)
selected_candidates, strategy_warnings = self._apply_strategy(
candidate_pool,
effective_params,
aoi_wkt=aoi_wkt,
)
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.setdefault("selection_strategy", effective_params.strategy)
candidate["selection_strategy"] = "dinsar_production"
self._ensure_candidate_identity(candidate)
network_run_id = await self._persist_network_run(
db,
@@ -176,6 +184,7 @@ class SpatialService:
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,
@@ -227,13 +236,20 @@ class SpatialService:
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"]),
)
)
if params.limit_footprint_center_distance:
stmt = stmt.where(center_distance_expr <= params.spatial_baseline_max_meters)
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(
@@ -241,8 +257,7 @@ class SpatialService:
slave_alias.has_orbit_data.is_(True),
)
if not params.cross_satellite_pairing:
stmt = stmt.where(PairingMetricCacheORM.same_satellite_family.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))
@@ -259,20 +274,19 @@ class SpatialService:
)
if params.allowed_satellites:
allowed_satellites = [
str(item).strip().upper()
for item in params.allowed_satellites
if str(item).strip()
]
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(
or_(
func.upper(master_alias.satellite).in_(allowed_satellites),
func.upper(master_alias.satellite_family).in_(allowed_satellites),
),
or_(
func.upper(slave_alias.satellite).in_(allowed_satellites),
func.upper(slave_alias.satellite_family).in_(allowed_satellites),
),
master_family_expr.in_(allowed_satellites),
slave_family_expr.in_(allowed_satellites),
)
if params.master_date_from:
@@ -286,23 +300,23 @@ class SpatialService:
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(master_alias.geom, aoi_geom),
ST_Intersects(slave_alias.geom, aoi_geom),
ST_Intersects(pair_overlap_geom, aoi_geom),
)
if params.aoi_overlap_threshold is not None:
aoi_geog = cast(aoi_geom, Geography)
aoi_area = func.nullif(ST_Area(aoi_geog), 0)
master_inter_geog = cast(ST_Intersection(master_alias.geom, aoi_geom), Geography)
slave_inter_geog = cast(ST_Intersection(slave_alias.geom, aoi_geom), Geography)
stmt = stmt.where(
ST_Area(master_inter_geog) / aoi_area >= params.aoi_overlap_threshold,
ST_Area(slave_inter_geog) / aoi_area >= params.aoi_overlap_threshold,
)
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(),
@@ -310,7 +324,9 @@ class SpatialService:
result = await db.execute(stmt)
candidate_pool: List[dict] = []
for metric_row, master_row, slave_row in result.all():
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
@@ -328,16 +344,54 @@ class SpatialService:
"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 = build_task_alias(master.imaging_date, slave.imaging_date)
task_alias, pair_key = self._ensure_candidate_identity(candidate)
selection_score = candidate.get("selection_score")
result_pairs.append(
RadarPair(
@@ -345,13 +399,7 @@ class SpatialService:
slave=slave,
task_name=task_alias,
task_alias=task_alias,
pair_key=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,
),
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"),
@@ -367,10 +415,292 @@ class SpatialService:
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
async def _persist_network_run(
self,
db: AsyncSession,
@@ -451,6 +781,8 @@ class SpatialService:
"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(
@@ -460,6 +792,11 @@ class SpatialService:
),
"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
),
}
def _stable_sha1(self, value: Any) -> str:
@@ -814,6 +1151,226 @@ class SpatialService:
"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],