- 9 个面板(step1~step6/step8_ml_train/step8_qaa/step9_ml_predict/step10)单步执行按钮从 parent 链上溯改为 global_event_bus.publish('RequestRunSingleStep')
- PipelineExecutor 新增 _on_request_run_single_step 订阅
- 新增 Handler: step8_ml_train / step9_ml_predict / step10_qaa_inversion / step11_concentration / step12_kriging / step13_visualization / step14_report
- 删除旧 water_quality_inversion_pipeline_GUI.py(上帝类已肢解完毕)
337 lines
12 KiB
Python
337 lines
12 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Step8 面板 - QAA 物理反演(非经验模型)
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
|
||
# 路径归一化 helper(与 pipeline.get_step_output_dir 互为表里)
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
if _HERE not in sys.path:
|
||
sys.path.insert(0, _HERE)
|
||
from _step_path_resolver import resolve_subdir
|
||
|
||
from PyQt5.QtWidgets import (
|
||
QWidget, QVBoxLayout, QGroupBox, QFormLayout, QGridLayout,
|
||
QHBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox,
|
||
QPushButton, QFileDialog, QMessageBox,
|
||
)
|
||
from PyQt5.QtGui import QFont
|
||
from PyQt5.QtCore import Qt
|
||
|
||
from src.gui.components.custom_widgets import FileSelectWidget
|
||
from src.gui.styles import ModernStylesheet
|
||
from src.utils.water_owt_config import (
|
||
get_all_lake_names,
|
||
get_lake_config,
|
||
get_lambda_0,
|
||
get_default_lake,
|
||
)
|
||
from src.core.algorithms.qaa import QAABaselineSolver
|
||
|
||
|
||
class Step8QAAPanel(QWidget):
|
||
"""步骤8:QAA 物理反演(非经验模型)"""
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.init_ui()
|
||
|
||
def init_ui(self):
|
||
layout = QVBoxLayout()
|
||
|
||
title = QLabel("步骤8:QAA 物理反演(非经验模型)")
|
||
title.setFont(QFont("Arial", 12, QFont.Bold))
|
||
layout.addWidget(title)
|
||
|
||
# 光谱 CSV 文件输入
|
||
self.spectrum_csv_file = FileSelectWidget(
|
||
"光谱 CSV 文件:",
|
||
"CSV Files (*.csv);;All Files (*.*)"
|
||
)
|
||
self.spectrum_csv_file.line_edit.setPlaceholderText(
|
||
"选择实测光谱或采样点光谱 CSV(含波长列)"
|
||
)
|
||
layout.addWidget(self.spectrum_csv_file)
|
||
|
||
# 水域类型选择
|
||
lake_group = QGroupBox("水域类型配置")
|
||
lake_layout = QFormLayout()
|
||
|
||
self.lake_combo = QComboBox()
|
||
lake_names = get_all_lake_names()
|
||
self.lake_combo.addItems(lake_names)
|
||
default_lake = get_default_lake()
|
||
if default_lake in lake_names:
|
||
self.lake_combo.setCurrentText(default_lake)
|
||
self.lake_combo.currentTextChanged.connect(self._on_lake_changed)
|
||
lake_layout.addRow("水域选择:", self.lake_combo)
|
||
|
||
# 参考波长显示
|
||
self.lambda_0_label = QLabel()
|
||
self.lambda_0_label.setStyleSheet(
|
||
f"color: {ModernStylesheet.COLORS['accent']}; "
|
||
f"font-weight: bold;"
|
||
)
|
||
lake_layout.addRow("参考波长 λ₀:", self.lambda_0_label)
|
||
|
||
# 算法提示
|
||
self.hint_label = QLabel()
|
||
self.hint_label.setWordWrap(True)
|
||
self.hint_label.setStyleSheet(
|
||
f"color: {ModernStylesheet.COLORS['text_secondary']}; "
|
||
"font-size: 12px;"
|
||
)
|
||
lake_layout.addRow("算法提示:", self.hint_label)
|
||
|
||
lake_group.setLayout(lake_layout)
|
||
layout.addWidget(lake_group)
|
||
|
||
# 输出路径
|
||
self.output_path = FileSelectWidget(
|
||
"输出文件:",
|
||
"CSV Files (*.csv);;All Files (*.*)",
|
||
mode="save"
|
||
)
|
||
self.output_path.line_edit.setPlaceholderText(
|
||
"自动生成到 8_QAA_Inversion,或手动指定..."
|
||
)
|
||
self.output_path.browse_btn.clicked.disconnect()
|
||
self.output_path.browse_btn.clicked.connect(self.browse_output_path)
|
||
layout.addWidget(self.output_path)
|
||
|
||
# 启用步骤
|
||
self.enable_checkbox = QCheckBox("启用此步骤")
|
||
self.enable_checkbox.setChecked(False)
|
||
layout.addWidget(self.enable_checkbox)
|
||
|
||
# 独立运行按钮
|
||
self.run_btn = QPushButton("执行 QAA 反演")
|
||
self.run_btn.setStyleSheet(ModernStylesheet.get_button_stylesheet('success'))
|
||
self.run_btn.clicked.connect(self._on_run_single_clicked)
|
||
layout.addWidget(self.run_btn)
|
||
|
||
layout.addStretch()
|
||
self.setLayout(layout)
|
||
|
||
self._on_lake_changed(self.lake_combo.currentText())
|
||
|
||
def _on_lake_changed(self, lake_name: str):
|
||
"""当用户切换水域时更新显示"""
|
||
cfg = get_lake_config(lake_name)
|
||
if cfg:
|
||
self.lambda_0_label.setText(
|
||
f"{cfg['lambda_0']} nm({cfg['qaa_version']})"
|
||
)
|
||
self.hint_label.setText(cfg.get('notes', ''))
|
||
else:
|
||
self.lambda_0_label.setText("—")
|
||
self.hint_label.setText("")
|
||
|
||
def _get_default_work_dir(self) -> str:
|
||
"""获取 work_dir,优先用 panel 自身缓存的,否则尝试从主窗口取"""
|
||
if hasattr(self, 'work_dir') and self.work_dir:
|
||
return str(self.work_dir)
|
||
mw = self.window()
|
||
if mw and hasattr(mw, 'work_dir') and mw.work_dir:
|
||
return str(mw.work_dir)
|
||
return ""
|
||
|
||
def browse_output_path(self):
|
||
"""浏览输出文件路径"""
|
||
current = self.output_path.get_path().strip()
|
||
if current:
|
||
initial_dir = os.path.dirname(current)
|
||
initial_file = os.path.basename(current)
|
||
else:
|
||
initial_dir = ""
|
||
initial_file = ""
|
||
|
||
if not initial_dir or not os.path.isdir(initial_dir):
|
||
work_dir = self._get_default_work_dir()
|
||
initial_dir = resolve_subdir(work_dir, 'qaa_inversion') if work_dir else ""
|
||
if initial_dir and not os.path.isdir(initial_dir):
|
||
os.makedirs(initial_dir, exist_ok=True)
|
||
|
||
file_path, _ = QFileDialog.getSaveFileName(
|
||
self, "保存输出文件",
|
||
os.path.join(initial_dir, initial_file) if initial_file else initial_dir,
|
||
"CSV Files (*.csv);;All Files (*.*)"
|
||
)
|
||
if file_path:
|
||
self.output_path.set_path(file_path)
|
||
|
||
def get_config(self) -> dict:
|
||
"""获取面板配置"""
|
||
config = {
|
||
'lake_name': self.lake_combo.currentText(),
|
||
'lambda_0': get_lambda_0(self.lake_combo.currentText()),
|
||
'spectrum_csv_path': self.spectrum_csv_file.get_path(),
|
||
}
|
||
output_path = self.output_path.get_path()
|
||
if output_path:
|
||
config['output_path'] = output_path
|
||
return config
|
||
|
||
def set_config(self, config: dict):
|
||
"""设置面板配置"""
|
||
if 'lake_name' in config:
|
||
lake_name = config['lake_name']
|
||
idx = self.lake_combo.findText(lake_name)
|
||
if idx >= 0:
|
||
self.lake_combo.setCurrentIndex(idx)
|
||
if 'spectrum_csv_path' in config:
|
||
self.spectrum_csv_file.set_path(config['spectrum_csv_path'])
|
||
if 'output_path' in config:
|
||
self.output_path.set_path(config['output_path'])
|
||
|
||
def update_from_config(self, work_dir=None, pipeline=None):
|
||
"""从全局配置自动填充训练数据和输出路径"""
|
||
if work_dir:
|
||
self.work_dir = work_dir
|
||
elif hasattr(self, 'work_dir') and self.work_dir:
|
||
pass
|
||
else:
|
||
self.work_dir = None
|
||
|
||
main_window = self.window()
|
||
|
||
if main_window and hasattr(main_window, 'step5_panel'):
|
||
step5_output = main_window.step5_panel.output_file.get_path()
|
||
if step5_output:
|
||
if not os.path.isabs(step5_output):
|
||
step5_output = os.path.join(self.work_dir or '', step5_output).replace('\\', '/')
|
||
self.spectrum_csv_file.set_path(step5_output)
|
||
|
||
if self.work_dir:
|
||
qaa_dir = resolve_subdir(self.work_dir, 'qaa_inversion')
|
||
os.makedirs(qaa_dir, exist_ok=True)
|
||
output_path = os.path.join(qaa_dir, "a_lambda_results.csv").replace('\\', '/')
|
||
self.output_path.set_path(output_path)
|
||
else:
|
||
self.output_path.set_path("")
|
||
|
||
def _on_run_single_clicked(self):
|
||
"""通过 EventBus 发布单步执行请求(解耦面板与 PipelineExecutor)。"""
|
||
from src.gui.core.event_bus import global_event_bus
|
||
|
||
spectrum_path = self.spectrum_csv_file.get_path()
|
||
if not spectrum_path:
|
||
QMessageBox.warning(self, "输入错误", "请选择光谱 CSV 文件!")
|
||
return
|
||
|
||
config = {'step8_qaa': self.get_config()}
|
||
global_event_bus.publish('RequestRunSingleStep', {
|
||
'step_name': 'step8_qaa',
|
||
'config': config,
|
||
})
|
||
|
||
def run_step(self):
|
||
"""独立运行 QAA 反演(旧版 parent 链上溯方式,保留兼容)。"""
|
||
spectrum_path = self.spectrum_csv_file.get_path()
|
||
if not spectrum_path:
|
||
QMessageBox.warning(self, "输入错误", "请选择光谱 CSV 文件!")
|
||
return
|
||
|
||
main_window = self.window()
|
||
if hasattr(main_window, 'run_single_step'):
|
||
config = {'step8_qaa': self.get_config()}
|
||
main_window.run_single_step('step8_qaa', config)
|
||
else:
|
||
self._run_qaa_direct()
|
||
|
||
def _run_qaa_direct(self):
|
||
"""直接执行 QAA 反演(不依赖主窗口流水线)"""
|
||
spectrum_path = self.spectrum_csv_file.get_path()
|
||
output_path = self.output_path.get_path()
|
||
lake_name = self.lake_combo.currentText()
|
||
lambda_0 = get_lambda_0(lake_name)
|
||
|
||
if not output_path:
|
||
work_dir = self._get_default_work_dir()
|
||
qaa_dir = resolve_subdir(work_dir, 'qaa_inversion') if work_dir else ""
|
||
if qaa_dir and not os.path.isdir(qaa_dir):
|
||
os.makedirs(qaa_dir, exist_ok=True)
|
||
output_path = os.path.join(qaa_dir, "a_lambda_results.csv").replace('\\', '/')
|
||
|
||
try:
|
||
import numpy as np
|
||
import pandas as pd
|
||
df = pd.read_csv(spectrum_path, encoding="utf-8-sig")
|
||
col_names = df.columns.tolist()
|
||
|
||
wavelength_col_idx = None
|
||
for i, col in enumerate(col_names):
|
||
try:
|
||
float(col)
|
||
wavelength_col_idx = i
|
||
break
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
if wavelength_col_idx is None:
|
||
QMessageBox.warning(
|
||
self, "解析错误",
|
||
"无法从 CSV 列名中识别波长信息,请确保列名包含数值型波长(nm)"
|
||
)
|
||
return
|
||
|
||
wavelengths = np.array(
|
||
[float(c) for c in col_names[wavelength_col_idx:]], dtype=np.float64
|
||
)
|
||
data_matrix = df.iloc[:, wavelength_col_idx:].values.astype(np.float64)
|
||
|
||
if data_matrix.ndim == 1:
|
||
data_matrix = data_matrix[np.newaxis, :]
|
||
|
||
solver = QAABaselineSolver()
|
||
raw_result = solver.run_inversion(wavelengths, data_matrix, lambda_0)
|
||
|
||
# run_inversion 返回:单样本 → dict,多样本 → list[dict]
|
||
if isinstance(raw_result, list):
|
||
sample_results = raw_result
|
||
aw_0 = raw_result[0].get('aw_0', 0)
|
||
bbw_0 = raw_result[0].get('bbw_0', 0)
|
||
else:
|
||
sample_results = [raw_result]
|
||
aw_0 = raw_result.get('aw_0', 0)
|
||
bbw_0 = raw_result.get('bbw_0', 0)
|
||
|
||
rows_out = []
|
||
for i, sample_result in enumerate(sample_results):
|
||
wl_arr = wavelengths
|
||
a_arr = sample_result['a_lambda']
|
||
bb_arr = sample_result['bb_lambda']
|
||
for j, wl in enumerate(wl_arr):
|
||
rows_out.append({
|
||
'sample_id': f"sample_{i}",
|
||
'Wavelength': wl,
|
||
'a_lambda': a_arr[j],
|
||
'bb_lambda': bb_arr[j],
|
||
})
|
||
|
||
result_df = pd.DataFrame(rows_out)
|
||
os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
|
||
result_df.to_csv(output_path, index=False, float_format='%.8f')
|
||
|
||
QMessageBox.information(
|
||
self, "执行成功",
|
||
f"QAA 反演完成!\n"
|
||
f"水域: {lake_name}\n"
|
||
f"参考波长 λ₀: {lambda_0} nm\n"
|
||
f"λ₀ 处 aw: {aw_0:.6f} m⁻¹\n"
|
||
f"λ₀ 处 bbw: {bbw_0:.6f} m⁻¹\n"
|
||
f"结果已保存到:\n{output_path}"
|
||
)
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "执行错误", f"QAA 反演失败:\n{str(e)}")
|
||
|
||
def get_training_params(self) -> dict:
|
||
"""获取反演参数"""
|
||
return {
|
||
'pipeline_type': 'qaa_non_empirical',
|
||
'lake_name': self.lake_combo.currentText(),
|
||
'lambda_0': get_lambda_0(self.lake_combo.currentText()),
|
||
} |