feat: improve D-InSAR pairing and distribution

This commit is contained in:
2026-05-09 14:59:20 +08:00
parent 69d15bffcb
commit 5c73e2c1c2
28 changed files with 1480 additions and 86 deletions
+173 -10
View File
@@ -1,6 +1,8 @@
import os import os
import shutil import shutil
import asyncio import asyncio
import tempfile
import zipfile
from datetime import datetime from datetime import datetime
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
@@ -34,14 +36,128 @@ async def _log_and_update(task_id: str, message: str, progress: Optional[int] =
def find_dinsar_source_to_copy(path: str) -> str: def find_dinsar_source_to_copy(path: str) -> str:
""" """
Find D-InSAR source path. Find D-InSAR source path.
Prefer the envi_import subfolder when present and non-empty. D-InSAR pairing/distribution works on the raw source product directory.
""" """
envi_path = os.path.join(path, "envi_import")
if os.path.isdir(envi_path) and os.listdir(envi_path):
return envi_path
return path return path
def _resolve_orbit_dest_path(
orbit_dir: str,
role: str,
source_path: str,
used_dest_paths: Dict[str, str],
) -> str:
base_name = os.path.basename(source_path)
dest_path = os.path.join(orbit_dir, base_name)
dest_key = os.path.normcase(os.path.abspath(dest_path))
source_key = os.path.normcase(os.path.abspath(source_path))
if dest_key not in used_dest_paths or used_dest_paths[dest_key] == source_key:
return dest_path
role_path = os.path.join(orbit_dir, f"{role}_{base_name}")
role_key = os.path.normcase(os.path.abspath(role_path))
if role_key not in used_dest_paths or used_dest_paths[role_key] == source_key:
return role_path
stem, ext = os.path.splitext(base_name)
counter = 2
while True:
numbered_path = os.path.join(orbit_dir, f"{role}_{stem}_{counter}{ext}")
numbered_key = os.path.normcase(os.path.abspath(numbered_path))
if numbered_key not in used_dest_paths:
return numbered_path
counter += 1
async def _copy_dinsar_orbit_files(
task_id: str,
item: Dict[str, Any],
task_dir: str,
include_orbit_files: bool,
) -> List[Dict[str, Any]]:
if not include_orbit_files:
return []
orbit_dir = os.path.join(task_dir, "orbit")
copied_by_source: Dict[str, str] = {}
used_dest_paths: Dict[str, str] = {}
orbit_entries: List[Dict[str, Any]] = []
for role, key in (
("master", "master_orbit_file_path"),
("slave", "slave_orbit_file_path"),
):
raw_path = item.get(key)
if not raw_path:
await _log_and_update(task_id, f" -> {role} orbit missing in catalog metadata")
orbit_entries.append(
{
"role": role,
"source_path": None,
"copied": False,
"reason": "missing_orbit_path",
}
)
continue
source_path = os.path.normpath(os.path.abspath(str(raw_path)))
if not os.path.isfile(source_path):
await _log_and_update(task_id, f" -> {role} orbit file not found: {source_path}")
orbit_entries.append(
{
"role": role,
"source_path": source_path,
"copied": False,
"reason": "source_file_not_found",
}
)
continue
await asyncio.to_thread(os.makedirs, orbit_dir, exist_ok=True)
source_key = os.path.normcase(source_path)
if source_key in copied_by_source:
dest_path = copied_by_source[source_key]
else:
dest_path = _resolve_orbit_dest_path(orbit_dir, role, source_path, used_dest_paths)
await asyncio.to_thread(shutil.copy2, source_path, dest_path)
copied_by_source[source_key] = dest_path
used_dest_paths[os.path.normcase(os.path.abspath(dest_path))] = source_key
orbit_entries.append(
{
"role": role,
"source_path": source_path,
"relative_path": os.path.relpath(dest_path, start=task_dir),
"copied": True,
}
)
return orbit_entries
def _zip_task_directory(task_dir: str, zip_path: str) -> None:
parent_dir = os.path.dirname(task_dir)
temp_zip_path = f"{zip_path}.tmp"
try:
if os.path.exists(temp_zip_path):
os.remove(temp_zip_path)
with zipfile.ZipFile(temp_zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for root, dirs, files in os.walk(task_dir):
rel_root = os.path.relpath(root, start=parent_dir)
for dirname in dirs:
arcname = os.path.join(rel_root, dirname).replace(os.sep, "/") + "/"
archive.writestr(arcname, "")
for filename in files:
file_path = os.path.join(root, filename)
arcname = os.path.join(rel_root, filename).replace(os.sep, "/")
archive.write(file_path, arcname)
os.replace(temp_zip_path, zip_path)
finally:
if os.path.exists(temp_zip_path):
try:
os.remove(temp_zip_path)
except OSError:
pass
async def run_ps_copy_items(task_id: str, items: List[Dict[str, Any]], dest_dir: str) -> None: async def run_ps_copy_items(task_id: str, items: List[Dict[str, Any]], dest_dir: str) -> None:
try: try:
await task_service.start_task(task_id, message="Starting PS-InSAR copy task...") await task_service.start_task(task_id, message="Starting PS-InSAR copy task...")
@@ -124,10 +240,25 @@ async def run_ps_copy_items(task_id: str, items: List[Dict[str, Any]], dest_dir:
raise CopyTaskExecutionError(fail_msg) from e raise CopyTaskExecutionError(fail_msg) from e
async def run_dinsar_copy_items(task_id: str, items: List[Dict[str, Any]], dest_dir: str) -> None: async def run_dinsar_copy_items(
task_id: str,
items: List[Dict[str, Any]],
dest_dir: str,
*,
include_orbit_files: bool = False,
export_zip: bool = False,
) -> None:
try: try:
await task_service.start_task(task_id, message="Starting D-InSAR copy task...") await task_service.start_task(task_id, message="Starting D-InSAR copy task...")
await _log_and_update(task_id, f"D-InSAR copy started. Dest: {dest_dir}") await _log_and_update(task_id, f"D-InSAR copy started. Dest: {dest_dir}")
await _log_and_update(
task_id,
(
"D-InSAR copy options: "
f"include_orbit_files={include_orbit_files}, "
f"export_zip={export_zip}"
),
)
if not os.path.exists(dest_dir): if not os.path.exists(dest_dir):
try: try:
@@ -175,11 +306,22 @@ async def run_dinsar_copy_items(task_id: str, items: List[Dict[str, Any]], dest_
await _log_and_update(task_id, f"[{i}/{total}] Processing: {task_name}") await _log_and_update(task_id, f"[{i}/{total}] Processing: {task_name}")
task_dir = os.path.join(dest_dir, task_alias) staging_root: Optional[str] = None
master_dir = os.path.join(task_dir, "master")
slave_dir = os.path.join(task_dir, "slave")
try: try:
if export_zip:
staging_root = await asyncio.to_thread(
tempfile.mkdtemp,
prefix="._dinsar_zip_",
dir=dest_dir,
)
task_dir = os.path.join(staging_root, task_alias)
zip_path = os.path.join(dest_dir, f"{task_alias}.zip")
else:
task_dir = os.path.join(dest_dir, task_alias)
zip_path = None
master_dir = os.path.join(task_dir, "master")
slave_dir = os.path.join(task_dir, "slave")
master_src_path = find_dinsar_source_to_copy(master_path) master_src_path = find_dinsar_source_to_copy(master_path)
if not os.path.exists(master_src_path): if not os.path.exists(master_src_path):
await _log_and_update(task_id, f" -> Missing master: {master_src_path}") await _log_and_update(task_id, f" -> Missing master: {master_src_path}")
@@ -194,6 +336,12 @@ async def run_dinsar_copy_items(task_id: str, items: List[Dict[str, Any]], dest_
await asyncio.to_thread(shutil.copytree, master_src_path, master_dir, dirs_exist_ok=True) await asyncio.to_thread(shutil.copytree, master_src_path, master_dir, dirs_exist_ok=True)
await asyncio.to_thread(shutil.copytree, slave_src_path, slave_dir, dirs_exist_ok=True) await asyncio.to_thread(shutil.copytree, slave_src_path, slave_dir, dirs_exist_ok=True)
orbit_entries = await _copy_dinsar_orbit_files(
task_id,
item,
task_dir,
include_orbit_files,
)
await asyncio.to_thread( await asyncio.to_thread(
write_pair_metadata, write_pair_metadata,
task_dir, task_dir,
@@ -213,6 +361,12 @@ async def run_dinsar_copy_items(task_id: str, items: List[Dict[str, Any]], dest_
"slave_polarization": item.get("slave_polarization"), "slave_polarization": item.get("slave_polarization"),
"time_baseline_days": item.get("time_baseline_days"), "time_baseline_days": item.get("time_baseline_days"),
"spatial_baseline_meters": item.get("spatial_baseline_meters"), "spatial_baseline_meters": item.get("spatial_baseline_meters"),
"scene_center_distance_meters": item.get("scene_center_distance_meters"),
"package_format": "zip" if export_zip else "folder",
"include_orbit_files": bool(include_orbit_files),
"master_orbit_file_path": item.get("master_orbit_file_path"),
"slave_orbit_file_path": item.get("slave_orbit_file_path"),
"orbit_files": orbit_entries,
"scene_pair_uid": item.get("scene_pair_uid") or item.get("pair_uid"), "scene_pair_uid": item.get("scene_pair_uid") or item.get("pair_uid"),
"pair_uid": item.get("pair_uid") or item.get("scene_pair_uid"), "pair_uid": item.get("pair_uid") or item.get("scene_pair_uid"),
"network_run_id": item.get("network_run_id"), "network_run_id": item.get("network_run_id"),
@@ -222,6 +376,9 @@ async def run_dinsar_copy_items(task_id: str, items: List[Dict[str, Any]], dest_
"copied_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", "copied_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
}, },
) )
if export_zip and zip_path:
await asyncio.to_thread(_zip_task_directory, task_dir, zip_path)
await _log_and_update(task_id, f" -> ZIP: {zip_path}")
await _log_and_update(task_id, " -> Success") await _log_and_update(task_id, " -> Success")
success_count += 1 success_count += 1
@@ -231,8 +388,14 @@ async def run_dinsar_copy_items(task_id: str, items: List[Dict[str, Any]], dest_
except Exception as e: except Exception as e:
await _log_and_update(task_id, f" -> Failed: {e}") await _log_and_update(task_id, f" -> Failed: {e}")
failed_count += 1 failed_count += 1
finally:
if staging_root:
await asyncio.to_thread(shutil.rmtree, staging_root, ignore_errors=True)
final_msg = f"D-InSAR copy finished. Success {success_count}, Failed {failed_count}" final_msg = (
f"D-InSAR copy finished. Mode {'zip' if export_zip else 'folder'}. "
f"Success {success_count}, Failed {failed_count}"
)
await _log_and_update(task_id, final_msg, progress=100) await _log_and_update(task_id, final_msg, progress=100)
if failed_count > 0: if failed_count > 0:
await task_service.update_task(task_id, status="FAILED", message=final_msg, progress=100) await task_service.update_task(task_id, status="FAILED", message=final_msg, progress=100)
+1
View File
@@ -35,6 +35,7 @@ MIGRATION_FILES = [
"006_result_pairing_trace.sql", "006_result_pairing_trace.sql",
"007_timeseries_stack_plan_trace.sql", "007_timeseries_stack_plan_trace.sql",
"008_timeseries_stack_plan_edges.sql", "008_timeseries_stack_plan_edges.sql",
"009_raw_source_pairing_fields.sql",
] ]
@@ -1334,6 +1334,7 @@ class Isce2Engine(DinsarEngine):
"slave_polarization": pair_meta.get("slave_polarization"), "slave_polarization": pair_meta.get("slave_polarization"),
"time_baseline_days": pair_meta.get("time_baseline_days"), "time_baseline_days": pair_meta.get("time_baseline_days"),
"spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"), "spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"),
"scene_center_distance_meters": pair_meta.get("scene_center_distance_meters"),
"scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"), "scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"),
"pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"), "pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"),
"network_run_id": pair_meta.get("network_run_id"), "network_run_id": pair_meta.get("network_run_id"),
@@ -1290,6 +1290,7 @@ class PyintEngine(DinsarEngine):
"slave_polarization": pair_meta.get("slave_polarization"), "slave_polarization": pair_meta.get("slave_polarization"),
"time_baseline_days": pair_meta.get("time_baseline_days") or time_baseline_days, "time_baseline_days": pair_meta.get("time_baseline_days") or time_baseline_days,
"spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"), "spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"),
"scene_center_distance_meters": pair_meta.get("scene_center_distance_meters"),
"scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"), "scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"),
"pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"), "pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"),
"network_run_id": pair_meta.get("network_run_id"), "network_run_id": pair_meta.get("network_run_id"),
+20 -2
View File
@@ -33,8 +33,17 @@ class RadarDataORM(Base):
scene_center_lat = Column(Float, nullable=True) scene_center_lat = Column(Float, nullable=True)
acquisition_time_utc = Column(String, nullable=True) acquisition_time_utc = Column(String, nullable=True)
product_type = Column(String, nullable=True) product_type = Column(String, nullable=True)
source_product_token = Column(String, nullable=True)
image_data_type = Column(String, nullable=True)
image_data_format = Column(String, nullable=True)
product_variant = Column(String, nullable=True)
product_level = Column(String, nullable=True) product_level = Column(String, nullable=True)
product_unique_id = Column(String, nullable=True) product_unique_id = Column(String, nullable=True)
satellite_family = Column(String, index=True, nullable=True)
look_direction = Column(String, index=True, nullable=True)
geocoded_flag = Column(Boolean, nullable=True)
insar_source_ready = Column(Boolean, nullable=False, default=False, server_default="false")
insar_source_reason = Column(Text, nullable=True)
file_path = Column(String, unique=True) file_path = Column(String, unique=True)
has_orbit_data = Column(Boolean) has_orbit_data = Column(Boolean)
orbit_file_path = Column(String, nullable=True) orbit_file_path = Column(String, nullable=True)
@@ -179,6 +188,7 @@ class DinsarProductProfileORM(Base):
orbit_direction = Column(String, index=True, nullable=True) orbit_direction = Column(String, index=True, nullable=True)
time_baseline_days = Column(Integer, index=True, nullable=True) time_baseline_days = Column(Integer, index=True, nullable=True)
spatial_baseline_meters = Column(Float, index=True, nullable=True) spatial_baseline_meters = Column(Float, index=True, nullable=True)
scene_center_distance_meters = Column(Float, index=True, nullable=True)
grid_size_m = Column(Float, nullable=True) grid_size_m = Column(Float, nullable=True)
radar_wavelength = Column(Float, nullable=True) radar_wavelength = Column(Float, nullable=True)
@@ -287,7 +297,7 @@ class PairingCacheStateORM(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
cache_scope = Column(String(32), unique=True, index=True, nullable=False, default="global") cache_scope = Column(String(32), unique=True, index=True, nullable=False, default="global")
metric_version = Column(String(32), nullable=False, default="2026.04.v1") metric_version = Column(String(32), nullable=False, default="2026.05.raw.v1")
status = Column(String(16), index=True, nullable=False, default="DIRTY") status = Column(String(16), index=True, nullable=False, default="DIRTY")
scene_count = Column(Integer, nullable=False, default=0) scene_count = Column(Integer, nullable=False, default=0)
pair_count = Column(Integer, nullable=False, default=0) pair_count = Column(Integer, nullable=False, default=0)
@@ -338,23 +348,30 @@ class PairingMetricCacheORM(Base):
master_scene_uid = Column(String, index=True, nullable=False) master_scene_uid = Column(String, index=True, nullable=False)
slave_scene_uid = Column(String, index=True, nullable=False) slave_scene_uid = Column(String, index=True, nullable=False)
pair_uid = Column(String, index=True, nullable=False) pair_uid = Column(String, index=True, nullable=False)
metric_version = Column(String(32), index=True, nullable=False, default="2026.04.v1") metric_version = Column(String(32), index=True, nullable=False, default="2026.05.raw.v1")
orientation_rule_version = Column(String(32), nullable=False, default="date_then_scene_uid_v1") orientation_rule_version = Column(String(32), nullable=False, default="date_then_scene_uid_v1")
time_baseline_days = Column(Integer, index=True, nullable=True) time_baseline_days = Column(Integer, index=True, nullable=True)
spatial_baseline_meters = Column(Float, index=True, nullable=True) spatial_baseline_meters = Column(Float, index=True, nullable=True)
scene_center_distance_meters = Column(Float, index=True, nullable=True)
scene_overlap_ratio = Column(Float, index=True, nullable=True) scene_overlap_ratio = Column(Float, index=True, nullable=True)
orbit_direction = Column(String, index=True, nullable=True) orbit_direction = Column(String, index=True, nullable=True)
same_satellite = Column(Boolean, nullable=False, default=True) same_satellite = Column(Boolean, nullable=False, default=True)
same_satellite_family = Column(Boolean, nullable=False, default=True, server_default="true")
same_look_direction = Column(Boolean, nullable=False, default=True, server_default="true")
same_imaging_mode = Column(Boolean, nullable=False, default=True) same_imaging_mode = Column(Boolean, nullable=False, default=True)
same_polarization = Column(Boolean, nullable=False, default=True) same_polarization = Column(Boolean, nullable=False, default=True)
master_imaging_date = Column(String(8), index=True, nullable=True) master_imaging_date = Column(String(8), index=True, nullable=True)
slave_imaging_date = Column(String(8), index=True, nullable=True) slave_imaging_date = Column(String(8), index=True, nullable=True)
master_satellite = Column(String, index=True, nullable=True) master_satellite = Column(String, index=True, nullable=True)
slave_satellite = Column(String, index=True, nullable=True) slave_satellite = Column(String, index=True, nullable=True)
master_satellite_family = Column(String, index=True, nullable=True)
slave_satellite_family = Column(String, index=True, nullable=True)
master_imaging_mode = Column(String, nullable=True) master_imaging_mode = Column(String, nullable=True)
slave_imaging_mode = Column(String, nullable=True) slave_imaging_mode = Column(String, nullable=True)
master_polarization = Column(String, nullable=True) master_polarization = Column(String, nullable=True)
slave_polarization = Column(String, nullable=True) slave_polarization = Column(String, nullable=True)
master_look_direction = Column(String, nullable=True)
slave_look_direction = Column(String, nullable=True)
master_file_path = Column(String, nullable=True) master_file_path = Column(String, nullable=True)
slave_file_path = Column(String, nullable=True) slave_file_path = Column(String, nullable=True)
status = Column(String(16), index=True, nullable=False, default="READY") status = Column(String(16), index=True, nullable=False, default="READY")
@@ -927,6 +944,7 @@ class DinsarTaskItemORM(Base):
slave_polarization = Column(String, nullable=True) slave_polarization = Column(String, nullable=True)
time_baseline_days = Column(Integer, nullable=True) time_baseline_days = Column(Integer, nullable=True)
spatial_baseline_meters = Column(Float, nullable=True) spatial_baseline_meters = Column(Float, nullable=True)
scene_center_distance_meters = Column(Float, nullable=True)
status = Column(String, index=True, nullable=False, default="PENDING") status = Column(String, index=True, nullable=False, default="PENDING")
remark = Column(Text, nullable=True) remark = Column(Text, nullable=True)
+30 -1
View File
@@ -5,7 +5,7 @@ Pydantic Schema 定义。
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
from ..config import read_int_env from ..config import read_int_env
@@ -178,8 +178,17 @@ class RadarData(BaseModel):
scene_center_lat: Optional[float] = None scene_center_lat: Optional[float] = None
acquisition_time_utc: Optional[str] = None acquisition_time_utc: Optional[str] = None
product_type: Optional[str] = None product_type: Optional[str] = None
source_product_token: Optional[str] = None
image_data_type: Optional[str] = None
image_data_format: Optional[str] = None
product_variant: Optional[str] = None
product_level: Optional[str] = None product_level: Optional[str] = None
product_unique_id: Optional[str] = None product_unique_id: Optional[str] = None
satellite_family: Optional[str] = None
look_direction: Optional[str] = None
geocoded_flag: Optional[bool] = None
insar_source_ready: bool = False
insar_source_reason: Optional[str] = None
file_path: str file_path: str
has_orbit_data: bool has_orbit_data: bool
orbit_file_path: Optional[str] = None orbit_file_path: Optional[str] = None
@@ -242,6 +251,9 @@ class PairingRequest(BaseModel):
require_same_imaging_mode: bool = True require_same_imaging_mode: bool = True
require_same_polarization: bool = True require_same_polarization: bool = True
aoi_overlap_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0) aoi_overlap_threshold: Optional[float] = Field(default=None, ge=0.0, le=1.0)
max_temporal_baseline_days: Optional[int] = Field(default=None, ge=1, le=3650)
pair_footprint_overlap_min_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0)
footprint_center_distance_max_meters: Optional[int] = Field(default=None, ge=0, le=100000)
# === 双池日期(新增) === # === 双池日期(新增) ===
master_date_from: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$') master_date_from: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$')
@@ -261,6 +273,20 @@ class PairingRequest(BaseModel):
# === 向后兼容(保留) === # === 向后兼容(保留) ===
start_date: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$') start_date: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$')
@model_validator(mode='before')
@classmethod
def _apply_aliases(cls, data):
if not isinstance(data, dict):
return data
normalized = dict(data)
if normalized.get('max_temporal_baseline_days') not in (None, ''):
normalized['time_baseline_max'] = normalized['max_temporal_baseline_days']
if normalized.get('pair_footprint_overlap_min_ratio') not in (None, ''):
normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio']
if normalized.get('footprint_center_distance_max_meters') not in (None, ''):
normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters']
return normalized
@field_validator( @field_validator(
'master_date_from', 'master_date_from',
'master_date_to', 'master_date_to',
@@ -336,6 +362,7 @@ class RadarPair(BaseModel):
selection_reason: Optional[str] = None selection_reason: Optional[str] = None
time_baseline_days: int time_baseline_days: int
spatial_baseline_meters: float spatial_baseline_meters: float
scene_center_distance_meters: Optional[float] = None
class PairingResponse(BaseModel): class PairingResponse(BaseModel):
@@ -413,6 +440,7 @@ class TimeseriesStackPlanEdge(BaseModel):
slave_imaging_date: Optional[str] = None slave_imaging_date: Optional[str] = None
temporal_baseline_days: Optional[int] = None temporal_baseline_days: Optional[int] = None
spatial_baseline_meters: Optional[float] = None spatial_baseline_meters: Optional[float] = None
scene_center_distance_meters: Optional[float] = None
perpendicular_baseline_meters: Optional[float] = None perpendicular_baseline_meters: Optional[float] = None
scene_overlap_ratio: Optional[float] = None scene_overlap_ratio: Optional[float] = None
pair_aoi_overlap_ratio: Optional[float] = None pair_aoi_overlap_ratio: Optional[float] = None
@@ -518,6 +546,7 @@ class DinsarTaskItem(BaseModel):
slave_polarization: Optional[str] = None slave_polarization: Optional[str] = None
time_baseline_days: Optional[int] = None time_baseline_days: Optional[int] = None
spatial_baseline_meters: Optional[float] = None spatial_baseline_meters: Optional[float] = None
scene_center_distance_meters: Optional[float] = None
status: str status: str
remark: Optional[str] = None remark: Optional[str] = None
created_at: datetime created_at: datetime
+6
View File
@@ -42,6 +42,9 @@ def get_pairing_request_from_form(
time_baseline_max: int = Form(90), time_baseline_max: int = Form(90),
overlap_threshold: float = Form(0.5), overlap_threshold: float = Form(0.5),
spatial_baseline_max_meters: int = Form(3000), spatial_baseline_max_meters: int = Form(3000),
max_temporal_baseline_days: Optional[int] = Form(None),
pair_footprint_overlap_min_ratio: Optional[float] = Form(None),
footprint_center_distance_max_meters: Optional[int] = Form(None),
coverage_diversity_penalty: float = Form(0.3), coverage_diversity_penalty: float = Form(0.3),
require_same_imaging_mode: bool = Form(True), require_same_imaging_mode: bool = Form(True),
require_same_polarization: bool = Form(True), require_same_polarization: bool = Form(True),
@@ -72,6 +75,9 @@ def get_pairing_request_from_form(
time_baseline_max=time_baseline_max, time_baseline_max=time_baseline_max,
overlap_threshold=overlap_threshold, overlap_threshold=overlap_threshold,
spatial_baseline_max_meters=spatial_baseline_max_meters, spatial_baseline_max_meters=spatial_baseline_max_meters,
max_temporal_baseline_days=max_temporal_baseline_days,
pair_footprint_overlap_min_ratio=pair_footprint_overlap_min_ratio,
footprint_center_distance_max_meters=footprint_center_distance_max_meters,
coverage_diversity_penalty=coverage_diversity_penalty, coverage_diversity_penalty=coverage_diversity_penalty,
require_same_imaging_mode=require_same_imaging_mode, require_same_imaging_mode=require_same_imaging_mode,
require_same_polarization=require_same_polarization, require_same_polarization=require_same_polarization,
+5
View File
@@ -266,6 +266,11 @@ async def create_dinsar_batch_endpoint(
slave_polarization=slave.polarization, slave_polarization=slave.polarization,
time_baseline_days=pair.time_baseline_days, time_baseline_days=pair.time_baseline_days,
spatial_baseline_meters=pair.spatial_baseline_meters, spatial_baseline_meters=pair.spatial_baseline_meters,
scene_center_distance_meters=(
pair.scene_center_distance_meters
if pair.scene_center_distance_meters is not None
else pair.spatial_baseline_meters
),
status="PENDING", status="PENDING",
) )
db.add(item) db.add(item)
+8
View File
@@ -33,6 +33,8 @@ class CopyBatchRequest(BaseModel):
batch_id: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH) batch_id: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH)
dest_dir: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH) dest_dir: str = Field(max_length=COPY_BATCH_TEXT_MAX_LENGTH)
copy_statuses: Optional[List[str]] = None copy_statuses: Optional[List[str]] = None
include_orbit_files: bool = False
export_zip: bool = False
@field_validator("batch_id", "dest_dir", mode="before") @field_validator("batch_id", "dest_dir", mode="before")
@classmethod @classmethod
@@ -138,6 +140,8 @@ async def copy_dinsar_pairs_endpoint(
"file_type": "DINSAR_PAIRS", "file_type": "DINSAR_PAIRS",
"batch_id": request.batch_id, "batch_id": request.batch_id,
"copy_statuses": copy_statuses, "copy_statuses": copy_statuses,
"include_orbit_files": bool(request.include_orbit_files),
"export_zip": bool(request.export_zip),
} }
task_id = await task_service.create_task("COPY_DATA", f"D-InSAR 数据分发: {request.dest_dir}", params=params) task_id = await task_service.create_task("COPY_DATA", f"D-InSAR 数据分发: {request.dest_dir}", params=params)
@@ -146,6 +150,8 @@ async def copy_dinsar_pairs_endpoint(
"dest_dir": request.dest_dir, "dest_dir": request.dest_dir,
"batch_id": request.batch_id, "batch_id": request.batch_id,
"copy_statuses": copy_statuses, "copy_statuses": copy_statuses,
"include_orbit_files": bool(request.include_orbit_files),
"export_zip": bool(request.export_zip),
} }
await job_queue_service.create_job("COPY_DATA", payload=payload, task_id=task_id) await job_queue_service.create_job("COPY_DATA", payload=payload, task_id=task_id)
await _add_operation_audit_log( await _add_operation_audit_log(
@@ -158,6 +164,8 @@ async def copy_dinsar_pairs_endpoint(
"batch_id": request.batch_id, "batch_id": request.batch_id,
"dest_dir": request.dest_dir, "dest_dir": request.dest_dir,
"copy_statuses": copy_statuses, "copy_statuses": copy_statuses,
"include_orbit_files": bool(request.include_orbit_files),
"export_zip": bool(request.export_zip),
}, },
) )
await db.commit() await db.commit()
+113 -14
View File
@@ -33,7 +33,13 @@ from ..config import settings
from ..models import ( from ..models import (
RadarDataORM, RadarData, DinsarResultORM, HazardPointORM, HazardPoint, ScanStateORM RadarDataORM, RadarData, DinsarResultORM, HazardPointORM, HazardPoint, ScanStateORM
) )
from ..utils import get_parser, RADAR_PARSERS, find_xml_file, parse_xml_metadata from ..utils import (
get_parser,
RADAR_PARSERS,
find_xml_file,
normalize_satellite_family,
parse_xml_metadata,
)
from .task_service import task_service from .task_service import task_service
from .image_service import image_service from .image_service import image_service
from .orbit_converter import get_source_orbit_inventory, sync_orbit_pools from .orbit_converter import get_source_orbit_inventory, sync_orbit_pools
@@ -50,7 +56,7 @@ def _safe_mtime(path: str) -> float:
_DATE_RE = re.compile(r"^\d{8}$") _DATE_RE = re.compile(r"^\d{8}$")
def _valid_imaging_date(value: str) -> bool: def _valid_imaging_date(value: Optional[str]) -> bool:
return bool(value and _DATE_RE.match(value)) return bool(value and _DATE_RE.match(value))
@@ -63,6 +69,62 @@ def _extract_date_from_text(value: Optional[str]) -> Optional[str]:
return None return None
def _text_or_none(value: Any) -> Optional[str]:
if value is None:
return None
text_value = str(value).strip()
return text_value or None
def _bool_or_none(value: Any) -> Optional[bool]:
if value is None or isinstance(value, bool):
return value
text_value = str(value).strip().lower()
if text_value in {"1", "true", "yes", "y"}:
return True
if text_value in {"0", "false", "no", "n"}:
return False
return None
def _build_insar_source_readiness(
meta: Dict[str, Any],
coverage_polygon: Optional[List[Tuple[float, float]]],
) -> Tuple[bool, Optional[str]]:
reasons: List[str] = []
if not coverage_polygon or len(coverage_polygon) < 3:
reasons.append("missing_footprint")
if not _valid_imaging_date(_text_or_none(meta.get("imaging_date"))):
reasons.append("missing_date")
for field_name, reason in (
("orbit_direction", "missing_orbit_direction"),
("imaging_mode", "missing_imaging_mode"),
("polarization", "missing_polarization"),
("satellite_family", "missing_satellite_family"),
):
if not _text_or_none(meta.get(field_name)):
reasons.append(reason)
geocoded_flag = _bool_or_none(meta.get("geocoded_flag"))
if geocoded_flag is True:
reasons.append("geocoded_product")
complex_tokens = {
_text_or_none(meta.get("image_data_type")),
_text_or_none(meta.get("product_type")),
_text_or_none(meta.get("source_product_token")),
_text_or_none(meta.get("product_variant")),
}
normalized_tokens = {str(token).strip().upper() for token in complex_tokens if token}
is_complex_source = bool(normalized_tokens.intersection({"COMPLEX", "SLC", "SSC"}))
if not is_complex_source:
reasons.append("not_complex_source")
if reasons:
return False, ";".join(reasons)
return True, None
def _iter_dirs(root: str, last_mtime: float): def _iter_dirs(root: str, last_mtime: float):
stack = [root] stack = [root]
while stack: while stack:
@@ -586,20 +648,31 @@ class DataService:
continue continue
merged_meta[key] = value merged_meta[key] = value
satellite = merged_meta.get("satellite") satellite = _text_or_none(merged_meta.get("satellite"))
imaging_date = merged_meta.get("imaging_date") imaging_date = _text_or_none(merged_meta.get("imaging_date"))
imaging_mode = merged_meta.get("imaging_mode") imaging_mode = _text_or_none(merged_meta.get("imaging_mode"))
polarization = merged_meta.get("polarization") polarization = _text_or_none(merged_meta.get("polarization"))
orbit_direction = merged_meta.get("orbit_direction") orbit_direction = _text_or_none(merged_meta.get("orbit_direction"))
satellite_mode = merged_meta.get("satellite_mode") satellite_mode = _text_or_none(merged_meta.get("satellite_mode"))
receiving_station = merged_meta.get("receiving_station") receiving_station = _text_or_none(merged_meta.get("receiving_station"))
orbit_circle = merged_meta.get("orbit_circle") orbit_circle = _text_or_none(merged_meta.get("orbit_circle"))
scene_center_lon = merged_meta.get("scene_center_lon") scene_center_lon = merged_meta.get("scene_center_lon")
scene_center_lat = merged_meta.get("scene_center_lat") scene_center_lat = merged_meta.get("scene_center_lat")
acquisition_time_utc = merged_meta.get("acquisition_time_utc") acquisition_time_utc = _text_or_none(merged_meta.get("acquisition_time_utc"))
product_type = merged_meta.get("product_type") product_type = _text_or_none(merged_meta.get("product_type"))
product_level = merged_meta.get("product_level") source_product_token = _text_or_none(merged_meta.get("source_product_token"))
product_unique_id = merged_meta.get("product_unique_id") image_data_type = _text_or_none(merged_meta.get("image_data_type"))
image_data_format = _text_or_none(merged_meta.get("image_data_format"))
product_variant = _text_or_none(merged_meta.get("product_variant"))
product_level = _text_or_none(merged_meta.get("product_level"))
product_unique_id = _text_or_none(merged_meta.get("product_unique_id"))
satellite_family = normalize_satellite_family(
_text_or_none(merged_meta.get("satellite_family")) or satellite
)
look_direction = _text_or_none(merged_meta.get("look_direction"))
if look_direction:
look_direction = look_direction.upper()
geocoded_flag = _bool_or_none(merged_meta.get("geocoded_flag"))
if not satellite: if not satellite:
continue continue
@@ -635,9 +708,27 @@ class DataService:
scene_center_lon = scene_center_lon if scene_center_lon is not None else poly.centroid.x scene_center_lon = scene_center_lon if scene_center_lon is not None else poly.centroid.x
scene_center_lat = scene_center_lat if scene_center_lat is not None else poly.centroid.y scene_center_lat = scene_center_lat if scene_center_lat is not None else poly.centroid.y
readiness_meta = {
"satellite_family": satellite_family,
"imaging_date": imaging_date,
"imaging_mode": imaging_mode,
"orbit_direction": orbit_direction,
"polarization": polarization,
"product_type": product_type,
"source_product_token": source_product_token,
"image_data_type": image_data_type,
"product_variant": product_variant,
"geocoded_flag": geocoded_flag,
}
insar_source_ready, insar_source_reason = _build_insar_source_readiness(
readiness_meta,
coverage_polygon,
)
data_to_upsert = { data_to_upsert = {
"unique_id": unique_id, "unique_id": unique_id,
"satellite": satellite, "satellite": satellite,
"satellite_family": satellite_family,
"imaging_date": imaging_date, "imaging_date": imaging_date,
"imaging_mode": imaging_mode, "imaging_mode": imaging_mode,
"orbit_direction": orbit_direction, "orbit_direction": orbit_direction,
@@ -649,8 +740,16 @@ class DataService:
"scene_center_lat": scene_center_lat, "scene_center_lat": scene_center_lat,
"acquisition_time_utc": acquisition_time_utc, "acquisition_time_utc": acquisition_time_utc,
"product_type": product_type, "product_type": product_type,
"source_product_token": source_product_token,
"image_data_type": image_data_type,
"image_data_format": image_data_format,
"product_variant": product_variant,
"product_level": product_level, "product_level": product_level,
"product_unique_id": product_unique_id, "product_unique_id": product_unique_id,
"look_direction": look_direction,
"geocoded_flag": geocoded_flag,
"insar_source_ready": insar_source_ready,
"insar_source_reason": insar_source_reason,
"file_path": radar_folder_path, "file_path": radar_folder_path,
"has_orbit_data": has_orbit_data, "has_orbit_data": has_orbit_data,
"orbit_file_path": orbit_file_path, "orbit_file_path": orbit_file_path,
+1
View File
@@ -530,6 +530,7 @@ def _write_envi_run_sidecar(
"slave_polarization": pair_meta.get("slave_polarization"), "slave_polarization": pair_meta.get("slave_polarization"),
"time_baseline_days": pair_meta.get("time_baseline_days"), "time_baseline_days": pair_meta.get("time_baseline_days"),
"spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"), "spatial_baseline_meters": pair_meta.get("spatial_baseline_meters"),
"scene_center_distance_meters": pair_meta.get("scene_center_distance_meters"),
"scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"), "scene_pair_uid": pair_meta.get("scene_pair_uid") or pair_meta.get("pair_uid"),
"pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"), "pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"),
"network_run_id": pair_meta.get("network_run_id"), "network_run_id": pair_meta.get("network_run_id"),
+38 -3
View File
@@ -19,7 +19,7 @@ from sqlalchemy import select
from .. import database from .. import database
from ..config import settings from ..config import settings
from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, GF3ProcessingORM, AiDiagnosisORM from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, GF3ProcessingORM, AiDiagnosisORM
from ..scheduler import scan_data_job from ..scheduler import scan_data_job
from .data_service import data_service from .data_service import data_service
from .dinsar_compat_service import dinsar_compat_service from .dinsar_compat_service import dinsar_compat_service
@@ -287,6 +287,8 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
dest_dir = payload.get("dest_dir") dest_dir = payload.get("dest_dir")
batch_id = payload.get("batch_id") batch_id = payload.get("batch_id")
copy_statuses = _normalize_copy_statuses(payload.get("copy_statuses")) copy_statuses = _normalize_copy_statuses(payload.get("copy_statuses"))
include_orbit_files = bool(payload.get("include_orbit_files"))
export_zip = bool(payload.get("export_zip"))
if not batch_id: if not batch_id:
raise ValueError("COPY_DATA requires batch_id payload.") raise ValueError("COPY_DATA requires batch_id payload.")
@@ -318,6 +320,26 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
.where(DinsarTaskItemORM.status.in_(copy_statuses)) .where(DinsarTaskItemORM.status.in_(copy_statuses))
.order_by(DinsarTaskItemORM.id.asc()) .order_by(DinsarTaskItemORM.id.asc())
) )
task_items = result.scalars().all()
orbit_by_path: Dict[str, Optional[str]] = {}
if include_orbit_files:
scene_paths = [
str(path)
for item in task_items
for path in (item.master_path, item.slave_path)
if path
]
if scene_paths:
scene_result = await db.execute(
select(RadarDataORM.file_path, RadarDataORM.orbit_file_path).where(
RadarDataORM.file_path.in_(scene_paths)
)
)
orbit_by_path = {
os.path.normcase(os.path.normpath(str(file_path))): orbit_path
for file_path, orbit_path in scene_result.all()
if file_path
}
items = [ items = [
{ {
"task_name": item.task_name, "task_name": item.task_name,
@@ -335,6 +357,13 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
"slave_polarization": item.slave_polarization, "slave_polarization": item.slave_polarization,
"time_baseline_days": item.time_baseline_days, "time_baseline_days": item.time_baseline_days,
"spatial_baseline_meters": item.spatial_baseline_meters, "spatial_baseline_meters": item.spatial_baseline_meters,
"scene_center_distance_meters": item.scene_center_distance_meters,
"master_orbit_file_path": orbit_by_path.get(
os.path.normcase(os.path.normpath(str(item.master_path)))
) if include_orbit_files and item.master_path else None,
"slave_orbit_file_path": orbit_by_path.get(
os.path.normcase(os.path.normpath(str(item.slave_path)))
) if include_orbit_files and item.slave_path else None,
"scene_pair_uid": item.scene_pair_uid, "scene_pair_uid": item.scene_pair_uid,
"pair_uid": item.scene_pair_uid, "pair_uid": item.scene_pair_uid,
"network_run_id": item.network_run_id, "network_run_id": item.network_run_id,
@@ -342,13 +371,19 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
"policy_version": item.policy_version, "policy_version": item.policy_version,
"selection_strategy": item.selection_strategy, "selection_strategy": item.selection_strategy,
} }
for item in result.scalars().all() for item in task_items
] ]
if not items: if not items:
raise ValueError( raise ValueError(
f"No D-InSAR items matched copy statuses: {', '.join(copy_statuses)}" f"No D-InSAR items matched copy statuses: {', '.join(copy_statuses)}"
) )
await run_dinsar_copy_items(job.task_id, items, dest_dir) await run_dinsar_copy_items(
job.task_id,
items,
dest_dir,
include_orbit_files=include_orbit_files,
export_zip=export_zip,
)
return return
raise ValueError(f"Unknown COPY_DATA file_type: {file_type}") raise ValueError(f"Unknown COPY_DATA file_type: {file_type}")
+87 -3
View File
@@ -28,6 +28,38 @@ def _scene_uid_expr(alias: str) -> str:
) )
def _satellite_family_expr(alias: str) -> str:
compact = (
f"upper(replace(replace(replace(COALESCE({alias}.satellite, ''), '-', ''), '_', ''), ' ', ''))"
)
return (
f"COALESCE(NULLIF({alias}.satellite_family, ''), "
f"CASE "
f"WHEN {compact} IN ('LT1', 'LT1A', 'LT1B', 'LUTAN1', 'LUTAN1A', 'LUTAN1B') THEN 'LT1' "
f"WHEN {compact} IN ('S1', 'S1A', 'S1B', 'SENTINEL1', 'SENTINEL1A', 'SENTINEL1B') THEN 'S1' "
f"WHEN NULLIF({alias}.satellite, '') IS NOT NULL THEN upper({alias}.satellite) "
f"ELSE NULL END)"
)
def _same_satellite_family_expr(left_alias: str, right_alias: str) -> str:
left_family = _satellite_family_expr(left_alias)
right_family = _satellite_family_expr(right_alias)
return (
f"(NULLIF({left_family}, '') IS NOT NULL "
f"AND NULLIF({right_family}, '') IS NOT NULL "
f"AND {left_family} = {right_family})"
)
def _same_look_direction_expr(left_alias: str, right_alias: str) -> str:
return (
f"(NULLIF({left_alias}.look_direction, '') IS NULL "
f"OR NULLIF({right_alias}.look_direction, '') IS NULL "
f"OR {left_alias}.look_direction = {right_alias}.look_direction)"
)
def _orientation_is_left_master_expr(left_alias: str, right_alias: str) -> str: def _orientation_is_left_master_expr(left_alias: str, right_alias: str) -> str:
left_uid = _scene_uid_expr(left_alias) left_uid = _scene_uid_expr(left_alias)
right_uid = _scene_uid_expr(right_alias) right_uid = _scene_uid_expr(right_alias)
@@ -48,6 +80,9 @@ def _hard_constraints_expr(left_alias: str, right_alias: str) -> str:
f"AND {left_alias}.orbit_direction IS NOT NULL " f"AND {left_alias}.orbit_direction IS NOT NULL "
f"AND {right_alias}.orbit_direction IS NOT NULL " f"AND {right_alias}.orbit_direction IS NOT NULL "
f"AND {left_alias}.orbit_direction = {right_alias}.orbit_direction " f"AND {left_alias}.orbit_direction = {right_alias}.orbit_direction "
f"AND COALESCE({left_alias}.insar_source_ready, false) "
f"AND COALESCE({right_alias}.insar_source_ready, false) "
f"AND { _same_look_direction_expr(left_alias, right_alias) } "
f"AND ST_Intersects({left_alias}.geom, {right_alias}.geom)" f"AND ST_Intersects({left_alias}.geom, {right_alias}.geom)"
) )
@@ -55,6 +90,9 @@ def _hard_constraints_expr(left_alias: str, right_alias: str) -> str:
def _full_rebuild_insert_sql() -> str: def _full_rebuild_insert_sql() -> str:
master_uid = _scene_uid_expr("m") master_uid = _scene_uid_expr("m")
slave_uid = _scene_uid_expr("s") slave_uid = _scene_uid_expr("s")
center_distance = "ST_DistanceSphere(ST_Centroid(m.geom), ST_Centroid(s.geom))::double precision"
master_family = _satellite_family_expr("m")
slave_family = _satellite_family_expr("s")
return f""" return f"""
INSERT INTO pairing_metric_cache ( INSERT INTO pairing_metric_cache (
master_scene_ref_id, master_scene_ref_id,
@@ -66,19 +104,26 @@ def _full_rebuild_insert_sql() -> str:
orientation_rule_version, orientation_rule_version,
time_baseline_days, time_baseline_days,
spatial_baseline_meters, spatial_baseline_meters,
scene_center_distance_meters,
scene_overlap_ratio, scene_overlap_ratio,
orbit_direction, orbit_direction,
same_satellite, same_satellite,
same_satellite_family,
same_look_direction,
same_imaging_mode, same_imaging_mode,
same_polarization, same_polarization,
master_imaging_date, master_imaging_date,
slave_imaging_date, slave_imaging_date,
master_satellite, master_satellite,
slave_satellite, slave_satellite,
master_satellite_family,
slave_satellite_family,
master_imaging_mode, master_imaging_mode,
slave_imaging_mode, slave_imaging_mode,
master_polarization, master_polarization,
slave_polarization, slave_polarization,
master_look_direction,
slave_look_direction,
master_file_path, master_file_path,
slave_file_path, slave_file_path,
status, status,
@@ -93,13 +138,16 @@ def _full_rebuild_insert_sql() -> str:
:metric_version AS metric_version, :metric_version AS metric_version,
:orientation_rule_version AS orientation_rule_version, :orientation_rule_version AS orientation_rule_version,
ABS(to_date(s.imaging_date, 'YYYYMMDD') - to_date(m.imaging_date, 'YYYYMMDD')) AS time_baseline_days, ABS(to_date(s.imaging_date, 'YYYYMMDD') - to_date(m.imaging_date, 'YYYYMMDD')) AS time_baseline_days,
ST_DistanceSphere(ST_Centroid(m.geom), ST_Centroid(s.geom))::double precision AS spatial_baseline_meters, {center_distance} AS spatial_baseline_meters,
{center_distance} AS scene_center_distance_meters,
( (
ST_Area(ST_Intersection(m.geom, s.geom)::geography) / ST_Area(ST_Intersection(m.geom, s.geom)::geography) /
NULLIF(GREATEST(ST_Area(m.geom::geography), ST_Area(s.geom::geography)), 0) NULLIF(GREATEST(ST_Area(m.geom::geography), ST_Area(s.geom::geography)), 0)
)::double precision AS scene_overlap_ratio, )::double precision AS scene_overlap_ratio,
m.orbit_direction, m.orbit_direction,
(m.satellite IS NOT NULL AND s.satellite IS NOT NULL AND m.satellite = s.satellite) AS same_satellite, (m.satellite IS NOT NULL AND s.satellite IS NOT NULL AND m.satellite = s.satellite) AS same_satellite,
{ _same_satellite_family_expr('m', 's') } AS same_satellite_family,
{ _same_look_direction_expr('m', 's') } AS same_look_direction,
( (
NULLIF(m.imaging_mode, '') IS NOT NULL NULLIF(m.imaging_mode, '') IS NOT NULL
AND NULLIF(s.imaging_mode, '') IS NOT NULL AND NULLIF(s.imaging_mode, '') IS NOT NULL
@@ -114,10 +162,14 @@ def _full_rebuild_insert_sql() -> str:
s.imaging_date AS slave_imaging_date, s.imaging_date AS slave_imaging_date,
m.satellite AS master_satellite, m.satellite AS master_satellite,
s.satellite AS slave_satellite, s.satellite AS slave_satellite,
{master_family} AS master_satellite_family,
{slave_family} AS slave_satellite_family,
m.imaging_mode AS master_imaging_mode, m.imaging_mode AS master_imaging_mode,
s.imaging_mode AS slave_imaging_mode, s.imaging_mode AS slave_imaging_mode,
m.polarization AS master_polarization, m.polarization AS master_polarization,
s.polarization AS slave_polarization, s.polarization AS slave_polarization,
m.look_direction AS master_look_direction,
s.look_direction AS slave_look_direction,
m.file_path AS master_file_path, m.file_path AS master_file_path,
s.file_path AS slave_file_path, s.file_path AS slave_file_path,
'READY' AS status, 'READY' AS status,
@@ -133,6 +185,9 @@ def _incremental_insert_sql() -> str:
dirty_uid = _scene_uid_expr("d") dirty_uid = _scene_uid_expr("d")
other_uid = _scene_uid_expr("o") other_uid = _scene_uid_expr("o")
dirty_is_master = _orientation_is_left_master_expr("d", "o") dirty_is_master = _orientation_is_left_master_expr("d", "o")
center_distance = "ST_DistanceSphere(ST_Centroid(d.geom), ST_Centroid(o.geom))::double precision"
dirty_family = _satellite_family_expr("d")
other_family = _satellite_family_expr("o")
return f""" return f"""
INSERT INTO pairing_metric_cache ( INSERT INTO pairing_metric_cache (
master_scene_ref_id, master_scene_ref_id,
@@ -144,19 +199,26 @@ def _incremental_insert_sql() -> str:
orientation_rule_version, orientation_rule_version,
time_baseline_days, time_baseline_days,
spatial_baseline_meters, spatial_baseline_meters,
scene_center_distance_meters,
scene_overlap_ratio, scene_overlap_ratio,
orbit_direction, orbit_direction,
same_satellite, same_satellite,
same_satellite_family,
same_look_direction,
same_imaging_mode, same_imaging_mode,
same_polarization, same_polarization,
master_imaging_date, master_imaging_date,
slave_imaging_date, slave_imaging_date,
master_satellite, master_satellite,
slave_satellite, slave_satellite,
master_satellite_family,
slave_satellite_family,
master_imaging_mode, master_imaging_mode,
slave_imaging_mode, slave_imaging_mode,
master_polarization, master_polarization,
slave_polarization, slave_polarization,
master_look_direction,
slave_look_direction,
master_file_path, master_file_path,
slave_file_path, slave_file_path,
status, status,
@@ -176,13 +238,16 @@ def _incremental_insert_sql() -> str:
:metric_version AS metric_version, :metric_version AS metric_version,
:orientation_rule_version AS orientation_rule_version, :orientation_rule_version AS orientation_rule_version,
ABS(to_date(o.imaging_date, 'YYYYMMDD') - to_date(d.imaging_date, 'YYYYMMDD')) AS time_baseline_days, ABS(to_date(o.imaging_date, 'YYYYMMDD') - to_date(d.imaging_date, 'YYYYMMDD')) AS time_baseline_days,
ST_DistanceSphere(ST_Centroid(d.geom), ST_Centroid(o.geom))::double precision AS spatial_baseline_meters, {center_distance} AS spatial_baseline_meters,
{center_distance} AS scene_center_distance_meters,
( (
ST_Area(ST_Intersection(d.geom, o.geom)::geography) / ST_Area(ST_Intersection(d.geom, o.geom)::geography) /
NULLIF(GREATEST(ST_Area(d.geom::geography), ST_Area(o.geom::geography)), 0) NULLIF(GREATEST(ST_Area(d.geom::geography), ST_Area(o.geom::geography)), 0)
)::double precision AS scene_overlap_ratio, )::double precision AS scene_overlap_ratio,
d.orbit_direction, d.orbit_direction,
(d.satellite IS NOT NULL AND o.satellite IS NOT NULL AND d.satellite = o.satellite) AS same_satellite, (d.satellite IS NOT NULL AND o.satellite IS NOT NULL AND d.satellite = o.satellite) AS same_satellite,
{ _same_satellite_family_expr('d', 'o') } AS same_satellite_family,
{ _same_look_direction_expr('d', 'o') } AS same_look_direction,
( (
NULLIF(d.imaging_mode, '') IS NOT NULL NULLIF(d.imaging_mode, '') IS NOT NULL
AND NULLIF(o.imaging_mode, '') IS NOT NULL AND NULLIF(o.imaging_mode, '') IS NOT NULL
@@ -197,10 +262,14 @@ def _incremental_insert_sql() -> str:
CASE WHEN {dirty_is_master} THEN o.imaging_date ELSE d.imaging_date END AS slave_imaging_date, CASE WHEN {dirty_is_master} THEN o.imaging_date ELSE d.imaging_date END AS slave_imaging_date,
CASE WHEN {dirty_is_master} THEN d.satellite ELSE o.satellite END AS master_satellite, CASE WHEN {dirty_is_master} THEN d.satellite ELSE o.satellite END AS master_satellite,
CASE WHEN {dirty_is_master} THEN o.satellite ELSE d.satellite END AS slave_satellite, CASE WHEN {dirty_is_master} THEN o.satellite ELSE d.satellite END AS slave_satellite,
CASE WHEN {dirty_is_master} THEN {dirty_family} ELSE {other_family} END AS master_satellite_family,
CASE WHEN {dirty_is_master} THEN {other_family} ELSE {dirty_family} END AS slave_satellite_family,
CASE WHEN {dirty_is_master} THEN d.imaging_mode ELSE o.imaging_mode END AS master_imaging_mode, CASE WHEN {dirty_is_master} THEN d.imaging_mode ELSE o.imaging_mode END AS master_imaging_mode,
CASE WHEN {dirty_is_master} THEN o.imaging_mode ELSE d.imaging_mode END AS slave_imaging_mode, CASE WHEN {dirty_is_master} THEN o.imaging_mode ELSE d.imaging_mode END AS slave_imaging_mode,
CASE WHEN {dirty_is_master} THEN d.polarization ELSE o.polarization END AS master_polarization, CASE WHEN {dirty_is_master} THEN d.polarization ELSE o.polarization END AS master_polarization,
CASE WHEN {dirty_is_master} THEN o.polarization ELSE d.polarization END AS slave_polarization, CASE WHEN {dirty_is_master} THEN o.polarization ELSE d.polarization END AS slave_polarization,
CASE WHEN {dirty_is_master} THEN d.look_direction ELSE o.look_direction END AS master_look_direction,
CASE WHEN {dirty_is_master} THEN o.look_direction ELSE d.look_direction END AS slave_look_direction,
CASE WHEN {dirty_is_master} THEN d.file_path ELSE o.file_path END AS master_file_path, CASE WHEN {dirty_is_master} THEN d.file_path ELSE o.file_path END AS master_file_path,
CASE WHEN {dirty_is_master} THEN o.file_path ELSE d.file_path END AS slave_file_path, CASE WHEN {dirty_is_master} THEN o.file_path ELSE d.file_path END AS slave_file_path,
'READY' AS status, 'READY' AS status,
@@ -228,7 +297,11 @@ class PairingCacheService:
return state return state
async def _count_pair_rows(self, db: AsyncSession) -> int: async def _count_pair_rows(self, db: AsyncSession) -> int:
result = await db.execute(select(func.count(PairingMetricCacheORM.id))) result = await db.execute(
select(func.count(PairingMetricCacheORM.id)).where(
PairingMetricCacheORM.metric_version == pairing_state_service.metric_version
)
)
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
async def _count_scene_rows(self, db: AsyncSession) -> int: async def _count_scene_rows(self, db: AsyncSession) -> int:
@@ -382,6 +455,17 @@ class PairingCacheService:
pair_count = await self._count_pair_rows(db) pair_count = await self._count_pair_rows(db)
scene_count = await self._count_scene_rows(db) scene_count = await self._count_scene_rows(db)
if dirty_scene_count == 0 and self._should_full_rebuild(
dirty_scene_count=dirty_scene_count,
scene_count=scene_count,
pair_count=pair_count,
force_full=force_full,
):
result = await self.rebuild_metric_cache(db, commit=commit)
result["trigger_dirty_scene_count"] = dirty_scene_count
result["forced"] = force_full
return result
if dirty_scene_count == 0: if dirty_scene_count == 0:
summary = await self._finalize_state_success(db, full_rebuild=False) summary = await self._finalize_state_success(db, full_rebuild=False)
if commit: if commit:
+12 -3
View File
@@ -18,7 +18,7 @@ from ..models import (
PAIRING_CACHE_SCOPE_GLOBAL = "global" PAIRING_CACHE_SCOPE_GLOBAL = "global"
DEFAULT_PAIRING_METRIC_VERSION = "2026.04.v1" DEFAULT_PAIRING_METRIC_VERSION = "2026.05.raw.v1"
PAIRING_ORIENTATION_RULE_VERSION = "date_then_scene_uid_v1" PAIRING_ORIENTATION_RULE_VERSION = "date_then_scene_uid_v1"
@@ -66,7 +66,11 @@ class PairingStateService:
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
async def _count_metric_cache_rows(self, db: AsyncSession) -> int: async def _count_metric_cache_rows(self, db: AsyncSession) -> int:
result = await db.execute(select(func.count(PairingMetricCacheORM.id))) result = await db.execute(
select(func.count(PairingMetricCacheORM.id)).where(
PairingMetricCacheORM.metric_version == self.metric_version
)
)
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
async def _get_global_state(self, db: AsyncSession) -> Optional[PairingCacheStateORM]: async def _get_global_state(self, db: AsyncSession) -> Optional[PairingCacheStateORM]:
@@ -135,7 +139,12 @@ class PairingStateService:
await db.flush() await db.flush()
created = True created = True
state.metric_version = state.metric_version or self.metric_version if state.metric_version != self.metric_version:
state.metric_version = self.metric_version
state.status = "DIRTY"
state.last_error = None
else:
state.metric_version = state.metric_version or self.metric_version
state.scene_count = scene_count state.scene_count = scene_count
state.pair_count = metric_cache_count state.pair_count = metric_cache_count
state.dirty_scene_count = dirty_scene_count state.dirty_scene_count = dirty_scene_count
@@ -149,6 +149,11 @@ def _build_pairing_trace_payload(
task_network_edge_id = getattr(task_item, "network_edge_id", None) if task_item is not None else None task_network_edge_id = getattr(task_item, "network_edge_id", None) if task_item is not None else None
task_policy_version = getattr(task_item, "policy_version", None) if task_item is not None else None task_policy_version = getattr(task_item, "policy_version", None) if task_item is not None else None
task_selection_strategy = getattr(task_item, "selection_strategy", None) if task_item is not None else None task_selection_strategy = getattr(task_item, "selection_strategy", None) if task_item is not None else None
task_scene_center_distance = (
getattr(task_item, "scene_center_distance_meters", None)
if task_item is not None
else None
)
candidate_network_edge_id = _coerce_optional_int(candidate_meta.get("network_edge_id")) candidate_network_edge_id = _coerce_optional_int(candidate_meta.get("network_edge_id"))
task_network_edge_id = _coerce_optional_int(task_network_edge_id) task_network_edge_id = _coerce_optional_int(task_network_edge_id)
@@ -176,6 +181,11 @@ def _build_pairing_trace_payload(
candidate_meta.get("selection_strategy"), candidate_meta.get("selection_strategy"),
task_selection_strategy, task_selection_strategy,
), ),
"scene_center_distance_meters": (
candidate_meta.get("scene_center_distance_meters")
if candidate_meta.get("scene_center_distance_meters") is not None
else task_scene_center_distance
),
} }
return { return {
key: value key: value
@@ -240,6 +250,7 @@ def _resolve_candidate_identity(candidate: Dict[str, Any]) -> Dict[str, Any]:
"slave_polarization", "slave_polarization",
"time_baseline_days", "time_baseline_days",
"spatial_baseline_meters", "spatial_baseline_meters",
"scene_center_distance_meters",
): ):
resolved[field] = run_meta.get(field) resolved[field] = run_meta.get(field)
if resolved[field] in (None, ""): if resolved[field] in (None, ""):
@@ -608,8 +619,11 @@ class ResultCatalogService:
"slave_polarization": metric.slave_polarization, "slave_polarization": metric.slave_polarization,
"time_baseline_days": metric.time_baseline_days, "time_baseline_days": metric.time_baseline_days,
"spatial_baseline_meters": metric.spatial_baseline_meters, "spatial_baseline_meters": metric.spatial_baseline_meters,
"scene_center_distance_meters": metric.scene_center_distance_meters,
"scene_overlap_ratio": metric.scene_overlap_ratio, "scene_overlap_ratio": metric.scene_overlap_ratio,
"same_satellite": metric.same_satellite, "same_satellite": metric.same_satellite,
"same_satellite_family": metric.same_satellite_family,
"same_look_direction": metric.same_look_direction,
"same_imaging_mode": metric.same_imaging_mode, "same_imaging_mode": metric.same_imaging_mode,
"same_polarization": metric.same_polarization, "same_polarization": metric.same_polarization,
"status": metric.status, "status": metric.status,
@@ -721,6 +735,12 @@ class ResultCatalogService:
"orbit_direction": None, "orbit_direction": None,
"time_baseline_days": getattr(task_item, "time_baseline_days", None) or candidate_meta.get("time_baseline_days"), "time_baseline_days": getattr(task_item, "time_baseline_days", None) or candidate_meta.get("time_baseline_days"),
"spatial_baseline_meters": getattr(task_item, "spatial_baseline_meters", None) or candidate_meta.get("spatial_baseline_meters"), "spatial_baseline_meters": getattr(task_item, "spatial_baseline_meters", None) or candidate_meta.get("spatial_baseline_meters"),
"scene_center_distance_meters": (
getattr(task_item, "scene_center_distance_meters", None)
or candidate_meta.get("scene_center_distance_meters")
or getattr(task_item, "spatial_baseline_meters", None)
or candidate_meta.get("spatial_baseline_meters")
),
"grid_size_m": profile_params.get("target_grid_size_m") or profile_params.get("geocoding_pixel_size_m"), "grid_size_m": profile_params.get("target_grid_size_m") or profile_params.get("geocoding_pixel_size_m"),
"radar_wavelength": profile_params.get("wavelength"), "radar_wavelength": profile_params.get("wavelength"),
"orbit_clip_margin": profile_params.get("orbit_margin_sec"), "orbit_clip_margin": profile_params.get("orbit_margin_sec"),
@@ -1138,6 +1158,7 @@ class ResultCatalogService:
orbit_direction=profile_payload.get("orbit_direction"), orbit_direction=profile_payload.get("orbit_direction"),
time_baseline_days=profile_payload.get("time_baseline_days"), time_baseline_days=profile_payload.get("time_baseline_days"),
spatial_baseline_meters=profile_payload.get("spatial_baseline_meters"), spatial_baseline_meters=profile_payload.get("spatial_baseline_meters"),
scene_center_distance_meters=profile_payload.get("scene_center_distance_meters"),
grid_size_m=profile_payload.get("grid_size_m"), grid_size_m=profile_payload.get("grid_size_m"),
radar_wavelength=profile_payload.get("radar_wavelength"), radar_wavelength=profile_payload.get("radar_wavelength"),
orbit_clip_margin=profile_payload.get("orbit_clip_margin"), orbit_clip_margin=profile_payload.get("orbit_clip_margin"),
@@ -1532,6 +1553,7 @@ class ResultCatalogService:
"orbit_direction": profile.orbit_direction, "orbit_direction": profile.orbit_direction,
"time_baseline_days": profile.time_baseline_days, "time_baseline_days": profile.time_baseline_days,
"spatial_baseline_meters": profile.spatial_baseline_meters, "spatial_baseline_meters": profile.spatial_baseline_meters,
"scene_center_distance_meters": profile.scene_center_distance_meters,
"grid_size_m": profile.grid_size_m, "grid_size_m": profile.grid_size_m,
"radar_wavelength": profile.radar_wavelength, "radar_wavelength": profile.radar_wavelength,
"orbit_clip_margin": profile.orbit_clip_margin, "orbit_clip_margin": profile.orbit_clip_margin,
+114 -21
View File
@@ -14,7 +14,7 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy import and_, cast, func from sqlalchemy import and_, cast, func, or_
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from geoalchemy2 import Geography from geoalchemy2 import Geography
@@ -43,7 +43,7 @@ from .dinsar_naming import build_pair_key, build_task_alias, ensure_unique_task_
from .pairing_state_service import pairing_state_service from .pairing_state_service import pairing_state_service
PAIRING_POLICY_VERSION = "2026.04.phase3.v1" PAIRING_POLICY_VERSION = "2026.05.raw-source.v1"
PAIRING_WARNING_CANDIDATE_THRESHOLD = 3000 PAIRING_WARNING_CANDIDATE_THRESHOLD = 3000
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -155,6 +155,10 @@ class SpatialService:
) -> List[dict]: ) -> List[dict]:
master_alias = aliased(RadarDataORM) master_alias = aliased(RadarDataORM)
slave_alias = aliased(RadarDataORM) slave_alias = aliased(RadarDataORM)
center_distance_expr = func.coalesce(
PairingMetricCacheORM.scene_center_distance_meters,
PairingMetricCacheORM.spatial_baseline_meters,
)
stmt = ( stmt = (
select(PairingMetricCacheORM, master_alias, slave_alias) select(PairingMetricCacheORM, master_alias, slave_alias)
@@ -165,8 +169,9 @@ class SpatialService:
PairingMetricCacheORM.status == "READY", PairingMetricCacheORM.status == "READY",
PairingMetricCacheORM.time_baseline_days >= params.time_baseline_min, PairingMetricCacheORM.time_baseline_days >= params.time_baseline_min,
PairingMetricCacheORM.time_baseline_days <= params.time_baseline_max, PairingMetricCacheORM.time_baseline_days <= params.time_baseline_max,
PairingMetricCacheORM.spatial_baseline_meters <= params.spatial_baseline_max_meters, center_distance_expr <= params.spatial_baseline_max_meters,
PairingMetricCacheORM.scene_overlap_ratio >= params.overlap_threshold, PairingMetricCacheORM.scene_overlap_ratio >= params.overlap_threshold,
PairingMetricCacheORM.same_look_direction.is_(True),
) )
) )
@@ -177,7 +182,7 @@ class SpatialService:
) )
if not params.cross_satellite_pairing: if not params.cross_satellite_pairing:
stmt = stmt.where(PairingMetricCacheORM.same_satellite.is_(True)) stmt = stmt.where(PairingMetricCacheORM.same_satellite_family.is_(True))
if params.require_same_imaging_mode: if params.require_same_imaging_mode:
stmt = stmt.where(PairingMetricCacheORM.same_imaging_mode.is_(True)) stmt = stmt.where(PairingMetricCacheORM.same_imaging_mode.is_(True))
@@ -186,9 +191,20 @@ class SpatialService:
stmt = stmt.where(PairingMetricCacheORM.same_polarization.is_(True)) stmt = stmt.where(PairingMetricCacheORM.same_polarization.is_(True))
if params.allowed_satellites: if params.allowed_satellites:
allowed_satellites = [
str(item).strip().upper()
for item in params.allowed_satellites
if str(item).strip()
]
stmt = stmt.where( stmt = stmt.where(
master_alias.satellite.in_(params.allowed_satellites), or_(
slave_alias.satellite.in_(params.allowed_satellites), 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),
),
) )
if params.master_date_from: if params.master_date_from:
@@ -220,12 +236,18 @@ class SpatialService:
PairingMetricCacheORM.master_imaging_date.asc(), PairingMetricCacheORM.master_imaging_date.asc(),
PairingMetricCacheORM.slave_imaging_date.asc(), PairingMetricCacheORM.slave_imaging_date.asc(),
func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(), func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(),
center_distance_expr.asc(),
PairingMetricCacheORM.pair_uid.asc(), PairingMetricCacheORM.pair_uid.asc(),
) )
result = await db.execute(stmt) result = await db.execute(stmt)
candidate_pool: List[dict] = [] candidate_pool: List[dict] = []
for metric_row, master_row, slave_row in result.all(): 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)
)
candidate_pool.append( candidate_pool.append(
{ {
"metric_cache_ref_id": int(metric_row.id), "metric_cache_ref_id": int(metric_row.id),
@@ -235,7 +257,8 @@ class SpatialService:
"master": RadarData.model_validate(master_row), "master": RadarData.model_validate(master_row),
"slave": RadarData.model_validate(slave_row), "slave": RadarData.model_validate(slave_row),
"days": int(metric_row.time_baseline_days or 0), "days": int(metric_row.time_baseline_days or 0),
"dist": float(metric_row.spatial_baseline_meters or 0), "dist": center_distance,
"scene_center_distance_meters": center_distance,
"overlap_ratio": float(metric_row.scene_overlap_ratio or 0), "overlap_ratio": float(metric_row.scene_overlap_ratio or 0),
} }
) )
@@ -270,6 +293,11 @@ class SpatialService:
selection_reason=candidate.get("selection_reason"), selection_reason=candidate.get("selection_reason"),
time_baseline_days=int(candidate["days"]), time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]), 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
),
) )
) )
return result_pairs return result_pairs
@@ -356,6 +384,12 @@ class SpatialService:
"pair_uid": candidate.get("pair_uid"), "pair_uid": candidate.get("pair_uid"),
"time_baseline_days": int(candidate.get("days") or 0), "time_baseline_days": int(candidate.get("days") or 0),
"spatial_baseline_meters": float(candidate.get("dist") or 0.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), "scene_overlap_ratio": float(candidate.get("overlap_ratio") or 0.0),
} }
@@ -420,6 +454,10 @@ class SpatialService:
master_alias = aliased(RadarDataORM) master_alias = aliased(RadarDataORM)
slave_alias = aliased(RadarDataORM) slave_alias = aliased(RadarDataORM)
center_distance_expr = func.coalesce(
PairingMetricCacheORM.scene_center_distance_meters,
PairingMetricCacheORM.spatial_baseline_meters,
)
stmt = ( stmt = (
select(PairingMetricCacheORM, master_alias, slave_alias) select(PairingMetricCacheORM, master_alias, slave_alias)
.join(master_alias, master_alias.id == PairingMetricCacheORM.master_scene_ref_id) .join(master_alias, master_alias.id == PairingMetricCacheORM.master_scene_ref_id)
@@ -431,14 +469,15 @@ class SpatialService:
PairingMetricCacheORM.slave_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_min,
PairingMetricCacheORM.time_baseline_days <= params.time_baseline_max, PairingMetricCacheORM.time_baseline_days <= params.time_baseline_max,
PairingMetricCacheORM.spatial_baseline_meters <= params.spatial_baseline_max_meters, center_distance_expr <= params.spatial_baseline_max_meters,
PairingMetricCacheORM.scene_overlap_ratio >= params.network_overlap_threshold, PairingMetricCacheORM.scene_overlap_ratio >= params.network_overlap_threshold,
PairingMetricCacheORM.same_look_direction.is_(True),
) )
.order_by( .order_by(
PairingMetricCacheORM.master_imaging_date.asc(), PairingMetricCacheORM.master_imaging_date.asc(),
PairingMetricCacheORM.slave_imaging_date.asc(), PairingMetricCacheORM.slave_imaging_date.asc(),
PairingMetricCacheORM.time_baseline_days.asc(), PairingMetricCacheORM.time_baseline_days.asc(),
PairingMetricCacheORM.spatial_baseline_meters.asc(), center_distance_expr.asc(),
func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(), func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(),
PairingMetricCacheORM.id.asc(), PairingMetricCacheORM.id.asc(),
) )
@@ -447,6 +486,11 @@ class SpatialService:
candidate_pool: List[dict] = [] candidate_pool: List[dict] = []
for metric_row, master_row, slave_row in result.all(): 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( candidate_pool.append(
{ {
"metric_cache_ref_id": int(metric_row.id), "metric_cache_ref_id": int(metric_row.id),
@@ -456,7 +500,8 @@ class SpatialService:
"master": RadarData.model_validate(master_row), "master": RadarData.model_validate(master_row),
"slave": RadarData.model_validate(slave_row), "slave": RadarData.model_validate(slave_row),
"days": int(metric_row.time_baseline_days or 0), "days": int(metric_row.time_baseline_days or 0),
"dist": float(metric_row.spatial_baseline_meters or 0.0), "dist": center_distance,
"scene_center_distance_meters": center_distance,
"overlap_ratio": float(metric_row.scene_overlap_ratio or 0.0), "overlap_ratio": float(metric_row.scene_overlap_ratio or 0.0),
} }
) )
@@ -511,6 +556,11 @@ class SpatialService:
"slave_imaging_date": slave.imaging_date, "slave_imaging_date": slave.imaging_date,
"temporal_baseline_days": int(candidate.get("days") or 0), "temporal_baseline_days": int(candidate.get("days") or 0),
"spatial_baseline_meters": float(candidate.get("dist") or 0.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), "scene_overlap_ratio": float(candidate.get("overlap_ratio") or 0.0),
"selection_reason": candidate.get("selection_reason"), "selection_reason": candidate.get("selection_reason"),
"selection_score": ( "selection_score": (
@@ -523,6 +573,11 @@ class SpatialService:
"selection_mode": selection_mode, "selection_mode": selection_mode,
"pair_uid": candidate.get("pair_uid"), "pair_uid": candidate.get("pair_uid"),
"metric_version": pairing_state_service.metric_version, "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_min": params.time_baseline_min,
"time_baseline_max": params.time_baseline_max, "time_baseline_max": params.time_baseline_max,
"spatial_baseline_max_meters": params.spatial_baseline_max_meters, "spatial_baseline_max_meters": params.spatial_baseline_max_meters,
@@ -703,18 +758,44 @@ class SpatialService:
if params.strategy == "sbas": if params.strategy == "sbas":
return self._apply_sbas_strategy(candidate_pool, params, aoi_wkt=aoi_wkt) return self._apply_sbas_strategy(candidate_pool, params, aoi_wkt=aoi_wkt)
if params.strategy == "sequential": if params.strategy == "sequential":
return self._apply_sequential_strategy(candidate_pool, params.num_connections) return self._apply_sequential_strategy(candidate_pool, params.num_connections, params)
if params.strategy == "star": if params.strategy == "star":
return self._apply_star_strategy(candidate_pool, params.reference_image_id) return self._apply_star_strategy(candidate_pool, params.reference_image_id, params)
return self._apply_all_strategy(candidate_pool) return self._apply_all_strategy(candidate_pool, params)
def _apply_all_strategy(self, candidate_pool: List[dict]) -> Tuple[List[dict], List[str]]: def _score_pair_candidate(self, candidate: dict, params: PairingRequest) -> float:
max_time = max(float(params.time_baseline_max or 1), 1.0)
max_center = max(float(params.spatial_baseline_max_meters or 1), 1.0)
time_score = 1.0 - min(float(candidate.get("days") or 0) / max_time, 1.0)
center_score = 1.0 - min(float(candidate.get("dist") or 0) / max_center, 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 ( return (
[ [
{ {
**candidate, **candidate,
"selection_reason": "all_candidate", "selection_reason": "all_candidate",
"selection_score": float(candidate.get("overlap_ratio") or 0), "selection_score": self._score_pair_candidate(candidate, params),
} }
for candidate in self._sorted_candidates(candidate_pool) for candidate in self._sorted_candidates(candidate_pool)
], ],
@@ -725,6 +806,7 @@ class SpatialService:
self, self,
candidate_pool: List[dict], candidate_pool: List[dict],
num_connections: int, num_connections: int,
params: PairingRequest,
) -> Tuple[List[dict], List[str]]: ) -> Tuple[List[dict], List[str]]:
""" """
Sequential: 按稳定时间序列排序每景连接后续 N Sequential: 按稳定时间序列排序每景连接后续 N
@@ -759,7 +841,7 @@ class SpatialService:
{ {
**candidate, **candidate,
"selection_reason": "sequential_neighbor", "selection_reason": "sequential_neighbor",
"selection_score": float(candidate.get("overlap_ratio") or 0), "selection_score": self._score_pair_candidate(candidate, params),
} }
) )
picked_count += 1 picked_count += 1
@@ -770,6 +852,7 @@ class SpatialService:
self, self,
candidate_pool: List[dict], candidate_pool: List[dict],
reference_image_id: Optional[int], reference_image_id: Optional[int],
params: PairingRequest,
) -> Tuple[List[dict], List[str]]: ) -> Tuple[List[dict], List[str]]:
""" """
Star: 参考像固定为 master Star: 参考像固定为 master
@@ -820,7 +903,7 @@ class SpatialService:
{ {
**candidate, **candidate,
"selection_reason": "star_reference_master", "selection_reason": "star_reference_master",
"selection_score": float(candidate.get("overlap_ratio") or 0), "selection_score": self._score_pair_candidate(candidate, params),
"is_reference_edge": True, "is_reference_edge": True,
"reference_image_id": int(reference_image_id), "reference_image_id": int(reference_image_id),
} }
@@ -1136,6 +1219,14 @@ class SpatialService:
time_score = 1.0 - min(float(candidate.get("days") or 0) / max_time, 1.0) time_score = 1.0 - min(float(candidate.get("days") or 0) / max_time, 1.0)
spatial_score = 1.0 - min(float(candidate.get("dist") or 0) / max_space, 1.0) spatial_score = 1.0 - min(float(candidate.get("dist") or 0) / max_space, 1.0)
overlap_score = min(max(float(candidate.get("overlap_ratio") or 0), 0.0), 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 aoi_gain = 0.0
redundancy_penalty = 0.0 redundancy_penalty = 0.0
@@ -1153,10 +1244,12 @@ class SpatialService:
redundancy_penalty = max(0.0, min(overlap_area / candidate_area, 1.0)) redundancy_penalty = max(0.0, min(overlap_area / candidate_area, 1.0))
return ( return (
0.35 * time_score 0.30 * time_score
+ 0.20 * spatial_score + 0.15 * spatial_score
+ 0.30 * overlap_score + 0.30 * overlap_score
+ 0.15 * aoi_gain + 0.10 * aoi_gain
+ 0.10 * source_score
+ 0.05 * orbit_score
- float(params.coverage_diversity_penalty or 0.0) * redundancy_penalty - float(params.coverage_diversity_penalty or 0.0) * redundancy_penalty
) )
@@ -1588,7 +1681,7 @@ class SpatialService:
slave: RadarDataORM slave: RadarDataORM
) -> float: ) -> float:
""" """
Calculate spatial baseline in meters using PostGIS sphere distance. Calculate footprint center distance in meters using PostGIS sphere distance.
""" """
master_alias = RadarDataORM.__table__.alias("master") master_alias = RadarDataORM.__table__.alias("master")
slave_alias = RadarDataORM.__table__.alias("slave") slave_alias = RadarDataORM.__table__.alias("slave")
+38 -3
View File
@@ -33,8 +33,15 @@ def _radar_meta_base() -> Dict[str, Any]:
"scene_center_lat": None, "scene_center_lat": None,
"acquisition_time_utc": None, "acquisition_time_utc": None,
"product_type": None, "product_type": None,
"source_product_token": None,
"image_data_type": None,
"image_data_format": None,
"product_variant": None,
"product_level": None, "product_level": None,
"product_unique_id": None, "product_unique_id": None,
"satellite_family": None,
"look_direction": None,
"geocoded_flag": None,
} }
@@ -69,6 +76,20 @@ def _parse_coord_token(value: Optional[str]) -> Optional[float]:
return None return None
def normalize_satellite_family(value: Optional[str]) -> Optional[str]:
raw = str(value or "").strip().upper()
if not raw:
return None
compact = raw.replace("-", "").replace("_", "").replace(" ", "")
if compact in {"LT1", "LT1A", "LT1B", "LUTAN1", "LUTAN1A", "LUTAN1B"}:
return "LT1"
if compact in {"S1", "S1A", "S1B", "SENTINEL1", "SENTINEL1A", "SENTINEL1B"}:
return "S1"
if compact in {"GF3", "GAOFEN3"}:
return "GF3"
return raw
def parse_s1_radar_filename(folder_name: str) -> Optional[Dict[str, Any]]: def parse_s1_radar_filename(folder_name: str) -> Optional[Dict[str, Any]]:
""" """
Parses key info from a Sentinel-1 radar data folder name. Parses key info from a Sentinel-1 radar data folder name.
@@ -81,8 +102,11 @@ def parse_s1_radar_filename(folder_name: str) -> Optional[Dict[str, Any]]:
meta = _radar_meta_base() meta = _radar_meta_base()
meta["satellite"] = parts[0] meta["satellite"] = parts[0]
meta["satellite_family"] = normalize_satellite_family(parts[0])
meta["imaging_date"] = parts[4].split('T')[0] meta["imaging_date"] = parts[4].split('T')[0]
meta["imaging_mode"] = parts[1] meta["imaging_mode"] = parts[1]
meta["source_product_token"] = parts[2]
meta["product_type"] = parts[2]
polarization = parts[3] # e.g., '1SDV' -> 'DV' is dual-pol VV/VH polarization = parts[3] # e.g., '1SDV' -> 'DV' is dual-pol VV/VH
meta["polarization"] = polarization[2:] if len(polarization) > 2 else polarization meta["polarization"] = polarization[2:] if len(polarization) > 2 else polarization
return meta return meta
@@ -121,6 +145,7 @@ def parse_lt1_radar_filename(folder_name: str) -> Optional[Dict[str, Any]]:
meta = _radar_meta_base() meta = _radar_meta_base()
meta["satellite"] = parts[0] meta["satellite"] = parts[0]
meta["satellite_family"] = normalize_satellite_family(parts[0])
if len(parts) > 1: if len(parts) > 1:
meta["satellite_mode"] = parts[1] meta["satellite_mode"] = parts[1]
if len(parts) > 2: if len(parts) > 2:
@@ -137,6 +162,7 @@ def parse_lt1_radar_filename(folder_name: str) -> Optional[Dict[str, Any]]:
meta["imaging_date"] = _extract_date_yyyymmdd(parts[7]) meta["imaging_date"] = _extract_date_yyyymmdd(parts[7])
meta["acquisition_time_utc"] = parts[7] meta["acquisition_time_utc"] = parts[7]
if len(parts) > 8: if len(parts) > 8:
meta["source_product_token"] = parts[8]
meta["product_type"] = parts[8] meta["product_type"] = parts[8]
if len(parts) > 9: if len(parts) > 9:
meta["polarization"] = parts[9] meta["polarization"] = parts[9]
@@ -182,6 +208,7 @@ def parse_gf3_l2_dirname(folder_name: str) -> Optional[Dict[str, Any]]:
meta = _radar_meta_base() meta = _radar_meta_base()
meta["satellite"] = "GF3" meta["satellite"] = "GF3"
meta["satellite_family"] = normalize_satellite_family("GF3")
parts = name.split("_") parts = name.split("_")
# Try to extract date: first 8-digit segment # Try to extract date: first 8-digit segment
@@ -437,14 +464,19 @@ def parse_xml_metadata(
]) ])
# --- Product Type / Level / Unique ID --- # --- Product Type / Level / Unique ID ---
product_type = _get_first_text([ image_data_type = _get_first_text([
f".//{ns_prefix}imageDataInfo/{ns_prefix}imageDataType", f".//{ns_prefix}imageDataInfo/{ns_prefix}imageDataType",
f".//{ns_prefix}orderInfo/{ns_prefix}productVariant",
f".//{ns_prefix}imageDataInfo/{ns_prefix}imageDataFormat",
"//*[local-name()='imageDataInfo']/*[local-name()='imageDataType']", "//*[local-name()='imageDataInfo']/*[local-name()='imageDataType']",
])
product_variant = _get_first_text([
f".//{ns_prefix}orderInfo/{ns_prefix}productVariant",
"//*[local-name()='orderInfo']/*[local-name()='productVariant']", "//*[local-name()='orderInfo']/*[local-name()='productVariant']",
])
image_data_format = _get_first_text([
f".//{ns_prefix}imageDataInfo/{ns_prefix}imageDataFormat",
"//*[local-name()='imageDataInfo']/*[local-name()='imageDataFormat']", "//*[local-name()='imageDataInfo']/*[local-name()='imageDataFormat']",
]) ])
product_type = image_data_type or product_variant or image_data_format
product_level = _get_first_text([ product_level = _get_first_text([
f".//{ns_prefix}generalHeader/{ns_prefix}itemName", f".//{ns_prefix}generalHeader/{ns_prefix}itemName",
"//*[local-name()='generalHeader']/*[local-name()='itemName']", "//*[local-name()='generalHeader']/*[local-name()='itemName']",
@@ -527,6 +559,9 @@ def parse_xml_metadata(
"scene_center_lat": scene_center_lat, "scene_center_lat": scene_center_lat,
"acquisition_time_utc": acquisition_time_utc, "acquisition_time_utc": acquisition_time_utc,
"product_type": product_type, "product_type": product_type,
"image_data_type": image_data_type,
"image_data_format": image_data_format,
"product_variant": product_variant,
"product_level": product_level, "product_level": product_level,
"product_unique_id": product_unique_id, "product_unique_id": product_unique_id,
"look_direction": look_direction, "look_direction": look_direction,
@@ -0,0 +1,185 @@
-- Migration: Raw source pairing readiness and center-distance metrics
-- Version: 9.0
-- Date: 2026-05-09
-- Purpose: Pair D-InSAR candidates from raw complex source products without requiring prebuilt SLC/envi_import folders.
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS satellite_family VARCHAR NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS source_product_token VARCHAR NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS image_data_type VARCHAR NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS image_data_format VARCHAR NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS product_variant VARCHAR NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS look_direction VARCHAR NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS geocoded_flag BOOLEAN NULL;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS insar_source_ready BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE IF EXISTS radar_data
ADD COLUMN IF NOT EXISTS insar_source_reason TEXT NULL;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS scene_center_distance_meters DOUBLE PRECISION NULL;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS same_satellite_family BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS same_look_direction BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS master_satellite_family VARCHAR NULL;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS slave_satellite_family VARCHAR NULL;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS master_look_direction VARCHAR NULL;
ALTER TABLE IF EXISTS pairing_metric_cache
ADD COLUMN IF NOT EXISTS slave_look_direction VARCHAR NULL;
ALTER TABLE IF EXISTS dinsar_task_items
ADD COLUMN IF NOT EXISTS scene_center_distance_meters DOUBLE PRECISION NULL;
ALTER TABLE IF EXISTS dinsar_product_profiles
ADD COLUMN IF NOT EXISTS scene_center_distance_meters DOUBLE PRECISION NULL;
CREATE INDEX IF NOT EXISTS idx_radar_data_satellite_family
ON radar_data (satellite_family);
CREATE INDEX IF NOT EXISTS idx_radar_data_look_direction
ON radar_data (look_direction);
CREATE INDEX IF NOT EXISTS idx_radar_data_insar_source_ready
ON radar_data (insar_source_ready);
CREATE INDEX IF NOT EXISTS idx_pairing_metric_cache_center_distance
ON pairing_metric_cache (scene_center_distance_meters);
CREATE INDEX IF NOT EXISTS idx_pairing_metric_cache_same_family
ON pairing_metric_cache (same_satellite_family);
UPDATE radar_data
SET
satellite_family = COALESCE(
NULLIF(satellite_family, ''),
CASE
WHEN upper(replace(replace(replace(COALESCE(satellite, ''), '-', ''), '_', ''), ' ', '')) IN
('LT1', 'LT1A', 'LT1B', 'LUTAN1', 'LUTAN1A', 'LUTAN1B')
THEN 'LT1'
WHEN upper(replace(replace(replace(COALESCE(satellite, ''), '-', ''), '_', ''), ' ', '')) IN
('S1', 'S1A', 'S1B', 'SENTINEL1', 'SENTINEL1A', 'SENTINEL1B')
THEN 'S1'
WHEN NULLIF(satellite, '') IS NOT NULL
THEN upper(satellite)
ELSE NULL
END
),
source_product_token = COALESCE(
NULLIF(source_product_token, ''),
CASE
WHEN split_part(regexp_replace(COALESCE(file_path, ''), '^.*[\\/]', ''), '_', 1) LIKE 'LT1%%'
THEN NULLIF(split_part(regexp_replace(COALESCE(file_path, ''), '^.*[\\/]', ''), '_', 9), '')
WHEN split_part(regexp_replace(COALESCE(file_path, ''), '^.*[\\/]', ''), '_', 1) LIKE 'S1%%'
THEN NULLIF(split_part(regexp_replace(COALESCE(file_path, ''), '^.*[\\/]', ''), '_', 3), '')
ELSE NULL
END
),
image_data_type = COALESCE(NULLIF(image_data_type, ''), NULLIF(product_type, ''))
WHERE satellite_family IS NULL
OR satellite_family = ''
OR source_product_token IS NULL
OR source_product_token = ''
OR image_data_type IS NULL
OR image_data_type = '';
UPDATE radar_data
SET
insar_source_ready = (
geom IS NOT NULL
AND imaging_date ~ '^[0-9]{8}$'
AND NULLIF(orbit_direction, '') IS NOT NULL
AND NULLIF(imaging_mode, '') IS NOT NULL
AND NULLIF(polarization, '') IS NOT NULL
AND NULLIF(satellite_family, '') IS NOT NULL
AND geocoded_flag IS DISTINCT FROM TRUE
AND (
upper(COALESCE(NULLIF(image_data_type, ''), NULLIF(product_type, ''), '')) = 'COMPLEX'
OR upper(COALESCE(NULLIF(source_product_token, ''), '')) IN ('SLC', 'SSC')
OR upper(COALESCE(NULLIF(product_variant, ''), '')) IN ('SLC', 'SSC')
)
),
insar_source_reason = CASE
WHEN (
geom IS NOT NULL
AND imaging_date ~ '^[0-9]{8}$'
AND NULLIF(orbit_direction, '') IS NOT NULL
AND NULLIF(imaging_mode, '') IS NOT NULL
AND NULLIF(polarization, '') IS NOT NULL
AND NULLIF(satellite_family, '') IS NOT NULL
AND geocoded_flag IS DISTINCT FROM TRUE
AND (
upper(COALESCE(NULLIF(image_data_type, ''), NULLIF(product_type, ''), '')) = 'COMPLEX'
OR upper(COALESCE(NULLIF(source_product_token, ''), '')) IN ('SLC', 'SSC')
OR upper(COALESCE(NULLIF(product_variant, ''), '')) IN ('SLC', 'SSC')
)
)
THEN NULL
ELSE concat_ws(
';',
CASE WHEN geom IS NULL THEN 'missing_footprint' END,
CASE WHEN imaging_date IS NULL OR imaging_date !~ '^[0-9]{8}$' THEN 'missing_date' END,
CASE WHEN NULLIF(orbit_direction, '') IS NULL THEN 'missing_orbit_direction' END,
CASE WHEN NULLIF(imaging_mode, '') IS NULL THEN 'missing_imaging_mode' END,
CASE WHEN NULLIF(polarization, '') IS NULL THEN 'missing_polarization' END,
CASE WHEN NULLIF(satellite_family, '') IS NULL THEN 'missing_satellite_family' END,
CASE WHEN geocoded_flag IS TRUE THEN 'geocoded_product' END,
CASE WHEN NOT (
upper(COALESCE(NULLIF(image_data_type, ''), NULLIF(product_type, ''), '')) = 'COMPLEX'
OR upper(COALESCE(NULLIF(source_product_token, ''), '')) IN ('SLC', 'SSC')
OR upper(COALESCE(NULLIF(product_variant, ''), '')) IN ('SLC', 'SSC')
) THEN 'not_complex_source' END
)
END;
UPDATE pairing_metric_cache pmc
SET
scene_center_distance_meters = COALESCE(pmc.scene_center_distance_meters, pmc.spatial_baseline_meters),
master_satellite_family = COALESCE(pmc.master_satellite_family, m.satellite_family),
slave_satellite_family = COALESCE(pmc.slave_satellite_family, s.satellite_family),
master_look_direction = COALESCE(pmc.master_look_direction, m.look_direction),
slave_look_direction = COALESCE(pmc.slave_look_direction, s.look_direction),
same_satellite_family = (
NULLIF(COALESCE(m.satellite_family, m.satellite), '') IS NOT NULL
AND NULLIF(COALESCE(s.satellite_family, s.satellite), '') IS NOT NULL
AND COALESCE(m.satellite_family, m.satellite) = COALESCE(s.satellite_family, s.satellite)
),
same_look_direction = (
NULLIF(m.look_direction, '') IS NULL
OR NULLIF(s.look_direction, '') IS NULL
OR m.look_direction = s.look_direction
)
FROM radar_data m, radar_data s
WHERE pmc.master_scene_ref_id = m.id
AND pmc.slave_scene_ref_id = s.id;
UPDATE pairing_cache_state
SET
metric_version = '2026.05.raw.v1',
status = CASE WHEN status = 'REBUILDING' THEN status ELSE 'DIRTY' END,
last_error = NULL,
updated_at = NOW()
WHERE metric_version IS DISTINCT FROM '2026.05.raw.v1';
@@ -0,0 +1,553 @@
# D-InSAR 配对与分发逻辑梳理
更新时间:2026-05-09
本文按当前代码实现梳理 D-InSAR 从“雷达数据入库”到“配对规划”、“批次保存”、“数据分发”和“多引擎生产执行”的主链路。重点依据源码,而不是早期设计文档。
## 1. 总览
当前 D-InSAR 链路分为两层:
1. 配对规划层:把 `radar_data` 中的影像先预计算成 `pairing_metric_cache` 候选边,再按用户阈值和策略筛选,最后固化为一次 `pairing_network_runs` 和若干 `pairing_network_edges`
2. 分发执行层:配对结果可保存为 `dinsar_task_batches/items`,再复制成 `Task_*/master``Task_*/slave` 生产目录;生产面板再以这个根目录提交到 SARscape、ISCE2 或 PyINT/Gamma 引擎,由 DB job queue 和 worker 执行。
核心入口:
- 配对 API[backend/app/routers/pairing.py](../backend/app/routers/pairing.py)
- 配对服务:[backend/app/services/spatial_service.py](../backend/app/services/spatial_service.py)
- 配对缓存:[backend/app/services/pairing_cache_service.py](../backend/app/services/pairing_cache_service.py)
- 批次 API[backend/app/routers/task_batches.py](../backend/app/routers/task_batches.py)
- 数据分发 API[backend/app/routers/tools.py](../backend/app/routers/tools.py)
- 数据复制执行:[backend/app/copier.py](../backend/app/copier.py)
- 生产提交 API[backend/app/routers/dinsar_production.py](../backend/app/routers/dinsar_production.py)
- 生产运行状态:[backend/app/services/dinsar_production_service.py](../backend/app/services/dinsar_production_service.py)
- job 队列和 worker[backend/app/services/job_queue_service.py](../backend/app/services/job_queue_service.py)、[backend/app/services/job_worker.py](../backend/app/services/job_worker.py)
## 2. 数据入库与配对缓存失效
雷达数据扫描在 [backend/app/services/data_service.py](../backend/app/services/data_service.py) 中写入或更新 `radar_data`。每个 scene 使用 `unique_id` 做 upsert;如果发现新 scene 或补齐了轨道文件,会调用 `pairing_state_service.mark_scenes_dirty()``mark_global_dirty()`
配对缓存状态由 [backend/app/services/pairing_state_service.py](../backend/app/services/pairing_state_service.py) 管理:
- 全局状态表:`pairing_cache_state`
- 待重算 scene 表:`pairing_dirty_scenes`
- 当前指标版本:`2026.05.raw.v1`
- 当前 master/slave 定向规则:`date_then_scene_uid_v1`
应用启动时会调用 `bootstrap_pairing_cache_state()`,但不会自动全量重建候选边。缓存如果是 `DIRTY`,配对仍可返回旧缓存结果并给 warning;如果是 `FAILED``UNINITIALIZED``ERROR`,或 scene 数大于 1 但候选边为 0,`/find-pairs` 会拒绝并提示先修复缓存。
## 3. 候选边缓存构建
候选边缓存由 [pairing_cache_service.py](../backend/app/services/pairing_cache_service.py) 写入 `pairing_metric_cache`
全量重建逻辑:
- 删除全部 `pairing_metric_cache`
- 从 `radar_data m JOIN radar_data s` 重新生成候选边
- 只保留满足硬约束的 pair
- `m.id <> s.id`
- 两景都有 `geom`
- `imaging_date` 是 8 位日期
- 两景都有 `orbit_direction` 且方向一致
- 两景都是可用于 InSAR 的原始复数源:`insar_source_ready = true`
- 如果两景都有 `look_direction`,要求视向一致
- 几何相交 `ST_Intersects`
- 按 `date_then_scene_uid_v1` 只保留一个方向,避免 A-B 和 B-A 双向重复
写入的主要指标:
- `time_baseline_days`:两景日期差的绝对值
- `scene_center_distance_meters`:两景 footprint 质心的球面距离
- `spatial_baseline_meters`:兼容旧 API 的历史字段;新缓存中暂存同一个 footprint 中心距,不能解释为 SAR 空间/垂直基线
- `scene_overlap_ratio`:两景交集面积 / 两景较大 footprint 面积
- `same_satellite`
- `same_satellite_family`:同一卫星族,例如 LT1A/LT1B 归为 `LT1`
- `same_look_direction`
- `same_imaging_mode`
- `same_polarization`
- `pair_uid = md5(master_scene_uid + '|' + slave_scene_uid)`
增量重算逻辑:
- 如果 dirty scene 数过多、占比过高、或缓存为空,会转全量重建
- 否则删除涉及 dirty scene 的缓存边
- 对每个 dirty scene 与其他 scene 重新计算边
- resolved 对应 dirty rows
阈值:
- dirty scene 数量达到 64 触发全量重建
- dirty scene 占 scene 总数比例达到 25% 触发全量重建
## 4. `/find-pairs` 配对查询
前端在 [frontend/src/hooks/usePairingLogic.js](../frontend/src/hooks/usePairingLogic.js) 中把配对参数、AOI 文件或行政区 GeoJSON 组装为 `FormData`,提交到 `POST /api/find-pairs`
后端入口是 [pairing.py](../backend/app/routers/pairing.py)
- 解析配对参数为 `PairingRequest`
- 解析 AOI:支持上传 Shapefile 或传入 GeoJSON
- 调用 `spatial_service.find_dinsar_pairs()`
- 返回 `PairingResponse`,包含 pairs、warnings、`network_run_id``policy_version`、候选数和入选边数
`PairingRequest` 在 [backend/app/models/schemas.py](../backend/app/models/schemas.py) 中定义,主要参数包括:
- `time_baseline_min/max`
- `overlap_threshold`
- `spatial_baseline_max_meters`
- `coverage_diversity_penalty`
- `require_same_imaging_mode`
- `require_same_polarization`
- `aoi_overlap_threshold`
- master/slave 日期范围
- `strategy`: `all | sbas | sequential | star`
- `num_connections`
- `reference_image_id`
- `allowed_satellites`
- `cross_satellite_pairing`
- `start_date` 兼容旧参数
## 5. 候选池过滤条件
`spatial_service._query_pairing_metric_cache()` 只查询缓存表,不再实时两两计算。基础过滤条件:
- `metric_version == 2026.05.raw.v1`
- `status == READY`
- `time_baseline_days` 在请求范围内
- `scene_center_distance_meters <= spatial_baseline_max_meters`
- `scene_overlap_ratio >= overlap_threshold`
- `same_look_direction = true`
- 如果 `require_orbit_data = true`master 和 slave 都要有精轨
- 默认要求同卫星族;除非 `cross_satellite_pairing = true`
- 默认要求成像模式一致、极化一致
- 如果 `allowed_satellites` 不为空,master/slave 的卫星名或卫星族都必须在列表内
- 如果传入 master/slave 日期范围,分别约束 `master_imaging_date``slave_imaging_date`
- 如果有 AOImaster/slave footprint 都要与 AOI 相交
- 如果 `aoi_overlap_threshold` 有值,master/slave 各自覆盖 AOI 的比例都要达标
排序默认按:
1. master 日期升序
2. slave 日期升序
3. overlap 降序
4. pair_uid 升序
## 6. 配对策略
策略选择在 `spatial_service._apply_strategy()`
### 6.1 all
`all` 策略不再做网络抽稀,直接返回过滤后的全部候选边。每条边的:
- `selection_reason = all_candidate`
- `selection_score` 综合时间基线、footprint 中心距、重叠率、源数据可用性和精轨状态
### 6.2 sequential
`sequential` 策略先从候选池提取 scene,按稳定时间键排序:
- 优先 `acquisition_time_utc`
- 否则 `imaging_date`
- 再按 scene_uid 和 id 打平同日多景
然后每个 scene 向后寻找最多 `num_connections` 个有候选边的后继 scene。不存在于候选池的边不会被补造。
输出边:
- `selection_reason = sequential_neighbor`
- `selection_score` 综合时间基线、footprint 中心距、重叠率、源数据可用性和精轨状态
### 6.3 star
`star` 策略要求参考影像固定作为 master。
如果用户未指定 `reference_image_id`,系统会在时间序列中找靠近中位位置、且能作为 master 的 scene 自动作为参考影像。注意当前实现不会把 slave 侧边反转为 master 侧边;如果参考影像在候选边中只能出现在 slave 侧,这些边会被跳过并给 warning。
输出边:
- `selection_reason = star_reference_master`
- `is_reference_edge = true`
- `reference_image_id` 写入 edge meta
### 6.4 sbas
`sbas` 策略用于构造小基线网络,流程是:
1. 按时间顺序先选相邻 scene 的候选边,形成时间骨架。
2. 如果网络有多个连通分量,优先选能连接分量的候选边。
3. 继续补低度数节点,直到达到目标连接数或达到最大边数。
4. 如果无法形成完整连通图,或存在 0 度/低度数节点,返回 warning。
关键参数:
- `min_degree = min(max(1, num_connections), scene_count - 1)`
- `max_degree = min(max(min_degree + 2, 3), scene_count - 1)`
- `max_edges = min(candidate_count, max(scene_count - 1, scene_count * min_degree))`
候选边评分:
```text
score =
0.30 * time_score
+ 0.15 * center_distance_score
+ 0.30 * overlap_score
+ 0.10 * aoi_gain
+ 0.10 * source_ready_score
+ 0.05 * orbit_score
- coverage_diversity_penalty * redundancy_penalty
```
其中 `aoi_gain``redundancy_penalty` 基于 master/slave 交集几何计算;如果有 AOI,会先把交集裁到 AOI 范围。
## 7. 网络运行留痕
每次 `/find-pairs` 都会创建一条 `pairing_network_runs`
- `network_run_id = pnr_<uuid>`
- `strategy`
- `policy_version = 2026.05.raw-source.v1`
- `request_hash`
- 请求参数 JSON
- AOI hash 和 summary
- 候选边数量、入选边数量、warning 数量
每条入选边写入 `pairing_network_edges`
- 指向 `pairing_metric_cache`
- `edge_rank`
- `selection_reason`
- `selection_score`
- `selection_meta_json`
- `is_reference_edge`
之后 `RadarPair` 响应会携带:
- `pair_key`
- `pair_uid`
- `metric_cache_ref_id`
- `network_run_id`
- `network_edge_id`
- `policy_version`
- `selection_strategy`
- `selection_score`
- `selection_reason`
- `scene_center_distance_meters`
- `task_name/task_alias`
`task_alias` 由 [dinsar_naming.py](../backend/app/services/dinsar_naming.py) 生成,格式是 `Task_YYYYMMDD_YYYYMMDD`;同名时追加 `_1``_2` 保证唯一。
## 8. 批次保存
前端找到 pairs 后,用户勾选结果并调用 `createDinsarBatch()`,提交到 `POST /api/task-batches/dinsar`
后端 [task_batches.py](../backend/app/routers/task_batches.py) 会创建:
- `dinsar_task_batches`
- `dinsar_task_items`
每条 item 会保存:
- `task_name/task_alias`
- `pair_key`
- `scene_pair_uid`
- `network_run_id`
- `network_edge_id`
- `policy_version`
- `selection_strategy`
- master/slave 文件路径
- master/slave 卫星、日期、成像模式、极化
- 时间基线、footprint 中心距
- 人工审核状态,默认 `PENDING`
前端批次面板可把 item 状态改成:
- `PENDING`
- `IN_PROGRESS`
- `COMPLETED`
- `FAILED`
数据分发默认只复制 `COMPLETED` 状态的条目。
## 9. 数据分发到 Task 目录
数据分发入口是 `POST /api/tools/copy-dinsar-pairs`,代码在 [tools.py](../backend/app/routers/tools.py)。
请求参数:
- `batch_id`
- `dest_dir`
- `copy_statuses`,为空时默认 `["COMPLETED"]`
- `include_orbit_files`,默认 `false`;为 `true` 时把 master/slave 精轨复制到 Task 内的 `orbit/`
- `export_zip`,默认 `false`;为 `true` 时每个 Task 输出为一个 `.zip`
后端动作:
1. 校验目标路径。
2. 创建 `SystemTask`,类型为 `COPY_DATA`
3. 创建 `SystemJob`job_type 也是 `COPY_DATA`
4. worker 领取 job 后进入 `job_handlers._handle_copy_data()`
5. `_handle_copy_data()` 根据 `batch_id` 查询 `dinsar_task_items`,只取 `copy_statuses` 命中的条目。
6. 调用 [backend/app/copier.py](../backend/app/copier.py) 的 `run_dinsar_copy_items()`
`run_dinsar_copy_items()` 对每个 item 执行:
- 文件夹模式目标目录:`<dest_dir>/<task_alias>/`
- zip 模式目标文件:`<dest_dir>/<task_alias>.zip`
- master 目录:`<task_alias>/master`
- slave 目录:`<task_alias>/slave`
- 如果启用 `include_orbit_files`,从 `radar_data.orbit_file_path` 找 master/slave 精轨并复制到 `<task_alias>/orbit/`
- 直接复制配对时保存的原始产品目录;D-InSAR 分发不再优先使用 `envi_import/`
- 使用 `shutil.copytree(..., dirs_exist_ok=True)` 复制 master/slave
- 写入 `<task_alias>/.dinsar_pair.json`
`.dinsar_pair.json` 是后续生产追踪的关键 sidecar,包含:
- `pair_key`
- `task_name/task_alias`
- master/slave 原始路径和元数据
- `time_baseline_days`
- `spatial_baseline_meters`
- `scene_center_distance_meters`
- `package_format`
- `include_orbit_files`
- `orbit_files`
- `scene_pair_uid/pair_uid`
- `network_run_id`
- `network_edge_id`
- `policy_version`
- `selection_strategy`
- `copied_at`
当前实现不会清空已有 Task 目录,而是合并复制;如果目标已有旧文件,需要人工确认目录状态。
## 10. 生产提交与运行分发
生产入口是 `POST /api/dinsar-production/run`,前端在 [frontend/src/DinsarProductionPanel.jsx](../frontend/src/DinsarProductionPanel.jsx) 手动输入“根目录或单个任务目录”并选择引擎/模板。
支持引擎来自 [backend/app/dinsar_engines/registry.py](../backend/app/dinsar_engines/registry.py)
- `sarscape`
- `isce2`
- `pyint`
- `landsar`,目前预留,不进入 D-InSAR queued production 主链路
提交流程:
1. 校验 engine 是否注册且可用。
2. 校验 profile 是否属于该 engine。
3. 对 ISCE2/PyINT 调用 engine 的 `validate_root_dir()``normalize_extra()`
4. PyINT 会额外做输入资产预检。
5. 当前 SARscape、ISCE2、PyINT 都走 managed production run。
6. 调用 `dinsar_production_service.create_run()`
`create_run()` 做的事情:
- 根据引擎映射 task_type
- SARscape -> `IDL_RUN_DINSAR`
- ISCE2 -> `ISCE2_RUN`
- PyINT/Gamma -> `PYINT_RUN`
- 扫描 root 下的 `Task_*` 目录,或把 root 本身当单个 Task 目录
- 从 `.dinsar_pair.json` 解析 pair identity;如果没有 sidecar,则按目录名和路径生成 fallback
- 根据 `rerun_mode` 跳过已有 current pointer 的完成项
- 创建 `SystemTask`
- 创建 `dinsar_production_runs`
- 创建 `dinsar_production_run_items`
- 创建一个 workflow run,只有一个 step`execute_items`
- workflow step 入队为 `SystemJob`
注意:生产面板目前不直接从 `dinsar_task_batches` 选择批次。实际串联方式是:先在“分发”面板把批次复制到生产根目录,再在“生产”面板提交这个根目录。
## 11. worker 与执行控制
后台 worker 在 [job_worker.py](../backend/app/services/job_worker.py)
- 周期性 `claim_next_job()`
- DB 查询使用 `FOR UPDATE SKIP LOCKED`
- 按 `priority DESC, id ASC` 领取 `READY/RETRY` job
- 支持 worker heartbeat
- 支持 stale RUNNING job 恢复为 RETRY 或 FAILED
- `run_worker_loop()` 参数支持 job 级并发,但默认并发为 1
`SystemTask` 在 [task_service.py](../backend/app/services/task_service.py) 管理:
- 创建任务时会检查同一 `task_type` 是否已有 `PENDING/RUNNING`
- PostgreSQL 下使用 advisory lock 防止并发创建同类任务
- 因此同一类生产任务天然串行提交
workflow 在 [workflow_service.py](../backend/app/services/workflow_service.py)
- 创建 workflow run 和 steps
- 没有依赖的 step 立即入队
- job 完成后 mark step completed
- step 全部终态后 workflow run 完成
## 12. 各引擎生产控制器
job handler 在 [job_handlers.py](../backend/app/services/job_handlers.py)。
### 12.1 SARscape
`_handle_idl_run_dinsar()` 如果 payload 有 `production_run_id`,会进入 `_run_dinsar_production_controller()`
执行特点:
- 使用 `engine_lock_service.acquire("envi_taskengine")`,保证 ENVI/SARscape taskengine 串行
- 对 run item 逐个执行
- 每个 item 创建一个 `DinsarProductionExecution`
- 调用 `build_envi_runner_command()` 启动 runner
- 运行结束后规范化输出目录
- 写 `execution_manifest.json`
- 写 `current/<engine>__<profile>.json`
- 标记 item completed/failed/cancelled
- 成功输出目录会进入 `result_catalog_service.publish_from_sources()`
### 12.2 ISCE2 与 PyINT/Gamma
`_handle_isce2_run()``_handle_pyint_run()` 在 managed 模式下都进入 `_run_wsl_dinsar_production_controller()`
执行特点:
- 使用 `engine_lock_service.acquire(f"wsl_dinsar_{engine_code}")`
- 每个 item 构造独立 managed run 目录:
- run dir
- native dir
- workflow dir
- export dir
- orbit output dir
- 构造 `RunRequest` 调用 engine 的 `run()`
- engine 返回 `primary_file``source_files``native_output_dir`
- 校验 primary output 存在
- 写 `execution_manifest.json`
- 写 current pointer
- 标记 item 状态
- 发布成功包,并对结果 catalog 做 rebuild
一个 production run 内部 item 是串行执行的。多个 worker 可以领取不同 job,但同类任务创建限制和 engine lock 会进一步限制实际并发。
## 13. 结果发布与追踪
生产完成后会生成标准包结构,并由 result catalog 接管。`execution_manifest.json` 中保留:
- `run_id`
- `task_id`
- `engine_code`
- `profile_code`
- `runtime_id`
- `task_name/task_alias`
- `pair_key`
- `pair_uid`
- `network_run_id`
- `network_edge_id`
- `policy_version`
- `selection_strategy`
- `source_task_dir`
- `results_root_dir`
- `publish_root_dir`
- `primary_file`
- `source_files`
- `metrics`
catalog 注册逻辑在 [backend/app/services/result_catalog_service.py](../backend/app/services/result_catalog_service.py) 中会继续把 pairing trace 字段写到结果产品,便于从结果反查配对网络。
## 14. 关键表关系
配对规划:
- `radar_data`
- `pairing_cache_state`
- `pairing_dirty_scenes`
- `pairing_metric_cache`
- `pairing_network_runs`
- `pairing_network_edges`
人工批次:
- `dinsar_task_batches`
- `dinsar_task_items`
后台任务:
- `system_tasks`
- `task_logs`
- `system_jobs`
- `system_worker_heartbeats`
- `workflow_runs`
- `workflow_steps`
生产执行:
- `dinsar_production_runs`
- `dinsar_production_run_items`
- `dinsar_production_executions`
## 15. 常用 API 链路
配对健康和修复:
- `GET /api/pairing/health`
- `POST /api/pairing/rebuild-cache`
- `POST /api/pairing/reconcile-dirty?force_full=false`
配对规划:
- `POST /api/find-pairs`
- `GET /api/pairing/networks/{network_run_id}`
批次:
- `POST /api/task-batches/dinsar`
- `GET /api/task-batches/dinsar`
- `GET /api/task-batches/dinsar/{batch_id}/items`
- `PATCH /api/task-batches/dinsar/items/{item_id}`
- `PATCH /api/task-batches/dinsar/{batch_id}/complete-all`
数据分发:
- `POST /api/tools/copy-dinsar-pairs`
- `GET /api/tools/copy-status/{task_id}`
生产:
- `GET /api/dinsar-production/engines`
- `POST /api/dinsar-production/engines/pyint/preview-input-assets`
- `POST /api/dinsar-production/run`
- `GET /api/dinsar-production/runs`
## 16. 当前实现边界
1. 配对查询完全依赖 `pairing_metric_cache`。缓存未初始化、失败、或 scene 足够但 pair 为 0 时不会降级实时计算。
2. `scene_center_distance_meters` 是 footprint 质心距离;`spatial_baseline_meters` 仅为旧 API 兼容字段,不是 SAR 几何中的垂直基线。
3. master/slave 方向在缓存层已经固定为“日期优先、scene_uid 次之”。`star` 策略不会把参考影像位于 slave 的边翻转。
4. `aoi_overlap_threshold` 约束的是每一景对 AOI 的覆盖比例,不是 pair 交集对 AOI 的覆盖比例。
5. 数据分发默认只复制 `COMPLETED` 状态 item;如果用户没有在批次面板审核或一键完成,分发可能没有条目。
6. 数据分发使用 `dirs_exist_ok=True` 合并复制,不会自动清理目标旧内容。
7. 生产提交和批次保存之间没有数据库级直接引用;生产侧通过 `Task_*` 目录和 `.dinsar_pair.json` sidecar 重新恢复 pair trace。
8. 每个 production run 内部 item 串行执行;job worker 可并发,但 task_type 冲突检查和 engine lock 会限制同类引擎并发。
9. `landsar` 已注册为 engine,但当前 `/dinsar-production/run` 仅对 SARscape、ISCE2、PyINT 建立 queued production 主链路。
## 17. 推荐排查路径
配对为空:
1. 查 `GET /api/pairing/health`
2. 看 `pair_count``dirty_scene_count``status`
3. 必要时执行 `POST /api/pairing/reconcile-dirty``POST /api/pairing/rebuild-cache`
4. 放宽 `time_baseline_max``spatial_baseline_max_meters``overlap_threshold`
5. 检查 `insar_source_ready``require_orbit_data`、同卫星族、同视向、同模式、同极化约束
分发为空:
1. 查 batch item 是否存在
2. 查 item 状态是否命中 `copy_statuses`,默认只取 `COMPLETED`
3. 查 master/slave 源路径是否存在
4. 查目标目录是否已有旧文件影响判断
生产未执行:
1. 查 `system_tasks` 状态和 task logs
2. 查 `system_jobs` 是否 READY/RUNNING/FAILED
3. 查 worker heartbeat
4. 查 engine lock 是否被长任务持有
5. 查生产根目录是否包含有效 `Task_*/master``Task_*/slave`
6. 对 PyINT 先跑输入资产预检
+2
View File
@@ -60,6 +60,8 @@
## 4. 配对与前端导航 ## 4. 配对与前端导航
- [DINSAR_PAIRING_DISTRIBUTION_LOGIC_20260508.md](DINSAR_PAIRING_DISTRIBUTION_LOGIC_20260508.md)
2026-05-08 源码走读记录,梳理 D-InSAR 配对缓存、策略筛选、批次保存、数据分发和生产 worker 执行链路。
- [PAIRING_ENHANCEMENT_DESIGN.md](PAIRING_ENHANCEMENT_DESIGN.md) - [PAIRING_ENHANCEMENT_DESIGN.md](PAIRING_ENHANCEMENT_DESIGN.md)
- [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md) - [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md)
+7 -7
View File
@@ -55,8 +55,8 @@
**参数** **参数**
- `time_baseline_min/max`:时间基线范围(天) - `time_baseline_min/max`:时间基线范围(天)
- `spatial_baseline_max_meters`空间基线上限(米 - `spatial_baseline_max_meters`footprint 中心距上限(米,兼容字段名保留
- `overlap_threshold`重叠率阈值 - `overlap_threshold`两景 footprint 最小重叠率(兼容字段名保留)
- `coverage_diversity_penalty`:覆盖多样性惩罚因子 - `coverage_diversity_penalty`:覆盖多样性惩罚因子
**配对逻辑** **配对逻辑**
@@ -214,7 +214,7 @@ if allowed_satellites:
不同卫星需要不同的配对参数: 不同卫星需要不同的配对参数:
| 卫星 | 典型时间基线 | 典型空间基线 | 波长 | 备注 | | 卫星 | 典型时间基线 | 典型 footprint 中心距上限 | 波长 | 备注 |
|---|---|---|---|---| |---|---|---|---|---|
| LT-1 | 30~90 天 | < 3000 m | L 波段 | 当前系统 | | LT-1 | 30~90 天 | < 3000 m | L 波段 | 当前系统 |
| Sentinel-1 | 6~12 天 | < 150 m | C 波段 | 高重访频率 | | Sentinel-1 | 6~12 天 | < 150 m | C 波段 | 高重访频率 |
@@ -415,8 +415,8 @@ async def find_dinsar_pairs(
│ ☑ 使用双池模式(不勾选则主辅池合并) │ │ ☑ 使用双池模式(不勾选则主辅池合并) │
├─────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────┤
│ 时间基线: [1] ~ [90] 天 │ │ 时间基线: [1] ~ [90] 天 │
空间基线上限: [3000] 米 footprint 中心距上限: [3000] 米 │
│ 重叠率阈值: [0.5] 两景 footprint 最小重叠率: [0.5] │
│ 覆盖多样性惩罚: [0.3] │ │ 覆盖多样性惩罚: [0.3] │
│ │ │ │
│ ☑ 成像模式一致 ☑ 极化一致 ☑ 仅精轨影像 │ │ ☑ 成像模式一致 ☑ 极化一致 ☑ 仅精轨影像 │
@@ -513,7 +513,7 @@ async def find_dinsar_pairs(
## 十、未来扩展 ## 十、未来扩展
1. **基线网络可视化**:时间-空间基线散点图(D3.js / ECharts 1. **基线网络可视化**:时间-中心距散点图(D3.js / ECharts
2. **配对质量评分**:根据相干性、大气条件预估配对质量 2. **配对质量评分**:根据相干性、大气条件预估配对质量
3. **自动参数推荐**:基于历史配对结果的机器学习推荐 3. **自动参数推荐**:基于历史配对结果的机器学习推荐
4. **批量配对模板**:保存常用配对参数为模板 4. **批量配对模板**:保存常用配对参数为模板
@@ -528,7 +528,7 @@ async def find_dinsar_pairs(
| 主影像 | Master / Reference | 配对中的参考影像 | | 主影像 | Master / Reference | 配对中的参考影像 |
| 辅影像 | Slave / Secondary | 配对中的从属影像 | | 辅影像 | Slave / Secondary | 配对中的从属影像 |
| 时间基线 | Temporal Baseline | 两景影像的时间间隔 | | 时间基线 | Temporal Baseline | 两景影像的时间间隔 |
| 空间基线 | Spatial Baseline | 两景影像的空间距离 | | footprint 中心距 | Footprint Center Distance | 两景影像 footprint 的中心距离 |
| 短基线子集 | SBAS (Small Baseline Subset) | 配对策略之一 | | 短基线子集 | SBAS (Small Baseline Subset) | 配对策略之一 |
| 星型配对 | Star Graph | 单主影像配对策略 | | 星型配对 | Star Graph | 单主影像配对策略 |
| 顺序配对 | Sequential Pairing | 时间顺序配对策略 | | 顺序配对 | Sequential Pairing | 时间顺序配对策略 |
+46 -3
View File
@@ -13,9 +13,11 @@ const BATCH_API_MAX_PAGES = 200;
const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => { const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
const { t } = useI18n(); const { t } = useI18n();
const [activeTab, setActiveTab] = useState('ps'); const [activeTab, setActiveTab] = useState('dinsar');
const [destDir, setDestDir] = useState(''); const [destDir, setDestDir] = useState('');
const [copyStatuses, setCopyStatuses] = useState(['COMPLETED']); const [copyStatuses, setCopyStatuses] = useState(['COMPLETED']);
const [includeDinsarOrbitFiles, setIncludeDinsarOrbitFiles] = useState(false);
const [dinsarExportZip, setDinsarExportZip] = useState(false);
const [batches, setBatches] = useState([]); const [batches, setBatches] = useState([]);
const [selectedBatchId, setSelectedBatchId] = useState(''); const [selectedBatchId, setSelectedBatchId] = useState('');
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@@ -117,11 +119,16 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
: `${apiEndpoint}/tools/copy-dinsar-pairs`; : `${apiEndpoint}/tools/copy-dinsar-pairs`;
try { try {
const response = await axios.post(endpoint, { const payload = {
batch_id: selectedBatchId, batch_id: selectedBatchId,
dest_dir: destDir, dest_dir: destDir,
copy_statuses: copyStatuses, copy_statuses: copyStatuses,
}, { withCredentials: true }); };
if (activeTab === 'dinsar') {
payload.include_orbit_files = includeDinsarOrbitFiles;
payload.export_zip = dinsarExportZip;
}
const response = await axios.post(endpoint, payload, { withCredentials: true });
const taskId = response.data.task_id; const taskId = response.data.task_id;
setTaskId(taskId); setTaskId(taskId);
@@ -178,6 +185,42 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
当前账号为只读模式无法发起复制任务 当前账号为只读模式无法发起复制任务
</div> </div>
)} )}
{activeTab === 'dinsar' && (
<div
className="input-group"
style={{
border: '1px solid #c7d2fe',
background: '#eef2ff',
borderRadius: '8px',
padding: '10px 12px',
}}
>
<label>D-InSAR 分发设置</label>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={includeDinsarOrbitFiles}
onChange={(event) => setIncludeDinsarOrbitFiles(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>复制精密轨道到 Task/orbit</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={dinsarExportZip}
onChange={(event) => setDinsarExportZip(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>导出为 ZIP 压缩包</span>
</label>
</div>
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
未勾选 ZIP 时直接导出 Task 文件夹勾选后每个 Task 输出一个 .zip
</div>
</div>
)}
<div className="input-group"> <div className="input-group">
<label>1. 选择批次</label> <label>1. 选择批次</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
@@ -476,7 +476,7 @@ export default function DinsarCatalogPanel({
<MetaField label="主影像日期" value={selectedProduct.profile?.master_imaging_date} /> <MetaField label="主影像日期" value={selectedProduct.profile?.master_imaging_date} />
<MetaField label="从影像日期" value={selectedProduct.profile?.slave_imaging_date} /> <MetaField label="从影像日期" value={selectedProduct.profile?.slave_imaging_date} />
<MetaField label="时间基线" value={selectedProduct.profile?.time_baseline_days} /> <MetaField label="时间基线" value={selectedProduct.profile?.time_baseline_days} />
<MetaField label="空间基线" value={selectedProduct.profile?.spatial_baseline_meters} /> <MetaField label="footprint 中心距" value={selectedProduct.profile?.scene_center_distance_meters ?? selectedProduct.profile?.spatial_baseline_meters} />
</div> </div>
<div className="dinsar-catalog-section-card"> <div className="dinsar-catalog-section-card">
<div className="dinsar-catalog-section-title">空间范围</div> <div className="dinsar-catalog-section-title">空间范围</div>
@@ -521,7 +521,7 @@ export default function DinsarCatalogPanel({
<MetaField label="主从模式" value={`${selectedPairingMetric?.master_imaging_mode || '-'} / ${selectedPairingMetric?.slave_imaging_mode || '-'}`} /> <MetaField label="主从模式" value={`${selectedPairingMetric?.master_imaging_mode || '-'} / ${selectedPairingMetric?.slave_imaging_mode || '-'}`} />
<MetaField label="主从极化" value={`${selectedPairingMetric?.master_polarization || '-'} / ${selectedPairingMetric?.slave_polarization || '-'}`} /> <MetaField label="主从极化" value={`${selectedPairingMetric?.master_polarization || '-'} / ${selectedPairingMetric?.slave_polarization || '-'}`} />
<MetaField label="时间基线" value={selectedPairingMetric?.time_baseline_days} /> <MetaField label="时间基线" value={selectedPairingMetric?.time_baseline_days} />
<MetaField label="空间基线" value={selectedPairingMetric?.spatial_baseline_meters} /> <MetaField label="footprint 中心距" value={selectedPairingMetric?.scene_center_distance_meters ?? selectedPairingMetric?.spatial_baseline_meters} />
</div> </div>
</div> </div>
)} )}
+8 -8
View File
@@ -11,23 +11,23 @@ const STRATEGY_DESCRIPTIONS = {
title: '全部配对(默认)', title: '全部配对(默认)',
description: '列出所有满足约束条件的候选干涉对,由用户自行筛选。', description: '列出所有满足约束条件的候选干涉对,由用户自行筛选。',
details: [ details: [
'• 系统遍历所有影像组合,保留满足时间基线、空间基线和重叠率阈值的配对', '• 系统遍历所有影像组合,保留满足时间基线、footprint 中心距和两景 footprint 最小重叠率的配对',
'• 结果按时间排序,用户可在配对列表中逐一勾选或取消', '• 结果按时间排序,用户可在配对列表中逐一勾选或取消',
'• 适用于研究型场景,需要精确控制每一对干涉组合', '• 适用于研究型场景,需要精确控制每一对干涉组合',
'• 配对数量可能较多,建议配合 AOI 和日期范围缩小结果' '• 配对数量可能较多,建议配合 AOI 和日期范围缩小结果'
], ],
params: '参数:时间基线范围、空间基线上限、最小重叠率' params: '参数:时间基线范围、中心距上限、两景 footprint 最小重叠率'
}, },
sbas: { sbas: {
title: 'SBAS (短基线子集)', title: 'SBAS (短基线子集)',
description: '基于短基线原则的配对策略,通过覆盖优化算法自动筛选配对。', description: '基于短基线原则的配对策略,通过覆盖优化算法自动筛选配对。',
details: [ details: [
'• 优先选择时间和空间基线都较短的配对', '• 优先选择时间间隔和 footprint 中心距都较短的配对',
'• 通过覆盖优化算法,去除冗余配对,确保时间序列连续性', '• 通过覆盖优化算法,去除冗余配对,确保时间序列连续性',
'• 适用于大范围、长时间序列的形变监测', '• 适用于大范围、长时间序列的形变监测',
'• 配对数量会比"全部配对"少,但覆盖更均匀' '• 配对数量会比"全部配对"少,但覆盖更均匀'
], ],
params: '参数:时间基线、空间基线、重叠率阈值、覆盖多样性惩罚' params: '参数:时间基线、中心距、两景 footprint 最小重叠率、覆盖多样性惩罚'
}, },
sequential: { sequential: {
title: 'Sequential (顺序配对)', title: 'Sequential (顺序配对)',
@@ -321,24 +321,24 @@ function PairingModal({
</div> </div>
)} )}
{/* 基线和重叠率约束 */} {/* 时间、中心距和重叠率约束 */}
<div className="form-group"> <div className="form-group">
<label>时间基线最小值 ():</label> <label>时间基线最小值 ():</label>
<input type="number" min="0" value={pairingParams.time_baseline_min} <input type="number" min="0" value={pairingParams.time_baseline_min}
onChange={e => setPairingParams({...pairingParams, time_baseline_min: parseInt(e.target.value) || 0})} /> onChange={e => setPairingParams({...pairingParams, time_baseline_min: parseInt(e.target.value) || 0})} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>时间基线最大值 ():</label> <label>最大时间间隔 ():</label>
<input type="number" min="1" value={pairingParams.time_baseline_max} <input type="number" min="1" value={pairingParams.time_baseline_max}
onChange={e => setPairingParams({...pairingParams, time_baseline_max: parseInt(e.target.value) || 90})} /> onChange={e => setPairingParams({...pairingParams, time_baseline_max: parseInt(e.target.value) || 90})} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>最小重叠率 (0-1):</label> <label>两景 footprint 最小重叠率 (0-1):</label>
<input type="number" step="0.1" min="0" max="1" value={pairingParams.overlap_threshold} <input type="number" step="0.1" min="0" max="1" value={pairingParams.overlap_threshold}
onChange={e => setPairingParams({...pairingParams, overlap_threshold: parseFloat(e.target.value) || 0})} /> onChange={e => setPairingParams({...pairingParams, overlap_threshold: parseFloat(e.target.value) || 0})} />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>空间基线上限 ():</label> <label>footprint 中心距上限 ():</label>
<input type="number" min="0" value={pairingParams.spatial_baseline_max_meters} <input type="number" min="0" value={pairingParams.spatial_baseline_max_meters}
onChange={e => setPairingParams({...pairingParams, spatial_baseline_max_meters: parseInt(e.target.value) || 3000})} /> onChange={e => setPairingParams({...pairingParams, spatial_baseline_max_meters: parseInt(e.target.value) || 3000})} />
</div> </div>
@@ -7,6 +7,7 @@ function PairListRow({
onVisualizePair, onVisualizePair,
onTogglePairVisibility, onTogglePairVisibility,
}) { }) {
const centerDistance = pair.scene_center_distance_meters ?? pair.spatial_baseline_meters;
return ( return (
<li className="pair-item"> <li className="pair-item">
<input <input
@@ -20,7 +21,7 @@ function PairListRow({
<strong>{pair.task_name}</strong> <strong>{pair.task_name}</strong>
<div className="pair-details"> <div className="pair-details">
<span>时基: {pair.time_baseline_days}d</span> <span>时基: {pair.time_baseline_days}d</span>
<span>空基: {pair.spatial_baseline_meters.toFixed(2)}m</span> <span>中心距: {Number(centerDistance || 0).toFixed(2)}m</span>
</div> </div>
</div> </div>
<button <button
+1 -1
View File
@@ -138,7 +138,7 @@
{ zh: '请先训练模型。', en: 'Please train the model first.' }, { zh: '请先训练模型。', en: 'Please train the model first.' },
// PairingPanel // PairingPanel
{ zh: '基于时间基线、空间基线与重叠率筛选干涉对,可选 AOI 限定范围。', en: 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.' }, { zh: '基于时间基线、footprint 中心距与两景 footprint 最小重叠率筛选干涉对,可选 AOI 限定范围。', en: 'Filter interferometric pairs by temporal baseline, footprint center distance, and pair footprint overlap ratio. Optional AOI constraint.' },
{ zh: '配对', en: 'Pair' }, { zh: '配对', en: 'Pair' },
{ zh: '时序准备', en: 'Timeseries Prep' }, { zh: '时序准备', en: 'Timeseries Prep' },
{ zh: '结果与刷新', en: 'Results & Refresh' }, { zh: '结果与刷新', en: 'Results & Refresh' },
+2 -2
View File
@@ -107,8 +107,8 @@ export default function PairPlanningPanel({
<div className="panel-card-title">{en ? 'Pair Planning' : '配对规划'}</div> <div className="panel-card-title">{en ? 'Pair Planning' : '配对规划'}</div>
<p className="panel-card-desc"> <p className="panel-card-desc">
{en {en
? 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.' ? 'Filter interferometric pairs by temporal baseline, footprint center distance, and pair footprint overlap ratio. Optional AOI constraint.'
: '基于时间基线、空间基线和重叠率筛选干涉对,可选 AOI 约束范围。'} : '基于时间基线、footprint 中心距和两景 footprint 最小重叠率筛选干涉对,可选 AOI 约束范围。'}
</p> </p>
<div className="header-buttons" style={{ marginTop: '10px' }}> <div className="header-buttons" style={{ marginTop: '10px' }}>
<button onClick={onOpenPairingModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}> <button onClick={onOpenPairingModal} disabled={isLoading || !hasEnoughRadarScenesForPlanning || isReadOnlyUser} style={{ flex: 1 }}>
+2 -2
View File
@@ -19,8 +19,8 @@ export default function PairingPanel({
<div className="panel-card-title">{en ? 'Pair Planning' : '配对规划'}</div> <div className="panel-card-title">{en ? 'Pair Planning' : '配对规划'}</div>
<p className="panel-card-desc"> <p className="panel-card-desc">
{en {en
? 'Filter interferometric pairs by temporal baseline, spatial baseline, and overlap ratio. Optional AOI constraint.' ? 'Filter interferometric pairs by temporal baseline, footprint center distance, and pair footprint overlap ratio. Optional AOI constraint.'
: '基于时间基线、空间基线与重叠率筛选干涉对,可选 AOI 限定范围。' : '基于时间基线、footprint 中心距与两景 footprint 最小重叠率筛选干涉对,可选 AOI 限定范围。'
} }
</p> </p>
<div className="header-buttons" style={{ marginTop: '10px' }}> <div className="header-buttons" style={{ marginTop: '10px' }}>