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

314 lines
9.9 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import DinsarResultORM, ResultProductORM
_BBOX_TOLERANCE = 1e-6
@dataclass(frozen=True)
class _ProjectedProduct:
product: ResultProductORM
name: str
file_path: str
def _safe_text(value: Any, *, default: str = "") -> str:
text = str(value or "").strip()
return text or default
def _safe_float(value: Any) -> Optional[float]:
try:
if value in (None, ""):
return None
return float(value)
except (TypeError, ValueError):
return None
def _same_float(left: Any, right: Any, *, tol: float = _BBOX_TOLERANCE) -> bool:
left_num = _safe_float(left)
right_num = _safe_float(right)
if left_num is None and right_num is None:
return True
if left_num is None or right_num is None:
return False
return abs(left_num - right_num) <= tol
def _same_bbox(row: DinsarResultORM, product: ResultProductORM) -> bool:
return (
_same_float(row.min_lon, product.min_lon)
and _same_float(row.min_lat, product.min_lat)
and _same_float(row.max_lon, product.max_lon)
and _same_float(row.max_lat, product.max_lat)
)
class DinsarCompatService:
def _project_product(self, product: ResultProductORM) -> Optional[_ProjectedProduct]:
file_path = _safe_text(
product.primary_asset_path
or product.source_primary_path
or product.manifest_path
)
if not file_path:
return None
name = _safe_text(
product.display_name
or product.task_alias
or product.task_name
or product.product_id,
default=product.product_id,
)
return _ProjectedProduct(
product=product,
name=name,
file_path=file_path,
)
def _apply_product_fields(self, row: DinsarResultORM, projected: _ProjectedProduct) -> bool:
product = projected.product
changed = False
for field_name, field_value in (
("compat_product_id", product.product_id),
("name", projected.name),
("file_path", projected.file_path),
("min_lon", product.min_lon),
("min_lat", product.min_lat),
("max_lon", product.max_lon),
("max_lat", product.max_lat),
("coverage_polygon", product.coverage_polygon),
("geom", product.geom),
):
if getattr(row, field_name) != field_value:
setattr(row, field_name, field_value)
changed = True
return changed
def _mirror_legacy_annotations(self, row: DinsarResultORM, product: ResultProductORM) -> bool:
changed = False
for field_name in ("ai_score", "user_label"):
row_value = getattr(row, field_name)
if getattr(product, field_name) != row_value:
setattr(product, field_name, row_value)
changed = True
return changed
def _mirror_product_annotations_to_row(self, product: ResultProductORM, row: DinsarResultORM) -> bool:
changed = False
for field_name in ("ai_score", "user_label"):
product_value = getattr(product, field_name)
if getattr(row, field_name) != product_value:
setattr(row, field_name, product_value)
changed = True
return changed
async def sync_from_catalog(
self,
db: AsyncSession,
*,
prune_missing: bool = True,
) -> Dict[str, Any]:
product_result = await db.execute(
select(ResultProductORM)
.where(ResultProductORM.catalog_name == "dinsar")
.order_by(ResultProductORM.id.asc())
)
products = product_result.scalars().all()
row_result = await db.execute(
select(DinsarResultORM).order_by(DinsarResultORM.id.asc())
)
rows = row_result.scalars().all()
by_product_id = {
str(row.compat_product_id): row
for row in rows
if _safe_text(row.compat_product_id)
}
by_file_path = {
_safe_text(row.file_path): row
for row in rows
if _safe_text(row.file_path)
}
by_name: Dict[str, List[DinsarResultORM]] = {}
for row in rows:
key = _safe_text(row.name)
if not key:
continue
by_name.setdefault(key, []).append(row)
touched_row_ids: set[int] = set()
created = 0
updated = 0
skipped = 0
mirrored_product_fields = 0
for product in products:
projected = self._project_product(product)
if projected is None:
skipped += 1
continue
row = by_product_id.get(product.product_id)
if row is None:
row = by_file_path.get(projected.file_path)
if row is None:
for candidate in by_name.get(projected.name, []):
if candidate.id in touched_row_ids:
continue
if _same_bbox(candidate, product):
row = candidate
break
if row is None:
row = DinsarResultORM(
compat_product_id=product.product_id,
name=projected.name,
file_path=projected.file_path,
min_lon=product.min_lon,
min_lat=product.min_lat,
max_lon=product.max_lon,
max_lat=product.max_lat,
coverage_polygon=product.coverage_polygon,
geom=product.geom,
is_cached=False,
ai_score=product.ai_score,
user_label=product.user_label,
)
db.add(row)
await db.flush()
created += 1
else:
if self._apply_product_fields(row, projected):
updated += 1
if self._mirror_legacy_annotations(row, product):
mirrored_product_fields += 1
touched_row_ids.add(int(row.id))
deleted = 0
if prune_missing:
for row in rows:
if row.id in touched_row_ids:
continue
await db.delete(row)
deleted += 1
await db.commit()
return {
"catalog_count": len(products),
"compat_count": len(touched_row_ids),
"created": created,
"updated": updated,
"deleted": deleted,
"skipped": skipped,
"mirrored_product_fields": mirrored_product_fields,
}
async def sync_product_annotations_from_result(
self,
db: AsyncSession,
*,
result_id: int,
) -> Optional[Dict[str, Any]]:
result = await db.execute(
select(DinsarResultORM).where(DinsarResultORM.id == int(result_id))
)
row = result.scalar_one_or_none()
if row is None:
return None
product_id = _safe_text(row.compat_product_id)
if not product_id:
return None
product_result = await db.execute(
select(ResultProductORM).where(ResultProductORM.product_id == product_id)
)
product = product_result.scalar_one_or_none()
if product is None:
return None
changed = self._mirror_legacy_annotations(row, product)
if changed:
await db.commit()
return {
"result_id": row.id,
"product_id": product.product_id,
"changed": changed,
}
async def sync_result_annotations_from_products(
self,
db: AsyncSession,
*,
product_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
stmt = select(ResultProductORM).where(ResultProductORM.catalog_name == "dinsar")
normalized_product_ids = [
str(product_id or "").strip()
for product_id in (product_ids or [])
if str(product_id or "").strip()
]
if normalized_product_ids:
stmt = stmt.where(ResultProductORM.product_id.in_(normalized_product_ids))
product_result = await db.execute(stmt.order_by(ResultProductORM.id.asc()))
products = product_result.scalars().all()
if not products:
return {
"requested": len(normalized_product_ids),
"synced": 0,
"updated": 0,
"missing_rows": 0,
}
product_keys = [product.product_id for product in products]
row_result = await db.execute(
select(DinsarResultORM)
.where(DinsarResultORM.compat_product_id.in_(product_keys))
.order_by(DinsarResultORM.id.asc())
)
rows = row_result.scalars().all()
rows_by_product_id: Dict[str, DinsarResultORM] = {}
for row in rows:
product_id = _safe_text(row.compat_product_id)
if product_id and product_id not in rows_by_product_id:
rows_by_product_id[product_id] = row
synced = 0
updated = 0
missing_rows = 0
for product in products:
row = rows_by_product_id.get(product.product_id)
if row is None:
missing_rows += 1
continue
synced += 1
if self._mirror_product_annotations_to_row(product, row):
updated += 1
if updated:
await db.commit()
return {
"requested": len(normalized_product_ids) or len(products),
"synced": synced,
"updated": updated,
"missing_rows": missing_rows,
}
dinsar_compat_service = DinsarCompatService()