feat: add D-InSAR source bundle distribution

This commit is contained in:
2026-05-11 11:08:53 +08:00
parent 6893041a9f
commit 508ca5641a
11 changed files with 1150 additions and 63 deletions
+782 -35
View File
@@ -3,6 +3,8 @@ import shutil
import asyncio
import tempfile
import zipfile
import json
import hashlib
from datetime import datetime
from typing import List, Tuple, Optional, Dict, Any
@@ -158,6 +160,724 @@ def _zip_task_directory(task_dir: str, zip_path: str) -> None:
pass
def _directory_has_entries(path: str) -> bool:
if not os.path.isdir(path):
return False
try:
with os.scandir(path) as entries:
return any(True for _ in entries)
except OSError:
return False
def _is_existing_dinsar_folder_complete(task_dir: str) -> bool:
return (
_directory_has_entries(os.path.join(task_dir, "master"))
and _directory_has_entries(os.path.join(task_dir, "slave"))
)
def _is_existing_dinsar_zip_complete(zip_path: str) -> bool:
try:
return os.path.isfile(zip_path) and os.path.getsize(zip_path) > 0
except OSError:
return False
def _build_dinsar_pair_metadata(
item: Dict[str, Any],
task_name: str,
task_alias: str,
package_format: str,
include_orbit_files: bool,
orbit_entries: List[Dict[str, Any]],
) -> Dict[str, Any]:
return {
"pair_key": item.get("pair_key"),
"task_name": task_name,
"task_alias": task_alias,
"master_path": item.get("master_path"),
"slave_path": item.get("slave_path"),
"master_satellite": item.get("master_satellite"),
"slave_satellite": item.get("slave_satellite"),
"master_imaging_date": item.get("master_imaging_date"),
"slave_imaging_date": item.get("slave_imaging_date"),
"master_imaging_mode": item.get("master_imaging_mode"),
"slave_imaging_mode": item.get("slave_imaging_mode"),
"master_polarization": item.get("master_polarization"),
"slave_polarization": item.get("slave_polarization"),
"time_baseline_days": item.get("time_baseline_days"),
"spatial_baseline_meters": item.get("spatial_baseline_meters"),
"scene_center_distance_meters": item.get("scene_center_distance_meters"),
"package_format": package_format,
"include_orbit_files": bool(include_orbit_files),
"master_orbit_file_path": item.get("master_orbit_file_path"),
"slave_orbit_file_path": item.get("slave_orbit_file_path"),
"orbit_files": orbit_entries,
"scene_pair_uid": item.get("scene_pair_uid") or item.get("pair_uid"),
"pair_uid": item.get("pair_uid") or item.get("scene_pair_uid"),
"network_run_id": item.get("network_run_id"),
"network_edge_id": item.get("network_edge_id"),
"policy_version": item.get("policy_version"),
"selection_strategy": item.get("selection_strategy"),
"copied_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
}
def _safe_bundle_entry_name(source_path: str, prefix: str) -> str:
base_name = os.path.basename(os.path.normpath(str(source_path or ""))) or "item"
digest = hashlib.sha1(os.path.normcase(os.path.abspath(source_path)).encode("utf-8")).hexdigest()[:10]
return f"{prefix}_{digest}_{base_name}"
def _bundle_relative_scene_path(source_path: str) -> str:
normalized = os.path.normpath(os.path.abspath(str(source_path)))
return os.path.join("data", _safe_bundle_entry_name(normalized, "scene")).replace(os.sep, "/")
def _normalize_bundle_relative_path(path: Any) -> str:
return str(path or "").strip().replace("\\", "/")
def _source_bundle_id_number(value: Any, prefix: str) -> int:
text = str(value or "")
marker = f"{prefix}_"
if not text.startswith(marker):
return 0
try:
return int(text[len(marker):])
except ValueError:
return 0
def _next_source_bundle_id(records: List[Dict[str, Any]], id_key: str, prefix: str) -> int:
max_number = 0
for record in records:
max_number = max(max_number, _source_bundle_id_number(record.get(id_key), prefix))
return max(max_number, len(records)) + 1
def _source_bundle_pair_keys_from_item(item: Dict[str, Any]) -> List[str]:
keys: List[str] = []
def add(label: str, value: Any) -> None:
text = str(value or "").strip()
if text:
keys.append(f"{label}:{text}")
add("uid", item.get("scene_pair_uid") or item.get("pair_uid"))
add("pair_key", item.get("pair_key"))
network_run_id = str(item.get("network_run_id") or "").strip()
network_edge_id = str(item.get("network_edge_id") or "").strip()
if network_run_id and network_edge_id:
keys.append(f"network:{network_run_id}:{network_edge_id}")
master_path = item.get("master_path")
slave_path = item.get("slave_path")
if master_path and slave_path:
master_abs = os.path.normcase(os.path.normpath(os.path.abspath(str(master_path))))
slave_abs = os.path.normcase(os.path.normpath(os.path.abspath(str(slave_path))))
keys.append(f"source_paths:{master_abs}|{slave_abs}")
keys.append(
"bundle_paths:"
f"{_bundle_relative_scene_path(str(master_path))}|"
f"{_bundle_relative_scene_path(str(slave_path))}"
)
return keys
def _source_bundle_pair_keys_from_pair(pair: Dict[str, Any]) -> List[str]:
keys: List[str] = []
identity_key = str(pair.get("identity_key") or "").strip()
if identity_key:
keys.append(identity_key)
def add(label: str, value: Any) -> None:
text = str(value or "").strip()
if text:
keys.append(f"{label}:{text}")
add("uid", pair.get("scene_pair_uid") or pair.get("pair_uid"))
add("pair_key", pair.get("pair_key"))
network_run_id = str(pair.get("network_run_id") or "").strip()
network_edge_id = str(pair.get("network_edge_id") or "").strip()
if network_run_id and network_edge_id:
keys.append(f"network:{network_run_id}:{network_edge_id}")
master_source_path = pair.get("master_source_path")
slave_source_path = pair.get("slave_source_path")
if master_source_path and slave_source_path:
master_abs = os.path.normcase(os.path.normpath(os.path.abspath(str(master_source_path))))
slave_abs = os.path.normcase(os.path.normpath(os.path.abspath(str(slave_source_path))))
keys.append(f"source_paths:{master_abs}|{slave_abs}")
master_data = _normalize_bundle_relative_path(pair.get("master_data"))
slave_data = _normalize_bundle_relative_path(pair.get("slave_data"))
if master_data and slave_data:
keys.append(f"bundle_paths:{master_data}|{slave_data}")
return keys
def _read_json_file(path: str) -> Dict[str, Any]:
if not os.path.isfile(path):
return {}
with open(path, "r", encoding="utf-8-sig") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
return {}
return payload
def _copy_source_into_bundle(source_path: str, dest_path: str, skip_existing: bool) -> str:
if skip_existing and os.path.exists(dest_path):
return "skipped"
if os.path.exists(dest_path):
if os.path.isdir(dest_path):
shutil.rmtree(dest_path)
else:
os.remove(dest_path)
parent_dir = os.path.dirname(dest_path)
os.makedirs(parent_dir, exist_ok=True)
if os.path.isdir(source_path):
shutil.copytree(source_path, dest_path)
else:
shutil.copy2(source_path, dest_path)
return "copied"
def _write_json_file(path: str, payload: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
temp_path = f"{path}.tmp"
try:
with open(temp_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
async def run_dinsar_source_bundle_items(
task_id: str,
items: List[Dict[str, Any]],
dest_dir: str,
*,
include_orbit_files: bool = True,
skip_existing: bool = True,
max_items: Optional[int] = None,
) -> None:
try:
if max_items is not None:
try:
max_items = int(max_items)
except (TypeError, ValueError):
max_items = None
if max_items is not None and max_items <= 0:
max_items = None
await task_service.start_task(task_id, message="Starting D-InSAR source bundle export...")
await _log_and_update(task_id, f"D-InSAR source bundle export started. Dest: {dest_dir}")
await _log_and_update(
task_id,
(
"D-InSAR source bundle options: "
f"include_orbit_files={include_orbit_files}, "
f"skip_existing={skip_existing}, "
f"max_items={max_items if max_items is not None else 'unlimited'}"
),
)
await asyncio.to_thread(os.makedirs, dest_dir, exist_ok=True)
data_dir = os.path.join(dest_dir, "data")
orbit_dir = os.path.join(dest_dir, "orbit")
await asyncio.to_thread(os.makedirs, data_dir, exist_ok=True)
if include_orbit_files:
await asyncio.to_thread(os.makedirs, orbit_dir, exist_ok=True)
pairs_path = os.path.join(dest_dir, "pairs.json")
manifest_path = os.path.join(dest_dir, "manifest.json")
existing_pairs_payload = await asyncio.to_thread(_read_json_file, pairs_path)
existing_manifest_payload = await asyncio.to_thread(_read_json_file, manifest_path)
raw_existing_pairs = existing_pairs_payload.get("pairs")
existing_pairs: List[Dict[str, Any]] = [
dict(pair)
for pair in raw_existing_pairs
if isinstance(pair, dict)
] if isinstance(raw_existing_pairs, list) else []
raw_existing_scenes = existing_manifest_payload.get("scenes")
existing_scene_list: List[Dict[str, Any]] = [
dict(scene)
for scene in raw_existing_scenes
if isinstance(scene, dict)
] if isinstance(raw_existing_scenes, list) else []
raw_existing_orbits = existing_manifest_payload.get("orbits")
existing_orbit_list: List[Dict[str, Any]] = [
dict(orbit)
for orbit in raw_existing_orbits
if isinstance(orbit, dict)
] if isinstance(raw_existing_orbits, list) else []
existing_pair_keys = {
key
for pair in existing_pairs
for key in _source_bundle_pair_keys_from_pair(pair)
}
if existing_pairs:
await _log_and_update(task_id, f"Existing source bundle pairs found: {len(existing_pairs)}")
candidate_tasks: List[Dict[str, Any]] = []
for item in items:
task_name = item.get("task_name") or item.get("task_alias") or "task"
task_alias = item.get("task_alias") or task_name
master_path = item.get("master_path")
slave_path = item.get("slave_path")
if not master_path or not slave_path:
continue
candidate_tasks.append(
{
**item,
"task_name": task_name,
"task_alias": task_alias,
"master_path": master_path,
"slave_path": slave_path,
}
)
skipped_existing_pair_count = 0
exportable_tasks: List[Dict[str, Any]] = []
for item in candidate_tasks:
pair_keys = _source_bundle_pair_keys_from_item(item)
item["identity_key"] = pair_keys[0] if pair_keys else None
if existing_pair_keys and any(key in existing_pair_keys for key in pair_keys):
skipped_existing_pair_count += 1
continue
exportable_tasks.append(item)
if max_items is not None:
deferred_count = max(0, len(exportable_tasks) - max_items)
tasks = exportable_tasks[:max_items]
else:
deferred_count = 0
tasks = exportable_tasks
total_pairs = len(tasks)
await _log_and_update(
task_id,
(
f"Found {len(candidate_tasks)} candidate pairs; "
f"{skipped_existing_pair_count} already exported; "
f"{total_pairs} new pairs selected for this run."
),
)
if total_pairs == 0:
await task_service.update_task(
task_id,
status="COMPLETED",
message=(
"No new source bundle pairs to export. "
f"Existing pairs: {len(existing_pairs)}; "
f"already exported in request: {skipped_existing_pair_count}."
),
progress=100,
)
return
scene_records: List[Dict[str, Any]] = []
scene_entries_by_source: Dict[str, Dict[str, Any]] = {}
scene_entries_by_relative: Dict[str, Dict[str, Any]] = {}
scene_entries_by_id: Dict[str, Dict[str, Any]] = {}
orbit_records: List[Dict[str, Any]] = []
orbit_entries_by_source: Dict[str, Dict[str, Any]] = {}
orbit_entries_by_relative: Dict[str, Dict[str, Any]] = {}
orbit_entries_by_id: Dict[str, Dict[str, Any]] = {}
next_scene_number = _next_source_bundle_id(existing_scene_list, "scene_id", "scene")
next_orbit_number = _next_source_bundle_id(existing_orbit_list, "orbit_id", "orbit")
next_pair_number = _next_source_bundle_id(existing_pairs, "pair_id", "pair")
copy_units_by_target: Dict[str, Tuple[str, str, str, str]] = {}
pairs: List[Dict[str, Any]] = []
missing_sources: List[Dict[str, Any]] = []
def register_scene_entry(raw_entry: Dict[str, Any]) -> Dict[str, Any]:
nonlocal next_scene_number
entry = dict(raw_entry)
source_path = entry.get("source_path")
source_key = ""
if source_path:
source_path = os.path.normpath(os.path.abspath(str(source_path)))
source_key = os.path.normcase(source_path)
entry["source_path"] = source_path
relative_path = _normalize_bundle_relative_path(entry.get("relative_path"))
if not relative_path and source_path:
relative_path = _bundle_relative_scene_path(source_path)
if relative_path:
entry["relative_path"] = relative_path
existing = None
if source_key:
existing = scene_entries_by_source.get(source_key)
if existing is None and relative_path:
existing = scene_entries_by_relative.get(relative_path)
scene_id = str(entry.get("scene_id") or "").strip()
if existing is None and scene_id:
existing = scene_entries_by_id.get(scene_id)
if existing is not None:
if source_path and not existing.get("source_path"):
existing["source_path"] = source_path
scene_entries_by_source[source_key] = existing
if relative_path and not existing.get("relative_path"):
existing["relative_path"] = relative_path
scene_entries_by_relative[relative_path] = existing
return existing
if not scene_id:
scene_id = f"scene_{next_scene_number:04d}"
entry["scene_id"] = scene_id
next_scene_number += 1
else:
next_scene_number = max(next_scene_number, _source_bundle_id_number(scene_id, "scene") + 1)
scene_records.append(entry)
scene_entries_by_id[scene_id] = entry
if source_key:
scene_entries_by_source[source_key] = entry
if relative_path:
scene_entries_by_relative[relative_path] = entry
return entry
def register_orbit_entry(raw_entry: Dict[str, Any]) -> Dict[str, Any]:
nonlocal next_orbit_number
entry = dict(raw_entry)
source_path = entry.get("source_path")
source_key = ""
if source_path:
source_path = os.path.normpath(os.path.abspath(str(source_path)))
source_key = os.path.normcase(source_path)
entry["source_path"] = source_path
relative_path = _normalize_bundle_relative_path(entry.get("relative_path"))
if not relative_path and source_path:
relative_path = os.path.join("orbit", _safe_bundle_entry_name(source_path, "orbit")).replace(os.sep, "/")
if relative_path:
entry["relative_path"] = relative_path
existing = None
if source_key:
existing = orbit_entries_by_source.get(source_key)
if existing is None and relative_path:
existing = orbit_entries_by_relative.get(relative_path)
orbit_id = str(entry.get("orbit_id") or "").strip()
if existing is None and orbit_id:
existing = orbit_entries_by_id.get(orbit_id)
if existing is not None:
if source_path and not existing.get("source_path"):
existing["source_path"] = source_path
orbit_entries_by_source[source_key] = existing
if relative_path and not existing.get("relative_path"):
existing["relative_path"] = relative_path
orbit_entries_by_relative[relative_path] = existing
return existing
if not orbit_id:
orbit_id = f"orbit_{next_orbit_number:04d}"
entry["orbit_id"] = orbit_id
next_orbit_number += 1
else:
next_orbit_number = max(next_orbit_number, _source_bundle_id_number(orbit_id, "orbit") + 1)
if not isinstance(entry.get("used_by"), list):
entry["used_by"] = []
orbit_records.append(entry)
orbit_entries_by_id[orbit_id] = entry
if source_key:
orbit_entries_by_source[source_key] = entry
if relative_path:
orbit_entries_by_relative[relative_path] = entry
return entry
def add_copy_unit(kind: str, unit_id: str, source_path: Optional[str], relative_path: Optional[str]) -> None:
if not source_path or not relative_path:
return
target_path = os.path.join(dest_dir, _normalize_bundle_relative_path(relative_path))
target_key = os.path.normcase(os.path.abspath(target_path))
copy_units_by_target.setdefault(target_key, (kind, unit_id, source_path, target_path))
def add_orbit_usage(entry: Dict[str, Any], scene_id: str, role: str) -> None:
used_by = entry.setdefault("used_by", [])
usage = {"scene_id": scene_id, "role": role}
if not any(
str(item.get("scene_id")) == scene_id and str(item.get("role")) == role
for item in used_by
if isinstance(item, dict)
):
used_by.append(usage)
for scene in existing_scene_list:
register_scene_entry(scene)
for orbit in existing_orbit_list:
register_orbit_entry(orbit)
for pair in existing_pairs:
for role in ("master", "slave"):
scene_relative = _normalize_bundle_relative_path(pair.get(f"{role}_data"))
if scene_relative:
register_scene_entry(
{
"scene_id": pair.get(f"{role}_scene_id"),
"source_path": pair.get(f"{role}_source_path"),
"relative_path": scene_relative,
}
)
orbit_relative = _normalize_bundle_relative_path(pair.get(f"{role}_orbit"))
if orbit_relative:
register_orbit_entry(
{
"orbit_id": pair.get(f"{role}_orbit_id"),
"source_path": pair.get(f"{role}_orbit_source_path"),
"relative_path": orbit_relative,
"used_by": [
{
"scene_id": pair.get(f"{role}_scene_id"),
"role": role,
}
] if pair.get(f"{role}_scene_id") else [],
}
)
def ensure_scene_entry(scene_path: str, role_item: Dict[str, Any], prefix: str) -> Dict[str, Any]:
source_path = os.path.normpath(os.path.abspath(str(scene_path)))
source_key = os.path.normcase(source_path)
relative_path = _bundle_relative_scene_path(source_path)
existing = scene_entries_by_source.get(source_key) or scene_entries_by_relative.get(relative_path)
if existing:
if not existing.get("source_path"):
existing["source_path"] = source_path
scene_entries_by_source[source_key] = existing
if not existing.get("relative_path"):
existing["relative_path"] = relative_path
scene_entries_by_relative[relative_path] = existing
add_copy_unit("scene", existing["scene_id"], source_path, existing["relative_path"])
return existing
entry = register_scene_entry(
{
"source_path": source_path,
"relative_path": relative_path,
"satellite": role_item.get("satellite"),
"imaging_date": role_item.get("imaging_date"),
"imaging_mode": role_item.get("imaging_mode"),
"polarization": role_item.get("polarization"),
}
)
add_copy_unit("scene", entry["scene_id"], source_path, entry["relative_path"])
return entry
def ensure_orbit_entry(orbit_path: Optional[str], scene_id: str, role: str) -> Optional[Dict[str, Any]]:
if not include_orbit_files or not orbit_path:
return None
source_path = os.path.normpath(os.path.abspath(str(orbit_path)))
source_key = os.path.normcase(source_path)
relative_path = os.path.join("orbit", _safe_bundle_entry_name(source_path, "orbit")).replace(os.sep, "/")
existing = orbit_entries_by_source.get(source_key) or orbit_entries_by_relative.get(relative_path)
if existing:
if not existing.get("source_path"):
existing["source_path"] = source_path
orbit_entries_by_source[source_key] = existing
if not existing.get("relative_path"):
existing["relative_path"] = relative_path
orbit_entries_by_relative[relative_path] = existing
add_orbit_usage(existing, scene_id, role)
add_copy_unit("orbit", existing["orbit_id"], source_path, existing["relative_path"])
return existing
entry = register_orbit_entry(
{
"source_path": source_path,
"relative_path": relative_path,
}
)
add_orbit_usage(entry, scene_id, role)
add_copy_unit("orbit", entry["orbit_id"], source_path, entry["relative_path"])
return entry
for item in tasks:
task_name = item["task_name"]
task_alias = item["task_alias"]
master_entry = ensure_scene_entry(
item["master_path"],
{
"satellite": item.get("master_satellite"),
"imaging_date": item.get("master_imaging_date"),
"imaging_mode": item.get("master_imaging_mode"),
"polarization": item.get("master_polarization"),
},
"scene",
)
slave_entry = ensure_scene_entry(
item["slave_path"],
{
"satellite": item.get("slave_satellite"),
"imaging_date": item.get("slave_imaging_date"),
"imaging_mode": item.get("slave_imaging_mode"),
"polarization": item.get("slave_polarization"),
},
"scene",
)
master_orbit = ensure_orbit_entry(
item.get("master_orbit_file_path"),
master_entry["scene_id"],
"master",
)
slave_orbit = ensure_orbit_entry(
item.get("slave_orbit_file_path"),
slave_entry["scene_id"],
"slave",
)
master_source_path = os.path.normpath(os.path.abspath(str(item["master_path"])))
slave_source_path = os.path.normpath(os.path.abspath(str(item["slave_path"])))
pairs.append(
{
"pair_id": f"pair_{next_pair_number:04d}",
"identity_key": item.get("identity_key"),
"task_name": task_name,
"task_alias": task_alias,
"pair_key": item.get("pair_key"),
"scene_pair_uid": item.get("scene_pair_uid") or item.get("pair_uid"),
"pair_uid": item.get("pair_uid") or item.get("scene_pair_uid"),
"master_scene_id": master_entry["scene_id"],
"slave_scene_id": slave_entry["scene_id"],
"master_source_path": master_source_path,
"slave_source_path": slave_source_path,
"master_data": master_entry["relative_path"],
"slave_data": slave_entry["relative_path"],
"master_orbit_id": master_orbit.get("orbit_id") if master_orbit else None,
"slave_orbit_id": slave_orbit.get("orbit_id") if slave_orbit else None,
"master_orbit_source_path": master_orbit.get("source_path") if master_orbit else None,
"slave_orbit_source_path": slave_orbit.get("source_path") if slave_orbit else None,
"master_orbit": master_orbit.get("relative_path") if master_orbit else None,
"slave_orbit": slave_orbit.get("relative_path") if slave_orbit else None,
"master_imaging_date": item.get("master_imaging_date"),
"slave_imaging_date": item.get("slave_imaging_date"),
"time_baseline_days": item.get("time_baseline_days"),
"scene_center_distance_meters": item.get("scene_center_distance_meters"),
"spatial_baseline_meters": item.get("spatial_baseline_meters"),
"network_run_id": item.get("network_run_id"),
"network_edge_id": item.get("network_edge_id"),
"policy_version": item.get("policy_version"),
"selection_strategy": item.get("selection_strategy"),
}
)
next_pair_number += 1
scene_list = sorted(
scene_records,
key=lambda entry: (_source_bundle_id_number(entry.get("scene_id"), "scene"), str(entry.get("scene_id") or "")),
)
orbit_list = sorted(
orbit_records,
key=lambda entry: (_source_bundle_id_number(entry.get("orbit_id"), "orbit"), str(entry.get("orbit_id") or "")),
)
copy_units = list(copy_units_by_target.values())
copied_count = 0
skipped_count = 0
failed_count = 0
total_units = max(1, len(copy_units))
for index, (kind, unit_id, source_path, target_path) in enumerate(copy_units, start=1):
prog = int(((index - 1) / total_units) * 90)
await task_service.update_task(
task_id,
progress=prog,
message=f"Bundling {kind} ({index}/{total_units}): {unit_id}",
)
await _log_and_update(task_id, f"[{index}/{total_units}] Bundling {kind}: {unit_id}")
if not os.path.exists(source_path):
await _log_and_update(task_id, f" -> Missing source: {source_path}")
missing_sources.append({"kind": kind, "id": unit_id, "source_path": source_path})
failed_count += 1
continue
try:
action = await asyncio.to_thread(_copy_source_into_bundle, source_path, target_path, skip_existing)
if action == "skipped":
skipped_count += 1
await _log_and_update(task_id, " -> Skipped (already exists in bundle)")
else:
copied_count += 1
await _log_and_update(task_id, " -> Success")
except PermissionError:
await _log_and_update(task_id, " -> Failed: permission denied")
failed_count += 1
except Exception as exc:
await _log_and_update(task_id, f" -> Failed: {exc}")
failed_count += 1
combined_pairs = existing_pairs + pairs
final_msg = (
"D-InSAR source bundle export finished. "
f"Pairs {len(combined_pairs)} (+{len(pairs)}), Scenes {len(scene_list)}, "
f"Orbits {len(orbit_list)}, "
f"Copied {copied_count}, Skipped {skipped_count}, "
f"Already exported pairs {skipped_existing_pair_count}, "
f"Deferred {deferred_count}, Failed {failed_count}"
)
if failed_count > 0:
await task_service.update_task(task_id, status="FAILED", message=final_msg, progress=100)
raise CopyTaskExecutionError(final_msg)
exported_at = datetime.utcnow().isoformat(timespec="seconds") + "Z"
pairs_payload = {
"schema": "dinsar_source_bundle_pairs.v1",
"exported_at": exported_at,
"pairs": combined_pairs,
}
manifest_payload = {
"schema": "dinsar_source_bundle_manifest.v1",
"exported_at": exported_at,
"package_format": "source_bundle",
"destination": os.path.normpath(os.path.abspath(dest_dir)),
"pair_count": len(combined_pairs),
"new_pair_count": len(pairs),
"existing_pair_count": len(existing_pairs),
"candidate_pair_count": len(candidate_tasks),
"skipped_existing_pairs": skipped_existing_pair_count,
"scene_count": len(scene_list),
"orbit_count": len(orbit_list),
"include_orbit_files": bool(include_orbit_files),
"skip_existing": bool(skip_existing),
"max_items": max_items,
"deferred_pairs": deferred_count,
"copied_units": copied_count,
"skipped_units": skipped_count,
"failed_units": failed_count,
"missing_sources": missing_sources,
"directories": {
"data": "data",
"orbit": "orbit" if orbit_list else None,
},
"scenes": scene_list,
"orbits": orbit_list,
}
await asyncio.to_thread(_write_json_file, pairs_path, pairs_payload)
await asyncio.to_thread(_write_json_file, manifest_path, manifest_payload)
await _log_and_update(task_id, final_msg, progress=100)
await task_service.update_task(task_id, status="COMPLETED", message=final_msg, progress=100)
except CopyTaskExecutionError:
raise
except Exception as e:
fail_msg = f"D-InSAR source bundle fatal error: {e}"
try:
await task_service.update_task(task_id, status="FAILED", message=fail_msg)
except Exception:
pass
raise CopyTaskExecutionError(fail_msg) from e
async def run_ps_copy_items(task_id: str, items: List[Dict[str, Any]], dest_dir: str) -> None:
try:
await task_service.start_task(task_id, message="Starting PS-InSAR copy task...")
@@ -247,8 +967,18 @@ async def run_dinsar_copy_items(
*,
include_orbit_files: bool = False,
export_zip: bool = False,
skip_existing: bool = True,
max_items: Optional[int] = None,
) -> None:
try:
if max_items is not None:
try:
max_items = int(max_items)
except (TypeError, ValueError):
max_items = None
if max_items is not None and max_items <= 0:
max_items = None
await task_service.start_task(task_id, message="Starting D-InSAR copy task...")
await _log_and_update(task_id, f"D-InSAR copy started. Dest: {dest_dir}")
await _log_and_update(
@@ -256,7 +986,9 @@ async def run_dinsar_copy_items(
(
"D-InSAR copy options: "
f"include_orbit_files={include_orbit_files}, "
f"export_zip={export_zip}"
f"export_zip={export_zip}, "
f"skip_existing={skip_existing}, "
f"max_items={max_items if max_items is not None else 'unlimited'}"
),
)
@@ -295,7 +1027,10 @@ async def run_dinsar_copy_items(
return
success_count = 0
skipped_count = 0
failed_count = 0
deferred_count = 0
attempted_count = 0
for i, item in enumerate(tasks, start=1):
task_name = item["task_name"]
task_alias = item["task_alias"]
@@ -308,6 +1043,28 @@ async def run_dinsar_copy_items(
staging_root: Optional[str] = None
try:
final_task_dir = os.path.join(dest_dir, task_alias)
zip_path = os.path.join(dest_dir, f"{task_alias}.zip") if export_zip else None
if skip_existing:
if export_zip and zip_path and _is_existing_dinsar_zip_complete(zip_path):
await _log_and_update(task_id, " -> Skipped (existing zip package)")
skipped_count += 1
continue
if (not export_zip) and _is_existing_dinsar_folder_complete(final_task_dir):
await _log_and_update(task_id, " -> Skipped (existing master/slave folders)")
skipped_count += 1
continue
if max_items is not None and attempted_count >= max_items:
deferred_count = total - i + 1
await _log_and_update(
task_id,
f"Reached max_items={max_items}; deferred {deferred_count} remaining candidates.",
)
break
attempted_count += 1
if export_zip:
staging_root = await asyncio.to_thread(
tempfile.mkdtemp,
@@ -315,10 +1072,13 @@ async def run_dinsar_copy_items(
dir=dest_dir,
)
task_dir = os.path.join(staging_root, task_alias)
zip_path = os.path.join(dest_dir, f"{task_alias}.zip")
else:
task_dir = os.path.join(dest_dir, task_alias)
zip_path = None
staging_root = await asyncio.to_thread(
tempfile.mkdtemp,
prefix="._dinsar_copy_",
dir=dest_dir,
)
task_dir = os.path.join(staging_root, task_alias)
master_dir = os.path.join(task_dir, "master")
slave_dir = os.path.join(task_dir, "slave")
@@ -345,40 +1105,26 @@ async def run_dinsar_copy_items(
await asyncio.to_thread(
write_pair_metadata,
task_dir,
{
"pair_key": item.get("pair_key"),
"task_name": task_name,
"task_alias": task_alias,
"master_path": item.get("master_path"),
"slave_path": item.get("slave_path"),
"master_satellite": item.get("master_satellite"),
"slave_satellite": item.get("slave_satellite"),
"master_imaging_date": item.get("master_imaging_date"),
"slave_imaging_date": item.get("slave_imaging_date"),
"master_imaging_mode": item.get("master_imaging_mode"),
"slave_imaging_mode": item.get("slave_imaging_mode"),
"master_polarization": item.get("master_polarization"),
"slave_polarization": item.get("slave_polarization"),
"time_baseline_days": item.get("time_baseline_days"),
"spatial_baseline_meters": item.get("spatial_baseline_meters"),
"scene_center_distance_meters": item.get("scene_center_distance_meters"),
"package_format": "zip" if export_zip else "folder",
"include_orbit_files": bool(include_orbit_files),
"master_orbit_file_path": item.get("master_orbit_file_path"),
"slave_orbit_file_path": item.get("slave_orbit_file_path"),
"orbit_files": orbit_entries,
"scene_pair_uid": item.get("scene_pair_uid") or item.get("pair_uid"),
"pair_uid": item.get("pair_uid") or item.get("scene_pair_uid"),
"network_run_id": item.get("network_run_id"),
"network_edge_id": item.get("network_edge_id"),
"policy_version": item.get("policy_version"),
"selection_strategy": item.get("selection_strategy"),
"copied_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
},
_build_dinsar_pair_metadata(
item,
task_name,
task_alias,
"zip" if export_zip else "folder",
include_orbit_files,
orbit_entries,
),
)
if export_zip and zip_path:
await asyncio.to_thread(_zip_task_directory, task_dir, zip_path)
await _log_and_update(task_id, f" -> ZIP: {zip_path}")
elif not export_zip:
if os.path.exists(final_task_dir):
raise CopyTaskExecutionError(
"Destination folder already exists and cannot be atomically replaced: "
f"{final_task_dir}"
)
await asyncio.to_thread(os.replace, task_dir, final_task_dir)
await _log_and_update(task_id, f" -> Folder: {final_task_dir}")
await _log_and_update(task_id, " -> Success")
success_count += 1
@@ -394,7 +1140,8 @@ async def run_dinsar_copy_items(
final_msg = (
f"D-InSAR copy finished. Mode {'zip' if export_zip else 'folder'}. "
f"Success {success_count}, Failed {failed_count}"
f"Copied {success_count}, Skipped {skipped_count}, "
f"Deferred {deferred_count}, Failed {failed_count}"
)
await _log_and_update(task_id, final_msg, progress=100)
if failed_count > 0:
+52 -3
View File
@@ -27,6 +27,13 @@ COPY_BATCH_MAX_STATUS_COUNT = read_int_env(
minimum=1,
maximum=64,
)
COPY_BATCH_MAX_COPY_ITEMS = read_int_env(
"COPY_BATCH_MAX_COPY_ITEMS",
5000,
minimum=1,
maximum=200000,
)
COPY_DINSAR_PACKAGE_MODES = {"task_folder", "task_zip", "source_bundle"}
class CopyBatchRequest(BaseModel):
@@ -35,6 +42,9 @@ class CopyBatchRequest(BaseModel):
copy_statuses: Optional[List[str]] = None
include_orbit_files: bool = False
export_zip: bool = False
package_mode: str = "task_folder"
skip_existing: bool = True
max_items: Optional[int] = None
@field_validator("batch_id", "dest_dir", mode="before")
@classmethod
@@ -57,6 +67,33 @@ class CopyBatchRequest(BaseModel):
)
return value
@field_validator("max_items", mode="before")
@classmethod
def _normalize_max_items(cls, value):
if value in (None, ""):
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError("max_items must be an integer.") from exc
if parsed <= 0:
return None
if parsed > COPY_BATCH_MAX_COPY_ITEMS:
raise ValueError(
f"max_items exceeds max item count ({COPY_BATCH_MAX_COPY_ITEMS})."
)
return parsed
@field_validator("package_mode", mode="before")
@classmethod
def _normalize_package_mode(cls, value):
normalized = str(value or "task_folder").strip().lower()
if normalized not in COPY_DINSAR_PACKAGE_MODES:
raise ValueError(
f"package_mode must be one of: {sorted(COPY_DINSAR_PACKAGE_MODES)}."
)
return normalized
def _normalize_copy_batch_statuses(copy_statuses: Optional[List[str]]) -> List[str]:
if not copy_statuses:
@@ -135,13 +172,19 @@ async def copy_dinsar_pairs_endpoint(
_validate_export_path(request.dest_dir, "dest_dir")
try:
copy_statuses = _normalize_copy_batch_statuses(request.copy_statuses)
package_mode = request.package_mode
if bool(request.export_zip) and package_mode == "task_folder":
package_mode = "task_zip"
params = {
"dest_dir": request.dest_dir,
"file_type": "DINSAR_PAIRS",
"batch_id": request.batch_id,
"copy_statuses": copy_statuses,
"include_orbit_files": bool(request.include_orbit_files),
"export_zip": bool(request.export_zip),
"export_zip": package_mode == "task_zip",
"package_mode": package_mode,
"skip_existing": bool(request.skip_existing),
"max_items": request.max_items,
}
task_id = await task_service.create_task("COPY_DATA", f"D-InSAR 数据分发: {request.dest_dir}", params=params)
@@ -151,7 +194,10 @@ async def copy_dinsar_pairs_endpoint(
"batch_id": request.batch_id,
"copy_statuses": copy_statuses,
"include_orbit_files": bool(request.include_orbit_files),
"export_zip": bool(request.export_zip),
"export_zip": package_mode == "task_zip",
"package_mode": package_mode,
"skip_existing": bool(request.skip_existing),
"max_items": request.max_items,
}
await job_queue_service.create_job("COPY_DATA", payload=payload, task_id=task_id)
await _add_operation_audit_log(
@@ -165,7 +211,10 @@ async def copy_dinsar_pairs_endpoint(
"dest_dir": request.dest_dir,
"copy_statuses": copy_statuses,
"include_orbit_files": bool(request.include_orbit_files),
"export_zip": bool(request.export_zip),
"export_zip": package_mode == "task_zip",
"package_mode": package_mode,
"skip_existing": bool(request.skip_existing),
"max_items": request.max_items,
},
)
await db.commit()
+33 -8
View File
@@ -50,7 +50,7 @@ from .timeseries_service import (
timeseries_service,
)
from .unpack_service import run_unpack_task
from ..copier import run_ps_copy_items, run_dinsar_copy_items
from ..copier import run_ps_copy_items, run_dinsar_copy_items, run_dinsar_source_bundle_items
from ..ai_service import (
train_quality_model,
predict_quality,
@@ -116,6 +116,16 @@ def _normalize_copy_statuses(raw_statuses: Any) -> List[str]:
return normalized or ["COMPLETED"]
def _normalize_positive_int(value: Any) -> Optional[int]:
if value in (None, ""):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _dedupe_existing_dirs(paths: Any) -> List[str]:
ordered: List[str] = []
for raw_path in paths or []:
@@ -289,6 +299,9 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
copy_statuses = _normalize_copy_statuses(payload.get("copy_statuses"))
include_orbit_files = bool(payload.get("include_orbit_files"))
export_zip = bool(payload.get("export_zip"))
package_mode = str(payload.get("package_mode") or ("task_zip" if export_zip else "task_folder")).strip().lower()
skip_existing = payload.get("skip_existing") is not False
max_items = _normalize_positive_int(payload.get("max_items"))
if not batch_id:
raise ValueError("COPY_DATA requires batch_id payload.")
@@ -377,13 +390,25 @@ async def _handle_copy_data(job: SystemJobORM) -> None:
raise ValueError(
f"No D-InSAR items matched copy statuses: {', '.join(copy_statuses)}"
)
await run_dinsar_copy_items(
job.task_id,
items,
dest_dir,
include_orbit_files=include_orbit_files,
export_zip=export_zip,
)
if package_mode in {"source_bundle", "bundle", "dedupe_source"}:
await run_dinsar_source_bundle_items(
job.task_id,
items,
dest_dir,
include_orbit_files=include_orbit_files,
skip_existing=skip_existing,
max_items=max_items,
)
else:
await run_dinsar_copy_items(
job.task_id,
items,
dest_dir,
include_orbit_files=include_orbit_files,
export_zip=(package_mode == "task_zip" or export_zip),
skip_existing=skip_existing,
max_items=max_items,
)
return
raise ValueError(f"Unknown COPY_DATA file_type: {file_type}")
@@ -279,7 +279,10 @@ score =
- `dest_dir`
- `copy_statuses`,为空时默认 `["COMPLETED"]`
- `include_orbit_files`,默认 `false`;为 `true` 时把 master/slave 精轨复制到 Task 内的 `orbit/`
- `export_zip`默认 `false`;为 `true` 时每个 Task 输出为一个 `.zip`
- `package_mode`支持 `task_folder``task_zip``source_bundle`
- `export_zip`,兼容旧参数;为 `true``package_mode=task_folder` 时等价于 `task_zip`
- `skip_existing`,默认 `true`
- `max_items`,每次最多处理的新 Task 或新 pair 数量;为空或 0 表示不限制
后端动作:
@@ -288,7 +291,9 @@ score =
3. 创建 `SystemJob`job_type 也是 `COPY_DATA`
4. worker 领取 job 后进入 `job_handlers._handle_copy_data()`
5. `_handle_copy_data()` 根据 `batch_id` 查询 `dinsar_task_items`,只取 `copy_statuses` 命中的条目。
6. 调用 [backend/app/copier.py](../backend/app/copier.py) 的 `run_dinsar_copy_items()`
6. 根据 `package_mode` 调用 [backend/app/copier.py](../backend/app/copier.py) 的 `run_dinsar_copy_items()``run_dinsar_source_bundle_items()`
### 9.1 Task 文件夹 / ZIP 模式
`run_dinsar_copy_items()` 对每个 item 执行:
@@ -298,8 +303,10 @@ score =
- slave 目录:`<task_alias>/slave`
- 如果启用 `include_orbit_files`,从 `radar_data.orbit_file_path` 找 master/slave 精轨并复制到 `<task_alias>/orbit/`
- 直接复制配对时保存的原始产品目录;D-InSAR 分发不再优先使用 `envi_import/`
- 使用 `shutil.copytree(..., dirs_exist_ok=True)` 复制 master/slave
- 先复制到临时目录,完成后再替换为最终 Task 目录或 ZIP,避免留下半成品
- 写入 `<task_alias>/.dinsar_pair.json`
- `skip_existing=true` 时,文件夹模式检查 `<task_alias>/master``<task_alias>/slave` 非空即跳过;ZIP 模式只检查 `<task_alias>.zip` 存在且大小大于 0,不打开 ZIP 做深度校验
- `max_items` 限制本次新复制数量;已跳过的既有 Task 不消耗本次额度
`.dinsar_pair.json` 是后续生产追踪的关键 sidecar,包含:
@@ -319,7 +326,32 @@ score =
- `selection_strategy`
- `copied_at`
当前实现不会清空已有 Task 目录,而是合并复制;如果目标已有旧文件,需要人工确认目录状态
当前实现不再合并写入已有 Task 目录。目标 Task 已完整存在时跳过;目标同名目录存在但不完整时,为避免误覆盖,会报错并要求人工处理
### 9.2 去重源数据包模式
`package_mode=source_bundle` 时,分发不生成每个 `Task_*`,而是在同一个目标目录内维护:
```text
<dest_dir>/
data/
orbit/
pairs.json
manifest.json
```
规则:
- `data/` 只复制唯一源影像目录或文件,命名为 `scene_<source_path_hash>_<source_name>`
- `orbit/` 只复制唯一精密轨道文件,命名为 `orbit_<source_path_hash>_<orbit_name>`
- `pairs.json` 记录每个 pair 的 master/slave 数据相对路径、轨道相对路径、pair 元数据和 `identity_key`
- `manifest.json` 记录 package 统计信息、场景清单、轨道清单和本次追加统计。
- 同一目标目录再次分发时,系统先读取已有 `pairs.json/manifest.json`,根据 `identity_key``scene_pair_uid/pair_uid``pair_key``network_run_id + network_edge_id`、源路径或 bundle 相对路径识别已导出的 pair。
- `max_items` 在跳过已导出 pair 后生效。因此 500 个 pair 第一次限制 100,第二次同一目录限制 100,会追加下一批未导出的 100 个 pair。
- `data/` / `orbit/` 仍按文件存在性跳过重复复制;pair 级续跑以 `pairs.json` 为准。
- `pairs.json``manifest.json` 写入时先写临时文件,再原子替换。
这个模式面向外部分发和后续离线还原,不直接作为本系统生产输入。反向还原工具任务书见 [DINSAR_SOURCE_BUNDLE_REVERSE_TOOL_TASK_20260511.md](DINSAR_SOURCE_BUNDLE_REVERSE_TOOL_TASK_20260511.md)。
## 10. 生产提交与运行分发
@@ -0,0 +1,168 @@
# D-InSAR 去重源数据包反向还原工具任务书
日期:2026-05-11
## 背景
本系统新增“去重源数据包”分发模式。该模式不直接生成每个干涉对的 `Task_*` 目录,而是只分发唯一源影像、唯一精密轨道文件和配对关系文件,减少外部分发时的重复复制量。
反向还原工具由任务接收方本地运行,将去重源数据包还原为传统 D-InSAR `Task_*` 目录结构。
## 输入目录结构
```text
BundleRoot/
data/
scene_<hash>_<source_name>/
...
orbit/
orbit_<hash>_<orbit_name>.txt
...
pairs.json
manifest.json
```
`orbit/` 可能不存在,或 `pairs.json` 内某些配对的轨道字段为空。
## 输出目录结构
```text
OutputRoot/
Task_YYYYMMDD_YYYYMMDD/
master/
<master source product content>
slave/
<slave source product content>
orbit/
<master/slave orbit files, if present>
.dinsar_pair.json
```
输出目录名称优先使用 `pairs.json` 内的 `task_alias`,若为空则使用 `task_name`,再为空则使用 `pair_id`
## pairs.json 关键字段
```json
{
"schema": "dinsar_source_bundle_pairs.v1",
"exported_at": "2026-05-11T00:00:00Z",
"pairs": [
{
"pair_id": "pair_0001",
"identity_key": "uid:<scene_pair_uid>",
"task_name": "Task_20250101_20250113",
"task_alias": "Task_20250101_20250113",
"master_source_path": "D:/Source/master",
"slave_source_path": "D:/Source/slave",
"master_scene_id": "scene_0001",
"slave_scene_id": "scene_0002",
"master_data": "data/scene_xxx_master",
"slave_data": "data/scene_yyy_slave",
"master_orbit_id": "orbit_0001",
"slave_orbit_id": "orbit_0002",
"master_orbit_source_path": "D:/Orbit/master.EOF",
"slave_orbit_source_path": "D:/Orbit/slave.EOF",
"master_orbit": "orbit/orbit_xxx.txt",
"slave_orbit": "orbit/orbit_yyy.txt",
"master_imaging_date": "20250101",
"slave_imaging_date": "20250113",
"time_baseline_days": 12
}
]
}
```
## 本系统分发续跑规则
去重源数据包支持向同一个 `BundleRoot` 多次分发:
- 每次启动时先读取目标目录内已有的 `pairs.json``manifest.json`
- 已导出的 pair 通过 `identity_key``scene_pair_uid/pair_uid``pair_key``network_run_id + network_edge_id``master/slave_source_path``master_data + slave_data` 识别。
- 开启“每次最多追加新配对”时,系统会先跳过已导出的 pair,再从剩余 pair 中取下一批追加;例如 500 个 pair 第一次限制 100,第二次同一目录仍限制 100 时,会追加第 101-200 个未导出的 pair。
- `data/``orbit/` 按源路径哈希命名,已有文件或目录在 `skip_existing` 开启时不会重复复制。
- `pairs.json``manifest.json` 采用临时文件写入后原子替换,避免中途失败留下半写 JSON。
注意:如果用户手动删除了 `pairs.json`,系统无法再根据记录判断哪些 pair 已经分发,只能根据重新生成的 `data/` 路径做源数据级去重,pair 级续跑能力会丢失。
## 还原规则
1. 读取 `pairs.json`
2. 对每个 pair 创建目标 `Task` 目录。
3.`master_data` 指向的数据复制到 `Task/master/`
4.`slave_data` 指向的数据复制到 `Task/slave/`
5.`master_orbit` / `slave_orbit` 存在,将轨道文件复制到 `Task/orbit/`
6. 生成 `.dinsar_pair.json`,至少保留:
- `pair_id`
- `identity_key`
- `task_name`
- `task_alias`
- `master_scene_id`
- `slave_scene_id`
- `master_data`
- `slave_data`
- `master_orbit_id`
- `slave_orbit_id`
- `master_orbit`
- `slave_orbit`
- `master_imaging_date`
- `slave_imaging_date`
- `time_baseline_days`
- `restored_at`
7. 每个 Task 应采用临时目录还原,全部成功后再重命名为最终目录,避免半成品。
## 覆盖策略
工具应提供参数:
- `--skip-existing`:默认开启。若目标 `Task/master``Task/slave` 均存在且非空,则跳过。
- `--overwrite`:删除并重建已存在的目标 Task。
- `--limit N`:最多还原 N 个 pair,便于分批执行。
- `--dry-run`:只打印计划,不复制。
`--skip-existing``--overwrite` 同时出现时应报错。
## 校验要求
启动前:
- 检查 `pairs.json` 是否存在且可解析。
- 检查 `data/` 是否存在。
- 检查每个 pair 的 `master_data` / `slave_data` 是否存在。
- 轨道缺失不应阻断还原,但要记录 warning。
还原后:
- `Task/master/` 非空。
- `Task/slave/` 非空。
- `.dinsar_pair.json` 存在。
## 日志与报告
工具结束后输出 `restore_report.json`
```json
{
"started_at": "...",
"finished_at": "...",
"input_root": "...",
"output_root": "...",
"total_pairs": 20,
"restored": 18,
"skipped": 2,
"failed": 0,
"warnings": []
}
```
同时建议输出人类可读日志 `restore.log`
## 建议实现
建议使用 Python 3.10+
- `argparse` 处理命令行参数。
- `pathlib.Path` 处理路径。
- `shutil.copytree(..., dirs_exist_ok=True)` / `shutil.copy2()` 处理复制。
- Windows 下注意长路径和权限异常。
该工具不需要连接本系统数据库,也不需要调用本系统 API。
+2
View File
@@ -68,6 +68,8 @@
Sentinel-1 D-InSAR 在不使用 ENVI + SARscape 核心时,基于 Gamma/PyINT 与 ISCE2 的可行性、接入边界和推荐实施顺序。
- [DINSAR_PAIRING_DISTRIBUTION_LOGIC_20260508.md](DINSAR_PAIRING_DISTRIBUTION_LOGIC_20260508.md)
2026-05-08 源码走读记录,梳理 D-InSAR 配对缓存、策略筛选、批次保存、数据分发和生产 worker 执行链路。
- [DINSAR_SOURCE_BUNDLE_REVERSE_TOOL_TASK_20260511.md](DINSAR_SOURCE_BUNDLE_REVERSE_TOOL_TASK_20260511.md)
D-InSAR 去重源数据包的目录协议、续分发规则和外部反向还原工具任务书。
- [PAIRING_ENHANCEMENT_DESIGN.md](PAIRING_ENHANCEMENT_DESIGN.md)
- [FRONTEND_NAVIGATION_ARCHITECTURE.md](FRONTEND_NAVIGATION_ARCHITECTURE.md)
+68 -10
View File
@@ -16,8 +16,10 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
const [activeTab, setActiveTab] = useState('dinsar');
const [destDir, setDestDir] = useState('');
const [copyStatuses, setCopyStatuses] = useState(['COMPLETED']);
const [includeDinsarOrbitFiles, setIncludeDinsarOrbitFiles] = useState(false);
const [dinsarExportZip, setDinsarExportZip] = useState(false);
const [includeDinsarOrbitFiles, setIncludeDinsarOrbitFiles] = useState(true);
const [dinsarPackageMode, setDinsarPackageMode] = useState('task_folder');
const [skipExistingDinsarTasks, setSkipExistingDinsarTasks] = useState(true);
const [dinsarMaxItems, setDinsarMaxItems] = useState('200');
const [batches, setBatches] = useState([]);
const [selectedBatchId, setSelectedBatchId] = useState('');
const [isUploading, setIsUploading] = useState(false);
@@ -84,7 +86,10 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
const fetchLogs = async () => {
if (!taskId) return;
try {
const response = await axios.get(`${apiEndpoint}/tools/copy-status/${taskId}`, { withCredentials: true });
const response = await axios.get(`${apiEndpoint}/tools/copy-status/${taskId}`, {
withCredentials: true,
params: { limit: 1000 },
});
setLogs(response.data.logs);
const nextStatus = normalizeStatus(response.data.status);
if (nextStatus && nextStatus !== 'UNKNOWN') {
@@ -126,13 +131,19 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
};
if (activeTab === 'dinsar') {
payload.include_orbit_files = includeDinsarOrbitFiles;
payload.export_zip = dinsarExportZip;
payload.package_mode = dinsarPackageMode;
payload.export_zip = dinsarPackageMode === 'task_zip';
payload.skip_existing = skipExistingDinsarTasks;
const parsedMaxItems = Number.parseInt(dinsarMaxItems, 10);
if (Number.isFinite(parsedMaxItems) && parsedMaxItems > 0) {
payload.max_items = parsedMaxItems;
}
}
const response = await axios.post(endpoint, payload, { withCredentials: true });
const taskId = response.data.task_id;
setTaskId(taskId);
// 触发全局锁定
// 通知全局任务状态;COPY_DATA 在全局控制里按非阻塞处理。
if (onJobQueued) {
onJobQueued(taskId);
}
@@ -196,6 +207,41 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
}}
>
<label>D-InSAR 分发设置</label>
<div style={{ display: 'grid', gap: '6px', marginTop: '8px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="dinsar-package-mode"
value="task_folder"
checked={dinsarPackageMode === 'task_folder'}
onChange={(event) => setDinsarPackageMode(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>生产 Task 文件夹</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="dinsar-package-mode"
value="task_zip"
checked={dinsarPackageMode === 'task_zip'}
onChange={(event) => setDinsarPackageMode(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>生产 Task ZIP</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="radio"
name="dinsar-package-mode"
value="source_bundle"
checked={dinsarPackageMode === 'source_bundle'}
onChange={(event) => setDinsarPackageMode(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>去重源数据包data / orbit / pairs.json</span>
</label>
</div>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
@@ -204,20 +250,32 @@ const DataCopierPanel = ({ apiEndpoint, readOnly = false, onJobQueued }) => {
onChange={(event) => setIncludeDinsarOrbitFiles(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>复制精密轨道到 Task/orbit</span>
<span>{dinsarPackageMode === 'source_bundle' ? '复制精密轨道到 orbit/' : '复制精密轨道到 Task/orbit'}</span>
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={dinsarExportZip}
onChange={(event) => setDinsarExportZip(event.target.checked)}
checked={skipExistingDinsarTasks}
onChange={(event) => setSkipExistingDinsarTasks(event.target.checked)}
disabled={status === 'RUNNING' || readOnly}
/>
<span>导出为 ZIP 压缩包</span>
<span>{dinsarPackageMode === 'source_bundle' ? '复用已存在的 data/orbit' : '跳过目标目录中已存在的 Task'}</span>
</label>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginTop: '8px' }}>
<label style={{ fontSize: '13px' }}>{dinsarPackageMode === 'source_bundle' ? '每次最多追加新配对:' : '每次最多分发新 Task:'}</label>
<input
type="number"
min="0"
value={dinsarMaxItems}
onChange={(event) => setDinsarMaxItems(event.target.value)}
disabled={status === 'RUNNING' || readOnly}
style={{ width: '110px', padding: '5px 7px' }}
/>
<span style={{ fontSize: '12px', color: '#64748b' }}>0 或留空表示不限制</span>
</div>
<div style={{ fontSize: '12px', color: '#475569', marginTop: '6px' }}>
未勾选 ZIP 时直接导出 Task 文件夹勾选后每个 Task 输出一个 .zip
去重源数据包只复制唯一影像和精轨并写出 pairs.json再次分发到同一目录时会接着追加未导出的配对
</div>
</div>
)}
+5 -1
View File
@@ -262,7 +262,11 @@ export default function AppSidePanel({
<LazyDataCopierPanel
apiEndpoint={apiEndpoint}
readOnly={isReadOnlyUser}
onJobQueued={(taskId) => taskPanel.onTaskStart(taskId, '数据分发任务已入队,正在处理...')}
onJobQueued={(taskId) => taskPanel.onTaskStart(
taskId,
'数据分发任务已入队,正在处理...',
{ taskType: 'COPY_DATA', nonBlocking: true },
)}
/>
</Suspense>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@ import { normalizePagePayload } from '../utils/appHelpers';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
import { DEFAULT_LIST_PAGE_SIZE } from '../config/appConstants';
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES']);
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES', 'COPY_DATA']);
export default function useDinsarOperations({
onCleanupDinsarLayers,
+1 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES']);
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES', 'COPY_DATA']);
const isTaskNonBlocking = (taskId, taskType, nonBlockingTaskIds = []) => (
NON_BLOCKING_TASK_TYPES.has(String(taskType || '').toUpperCase())
+2
View File
@@ -18,6 +18,8 @@ http {
sendfile on;
keepalive_timeout 65;
# Large D-InSAR batch saves can exceed nginx's default 1m request body limit.
client_max_body_size 32m;
server {
listen 80;