docs: update production workflow design and runtime changes

This commit is contained in:
2026-06-14 18:47:00 +08:00
parent 58a87706ef
commit b1c59051b9
41 changed files with 2042 additions and 134 deletions
@@ -115,7 +115,7 @@ def _resolve_config_path(raw: Any) -> Path | None:
def _fallback_wrapper_exe() -> Path | None:
candidate = (
Path(settings.PROJECT_ROOT)
/ ".codex_tmp"
/ "third_party"
/ "GF3_L1A_To_L2_pipeline"
/ "dist"
/ "windows"
@@ -443,7 +443,12 @@ def run_gf3_sarscape_production(
"results": [],
}
runtime_dir = native_root_path / ".gf3_runtime"
configured_runtime_dir = _clean_text(getattr(settings, "GF3_SARSCAPE_RUNTIME_DIR", ""))
runtime_dir = (
Path(os.path.normpath(configured_runtime_dir)).resolve()
if configured_runtime_dir
else native_root_path / ".gf3_runtime"
)
runtime_dir.mkdir(parents=True, exist_ok=True)
config_path = runtime_dir / "gf3wrapper.json"
env = os.environ.copy()
+11 -2
View File
@@ -969,7 +969,11 @@ async def _check_source_roots() -> Dict[str, Any]:
for path in split_env_paths(settings.GF3_SOURCE_DIRS):
status = _probe_directory_status(path)
status["role"] = "gf3_l1a_source"
status["role"] = (
"gf3_l1a_source"
if settings.GF3_LEGACY_GDAL_ENABLED
else "gf3_legacy_l1a_source_disabled"
)
items.append(status)
for path in split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS):
@@ -982,6 +986,12 @@ async def _check_source_roots() -> Dict[str, Any]:
status["role"] = "gf3_l2_storage"
items.append(status)
runtime_dir = str(getattr(settings, "GF3_SARSCAPE_RUNTIME_DIR", "") or "").strip()
if runtime_dir:
status = _probe_directory_status(runtime_dir)
status["role"] = "gf3_sarscape_runtime"
items.append(status)
wrapper_exe = str(settings.GF3_SARSCAPE_WRAPPER_EXE or "").strip()
if wrapper_exe:
status = _probe_file_status(wrapper_exe)
@@ -1017,7 +1027,6 @@ async def _check_sar_analysis_ready() -> Dict[str, Any]:
roots = {
"ready": _probe_directory_status(settings.SAR_ANALYSIS_READY_ROOT),
"work": _probe_directory_status(settings.SAR_ANALYSIS_WORK_ROOT),
"preview": _probe_directory_status(settings.SAR_ANALYSIS_PREVIEW_ROOT),
}
for role, payload in roots.items():
payload["role"] = role
+18
View File
@@ -3612,6 +3612,12 @@ async def _handle_gf3_process(job: SystemJobORM) -> None:
"""GF3 L1A→L2 处理 job handler(辐射定标 + RPC 几何校正)。"""
from .gf3_service import run_gf3_l1a_to_l2
if not settings.GF3_LEGACY_GDAL_ENABLED:
raise ValueError(
"Legacy GF3 Python/GDAL preprocessing is disabled. "
"Use GF3 SARscape production or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
)
payload = job.payload or {}
processing_id = payload.get("processing_id")
if not processing_id:
@@ -3685,6 +3691,12 @@ async def _handle_gf3_unpack(job: SystemJobORM) -> None:
"""GF3 archive inbox -> persistent L1A source pool."""
from .gf3_unpack_service import run_gf3_archive_unpack
if not settings.GF3_LEGACY_GDAL_ENABLED:
raise ValueError(
"Legacy GF3 archive unpack is disabled. "
"Use GF3 SARscape production or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
)
if not job.task_id:
raise ValueError("GF3_UNPACK requires task_id for progress tracking.")
@@ -3746,6 +3758,12 @@ async def _handle_gf3_batch_process(job: SystemJobORM) -> None:
from .gf3_service import run_gf3_l1a_to_l2, register_l2_to_radar_data
from .sar_analysis_ready_service import standardize_gf3_l2_for_radar
if not settings.GF3_LEGACY_GDAL_ENABLED:
raise ValueError(
"Legacy GF3 Python/GDAL preprocessing is disabled. "
"Use GF3 SARscape production or set GF3_LEGACY_GDAL_ENABLED=true explicitly."
)
payload = job.payload or {}
source_dirs = payload.get("source_dirs") or []
if not source_dirs:
@@ -271,6 +271,16 @@ def _build_root_specs_from_settings() -> List[RootSpec]:
scan_mode="scene_directory",
)
)
specs.extend(
_iter_single_root_specs(
env_var="GF3_SARSCAPE_RUNTIME_DIR",
path=settings.GF3_SARSCAPE_RUNTIME_DIR,
root_role="work_root_gf3_sarscape",
display_name="GF3 SARscape Runtime",
scan_mode="workspace",
owner_engine="sarscape",
)
)
specs.extend(
_iter_single_root_specs(
env_var="SAR_ANALYSIS_READY_ROOT",
@@ -438,6 +448,46 @@ def _build_root_specs_from_settings() -> List[RootSpec]:
owner_engine="pyint",
)
)
specs.extend(
_iter_single_root_specs(
env_var="PYINT_DEM_ROOT",
path=settings.PYINT_DEM_ROOT,
root_role="dem_cache_root_pyint",
display_name="PyINT DEM Cache Root",
scan_mode="workspace",
owner_engine="pyint",
)
)
specs.extend(
_iter_single_root_specs(
env_var="GAMMA_SBAS_WORK_ROOT",
path=settings.GAMMA_SBAS_WORK_ROOT,
root_role="work_root_gamma_sbas",
display_name="Gamma SBAS Work Root",
scan_mode="workspace",
owner_engine="gamma",
)
)
specs.extend(
_iter_single_root_specs(
env_var="GAMMA_SBAS_PRODUCT_ROOT",
path=settings.GAMMA_SBAS_PRODUCT_ROOT,
root_role="publish_root_gamma_sbas",
display_name="Gamma SBAS Publish Root",
scan_mode="manifest_tree",
owner_engine="gamma",
)
)
specs.extend(
_iter_single_root_specs(
env_var="GAMMA_SBAS_TRIAL_ROOT",
path=settings.GAMMA_SBAS_TRIAL_ROOT,
root_role="trial_root_gamma_sbas",
display_name="Gamma SBAS Trial Root",
scan_mode="workspace",
owner_engine="gamma",
)
)
return specs
@@ -289,6 +289,50 @@ def _read_gamma_int_param(path: Path, key: str) -> Optional[int]:
return _safe_int(_read_gamma_key_values(path).get(key))
def _expert_gamma_work_run_dir(run_dir: Path) -> Path:
run_id = run_dir.name
candidates = [
run_dir,
Path(settings.GAMMA_SBAS_WORK_ROOT or "") / "runs" / run_id if settings.GAMMA_SBAS_WORK_ROOT else run_dir,
]
seen: set[str] = set()
for candidate in candidates:
normalized = _normalize_path(candidate)
if normalized in seen:
continue
seen.add(normalized)
if (
(candidate / "sbas" / "disp.TS_tab").is_file()
and (candidate / "sbas" / "mli.ave.par").is_file()
and any((candidate / "dem").glob("*.lt_fine"))
):
return candidate
return run_dir
def _expert_gamma_lookup_path(work_run_dir: Path, manifest: dict[str, Any]) -> Path:
lt_path = work_run_dir / "dem" / f"{manifest.get('reference_date') or ''}.lt_fine"
if lt_path.is_file():
return lt_path
candidates = sorted((work_run_dir / "dem").glob("*.lt_fine"))
return candidates[0] if candidates else lt_path
def _resolve_expert_gamma_tab_path(raw_path: str, *, work_run_dir: Path) -> Path:
path = Path(_wsl_path_to_windows(raw_path))
if path.is_file():
return path
run_id = work_run_dir.name
parts = list(path.parts)
if run_id in parts:
index = parts.index(run_id)
relative_parts = parts[index + 1 :]
candidate = work_run_dir.joinpath(*relative_parts)
if candidate.is_file():
return candidate
return path
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
radius_m = 6371008.8
phi1 = math.radians(lat1)
@@ -762,12 +806,13 @@ def _read_geo_rate_window(
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"
work_run_dir = _expert_gamma_work_run_dir(run_dir)
work_manifest = _safe_read_json(work_run_dir / "run_manifest.json")
if not work_manifest:
work_manifest = _safe_read_json(run_dir / "run_manifest.json")
lt_path = _expert_gamma_lookup_path(work_run_dir, work_manifest)
mli_par = work_run_dir / "sbas" / "mli.ave.par"
disp_tab = work_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():
@@ -852,11 +897,13 @@ def _query_expert_gamma_point_timeseries(run_dir: Path, *, lon: float, lat: floa
img_y = min(max(0, img_y), radar_lines - 1)
raw_disp_paths = [
Path(_wsl_path_to_windows(line.strip()))
_resolve_expert_gamma_tab_path(line.strip(), work_run_dir=work_run_dir)
for line in disp_tab.read_text(encoding="utf-8", errors="ignore").splitlines()
if line.strip()
]
dates = _read_expert_sbas_dates(run_dir)
dates = _read_expert_sbas_dates(work_run_dir)
if not dates:
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)
@@ -872,6 +919,8 @@ def _query_expert_gamma_point_timeseries(run_dir: Path, *, lon: float, lat: floa
return {
"schema": "insar.gamma-sbas-point-query/v1",
"source_tool": "disp.TS_tab_radar_pixel_sample",
"source_run_dir": str(run_dir),
"work_run_dir": str(work_run_dir),
"query": {"lon": lon, "lat": lat},
"matched": {
**valid_choice,
@@ -906,11 +955,11 @@ def _locate_radar_points_in_geocoded_product(run_dir: Path, points: list[dict[st
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
work_run_dir = _expert_gamma_work_run_dir(run_dir)
manifest = _safe_read_json(work_run_dir / "run_manifest.json")
if not manifest:
manifest = _safe_read_json(run_dir / "run_manifest.json")
lt_path = _expert_gamma_lookup_path(work_run_dir, manifest)
if not source_path.is_file() or not lt_path.is_file():
return {}
@@ -1982,6 +2031,12 @@ def _stack_dates_from_manifest(stack_manifest: dict[str, Any], manifest: dict[st
class SbasInsarCatalogService:
def get_run_root(self) -> str:
root = Path(settings.GAMMA_SBAS_PRODUCT_ROOT or Path(settings.TIMESERIES_PRODUCT_DIR) / "sbas")
run_root = root / "runs"
run_root.mkdir(parents=True, exist_ok=True)
return _normalize_path(run_root)
def get_work_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)
@@ -1989,6 +2044,9 @@ class SbasInsarCatalogService:
def get_run_roots(self) -> list[str]:
roots = [self.get_run_root()]
work_root = self.get_work_run_root()
if work_root not in roots:
roots.append(work_root)
try:
landsar_root = landsar_sbas_service.configured_run_root()
if landsar_root not in roots:
@@ -1999,17 +2057,17 @@ class SbasInsarCatalogService:
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] = []
manifest_paths_by_run: dict[str, 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))
for path in sorted(root.glob("*/run_manifest.json")):
if not self._is_publish_ready(path.parent, _safe_read_json(path)):
continue
run_id = path.parent.name
manifest_paths_by_run.setdefault(run_id, _normalize_path(path))
return list(manifest_paths_by_run.values())
@staticmethod
def _is_landsar_manifest(manifest: dict[str, Any]) -> bool:
@@ -2295,9 +2353,12 @@ class SbasInsarCatalogService:
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")
try:
detail = sbas_insar_production_service.get_run_detail(run_dir.name)
coverage = detail.get("geographic_coverage") or {}
except FileNotFoundError:
coverage = sbas_insar_production_service._build_run_geographic_coverage(run_dir, manifest)
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)
@@ -786,8 +786,20 @@ class SbasInsarProductionService:
}
def __init__(self) -> None:
self.trial_root = Path(settings.BACKEND_DIR) / "runtime" / "gamma_ipta_trials"
self.production_root = Path(settings.GAMMA_SBAS_WORK_ROOT or (Path(settings.BACKEND_DIR) / "runtime" / "sbas_insar_production"))
project_drive = Path(settings.PROJECT_ROOT).drive
default_runtime_root = Path(f"{project_drive}\\production_runtime") if project_drive else Path(settings.PROJECT_ROOT) / "runtime"
self.trial_root = Path(settings.GAMMA_SBAS_TRIAL_ROOT or default_runtime_root / "gamma_ipta_trials")
self.production_root = Path(settings.GAMMA_SBAS_WORK_ROOT or default_runtime_root / "sbas_insar_work")
self.product_root = Path(settings.GAMMA_SBAS_PRODUCT_ROOT or Path(settings.TIMESERIES_PRODUCT_DIR) / "sbas")
def product_run_root(self) -> Path:
return self.product_root / "runs"
def product_run_dir(self, run_id: str) -> Path:
clean_id = str(run_id or "").strip()
if not clean_id or Path(clean_id).name != clean_id:
raise ValueError("invalid run id")
return self.product_run_root() / clean_id
def get_capabilities(self) -> dict[str, Any]:
return {
@@ -797,6 +809,7 @@ class SbasInsarProductionService:
"implementation_state": "expert_manifest_script_runner_primary",
"trial_root": str(self.trial_root),
"production_root": str(self.production_root),
"product_root": str(self.product_root),
"min_common_overlap_ratio": self._effective_min_common_overlap_ratio(None),
"workflow_runner": {
"enabled": bool(settings.GAMMA_SBAS_ENABLED),
@@ -3412,6 +3425,8 @@ class SbasInsarProductionService:
self._write_json(run_dir / "monitor_points_summary.json", summary)
self._write_json(manifest_path, manifest)
self._refresh_command_manifest_after_monitor_points(run_dir, manifest)
if manifest["status"] == "MONITOR_POINTS_READY":
self.sync_product_package(run_id)
return self.get_run_detail(run_id)
def list_trial_runs(self) -> dict[str, Any]:
@@ -3828,6 +3843,8 @@ class SbasInsarProductionService:
run_manifest["next_stage"] = "fix_workflow"
self._write_json(manifest_path, run_manifest)
self._write_json(run_dir / "workflow_summary.json", summary)
if run_manifest.get("status") == "WORKFLOW_COMPLETED":
self.sync_product_package(run_id)
return self.get_run_detail(run_id)
@staticmethod
@@ -5204,6 +5221,7 @@ class SbasInsarProductionService:
errors.append(f"{label} does not point to an existing Gamma DEM + .par pair: {raw_path}")
cache_roots = [
Path(settings.PYINT_DEM_ROOT),
Path(settings.BACKEND_DIR) / "runtime" / "pyint_dem",
Path(settings.BACKEND_DIR) / "runtime" / "pyint_dem_cache",
]
@@ -7138,6 +7156,118 @@ class SbasInsarProductionService:
out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
return out_path
@staticmethod
def _copy_file_if_newer(source: Path, target: Path) -> bool:
if not source.is_file():
return False
target.parent.mkdir(parents=True, exist_ok=True)
try:
source_stat = source.stat()
target_stat = target.stat() if target.exists() else None
if (
target_stat is not None
and target_stat.st_size == source_stat.st_size
and int(target_stat.st_mtime) >= int(source_stat.st_mtime)
):
return False
shutil.copy2(source, target)
return True
except OSError:
shutil.copy2(source, target)
return True
@staticmethod
def _copy_tree_files(source_dir: Path, target_dir: Path) -> tuple[int, int]:
if not source_dir.is_dir():
return 0, 0
copied = 0
skipped = 0
for source in source_dir.rglob("*"):
if not source.is_file():
continue
target = target_dir / source.relative_to(source_dir)
if SbasInsarProductionService._copy_file_if_newer(source, target):
copied += 1
else:
skipped += 1
return copied, skipped
def sync_product_package(self, run_id: str) -> dict[str, Any]:
run_dir = self._resolve_run_dir(run_id)
product_dir = self.product_run_dir(run_dir.name)
product_dir.mkdir(parents=True, exist_ok=True)
copied = 0
skipped = 0
for relative in (
"run_manifest.json",
"stack_manifest.json",
"pair_network.json",
"workflow_summary.json",
"monitor_points_summary.json",
"product_summary.json",
"quality_summary.json",
"gamma_command_manifest.json",
"expert_command_audit.json",
):
if self._copy_file_if_newer(run_dir / relative, product_dir / relative):
copied += 1
else:
skipped += 1
for dirname in ("publish",):
tree_copied, tree_skipped = self._copy_tree_files(run_dir / dirname, product_dir / dirname)
copied += tree_copied
skipped += tree_skipped
for relative in (
"diff_dir/bprep_file.png",
"diff_dir/mean.cc_mask.bmp",
"sbas/final_unw_tab",
):
if self._copy_file_if_newer(run_dir / relative, product_dir / relative):
copied += 1
else:
skipped += 1
final_tab = run_dir / "sbas" / "final_unw_tab"
diff_dir = run_dir / "diff_dir"
pair_ids: list[str] = []
if final_tab.is_file():
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(self._path_to_windows(raw_path) or 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)
qcs = [diff_dir / f"{pair_id}.adf.unw.bmp" for pair_id in pair_ids if (diff_dir / f"{pair_id}.adf.unw.bmp").is_file()]
if not qcs and diff_dir.is_dir():
qcs = sorted(diff_dir.glob("*.adf.unw.bmp"))
if len(qcs) > 3:
last_index = len(qcs) - 1
indexes = sorted({round(index * last_index / 2) for index in range(3)})
qcs = [qcs[index] for index in indexes]
for source in qcs:
target = product_dir / source.relative_to(run_dir)
if self._copy_file_if_newer(source, target):
copied += 1
else:
skipped += 1
marker = {
"schema": "insar.gamma-sbas-product-package/v1",
"run_id": run_dir.name,
"source_run_dir": str(run_dir),
"product_run_dir": str(product_dir),
"copied_files": copied,
"skipped_files": skipped,
"synced_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
}
self._write_json(product_dir / "product_package_manifest.json", marker)
return marker
@staticmethod
def _write_json(path: Path, payload: dict[str, Any]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
+9 -1
View File
@@ -24,6 +24,14 @@ def _project_file_windows(*relative_parts: str) -> str:
return os.path.normpath(str(Path(settings.PROJECT_ROOT, *relative_parts)))
def _default_runtime_root_windows() -> str:
root = os.path.normpath(str(settings.PROJECT_ROOT))
drive, _tail = os.path.splitdrive(root)
if drive:
return os.path.join(drive + os.sep, "production_runtime")
return os.path.join(root, "runtime")
@dataclass(frozen=True)
class WslRuntimeDefinition:
runtime_id: str
@@ -106,7 +114,7 @@ def build_wsl_runtime_registry() -> WslRuntimeRegistry:
).strip()
broker_job_root_windows = os.path.normpath(
settings.WSL_BROKER_JOB_ROOT
or os.path.join(settings.BACKEND_DIR, "runtime", "wsl_jobs")
or os.path.join(_default_runtime_root_windows(), "wsl_jobs")
)
broker_job_root_wsl = _windows_path_to_wsl_mount(broker_job_root_windows)