from __future__ import annotations import asyncio import gzip import hashlib import math import os import re import shutil import tarfile import zipfile from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from datetime import datetime, timedelta from pathlib import PurePosixPath from types import SimpleNamespace from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Sequence, Tuple from geoalchemy2.shape import from_shape from lxml import etree from shapely.geometry import Polygon from sqlalchemy import and_, case, delete, func, or_, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from .. import database from ..config import settings from ..models import ( AssetInventoryIssueORM, AssetInventoryStateORM, ManagedRootORM, OrbitAssetORM, OrbitAssetDerivativeORM, RadarDataORM, SARSceneGeometryProfileORM, SceneOrbitBindingORM, SourceMetadataDocumentORM, SourceProductAssetORM, ) from ..utils import ( build_corner_pixel_mapping, find_xml_file, normalize_satellite_family, parse_gf3_l2_dirname, parse_lt1_radar_filename, parse_xml_metadata, ) from .pairing_state_service import pairing_state_service from .data_service import DataService from .image_service import image_service from .orbit_converter import sync_orbit_pools from .task_service import task_service PARSER_VERSION = "asset_inventory_v2" ARCHIVE_INTEGRITY_VERSION = "archive_integrity_v1" S1_ORBIT_MATCH_RULE_VERSION = "s1_orbit_window_v1" LT1_ORBIT_MATCH_RULE_VERSION = "lt1_orbit_day_v1" ASSET_SCAN_LOG_INTERVAL = 100 ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT = 200 ARCHIVE_INTEGRITY_LOG_INTERVAL = 10 DEFAULT_ASSET_SCAN_PARSE_WORKERS = 4 DEFAULT_ASSET_SCAN_PARSE_INFLIGHT = 64 DEFAULT_ASSET_SCAN_DB_BATCH_SIZE = 50 _WINDOWS_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]") _S1_SOURCE_RE = re.compile( r"^(?PS1[A-Z])_" r"(?P[A-Z0-9]+)_" r"(?P[A-Z0-9]+)_+" r"(?P[0-9A-Z]{4})_" r"(?P\d{8}T\d{6}(?:\.\d+)?)_" r"(?P\d{8}T\d{6}(?:\.\d+)?)_" r"(?P\d+)_" r"(?P[0-9A-F]+)_" r"(?P[0-9A-F]+)" r"(?:\.SAFE|\.zip)?$", re.IGNORECASE, ) _S1_EOF_RE = re.compile( r"^(?PS1[A-Z])_OPER_" r"(?PAUX_[A-Z0-9]+)_" r"(?P[A-Z0-9]+)_" r"(?P\d{8}T\d{6})_" r"V(?P\d{8}T\d{6})_" r"(?P\d{8}T\d{6})\.EOF$", re.IGNORECASE, ) _LT1_ORBIT_RE = re.compile( r"^(?PLT1[A-Z]?)_GpsData_GAS_C_(?P\d{8})\.txt$", re.IGNORECASE, ) _LT1_ARCHIVE_EXTS = (".tar.gz", ".tgz", ".zip", ".tar") _GF3_ARCHIVE_EXTS = (".tar.gz", ".tgz", ".zip", ".tar") def _parse_bool(value: Any, default: bool = False) -> bool: if value is None: return default return str(value).strip().lower() in {"1", "true", "yes", "on"} def _parse_float(value: Any, default: float = 0.0) -> float: try: return float(str(value).strip()) except (TypeError, ValueError): return default def _configured_sentinel1_storage_dirs() -> List[str]: values = settings.SENTINEL1_STORAGE_DIRS or settings.SOURCE_PRODUCT_DIRS paths = [ _normalize_path(item) for item in str(values or "").replace(";", ",").split(",") if str(item or "").strip() ] if not paths: paths = [_normalize_path(os.path.join(settings.BACKEND_DIR, "runtime", "sentinel1_safe"))] return paths def _configured_sentinel1_archive_dirs() -> List[str]: values = settings.SOURCE_PRODUCT_DIRS paths = [ _normalize_path(item) for item in str(values or "").replace(";", ",").split(",") if str(item or "").strip() ] deduped: List[str] = [] for path in paths: if path and path not in deduped: deduped.append(path) return deduped def _target_root_for_s1_archive(archive_path: str, target_root: Optional[str] = None) -> str: requested = _normalize_path(target_root or "") if requested: return requested storage_dirs = _configured_sentinel1_storage_dirs() source_dirs = [ _normalize_path(item) for item in str(settings.SOURCE_PRODUCT_DIRS or "").replace(";", ",").split(",") if str(item or "").strip() ] if len(storage_dirs) > 1 and source_dirs and len(source_dirs) == len(storage_dirs): archive_norm = os.path.normcase(_normalize_path(archive_path)) matches: List[Tuple[int, int]] = [] for index, source_dir in enumerate(source_dirs): source_norm = os.path.normcase(_normalize_path(source_dir)) if archive_norm == source_norm or archive_norm.startswith(source_norm + os.sep): matches.append((len(source_norm), index)) if matches: _, best_index = max(matches) return storage_dirs[best_index] return storage_dirs[0] def _task_pool_materialize_root(source_format: str) -> str: base = _normalize_path(getattr(settings, "TASK_POOL_ROOT", "") or "") if not base: base = _normalize_path(os.path.join(settings.BACKEND_DIR, "runtime", "task_pool")) folder = { "S1_ZIP": "sentinel1", "S1_SAFE_DIR": "sentinel1", "LT1_ARCHIVE": "lutan1", "GF3_ARCHIVE": "gf3", }.get(str(source_format or "").upper(), "source") return _normalize_path(os.path.join(base, "source_materialized", folder)) def _source_ref_for_materialized_root(path: str) -> str: normalized = os.path.normcase(_normalize_path(path)) task_root = os.path.normcase(_normalize_path(getattr(settings, "TASK_POOL_ROOT", "") or "")) if task_root and (normalized == task_root or normalized.startswith(task_root + os.sep)): return "TASK_POOL_ROOT" storage_roots = _configured_sentinel1_storage_dirs() for storage_root in storage_roots: storage_norm = os.path.normcase(_normalize_path(storage_root)) if storage_norm and (normalized == storage_norm or normalized.startswith(storage_norm + os.sep)): return "SENTINEL1_STORAGE_DIRS" return "SOURCE_PRODUCT_DIRS" def _new_session() -> AsyncSession: if database.AsyncSessionLocal is None: database.init_db() if database.AsyncSessionLocal is None: raise RuntimeError("Database session factory is not initialized.") return database.AsyncSessionLocal() def _utcnow() -> datetime: return datetime.utcnow() def _normalize_path(path: str) -> str: text = str(path or "").strip() if not text: return "" if text.startswith("\\\\"): return os.path.normpath(text) if _WINDOWS_DRIVE_RE.match(text): return os.path.normpath(text) if text.startswith("/"): return text.replace("\\", "/") return os.path.normpath(os.path.abspath(text)) def _path_kind(path: str) -> str: text = str(path or "").strip() if text.startswith("\\\\"): return "unc" if _WINDOWS_DRIVE_RE.match(text): return "windows" if text.startswith("/mnt/"): return "wsl_mount" if text.startswith("/"): return "posix" return "relative" def _ensure_local_runtime_path(path: str, label: str) -> str: normalized = _normalize_path(path) if _path_kind(normalized) == "unc": raise ValueError(f"{label} cannot use UNC path for active production: {normalized}") return normalized def _stat_path(path: str) -> Dict[str, Optional[float]]: try: stat = os.stat(path) return { "size_bytes": int(stat.st_size), "mtime_epoch": float(stat.st_mtime), "ctime_epoch": float(stat.st_ctime), } except OSError: return {"size_bytes": None, "mtime_epoch": None, "ctime_epoch": None} def _activity_progress(progress_start: int, progress_end: int, count: int) -> int: start = max(0, min(100, int(progress_start))) end = max(start, min(100, int(progress_end))) collect_end = max(start, end - 6) if count <= 0 or collect_end <= start: return start return min(collect_end, start + 1 + int(count // 50)) def _asset_uid(prefix: str, path: str) -> str: digest = hashlib.sha1(_normalize_path(path).lower().encode("utf-8", errors="ignore")).hexdigest() return f"{prefix}:{digest[:32]}" def _strip_known_suffix(name: str) -> str: lower = name.lower() for suffix in (".tar.gz", ".tgz"): if lower.endswith(suffix): return name[: -len(suffix)] if lower.endswith(".zip"): return name[:-4] if lower.endswith(".tar"): return name[:-4] if lower.endswith(".safe"): return name[:-5] return name def _file_ext_for_path(path: str) -> str: name = os.path.basename(str(path or "")) lower = name.lower() for suffix in (".tar.gz", ".tgz", ".zip", ".tar", ".safe", ".eof", ".txt"): if lower.endswith(suffix): return suffix ext = os.path.splitext(name)[1].lower() return ext[:32] if ext else "" def _ordered_closed_polygon(points: Sequence[Any]) -> Optional[List[Tuple[float, float]]]: unique: List[Tuple[float, float]] = [] for point in points or []: try: lon = float(point[0]) lat = float(point[1]) except (TypeError, ValueError, IndexError): continue current = (lon, lat) if unique and abs(unique[-1][0] - lon) < 1e-12 and abs(unique[-1][1] - lat) < 1e-12: continue if unique and abs(unique[0][0] - lon) < 1e-12 and abs(unique[0][1] - lat) < 1e-12: continue if current not in unique: unique.append(current) if len(unique) < 3: return None candidates: List[List[Tuple[float, float]]] = [] candidates.append(unique) if len(unique) == 4: center_lon = sum(point[0] for point in unique) / len(unique) center_lat = sum(point[1] for point in unique) / len(unique) candidates.insert( 0, sorted( unique, key=lambda point: math.atan2(point[1] - center_lat, point[0] - center_lon), ), ) for candidate in candidates: ring = list(candidate) if ring[0] != ring[-1]: ring.append(ring[0]) try: polygon = Polygon(ring) if polygon.is_valid and not polygon.is_empty and polygon.area > 0: return ring except Exception: continue return None def _closed_polygon_if_valid(points: Sequence[Any]) -> Optional[List[Tuple[float, float]]]: ring: List[Tuple[float, float]] = [] for point in points or []: try: ring.append((float(point[0]), float(point[1]))) except (TypeError, ValueError, IndexError): return None if len(ring) < 3: return None if ring[0] != ring[-1]: ring.append(ring[0]) try: polygon = Polygon(ring) if polygon.is_valid and not polygon.is_empty and polygon.area > 0: return ring except Exception: return None return None def _ordered_closed_polygon_from_corners(corners: Sequence[Dict[str, Any]]) -> Optional[List[Tuple[float, float]]]: entries = [ item for item in (corners or []) if item.get("lon") is not None and item.get("lat") is not None ] if len(entries) < 3: return None by_name = { str(item.get("name") or "").strip().lower(): item for item in entries if str(item.get("name") or "").strip() } name_order = ["bottomleft", "bottomright", "topright", "topleft"] if all(name in by_name for name in name_order): ordered = [(by_name[name]["lon"], by_name[name]["lat"]) for name in name_order] valid = _closed_polygon_if_valid(ordered) if valid: return valid ref_entries = [ item for item in entries if item.get("ref_row") is not None and item.get("ref_col") is not None ] if len(ref_entries) >= 4: min_row = min(float(item["ref_row"]) for item in ref_entries) max_row = max(float(item["ref_row"]) for item in ref_entries) min_col = min(float(item["ref_col"]) for item in ref_entries) max_col = max(float(item["ref_col"]) for item in ref_entries) targets = [(min_row, min_col), (min_row, max_col), (max_row, max_col), (max_row, min_col)] remaining = list(ref_entries) ordered_entries: List[Dict[str, Any]] = [] for target_row, target_col in targets: chosen = min( remaining, key=lambda item: ( abs(float(item["ref_row"]) - target_row) + abs(float(item["ref_col"]) - target_col), str(item.get("name") or ""), ), ) ordered_entries.append(chosen) remaining.remove(chosen) ordered = [(item["lon"], item["lat"]) for item in ordered_entries] valid = _closed_polygon_if_valid(ordered) if valid: return valid return _ordered_closed_polygon([(item["lon"], item["lat"]) for item in entries]) def _root_supported_families(root: ManagedRootORM) -> List[str]: text = " ".join( str(value or "") for value in ( root.path, root.root_code, root.root_role, root.display_name, root.source_ref, ) ).lower() families: List[str] = [] if "lutan" in text or "lt1" in text or "lt-1" in text: families.append("LT1") if "sentinel" in text or "sentinel1" in text or "eof" in text or "safe" in text: families.append("S1") if "gaofen" in text or "gf3" in text: families.append("GF3") return families def _has_archive_suffix(name: str, suffixes: Sequence[str]) -> bool: lower = str(name or "").lower() return any(lower.endswith(suffix) for suffix in suffixes) def _archive_member_base_name(member_name: str) -> str: text = str(member_name or "").replace("\\", "/").strip("/") return PurePosixPath(text).name def _archive_member_scene_name(member_name: str, fallback: str) -> str: parts = [part for part in str(member_name or "").replace("\\", "/").split("/") if part] for part in parts: stem = _strip_known_suffix(part) if parse_lt1_radar_filename(stem) or parse_gf3_l2_dirname(stem): return stem return fallback def _archive_read_first_matching(path: str, predicate: Callable[[str], bool]) -> Tuple[Optional[str], Optional[bytes], List[str]]: members: List[str] = [] if zipfile.is_zipfile(path): with zipfile.ZipFile(path) as archive: for info in archive.infolist(): name = info.filename if info.is_dir(): continue members.append(name) if predicate(name): return name, archive.read(info), members return None, None, members if tarfile.is_tarfile(path): with tarfile.open(path, "r:*") as archive: for member in archive: name = member.name if not member.isfile(): continue members.append(name) if predicate(name): source = archive.extractfile(member) if source is None: continue with source: return name, source.read(), members return None, None, members return None, None, members def _archive_collect_members( path: str, *, content_predicate: Callable[[str], bool], list_predicate: Callable[[str], bool], list_limit: int = 20, ) -> Tuple[Optional[str], Optional[bytes], List[str], List[str]]: content_name: Optional[str] = None content_data: Optional[bytes] = None listed: List[str] = [] scanned: List[str] = [] target_list_count = max(0, int(list_limit)) def _visit(name: str, reader: Callable[[], bytes]) -> None: nonlocal content_name, content_data scanned.append(name) if list_predicate(name) and len(listed) < target_list_count: listed.append(name) if content_name is None and content_predicate(name): content_name = name content_data = reader() def _done() -> bool: return content_name is not None and len(listed) >= target_list_count if zipfile.is_zipfile(path): with zipfile.ZipFile(path) as archive: for info in archive.infolist(): if info.is_dir(): continue _visit(info.filename, lambda info=info: archive.read(info)) if _done(): break return content_name, content_data, listed, scanned if tarfile.is_tarfile(path): with tarfile.open(path, "r:*") as archive: for member in archive: if not member.isfile(): continue def _read(member=member) -> bytes: source = archive.extractfile(member) if source is None: return b"" with source: return source.read() _visit(member.name, _read) if _done(): break return content_name, content_data, listed, scanned return None, None, listed, scanned def _safe_archive_member_name(member_name: str, archive_path: str) -> str: name = str(member_name or "").replace("\\", "/").strip("/") if not name or name.startswith("../") or "/../" in f"/{name}/": raise ValueError(f"Unsafe archive member path in {archive_path}: {member_name}") if os.path.isabs(name) or os.path.splitdrive(name)[0]: raise ValueError(f"Unsafe archive member path in {archive_path}: {member_name}") return name def _extract_archive_to_dir(archive_path: str, target_dir: str, *, overwrite: bool = False) -> Dict[str, Any]: archive = _normalize_path(archive_path) target = _normalize_path(target_dir) if not os.path.isfile(archive): raise FileNotFoundError(f"Archive does not exist: {archive}") if os.path.exists(target): if not overwrite: return {"status": "EXISTS", "archive_path": archive, "target_dir": target, "extracted": False, "member_count": None} shutil.rmtree(target) tmp_dir = target + ".materialize_tmp" if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) os.makedirs(tmp_dir, exist_ok=True) extracted = 0 try: if zipfile.is_zipfile(archive): with zipfile.ZipFile(archive) as zip_obj: for info in zip_obj.infolist(): rel_name = _safe_archive_member_name(info.filename, archive) destination = os.path.abspath(os.path.join(tmp_dir, rel_name)) if not destination.startswith(os.path.abspath(tmp_dir) + os.sep): raise ValueError(f"Unsafe ZIP member path: {info.filename}") if info.is_dir(): os.makedirs(destination, exist_ok=True) continue os.makedirs(os.path.dirname(destination), exist_ok=True) with zip_obj.open(info, "r") as source, open(destination, "wb") as target_stream: shutil.copyfileobj(source, target_stream, length=1024 * 1024) extracted += 1 elif tarfile.is_tarfile(archive): with tarfile.open(archive, "r:*") as tar_obj: for member in tar_obj: rel_name = _safe_archive_member_name(member.name, archive) destination = os.path.abspath(os.path.join(tmp_dir, rel_name)) if not destination.startswith(os.path.abspath(tmp_dir) + os.sep): raise ValueError(f"Unsafe TAR member path: {member.name}") if member.isdir(): os.makedirs(destination, exist_ok=True) continue if not member.isfile(): continue source = tar_obj.extractfile(member) if source is None: continue os.makedirs(os.path.dirname(destination), exist_ok=True) with source, open(destination, "wb") as target_stream: shutil.copyfileobj(source, target_stream, length=1024 * 1024) extracted += 1 else: raise ValueError(f"Unsupported archive format: {archive}") if extracted <= 0: raise OSError(f"Archive extraction produced no files: {archive}") os.makedirs(os.path.dirname(target), exist_ok=True) os.replace(tmp_dir, target) return {"status": "EXTRACTED", "archive_path": archive, "target_dir": target, "extracted": True, "member_count": extracted} finally: if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir, ignore_errors=True) def _archive_integrity_supported(source_format: Any, path: str) -> bool: normalized_format = str(source_format or "").upper() if normalized_format not in {"LT1_ARCHIVE", "S1_ZIP", "GF3_ARCHIVE"}: return False ext = _file_ext_for_path(path) if normalized_format == "S1_ZIP": return ext == ".zip" return ext in {".tar.gz", ".tgz", ".tar", ".zip"} def _truncate_error_text(value: Any, limit: int = 1000) -> Optional[str]: if value is None: return None text = str(value) if len(text) <= limit: return text return text[: limit - 3] + "..." def _check_zip_archive_integrity(path: str) -> Dict[str, Any]: method = "zip_testzip" with zipfile.ZipFile(path, "r") as archive: infos = archive.infolist() member_count = 0 total_uncompressed = 0 for info in infos: _safe_archive_member_name(info.filename, path) if info.is_dir(): continue member_count += 1 total_uncompressed += int(info.file_size or 0) bad_member = archive.testzip() if bad_member: return { "status": "FAILED", "method": method, "error": f"ZIP CRC failed at member: {bad_member}", "member_count": member_count, "uncompressed_bytes": total_uncompressed, } return { "status": "OK", "method": method, "error": None, "member_count": member_count, "uncompressed_bytes": total_uncompressed, } def _check_tar_archive_integrity(path: str) -> Dict[str, Any]: method = "tar_stream_list" member_count = 0 total_uncompressed = 0 with tarfile.open(path, "r:*") as archive: for member in archive: _safe_archive_member_name(member.name, path) if member.issym() or member.islnk() or member.isdev(): raise ValueError(f"Unsupported TAR member type: {member.name}") if member.isfile(): member_count += 1 total_uncompressed += int(member.size or 0) elif member.isdir(): continue else: raise ValueError(f"Unsupported TAR member type: {member.name}") return { "status": "OK", "method": method, "error": None, "member_count": member_count, "uncompressed_bytes": total_uncompressed, } def _check_archive_integrity(path: str, source_format: Any = None) -> Dict[str, Any]: archive = _normalize_path(path) started = _utcnow() stat = _stat_path(archive) if not os.path.isfile(archive): return { "status": "FAILED", "method": None, "error": f"Archive file is missing: {archive}", "member_count": None, "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "duration_seconds": 0.0, "version": ARCHIVE_INTEGRITY_VERSION, } if not _archive_integrity_supported(source_format, archive): return { "status": "UNSUPPORTED", "method": None, "error": f"Unsupported archive integrity source_format={source_format} ext={_file_ext_for_path(archive)}", "member_count": None, "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "duration_seconds": 0.0, "version": ARCHIVE_INTEGRITY_VERSION, } try: ext = _file_ext_for_path(archive) if ext == ".zip": result = _check_zip_archive_integrity(archive) elif ext in {".tar.gz", ".tgz", ".tar"}: result = _check_tar_archive_integrity(archive) else: result = { "status": "UNSUPPORTED", "method": None, "error": f"Unsupported archive extension: {ext}", "member_count": None, } except Exception as exc: result = { "status": "FAILED", "method": "zip_testzip" if _file_ext_for_path(archive) == ".zip" else "tar_stream_list", "error": str(exc), "member_count": None, } duration = (_utcnow() - started).total_seconds() result.update( { "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "duration_seconds": round(max(0.0, duration), 3), "version": ARCHIVE_INTEGRITY_VERSION, } ) result["error"] = _truncate_error_text(result.get("error")) return result def _parse_datetime_token(value: Optional[str]) -> Optional[datetime]: text = str(value or "").strip() if not text: return None if text.startswith("UTC="): text = text[4:] text = text.rstrip("Z") try: if "-" in text and "." in text: return datetime.strptime(text, "%Y-%m-%dT%H:%M:%S.%f") if "-" in text: return datetime.strptime(text, "%Y-%m-%dT%H:%M:%S") if "." in text: return datetime.strptime(text, "%Y%m%dT%H%M%S.%f") return datetime.strptime(text, "%Y%m%dT%H%M%S") except ValueError: return None def _xml_text_by_local_names(root: etree._Element, names: Sequence[str]) -> Optional[str]: name_set = {str(item).lower() for item in names} for element in root.iter(): local_name = etree.QName(element).localname.lower() if local_name not in name_set: continue text = str(element.text or "").strip() if text: return text return None def _xml_text_under_local_path(root: etree._Element, parent_name: str, child_name: str) -> Optional[str]: parent_key = parent_name.lower() child_key = child_name.lower() for parent in root.iter(): if etree.QName(parent).localname.lower() != parent_key: continue for child in parent.iter(): if child is parent: continue if etree.QName(child).localname.lower() == child_key: text = str(child.text or "").strip() if text: return text return None def _xml_float(value: Optional[str]) -> Optional[float]: try: if value is None or str(value).strip() == "": return None return float(str(value).strip()) except (TypeError, ValueError): return None def _xml_int(value: Optional[str]) -> Optional[int]: try: if value is None or str(value).strip() == "": return None return int(float(str(value).strip())) except (TypeError, ValueError): return None def _parse_radar_xml_metadata_bytes(data: bytes) -> Tuple[Optional[List[Tuple[float, float]]], Dict[str, Any]]: parser = _xml_parser() root = etree.fromstring(data, parser=parser) corners: List[Dict[str, Any]] = [] for element in root.iter(): if etree.QName(element).localname.lower() != "scenecornercoord": continue lon = _xml_float(_xml_text_under_local_path(element, "sceneCornerCoord", "lon") or _xml_text_by_local_names(element, ["lon"])) lat = _xml_float(_xml_text_under_local_path(element, "sceneCornerCoord", "lat") or _xml_text_by_local_names(element, ["lat"])) if lon is not None and lat is not None: corners.append( { "name": element.get("name"), "lon": lon, "lat": lat, "ref_row": _xml_int( _xml_text_under_local_path(element, "sceneCornerCoord", "refRow") or _xml_text_by_local_names(element, ["refRow"]) ), "ref_col": _xml_int( _xml_text_under_local_path(element, "sceneCornerCoord", "refColumn") or _xml_text_by_local_names(element, ["refColumn"]) ), } ) coverage_polygon: Optional[List[Tuple[float, float]]] = None corner_details: Dict[str, Dict[str, Any]] = {} if len(corners) >= 4: coverage_polygon = _ordered_closed_polygon_from_corners(corners[:4]) corner_details = { str(item.get("name")): { "lon": item.get("lon"), "lat": item.get("lat"), "ref_row": item.get("ref_row"), "ref_col": item.get("ref_col"), } for item in corners if item.get("name") } start_time = ( _xml_text_under_local_path(root, "start", "timeUTC") or _xml_text_by_local_names(root, ["startTime", "start_time", "beginPosition"]) ) stop_time = ( _xml_text_under_local_path(root, "stop", "timeUTC") or _xml_text_by_local_names(root, ["stopTime", "stop_time", "endPosition"]) ) center_lon = _xml_float(_xml_text_under_local_path(root, "sceneCenterCoord", "lon")) center_lat = _xml_float(_xml_text_under_local_path(root, "sceneCenterCoord", "lat")) metadata = { "orbit_direction": (_xml_text_by_local_names(root, ["pass", "orbitDirection"]) or "").upper() or None, "imaging_mode": _xml_text_under_local_path(root, "acquisitionInfo", "imagingMode") or _xml_text_under_local_path(root, "orderInfo", "imagingMode") or _xml_text_by_local_names(root, ["imagingMode"]), "polarization": _xml_text_under_local_path(root, "acquisitionInfo", "polarisationMode") or _xml_text_under_local_path(root, "polarisationList", "polLayer") or _xml_text_under_local_path(root, "polList", "polLayer") or _xml_text_by_local_names(root, ["polarisationMode", "polarization", "polarisation", "polLayer"]), "receiving_station": _xml_text_under_local_path(root, "generationInfo", "receivingStation") or _xml_text_by_local_names(root, ["receivingStation"]), "satellite_mode": _xml_text_by_local_names(root, ["satelliteMode"]), "orbit_circle": _xml_text_under_local_path(root, "missionInfo", "absOrbit") or _xml_text_by_local_names(root, ["absOrbit", "absoluteOrbit"]), "relative_orbit": _xml_text_under_local_path(root, "missionInfo", "relOrbit") or _xml_text_by_local_names(root, ["relOrbit", "relativeOrbit"]), "scene_center_lon": center_lon, "scene_center_lat": center_lat, "acquisition_time_utc": start_time, "acquisition_stop_time_utc": stop_time, "product_type": _xml_text_by_local_names(root, ["productType"]), "image_data_type": _xml_text_under_local_path(root, "imageDataInfo", "imageDataType") or _xml_text_by_local_names(root, ["imageDataType"]), "product_variant": _xml_text_under_local_path(root, "orderInfo", "productVariant") or _xml_text_by_local_names(root, ["productVariant"]), "image_data_format": _xml_text_under_local_path(root, "imageDataInfo", "imageDataFormat") or _xml_text_by_local_names(root, ["imageDataFormat"]), "product_level": _xml_text_by_local_names(root, ["productLevel", "itemName"]), "product_unique_id": _xml_text_by_local_names(root, ["logicalProductID", "sceneID", "productID"]), "look_direction": (_xml_text_under_local_path(root, "acquisitionInfo", "lookDirection") or "").upper() or None, "corner_ref_pixels": { str(item.get("name")): { "ref_row": item.get("ref_row"), "ref_col": item.get("ref_col"), } for item in corners if item.get("name") }, "corner_pixel_mapping": build_corner_pixel_mapping(corner_details), "coverage_polygon": coverage_polygon, } return coverage_polygon, {key: value for key, value in metadata.items() if value not in (None, "", [])} def _date_start_stop(date_yyyymmdd: str) -> Tuple[Optional[datetime], Optional[datetime]]: try: start = datetime.strptime(date_yyyymmdd, "%Y%m%d") except ValueError: return None, None return start, start + timedelta(days=1) def _json_safe(value: Any) -> Any: if isinstance(value, datetime): return value.isoformat() if isinstance(value, tuple): return [_json_safe(item) for item in value] if isinstance(value, list): return [_json_safe(item) for item in value] if isinstance(value, dict): return {str(key): _json_safe(item) for key, item in value.items()} return value def _metadata_document( *, document_type: str, member_path: str, content: bytes, source_format: Optional[str], satellite_family: Optional[str], archive_path: str, archive_mtime: Optional[float], parse_status: str = "OK", parse_error: Optional[str] = None, ) -> Dict[str, Any]: payload = bytes(content or b"") return { "document_type": document_type, "member_path": member_path or document_type, "content_sha256": hashlib.sha256(payload).hexdigest(), "content_encoding": "gzip", "content_bytes": gzip.compress(payload), "content_size_bytes": len(payload), "source_format": source_format, "satellite_family": satellite_family, "archive_path": archive_path, "archive_mtime": archive_mtime, "parser_version": PARSER_VERSION, "parse_status": parse_status, "parse_error": parse_error, "extracted_at": _utcnow(), } def _s1_annotation_sort_key(path: str) -> Tuple[int, str]: normalized = str(path or "").replace("\\", "/") lower = normalized.lower() base = PurePosixPath(normalized).name.lower() is_direct_annotation = "/annotation/" in lower and lower.count("/annotation/") == 1 is_measurement_annotation = is_direct_annotation and base.startswith("s1") and base.endswith(".xml") if is_measurement_annotation: rank = 0 elif "/annotation/calibration/" in lower: rank = 2 elif "/annotation/noise/" in lower: rank = 3 elif "/annotation/rfi/" in lower: rank = 4 else: rank = 1 return rank, lower def _parse_s1_preview_kml_bytes(data: bytes) -> Dict[str, Any]: try: root = etree.fromstring(data, parser=_xml_parser()) except Exception: return {} coordinate_texts = root.xpath("//*[local-name()='LatLonQuad']/*[local-name()='coordinates']/text()") if not coordinate_texts: return {} points: List[Tuple[float, float]] = [] for token in str(coordinate_texts[0] or "").strip().split(): parts = token.split(",") if len(parts) < 2: continue try: points.append((float(parts[0]), float(parts[1]))) except ValueError: continue if len(points) != 4: return {} mapping = { "bottom_left": [points[0][0], points[0][1]], "bottom_right": [points[1][0], points[1][1]], "top_right": [points[2][0], points[2][1]], "top_left": [points[3][0], points[3][1]], "source": "s1_preview_map_overlay_kml", } polygon = [points[0], points[1], points[2], points[3], points[0]] return { "preview_map_overlay_polygon": polygon, "corner_pixel_mapping": mapping, } def _extract_s1_annotation_documents(source_path: str, *, limit: int = 16) -> List[Dict[str, Any]]: docs: List[Dict[str, Any]] = [] stat = _stat_path(source_path) if os.path.isdir(source_path): annotation_root = os.path.join(source_path, "annotation") if not os.path.isdir(annotation_root): return docs candidates: List[str] = [] for current_root, _, files in os.walk(annotation_root): for file_name in files: if file_name.lower().endswith(".xml"): candidates.append(os.path.join(current_root, file_name)) for path in sorted(candidates, key=_s1_annotation_sort_key)[: max(0, limit)]: try: with open(path, "rb") as stream: data = stream.read() docs.append( _metadata_document( document_type="S1_ANNOTATION", member_path=os.path.relpath(path, source_path).replace("\\", "/"), content=data, source_format="S1_SAFE_DIR", satellite_family="S1", archive_path=source_path, archive_mtime=stat.get("mtime_epoch"), ) ) except OSError: continue return docs try: with zipfile.ZipFile(source_path) as archive: names = [ name for name in archive.namelist() if "/annotation/" in name.lower() and name.lower().endswith(".xml") ] for name in sorted(names, key=_s1_annotation_sort_key)[: max(0, limit)]: docs.append( _metadata_document( document_type="S1_ANNOTATION", member_path=name, content=archive.read(name), source_format="S1_ZIP", satellite_family="S1", archive_path=source_path, archive_mtime=stat.get("mtime_epoch"), ) ) except Exception: return docs return docs def _xml_parser() -> etree.XMLParser: return etree.XMLParser( resolve_entities=False, load_dtd=False, no_network=True, huge_tree=False, recover=False, ) def _local_name(element: etree._Element) -> str: try: return etree.QName(element).localname except Exception: return str(element.tag).split("}")[-1] def _first_text_by_local_name(root: etree._Element, names: Sequence[str]) -> Optional[str]: wanted = {name.lower() for name in names} for element in root.iter(): if _local_name(element).lower() in wanted: text = (element.text or "").strip() if text: return text return None def _texts_by_local_name(root: etree._Element, name: str) -> List[str]: wanted = name.lower() values: List[str] = [] for element in root.iter(): if _local_name(element).lower() != wanted: continue text = (element.text or "").strip() if text and text not in values: values.append(text) return values def _s1_polygon_from_coordinates(text: Optional[str]) -> Optional[List[Tuple[float, float]]]: if not text: return None points: List[Tuple[float, float]] = [] for token in re.split(r"\s+", text.strip()): if not token: continue parts = [part for part in re.split(r"[,;]", token) if part] if len(parts) < 2: continue try: first = float(parts[0]) second = float(parts[1]) except ValueError: continue # Sentinel-1 manifest gml:coordinates commonly stores lat,lon. if abs(first) > 90.0 and abs(second) <= 90.0: lon, lat = first, second else: lon, lat = second, first points.append((lon, lat)) return _ordered_closed_polygon(points) def _bbox_from_polygon(points: Optional[List[Tuple[float, float]]]) -> Optional[Tuple[float, float, float, float]]: ordered = _ordered_closed_polygon(points or []) if not ordered or len(ordered) < 4: return None lons = [float(point[0]) for point in ordered] lats = [float(point[1]) for point in ordered] return min(lons), min(lats), max(lons), max(lats) def _centroid_from_polygon(points: Optional[List[Tuple[float, float]]]) -> Tuple[Optional[float], Optional[float]]: ordered = _ordered_closed_polygon(points or []) if not ordered or len(ordered) < 4: return None, None try: poly = Polygon(ordered) if not poly.is_valid: poly = poly.buffer(0) if poly.is_empty: return None, None return float(poly.centroid.x), float(poly.centroid.y) except Exception: return None, None def _parse_s1_source_name(name: str) -> Optional[Dict[str, Any]]: base = _strip_known_suffix(os.path.basename(name or "")) match = _S1_SOURCE_RE.match(base) if not match: return None start_time = _parse_datetime_token(match.group("start")) stop_time = _parse_datetime_token(match.group("stop")) class_token = match.group("class").upper() polarization = class_token[-2:] if len(class_token) >= 2 else class_token absolute_orbit = match.group("absolute_orbit").lstrip("0") or match.group("absolute_orbit") return { "logical_product_uid": base, "satellite": match.group("satellite").upper(), "satellite_family": "S1", "source_format": "S1_ZIP" if name.lower().endswith(".zip") else "S1_SAFE_DIR", "product_type": match.group("product").upper(), "product_level": "L1", "imaging_mode": match.group("mode").upper(), "polarization": polarization, "absolute_orbit": absolute_orbit, "acquisition_start_time_utc": start_time, "acquisition_stop_time_utc": stop_time, "imaging_date": match.group("start")[:8], "source_product_token": class_token, "metadata": { "filename_datatake": match.group("datatake").upper(), "filename_product_uid": match.group("product_uid").upper(), "filename_absolute_orbit": match.group("absolute_orbit"), "filename_class_token": class_token, }, } def _parse_s1_manifest_bytes(data: bytes) -> Dict[str, Any]: root = etree.fromstring(data, parser=_xml_parser()) start_time = _parse_datetime_token(_first_text_by_local_name(root, ["startTime"])) stop_time = _parse_datetime_token(_first_text_by_local_name(root, ["stopTime"])) product_type = _first_text_by_local_name(root, ["productType"]) mode = _first_text_by_local_name(root, ["mode"]) orbit_direction = _first_text_by_local_name(root, ["pass"]) polarizations = _texts_by_local_name(root, "transmitterReceiverPolarisation") absolute_orbit = _first_text_by_local_name(root, ["orbitNumber"]) relative_orbit = _first_text_by_local_name(root, ["relativeOrbitNumber"]) coordinates = _first_text_by_local_name(root, ["coordinates"]) coverage_polygon = _s1_polygon_from_coordinates(coordinates) values: Dict[str, Any] = { "manifest_start_time": start_time, "manifest_stop_time": stop_time, "manifest_product_type": product_type.strip().upper() if product_type else None, "manifest_mode": mode.strip().upper() if mode else None, "manifest_orbit_direction": orbit_direction.strip().upper() if orbit_direction else None, "manifest_polarizations": [item.strip().upper() for item in polarizations if item.strip()], "manifest_absolute_orbit": absolute_orbit.strip() if absolute_orbit else None, "manifest_relative_orbit": relative_orbit.strip() if relative_orbit else None, "coverage_polygon": coverage_polygon, } return {key: value for key, value in values.items() if value not in (None, "", [])} def _parse_s1_zip_manifest(path: str) -> Dict[str, Any]: stat = _stat_path(path) with zipfile.ZipFile(path) as archive: manifest_name = None preview_kml_name = None annotation_names: List[str] = [] for info in archive.infolist(): if info.is_dir(): continue name = info.filename lower = name.lower() if manifest_name is None and (lower.endswith("/manifest.safe") or lower == "manifest.safe"): manifest_name = name if preview_kml_name is None and lower.endswith("/preview/map-overlay.kml"): preview_kml_name = name if "/annotation/" in lower and lower.endswith(".xml"): annotation_names.append(name) if not manifest_name: return {"manifest_parse_status": "MISSING"} manifest_bytes = archive.read(manifest_name) annotation_documents = [ _metadata_document( document_type="S1_ANNOTATION", member_path=name, content=archive.read(name), source_format="S1_ZIP", satellite_family="S1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ) for name in sorted(annotation_names, key=_s1_annotation_sort_key)[:16] ] preview_kml_bytes = archive.read(preview_kml_name) if preview_kml_name else None preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {} preview_kml_documents = [ _metadata_document( document_type="S1_PREVIEW_KML", member_path=preview_kml_name or "preview/map-overlay.kml", content=preview_kml_bytes, source_format="S1_ZIP", satellite_family="S1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ) ] if preview_kml_bytes else [] return { "manifest_parse_status": "OK", "manifest_path": manifest_name, "metadata_documents": [ _metadata_document( document_type="S1_MANIFEST", member_path=manifest_name, content=manifest_bytes, source_format="S1_ZIP", satellite_family="S1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ), *preview_kml_documents, *annotation_documents, ], **preview_kml_meta, **_parse_s1_manifest_bytes(manifest_bytes), } def _parse_s1_safe_manifest(path: str) -> Dict[str, Any]: manifest_path = os.path.join(path, "manifest.safe") if not os.path.isfile(manifest_path): return {"manifest_parse_status": "MISSING"} stat = _stat_path(path) with open(manifest_path, "rb") as stream: manifest_bytes = stream.read() preview_kml_path = os.path.join(path, "preview", "map-overlay.kml") preview_kml_bytes = None if os.path.isfile(preview_kml_path): with open(preview_kml_path, "rb") as preview_stream: preview_kml_bytes = preview_stream.read() preview_kml_meta = _parse_s1_preview_kml_bytes(preview_kml_bytes) if preview_kml_bytes else {} preview_kml_documents = [ _metadata_document( document_type="S1_PREVIEW_KML", member_path="preview/map-overlay.kml", content=preview_kml_bytes, source_format="S1_SAFE_DIR", satellite_family="S1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ) ] if preview_kml_bytes else [] return { "manifest_parse_status": "OK", "manifest_path": manifest_path, "metadata_documents": [ _metadata_document( document_type="S1_MANIFEST", member_path="manifest.safe", content=manifest_bytes, source_format="S1_SAFE_DIR", satellite_family="S1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ), *preview_kml_documents, *_extract_s1_annotation_documents(path), ], **preview_kml_meta, **_parse_s1_manifest_bytes(manifest_bytes), } def _parse_lt1_archive_metadata(path: str) -> Dict[str, Any]: archive_stem = _strip_known_suffix(os.path.basename(path)) stat = _stat_path(path) xml_member, xml_data, tiff_members, members = _archive_collect_members( path, content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".meta.xml"), list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".tiff", ".tif")), list_limit=8, ) if not xml_member or not xml_data: return { "archive_parse_status": "MISSING_XML", "archive_member_count_scanned": len(members), "contained_tiff_members": tiff_members, } coverage_polygon, xml_meta = _parse_radar_xml_metadata_bytes(xml_data) return { "archive_parse_status": "OK", "archive_xml_member": xml_member, "archive_scene_name": _archive_member_scene_name(xml_member, archive_stem), "contained_tiff_members": tiff_members, "metadata_documents": [ _metadata_document( document_type="LT1_META", member_path=xml_member, content=xml_data, source_format="LT1_ARCHIVE", satellite_family="LT1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ) ], "coverage_polygon": coverage_polygon, **xml_meta, } def _parse_gf3_archive_metadata(path: str) -> Dict[str, Any]: archive_stem = _strip_known_suffix(os.path.basename(path)) xml_member, xml_data, quicklooks, members = _archive_collect_members( path, content_predicate=lambda name: _archive_member_base_name(name).lower().endswith(".xml"), list_predicate=lambda name: _archive_member_base_name(name).lower().endswith((".jpg", ".jpeg", ".png", ".bmp", "_ql.tif", "_ql.tiff")), list_limit=8, ) if not xml_member or not xml_data: return { "archive_parse_status": "MISSING_XML", "archive_member_count_scanned": len(members), "quicklook_members": quicklooks, } coverage_polygon, xml_meta = _parse_radar_xml_metadata_bytes(xml_data) return { "archive_parse_status": "OK", "archive_xml_member": xml_member, "archive_scene_name": _archive_member_scene_name(xml_member, archive_stem), "quicklook_members": quicklooks, "coverage_polygon": coverage_polygon, **xml_meta, } def _parse_s1_eof_header(path: str) -> Dict[str, Any]: try: root = etree.parse(path, parser=_xml_parser()).getroot() except Exception as exc: return {"header_parse_status": "FAILED", "header_parse_error": str(exc)} def _time(name: str) -> Optional[datetime]: return _parse_datetime_token(_first_text_by_local_name(root, [name])) mission = _first_text_by_local_name(root, ["Mission"]) file_type = _first_text_by_local_name(root, ["File_Type"]) return { "header_parse_status": "OK", "header_mission": mission, "header_file_type": file_type, "header_validity_start": _time("Validity_Start"), "header_validity_stop": _time("Validity_Stop"), "header_creation_date": _time("Creation_Date"), } def _parse_source_entry(path: str, root: ManagedRootORM) -> Optional[Dict[str, Any]]: path = _normalize_path(path) name = os.path.basename(path) lower_name = name.lower() stat = _stat_path(path) now = _utcnow() name_stem = _strip_known_suffix(name) if lower_name.endswith(".zip") and name.upper().startswith("S1"): name_meta = _parse_s1_source_name(name) if not name_meta: return None parse_status = "OK" parse_error = None manifest_meta: Dict[str, Any] = {} try: manifest_meta = _parse_s1_zip_manifest(path) except Exception as exc: parse_status = "PARTIAL" parse_error = str(exc) manifest_meta = {"manifest_parse_status": "FAILED", "manifest_parse_error": str(exc)} return _build_s1_source_asset(path, root, name_meta, manifest_meta, stat, parse_status, parse_error, now) if _has_archive_suffix(name, _LT1_ARCHIVE_EXTS) and name_stem.upper().startswith("LT1"): parsed = parse_lt1_radar_filename(name_stem) if not parsed: return None parse_status = "OK" parse_error = None archive_meta: Dict[str, Any] = {} try: archive_meta = _parse_lt1_archive_metadata(path) if archive_meta.get("archive_parse_status") != "OK": parse_status = "PARTIAL" parse_error = str(archive_meta.get("archive_parse_status") or "archive metadata incomplete") except Exception as exc: parse_status = "PARTIAL" parse_error = str(exc) archive_meta = {"archive_parse_status": "FAILED", "archive_parse_error": str(exc)} return _build_lt1_source_asset( path, root, parsed, archive_meta, archive_meta.get("coverage_polygon"), stat, now, source_format="LT1_ARCHIVE", archive_path=path, parser_name="lt1_archive_metadata", parse_status=parse_status, parse_error=parse_error, ) if _has_archive_suffix(name, _GF3_ARCHIVE_EXTS) and name_stem.upper().startswith("GF3"): parsed = parse_gf3_l2_dirname(name_stem) if not parsed: return None parse_status = "OK" parse_error = None archive_meta = {} try: archive_meta = _parse_gf3_archive_metadata(path) if archive_meta.get("archive_parse_status") != "OK": parse_status = "PARTIAL" parse_error = str(archive_meta.get("archive_parse_status") or "archive metadata incomplete") except Exception as exc: parse_status = "PARTIAL" parse_error = str(exc) archive_meta = {"archive_parse_status": "FAILED", "archive_parse_error": str(exc)} return _build_gf3_archive_asset(path, root, parsed, archive_meta, stat, parse_status, parse_error, now) if lower_name.endswith(".safe") and os.path.isdir(path) and name.upper().startswith("S1"): name_meta = _parse_s1_source_name(name) if not name_meta: return None name_meta["source_format"] = "S1_SAFE_DIR" parse_status = "OK" parse_error = None manifest_meta = {} try: manifest_meta = _parse_s1_safe_manifest(path) except Exception as exc: parse_status = "PARTIAL" parse_error = str(exc) manifest_meta = {"manifest_parse_status": "FAILED", "manifest_parse_error": str(exc)} return _build_s1_source_asset(path, root, name_meta, manifest_meta, stat, parse_status, parse_error, now) if os.path.isdir(path) and name.upper().startswith("LT1"): parsed = parse_lt1_radar_filename(name) if not parsed: return None coverage_polygon = None xml_meta: Dict[str, Any] = {} xml_path = find_xml_file(path) if xml_path: try: coverage_polygon, parsed_xml = parse_xml_metadata(xml_path) if parsed_xml: xml_meta = parsed_xml with open(xml_path, "rb") as stream: xml_meta["metadata_documents"] = [ _metadata_document( document_type="LT1_META", member_path=os.path.relpath(xml_path, path).replace("\\", "/"), content=stream.read(), source_format="LT1_DIR", satellite_family="LT1", archive_path=path, archive_mtime=stat.get("mtime_epoch"), ) ] except Exception as exc: xml_meta = {"xml_parse_error": str(exc), "xml_path": xml_path} return _build_lt1_source_asset(path, root, parsed, xml_meta, coverage_polygon, stat, now) return None def _build_s1_source_asset( path: str, root: ManagedRootORM, name_meta: Dict[str, Any], manifest_meta: Dict[str, Any], stat: Dict[str, Optional[float]], parse_status: str, parse_error: Optional[str], now: datetime, ) -> Dict[str, Any]: metadata = dict(name_meta.get("metadata") or {}) metadata.update({key: value for key, value in manifest_meta.items() if key != "metadata_documents"}) coverage_polygon = manifest_meta.get("coverage_polygon") centroid_lon, centroid_lat = _centroid_from_polygon(coverage_polygon) bbox = _bbox_from_polygon(coverage_polygon) metadata.update( { "coverage_polygon": coverage_polygon, "coverage_bbox": bbox, "scene_center_lon": centroid_lon, "scene_center_lat": centroid_lat, } ) manifest_pols = manifest_meta.get("manifest_polarizations") or [] if manifest_pols: metadata["polarization_channels"] = manifest_pols row = { "asset_uid": _asset_uid("source", path), "logical_product_uid": name_meta.get("logical_product_uid"), "satellite_family": "S1", "satellite": name_meta.get("satellite"), "source_format": name_meta.get("source_format") or "S1_ZIP", "product_type": manifest_meta.get("manifest_product_type") or name_meta.get("product_type"), "product_level": name_meta.get("product_level"), "imaging_mode": manifest_meta.get("manifest_mode") or name_meta.get("imaging_mode"), "polarization": name_meta.get("polarization"), "absolute_orbit": manifest_meta.get("manifest_absolute_orbit") or name_meta.get("absolute_orbit"), "relative_orbit": manifest_meta.get("manifest_relative_orbit"), "orbit_direction": manifest_meta.get("manifest_orbit_direction"), "acquisition_start_time_utc": manifest_meta.get("manifest_start_time") or name_meta.get("acquisition_start_time_utc"), "acquisition_stop_time_utc": manifest_meta.get("manifest_stop_time") or name_meta.get("acquisition_stop_time_utc"), "imaging_date": name_meta.get("imaging_date"), "root_ref_id": root.id, "root_path": root.path, "file_path": path, "archive_path": path if path.lower().endswith(".zip") else None, "path_kind": _path_kind(path), "file_name": os.path.basename(path), "file_stem": _strip_known_suffix(os.path.basename(path)), "file_ext": _file_ext_for_path(path), "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "checksum_status": "NOT_COMPUTED", "parser_name": "sentinel1_source_manifest", "parser_version": PARSER_VERSION, "parse_status": parse_status, "parse_error": parse_error, "parsed_at": now, "metadata_json": _json_safe(metadata), "is_active": True, "missing_since": None, "updated_at": now, } row["_metadata_documents"] = manifest_meta.get("metadata_documents") or [] return row def _build_lt1_source_asset( path: str, root: ManagedRootORM, parsed: Dict[str, Any], xml_meta: Dict[str, Any], coverage_polygon: Optional[List[Tuple[float, float]]], stat: Dict[str, Optional[float]], now: datetime, *, source_format: str = "LT1_DIR", archive_path: Optional[str] = None, parser_name: str = "lt1_source_directory", parse_status: str = "OK", parse_error: Optional[str] = None, ) -> Dict[str, Any]: metadata = dict(parsed) metadata.update({key: value for key, value in xml_meta.items() if key != "metadata_documents" and value not in (None, "")}) metadata["coverage_polygon"] = coverage_polygon metadata["coverage_bbox"] = _bbox_from_polygon(coverage_polygon) centroid_lon, centroid_lat = _centroid_from_polygon(coverage_polygon) metadata["scene_center_lon"] = parsed.get("scene_center_lon") if parsed.get("scene_center_lon") is not None else centroid_lon metadata["scene_center_lat"] = parsed.get("scene_center_lat") if parsed.get("scene_center_lat") is not None else centroid_lat satellite = parsed.get("satellite") imaging_date = parsed.get("imaging_date") row = { "asset_uid": _asset_uid("source", path), "logical_product_uid": _strip_known_suffix(os.path.basename(path)), "satellite_family": normalize_satellite_family(satellite), "satellite": satellite, "source_format": source_format, "product_type": parsed.get("product_type") or xml_meta.get("product_type"), "product_level": xml_meta.get("product_level") or parsed.get("product_level"), "imaging_mode": xml_meta.get("imaging_mode") or parsed.get("imaging_mode"), "polarization": xml_meta.get("polarization") or parsed.get("polarization"), "absolute_orbit": xml_meta.get("orbit_circle") or parsed.get("orbit_circle"), "relative_orbit": xml_meta.get("relative_orbit"), "orbit_direction": xml_meta.get("orbit_direction") or parsed.get("orbit_direction"), "acquisition_start_time_utc": _parse_datetime_token(xml_meta.get("acquisition_time_utc")), "acquisition_stop_time_utc": _parse_datetime_token(xml_meta.get("acquisition_stop_time_utc")), "imaging_date": imaging_date, "root_ref_id": root.id, "root_path": root.path, "file_path": path, "archive_path": archive_path, "path_kind": _path_kind(path), "file_name": os.path.basename(path), "file_stem": _strip_known_suffix(os.path.basename(path)), "file_ext": _file_ext_for_path(path), "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "checksum_status": "NOT_COMPUTED", "parser_name": parser_name, "parser_version": PARSER_VERSION, "parse_status": parse_status, "parse_error": parse_error, "parsed_at": now, "metadata_json": _json_safe(metadata), "is_active": True, "missing_since": None, "updated_at": now, } row["_metadata_documents"] = xml_meta.get("metadata_documents") or [] return row def _build_gf3_archive_asset( path: str, root: ManagedRootORM, parsed: Dict[str, Any], archive_meta: Dict[str, Any], stat: Dict[str, Optional[float]], parse_status: str, parse_error: Optional[str], now: datetime, ) -> Dict[str, Any]: coverage_polygon = archive_meta.get("coverage_polygon") metadata = dict(parsed) metadata.update({key: value for key, value in archive_meta.items() if value not in (None, "")}) metadata["coverage_polygon"] = coverage_polygon metadata["coverage_bbox"] = _bbox_from_polygon(coverage_polygon) centroid_lon, centroid_lat = _centroid_from_polygon(coverage_polygon) metadata["scene_center_lon"] = parsed.get("scene_center_lon") if parsed.get("scene_center_lon") is not None else centroid_lon metadata["scene_center_lat"] = parsed.get("scene_center_lat") if parsed.get("scene_center_lat") is not None else centroid_lat start_time = _parse_datetime_token(archive_meta.get("acquisition_time_utc")) stop_time = _parse_datetime_token(archive_meta.get("acquisition_stop_time_utc")) imaging_date = parsed.get("imaging_date") if not imaging_date and start_time: imaging_date = start_time.strftime("%Y%m%d") stem = _strip_known_suffix(os.path.basename(path)) return { "asset_uid": _asset_uid("source", path), "logical_product_uid": archive_meta.get("product_unique_id") or stem, "satellite_family": "GF3", "satellite": "GF3", "source_format": "GF3_ARCHIVE", "product_type": archive_meta.get("product_type") or parsed.get("product_type") or "L1A", "product_level": archive_meta.get("product_level") or parsed.get("product_level") or "L1A", "imaging_mode": archive_meta.get("imaging_mode") or parsed.get("imaging_mode"), "polarization": archive_meta.get("polarization") or parsed.get("polarization"), "absolute_orbit": archive_meta.get("orbit_circle") or parsed.get("orbit_circle"), "relative_orbit": archive_meta.get("relative_orbit"), "orbit_direction": archive_meta.get("orbit_direction") or parsed.get("orbit_direction"), "acquisition_start_time_utc": start_time, "acquisition_stop_time_utc": stop_time, "imaging_date": imaging_date, "root_ref_id": root.id, "root_path": root.path, "file_path": path, "archive_path": path, "path_kind": _path_kind(path), "file_name": os.path.basename(path), "file_stem": stem, "file_ext": _file_ext_for_path(path), "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "checksum_status": "NOT_COMPUTED", "parser_name": "gf3_archive_metadata", "parser_version": PARSER_VERSION, "parse_status": parse_status, "parse_error": parse_error, "parsed_at": now, "metadata_json": _json_safe(metadata), "is_active": True, "missing_since": None, "updated_at": now, } def _parse_orbit_entry(path: str, root: ManagedRootORM) -> Optional[Dict[str, Any]]: path = _normalize_path(path) name = os.path.basename(path) lower_name = name.lower() stat = _stat_path(path) now = _utcnow() if lower_name.endswith(".eof") and name.upper().startswith("S1"): match = _S1_EOF_RE.match(name) if not match: return None header_meta = _parse_s1_eof_header(path) orbit_type = match.group("orbit_type").upper() valid_start = header_meta.get("header_validity_start") or _parse_datetime_token(match.group("valid_start")) valid_stop = header_meta.get("header_validity_stop") or _parse_datetime_token(match.group("valid_stop")) generation_time = header_meta.get("header_creation_date") or _parse_datetime_token(match.group("generation")) parse_status = "OK" if header_meta.get("header_parse_status") != "FAILED" else "PARTIAL" quality_class = "precise" if orbit_type == "AUX_POEORB" else "restituted" if orbit_type == "AUX_RESORB" else "unknown" metadata = { "filename_provider": match.group("provider").upper(), "filename_generation_time": match.group("generation"), "filename_validity_start": match.group("valid_start"), "filename_validity_stop": match.group("valid_stop"), **header_meta, } return { "orbit_uid": _asset_uid("orbit", path), "satellite_family": "S1", "satellite": match.group("satellite").upper(), "orbit_type": orbit_type, "native_format": "EOF", "quality_class": quality_class, "root_ref_id": root.id, "root_path": root.path, "file_path": path, "file_name": name, "file_stem": os.path.splitext(name)[0], "file_ext": ".eof", "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "checksum_status": "NOT_COMPUTED", "validity_start_time_utc": valid_start, "validity_stop_time_utc": valid_stop, "generation_time_utc": generation_time, "published_time_utc": None, "parser_name": "sentinel1_eof", "parser_version": PARSER_VERSION, "parse_status": parse_status, "parse_error": header_meta.get("header_parse_error"), "parsed_at": now, "metadata_json": _json_safe(metadata), "is_active": True, "missing_since": None, "updated_at": now, } if lower_name.endswith(".txt"): match = _LT1_ORBIT_RE.match(name) if not match: return None date_text = match.group("date") valid_start, valid_stop = _date_start_stop(date_text) return { "orbit_uid": _asset_uid("orbit", path), "satellite_family": "LT1", "satellite": match.group("satellite").upper(), "orbit_type": "GPSDATA_GAS_C", "native_format": "TXT", "quality_class": "precise", "root_ref_id": root.id, "root_path": root.path, "file_path": path, "file_name": name, "file_stem": os.path.splitext(name)[0], "file_ext": ".txt", "size_bytes": stat.get("size_bytes"), "mtime_epoch": stat.get("mtime_epoch"), "checksum_status": "NOT_COMPUTED", "validity_start_time_utc": valid_start, "validity_stop_time_utc": valid_stop, "generation_time_utc": None, "published_time_utc": None, "parser_name": "lt1_gps_txt", "parser_version": PARSER_VERSION, "parse_status": "OK", "parse_error": None, "parsed_at": now, "metadata_json": {"orbit_date": date_text}, "is_active": True, "missing_since": None, "updated_at": now, } return None def _iter_source_candidates(root_path: str) -> Iterable[str]: stack = [_normalize_path(root_path)] while stack: current = stack.pop() try: with os.scandir(current) as iterator: for entry in iterator: try: if entry.is_dir(follow_symlinks=False): name_upper = entry.name.upper() if name_upper.startswith("S1") and entry.name.lower().endswith(".safe"): yield _normalize_path(entry.path) continue if name_upper.startswith("LT1") and parse_lt1_radar_filename(entry.name): yield _normalize_path(entry.path) continue stack.append(entry.path) elif entry.is_file(follow_symlinks=False): stem_upper = _strip_known_suffix(entry.name).upper() if entry.name.upper().startswith("S1") and entry.name.lower().endswith(".zip"): yield _normalize_path(entry.path) continue if stem_upper.startswith("LT1") and _has_archive_suffix(entry.name, _LT1_ARCHIVE_EXTS): yield _normalize_path(entry.path) continue if stem_upper.startswith("GF3") and _has_archive_suffix(entry.name, _GF3_ARCHIVE_EXTS): yield _normalize_path(entry.path) except OSError: continue except OSError: continue def _iter_s1_zip_candidates(root_path: str) -> Iterable[str]: stack = [_normalize_path(root_path)] while stack: current = stack.pop() try: with os.scandir(current) as iterator: for entry in iterator: try: if entry.is_dir(follow_symlinks=False): stack.append(entry.path) elif entry.is_file(follow_symlinks=False): name_upper = entry.name.upper() if name_upper.startswith("S1") and entry.name.lower().endswith(".zip"): yield _normalize_path(entry.path) except OSError: continue except OSError: continue def _iter_orbit_candidates(root_path: str) -> Iterable[str]: stack = [_normalize_path(root_path)] while stack: current = stack.pop() try: with os.scandir(current) as iterator: for entry in iterator: try: if entry.is_dir(follow_symlinks=False): stack.append(entry.path) elif entry.is_file(follow_symlinks=False): lower = entry.name.lower() if lower.endswith(".eof") or lower.endswith(".txt"): yield _normalize_path(entry.path) except OSError: continue except OSError: continue def _collect_source_assets(root: ManagedRootORM) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int]: rows: List[Dict[str, Any]] = [] issues: List[Dict[str, Any]] = [] entry_count = 0 for path in _iter_source_candidates(root.path): entry_count += 1 try: row = _parse_source_entry(path, root) except Exception as exc: row = None issues.append( { "severity": "warning", "issue_code": "source_parse_failed", "issue_message": str(exc), "source_path": path, } ) if row is None: continue rows.append(row) if row.get("parse_status") in {"FAILED", "PARTIAL"}: issues.append( { "severity": "warning", "issue_code": "source_parse_partial" if row.get("parse_status") == "PARTIAL" else "source_parse_failed", "issue_message": row.get("parse_error"), "source_path": row.get("file_path"), } ) return rows, issues, entry_count def _same_mtime(left: Any, right: Any) -> bool: if left is None or right is None: return left is None and right is None try: return abs(float(left) - float(right)) <= 0.001 except (TypeError, ValueError): return False def _same_size(left: Any, right: Any) -> bool: if left is None or right is None: return left is None and right is None try: return int(left) == int(right) except (TypeError, ValueError): return False def _cached_source_asset_is_unchanged( cached: Optional[Dict[str, Any]], stat: Dict[str, Optional[float]], root: ManagedRootORM, *, skip_unchanged_failures: bool = True, ) -> bool: if not cached: return False source_format = str(cached.get("source_format") or "").upper() if source_format not in {"S1_ZIP", "LT1_ARCHIVE", "GF3_ARCHIVE"}: return False try: if int(cached.get("root_ref_id") or 0) != int(root.id or 0): return False except (TypeError, ValueError): return False if not bool(cached.get("is_active")): return False if str(cached.get("parser_version") or "") != PARSER_VERSION: return False parse_status = str(cached.get("parse_status") or "").upper() allowed_statuses = {"OK"} if skip_unchanged_failures: allowed_statuses.update({"PARTIAL", "FAILED"}) if parse_status not in allowed_statuses: return False return _same_size(cached.get("size_bytes"), stat.get("size_bytes")) and _same_mtime( cached.get("mtime_epoch"), stat.get("mtime_epoch"), ) def _cached_orbit_asset_is_unchanged( cached: Optional[Dict[str, Any]], stat: Dict[str, Optional[float]], root: ManagedRootORM, ) -> bool: if not cached: return False native_format = str(cached.get("native_format") or "").upper() if native_format not in {"TXT", "EOF"}: return False try: if int(cached.get("root_ref_id") or 0) != int(root.id or 0): return False except (TypeError, ValueError): return False if not bool(cached.get("is_active")): return False if str(cached.get("parser_version") or "") != PARSER_VERSION: return False if str(cached.get("parse_status") or "").upper() != "OK": return False return _same_size(cached.get("size_bytes"), stat.get("size_bytes")) and _same_mtime( cached.get("mtime_epoch"), stat.get("mtime_epoch"), ) def _collect_source_assets_incremental( root: ManagedRootORM, existing_by_path: Dict[str, Dict[str, Any]], *, progress_callback: Optional[Callable[[int, str], None]] = None, log_callback: Optional[Callable[[str, str], None]] = None, progress_start: int = 0, progress_end: int = 100, parse_workers: int = DEFAULT_ASSET_SCAN_PARSE_WORKERS, parse_inflight: int = DEFAULT_ASSET_SCAN_PARSE_INFLIGHT, skip_unchanged_failures: bool = True, row_batch_callback: Optional[Callable[[List[Dict[str, Any]]], None]] = None, row_batch_size: int = DEFAULT_ASSET_SCAN_DB_BATCH_SIZE, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int, int, List[str]]: rows: List[Dict[str, Any]] = [] pending_rows: List[Dict[str, Any]] = [] issues: List[Dict[str, Any]] = [] seen_paths: List[str] = [] seen_path_set: set[str] = set() entry_count = 0 skipped_unchanged = 0 skipped_unchanged_ok = 0 skipped_unchanged_failed = 0 parse_attempts = 0 parse_completed = 0 last_progress_count = 0 parse_workers = max(1, int(parse_workers or 1)) parse_inflight = max(parse_workers, int(parse_inflight or parse_workers)) row_batch_size = max(1, int(row_batch_size or 1)) def _log(level: str, message: str) -> None: if log_callback: log_callback(level, message) def _progress(message: str) -> None: if progress_callback: progress_callback(_activity_progress(progress_start, progress_end, entry_count), message) def _mark_seen(path: str) -> None: normalized = _normalize_path(path) if normalized and normalized not in seen_path_set: seen_path_set.add(normalized) seen_paths.append(normalized) def _emit_row_batch(*, force: bool = False) -> None: if not row_batch_callback or not pending_rows: return if not force and len(pending_rows) < row_batch_size: return batch = pending_rows[:] pending_rows.clear() row_batch_callback(batch) def _parse_one(index: int, normalized_path: str) -> Dict[str, Any]: file_name = os.path.basename(normalized_path) try: row = _parse_source_entry(normalized_path, parse_root) return {"index": index, "path": normalized_path, "file_name": file_name, "row": row, "error": None} except Exception as exc: return {"index": index, "path": normalized_path, "file_name": file_name, "row": None, "error": exc} def _handle_parse_result(result: Dict[str, Any]) -> None: nonlocal parse_completed, last_progress_count parse_completed += 1 normalized_path = str(result.get("path") or "") file_name = str(result.get("file_name") or os.path.basename(normalized_path)) exc = result.get("error") if exc is not None: _mark_seen(normalized_path) _log("WARNING", f"Source archive metadata parse failed: {file_name}: {exc}") issues.append( { "severity": "warning", "issue_code": "source_parse_failed", "issue_message": str(exc), "source_path": normalized_path, } ) return row = result.get("row") if row is None: _mark_seen(normalized_path) return rows.append(row) pending_rows.append(row) _mark_seen(str(row["file_path"])) if row.get("parse_status") in {"FAILED", "PARTIAL"}: _log( "WARNING", f"Source archive metadata {str(row.get('parse_status')).lower()}: " f"{os.path.basename(str(row.get('file_path') or normalized_path))}: {row.get('parse_error')}", ) issues.append( { "severity": "warning", "issue_code": "source_parse_partial" if row.get("parse_status") == "PARTIAL" else "source_parse_failed", "issue_message": row.get("parse_error"), "source_path": row.get("file_path"), } ) if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: _progress( "Scanning source archives: " f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, " f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}" ) last_progress_count = entry_count _emit_row_batch() def _drain_completed(pending: set, *, wait_for_one: bool = False) -> set: if not pending: return pending timeout = None if wait_for_one else 0 done, remaining = wait(pending, timeout=timeout, return_when=FIRST_COMPLETED) for future in done: _handle_parse_result(future.result()) return remaining _log( "INFO", "Source root discovery started: " f"{root.path} (workers={parse_workers}, inflight={parse_inflight}, " f"db_batch_size={row_batch_size}, skip_unchanged_failures={skip_unchanged_failures})", ) parse_root = SimpleNamespace(id=root.id, path=root.path) with ThreadPoolExecutor(max_workers=parse_workers, thread_name_prefix="asset-parse") as executor: pending = set() for path in _iter_source_candidates(root.path): entry_count += 1 normalized_path = _normalize_path(path) stat = _stat_path(normalized_path) cached = existing_by_path.get(normalized_path) if _cached_source_asset_is_unchanged( cached, stat, root, skip_unchanged_failures=skip_unchanged_failures, ): _mark_seen(normalized_path) skipped_unchanged += 1 cached_status = str((cached or {}).get("parse_status") or "").upper() if cached_status == "OK": skipped_unchanged_ok += 1 else: skipped_unchanged_failed += 1 if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0: _log( "INFO", "Skipped unchanged source archives: " f"{skipped_unchanged} (ok={skipped_unchanged_ok}, failed_cached={skipped_unchanged_failed}, " f"candidates={entry_count})", ) if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: _progress( "Scanning source archives: " f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, " f"completed={parse_completed}/{parse_attempts}, issues={len(issues)}" ) last_progress_count = entry_count continue parse_attempts += 1 file_name = os.path.basename(normalized_path) if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0: _log("INFO", f"Extracting source archive metadata {parse_attempts}: {file_name}") if parse_attempts <= 50 or parse_attempts % 25 == 0: _progress( "Extracting source archive metadata: " f"{file_name} (changed/new={parse_attempts}, completed={parse_completed}, " f"workers={parse_workers}, skipped={skipped_unchanged}, issue={len(issues)})" ) pending.add(executor.submit(_parse_one, parse_attempts, normalized_path)) while len(pending) >= parse_inflight: pending = _drain_completed(pending, wait_for_one=True) pending = _drain_completed(pending, wait_for_one=False) while pending: pending = _drain_completed(pending, wait_for_one=True) _emit_row_batch(force=True) _log( "INFO", "Source root discovery finished: " f"candidates={entry_count}, skipped={skipped_unchanged}, " f"skipped_ok={skipped_unchanged_ok}, skipped_failed_cached={skipped_unchanged_failed}, " f"changed_or_new={len(rows)}, parse_attempts={parse_attempts}, issues={len(issues)}", ) _progress( "Source archive discovery finished: " f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, " f"workers={parse_workers}" ) return rows, issues, entry_count, skipped_unchanged, seen_paths def _collect_orbit_assets_incremental( root: ManagedRootORM, existing_by_path: Dict[str, Dict[str, Any]], *, progress_callback: Optional[Callable[[int, str], None]] = None, log_callback: Optional[Callable[[str, str], None]] = None, progress_start: int = 0, progress_end: int = 100, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int, int, List[str]]: rows: List[Dict[str, Any]] = [] issues: List[Dict[str, Any]] = [] seen_paths: List[str] = [] entry_count = 0 skipped_unchanged = 0 parse_attempts = 0 last_progress_count = 0 def _log(level: str, message: str) -> None: if log_callback: log_callback(level, message) def _progress(message: str) -> None: if progress_callback: progress_callback(_activity_progress(progress_start, progress_end, entry_count), message) _log("INFO", f"Orbit root discovery started: {root.path}") for path in _iter_orbit_candidates(root.path): entry_count += 1 normalized_path = _normalize_path(path) stat = _stat_path(normalized_path) if _cached_orbit_asset_is_unchanged(existing_by_path.get(normalized_path), stat, root): seen_paths.append(normalized_path) skipped_unchanged += 1 if skipped_unchanged % ASSET_SCAN_LOG_INTERVAL == 0: _log( "INFO", f"Skipped unchanged orbit assets: {skipped_unchanged} (candidates={entry_count})", ) if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: _progress( "Scanning orbit assets: " f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}" ) last_progress_count = entry_count continue parse_attempts += 1 file_name = os.path.basename(normalized_path) if parse_attempts <= ASSET_SCAN_DETAILED_PARSE_LOG_LIMIT or parse_attempts % ASSET_SCAN_LOG_INTERVAL == 0: _log("INFO", f"Extracting orbit asset metadata {parse_attempts}: {file_name}") if parse_attempts <= 50 or parse_attempts % 25 == 0: _progress( "Extracting orbit asset metadata: " f"{file_name} (changed/new={parse_attempts}, skipped={skipped_unchanged})" ) try: row = _parse_orbit_entry(normalized_path, root) except Exception as exc: row = None _log("WARNING", f"Orbit asset parse failed: {file_name}: {exc}") issues.append( { "severity": "warning", "issue_code": "orbit_parse_failed", "issue_message": str(exc), "source_path": normalized_path, } ) if row is None: continue rows.append(row) seen_paths.append(str(row["file_path"])) if row.get("parse_status") in {"FAILED", "PARTIAL"}: _log( "WARNING", f"Orbit asset metadata {str(row.get('parse_status')).lower()}: " f"{os.path.basename(str(row.get('file_path') or normalized_path))}: {row.get('parse_error')}", ) issues.append( { "severity": "warning", "issue_code": "orbit_parse_partial" if row.get("parse_status") == "PARTIAL" else "orbit_parse_failed", "issue_message": row.get("parse_error"), "source_path": row.get("file_path"), } ) if entry_count - last_progress_count >= ASSET_SCAN_LOG_INTERVAL: _progress( "Scanning orbit assets: " f"candidates={entry_count}, skipped={skipped_unchanged}, parsed={len(rows)}, issues={len(issues)}" ) last_progress_count = entry_count _log( "INFO", "Orbit root discovery finished: " f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}, issues={len(issues)}", ) _progress( "Orbit asset discovery finished: " f"candidates={entry_count}, skipped={skipped_unchanged}, changed_or_new={len(rows)}" ) return rows, issues, entry_count, skipped_unchanged, seen_paths def _insar_source_ready(row: Dict[str, Any], coverage_polygon: Optional[List[Tuple[float, float]]]) -> Tuple[bool, Optional[str]]: reasons: List[str] = [] metadata = dict(row.get("metadata_json") or {}) if not coverage_polygon or len(coverage_polygon) < 3: reasons.append("missing_footprint") if not row.get("imaging_date"): reasons.append("missing_date") if not row.get("imaging_mode"): reasons.append("missing_imaging_mode") if not row.get("polarization"): reasons.append("missing_polarization") complex_tokens = { str(row.get("product_type") or "").strip().upper(), str(metadata.get("image_data_type") or "").strip().upper(), str(metadata.get("product_variant") or "").strip().upper(), str(metadata.get("filename_class_token") or "").strip().upper(), str(metadata.get("source_product_token") or "").strip().upper(), } if not complex_tokens.intersection({"COMPLEX", "SLC", "SSC"}): reasons.append("not_complex_source") if reasons: return False, ";".join(reasons) return True, None def _image_data_format_for_source(row: Dict[str, Any]) -> str: source_format = str(row.get("source_format") or "").upper() if source_format in {"S1_ZIP", "LT1_ARCHIVE", "GF3_ARCHIVE"}: return "ARCHIVE" return "DIRECTORY" class AssetInventoryService: async def _progress(self, task_id: Optional[str], message: str, progress: int) -> None: if not task_id: return await task_service.update_task(task_id, message=message, progress=max(0, min(100, int(progress)))) def _normalize_scan_families(self, families: Optional[Sequence[str]]) -> List[str]: normalized: List[str] = [] for item in families or []: family = str(normalize_satellite_family(item) or item or "").strip().upper() if family and family not in normalized: normalized.append(family) return normalized def _scan_includes_type(self, inventory_types: Optional[Sequence[str]], *names: str) -> bool: type_set = {str(item or "").strip().lower() for item in (inventory_types or []) if str(item or "").strip()} if not type_set: return True return bool(type_set.intersection({str(name).strip().lower() for name in names})) def _normalize_family_filter(self, satellite_family: Optional[str]) -> List[str]: values: List[str] = [] for raw in re.split(r"[,;]", str(satellite_family or "")): family = str(normalize_satellite_family(raw) or raw or "").strip().upper() if family and family not in values: values.append(family) return values def _thread_callbacks( self, task_id: Optional[str], loop: asyncio.AbstractEventLoop, ) -> Tuple[ Optional[Callable[[int, str], None]], Optional[Callable[[str, str], None]], Callable[[], Awaitable[None]], ]: async def _noop() -> None: return None if not task_id: return None, None, _noop pending: List[Any] = [] def _submit(coro: Any) -> None: pending.append(asyncio.run_coroutine_threadsafe(coro, loop)) def _progress(progress: int, message: str) -> None: _submit( task_service.update_task( task_id, progress=max(0, min(100, int(progress))), message=message, ) ) def _log(level: str, message: str) -> None: _submit(task_service.add_log(task_id, level, message)) async def _drain() -> None: while pending: current = pending[:] pending.clear() await asyncio.gather( *(asyncio.wrap_future(item) for item in current), return_exceptions=True, ) return _progress, _log, _drain async def build_archive_preview_caches( self, db: Optional[AsyncSession] = None, *, families: Optional[Sequence[str]] = None, limit: int = 0, force: bool = False, apply: bool = True, task_id: Optional[str] = None, progress_start: int = 84, progress_end: int = 96, progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None, progress_interval: int = 1, ) -> Dict[str, Any]: generated_session = db is None if generated_session: db = _new_session() assert db is not None family_map = { "LT1": {"LT1_ARCHIVE"}, "S1": {"S1_ZIP"}, } requested_families = self._normalize_scan_families(families) or ["LT1", "S1"] target_families = [item for item in requested_families if item in family_map] source_formats = sorted( { source_format for family in target_families for source_format in family_map.get(family, set()) } ) summary: Dict[str, Any] = { "records_seen": 0, "candidate_count": 0, "ready": 0, "cached": 0, "skipped_ready": 0, "failed": 0, "missing_source": 0, "raw_cached": 0, "raw_skipped": 0, "raw_failed": 0, "families": target_families, } if not target_families or not source_formats: await self._progress(task_id, "No LT1/S1 archive previews to build for selected families.", progress_end) return summary try: stmt = ( select(RadarDataORM) .where(RadarDataORM.satellite_family.in_(target_families)) .where(RadarDataORM.source_format.in_(source_formats)) .order_by(RadarDataORM.satellite_family.asc(), RadarDataORM.id.asc()) ) result = await db.execute(stmt) records = list(result.scalars().all()) summary["records_seen"] = len(records) candidates: List[RadarDataORM] = [] for record in records: unique_id = record.unique_id or record.file_path raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path) geo_cache_path = DataService.get_radar_geo_cache_path(unique_id, record.file_path) ready = ( (record.preview_cache_status or "NONE") == "READY" and (record.preview_cache_version or "") == settings.RADAR_GEO_CACHE_VERSION and bool(record.preview_cache_path or geo_cache_path) and os.path.exists(record.preview_cache_path or geo_cache_path) and os.path.exists(raw_cache_path) ) if ready and not force: summary["skipped_ready"] += 1 continue candidates.append(record) if limit and limit > 0: candidates = candidates[: int(limit)] summary["candidate_count"] = len(candidates) if progress_callback: progress_callback( { "event": "planned", "records_seen": summary["records_seen"], "candidate_count": summary["candidate_count"], "skipped_ready": summary["skipped_ready"], "families": target_families, } ) if not apply: return summary if not candidates: await self._progress( task_id, f"Archive preview cache already ready: skipped={summary['skipped_ready']}", progress_end, ) if progress_callback: progress_callback({"event": "completed", **summary}) return summary await self._progress( task_id, f"Building archive preview cache: candidates={len(candidates)}, skipped_ready={summary['skipped_ready']}", progress_start, ) thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE) total = len(candidates) progress_interval = max(1, int(progress_interval or 1)) for index, record in enumerate(candidates, start=1): unique_id = record.unique_id or record.file_path raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path) geo_cache_path = DataService.get_radar_geo_cache_path(unique_id, record.file_path) product_name = os.path.basename(str(record.file_path or "")) preview_source = await asyncio.to_thread(DataService.find_radar_preview_source, record.file_path) if not preview_source: record.preview_cache_status = "NONE" record.preview_cache_path = None record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION record.preview_cache_updated_at = _utcnow() record.preview_cache_error = "preview_source_not_found" summary["missing_source"] += 1 if task_id: await task_service.add_log(task_id, "WARNING", f"Preview source missing: {product_name}") db.add(record) else: coverage_polygon = DataService._normalize_coverage_polygon(record.coverage_polygon) try: bbox = ( float(record.min_lon), float(record.min_lat), float(record.max_lon), float(record.max_lat), ) if bbox[0] >= bbox[2] or bbox[1] >= bbox[3]: bbox = None except (TypeError, ValueError): bbox = None if not coverage_polygon or not bbox: record.preview_cache_status = "FAILED" record.preview_cache_path = None record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION record.preview_cache_updated_at = _utcnow() record.preview_cache_error = "invalid_coverage_polygon" if not coverage_polygon else "invalid_bbox" summary["failed"] += 1 if task_id: await task_service.add_log(task_id, "ERROR", f"Preview geometry invalid: {product_name}: {record.preview_cache_error}") db.add(record) else: source_corner_mapping = DataService.get_radar_record_corner_mapping(record) ok_geo, geo_error = await asyncio.to_thread( image_service.create_geocorrected_radar_cached_image, preview_source, geo_cache_path, coverage_polygon, bbox, source_corner_mapping, thumb_size, settings.RADAR_GEO_CACHE_QUALITY, ) ok_raw = await asyncio.to_thread( image_service.create_radar_cached_image, preview_source, raw_cache_path, thumb_size, ) if ok_raw: summary["raw_cached"] += 1 else: summary["raw_failed"] += 1 if ok_geo and os.path.exists(geo_cache_path): record.preview_cache_status = "READY" record.preview_cache_path = geo_cache_path record.preview_cache_error = None summary["ready"] += 1 summary["cached"] += 1 if task_id: await task_service.add_log(task_id, "INFO", f"Preview cache ready: {index}/{total} {product_name}") else: record.preview_cache_status = "FAILED" record.preview_cache_path = None record.preview_cache_error = geo_error or ("raw_cache_ready_only" if ok_raw else "preview_cache_build_failed") summary["failed"] += 1 if task_id: await task_service.add_log(task_id, "ERROR", f"Preview cache failed: {product_name}: {record.preview_cache_error}") record.preview_cache_version = settings.RADAR_GEO_CACHE_VERSION record.preview_cache_updated_at = _utcnow() db.add(record) progress = progress_start + int(index / max(1, total) * max(1, progress_end - progress_start)) await self._progress( task_id, f"Building archive preview cache ({index}/{total}): ready={summary['ready']}, failed={summary['failed']}, missing={summary['missing_source']}", min(progress_end, progress), ) if progress_callback and ( index == 1 or index == total or index % progress_interval == 0 or (record.preview_cache_status or "").upper() == "FAILED" ): progress_callback( { "event": "item", "processed": index, "total": total, "records_seen": summary["records_seen"], "candidate_count": summary["candidate_count"], "skipped_ready": summary["skipped_ready"], "ready": summary["ready"], "cached": summary["cached"], "failed": summary["failed"], "missing_source": summary["missing_source"], "raw_cached": summary["raw_cached"], "raw_failed": summary["raw_failed"], "product_name": product_name, "status": record.preview_cache_status, "error": record.preview_cache_error, } ) await db.commit() await self._progress( task_id, ( "Archive preview cache completed: " f"ready={summary['ready']}, skipped={summary['skipped_ready']}, " f"failed={summary['failed']}, missing={summary['missing_source']}" ), progress_end, ) if progress_callback: progress_callback({"event": "completed", **summary}) return summary except Exception: if db is not None: await db.rollback() raise finally: if generated_session and db is not None: await db.close() async def _get_scan_roots( self, db: AsyncSession, *, inventory_types: Optional[Sequence[str]] = None, root_ids: Optional[Sequence[int]] = None, families: Optional[Sequence[str]] = None, ) -> List[ManagedRootORM]: type_set = {str(item or "").strip().lower() for item in (inventory_types or []) if str(item or "").strip()} family_set = { str(normalize_satellite_family(item) or item or "").strip().upper() for item in (families or []) if str(item or "").strip() } family_set.discard("") roles: List[str] = [] if not type_set or "source_product" in type_set or "source" in type_set: roles.extend( [ "source_product_pool", ] ) if not type_set or "orbit_asset" in type_set or "orbit" in type_set: roles.append("orbit_asset_pool") stmt = ( select(ManagedRootORM) .where(ManagedRootORM.enabled == True) # noqa: E712 .where(ManagedRootORM.root_role.in_(roles)) .order_by(ManagedRootORM.root_role.asc(), ManagedRootORM.id.asc()) ) if root_ids: stmt = stmt.where(ManagedRootORM.id.in_([int(item) for item in root_ids])) result = await db.execute(stmt) roots = result.scalars().all() if family_set: roots = [ root for root in roots if family_set.intersection(_root_supported_families(root)) ] return roots async def scan_configured_roots( self, db: Optional[AsyncSession] = None, *, inventory_types: Optional[Sequence[str]] = None, root_ids: Optional[Sequence[int]] = None, families: Optional[Sequence[str]] = None, bind_orbits: bool = True, build_previews: bool = True, task_id: Optional[str] = None, ) -> Dict[str, Any]: generated_session = db is None if generated_session: db = _new_session() assert db is not None try: await self._progress(task_id, "Preparing source/orbit asset scan...", 2) roots = await self._get_scan_roots( db, inventory_types=inventory_types, root_ids=root_ids, families=families, ) results: List[Dict[str, Any]] = [] totals = { "source_roots": 0, "orbit_roots": 0, "source_assets": 0, "orbit_assets": 0, "issues": 0, "inaccessible_roots": 0, } total_roots = len(roots) for index, root in enumerate(roots, start=1): progress = 5 + int((index - 1) / max(1, total_roots) * 78) next_progress = 5 + int(index / max(1, total_roots) * 78) await self._progress(task_id, f"Scanning {root.display_name}: {root.path}", progress) if root.root_role == "source_product_pool": result = await self.scan_source_root( db, root, task_id=task_id, progress_start=progress, progress_end=next_progress, ) totals["source_roots"] += 1 totals["source_assets"] += int(result.get("asset_count") or 0) elif root.root_role == "orbit_asset_pool": result = await self.scan_orbit_root( db, root, task_id=task_id, progress_start=progress, progress_end=next_progress, ) totals["orbit_roots"] += 1 totals["orbit_assets"] += int(result.get("asset_count") or 0) else: continue await self._progress( task_id, f"Finished {root.display_name}: assets={result.get('asset_count', 0)}, issues={result.get('issue_count', 0)}", max(progress, next_progress - 1), ) totals["issues"] += int(result.get("issue_count") or 0) if result.get("status") == "INACCESSIBLE": totals["inaccessible_roots"] += 1 results.append(result) await db.commit() binding_summary: Dict[str, Any] = {} if bind_orbits: await self._progress(task_id, "Binding scenes to precise orbit assets...", 84) binding_summary = await self.bind_scene_orbits(db) await db.commit() preview_summary: Dict[str, Any] = {} should_build_previews = ( build_previews and totals["source_roots"] > 0 and self._scan_includes_type(inventory_types, "source_product", "source") ) if should_build_previews: preview_summary = await self.build_archive_preview_caches( db, families=families, task_id=task_id, progress_start=88, progress_end=98, ) await db.commit() summary = { "message": "Asset inventory scan completed", "root_count": total_roots, **totals, "binding": binding_summary, "preview_cache": preview_summary, "results": results, } await self._progress(task_id, "Asset inventory scan completed", 100) return summary except Exception: if db is not None: await db.rollback() raise finally: if generated_session and db is not None: await db.close() async def audit_source_archive_integrity( self, db: Optional[AsyncSession] = None, *, families: Optional[Sequence[str]] = None, source_formats: Optional[Sequence[str]] = None, asset_ids: Optional[Sequence[int]] = None, force: bool = False, limit: Optional[int] = None, task_id: Optional[str] = None, ) -> Dict[str, Any]: generated_session = db is None if generated_session: db = _new_session() assert db is not None safe_limit: Optional[int] = None if limit is not None: try: parsed_limit = int(limit) safe_limit = parsed_limit if parsed_limit > 0 else None except (TypeError, ValueError): safe_limit = None family_filter = self._normalize_scan_families(families) format_filter = [ str(item or "").strip().upper() for item in (source_formats or []) if str(item or "").strip() ] if not format_filter: format_filter = ["LT1_ARCHIVE", "S1_ZIP"] format_filter = [item for item in format_filter if item in {"LT1_ARCHIVE", "S1_ZIP", "GF3_ARCHIVE"}] if not format_filter: format_filter = ["LT1_ARCHIVE", "S1_ZIP"] try: await self._progress(task_id, "Preparing source archive integrity audit...", 2) filters = [ SourceProductAssetORM.is_active == True, # noqa: E712 SourceProductAssetORM.source_format.in_(format_filter), ] if family_filter: filters.append(SourceProductAssetORM.satellite_family.in_(family_filter)) if asset_ids: filters.append(SourceProductAssetORM.id.in_([int(item) for item in asset_ids])) stmt = ( select(SourceProductAssetORM) .where(*filters) .order_by( SourceProductAssetORM.satellite_family.asc().nullslast(), SourceProductAssetORM.imaging_date.asc().nullslast(), SourceProductAssetORM.id.asc(), ) ) if safe_limit: stmt = stmt.limit(safe_limit) rows = (await db.execute(stmt)).scalars().all() total = len(rows) summary: Dict[str, Any] = { "message": "Source archive integrity audit completed", "total": total, "checked": 0, "skipped": 0, "ok": 0, "failed": 0, "unsupported": 0, "missing": 0, "force": bool(force), "families": family_filter, "source_formats": format_filter, "version": ARCHIVE_INTEGRITY_VERSION, } if total <= 0: await self._progress(task_id, "No source archives matched integrity audit filters.", 100) return summary if task_id: await task_service.add_log( task_id, "INFO", ( "Source archive integrity audit candidates: " f"total={total}, families={family_filter or 'ALL'}, formats={format_filter}, force={bool(force)}" ), ) for index, asset in enumerate(rows, start=1): file_path = _normalize_path(asset.file_path or "") file_name = os.path.basename(file_path) or str(asset.logical_product_uid or asset.id) progress = 5 + int((index - 1) / max(1, total) * 90) stat = _stat_path(file_path) unchanged = ( _same_size(asset.size_bytes, stat.get("size_bytes")) and _same_mtime(asset.mtime_epoch, stat.get("mtime_epoch")) ) previous_status = str(asset.archive_integrity_status or "NOT_CHECKED").upper() previous_version = str(asset.archive_integrity_version or "") can_skip = ( not force and unchanged and previous_version == ARCHIVE_INTEGRITY_VERSION and previous_status in {"OK", "FAILED", "UNSUPPORTED"} ) if can_skip: summary["skipped"] += 1 if previous_status == "OK": summary["ok"] += 1 elif previous_status == "FAILED": summary["failed"] += 1 elif previous_status == "UNSUPPORTED": summary["unsupported"] += 1 if index <= 5 or index % ARCHIVE_INTEGRITY_LOG_INTERVAL == 0 or index == total: await self._progress( task_id, f"Skipping unchanged archive integrity {index}/{total}: {file_name}", 5 + int(index / max(1, total) * 90), ) if task_id and (summary["skipped"] <= 5 or summary["skipped"] % ARCHIVE_INTEGRITY_LOG_INTERVAL == 0): await task_service.add_log( task_id, "INFO", f"Skipped unchanged archive integrity: {summary['skipped']} skipped, {file_name}, status={previous_status}", ) continue await self._progress( task_id, f"Checking archive integrity {index}/{total}: {file_name}", progress, ) if task_id: await task_service.add_log( task_id, "INFO", f"Checking archive integrity {index}/{total}: {file_path}", ) result = await asyncio.to_thread(_check_archive_integrity, file_path, asset.source_format) checked_at = _utcnow() status = str(result.get("status") or "FAILED").upper() asset.size_bytes = result.get("size_bytes") asset.mtime_epoch = result.get("mtime_epoch") asset.archive_integrity_status = status asset.archive_integrity_method = result.get("method") asset.archive_integrity_checked_at = checked_at asset.archive_integrity_error = result.get("error") asset.archive_integrity_version = ARCHIVE_INTEGRITY_VERSION asset.archive_integrity_member_count = result.get("member_count") asset.updated_at = checked_at db.add(asset) summary["checked"] += 1 if status == "OK": summary["ok"] += 1 await self._resolve_archive_integrity_issue(db, asset, now=checked_at) elif status == "UNSUPPORTED": summary["unsupported"] += 1 await self._resolve_archive_integrity_issue(db, asset, now=checked_at) else: summary["failed"] += 1 if str(result.get("error") or "").lower().startswith("archive file is missing"): summary["missing"] += 1 await self._record_archive_integrity_issue(db, asset, result, now=checked_at) await db.commit() if task_id: level = "INFO" if status == "OK" else "WARNING" if status == "UNSUPPORTED" else "ERROR" duration = result.get("duration_seconds") detail = ( f"Archive integrity {status}: {file_name}, " f"members={result.get('member_count')}, duration={duration}s" ) if result.get("error"): detail += f", error={result.get('error')}" await task_service.add_log(task_id, level, detail) await self._progress( task_id, ( "Source archive integrity audit completed: " f"checked={summary['checked']}, skipped={summary['skipped']}, " f"ok={summary['ok']}, failed={summary['failed']}, unsupported={summary['unsupported']}" ), 100, ) return summary except Exception: if db is not None: await db.rollback() raise finally: if generated_session and db is not None: await db.close() async def scan_source_root( self, db: AsyncSession, root: ManagedRootORM, *, task_id: Optional[str] = None, progress_start: int = 0, progress_end: int = 100, ) -> Dict[str, Any]: started_at = _utcnow() state = await self._ensure_state(db, root, "source_product", started_at) if not os.path.isdir(root.path): await self._finish_state( db, state, status="INACCESSIBLE", started_at=started_at, entry_count=0, asset_count=0, issue_count=1, error=f"Source product root is not accessible: {root.path}", ) await self._replace_root_issues( db, root, "source_product", [ { "severity": "error", "issue_code": "root_inaccessible", "issue_message": f"Source product root is not accessible: {root.path}", "source_path": root.path, } ], ) return {"root_id": root.id, "inventory_type": "source_product", "status": "INACCESSIBLE", "asset_count": 0, "issue_count": 1} existing_result = await db.execute( select(SourceProductAssetORM).where(SourceProductAssetORM.root_ref_id == root.id) ) existing_by_path = { str(asset.file_path): { "root_ref_id": asset.root_ref_id, "source_format": asset.source_format, "size_bytes": asset.size_bytes, "mtime_epoch": asset.mtime_epoch, "parser_version": asset.parser_version, "parse_status": asset.parse_status, "is_active": bool(asset.is_active), } for asset in existing_result.scalars().all() if asset.file_path } loop = asyncio.get_running_loop() progress_callback, log_callback, drain_thread_events = self._thread_callbacks(task_id, loop) batch_write_start = max(progress_start, progress_end - 10) batch_write_end = max(batch_write_start, progress_end - 3) persisted_changed_count = 0 async def _upsert_source_asset_batch(rows_batch: Sequence[Dict[str, Any]], *, batch_index: int) -> int: if not rows_batch: return 0 now = _utcnow() db_rows = [ {key: value for key, value in row.items() if not str(key).startswith("_")} for row in rows_batch ] for row in db_rows: stmt = pg_insert(SourceProductAssetORM).values(row) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( index_elements=["file_path"], set_={ "asset_uid": excluded.asset_uid, "logical_product_uid": excluded.logical_product_uid, "satellite_family": excluded.satellite_family, "satellite": excluded.satellite, "source_format": excluded.source_format, "product_type": excluded.product_type, "product_level": excluded.product_level, "imaging_mode": excluded.imaging_mode, "polarization": excluded.polarization, "absolute_orbit": excluded.absolute_orbit, "relative_orbit": excluded.relative_orbit, "orbit_direction": excluded.orbit_direction, "acquisition_start_time_utc": excluded.acquisition_start_time_utc, "acquisition_stop_time_utc": excluded.acquisition_stop_time_utc, "imaging_date": excluded.imaging_date, "root_ref_id": excluded.root_ref_id, "root_path": excluded.root_path, "archive_path": excluded.archive_path, "path_kind": excluded.path_kind, "file_name": excluded.file_name, "file_stem": excluded.file_stem, "file_ext": excluded.file_ext, "size_bytes": excluded.size_bytes, "mtime_epoch": excluded.mtime_epoch, "checksum_status": excluded.checksum_status, "archive_integrity_status": "NOT_CHECKED", "archive_integrity_method": None, "archive_integrity_checked_at": None, "archive_integrity_error": None, "archive_integrity_version": None, "archive_integrity_member_count": None, "parser_name": excluded.parser_name, "parser_version": excluded.parser_version, "parse_status": excluded.parse_status, "parse_error": excluded.parse_error, "parsed_at": excluded.parsed_at, "metadata_json": excluded.metadata_json, "is_active": True, "missing_since": None, "updated_at": now, }, ) await db.execute(stmt) await db.flush() changed_paths = [str(row["file_path"]) for row in rows_batch if row.get("file_path")] asset_ids_by_path: Dict[str, int] = {} if changed_paths: result = await db.execute( select(SourceProductAssetORM.file_path, SourceProductAssetORM.id).where( SourceProductAssetORM.file_path.in_(changed_paths) ) ) asset_ids_by_path = {str(path): int(asset_id) for path, asset_id in result.all()} await self._upsert_metadata_documents_for_source_assets(db, rows_batch, asset_ids_by_path) await self._upsert_radar_records_for_source_assets(db, rows_batch, asset_ids_by_path) await db.commit() if task_id: await task_service.add_log( task_id, "INFO", f"Source asset DB batch committed: batch={batch_index}, rows={len(rows_batch)}", ) await self._progress( task_id, f"Committed source asset DB batch {batch_index}: rows={len(rows_batch)}", batch_write_start, ) return len(rows_batch) batch_index = 0 def _persist_row_batch(rows_batch: List[Dict[str, Any]]) -> None: nonlocal batch_index, persisted_changed_count if not rows_batch: return batch_index += 1 future = asyncio.run_coroutine_threadsafe( _upsert_source_asset_batch(rows_batch, batch_index=batch_index), loop, ) persisted = int(future.result()) persisted_changed_count += persisted rows, issues, entry_count, skipped_unchanged, seen_paths = await asyncio.to_thread( _collect_source_assets_incremental, root, existing_by_path, progress_callback=progress_callback, log_callback=log_callback, progress_start=progress_start, progress_end=max(progress_start + 1, progress_end - 8), parse_workers=settings.ASSET_SCAN_PARSE_WORKERS, parse_inflight=settings.ASSET_SCAN_PARSE_INFLIGHT, skip_unchanged_failures=settings.ASSET_SCAN_SKIP_UNCHANGED_FAILURES, row_batch_callback=_persist_row_batch if task_id else None, row_batch_size=settings.ASSET_SCAN_DB_BATCH_SIZE, ) await drain_thread_events() if not task_id and rows: persisted_changed_count += await _upsert_source_asset_batch(rows, batch_index=1) now = _utcnow() if task_id: await task_service.add_log( task_id, "INFO", "Source asset DB batch upsert finished: " f"changed_or_new={len(rows)}, persisted={persisted_changed_count}, " f"batches={batch_index}, skipped={skipped_unchanged}, seen={len(seen_paths)}", ) await self._progress(task_id, "Marking missing source assets and refreshing scan issues...", max(progress_end - 2, progress_start)) await self._mark_missing_source_assets(db, root, seen_paths, now) await self._replace_root_issues(db, root, "source_product", issues) await self._finish_state( db, state, status="OK" if not any(item.get("severity") == "error" for item in issues) else "WARNING", started_at=started_at, entry_count=entry_count, asset_count=len(seen_paths), issue_count=len(issues), error=None, ) if task_id: await task_service.add_log( task_id, "INFO", "Source root scan summary: " f"path={root.path}, candidates={entry_count}, active={len(seen_paths)}, " f"changed_or_new={len(rows)}, skipped={skipped_unchanged}, issues={len(issues)}", ) return { "root_id": root.id, "root_path": root.path, "inventory_type": "source_product", "status": state.status, "entry_count": entry_count, "asset_count": len(seen_paths), "changed_asset_count": len(rows), "unchanged_asset_count": skipped_unchanged, "issue_count": len(issues), } async def scan_orbit_root( self, db: AsyncSession, root: ManagedRootORM, *, task_id: Optional[str] = None, progress_start: int = 0, progress_end: int = 100, ) -> Dict[str, Any]: started_at = _utcnow() state = await self._ensure_state(db, root, "orbit_asset", started_at) if not os.path.isdir(root.path): await self._finish_state( db, state, status="INACCESSIBLE", started_at=started_at, entry_count=0, asset_count=0, issue_count=1, error=f"Orbit asset root is not accessible: {root.path}", ) await self._replace_root_issues( db, root, "orbit_asset", [ { "severity": "error", "issue_code": "root_inaccessible", "issue_message": f"Orbit asset root is not accessible: {root.path}", "source_path": root.path, } ], ) return {"root_id": root.id, "inventory_type": "orbit_asset", "status": "INACCESSIBLE", "asset_count": 0, "issue_count": 1} existing_result = await db.execute( select(OrbitAssetORM).where(OrbitAssetORM.root_ref_id == root.id) ) existing_by_path = { str(asset.file_path): { "root_ref_id": asset.root_ref_id, "native_format": asset.native_format, "size_bytes": asset.size_bytes, "mtime_epoch": asset.mtime_epoch, "parser_version": asset.parser_version, "parse_status": asset.parse_status, "is_active": bool(asset.is_active), } for asset in existing_result.scalars().all() if asset.file_path } loop = asyncio.get_running_loop() progress_callback, log_callback, drain_thread_events = self._thread_callbacks(task_id, loop) rows, issues, entry_count, skipped_unchanged, seen_paths = await asyncio.to_thread( _collect_orbit_assets_incremental, root, existing_by_path, progress_callback=progress_callback, log_callback=log_callback, progress_start=progress_start, progress_end=max(progress_start + 1, progress_end - 6), ) await drain_thread_events() now = _utcnow() write_start = max(progress_start, progress_end - 5) write_end = max(write_start, progress_end - 2) await self._progress( task_id, f"Writing orbit asset index: changed_or_new={len(rows)}, skipped={skipped_unchanged}", write_start, ) if task_id: await task_service.add_log( task_id, "INFO", f"Orbit asset DB upsert started: changed_or_new={len(rows)}, skipped={skipped_unchanged}, seen={len(seen_paths)}, issues={len(issues)}", ) for index, row in enumerate(rows, start=1): stmt = pg_insert(OrbitAssetORM).values(row) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( index_elements=["file_path"], set_={ "orbit_uid": excluded.orbit_uid, "satellite_family": excluded.satellite_family, "satellite": excluded.satellite, "orbit_type": excluded.orbit_type, "native_format": excluded.native_format, "quality_class": excluded.quality_class, "root_ref_id": excluded.root_ref_id, "root_path": excluded.root_path, "file_name": excluded.file_name, "file_stem": excluded.file_stem, "file_ext": excluded.file_ext, "size_bytes": excluded.size_bytes, "mtime_epoch": excluded.mtime_epoch, "checksum_status": excluded.checksum_status, "validity_start_time_utc": excluded.validity_start_time_utc, "validity_stop_time_utc": excluded.validity_stop_time_utc, "generation_time_utc": excluded.generation_time_utc, "published_time_utc": excluded.published_time_utc, "parser_name": excluded.parser_name, "parser_version": excluded.parser_version, "parse_status": excluded.parse_status, "parse_error": excluded.parse_error, "parsed_at": excluded.parsed_at, "metadata_json": excluded.metadata_json, "is_active": True, "missing_since": None, "updated_at": now, }, ) await db.execute(stmt) if index % ASSET_SCAN_LOG_INTERVAL == 0 or index == len(rows): progress_value = write_start if rows and write_end > write_start: progress_value = write_start + int(index / max(1, len(rows)) * (write_end - write_start)) await self._progress( task_id, f"Writing orbit asset index: {index}/{len(rows)} changed_or_new, skipped={skipped_unchanged}", progress_value, ) await db.flush() derivative_summary: Dict[str, Any] = {} has_lt1_seen = any( str(existing_by_path.get(path, {}).get("native_format") or "").upper() == "TXT" for path in seen_paths ) or any(row.get("satellite_family") == "LT1" for row in rows) if has_lt1_seen and settings.ORBIT_POOL_ENVI: await self._progress(task_id, "Syncing LT-1 TXT orbit production pool...", max(progress_end - 3, progress_start)) isce2_pool = settings.ORBIT_POOL_ISCE2 if settings.ISCE2_ENABLED else "" try: derivative_summary = await asyncio.to_thread( sync_orbit_pools, root.path, settings.ORBIT_POOL_ENVI, isce2_pool, settings.ORBIT_POOL_LANDSAR, bool(settings.ISCE2_ENABLED), ) except Exception as exc: derivative_summary = {"error": str(exc)} issues.append( { "severity": "warning", "issue_code": "lt1_orbit_pool_sync_failed", "issue_message": str(exc), "source_path": root.path, } ) derivative_summary["db_derivatives"] = await self._record_lt1_orbit_pool_derivatives_for_paths(db, seen_paths, now=now) if task_id: envi_summary = derivative_summary.get("envi") or {} isce2_summary = derivative_summary.get("isce2") or {} db_derivatives = derivative_summary.get("db_derivatives") or {} await task_service.add_log( task_id, "INFO" if not derivative_summary.get("error") else "WARN", ( "LT-1 orbit production pool sync: " f"txt copied={len(envi_summary.get('copied', []) or [])}, " f"updated={len(envi_summary.get('updated', []) or [])}, " f"skipped={len(envi_summary.get('skipped', []) or [])}; " + ( f"isce2 converted={len(isce2_summary.get('converted', []) or [])}, " f"reconverted={len(isce2_summary.get('reconverted', []) or [])}; " if settings.ISCE2_ENABLED else "isce2 disabled; " ) + f"derivatives recorded={int(db_derivatives.get('recorded') or 0)}" ), ) await self._progress(task_id, "Marking missing orbit assets and refreshing scan issues...", max(progress_end - 2, progress_start)) await self._mark_missing_orbit_assets(db, root, seen_paths, now) await self._replace_root_issues(db, root, "orbit_asset", issues) await self._finish_state( db, state, status="OK" if not any(item.get("severity") == "error" for item in issues) else "WARNING", started_at=started_at, entry_count=entry_count, asset_count=len(seen_paths), issue_count=len(issues), error=None, ) if task_id: await task_service.add_log( task_id, "INFO", "Orbit root scan summary: " f"path={root.path}, candidates={entry_count}, active={len(seen_paths)}, " f"changed_or_new={len(rows)}, skipped={skipped_unchanged}, issues={len(issues)}", ) return { "root_id": root.id, "root_path": root.path, "inventory_type": "orbit_asset", "status": state.status, "entry_count": entry_count, "asset_count": len(seen_paths), "changed_asset_count": len(rows), "unchanged_asset_count": skipped_unchanged, "issue_count": len(issues), "derivative_summary": derivative_summary, } async def _find_source_asset_root( self, db: AsyncSession, target_path: str, ) -> Optional[ManagedRootORM]: target_norm = os.path.normcase(_normalize_path(target_path)) result = await db.execute( select(ManagedRootORM) .where(ManagedRootORM.enabled == True) # noqa: E712 .where(ManagedRootORM.root_role == "source_product_pool") .order_by(func.length(ManagedRootORM.path).desc()) ) for root in result.scalars().all(): root_norm = os.path.normcase(_normalize_path(root.path)) if target_norm == root_norm or target_norm.startswith(root_norm + os.sep): return root return None async def ensure_source_root_for_path( self, db: AsyncSession, root_path: str, *, source_ref: str = "TASK_POOL_ROOT", ) -> ManagedRootORM: from .root_registry_service import root_registry_service root = await self._find_source_asset_root(db, root_path) if root is not None: return root await root_registry_service.sync_from_settings(db) root = await self._find_source_asset_root(db, root_path) if root is not None: return root normalized = _normalize_path(root_path) source_ref_text = str(source_ref or "TASK_POOL_ROOT").strip() or "TASK_POOL_ROOT" source_ref_slug = re.sub(r"[^a-z0-9]+", "_", source_ref_text.lower()).strip("_") or "task_pool_root" root_code = f"source_product_pool__{source_ref_slug}_{hashlib.sha1(normalized.encode('utf-8')).hexdigest()[:12]}" root = ManagedRootORM( root_code=root_code, root_role="source_product_pool", display_name="Task Pool Materialized Source Pool" if source_ref_text == "TASK_POOL_ROOT" else "Source Product Pool", path=normalized, path_kind=_path_kind(normalized), source_kind="env", source_ref=source_ref_text, scan_mode="file_pool", enabled=True, exists_flag=os.path.exists(normalized), metadata_json={ "env_var": source_ref_text, "created_by": "source_materialize", }, ) db.add(root) await db.flush() return root async def scan_source_path_after_unpack( self, db: AsyncSession, source_path: str, *, bind_orbits: bool = True, ) -> Dict[str, Any]: path = _normalize_path(source_path) root = await self._find_source_asset_root(db, path) if root is None: return { "scanned": False, "reason": "Sentinel-1 storage directory is not registered as a source product pool.", "source_path": path, } row = await asyncio.to_thread(_parse_source_entry, path, root) if row is None: return { "scanned": False, "reason": "Unpacked Sentinel-1 SAFE could not be parsed.", "source_path": path, "root_id": root.id, } now = _utcnow() db_row = {key: value for key, value in row.items() if not str(key).startswith("_")} stmt = pg_insert(SourceProductAssetORM).values(db_row) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( index_elements=["file_path"], set_={ "asset_uid": excluded.asset_uid, "logical_product_uid": excluded.logical_product_uid, "satellite_family": excluded.satellite_family, "satellite": excluded.satellite, "source_format": excluded.source_format, "product_type": excluded.product_type, "product_level": excluded.product_level, "imaging_mode": excluded.imaging_mode, "polarization": excluded.polarization, "absolute_orbit": excluded.absolute_orbit, "relative_orbit": excluded.relative_orbit, "orbit_direction": excluded.orbit_direction, "acquisition_start_time_utc": excluded.acquisition_start_time_utc, "acquisition_stop_time_utc": excluded.acquisition_stop_time_utc, "imaging_date": excluded.imaging_date, "root_ref_id": excluded.root_ref_id, "root_path": excluded.root_path, "archive_path": excluded.archive_path, "path_kind": excluded.path_kind, "file_name": excluded.file_name, "file_stem": excluded.file_stem, "file_ext": excluded.file_ext, "size_bytes": excluded.size_bytes, "mtime_epoch": excluded.mtime_epoch, "checksum_status": excluded.checksum_status, "archive_integrity_status": "NOT_CHECKED", "archive_integrity_method": None, "archive_integrity_checked_at": None, "archive_integrity_error": None, "archive_integrity_version": None, "archive_integrity_member_count": None, "parser_name": excluded.parser_name, "parser_version": excluded.parser_version, "parse_status": excluded.parse_status, "parse_error": excluded.parse_error, "parsed_at": excluded.parsed_at, "metadata_json": excluded.metadata_json, "is_active": True, "missing_since": None, "updated_at": now, }, ) await db.execute(stmt) await db.flush() result = await db.execute( select(SourceProductAssetORM.id).where(SourceProductAssetORM.file_path == path) ) asset_id = result.scalar_one_or_none() radar_data_id = None if asset_id is not None: await self._upsert_metadata_documents_for_source_assets(db, [row], {path: int(asset_id)}) await self._upsert_radar_records_for_source_assets(db, [row], {path: int(asset_id)}) radar_result = await db.execute( select(RadarDataORM.id).where(RadarDataORM.file_path == path) ) radar_data_id = radar_result.scalar_one_or_none() binding_summary: Dict[str, Any] = {} if bind_orbits and radar_data_id is not None: binding_summary = await self.bind_scene_orbits(db, radar_data_ids=[int(radar_data_id)]) return { "scanned": True, "root_id": root.id, "root_path": root.path, "asset_id": asset_id, "radar_data_id": radar_data_id, "parse_status": row.get("parse_status"), "binding": binding_summary, } def unpack_sentinel1_archive( self, archive_path: str, *, target_root: Optional[str] = None, overwrite: bool = False, min_disk_space_gb: Optional[float] = None, tmp_suffix: Optional[str] = None, delete_archive: Optional[bool] = None, progress_callback: Optional[Callable[[int, str], None]] = None, log_callback: Optional[Callable[[str, str], None]] = None, ) -> Dict[str, Any]: archive = _normalize_path(archive_path) _ensure_local_runtime_path(archive, "Sentinel-1 archive source") if not os.path.isfile(archive): raise FileNotFoundError(archive) if not archive.lower().endswith(".zip"): raise ValueError("Only Sentinel-1 ZIP archives can be unpacked.") target_dir = _ensure_local_runtime_path( _target_root_for_s1_archive(archive, target_root), "Sentinel-1 unpack target_root", ) os.makedirs(target_dir, exist_ok=True) tmp_suffix_text = str(tmp_suffix or os.getenv("UNPACK_TMP_SUFFIX") or ".unpack_tmp").strip() or ".unpack_tmp" min_free_gb = min_disk_space_gb if min_free_gb is None: min_free_gb = _parse_float(os.getenv("UNPACK_MIN_DISK_SPACE_GB"), 50.0) def _log(level: str, message: str) -> None: if log_callback: log_callback(level, message) def _progress(progress: int, message: str) -> None: if progress_callback: progress_callback(progress, message) if delete_archive: _log("WARNING", "Ignoring delete_archive=true; Sentinel-1 ZIP archives are the source of record.") _progress(3, "Reading Sentinel-1 ZIP manifest...") with zipfile.ZipFile(archive) as zip_obj: names = zip_obj.namelist() if not names: raise ValueError("ZIP archive is empty.") safe_dirs = { name.split("/", 1)[0] for name in names if "/" in name and name.split("/", 1)[0].lower().endswith(".safe") } if len(safe_dirs) != 1: raise ValueError("Expected exactly one top-level .SAFE directory in Sentinel-1 ZIP.") safe_name = next(iter(safe_dirs)) output_safe_dir = _normalize_path(os.path.join(target_dir, safe_name)) tmp_dir = output_safe_dir + tmp_suffix_text lock_path = output_safe_dir + ".unpacking" target_root_abs = os.path.abspath(target_dir) output_abs = os.path.abspath(output_safe_dir) if not output_abs.startswith(target_root_abs + os.sep): raise ValueError("Unsafe Sentinel-1 target path.") for member in names: member_target = os.path.abspath(os.path.join(target_dir, member)) if not member_target.startswith(target_root_abs + os.sep): raise ValueError(f"Unsafe ZIP member path: {member}") required_bytes = sum(max(0, int(info.file_size or 0)) for info in zip_obj.infolist()) _, _, free_bytes = shutil.disk_usage(target_dir) min_free_bytes = int(float(min_free_gb or 0) * (1024 ** 3)) if free_bytes - required_bytes < min_free_bytes: raise OSError( "Sentinel-1 storage has insufficient free space: " f"needed {required_bytes / (1024 ** 3):.2f} GB, " f"free {free_bytes / (1024 ** 3):.2f} GB, " f"min free after {float(min_free_gb or 0):.2f} GB" ) if os.path.exists(output_safe_dir): if not overwrite: return { "status": "EXISTS", "archive_path": archive, "target_root": target_dir, "safe_dir": output_safe_dir, "extracted": False, "member_count": len(names), } shutil.rmtree(output_safe_dir) if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) if os.path.exists(lock_path): raise OSError(f"Sentinel-1 unpack lock exists: {lock_path}") os.makedirs(tmp_dir, exist_ok=True) with open(lock_path, "w", encoding="utf-8") as stream: stream.write(_utcnow().isoformat()) try: total_members = len(names) for index, member in enumerate(names, start=1): if index % 100 == 0 or index == total_members: pct = 5 + int(index / max(1, total_members) * 85) _progress(pct, f"Extracting Sentinel-1 SAFE ({index}/{total_members})") rel_member = member.split("/", 1)[1] if "/" in member else "" if not rel_member: continue destination = os.path.abspath(os.path.join(tmp_dir, rel_member)) if not destination.startswith(os.path.abspath(tmp_dir) + os.sep): raise ValueError(f"Unsafe ZIP member path: {member}") info = zip_obj.getinfo(member) if info.is_dir(): os.makedirs(destination, exist_ok=True) continue os.makedirs(os.path.dirname(destination), exist_ok=True) with zip_obj.open(info, "r") as source, open(destination, "wb") as target: shutil.copyfileobj(source, target, length=1024 * 1024) if not os.listdir(tmp_dir): raise OSError("Extracted SAFE directory is empty.") os.replace(tmp_dir, output_safe_dir) finally: if os.path.exists(lock_path): try: os.remove(lock_path) except OSError: pass if os.path.exists(tmp_dir): try: shutil.rmtree(tmp_dir) except OSError: pass _progress(92, "Sentinel-1 SAFE extracted.") return { "status": "EXTRACTED", "archive_path": archive, "target_root": target_dir, "safe_dir": output_safe_dir, "extracted": True, "member_count": len(names), } def materialize_source_asset( self, asset: SourceProductAssetORM, *, target_root: Optional[str] = None, overwrite: bool = False, ) -> Dict[str, Any]: source_format = str(asset.source_format or "").upper() source_path = _normalize_path(str(asset.archive_path or asset.file_path or "")) if not source_path: raise ValueError("Source asset path is empty.") _ensure_local_runtime_path(source_path, "Source asset path") if source_format == "S1_ZIP": requested_root = _normalize_path(target_root or "") or _task_pool_materialize_root(source_format) requested_root = _ensure_local_runtime_path(requested_root, "Source materialize target_root") return self.unpack_sentinel1_archive(source_path, target_root=requested_root, overwrite=overwrite) if source_format not in {"LT1_ARCHIVE", "GF3_ARCHIVE"}: if os.path.isdir(source_path): return { "status": "DIRECTORY_READY", "source_path": source_path, "target_dir": source_path, "extracted": False, "source_format": source_format, } raise ValueError(f"Source format is not materializable from archive: {source_format}") requested_root = _normalize_path(target_root or "") if not requested_root: requested_root = _task_pool_materialize_root(source_format) requested_root = _ensure_local_runtime_path(requested_root, "Source materialize target_root") scene_name = _strip_known_suffix(os.path.basename(source_path)) target_dir = os.path.join(requested_root, scene_name) result = _extract_archive_to_dir(source_path, target_dir, overwrite=overwrite) result["source_format"] = source_format return result async def run_sentinel1_unpack_task(self, task_id: str, payload: Optional[Dict[str, Any]] = None) -> None: payload = payload if isinstance(payload, dict) else {} asset_id = payload.get("asset_id") if not asset_id: raise ValueError("asset_id is required.") await task_service.start_task(task_id, message="Sentinel-1 unpack started") async with _new_session() as db: asset = await db.get(SourceProductAssetORM, int(asset_id)) if asset is None: raise ValueError("Source product asset not found.") if asset.source_format != "S1_ZIP": raise ValueError("Only Sentinel-1 ZIP assets can be unpacked.") archive_path = asset.file_path target_root = _ensure_local_runtime_path( payload.get("target_root") or _task_pool_materialize_root("S1_ZIP"), "Sentinel-1 unpack target_root", ) overwrite = bool(payload.get("overwrite", False)) min_disk_space_gb = payload.get("min_disk_space_gb") delete_archive = payload.get("delete_archive") if "delete_archive" in payload else None tmp_suffix = payload.get("tmp_suffix") or os.getenv("UNPACK_TMP_SUFFIX") or ".unpack_tmp" loop = asyncio.get_running_loop() def _log(level: str, message: str) -> None: async def _add() -> None: await task_service.add_log(task_id, level, message) asyncio.run_coroutine_threadsafe(_add(), loop) def _progress(progress: int, message: str) -> None: async def _update() -> None: await task_service.update_task(task_id, progress=progress, message=message) asyncio.run_coroutine_threadsafe(_update(), loop) result = await asyncio.to_thread( self.unpack_sentinel1_archive, archive_path, target_root=target_root, overwrite=overwrite, min_disk_space_gb=min_disk_space_gb, tmp_suffix=tmp_suffix, delete_archive=delete_archive, progress_callback=_progress, log_callback=_log, ) if result.get("target_root"): target_root_text = str(result["target_root"]) await self.ensure_source_root_for_path( db, target_root_text, source_ref=_source_ref_for_materialized_root(target_root_text), ) await db.commit() metadata = dict(asset.metadata_json or {}) metadata["last_unpacked_safe_dir"] = result.get("safe_dir") metadata["last_unpacked_target_root"] = result.get("target_root") metadata["last_unpacked_at"] = _utcnow().isoformat() metadata["last_unpacked_status"] = result.get("status") asset.metadata_json = metadata await db.commit() scan_summary: Dict[str, Any] = {} if result.get("safe_dir"): await task_service.update_task(task_id, progress=94, message="Scanning unpacked Sentinel-1 SAFE...") scan_summary = await self.scan_source_path_after_unpack(db, str(result["safe_dir"]), bind_orbits=True) await db.commit() await task_service.add_log(task_id, "INFO", f"Sentinel-1 unpack result: {result}") if scan_summary: await task_service.add_log(task_id, "INFO", f"Sentinel-1 SAFE inventory scan: {scan_summary}") await task_service.update_task( task_id, status="COMPLETED", progress=100, message=( "Sentinel-1 unpack complete: " f"{os.path.basename(str(result.get('safe_dir') or '')) or result.get('status')}" ), ) async def run_sentinel1_unpack_batch_task(self, task_id: str, payload: Optional[Dict[str, Any]] = None) -> None: payload = payload if isinstance(payload, dict) else {} overwrite = bool(payload.get("overwrite", False)) min_disk_space_gb = payload.get("min_disk_space_gb") delete_archive = payload.get("delete_archive") if "delete_archive" in payload else None target_root = _ensure_local_runtime_path( payload.get("target_root") or _task_pool_materialize_root("S1_ZIP"), "Sentinel-1 batch unpack target_root", ) scan_before_unpack = bool(payload.get("scan_before_unpack", True)) await task_service.start_task(task_id, message="Sentinel-1 batch unpack started") async with _new_session() as db: if scan_before_unpack: await task_service.update_task(task_id, progress=5, message="Refreshing Sentinel-1 inventory...") await self.scan_configured_roots( db, inventory_types=["source_product", "orbit_asset"], bind_orbits=True, task_id=task_id, ) await db.commit() archive_dirs = _configured_sentinel1_archive_dirs() archives: List[Dict[str, Any]] = [] seen_archives: set[str] = set() for archive_dir in archive_dirs: if not archive_dir or not os.path.isdir(archive_dir): continue for archive_path in _iter_s1_zip_candidates(archive_dir): normalized_path = _normalize_path(archive_path) if not normalized_path or normalized_path in seen_archives: continue seen_archives.add(normalized_path) name_meta = _parse_s1_source_name(os.path.basename(normalized_path)) or {} archives.append( { "file_path": normalized_path, "logical_product_uid": name_meta.get("logical_product_uid"), } ) archives.sort(key=lambda item: (str(item.get("logical_product_uid") or ""), str(item.get("file_path") or ""))) if not archives: raise ValueError("No Sentinel-1 ZIP archives were found in SOURCE_PRODUCT_DIRS.") loop = asyncio.get_running_loop() def _log(level: str, message: str) -> None: async def _add() -> None: await task_service.add_log(task_id, level, message) asyncio.run_coroutine_threadsafe(_add(), loop) processed = 0 skipped = 0 failed = 0 total = len(archives) for index, archive_item in enumerate(archives, start=1): archive_path = str(archive_item.get("file_path") or "") logical_product_uid = str(archive_item.get("logical_product_uid") or "").strip() asset_name = os.path.basename(archive_path or logical_product_uid or f"archive-{index}") await task_service.update_task( task_id, progress=10 + int((index - 1) / max(1, total) * 80), message=f"Processing Sentinel-1 archive {index}/{total}: {asset_name}", ) if await self._s1_zip_has_unpacked_safe( db, { "source_format": "S1_ZIP", "logical_product_uid": logical_product_uid, }, ) and not overwrite: skipped += 1 await task_service.add_log(task_id, "INFO", f"Skipping already unpacked Sentinel-1 archive: {archive_path}") continue try: result = await asyncio.to_thread( self.unpack_sentinel1_archive, archive_path, target_root=target_root, overwrite=overwrite, min_disk_space_gb=min_disk_space_gb, delete_archive=delete_archive, log_callback=_log, ) if result.get("target_root"): target_root_text = str(result["target_root"]) await self.ensure_source_root_for_path( db, target_root_text, source_ref=_source_ref_for_materialized_root(target_root_text), ) await db.commit() scan_summary: Dict[str, Any] = {} if result.get("safe_dir"): await task_service.update_task( task_id, progress=10 + int((index - 1) / max(1, total) * 80) + 3, message=f"Scanning unpacked Sentinel-1 SAFE {index}/{total}...", ) scan_summary = await self.scan_source_path_after_unpack(db, str(result["safe_dir"]), bind_orbits=True) await db.commit() await task_service.add_log(task_id, "INFO", f"Sentinel-1 unpack result: {result}") if scan_summary: await task_service.add_log(task_id, "INFO", f"Sentinel-1 SAFE inventory scan: {scan_summary}") if result.get("status") == "EXISTS": skipped += 1 else: processed += 1 except Exception as exc: failed += 1 await task_service.add_log(task_id, "ERROR", f"Sentinel-1 archive unpack failed: {archive_path} -> {exc}") status = "COMPLETED" if failed < total else "FAILED" message = ( "Sentinel-1 batch unpack complete: " f"processed={processed}, skipped={skipped}, failed={failed}, total={total}" ) await task_service.update_task(task_id, status=status, progress=100, message=message) async def _ensure_state( self, db: AsyncSession, root: ManagedRootORM, inventory_type: str, started_at: datetime, ) -> AssetInventoryStateORM: result = await db.execute( select(AssetInventoryStateORM).where( AssetInventoryStateORM.root_ref_id == root.id, AssetInventoryStateORM.inventory_type == inventory_type, ) ) state = result.scalar_one_or_none() if state is None: state = AssetInventoryStateORM( root_ref_id=root.id, inventory_type=inventory_type, root_path=root.path, scan_mode=root.scan_mode, status="RUNNING", parser_version=PARSER_VERSION, needs_rescan=True, metadata_json={"created_by": "asset_inventory_scan"}, ) db.add(state) await db.flush() state.status = "RUNNING" state.root_path = root.path state.scan_mode = root.scan_mode state.last_scan_started_at = started_at state.last_error = None state.parser_version = PARSER_VERSION state.updated_at = started_at return state async def _finish_state( self, db: AsyncSession, state: AssetInventoryStateORM, *, status: str, started_at: datetime, entry_count: int, asset_count: int, issue_count: int, error: Optional[str], ) -> None: state.status = status state.last_scan_started_at = started_at state.last_scan_finished_at = _utcnow() state.last_seen_entry_count = int(entry_count) state.last_asset_count = int(asset_count) state.last_issue_count = int(issue_count) state.parser_version = PARSER_VERSION state.needs_rescan = status not in {"OK", "WARNING"} state.last_error = error state.updated_at = _utcnow() db.add(state) async def _replace_root_issues( self, db: AsyncSession, root: ManagedRootORM, inventory_type: str, issues: Sequence[Dict[str, Any]], ) -> None: now = _utcnow() await db.execute( update(AssetInventoryIssueORM) .where( AssetInventoryIssueORM.root_ref_id == root.id, AssetInventoryIssueORM.inventory_type == inventory_type, AssetInventoryIssueORM.status == "OPEN", ) .values(status="RESOLVED", resolved_at=now, last_seen_at=now) ) for issue in issues: db.add( AssetInventoryIssueORM( root_ref_id=root.id, inventory_type=inventory_type, severity=str(issue.get("severity") or "warning").lower(), issue_code=str(issue.get("issue_code") or "unknown"), issue_message=issue.get("issue_message"), source_path=issue.get("source_path"), status="OPEN", first_seen_at=now, last_seen_at=now, metadata_json=issue.get("metadata_json"), ) ) async def _resolve_archive_integrity_issue( self, db: AsyncSession, asset: SourceProductAssetORM, *, now: datetime, ) -> None: if asset.id is None: return await db.execute( update(AssetInventoryIssueORM) .where( AssetInventoryIssueORM.asset_ref_id == int(asset.id), AssetInventoryIssueORM.inventory_type == "source_product", AssetInventoryIssueORM.issue_code == "source_archive_integrity_failed", AssetInventoryIssueORM.status == "OPEN", ) .values(status="RESOLVED", resolved_at=now, last_seen_at=now) ) async def _record_archive_integrity_issue( self, db: AsyncSession, asset: SourceProductAssetORM, result: Dict[str, Any], *, now: datetime, ) -> None: if asset.id is None: return await self._resolve_archive_integrity_issue(db, asset, now=now) db.add( AssetInventoryIssueORM( root_ref_id=asset.root_ref_id, inventory_type="source_product", asset_ref_id=int(asset.id), severity="error", issue_code="source_archive_integrity_failed", issue_message=result.get("error") or "Source archive integrity check failed.", source_path=asset.file_path, status="OPEN", first_seen_at=now, last_seen_at=now, metadata_json={ "asset_uid": asset.asset_uid, "logical_product_uid": asset.logical_product_uid, "satellite_family": asset.satellite_family, "source_format": asset.source_format, "method": result.get("method"), "member_count": result.get("member_count"), "duration_seconds": result.get("duration_seconds"), "version": ARCHIVE_INTEGRITY_VERSION, }, ) ) async def _mark_missing_source_assets( self, db: AsyncSession, root: ManagedRootORM, seen_paths: Sequence[str], now: datetime, ) -> None: stmt = update(SourceProductAssetORM).where(SourceProductAssetORM.root_ref_id == root.id) if seen_paths: stmt = stmt.where(SourceProductAssetORM.file_path.notin_(list(seen_paths))) await db.execute(stmt.values(is_active=False, missing_since=now, updated_at=now)) async def _mark_missing_orbit_assets( self, db: AsyncSession, root: ManagedRootORM, seen_paths: Sequence[str], now: datetime, ) -> None: stmt = update(OrbitAssetORM).where(OrbitAssetORM.root_ref_id == root.id) if seen_paths: stmt = stmt.where(OrbitAssetORM.file_path.notin_(list(seen_paths))) await db.execute(stmt.values(is_active=False, missing_since=now, updated_at=now)) async def _record_lt1_orbit_pool_derivatives( self, db: AsyncSession, rows: Sequence[Dict[str, Any]], *, now: datetime, ) -> Dict[str, Any]: lt1_rows = [row for row in rows if row.get("satellite_family") == "LT1"] if not lt1_rows or not settings.ORBIT_POOL_ENVI: return {"recorded": 0, "missing": 0} paths = [str(row.get("file_path") or "") for row in lt1_rows if row.get("file_path")] result = await db.execute(select(OrbitAssetORM).where(OrbitAssetORM.file_path.in_(paths))) assets_by_path = {str(asset.file_path): asset for asset in result.scalars().all()} recorded = 0 missing = 0 for row in lt1_rows: asset = assets_by_path.get(str(row.get("file_path") or "")) if not asset or not asset.id: continue satellite = str(row.get("satellite") or "").upper() file_name = str(row.get("file_name") or "").strip() pool_path = _normalize_path(os.path.join(settings.ORBIT_POOL_ENVI, satellite, file_name)) if not os.path.isfile(pool_path): missing += 1 continue stat = _stat_path(pool_path) stmt = pg_insert(OrbitAssetDerivativeORM).values( orbit_asset_id=int(asset.id), engine_code="lt1_txt_pool", derivative_format="LT1_TXT", derivative_role="production_orbit_txt", pool_path=pool_path, size_bytes=stat.get("size_bytes"), mtime_epoch=stat.get("mtime_epoch"), checksum_sha256=None, generation_status="READY", generation_error=None, generated_at=now, metadata_json=_json_safe( { "pool_root": settings.ORBIT_POOL_ENVI, "layout": "satellite_split", "consumers": ["ENVI/SARscape", "Gamma/PyINT D-InSAR", "Gamma SBAS"], } ), created_at=now, updated_at=now, ) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( index_elements=["orbit_asset_id", "engine_code", "derivative_format", "pool_path"], set_={ "derivative_role": excluded.derivative_role, "size_bytes": excluded.size_bytes, "mtime_epoch": excluded.mtime_epoch, "generation_status": "READY", "generation_error": None, "generated_at": now, "metadata_json": excluded.metadata_json, "updated_at": now, }, ) await db.execute(stmt) recorded += 1 return {"recorded": recorded, "missing": missing, "pool_root": settings.ORBIT_POOL_ENVI} async def _record_lt1_orbit_pool_derivatives_for_paths( self, db: AsyncSession, paths: Sequence[str], *, now: datetime, ) -> Dict[str, Any]: unique_paths = [] seen = set() for path in paths: normalized = _normalize_path(str(path or "")) if not normalized or normalized in seen: continue seen.add(normalized) unique_paths.append(normalized) if not unique_paths or not settings.ORBIT_POOL_ENVI: return {"recorded": 0, "missing": 0} result = await db.execute( select(OrbitAssetORM).where( OrbitAssetORM.file_path.in_(unique_paths), OrbitAssetORM.satellite_family == "LT1", OrbitAssetORM.is_active == True, # noqa: E712 ) ) rows = [ { "satellite_family": asset.satellite_family, "satellite": asset.satellite, "file_name": asset.file_name, "file_path": asset.file_path, } for asset in result.scalars().all() ] return await self._record_lt1_orbit_pool_derivatives(db, rows, now=now) async def _upsert_radar_records_for_source_assets( self, db: AsyncSession, rows: Sequence[Dict[str, Any]], asset_ids_by_path: Dict[str, int], ) -> None: dirty_scene_ids: List[int] = [] profile_inputs: List[Tuple[Dict[str, Any], int, int]] = [] for row in rows: metadata = dict(row.get("metadata_json") or {}) coverage_polygon = _ordered_closed_polygon(metadata.get("coverage_polygon") or []) if not coverage_polygon or len(coverage_polygon) < 3: continue metadata["coverage_polygon"] = coverage_polygon metadata["coverage_bbox"] = _bbox_from_polygon(coverage_polygon) family = normalize_satellite_family(row.get("satellite_family") or row.get("satellite")) if family not in {"S1", "LT1"}: continue bbox = _bbox_from_polygon(coverage_polygon) if not bbox: continue try: poly = Polygon(coverage_polygon) if not poly.is_valid: poly = poly.buffer(0) if poly.is_empty: continue except Exception: continue asset_id = asset_ids_by_path.get(str(row.get("file_path"))) if not asset_id: continue archive_asset_id = await self._resolve_archive_asset_id_for_source_row(db, row, asset_id) center_lon, center_lat = _centroid_from_polygon(coverage_polygon) metadata_center_lon = metadata.get("scene_center_lon") metadata_center_lat = metadata.get("scene_center_lat") if metadata_center_lon is not None: center_lon = metadata_center_lon if metadata_center_lat is not None: center_lat = metadata_center_lat ready, reason = _insar_source_ready(row, coverage_polygon) radar_values = { "satellite": row.get("satellite") or "", "satellite_family": family, "imaging_date": row.get("imaging_date") or "", "imaging_mode": row.get("imaging_mode") or "", "orbit_direction": row.get("orbit_direction"), "polarization": row.get("polarization") or "", "satellite_mode": metadata.get("satellite_mode"), "receiving_station": metadata.get("receiving_station"), "orbit_circle": row.get("absolute_orbit"), "scene_center_lon": center_lon, "scene_center_lat": center_lat, "acquisition_time_utc": ( row.get("acquisition_start_time_utc").isoformat() if row.get("acquisition_start_time_utc") else None ), "product_type": row.get("product_type"), "source_product_token": metadata.get("filename_class_token") or metadata.get("source_product_token"), "image_data_type": "COMPLEX", "image_data_format": _image_data_format_for_source(row), "product_variant": metadata.get("product_variant"), "product_level": row.get("product_level"), "product_unique_id": row.get("logical_product_uid"), "look_direction": metadata.get("look_direction"), "acquisition_start_time_utc": row.get("acquisition_start_time_utc"), "acquisition_stop_time_utc": row.get("acquisition_stop_time_utc"), "absolute_orbit": row.get("absolute_orbit"), "relative_orbit": row.get("relative_orbit"), "source_format": row.get("source_format"), "source_product_ref_id": asset_id, "source_archive_asset_id": archive_asset_id, "metadata_json": _json_safe(metadata), "geocoded_flag": False, "insar_source_ready": ready, "insar_source_reason": reason, "file_path": row.get("file_path"), "coverage_polygon": coverage_polygon, "geom": from_shape(poly, srid=4326), "min_lon": bbox[0], "min_lat": bbox[1], "max_lon": bbox[2], "max_lat": bbox[3], } result = await db.execute( self._radar_record_match_stmt(row, asset_id, archive_asset_id) ) existing = result.scalar_one_or_none() if existing is None: scene = RadarDataORM( unique_id=f"asset:{row.get('asset_uid')}", has_orbit_data=False, orbit_binding_status="UNBOUND", is_envi_processed=False, **radar_values, ) db.add(scene) await db.flush() if scene.id is not None: dirty_scene_ids.append(int(scene.id)) profile_inputs.append((row, asset_id, int(scene.id))) else: before_orbit_id = existing.selected_orbit_asset_id for key, value in radar_values.items(): setattr(existing, key, value) if not existing.orbit_binding_status: existing.orbit_binding_status = "UNBOUND" db.add(existing) if existing.id is not None: profile_inputs.append((row, asset_id, int(existing.id))) if existing.id is not None and before_orbit_id != existing.selected_orbit_asset_id: dirty_scene_ids.append(int(existing.id)) await db.flush() if profile_inputs: await self._upsert_geometry_profiles(db, profile_inputs) await self._attach_radar_ids_to_metadata_documents(db, profile_inputs) for _, _, radar_id in profile_inputs: if radar_id not in dirty_scene_ids: dirty_scene_ids.append(radar_id) if dirty_scene_ids: await pairing_state_service.mark_scenes_dirty(db, scene_ids=dirty_scene_ids, reason="asset_inventory_source_update", commit=False) async def _upsert_metadata_documents_for_source_assets( self, db: AsyncSession, rows: Sequence[Dict[str, Any]], asset_ids_by_path: Dict[str, int], ) -> None: now = _utcnow() for row in rows: asset_id = asset_ids_by_path.get(str(row.get("file_path"))) if not asset_id: continue for doc in row.get("_metadata_documents") or []: values = { "source_asset_id": int(asset_id), "satellite_family": doc.get("satellite_family") or row.get("satellite_family"), "source_format": doc.get("source_format") or row.get("source_format"), "document_type": doc.get("document_type") or "UNKNOWN", "member_path": doc.get("member_path") or "", "content_sha256": doc.get("content_sha256") or "", "content_encoding": doc.get("content_encoding") or "gzip", "content_bytes": doc.get("content_bytes") or b"", "content_size_bytes": doc.get("content_size_bytes"), "archive_path": doc.get("archive_path") or row.get("archive_path") or row.get("file_path"), "archive_mtime": doc.get("archive_mtime") if doc.get("archive_mtime") is not None else row.get("mtime_epoch"), "parser_version": doc.get("parser_version") or PARSER_VERSION, "parse_status": doc.get("parse_status") or "OK", "parse_error": doc.get("parse_error"), "extracted_at": doc.get("extracted_at") or now, "updated_at": now, } stmt = pg_insert(SourceMetadataDocumentORM).values(values) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( constraint="uq_source_metadata_document_member", set_={ "radar_data_id": excluded.radar_data_id, "satellite_family": excluded.satellite_family, "source_format": excluded.source_format, "content_sha256": excluded.content_sha256, "content_encoding": excluded.content_encoding, "content_bytes": excluded.content_bytes, "content_size_bytes": excluded.content_size_bytes, "archive_path": excluded.archive_path, "archive_mtime": excluded.archive_mtime, "parser_version": excluded.parser_version, "parse_status": excluded.parse_status, "parse_error": excluded.parse_error, "extracted_at": excluded.extracted_at, "updated_at": now, }, ) await db.execute(stmt) await db.flush() async def _attach_radar_ids_to_metadata_documents( self, db: AsyncSession, profile_inputs: Sequence[Tuple[Dict[str, Any], int, int]], ) -> None: for row, asset_id, radar_id in profile_inputs: await db.execute( update(SourceMetadataDocumentORM) .where(SourceMetadataDocumentORM.source_asset_id == int(asset_id)) .values( radar_data_id=int(radar_id), satellite_family=row.get("satellite_family"), source_format=row.get("source_format"), updated_at=_utcnow(), ) ) async def _upsert_geometry_profiles( self, db: AsyncSession, profile_inputs: Sequence[Tuple[Dict[str, Any], int, int]], ) -> None: now = _utcnow() for row, asset_id, radar_id in profile_inputs: metadata = dict(row.get("metadata_json") or {}) footprint = _ordered_closed_polygon(metadata.get("coverage_polygon") or []) footprint_geom = None if footprint and len(footprint) >= 4: try: poly = Polygon(footprint) if not poly.is_valid: poly = poly.buffer(0) if not poly.is_empty: footprint_geom = from_shape(poly, srid=4326) except Exception: footprint_geom = None reasons: List[str] = [] for key, reason in ( ("satellite_family", "missing_satellite_family"), ("imaging_mode", "missing_imaging_mode"), ("polarization", "missing_polarization"), ("orbit_direction", "missing_orbit_direction"), ): if not row.get(key): reasons.append(reason) if not footprint: reasons.append("missing_footprint") family = normalize_satellite_family(row.get("satellite_family") or row.get("satellite")) relative_orbit = row.get("relative_orbit") if family == "S1" and not relative_orbit: reasons.append("missing_relative_orbit") metadata_quality = "READY" if not reasons else ("PARTIAL" if footprint else "INCOMPLETE") production_readiness = "READY" if metadata_quality == "READY" else "CANDIDATE" values = { "source_asset_id": int(asset_id), "radar_data_id": int(radar_id), "satellite_family": family, "satellite": row.get("satellite"), "source_format": row.get("source_format"), "imaging_mode": row.get("imaging_mode"), "polarization": row.get("polarization"), "orbit_direction": row.get("orbit_direction"), "look_direction": metadata.get("look_direction"), "absolute_orbit": row.get("absolute_orbit"), "relative_orbit": relative_orbit, "acquisition_start_time_utc": row.get("acquisition_start_time_utc"), "acquisition_stop_time_utc": row.get("acquisition_stop_time_utc"), "scene_center_lon": metadata.get("scene_center_lon"), "scene_center_lat": metadata.get("scene_center_lat"), "footprint_geom": footprint_geom, "footprint_polygon": _json_safe(footprint), "swath_summary_json": metadata.get("swath_summary"), "burst_summary_json": metadata.get("burst_summary"), "incidence_angle_min": metadata.get("incidence_angle_min"), "incidence_angle_max": metadata.get("incidence_angle_max"), "doppler_summary_json": metadata.get("doppler_summary"), "state_vector_summary_json": metadata.get("state_vector_summary"), "metadata_quality": metadata_quality, "production_readiness": production_readiness, "readiness_reasons_json": reasons, "parser_version": PARSER_VERSION, "parsed_at": now, "updated_at": now, } stmt = pg_insert(SARSceneGeometryProfileORM).values(values) excluded = stmt.excluded stmt = stmt.on_conflict_do_update( index_elements=["source_asset_id"], set_={ "radar_data_id": excluded.radar_data_id, "satellite_family": excluded.satellite_family, "satellite": excluded.satellite, "source_format": excluded.source_format, "imaging_mode": excluded.imaging_mode, "polarization": excluded.polarization, "orbit_direction": excluded.orbit_direction, "look_direction": excluded.look_direction, "absolute_orbit": excluded.absolute_orbit, "relative_orbit": excluded.relative_orbit, "acquisition_start_time_utc": excluded.acquisition_start_time_utc, "acquisition_stop_time_utc": excluded.acquisition_stop_time_utc, "scene_center_lon": excluded.scene_center_lon, "scene_center_lat": excluded.scene_center_lat, "footprint_geom": excluded.footprint_geom, "footprint_polygon": excluded.footprint_polygon, "swath_summary_json": excluded.swath_summary_json, "burst_summary_json": excluded.burst_summary_json, "incidence_angle_min": excluded.incidence_angle_min, "incidence_angle_max": excluded.incidence_angle_max, "doppler_summary_json": excluded.doppler_summary_json, "state_vector_summary_json": excluded.state_vector_summary_json, "metadata_quality": excluded.metadata_quality, "production_readiness": excluded.production_readiness, "readiness_reasons_json": excluded.readiness_reasons_json, "parser_version": excluded.parser_version, "parsed_at": excluded.parsed_at, "updated_at": now, }, ) await db.execute(stmt) await db.flush() async def _s1_zip_has_unpacked_safe(self, db: AsyncSession, row: Dict[str, Any]) -> bool: if row.get("source_format") != "S1_ZIP": return False logical_uid = str(row.get("logical_product_uid") or "").strip() if not logical_uid: return False result = await db.execute( select(func.count(SourceProductAssetORM.id)).where( SourceProductAssetORM.satellite_family == "S1", SourceProductAssetORM.source_format == "S1_SAFE_DIR", SourceProductAssetORM.logical_product_uid == logical_uid, SourceProductAssetORM.is_active == True, # noqa: E712 ) ) return int(result.scalar_one() or 0) > 0 async def _resolve_archive_asset_id_for_source_row( self, db: AsyncSession, row: Dict[str, Any], fallback_asset_id: int, ) -> Optional[int]: if row.get("source_format") == "S1_ZIP": return fallback_asset_id if row.get("source_format") != "S1_SAFE_DIR": return None logical_uid = str(row.get("logical_product_uid") or "").strip() if not logical_uid: return None result = await db.execute( select(SourceProductAssetORM.id).where( SourceProductAssetORM.satellite_family == "S1", SourceProductAssetORM.source_format == "S1_ZIP", SourceProductAssetORM.logical_product_uid == logical_uid, SourceProductAssetORM.is_active == True, # noqa: E712 ) ) return result.scalar_one_or_none() def _radar_record_match_stmt( self, row: Dict[str, Any], asset_id: int, archive_asset_id: Optional[int], ): logical_uid = str(row.get("logical_product_uid") or "").strip() file_path_match = RadarDataORM.file_path == row.get("file_path") unique_id_match = RadarDataORM.unique_id == f"asset:{row.get('asset_uid')}" clauses = [ file_path_match, unique_id_match, ] priority = [ (file_path_match, 0), (unique_id_match, 1), ] if archive_asset_id is not None: archive_match = RadarDataORM.source_archive_asset_id == int(archive_asset_id) clauses.append(archive_match) priority.append((archive_match, 2)) if row.get("source_format") == "S1_SAFE_DIR" and logical_uid: logical_match = and_( RadarDataORM.satellite_family == "S1", RadarDataORM.product_unique_id == logical_uid, ) clauses.append(logical_match) priority.append((logical_match, 3)) elif row.get("source_format") == "S1_ZIP" and logical_uid: logical_match = and_( RadarDataORM.satellite_family == "S1", RadarDataORM.product_unique_id == logical_uid, ) clauses.append(logical_match) priority.append((logical_match, 3)) elif row.get("source_format") == "LT1_ARCHIVE" and logical_uid: logical_match = and_( RadarDataORM.satellite_family == "LT1", RadarDataORM.product_unique_id == logical_uid, ) clauses.append(logical_match) priority.append((logical_match, 3)) return ( select(RadarDataORM) .where(or_(*clauses)) .order_by(case(*priority, else_=9), RadarDataORM.id.asc()) .limit(1) ) async def bind_scene_orbits( self, db: AsyncSession, radar_data_ids: Optional[Sequence[int]] = None, ) -> Dict[str, Any]: now = _utcnow() scene_stmt = select(RadarDataORM).where( RadarDataORM.satellite_family.in_(["S1", "LT1"]), RadarDataORM.source_product_ref_id.is_not(None), ) if radar_data_ids: scene_stmt = scene_stmt.where(RadarDataORM.id.in_([int(item) for item in radar_data_ids])) scene_result = await db.execute(scene_stmt) scenes = scene_result.scalars().all() issue_stmt = update(AssetInventoryIssueORM).where( AssetInventoryIssueORM.issue_code.in_(["scene_missing_orbit", "scene_ambiguous_orbit"]), AssetInventoryIssueORM.status == "OPEN", ) if radar_data_ids: issue_stmt = issue_stmt.where(AssetInventoryIssueORM.radar_data_id.in_([int(item) for item in radar_data_ids])) await db.execute(issue_stmt.values(status="RESOLVED", resolved_at=now, last_seen_at=now)) if scenes: await db.execute(delete(SceneOrbitBindingORM).where(SceneOrbitBindingORM.radar_data_id.in_([scene.id for scene in scenes if scene.id]))) matched = 0 missing = 0 candidate_count = 0 dirty_scene_ids: List[int] = [] for scene in scenes: candidates = await self._find_orbit_candidates(db, scene) if not candidates: scene.has_orbit_data = False scene.orbit_file_path = None scene.selected_orbit_asset_id = None scene.orbit_binding_status = "MISSING" scene.orbit_binding_reason = "No active orbit asset covers the scene acquisition window." missing += 1 db.add( AssetInventoryIssueORM( inventory_type="orbit_asset", radar_data_id=scene.id, severity="warning", issue_code="scene_missing_orbit", issue_message=scene.orbit_binding_reason, source_path=scene.file_path, status="OPEN", first_seen_at=now, last_seen_at=now, metadata_json={ "satellite": scene.satellite, "imaging_date": scene.imaging_date, "acquisition_start_time_utc": scene.acquisition_start_time_utc.isoformat() if scene.acquisition_start_time_utc else None, }, ) ) if scene.id is not None: dirty_scene_ids.append(int(scene.id)) continue candidate_count += len(candidates) selected = candidates[0] for rank, (orbit, score, reason, margins, rule_version) in enumerate(candidates, start=1): db.add( SceneOrbitBindingORM( radar_data_id=scene.id, orbit_asset_id=orbit.id, binding_role="primary_orbit", match_status="MATCHED", selection_status="SELECTED" if rank == 1 else "CANDIDATE", selection_rank=rank, priority_score=score, coverage_margin_before_seconds=margins[0], coverage_margin_after_seconds=margins[1], match_rule_version=rule_version, match_reason=reason, selected_at=now if rank == 1 else None, ) ) selected_orbit = selected[0] scene.has_orbit_data = True scene.orbit_file_path = selected_orbit.file_path scene.selected_orbit_asset_id = selected_orbit.id scene.orbit_binding_status = "MATCHED" scene.orbit_binding_reason = selected[2] db.add(scene) matched += 1 if scene.id is not None: dirty_scene_ids.append(int(scene.id)) if len(candidates) > 1 and abs(float(candidates[0][1]) - float(candidates[1][1])) < 0.001: db.add( AssetInventoryIssueORM( inventory_type="orbit_asset", radar_data_id=scene.id, orbit_asset_id=selected_orbit.id, severity="warning", issue_code="scene_ambiguous_orbit", issue_message="Multiple orbit assets have equivalent selection priority.", source_path=scene.file_path, status="OPEN", first_seen_at=now, last_seen_at=now, ) ) if dirty_scene_ids: await pairing_state_service.mark_scenes_dirty( db, scene_ids=sorted(set(dirty_scene_ids)), reason="asset_inventory_orbit_binding", commit=False, ) return { "scene_count": len(scenes), "matched_count": matched, "missing_count": missing, "candidate_count": candidate_count, } async def _find_orbit_candidates( self, db: AsyncSession, scene: RadarDataORM, ) -> List[Tuple[OrbitAssetORM, float, str, Tuple[Optional[float], Optional[float]], str]]: family = normalize_satellite_family(scene.satellite_family or scene.satellite) satellite = str(scene.satellite or "").upper() if family == "S1": scene_start = scene.acquisition_start_time_utc scene_stop = scene.acquisition_stop_time_utc or scene_start if not scene_start or not scene_stop: return [] result = await db.execute( select(OrbitAssetORM).where( OrbitAssetORM.is_active == True, # noqa: E712 OrbitAssetORM.satellite_family == "S1", OrbitAssetORM.satellite == satellite, OrbitAssetORM.validity_start_time_utc <= scene_start, OrbitAssetORM.validity_stop_time_utc >= scene_stop, ) ) rows = result.scalars().all() candidates = [] for orbit in rows: before = (scene_start - orbit.validity_start_time_utc).total_seconds() if orbit.validity_start_time_utc else None after = (orbit.validity_stop_time_utc - scene_stop).total_seconds() if orbit.validity_stop_time_utc else None quality_score = 1000.0 if orbit.quality_class == "precise" else 500.0 if orbit.quality_class == "restituted" else 100.0 margin_score = min(before or 0.0, after or 0.0) / 100000.0 generation_score = (orbit.generation_time_utc.timestamp() / 1000000000.0) if orbit.generation_time_utc else 0.0 score = quality_score + margin_score + generation_score reason = ( f"{orbit.orbit_type} covers scene window " f"{scene_start.isoformat()} to {scene_stop.isoformat()}" ) candidates.append((orbit, score, reason, (before, after), S1_ORBIT_MATCH_RULE_VERSION)) return sorted(candidates, key=lambda item: item[1], reverse=True) if family == "LT1": if not scene.imaging_date: return [] day_start, day_stop = _date_start_stop(str(scene.imaging_date)) if not day_start or not day_stop: return [] result = await db.execute( select(OrbitAssetORM).where( OrbitAssetORM.is_active == True, # noqa: E712 OrbitAssetORM.satellite_family == "LT1", OrbitAssetORM.satellite == satellite, OrbitAssetORM.validity_start_time_utc <= day_start, OrbitAssetORM.validity_stop_time_utc >= day_stop, ) ) rows = result.scalars().all() candidates = [] for orbit in rows: score = 1000.0 reason = f"LT1 orbit date matches scene imaging_date {scene.imaging_date}" candidates.append((orbit, score, reason, (0.0, 0.0), LT1_ORBIT_MATCH_RULE_VERSION)) return sorted(candidates, key=lambda item: item[1], reverse=True) return [] async def get_status(self, db: AsyncSession) -> Dict[str, Any]: root_rows = ( await db.execute( select(ManagedRootORM) .where(ManagedRootORM.enabled == True) # noqa: E712 .where(ManagedRootORM.root_role.in_(["source_product_pool", "orbit_asset_pool"])) .order_by(ManagedRootORM.root_role.asc(), ManagedRootORM.path.asc()) ) ).scalars().all() state_rows = ( await db.execute( select(AssetInventoryStateORM, ManagedRootORM) .join(ManagedRootORM, AssetInventoryStateORM.root_ref_id == ManagedRootORM.id) .where(ManagedRootORM.enabled == True) # noqa: E712 .where(ManagedRootORM.root_role.in_(["source_product_pool", "orbit_asset_pool"])) .order_by(AssetInventoryStateORM.inventory_type.asc(), AssetInventoryStateORM.root_path.asc()) ) ).all() source_count = int( ( await db.execute( select(func.count(SourceProductAssetORM.id)).where( SourceProductAssetORM.is_active == True, # noqa: E712 ) ) ).scalar_one() or 0 ) orbit_count = int((await db.execute(select(func.count(OrbitAssetORM.id)).where(OrbitAssetORM.is_active == True))).scalar_one() or 0) # noqa: E712 binding_count = int((await db.execute(select(func.count(SceneOrbitBindingORM.id)).where(SceneOrbitBindingORM.selection_status == "SELECTED"))).scalar_one() or 0) open_issue_count = int((await db.execute(select(func.count(AssetInventoryIssueORM.id)).where(AssetInventoryIssueORM.status == "OPEN"))).scalar_one() or 0) integrity_rows = ( await db.execute( select(SourceProductAssetORM.archive_integrity_status, func.count(SourceProductAssetORM.id)) .where( SourceProductAssetORM.is_active == True, # noqa: E712 SourceProductAssetORM.source_format.in_(["LT1_ARCHIVE", "S1_ZIP"]), ) .group_by(SourceProductAssetORM.archive_integrity_status) ) ).all() archive_integrity_counts = { str(status or "NOT_CHECKED").upper(): int(count or 0) for status, count in integrity_rows } return { "source_asset_count": source_count, "orbit_asset_count": orbit_count, "selected_binding_count": binding_count, "open_issue_count": open_issue_count, "archive_integrity_counts": archive_integrity_counts, "roots": [ { "id": row.id, "root_code": row.root_code, "root_role": row.root_role, "display_name": row.display_name, "root_path": row.path, "path_kind": row.path_kind, "source_ref": row.source_ref, "scan_mode": row.scan_mode, "enabled": bool(row.enabled), "exists_flag": bool(row.exists_flag), "supported_families": _root_supported_families(row), } for row in root_rows ], "states": [ { "id": row.id, "root_ref_id": row.root_ref_id, "root_role": root.root_role, "root_code": root.root_code, "display_name": root.display_name, "source_ref": root.source_ref, "enabled": bool(root.enabled), "exists_flag": bool(root.exists_flag), "supported_families": _root_supported_families(root), "inventory_type": row.inventory_type, "root_path": row.root_path, "scan_mode": row.scan_mode, "status": row.status, "last_scan_started_at": row.last_scan_started_at, "last_scan_finished_at": row.last_scan_finished_at, "last_seen_entry_count": row.last_seen_entry_count, "last_asset_count": row.last_asset_count, "last_issue_count": row.last_issue_count, "parser_version": row.parser_version, "needs_rescan": bool(row.needs_rescan), "last_error": row.last_error, } for row, root in state_rows ], } async def list_source_products( self, db: AsyncSession, *, satellite_family: Optional[str] = None, satellite: Optional[str] = None, source_format: Optional[str] = None, parse_status: Optional[str] = None, include_inactive: bool = False, limit: int = 200, offset: int = 0, ) -> Dict[str, Any]: safe_limit = max(1, min(int(limit or 200), 1000)) safe_offset = max(0, int(offset or 0)) filters = [] if not include_inactive: filters.append(SourceProductAssetORM.is_active == True) # noqa: E712 family_filter = self._normalize_family_filter(satellite_family) if family_filter: filters.append(SourceProductAssetORM.satellite_family.in_(family_filter)) if satellite: filters.append(SourceProductAssetORM.satellite == satellite.upper()) if source_format: filters.append(SourceProductAssetORM.source_format == source_format.upper()) if parse_status: filters.append(SourceProductAssetORM.parse_status == parse_status.upper()) stmt = select(SourceProductAssetORM) count_stmt = select(func.count(SourceProductAssetORM.id)) for item in filters: stmt = stmt.where(item) count_stmt = count_stmt.where(item) total = int((await db.execute(count_stmt)).scalar_one() or 0) rows = ( await db.execute( stmt.order_by(SourceProductAssetORM.acquisition_start_time_utc.desc().nullslast(), SourceProductAssetORM.id.desc()) .offset(safe_offset) .limit(safe_limit) ) ).scalars().all() return { "items": [self._source_asset_payload(row) for row in rows], "total": total, "limit": safe_limit, "offset": safe_offset, "has_more": safe_offset + len(rows) < total, } async def list_orbits( self, db: AsyncSession, *, satellite_family: Optional[str] = None, satellite: Optional[str] = None, orbit_type: Optional[str] = None, parse_status: Optional[str] = None, include_inactive: bool = False, limit: int = 200, offset: int = 0, ) -> Dict[str, Any]: safe_limit = max(1, min(int(limit or 200), 1000)) safe_offset = max(0, int(offset or 0)) filters = [] if not include_inactive: filters.append(OrbitAssetORM.is_active == True) # noqa: E712 family_filter = self._normalize_family_filter(satellite_family) if family_filter: filters.append(OrbitAssetORM.satellite_family.in_(family_filter)) if satellite: filters.append(OrbitAssetORM.satellite == satellite.upper()) if orbit_type: filters.append(OrbitAssetORM.orbit_type == orbit_type.upper()) if parse_status: filters.append(OrbitAssetORM.parse_status == parse_status.upper()) stmt = select(OrbitAssetORM) count_stmt = select(func.count(OrbitAssetORM.id)) for item in filters: stmt = stmt.where(item) count_stmt = count_stmt.where(item) total = int((await db.execute(count_stmt)).scalar_one() or 0) rows = ( await db.execute( stmt.order_by(OrbitAssetORM.validity_start_time_utc.desc().nullslast(), OrbitAssetORM.id.desc()) .offset(safe_offset) .limit(safe_limit) ) ).scalars().all() return { "items": [self._orbit_asset_payload(row) for row in rows], "total": total, "limit": safe_limit, "offset": safe_offset, "has_more": safe_offset + len(rows) < total, } async def list_issues( self, db: AsyncSession, *, status: str = "OPEN", severity: Optional[str] = None, issue_code: Optional[str] = None, limit: int = 200, offset: int = 0, ) -> Dict[str, Any]: safe_limit = max(1, min(int(limit or 200), 1000)) safe_offset = max(0, int(offset or 0)) filters = [] if status: filters.append(AssetInventoryIssueORM.status == status.upper()) if severity: filters.append(AssetInventoryIssueORM.severity == severity.lower()) if issue_code: filters.append(AssetInventoryIssueORM.issue_code == issue_code) stmt = select(AssetInventoryIssueORM) count_stmt = select(func.count(AssetInventoryIssueORM.id)) for item in filters: stmt = stmt.where(item) count_stmt = count_stmt.where(item) total = int((await db.execute(count_stmt)).scalar_one() or 0) rows = ( await db.execute( stmt.order_by(AssetInventoryIssueORM.last_seen_at.desc(), AssetInventoryIssueORM.id.desc()) .offset(safe_offset) .limit(safe_limit) ) ).scalars().all() return { "items": [ { "id": row.id, "root_ref_id": row.root_ref_id, "inventory_type": row.inventory_type, "asset_ref_id": row.asset_ref_id, "radar_data_id": row.radar_data_id, "orbit_asset_id": row.orbit_asset_id, "severity": row.severity, "issue_code": row.issue_code, "issue_message": row.issue_message, "source_path": row.source_path, "status": row.status, "first_seen_at": row.first_seen_at, "last_seen_at": row.last_seen_at, "resolved_at": row.resolved_at, "metadata_json": row.metadata_json, } for row in rows ], "total": total, "limit": safe_limit, "offset": safe_offset, "has_more": safe_offset + len(rows) < total, } def _source_asset_payload(self, row: SourceProductAssetORM) -> Dict[str, Any]: return { "id": row.id, "asset_uid": row.asset_uid, "logical_product_uid": row.logical_product_uid, "satellite_family": row.satellite_family, "satellite": row.satellite, "source_format": row.source_format, "product_type": row.product_type, "product_level": row.product_level, "imaging_mode": row.imaging_mode, "polarization": row.polarization, "absolute_orbit": row.absolute_orbit, "relative_orbit": row.relative_orbit, "orbit_direction": row.orbit_direction, "acquisition_start_time_utc": row.acquisition_start_time_utc, "acquisition_stop_time_utc": row.acquisition_stop_time_utc, "imaging_date": row.imaging_date, "root_ref_id": row.root_ref_id, "root_path": row.root_path, "file_path": row.file_path, "archive_path": row.archive_path, "size_bytes": row.size_bytes, "mtime_epoch": row.mtime_epoch, "checksum_status": row.checksum_status, "archive_integrity_status": row.archive_integrity_status, "archive_integrity_method": row.archive_integrity_method, "archive_integrity_checked_at": row.archive_integrity_checked_at, "archive_integrity_error": row.archive_integrity_error, "archive_integrity_version": row.archive_integrity_version, "archive_integrity_member_count": row.archive_integrity_member_count, "parser_name": row.parser_name, "parser_version": row.parser_version, "parse_status": row.parse_status, "parse_error": row.parse_error, "parsed_at": row.parsed_at, "metadata_json": row.metadata_json, "is_active": bool(row.is_active), "missing_since": row.missing_since, "created_at": row.created_at, "updated_at": row.updated_at, } def _orbit_asset_payload(self, row: OrbitAssetORM) -> Dict[str, Any]: return { "id": row.id, "orbit_uid": row.orbit_uid, "satellite_family": row.satellite_family, "satellite": row.satellite, "orbit_type": row.orbit_type, "native_format": row.native_format, "quality_class": row.quality_class, "root_ref_id": row.root_ref_id, "root_path": row.root_path, "file_path": row.file_path, "file_name": row.file_name, "size_bytes": row.size_bytes, "mtime_epoch": row.mtime_epoch, "checksum_status": row.checksum_status, "validity_start_time_utc": row.validity_start_time_utc, "validity_stop_time_utc": row.validity_stop_time_utc, "generation_time_utc": row.generation_time_utc, "published_time_utc": row.published_time_utc, "parser_name": row.parser_name, "parser_version": row.parser_version, "parse_status": row.parse_status, "parse_error": row.parse_error, "parsed_at": row.parsed_at, "metadata_json": row.metadata_json, "is_active": bool(row.is_active), "missing_since": row.missing_since, "created_at": row.created_at, "updated_at": row.updated_at, } asset_inventory_service = AssetInventoryService()