2026-06-03 16:31:45 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
PipelineRunner:基于 StepSpec 声明式调度 14 个 step。
|
|
|
|
|
|
|
|
|
|
|
|
设计要点:
|
|
|
|
|
|
- StepSpec 声明 requires(ctx 字段名列表)+ produces(ctx 字段名列表)
|
2026-06-03 17:29:41 +08:00
|
|
|
|
- 命名约定:ctx 字段名 == panel key 名 == step 形参名(全链路无翻译)
|
2026-06-09 09:07:59 +08:00
|
|
|
|
- 步骤命名:step_id 格式为 stepN 或 stepN_suffix(无小数位),method_name 与 step_id 对齐
|
2026-06-03 16:31:45 +08:00
|
|
|
|
- 调度顺序:按 PIPELINE_STEPS 列表顺序,requires 缺则 skip
|
|
|
|
|
|
- 软取消:在每个 step 前检查 ctx.is_cancelled()
|
2026-06-09 09:07:59 +08:00
|
|
|
|
- 断点续跑:spec.output_file 已落盘则跳过执行
|
|
|
|
|
|
- 错误汇总:全流程结束后 error_summary 记录所有 step 的异常
|
|
|
|
|
|
- 预检:run() 入口硬校验 step1 img_path;其余依赖通过智能补全 + 软警告处理
|
|
|
|
|
|
- PipelineHalt:外层 run() 不 catch,触发循环 break,实现硬终止
|
|
|
|
|
|
- STEP_MAP:旧 step_id → 新 step_id 双向映射,供 GUI 配置兼容使用
|
2026-06-03 16:31:45 +08:00
|
|
|
|
- duck-typed pipeline:runner 只调 getattr(pipeline, method_name),不强依赖类层级
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
2026-06-09 09:07:59 +08:00
|
|
|
|
import os
|
2026-06-03 16:31:45 +08:00
|
|
|
|
import time
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|
|
|
|
|
|
2026-06-09 09:07:59 +08:00
|
|
|
|
from .context import PipelineContext, STEP_MAP_OLD_TO_NEW, STEP_MAP_NEW_TO_OLD, resolve_step_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 终止异常(外层 run() 不 catch,触发循环 break)
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
class PipelineHalt(Exception):
|
|
|
|
|
|
"""不可恢复的错误,在 run() 循环中抛出后直接 break,不走 Exception 处理分支。
|
|
|
|
|
|
|
|
|
|
|
|
适用场景:
|
|
|
|
|
|
- GUI 层通过 _notify 弹窗拦截后主动抛出的硬终止信号
|
|
|
|
|
|
"""
|
|
|
|
|
|
pass
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
# StepSpec 声明式描述
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class StepSpec:
|
|
|
|
|
|
"""单个 step 的元信息(声明式,避免硬编码)"""
|
|
|
|
|
|
step_id: str
|
|
|
|
|
|
method_name: str
|
2026-06-09 09:07:59 +08:00
|
|
|
|
requires: List[str] # PipelineContext 字段名列表
|
|
|
|
|
|
produces: List[str] = field(default_factory=list) # 写入 ctx 的字段名列表
|
2026-06-03 16:31:45 +08:00
|
|
|
|
enabled: bool = True
|
|
|
|
|
|
parameter_map: Dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
# 当 requires 中任一字段为 None 时是否跳过;默认 True(缺输入就 skip)
|
|
|
|
|
|
skip_when_missing: bool = True
|
|
|
|
|
|
# 备注(仅用于文档生成 / 调试输出)
|
|
|
|
|
|
description: str = ""
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# ★ 断点续跑:产物文件路径,支持 {work_dir} 占位符(运行时解析)
|
|
|
|
|
|
output_file: Optional[str] = None
|
|
|
|
|
|
# ★ 预检用:需要验证磁盘文件实际存在的 ctx key 列表
|
|
|
|
|
|
required_input_files: List[str] = field(default_factory=list)
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 14 个 step 的声明表(顺序即调度顺序)
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# step_id / method_name 均不含小数位,与前端显示对齐
|
|
|
|
|
|
# output_file / required_input_files 使用 {work_dir} 占位符,由 _resolve_path 展开
|
2026-06-03 16:31:45 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
PIPELINE_STEPS: List[StepSpec] = [
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step1", method_name="step1_water_mask",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["img_path"], produces=["water_mask_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["img_path"],
|
|
|
|
|
|
output_file="{work_dir}/1_water_mask/water_mask.dat",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="水域掩膜生成(NDWI 或 SHP)",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step2", method_name="step2_glint_detection",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["img_path", "water_mask_path"], produces=["glint_mask_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["img_path", "water_mask_path"],
|
|
|
|
|
|
output_file="{work_dir}/2_glint/glint_mask.dat",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="耀斑区域检测",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step3", method_name="step3_deglint",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["img_path", "water_mask_path", "glint_mask_path"],
|
|
|
|
|
|
produces=["deglint_img_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["img_path", "water_mask_path", "glint_mask_path"],
|
|
|
|
|
|
output_file="{work_dir}/3_deglint/deglint.bsq",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="耀斑去除",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step4", method_name="step4_data_preparation",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["csv_path"], produces=["processed_csv_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["csv_path"],
|
|
|
|
|
|
output_file="{work_dir}/4_processed_data/processed_data.csv",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="CSV 异常值清洗",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step5", method_name="step5_spectral_extraction",
|
2026-06-04 10:38:33 +08:00
|
|
|
|
requires=["deglint_img_path", "processed_csv_path", "csv_path", "boundary_path", "glint_mask_path"],
|
2026-06-03 17:29:41 +08:00
|
|
|
|
produces=["training_csv_path"],
|
2026-06-04 10:38:33 +08:00
|
|
|
|
parameter_map={
|
|
|
|
|
|
"processed_csv_path": "csv_path",
|
|
|
|
|
|
"csv_path": "_raw_csv_ignored",
|
|
|
|
|
|
},
|
|
|
|
|
|
skip_when_missing=False,
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["deglint_img_path", "processed_csv_path", "boundary_path", "glint_mask_path"],
|
|
|
|
|
|
output_file="{work_dir}/5_training_spectra/training_spectra.csv",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="实测样本点光谱提取",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step8", method_name="step8_water_quality_indices",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["training_csv_path"], produces=["indices_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["training_csv_path"],
|
|
|
|
|
|
output_file="{work_dir}/6_water_quality_indices/water_quality_indices.csv",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="水质光谱指数计算(optional)",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step7", method_name="step7_ml_modeling",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["training_csv_path"], produces=["models_dir"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["training_csv_path"],
|
|
|
|
|
|
output_file="{work_dir}/7_Supervised_Model_Training/best_models.pkl",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="ML 建模(GridSearchCV / AutoML)",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step8_non_empirical_modeling",
|
|
|
|
|
|
method_name="step8_non_empirical_modeling",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["training_csv_path"], produces=["models_dir"],
|
2026-06-04 09:15:04 +08:00
|
|
|
|
parameter_map={"training_csv_path": "csv_path"},
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["training_csv_path"],
|
|
|
|
|
|
output_file="{work_dir}/8_Regression_Modeling/non_empirical_models.pkl",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="非经验统计回归",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step9", method_name="step9_custom_regression",
|
2026-06-04 09:15:04 +08:00
|
|
|
|
requires=["indices_path"], produces=["models_dir"],
|
|
|
|
|
|
parameter_map={"indices_path": "csv_path"},
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["indices_path"],
|
|
|
|
|
|
output_file="{work_dir}/9_Custom_Regression_Modeling/custom_regression_models.pkl",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="自定义回归分析",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step10", method_name="step10_sampling",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
requires=["deglint_img_path", "water_mask_path"], produces=["sampling_csv_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["deglint_img_path", "water_mask_path"],
|
|
|
|
|
|
output_file="{work_dir}/10_sampling/sampling_spectra.csv",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="整景密集采样点生成 + 光谱提取",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step11_ml", method_name="step11_ml_prediction",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
requires=["sampling_csv_path", "models_dir"], produces=["prediction_csv_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["sampling_csv_path", "models_dir"],
|
|
|
|
|
|
output_file="{work_dir}/11_12_13_predictions/prediction_results.csv",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="ML 模型预测(采样点)",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step11", method_name="step11_non_empirical_prediction",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["sampling_csv_path", "models_dir"], produces=["prediction_dir"],
|
2026-06-04 09:15:04 +08:00
|
|
|
|
parameter_map={"models_dir": "non_empirical_models_dir"},
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["sampling_csv_path", "models_dir"],
|
|
|
|
|
|
output_file="{work_dir}/11_12_13_predictions/non_empirical_predictions",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="非经验模型预测",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step12", method_name="step12_custom_regression_prediction",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["sampling_csv_path", "models_dir", "formula_csv_path"],
|
|
|
|
|
|
produces=["prediction_dir"],
|
2026-06-04 09:15:04 +08:00
|
|
|
|
parameter_map={"models_dir": "custom_regression_dir"},
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["sampling_csv_path", "models_dir", "formula_csv_path"],
|
|
|
|
|
|
output_file="{work_dir}/11_12_13_predictions/custom_regression_predictions",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="自定义回归预测",
|
|
|
|
|
|
),
|
|
|
|
|
|
StepSpec(
|
2026-06-09 09:07:59 +08:00
|
|
|
|
step_id="step14", method_name="step14_distribution_map",
|
2026-06-03 17:29:41 +08:00
|
|
|
|
requires=["prediction_csv_path", "boundary_shp_path"],
|
2026-06-03 16:31:45 +08:00
|
|
|
|
produces=["distribution_map_path"],
|
2026-06-09 09:07:59 +08:00
|
|
|
|
required_input_files=["prediction_csv_path", "boundary_shp_path"],
|
|
|
|
|
|
output_file="{work_dir}/distribution_map.png",
|
2026-06-03 16:31:45 +08:00
|
|
|
|
description="克里金插值成图",
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
# PipelineRunner:执行者
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
class PipelineRunner:
|
2026-06-09 09:07:59 +08:00
|
|
|
|
"""按 StepSpec 调度 14 个 step 方法,支持软取消 + 断点续跑 + 错误汇总。
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
|
|
|
|
|
用法:
|
2026-06-09 09:07:59 +08:00
|
|
|
|
ctx = PipelineContext(img_path=..., work_dir=..., user_config=config)
|
2026-06-03 16:31:45 +08:00
|
|
|
|
runner = PipelineRunner(pipeline_instance)
|
2026-06-09 09:07:59 +08:00
|
|
|
|
result_ctx = runner.run(ctx) # 预检通过后开始执行
|
|
|
|
|
|
print(result_ctx.error_summary) # [(step_id, error_msg), ...]
|
2026-06-03 16:31:45 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, pipeline, steps: Optional[Sequence[StepSpec]] = None):
|
|
|
|
|
|
self.pipeline = pipeline
|
|
|
|
|
|
self.steps: List[StepSpec] = list(steps) if steps else list(PIPELINE_STEPS)
|
|
|
|
|
|
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 主入口
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def run(self, ctx: PipelineContext, skip_list: Optional[List[str]] = None) -> PipelineContext:
|
|
|
|
|
|
"""全流程入口:智能补全 → 预检(软警告)→ 执行。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
ctx: PipelineContext
|
|
|
|
|
|
skip_list: 用户在 PreflightDialog 中选择忽略的 step_id 列表。
|
|
|
|
|
|
命中项设置 status="user_skipped",打印醒目日志。
|
|
|
|
|
|
"""
|
2026-06-03 16:31:45 +08:00
|
|
|
|
ctx.pipeline_start_time = time.time()
|
2026-06-09 09:07:59 +08:00
|
|
|
|
error_summary: List[tuple[str, str]] = []
|
|
|
|
|
|
skip_set = set(skip_list) if skip_list else set()
|
|
|
|
|
|
|
|
|
|
|
|
# ── ★ Step1 img_path 硬校验(缺失则立即终止整个流程) ──
|
|
|
|
|
|
if not ctx.get("img_path"):
|
|
|
|
|
|
msg = "【全流程预检失败】缺少参考影像路径 (img_path),流程无法启动。"
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] {msg}")
|
|
|
|
|
|
self._notify_step("全流程", "error", msg)
|
|
|
|
|
|
ctx.last_error = msg
|
|
|
|
|
|
ctx.pipeline_end_time = time.time()
|
|
|
|
|
|
return ctx
|
|
|
|
|
|
|
|
|
|
|
|
# ── ★ 智能补全:扫描 work_dir 默认产物路径,回填 ctx ──
|
|
|
|
|
|
self._scan_workdir_outputs(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
# ── ★ 自动补全缺失步骤:work_dir 有产物则强制开启 + 回填路径 ──
|
|
|
|
|
|
self._auto_fill_missing_steps(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
# ── 软预检警告(不再阻断,仅记录日志)──
|
|
|
|
|
|
self._preflight_warnings(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
# 断点续跑预扫描:ctx 已有产物则记录诊断日志
|
|
|
|
|
|
self._restore_outputs_from_ctx(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
# ── ★ 依赖级联自动唤醒:在主循环开始前补齐所有前置缺口 ──
|
|
|
|
|
|
self._resolve_dependencies(ctx)
|
|
|
|
|
|
|
2026-06-03 16:31:45 +08:00
|
|
|
|
for spec in self.steps:
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# ── 软取消 ──
|
2026-06-03 16:31:45 +08:00
|
|
|
|
if ctx.is_cancelled():
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] 收到取消信号,提前终止 @ {spec.step_id}")
|
|
|
|
|
|
break
|
2026-06-09 09:07:59 +08:00
|
|
|
|
|
|
|
|
|
|
# ── disabled 跳过(locked_steps 不受此约束)──
|
|
|
|
|
|
if not spec.enabled and spec.step_id not in ctx.locked_steps:
|
2026-06-03 16:31:45 +08:00
|
|
|
|
ctx.status[spec.step_id] = "skipped"
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] {spec.step_id} 标记为 disabled,跳过")
|
|
|
|
|
|
continue
|
2026-06-09 09:07:59 +08:00
|
|
|
|
|
|
|
|
|
|
# ── ★ 用户强制跳过(PreflightDialog 勾选) ──
|
|
|
|
|
|
if spec.step_id in skip_set:
|
|
|
|
|
|
ctx.status[spec.step_id] = "user_skipped"
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"\n{'='*60}\n"
|
|
|
|
|
|
f" ⚠ 用户强制跳过: {spec.step_id}({spec.description})\n"
|
|
|
|
|
|
f" 原因:用户在预检弹窗中勾选「忽略」,已确认跳过\n"
|
|
|
|
|
|
f"{'='*60}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
self._notify_step(spec.step_id, "skipped", "用户强制跳过(预检弹窗)")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── 依赖缺失检查 ──
|
2026-06-03 16:31:45 +08:00
|
|
|
|
if spec.skip_when_missing:
|
|
|
|
|
|
missing = [k for k in spec.requires if not ctx.get(k)]
|
|
|
|
|
|
if missing:
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# ── ★ 智能补全的步骤:work_dir 有产物,但 requires 仍缺失(罕见),报 warning 不跳过
|
|
|
|
|
|
if spec.step_id in ctx.locked_steps:
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[RUNNER] ⚠ {spec.step_id} 已锁定但 requires 仍缺失 {missing},"
|
|
|
|
|
|
"尝试执行(可能因依赖前置步骤失败)"
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
ctx.status[spec.step_id] = "skipped"
|
|
|
|
|
|
reason = f"缺少必要的上下文参数,自动跳过: {missing}"
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] {spec.step_id} {reason}")
|
|
|
|
|
|
self._notify_step(spec.step_id, "skipped", reason)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── ★ 断点续跑:产物文件已存在则跳过 ──
|
|
|
|
|
|
resolved_path = self._resolve_path(spec.output_file, ctx)
|
|
|
|
|
|
if resolved_path and os.path.exists(resolved_path):
|
|
|
|
|
|
ctx.status[spec.step_id] = "skipped"
|
|
|
|
|
|
reason = f"产物已存在,跳过: {resolved_path}"
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] {spec.step_id} {reason}")
|
|
|
|
|
|
self._notify_step(spec.step_id, "skipped", reason)
|
|
|
|
|
|
self._restore_ctx_from_output(spec, resolved_path, ctx)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── 执行(正常路径) ──
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._invoke(spec, ctx)
|
|
|
|
|
|
except PipelineHalt:
|
|
|
|
|
|
# ★ PipelineHalt 不走 error_summary,触发立即 break
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] PipelineHalt 硬终止 @ {spec.step_id}")
|
|
|
|
|
|
self._notify_step(spec.step_id, "error", "预检失败,硬终止")
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
ctx.status[spec.step_id] = "error"
|
|
|
|
|
|
error_summary.append((spec.step_id, str(exc)))
|
|
|
|
|
|
ctx.last_error = f"{spec.step_id}: {exc!r}"
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] {spec.step_id} 异常: {exc!r}")
|
|
|
|
|
|
self._notify_step(spec.step_id, "error", str(exc))
|
|
|
|
|
|
# ★ 任意 Exception 均立即 break,不再执行后续步骤
|
|
|
|
|
|
break
|
|
|
|
|
|
|
2026-06-03 16:31:45 +08:00
|
|
|
|
ctx.pipeline_end_time = time.time()
|
2026-06-09 09:07:59 +08:00
|
|
|
|
ctx.error_summary = error_summary
|
2026-06-03 16:31:45 +08:00
|
|
|
|
return ctx
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# ★ 智能补全:工作目录产物扫描
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _scan_workdir_outputs(self, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""扫描 work_dir 下所有步骤的默认产物路径,若存在则回填 ctx。
|
|
|
|
|
|
|
|
|
|
|
|
利用 spec.output_file 的 {work_dir} 占位符,展开为实际绝对路径。
|
|
|
|
|
|
存在则写入对应的 ctx 字段(produces),供后续步骤直接使用。
|
|
|
|
|
|
已在 ctx 中有值的字段不会被覆盖。
|
|
|
|
|
|
"""
|
|
|
|
|
|
work_dir = ctx.get("work_dir") or ""
|
|
|
|
|
|
if not work_dir:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if not spec.produces:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for produce_key in spec.produces:
|
|
|
|
|
|
if ctx.get(produce_key):
|
|
|
|
|
|
continue # 已有人工填写的值,不覆盖
|
|
|
|
|
|
resolved = self._resolve_path(spec.output_file, ctx)
|
|
|
|
|
|
if resolved and os.path.exists(resolved):
|
|
|
|
|
|
ctx.set(produce_key, resolved)
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[AUTO_FILL] 检测到已有产物,回填 {produce_key} = {resolved}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# ★ 智能补全:强制开启被静默跳过的步骤
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _auto_fill_missing_steps(self, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""检查所有 disabled 步骤。
|
|
|
|
|
|
|
|
|
|
|
|
若某步骤的 output_file 已在 work_dir 落盘(断点续跑),
|
|
|
|
|
|
说明该步骤之前已完成但被用户在 GUI 中禁用了。
|
|
|
|
|
|
此时系统自动重开启该步骤(forced=True),并将其加入 locked_steps。
|
|
|
|
|
|
|
|
|
|
|
|
同时,将已落盘的产物路径回填到对应的 ctx 字段,
|
|
|
|
|
|
确保下游步骤能正常拿到输入。
|
|
|
|
|
|
|
|
|
|
|
|
阻断性缺失(step1 img_path)已在 run() 入口硬校验,此处不处理。
|
|
|
|
|
|
"""
|
|
|
|
|
|
newly_locked: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if spec.enabled:
|
|
|
|
|
|
continue # 用户主动开启的步骤不受影响
|
|
|
|
|
|
skip_set = getattr(ctx, '_skip_set', set())
|
|
|
|
|
|
if spec.step_id in skip_set:
|
|
|
|
|
|
continue # 用户在 PreflightDialog 中手动忽略的步骤不自动补全
|
|
|
|
|
|
|
|
|
|
|
|
resolved = self._resolve_path(spec.output_file, ctx)
|
|
|
|
|
|
if resolved and os.path.exists(resolved):
|
|
|
|
|
|
# ── 该步骤已有产物但被禁用 → 自动开启 ──
|
|
|
|
|
|
spec.enabled = True
|
|
|
|
|
|
ctx.locked_steps.append(spec.step_id)
|
|
|
|
|
|
newly_locked.append(spec.step_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 回填所有产物字段到 ctx
|
|
|
|
|
|
for produce_key in spec.produces:
|
|
|
|
|
|
if not ctx.get(produce_key):
|
|
|
|
|
|
ctx.set(produce_key, resolved)
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[AUTO_FILL] 强制开启并回填 {spec.step_id} 产物 {produce_key} = {resolved}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"\n{'='*60}\n"
|
|
|
|
|
|
f" ⚡ 智能补全:步骤 {spec.step_id}({spec.description})\n"
|
|
|
|
|
|
f" 原因:该步骤在 work_dir 中已有产物但被您在 GUI 中禁用了。\n"
|
|
|
|
|
|
f" 操作:系统已自动开启该步骤,产物路径已回填。\n"
|
|
|
|
|
|
f" 注意:运行期间该步骤已被锁定,您无法临时关闭。\n"
|
|
|
|
|
|
f"{'='*60}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if newly_locked:
|
|
|
|
|
|
self._notify_step(
|
|
|
|
|
|
"全流程",
|
|
|
|
|
|
"info",
|
|
|
|
|
|
f"智能补全已自动开启 {len(newly_locked)} 个步骤:{newly_locked}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# ★ 依赖级联自动唤醒引擎
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_dependencies(self, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""依赖追溯循环:遍历所有 enabled 步骤,强制唤醒缺失前置。
|
|
|
|
|
|
|
|
|
|
|
|
核心逻辑:
|
|
|
|
|
|
- 遍历当前 enabled=True 的步骤,检查其 requires
|
|
|
|
|
|
- 若所需 key 在 ctx 中不存在,则向上寻找 produces 该 key 的前置 Step
|
|
|
|
|
|
- 将该前置 Step 强制设为 enabled=True(加入 locked_steps)
|
|
|
|
|
|
- 递归执行,直到所有前置缺口都被强制补齐
|
|
|
|
|
|
- 已存在的产物文件自动回填 ctx
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 构建 produces→step_id 反查表(仅关注 enabled 或潜在的前置步骤)
|
|
|
|
|
|
produce_to_step: Dict[str, StepSpec] = {}
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
for key in spec.produces:
|
|
|
|
|
|
produce_to_step[key] = spec
|
|
|
|
|
|
|
|
|
|
|
|
woke_up: List[str] = []
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
|
|
|
|
|
|
while changed:
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if not spec.enabled:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
for required_key in spec.requires:
|
|
|
|
|
|
# ctx 已有值 → 无需追溯
|
|
|
|
|
|
if ctx.get(required_key):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 磁盘文件是否存在(work_dir 产物已落盘但 ctx 未回填的情况)
|
|
|
|
|
|
resolved = self._resolve_output_for_key(required_key, ctx)
|
|
|
|
|
|
if resolved and os.path.exists(resolved):
|
|
|
|
|
|
ctx.set(required_key, resolved)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# 缺少且无磁盘产物 → 追溯 produces 者
|
|
|
|
|
|
if required_key not in produce_to_step:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
provider = produce_to_step[required_key]
|
|
|
|
|
|
if provider.enabled:
|
|
|
|
|
|
continue # 已开启但尚未执行(会在主循环中处理)
|
|
|
|
|
|
|
|
|
|
|
|
# 强制唤醒
|
|
|
|
|
|
provider.enabled = True
|
|
|
|
|
|
if provider.step_id not in ctx.locked_steps:
|
|
|
|
|
|
ctx.locked_steps.append(provider.step_id)
|
|
|
|
|
|
woke_up.append(provider.step_id)
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[INFO] 因下游依赖需求,自动唤醒并执行步骤: {provider.step_id}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 递归:检查新开启步骤自身的前置是否也缺失
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
|
|
|
|
|
|
if woke_up:
|
|
|
|
|
|
detail = "、".join(woke_up)
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[RUNNER] ★ 依赖级联自动唤醒已完成,共开启 {len(woke_up)} 个步骤:{detail}"
|
|
|
|
|
|
)
|
|
|
|
|
|
self._notify_step(
|
|
|
|
|
|
"全流程", "info",
|
|
|
|
|
|
f"依赖级联自动唤醒 {len(woke_up)} 个步骤:{woke_up}"
|
|
|
|
|
|
)
|
|
|
|
|
|
# 扫描新开启步骤的 work_dir 产物,回填 ctx
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if spec.step_id in woke_up:
|
|
|
|
|
|
self._scan_single_step_outputs(spec, ctx)
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_output_for_key(
|
|
|
|
|
|
self, produce_key: str, ctx: PipelineContext
|
|
|
|
|
|
) -> Optional[str]:
|
|
|
|
|
|
"""根据 produces key 查找对应步骤的 output_file 并展开路径。"""
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if produce_key in spec.produces:
|
|
|
|
|
|
return self._resolve_path(spec.output_file, ctx)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _scan_single_step_outputs(
|
|
|
|
|
|
self, spec: StepSpec, ctx: PipelineContext
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""扫描单个步骤的 work_dir 产物,回填 ctx(不覆盖已有值)。"""
|
|
|
|
|
|
if not spec.produces:
|
|
|
|
|
|
return
|
|
|
|
|
|
for produce_key in spec.produces:
|
|
|
|
|
|
if ctx.get(produce_key):
|
|
|
|
|
|
continue
|
|
|
|
|
|
resolved = self._resolve_path(spec.output_file, ctx)
|
|
|
|
|
|
if resolved and os.path.exists(resolved):
|
|
|
|
|
|
ctx.set(produce_key, resolved)
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[AUTO_FILL] 依赖唤醒后检测到产物,回填 {produce_key} = {resolved}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 软预检警告(不再阻断)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _preflight_warnings(self, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""软预检警告:遍历所有步骤,检测可预见的运行时跳过。
|
|
|
|
|
|
|
|
|
|
|
|
所有缺失均以 warning 记录日志,不抛异常,不阻止执行。
|
|
|
|
|
|
GUI 层可通过回调函数 _notify_step 向用户展示警告列表。
|
|
|
|
|
|
"""
|
|
|
|
|
|
warnings: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if not spec.enabled:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# ── Step4 csv_path 缺失警告 ──
|
|
|
|
|
|
if spec.step_id == "step4":
|
|
|
|
|
|
if not ctx.get("csv_path"):
|
|
|
|
|
|
warnings.append(
|
|
|
|
|
|
f"[{spec.step_id}] 缺少实测水质数据 (csv_path),"
|
|
|
|
|
|
"步骤 5-9 将被自动跳过"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ── 磁盘文件缺失警告(已填充 ctx 但文件实际不存在)──
|
|
|
|
|
|
for ctx_key in spec.required_input_files:
|
|
|
|
|
|
value = ctx.get(ctx_key)
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if not os.path.exists(value):
|
|
|
|
|
|
warnings.append(
|
|
|
|
|
|
f"[{spec.step_id}] 磁盘文件缺失(但 ctx 已回填): {ctx_key} = {value}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if warnings:
|
|
|
|
|
|
detail = "\n".join(f" - {w}" for w in warnings)
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[RUNNER] 【软预检警告】(流程将继续执行,缺失项将被自动跳过)\n{detail}"
|
|
|
|
|
|
)
|
|
|
|
|
|
self._notify_step("全流程", "warning", f"预检警告:{len(warnings)} 项\n{detail}")
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 单步调用
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
2026-06-03 16:31:45 +08:00
|
|
|
|
def _invoke(self, spec: StepSpec, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""调一个 step 方法:ctx 路径 → 形参;产出 → ctx 字段。"""
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[DEBUG] Step {spec.step_id} requires: {spec.requires}, "
|
|
|
|
|
|
f"actual ctx data: {[ctx.get(k) for k in spec.requires]}"
|
|
|
|
|
|
)
|
|
|
|
|
|
method = getattr(self.pipeline, spec.method_name, None)
|
|
|
|
|
|
if method is None:
|
|
|
|
|
|
ctx.append_log(f"[RUNNER] 步骤方法缺失: {spec.method_name}(跳过)")
|
|
|
|
|
|
ctx.status[spec.step_id] = "skipped"
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# 1) 把 ctx 路径作为形参注入
|
2026-06-03 16:31:45 +08:00
|
|
|
|
kwargs: Dict[str, Any] = {}
|
|
|
|
|
|
for ctx_key in spec.requires:
|
|
|
|
|
|
param_name = spec.parameter_map.get(ctx_key, self._default_param_name(ctx_key))
|
|
|
|
|
|
kwargs[param_name] = ctx.get(ctx_key)
|
|
|
|
|
|
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# 2) 允许用户在 ctx.user_config[step_id] 覆盖/补充(非空值才覆盖)
|
2026-06-03 16:31:45 +08:00
|
|
|
|
user_overrides = ctx.user_config.get(spec.step_id) or {}
|
|
|
|
|
|
if isinstance(user_overrides, dict):
|
2026-06-04 09:15:04 +08:00
|
|
|
|
for k, v in user_overrides.items():
|
|
|
|
|
|
if v is not None and v != "":
|
|
|
|
|
|
kwargs[k] = v
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
|
|
|
|
|
# 3) 状态置 start
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[RUNNER] -> {spec.method_name}({list(kwargs.keys())})"
|
|
|
|
|
|
)
|
|
|
|
|
|
ctx.status[spec.step_id] = "start"
|
2026-06-09 09:07:59 +08:00
|
|
|
|
self._notify_step(spec.step_id, "start", spec.method_name)
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# 4) 执行(外层 run() 统一捕获异常)
|
2026-06-03 16:31:45 +08:00
|
|
|
|
t0 = time.time()
|
2026-06-09 09:07:59 +08:00
|
|
|
|
result = method(**kwargs)
|
|
|
|
|
|
ctx.status[spec.step_id] = "completed"
|
|
|
|
|
|
ctx.step_timings[spec.step_id] = time.time() - t0
|
|
|
|
|
|
|
|
|
|
|
|
# 5) 产出收割
|
|
|
|
|
|
self._harvest(spec, result, ctx)
|
|
|
|
|
|
self._notify_step(
|
|
|
|
|
|
spec.step_id, "completed",
|
|
|
|
|
|
str(result)[:200] if result is not None else "",
|
|
|
|
|
|
)
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# 产出收割
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-06-03 16:31:45 +08:00
|
|
|
|
|
2026-06-09 09:07:59 +08:00
|
|
|
|
def _harvest(self, spec: StepSpec, result: Any, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""把 step 方法返回值灌入 ctx 的 produces 字段。"""
|
2026-06-03 16:31:45 +08:00
|
|
|
|
if not spec.produces:
|
|
|
|
|
|
return
|
|
|
|
|
|
if isinstance(result, dict):
|
|
|
|
|
|
for produce_key in spec.produces:
|
|
|
|
|
|
if produce_key in result:
|
|
|
|
|
|
ctx.set(produce_key, result[produce_key])
|
|
|
|
|
|
elif result is not None:
|
|
|
|
|
|
ctx.set(spec.produces[0], result)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-06-09 09:07:59 +08:00
|
|
|
|
# 断点续跑辅助
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_path(
|
|
|
|
|
|
self, template: Optional[str], ctx: PipelineContext
|
|
|
|
|
|
) -> Optional[str]:
|
|
|
|
|
|
"""解析模板中的 {work_dir} 占位符,返回展开后的绝对路径或 None。"""
|
|
|
|
|
|
if not template:
|
|
|
|
|
|
return None
|
|
|
|
|
|
work_dir = ctx.get("work_dir") or ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return template.format(work_dir=work_dir)
|
|
|
|
|
|
except (KeyError, ValueError):
|
|
|
|
|
|
return template
|
|
|
|
|
|
|
|
|
|
|
|
def _restore_outputs_from_ctx(self, ctx: PipelineContext) -> None:
|
|
|
|
|
|
"""诊断日志:记录 ctx 中已有的非 None 产物。"""
|
|
|
|
|
|
for spec in self.steps:
|
|
|
|
|
|
if not (spec.enabled and spec.produces):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for key in spec.produces:
|
|
|
|
|
|
val = ctx.get(key)
|
|
|
|
|
|
if val:
|
|
|
|
|
|
ctx.append_log(
|
|
|
|
|
|
f"[RUNNER] 断点续跑检测: {spec.step_id} 已有 {key} = {val}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _restore_ctx_from_output(
|
|
|
|
|
|
self, spec: StepSpec, resolved_path: str, ctx: PipelineContext
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""断点跳过时:将已存在的 output_file 写回 ctx 所有 produces 字段,供下游使用。
|
|
|
|
|
|
|
|
|
|
|
|
接力棒断链修复:遍历 spec.produces 逐一注册,不遗漏任何下游可能依赖的 key。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not spec.produces:
|
|
|
|
|
|
return
|
|
|
|
|
|
for produce_key in spec.produces:
|
|
|
|
|
|
ctx.set(produce_key, resolved_path)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 工具
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
2026-06-03 16:31:45 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _default_param_name(ctx_key: str) -> str:
|
2026-06-09 09:07:59 +08:00
|
|
|
|
"""默认原样返回 ctx 键名作为形参名。特殊缩写由 parameter_map 显式处理。"""
|
2026-06-03 16:31:45 +08:00
|
|
|
|
return ctx_key
|
2026-06-09 09:07:59 +08:00
|
|
|
|
|
|
|
|
|
|
def _notify_step(self, step_id: str, status: str, message: str) -> None:
|
|
|
|
|
|
"""通过 pipeline.callback 通知 GUI 当前步骤状态。"""
|
|
|
|
|
|
notify = getattr(self.pipeline, "_notify", None)
|
|
|
|
|
|
if callable(notify):
|
|
|
|
|
|
try:
|
|
|
|
|
|
notify(step_id, status, message)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|