Files
insar-management-system-v2/frontend/src/hooks/usePairingLogic.js
T

470 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* usePairingLogic - pairing and time-series stack business logic extracted from App.jsx.
*/
import apiClient from '../api/client';
import { getPairingHealth } from '../api/pairing';
import {
useUiStore, usePairingStore, useMapStore, useBatchStore, useAuthStore,
} from '../store';
import { getSelectedRegionTreeId } from '../utils/appUiHelpers';
const compactDinsarBatchScene = (scene = {}) => ({
file_path: scene.file_path || '',
satellite: scene.satellite || null,
satellite_family: scene.satellite_family || null,
imaging_date: scene.imaging_date || null,
imaging_mode: scene.imaging_mode || null,
polarization: scene.polarization || null,
orbit_direction: scene.orbit_direction || null,
relative_orbit: scene.relative_orbit || null,
absolute_orbit: scene.absolute_orbit || null,
has_orbit_data: scene.has_orbit_data ?? null,
orbit_file_path: scene.orbit_file_path || null,
});
const compactDinsarBatchPair = (pair = {}) => ({
task_name: pair.task_name || null,
task_alias: pair.task_alias || pair.task_name || null,
pair_key: pair.pair_key || null,
pair_uid: pair.pair_uid || null,
network_run_id: pair.network_run_id || null,
network_edge_id: pair.network_edge_id ?? null,
policy_version: pair.policy_version || null,
selection_strategy: pair.selection_strategy || null,
time_baseline_days: pair.time_baseline_days ?? null,
spatial_baseline_meters: pair.spatial_baseline_meters ?? null,
scene_center_distance_meters: pair.scene_center_distance_meters ?? pair.spatial_baseline_meters ?? null,
dinsar_quality_tier: pair.dinsar_quality_tier || null,
dinsar_quality_score: pair.dinsar_quality_score ?? null,
dinsar_readiness: pair.dinsar_readiness || null,
master: compactDinsarBatchScene(pair.master),
slave: compactDinsarBatchScene(pair.slave),
});
const normalizePairingFamilies = (values) => {
if (!Array.isArray(values)) return null;
const normalized = [];
values.forEach((value) => {
const compact = String(value || '').trim().toUpperCase().replace(/[-_\s]/g, '');
if (['LT1', 'LT1A', 'LT1B', 'LUTAN1', 'LUTAN1A', 'LUTAN1B'].includes(compact)) {
normalized.push('LT1');
} else if (['S1', 'S1A', 'S1B', 'S1C', 'SENTINEL1', 'SENTINEL1A', 'SENTINEL1B', 'SENTINEL1C'].includes(compact)) {
normalized.push('S1');
}
});
return [...new Set(normalized)];
};
const readPairingNumber = (params, key, label, { integer = false, min = -Infinity, max = Infinity, defaultValue = null } = {}) => {
const rawValue = params[key];
const text = String(rawValue ?? '').trim();
const parsed = text === '' && defaultValue !== null ? defaultValue : Number(text);
if (!Number.isFinite(parsed) || (integer && !Number.isInteger(parsed))) {
return { error: `${label}必须是${integer ? '整数' : '数字'}。` };
}
if (parsed < min || parsed > max) {
return { error: `${label}必须在 ${min}${max} 之间。` };
}
return { value: parsed };
};
const normalizePairingParamsForRequest = (params = {}) => {
const timeMin = readPairingNumber(params, 'time_baseline_min', '最小时间基线', {
integer: true,
min: 0,
max: 3650,
defaultValue: 1,
});
if (timeMin.error) return timeMin;
const timeMax = readPairingNumber(params, 'time_baseline_max', '最大时间基线', {
integer: true,
min: 1,
max: 3650,
defaultValue: 30,
});
if (timeMax.error) return timeMax;
if (timeMin.value > timeMax.value) {
return { error: '最小时间基线不能大于最大时间基线。' };
}
const overlap = readPairingNumber(params, 'overlap_threshold', '两景最小重叠率', {
min: 0,
max: 1,
defaultValue: 0.5,
});
if (overlap.error) return overlap;
const centerDistance = readPairingNumber(params, 'spatial_baseline_max_meters', 'footprint 中心距离上限', {
integer: true,
min: 0,
max: 20000000,
defaultValue: 5000,
});
if (centerDistance.error) return centerDistance;
const aoiOverlap = readPairingNumber(params, 'aoi_overlap_threshold', 'AOI 覆盖率阈值', {
min: 0,
max: 1,
defaultValue: 0,
});
if (aoiOverlap.error) return aoiOverlap;
return {
value: {
...params,
time_baseline_min: timeMin.value,
time_baseline_max: timeMax.value,
overlap_threshold: overlap.value,
spatial_baseline_max_meters: centerDistance.value,
aoi_overlap_threshold: aoiOverlap.value,
},
};
};
export default function usePairingLogic({
fetchRegionGeometry,
refreshBatchList,
fetchBatchItems,
onClearAoiLayer,
}) {
const { addLog, setIsLoading, setLeftPanelTab } = useUiStore();
const {
pairingParams, pairingAoiMode,
pairingFiles, setPairingFiles,
pairingRegionSelection,
setPairingRegionError,
setShowPairingModal, setFoundPairs, setPairingAlert,
psAoiMode, psFiles, setPsFiles, psRegionSelection,
psParams, setPsResults,
} = usePairingStore();
const { setAoiLayer } = useMapStore();
const { setBatchTab, setSelectedBatchId, setBatchItems, setPendingTimeseriesBatchId } = useBatchStore();
const { currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin';
const ensureCanOperate = () => {
if (!isAdmin) {
addLog('warn', '当前账号为只读用户,无法执行写操作。');
return false;
}
return true;
};
const focusBatchAfterCreate = async (type, batchId) => {
if (!batchId) return;
setBatchTab(type);
setLeftPanelTab('batches');
setSelectedBatchId(batchId);
setBatchItems([]);
await refreshBatchList();
await fetchBatchItems(type, batchId);
};
const createPsBatch = async (direction, stack, options = {}) => {
if (!ensureCanOperate()) return;
const { focusAfterCreate = true, sendToProduction = false } = options;
if (!Array.isArray(stack) || stack.length < 3) {
addLog('warn', `当前候选栈仅 ${Array.isArray(stack) ? stack.length : 0} 景,SBAS 至少需要 3 景。`);
return;
}
const firstScene = stack[0] || {};
const planId = firstScene.stack_plan_id || null;
const batchDirection = firstScene.orbit_direction || direction;
const planningContext = {
source: planId ? 'timeseries_stack_plan' : 'find_ps_timeseries',
plan_id: planId,
strategy: 'sbas_stack',
pool_role: 'candidate_timeseries_pool',
production_contract: 'prepare_run_will_freeze_prepared_sbas_stack',
direction: batchDirection,
display_group: direction,
scene_count: stack.length,
group_key: firstScene.stack_group_key || null,
stack_key: firstScene.stack_key || null,
initial_overlap_threshold: psParams?.initial_overlap_threshold ?? null,
final_overlap_threshold: psParams?.final_overlap_threshold ?? null,
network_edge_count: firstScene.stack_network_edge_count ?? null,
network_warnings: firstScene.stack_network_warnings ?? [],
stack_dates: stack.map(item => item.imaging_date).filter(Boolean),
};
try {
const response = await apiClient.post('/task-batches/ps', {
direction: batchDirection,
plan_id: planId,
stack,
name: `TS_${batchDirection}_${new Date().toISOString().slice(0, 10)}`,
planning_context: planningContext,
});
const batchId = response.data?.batch_id || '';
if (planId && batchId) {
addLog('info', `时序批次已关联候选栈计划 ${planId}`);
}
addLog('success', `已创建时序批次: ${batchId || batchDirection}`);
addLog('info', '当前批次是候选时序池;正式生产会先执行 prepare,冻结 prepared SBAS 小栈后再进入处理器。');
if (batchId && sendToProduction) {
setBatchTab('ps');
setSelectedBatchId(batchId);
setPendingTimeseriesBatchId(batchId);
setLeftPanelTab('ps_production');
addLog('info', `已将批次 ${batchId} 送入时序生产入口。`);
return;
}
if (focusAfterCreate && batchId) {
await focusBatchAfterCreate('ps', batchId);
}
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `时序批次创建失败: ${errorMessage}`);
}
};
const createDinsarBatch = async () => {
if (!ensureCanOperate()) return;
const foundPairs = usePairingStore.getState().foundPairs;
const selectedPairs = foundPairs.filter(p => p.isSelected);
if (selectedPairs.length === 0) {
addLog('warn', '没有选中的配对可保存。');
return;
}
try {
const batchPairs = selectedPairs.map(compactDinsarBatchPair);
const response = await apiClient.post('/task-batches/dinsar', {
name: `DINSAR_${new Date().toISOString().slice(0, 10)}`,
pairs: batchPairs,
});
const batchId = response.data?.batch_id || '';
addLog('success', `已创建 D-InSAR 批次: ${batchId || 'OK'}`);
if (batchId) {
await focusBatchAfterCreate('dinsar', batchId);
}
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `D-InSAR 批次创建失败: ${errorMessage}`);
}
};
const clearPsResults = () => {
setPsResults(null);
onClearAoiLayer();
setAoiLayer(null);
addLog('info', '时序 InSAR 候选栈结果已清空。');
};
const findPairs = async (e, externalRequireOrbitRef, overridePairingParams = null) => {
e?.preventDefault?.();
if (!ensureCanOperate()) return;
setIsLoading(true);
addLog('info', '开始查找 D-InSAR 生产配对...');
setPairingAlert({ warnings: [], fallbackUsed: false });
const formData = new FormData();
const sourcePairingParams = overridePairingParams || pairingParams;
const normalizedParams = normalizePairingParamsForRequest(sourcePairingParams);
if (normalizedParams.error) {
addLog('error', normalizedParams.error);
setIsLoading(false);
return;
}
const effectivePairingParams = {
...normalizedParams.value,
strategy: 'dinsar_production',
limit_footprint_center_distance: true,
cross_satellite_pairing: false,
};
effectivePairingParams.allowed_satellites = normalizePairingFamilies(sourcePairingParams.allowed_satellites);
try {
const pairingHealth = await getPairingHealth();
if (pairingHealth?.needs_rebuild || pairingHealth?.status !== 'READY') {
addLog(
'warn',
`配对基础当前状态为 ${pairingHealth?.status || 'UNKNOWN'}dirty 场景 ${Number(pairingHealth?.dirty_scene_count || 0)}。请先在生产规划页执行“修复配对基础”。`
);
setIsLoading(false);
return;
}
} catch (error) {
addLog('warn', `配对基础状态检查失败: ${error.response?.data?.detail || error.message}`);
setIsLoading(false);
return;
}
for (const key in effectivePairingParams) {
const value = effectivePairingParams[key];
if (value === null || value === undefined) continue;
if (typeof value === 'string' && value.trim() === '') continue;
if (key === 'allowed_satellites' && Array.isArray(value)) {
if (value.length === 0) continue;
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value);
}
}
if (externalRequireOrbitRef?.current) {
formData.append('require_orbit_data', externalRequireOrbitRef.current.checked);
}
if (pairingAoiMode === 'shp') {
if (pairingFiles) {
Array.from(pairingFiles).forEach(file => {
formData.append('files', file);
});
}
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(pairingRegionSelection);
if (selectedRegionTreeId) {
try {
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
addLog('error', '未获取到行政区边界,请检查后端行政区边界数据。');
setIsLoading(false);
return;
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeoJson));
setAoiLayer(selectedAoiGeoJson);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '行政区边界加载失败';
addLog('error', `加载行政区边界失败: ${errorMessage}`);
setIsLoading(false);
return;
}
}
}
try {
const response = await apiClient.post('/find-pairs', formData);
const { pairs, aoi_geojson } = response.data;
const warnings = Array.isArray(response.data?.warnings) ? response.data.warnings : [];
const fallbackUsed = Boolean(response.data?.fallback_used ?? response.data?.fallbackUsed);
const degraded = Boolean(response.data?.degraded);
const networkRunId = response.data?.network_run_id || response.data?.networkRunId;
const policyVersion = response.data?.policy_version || response.data?.policyVersion;
const candidateCount = Number(response.data?.candidate_count ?? response.data?.candidateCount ?? pairs.length ?? 0);
const selectedEdgeCount = Number(response.data?.selected_edge_count ?? response.data?.selectedEdgeCount ?? pairs.length ?? 0);
setFoundPairs(Array.isArray(pairs) ? pairs.map(p => ({ ...p, isSelected: true, isVis: false })) : []);
setPairingAlert({ warnings, fallbackUsed });
if (!Array.isArray(pairs) || pairs.length === 0) {
const emptyMessage = `当前参数下没有满足条件的 D-InSAR 配对。候选 ${candidateCount},入选 ${selectedEdgeCount}。`;
const detailText = warnings.length > 0
? warnings.join('\n')
: '可以放宽时间基线、footprint 中心距离、重叠率、AOI,或检查配对缓存/精轨状态。';
addLog('warn', emptyMessage);
warnings.forEach(msg => addLog('warn', msg));
setPairingRegionError(`${emptyMessage}\n${detailText}`);
return;
}
if (aoi_geojson) {
setAoiLayer(aoi_geojson);
}
warnings.forEach(msg => addLog('warn', msg));
if (fallbackUsed && warnings.length === 0) {
addLog('warn', '配对进入回退路径,请检查数据库函数或收紧筛选条件。');
}
if (networkRunId) {
addLog('info', `配对网络已生成 ${networkRunId} (${policyVersion || 'unknown policy'})`);
}
if (degraded) {
addLog('warn', '当前配对结果来自降级缓存状态,建议尽快执行缓存修复。');
}
addLog('success', `成功找到 ${pairs.length} 个 D-InSAR 生产配对(候选 ${candidateCount},入选 ${selectedEdgeCount})。`);
setShowPairingModal(false);
setPairingFiles(null);
setLeftPanelTab('pairs');
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message;
addLog('error', `查找 D-InSAR 配对失败: ${errorMessage}`);
} finally {
setIsLoading(false);
}
};
const handleFindPsStack = async (e) => {
e.preventDefault();
if (!ensureCanOperate()) return;
if (psAoiMode === 'shp') {
if (!psFiles || psFiles.length === 0) {
addLog('warn', '请先选择有效的 Shapefile 文件。');
return;
}
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(psRegionSelection);
if (!selectedRegionTreeId) {
addLog('warn', '请先选择行政区。');
return;
}
}
setIsLoading(true);
addLog('info', '开始准备时序 InSAR 候选栈...');
const formData = new FormData();
for (const key in psParams) {
formData.append(key, psParams[key]);
}
if (psAoiMode === 'shp') {
Array.from(psFiles).forEach(file => {
formData.append('files', file);
});
} else {
const selectedRegionTreeId = getSelectedRegionTreeId(psRegionSelection);
try {
const selectedAoiGeoJson = await fetchRegionGeometry(selectedRegionTreeId);
if (!selectedAoiGeoJson) {
addLog('error', '未获取到行政区边界,请检查后端行政区边界数据。');
setIsLoading(false);
return;
}
formData.append('aoi_geojson', JSON.stringify(selectedAoiGeoJson));
setAoiLayer(selectedAoiGeoJson);
} catch (error) {
const errorMessage = error.response?.data?.detail || error.message || '行政区边界加载失败';
addLog('error', `加载行政区边界失败: ${errorMessage}`);
setIsLoading(false);
return;
}
}
try {
const response = await apiClient.post('/find-ps-timeseries', formData);
const results = response.data;
const processedResults = {};
for (const [direction, stack] of Object.entries(results)) {
const nameCounts = {};
processedResults[direction] = stack.map(item => {
const baseName = `${item.satellite}_${item.imaging_mode}_${item.imaging_date}`;
nameCounts[baseName] = (nameCounts[baseName] || 0) + 1;
const count = nameCounts[baseName];
const displayName = count > 1 ? `${baseName}_${count - 1}` : baseName;
return { ...item, displayName };
});
}
setPsResults(processedResults);
if (Object.keys(processedResults).length > 0) {
addLog('success', `成功找到 ${Object.keys(processedResults).length} 个时序 InSAR 候选栈。`);
setLeftPanelTab('ps_results');
addLog('info', '候选栈仅作为预览结果保留;需要生产时请手动保存批次或送入生产。');
} else {
addLog('info', '在给定的 AOI 和阈值下,未找到满足 SBAS 至少 3 景要求的时序影像栈。');
setLeftPanelTab('ps_results');
}
} catch (error) {
console.error('时序 InSAR 候选栈准备失败:', error);
const errorMessage = error.response?.data?.detail || error.message || '未知错误';
addLog('error', `时序 InSAR 候选栈准备失败: ${errorMessage}`);
} finally {
setIsLoading(false);
setPsFiles(null);
}
};
return {
findPairs,
handleFindPsStack,
createDinsarBatch,
createPsBatch,
clearPsResults,
};
}