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

3125 lines
133 KiB
Python

from __future__ import annotations
import asyncio
import gzip
import hashlib
import json
import math
import mimetypes
import os
import re
import struct
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon
from sqlalchemy import String, cast, delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import ResultAssetORM, ResultCatalogStateORM, ResultIssueORM, ResultProductORM
from .admin_region_lookup_service import lookup_admin_region_for_point
from .landsar_sbas_service import landsar_sbas_service
from .sbas_insar_production_service import sbas_insar_production_service
SBAS_INSAR_CATALOG_NAME = "sbas_insar"
JOB_TYPE_REBUILD_SBAS_INSAR_CATALOG = "REBUILD_SBAS_INSAR_CATALOG"
TASK_TYPE_REBUILD_SBAS_INSAR_CATALOG = "REBUILD_SBAS_INSAR_CATALOG"
_READY_STATUSES = {
"PRODUCTS_READY",
"MONITOR_POINTS_READY",
"WORKFLOW_COMPLETED",
"LANDSAR_SBAS_COMPLETED",
"LANDSAR_SBAS_PARTIAL",
}
_REQUIRED_ASSET_ROLES = {"primary_geotiff", "quality_geotiff"}
_LANDSAR_REQUIRED_ASSET_ROLES = {"primary_geotiff"}
_WGS84_GEOGCS_WKT = (
'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]],'
'PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]'
)
_CORE_ASSETS = (
("run_manifest", "Run manifest", "run_manifest.json", True, False),
("stack_manifest", "Stack manifest", "stack_manifest.json", True, False),
("workflow_summary", "Workflow summary", "workflow_summary.json", False, False),
("product_summary", "Product summary", "product_summary.json", False, False),
("quality_summary", "Quality summary", "quality_summary.json", False, False),
("monitor_points_summary", "Monitor points summary", "monitor_points_summary.json", False, False),
(
"point_vector_summary",
"LOS point-vector summary",
"publish/vectors/los_rate_points_summary.json",
False,
False,
),
(
"point_vector_geojson_gz",
"LOS point-vector GeoJSON.gz",
"publish/vectors/los_rate_points.geojson.gz",
False,
False,
),
(
"primary_geocoded_preview",
"LOS velocity preview, toward radar positive",
"publish/geotiff/los_rate_toward_m_per_year.hls.geo_preview.png",
False,
True,
),
(
"quality_geocoded_preview",
"LOS velocity sigma preview",
"publish/geotiff/los_sigma_m_per_year.cc.geo_preview.png",
False,
False,
),
(
"primary_geotiff",
"LOS velocity GeoTIFF, toward radar positive",
"publish/geotiff/los_rate_toward_m_per_year.tif",
True,
True,
),
(
"alternate_geotiff",
"LOS velocity GeoTIFF, away from radar positive",
"publish/geotiff/los_rate_away_m_per_year.tif",
False,
False,
),
(
"quality_geotiff",
"LOS velocity sigma GeoTIFF",
"publish/geotiff/los_sigma_m_per_year.tif",
True,
False,
),
(
"primary_rgb_geotiff",
"LOS velocity RGB GeoTIFF",
"publish/geotiff/los_rate_toward_m_per_year.hls.geo_rgb.tif",
False,
False,
),
(
"quality_rgb_geotiff",
"LOS velocity sigma RGB GeoTIFF",
"publish/geotiff/los_sigma_m_per_year.cc.geo_rgb.tif",
False,
False,
),
("gamma_phase_rate", "Gamma phase-rate GeoTIFF", "publish/geotiff/ts_rate_rad_per_year.tif", False, False),
("gamma_sigma_rate", "Gamma sigma-rate GeoTIFF", "publish/geotiff/sigma_rate_rad_per_year.tif", False, False),
("height_correction", "Height correction GeoTIFF", "publish/geotiff/hgt_correction_m.tif", False, False),
)
_LANDSAR_CORE_ASSETS = (
("run_manifest", "Run manifest", "run_manifest.json", True, False),
("stack_manifest", "Task/Input_Data manifest", "stack_manifest.json", True, False),
("workflow_summary", "LandSAR SBAS workflow summary", "workflow_summary.json", True, False),
("product_summary", "LandSAR SBAS product summary", "product_summary.json", False, False),
("quality_summary", "LandSAR SBAS quality summary", "quality_summary.json", False, False),
("command_manifest", "LandSAR SBAS command manifest", "landsar_command_manifest.json", False, False),
("native_console_log", "LandSAR SBAS console log", "native_logs", False, False),
("primary_preview", "LandSAR LOS preview", "publish/landsar/preview.png", False, True),
("primary_geotiff", "LandSAR LOS time-series GeoTIFF", "publish/landsar/los_timeseries.tif", True, True),
("secondary_geotiff", "LandSAR post-raster GeoTIFF", "publish/landsar/post_raster.tif", False, False),
)
_EXPERT_GAMMA_CORE_ASSETS = (
("run_manifest", "Run manifest", "run_manifest.json", True, False),
("stack_manifest", "Stack manifest", "stack_manifest.json", True, False),
("workflow_summary", "Workflow summary", "workflow_summary.json", False, False),
("monitor_points_summary", "Monitor points summary", "monitor_points_summary.json", False, False),
(
"unwrapped_phase_summary",
"Expert Gamma geocoded unwrapped phase summary",
"publish/geotiff/unwrapped/unwrapped_phase_summary.json",
False,
False,
),
(
"unwrapped_phase_radar_colorbar",
"Expert Gamma rmg.cm unwrapped phase radar colorbar",
"publish/geotiff/unwrapped/unwrapped_phase_rmg_colorbar.png",
False,
False,
),
(
"point_vector_summary",
"Expert Gamma LOS point-vector summary",
"publish/vectors/los_rate_points_summary.json",
False,
False,
),
(
"point_vector_geojson_gz",
"Expert Gamma LOS point-vector GeoJSON.gz",
"publish/vectors/los_rate_points.geojson.gz",
False,
False,
),
(
"primary_geocoded_preview",
"Expert Gamma geo_los_def_rate RGB PNG preview",
"publish/geotiff/geo_los_def_rate_rgb_preview.png",
False,
True,
),
(
"primary_rate_color_preview",
"Expert Gamma pure geo_los_def_rate hls.cm PNG preview",
"publish/geotiff/geo_los_def_rate_pure_hls_preview.png",
False,
False,
),
(
"primary_geotiff",
"Expert Gamma geo_los_def_rate GeoTIFF",
"publish/geotiff/geo_los_def_rate.tif",
True,
True,
),
(
"primary_rgb_geotiff",
"Expert Gamma geo_los_def_rate RGB GeoTIFF",
"publish/geotiff/geo_los_def_rate_rgb.tif",
False,
False,
),
(
"primary_colorbar",
"Expert Gamma hls.cm deformation-rate colorbar",
"publish/geotiff/geo_los_def_rate_hls_colorbar.png",
False,
False,
),
(
"monitor_points",
"Expert Gamma disp_prt_2d point time series",
"publish/points/disp_point.txt",
False,
False,
),
(
"monitor_point_items",
"Expert Gamma disp_prt_2d column definitions",
"publish/points/items.txt",
False,
False,
),
(
"monitor_point_selection",
"Expert Gamma disp_prt_2d selected radar points",
"publish/points/disp_point_sel.txt",
False,
False,
),
(
"monitor_point_selection_metadata",
"Expert Gamma monitor point selection metadata",
"publish/points/disp_point_selection.json",
False,
False,
),
)
def _utcnow() -> datetime:
return datetime.utcnow()
def _normalize_path(path: str | os.PathLike[str]) -> str:
return os.path.normpath(os.path.abspath(os.fspath(path)))
def _read_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as fp:
payload = json.load(fp)
return payload if isinstance(payload, dict) else {}
def _safe_read_json(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
return _read_json(path)
except Exception:
return {}
def _safe_float(value: Any) -> Optional[float]:
try:
parsed = float(value)
except (TypeError, ValueError):
return None
if parsed != parsed:
return None
return parsed
def _safe_int(value: Any) -> Optional[int]:
try:
return int(float(value))
except (TypeError, ValueError):
return None
def _read_gamma_key_values(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.is_file():
return values
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
if ":" not in line:
continue
key, raw_value = line.split(":", 1)
key = key.strip()
value = raw_value.strip().split()[0] if raw_value.strip() else ""
if key:
values[key] = value
return values
def _read_gamma_int_param(path: Path, key: str) -> Optional[int]:
return _safe_int(_read_gamma_key_values(path).get(key))
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
radius_m = 6371008.8
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * radius_m * math.asin(min(1.0, math.sqrt(a)))
def _parse_datetime(value: Any) -> Optional[datetime]:
text = str(value or "").strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text).replace(tzinfo=None)
except ValueError:
return None
def _stable_digest(*parts: Any, length: int = 20) -> str:
payload = "||".join(str(part or "") for part in parts)
return hashlib.sha1(payload.encode("utf-8", errors="ignore")).hexdigest()[:length]
def _asset_format(path: str) -> Optional[str]:
lowered = path.lower()
if lowered.endswith(".geojson.gz"):
return "geojson.gz"
ext = Path(path).suffix.lower()
return {
".bmp": "bmp",
".csv": "csv",
".geo": "gamma_binary",
".gz": "gzip",
".json": "json",
".log": "log",
".png": "png",
".sh": "shell",
".tif": "geotiff",
".tiff": "geotiff",
".txt": "text",
}.get(ext)
def _media_type(path: str) -> Optional[str]:
lowered = path.lower()
if lowered.endswith(".geojson.gz"):
return "application/gzip"
ext = Path(path).suffix.lower()
explicit = {
".bmp": "image/bmp",
".csv": "text/csv",
".gz": "application/gzip",
".json": "application/json",
".log": "text/plain",
".png": "image/png",
".sh": "text/x-shellscript",
".tif": "image/tiff",
".tiff": "image/tiff",
".txt": "text/plain",
}
return explicit.get(ext) or mimetypes.guess_type(path)[0]
def _build_rgb_geotiff_preview(source: Path, target: Path) -> Optional[str]:
if target.is_file():
return str(target)
if not source.is_file():
return None
try:
import numpy as np
import rasterio
from PIL import Image
from rasterio.enums import Resampling
except Exception:
return None
try:
with rasterio.open(source) as src:
scale = max(src.width / 1600, src.height / 1600, 1)
out_w = max(1, int(src.width / scale))
out_h = max(1, int(src.height / scale))
mask = src.dataset_mask(out_shape=(out_h, out_w)) > 0
if src.count >= 3:
data = src.read(
[1, 2, 3],
out_shape=(3, out_h, out_w),
resampling=Resampling.bilinear,
)
rgb = np.moveaxis(data, 0, -1)
if rgb.dtype != np.uint8:
rgb = np.clip(rgb, 0, 255).astype("uint8")
else:
data = src.read(1, out_shape=(out_h, out_w), resampling=Resampling.bilinear).astype("float32")
finite = np.isfinite(data)
valid = finite & mask
if np.any(valid):
p2, p98 = np.nanpercentile(data[valid], [2, 98])
if not np.isfinite(p2) or not np.isfinite(p98) or p98 <= p2:
p2 = float(np.nanmin(data[valid]))
p98 = float(np.nanmax(data[valid]))
norm = np.clip((data - p2) / max(p98 - p2, 1e-6), 0, 1)
else:
norm = np.zeros_like(data, dtype="float32")
gray = np.where(np.isfinite(norm), norm * 255, 0).astype("uint8")
rgb = np.dstack([gray, gray, gray])
alpha = np.where(mask, 255, 0).astype("uint8")
image = Image.fromarray(np.dstack([rgb, alpha]), mode="RGBA")
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
image.thumbnail((1600, 1600), resampling)
target.parent.mkdir(parents=True, exist_ok=True)
image.save(target, "PNG", optimize=True)
return str(target)
except Exception:
return None
def _find_gamma_hls_colormap() -> Optional[Path]:
candidates = [
Path(r"\\wsl.localhost\Ubuntu-24.04\usr\local\GAMMA_SOFTWARE-20240627\DISP\cmaps\hls.cm"),
Path(r"\\wsl$\Ubuntu-24.04\usr\local\GAMMA_SOFTWARE-20240627\DISP\cmaps\hls.cm"),
]
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def _find_gamma_colormap(name: str) -> Optional[Path]:
safe_name = Path(str(name or "")).name
if not safe_name:
return None
candidates = [
Path(r"\\wsl.localhost\Ubuntu-24.04\usr\local\GAMMA_SOFTWARE-20240627\DISP\cmaps") / safe_name,
Path(r"\\wsl$\Ubuntu-24.04\usr\local\GAMMA_SOFTWARE-20240627\DISP\cmaps") / safe_name,
]
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def _read_gamma_colormap(path: Path) -> list[tuple[int, int, int]]:
colors: list[tuple[int, int, int]] = []
if not path.is_file():
return colors
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
parts = line.strip().split()
if len(parts) < 3:
continue
try:
red, green, blue = (max(0, min(255, int(float(value)))) for value in parts[:3])
except ValueError:
continue
colors.append((red, green, blue))
return colors
def _build_gamma_colormap_colorbar(
target: Path,
*,
colormap_name: str,
min_value: float,
max_value: float,
unit: str,
title: str,
) -> Optional[str]:
if target.is_file():
return str(target)
source = _find_gamma_colormap(colormap_name)
colors = _read_gamma_colormap(source) if source else []
if not colors:
return None
try:
import numpy as np
from PIL import Image, ImageDraw, ImageFont
width = 920
bar_height = 34
label_height = 78
margin_x = 48
margin_top = 14
target.parent.mkdir(parents=True, exist_ok=True)
image = Image.new("RGBA", (width, bar_height + label_height), (255, 255, 255, 0))
draw = ImageDraw.Draw(image)
gradient_width = width - margin_x * 2
color_array = np.asarray(colors, dtype=np.uint8)
for x in range(gradient_width):
idx = int(round((x / max(1, gradient_width - 1)) * (len(color_array) - 1)))
draw.line(
[(margin_x + x, margin_top), (margin_x + x, margin_top + bar_height)],
fill=tuple(int(value) for value in color_array[idx]) + (255,),
)
draw.rectangle(
[margin_x, margin_top, margin_x + gradient_width, margin_top + bar_height],
outline=(15, 23, 42, 180),
width=1,
)
try:
font = ImageFont.truetype("arial.ttf", 14)
small_font = ImageFont.truetype("arial.ttf", 12)
except Exception:
font = ImageFont.load_default()
small_font = font
ticks = [
(min_value, f"{min_value:g}"),
(0.0, "0"),
(max_value, f"{max_value:g}"),
]
for value, label in ticks:
ratio = (value - min_value) / max(max_value - min_value, 1e-6)
x = margin_x + int(round(ratio * gradient_width))
draw.line([(x, margin_top + bar_height), (x, margin_top + bar_height + 7)], fill=(15, 23, 42, 220), width=1)
text = f"{label} {unit}".strip()
bbox = draw.textbbox((0, 0), text, font=font)
draw.text((x - (bbox[2] - bbox[0]) / 2, margin_top + bar_height + 10), text, fill=(15, 23, 42, 255), font=font)
source_label = f"{title} / {colormap_name}"
source_bbox = draw.textbbox((0, 0), source_label, font=small_font)
draw.text(
(width - margin_x - (source_bbox[2] - source_bbox[0]), margin_top + bar_height + 34),
source_label,
fill=(71, 85, 105, 255),
font=small_font,
)
image.save(target, "PNG", optimize=True)
return str(target)
except Exception:
return None
def _build_gamma_hls_colorbar(target: Path, *, min_mm_year: float = -80.0, max_mm_year: float = 80.0) -> Optional[str]:
if target.is_file():
return str(target)
source = _find_gamma_hls_colormap()
colors = _read_gamma_colormap(source) if source else []
if not colors:
return None
try:
import numpy as np
from PIL import Image, ImageDraw, ImageFont
width = 900
bar_height = 34
label_height = 58
margin_x = 46
margin_top = 14
target.parent.mkdir(parents=True, exist_ok=True)
image = Image.new("RGBA", (width, bar_height + label_height), (255, 255, 255, 0))
draw = ImageDraw.Draw(image)
gradient_width = width - margin_x * 2
color_array = np.asarray(colors, dtype=np.uint8)
for x in range(gradient_width):
idx = int(round((x / max(1, gradient_width - 1)) * (len(color_array) - 1)))
draw.line(
[(margin_x + x, margin_top), (margin_x + x, margin_top + bar_height)],
fill=tuple(int(value) for value in color_array[idx]) + (255,),
)
draw.rectangle(
[margin_x, margin_top, margin_x + gradient_width, margin_top + bar_height],
outline=(15, 23, 42, 180),
width=1,
)
ticks = [
(min_mm_year, f"{min_mm_year:g}"),
(0.0, "0"),
(max_mm_year, f"{max_mm_year:g}"),
]
try:
font = ImageFont.truetype("arial.ttf", 14)
small_font = ImageFont.truetype("arial.ttf", 12)
except Exception:
font = ImageFont.load_default()
small_font = font
for value, label in ticks:
ratio = (value - min_mm_year) / max(max_mm_year - min_mm_year, 1e-6)
x = margin_x + int(round(ratio * gradient_width))
draw.line([(x, margin_top + bar_height), (x, margin_top + bar_height + 7)], fill=(15, 23, 42, 220), width=1)
text = f"{label} mm/yr"
bbox = draw.textbbox((0, 0), text, font=font)
draw.text((x - (bbox[2] - bbox[0]) / 2, margin_top + bar_height + 10), text, fill=(15, 23, 42, 255), font=font)
source_label = "Gamma hls.cm"
source_bbox = draw.textbbox((0, 0), source_label, font=small_font)
draw.text(
(width - margin_x - (source_bbox[2] - source_bbox[0]), margin_top + bar_height + 33),
source_label,
fill=(71, 85, 105, 255),
font=small_font,
)
image.save(target, "PNG", optimize=True)
return str(target)
except Exception:
return None
def _build_gamma_hls_rate_preview(
source: Path,
target: Path,
*,
coverage_source: Optional[Path] = None,
min_native: float = -0.08,
max_native: float = 0.08,
) -> Optional[str]:
if target.is_file():
return str(target)
if not source.is_file():
return None
colormap_path = _find_gamma_hls_colormap()
colors = _read_gamma_colormap(colormap_path) if colormap_path else []
if not colors:
return None
try:
import numpy as np
import rasterio
from PIL import Image
from rasterio.enums import Resampling
except Exception:
return None
try:
with rasterio.open(source) as src:
scale = max(src.width / 1600, src.height / 1600, 1)
out_w = max(1, int(src.width / scale))
out_h = max(1, int(src.height / scale))
data = src.read(1, out_shape=(out_h, out_w), resampling=Resampling.nearest).astype("float32")
coverage = np.ones((out_h, out_w), dtype=bool)
if coverage_source and coverage_source.is_file():
with rasterio.open(coverage_source) as coverage_src:
if coverage_src.width == src.width and coverage_src.height == src.height and coverage_src.count >= 3:
coverage_rgb = coverage_src.read(
[1, 2, 3],
out_shape=(3, out_h, out_w),
resampling=Resampling.nearest,
)
coverage = np.any(coverage_rgb != 0, axis=0)
valid = np.isfinite(data) & coverage & (data != 0.0)
ratio = np.clip((data - min_native) / max(max_native - min_native, 1e-12), 0.0, 1.0)
color_array = np.asarray(colors, dtype=np.uint8)
indices = np.rint(ratio * (len(color_array) - 1)).astype(np.int32)
rgb = color_array[np.clip(indices, 0, len(color_array) - 1)]
alpha = np.where(valid, 255, 0).astype(np.uint8)
image = Image.fromarray(np.dstack([rgb, alpha]), mode="RGBA")
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
image.thumbnail((1600, 1600), resampling)
target.parent.mkdir(parents=True, exist_ok=True)
image.save(target, "PNG", optimize=True)
return str(target)
except Exception:
return None
def _parse_expert_disp_point_table(disp_point_path: Path) -> list[dict[str, Any]]:
if not disp_point_path.is_file():
return []
import csv
rows = list(csv.reader(disp_point_path.read_text(encoding="utf-8", errors="ignore").splitlines()))
if len(rows) < 2:
return []
header = [str(value or "").strip() for value in rows[0]]
dates = [value for value in header[5:] if value]
points: list[dict[str, Any]] = []
for index, row in enumerate(rows[1:], start=1):
if len(row) < 5:
continue
values = [str(value or "").strip() for value in row]
try:
img_x = int(float(values[0]))
img_y = int(float(values[1]))
except ValueError:
continue
displacements: list[dict[str, Any]] = []
for date_text, value_text in zip(dates, values[5:]):
try:
displacement = float(value_text)
except ValueError:
continue
date_clean = date_text.strip()
if len(date_clean) == 8 and date_clean.isdigit():
date_iso = f"{date_clean[0:4]}-{date_clean[4:6]}-{date_clean[6:8]}"
else:
date_iso = date_clean
displacements.append({"date": date_iso, "displacement_mm": displacement})
points.append(
{
"point_id": f"expert_point_{index:03d}",
"img_x": img_x,
"img_y": img_y,
"height_m": _safe_float(values[2]) if len(values) > 2 else None,
"deformation_rate_mm_per_year": _safe_float(values[3]) if len(values) > 3 else None,
"stdev_residual_phase_rad": _safe_float(values[4]) if len(values) > 4 else None,
"displacements": displacements,
}
)
return points
def _read_expert_sbas_dates(run_dir: Path) -> list[str]:
rmli_tab = run_dir / "sbas" / "RMLI_tab"
dates: list[str] = []
if rmli_tab.is_file():
for line in rmli_tab.read_text(encoding="utf-8", errors="ignore").splitlines():
parts = line.split()
if not parts:
continue
match = re.search(r"(\d{8})", Path(parts[0]).name)
if match:
raw = match.group(1)
dates.append(f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}")
if dates:
return dates
points = _parse_expert_disp_point_table(run_dir / "publish" / "points" / "disp_point.txt")
for point in points:
displacements = point.get("displacements") or []
if displacements:
return [str(item.get("date") or "") for item in displacements if item.get("date")]
return []
def _read_radar_float32(path: Path, *, width: int, img_x: int, img_y: int) -> Optional[float]:
if width <= 0 or img_x < 0 or img_y < 0 or not path.is_file():
return None
offset = (img_y * width + img_x) * 4
try:
if offset < 0 or offset + 4 > path.stat().st_size:
return None
with path.open("rb") as fp:
fp.seek(offset)
payload = fp.read(4)
if len(payload) != 4:
return None
value = struct.unpack(">f", payload)[0]
return value if math.isfinite(value) else None
except Exception:
return None
def _read_geo_rate_window(
*,
source_path: Path,
coverage_path: Path,
center_row: int,
center_col: int,
radius: int,
width: int,
height: int,
) -> tuple[Any, Any, int, int]:
import numpy as np
import rasterio
row0 = max(0, center_row - radius)
row1 = min(height - 1, center_row + radius)
col0 = max(0, center_col - radius)
col1 = min(width - 1, center_col + radius)
if row0 > row1 or col0 > col1:
return None, None, row0, col0
window = rasterio.windows.Window(col0, row0, col1 - col0 + 1, row1 - row0 + 1)
with rasterio.open(source_path) as src:
rate = src.read(1, window=window)
coverage = None
if coverage_path.is_file():
with rasterio.open(coverage_path) as cov:
if cov.width == width and cov.height == height and cov.count >= 3:
rgb = cov.read([1, 2, 3], window=window)
coverage = np.any(rgb != 0, axis=0)
return rate, coverage, row0, col0
def _query_expert_gamma_point_timeseries(run_dir: Path, *, lon: float, lat: float) -> dict[str, Any]:
source_path = run_dir / "publish" / "geotiff" / "geo_los_def_rate.tif"
coverage_path = run_dir / "publish" / "geotiff" / "geo_los_def_rate_rgb.tif"
lt_path = run_dir / "dem" / f"{_safe_read_json(run_dir / 'run_manifest.json').get('reference_date') or ''}.lt_fine"
if not lt_path.is_file():
candidates = sorted((run_dir / "dem").glob("*.lt_fine"))
lt_path = candidates[0] if candidates else lt_path
mli_par = run_dir / "sbas" / "mli.ave.par"
disp_tab = run_dir / "sbas" / "disp.TS_tab"
if not source_path.is_file():
raise FileNotFoundError("geo_los_def_rate.tif is missing")
if not lt_path.is_file():
raise FileNotFoundError("Gamma lookup table *.lt_fine is missing")
if not mli_par.is_file():
raise FileNotFoundError("mli.ave.par is missing")
if not disp_tab.is_file():
raise FileNotFoundError("disp.TS_tab is missing")
try:
import numpy as np
import rasterio
except Exception as exc:
raise RuntimeError(f"point_query_dependency_unavailable: {exc}") from exc
with rasterio.open(source_path) as src:
width = int(src.width)
height = int(src.height)
if lon < src.bounds.left or lon > src.bounds.right or lat < src.bounds.bottom or lat > src.bounds.top:
raise ValueError("requested WGS84 coordinate is outside product geocoded bounds")
center_row, center_col = src.index(lon, lat)
transform = src.transform
valid_choice: Optional[dict[str, Any]] = None
for radius in [0, 1, 2, 4, 8, 16, 32, 64, 128, 256]:
rate, coverage, row0, col0 = _read_geo_rate_window(
source_path=source_path,
coverage_path=coverage_path,
center_row=int(center_row),
center_col=int(center_col),
radius=radius,
width=width,
height=height,
)
if rate is None:
continue
valid = np.isfinite(rate) & (rate != 0.0)
if coverage is not None:
valid &= coverage
if not np.any(valid):
continue
rows, cols = np.where(valid)
abs_rows = rows + row0
abs_cols = cols + col0
distances = (abs_rows - int(center_row)) ** 2 + (abs_cols - int(center_col)) ** 2
best_index = int(np.argmin(distances))
matched_row = int(abs_rows[best_index])
matched_col = int(abs_cols[best_index])
matched_lon, matched_lat = rasterio.transform.xy(transform, matched_row, matched_col, offset="center")
rate_value = float(rate[rows[best_index], cols[best_index]])
valid_choice = {
"geo_row": matched_row,
"geo_col": matched_col,
"lon": float(matched_lon),
"lat": float(matched_lat),
"los_rate_mm_per_year": rate_value * 1000.0,
"source_native_m_per_year": rate_value,
"search_radius_pixels": radius,
"distance_m": _haversine_m(lon, lat, float(matched_lon), float(matched_lat)),
}
break
if valid_choice is None:
raise ValueError("no valid deformation pixel found near requested WGS84 coordinate")
lt_offset = (int(valid_choice["geo_row"]) * width + int(valid_choice["geo_col"])) * 8
if lt_offset < 0 or lt_offset + 8 > lt_path.stat().st_size:
raise ValueError("matched geocoded pixel is outside lookup table")
with lt_path.open("rb") as fp:
fp.seek(lt_offset)
range_value, azimuth_value = struct.unpack(">ff", fp.read(8))
if not math.isfinite(range_value) or not math.isfinite(azimuth_value):
raise ValueError("lookup table returned invalid radar coordinates")
radar_width = _read_gamma_int_param(mli_par, "range_samples") or _read_gamma_int_param(mli_par, "width") or 0
radar_lines = _read_gamma_int_param(mli_par, "azimuth_lines") or _read_gamma_int_param(mli_par, "nlines") or 0
img_x = int(round(range_value))
img_y = int(round(azimuth_value))
if radar_width <= 0 or radar_lines <= 0:
raise ValueError("invalid radar image dimensions")
img_x = min(max(0, img_x), radar_width - 1)
img_y = min(max(0, img_y), radar_lines - 1)
raw_disp_paths = [
Path(_wsl_path_to_windows(line.strip()))
for line in disp_tab.read_text(encoding="utf-8", errors="ignore").splitlines()
if line.strip()
]
dates = _read_expert_sbas_dates(run_dir)
displacements: list[dict[str, Any]] = []
for index, disp_path in enumerate(raw_disp_paths):
value_m = _read_radar_float32(disp_path, width=radar_width, img_x=img_x, img_y=img_y)
date = dates[index] if index < len(dates) else f"epoch_{index + 1:03d}"
if value_m is None:
displacements.append({"date": date, "displacement_mm": None})
else:
displacements.append({"date": date, "displacement_mm": value_m * 1000.0})
if not any(item.get("displacement_mm") is not None for item in displacements):
raise ValueError("matched radar pixel has no readable displacement values")
return {
"schema": "insar.gamma-sbas-point-query/v1",
"source_tool": "disp.TS_tab_radar_pixel_sample",
"query": {"lon": lon, "lat": lat},
"matched": {
**valid_choice,
"used_nearest": int(valid_choice["geo_row"]) != int(center_row)
or int(valid_choice["geo_col"]) != int(center_col),
"input_geo_row": int(center_row),
"input_geo_col": int(center_col),
"radar_range": float(range_value),
"radar_azimuth": float(azimuth_value),
"img_x": img_x,
"img_y": img_y,
"radar_width": radar_width,
"radar_lines": radar_lines,
},
"unit": "mm",
"rate_unit": "mm/yr",
"displacement_count": len(displacements),
"displacements": displacements,
}
def _locate_radar_points_in_geocoded_product(run_dir: Path, points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
targets: list[tuple[str, float, float]] = []
for point in points:
point_id = str(point.get("point_id") or "").strip()
img_x = _safe_float(point.get("img_x"))
img_y = _safe_float(point.get("img_y"))
if point_id and img_x is not None and img_y is not None:
targets.append((point_id, img_x, img_y))
if not targets:
return {}
source_path = run_dir / "publish" / "geotiff" / "geo_los_def_rate.tif"
coverage_path = run_dir / "publish" / "geotiff" / "geo_los_def_rate_rgb.tif"
manifest = _safe_read_json(run_dir / "run_manifest.json")
lt_path = run_dir / "dem" / f"{manifest.get('reference_date') or ''}.lt_fine"
if not lt_path.is_file():
candidates = sorted((run_dir / "dem").glob("*.lt_fine"))
lt_path = candidates[0] if candidates else lt_path
if not source_path.is_file() or not lt_path.is_file():
return {}
try:
import numpy as np
import rasterio
from rasterio.windows import Window
except Exception:
return {}
try:
with rasterio.open(source_path) as src:
width = int(src.width)
height = int(src.height)
transform = src.transform
expected_size = width * height * 2 * 4
if lt_path.stat().st_size < expected_size:
return {}
lookup = np.memmap(lt_path, dtype=">f4", mode="r", shape=(height, width, 2))
coarse_step = 16
coarse_best: dict[str, dict[str, Any]] = {
point_id: {"distance2": math.inf, "row": None, "col": None}
for point_id, _, _ in targets
}
for row0 in range(0, height, coarse_step * 128):
row1 = min(height, row0 + coarse_step * 128)
sampled = lookup[row0:row1:coarse_step, 0:width:coarse_step, :]
if sampled.size == 0:
continue
range_chunk = np.asarray(sampled[:, :, 0], dtype=np.float32)
azimuth_chunk = np.asarray(sampled[:, :, 1], dtype=np.float32)
valid = np.isfinite(range_chunk) & np.isfinite(azimuth_chunk) & (range_chunk >= 0.0) & (azimuth_chunk >= 0.0)
if not np.any(valid):
continue
for point_id, img_x, img_y in targets:
distances = (range_chunk - np.float32(img_x)) ** 2 + (azimuth_chunk - np.float32(img_y)) ** 2
distances = np.where(valid, distances, np.float32(np.inf))
flat_index = int(np.argmin(distances))
value = float(distances.flat[flat_index])
if value >= coarse_best[point_id]["distance2"]:
continue
local_row, local_col = np.unravel_index(flat_index, distances.shape)
coarse_best[point_id] = {
"distance2": value,
"row": int(row0 + local_row * coarse_step),
"col": int(local_col * coarse_step),
}
results: dict[str, dict[str, Any]] = {}
coverage_src = None
if coverage_path.is_file():
try:
candidate = rasterio.open(coverage_path)
if candidate.width == width and candidate.height == height and candidate.count >= 3:
coverage_src = candidate
else:
candidate.close()
except Exception:
coverage_src = None
try:
refine_radius = coarse_step * 12
for point_id, img_x, img_y in targets:
seed = coarse_best.get(point_id) or {}
seed_row = seed.get("row")
seed_col = seed.get("col")
if seed_row is None or seed_col is None:
continue
row0 = max(0, int(seed_row) - refine_radius)
row1 = min(height, int(seed_row) + refine_radius + 1)
col0 = max(0, int(seed_col) - refine_radius)
col1 = min(width, int(seed_col) + refine_radius + 1)
local = lookup[row0:row1, col0:col1, :]
range_local = np.asarray(local[:, :, 0], dtype=np.float32)
azimuth_local = np.asarray(local[:, :, 1], dtype=np.float32)
valid = np.isfinite(range_local) & np.isfinite(azimuth_local) & (range_local >= 0.0) & (azimuth_local >= 0.0)
if not np.any(valid):
continue
distances = (range_local - np.float32(img_x)) ** 2 + (azimuth_local - np.float32(img_y)) ** 2
distances = np.where(valid, distances, np.float32(np.inf))
flat_index = int(np.argmin(distances))
value = float(distances.flat[flat_index])
if not math.isfinite(value):
continue
local_row, local_col = np.unravel_index(flat_index, distances.shape)
row = int(row0 + local_row)
col = int(col0 + local_col)
lon, lat = rasterio.transform.xy(transform, row, col, offset="center")
rate_mm_per_year = None
coverage_valid = None
try:
rate_window = src.read(1, window=Window(col, row, 1, 1))
rate_value = float(rate_window[0, 0])
if math.isfinite(rate_value):
rate_mm_per_year = rate_value * 1000.0
except Exception:
rate_mm_per_year = None
if coverage_src is not None:
try:
rgb = coverage_src.read([1, 2, 3], window=Window(col, row, 1, 1))
coverage_valid = bool(np.any(rgb != 0))
except Exception:
coverage_valid = None
results[point_id] = {
"lon": float(lon),
"lat": float(lat),
"geo_row": row,
"geo_col": col,
"geo_match_distance_px": math.sqrt(value),
"geo_los_rate_mm_per_year": rate_mm_per_year,
"geo_coverage_valid": coverage_valid,
"geo_source": "gamma_lt_fine_nearest_inverse",
}
finally:
if coverage_src is not None:
coverage_src.close()
del lookup
return results
except Exception:
return {}
def _file_summary(path: Path) -> dict[str, Any]:
return {
"path": str(path),
"exists": path.is_file(),
"size_bytes": path.stat().st_size if path.is_file() else None,
}
def _build_expert_gamma_primary_geotiff_stats(path: Path) -> dict[str, Any]:
stats = {
**_file_summary(path),
"schema": "insar.gamma-sbas-expert-primary-geotiff-stats/v1",
"source": "expert_gamma_geo_los_def_rate",
"unit": "mm/yr",
"native_unit": "m/yr",
"scale_to_unit": 1000.0,
"zero_is_valid": False,
"validity_rule": "expert_rgb_coverage_finite_nonzero_values",
}
if not path.is_file():
return stats
try:
import numpy as np
import rasterio
from rasterio.windows import Window
except Exception as exc:
stats["error"] = f"raster_stats_dependency_unavailable: {exc}"
return stats
try:
coverage_path = path.with_name("geo_los_def_rate_rgb.tif")
coverage_mask_source = str(coverage_path) if coverage_path.is_file() else None
valid_count = 0
zero_count = 0
nonzero_count = 0
sample_count = 0
value_sum = 0.0
value_sumsq = 0.0
value_min: Optional[float] = None
value_max: Optional[float] = None
percentile_chunks: list[Any] = []
max_percentile_samples = 5_000_000
tile_size = 1024
with rasterio.open(path) as src:
coverage_src = None
if coverage_path.is_file():
try:
candidate = rasterio.open(coverage_path)
if candidate.width == src.width and candidate.height == src.height and candidate.count >= 3:
coverage_src = candidate
else:
candidate.close()
except Exception:
coverage_src = None
total_count = int(src.width * src.height)
stats.update(
{
"width": int(src.width),
"height": int(src.height),
"band_count": int(src.count),
"dtype": str(src.dtypes[0]) if src.dtypes else None,
"crs": str(src.crs) if src.crs else None,
"nodata": _safe_float(src.nodata),
"metadata_nodata_applied": _safe_float(src.nodata) == 0.0,
"coverage_mask_source": coverage_mask_source if coverage_src is not None else None,
"bounds": {
"left": _safe_float(src.bounds.left),
"bottom": _safe_float(src.bounds.bottom),
"right": _safe_float(src.bounds.right),
"top": _safe_float(src.bounds.top),
},
"total_count": total_count,
}
)
for row_off in range(0, src.height, tile_size):
for col_off in range(0, src.width, tile_size):
window = Window(
col_off=col_off,
row_off=row_off,
width=min(tile_size, src.width - col_off),
height=min(tile_size, src.height - row_off),
)
data = src.read(1, window=window, masked=False)
values = np.asarray(data, dtype=np.float64).reshape(-1)
if values.size == 0:
continue
finite = np.isfinite(values)
if coverage_src is not None:
coverage_rgb = coverage_src.read([1, 2, 3], window=window, masked=False)
coverage = np.any(coverage_rgb != 0, axis=0).reshape(-1)
else:
coverage = np.ones(values.shape, dtype=bool)
covered_values = values[finite & coverage]
zero_count += int(np.count_nonzero(covered_values == 0.0))
values = values[finite & coverage & (values != 0.0)]
if values.size == 0:
continue
scaled = values * 1000.0
valid_count += int(scaled.size)
nonzero_count += int(np.count_nonzero(values != 0.0))
value_sum += float(scaled.sum(dtype=np.float64))
value_sumsq += float(np.square(scaled, dtype=np.float64).sum(dtype=np.float64))
chunk_min = float(scaled.min())
chunk_max = float(scaled.max())
value_min = chunk_min if value_min is None else min(value_min, chunk_min)
value_max = chunk_max if value_max is None else max(value_max, chunk_max)
if sample_count < max_percentile_samples:
remaining = max_percentile_samples - sample_count
if scaled.size <= remaining:
sample = scaled
else:
step = max(1, int(np.ceil(scaled.size / remaining)))
sample = scaled[::step][:remaining]
percentile_chunks.append(sample.astype(np.float64, copy=True))
sample_count += int(sample.size)
if coverage_src is not None:
coverage_src.close()
stats["valid_count"] = valid_count
stats["zero_count"] = zero_count
stats["nonzero_count"] = nonzero_count
stats["valid_ratio"] = (valid_count / stats["total_count"]) if stats.get("total_count") else None
if valid_count <= 0:
return stats
mean = value_sum / valid_count
variance = max((value_sumsq / valid_count) - (mean * mean), 0.0)
stats.update(
{
"min": value_min,
"max": value_max,
"mean": mean,
"stddev": float(variance ** 0.5),
"sample_count": sample_count,
"percentiles_sampled": sample_count < valid_count,
}
)
if percentile_chunks:
percentile_values = np.concatenate(percentile_chunks)
p01, p05, median, p95, p99 = np.percentile(percentile_values, [1, 5, 50, 95, 99])
stats.update(
{
"p01": float(p01),
"p05": float(p05),
"median": float(median),
"p95": float(p95),
"p99": float(p99),
}
)
return stats
except Exception as exc:
stats["error"] = f"raster_stats_failed: {exc}"
return stats
def _build_expert_gamma_quality_summary(run_dir: Path) -> dict[str, Any]:
primary_stats = _build_expert_gamma_primary_geotiff_stats(
run_dir / "publish" / "geotiff" / "geo_los_def_rate.tif"
)
return {
"schema": "insar.gamma-sbas-derived-quality-summary/v1",
"source": "derived_from_expert_gamma_outputs",
"note": "Catalog/UI inspection stats derived from expert geo_los_def_rate.tif; quality_summary.json is not required for the expert workflow.",
"primary_geotiff": primary_stats,
}
def _pixel_center(transform: tuple[float, float, float, float, float, float], row: int, col: int) -> tuple[float, float]:
a, b, c, d, e, f = transform
x = c + (col + 0.5) * a + (row + 0.5) * b
y = f + (col + 0.5) * d + (row + 0.5) * e
return float(x), float(y)
def _normalize_crs_label(value: Any) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
upper = text.upper()
if "EPSG" in upper and "4326" in upper:
return "EPSG:4326"
if "WGS 84" in upper or "WGS_1984" in upper:
return "EPSG:4326"
return text[:240]
def _build_expert_gamma_point_vector(run_dir: Path, *, summary_context: Optional[dict[str, Any]] = None) -> dict[str, Any]:
source_path = run_dir / "publish" / "geotiff" / "geo_los_def_rate.tif"
coverage_path = run_dir / "publish" / "geotiff" / "geo_los_def_rate_rgb.tif"
output_path = run_dir / "publish" / "vectors" / "los_rate_points.geojson.gz"
summary_path = run_dir / "publish" / "vectors" / "los_rate_points_summary.json"
previous_summary = _safe_read_json(summary_path)
context = summary_context or {}
stack_dates = context.get("stack_dates") or []
admin_region = context.get("admin_region") if isinstance(context.get("admin_region"), dict) else {}
admin_names = admin_region.get("names") if isinstance(admin_region.get("names"), dict) else {}
fields = [
"run_id",
"row",
"col",
"lon",
"lat",
"los_rate_mm_per_year",
"source_native_m_per_year",
"date_start",
"date_end",
"reference_date",
"admin_province",
"admin_city",
]
summary: dict[str, Any] = {
"schema": "insar.gamma-sbas-expert-point-vector-summary/v1",
"generated_at": previous_summary.get("generated_at") or _utcnow().isoformat(timespec="seconds") + "Z",
"ready": False,
"feature_count": 0,
"output_geojson_gz": str(output_path),
"output_size_bytes": output_path.stat().st_size if output_path.is_file() else 0,
"fields": fields,
"source_geotiffs": {
"geo_los_def_rate": str(source_path),
"coverage_mask": str(coverage_path) if coverage_path.is_file() else None,
},
"unit": "mm/yr",
"native_unit": "m/yr",
"scale_to_unit": 1000.0,
"zero_is_valid": False,
"validity_rule": "expert_rgb_coverage_finite_nonzero_values",
"date_start": context.get("date_start") or (stack_dates[0] if stack_dates else None),
"date_end": context.get("date_end") or (stack_dates[-1] if stack_dates else None),
"reference_date": context.get("reference_date"),
"admin_region": {
"province": admin_names.get("province") or admin_region.get("province"),
"city": admin_names.get("city") or admin_region.get("city"),
},
"los_convention": context.get("los_sign_convention") or "Gamma expert geo_los_def_rate output; sign follows the expert workflow.",
"frontend_policy": "download_only; do not render full point GeoJSON in browser",
}
if not source_path.is_file():
_write_json_if_changed(summary_path, {**summary, "error": "source_geotiff_missing"})
return summary
if output_path.is_file() and summary_path.is_file():
input_paths = [source_path]
if coverage_path.is_file():
input_paths.append(coverage_path)
output_mtime = output_path.stat().st_mtime
if (
previous_summary.get("validity_rule") == "expert_rgb_coverage_finite_nonzero_values"
and previous_summary.get("zero_is_valid") is False
and all(output_mtime >= item.stat().st_mtime for item in input_paths)
):
return previous_summary
try:
import numpy as np
import rasterio
from rasterio.windows import Window
except Exception as exc:
summary["error"] = f"point_vector_dependency_unavailable: {exc}"
_write_json_if_changed(summary_path, summary)
return summary
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
feature_count = 0
zero_count = 0
tile_size = 512
with rasterio.open(source_path) as src:
coverage_src = None
if coverage_path.is_file():
candidate = rasterio.open(coverage_path)
if candidate.width == src.width and candidate.height == src.height and candidate.count >= 3:
coverage_src = candidate
else:
candidate.close()
transform = tuple(float(value) for value in src.transform.to_gdal())
# Convert GDAL geotransform (c, a, b, f, d, e) to affine tuple used by _pixel_center.
transform = (transform[1], transform[2], transform[0], transform[4], transform[5], transform[3])
nodata = _safe_float(src.nodata)
summary.update(
{
"width": int(src.width),
"height": int(src.height),
"crs": _normalize_crs_label(str(src.crs) if src.crs else None),
"nodata": nodata,
"metadata_nodata_applied": nodata == 0.0,
"coverage_mask_source": str(coverage_path) if coverage_src is not None else None,
"total_count": int(src.width * src.height),
}
)
with gzip.open(output_path, "wt", encoding="utf-8", compresslevel=6) as handle:
handle.write('{"type":"FeatureCollection","features":[\n')
first = True
for row_off in range(0, src.height, tile_size):
for col_off in range(0, src.width, tile_size):
window = Window(
col_off=col_off,
row_off=row_off,
width=min(tile_size, src.width - col_off),
height=min(tile_size, src.height - row_off),
)
data = src.read(1, window=window, masked=False).astype("float64", copy=False)
finite = np.isfinite(data)
coverage = np.ones(data.shape, dtype=bool)
if coverage_src is not None:
rgb = coverage_src.read([1, 2, 3], window=window, masked=False)
coverage = np.any(rgb != 0, axis=0)
covered = finite & coverage
zero_count += int(np.count_nonzero(data[covered] == 0.0))
valid = covered & (data != 0.0)
rows, cols = np.where(valid)
for local_row, local_col in zip(rows.tolist(), cols.tolist()):
row = int(row_off + local_row)
col = int(col_off + local_col)
lon, lat = _pixel_center(transform, row, col)
native_value = float(data[local_row, local_col])
properties = {
"run_id": context.get("run_id") or run_dir.name,
"row": row,
"col": col,
"lon": lon,
"lat": lat,
"los_rate_mm_per_year": native_value * 1000.0,
"source_native_m_per_year": native_value,
"date_start": summary.get("date_start"),
"date_end": summary.get("date_end"),
"reference_date": summary.get("reference_date"),
"admin_province": summary["admin_region"].get("province"),
"admin_city": summary["admin_region"].get("city"),
}
feature = {
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": properties,
}
if not first:
handle.write(",\n")
handle.write(json.dumps(feature, ensure_ascii=False, separators=(",", ":")))
first = False
feature_count += 1
handle.write("\n]}\n")
if coverage_src is not None:
coverage_src.close()
summary.update(
{
"ready": output_path.is_file() and output_path.stat().st_size > 0,
"feature_count": feature_count,
"zero_count": zero_count,
"output_size_bytes": output_path.stat().st_size if output_path.is_file() else 0,
}
)
_write_json_if_changed(summary_path, summary)
return summary
except Exception as exc:
summary["error"] = f"point_vector_export_failed: {exc}"
_write_json_if_changed(summary_path, summary)
return summary
def _write_text_if_changed(path: Path, text: str) -> bool:
if path.is_file():
try:
if path.read_text(encoding="utf-8", errors="ignore") == text:
return False
except Exception:
pass
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return True
def _write_json_if_changed(path: Path, payload: dict[str, Any]) -> bool:
text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
return _write_text_if_changed(path, text)
_MONITOR_POINT_SELECTION_DEFINITIONS = [
(
"toward_high_rate_low_sigma",
"趋近雷达高形变低残差点",
"rate > 0,且绝对速率位于高分位,残差低,用于检查明显正向形变区域。",
),
(
"away_high_rate_low_sigma",
"远离雷达高形变低残差点",
"rate < 0,且绝对速率位于高分位,残差低,用于检查明显负向形变区域。",
),
(
"high_abs_rate_low_sigma",
"高绝对速率低残差点",
"不区分正负,优先选择绝对速率高且残差低的有效点。",
),
(
"stable_low_sigma",
"近零低残差代表点",
"绝对速率位于低分位且残差低,用于对照相对稳定区域。",
),
(
"center_valid",
"覆盖区中心有效点",
"从有效像元中选取最接近雷达网格中心的点,用于空间位置对照。",
),
]
def _read_expert_monitor_point_selection(points_dir: Path) -> dict[tuple[int, int], dict[str, Any]]:
selection_path = points_dir / "disp_point_sel.txt"
rows: list[dict[str, Any]] = []
if selection_path.is_file():
for index, line in enumerate(selection_path.read_text(encoding="utf-8", errors="ignore").splitlines()):
parts = line.strip().split()
if len(parts) < 2:
continue
try:
img_x = int(float(parts[0]))
img_y = int(float(parts[1]))
except ValueError:
continue
key, label, description = (
_MONITOR_POINT_SELECTION_DEFINITIONS[index]
if index < len(_MONITOR_POINT_SELECTION_DEFINITIONS)
else (
f"extra_representative_{index + 1:03d}",
f"补充代表点 {index + 1}",
"自动选点数量超过内置策略说明时的补充代表点。",
)
)
rows.append(
{
"selection_rank": index + 1,
"selection_key": key,
"selection_label": label,
"selection_description": description,
"img_x": img_x,
"img_y": img_y,
}
)
payload = {
"schema": "insar.gamma-sbas-expert-monitor-point-selection/v1",
"generated_at": _utcnow().isoformat(timespec="seconds") + "Z",
"source": "disp_point_sel.txt",
"selection_count": len(rows),
"strategy": "auto_representative_points",
"strategy_note": "自动选取趋近/远离雷达高形变、绝对高形变、近零稳定和中心有效点;时序仍由 Gamma disp_prt_2d 输出。",
"points": rows,
}
if rows:
_write_json_if_changed(points_dir / "disp_point_selection.json", payload)
return {(int(item["img_x"]), int(item["img_y"])): item for item in rows}
def _wsl_path_to_windows(path: str) -> str:
text = str(path or "").strip()
if text.startswith("/mnt/") and len(text) > 6 and text[6] == "/":
drive = text[5].upper()
return f"{drive}:{text[6:]}".replace("/", "\\")
return text
def _build_expert_unwrapped_phase_radar_browse(
run_dir: Path,
source_paths: list[Path],
*,
width: int,
lines: int,
) -> dict[str, dict[str, Any]]:
unwrapped_dir = run_dir / "publish" / "geotiff" / "unwrapped"
browse_by_pair: dict[str, dict[str, Any]] = {}
if not source_paths or width <= 0 or lines <= 0:
return browse_by_pair
try:
import numpy as np
from PIL import Image
except Exception:
return browse_by_pair
colormap_path = _find_gamma_colormap("rmg.cm")
colors = _read_gamma_colormap(colormap_path) if colormap_path else []
if not colors:
return browse_by_pair
color_array = np.asarray(colors, dtype=np.uint8)
unwrapped_dir.mkdir(parents=True, exist_ok=True)
_build_gamma_colormap_colorbar(
unwrapped_dir / "unwrapped_phase_rmg_colorbar.png",
colormap_name="rmg.cm",
min_value=-6.28,
max_value=6.28,
unit="rad",
title="Gamma unwrapped phase browse",
)
for source in source_paths:
pair_id = source.name.replace(".unw.atmsub_1", "")
output_bmp = unwrapped_dir / f"{source.name}.rdc_rmg.bmp"
output_png = unwrapped_dir / f"{source.name}.rdc_rmg_preview.png"
item = {
"pair_id": pair_id,
"radar_coordinates": True,
"browse_command": "rasdt_pwr <unw.atmsub_1> mli.ave <width> 1 - 1 1 -6.28 6.28 1 rmg.cm ... 1.0 0.35 24",
"colormap": "Gamma rmg.cm",
"display_range_rad": [-6.28, 6.28],
"bmp": _file_summary(output_bmp),
"preview": _file_summary(output_png),
"ready": False,
}
if not source.is_file():
item["error"] = "source_missing"
browse_by_pair[pair_id] = item
continue
if (
output_png.is_file()
and output_png.stat().st_mtime >= source.stat().st_mtime
and (not output_bmp.is_file() or output_bmp.stat().st_mtime >= source.stat().st_mtime)
):
item["bmp"] = _file_summary(output_bmp)
item["preview"] = _file_summary(output_png)
item["ready"] = output_png.stat().st_size > 0
browse_by_pair[pair_id] = item
continue
try:
data = np.fromfile(source, dtype=">f4", count=int(width) * int(lines)).reshape((int(lines), int(width)))
valid = np.isfinite(data) & (data != 0.0)
wrapped = ((data + 6.28) % 12.56) - 6.28
ratio = np.clip((wrapped + 6.28) / 12.56, 0.0, 1.0)
indices = np.rint(ratio * (len(color_array) - 1)).astype(np.int32)
rgb = color_array[np.clip(indices, 0, len(color_array) - 1)]
rgb = np.where(valid[..., None], rgb, 0).astype(np.uint8)
image = Image.fromarray(rgb, mode="RGB")
image.save(output_bmp, "BMP")
preview = image.copy()
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
preview.thumbnail((1600, 1600), resampling)
preview.save(output_png, "PNG", optimize=True)
item.update(
{
"bmp": _file_summary(output_bmp),
"preview": _file_summary(output_png),
"valid_count": int(np.count_nonzero(valid)),
"ready": output_png.is_file() and output_png.stat().st_size > 0,
}
)
except Exception as exc:
item["error"] = str(exc)
browse_by_pair[pair_id] = item
return browse_by_pair
def _build_expert_unwrapped_phase_derivatives(run_dir: Path) -> dict[str, Any]:
unwrapped_dir = run_dir / "publish" / "geotiff" / "unwrapped"
summary_path = unwrapped_dir / "unwrapped_phase_summary.json"
final_tab = run_dir / "sbas" / "final_unw_tab"
manifest = _safe_read_json(run_dir / "run_manifest.json")
stack_manifest = _safe_read_json(run_dir / "stack_manifest.json")
stack = stack_manifest.get("stack") if isinstance(stack_manifest.get("stack"), dict) else {}
reference_date = str(
manifest.get("reference_date")
or stack.get("reference_date")
or (manifest.get("coregistration") or {}).get("reference_date")
or ""
).strip()
dem_par = run_dir / "dem" / f"{reference_date}_seg.dem_par" if reference_date else next(iter(sorted((run_dir / "dem").glob("*_seg.dem_par"))), run_dir / "dem" / "_missing_seg.dem_par")
lookup = run_dir / "dem" / f"{reference_date}.lt_fine" if reference_date else next(iter(sorted((run_dir / "dem").glob("*.lt_fine"))), run_dir / "dem" / "_missing.lt_fine")
mli_par = run_dir / "sbas" / "mli.ave.par"
previous = _safe_read_json(summary_path)
source_paths: list[Path] = []
if final_tab.is_file():
for line in final_tab.read_text(encoding="utf-8", errors="ignore").splitlines():
text = line.strip().split()
if text:
source_paths.append(Path(_wsl_path_to_windows(text[0])))
summary: dict[str, Any] = {
"schema": "insar.gamma-sbas-expert-unwrapped-phase-summary/v1",
"generated_at": previous.get("generated_at") or _utcnow().isoformat(timespec="seconds") + "Z",
"source_stage": "final_unw_tab",
"source_tab": _file_summary(final_tab),
"source_count": len(source_paths),
"ready": False,
"products": [],
"note": "Geocoded GeoTIFF derivatives from the final unwrapped phase files consumed by the expert Gamma SBAS inversion.",
}
if not source_paths:
_write_json_if_changed(summary_path, summary)
return summary
outputs = [
unwrapped_dir / f"{path.name}.geo.tif"
for path in source_paths
]
radar_outputs = [
unwrapped_dir / f"{path.name}.rdc_rmg_preview.png"
for path in source_paths
]
radar_colorbar = unwrapped_dir / "unwrapped_phase_rmg_colorbar.png"
previous_products = previous.get("products") if isinstance(previous.get("products"), list) else []
previous_has_radar_browse = (
len(previous_products) == len(source_paths)
and all((item.get("radar_browse") or {}).get("ready") for item in previous_products if isinstance(item, dict))
)
if (
summary_path.is_file()
and all(output.is_file() for output in outputs)
and all(output.is_file() for output in radar_outputs)
and radar_colorbar.is_file()
and previous_has_radar_browse
and all(
output.stat().st_mtime >= source.stat().st_mtime
for source, output in zip(source_paths, outputs)
if source.is_file()
)
and all(
output.stat().st_mtime >= source.stat().st_mtime
for source, output in zip(source_paths, radar_outputs)
if source.is_file()
)
):
return previous
try:
import numpy as np
import rasterio
from PIL import Image
from rasterio.transform import from_origin
except Exception as exc:
summary["error"] = f"unwrapped_phase_dependency_unavailable: {exc}"
_write_json_if_changed(summary_path, summary)
return summary
def read_gamma_param(path: Path, key: str) -> Optional[str]:
if not path.is_file():
return None
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
parts = line.split()
if parts and parts[0].rstrip(":") == key.rstrip(":") and len(parts) > 1:
return parts[1]
return None
try:
rdc_width = _safe_int(read_gamma_param(mli_par, "range_samples"))
rdc_lines = _safe_int(read_gamma_param(mli_par, "azimuth_lines"))
dem_width = _safe_int(read_gamma_param(dem_par, "width"))
dem_lines = _safe_int(read_gamma_param(dem_par, "nlines"))
corner_lon = _safe_float(read_gamma_param(dem_par, "corner_lon"))
corner_lat = _safe_float(read_gamma_param(dem_par, "corner_lat"))
post_lon = _safe_float(read_gamma_param(dem_par, "post_lon"))
post_lat = _safe_float(read_gamma_param(dem_par, "post_lat"))
if None in (rdc_width, rdc_lines, dem_width, dem_lines, corner_lon, corner_lat, post_lon, post_lat):
raise RuntimeError("required Gamma geometry parameters are missing")
if not lookup.is_file():
raise FileNotFoundError(str(lookup))
unwrapped_dir.mkdir(parents=True, exist_ok=True)
radar_browse_by_pair = _build_expert_unwrapped_phase_radar_browse(
run_dir,
source_paths,
width=int(rdc_width),
lines=int(rdc_lines),
)
lut = np.fromfile(lookup, dtype=">c8").reshape((int(dem_lines), int(dem_width)))
rng = np.rint(lut.real).astype(np.int32)
az = np.rint(lut.imag).astype(np.int32)
valid_lut = (
np.isfinite(lut.real)
& np.isfinite(lut.imag)
& (rng >= 0)
& (rng < int(rdc_width))
& (az >= 0)
& (az < int(rdc_lines))
)
del lut
transform = from_origin(float(corner_lon), float(corner_lat), abs(float(post_lon)), abs(float(post_lat)))
products: list[dict[str, Any]] = []
preview_limit = 6
for source in source_paths:
if not source.is_file():
products.append({"source": str(source), "ready": False, "error": "source_missing"})
continue
pair_id = source.name.replace(".unw.atmsub_1", "")
output_tif = unwrapped_dir / f"{source.name}.geo.tif"
output_preview = unwrapped_dir / f"{source.name}.geo_preview.png"
data = np.fromfile(source, dtype=">f4", count=int(rdc_width) * int(rdc_lines)).reshape((int(rdc_lines), int(rdc_width)))
geo = np.full((int(dem_lines), int(dem_width)), np.nan, dtype=np.float32)
geo[valid_lut] = data[az[valid_lut], rng[valid_lut]]
finite_nonzero = np.isfinite(geo) & (geo != 0.0)
with rasterio.open(
output_tif,
"w",
driver="GTiff",
width=int(dem_width),
height=int(dem_lines),
count=1,
dtype="float32",
crs=_WGS84_GEOGCS_WKT,
transform=transform,
nodata=np.nan,
compress="deflate",
) as dst:
dst.write(geo, 1)
if np.any(finite_nonzero) and len(products) < preview_limit:
valid_values = geo[finite_nonzero]
p02, p98 = np.nanpercentile(valid_values, [2, 98])
if not np.isfinite(p02) or not np.isfinite(p98) or p98 <= p02:
p02 = float(np.nanmin(valid_values))
p98 = float(np.nanmax(valid_values))
norm = np.clip((geo - p02) / max(p98 - p02, 1e-6), 0, 1)
gray = np.where(finite_nonzero, norm * 255, 0).astype("uint8")
alpha = np.where(finite_nonzero, 255, 0).astype("uint8")
rgba = np.dstack([gray, gray, gray, alpha])
image = Image.fromarray(rgba, mode="RGBA")
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
image.thumbnail((1600, 1600), resampling)
image.save(output_preview, "PNG", optimize=True)
products.append(
{
"pair_id": pair_id,
"source": str(source),
"geotiff": _file_summary(output_tif),
"preview": _file_summary(output_preview),
"radar_browse": radar_browse_by_pair.get(pair_id) or {},
"valid_count": int(np.count_nonzero(finite_nonzero)),
"unit": "rad",
"description": "Final model-corrected unwrapped phase used by Gamma SBAS inversion, geocoded for inspection.",
"ready": output_tif.is_file() and output_tif.stat().st_size > 0,
}
)
del data, geo, finite_nonzero
summary.update(
{
"generated_at": _utcnow().isoformat(timespec="seconds") + "Z",
"ready": bool(products) and all(item.get("ready") for item in products),
"products": products,
}
)
_write_json_if_changed(summary_path, summary)
return summary
except Exception as exc:
summary["error"] = f"unwrapped_phase_export_failed: {exc}"
_write_json_if_changed(summary_path, summary)
return summary
def _write_expert_monitor_point_derivatives(run_dir: Path) -> dict[str, Any]:
points_dir = run_dir / "publish" / "points"
disp_point_path = points_dir / "disp_point.txt"
items_path = points_dir / "items.txt"
selection_by_xy = _read_expert_monitor_point_selection(points_dir)
points = _parse_expert_disp_point_table(disp_point_path)
geo_locations = _locate_radar_points_in_geocoded_product(run_dir, points)
out_dir = run_dir / "publish" / "monitor_points"
summary_path = run_dir / "monitor_points_summary.json"
monitor_outputs: list[dict[str, Any]] = []
if points:
out_dir.mkdir(parents=True, exist_ok=True)
expected_names = set()
for index in range(1, len(points) + 1):
point_id = f"expert_point_{index:03d}"
expected_names.update(
{
f"{point_id}_timeseries.csv",
f"{point_id}_timeseries.png",
f"{point_id}_metadata.json",
}
)
for old_path in out_dir.iterdir():
if old_path.is_file() and old_path.name not in expected_names:
try:
old_path.unlink()
except Exception:
pass
for point in points:
point_id = str(point["point_id"])
csv_path = out_dir / f"{point_id}_timeseries.csv"
png_path = out_dir / f"{point_id}_timeseries.png"
metadata_path = out_dir / f"{point_id}_metadata.json"
displacements = point.get("displacements") or []
csv_lines = ["date,displacement_mm"]
csv_lines.extend(f"{item['date']},{item['displacement_mm']:.6f}" for item in displacements)
curve_needs_refresh = _write_text_if_changed(csv_path, "\n".join(csv_lines) + "\n")
selection = selection_by_xy.get((int(point.get("img_x") or 0), int(point.get("img_y") or 0)), {})
geo_location = geo_locations.get(point_id) or {}
metadata = {
"schema": "insar.gamma-sbas-expert-monitor-point/v1",
"point_id": point_id,
"source_tool": "disp_prt_2d",
"selection_rank": selection.get("selection_rank"),
"selection_key": selection.get("selection_key"),
"selection_label": selection.get("selection_label"),
"selection_description": selection.get("selection_description"),
"img_x": point.get("img_x"),
"img_y": point.get("img_y"),
"lon": geo_location.get("lon"),
"lat": geo_location.get("lat"),
"geo_row": geo_location.get("geo_row"),
"geo_col": geo_location.get("geo_col"),
"geo_match_distance_px": geo_location.get("geo_match_distance_px"),
"geo_los_rate_mm_per_year": geo_location.get("geo_los_rate_mm_per_year"),
"geo_coverage_valid": geo_location.get("geo_coverage_valid"),
"geo_source": geo_location.get("geo_source"),
"height_m": point.get("height_m"),
"deformation_rate_mm_per_year": point.get("deformation_rate_mm_per_year"),
"stdev_residual_phase_rad": point.get("stdev_residual_phase_rad"),
"displacement_count": len(displacements),
"displacements": displacements,
"source_files": {
"items": str(items_path),
"disp_point": str(disp_point_path),
},
}
_write_json_if_changed(metadata_path, metadata)
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.dates import DateFormatter
if curve_needs_refresh or not png_path.is_file():
x_values = [datetime.fromisoformat(str(item["date"])) for item in displacements]
x_labels = [str(item["date"]) for item in displacements]
y_values = [float(item["displacement_mm"]) for item in displacements]
fig_width = max(8.0, len(x_values) * 1.2)
fig, ax = plt.subplots(figsize=(fig_width, 4.5), dpi=150)
ax.plot(x_values, y_values, marker="o", markersize=5, linewidth=1.8, color="#1d4ed8")
ax.axhline(0, color="#94a3b8", linewidth=0.8)
ax.set_title(
f"Gamma SBAS {point_id} rate={point.get('deformation_rate_mm_per_year') or 0:.3f} mm/yr",
fontsize=10,
)
ax.set_xlabel("Date")
ax.set_ylabel("Displacement (mm)")
ax.grid(True, alpha=0.25)
ax.set_xticks(x_values)
ax.set_xticklabels(x_labels, rotation=35, ha="right")
ax.xaxis.set_major_formatter(DateFormatter("%Y-%m-%d"))
fig.tight_layout()
fig.savefig(png_path)
plt.close(fig)
except Exception:
if not png_path.is_file():
png_path.write_bytes(b"")
monitor_outputs.append(
{
"point_id": point_id,
"metadata": metadata,
"files": {
"png": _file_summary(png_path),
"csv": _file_summary(csv_path),
"metadata": _file_summary(metadata_path),
},
}
)
summary = {
"schema": "insar.gamma-sbas-expert-monitor-points-summary/v1",
"generated_at": _utcnow().isoformat(timespec="seconds") + "Z",
"mode": "expert_disp_prt_2d",
"source_tool": "disp_prt_2d",
"source_files": {
"items": _file_summary(items_path),
"disp_point": _file_summary(disp_point_path),
"selection": _file_summary(points_dir / "disp_point_selection.json"),
},
"monitor_points": [
{
"point_id": point.get("point_id"),
"selection_rank": selection_by_xy.get((int(point.get("img_x") or 0), int(point.get("img_y") or 0)), {}).get("selection_rank"),
"selection_key": selection_by_xy.get((int(point.get("img_x") or 0), int(point.get("img_y") or 0)), {}).get("selection_key"),
"selection_label": selection_by_xy.get((int(point.get("img_x") or 0), int(point.get("img_y") or 0)), {}).get("selection_label"),
"selection_description": selection_by_xy.get((int(point.get("img_x") or 0), int(point.get("img_y") or 0)), {}).get("selection_description"),
"img_x": point.get("img_x"),
"img_y": point.get("img_y"),
"lon": (geo_locations.get(str(point.get("point_id"))) or {}).get("lon"),
"lat": (geo_locations.get(str(point.get("point_id"))) or {}).get("lat"),
"geo_row": (geo_locations.get(str(point.get("point_id"))) or {}).get("geo_row"),
"geo_col": (geo_locations.get(str(point.get("point_id"))) or {}).get("geo_col"),
"geo_match_distance_px": (geo_locations.get(str(point.get("point_id"))) or {}).get("geo_match_distance_px"),
"geo_los_rate_mm_per_year": (geo_locations.get(str(point.get("point_id"))) or {}).get("geo_los_rate_mm_per_year"),
"geo_coverage_valid": (geo_locations.get(str(point.get("point_id"))) or {}).get("geo_coverage_valid"),
"geo_source": (geo_locations.get(str(point.get("point_id"))) or {}).get("geo_source"),
"height_m": point.get("height_m"),
"deformation_rate_mm_per_year": point.get("deformation_rate_mm_per_year"),
"stdev_residual_phase_rad": point.get("stdev_residual_phase_rad"),
"displacement_count": len(point.get("displacements") or []),
"displacements": point.get("displacements") or [],
}
for point in points
],
"monitor_outputs": monitor_outputs,
"ready": bool(monitor_outputs)
and all(
(item.get("files") or {}).get("png", {}).get("exists")
and (item.get("files") or {}).get("csv", {}).get("exists")
and (item.get("files") or {}).get("metadata", {}).get("exists")
for item in monitor_outputs
),
}
if points:
_write_json_if_changed(summary_path, summary)
return summary
def _bbox_polygon(
min_lon: Optional[float],
min_lat: Optional[float],
max_lon: Optional[float],
max_lat: Optional[float],
):
if None in (min_lon, min_lat, max_lon, max_lat):
return None
if min_lon == max_lon or min_lat == max_lat:
return None
return Polygon(
[
(min_lon, min_lat),
(max_lon, min_lat),
(max_lon, max_lat),
(min_lon, max_lat),
(min_lon, min_lat),
]
)
def _stack_dates_from_manifest(stack_manifest: dict[str, Any], manifest: dict[str, Any], stack: dict[str, Any]) -> list[str]:
values: list[str] = []
for source in (
stack_manifest.get("dates"),
stack.get("dates"),
manifest.get("dates"),
[scene.get("date") for scene in stack_manifest.get("scenes") or [] if isinstance(scene, dict)],
[scene.get("date") for scene in manifest.get("scenes") or [] if isinstance(scene, dict)],
):
if not isinstance(source, list):
continue
for item in source:
text = str(item or "").strip()
if text:
values.append(text)
return sorted(dict.fromkeys(values))
class SbasInsarCatalogService:
def get_run_root(self) -> str:
root = Path(settings.GAMMA_SBAS_WORK_ROOT or Path(settings.BACKEND_DIR) / "runtime" / "sbas_insar_production")
run_root = root / "runs"
run_root.mkdir(parents=True, exist_ok=True)
return _normalize_path(run_root)
def get_run_roots(self) -> list[str]:
roots = [self.get_run_root()]
try:
landsar_root = landsar_sbas_service.configured_run_root()
if landsar_root not in roots:
roots.append(landsar_root)
except Exception:
pass
return roots
def _iter_run_manifest_paths(self, run_root: Optional[str] = None) -> list[str]:
roots = [run_root] if run_root else self.get_run_roots()
manifest_paths: list[str] = []
for raw_root in roots:
root = Path(raw_root)
if not root.is_dir():
continue
manifest_paths.extend(
_normalize_path(path)
for path in sorted(root.glob("*/run_manifest.json"))
if self._is_publish_ready(path.parent, _safe_read_json(path))
)
return sorted(dict.fromkeys(manifest_paths))
@staticmethod
def _is_landsar_manifest(manifest: dict[str, Any]) -> bool:
return str(manifest.get("processor_code") or "").strip().lower() == "landsar_sbas"
@staticmethod
def _is_expert_gamma_manifest(manifest: dict[str, Any], run_dir: Optional[Path] = None) -> bool:
execution_mode = str(manifest.get("execution_mode") or "").strip().lower()
if execution_mode == "expert_manifest_script_workflow":
return True
if run_dir is not None and (run_dir / "publish" / "geotiff" / "geo_los_def_rate.tif").is_file():
return True
return False
def _asset_definitions_for_manifest(self, manifest: dict[str, Any], run_dir: Optional[Path] = None):
if self._is_landsar_manifest(manifest):
return _LANDSAR_CORE_ASSETS
if self._is_expert_gamma_manifest(manifest, run_dir):
return _EXPERT_GAMMA_CORE_ASSETS
return _CORE_ASSETS
def _required_roles_for_manifest(self, manifest: dict[str, Any], run_dir: Optional[Path] = None) -> set[str]:
if self._is_expert_gamma_manifest(manifest, run_dir):
return {"primary_geotiff"}
return _LANDSAR_REQUIRED_ASSET_ROLES if self._is_landsar_manifest(manifest) else _REQUIRED_ASSET_ROLES
def _is_publish_ready(self, run_dir: Path, manifest: dict[str, Any]) -> bool:
status = str(manifest.get("status") or "").strip().upper()
asset_defs = self._asset_definitions_for_manifest(manifest, run_dir)
required_roles = self._required_roles_for_manifest(manifest, run_dir)
required_outputs_ready = all(
(run_dir / relative_path).is_file()
for role, _name, relative_path, is_required, _is_primary in asset_defs
if role in required_roles and is_required
)
return status in _READY_STATUSES or required_outputs_ready
def _tree_fingerprint(self, manifest_paths: list[str]) -> str:
records: list[dict[str, Any]] = []
for raw_path in manifest_paths:
manifest_path = Path(raw_path)
run_dir = manifest_path.parent
manifest_payload = _safe_read_json(manifest_path)
tracked_paths = [
manifest_path,
run_dir / "monitor_points_summary.json",
]
if not self._is_expert_gamma_manifest(manifest_payload, run_dir):
tracked_paths.extend(
[
run_dir / "product_summary.json",
run_dir / "quality_summary.json",
]
)
else:
tracked_paths.extend(
[
run_dir / "diff_dir" / "bprep_file.png",
run_dir / "diff_dir" / "mean.cc_mask.bmp",
run_dir / "sbas" / "final_unw_tab",
]
)
diff_dir = run_dir / "diff_dir"
if diff_dir.is_dir():
tracked_paths.extend(sorted(diff_dir.glob("*.adf.unw.bmp")))
tracked_paths.extend(
run_dir / relative_path
for _role, _name, relative_path, _required, _primary in self._asset_definitions_for_manifest(
manifest_payload, run_dir
)
if not (run_dir / relative_path).is_dir()
)
for path in tracked_paths:
if not path.exists():
continue
stat = path.stat()
records.append(
{
"path": str(path.relative_to(run_dir)).replace("\\", "/"),
"run": run_dir.name,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
}
)
encoded = json.dumps(records, sort_keys=True, ensure_ascii=True)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
async def _get_or_create_catalog_state(self, db: AsyncSession, *, storage_root: str) -> ResultCatalogStateORM:
result = await db.execute(
select(ResultCatalogStateORM).where(ResultCatalogStateORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
)
state = result.scalar_one_or_none()
if state is None:
state = ResultCatalogStateORM(
catalog_name=SBAS_INSAR_CATALOG_NAME,
product_family="timeseries",
storage_root=storage_root,
status="READY",
needs_rebuild=False,
)
db.add(state)
await db.flush()
elif state.storage_root != storage_root:
state.storage_root = storage_root
if state.product_family != "timeseries":
state.product_family = "timeseries"
return state
def _asset_row(
self,
run_dir: Path,
*,
role: str,
name: str,
relative_path: str,
is_required: bool,
is_primary: bool,
) -> ResultAssetORM:
absolute_path = run_dir / relative_path
exists = absolute_path.is_file()
return ResultAssetORM(
asset_role=role[:32],
asset_name=name,
relative_path=relative_path.replace("\\", "/"),
absolute_path=_normalize_path(absolute_path),
format=_asset_format(relative_path),
media_type=_media_type(relative_path),
is_required=is_required,
is_primary=is_primary,
exists_flag=exists,
file_size=absolute_path.stat().st_size if exists else None,
srid=4326 if (
(relative_path.lower().endswith((".tif", ".tiff")) and "/geotiff/" in relative_path)
or (relative_path.lower().endswith((".tif", ".tiff")) and "/landsar/" in relative_path)
or relative_path.lower().endswith(".geojson.gz")
) else None,
)
def _monitor_asset_rows(self, run_dir: Path) -> list[ResultAssetORM]:
monitor_dir = run_dir / "publish" / "monitor_points"
if not monitor_dir.is_dir():
return []
rows: list[ResultAssetORM] = []
for path in sorted(monitor_dir.iterdir()):
if not path.is_file():
continue
suffix = path.suffix.lower()
if suffix not in {".png", ".csv", ".json"}:
continue
role = {
".png": "monitor_point_curve",
".csv": "monitor_point_csv",
".json": "monitor_point_metadata",
}[suffix]
relative_path = str(path.relative_to(run_dir)).replace("\\", "/")
rows.append(
self._asset_row(
run_dir,
role=role,
name=path.name,
relative_path=relative_path,
is_required=False,
is_primary=False,
)
)
return rows
def _unwrapped_phase_asset_rows(self, run_dir: Path) -> list[ResultAssetORM]:
unwrapped_dir = run_dir / "publish" / "geotiff" / "unwrapped"
if not unwrapped_dir.is_dir():
return []
rows: list[ResultAssetORM] = []
for path in sorted(unwrapped_dir.iterdir()):
if not path.is_file():
continue
lowered = path.name.lower()
if lowered in {"unwrapped_phase_summary.json", "unwrapped_phase_rmg_colorbar.png"}:
continue
if lowered.endswith(".rdc_rmg_preview.png"):
role = "unwrapped_phase_radar_preview"
name = f"Radar-coordinate rmg unwrapped phase preview {path.name}"
elif lowered.endswith(".rdc_rmg.bmp"):
role = "unwrapped_phase_radar_bmp"
name = f"Gamma radar-coordinate rmg unwrapped phase BMP {path.name}"
elif lowered.endswith((".tif", ".tiff")):
role = "unwrapped_phase_geotiff"
name = f"Geocoded unwrapped phase {path.name}"
elif lowered.endswith(".png"):
role = "unwrapped_phase_preview"
name = f"Unwrapped phase preview {path.name}"
else:
continue
relative_path = str(path.relative_to(run_dir)).replace("\\", "/")
rows.append(
self._asset_row(
run_dir,
role=role,
name=name,
relative_path=relative_path,
is_required=False,
is_primary=False,
)
)
return rows
def _gamma_intermediate_qc_asset_rows(self, run_dir: Path) -> list[ResultAssetORM]:
rows: list[ResultAssetORM] = []
static_assets = (
(
"gamma_qc_baseline_plot",
"Gamma baseline network plot",
run_dir / "diff_dir" / "bprep_file.png",
),
(
"gamma_qc_mean_coherence",
"Gamma mean coherence mask",
run_dir / "diff_dir" / "mean.cc_mask.bmp",
),
)
for role, name, path in static_assets:
if not path.is_file():
continue
rows.append(
self._asset_row(
run_dir,
role=role,
name=f"{name} {path.name}",
relative_path=str(path.relative_to(run_dir)).replace("\\", "/"),
is_required=False,
is_primary=False,
)
)
diff_dir = run_dir / "diff_dir"
final_tab = run_dir / "sbas" / "final_unw_tab"
pair_ids: list[str] = []
if final_tab.is_file():
try:
for line in final_tab.read_text(encoding="utf-8", errors="ignore").splitlines():
raw_path = line.strip().split()[0] if line.strip() else ""
if not raw_path:
continue
name = Path(_wsl_path_to_windows(raw_path)).name
pair_id = name.replace(".unw.atmsub_1", "").replace(".unw", "")
if pair_id and pair_id not in pair_ids:
pair_ids.append(pair_id)
except OSError:
pair_ids = []
unwrapped_paths: list[Path] = []
for pair_id in pair_ids:
path = diff_dir / f"{pair_id}.adf.unw.bmp"
if path.is_file():
unwrapped_paths.append(path)
if not unwrapped_paths and diff_dir.is_dir():
unwrapped_paths = sorted(diff_dir.glob("*.adf.unw.bmp"))
if len(unwrapped_paths) > 3:
last_index = len(unwrapped_paths) - 1
indexes = sorted({round(index * last_index / 2) for index in range(3)})
unwrapped_paths = [unwrapped_paths[index] for index in indexes]
for path in unwrapped_paths:
rows.append(
self._asset_row(
run_dir,
role="gamma_qc_unwrapped_phase",
name=f"Gamma representative filtered unwrapped phase {path.name}",
relative_path=str(path.relative_to(run_dir)).replace("\\", "/"),
is_required=False,
is_primary=False,
)
)
return rows
def _build_product(self, manifest_path: str) -> ResultProductORM:
manifest_file = Path(manifest_path)
run_dir = manifest_file.parent
manifest = _read_json(manifest_file)
if not self._is_publish_ready(run_dir, manifest):
raise ValueError(f"run is not publish-ready: {manifest.get('status') or 'UNKNOWN'}")
if self._is_landsar_manifest(manifest):
return self._build_landsar_product(run_dir, manifest_file, manifest)
detail = sbas_insar_production_service.get_run_detail(run_dir.name)
coverage = detail.get("geographic_coverage") or {}
stack_manifest = _safe_read_json(run_dir / "stack_manifest.json")
monitor_summary = _safe_read_json(run_dir / "monitor_points_summary.json")
workflow_summary = _safe_read_json(run_dir / "workflow_summary.json")
is_expert_gamma = self._is_expert_gamma_manifest(manifest, run_dir)
product_summary = {} if is_expert_gamma else _safe_read_json(run_dir / "product_summary.json")
quality_summary = (
_build_expert_gamma_quality_summary(run_dir)
if is_expert_gamma
else _safe_read_json(run_dir / "quality_summary.json")
)
point_vector_summary = _safe_read_json(run_dir / "publish" / "vectors" / "los_rate_points_summary.json")
asset_definitions = self._asset_definitions_for_manifest(manifest, run_dir)
if is_expert_gamma:
_build_rgb_geotiff_preview(
run_dir / "publish" / "geotiff" / "geo_los_def_rate_rgb.tif",
run_dir / "publish" / "geotiff" / "geo_los_def_rate_rgb_preview.png",
)
_build_gamma_hls_rate_preview(
run_dir / "publish" / "geotiff" / "geo_los_def_rate.tif",
run_dir / "publish" / "geotiff" / "geo_los_def_rate_pure_hls_preview.png",
coverage_source=run_dir / "publish" / "geotiff" / "geo_los_def_rate_rgb.tif",
min_native=-0.08,
max_native=0.08,
)
_build_gamma_hls_colorbar(
run_dir / "publish" / "geotiff" / "geo_los_def_rate_hls_colorbar.png",
min_mm_year=-80.0,
max_mm_year=80.0,
)
point_vector_context = {
"run_id": str(manifest.get("run_id") or run_dir.name).strip() or run_dir.name,
"reference_date": str(
manifest.get("reference_date")
or (stack_manifest.get("stack") or {}).get("reference_date")
or ""
).strip() or None,
"stack_dates": _stack_dates_from_manifest(stack_manifest, manifest, stack_manifest.get("stack") or {}),
"los_sign_convention": "Gamma expert geo_los_def_rate output; sign and unit semantics follow the expert workflow.",
"admin_region": coverage.get("admin_region"),
}
point_vector_context["date_start"] = (
point_vector_context["stack_dates"][0] if point_vector_context["stack_dates"] else None
)
point_vector_context["date_end"] = (
point_vector_context["stack_dates"][-1] if point_vector_context["stack_dates"] else None
)
point_vector_summary = _build_expert_gamma_point_vector(
run_dir,
summary_context=point_vector_context,
)
unwrapped_phase_summary = _build_expert_unwrapped_phase_derivatives(run_dir)
monitor_summary = _write_expert_monitor_point_derivatives(run_dir) or monitor_summary
else:
unwrapped_phase_summary = {}
bbox = coverage.get("bbox") or {}
min_lon = _safe_float(bbox.get("min_lon"))
min_lat = _safe_float(bbox.get("min_lat"))
max_lon = _safe_float(bbox.get("max_lon"))
max_lat = _safe_float(bbox.get("max_lat"))
poly = _bbox_polygon(min_lon, min_lat, max_lon, max_lat)
run_id = str(manifest.get("run_id") or run_dir.name).strip() or run_dir.name
stack = stack_manifest.get("stack") or manifest.get("stack") or {}
stack_id = str(manifest.get("stack_id") or stack_manifest.get("stack_id") or stack.get("stack_id") or "").strip()
stack_dates = _stack_dates_from_manifest(stack_manifest, manifest, stack)
reference_date = str(
manifest.get("reference_date")
or stack.get("reference_date")
or (manifest.get("coregistration") or {}).get("reference_date")
or ""
).strip() or None
display_name = stack_id or f"Gamma SBAS {run_id}"
product_id = str(manifest.get("product_id") or "").strip() or f"gamma_sbas_{run_id}"
if len(product_id) > 64:
product_id = f"gamma_sbas_{_stable_digest(product_id, run_dir, length=32)}"
assets: list[ResultAssetORM] = [
self._asset_row(
run_dir,
role=role,
name=name,
relative_path=relative_path,
is_required=is_required,
is_primary=is_primary,
)
for role, name, relative_path, is_required, is_primary in asset_definitions
]
assets.extend(self._monitor_asset_rows(run_dir))
if is_expert_gamma:
assets.extend(self._unwrapped_phase_asset_rows(run_dir))
assets.extend(self._gamma_intermediate_qc_asset_rows(run_dir))
preview_asset = next((asset for asset in assets if asset.asset_role == "primary_geocoded_preview" and asset.exists_flag), None)
primary_asset = next((asset for asset in assets if asset.asset_role == "primary_geotiff" and asset.exists_flag), None)
missing_required = [asset for asset in assets if asset.is_required and not asset.exists_flag]
default_los_product = product_summary.get("default_los_product")
los_sign_convention = product_summary.get("los_sign_convention")
if is_expert_gamma:
default_los_product = default_los_product or "geo_los_def_rate"
los_sign_convention = (
los_sign_convention
or "Gamma expert geo_los_def_rate output; sign and unit semantics follow the expert workflow."
)
color_policy = {
"schema": "insar.gamma-sbas-color-policy/v1",
"source": "expert_gamma_command",
"browse_command": "rasdt_pwr los_def_rate ... -0.08 0.08 0 hls.cm ... 24",
"colormap": "Gamma hls.cm",
"data_range_native": [-0.08, 0.08],
"display_range_mm_per_year": [-80.0, 80.0],
"note": "The RGB browse GeoTIFF is generated by Gamma with hls.cm. Treat it as the expert browse standard unless the project defines a separate cartographic standard.",
}
else:
default_los_product = default_los_product or "los_rate_toward_m_per_year"
los_sign_convention = los_sign_convention or "toward radar positive; away from radar negative"
color_policy = product_summary.get("color_policy")
produced_at = (
_parse_datetime(monitor_summary.get("generated_at"))
or _parse_datetime(product_summary.get("generated_at"))
or _parse_datetime(workflow_summary.get("generated_at"))
or _parse_datetime(manifest.get("updated_at"))
or _parse_datetime(manifest.get("created_at"))
)
center = coverage.get("center") or {}
admin_region = coverage.get("admin_region") or lookup_admin_region_for_point(center.get("lon"), center.get("lat"))
scene_count = (
_safe_int(manifest.get("scene_count"))
or len(stack_manifest.get("scenes") or [])
or len(stack_dates)
)
summary_json = {
"schema": "insar.gamma-sbas-result-catalog-summary/v1",
"run_id": run_id,
"stack_id": stack_id or None,
"stack": stack,
"reference_date": reference_date,
"stack_dates": stack_dates,
"stack_size": len(stack_dates),
"date_start": stack_dates[0] if stack_dates else None,
"date_end": stack_dates[-1] if stack_dates else None,
"scene_count": scene_count,
"pair_count": _safe_int(manifest.get("pair_count")),
"status": manifest.get("status"),
"next_stage": manifest.get("next_stage"),
"los_sign_convention": los_sign_convention,
"default_los_product": default_los_product,
"color_policy": color_policy,
"center": center or None,
"admin_region": admin_region,
"geographic_coverage": coverage,
"quality": quality_summary,
"monitor_points": monitor_summary,
"point_vector": point_vector_summary,
"unwrapped_phase": unwrapped_phase_summary,
"workflow": {
"status": ((manifest.get("workflow") or {}).get("status")),
"summary": ((manifest.get("workflow") or {}).get("summary")) or workflow_summary,
},
"source_run_dir": str(run_dir),
}
product = ResultProductORM(
product_id=product_id,
catalog_name=SBAS_INSAR_CATALOG_NAME,
product_family="timeseries",
product_type="sbas_insar",
display_name=display_name,
task_name="Gamma SBAS-InSAR",
task_alias=run_id,
stack_key=stack_id or run_id,
run_key=run_id,
profile_code=str(stack.get("relative_orbit") or manifest.get("relative_orbit") or "").strip() or None,
engine_code="gamma",
engine_version=str((manifest.get("engine") or {}).get("version") or "").strip() or None,
package_schema=str(manifest.get("schema") or "").strip() or "insar.gamma-sbas-run/v1",
package_layout="gamma_sbas_expert_workflow_run",
processor_code="gamma_ipta_sbas",
runtime_id=settings.GAMMA_SBAS_RUNTIME_ID,
status="READY" if not missing_required else "INCOMPLETE",
health_status="OK" if not missing_required else "WARN",
publish_dir=_normalize_path(run_dir / "publish"),
manifest_path=_normalize_path(manifest_file),
source_primary_path=primary_asset.absolute_path if primary_asset else None,
native_output_dir=_normalize_path(run_dir),
preview_path=preview_asset.absolute_path if preview_asset else None,
primary_asset_path=primary_asset.absolute_path if primary_asset else None,
summary_json=summary_json,
tags_json={
"sensor": stack.get("satellite") or manifest.get("platform"),
"orbit_direction": stack.get("orbit_direction") or manifest.get("direction"),
"product": "Gamma SBAS",
"workflow_mode": "expert_document" if is_expert_gamma else "legacy_gamma",
"admin_region": (admin_region or {}).get("display_name") if isinstance(admin_region, dict) else None,
},
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
geom=from_shape(poly, srid=4326) if poly is not None else None,
coverage_polygon=(coverage.get("geojson") or coverage.get("scene_footprints_geojson")),
produced_at=produced_at,
published_at=produced_at,
)
for asset in assets:
product.assets.append(asset)
if asset.is_required and not asset.exists_flag:
product.issues.append(
ResultIssueORM(
asset=asset,
issue_code="MISSING_REQUIRED_ASSET",
severity="ERROR",
status="OPEN",
scope="file",
message=f"Required SBAS asset is missing: {asset.relative_path}",
)
)
if not preview_asset:
product.issues.append(
ResultIssueORM(
issue_code="MISSING_PREVIEW",
severity="WARN",
status="OPEN",
scope="product",
message="Primary geocoded preview PNG is missing.",
)
)
if poly is None:
product.issues.append(
ResultIssueORM(
issue_code="MISSING_COVERAGE",
severity="WARN",
status="OPEN",
scope="product",
message="No valid EPSG:4326 geographic coverage bbox was found.",
)
)
return product
def _build_landsar_product(self, run_dir: Path, manifest_file: Path, manifest: dict[str, Any]) -> ResultProductORM:
try:
detail = landsar_sbas_service.get_run_detail(run_dir.name)
except Exception:
detail = {}
coverage = detail.get("geographic_coverage") or manifest.get("geographic_coverage") or {}
stack_manifest = _safe_read_json(run_dir / "stack_manifest.json")
product_summary = _safe_read_json(run_dir / "product_summary.json")
quality_summary = _safe_read_json(run_dir / "quality_summary.json")
workflow_summary = _safe_read_json(run_dir / "workflow_summary.json")
bbox = coverage.get("bbox") or {}
min_lon = _safe_float(bbox.get("min_lon"))
min_lat = _safe_float(bbox.get("min_lat"))
max_lon = _safe_float(bbox.get("max_lon"))
max_lat = _safe_float(bbox.get("max_lat"))
poly = _bbox_polygon(min_lon, min_lat, max_lon, max_lat)
run_id = str(manifest.get("run_id") or run_dir.name).strip() or run_dir.name
stack_id = str(manifest.get("stack_id") or run_id).strip()
stack_dates = _stack_dates_from_manifest(stack_manifest, manifest, {})
if not stack_dates:
stack_dates = [str(item or "").strip() for item in manifest.get("dates") or [] if str(item or "").strip()]
display_name = str(manifest.get("run_label") or f"LandSAR SBAS {run_id}").strip()
product_id = str(manifest.get("product_id") or "").strip() or f"landsar_sbas_{run_id}"
if len(product_id) > 64:
product_id = f"landsar_sbas_{_stable_digest(product_id, run_dir, length=32)}"
assets: list[ResultAssetORM] = [
self._asset_row(
run_dir,
role=role,
name=name,
relative_path=relative_path,
is_required=is_required,
is_primary=is_primary,
)
for role, name, relative_path, is_required, is_primary in _LANDSAR_CORE_ASSETS
if not (run_dir / relative_path).is_dir()
]
native_logs_dir = run_dir / "native_logs"
if native_logs_dir.is_dir():
for path in sorted(native_logs_dir.rglob("*")):
if not path.is_file():
continue
relative_path = str(path.relative_to(run_dir)).replace("\\", "/")
assets.append(
self._asset_row(
run_dir,
role="native_log" if path.suffix.lower() == ".log" else "native_parameter",
name=path.name,
relative_path=relative_path,
is_required=False,
is_primary=False,
)
)
task_publish_root = run_dir / "publish" / "landsar"
if task_publish_root.is_dir():
for path in sorted(task_publish_root.rglob("*")):
if not path.is_file():
continue
relative_path = str(path.relative_to(run_dir)).replace("\\", "/")
if relative_path in {item.relative_path for item in assets}:
continue
role = "landsar_task_geotiff" if path.suffix.lower() in {".tif", ".tiff"} else "landsar_task_asset"
assets.append(
self._asset_row(
run_dir,
role=role,
name=path.name,
relative_path=relative_path,
is_required=False,
is_primary=False,
)
)
preview_asset = next((asset for asset in assets if asset.asset_role == "primary_preview" and asset.exists_flag), None)
primary_asset = next((asset for asset in assets if asset.asset_role == "primary_geotiff" and asset.exists_flag), None)
missing_required = [asset for asset in assets if asset.is_required and not asset.exists_flag]
produced_at = (
_parse_datetime(workflow_summary.get("ended_at"))
or _parse_datetime(product_summary.get("generated_at"))
or _parse_datetime(manifest.get("ended_at"))
or _parse_datetime(manifest.get("created_at"))
)
center = coverage.get("center") or {}
admin_region = coverage.get("admin_region") or lookup_admin_region_for_point(center.get("lon"), center.get("lat"))
scene_count = _safe_int(manifest.get("scene_count")) or len(stack_dates)
task_count = _safe_int(manifest.get("task_count"))
summary_json = {
"schema": "insar.landsar-sbas-result-catalog-summary/v1",
"run_id": run_id,
"stack_id": stack_id or None,
"reference_date": stack_dates[0] if stack_dates else None,
"stack_dates": stack_dates,
"stack_size": len(stack_dates),
"date_start": manifest.get("date_start") or (stack_dates[0] if stack_dates else None),
"date_end": manifest.get("date_end") or (stack_dates[-1] if stack_dates else None),
"scene_count": scene_count,
"task_count": task_count,
"pair_count": _safe_int(manifest.get("pair_count")),
"status": manifest.get("status"),
"next_stage": manifest.get("next_stage"),
"los_sign_convention": product_summary.get("los_sign_convention") or "LandSAR LOS output; semantics pending algorithm confirmation.",
"default_los_product": product_summary.get("default_los_product") or "los_timeseries",
"center": center or None,
"admin_region": admin_region,
"geographic_coverage": coverage,
"quality": quality_summary,
"workflow": workflow_summary,
"source_run_dir": str(run_dir),
"output_semantics_note": product_summary.get("output_semantics_note"),
}
product = ResultProductORM(
product_id=product_id,
catalog_name=SBAS_INSAR_CATALOG_NAME,
product_family="timeseries",
product_type="sbas_insar",
display_name=display_name,
task_name="LandSAR SBAS-InSAR",
task_alias=run_id,
stack_key=stack_id or run_id,
run_key=run_id,
profile_code="lt1_landsar_sbas",
engine_code="landsar",
engine_version=None,
package_schema=str(manifest.get("schema") or "").strip() or "insar.landsar-sbas-run/v1",
package_layout="landsar_sbas_console_run",
processor_code="landsar_sbas",
runtime_id="landsar_console",
status="READY" if not missing_required else "INCOMPLETE",
health_status="OK" if not missing_required else "WARN",
publish_dir=_normalize_path(run_dir / "publish"),
manifest_path=_normalize_path(manifest_file),
source_primary_path=primary_asset.absolute_path if primary_asset else None,
native_output_dir=_normalize_path(run_dir),
preview_path=preview_asset.absolute_path if preview_asset else None,
primary_asset_path=primary_asset.absolute_path if primary_asset else None,
summary_json=summary_json,
tags_json={
"sensor": "LT1",
"product": "LandSAR SBAS",
"processor_code": "landsar_sbas",
"admin_region": (admin_region or {}).get("display_name") if isinstance(admin_region, dict) else None,
},
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
geom=from_shape(poly, srid=4326) if poly is not None else None,
coverage_polygon=(coverage.get("geojson") or coverage.get("scene_footprints_geojson")),
produced_at=produced_at,
published_at=produced_at,
)
for asset in assets:
product.assets.append(asset)
if asset.is_required and not asset.exists_flag:
product.issues.append(
ResultIssueORM(
asset=asset,
issue_code="MISSING_REQUIRED_ASSET",
severity="ERROR",
status="OPEN",
scope="file",
message=f"Required LandSAR SBAS asset is missing: {asset.relative_path}",
)
)
if not preview_asset:
product.issues.append(
ResultIssueORM(
issue_code="MISSING_PREVIEW",
severity="WARN",
status="OPEN",
scope="product",
message="LandSAR SBAS preview PNG is missing.",
)
)
if poly is None:
product.issues.append(
ResultIssueORM(
issue_code="MISSING_COVERAGE",
severity="WARN",
status="OPEN",
scope="product",
message="No valid EPSG:4326 geographic coverage bbox was found.",
)
)
return product
async def rebuild_catalog(self, db: AsyncSession, *, full_rebuild: bool = True) -> dict[str, Any]:
run_root = self.get_run_root()
run_roots = self.get_run_roots()
manifest_paths = await asyncio.to_thread(self._iter_run_manifest_paths)
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
state.status = "REBUILDING"
state.needs_rebuild = False
state.last_message = "SBAS catalog rebuild in progress"
await db.commit()
if full_rebuild:
await db.execute(delete(ResultProductORM).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME))
await db.commit()
registered = 0
failed = 0
issue_count = 0
details: list[dict[str, Any]] = []
for manifest_path in manifest_paths:
try:
product = await asyncio.to_thread(self._build_product, manifest_path)
product_issue_count = len(product.issues)
product_id = product.product_id
product_status = product.status
db.add(product)
await db.flush()
await db.commit()
registered += 1
issue_count += product_issue_count
details.append(
{
"manifest_path": manifest_path,
"product_id": product_id,
"status": product_status,
"issues": product_issue_count,
}
)
except Exception as exc:
await db.rollback()
failed += 1
issue_count += 1
details.append({"manifest_path": manifest_path, "status": "error", "message": str(exc)})
await db.commit()
db_count_result = await db.execute(
select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
)
db_count = int(db_count_result.scalar_one() or 0)
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
state.manifest_count = len(manifest_paths)
state.manifest_fingerprint = fingerprint
state.db_count = db_count
state.issue_count = issue_count
state.needs_rebuild = False
state.status = "READY" if failed == 0 else "WARN"
now = _utcnow()
state.last_full_rebuild_at = now
state.last_incremental_scan_at = now
state.last_message = (
f"SBAS catalog rebuild finished: runs={len(manifest_paths)}, "
f"registered={registered}, failed={failed}, issues={issue_count}"
)
await db.commit()
return {
"catalog_name": SBAS_INSAR_CATALOG_NAME,
"storage_root": run_root,
"storage_roots": run_roots,
"run_count": len(manifest_paths),
"manifest_count": len(manifest_paths),
"manifest_fingerprint": fingerprint,
"registered": registered,
"failed": failed,
"issue_count": issue_count,
"details": details,
}
async def list_products(
self,
db: AsyncSession,
*,
limit: int = 100,
offset: int = 0,
status: Optional[str] = None,
query: Optional[str] = None,
admin_region: Optional[str] = None,
) -> dict[str, Any]:
safe_limit = max(1, min(int(limit or 100), 500))
safe_offset = max(0, int(offset or 0))
stmt = select(ResultProductORM).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
count_stmt = select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
if status:
stmt = stmt.where(ResultProductORM.status == status)
count_stmt = count_stmt.where(ResultProductORM.status == status)
if query:
like_value = f"%{query.strip()}%"
predicate = or_(
ResultProductORM.display_name.ilike(like_value),
ResultProductORM.product_id.ilike(like_value),
ResultProductORM.run_key.ilike(like_value),
ResultProductORM.stack_key.ilike(like_value),
)
stmt = stmt.where(predicate)
count_stmt = count_stmt.where(predicate)
if admin_region:
like_value = f"%{admin_region.strip()}%"
predicate = or_(
cast(ResultProductORM.summary_json, String).ilike(like_value),
cast(ResultProductORM.tags_json, String).ilike(like_value),
)
stmt = stmt.where(predicate)
count_stmt = count_stmt.where(predicate)
total_result = await db.execute(count_stmt)
total = int(total_result.scalar_one() or 0)
result = await db.execute(
stmt.order_by(ResultProductORM.published_at.desc().nullslast(), ResultProductORM.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
items: list[dict[str, Any]] = []
for product in result.scalars().all():
summary = product.summary_json or {}
items.append(
{
"id": product.id,
"product_id": product.product_id,
"display_name": product.display_name,
"run_key": product.run_key,
"stack_key": product.stack_key,
"engine_code": product.engine_code,
"processor_code": product.processor_code,
"runtime_id": product.runtime_id,
"status": product.status,
"health_status": product.health_status,
"preview_path": product.preview_path,
"primary_asset_path": product.primary_asset_path,
"reference_date": summary.get("reference_date"),
"date_start": summary.get("date_start"),
"date_end": summary.get("date_end"),
"stack_dates": summary.get("stack_dates") or [],
"stack_size": summary.get("stack_size") or len(summary.get("stack_dates") or []),
"scene_count": summary.get("scene_count"),
"pair_count": summary.get("pair_count"),
"los_sign_convention": summary.get("los_sign_convention"),
"color_policy": summary.get("color_policy"),
"center": summary.get("center") or ((summary.get("geographic_coverage") or {}).get("center")),
"admin_region": summary.get("admin_region") or ((summary.get("geographic_coverage") or {}).get("admin_region")),
"min_lon": product.min_lon,
"min_lat": product.min_lat,
"max_lon": product.max_lon,
"max_lat": product.max_lat,
"published_at": product.published_at,
}
)
return {
"items": items,
"total": total,
"limit": safe_limit,
"offset": safe_offset,
"has_more": safe_offset + len(items) < total,
}
async def get_product_detail(self, db: AsyncSession, *, product_db_id: int) -> Optional[dict[str, Any]]:
result = await db.execute(select(ResultProductORM).where(ResultProductORM.id == product_db_id))
product = result.scalar_one_or_none()
if product is None or product.catalog_name != SBAS_INSAR_CATALOG_NAME:
return None
assets_result = await db.execute(
select(ResultAssetORM)
.where(ResultAssetORM.product_ref_id == product.id)
.order_by(ResultAssetORM.is_primary.desc(), ResultAssetORM.asset_role.asc(), ResultAssetORM.id.asc())
)
issues_result = await db.execute(
select(ResultIssueORM)
.where(ResultIssueORM.product_ref_id == product.id)
.order_by(ResultIssueORM.severity.asc(), ResultIssueORM.id.asc())
)
summary = product.summary_json or {}
return {
"id": product.id,
"product_id": product.product_id,
"catalog_name": product.catalog_name,
"product_type": product.product_type,
"display_name": product.display_name,
"run_key": product.run_key,
"run_id": summary.get("run_id") or product.run_key,
"stack_key": product.stack_key,
"profile_code": product.profile_code,
"engine_code": product.engine_code,
"engine_version": product.engine_version,
"package_schema": product.package_schema,
"package_layout": product.package_layout,
"processor_code": product.processor_code,
"runtime_id": product.runtime_id,
"status": product.status,
"health_status": product.health_status,
"publish_dir": product.publish_dir,
"manifest_path": product.manifest_path,
"source_primary_path": product.source_primary_path,
"native_output_dir": product.native_output_dir,
"preview_path": product.preview_path,
"primary_asset_path": product.primary_asset_path,
"reference_date": summary.get("reference_date"),
"date_start": summary.get("date_start"),
"date_end": summary.get("date_end"),
"stack_dates": summary.get("stack_dates") or [],
"stack_size": summary.get("stack_size") or len(summary.get("stack_dates") or []),
"scene_count": summary.get("scene_count"),
"pair_count": summary.get("pair_count"),
"los_sign_convention": summary.get("los_sign_convention"),
"default_los_product": summary.get("default_los_product"),
"color_policy": summary.get("color_policy"),
"quality": summary.get("quality"),
"monitor_points": summary.get("monitor_points"),
"point_vector": summary.get("point_vector"),
"unwrapped_phase": summary.get("unwrapped_phase"),
"workflow": summary.get("workflow"),
"geographic_coverage": summary.get("geographic_coverage"),
"center": summary.get("center") or ((summary.get("geographic_coverage") or {}).get("center")),
"admin_region": summary.get("admin_region") or ((summary.get("geographic_coverage") or {}).get("admin_region")),
"coverage_polygon": product.coverage_polygon,
"min_lon": product.min_lon,
"min_lat": product.min_lat,
"max_lon": product.max_lon,
"max_lat": product.max_lat,
"produced_at": product.produced_at,
"published_at": product.published_at,
"registered_at": product.registered_at,
"updated_at": product.updated_at,
"assets": [
{
"id": asset.id,
"asset_role": asset.asset_role,
"asset_name": asset.asset_name,
"relative_path": asset.relative_path,
"absolute_path": asset.absolute_path,
"format": asset.format,
"media_type": asset.media_type,
"is_required": asset.is_required,
"is_primary": asset.is_primary,
"exists_flag": asset.exists_flag,
"file_size": asset.file_size,
"srid": asset.srid,
}
for asset in assets_result.scalars().all()
],
"issues": [
{
"id": issue.id,
"issue_code": issue.issue_code,
"severity": issue.severity,
"status": issue.status,
"scope": issue.scope,
"message": issue.message,
"detected_at": issue.detected_at,
}
for issue in issues_result.scalars().all()
],
}
async def query_point_timeseries(
self,
db: AsyncSession,
*,
product_db_id: int,
lon: float,
lat: float,
) -> Optional[dict[str, Any]]:
result = await db.execute(select(ResultProductORM).where(ResultProductORM.id == product_db_id))
product = result.scalar_one_or_none()
if product is None or product.catalog_name != SBAS_INSAR_CATALOG_NAME:
return None
manifest_path = str(product.manifest_path or "").strip()
run_dir = Path(manifest_path).parent if manifest_path else Path(str(product.native_output_dir or ""))
if not run_dir.is_dir():
raise FileNotFoundError("SBAS run directory not found")
manifest = _safe_read_json(run_dir / "run_manifest.json")
if not self._is_expert_gamma_manifest(manifest, run_dir):
raise ValueError("point time-series query is only available for expert Gamma SBAS products")
return _query_expert_gamma_point_timeseries(run_dir, lon=lon, lat=lat)
async def get_asset(self, db: AsyncSession, *, product_db_id: int, asset_id: int) -> Optional[ResultAssetORM]:
result = await db.execute(
select(ResultAssetORM)
.join(ResultProductORM, ResultProductORM.id == ResultAssetORM.product_ref_id)
.where(
ResultProductORM.id == product_db_id,
ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME,
ResultAssetORM.id == asset_id,
)
)
return result.scalar_one_or_none()
async def get_catalog_status(self, db: AsyncSession) -> dict[str, Any]:
run_root = self.get_run_root()
run_roots = self.get_run_roots()
manifest_paths = await asyncio.to_thread(self._iter_run_manifest_paths)
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
db_count_result = await db.execute(
select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
)
db_count = int(db_count_result.scalar_one() or 0)
needs_rebuild = (
state.manifest_count != len(manifest_paths)
or state.db_count != db_count
or state.manifest_fingerprint != fingerprint
)
state.manifest_count = len(manifest_paths)
state.db_count = db_count
state.needs_rebuild = needs_rebuild
state.last_incremental_scan_at = _utcnow()
state.status = "WARN" if needs_rebuild else "READY"
state.last_message = (
f"SBAS catalog rebuild required: runs={len(manifest_paths)}, db={db_count}"
if needs_rebuild
else "SBAS catalog is in sync"
)
await db.commit()
return {
"catalog_name": state.catalog_name,
"product_family": state.product_family,
"storage_root": state.storage_root,
"storage_roots": run_roots,
"status": state.status,
"needs_rebuild": state.needs_rebuild,
"run_count": len(manifest_paths),
"manifest_count": state.manifest_count,
"manifest_fingerprint": state.manifest_fingerprint,
"current_manifest_fingerprint": fingerprint,
"db_count": db_count,
"issue_count": state.issue_count,
"last_message": state.last_message,
"last_boot_check_at": state.last_boot_check_at,
"last_full_rebuild_at": state.last_full_rebuild_at,
"last_incremental_scan_at": state.last_incremental_scan_at,
}
async def bootstrap_catalog_on_startup_clean(self) -> dict[str, Any]:
from ..database import AsyncSessionLocal
if AsyncSessionLocal is None:
raise RuntimeError("Database session factory is not initialized.")
async with AsyncSessionLocal() as db:
run_root = self.get_run_root()
run_roots = self.get_run_roots()
manifest_paths = await asyncio.to_thread(self._iter_run_manifest_paths)
fingerprint = await asyncio.to_thread(self._tree_fingerprint, manifest_paths)
state = await self._get_or_create_catalog_state(db, storage_root=run_root)
db_count_result = await db.execute(
select(func.count(ResultProductORM.id)).where(ResultProductORM.catalog_name == SBAS_INSAR_CATALOG_NAME)
)
db_count = int(db_count_result.scalar_one() or 0)
needs_rebuild = (
state.manifest_count != len(manifest_paths)
or state.db_count != db_count
or state.manifest_fingerprint != fingerprint
)
state.last_boot_check_at = _utcnow()
await db.commit()
rebuilt = False
result: dict[str, Any] = {}
if needs_rebuild and settings.RESULT_CATALOG_AUTO_REBUILD_ON_STARTUP:
result = await self.rebuild_catalog(db, full_rebuild=True)
rebuilt = True
db_count = int(result.get("registered") or db_count)
else:
state.manifest_count = len(manifest_paths)
state.db_count = db_count
state.manifest_fingerprint = fingerprint if not needs_rebuild else state.manifest_fingerprint
state.needs_rebuild = needs_rebuild
state.status = "WARN" if needs_rebuild else "READY"
state.last_message = "SBAS boot check complete"
await db.commit()
return {
"storage_root": run_root,
"storage_roots": run_roots,
"manifest_count": len(manifest_paths),
"current_manifest_fingerprint": fingerprint,
"indexed_manifest_fingerprint": state.manifest_fingerprint,
"db_count": db_count,
"needs_rebuild": needs_rebuild and not rebuilt,
"rebuilt": rebuilt,
"queued": False,
"registered": result.get("registered"),
"failed": result.get("failed"),
}
sbas_insar_catalog_service = SbasInsarCatalogService()