314 lines
9.9 KiB
Python
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()
|