Improve pairing planning and statistics visibility

This commit is contained in:
2026-07-07 01:04:47 +08:00
parent 2da2121829
commit 36a406ae49
22 changed files with 2085 additions and 459 deletions
+8 -1
View File
@@ -284,7 +284,7 @@ class PairingRequest(BaseModel):
slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$') slave_date_to: Optional[str] = Field(default=None, pattern=r'^\d{8}$|^$')
# === 配对策略(新增) === # === 配对策略(新增) ===
strategy: str = Field(default="dinsar_production", pattern=r'^dinsar_production$') strategy: str = Field(default="dinsar_production", pattern=r'^(dinsar_production|dinsar_province_coverage)$')
num_connections: int = Field(default=1, ge=1, le=10) num_connections: int = Field(default=1, ge=1, le=10)
reference_image_id: Optional[int] = None reference_image_id: Optional[int] = None
@@ -307,6 +307,7 @@ class PairingRequest(BaseModel):
normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio'] normalized['overlap_threshold'] = normalized['pair_footprint_overlap_min_ratio']
if normalized.get('footprint_center_distance_max_meters') not in (None, ''): if normalized.get('footprint_center_distance_max_meters') not in (None, ''):
normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters'] normalized['spatial_baseline_max_meters'] = normalized['footprint_center_distance_max_meters']
if normalized.get('strategy') not in {'dinsar_production', 'dinsar_province_coverage'}:
normalized['strategy'] = 'dinsar_production' normalized['strategy'] = 'dinsar_production'
return normalized return normalized
@@ -390,6 +391,11 @@ class RadarPair(BaseModel):
selection_strategy: Optional[str] = None selection_strategy: Optional[str] = None
selection_score: Optional[float] = None selection_score: Optional[float] = None
selection_reason: Optional[str] = None selection_reason: Optional[str] = None
coverage_rank: Optional[int] = None
aoi_new_area_ratio: Optional[float] = None
aoi_pair_area_ratio: Optional[float] = None
aoi_coverage_ratio_after: Optional[float] = None
effective_coverage_geojson: Optional[Dict[str, Any]] = None
time_baseline_days: int time_baseline_days: int
spatial_baseline_meters: float spatial_baseline_meters: float
scene_center_distance_meters: Optional[float] = None scene_center_distance_meters: Optional[float] = None
@@ -416,6 +422,7 @@ class PairingResponse(BaseModel):
network_run_id: Optional[str] = None network_run_id: Optional[str] = None
candidate_count: int = 0 candidate_count: int = 0
selected_edge_count: int = 0 selected_edge_count: int = 0
coverage: Optional[Dict[str, Any]] = None
class PsRequest(BaseModel): class PsRequest(BaseModel):
+1 -6
View File
@@ -17,12 +17,7 @@ router = APIRouter(prefix="/ops-maintenance", tags=["ops-maintenance"])
class CleanupRequest(BaseModel): class CleanupRequest(BaseModel):
confirm: bool = False confirm: bool = False
delete_task_records: bool = True delete_landsar_work_dir: bool = True
delete_logs: bool = True
delete_production_records: bool = True
delete_result_products: bool = True
delete_production_dirs: bool = True
delete_task_pool_dir: bool = True
@router.get("/tasks") @router.get("/tasks")
+61
View File
@@ -329,6 +329,7 @@ async def find_pairs_endpoint(
network_run_id=pairing_metadata.get("network_run_id"), network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0), candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0), selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=None,
) )
except Exception as e: except Exception as e:
if isinstance(e, HTTPException): if isinstance(e, HTTPException):
@@ -339,6 +340,66 @@ async def find_pairs_endpoint(
raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志") raise HTTPException(status_code=500, detail="处理 AOI 或查找干涉对时发生错误,请查看后端日志")
@router.post("/pairing/coverage-plan", response_model=PairingResponse)
async def build_coverage_pairing_plan_endpoint(
params: PairingRequest = Depends(get_pairing_request_from_form),
target_date_from: str = Form(...),
target_date_to: str = Form(...),
extension_days: int = Form(15),
max_pairs: int = Form(200),
target_coverage_ratio: float = Form(0.98),
min_new_coverage_ratio: float = Form(0.0005),
files: Optional[List[UploadFile]] = File(None),
aoi_geojson: Optional[str] = Form(None),
require_orbit_data: bool = Form(True),
db: AsyncSession = Depends(get_db),
):
try:
resolved_aoi = await _parse_aoi_from_files(files)
if resolved_aoi is None:
resolved_aoi = _parse_aoi_geojson_form_value(aoi_geojson)
if files and resolved_aoi is None:
raise HTTPException(status_code=400, detail="Uploaded files must include a valid SHP or GeoJSON AOI.")
aoi_wkt = resolved_aoi[0] if resolved_aoi else None
response_aoi_geojson = resolved_aoi[1] if resolved_aoi else None
if not aoi_wkt:
raise HTTPException(status_code=400, detail="区域空间覆盖规划必须选择行政区或上传 AOI。")
coverage_params = params.model_copy(update={"strategy": "dinsar_province_coverage"})
pairs, runtime_warnings, pairing_metadata = await spatial_service.find_dinsar_coverage_pairs(
db,
coverage_params,
target_date_from=target_date_from,
target_date_to=target_date_to,
extension_days=extension_days,
max_pairs=max_pairs,
target_coverage_ratio=target_coverage_ratio,
min_new_coverage_ratio=min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
return PairingResponse(
pairs=pairs,
aoi_geojson=response_aoi_geojson,
warnings=runtime_warnings,
fallback_used=bool(pairing_metadata.get("fallback_used")),
degraded=bool(pairing_metadata.get("degraded")),
policy_version=pairing_metadata.get("policy_version"),
network_run_id=pairing_metadata.get("network_run_id"),
candidate_count=int(pairing_metadata.get("candidate_count") or 0),
selected_edge_count=int(pairing_metadata.get("selected_edge_count") or 0),
coverage=pairing_metadata.get("coverage"),
)
except Exception as e:
if isinstance(e, HTTPException):
raise e
if isinstance(e, (RuntimeError, ValueError)):
raise HTTPException(status_code=409, detail=str(e))
logger.exception("Failed to build D-InSAR coverage pairing plan")
raise HTTPException(status_code=500, detail="Failed to build D-InSAR coverage pairing plan.")
@router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]]) @router.post("/find-ps-timeseries", response_model=Dict[str, List[RadarData]])
async def find_ps_timeseries_endpoint( async def find_ps_timeseries_endpoint(
params: PsRequest = Depends(get_ps_request_from_form), params: PsRequest = Depends(get_ps_request_from_form),
+220 -51
View File
@@ -11,6 +11,8 @@ from typing import Any, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SOURCE_METADATA_MANAGED_FORMATS = ("LT1_ARCHIVE", "S1_ZIP")
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import distinct, func, text from sqlalchemy import distinct, func, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -51,7 +53,6 @@ from ..services.data_service import data_service
from ..services.dinsar_read_service import dinsar_read_service from ..services.dinsar_read_service import dinsar_read_service
from ..services.pairing_state_service import pairing_state_service from ..services.pairing_state_service import pairing_state_service
from ..services.admin_region_lookup_service import _build_region_path, _load_region_records from ..services.admin_region_lookup_service import _build_region_path, _load_region_records
from ..utils import find_xml_file
from . import dependencies as _deps from . import dependencies as _deps
from .dependencies import _get_current_user from .dependencies import _get_current_user
@@ -80,6 +81,10 @@ def _ratio(numerator: int, denominator: int) -> float:
return round(float(numerator) / float(denominator), 4) return round(float(numerator) / float(denominator), 4)
def _is_source_metadata_managed_format(value: Any) -> bool:
return str(value or "").strip().upper() in SOURCE_METADATA_MANAGED_FORMATS
def _family_label(value: Any) -> str: def _family_label(value: Any) -> str:
text = str(value or "").strip().upper() text = str(value or "").strip().upper()
if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}: if text in {"LT1", "LT-1", "LUTAN", "LUTAN1"}:
@@ -102,6 +107,14 @@ def _month_from_yyyymmdd(value: Any) -> Optional[str]:
return None return None
def _compact_yyyymmdd(value: Any) -> Optional[str]:
text = str(value or "").strip()
digits = "".join(ch for ch in text if ch.isdigit())
if len(digits) >= 8:
return digits[:8]
return None
def _month_from_datetime(value: Any) -> Optional[str]: def _month_from_datetime(value: Any) -> Optional[str]:
if not value: if not value:
return None return None
@@ -594,16 +607,36 @@ async def get_statistics_dashboard(
db, db,
select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True), select(func.count(SourceProductAssetORM.id)).where(SourceProductAssetORM.is_active == True),
) )
metadata_source_total = await _scalar_count(
db,
select(func.count(SourceProductAssetORM.id)).where(
SourceProductAssetORM.is_active == True,
SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id))) radar_total = await _scalar_count(db, select(func.count(RadarDataORM.id)))
metadata_asset_total = await _scalar_count( metadata_asset_total = await _scalar_count(
db, db,
select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))), select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_doc_total = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
geometry_total = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
) )
metadata_doc_total = await _scalar_count(db, select(func.count(SourceMetadataDocumentORM.id)))
geometry_total = await _scalar_count(db, select(func.count(SARSceneGeometryProfileORM.id)))
geometry_ready = await _scalar_count( geometry_ready = await _scalar_count(
db, db,
select(func.count(SARSceneGeometryProfileORM.id)).where( select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY", SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "READY", SARSceneGeometryProfileORM.production_readiness == "READY",
), ),
@@ -633,6 +666,7 @@ async def get_statistics_dashboard(
for family, source_format, parse_status, count in source_group_rows.all(): for family, source_format, parse_status, count in source_group_rows.all():
family_label = _family_label(family) family_label = _family_label(family)
status_label = _status_label(parse_status) status_label = _status_label(parse_status)
format_label = str(source_format or "UNKNOWN")
count_int = _safe_int(count) count_int = _safe_int(count)
family_bucket = source_by_family_map.setdefault( family_bucket = source_by_family_map.setdefault(
family_label, family_label,
@@ -649,7 +683,6 @@ async def get_statistics_dashboard(
family_bucket["ready_count"] += count_int family_bucket["ready_count"] += count_int
else: else:
family_bucket["issue_count"] += count_int family_bucket["issue_count"] += count_int
format_label = str(source_format or "UNKNOWN")
family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int family_bucket["formats"][format_label] = family_bucket["formats"].get(format_label, 0) + count_int
source_by_format.append( source_by_format.append(
{ {
@@ -673,19 +706,22 @@ async def get_statistics_dashboard(
geometry_rows = await db.execute( geometry_rows = await db.execute(
select( select(
SARSceneGeometryProfileORM.satellite_family, SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality, SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness, SARSceneGeometryProfileORM.production_readiness,
func.count(SARSceneGeometryProfileORM.id), func.count(SARSceneGeometryProfileORM.id),
) )
.where(SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS))
.group_by( .group_by(
SARSceneGeometryProfileORM.satellite_family, SARSceneGeometryProfileORM.satellite_family,
SARSceneGeometryProfileORM.source_format,
SARSceneGeometryProfileORM.metadata_quality, SARSceneGeometryProfileORM.metadata_quality,
SARSceneGeometryProfileORM.production_readiness, SARSceneGeometryProfileORM.production_readiness,
) )
.order_by(SARSceneGeometryProfileORM.satellite_family) .order_by(SARSceneGeometryProfileORM.satellite_family)
) )
geometry_by_family_map: dict[str, dict[str, Any]] = {} geometry_by_family_map: dict[str, dict[str, Any]] = {}
for family, metadata_quality, production_readiness, count in geometry_rows.all(): for family, source_format, metadata_quality, production_readiness, count in geometry_rows.all():
family_label = _family_label(family) family_label = _family_label(family)
count_int = _safe_int(count) count_int = _safe_int(count)
bucket = geometry_by_family_map.setdefault( bucket = geometry_by_family_map.setdefault(
@@ -1075,6 +1111,82 @@ async def get_statistics_dashboard(
issue_by_severity[severity_label] = issue_by_severity.get(severity_label, 0) + count_int issue_by_severity[severity_label] = issue_by_severity.get(severity_label, 0) + count_int
issue_by_code[code_label] = issue_by_code.get(code_label, 0) + count_int issue_by_code[code_label] = issue_by_code.get(code_label, 0) + count_int
missing_orbit_issue_rows = await db.execute(
select(AssetInventoryIssueORM.metadata_json)
.where(AssetInventoryIssueORM.status == "OPEN")
.where(AssetInventoryIssueORM.issue_code == "scene_missing_orbit")
)
orbit_missing_satellite_map: dict[str, dict[str, Any]] = {}
orbit_missing_top_dates: dict[tuple[str, str], int] = {}
orbit_missing_unknown_date_scene_count = 0
for (metadata_json,) in missing_orbit_issue_rows.all():
metadata = metadata_json if isinstance(metadata_json, dict) else {}
satellite = str(metadata.get("satellite") or "UNKNOWN").strip().upper() or "UNKNOWN"
ymd = _compact_yyyymmdd(metadata.get("imaging_date") or metadata.get("acquisition_start_time_utc"))
month = _month_from_yyyymmdd(ymd) if ymd else None
satellite_bucket = orbit_missing_satellite_map.setdefault(
satellite,
{
"satellite": satellite,
"affected_scene_count": 0,
"dates": defaultdict(int),
"months": defaultdict(lambda: {"affected_scene_count": 0, "dates": set()}),
"unknown_date_scene_count": 0,
},
)
satellite_bucket["affected_scene_count"] += 1
if not ymd:
satellite_bucket["unknown_date_scene_count"] += 1
orbit_missing_unknown_date_scene_count += 1
continue
satellite_bucket["dates"][ymd] += 1
orbit_missing_top_dates[(satellite, ymd)] = orbit_missing_top_dates.get((satellite, ymd), 0) + 1
if month:
month_bucket = satellite_bucket["months"][month]
month_bucket["affected_scene_count"] += 1
month_bucket["dates"].add(ymd)
orbit_missing_by_satellite: list[dict[str, Any]] = []
for satellite, bucket in sorted(orbit_missing_satellite_map.items()):
date_items = [
{"date": date_text, "affected_scene_count": _safe_int(count)}
for date_text, count in sorted(bucket["dates"].items())
]
month_items = [
{
"month": month,
"affected_scene_count": _safe_int(month_bucket["affected_scene_count"]),
"missing_orbit_date_count": len(month_bucket["dates"]),
}
for month, month_bucket in sorted(bucket["months"].items())
]
orbit_missing_by_satellite.append(
{
"satellite": satellite,
"affected_scene_count": _safe_int(bucket["affected_scene_count"]),
"missing_orbit_date_count": len(date_items),
"unknown_date_scene_count": _safe_int(bucket["unknown_date_scene_count"]),
"first_missing_date": date_items[0]["date"] if date_items else None,
"last_missing_date": date_items[-1]["date"] if date_items else None,
"months": month_items,
"dates": date_items,
}
)
orbit_missing_summary = {
"affected_scene_count": sum(item["affected_scene_count"] for item in orbit_missing_by_satellite),
"missing_orbit_date_count": sum(item["missing_orbit_date_count"] for item in orbit_missing_by_satellite),
"unknown_date_scene_count": orbit_missing_unknown_date_scene_count,
"by_satellite": orbit_missing_by_satellite,
"top_dates": [
{"satellite": satellite, "date": date_text, "affected_scene_count": _safe_int(count)}
for (satellite, date_text), count in sorted(
orbit_missing_top_dates.items(),
key=lambda item: (-item[1], item[0][0], item[0][1]),
)[:20]
],
}
inventory_state_rows = await db.execute( inventory_state_rows = await db.execute(
select( select(
AssetInventoryStateORM.inventory_type, AssetInventoryStateORM.inventory_type,
@@ -1114,16 +1226,24 @@ async def get_statistics_dashboard(
avg_duration_seconds = round(sum(duration_seconds) / len(duration_seconds), 1) if duration_seconds else None avg_duration_seconds = round(sum(duration_seconds) / len(duration_seconds), 1) if duration_seconds else None
selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total) selected_orbit_rate = _ratio(selected_orbit_bindings, orbit_required_total)
geometry_ready_rate = _ratio(geometry_ready, source_total) geometry_ready_rate = _ratio(geometry_ready, geometry_total)
metadata_ready_rate = _ratio(metadata_asset_total, source_total) metadata_ready_rate = _ratio(metadata_asset_total, metadata_source_total)
result_ready_total = sum(item["ready_count"] for item in results_by_catalog) result_ready_total = sum(item["ready_count"] for item in results_by_catalog)
metadata_missing_count = max(0, metadata_source_total - metadata_asset_total)
geometry_not_ready_count = max(0, geometry_total - geometry_ready)
orbit_missing_gap_count = max(0, orbit_required_total - selected_orbit_bindings)
non_orbit_open_issue_total = sum(
count
for code, count in issue_by_code.items()
if code != "scene_missing_orbit"
)
risk_count = ( risk_count = (
max(0, source_total - metadata_asset_total) metadata_missing_count
+ max(0, source_total - geometry_ready) + geometry_not_ready_count
+ max(0, orbit_required_total - selected_orbit_bindings) + orbit_missing_gap_count
+ result_assets_missing + result_assets_missing
+ open_issue_total + non_orbit_open_issue_total
) )
kpis = [ kpis = [
@@ -1140,7 +1260,7 @@ async def get_statistics_dashboard(
"label": "元数据入库率", "label": "元数据入库率",
"value": round(metadata_ready_rate * 100, 1), "value": round(metadata_ready_rate * 100, 1),
"unit": "%", "unit": "%",
"note": f"{metadata_asset_total}/{source_total} 景已提取 XML/元数据", "note": f"{metadata_asset_total}/{metadata_source_total} 景已提取 XML/元数据",
"tone": "success" if metadata_ready_rate >= 0.98 else "warning", "tone": "success" if metadata_ready_rate >= 0.98 else "warning",
}, },
{ {
@@ -1148,7 +1268,7 @@ async def get_statistics_dashboard(
"label": "几何画像可用率", "label": "几何画像可用率",
"value": round(geometry_ready_rate * 100, 1), "value": round(geometry_ready_rate * 100, 1),
"unit": "%", "unit": "%",
"note": f"{geometry_ready}/{source_total} 景可用于覆盖统计", "note": f"{geometry_ready}/{geometry_total} 景可用于覆盖统计",
"tone": "success" if geometry_ready_rate >= 0.95 else "warning", "tone": "success" if geometry_ready_rate >= 0.95 else "warning",
}, },
{ {
@@ -1172,7 +1292,7 @@ async def get_statistics_dashboard(
"label": "待关注项", "label": "待关注项",
"value": risk_count, "value": risk_count,
"unit": "", "unit": "",
"note": f"开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}", "note": f"缺精轨 {orbit_missing_gap_count}开放问题 {open_issue_total},缺失成果资产 {result_assets_missing}",
"tone": "danger" if risk_count else "success", "tone": "danger" if risk_count else "success",
}, },
] ]
@@ -1186,20 +1306,23 @@ async def get_statistics_dashboard(
"source_by_family": source_by_family, "source_by_family": source_by_family,
"source_by_format": source_by_format, "source_by_format": source_by_format,
"source_by_month": source_by_month, "source_by_month": source_by_month,
"metadata_source_total": metadata_source_total,
"metadata_asset_total": metadata_asset_total, "metadata_asset_total": metadata_asset_total,
"metadata_doc_total": metadata_doc_total, "metadata_doc_total": metadata_doc_total,
"metadata_missing_count": metadata_missing_count,
"metadata_ready_rate": metadata_ready_rate, "metadata_ready_rate": metadata_ready_rate,
"geometry_total": geometry_total, "geometry_total": geometry_total,
"geometry_ready": geometry_ready, "geometry_ready": geometry_ready,
"geometry_not_ready_count": geometry_not_ready_count,
"geometry_ready_rate": geometry_ready_rate, "geometry_ready_rate": geometry_ready_rate,
"geometry_by_family": geometry_by_family, "geometry_by_family": geometry_by_family,
"preview_ready": preview_ready, "preview_ready": preview_ready,
"pipeline": [ "pipeline": [
{"key": "source", "label": "源资产登记", "value": source_total, "rate": 1.0}, {"key": "source", "label": "源资产登记", "value": source_total, "denominator": source_total, "rate": 1.0},
{"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "rate": metadata_ready_rate}, {"key": "metadata", "label": "元数据入库", "value": metadata_asset_total, "denominator": metadata_source_total, "rate": metadata_ready_rate},
{"key": "geometry", "label": "几何画像", "value": geometry_total, "rate": _ratio(geometry_total, source_total)}, {"key": "geometry", "label": "几何画像", "value": geometry_total, "denominator": metadata_source_total, "rate": _ratio(geometry_total, metadata_source_total)},
{"key": "ready", "label": "可生产画像", "value": geometry_ready, "rate": geometry_ready_rate}, {"key": "ready", "label": "可生产画像", "value": geometry_ready, "denominator": geometry_total, "rate": geometry_ready_rate},
{"key": "preview", "label": "预览缓存", "value": preview_ready, "rate": _ratio(preview_ready, radar_total)}, {"key": "preview", "label": "预览缓存", "value": preview_ready, "denominator": radar_total, "rate": _ratio(preview_ready, radar_total)},
], ],
}, },
"orbit": { "orbit": {
@@ -1208,6 +1331,8 @@ async def get_statistics_dashboard(
"orbit_required_total": orbit_required_total, "orbit_required_total": orbit_required_total,
"selected_bindings": selected_orbit_bindings, "selected_bindings": selected_orbit_bindings,
"matched_bindings": matched_orbit_bindings, "matched_bindings": matched_orbit_bindings,
"missing_scene_count": orbit_missing_gap_count,
"missing_summary": orbit_missing_summary,
"selected_rate": selected_orbit_rate, "selected_rate": selected_orbit_rate,
}, },
"coverage": { "coverage": {
@@ -1242,6 +1367,9 @@ async def get_statistics_dashboard(
"issues": { "issues": {
"issue_total": issue_total, "issue_total": issue_total,
"open_issue_total": open_issue_total, "open_issue_total": open_issue_total,
"risk_count": risk_count,
"non_orbit_open_issue_total": non_orbit_open_issue_total,
"orbit_missing_gap_count": orbit_missing_gap_count,
"by_severity": [ "by_severity": [
{"severity": severity, "count": count} {"severity": severity, "count": count}
for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0])) for severity, count in sorted(issue_by_severity.items(), key=lambda kv: (-kv[1], kv[0]))
@@ -1361,12 +1489,16 @@ async def get_statistics(
"db_ready_and_cache_exists_count": 0, "db_ready_and_cache_exists_count": 0,
"db_ready_but_cache_missing_count": 0, "db_ready_but_cache_missing_count": 0,
} }
source_xml_consistency = { source_metadata_consistency = {
"total_records_count": 0, "total_records_count": 0,
"xml_detected_count": 0, "metadata_document_asset_count": 0,
"xml_missing_count": 0, "metadata_document_count": 0,
"xml_parsed_ok_count": 0, "metadata_document_missing_count": 0,
"xml_detected_but_unparsed_count": 0, "metadata_parsed_ok_count": 0,
"metadata_detected_but_unparsed_count": 0,
"geometry_ready_count": 0,
"geometry_missing_count": 0,
"geometry_not_ready_count": 0,
} }
try: try:
@@ -1399,7 +1531,6 @@ async def get_statistics(
) )
source_rows = source_rows_res.all() source_rows = source_rows_res.all()
source_preview_consistency["total_records_count"] = len(source_rows) source_preview_consistency["total_records_count"] = len(source_rows)
source_xml_consistency["total_records_count"] = len(source_rows)
for ( for (
unique_id, unique_id,
@@ -1415,7 +1546,6 @@ async def get_statistics(
) in source_rows: ) in source_rows:
if not file_path: if not file_path:
source_preview_consistency["preview_missing_count"] += 1 source_preview_consistency["preview_missing_count"] += 1
source_xml_consistency["xml_missing_count"] += 1
continue continue
cache_key = unique_id or file_path cache_key = unique_id or file_path
@@ -1443,34 +1573,64 @@ async def get_statistics(
else: else:
source_preview_consistency["db_ready_but_cache_missing_count"] += 1 source_preview_consistency["db_ready_but_cache_missing_count"] += 1
scene_dir = file_path if os.path.isdir(file_path) else os.path.dirname(file_path) active_source_count = await _scalar_count(
xml_path = find_xml_file(scene_dir) if scene_dir else None db,
has_xml = bool(xml_path and os.path.exists(xml_path)) select(func.count(SourceProductAssetORM.id)).where(
if has_xml: SourceProductAssetORM.is_active == True,
source_xml_consistency["xml_detected_count"] += 1 SourceProductAssetORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
parsed_ok = any( ),
value is not None and value != "" )
for value in [ metadata_asset_count = await _scalar_count(
scene_center_lon, db,
scene_center_lat, select(func.count(distinct(SourceMetadataDocumentORM.source_asset_id))).where(
acquisition_time_utc, SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
satellite_mode, ),
receiving_station, )
product_level, metadata_doc_count = await _scalar_count(
product_unique_id, db,
] select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
metadata_parse_issue_count = await _scalar_count(
db,
select(func.count(SourceMetadataDocumentORM.id)).where(
SourceMetadataDocumentORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SourceMetadataDocumentORM.parse_status != "OK",
),
)
geometry_ready_count = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
SARSceneGeometryProfileORM.metadata_quality == "READY",
SARSceneGeometryProfileORM.production_readiness == "READY",
),
)
geometry_total_count = await _scalar_count(
db,
select(func.count(SARSceneGeometryProfileORM.id)).where(
SARSceneGeometryProfileORM.source_format.in_(SOURCE_METADATA_MANAGED_FORMATS),
),
)
source_metadata_consistency.update(
{
"total_records_count": active_source_count,
"metadata_document_asset_count": metadata_asset_count,
"metadata_document_count": metadata_doc_count,
"metadata_document_missing_count": max(0, active_source_count - metadata_asset_count),
"metadata_parsed_ok_count": max(0, metadata_doc_count - metadata_parse_issue_count),
"metadata_detected_but_unparsed_count": metadata_parse_issue_count,
"geometry_ready_count": geometry_ready_count,
"geometry_missing_count": max(0, active_source_count - geometry_total_count),
"geometry_not_ready_count": max(0, geometry_total_count - geometry_ready_count),
}
) )
if parsed_ok:
source_xml_consistency["xml_parsed_ok_count"] += 1
else:
source_xml_consistency["xml_detected_but_unparsed_count"] += 1
else:
source_xml_consistency["xml_missing_count"] += 1
except Exception as e: except Exception as e:
logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e) logger.warning("统计源数据时发生错误 (可能是表不存在): %s", e)
source_preview_consistency["error"] = str(e) source_preview_consistency["error"] = str(e)
source_xml_consistency["error"] = str(e) source_metadata_consistency["error"] = str(e)
# 4. AI 质量统计 # 4. AI 质量统计
labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1) labeled_good_count = sum(1 for record in dinsar_records if record.product.user_label == 1)
@@ -1756,7 +1916,16 @@ async def get_statistics(
"with_orbit_data_count": with_orbit_data_count, "with_orbit_data_count": with_orbit_data_count,
}, },
"source_preview_consistency": source_preview_consistency, "source_preview_consistency": source_preview_consistency,
"source_xml_consistency": source_xml_consistency, "source_metadata_consistency": source_metadata_consistency,
"source_xml_consistency": {
"deprecated": True,
"replacement": "source_metadata_consistency",
"total_records_count": source_metadata_consistency.get("total_records_count", 0),
"xml_detected_count": source_metadata_consistency.get("metadata_document_asset_count", 0),
"xml_missing_count": 0,
"xml_parsed_ok_count": source_metadata_consistency.get("metadata_parsed_ok_count", 0),
"xml_detected_but_unparsed_count": 0,
},
"water_geo_consistency": water_geo_consistency, "water_geo_consistency": water_geo_consistency,
"pairing_consistency": pairing_consistency, "pairing_consistency": pairing_consistency,
"by_satellite": by_satellite, "by_satellite": by_satellite,
@@ -80,6 +80,16 @@ TERMINAL_ITEM_STATUSES = {
_SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+") _SAFE_POINTER_RE = re.compile(r"[^0-9A-Za-z._-]+")
def _item_status_counts(items: List[DinsarProductionRunItemORM]) -> Dict[str, int]:
counts: Dict[str, int] = {}
for item in items:
status = str(item.status or "").strip().upper()
if not status:
continue
counts[status] = counts.get(status, 0) + 1
return counts
def _task_type_for_engine(engine_code: str) -> str: def _task_type_for_engine(engine_code: str) -> str:
normalized = str(engine_code or "").strip().lower() normalized = str(engine_code or "").strip().lower()
if normalized == "sarscape": if normalized == "sarscape":
@@ -997,9 +1007,11 @@ class DinsarProductionService:
) )
for item in items_result.scalars().all(): for item in items_result.scalars().all():
items_by_run_id.setdefault(item.run_id, []).append(item) items_by_run_id.setdefault(item.run_id, []).append(item)
def serialize_run(run: DinsarProductionRunORM) -> Dict[str, Any]:
run_items = items_by_run_id.get(run.run_id, [])
item_counts = _item_status_counts(run_items)
return { return {
"runs": [
{
"run_id": run.run_id, "run_id": run.run_id,
"product_family": run.product_family, "product_family": run.product_family,
"engine": run.engine_code, "engine": run.engine_code,
@@ -1014,10 +1026,13 @@ class DinsarProductionService:
"publish_root_dir": run.publish_root_dir, "publish_root_dir": run.publish_root_dir,
"message": run.latest_message, "message": run.latest_message,
"summary_json": run.summary_json if isinstance(run.summary_json, dict) else {}, "summary_json": run.summary_json if isinstance(run.summary_json, dict) else {},
"total_items": run.total_items, "total_items": max(int(run.total_items or 0), len(run_items)),
"completed_items": run.completed_items, "completed_items": item_counts.get(RUN_ITEM_STATUS_COMPLETED, int(run.completed_items or 0)),
"failed_items": run.failed_items, "running_items": item_counts.get(RUN_ITEM_STATUS_RUNNING, 0),
"skipped_items": run.skipped_items, "pending_items": item_counts.get(RUN_ITEM_STATUS_PENDING, 0),
"failed_items": item_counts.get(RUN_ITEM_STATUS_FAILED, int(run.failed_items or 0)),
"skipped_items": item_counts.get(RUN_ITEM_STATUS_SKIPPED, int(run.skipped_items or 0)),
"cancelled_items": item_counts.get(RUN_ITEM_STATUS_CANCELLED, 0),
"items": [ "items": [
{ {
"task_name": item.task_name, "task_name": item.task_name,
@@ -1039,11 +1054,12 @@ class DinsarProductionService:
if item.latest_run_key if item.latest_run_key
else {}, else {},
} }
for item in items_by_run_id.get(run.run_id, [])[:5] for item in run_items[:5]
], ],
} }
for run in runs
], return {
"runs": [serialize_run(run) for run in runs],
"total": total, "total": total,
} }
+78 -236
View File
@@ -4,9 +4,9 @@ import os
import shutil import shutil
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, List, Optional
from sqlalchemy import delete, func, or_, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings from ..config import settings
@@ -14,25 +14,18 @@ from ..models import (
DinsarProductionExecutionORM, DinsarProductionExecutionORM,
DinsarProductionRunItemORM, DinsarProductionRunItemORM,
DinsarProductionRunORM, DinsarProductionRunORM,
DinsarResultORM,
ResultProductORM,
SystemJobORM, SystemJobORM,
SystemTaskORM, SystemTaskORM,
TaskLogORM, TaskLogORM,
WorkflowArtifactORM,
WorkflowRunORM,
WorkflowStepORM,
) )
from .dinsar_production_service import dinsar_production_service
MAINTENANCE_LIST_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
TERMINAL_TASK_STATUSES = {"COMPLETED", "FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
MAINTENANCE_LIST_STATUSES = {"FAILED", "PARTIAL_SUCCESS", "CANCELLED", "PENDING", "RUNNING"}
ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"} ACTIVE_TASK_STATUSES = {"PENDING", "RUNNING"}
ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"} ACTIVE_JOB_STATUSES = {"READY", "RETRY", "RUNNING"}
DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"} DINSAR_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN", "PYINT_RUN", "IDL_RUN_DINSAR"}
SUPPORTED_CLEANUP_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA"} SUPPORTED_CLEANUP_TASK_TYPES = {"LANDSAR_RUN", "LANDSAR_CLUSTER_RUN"}
DEFAULT_TASK_TYPES = SUPPORTED_CLEANUP_TASK_TYPES | {"PAIRING_CACHE_REBUILD"} DEFAULT_TASK_TYPES = DINSAR_TASK_TYPES | {"COPY_DATA", "PAIRING_CACHE_REBUILD"}
LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES = {"COMPLETED", "FAILED", "CANCELLED"}
def _utcnow_naive() -> datetime: def _utcnow_naive() -> datetime:
@@ -68,8 +61,15 @@ def _path_exists(path: str) -> bool:
return bool(path) and os.path.exists(path) return bool(path) and os.path.exists(path)
def _safe_count(rows: Iterable[Any]) -> int: def _is_within_path(path: str, parent: str) -> bool:
return len(list(rows)) path_text = _normalize_path_text(path)
parent_text = _normalize_path_text(parent)
if not path_text or not parent_text:
return False
try:
return os.path.commonpath([os.path.abspath(path_text), os.path.abspath(parent_text)]) == os.path.abspath(parent_text)
except ValueError:
return False
class OpsMaintenanceService: class OpsMaintenanceService:
@@ -108,11 +108,12 @@ class OpsMaintenanceService:
if item.get("issue_level") in {"warning", "danger"} if item.get("issue_level") in {"warning", "danger"}
or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"} or _norm_status(item.get("status")) in {"FAILED", "PARTIAL_SUCCESS", "CANCELLED"}
] ]
visible_items = items if status_filter else abnormal_items
return { return {
"items": abnormal_items, "items": visible_items,
"limit": safe_limit, "limit": safe_limit,
"offset": safe_offset, "offset": safe_offset,
"returned": len(abnormal_items), "returned": len(visible_items),
} }
async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]: async def diagnose_task(self, db: AsyncSession, task_id: str) -> Optional[Dict[str, Any]]:
@@ -124,19 +125,11 @@ class OpsMaintenanceService:
item_counts: Dict[str, int] = {} item_counts: Dict[str, int] = {}
execution_counts: Dict[str, int] = {} execution_counts: Dict[str, int] = {}
disk_paths: List[Dict[str, Any]] = [] disk_paths: List[Dict[str, Any]] = []
products: List[Dict[str, Any]] = []
if run is not None: if run is not None:
item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id) item_counts = await self._status_counts(db, DinsarProductionRunItemORM, run.run_id)
execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id) execution_counts = await self._status_counts(db, DinsarProductionExecutionORM, run.run_id)
disk_paths.extend(await self._collect_run_disk_paths(db, run)) disk_paths.extend(await self._collect_run_disk_paths(db, run))
products = await self._collect_result_products(db, run)
related_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
copy_dest = self._copy_task_dest_dir(task)
if copy_dest:
disk_paths.append(self._path_payload(copy_dest, "task_pool"))
recent_logs = await self._recent_logs(db, task.task_id) recent_logs = await self._recent_logs(db, task.task_id)
findings, cleanup_supported, cleanup_blockers = self._diagnose_findings( findings, cleanup_supported, cleanup_blockers = self._diagnose_findings(
task=task, task=task,
@@ -152,8 +145,6 @@ class OpsMaintenanceService:
"production_run": self._run_payload(run) if run else None, "production_run": self._run_payload(run) if run else None,
"production_item_counts": item_counts, "production_item_counts": item_counts,
"production_execution_counts": execution_counts, "production_execution_counts": execution_counts,
"result_products": products,
"related_tasks": [self._task_payload(item) for item in related_tasks],
"recent_logs": recent_logs, "recent_logs": recent_logs,
"disk_paths": disk_paths, "disk_paths": disk_paths,
"diagnosis": { "diagnosis": {
@@ -179,15 +170,17 @@ class OpsMaintenanceService:
if task_status in ACTIVE_TASK_STATUSES: if task_status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态,不能清理。") blockers.append("任务仍处于活动状态,不能清理。")
db_counts = await self._cleanup_db_counts(db, diagnosis)
disk_deletes = self._cleanup_disk_targets(diagnosis) disk_deletes = self._cleanup_disk_targets(diagnosis)
has_existing_target = any(item.get("exists") and item.get("allowed") for item in disk_deletes)
if not has_existing_target:
blockers.append("未发现可清理的 LandSAR_WORK_ROOT/run_* 目录。")
blocked = bool(blockers) or not cleanup_supported blocked = bool(blockers) or not cleanup_supported
return { return {
"task_id": task_id, "task_id": task_id,
"blocked": blocked, "blocked": blocked,
"blockers": blockers, "blockers": blockers,
"cleanup_supported": cleanup_supported and not blocked, "cleanup_supported": cleanup_supported and not blocked,
"database_deletes": db_counts, "database_deletes": {},
"disk_deletes": disk_deletes, "disk_deletes": disk_deletes,
} }
@@ -204,82 +197,17 @@ class OpsMaintenanceService:
if preview.get("blocked"): if preview.get("blocked"):
raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"])) raise ValueError("; ".join(preview.get("blockers") or ["清理被阻止。"]))
deleted_db: Dict[str, int] = {}
task = await self._get_task(db, task_id) task = await self._get_task(db, task_id)
if task is None: if task is None:
return None return None
jobs = await self._get_jobs(db, task_id)
run = await self._get_production_run_for_task(db, task, jobs)
delete_logs = bool(options.get("delete_logs", True))
delete_task_records = bool(options.get("delete_task_records", True))
delete_production_records = bool(options.get("delete_production_records", True))
delete_result_products = bool(options.get("delete_result_products", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
related_copy_tasks = await self._related_copy_tasks_for_run(db, run) if run is not None else []
if delete_result_products and run is not None:
products = await self._result_product_orms_for_run(db, run)
product_ids = [item.product_id for item in products]
compat_ids = await self._compat_ids_for_products(db, product_ids)
if product_ids:
result = await db.execute(delete(ResultProductORM).where(ResultProductORM.product_id.in_(product_ids)))
deleted_db["result_products"] = int(result.rowcount or 0)
if compat_ids:
result = await db.execute(delete(DinsarResultORM).where(DinsarResultORM.id.in_(compat_ids)))
deleted_db["dinsar_results"] = int(result.rowcount or 0)
if delete_production_records and run is not None:
deleted_run = await dinsar_production_service.delete_run_record(run.run_id, db=db)
deleted_db["dinsar_production_runs"] = 1 if deleted_run else 0
deleted_db["dinsar_production_run_items"] = int(preview["database_deletes"].get("dinsar_production_run_items", 0))
deleted_db["dinsar_production_executions"] = int(preview["database_deletes"].get("dinsar_production_executions", 0))
deleted_db["system_jobs"] = int(preview["database_deletes"].get("system_jobs", 0))
deleted_db["system_tasks"] = 1
deleted_db["task_logs"] = int(preview["database_deletes"].get("task_logs", 0))
if delete_task_pool_dir:
related_deleted = await self._delete_related_tasks(db, related_copy_tasks)
for key, value in related_deleted.items():
deleted_db[key] = int(deleted_db.get(key, 0)) + int(value or 0)
else:
if delete_logs:
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id == task_id))
deleted_db["task_logs"] = int(result.rowcount or 0)
if delete_task_records:
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id == task_id))
deleted_db["system_jobs"] = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id == task_id))
deleted_db["system_tasks"] = int(result.rowcount or 0)
await db.commit()
disk_result = await self._delete_disk_targets(preview, options) disk_result = await self._delete_disk_targets(preview, options)
return { return {
"task_id": task_id, "task_id": task_id,
"deleted_database": deleted_db, "deleted_database": {},
"deleted_disk": disk_result, "deleted_disk": disk_result,
} }
async def _delete_related_tasks(
self,
db: AsyncSession,
tasks: List[SystemTaskORM],
) -> Dict[str, int]:
task_ids = [task.task_id for task in tasks if task.task_id]
if not task_ids:
return {}
result = await db.execute(delete(TaskLogORM).where(TaskLogORM.task_id.in_(task_ids)))
logs = int(result.rowcount or 0)
result = await db.execute(delete(SystemJobORM).where(SystemJobORM.task_id.in_(task_ids)))
jobs = int(result.rowcount or 0)
result = await db.execute(delete(SystemTaskORM).where(SystemTaskORM.task_id.in_(task_ids)))
task_count = int(result.rowcount or 0)
await db.commit()
return {
"related_task_logs": logs,
"related_system_jobs": jobs,
"related_system_tasks": task_count,
}
async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]: async def _get_task(self, db: AsyncSession, task_id: str) -> Optional[SystemTaskORM]:
result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip())) result = await db.execute(select(SystemTaskORM).where(SystemTaskORM.task_id == str(task_id or "").strip()))
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -383,87 +311,33 @@ class OpsMaintenanceService:
async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]: async def _collect_run_disk_paths(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]:
paths: Dict[str, Dict[str, Any]] = {} paths: Dict[str, Dict[str, Any]] = {}
if run.source_root:
paths[_normalize_path_text(run.source_root)] = self._path_payload(run.source_root, "task_pool")
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id)) result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
for execution in result.scalars().all(): for execution in result.scalars().all():
if execution.output_dir: landsar_work_dir = self._landsar_work_dir_for_execution(run, execution)
publish_dir = self._publish_package_dir(execution.output_dir) if landsar_work_dir:
paths[publish_dir] = self._path_payload(publish_dir, "production_result") payload = self._path_payload(landsar_work_dir, "landsar_work")
log_path = dinsar_production_service.read_run_log(run.run_id, max_bytes=1).get("path") payload["run_key"] = execution.run_key
if log_path: payload["execution_status"] = execution.status
paths[_normalize_path_text(log_path)] = self._path_payload(log_path, "run_log") paths[_normalize_path_text(landsar_work_dir).lower()] = payload
return list(paths.values()) return list(paths.values())
async def _collect_result_products(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[Dict[str, Any]]: def _landsar_work_dir_for_execution(
products = await self._result_product_orms_for_run(db, run)
return [
{
"product_id": item.product_id,
"display_name": item.display_name,
"status": item.status,
"health_status": item.health_status,
"publish_dir": item.publish_dir,
"manifest_path": item.manifest_path,
}
for item in products
]
async def _result_product_orms_for_run(self, db: AsyncSession, run: DinsarProductionRunORM) -> List[ResultProductORM]:
result = await db.execute(select(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run.run_id))
dirs = [self._publish_package_dir(item.output_dir) for item in result.scalars().all() if item.output_dir]
clauses = []
for path in dirs:
clauses.append(ResultProductORM.publish_dir == path)
clauses.append(ResultProductORM.native_output_dir.like(path + "%"))
clauses.append(ResultProductORM.manifest_path.like(path + "%"))
clauses.append(ResultProductORM.primary_asset_path.like(path + "%"))
if not clauses:
return []
products = await db.execute(select(ResultProductORM).where(or_(*clauses)))
by_id: Dict[str, ResultProductORM] = {}
for product in products.scalars().all():
by_id[product.product_id] = product
return list(by_id.values())
async def _compat_ids_for_products(self, db: AsyncSession, product_ids: List[str]) -> List[int]:
if not product_ids:
return []
result = await db.execute(select(DinsarResultORM).where(DinsarResultORM.compat_product_id.in_(product_ids)))
return [int(item.id) for item in result.scalars().all()]
def _publish_package_dir(self, output_dir: str) -> str:
normalized = _normalize_path_text(output_dir)
marker = os.sep + "runs" + os.sep
if marker.lower() in normalized.lower():
lower = normalized.lower()
index = lower.index(marker.lower())
return normalized[:index]
return normalized
def _copy_task_dest_dir(self, task: SystemTaskORM) -> str:
params = task.params if isinstance(task.params, dict) else {}
return _normalize_path_text(params.get("dest_dir")) if params.get("dest_dir") else ""
async def _related_copy_tasks_for_run(
self, self,
db: AsyncSession, run: DinsarProductionRunORM,
run: Optional[DinsarProductionRunORM], execution: DinsarProductionExecutionORM,
) -> List[SystemTaskORM]: ) -> str:
source_root = _normalize_path_text(run.source_root if run is not None else "") if _norm_status(run.engine_code) != "LANDSAR":
if not source_root: return ""
return [] if _norm_status(run.status) in ACTIVE_TASK_STATUSES:
result = await db.execute( return ""
select(SystemTaskORM) if _norm_status(execution.status) not in LANDSAR_WORK_TERMINAL_EXECUTION_STATUSES:
.where(SystemTaskORM.task_type == "COPY_DATA") return ""
.order_by(SystemTaskORM.updated_at.desc(), SystemTaskORM.id.desc()) work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
.limit(1000) run_key = str(execution.run_key or "").strip()
) if not work_root or not run_key or Path(run_key).name != run_key or not run_key.startswith("run_"):
tasks = [] return ""
for task in result.scalars().all(): candidate = _normalize_path_text(os.path.join(work_root, run_key))
if _normalize_path_text(self._copy_task_dest_dir(task)).lower() == source_root.lower(): return candidate if self._is_landsar_work_delete_path(candidate) else ""
tasks.append(task)
return tasks
def _diagnose_findings( def _diagnose_findings(
self, self,
@@ -481,9 +355,9 @@ class OpsMaintenanceService:
if status == "FAILED": if status == "FAILED":
findings.append("任务已失败,需要人工确认后清理。") findings.append("任务已失败,需要人工确认后清理。")
elif status == "PARTIAL_SUCCESS": elif status == "PARTIAL_SUCCESS":
findings.append("任务部分成功,清理前请确认保留策略") findings.append("任务部分成功,可按需清理 LandSAR 工作目录")
elif status == "CANCELLED": elif status == "CANCELLED":
findings.append("任务已取消,可按需清理残留记录和目录。") findings.append("任务已取消,可按需清理 LandSAR 工作目录。")
elif status in ACTIVE_TASK_STATUSES: elif status in ACTIVE_TASK_STATUSES:
blockers.append("任务仍处于活动状态。") blockers.append("任务仍处于活动状态。")
@@ -507,58 +381,29 @@ class OpsMaintenanceService:
missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")] missing_paths = [item for item in disk_paths if item.get("path") and not item.get("exists")]
if missing_paths: if missing_paths:
findings.append(f"{len(missing_paths)} 个登记路径已不存在。") findings.append(f"{len(missing_paths)} 个登记路径已不存在。")
landsar_work_paths = [
item
for item in disk_paths
if item.get("kind") == "landsar_work" and item.get("exists")
]
if landsar_work_paths:
findings.append(f"发现 {len(landsar_work_paths)} 个可清理的 LandSAR 工作目录。")
cleanup_supported = _norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES and not blockers cleanup_supported = (
_norm_status(task.task_type) in SUPPORTED_CLEANUP_TASK_TYPES
and not blockers
and bool(landsar_work_paths)
)
return findings, cleanup_supported, blockers return findings, cleanup_supported, blockers
async def _cleanup_db_counts(self, db: AsyncSession, diagnosis: Dict[str, Any]) -> Dict[str, int]:
task_id = diagnosis["task"]["task_id"]
run = diagnosis.get("production_run") or {}
run_id = run.get("run_id")
workflow_run_id = run.get("workflow_run_id")
counts = {
"system_tasks": await self._count(db, select(func.count()).select_from(SystemTaskORM).where(SystemTaskORM.task_id == task_id)),
"system_jobs": await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id == task_id)),
"task_logs": await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id == task_id)),
"related_system_tasks": 0,
"related_system_jobs": 0,
"related_task_logs": 0,
"dinsar_production_runs": 0,
"dinsar_production_run_items": 0,
"dinsar_production_executions": 0,
"result_products": len(diagnosis.get("result_products") or []),
"dinsar_results": 0,
"workflow_runs": 0,
"workflow_steps": 0,
"workflow_artifacts": 0,
}
if run_id:
counts["dinsar_production_runs"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
counts["dinsar_production_run_items"] = await self._count(db, select(func.count()).select_from(DinsarProductionRunItemORM).where(DinsarProductionRunItemORM.run_id == run_id))
counts["dinsar_production_executions"] = await self._count(db, select(func.count()).select_from(DinsarProductionExecutionORM).where(DinsarProductionExecutionORM.run_id == run_id))
run_obj = await self._get_run_by_id(db, run_id)
related_tasks = await self._related_copy_tasks_for_run(db, run_obj)
related_task_ids = [item.task_id for item in related_tasks if item.task_id]
counts["related_system_tasks"] = len(related_task_ids)
if related_task_ids:
counts["related_system_jobs"] = await self._count(db, select(func.count()).select_from(SystemJobORM).where(SystemJobORM.task_id.in_(related_task_ids)))
counts["related_task_logs"] = await self._count(db, select(func.count()).select_from(TaskLogORM).where(TaskLogORM.task_id.in_(related_task_ids)))
if workflow_run_id:
counts["workflow_runs"] = await self._count(db, select(func.count()).select_from(WorkflowRunORM).where(WorkflowRunORM.run_id == workflow_run_id))
counts["workflow_steps"] = await self._count(db, select(func.count()).select_from(WorkflowStepORM).where(WorkflowStepORM.run_id == workflow_run_id))
counts["workflow_artifacts"] = await self._count(db, select(func.count()).select_from(WorkflowArtifactORM).where(WorkflowArtifactORM.run_id == workflow_run_id))
return counts
async def _get_run_by_id(self, db: AsyncSession, run_id: str) -> Optional[DinsarProductionRunORM]:
result = await db.execute(select(DinsarProductionRunORM).where(DinsarProductionRunORM.run_id == run_id))
return result.scalar_one_or_none()
async def _count(self, db: AsyncSession, stmt: Any) -> int: async def _count(self, db: AsyncSession, stmt: Any) -> int:
return int((await db.execute(stmt)).scalar_one() or 0) return int((await db.execute(stmt)).scalar_one() or 0)
def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]: def _cleanup_disk_targets(self, diagnosis: Dict[str, Any]) -> List[Dict[str, Any]]:
targets: Dict[str, Dict[str, Any]] = {} targets: Dict[str, Dict[str, Any]] = {}
for item in diagnosis.get("disk_paths") or []: for item in diagnosis.get("disk_paths") or []:
if item.get("kind") != "landsar_work":
continue
path = _normalize_path_text(item.get("path")) path = _normalize_path_text(item.get("path"))
if not path: if not path:
continue continue
@@ -581,29 +426,26 @@ class OpsMaintenanceService:
normalized = _normalize_path_text(path) normalized = _normalize_path_text(path)
if not normalized: if not normalized:
return False return False
roots = [ return self._is_landsar_work_delete_path(normalized)
settings.DINSAR_TASK_POOL_ROOT,
settings.DINSAR_PRODUCT_DIR, def _is_landsar_work_delete_path(self, path: str) -> bool:
os.path.join(settings.PROJECT_ROOT, "backend", "runtime", "dinsar_production"), work_root = _normalize_path_text(settings.LANDSAR_WORK_ROOT)
] normalized = _normalize_path_text(path)
if not work_root or not normalized:
return False
root_full = os.path.abspath(work_root)
full = os.path.abspath(normalized) full = os.path.abspath(normalized)
for root in roots: if full == root_full or not _is_within_path(full, root_full):
root_text = _normalize_path_text(root)
if not root_text:
continue
root_full = os.path.abspath(root_text)
if full == root_full:
return False return False
try: try:
if os.path.commonpath([full, root_full]) == root_full: relative = os.path.relpath(full, root_full)
return True
except ValueError: except ValueError:
continue
return False return False
parts = [part for part in relative.split(os.sep) if part]
return len(parts) == 1 and parts[0].startswith("run_")
async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]: async def _delete_disk_targets(self, preview: Dict[str, Any], options: Dict[str, bool]) -> Dict[str, Any]:
delete_production_dirs = bool(options.get("delete_production_dirs", True)) delete_landsar_work_dir = bool(options.get("delete_landsar_work_dir", True))
delete_task_pool_dir = bool(options.get("delete_task_pool_dir", True))
deleted: List[str] = [] deleted: List[str] = []
missing: List[str] = [] missing: List[str] = []
skipped: List[str] = [] skipped: List[str] = []
@@ -611,13 +453,13 @@ class OpsMaintenanceService:
for item in preview.get("disk_deletes") or []: for item in preview.get("disk_deletes") or []:
kind = item.get("kind") kind = item.get("kind")
path = _normalize_path_text(item.get("path")) path = _normalize_path_text(item.get("path"))
if kind != "landsar_work":
skipped.append(path)
continue
if not item.get("allowed"): if not item.get("allowed"):
skipped.append(path) skipped.append(path)
continue continue
if kind == "task_pool" and not delete_task_pool_dir: if not delete_landsar_work_dir:
skipped.append(path)
continue
if kind == "production_result" and not delete_production_dirs:
skipped.append(path) skipped.append(path)
continue continue
if not _path_exists(path): if not _path_exists(path):
+436 -5
View File
@@ -7,8 +7,10 @@ import hashlib
import json import json
import logging import logging
import math import math
import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta
from itertools import combinations from itertools import combinations
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -20,7 +22,7 @@ from sqlalchemy.orm import aliased
from geoalchemy2 import Geography from geoalchemy2 import Geography
from geoalchemy2.shape import to_shape from geoalchemy2.shape import to_shape
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area, ST_Centroid, ST_Covers
from shapely.geometry import Polygon from shapely.geometry import Polygon, mapping, shape
from shapely.ops import unary_union from shapely.ops import unary_union
from ..models import ( from ..models import (
@@ -196,6 +198,112 @@ class SpatialService:
} }
return result_pairs, warnings, metadata return result_pairs, warnings, metadata
async def find_dinsar_coverage_pairs(
self,
db: AsyncSession,
params: PairingRequest,
*,
target_date_from: str,
target_date_to: str,
extension_days: int = 15,
max_pairs: int = 200,
target_coverage_ratio: float = 0.98,
min_new_coverage_ratio: float = 0.0005,
aoi_wkt: Optional[str] = None,
require_orbit_data: bool = True,
) -> Tuple[List[RadarPair], List[str], Dict[str, Any]]:
warnings: List[str] = []
target_start = self._parse_yyyymmdd(target_date_from, field_name="target_date_from")
target_end = self._parse_yyyymmdd(target_date_to, field_name="target_date_to")
if target_end < target_start:
raise ValueError("target_date_to must be greater than or equal to target_date_from.")
safe_extension_days = max(0, min(180, int(extension_days or 0)))
safe_max_pairs = max(1, min(5000, int(max_pairs or 200)))
safe_target_coverage_ratio = max(0.0, min(1.0, float(target_coverage_ratio or 0.98)))
safe_min_new_coverage_ratio = max(0.0, min(1.0, float(min_new_coverage_ratio or 0.0)))
query_start = target_start - timedelta(days=safe_extension_days)
query_end = target_end + timedelta(days=safe_extension_days)
effective_params = self._normalize_pairing_request(params).model_copy(
update={
"master_date_from": self._format_yyyymmdd(query_start),
"master_date_to": self._format_yyyymmdd(query_end),
"slave_date_from": self._format_yyyymmdd(query_start),
"slave_date_to": self._format_yyyymmdd(query_end),
"strategy": "dinsar_province_coverage",
}
)
pairing_status = await pairing_state_service.get_pairing_system_status(db)
cache_status = str(pairing_status.get("status") or "UNINITIALIZED")
scene_count = int(pairing_status.get("scene_count") or 0)
pair_count = int(pairing_status.get("pair_count") or 0)
degraded = bool(pairing_status.get("needs_rebuild"))
if cache_status in {"FAILED", "UNINITIALIZED", "ERROR"} or (scene_count > 1 and pair_count == 0):
raise RuntimeError(
"Pairing candidate cache is not available. Repair or rebuild the pairing foundation first."
)
if degraded:
warnings.append(
f"Pairing foundation status is {cache_status}; coverage plan uses current cached candidates."
)
candidate_pool = await self._query_pairing_metric_cache(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
selected_candidates, coverage_meta, strategy_warnings = self._apply_province_coverage_strategy(
candidate_pool,
target_start=target_start,
target_end=target_end,
query_start=query_start,
query_end=query_end,
max_pairs=safe_max_pairs,
target_coverage_ratio=safe_target_coverage_ratio,
min_new_coverage_ratio=safe_min_new_coverage_ratio,
aoi_wkt=aoi_wkt,
)
warnings.extend(strategy_warnings)
if not selected_candidates:
warnings.extend(
await self._build_empty_pairing_diagnostics(
db,
effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
)
)
for candidate in selected_candidates:
candidate["selection_strategy"] = "dinsar_province_coverage"
self._ensure_candidate_identity(candidate)
network_run_id = await self._persist_network_run(
db,
params=effective_params,
aoi_wkt=aoi_wkt,
require_orbit_data=require_orbit_data,
warnings=warnings,
candidate_pool=candidate_pool,
selected_candidates=selected_candidates,
)
await self._attach_dinsar_production_summaries(db, selected_candidates)
result_pairs = self._generate_task_names(self._build_radar_pairs(selected_candidates))
metadata = {
"fallback_used": False,
"degraded": degraded,
"policy_version": PAIRING_POLICY_VERSION,
"network_run_id": network_run_id,
"candidate_count": len(candidate_pool),
"selected_edge_count": len(result_pairs),
"coverage": coverage_meta,
}
return result_pairs, warnings, metadata
def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest: def _normalize_pairing_request(self, params: PairingRequest) -> PairingRequest:
updates: Dict[str, Any] = {} updates: Dict[str, Any] = {}
@@ -408,6 +516,27 @@ class SpatialService:
selection_strategy=candidate.get("selection_strategy"), selection_strategy=candidate.get("selection_strategy"),
selection_score=float(selection_score) if selection_score is not None else None, selection_score=float(selection_score) if selection_score is not None else None,
selection_reason=candidate.get("selection_reason"), selection_reason=candidate.get("selection_reason"),
coverage_rank=(
int(candidate["coverage_rank"])
if candidate.get("coverage_rank") is not None
else None
),
aoi_new_area_ratio=(
float(candidate["aoi_new_area_ratio"])
if candidate.get("aoi_new_area_ratio") is not None
else None
),
aoi_pair_area_ratio=(
float(candidate["aoi_pair_area_ratio"])
if candidate.get("aoi_pair_area_ratio") is not None
else None
),
aoi_coverage_ratio_after=(
float(candidate["aoi_coverage_ratio_after"])
if candidate.get("aoi_coverage_ratio_after") is not None
else None
),
effective_coverage_geojson=candidate.get("effective_coverage_geojson"),
time_baseline_days=int(candidate["days"]), time_baseline_days=int(candidate["days"]),
spatial_baseline_meters=float(candidate["dist"]), spatial_baseline_meters=float(candidate["dist"]),
scene_center_distance_meters=float( scene_center_distance_meters=float(
@@ -701,6 +830,253 @@ class SpatialService:
output.append(item) output.append(item)
return output return output
def _apply_province_coverage_strategy(
self,
candidate_pool: List[dict],
*,
target_start: datetime,
target_end: datetime,
query_start: datetime,
query_end: datetime,
max_pairs: int,
target_coverage_ratio: float,
min_new_coverage_ratio: float,
aoi_wkt: Optional[str],
) -> Tuple[List[dict], Dict[str, Any], List[str]]:
warnings: List[str] = []
target_days = self._date_set(target_start, target_end)
covered_days: set[datetime] = set()
selected: List[dict] = []
remaining = [candidate for candidate in candidate_pool if self._candidate_date_window(candidate) is not None]
seen_metric_ids: set[int] = set()
aoi_poly = self._parse_optional_aoi_polygon(aoi_wkt)
geometry_cache: Dict[int, Any] = {}
selected_coverage = Polygon()
aoi_area = float(aoi_poly.area or 0.0) if aoi_poly is not None else 0.0
stop_reason = "no_more_gain"
max_pairs_reached = False
while remaining and len(selected) < max_pairs:
best_candidate = None
best_score: Optional[Tuple[float, float, float, float, float, float, str]] = None
current_aoi_coverage_ratio = (
float(selected_coverage.area or 0.0) / aoi_area
if aoi_poly is not None and aoi_area > 0
else None
)
if current_aoi_coverage_ratio is not None and current_aoi_coverage_ratio >= target_coverage_ratio:
stop_reason = "target_coverage_reached"
break
for candidate in remaining:
window = self._candidate_date_window(candidate)
if window is None:
continue
candidate_days = self._date_set(*window) & target_days
new_days = candidate_days - covered_days
new_area = 0.0
candidate_area = 0.0
if aoi_poly is not None:
candidate_geom = self._get_candidate_intersection_geom(
candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
)
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
if aoi_poly is not None:
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if new_area_ratio <= 0:
continue
elif not new_days:
continue
quality_score = float(candidate.get("dinsar_quality_score") or 0.0)
overlap = float(candidate.get("overlap_ratio") or 0.0)
temporal_days = float(candidate.get("days") or 0.0)
distance = float(candidate.get("scene_center_distance_meters") or candidate.get("dist") or 0.0)
if aoi_poly is not None and aoi_area > 0:
score = (
new_area_ratio,
candidate_area / aoi_area,
float(len(new_days)) / max(1, len(target_days)),
overlap,
quality_score,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
else:
score = (
float(len(new_days)),
float(len(candidate_days)),
overlap,
quality_score,
0.0,
-temporal_days - (distance / 1000000.0),
str(candidate.get("pair_uid") or ""),
)
if best_score is None or score > best_score:
best_candidate = candidate
best_score = score
if best_candidate is None:
stop_reason = "no_candidate_adds_coverage"
break
if aoi_poly is None and covered_days == target_days:
stop_reason = "target_time_reached"
break
window = self._candidate_date_window(best_candidate)
candidate_days = self._date_set(*window) & target_days if window else set()
new_days = candidate_days - covered_days
candidate_geom = self._get_candidate_intersection_geom(
best_candidate,
aoi_poly=aoi_poly,
geometry_cache=geometry_cache,
) if aoi_poly is not None else None
new_area = 0.0
candidate_area = 0.0
if candidate_geom is not None and not candidate_geom.is_empty:
candidate_area = float(candidate_geom.area or 0.0)
new_area = float(candidate_geom.difference(selected_coverage).area or 0.0)
new_area_ratio = (new_area / aoi_area) if aoi_area > 0 else 0.0
if aoi_poly is not None and len(selected) > 0 and new_area_ratio < min_new_coverage_ratio:
stop_reason = "marginal_gain_below_threshold"
break
best_candidate["selection_reason"] = "province_coverage_new_days"
if aoi_poly is not None and new_area > 0:
best_candidate["selection_reason"] = "province_aoi_new_area"
best_candidate["selection_score"] = float(best_score[0] if best_score else len(new_days))
best_candidate["coverage_rank"] = len(selected) + 1
best_candidate["coverage_new_days"] = len(new_days)
best_candidate["coverage_total_days"] = len(candidate_days)
best_candidate["aoi_new_area_ratio"] = new_area_ratio if aoi_area > 0 else None
best_candidate["aoi_pair_area_ratio"] = (candidate_area / aoi_area) if aoi_area > 0 else None
next_selected_coverage = selected_coverage
if candidate_geom is not None and not candidate_geom.is_empty:
next_selected_coverage = unary_union([selected_coverage, candidate_geom])
best_candidate["aoi_coverage_ratio_after"] = (
float(next_selected_coverage.area or 0.0) / aoi_area
if aoi_area > 0 else None
)
best_candidate["effective_coverage_geojson"] = self._geometry_to_geojson(candidate_geom)
best_candidate["target_coverage_ratio_after"] = (
len(covered_days | candidate_days) / max(1, len(target_days))
)
selected.append(best_candidate)
covered_days |= candidate_days
selected_coverage = next_selected_coverage
seen_metric_ids.add(int(best_candidate.get("metric_cache_ref_id") or 0))
remaining = [
candidate for candidate in remaining
if int(candidate.get("metric_cache_ref_id") or 0) not in seen_metric_ids
]
if remaining and len(selected) >= max_pairs:
max_pairs_reached = True
stop_reason = "max_pairs_reached"
uncovered_ranges = self._date_ranges_from_days(target_days - covered_days)
if uncovered_ranges:
warnings.append(
"Coverage plan did not fully cover the requested time range. "
f"Uncovered ranges: {', '.join(f'{item[0]}~{item[1]}' for item in uncovered_ranges[:6])}"
)
if max_pairs_reached:
warnings.append(
f"Coverage planning reached task limit max_pairs={max_pairs}; increase the limit for better spatial coverage."
)
selected_area = float(selected_coverage.area or 0.0) if aoi_poly is not None else 0.0
aoi_coverage_ratio = (selected_area / aoi_area) if aoi_area > 0 else None
if aoi_coverage_ratio is not None and aoi_coverage_ratio < target_coverage_ratio:
warnings.append(
f"AOI spatial coverage is {aoi_coverage_ratio:.1%}; target is {target_coverage_ratio:.1%}."
)
temporal_ratio = len(covered_days) / max(1, len(target_days))
coverage_meta = {
"strategy": "dinsar_province_coverage",
"optimization_goal": "minimize_pair_count_for_aoi_spatial_coverage",
"greedy_rule": "select_pair_with_largest_new_aoi_intersection_area_each_step",
"stop_reason": stop_reason,
"max_pairs": max_pairs,
"target_coverage_ratio": round(target_coverage_ratio, 6),
"min_new_coverage_ratio": round(min_new_coverage_ratio, 6),
"coverage_basis": "aoi_spatial" if aoi_coverage_ratio is not None else "temporal",
"target_date_from": self._format_yyyymmdd(target_start),
"target_date_to": self._format_yyyymmdd(target_end),
"query_date_from": self._format_yyyymmdd(query_start),
"query_date_to": self._format_yyyymmdd(query_end),
"target_day_count": len(target_days),
"covered_day_count": len(covered_days),
"temporal_coverage_ratio": round(temporal_ratio, 6),
"aoi_coverage_ratio": round(aoi_coverage_ratio, 6) if aoi_coverage_ratio is not None else None,
"coverage_ratio": round(aoi_coverage_ratio if aoi_coverage_ratio is not None else temporal_ratio, 6),
"uncovered_ranges": [
{"date_from": start, "date_to": end}
for start, end in uncovered_ranges
],
"selected_pair_count": len(selected),
"candidate_count": len(candidate_pool),
}
return selected, coverage_meta, warnings
def _candidate_date_window(self, candidate: dict) -> Optional[Tuple[datetime, datetime]]:
master = candidate.get("master")
slave = candidate.get("slave")
master_date = self._try_parse_yyyymmdd(getattr(master, "imaging_date", None))
slave_date = self._try_parse_yyyymmdd(getattr(slave, "imaging_date", None))
if master_date is None or slave_date is None:
return None
return (master_date, slave_date) if master_date <= slave_date else (slave_date, master_date)
def _sort_coverage_candidates(self, candidates: List[dict]) -> List[dict]:
return sorted(
candidates,
key=lambda item: (
str(getattr(item.get("master"), "imaging_date", "") or ""),
str(getattr(item.get("slave"), "imaging_date", "") or ""),
-float(item.get("selection_score") or 0.0),
str(item.get("pair_uid") or ""),
),
)
def _date_set(self, start: datetime, end: datetime) -> set[datetime]:
if end < start:
return set()
return {start + timedelta(days=offset) for offset in range((end - start).days + 1)}
def _date_ranges_from_days(self, days: set[datetime]) -> List[Tuple[str, str]]:
if not days:
return []
ordered = sorted(days)
ranges: List[Tuple[datetime, datetime]] = []
start = previous = ordered[0]
for day in ordered[1:]:
if day == previous + timedelta(days=1):
previous = day
continue
ranges.append((start, previous))
start = previous = day
ranges.append((start, previous))
return [(self._format_yyyymmdd(start), self._format_yyyymmdd(end)) for start, end in ranges]
def _parse_yyyymmdd(self, value: str, *, field_name: str) -> datetime:
parsed = self._try_parse_yyyymmdd(value)
if parsed is None:
raise ValueError(f"{field_name} must be YYYYMMDD.")
return parsed
def _try_parse_yyyymmdd(self, value: Any) -> Optional[datetime]:
text_value = str(value or "").strip()
if not re.match(r"^\d{8}$", text_value):
return None
try:
return datetime.strptime(text_value, "%Y%m%d")
except ValueError:
return None
def _format_yyyymmdd(self, value: datetime) -> str:
return value.strftime("%Y%m%d")
async def _persist_network_run( async def _persist_network_run(
self, self,
db: AsyncSession, db: AsyncSession,
@@ -797,6 +1173,13 @@ class SpatialService:
if candidate.get("pair_aoi_overlap_ratio") is not None if candidate.get("pair_aoi_overlap_ratio") is not None
else None else None
), ),
"coverage_rank": candidate.get("coverage_rank"),
"coverage_new_days": candidate.get("coverage_new_days"),
"coverage_total_days": candidate.get("coverage_total_days"),
"aoi_new_area_ratio": candidate.get("aoi_new_area_ratio"),
"aoi_pair_area_ratio": candidate.get("aoi_pair_area_ratio"),
"aoi_coverage_ratio_after": candidate.get("aoi_coverage_ratio_after"),
"target_coverage_ratio_after": candidate.get("target_coverage_ratio_after"),
} }
def _stable_sha1(self, value: Any) -> str: def _stable_sha1(self, value: Any) -> str:
@@ -949,7 +1332,12 @@ class SpatialService:
warnings.extend(strategy_warnings) warnings.extend(strategy_warnings)
edges: List[Dict[str, Any]] = [] edges: List[Dict[str, Any]] = []
for edge_rank, candidate in enumerate(self._sorted_candidates(selected_candidates), start=1): ordered_candidates = (
selected_candidates
if params.strategy == "dinsar_province_coverage"
else self._sorted_candidates(selected_candidates)
)
for edge_rank, candidate in enumerate(ordered_candidates, start=1):
master = candidate["master"] master = candidate["master"]
slave = candidate["slave"] slave = candidate["slave"]
edges.append( edges.append(
@@ -1820,9 +2208,14 @@ class SpatialService:
return geometry_cache[cache_key] return geometry_cache[cache_key]
try: try:
master_poly = Polygon(candidate["master"].coverage_polygon) master_poly = self._coverage_polygon_to_shape(getattr(candidate["master"], "coverage_polygon", None))
slave_poly = Polygon(candidate["slave"].coverage_polygon) slave_poly = self._coverage_polygon_to_shape(getattr(candidate["slave"], "coverage_polygon", None))
if master_poly.is_empty or slave_poly.is_empty: if (
master_poly is None
or slave_poly is None
or master_poly.is_empty
or slave_poly.is_empty
):
geometry_cache[cache_key] = None geometry_cache[cache_key] = None
return None return None
pair_geom = master_poly.intersection(slave_poly) pair_geom = master_poly.intersection(slave_poly)
@@ -1834,6 +2227,44 @@ class SpatialService:
geometry_cache[cache_key] = None geometry_cache[cache_key] = None
return None return None
def _coverage_polygon_to_shape(self, coverage_polygon: Any):
if not coverage_polygon:
return None
try:
if isinstance(coverage_polygon, dict):
if coverage_polygon.get("type") == "Feature":
geometry = coverage_polygon.get("geometry")
if not geometry:
return None
geom = shape(geometry)
else:
geom = shape(coverage_polygon)
elif isinstance(coverage_polygon, list):
points = []
for point in coverage_polygon:
if isinstance(point, (list, tuple)) and len(point) >= 2:
lon = float(point[0])
lat = float(point[1])
points.append((lon, lat))
if len(points) < 3:
return None
geom = Polygon(points)
else:
return None
if geom.is_empty or not geom.is_valid:
return None
return geom
except Exception:
return None
def _geometry_to_geojson(self, geometry: Any) -> Optional[Dict[str, Any]]:
try:
if geometry is None or geometry.is_empty:
return None
return mapping(geometry)
except Exception:
return None
def _score_sbas_candidate( def _score_sbas_candidate(
self, self,
candidate: dict, candidate: dict,
@@ -0,0 +1,63 @@
# D-InSAR Province Coverage Pairing Design
Date: 2026-07-06
## Goal
Provide a planning mode for near-term province-wide D-InSAR delivery. The user enters a target production time range as the scene pool and selects a province/AOI. The system builds standard D-InSAR candidate pairs, then greedily selects pairs that add the most new AOI spatial coverage. The time range is a freshness/pool constraint, not the primary coverage objective.
## Scope
- Reuse `pairing_metric_cache` as the only candidate source.
- Keep the existing standard D-InSAR thresholds: temporal baseline, overlap, center distance, same mode, same polarization, same family, orbit availability.
- Add a planning endpoint that returns normal `RadarPair` rows, so existing pair list, batch save, copy, and production flow remain unchanged.
- Store the selected network through `pairing_network_runs` / `pairing_network_edges` for traceability.
## Greedy Policy
1. Build candidate windows from `master_imaging_date` to `slave_imaging_date`.
2. Start with the requested target window.
3. Query candidates in the target pool plus `extension_days`.
4. When an AOI is available, prefer candidates that add new uncovered AOI area.
5. Use target-window temporal coverage as a secondary score and diagnostic.
6. Score each candidate:
- new AOI area covered
- total AOI area covered by the pair
- new target days covered
- existing target coverage overlap
- scene overlap ratio
- D-InSAR quality score
- shorter temporal baseline and smaller center distance as tie-breakers
7. Select the best edge, mark covered AOI area and days, then repeat until:
- AOI area has no meaningful new coverage candidates
- no candidate adds coverage
- `max_pairs` is reached
## Coverage Semantics
Coverage here means AOI spatial coverage when an AOI is supplied. Temporal coverage is reported separately to show whether the selected pairs span the requested production window. For province-wide use, the user must select the province AOI and set an AOI overlap threshold appropriate for production planning.
## API
`POST /pairing/coverage-plan`
Form fields:
- `target_date_from`, `target_date_to`: required `YYYYMMDD`
- standard pairing fields: `time_baseline_min`, `time_baseline_max`, `overlap_threshold`, `spatial_baseline_max_meters`, `aoi_overlap_threshold`, `allowed_satellites`
- `extension_days`: default 15
- `max_pairs`: default 200
- `require_orbit_data`: default true
- `files` or `aoi_geojson`: optional AOI, same as `/find-pairs`
Response:
- `pairs`: selected `RadarPair` rows
- `coverage`: requested range, effective query range, covered days, coverage ratio, uncovered ranges
- `warnings`, `network_run_id`, `candidate_count`, `selected_edge_count`
## Notes
- This is a planning tool, not a new production engine.
- Existing batch splitting remains the operational control for producing 100-by-100 or any user-selected batch size.
- Produced pairs should still be filtered in the pair list by existing result status indicators before batch creation.
+496 -12
View File
@@ -1265,6 +1265,7 @@ input[type="checkbox"] {
.list-toolbar { .list-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px; gap: 8px;
padding: 8px 15px; padding: 8px 15px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@@ -1272,6 +1273,31 @@ input[type="checkbox"] {
font-size: 12px; font-size: 12px;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.list-toolbar-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.list-toolbar-main strong {
color: var(--color-text-primary);
font-size: 13px;
}
.list-toolbar-main span {
color: var(--color-text-muted);
line-height: 1.4;
}
.select-all-pairs-control {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
color: var(--color-text-primary);
font-weight: 650;
}
.select-all-pairs-control input {
margin: 0;
}
.list-toolbar.column-layout { .list-toolbar.column-layout {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -2007,6 +2033,10 @@ input[type="checkbox"] {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.statistics-section--orbit {
grid-column: span 2;
}
.statistics-bottom-grid { .statistics-bottom-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -2107,6 +2137,18 @@ input[type="checkbox"] {
font-size: 17px; font-size: 17px;
} }
.statistics-orbit-layout {
display: grid;
grid-template-columns: minmax(260px, 0.72fr) minmax(360px, 1.28fr);
gap: 12px;
align-items: start;
}
.statistics-orbit-overview {
display: grid;
gap: 10px;
}
.statistics-mini-bars { .statistics-mini-bars {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -2130,6 +2172,180 @@ input[type="checkbox"] {
background: #16a34a; background: #16a34a;
} }
.statistics-orbit-gap {
border: 1px solid #fed7aa;
border-radius: 6px;
background: #fff7ed;
padding: 10px;
min-width: 0;
}
.statistics-orbit-gap--ok {
border-color: #bbf7d0;
background: #f0fdf4;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.statistics-orbit-gap--ok strong {
color: #166534;
font-size: 13px;
}
.statistics-orbit-gap--ok span {
color: #166534;
font-size: 12px;
font-weight: 700;
}
.statistics-orbit-gap-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding-bottom: 9px;
border-bottom: 1px solid #fed7aa;
}
.statistics-orbit-gap-head strong {
display: block;
color: #7c2d12;
font-size: 13px;
line-height: 1.35;
}
.statistics-orbit-gap-head span {
display: block;
margin-top: 3px;
color: #9a3412;
font-size: 12px;
line-height: 1.45;
font-weight: 700;
}
.statistics-orbit-gap-metrics {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.statistics-orbit-gap-metrics div {
min-width: 94px;
border: 1px solid #fdba74;
border-radius: 6px;
background: #ffffff;
padding: 6px 8px;
text-align: right;
}
.statistics-orbit-gap-metrics span {
margin: 0;
color: #9a3412;
font-size: 11px;
}
.statistics-orbit-gap-metrics b {
display: block;
margin-top: 2px;
color: #7c2d12;
font-size: 16px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.statistics-orbit-gap-list {
display: grid;
gap: 9px;
max-height: 310px;
overflow: auto;
padding: 9px 2px 0 0;
}
.statistics-orbit-gap-satellite {
border: 1px solid #fde68a;
border-radius: 6px;
background: #ffffff;
padding: 8px;
min-width: 0;
}
.statistics-orbit-gap-satellite-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
margin-bottom: 7px;
}
.statistics-orbit-gap-satellite-head strong {
color: #0f172a;
font-size: 13px;
}
.statistics-orbit-gap-satellite-head span {
color: #475569;
font-size: 12px;
font-weight: 700;
text-align: right;
}
.statistics-orbit-month-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
gap: 6px;
margin-bottom: 8px;
}
.statistics-orbit-month-grid span {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 5px;
align-items: center;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
padding: 5px 6px;
color: #334155;
font-size: 11px;
font-weight: 800;
}
.statistics-orbit-month-grid b,
.statistics-orbit-month-grid em {
color: #7c2d12;
font-style: normal;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.statistics-orbit-date-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.statistics-orbit-date-row span {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #fed7aa;
border-radius: 999px;
background: #fff7ed;
color: #7c2d12;
font-size: 11px;
line-height: 1;
font-weight: 800;
padding: 5px 7px;
}
.statistics-orbit-date-row b {
color: #9a3412;
font-variant-numeric: tabular-nums;
}
.statistics-run-list, .statistics-run-list,
.statistics-inventory-list { .statistics-inventory-list {
display: grid; display: grid;
@@ -2476,11 +2692,17 @@ input[type="checkbox"] {
@media (max-width: 980px) { @media (max-width: 980px) {
.statistics-kpi-grid, .statistics-kpi-grid,
.statistics-grid, .statistics-command-grid,
.statistics-plan-grid { .statistics-dashboard-grid,
.statistics-bottom-grid,
.statistics-orbit-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.statistics-section--orbit {
grid-column: auto;
}
.statistics-header { .statistics-header {
flex-direction: column; flex-direction: column;
} }
@@ -4011,6 +4233,257 @@ input[type="checkbox"] {
margin-top: 6px; margin-top: 6px;
} }
.province-coverage-card {
display: flex;
flex-direction: column;
gap: 10px;
}
.coverage-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
}
.coverage-form-grid label {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.coverage-form-grid input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 7px 8px;
color: var(--color-text-primary);
background: #fff;
}
.coverage-aoi-panel {
display: flex;
flex-direction: column;
gap: 9px;
padding: 10px 12px;
border: 1px solid #d8dee8;
border-radius: 8px;
background: #f8fafc;
}
.coverage-aoi-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: var(--color-text-primary);
}
.coverage-aoi-header strong {
font-size: 13px;
line-height: 1.4;
}
.coverage-aoi-header span {
max-width: 56ch;
color: var(--color-text-muted);
font-size: 12px;
line-height: 1.45;
text-align: right;
}
.coverage-aoi-mode-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.coverage-aoi-mode {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 5px 9px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
color: #334155;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.coverage-aoi-mode input {
margin: 0;
}
.coverage-aoi-mode.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.coverage-aoi-selects {
display: grid;
grid-template-columns: repeat(2, minmax(160px, 1fr));
gap: 10px;
}
.coverage-aoi-selects label,
.coverage-aoi-file {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
.coverage-aoi-selects select,
.coverage-aoi-file input {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 7px 8px;
color: var(--color-text-primary);
background: #fff;
}
.coverage-aoi-note {
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.coverage-aoi-error {
padding: 7px 9px;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fef2f2;
color: #991b1b;
font-size: 12px;
line-height: 1.45;
}
.coverage-action-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.coverage-action-row button {
min-width: 132px;
}
.coverage-action-row span {
font-size: 12px;
color: var(--color-text-muted);
}
.coverage-result {
display: flex;
flex-direction: column;
gap: 3px;
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.45;
}
.coverage-result.ready {
border: 1px solid #bae6fd;
background: #f0f9ff;
color: #075985;
}
.coverage-result.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.coverage-result-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 7px;
}
.coverage-result-actions button {
border-color: #2563eb;
background: #2563eb;
color: #fff;
font-size: 12px;
font-weight: 700;
box-shadow: none;
}
.coverage-result-actions button:hover:not(:disabled) {
border-color: #1d4ed8;
background: #1d4ed8;
box-shadow: none;
}
.coverage-map-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
color: var(--color-text-secondary);
font-size: 12px;
}
.coverage-map-toolbar strong {
color: var(--color-text-primary);
font-size: 13px;
}
.coverage-map-toolbar span {
flex: 1 1 260px;
min-width: 0;
}
.coverage-map-toolbar button {
padding: 5px 9px;
font-size: 12px;
box-shadow: none;
}
.coverage-map-toolbar button.active {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
@media (max-width: 760px) {
.coverage-form-grid,
.coverage-aoi-selects {
grid-template-columns: 1fr;
}
.coverage-aoi-header {
flex-direction: column;
gap: 4px;
}
.coverage-aoi-header span {
max-width: none;
text-align: left;
}
}
.asset-inventory-panel { .asset-inventory-panel {
width: 100%; width: 100%;
max-width: 1280px; max-width: 1280px;
@@ -6177,6 +6650,17 @@ input[type="checkbox"] {
padding: 0; padding: 0;
} }
.production-workspace-shell .dinsar-products-hero,
.production-workspace-shell .dinsar-products-section-head,
.production-workspace-shell .dinsar-products-top-grid,
.production-workspace-shell .dinsar-products-catalog-section,
.production-workspace-shell .dinsar-products-catalog-section .dinsar-catalog-shell,
.production-workspace-shell .dinsar-products-catalog-section .dinsar-catalog-workspace {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.dinsar-production-header { .dinsar-production-header {
display: grid; display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr); grid-template-columns: minmax(280px, 1fr) minmax(420px, 0.95fr);
@@ -6877,7 +7361,7 @@ input[type="checkbox"] {
.dinsar-products-page { .dinsar-products-page {
width: 100%; width: 100%;
max-width: 1480px; max-width: none;
margin: 0 auto; margin: 0 auto;
padding: 6px 0 24px; padding: 6px 0 24px;
display: grid; display: grid;
@@ -6885,12 +7369,12 @@ input[type="checkbox"] {
} }
.panel--standalone .dinsar-products-page { .panel--standalone .dinsar-products-page {
max-width: 1480px; max-width: none;
} }
.dinsar-products-hero { .dinsar-products-hero {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) minmax(360px, 0.42fr);
gap: 18px; gap: 18px;
align-items: flex-start; align-items: flex-start;
padding: 16px 18px; padding: 16px 18px;
@@ -6907,7 +7391,7 @@ input[type="checkbox"] {
} }
.dinsar-products-hero p { .dinsar-products-hero p {
max-width: 72ch; max-width: 96ch;
margin: 8px 0 0; margin: 8px 0 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 13px; font-size: 13px;
@@ -6916,9 +7400,9 @@ input[type="checkbox"] {
.dinsar-products-signals { .dinsar-products-signals {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(116px, 1fr)); grid-template-columns: repeat(4, minmax(112px, 1fr));
gap: 8px; gap: 8px;
min-width: 280px; min-width: 0;
} }
.dinsar-products-section-head { .dinsar-products-section-head {
@@ -6946,7 +7430,7 @@ input[type="checkbox"] {
.dinsar-products-top-grid { .dinsar-products-top-grid {
display: grid; display: grid;
grid-template-columns: minmax(420px, 0.95fr) minmax(460px, 1.05fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px; gap: 16px;
align-items: start; align-items: start;
} }
@@ -7064,7 +7548,7 @@ input[type="checkbox"] {
} }
.dinsar-products-catalog-section .dinsar-catalog-workspace { .dinsar-products-catalog-section .dinsar-catalog-workspace {
grid-template-columns: minmax(380px, 0.36fr) minmax(0, 1fr); grid-template-columns: minmax(420px, 0.34fr) minmax(0, 1fr);
} }
.sbas-products-page { .sbas-products-page {
@@ -7848,7 +8332,7 @@ input[type="checkbox"] {
} }
} }
@media (max-width: 1500px) { @media (max-width: 1100px) {
.panel--standalone .dinsar-products-top-grid { .panel--standalone .dinsar-products-top-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+10 -4
View File
@@ -299,15 +299,18 @@ function safeCount(value) {
function getRunCounts(run) { function getRunCounts(run) {
const completed = safeCount(run?.completed_items); const completed = safeCount(run?.completed_items);
const running = safeCount(run?.running_items);
const pending = safeCount(run?.pending_items);
const failed = safeCount(run?.failed_items); const failed = safeCount(run?.failed_items);
const skipped = safeCount(run?.skipped_items); const skipped = safeCount(run?.skipped_items);
const cancelled = safeCount(run?.cancelled_items);
const total = safeCount(run?.total_items); const total = safeCount(run?.total_items);
return { completed, failed, skipped, total }; return { completed, running, pending, failed, skipped, cancelled, total };
} }
function hasRunCounts(run) { function hasRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run); const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
return [completed, failed, skipped, total].some(value => value != null); return [completed, running, pending, failed, skipped, cancelled, total].some(value => value != null);
} }
function hasMixedRunOutcome(run) { function hasMixedRunOutcome(run) {
@@ -345,11 +348,14 @@ function getDisplayTaskStatus(task, relatedRun = null) {
} }
function formatRunCounts(run) { function formatRunCounts(run) {
const { completed, failed, skipped, total } = getRunCounts(run); const { completed, running, pending, failed, skipped, cancelled, total } = getRunCounts(run);
const parts = []; const parts = [];
if (running != null && running > 0) parts.push(`运行中 ${running}`);
if (completed != null) parts.push(`成功 ${completed}`); if (completed != null) parts.push(`成功 ${completed}`);
if (failed != null) parts.push(`失败 ${failed}`); if (failed != null) parts.push(`失败 ${failed}`);
if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`); if (skipped != null && skipped > 0) parts.push(`跳过 ${skipped}`);
if (cancelled != null && cancelled > 0) parts.push(`取消 ${cancelled}`);
if (pending != null && pending > 0) parts.push(`待处理 ${pending}`);
if (total != null) parts.push(`总数 ${total}`); if (total != null) parts.push(`总数 ${total}`);
return parts.join(' / '); return parts.join(' / ');
} }
+7 -9
View File
@@ -7,7 +7,6 @@ import { syncWaterScenesFromDisk } from './api/water';
import { listEngines, runWslCheck } from './api/dinsarProduction'; import { listEngines, runWslCheck } from './api/dinsarProduction';
import { getOrbitStatus, syncOrbitPools } from './api/orbit'; import { getOrbitStatus, syncOrbitPools } from './api/orbit';
import LogManagementPanel from './LogManagementPanel'; import LogManagementPanel from './LogManagementPanel';
import OpsTaskMaintenancePanel from './OpsTaskMaintenancePanel';
const toNumber = (value) => { const toNumber = (value) => {
const parsed = Number(value); const parsed = Number(value);
@@ -129,7 +128,7 @@ const formatSourceRootRole = (role, en = false) => {
const buildConsistencySummary = (stats, en = false) => { const buildConsistencySummary = (stats, en = false) => {
const dinsar = stats?.dinsar_cache_consistency || {}; const dinsar = stats?.dinsar_cache_consistency || {};
const preview = stats?.source_preview_consistency || {}; const preview = stats?.source_preview_consistency || {};
const xml = stats?.source_xml_consistency || {}; const metadata = stats?.source_metadata_consistency || {};
const water = stats?.water_geo_consistency || {}; const water = stats?.water_geo_consistency || {};
const issues = [ const issues = [
@@ -164,15 +163,15 @@ const buildConsistencySummary = (stats, en = false) => {
level: 'warn', level: 'warn',
}, },
{ {
key: 'xml_unparsed', key: 'metadata_parse_issue',
label: en ? 'Source: XML detected but key fields not imported' : '源影像:检测到 XML 但关键字段未入库', label: en ? 'Source: metadata document parse issues' : '源影像:元数据文档解析异常',
count: toNumber(xml.xml_detected_but_unparsed_count), count: toNumber(metadata.metadata_detected_but_unparsed_count),
level: 'warn', level: 'warn',
}, },
{ {
key: 'xml_missing', key: 'metadata_missing',
label: en ? 'Source: XML not detected' : '源影像:未检测到 XML', label: en ? 'Source: metadata document not registered' : '源影像:元数据文档未入库',
count: toNumber(xml.xml_missing_count), count: toNumber(metadata.metadata_document_missing_count),
level: 'warn', level: 'warn',
}, },
{ {
@@ -1508,7 +1507,6 @@ const HealthCheckPanel = ({ currentUser }) => {
)} )}
{/* 日志管理 */} {/* 日志管理 */}
<OpsTaskMaintenancePanel isAdmin={isAdmin} />
<LogManagementPanel isAdmin={isAdmin} /> <LogManagementPanel isAdmin={isAdmin} />
</div> </div>
</section> </section>
+20 -34
View File
@@ -18,6 +18,7 @@ const TASK_TYPES = [
const STATUSES = [ const STATUSES = [
['', '全部状态'], ['', '全部状态'],
['COMPLETED', '已完成'],
['FAILED', '失败'], ['FAILED', '失败'],
['PARTIAL_SUCCESS', '部分成功'], ['PARTIAL_SUCCESS', '部分成功'],
['CANCELLED', '已取消'], ['CANCELLED', '已取消'],
@@ -93,7 +94,7 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [preview, setPreview] = useState(null); const [preview, setPreview] = useState(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false); const [workCleanupLoading, setWorkCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState(null); const [cleanupResult, setCleanupResult] = useState(null);
const [panelMessage, setPanelMessage] = useState(null); const [panelMessage, setPanelMessage] = useState(null);
@@ -154,39 +155,30 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
} }
}, [selectedTaskId]); }, [selectedTaskId]);
const executeCleanup = useCallback(async () => { const executeLandsarWorkCleanup = useCallback(async () => {
if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return; if (!selectedTaskId || !preview || preview.blocked || !isAdmin) return;
setCleanupLoading(true); setWorkCleanupLoading(true);
setCleanupResult(null); setCleanupResult(null);
try { try {
const data = await cleanupMaintenanceTask(selectedTaskId, { const data = await cleanupMaintenanceTask(selectedTaskId, {
confirm: true, confirm: true,
delete_task_records: true, delete_landsar_work_dir: true,
delete_logs: true,
delete_production_records: true,
delete_result_products: true,
delete_production_dirs: true,
delete_task_pool_dir: true,
});
setCleanupResult({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
});
setPanelMessage({
ok: true,
message: `清理完成:数据库 ${Object.values(data.deleted_database || {}).reduce((sum, value) => sum + Number(value || 0), 0)} 条,目录 ${(data.deleted_disk?.deleted || []).length} 个。`,
}); });
const deletedCount = (data.deleted_disk?.deleted || []).length;
const message = `LandSAR 工作目录清理完成:目录 ${deletedCount} 个。`;
setCleanupResult({ ok: true, message });
setPanelMessage({ ok: true, message });
setPreview(null); setPreview(null);
setDiagnosis(null); await loadDiagnosis(selectedTaskId);
setSelectedTaskId('');
await loadTasks(); await loadTasks();
} catch (err) { } catch (err) {
setCleanupResult({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' }); const message = err.response?.data?.detail || err.message || 'LandSAR 工作目录清理失败';
setPanelMessage({ ok: false, message: err.response?.data?.detail || err.message || '清理失败' }); setCleanupResult({ ok: false, message });
setPanelMessage({ ok: false, message });
} finally { } finally {
setCleanupLoading(false); setWorkCleanupLoading(false);
} }
}, [isAdmin, loadTasks, preview, selectedTaskId]); }, [isAdmin, loadDiagnosis, loadTasks, preview, selectedTaskId]);
const abnormalCount = tasks.length; const abnormalCount = tasks.length;
const cleanableCount = tasks.filter(item => item.cleanup_supported).length; const cleanableCount = tasks.filter(item => item.cleanup_supported).length;
@@ -331,24 +323,18 @@ const OpsTaskMaintenancePanel = ({ isAdmin }) => {
{preview.blockers?.join('') || '当前任务不允许清理。'} {preview.blockers?.join('') || '当前任务不允许清理。'}
</div> </div>
)} )}
<div className="ops-task-two-col">
<div> <div>
<div className="ops-task-section-title">数据库记录</div> <div className="ops-task-section-title">LANDSAR_WORK_ROOT</div>
<DatabasePreview counts={preview.database_deletes} />
</div>
<div>
<div className="ops-task-section-title">磁盘路径</div>
<DiskPreview paths={preview.disk_deletes} /> <DiskPreview paths={preview.disk_deletes} />
</div> </div>
</div>
<div className="ops-task-actions"> <div className="ops-task-actions">
{!isAdmin && <span className="ops-task-muted">仅管理员可执行清理</span>} {!isAdmin && <span className="ops-task-muted">仅管理员可执行清理</span>}
<button <button
className="health-action-button danger" className="health-inline-button"
onClick={executeCleanup} onClick={executeLandsarWorkCleanup}
disabled={!isAdmin || cleanupLoading || preview.blocked} disabled={!isAdmin || workCleanupLoading || preview.blocked}
> >
{cleanupLoading ? '清理中...' : '确认清理'} {workCleanupLoading ? '清理中...' : '清理 LandSAR 工作目录'}
</button> </button>
</div> </div>
</div> </div>
+1
View File
@@ -217,6 +217,7 @@ export default function ProductionWorkspace({
onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch} onRefreshRadarSearch={pairingPanel?.onRefreshRadarSearch}
onSearchAll={radarPanel?.onSearchAll} onSearchAll={radarPanel?.onSearchAll}
onRefreshDinsar={pairingPanel?.onRefreshDinsar} onRefreshDinsar={pairingPanel?.onRefreshDinsar}
onOpenPairsView={() => setActiveView('dinsar_pairs')}
language={language} language={language}
/> />
); );
+112 -6
View File
@@ -295,7 +295,11 @@ function buildPipelineOption(rows) {
axisPointer: { type: 'shadow' }, axisPointer: { type: 'shadow' },
formatter: (params) => { formatter: (params) => {
const item = params?.[0]?.data || {}; const item = params?.[0]?.data || {};
return `${item.label}<br/>数量:${formatNumber(item.value)}<br/>比例:${formatPercent(item.rate)}`; const denominator = Number(item.denominator);
const denominatorText = Number.isFinite(denominator) && denominator > 0
? `<br/>适用总数:${formatNumber(denominator)}`
: '';
return `${item.label}<br/>数量:${formatNumber(item.value)}${denominatorText}<br/>比例:${formatPercent(item.rate)}`;
}, },
}, },
xAxis: { xAxis: {
@@ -327,7 +331,13 @@ function buildPipelineOption(rows) {
show: true, show: true,
position: 'right', position: 'right',
color: '#475569', color: '#475569',
formatter: (params) => `${formatNumber(params.data.value)} / ${formatPercent(params.data.rate)}`, formatter: (params) => {
const denominator = Number(params.data.denominator);
const totalText = Number.isFinite(denominator) && denominator > 0
? `/${formatNumber(denominator)}`
: '';
return `${formatNumber(params.data.value)}${totalText} / ${formatPercent(params.data.rate)}`;
},
}, },
}, },
], ],
@@ -743,6 +753,87 @@ function FamilyLegend({ rows }) {
); );
} }
function formatYmd(value) {
const text = String(value || '').trim();
if (/^\d{8}$/.test(text)) {
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
return text || '-';
}
function MissingOrbitSummary({ summary }) {
const affectedSceneCount = Number(summary?.affected_scene_count || 0);
const missingDateCount = Number(summary?.missing_orbit_date_count || 0);
const bySatellite = Array.isArray(summary?.by_satellite) ? summary.by_satellite : [];
if (!affectedSceneCount) {
return (
<div className="statistics-orbit-gap statistics-orbit-gap--ok">
<strong>精轨缺口</strong>
<span>当前没有开放的缺精轨场景</span>
</div>
);
}
return (
<div className="statistics-orbit-gap">
<div className="statistics-orbit-gap-head">
<div>
<strong>精轨缺口说明</strong>
<span>缺轨按受影响影像景数统计补齐时按卫星日期准备 LT-1 TXT 精轨</span>
</div>
<div className="statistics-orbit-gap-metrics">
<div>
<span>受影响景数</span>
<b>{formatNumber(affectedSceneCount)}</b>
</div>
<div>
<span>缺失卫星日期</span>
<b>{formatNumber(missingDateCount)}</b>
</div>
</div>
</div>
<div className="statistics-orbit-gap-list">
{bySatellite.map((item) => {
const months = Array.isArray(item.months) ? item.months : [];
const dates = Array.isArray(item.dates) ? item.dates : [];
return (
<div className="statistics-orbit-gap-satellite" key={item.satellite}>
<div className="statistics-orbit-gap-satellite-head">
<strong>{item.satellite || 'UNKNOWN'}</strong>
<span>
{formatNumber(item.affected_scene_count)} / {formatNumber(item.missing_orbit_date_count)} 个日期
{item.first_missing_date && item.last_missing_date
? ` / ${formatYmd(item.first_missing_date)}${formatYmd(item.last_missing_date)}`
: ''}
</span>
</div>
<div className="statistics-orbit-month-grid">
{months.map((month) => (
<span key={`${item.satellite}-${month.month}`}>
{month.month}
<b>{formatNumber(month.affected_scene_count)}</b>
<em>{formatNumber(month.missing_orbit_date_count)}</em>
</span>
))}
</div>
<div className="statistics-orbit-date-row" aria-label={`${item.satellite} 缺失精轨日期`}>
{dates.map((date) => (
<span key={`${item.satellite}-${date.date}`} title={`${formatNumber(date.affected_scene_count)} 景受影响`}>
{formatYmd(date.date)}
<b>{formatNumber(date.affected_scene_count)}</b>
</span>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
function ProductionRunList({ rows }) { function ProductionRunList({ rows }) {
const data = Array.isArray(rows) ? rows : []; const data = Array.isArray(rows) ? rows : [];
if (!data.length) { if (!data.length) {
@@ -792,11 +883,17 @@ export default function StatisticsDashboard() {
const [lastLoadedAt, setLastLoadedAt] = useState(null); const [lastLoadedAt, setLastLoadedAt] = useState(null);
const [coverageMode, setCoverageMode] = useState('source'); const [coverageMode, setCoverageMode] = useState('source');
const loadDashboard = async () => { const loadDashboard = async ({ fresh = false } = {}) => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const payload = await getStatisticsDashboard(); let payload;
try {
payload = await getStatisticsDashboard(fresh);
} catch (err) {
if (!fresh) throw err;
payload = await getStatisticsDashboard(false);
}
setData(payload); setData(payload);
setLastLoadedAt(new Date()); setLastLoadedAt(new Date());
} catch (err) { } catch (err) {
@@ -875,7 +972,7 @@ export default function StatisticsDashboard() {
{data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''} {data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''}
</span> </span>
)} )}
<button type="button" onClick={loadDashboard} disabled={loading}> <button type="button" onClick={() => loadDashboard({ fresh: true })} disabled={loading}>
{loading ? '刷新中' : '刷新'} {loading ? '刷新中' : '刷新'}
</button> </button>
</div> </div>
@@ -948,7 +1045,13 @@ export default function StatisticsDashboard() {
<Chart option={sourceTrendOption} className="statistics-chart-md" /> <Chart option={sourceTrendOption} className="statistics-chart-md" />
</Section> </Section>
<Section title="精轨保障" subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中`}> <Section
title="精轨保障"
subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中,${formatNumber(data?.orbit?.missing_summary?.affected_scene_count || data?.orbit?.missing_scene_count)} 景待补`}
className="statistics-section--orbit"
>
<div className="statistics-orbit-layout">
<div className="statistics-orbit-overview">
<div className="statistics-orbit-summary"> <div className="statistics-orbit-summary">
<div> <div>
<span>精轨资产</span> <span>精轨资产</span>
@@ -972,6 +1075,9 @@ export default function StatisticsDashboard() {
</div> </div>
))} ))}
</div> </div>
</div>
<MissingOrbitSummary summary={data?.orbit?.missing_summary} />
</div>
</Section> </Section>
<Section title="D-InSAR 生产运行" subtitle={`平均耗时 ${formatDuration(data?.production?.avg_duration_seconds)}`}> <Section title="D-InSAR 生产运行" subtitle={`平均耗时 ${formatDuration(data?.production?.avg_duration_seconds)}`}>
+1
View File
@@ -1,6 +1,7 @@
import apiClient from './client'; import apiClient from './client';
export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data); export const findPairs = (formData) => apiClient.post('/find-pairs', formData).then(r => r.data);
export const buildCoveragePlan = (formData) => apiClient.post('/pairing/coverage-plan', formData).then(r => r.data);
export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data); export const findPsTimeseries = (formData) => apiClient.post('/find-ps-timeseries', formData).then(r => r.data);
export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data); export const getPairingHealth = () => apiClient.get('/pairing/health').then(r => r.data);
export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data); export const rebuildPairingCache = () => apiClient.post('/pairing/rebuild-cache').then(r => r.data);
+2 -2
View File
@@ -3,5 +3,5 @@ import apiClient from './client';
export const getStatistics = (fresh = false) => export const getStatistics = (fresh = false) =>
apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data); apiClient.get('/statistics', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
export const getStatisticsDashboard = () => export const getStatisticsDashboard = (fresh = false) =>
apiClient.get('/statistics/dashboard').then(r => r.data); apiClient.get('/statistics/dashboard', { params: fresh ? { fresh: true } : undefined }).then(r => r.data);
@@ -18,6 +18,7 @@ const LazyAssetInventoryPanel = lazy(() => import('../../AssetInventoryPanel'));
const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel')); const LazyIDLAutomationPanel = lazy(() => import('../../IDLAutomationPanel'));
const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel')); const LazyHazardPointPanel = lazy(() => import('../../HazardPointPanel'));
const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel')); const LazyHealthCheckPanel = lazy(() => import('../../HealthCheckPanel'));
const LazyOpsTaskMaintenancePanel = lazy(() => import('../../OpsTaskMaintenancePanel'));
const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace')); const LazyFloodAnalysisWorkspace = lazy(() => import('../../FloodAnalysisWorkspace'));
const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel')); const LazyUserAdminPanel = lazy(() => import('../../UserAdminPanel'));
const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel')); const LazyAuditLogPanel = lazy(() => import('../../AuditLogPanel'));
@@ -329,6 +330,20 @@ export default function AppSidePanel({
</div> </div>
)} )}
{leftPanelTab === 'ops_tasks' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? (
<Suspense fallback={<PanelLoadingBody message="正在加载任务维护面板..." />}>
<LazyOpsTaskMaintenancePanel isAdmin={isAdmin} />
</Suspense>
) : (
<div style={{ padding: '16px' }}>
<p className="empty-state">仅管理员可访问任务维护</p>
</div>
)}
</div>
)}
{leftPanelTab === 'users' && ( {leftPanelTab === 'users' && (
<div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}> <div className="panel-content" style={{ flex: '1 1 auto', padding: 0, overflow: 'auto' }}>
{isAdmin ? ( {isAdmin ? (
@@ -82,6 +82,7 @@ function PairListRow({
const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠'; const overlapLabel = pair.pair_aoi_overlap_ratio != null ? 'AOI覆盖' : '影像重叠';
const production = productionLabel(pair.production_summary); const production = productionLabel(pair.production_summary);
const quality = qualityLabel(pair); const quality = qualityLabel(pair);
const hasCoverageContribution = pair.aoi_new_area_ratio != null || pair.aoi_coverage_ratio_after != null;
const engines = Array.isArray(pair.production_summary?.engine_codes) const engines = Array.isArray(pair.production_summary?.engine_codes)
? pair.production_summary.engine_codes.filter(Boolean).join('/') ? pair.production_summary.engine_codes.filter(Boolean).join('/')
: ''; : '';
@@ -104,6 +105,13 @@ function PairListRow({
<span>时基: {pair.time_baseline_days}d</span> <span>时基: {pair.time_baseline_days}d</span>
<span>中心距: {formatDistance(centerDistance)}</span> <span>中心距: {formatDistance(centerDistance)}</span>
<span>{overlapLabel}: {formatPercent(overlap)}</span> <span>{overlapLabel}: {formatPercent(overlap)}</span>
{hasCoverageContribution && (
<span>
覆盖贡献: {pair.coverage_rank ? `#${pair.coverage_rank} ` : ''}
新增 {formatPercent(pair.aoi_new_area_ratio)}
{pair.aoi_coverage_ratio_after != null ? ` / 累计 ${formatPercent(pair.aoi_coverage_ratio_after)}` : ''}
</span>
)}
{quality && <span>D-InSAR: {quality}</span>} {quality && <span>D-InSAR: {quality}</span>}
</div> </div>
<div className="pair-status-line"> <div className="pair-status-line">
+2 -1
View File
@@ -221,7 +221,7 @@ export const LEFT_GROUP_TABS = {
insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs), insar_analysis: LEFT_GROUP_SECTIONS.insar_analysis.flatMap(section => section.tabs),
statistics: ['statistics'], statistics: ['statistics'],
flood_analysis: ['flood_analysis'], flood_analysis: ['flood_analysis'],
ops: ['health', 'users', 'audit'], ops: ['health', 'ops_tasks', 'users', 'audit'],
}; };
export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => { export const LEFT_TAB_GROUP = Object.entries(LEFT_GROUP_TABS).reduce((acc, [group, tabs]) => {
@@ -262,6 +262,7 @@ export const ADMIN_ONLY_TABS = new Set([
...PRODUCTION_WORKSPACE_LEGACY_TABS, ...PRODUCTION_WORKSPACE_LEGACY_TABS,
'users', 'users',
'audit', 'audit',
'ops_tasks',
]); ]);
export const DEFAULT_LIST_PAGE_SIZE = 200; export const DEFAULT_LIST_PAGE_SIZE = 200;
+436 -8
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
buildCoveragePlan,
getPairingHealth, getPairingHealth,
rebuildPairingCache, rebuildPairingCache,
reconcileDirtyPairingCache, reconcileDirtyPairingCache,
} from '../api/pairing'; } from '../api/pairing';
import { getRegionChildren, getRegionGeometry } from '../api/aoi';
import MiniCoverageMap from '../components/MiniCoverageMap'; import MiniCoverageMap from '../components/MiniCoverageMap';
import { usePairingStore } from '../store';
const formatIso = (value, en = false) => { const formatIso = (value, en = false) => {
if (!value) return en ? 'Never' : '未执行'; if (!value) return en ? 'Never' : '未执行';
@@ -41,6 +44,7 @@ export default function PairPlanningPanel({
onRefreshRadarSearch, onRefreshRadarSearch,
onSearchAll, onSearchAll,
onRefreshDinsar, onRefreshDinsar,
onOpenPairsView,
language, language,
}) { }) {
const en = language === 'en'; const en = language === 'en';
@@ -50,7 +54,60 @@ export default function PairPlanningPanel({
const [pairingRepairing, setPairingRepairing] = useState(false); const [pairingRepairing, setPairingRepairing] = useState(false);
const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false); const [pairingFullRebuilding, setPairingFullRebuilding] = useState(false);
const [pairingActionResult, setPairingActionResult] = useState(null); const [pairingActionResult, setPairingActionResult] = useState(null);
const previewPairs = useMemo(() => foundPairs.slice(0, 20), [foundPairs]); const [coveragePlanning, setCoveragePlanning] = useState(false);
const [coverageResult, setCoverageResult] = useState(null);
const [coverageAoiMode, setCoverageAoiMode] = useState('region');
const [coverageAoiFiles, setCoverageAoiFiles] = useState(null);
const [coverageAoiGeojson, setCoverageAoiGeojson] = useState(null);
const [coverageAoiError, setCoverageAoiError] = useState('');
const [coverageRegionLoading, setCoverageRegionLoading] = useState(false);
const [coverageRegionLoaded, setCoverageRegionLoaded] = useState(false);
const [coverageRegionOptions, setCoverageRegionOptions] = useState({ provinces: [], cities: [] });
const [coverageRegionSelection, setCoverageRegionSelection] = useState({ province: '', city: '' });
const [coverageMapMode, setCoverageMapMode] = useState('effective');
const [coverageForm, setCoverageForm] = useState({
target_date_from: '',
target_date_to: '',
extension_days: '15',
max_pairs: '200',
target_coverage_ratio: '0.98',
min_new_coverage_ratio: '0.0005',
time_baseline_min: '1',
time_baseline_max: '30',
overlap_threshold: '0.5',
spatial_baseline_max_meters: '5000',
aoi_overlap_threshold: '0',
allowed_satellites: 'LT1',
});
const { setFoundPairs, setPairingAlert } = usePairingStore();
const selectedCoverageRegionTreeId = coverageRegionSelection.city || coverageRegionSelection.province || '';
const selectedCoverageProvince = useMemo(
() => coverageRegionOptions.provinces.find(item => String(item.tree_id) === String(coverageRegionSelection.province)),
[coverageRegionOptions.provinces, coverageRegionSelection.province],
);
const selectedCoverageCity = useMemo(
() => coverageRegionOptions.cities.find(item => String(item.tree_id) === String(coverageRegionSelection.city)),
[coverageRegionOptions.cities, coverageRegionSelection.city],
);
const coverageAoiSummary = useMemo(() => {
if (coverageAoiMode === 'region') {
if (!selectedCoverageRegionTreeId) return '请选择行政区作为空间覆盖范围';
const name = selectedCoverageCity?.name || selectedCoverageProvince?.name || selectedCoverageRegionTreeId;
return `行政区 AOI${name}${coverageRegionSelection.city ? '' : '(全域)'}`;
}
const files = Array.from(coverageAoiFiles || []);
if (files.length === 0) return '请上传 SHP/GeoJSON 作为空间覆盖范围';
const names = files.slice(0, 2).map(file => file.name).join('、');
return `上传 AOI${names}${files.length > 2 ? `${files.length} 个文件` : ''}`;
}, [
coverageAoiFiles,
coverageAoiMode,
coverageRegionSelection.city,
selectedCoverageCity,
selectedCoverageProvince,
selectedCoverageRegionTreeId,
]);
const previewPairs = useMemo(() => foundPairs, [foundPairs]);
const previewPolygons = useMemo(() => ( const previewPolygons = useMemo(() => (
previewPairs.flatMap((pair, index) => { previewPairs.flatMap((pair, index) => {
const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`; const taskLabel = pair.task_alias || pair.task_name || `Pair ${index + 1}`;
@@ -70,6 +127,22 @@ export default function PairPlanningPanel({
]; ];
}) })
), [previewPairs]); ), [previewPairs]);
const previewEffectiveGeojson = useMemo(() => ({
type: 'FeatureCollection',
features: previewPairs
.filter(pair => pair.effective_coverage_geojson)
.map((pair, index) => ({
type: 'Feature',
properties: {
name: `${pair.coverage_rank || index + 1}. ${pair.task_alias || pair.task_name || `Pair ${index + 1}`}`,
label: `${pair.coverage_rank || index + 1}. 新增 ${((Number(pair.aoi_new_area_ratio || 0)) * 100).toFixed(2)}% / 累计 ${((Number(pair.aoi_coverage_ratio_after || 0)) * 100).toFixed(1)}%`,
color: '#2563eb',
fillColor: '#3b82f6',
},
geometry: pair.effective_coverage_geojson,
})),
}), [previewPairs]);
const mapShowsEffectiveCoverage = coverageMapMode === 'effective' && previewEffectiveGeojson.features.length > 0;
const refreshPairingStatus = useCallback(async () => { const refreshPairingStatus = useCallback(async () => {
if (isReadOnlyUser) { if (isReadOnlyUser) {
@@ -122,6 +195,169 @@ export default function PairPlanningPanel({
}, [en, refreshPairingStatus]); }, [en, refreshPairingStatus]);
const pairingActionBusy = pairingRepairing || pairingFullRebuilding; const pairingActionBusy = pairingRepairing || pairingFullRebuilding;
const handleCoverageFieldChange = useCallback((field, value) => {
setCoverageForm(current => ({ ...current, [field]: value }));
}, []);
const loadCoverageProvinces = useCallback(async () => {
setCoverageRegionLoading(true);
setCoverageAoiError('');
try {
const result = await getRegionChildren('1');
setCoverageRegionOptions({
provinces: Array.isArray(result?.children) ? result.children : [],
cities: [],
});
setCoverageRegionLoaded(true);
} catch (error) {
setCoverageAoiError(error.response?.data?.detail || error.message || '行政区加载失败。');
setCoverageRegionOptions({ provinces: [], cities: [] });
setCoverageRegionLoaded(true);
} finally {
setCoverageRegionLoading(false);
}
}, []);
const loadCoverageCities = useCallback(async (provinceId) => {
if (!provinceId) {
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
return;
}
setCoverageRegionLoading(true);
setCoverageAoiError('');
try {
const result = await getRegionChildren(provinceId);
setCoverageRegionOptions(current => ({
...current,
cities: Array.isArray(result?.children) ? result.children : [],
}));
} catch (error) {
setCoverageAoiError(error.response?.data?.detail || error.message || '地市加载失败。');
setCoverageRegionOptions(current => ({ ...current, cities: [] }));
} finally {
setCoverageRegionLoading(false);
}
}, []);
useEffect(() => {
if (isReadOnlyUser || coverageAoiMode !== 'region' || coverageRegionLoaded || coverageRegionLoading) return;
void loadCoverageProvinces();
}, [
coverageAoiMode,
coverageRegionLoaded,
coverageRegionLoading,
isReadOnlyUser,
loadCoverageProvinces,
]);
const handleCoverageAoiModeChange = useCallback(async (mode) => {
setCoverageAoiMode(mode);
setCoverageAoiError('');
setCoverageResult(null);
setCoverageAoiGeojson(null);
if (mode === 'region' && !coverageRegionLoaded) {
await loadCoverageProvinces();
}
}, [coverageRegionLoaded, loadCoverageProvinces]);
const handleCoverageProvinceChange = useCallback(async (value) => {
setCoverageRegionSelection({ province: value, city: '' });
setCoverageAoiGeojson(null);
setCoverageResult(null);
if (value) await loadCoverageCities(value);
else setCoverageRegionOptions(current => ({ ...current, cities: [] }));
}, [loadCoverageCities]);
const handleCoverageCityChange = useCallback((value) => {
setCoverageRegionSelection(current => ({ ...current, city: value }));
setCoverageAoiGeojson(null);
setCoverageResult(null);
}, []);
const handleCoverageFilesChange = useCallback((files) => {
setCoverageAoiFiles(files);
setCoverageAoiGeojson(null);
setCoverageAoiError('');
setCoverageResult(null);
}, []);
const handleCoveragePlan = useCallback(async () => {
if (isReadOnlyUser || coveragePlanning) return;
const targetFrom = String(coverageForm.target_date_from || '').trim();
const targetTo = String(coverageForm.target_date_to || '').trim();
if (!/^\d{8}$/.test(targetFrom) || !/^\d{8}$/.test(targetTo)) {
setCoverageResult({ error: '请输入 YYYYMMDD 格式的目标起止日期。' });
return;
}
setCoveragePlanning(true);
setCoverageResult(null);
setCoverageAoiError('');
try {
const formData = new FormData();
Object.entries(coverageForm).forEach(([key, value]) => {
const text = String(value ?? '').trim();
if (!text) return;
if (key === 'allowed_satellites') {
const families = text.split(',').map(item => item.trim()).filter(Boolean);
if (families.length > 0) formData.append(key, JSON.stringify(families));
return;
}
formData.append(key, text);
});
formData.append('require_orbit_data', 'true');
formData.append('require_same_imaging_mode', 'true');
formData.append('require_same_polarization', 'true');
formData.append('limit_footprint_center_distance', 'true');
let selectedAoiGeojson = null;
if (coverageAoiMode === 'file') {
const files = Array.from(coverageAoiFiles || []);
if (files.length === 0) {
throw new Error('请选择 AOI 文件。');
}
files.forEach(file => formData.append('files', file));
} else {
if (!selectedCoverageRegionTreeId) {
throw new Error('请选择行政区 AOI。');
}
const result = await getRegionGeometry(selectedCoverageRegionTreeId);
selectedAoiGeojson = result?.aoi_geojson || null;
if (!selectedAoiGeojson) {
throw new Error('行政区边界为空,无法进行空间覆盖规划。');
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeojson));
}
const response = await buildCoveragePlan(formData);
const pairs = Array.isArray(response?.pairs) ? response.pairs : [];
const warnings = Array.isArray(response?.warnings) ? response.warnings : [];
const responseAoi = response?.aoi_geojson || selectedAoiGeojson || null;
setFoundPairs(pairs.map(pair => ({ ...pair, isSelected: true, isVis: false })));
setPairingAlert({ warnings, fallbackUsed: Boolean(response?.fallback_used) });
setCoverageAoiGeojson(responseAoi);
setCoverageResult({
...response,
aoi_geojson: responseAoi,
pairCount: pairs.length,
warningCount: warnings.length,
});
} catch (error) {
const message = error.response?.data?.detail || error.message || '区域空间覆盖配对规划失败。';
setCoverageAoiError(message);
setCoverageResult({ error: message });
} finally {
setCoveragePlanning(false);
}
}, [
coverageAoiFiles,
coverageAoiMode,
coverageForm,
coveragePlanning,
isReadOnlyUser,
selectedCoverageRegionTreeId,
setFoundPairs,
setPairingAlert,
]);
return ( return (
<div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}> <div className="panel-content" style={{ flex: '1 1 auto', overflowY: 'auto', padding: '12px' }}>
@@ -139,16 +375,208 @@ export default function PairPlanningPanel({
</div> </div>
</div> </div>
<div className="panel-card province-coverage-card" style={{ marginTop: '12px' }}>
<div className="panel-card-title">区域空间覆盖配对规划</div>
<p className="panel-card-desc">
以目标生产时段限定影像池按标准 D-InSAR 约束生成候选对并优先选择能增加 AOI 空间覆盖范围的配对扩展天数用于补充边界时段的可用影像
</p>
<div className="coverage-form-grid">
<label>
目标开始
<input value={coverageForm.target_date_from} onChange={event => handleCoverageFieldChange('target_date_from', event.target.value)} placeholder="20260101" />
</label>
<label>
目标结束
<input value={coverageForm.target_date_to} onChange={event => handleCoverageFieldChange('target_date_to', event.target.value)} placeholder="20260531" />
</label>
<label>
扩展天数
<input type="number" min="0" max="180" value={coverageForm.extension_days} onChange={event => handleCoverageFieldChange('extension_days', event.target.value)} />
</label>
<label>
任务上限
<input type="number" min="1" max="5000" value={coverageForm.max_pairs} onChange={event => handleCoverageFieldChange('max_pairs', event.target.value)} />
</label>
<label>
目标覆盖率
<input type="number" min="0" max="1" step="0.01" value={coverageForm.target_coverage_ratio} onChange={event => handleCoverageFieldChange('target_coverage_ratio', event.target.value)} />
</label>
<label>
最低新增覆盖
<input type="number" min="0" max="1" step="0.0001" value={coverageForm.min_new_coverage_ratio} onChange={event => handleCoverageFieldChange('min_new_coverage_ratio', event.target.value)} />
</label>
<label>
时间基线
<input value={coverageForm.time_baseline_max} onChange={event => handleCoverageFieldChange('time_baseline_max', event.target.value)} />
</label>
<label>
最小重叠
<input value={coverageForm.overlap_threshold} onChange={event => handleCoverageFieldChange('overlap_threshold', event.target.value)} />
</label>
<label>
中心距上限
<input value={coverageForm.spatial_baseline_max_meters} onChange={event => handleCoverageFieldChange('spatial_baseline_max_meters', event.target.value)} />
</label>
<label>
卫星族
<input value={coverageForm.allowed_satellites} onChange={event => handleCoverageFieldChange('allowed_satellites', event.target.value)} />
</label>
</div>
<div className="coverage-aoi-panel">
<div className="coverage-aoi-header">
<strong>空间覆盖范围</strong>
<span>{coverageAoiSummary}</span>
</div>
<div className="coverage-aoi-mode-row" role="radiogroup" aria-label="空间覆盖范围来源">
<label className={`coverage-aoi-mode ${coverageAoiMode === 'region' ? 'active' : ''}`}>
<input
type="radio"
name="coverage-aoi-mode"
value="region"
checked={coverageAoiMode === 'region'}
disabled={coveragePlanning || isReadOnlyUser}
onChange={() => void handleCoverageAoiModeChange('region')}
/>
行政区边界
</label>
<label className={`coverage-aoi-mode ${coverageAoiMode === 'file' ? 'active' : ''}`}>
<input
type="radio"
name="coverage-aoi-mode"
value="file"
checked={coverageAoiMode === 'file'}
disabled={coveragePlanning || isReadOnlyUser}
onChange={() => void handleCoverageAoiModeChange('file')}
/>
上传 AOI 文件
</label>
</div>
{coverageAoiMode === 'region' ? (
<div className="coverage-aoi-selects">
<label>
省份
<select
value={coverageRegionSelection.province}
onChange={event => void handleCoverageProvinceChange(event.target.value)}
disabled={coveragePlanning || coverageRegionLoading || isReadOnlyUser}
>
<option value="">选择省份</option>
{coverageRegionOptions.provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</label>
<label>
地市
<select
value={coverageRegionSelection.city}
onChange={event => handleCoverageCityChange(event.target.value)}
disabled={coveragePlanning || coverageRegionLoading || !coverageRegionSelection.province || isReadOnlyUser}
>
<option value="">全省/不限地市</option>
{coverageRegionOptions.cities.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</label>
</div>
) : (
<div className="coverage-aoi-file">
<input
type="file"
multiple
accept=".zip,.shp,.shx,.dbf,.prj,.cpg,.geojson,.json"
disabled={coveragePlanning || isReadOnlyUser}
onChange={event => handleCoverageFilesChange(event.target.files)}
/>
</div>
)}
<div className="coverage-aoi-note">
覆盖目标优先增加所选 AOI 的空间面覆盖时间范围用于限定候选影像池
</div>
{coverageAoiError && (
<div className="coverage-aoi-error">{coverageAoiError}</div>
)}
</div>
<div className="coverage-action-row">
<button onClick={handleCoveragePlan} disabled={coveragePlanning || isLoading || isReadOnlyUser}>
{coveragePlanning ? '规划中...' : '生成覆盖配对'}
</button>
<span>{coverageAoiSummary}</span>
</div>
{coverageResult && (
<div className={`coverage-result ${coverageResult.error ? 'error' : 'ready'}`}>
{coverageResult.error ? (
<span>{coverageResult.error}</span>
) : (
<>
<strong>
已生成 {coverageResult.pairCount} AOI 空间覆盖率 {Math.round(Number(coverageResult.coverage?.aoi_coverage_ratio ?? coverageResult.coverage?.coverage_ratio ?? 0) * 100)}%
</strong>
<span>
候选 {Number(coverageResult.candidate_count || 0)}网络 {coverageResult.network_run_id || '-'}
影像池范围 {coverageResult.coverage?.query_date_from || '-'} {coverageResult.coverage?.query_date_to || '-'}
</span>
<span>
时间覆盖率 {Math.round(Number(coverageResult.coverage?.temporal_coverage_ratio || 0) * 100)}%
任务上限 {coverageResult.coverage?.max_pairs || coverageForm.max_pairs}
停止原因{coverageResult.coverage?.stop_reason || '-'}
</span>
<span>
贪心规则每一步选择新增 AOI 有效覆盖面积最大的候选对达到目标覆盖率或新增收益低于阈值后停止
</span>
{coverageResult.coverage?.uncovered_ranges?.length > 0 && (
<span>
时间缺口{coverageResult.coverage.uncovered_ranges.slice(0, 3).map(item => `${item.date_from}-${item.date_to}`).join('')}
</span>
)}
<div className="coverage-result-actions">
<button
type="button"
onClick={onOpenPairsView}
disabled={typeof onOpenPairsView !== 'function' || foundPairs.length === 0}
>
查看全部并保存批次
</button>
</div>
</>
)}
</div>
)}
</div>
<div style={{ marginTop: '12px' }}> <div style={{ marginTop: '12px' }}>
{foundPairs.length > 0 && (
<div className="coverage-map-toolbar">
<strong>覆盖检视</strong>
<span>
{mapShowsEffectiveCoverage
? `显示 ${previewEffectiveGeojson.features.length} 个有效覆盖面(master ∩ slave ∩ AOI`
: `显示 ${previewPairs.length} 对主辅影像 footprint`}
</span>
<button
type="button"
className={coverageMapMode === 'effective' ? 'active' : ''}
onClick={() => setCoverageMapMode('effective')}
disabled={previewEffectiveGeojson.features.length === 0}
>
有效覆盖面
</button>
<button
type="button"
className={coverageMapMode === 'footprint' ? 'active' : ''}
onClick={() => setCoverageMapMode('footprint')}
>
主辅影像范围
</button>
</div>
)}
<MiniCoverageMap <MiniCoverageMap
title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'} title={en ? 'D-InSAR Pair Coverage Preview' : 'D-InSAR配对范围预览'}
subtitle={ subtitle={mapShowsEffectiveCoverage ? `${previewEffectiveGeojson.features.length} 个有效覆盖面` : `${previewPairs.length}`}
foundPairs.length > previewPairs.length polygons={mapShowsEffectiveCoverage ? [] : previewPolygons}
? `${previewPairs.length}/${foundPairs.length}` geojson={mapShowsEffectiveCoverage ? previewEffectiveGeojson : (coverageAoiGeojson || coverageResult?.aoi_geojson)}
: `${foundPairs.length}` height={420}
}
polygons={previewPolygons}
height={260}
emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'} emptyText={en ? 'Run pair planning to preview pair footprints.' : '生成配对后显示候选范围。'}
/> />
</div> </div>
+7 -3
View File
@@ -112,14 +112,18 @@ function PairsListPanel({
) : ( ) : (
<> <>
<div className="list-toolbar"> <div className="list-toolbar">
<div className="list-toolbar-main">
<strong>候选配对列表</strong>
<span> {foundPairs.length} 已选择 {selectedPairsCount} 列表支持滚动查看全部结果</span>
</div>
<label className="select-all-pairs-control" htmlFor="select-all-pairs">
<input <input
type="checkbox" type="checkbox"
checked={allPairsSelected} checked={allPairsSelected}
onChange={handleSelectAllPairs} onChange={handleSelectAllPairs}
id="select-all-pairs" id="select-all-pairs"
/> />
<label htmlFor="select-all-pairs"> 全选
全选 ({selectedPairsCount} / {foundPairs.length} 已选择)
</label> </label>
</div> </div>
<VirtualizedList <VirtualizedList
@@ -179,7 +183,7 @@ function PairsListPanel({
className="footer-button" className="footer-button"
title="保存选中的配对为任务批次" title="保存选中的配对为任务批次"
> >
保存批次 ({selectedPairsCount}) 保存选中配对为批次 ({selectedPairsCount})
</button> </button>
</footer> </footer>
</> </>
+4
View File
@@ -87,6 +87,8 @@ export const getLeftTabLabel = (tabKey, metrics = {}) => {
return '洪涝灾害分析'; return '洪涝灾害分析';
case 'health': case 'health':
return '运行维护'; return '运行维护';
case 'ops_tasks':
return '任务维护';
case 'users': case 'users':
return '用户管理'; return '用户管理';
case 'audit': case 'audit':
@@ -120,6 +122,8 @@ export const getLeftTabDescription = (tabKey) => {
return '围绕洪涝场景开展水体提取、过程分析和专题制图。'; return '围绕洪涝场景开展水体提取、过程分析和专题制图。';
case 'health': case 'health':
return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。'; return '检查核心服务、数据目录、生产索引和运行环境,定位影响生产的阻断项。';
case 'ops_tasks':
return '查看失败、部分成功、取消和残留运行的任务,按诊断预览清理数据库记录和生产目录。';
case 'users': case 'users':
return '维护系统用户、角色与访问权限。'; return '维护系统用户、角色与访问权限。';
case 'audit': case 'audit':