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
+113 -14
View File
@@ -33,7 +33,13 @@ from ..config import settings
from ..models import (
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 .image_service import image_service
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}$")
def _valid_imaging_date(value: str) -> bool:
def _valid_imaging_date(value: Optional[str]) -> bool:
return bool(value and _DATE_RE.match(value))
@@ -63,6 +69,62 @@ def _extract_date_from_text(value: Optional[str]) -> Optional[str]:
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):
stack = [root]
while stack:
@@ -586,20 +648,31 @@ class DataService:
continue
merged_meta[key] = value
satellite = merged_meta.get("satellite")
imaging_date = merged_meta.get("imaging_date")
imaging_mode = merged_meta.get("imaging_mode")
polarization = merged_meta.get("polarization")
orbit_direction = merged_meta.get("orbit_direction")
satellite_mode = merged_meta.get("satellite_mode")
receiving_station = merged_meta.get("receiving_station")
orbit_circle = merged_meta.get("orbit_circle")
satellite = _text_or_none(merged_meta.get("satellite"))
imaging_date = _text_or_none(merged_meta.get("imaging_date"))
imaging_mode = _text_or_none(merged_meta.get("imaging_mode"))
polarization = _text_or_none(merged_meta.get("polarization"))
orbit_direction = _text_or_none(merged_meta.get("orbit_direction"))
satellite_mode = _text_or_none(merged_meta.get("satellite_mode"))
receiving_station = _text_or_none(merged_meta.get("receiving_station"))
orbit_circle = _text_or_none(merged_meta.get("orbit_circle"))
scene_center_lon = merged_meta.get("scene_center_lon")
scene_center_lat = merged_meta.get("scene_center_lat")
acquisition_time_utc = merged_meta.get("acquisition_time_utc")
product_type = merged_meta.get("product_type")
product_level = merged_meta.get("product_level")
product_unique_id = merged_meta.get("product_unique_id")
acquisition_time_utc = _text_or_none(merged_meta.get("acquisition_time_utc"))
product_type = _text_or_none(merged_meta.get("product_type"))
source_product_token = _text_or_none(merged_meta.get("source_product_token"))
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:
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_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 = {
"unique_id": unique_id,
"satellite": satellite,
"satellite_family": satellite_family,
"imaging_date": imaging_date,
"imaging_mode": imaging_mode,
"orbit_direction": orbit_direction,
@@ -649,8 +740,16 @@ class DataService:
"scene_center_lat": scene_center_lat,
"acquisition_time_utc": acquisition_time_utc,
"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_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,
"has_orbit_data": has_orbit_data,
"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"),
"time_baseline_days": pair_meta.get("time_baseline_days"),
"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"),
"pair_uid": pair_meta.get("pair_uid") or pair_meta.get("scene_pair_uid"),
"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 ..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 .data_service import data_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")
batch_id = payload.get("batch_id")
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:
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))
.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 = [
{
"task_name": item.task_name,
@@ -335,6 +357,13 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
"slave_polarization": item.slave_polarization,
"time_baseline_days": item.time_baseline_days,
"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,
"pair_uid": item.scene_pair_uid,
"network_run_id": item.network_run_id,
@@ -342,13 +371,19 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
"policy_version": item.policy_version,
"selection_strategy": item.selection_strategy,
}
for item in result.scalars().all()
for item in task_items
]
if not items:
raise ValueError(
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
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:
left_uid = _scene_uid_expr(left_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 {right_alias}.orbit_direction IS NOT NULL "
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)"
)
@@ -55,6 +90,9 @@ def _hard_constraints_expr(left_alias: str, right_alias: str) -> str:
def _full_rebuild_insert_sql() -> str:
master_uid = _scene_uid_expr("m")
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"""
INSERT INTO pairing_metric_cache (
master_scene_ref_id,
@@ -66,19 +104,26 @@ def _full_rebuild_insert_sql() -> str:
orientation_rule_version,
time_baseline_days,
spatial_baseline_meters,
scene_center_distance_meters,
scene_overlap_ratio,
orbit_direction,
same_satellite,
same_satellite_family,
same_look_direction,
same_imaging_mode,
same_polarization,
master_imaging_date,
slave_imaging_date,
master_satellite,
slave_satellite,
master_satellite_family,
slave_satellite_family,
master_imaging_mode,
slave_imaging_mode,
master_polarization,
slave_polarization,
master_look_direction,
slave_look_direction,
master_file_path,
slave_file_path,
status,
@@ -93,13 +138,16 @@ def _full_rebuild_insert_sql() -> str:
:metric_version AS metric_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,
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) /
NULLIF(GREATEST(ST_Area(m.geom::geography), ST_Area(s.geom::geography)), 0)
)::double precision AS scene_overlap_ratio,
m.orbit_direction,
(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
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,
m.satellite AS master_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,
s.imaging_mode AS slave_imaging_mode,
m.polarization AS master_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,
s.file_path AS slave_file_path,
'READY' AS status,
@@ -133,6 +185,9 @@ def _incremental_insert_sql() -> str:
dirty_uid = _scene_uid_expr("d")
other_uid = _scene_uid_expr("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"""
INSERT INTO pairing_metric_cache (
master_scene_ref_id,
@@ -144,19 +199,26 @@ def _incremental_insert_sql() -> str:
orientation_rule_version,
time_baseline_days,
spatial_baseline_meters,
scene_center_distance_meters,
scene_overlap_ratio,
orbit_direction,
same_satellite,
same_satellite_family,
same_look_direction,
same_imaging_mode,
same_polarization,
master_imaging_date,
slave_imaging_date,
master_satellite,
slave_satellite,
master_satellite_family,
slave_satellite_family,
master_imaging_mode,
slave_imaging_mode,
master_polarization,
slave_polarization,
master_look_direction,
slave_look_direction,
master_file_path,
slave_file_path,
status,
@@ -176,13 +238,16 @@ def _incremental_insert_sql() -> str:
:metric_version AS metric_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,
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) /
NULLIF(GREATEST(ST_Area(d.geom::geography), ST_Area(o.geom::geography)), 0)
)::double precision AS scene_overlap_ratio,
d.orbit_direction,
(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
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 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 {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 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 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 o.file_path ELSE d.file_path END AS slave_file_path,
'READY' AS status,
@@ -228,7 +297,11 @@ class PairingCacheService:
return state
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)
async def _count_scene_rows(self, db: AsyncSession) -> int:
@@ -382,6 +455,17 @@ class PairingCacheService:
pair_count = await self._count_pair_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:
summary = await self._finalize_state_success(db, full_rebuild=False)
if commit:
+12 -3
View File
@@ -18,7 +18,7 @@ from ..models import (
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"
@@ -66,7 +66,11 @@ class PairingStateService:
return int(result.scalar_one() or 0)
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)
async def _get_global_state(self, db: AsyncSession) -> Optional[PairingCacheStateORM]:
@@ -135,7 +139,12 @@ class PairingStateService:
await db.flush()
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.pair_count = metric_cache_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_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_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"))
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"),
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 {
key: value
@@ -240,6 +250,7 @@ def _resolve_candidate_identity(candidate: Dict[str, Any]) -> Dict[str, Any]:
"slave_polarization",
"time_baseline_days",
"spatial_baseline_meters",
"scene_center_distance_meters",
):
resolved[field] = run_meta.get(field)
if resolved[field] in (None, ""):
@@ -608,8 +619,11 @@ class ResultCatalogService:
"slave_polarization": metric.slave_polarization,
"time_baseline_days": metric.time_baseline_days,
"spatial_baseline_meters": metric.spatial_baseline_meters,
"scene_center_distance_meters": metric.scene_center_distance_meters,
"scene_overlap_ratio": metric.scene_overlap_ratio,
"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_polarization": metric.same_polarization,
"status": metric.status,
@@ -721,6 +735,12 @@ class ResultCatalogService:
"orbit_direction": None,
"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"),
"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"),
"radar_wavelength": profile_params.get("wavelength"),
"orbit_clip_margin": profile_params.get("orbit_margin_sec"),
@@ -1138,6 +1158,7 @@ class ResultCatalogService:
orbit_direction=profile_payload.get("orbit_direction"),
time_baseline_days=profile_payload.get("time_baseline_days"),
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"),
radar_wavelength=profile_payload.get("radar_wavelength"),
orbit_clip_margin=profile_payload.get("orbit_clip_margin"),
@@ -1532,6 +1553,7 @@ class ResultCatalogService:
"orbit_direction": profile.orbit_direction,
"time_baseline_days": profile.time_baseline_days,
"spatial_baseline_meters": profile.spatial_baseline_meters,
"scene_center_distance_meters": profile.scene_center_distance_meters,
"grid_size_m": profile.grid_size_m,
"radar_wavelength": profile.radar_wavelength,
"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.future import select
from sqlalchemy import and_, cast, func
from sqlalchemy import and_, cast, func, or_
from sqlalchemy.orm import aliased
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
PAIRING_POLICY_VERSION = "2026.04.phase3.v1"
PAIRING_POLICY_VERSION = "2026.05.raw-source.v1"
PAIRING_WARNING_CANDIDATE_THRESHOLD = 3000
logger = logging.getLogger(__name__)
@@ -155,6 +155,10 @@ class SpatialService:
) -> List[dict]:
master_alias = aliased(RadarDataORM)
slave_alias = aliased(RadarDataORM)
center_distance_expr = func.coalesce(
PairingMetricCacheORM.scene_center_distance_meters,
PairingMetricCacheORM.spatial_baseline_meters,
)
stmt = (
select(PairingMetricCacheORM, master_alias, slave_alias)
@@ -165,8 +169,9 @@ class SpatialService:
PairingMetricCacheORM.status == "READY",
PairingMetricCacheORM.time_baseline_days >= params.time_baseline_min,
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.same_look_direction.is_(True),
)
)
@@ -177,7 +182,7 @@ class SpatialService:
)
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:
stmt = stmt.where(PairingMetricCacheORM.same_imaging_mode.is_(True))
@@ -186,9 +191,20 @@ class SpatialService:
stmt = stmt.where(PairingMetricCacheORM.same_polarization.is_(True))
if params.allowed_satellites:
allowed_satellites = [
str(item).strip().upper()
for item in params.allowed_satellites
if str(item).strip()
]
stmt = stmt.where(
master_alias.satellite.in_(params.allowed_satellites),
slave_alias.satellite.in_(params.allowed_satellites),
or_(
func.upper(master_alias.satellite).in_(allowed_satellites),
func.upper(master_alias.satellite_family).in_(allowed_satellites),
),
or_(
func.upper(slave_alias.satellite).in_(allowed_satellites),
func.upper(slave_alias.satellite_family).in_(allowed_satellites),
),
)
if params.master_date_from:
@@ -220,12 +236,18 @@ class SpatialService:
PairingMetricCacheORM.master_imaging_date.asc(),
PairingMetricCacheORM.slave_imaging_date.asc(),
func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(),
center_distance_expr.asc(),
PairingMetricCacheORM.pair_uid.asc(),
)
result = await db.execute(stmt)
candidate_pool: List[dict] = []
for 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(
{
"metric_cache_ref_id": int(metric_row.id),
@@ -235,7 +257,8 @@ class SpatialService:
"master": RadarData.model_validate(master_row),
"slave": RadarData.model_validate(slave_row),
"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),
}
)
@@ -270,6 +293,11 @@ class SpatialService:
selection_reason=candidate.get("selection_reason"),
time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]),
scene_center_distance_meters=float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0
),
)
)
return result_pairs
@@ -356,6 +384,12 @@ class SpatialService:
"pair_uid": candidate.get("pair_uid"),
"time_baseline_days": int(candidate.get("days") or 0),
"spatial_baseline_meters": float(candidate.get("dist") or 0.0),
"scene_center_distance_meters": float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0.0
),
"legacy_spatial_baseline_field": "scene_center_distance_meters",
"scene_overlap_ratio": float(candidate.get("overlap_ratio") or 0.0),
}
@@ -420,6 +454,10 @@ class SpatialService:
master_alias = aliased(RadarDataORM)
slave_alias = aliased(RadarDataORM)
center_distance_expr = func.coalesce(
PairingMetricCacheORM.scene_center_distance_meters,
PairingMetricCacheORM.spatial_baseline_meters,
)
stmt = (
select(PairingMetricCacheORM, master_alias, slave_alias)
.join(master_alias, master_alias.id == PairingMetricCacheORM.master_scene_ref_id)
@@ -431,14 +469,15 @@ class SpatialService:
PairingMetricCacheORM.slave_scene_ref_id.in_(scene_ids),
PairingMetricCacheORM.time_baseline_days >= params.time_baseline_min,
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.same_look_direction.is_(True),
)
.order_by(
PairingMetricCacheORM.master_imaging_date.asc(),
PairingMetricCacheORM.slave_imaging_date.asc(),
PairingMetricCacheORM.time_baseline_days.asc(),
PairingMetricCacheORM.spatial_baseline_meters.asc(),
center_distance_expr.asc(),
func.coalesce(PairingMetricCacheORM.scene_overlap_ratio, 0).desc(),
PairingMetricCacheORM.id.asc(),
)
@@ -447,6 +486,11 @@ class SpatialService:
candidate_pool: List[dict] = []
for metric_row, master_row, slave_row in result.all():
center_distance = float(
metric_row.scene_center_distance_meters
if metric_row.scene_center_distance_meters is not None
else (metric_row.spatial_baseline_meters or 0.0)
)
candidate_pool.append(
{
"metric_cache_ref_id": int(metric_row.id),
@@ -456,7 +500,8 @@ class SpatialService:
"master": RadarData.model_validate(master_row),
"slave": RadarData.model_validate(slave_row),
"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),
}
)
@@ -511,6 +556,11 @@ class SpatialService:
"slave_imaging_date": slave.imaging_date,
"temporal_baseline_days": int(candidate.get("days") or 0),
"spatial_baseline_meters": float(candidate.get("dist") or 0.0),
"scene_center_distance_meters": float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0.0
),
"scene_overlap_ratio": float(candidate.get("overlap_ratio") or 0.0),
"selection_reason": candidate.get("selection_reason"),
"selection_score": (
@@ -523,6 +573,11 @@ class SpatialService:
"selection_mode": selection_mode,
"pair_uid": candidate.get("pair_uid"),
"metric_version": pairing_state_service.metric_version,
"scene_center_distance_meters": float(
candidate.get("scene_center_distance_meters")
if candidate.get("scene_center_distance_meters") is not None
else candidate.get("dist") or 0.0
),
"time_baseline_min": params.time_baseline_min,
"time_baseline_max": params.time_baseline_max,
"spatial_baseline_max_meters": params.spatial_baseline_max_meters,
@@ -703,18 +758,44 @@ class SpatialService:
if params.strategy == "sbas":
return self._apply_sbas_strategy(candidate_pool, params, aoi_wkt=aoi_wkt)
if params.strategy == "sequential":
return self._apply_sequential_strategy(candidate_pool, params.num_connections)
return self._apply_sequential_strategy(candidate_pool, params.num_connections, params)
if params.strategy == "star":
return self._apply_star_strategy(candidate_pool, params.reference_image_id)
return self._apply_all_strategy(candidate_pool)
return self._apply_star_strategy(candidate_pool, params.reference_image_id, params)
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 (
[
{
**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)
],
@@ -725,6 +806,7 @@ class SpatialService:
self,
candidate_pool: List[dict],
num_connections: int,
params: PairingRequest,
) -> Tuple[List[dict], List[str]]:
"""
Sequential: 按稳定时间序列排序,每景连接后续 N 景。
@@ -759,7 +841,7 @@ class SpatialService:
{
**candidate,
"selection_reason": "sequential_neighbor",
"selection_score": float(candidate.get("overlap_ratio") or 0),
"selection_score": self._score_pair_candidate(candidate, params),
}
)
picked_count += 1
@@ -770,6 +852,7 @@ class SpatialService:
self,
candidate_pool: List[dict],
reference_image_id: Optional[int],
params: PairingRequest,
) -> Tuple[List[dict], List[str]]:
"""
Star: 参考像固定为 master。
@@ -820,7 +903,7 @@ class SpatialService:
{
**candidate,
"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,
"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)
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)
source_score = 1.0 if (
bool(getattr(candidate.get("master"), "insar_source_ready", False))
and bool(getattr(candidate.get("slave"), "insar_source_ready", False))
) else 0.0
orbit_score = 1.0 if (
bool(getattr(candidate.get("master"), "has_orbit_data", False))
and bool(getattr(candidate.get("slave"), "has_orbit_data", False))
) else 0.0
aoi_gain = 0.0
redundancy_penalty = 0.0
@@ -1153,10 +1244,12 @@ class SpatialService:
redundancy_penalty = max(0.0, min(overlap_area / candidate_area, 1.0))
return (
0.35 * time_score
+ 0.20 * spatial_score
0.30 * time_score
+ 0.15 * spatial_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
)
@@ -1588,7 +1681,7 @@ class SpatialService:
slave: RadarDataORM
) -> 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")
slave_alias = RadarDataORM.__table__.alias("slave")