#include "nmWxSensitive.h" #include "nmDataAnalyzeManager.h" #include "nmDataSensitive.h" #include "nmDataReservoir.h" #include "nmDataWellBase.h" #include "nmDataPerforation.h" #include "nmPebiResultSnapshot.h" #include "nmCalculationDllPebiSolverTask.h" #include "iUnitHelper.h" #include "iUnitGroup.h" #include "iSysParaHelper.h" #include "iParameter.h" #include "mModuleDefines.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "iGuiPlot.h" #include "ZxPlot.h" #include "ZxObjCurve.h" #include "ZxSubAxisX.h" #include "ZxSubAxisY.h" #include "tCurvePlotView.h" #include "iCurveDesc.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { const char* distributionFields[] = {"mean", "stdDev", "peak", "minimum", "maximum"}; QString formatSamplingValue(double value, int digits) { // 仅对显示值取有效数字,较大或较小的数自动使用科学计数法。 return value == 0.0 ? QString("0") : QString::number(value, 'g', digits); } void setSamplingEditValue(QLineEdit* edit, double value) { // 显示文本与原始数值分开保存,打开或保存窗口不会丢失采样精度。 const QString text = formatSamplingValue(value, 6); edit->setProperty("samplingValue", value); edit->setProperty("samplingText", text); edit->setText(text); } double samplingEditValue(const QLineEdit* edit, bool* ok) { // 未修改的格式化文本读取原始值;用户的新输入按实际文本解析。 const QVariant value = edit->property("samplingValue"); if (value.isValid() && edit->text() == edit->property("samplingText").toString()) return value.toDouble(ok); return edit->text().trimmed().toDouble(ok); } QString samplingBaseUnit(const QString& name) { // 与现有 PEBI 数据层的业务基准单位保持一致。 if (name == "Pi") return "MPa"; if (name == "k") return "mD"; if (name == "h") return "m"; if (name == "C") return "m^3/MPa"; return QString(); } bool convertSamplingValue(const QString& name, double value, QString from, QString to, double& converted) { // 优先复用项目单位组;旧配置的 psi 按绝对压力单位迁移。 if (from == "psi") from = "psia"; if (to == "psi") to = "psia"; if (from == "dimensionless") from.clear(); if (to == "dimensionless") to.clear(); if (from == to || (from.isEmpty() && to.isEmpty())) { converted = value; return qIsFinite(converted); } iUnitGroup* group = iUnitHelper::getUnitGroupByUnit(from); int digit = 0; if (group && group->indexOf(to) >= 0 && group->convert(from, value, to, converted, digit)) return qIsFinite(converted); if (name == "h") converted = nmDataAttribute::convertLengthUnit(value, from, to); else if (name == "Pi") converted = nmDataAttribute::convertPressureUnit(value, from, to); else if (name == "k") converted = nmDataAttribute::convertPermeabilityUnit(value, from, to); else return false; return qIsFinite(converted); } bool isSupportedSamplingVariable(const QString& name) { return name == "Skin" || name == "C" || name == "Pi" || name == "k" || name == "h" || name == QString(QChar(0x03c6)); } bool samplingPhysicalRange(const QString& name, double& minimum, double& maximum) { // 与自动拟合共用 NM 系统参数表,数值均为业务基准单位。 QString parameterName; if (name == "Skin") parameterName = "Result_W_Skin"; else if (name == "C") parameterName = "Result_W_C"; else if (name == "Pi") parameterName = "Result_Pi"; else if (name == "k") parameterName = "Result_K"; else if (name == "h") parameterName = "Result_h"; else if (name == QString(QChar(0x03c6))) parameterName = "Result_phi"; else return false; iSysParaHelper* helper = _paraHelper; iParameter* parameter = helper ? helper->getPara(parameterName, s_Nm_Serie) : nullptr; if (!parameter || !qIsFinite(parameter->m_dMin) || !qIsFinite(parameter->m_dMax) || parameter->m_dMin >= parameter->m_dMax) return false; minimum = name == "Skin" ? 0.0 : parameter->m_dMin; maximum = parameter->m_dMax; return minimum < maximum; } bool nearlyEqualSamplingValue(double left, double right) { // 沿用自动拟合的八个机器精度容差,只处理边界的浮点舍入差。 return qAbs(left - right) <= DBL_EPSILON * 8.0 * qMax(qAbs(left), qAbs(right)); } bool validSamplingValue(const QString& name, double& value) { // 所有采样值都受系统物理范围约束,边界末位误差归一到精确边界。 if (!qIsFinite(value)) return false; double minimum = 0.0, maximum = 0.0; if (!samplingPhysicalRange(name, minimum, maximum)) return false; if (value < minimum && !nearlyEqualSamplingValue(value, minimum)) return false; if (value > maximum && !nearlyEqualSamplingValue(value, maximum)) return false; value = qBound(minimum, value, maximum); return true; } } namespace { bool hasSensitivityError(const nmDataSensitive::RunResult& run) { // 统计只使用成功且误差有效的运行,与曲线显示勾选无关。 return run.m_bSucceeded && !run.m_bCancelled && qIsFinite(run.m_dPressureRmse) && run.m_dPressureRmse >= 0.0; } QString sensitivityRunParameters(const nmDataSensitive::BatchResult& results, const nmDataSensitive::RunResult& run) { // 表格和散点提示共用冻结参数的单位换算。 QStringList descriptions; for(int i = 0; i < results.m_variables.size() && i < run.m_vecParameters.size(); ++i) { const nmDataSensitive::VariableSampling& variable = results.m_variables[i]; if(!variable.m_enabled) continue; double value = run.m_vecParameters[i]; const QString unit = variable.m_modelValue.getUnit(); convertSamplingValue(variable.m_varName, value, samplingBaseUnit(variable.m_varName), unit, value); descriptions << variable.m_varName + " = " + formatSamplingValue(value, 3) + (unit.isEmpty() ? QString() : " " + unit); } return descriptions.join(" + "); } } // ========= 已完成批次的连续结果列表 ========= class SensitivityResultsModel : public QAbstractTableModel { public: SensitivityResultsModel(const QSharedPointer& results, iGuiPlot* plot, QObject* parent, bool statistics = false) : QAbstractTableModel(parent), m_results(results), m_plot(plot), m_statistics(statistics) {} QList runCurves(int row) const { // 双对数图的一组包含压力与导数,历史压力图只包含压力。 QList curves; if(m_plot.isNull()) return curves; const quint64 number = m_results->m_runs[row].m_nRunIndex + 1; ZxObjCurve* pressure = m_plot->getCurve(nmWxSensitive::tr("Run %1 - Pressure").arg(number)); ZxObjCurve* derivative = m_plot->getCurve(nmWxSensitive::tr("Run %1 - Derivative").arg(number)); if(pressure) curves.append(pressure); if(derivative) curves.append(derivative); return curves; } bool isRunVisible(int row) const { // 从所属图读取已应用状态,两个 Show 表格互不共享勾选结果。 const QList curves = runCurves(row); for(int i = 0; i < curves.size(); ++i) if(curves[i]->isVisible()) return true; return false; } int rowCount(const QModelIndex& parent = QModelIndex()) const override { // QTableView 按需读取行,结果列表连续滚动,不一次性创建全部表格项。 return parent.isValid() || m_results.isNull() ? 0 : int(m_results->m_runs.size()); } int columnCount(const QModelIndex& parent = QModelIndex()) const override { return parent.isValid() ? 0 : 3; } QVariant headerData(int section, Qt::Orientation orientation, int role) const override { // 表格仅保留显示选择、运行编号与冻结参数。 if(orientation != Qt::Horizontal || role != Qt::DisplayRole) return QVariant(); if(m_statistics) return (QStringList() << "#" << nmWxSensitive::tr("Realization") << nmWxSensitive::tr("Goodness of fit [MPa]")).value(section); const QStringList titles = QStringList() << nmWxSensitive::tr("Show") << "#" << nmWxSensitive::tr("Parameters"); return titles.value(section); } QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override { // 参数显示使用冻结配置中的单位和三位有效数字,不修改原始采样值。 if(!index.isValid() || index.row() >= rowCount()) return QVariant(); const nmDataSensitive::RunResult& run = m_results->m_runs[index.row()]; if(m_statistics) { if(role == Qt::ToolTipRole && !run.m_sFailureReason.isEmpty()) return run.m_sFailureReason; if(role != Qt::DisplayRole && role != Qt::ToolTipRole) return QVariant(); if(index.column() == 0) return QString::number(run.m_nRunIndex + 1); if(index.column() == 1) return sensitivityRunParameters(*m_results, run); return hasSensitivityError(run) ? QString::number(run.m_dPressureRmse, 'g', 6) : run.m_bCancelled ? nmWxSensitive::tr("Cancelled") : !run.m_bSucceeded ? nmWxSensitive::tr("Failed") : nmWxSensitive::tr("N/A"); } if(role == Qt::CheckStateRole && index.column() == 0) return run.m_bSucceeded && !run.m_bCancelled && m_pendingVisibility.value(index.row(), isRunVisible(index.row())) ? Qt::Checked : Qt::Unchecked; if(role == Qt::TextAlignmentRole && index.column() == 1) return int(Qt::AlignRight | Qt::AlignVCenter); if(role == Qt::ToolTipRole && !run.m_sFailureReason.isEmpty()) return run.m_sFailureReason; if(role != Qt::DisplayRole && role != Qt::ToolTipRole) return QVariant(); if(index.column() == 1) return QString::number(run.m_nRunIndex + 1); if(index.column() != 2) return QVariant(); return sensitivityRunParameters(*m_results, run); } Qt::ItemFlags flags(const QModelIndex& index) const override { // 失败和取消组只能查看,不能勾选不存在的计算曲线。 if(!index.isValid()) return Qt::NoItemFlags; Qt::ItemFlags result = Qt::ItemIsEnabled | Qt::ItemIsSelectable; const nmDataSensitive::RunResult& run = m_results->m_runs[index.row()]; if(!m_statistics && index.column() == 0 && run.m_bSucceeded && !run.m_bCancelled && !runCurves(index.row()).isEmpty()) result |= Qt::ItemIsUserCheckable; return result; } bool setData(const QModelIndex& index, const QVariant& value, int role) override { // 勾选只记在本窗口,点击 Apply 或 OK 后才改变所属图的显示。 if(m_statistics || !index.isValid() || index.column() != 0 || role != Qt::CheckStateRole) return false; const nmDataSensitive::RunResult& run = m_results->m_runs[index.row()]; if(!run.m_bSucceeded || run.m_bCancelled || runCurves(index.row()).isEmpty()) return false; const bool visible = value.toInt() == Qt::Checked; if(visible == isRunVisible(index.row())) m_pendingVisibility.remove(index.row()); else m_pendingVisibility.insert(index.row(), visible); emit dataChanged(index, index); return true; } void setAllVisible(bool visible) { // 全选与全隐藏也只改变待应用选择,不立即刷新曲线。 if(m_results.isNull() || m_results->m_runs.empty()) return; for(int i = 0; i < rowCount(); ++i) { const nmDataSensitive::RunResult& run = m_results->m_runs[i]; if(!run.m_bSucceeded || run.m_bCancelled || runCurves(i).isEmpty()) continue; if(visible == isRunVisible(i)) m_pendingVisibility.remove(i); else m_pendingVisibility.insert(i, visible); } emit dataChanged(index(0, 0), index(rowCount() - 1, 0)); } void applyVisibility() { // Apply 仅更新所属图的采样曲线,保留实测、基准和流量参考线。 QMap::const_iterator it = m_pendingVisibility.constBegin(); for(; it != m_pendingVisibility.constEnd(); ++it) { const QList curves = runCurves(it.key()); for(int i = 0; i < curves.size(); ++i) curves[i]->setVisible(it.value()); } m_pendingVisibility.clear(); if(!m_plot.isNull()) { m_plot->clearUndoStack(); m_plot->m_pPlot->update(); } } private: QSharedPointer m_results; QMap m_pendingVisibility; QPointer m_plot; bool m_statistics; }; nmWxSensitivityShow::nmWxSensitivityShow( const QSharedPointer& results, iGuiPlot* plot, QWidget* parent) : iDlgBase(parent) { // 沿用原 Results 的白色表格及按钮布局,勾选在 Apply 或 OK 后生效。 setAttribute(Qt::WA_DeleteOnClose); setObjectName("nmSensitivityShowDialog"); resize(700, 380); QVBoxLayout* layout = new QVBoxLayout(this); QTableView* table = new QTableView(this); table->setStyleSheet("QTableView { background-color: white; }"); m_model = new SensitivityResultsModel(results, plot, this); table->setModel(m_model); table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setSelectionMode(QAbstractItemView::SingleSelection); table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setWordWrap(false); table->verticalHeader()->setVisible(false); table->horizontalHeader()->setResizeMode(0, QHeaderView::ResizeToContents); table->horizontalHeader()->setResizeMode(1, QHeaderView::ResizeToContents); table->horizontalHeader()->setResizeMode(2, QHeaderView::Stretch); layout->addWidget(table); QHBoxLayout* buttons = new QHBoxLayout(); QPushButton* showAll = new QPushButton(tr("Show all"), this); QPushButton* hideAll = new QPushButton(tr("Hide all"), this); // 明确按钮顺序,避免系统的标准按钮布局把 Apply 排到最后。 QPushButton* apply = new QPushButton(tr("Apply"), this); QPushButton* ok = new QPushButton(tr("OK"), this); QPushButton* cancel = new QPushButton(tr("Cancel"), this); apply->setAutoDefault(false); cancel->setAutoDefault(false); ok->setDefault(true); buttons->addWidget(showAll); buttons->addWidget(hideAll); buttons->addStretch(); buttons->addWidget(apply); buttons->addWidget(ok); buttons->addWidget(cancel); layout->addLayout(buttons); connect(showAll, SIGNAL(clicked()), this, SLOT(onShowAll())); connect(hideAll, SIGNAL(clicked()), this, SLOT(onHideAll())); connect(apply, SIGNAL(clicked()), this, SLOT(onApply())); connect(ok, SIGNAL(clicked()), this, SLOT(onAccept())); connect(cancel, SIGNAL(clicked()), this, SLOT(reject())); } void nmWxSensitivityShow::onApply() { // 应用选择,保留窗口以便继续调整。 m_model->applyVisibility(); } void nmWxSensitivityShow::onAccept() { // 确认时提交勾选,取消则直接丢弃未应用的选择。 onApply(); accept(); } void nmWxSensitivityShow::onShowAll() { // 只修改待应用的采样组选择。 m_model->setAllVisible(true); } void nmWxSensitivityShow::onHideAll() { // 参考曲线不属于采样组,保持原来的显示方式。 m_model->setAllVisible(false); } nmWxSensitivityHistogramOptions::nmWxSensitivityHistogramOptions( const QSharedPointer& results, int bins, QWidget* parent) : iDlgBase(parent) { // 复用运行表格的参数展示,普通 Monte-Carlo 的统计变量只有误差。 setAttribute(Qt::WA_DeleteOnClose); setObjectName("nmHistogramOptions"); setWindowTitle(tr("Histogram options")); resize(720, 400); QVBoxLayout* layout = new QVBoxLayout(this); QHBoxLayout* options = new QHBoxLayout; options->addWidget(new QLabel(tr("Number of bins:"), this)); m_bins = new QSpinBox(this); m_bins->setRange(1, 1000); m_bins->setValue(bins); options->addWidget(m_bins); options->addStretch(); options->addWidget(new QLabel(tr("Variable:"), this)); QComboBox* variable = new QComboBox(this); variable->addItem(tr("Goodness of fit")); options->addWidget(variable); layout->addLayout(options); QTableView* table = new QTableView(this); table->setStyleSheet("QTableView { background-color: white; }"); table->setModel(new SensitivityResultsModel(results, nullptr, table, true)); table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setWordWrap(false); table->verticalHeader()->hide(); table->horizontalHeader()->setResizeMode(0, QHeaderView::ResizeToContents); table->horizontalHeader()->setResizeMode(1, QHeaderView::Stretch); table->horizontalHeader()->setResizeMode(2, QHeaderView::ResizeToContents); layout->addWidget(table); QHBoxLayout* buttons = new QHBoxLayout; buttons->addStretch(); QPushButton* apply = new QPushButton(tr("Apply"), this); QPushButton* ok = new QPushButton(tr("OK"), this); QPushButton* cancel = new QPushButton(tr("Cancel"), this); buttons->addWidget(apply); buttons->addWidget(ok); buttons->addWidget(cancel); layout->addLayout(buttons); connect(apply, SIGNAL(clicked()), this, SLOT(onApply())); connect(ok, SIGNAL(clicked()), this, SLOT(onAccept())); connect(cancel, SIGNAL(clicked()), this, SLOT(reject())); } void nmWxSensitivityHistogramOptions::onApply() { // 只提交分箱数,取消不会撤销已经应用的设置。 emit binsChanged(m_bins->value()); } void nmWxSensitivityHistogramOptions::onAccept() { // 与 Show 表格保持相同的确认行为。 onApply(); accept(); } nmWxSensitivityScatter::nmWxSensitivityScatter( const QSharedPointer& results, double baseError, QWidget* parent) : iDlgBase(parent), m_results(results), m_baseError(baseError), m_defaultThreshold(0.0), m_draggingThreshold(false) { // 散点与基准均来自同一批次快照,不读取后来编辑的模型参数。 setObjectName("nmSensitivityScatterPlot"); setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(tr("Scatter plot")); resize(850, 550); QVBoxLayout* layout = new QVBoxLayout(this); layout->setContentsMargins(8, 8, 8, 8); QHBoxLayout* options = new QHBoxLayout; options->addWidget(new QLabel(tr("X Axis:"), this)); m_parameter = new QComboBox(this); for(int i = 0; i < results->m_variables.size(); ++i) if(results->m_variables[i].m_enabled) m_parameter->addItem(results->m_variables[i].m_varName, i); options->addWidget(m_parameter, 1); options->addSpacing(20); options->addWidget(new QLabel(tr("Y Axis:"), this)); QComboBox* ordinate = new QComboBox(this); ordinate->addItem(tr("Goodness of fit")); options->addWidget(ordinate, 1); for(size_t i = 0; i < results->m_runs.size(); ++i) if(hasSensitivityError(results->m_runs[i])) m_defaultThreshold = qMax(m_defaultThreshold, results->m_runs[i].m_dPressureRmse); if(qIsFinite(baseError) && baseError >= 0.0) m_defaultThreshold = baseError; m_threshold = results->m_dFilterThreshold >= 0.0 ? results->m_dFilterThreshold : m_defaultThreshold; layout->addLayout(options); m_plot = new iGuiPlot(true, this); m_plot->initUI(tr("Sensitivity scatter plot")); m_plot->setDropEnabled(false); m_plot->setAxisY(tr("Goodness of fit"), "MPa"); m_plot->setAxisXYLog(false, 0); m_plot->setAxisXYLog(false, 1); m_plot->getAxisOf(2)->setVisible(false); // 给纵轴标题与刻度留出间距。 m_plot->getAxisOf(1)->setSpan(20.0); m_plot->m_pPlot->setBKColor(Qt::white); m_plot->m_pPlot->setLegendVisible(false); layout->addWidget(m_plot); // 两项固定图例不使用框架的分组标题,覆盖在绘图区左上角。 m_legend = new QLabel(m_plot->m_pPlotView->viewport()); m_legend->setStyleSheet("QLabel { background: white; color: black; border: 1px solid #cccccc; }"); m_legend->setAttribute(Qt::WA_TransparentForMouseEvents); m_resultsInPlot = new QAction(tr("Results in plot"), this); m_resultsInPlot->setCheckable(true); connect(m_resultsInPlot, SIGNAL(toggled(bool)), this, SLOT(updateLegend())); updateLegend(); m_plot->m_pPlotView->viewport()->installEventFilter(this); connect(m_parameter, SIGNAL(currentIndexChanged(int)), this, SLOT(updateParameter())); connect(m_plot->m_pPlotView, SIGNAL(sigAdjustContextMenu(QMenu*)), this, SLOT(adjustContextMenu(QMenu*))); updateParameter(); } void nmWxSensitivityScatter::updateParameter() { // 切换横轴时重建参数坐标,阈值在各参数之间共用。 m_points.clear(); m_runRows.clear(); if(m_parameter->currentIndex() < 0) { m_plot->setToolTip(tr("No sampled parameters")); return; } const int index = m_parameter->itemData(m_parameter->currentIndex()).toInt(); const nmDataSensitive::VariableSampling& variable = m_results->m_variables[index]; const QString unit = variable.m_modelValue.getUnit(); m_plot->setAxisX(variable.m_varName, unit); double modelValue = variable.m_modelValue.getValue().toDouble(); const bool hasModel = convertSamplingValue(variable.m_varName, modelValue, samplingBaseUnit(variable.m_varName), unit, modelValue) && m_baseError >= 0.0 && qIsFinite(m_baseError); for(size_t i = 0; i < m_results->m_runs.size(); ++i) { const nmDataSensitive::RunResult& run = m_results->m_runs[i]; if(!hasSensitivityError(run) || index >= run.m_vecParameters.size()) continue; double value = run.m_vecParameters[index]; if(!convertSamplingValue(variable.m_varName, value, samplingBaseUnit(variable.m_varName), unit, value)) continue; m_points.append(QPointF(value, run.m_dPressureRmse)); m_runRows.append(int(i)); } QVector curves; for(int i = 0; i < 4; ++i) { iCurveDesc curve; curve.m_sCurveName = i == 0 ? tr("sensitivity data") : i == 1 ? tr("Below threshold") : i == 2 ? tr("reference") : tr("Threshold"); curve.m_oCurveType = POT_CurveDiscrete; curve.m_bLineVisible = false; curve.m_bPointVisible = true; curve.m_oDot = ZxDot(DTS_Square, i == 2 ? Qt::blue : Qt::black, 1.0, i != 0); if(i < 2) for(int j = 0; j < m_points.size(); ++j) { if(i == 1 && m_points[j].y() >= m_threshold) continue; curve.m_vecX.append(m_points[j].x()); curve.m_vecY.append(m_points[j].y()); } if(i == 2 && hasModel) { curve.m_vecX.append(modelValue); curve.m_vecY.append(m_baseError); } if(i == 3) { curve.m_bLineVisible = true; curve.m_bPointVisible = false; curve.m_oPen = QPen(Qt::red, 0.35); curve.m_vecX << 0.0 << 1.0; curve.m_vecY << m_threshold << m_threshold; } curves.append(curve); } // 固定图层名称,参数切换不叠加上一参数的点,也不让旧阈值影响自动范围。 for(int i = m_plot->m_pPlot->getObjCount() - 1; i >= 0; --i) if(qobject_cast(m_plot->m_pPlot->getObjByIndex(i))) m_plot->m_pPlot->removeObjByIndex(i); m_plot->clearUndoStack(); m_plot->setCurveData(curves); m_plot->runUpdate(); m_plot->setReadOnly(true); double left = hasModel ? modelValue : m_points.isEmpty() ? 0.0 : m_points.first().x(); double right = left, top = hasModel ? m_baseError : 0.0; for(int i = 0; i < m_points.size(); ++i) { left = qMin(left, m_points[i].x()); right = qMax(right, m_points[i].x()); top = qMax(top, m_points[i].y()); } const double padding = qMax(1e-6, (right > left ? right - left : qMax(1.0, qAbs(left))) * 0.05); left -= padding; right += padding; top = qMax(1e-6, qMax(top, m_threshold) * 1.1); VecDouble ticksX, ticksY; for(int i = 0; i <= 5; ++i) { ticksX.append(left + i * (right - left) / 5.0); ticksY.append(i * top / 5.0); } m_plot->setAxisScaleX(left, right, ticksX); m_plot->setAxisScaleY(0.0, top, ticksY); updateThreshold(); } void nmWxSensitivityScatter::updateThreshold() { // 阈值以下的点填黑,并同步图例中的阈值显示。 if(m_parameter->currentIndex() < 0) return; // 拖动时只更新图内反馈,松开后再保存阈值并刷新结果面板。 if(!m_draggingThreshold && m_results->m_dFilterThreshold != m_threshold) { m_results->m_dFilterThreshold = m_threshold; emit filterChanged(); } updateLegend(); QVector accepted; for(int i = 0; i < m_points.size(); ++i) if(m_points[i].y() < m_threshold) { accepted.append(m_points[i]); } if(m_threshold > m_plot->getAxisOf(1)->getRangeMax()) { VecDouble ticks; const double top = m_threshold * 1.1; for(int i = 0; i <= 5; ++i) ticks.append(i * top / 5.0); m_plot->setAxisScaleY(0.0, top, ticks); } QVector line; line << QPointF(m_plot->getAxisOf(0)->getRangeMin(), m_threshold) << QPointF(m_plot->getAxisOf(0)->getRangeMax(), m_threshold); QVector curves = m_plot->getCurveData(); // 原位更新图元及导出数据,不触发框架重新缩放,拖动时坐标轴保持稳定。 for(int i = 0; i < curves.size(); ++i) { const QString name = curves[i].m_sCurveName; if(name != tr("Below threshold") && name != tr("Threshold")) continue; const QVector& points = name == tr("Threshold") ? line : accepted; curves[i].m_vecX.clear(); curves[i].m_vecY.clear(); for(int j = 0; j < points.size(); ++j) { curves[i].m_vecX.append(points[j].x()); curves[i].m_vecY.append(points[j].y()); } ZxObjCurve* curve = m_plot->getCurve(name); if(curve) curve->setAllValues(points); else if(!points.isEmpty()) { curve = m_plot->appendSeriesD(curves[i].m_vecX, curves[i].m_vecY, name); if(curve) curve->setCurvePty(curves[i].m_oPen, curves[i].m_oDot, curves[i].m_bLineVisible, curves[i].m_bPointVisible); } } m_plot->setCurveData(curves); // 基准点始终置顶,包括首次出现阈值以下的点时。 ZxObjCurve* reference = m_plot->getCurve(tr("reference")); if(reference) m_plot->m_pPlot->bringToFront(reference); m_plot->m_pPlot->update(); } void nmWxSensitivityScatter::resetThreshold() { // 有基准误差时恢复至基准值,无实测数据时使用本批最大有效误差。 m_threshold = m_defaultThreshold; updateThreshold(); } void nmWxSensitivityScatter::adjustContextMenu(QMenu* menu) { // 复用绘图右键菜单,保留框架的缩放、复制与导出入口。 menu->addAction(m_resultsInPlot); menu->addAction(tr("Reset line"), this, SLOT(resetThreshold())); } void nmWxSensitivityScatter::updateLegend() { // 勾选后在原有两项图例下追加阈值,拖线和重置时实时更新。 QString text = "
□" + tr("sensitivity data") + "
■" + tr("reference") + "
"; if(m_resultsInPlot->isChecked()) text += "
" + tr("Threshold: %1 MPa").arg(m_threshold, 0, 'g', 6) + "
"; m_legend->setText(text); m_legend->adjustSize(); } bool nmWxSensitivityScatter::eventFilter(QObject* object, QEvent* event) { if(object != m_plot->m_pPlotView->viewport()) return iDlgBase::eventFilter(object, event); tCurvePlotView* view = m_plot->m_pPlotView; ZxSubAxisBase* axisY = m_plot->getAxisOf(1); // 位置跟随实际绘图区,窗口缩放后仍贴在坐标轴内侧。 if(event->type() == QEvent::Paint) m_legend->move(view->mapFromScene(m_plot->m_pPlot->getInnerRectF().topLeft()).toPoint() + QPoint(8, 8)); // 只接管阈值线附近的左键拖动,其余缩放、右键及导出仍由框架处理。 if(event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonRelease) { QMouseEvent* mouse = static_cast(event); const QPointF scene = view->mapToScene(QPointF(mouse->pos())); const double lineY = view->mapFromScene(QPointF(scene.x(), axisY->getPosForValue(m_threshold))).y(); if(event->type() == QEvent::MouseButtonPress && mouse->button() == Qt::LeftButton && m_plot->m_pPlot->getInnerRectF().contains(scene) && qAbs(mouse->pos().y() - lineY) <= 6.0) m_draggingThreshold = true; if(m_draggingThreshold) { m_threshold = qBound(0.0, axisY->getValueForPos(scene.y()), axisY->getRangeMax()); if(event->type() == QEvent::MouseButtonRelease) m_draggingThreshold = false; updateThreshold(); return true; } } if(event->type() == QEvent::ToolTip) { // 点提示显示对应运行的完整参数组合;不以排序后的点号代替运行编号。 QHelpEvent* help = static_cast(event); for(int i = 0; i < m_points.size(); ++i) { const QPointF position = view->mapFromScene(QPointF(m_plot->getAxisOf(0)->getPosForValue(m_points[i].x()), axisY->getPosForValue(m_points[i].y()))); if(qAbs(position.x() - help->pos().x()) > 6.0 || qAbs(position.y() - help->pos().y()) > 6.0) continue; const nmDataSensitive::RunResult& run = m_results->m_runs[m_runRows[i]]; QToolTip::showText(help->globalPos(), tr("Run %1\n%2\nGoodness of fit: %3 MPa") .arg(run.m_nRunIndex + 1).arg(sensitivityRunParameters(*m_results, run)) .arg(run.m_dPressureRmse, 0, 'g', 6), view->viewport()); return true; } QToolTip::hideText(); event->ignore(); return true; } return iDlgBase::eventFilter(object, event); } class SensitivityDistributionPlot : public QWidget { public: explicit SensitivityDistributionPlot(QWidget* parent) : QWidget(parent), m_valid(false) { // 分布图复用参数分位数计算,避免绘图与实际采样使用两套分布定义。 setMinimumSize(340, 300); } void setVariable(const nmDataSensitive::VariableSampling& variable, bool valid) { m_variable = variable; m_valid = valid; update(); } protected: void paintEvent(QPaintEvent*) override { // 蓝线为累积概率,红色虚线为按峰值归一化的概率密度,与 Saphir 的显示一致。 QPainter painter(this); painter.fillRect(rect(), palette().color(QPalette::Window)); painter.setRenderHint(QPainter::Antialiasing); QVector x(201), cumulativeValues(201); const bool bounded = m_variable.m_distribution >= nmDataSensitive::VariableSampling::TRUNCATED_NORMAL; bool valid = m_valid; for(int i = 0; valid && i < x.size(); ++i) { // 两端加密以平滑显示尾部,有界分布的横轴保留精确上下限。 const double probability = (1.0 - std::cos(3.141592653589793 * i / 200.0)) * 0.5; cumulativeValues[i] = bounded ? probability : 0.0001 + 0.9998 * probability; // Skin 保留原分布绘图,其余参数显示与实际采样一致的受限分布。 valid = m_variable.distributionQuantile(qBound(1e-10, cumulativeValues[i], 1.0 - 1e-10), x[i], m_variable.m_varName != "Skin"); if(bounded && i == 0) x[i] = m_variable.m_distributionMin; if(bounded && i == 200) x[i] = m_variable.m_distributionMax; valid = valid && convertSamplingValue(m_variable.m_varName, x[i], samplingBaseUnit(m_variable.m_varName), m_variable.m_modelValue.getUnit(), x[i]); } if(!valid || x.last() <= x.first()) { painter.drawText(rect(), Qt::AlignCenter, nmWxSensitive::tr("Invalid distribution parameters")); return; } // 坐标外侧沿用窗口底色,绘图区留白,标题放在纵轴左侧。 const QRectF plot(76, 12, width() - 96, height() - 66); painter.fillRect(plot, Qt::white); const QRectF dataArea = plot.adjusted(12, 16, -12, -16); // 横轴采用 1、2、5 的整齐步长,按分布范围自适应,不限制实际采样值。 const double roughStep = (x.last() - x.first()) / 5.0; const double scale = std::pow(10.0, std::floor(std::log10(roughStep))); const double fraction = roughStep / scale; const double step = (fraction <= 1.0 ? 1.0 : fraction <= 2.0 ? 2.0 : fraction <= 5.0 ? 5.0 : 10.0) * scale; double axisMin = std::floor(x.first() / step) * step; double axisMax = std::ceil(x.last() / step) * step; // 除 Skin 外,绘图范围与系统允许范围取交集;只裁剪显示,不改变分布概率。 if(m_variable.m_varName != "Skin") { double minimum = 0.0, maximum = 0.0; const QString unit = m_variable.m_modelValue.getUnit(); if(samplingPhysicalRange(m_variable.m_varName, minimum, maximum) && convertSamplingValue(m_variable.m_varName, minimum, samplingBaseUnit(m_variable.m_varName), unit, minimum) && convertSamplingValue(m_variable.m_varName, maximum, samplingBaseUnit(m_variable.m_varName), unit, maximum)) { axisMin = qMax(axisMin, minimum); axisMax = qMin(axisMax, maximum); } } if(axisMax <= axisMin) { painter.drawText(plot, Qt::AlignCenter, nmWxSensitive::tr("Distribution outside the allowed range")); return; } // 曲线保留真实边界,坐标刻度向外取整;压力下限 0.1 等情形也能显示 0 刻度。 const double curveMin = axisMin, curveMax = axisMax; axisMin = std::floor(curveMin / step) * step; axisMax = std::ceil(curveMax / step) * step; QVector density(201); double peak = 0.0; for(int i = 0; i < x.size(); ++i) { const int a = qMax(0, i - 1), b = qMin(200, i + 1); density[i] = x[b] > x[a] ? (cumulativeValues[b] - cumulativeValues[a]) / (x[b] - x[a]) : 0.0; if(m_variable.m_distribution == nmDataSensitive::VariableSampling::TRIANGULAR && ((i == 0 && m_variable.m_peak > m_variable.m_distributionMin) || (i == 200 && m_variable.m_peak < m_variable.m_distributionMax))) density[i] = 0.0; peak = qMax(peak, density[i]); } // 蓝色点状主网格配合黑色主、次刻度,概率轴按 0.1 标注。 const double tickStart = std::floor(axisMin / step) * step; const int intervals = int(std::ceil((axisMax - tickStart) / step)); // 仅容忍刻度计算的浮点舍入误差。 const double tickTolerance = DBL_EPSILON * 8.0 * qMax(qAbs(axisMin), qAbs(axisMax)); for(int i = 0; i <= intervals * 5; ++i) { const double value = tickStart + i * step / 5.0; if(value < axisMin - tickTolerance || value > axisMax + tickTolerance) continue; const double px = dataArea.left() + (value - axisMin) / (axisMax - axisMin) * dataArea.width(); if(i % 5 == 0) { painter.setPen(QPen(QColor(100, 120, 255), 1, Qt::DotLine)); painter.drawLine(QPointF(px, plot.top()), QPointF(px, plot.bottom())); painter.setPen(Qt::black); painter.drawText(QRectF(px - 36, plot.bottom() + 8, 72, 20), Qt::AlignCenter, formatSamplingValue(value, 4)); } painter.setPen(Qt::black); painter.drawLine(QPointF(px, plot.bottom()), QPointF(px, plot.bottom() + (i % 5 == 0 ? 7 : 4))); } for(int i = 0; i <= 50; ++i) { const double ratio = i / 50.0; const double py = dataArea.bottom() - ratio * dataArea.height(); if(i % 10 == 0) { painter.setPen(QPen(QColor(100, 120, 255), 1, Qt::DotLine)); painter.drawLine(QPointF(plot.left(), py), QPointF(plot.right(), py)); } painter.setPen(Qt::black); painter.drawLine(QPointF(plot.left(), py), QPointF(plot.left() - (i % 5 == 0 ? 7 : 4), py)); if(i % 5 == 0) painter.drawText(QRectF(28, py - 10, plot.left() - 40, 20), Qt::AlignRight | Qt::AlignVCenter, QString::number(ratio)); } QPolygonF cumulative, probability; for(int i = 0; i < x.size(); ++i) { const double px = dataArea.left() + (x[i] - axisMin) / (axisMax - axisMin) * dataArea.width(); cumulative << QPointF(px, dataArea.bottom() - cumulativeValues[i] * dataArea.height()); probability << QPointF(px, dataArea.bottom() - (peak > 0.0 ? density[i] / peak : 0.0) * dataArea.height()); } // 曲线也按允许的横轴区间裁剪,避免负值部分画进坐标留白或标签区域。 painter.save(); const double clipLeft = dataArea.left() + (curveMin - axisMin) / (axisMax - axisMin) * dataArea.width(); const double clipWidth = (curveMax - curveMin) / (axisMax - axisMin) * dataArea.width(); painter.setClipRect(QRectF(clipLeft, plot.top(), clipWidth, plot.height())); painter.setPen(QPen(Qt::blue, 1.5)); painter.drawPolyline(cumulative); painter.setPen(QPen(Qt::red, 1, Qt::DashLine)); painter.drawPolyline(probability); painter.restore(); painter.setPen(Qt::black); painter.drawRect(plot); painter.save(); painter.translate(17, plot.center().y()); painter.rotate(-90); painter.drawText(QRectF(-plot.height() / 2, -10, plot.height(), 20), Qt::AlignCenter, nmWxSensitive::tr("Probability distribution function")); painter.restore(); painter.drawText(QRectF(plot.left(), height() - 22, plot.width(), 20), Qt::AlignCenter, m_variable.m_varName + " [" + m_variable.m_modelValue.getUnit() + "]"); } private: nmDataSensitive::VariableSampling m_variable; bool m_valid; }; // ========= 右对齐委托:保证显示和编辑时都是右对齐 ========= class RightAlignDelegate : public QStyledItemDelegate { public: explicit RightAlignDelegate(QObject *parent = nullptr) : QStyledItemDelegate(parent) {} void initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const override { QStyledItemDelegate::initStyleOption(option, index); option->displayAlignment = Qt::AlignRight | Qt::AlignVCenter; } QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override { QWidget *editor = QStyledItemDelegate::createEditor(parent, option, index); if (QLineEdit *line = qobject_cast(editor)) { line->setAlignment(Qt::AlignRight | Qt::AlignVCenter); } return editor; } void setEditorData(QWidget *editor, const QModelIndex &index) const override { QLineEdit* line = qobject_cast(editor); // 编辑时沿用三位有效数字显示,避免重新暴露浮点尾数。 if (line) line->setText(index.data(Qt::UserRole).isValid() ? formatSamplingValue(index.data(Qt::UserRole).toDouble(), 3) : index.data(Qt::DisplayRole).toString()); } void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override { QLineEdit* line = qobject_cast(editor); if (!line) return; bool ok = false; double value = line->text().trimmed().toDouble(&ok); // 编辑后文本未改变时保留完整采样值,不把显示舍入当成用户修改。 const QVariant original = index.data(Qt::UserRole); if (original.isValid() && line->text() == formatSamplingValue(original.toDouble(), 3)) value = original.toDouble(&ok); // 非法输入保留原文,由 Generate 或预览统一定位并提示。 model->setData(index, ok && qIsFinite(value) ? QVariant(value) : QVariant(), Qt::UserRole); model->setData(index, ok && qIsFinite(value) ? formatSamplingValue(value, 3) : line->text(), Qt::DisplayRole); } }; // 表格仅承载可见行,完整组合编号使用六十四位整数,滚动过程中按编号取数。 class SensitivityRunsView : public QAbstractScrollArea { public: SensitivityRunsView(const nmDataSensitive& data, const QIcon& arrow, QWidget* parent) : QAbstractScrollArea(parent), m_data(data), m_arrow(arrow), m_table(nullptr), m_total(data.getTotalModelCount()), m_first(0), m_selected(0), m_visibleRows(1), m_updating(false), m_wheelDelta(0) { // 使用现有表格控件绘制表头、行选中和单元格,滚动条控制完整列表。 setFrameShape(QFrame::StyledPanel); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_table = new QTableWidget(viewport()); m_table->setFrameShape(QFrame::NoFrame); m_table->setColumnCount(3); m_table->setHorizontalHeaderLabels(QStringList() << "" << "#" << nmWxSensitive::tr("Description")); m_table->verticalHeader()->setVisible(false); m_table->verticalHeader()->setDefaultSectionSize(24); m_table->horizontalHeader()->setResizeMode(0, QHeaderView::Fixed); m_table->horizontalHeader()->setResizeMode(1, QHeaderView::Fixed); m_table->horizontalHeader()->setResizeMode(2, QHeaderView::Interactive); m_table->horizontalHeader()->setStretchLastSection(true); m_table->horizontalHeader()->setHighlightSections(false); m_table->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); m_table->setColumnWidth(0, 20); m_table->setColumnWidth(1, m_table->fontMetrics().width(QString::number(m_total)) + 16); m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); m_table->setSelectionBehavior(QAbstractItemView::SelectRows); m_table->setSelectionMode(QAbstractItemView::SingleSelection); m_table->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_table->setWordWrap(false); m_table->setStyleSheet("QTableWidget { background-color: white; }"); int descriptionWidth = 0; for (int i = 0; i < m_data.getVariableCount(); ++i) { nmDataSensitive::VariableSampling& variable = m_data.getVariable(i); if (!variable.getEnabled()) continue; m_indices.append(i); int width = 0; const QVector samples = variable.samplingValues(); for (int row = 0; row < samples.size(); ++row) width = qMax(width, m_table->fontMetrics().width(parameterDescription(i, samples[row]))); if (m_data.getCalculationType() == nmDataSensitive::CALC_DETERMINISTIC) descriptionWidth = qMax(descriptionWidth, width); else descriptionWidth += width + m_table->fontMetrics().width(" + "); } // 长组合可横向滚动并通过提示查看完整文本,保持各行高度一致。 m_table->setColumnWidth(2, qMax(200, descriptionWidth + 16)); QVBoxLayout* layout = new QVBoxLayout(viewport()); layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(m_table); m_table->installEventFilter(this); m_table->viewport()->installEventFilter(this); verticalScrollBar()->installEventFilter(this); } void scrollAction(int action) { // 超大列表的滚动条位置按比例映射,单步、整屏移动仍保持精确行号。 switch (action) { case QAbstractSlider::SliderSingleStepAdd: scrollRows(1); break; case QAbstractSlider::SliderSingleStepSub: scrollRows(-1); break; case QAbstractSlider::SliderPageStepAdd: scrollRows(m_visibleRows); break; case QAbstractSlider::SliderPageStepSub: scrollRows(-m_visibleRows); break; case QAbstractSlider::SliderToMinimum: m_first = 0; refreshRows(); break; case QAbstractSlider::SliderToMaximum: m_first = maximumFirst(); refreshRows(); break; case QAbstractSlider::SliderMove: m_first = firstFromPosition(verticalScrollBar()->sliderPosition()); refreshRows(); break; default: break; } } protected: void resizeEvent(QResizeEvent* event) override { // 窗口变化时仅调整可见行数量,绝不按总次数分配表格项。 QAbstractScrollArea::resizeEvent(event); refreshRows(); } void scrollContentsBy(int, int) override { // 处理拖动滚动条或系统直接改变位置的情况。 if (m_updating) return; m_first = firstFromPosition(verticalScrollBar()->value()); refreshRows(); } bool eventFilter(QObject* object, QEvent* event) override { // 滚轮、键盘和鼠标均操作完整运行列表,而不是可见行缓存。 if (event->type() == QEvent::Wheel) { QWheelEvent* wheel = static_cast(event); if (wheel->orientation() == Qt::Vertical) { m_wheelDelta += wheel->delta(); const int steps = m_wheelDelta / 120; m_wheelDelta %= 120; scrollRows(-qint64(steps) * QApplication::wheelScrollLines()); wheel->accept(); return true; } } else if (event->type() == QEvent::KeyPress) { QKeyEvent* key = static_cast(event); qint64 movement = 0; switch (key->key()) { case Qt::Key_Up: movement = -1; break; case Qt::Key_Down: movement = 1; break; case Qt::Key_PageUp: movement = -m_visibleRows; break; case Qt::Key_PageDown: movement = m_visibleRows; break; case Qt::Key_Home: m_selected = 0; break; case Qt::Key_End: m_selected = m_total - 1; break; default: return QAbstractScrollArea::eventFilter(object, event); } if (movement < 0) m_selected -= qMin(m_selected, quint64(-movement)); else if (movement > 0) m_selected += qMin(m_total - 1 - m_selected, quint64(movement)); if (m_selected < m_first) m_first = m_selected; else if (m_selected - m_first >= quint64(m_visibleRows)) m_first = m_selected - m_visibleRows + 1; refreshRows(); key->accept(); return true; } else if (object == m_table->viewport() && (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonRelease)) { QMouseEvent* mouse = static_cast(event); const int row = m_table->rowAt(mouse->pos().y()); if (mouse->button() == Qt::LeftButton && row >= 0) { m_selected = m_first + row; refreshRows(); } } else if (object == m_table->viewport() && event->type() == QEvent::Resize) { refreshRows(); } return QAbstractScrollArea::eventFilter(object, event); } private: quint64 maximumFirst() const { return m_total > quint64(m_visibleRows) ? m_total - m_visibleRows : 0; } quint64 firstFromPosition(int position) const { // 两端准确对应首尾,中间位置不把六十四位总数截断为 int。 const int maximum = verticalScrollBar()->maximum(); if (position <= 0 || maximum == 0) return 0; if (position >= maximum) return maximumFirst(); return qMin(maximumFirst(), quint64((long double)position * maximumFirst() / maximum)); } void scrollRows(qint64 movement) { // 用差值限制移动距离,避免无符号行号在首尾发生溢出。 if (movement < 0) m_first -= qMin(m_first, quint64(-movement)); else m_first += qMin(maximumFirst() - m_first, quint64(movement)); refreshRows(); } QString parameterDescription(int index, double value) { // 描述沿用采样表的三位有效数字和当前显示单位。 nmDataSensitive::VariableSampling& variable = m_data.getVariable(index); const QString unit = variable.getModelValue().getUnit(); convertSamplingValue(variable.getVarName(), value, samplingBaseUnit(variable.getVarName()), unit, value); return variable.getVarName() + " = " + formatSamplingValue(value, 3) + (unit.isEmpty() ? QString() : " " + unit); } void refreshRows() { if (m_updating || !m_table || !viewport()->layout()) return; // 始终只生成视口内的行,所有组合共用一根连续滚动条。 m_updating = true; viewport()->layout()->activate(); m_visibleRows = qMax(1, m_table->viewport()->height() / 24); setVerticalScrollBarPolicy(m_total > quint64(m_visibleRows) ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); m_first = qMin(m_first, maximumFirst()); QScrollBar* bar = verticalScrollBar(); const quint64 last = maximumFirst(); const int maximum = int(qMin(last, quint64(INT_MAX))); const bool blocked = bar->blockSignals(true); bar->setRange(0, maximum); bar->setSingleStep(1); bar->setPageStep(last ? qMax(1, int((long double)m_visibleRows * maximum / last)) : m_visibleRows); int position = last ? int((long double)m_first * maximum / last) : 0; // 比例舍入后仍保留首尾箭头的可用状态。 if (last > quint64(INT_MAX) && m_first > 0 && m_first < last) position = qBound(1, position, maximum - 1); bar->setValue(position); bar->blockSignals(blocked); const int rows = int(qMin(quint64(m_visibleRows), m_total - m_first)); m_table->setRowCount(rows); for (int row = 0; row < rows; ++row) { const quint64 run = m_first + row; QVector values; if (!m_data.getRunValues(run, values)) break; int changingIndex = -1; if (m_data.getCalculationType() == nmDataSensitive::CALC_DETERMINISTIC) { quint64 remaining = run; for (int i = 0; i < m_indices.size(); ++i) { const quint64 count = m_data.getVariable(m_indices[i]).getNumber(); if (remaining < count) { changingIndex = m_indices[i]; break; } remaining -= count; } } QStringList descriptions; for (int i = 0; i < m_indices.size(); ++i) { const int index = m_indices[i]; if (changingIndex < 0 || index == changingIndex) descriptions << parameterDescription(index, values[index]); } QTableWidgetItem* arrow = new QTableWidgetItem(); arrow->setIcon(run == m_selected ? m_arrow : QIcon()); m_table->setItem(row, 0, arrow); QTableWidgetItem* number = new QTableWidgetItem(QString::number(run + 1)); number->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); m_table->setItem(row, 1, number); QTableWidgetItem* description = new QTableWidgetItem(descriptions.join(" + ")); description->setToolTip(description->text()); m_table->setItem(row, 2, description); } if (m_selected >= m_first && m_selected - m_first < quint64(rows)) { m_table->setCurrentCell(int(m_selected - m_first), 1); m_table->selectRow(int(m_selected - m_first)); } else { m_table->setCurrentIndex(QModelIndex()); m_table->clearSelection(); } m_updating = false; // 横向滚动条显隐可能改变视口高度,补齐最终可见行数量。 if (m_visibleRows != qMax(1, m_table->viewport()->height() / 24)) refreshRows(); } nmDataSensitive m_data; QIcon m_arrow; QTableWidget* m_table; QList m_indices; quint64 m_total; quint64 m_first; quint64 m_selected; int m_visibleRows; bool m_updating; int m_wheelDelta; }; nmWxSensitive::nmWxSensitive(QWidget *parent, nmDataAnalyzeManager* manager) : iDlgBase(parent), m_selectedValueIndex(0), m_loading(true), m_pDataManager(manager ? manager : nmDataAnalyzeManager::getCurrentInstance()), m_previewView(nullptr), m_pSensitivityTask(nullptr), m_calculationRunning(false) { // 显式绑定所属分析窗口,后续切换窗口不会改变配置保存目标。 if (m_pDataManager) m_sensitiveData = m_pDataManager->getSensitiveDataCopy(); refreshModelValues(); setupUI(); createConnections(); populateTreeWidget(); createDetailPages(); loadDataToUI(); m_loading = false; onTreeItemChanged(nullptr, 0); // 结果发布或清除后,同步本窗口持有的批次数据。 if(m_pDataManager) { connect(m_pDataManager, SIGNAL(sigSensitiveResultsChanged()), this, SLOT(onResultsChanged())); connect(m_pDataManager, SIGNAL(sigPebiResultSnapshotChanged()), this, SLOT(onModelResultChanged())); } onResultsChanged(); } nmWxSensitive::~nmWxSensitive() { // 父窗口强制销毁时先请求停止并等 DLL 返回,线程结束前不释放计算副本。 if(m_pSensitivityTask) { disconnect(m_pSensitivityTask, nullptr, this, nullptr); m_pSensitivityTask->requestCancel(); m_pSensitivityTask->wait(); delete m_pSensitivityTask; } } void nmWxSensitive::reject() { // Esc 与取消按钮使用相同的停止路径,保留已完成组供结束后查看。 if(m_calculationRunning) onCancelClicked(); else iDlgBase::reject(); } void nmWxSensitive::closeEvent(QCloseEvent* event) { if(m_calculationRunning) { onCancelClicked(); event->ignore(); return; } iDlgBase::closeEvent(event); } void nmWxSensitive::refreshModelValues() { // 六个参数以最后成功求解的快照为准,编辑中的模型只提供属性类型和显示单位。 const QSharedPointer snapshot = m_pDataManager ? m_pDataManager->getPebiResultSnapshot() : QSharedPointer(); const bool resetRanges = !snapshot.isNull() && snapshot->getSnapshotId() != m_sensitiveData.getModelSnapshotId(); const QString wellCode = !snapshot.isNull() ? snapshot->getSensitivityWellCode() : QString(); nmDataWellBase* well = m_pDataManager ? m_pDataManager->findWellByCode(wellCode) : nullptr; nmDataReservoir* reservoir = m_pDataManager ? m_pDataManager->getReservoirData() : nullptr; const nmPebiResultWellSnapshot* solvedWell = nullptr; for(int i = 0; !snapshot.isNull() && i < snapshot->getWellCount(); ++i) { if(snapshot->getWellAt(i)->m_sWellCode == wellCode) { solvedWell = snapshot->getWellAt(i); break; } } const QStringList names = QStringList() << "Skin" << "C" << "Pi" << "k" << "h" << QString(QChar(0x03c6)); for (int n = 0; n < names.size(); ++n) { nmDataAttribute* attribute = nullptr; const QString name = names[n]; if (well && name == "Skin" && well->getPerforationCount() > 0) attribute = &well->getPerforation(0)->getSkin(); else if (well && name == "C") attribute = &well->getWellboreStorage(); else if (reservoir && name == "Pi") attribute = &reservoir->getInitialPressure(); else if (reservoir && name == "k") attribute = &reservoir->getPermeability(); else if (reservoir && name == "h") attribute = &reservoir->getThickness(); else if (reservoir && name == QString(QChar(0x03c6))) attribute = &reservoir->getPorosity(); int index = -1; for (int i = 0; i < m_sensitiveData.getVariableCount(); ++i) if (m_sensitiveData.getVariable(i).getVarName() == name) { index = i; break; } const bool fresh = index < 0; if (fresh) { nmDataSensitive::VariableSampling variable; variable.setVarName(name); variable.setVarGroup(n < 2 ? "Tested Well" : "Reservoir"); variable.setNumber(5); m_sensitiveData.getVariables().append(variable); index = m_sensitiveData.getVariableCount() - 1; } nmDataSensitive::VariableSampling& variable = m_sensitiveData.getVariable(index); // 在局部属性上替换数值,不能把未求解的编辑值带入 Model value 或默认范围。 nmDataAttribute solvedAttribute; if (!snapshot.isNull()) { if (attribute) solvedAttribute = *attribute; else solvedAttribute.setUnit(samplingBaseUnit(name)); attribute = &solvedAttribute; const nmPebiResultReservoirParameters& solvedReservoir = snapshot->getReservoirParameters(); if (name == "Skin" || name == "C") { if (!solvedWell || (name == "Skin" && !solvedWell->m_bHasSkin)) attribute = nullptr; else solvedAttribute.setValue(name == "Skin" ? solvedWell->m_dSkin : solvedWell->m_dWellboreStorage); } else if (name == "Pi") solvedAttribute.setValue(solvedReservoir.m_dInitialPressure); else if (name == "k") solvedAttribute.setValue(solvedReservoir.m_dPermeability); else if (name == "h") solvedAttribute.setValue(solvedReservoir.m_dThickness); else if (name == QString(QChar(0x03c6))) solvedAttribute.setValue(solvedReservoir.m_dPorosity); } double physicalMin = 0.0, physicalMax = 0.0; if (!attribute || !samplingPhysicalRange(name, physicalMin, physicalMax)) { variable.setEnabled(false); variable.getModelValue().setValue(QVariant()); continue; } const QString baseUnit = samplingBaseUnit(name); QString displayUnit = attribute->getUnit(); if (displayUnit == "dimensionless") displayUnit.clear(); double converted = 0.0; if (!convertSamplingValue(name, attribute->getValue().toDouble(), baseUnit, displayUnit, converted)) displayUnit = baseUnit; double minimum = variable.getMinValue().getValue().toDouble(); double maximum = variable.getMaxValue().getValue().toDouble(); QVector values = variable.getValues(); double previousModel = variable.getModelValue().getValue().toDouble(); bool migrated = !fresh && (m_sensitiveData.usesBaseUnits() || variable.getMode() == nmDataSensitive::VariableSampling::MODE_MANUAL || !values.isEmpty()); if (migrated && !m_sensitiveData.usesBaseUnits()) { const QString oldUnit = variable.getModelValue().getUnit(); convertSamplingValue(name, previousModel, oldUnit, baseUnit, previousModel); migrated = convertSamplingValue(name, minimum, oldUnit, baseUnit, minimum) && convertSamplingValue(name, maximum, oldUnit, baseUnit, maximum); for (int i = 0; migrated && i < values.size(); ++i) migrated = convertSamplingValue(name, values[i], oldUnit, baseUnit, values[i]); } // 识别 Skin、Pi 的自动默认范围,按当前模型更新,保留用户自定义区间。 const bool previousDefault = variable.getMode() == nmDataSensitive::VariableSampling::MODE_AUTOMATIC && ((nearlyEqualSamplingValue(minimum, qMin(previousModel / 2.0, previousModel * 2.0)) && nearlyEqualSamplingValue(maximum, qMax(previousModel / 2.0, previousModel * 2.0))) || (name == "Skin" && previousModel != 0.0 && nearlyEqualSamplingValue(minimum, qMax(physicalMin, qMin(previousModel * 0.9, previousModel * 1.1))) && nearlyEqualSamplingValue(maximum, qMin(physicalMax, qMax(previousModel * 0.9, previousModel * 1.1)))) || (name == "Skin" && previousModel == 0.0 && (minimum == -1.0 || minimum == 0.0) && maximum == 1.0) || (name == "Pi" && variable.getModelValue().getMinValue().isValid() && variable.getModelValue().getMaxValue().isValid() && nearlyEqualSamplingValue(minimum, variable.getModelValue().getMinValue().toDouble()) && nearlyEqualSamplingValue(maximum, variable.getModelValue().getMaxValue().toDouble()))); // 新的正式求解结果重新建立范围,采样数量、模式及手动值保持原设置。 if (resetRanges || !migrated || ((name == "Skin" || name == "Pi") && previousDefault)) { const double model = attribute->getValue().toDouble(); minimum = qMin(model / 2.0, model * 2.0); maximum = qMax(model / 2.0, model * 2.0); if (name == "Skin") { minimum = model == 0.0 ? -1.0 : qMin(model * 0.9, model * 1.1); maximum = model == 0.0 ? 1.0 : qMax(model * 0.9, model * 1.1); } if (name == "Pi") { minimum = physicalMin; maximum = physicalMax; } } if (!migrated) { values.clear(); variable.setMode(nmDataSensitive::VariableSampling::MODE_AUTOMATIC); variable.setLog(false); variable.setNumber(5); } // 初始范围和已保存范围都截断到硬限制,手动采样值留给提交校验。 minimum = qBound(physicalMin, minimum, physicalMax); maximum = qBound(physicalMin, maximum, physicalMax); variable.setModelValue(*attribute); variable.getModelValue().setUnit(displayUnit); variable.getModelValue().setValueRange(physicalMin, physicalMax); variable.setMinValue(variable.getModelValue()); variable.setMaxValue(variable.getModelValue()); variable.getMinValue().setValue(minimum); variable.getMaxValue().setValue(maximum); variable.setValues(values); if(!variable.m_distributionInitialized || resetRanges) { // Saphir 默认正态均值为模型值、标准差为其三分之一;Skin=0 时使用 5/3。 const double model = attribute->getValue().toDouble(); variable.m_mean = model; variable.m_stdDev = model == 0.0 ? 5.0 / 3.0 : std::fabs(model) / 3.0; variable.m_distributionMin = minimum; variable.m_distributionMax = maximum; variable.m_peak = qBound(variable.m_distributionMin, model * 1.01, variable.m_distributionMax); variable.m_distributionInitialized = true; } // Saphir 在重新打开对话框时同步无效 Log 范围的实际状态。 if (variable.getMode() == nmDataSensitive::VariableSampling::MODE_AUTOMATIC && (minimum <= 0.0 || maximum <= 0.0)) variable.setLog(false); } for (int i = 0; i < m_sensitiveData.getVariableCount(); ++i) if (!isSupportedSamplingVariable(m_sensitiveData.getVariable(i).getVarName())) m_sensitiveData.getVariable(i).setEnabled(false); m_sensitiveData.setUsesBaseUnits(true); if (!snapshot.isNull()) m_sensitiveData.setModelSnapshotId(snapshot->getSnapshotId()); } void nmWxSensitive::onModelResultChanged() { // 查看井切换也会发送快照通知;只有新求解快照才刷新已打开窗口。 const QSharedPointer snapshot = m_pDataManager->getPebiResultSnapshot(); if (snapshot.isNull() || snapshot->getSnapshotId() == m_sensitiveData.getModelSnapshotId()) return; m_loading = true; refreshModelValues(); loadDataToUI(true); m_loading = false; updateModelCount(); } void nmWxSensitive::loadDataToUI(bool refreshRangesOnly) { // 加载过程中阻止联动刷新,先恢复设置,再一次生成各参数表格。 if (!refreshRangesOnly) calculationTypeCombo->setCurrentIndex(int(m_sensitiveData.getCalculationType())); m_monteCarloCount->setValue(m_sensitiveData.getMonteCarloCount()); for (int i = 0; i < m_sensitiveData.getVariableCount(); ++i) { nmDataSensitive::VariableSampling& variable = m_sensitiveData.getVariable(i); const QString name = variable.getVarName(); const int idx = findVariableIndex(name); if (idx < 0) continue; const bool available = isSupportedSamplingVariable(name) && variable.getModelValue().getValue().isValid(); for (int group = 0; group < variablesTree->topLevelItemCount(); ++group) { QTreeWidgetItem* parent = variablesTree->topLevelItem(group); for (int child = 0; child < parent->childCount(); ++child) { QTreeWidgetItem* item = parent->child(child); if (item->text(0) != name) continue; item->setDisabled(!available); if (!refreshRangesOnly) item->setCheckState(0, available && variable.getEnabled() ? Qt::Checked : Qt::Unchecked); } } detailStack->widget(idx + 1)->setEnabled(available); // 求解后的刷新只更新模型值和范围,不覆盖窗口中尚未提交的采样设置。 if (!refreshRangesOnly) { m_modeCombos[idx]->setCurrentIndex(int(variable.getMode())); m_logChecks[idx]->setChecked(variable.getLog()); m_numSpins[idx]->setValue(qMax(1, variable.getNumber())); } // 已打开窗口沿用原显示单位,避免手动表格的数值被换成另一种单位解释。 const QString unit = refreshRangesOnly ? m_unitLabels[idx]->text() : variable.getModelValue().getUnit(); variable.getModelValue().setUnit(unit); m_unitLabels[idx]->setText(unit); double model = variable.getModelValue().getValue().toDouble(); double minimum = variable.getMinValue().getValue().toDouble(); double maximum = variable.getMaxValue().getValue().toDouble(); convertSamplingValue(name, model, samplingBaseUnit(name), unit, model); convertSamplingValue(name, minimum, samplingBaseUnit(name), unit, minimum); convertSamplingValue(name, maximum, samplingBaseUnit(name), unit, maximum); setSamplingEditValue(m_modelSpins[idx], model); setSamplingEditValue(m_minSpins[idx], minimum); setSamplingEditValue(m_maxSpins[idx], maximum); m_distributionCombos[idx]->setCurrentIndex(int(variable.m_distribution)); const double distributionValues[] = {variable.m_mean, variable.m_stdDev, variable.m_peak, variable.m_distributionMin, variable.m_distributionMax}; for(int field = 0; field < 5; ++field) { double display = distributionValues[field]; convertSamplingValue(name, display, samplingBaseUnit(name), unit, display); setSamplingEditValue(m_distributionPanels[idx]->findChild(distributionFields[field]), display); } double displayMin = variable.getModelValue().getMinValue().toDouble(); double displayMax = variable.getModelValue().getMaxValue().toDouble(); convertSamplingValue(name, displayMin, samplingBaseUnit(name), unit, displayMin); convertSamplingValue(name, displayMax, samplingBaseUnit(name), unit, displayMax); const QString rangeHint = tr("Allowed range: [%1, %2] %3") .arg(formatSamplingValue(displayMin, 6)).arg(formatSamplingValue(displayMax, 6)).arg(unit); m_minSpins[idx]->setToolTip(rangeHint); m_maxSpins[idx]->setToolTip(rangeHint); if (!refreshRangesOnly && variable.getMode() == nmDataSensitive::VariableSampling::MODE_MANUAL) { QTableWidget* table = m_valueTables[idx]; table->setRowCount(variable.getNumber()); const QVector& values = variable.getValues(); for (int row = 0; row < values.size() && row < table->rowCount(); ++row) { double display = values[row]; convertSamplingValue(name, display, samplingBaseUnit(name), unit, display); QTableWidgetItem* item = new QTableWidgetItem(formatSamplingValue(display, 3)); item->setData(Qt::UserRole, display); table->setItem(row, 1, item); } } updateValuesForPage(idx); } } int nmWxSensitive::findVariableIndex(const QString& varName) { // 根据创建顺序查找变量索引 QStringList allVars; allVars << "Zw" << "Hw" << "Lw" << "Skin" << "C" // Tested Well << "Pi" << "k" << "h" << QString(QChar(0x03c6)) << "ntg" << "kz/kr" // Reservoir << "Total compressibility"; // Pvt return allVars.indexOf(varName); } void nmWxSensitive::updateModelCount() { // 勾选、数量和方法修改后立即更新总次数,不等待 Generate。 if (m_loading) return; m_sensitiveData.setCalculationType( static_cast(calculationTypeCombo->currentIndex())); for (int group = 0; group < variablesTree->topLevelItemCount(); ++group) { QTreeWidgetItem* parent = variablesTree->topLevelItem(group); for (int child = 0; child < parent->childCount(); ++child) { QTreeWidgetItem* item = parent->child(child); for (int i = 0; i < m_sensitiveData.getVariableCount(); ++i) { nmDataSensitive::VariableSampling& variable = m_sensitiveData.getVariable(i); if (variable.getVarName() != item->text(0)) continue; variable.setEnabled(!item->isDisabled() && item->checkState(0) == Qt::Checked); const int idx = findVariableIndex(variable.getVarName()); if (idx >= 0) variable.setNumber(m_numSpins[idx]->value()); } } } bool overflow = false; m_sensitiveData.setMonteCarloCount(m_monteCarloCount->value()); const quint64 count = m_sensitiveData.calculateModelCount(&overflow); m_sensitiveData.setTotalModelCount(count); modelCountLabel->setText(overflow ? tr("Too many models") : QString::number(count)); previewButton->setEnabled(!overflow && count > 0); const bool monteCarlo = m_sensitiveData.getCalculationType() == nmDataSensitive::CALC_MONTE_CARLO; modelCountLabel->setVisible(!monteCarlo); m_monteCarloCount->setVisible(monteCarlo); generateButton->setEnabled(m_pDataManager && !overflow && count > 0 && !m_calculationRunning); } bool nmWxSensitive::saveDataFromUI() { // 校验选中参数后再提交副本,失败不改写数据中心已有配置。 updateModelCount(); if (m_sensitiveData.getTotalModelCount() == 0) return false; nmDataSensitive candidate(m_sensitiveData); for (int i = 0; i < candidate.getVariableCount(); ++i) { nmDataSensitive::VariableSampling& variable = candidate.getVariable(i); const QString name = variable.getVarName(); const int idx = findVariableIndex(name); if (idx < 0 || !isSupportedSamplingVariable(name)) continue; const QString unit = m_unitLabels[idx]->text(); const QString baseUnit = samplingBaseUnit(name); bool valid = true; if(candidate.getCalculationType() == nmDataSensitive::CALC_MONTE_CARLO) { double median = 0.0; valid = readDistributionPage(idx, variable) && variable.distributionQuantile(0.5, median); if(!valid && variable.getEnabled()) { QMessageBox::warning(this, tr("Sensitivity"), tr("%1: invalid distribution parameters.").arg(name)); detailStack->setCurrentIndex(idx + 1); return false; } // 均值描述原分布,可以在截断区间之外;显式上下限及众数仍需符合系统范围。 if(valid && variable.m_distribution >= nmDataSensitive::VariableSampling::TRUNCATED_NORMAL) valid = validSamplingValue(name, variable.m_distributionMin) && validSamplingValue(name, variable.m_distributionMax); if(valid && variable.m_distribution == nmDataSensitive::VariableSampling::TRIANGULAR) valid = validSamplingValue(name, variable.m_peak); } else { variable.setMode(static_cast(m_modeCombos[idx]->currentIndex())); variable.setLog(m_logChecks[idx]->isChecked()); if (variable.getMode() == nmDataSensitive::VariableSampling::MODE_AUTOMATIC) { bool minOk = false, maxOk = false; double minimum = samplingEditValue(m_minSpins[idx], &minOk); double maximum = samplingEditValue(m_maxSpins[idx], &maxOk); valid = minOk && maxOk && convertSamplingValue(name, minimum, unit, baseUnit, minimum) && convertSamplingValue(name, maximum, unit, baseUnit, maximum) && minimum <= maximum && validSamplingValue(name, minimum) && validSamplingValue(name, maximum); if (valid) { variable.getMinValue().setValue(minimum); variable.getMaxValue().setValue(maximum); // Number=1 使用模型值,也必须在同一物理范围内。 QVector values = variable.samplingValues(); for (int row = 0; valid && row < values.size(); ++row) valid = validSamplingValue(name, values[row]); if (valid) variable.setValues(values); } } else { QVector values; QTableWidget* table = m_valueTables[idx]; for (int row = 0; row < table->rowCount(); ++row) { QTableWidgetItem* item = table->item(row, 1); bool ok = false; double value = 0.0; if (item) value = item->data(Qt::UserRole).isValid() ? item->data(Qt::UserRole).toDouble(&ok) : item->text().trimmed().toDouble(&ok); if (!ok || !convertSamplingValue(name, value, unit, baseUnit, value) || !validSamplingValue(name, value)) { valid = false; break; } values.append(value); } valid = valid && values.size() == variable.getNumber(); if (valid) variable.setValues(values); } } if (!valid && variable.getEnabled()) { double lower = 0.0, upper = 0.0; const bool hasRange = samplingPhysicalRange(name, lower, upper) && convertSamplingValue(name, lower, baseUnit, unit, lower) && convertSamplingValue(name, upper, baseUnit, unit, upper); QMessageBox::warning(this, tr("Sensitivity"), hasRange ? tr("%1: invalid sampling values. Allowed range: [%2, %3] %4. Check the sampling settings.") .arg(name).arg(formatSamplingValue(lower, 6)).arg(formatSamplingValue(upper, 6)).arg(unit) : tr("%1: the physical range is unavailable.").arg(name)); detailStack->setCurrentIndex(idx + 1); return false; } } m_sensitiveData = candidate; return true; } void nmWxSensitive::setupUI() { setWindowTitle(tr("Sensitivity")); setMinimumSize(900, 550); // 默认放大窗口,同时为屏幕边缘和窗口标题栏留出空间。 const QSize available = QApplication::desktop()->availableGeometry(this).size() - QSize(60, 80); resize(QSize(1250, 760).boundedTo(available)); QVBoxLayout *mainLayout = new QVBoxLayout(this); // ===== 顶部:Calculation type ===== QHBoxLayout *calcTypeLayout = new QHBoxLayout(); QLabel *calcTypeLabel = new QLabel(tr("Calculation type:"), this); calculationTypeCombo = new QComboBox(this); calculationTypeCombo->addItem(tr("Deterministic")); calculationTypeCombo->addItem(tr("Deterministic multivariate")); calculationTypeCombo->addItem(tr("Monte-Carlo")); //calculationTypeCombo->addItem(tr("Monte-Carlo + Improve")); calcTypeLayout->addWidget(calcTypeLabel); calcTypeLayout->addWidget(calculationTypeCombo); calcTypeLayout->addStretch(); mainLayout->addLayout(calcTypeLayout); // ===== 中间:左 Variables 面板 + 右 detailStack ===== QSplitter *centerSplitter = new QSplitter(Qt::Horizontal, this); centerSplitter->setChildrenCollapsible(false); // 不允许把某一侧完全折叠 centerSplitter->setHandleWidth(4); // 中间拖动条宽度 centerSplitter->setStyleSheet( "QSplitter::handle:horizontal {" " background-color: #C0C0C0;" "}" "QSplitter::handle:horizontal:hover {" " background-color: #A0A0A0;" "}" ); // --- 左侧:Variables 面板(QFrame + 标题栏 + Tree)--- QFrame *variablesPanel = new QFrame(this); variablesPanel->setFrameShape(QFrame::StyledPanel); variablesPanel->setFrameShadow(QFrame::Raised); variablesPanel->setMinimumWidth(230); QVBoxLayout *variablesPanelLayout = new QVBoxLayout(variablesPanel); variablesPanelLayout->setContentsMargins(0, 0, 0, 0); variablesPanelLayout->setSpacing(0); // 标题栏 QWidget *varHeader = new QWidget(variablesPanel); varHeader->setFixedHeight(24); varHeader->setAutoFillBackground(true); { QPalette pal = varHeader->palette(); pal.setColor(QPalette::Window, palette().color(QPalette::Midlight)); varHeader->setPalette(pal); } QHBoxLayout *varHeaderLayout = new QHBoxLayout(varHeader); varHeaderLayout->setContentsMargins(6, 0, 6, 0); varHeaderLayout->setSpacing(0); QLabel *varTitle = new QLabel(tr("Variables"), varHeader); varTitle->setAlignment(Qt::AlignVCenter | Qt::AlignLeft); QFont f = varTitle->font(); f.setBold(true); varTitle->setFont(f); varHeaderLayout->addWidget(varTitle); varHeaderLayout->addStretch(); // Tree 区域 variablesTree = new QTreeWidget(variablesPanel); variablesTree->setHeaderHidden(true); variablesTree->setFrameShape(QFrame::NoFrame); // 外层已经有 frame variablesTree->setMinimumHeight(350); variablesPanelLayout->addWidget(varHeader); variablesPanelLayout->addWidget(variablesTree, 1); // 把左侧 panel 加进 splitter centerSplitter->addWidget(variablesPanel); // --- 右侧:detailStack(后面每个 page 自己做标题栏)--- detailStack = new QStackedWidget(this); detailStack->setContentsMargins(0, 0, 0, 0); // index 0:默认提示页 QWidget *blankPage = new QWidget(this); QVBoxLayout *blankLayout = new QVBoxLayout(blankPage); blankLayout->setContentsMargins(0, 0, 0, 0); QLabel *blankLabel = new QLabel(tr("Select a variable on the left."), blankPage); blankLayout->addStretch(); blankLayout->addWidget(blankLabel, 0, Qt::AlignHCenter); blankLayout->addStretch(); detailStack->addWidget(blankPage); // 把右侧 stack 加进 splitter centerSplitter->addWidget(detailStack); // 左边宽度基本固定,右边自适应 centerSplitter->setStretchFactor(0, 0); centerSplitter->setStretchFactor(1, 1); centerSplitter->setSizes(QList() << 260 << 960); mainLayout->addWidget(centerSplitter, 1); // ===== 底部:Total number of models + 按钮 ===== QHBoxLayout *bottomLayout = new QHBoxLayout(); QLabel *totalLabel = new QLabel(tr("Total number of models:"), this); modelCountLabel = new QLabel(tr("0"), this); m_monteCarloCount = new QSpinBox(this); m_monteCarloCount->setObjectName("sensitivityMonteCarloCount"); m_monteCarloCount->setRange(1, 100000); m_monteCarloCount->setValue(m_sensitiveData.getMonteCarloCount()); m_monteCarloCount->setButtonSymbols(QAbstractSpinBox::NoButtons); m_monteCarloCount->setVisible(false); bottomLayout->addWidget(totalLabel); bottomLayout->addWidget(modelCountLabel); bottomLayout->addWidget(m_monteCarloCount); previewButton = new QPushButton(this); previewButton->setIcon(QIcon(QCoreApplication::applicationDirPath().section('/', 0, -2) + "/Res/Icon/Eyes.png")); previewButton->setFixedSize(28, 24); previewButton->setToolTip(tr("Sensitivity runs")); bottomLayout->addWidget(previewButton); bottomLayout->addStretch(); generateButton = new QPushButton(tr("Generate"), this); cancelButton = new QPushButton(tr("Cancel"), this); bottomLayout->addWidget(generateButton); bottomLayout->addWidget(cancelButton); mainLayout->addLayout(bottomLayout); } void nmWxSensitive::createConnections() { connect(m_monteCarloCount, SIGNAL(valueChanged(int)), this, SLOT(onVariableParamChanged())); connect(generateButton, SIGNAL(clicked()), this, SLOT(onGenerateClicked())); connect(cancelButton, SIGNAL(clicked()), this, SLOT(onCancelClicked())); connect(previewButton, SIGNAL(clicked()), this, SLOT(onPreviewClicked())); connect(calculationTypeCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(onCalculationTypeChanged(int))); connect(variablesTree, SIGNAL(itemClicked(QTreeWidgetItem*,int)), this, SLOT(onTreeItemClicked(QTreeWidgetItem*,int))); connect(variablesTree, SIGNAL(itemChanged(QTreeWidgetItem*,int)), this, SLOT(onTreeItemChanged(QTreeWidgetItem*,int))); } void nmWxSensitive::populateTreeWidget() { variablesTree->clear(); // ===== Tested Well ===== QTreeWidgetItem *testedWellItem = new QTreeWidgetItem(variablesTree); testedWellItem->setText(0, tr("Tested Well")); testedWellItem->setCheckState(0, Qt::Unchecked); testedWellItem->setFlags(testedWellItem->flags() | Qt::ItemIsUserCheckable); QStringList testedWellChildren; testedWellChildren << "Zw" << "Hw" << "Lw" << "Skin" << "C"; int i; for (i = 0; i < testedWellChildren.size(); ++i) { QTreeWidgetItem *childItem = new QTreeWidgetItem(testedWellItem); childItem->setText(0, testedWellChildren.at(i)); childItem->setCheckState(0, Qt::Unchecked); childItem->setFlags(childItem->flags() | Qt::ItemIsUserCheckable); childItem->setDisabled(!isSupportedSamplingVariable(testedWellChildren.at(i))); } // ===== Reservoir ===== QTreeWidgetItem *reservoirItem = new QTreeWidgetItem(variablesTree); reservoirItem->setText(0, tr("Reservoir")); reservoirItem->setCheckState(0, Qt::Unchecked); reservoirItem->setFlags(reservoirItem->flags() | Qt::ItemIsUserCheckable); QStringList reservoirChildren; reservoirChildren << "Pi" << "k" << "h" << QString(QChar(0x03c6)) << "ntg" << "kz/kr"; for (i = 0; i < reservoirChildren.size(); ++i) { QTreeWidgetItem *childItem = new QTreeWidgetItem(reservoirItem); childItem->setText(0, reservoirChildren.at(i)); childItem->setCheckState(0, Qt::Unchecked); childItem->setFlags(childItem->flags() | Qt::ItemIsUserCheckable); childItem->setDisabled(!isSupportedSamplingVariable(reservoirChildren.at(i))); } // ===== Pvt ===== QTreeWidgetItem *pvtItem = new QTreeWidgetItem(variablesTree); pvtItem->setText(0, tr("Pvt")); pvtItem->setCheckState(0, Qt::Unchecked); pvtItem->setFlags(pvtItem->flags() | Qt::ItemIsUserCheckable); QTreeWidgetItem *compressibilityItem = new QTreeWidgetItem(pvtItem); compressibilityItem->setText(0, tr("Total compressibility")); compressibilityItem->setCheckState(0, Qt::Unchecked); compressibilityItem->setFlags(compressibilityItem->flags() | Qt::ItemIsUserCheckable); compressibilityItem->setDisabled(true); variablesTree->expandAll(); } void nmWxSensitive::createDetailPages() { int i, j; // 依次为每个"叶子变量"创建一个页面,并把 index 存到 item->data 里 for (i = 0; i < variablesTree->topLevelItemCount(); ++i) { QTreeWidgetItem *topItem = variablesTree->topLevelItem(i); for (j = 0; j < topItem->childCount(); ++j) { QTreeWidgetItem *childItem = topItem->child(j); if (childItem == nullptr) { continue; } QString varName = childItem->text(0); QWidget *page = createVariablePage(varName); int pageIndex = detailStack->addWidget(page); // index 从 1 开始 childItem->setData(0, Qt::UserRole, pageIndex); } } } QIcon nmWxSensitive::createTriangleIcon() { QPixmap trianglePixmap(16, 16); trianglePixmap.fill(Qt::transparent); QPainter painter(&trianglePixmap); painter.setRenderHint(QPainter::Antialiasing, true); // 绘制小三角形(指向右的箭头) QPolygon triangle; triangle << QPoint(4, 6) << QPoint(4, 10) << QPoint(10, 8); painter.setBrush(QBrush(QColor(0, 0, 0))); // 黑色三角形 painter.setPen(QPen(QColor(0, 0, 0), 1)); painter.drawPolygon(triangle); return QIcon(trianglePixmap); } QWidget *nmWxSensitive::createVariablePage(const QString &varName) { QWidget *page = new QWidget(this); QHBoxLayout *pageLayout = new QHBoxLayout(page); pageLayout->setContentsMargins(0, 0, 0, 0); pageLayout->setSpacing(0); // ===== 整个 Sampling 面板:QFrame + 标题栏 + 内容 ===== QFrame *samplingPanel = new QFrame(page); samplingPanel->setFrameShape(QFrame::StyledPanel); samplingPanel->setFrameShadow(QFrame::Raised); QVBoxLayout *panelLayout = new QVBoxLayout(samplingPanel); panelLayout->setContentsMargins(0, 0, 0, 0); panelLayout->setSpacing(0); // ---- 标题栏:Sampling Xxx ---- QWidget *header = new QWidget(samplingPanel); header->setFixedHeight(24); header->setAutoFillBackground(true); { QPalette pal = header->palette(); pal.setColor(QPalette::Window, palette().color(QPalette::Midlight)); header->setPalette(pal); } QHBoxLayout *headerLayout = new QHBoxLayout(header); headerLayout->setContentsMargins(6, 0, 6, 0); headerLayout->setSpacing(0); QLabel *title = new QLabel(tr("Sampling %1").arg(varName), header); QFont tf = title->font(); tf.setBold(true); title->setFont(tf); headerLayout->addWidget(title); headerLayout->addStretch(); panelLayout->addWidget(header); // ---- 内容区:左参数 + 右表格 ---- QWidget *content = new QWidget(samplingPanel); QHBoxLayout *contentLayout = new QHBoxLayout(content); contentLayout->setContentsMargins(6, 0, 6, 6); contentLayout->setSpacing(12); // 左:参数区(GridLayout) QGridLayout *paramLayout = new QGridLayout(); paramLayout->setVerticalSpacing(6); int row = 0; // Mode + Log? QLabel *modeLabel = new QLabel(tr("Mode"), content); QComboBox *modeCombo = new QComboBox(content); modeCombo->addItem(tr("Automatic")); // 0 modeCombo->addItem(tr("Manual")); // 1 QCheckBox *logCheck = new QCheckBox(tr("Log?"), content); QWidget *modeRowWidget = new QWidget(content); QHBoxLayout *modeRowLayout = new QHBoxLayout(modeRowWidget); modeRowLayout->setContentsMargins(0, 0, 0, 0); modeRowLayout->addWidget(modeCombo); QComboBox* distributionCombo = new QComboBox(content); distributionCombo->setObjectName("sensitivityDistribution_" + varName); distributionCombo->addItems(QStringList() << tr("Normal") << tr("Lognormal") << tr("Truncated normal") << tr("Truncated lognormal") << tr("Uniform") << tr("Triangular")); distributionCombo->setFixedWidth(180); modeRowLayout->addWidget(distributionCombo); modeRowLayout->addWidget(logCheck); modeRowLayout->addStretch(); paramLayout->addWidget(modeLabel, row, 0); paramLayout->addWidget(modeRowWidget, row, 1); ++row; // Number QLabel *numLabel = new QLabel(tr("Number"), content); QSpinBox *numSpin = new QSpinBox(content); numSpin->setRange(1, 1000); //numSpin->setValue(1); numSpin->setButtonSymbols(QAbstractSpinBox::NoButtons); paramLayout->addWidget(numLabel, row, 0); paramLayout->addWidget(numSpin, row, 1); ++row; // 模式、数量与下方采样参数之间留出约三行空白。 int gapRow = row; paramLayout->setRowMinimumHeight(gapRow, numSpin->sizeHint().height() * 3); ++row; // Model value + 单位 QLabel *modelLabel = new QLabel(tr("Model value:"), content); QLineEdit *modelSpin = new QLineEdit(content); modelSpin->setObjectName("sensitivityModel_" + varName); // 基准值来自真实模型,只作参照;输入框支持科学计数法和完整精度。 modelSpin->setReadOnly(true); QLabel *unitLabel = new QLabel(tr(""), content); // ⭐ 单位也从数据加载 QWidget *modelRowWidget = new QWidget(content); QHBoxLayout *modelRowLayout = new QHBoxLayout(modelRowWidget); modelRowLayout->setContentsMargins(0, 0, 0, 0); modelRowLayout->addWidget(modelSpin); modelRowLayout->addWidget(unitLabel); modelRowLayout->addStretch(); paramLayout->addWidget(modelLabel, row, 0); paramLayout->addWidget(modelRowWidget, row, 1); ++row; // Monte-Carlo 使用独立分布字段,切回确定性方法时保留原范围与手动表格。 QWidget* distributionPanel = new QWidget(content); QGridLayout* distributionLayout = new QGridLayout(distributionPanel); distributionLayout->setContentsMargins(0, 0, 0, 0); distributionLayout->setHorizontalSpacing(6); distributionLayout->setVerticalSpacing(0); distributionLayout->setColumnMinimumWidth(0, modelLabel->sizeHint().width()); const int fieldHeight = modelSpin->sizeHint().height(); // Mean 与三角分布的 Mode 共用一行,其余字段保持固定位置。 distributionPanel->setFixedHeight(fieldHeight * 4 + 6 * 3); const QStringList distributionLabels = QStringList() << tr("Mean:") << tr("Std. dev.:") << tr("Mode:") << tr("Minimum:") << tr("Maximum:"); for(int field = 0; field < 5; ++field) { QLineEdit* edit = new QLineEdit(distributionPanel); edit->setObjectName(distributionFields[field]); edit->setFixedSize(qMax(150, modelSpin->sizeHint().width()), fieldHeight); QLabel* label = new QLabel(distributionLabels[field], distributionPanel); label->setObjectName(QString(distributionFields[field]) + "Label"); label->setFixedHeight(fieldHeight); const int fieldRow = field == 2 ? 0 : field > 2 ? field - 1 : field; distributionLayout->setRowMinimumHeight(fieldRow, fieldHeight + (fieldRow < 3 ? 6 : 0)); distributionLayout->addWidget(label, fieldRow, 0, Qt::AlignTop); distributionLayout->addWidget(edit, fieldRow, 1, Qt::AlignTop); connect(edit, SIGNAL(textChanged(QString)), this, SLOT(onVariableParamChanged())); } distributionLayout->setColumnStretch(2, 1); paramLayout->addWidget(distributionPanel, row++, 0, 1, 2); // Minimum QLabel *minLabel = new QLabel(tr("Minimum:"), content); QLineEdit *minSpin = new QLineEdit(content); minSpin->setObjectName("sensitivityMinimum_" + varName); paramLayout->addWidget(minLabel, row, 0); paramLayout->addWidget(minSpin, row, 1); ++row; // Maximum QLabel *maxLabel = new QLabel(tr("Maximum:"), content); QLineEdit *maxSpin = new QLineEdit(content); maxSpin->setObjectName("sensitivityMaximum_" + varName); paramLayout->addWidget(maxLabel, row, 0); paramLayout->addWidget(maxSpin, row, 1); ++row; // 统一编辑框宽度 int fullWidth = modelSpin->sizeHint().width(); int editWidth = qMax(150, fullWidth); modeCombo->setFixedWidth(editWidth); numSpin->setFixedWidth(editWidth); modelSpin->setFixedWidth(editWidth); minSpin->setFixedWidth(editWidth); maxSpin->setFixedWidth(editWidth); modeCombo->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); numSpin->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); modelSpin->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); minSpin->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); maxSpin->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); QWidget *paramWidget = new QWidget(content); QVBoxLayout *leftVBox = new QVBoxLayout(paramWidget); leftVBox->setContentsMargins(0, 6, 0, 0); leftVBox->addLayout(paramLayout); leftVBox->addStretch(); // 右:Values 表格(双列:箭头 + 值) QTableWidget *valuesTable = new QTableWidget(content); valuesTable->setObjectName("sensitivityValues_" + varName); valuesTable->setColumnCount(2); QStringList headers; headers << "" << tr("Values"); valuesTable->setHorizontalHeaderLabels(headers); valuesTable->verticalHeader()->setVisible(false); QHeaderView *hh = valuesTable->horizontalHeader(); hh->setResizeMode(QHeaderView::Fixed); hh->setHighlightSections(false); // 箭头列固定,数值列在下方设置为填满加宽后的表格。 valuesTable->setColumnWidth(0, 15); valuesTable->setStyleSheet( "QHeaderView::section {" " border-top: 0px;" " border-left: 0px;" " border-right: 1px solid lightgray;" " border-bottom: 1px solid lightgray;" "}" "QTableCornerButton::section {" " border-top: 0px;" " border-left: 0px;" " border-right: 1px solid lightgray;" " border-bottom: 1px solid lightgray;" "}" ); valuesTable->horizontalHeader()->setStretchLastSection(true); valuesTable->setEditTriggers(QAbstractItemView::DoubleClicked); valuesTable->setSelectionBehavior(QAbstractItemView::SelectRows); valuesTable->setSelectionMode(QAbstractItemView::NoSelection); valuesTable->setFocusPolicy(Qt::NoFocus); valuesTable->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); valuesTable->setShowGrid(true); valuesTable->setItemDelegateForColumn(1, new RightAlignDelegate(valuesTable)); // 初始空表的列宽尚未稳定,直接固定容器宽度,不从未显示的表头反算。 const int tableWidth = 190; valuesTable->setFixedWidth(tableWidth); QWidget *tableContainer = new QWidget(content); QVBoxLayout *tableLayout = new QVBoxLayout(tableContainer); tableLayout->setContentsMargins(0, 0, 0, 0); tableLayout->setSpacing(0); tableLayout->addWidget(valuesTable); tableContainer->setFixedWidth(tableWidth); contentLayout->addWidget(paramWidget, 1); contentLayout->addStretch(); contentLayout->addWidget(tableContainer, 0, Qt::AlignRight); SensitivityDistributionPlot* distributionPlot = new SensitivityDistributionPlot(content); contentLayout->addWidget(distributionPlot, 2); panelLayout->addWidget(content); pageLayout->addWidget(samplingPanel); page->setEnabled(isSupportedSamplingVariable(varName)); // ===== 保存控件指针 ===== m_modeCombos.append(modeCombo); m_logChecks.append(logCheck); m_numSpins.append(numSpin); m_modelSpins.append(modelSpin); m_minSpins.append(minSpin); m_maxSpins.append(maxSpin); m_valueTables.append(valuesTable); m_unitLabels.append(unitLabel); m_numLabels.append(numLabel); m_minLabels.append(minLabel); m_maxLabels.append(maxLabel); m_distributionCombos.append(distributionCombo); m_distributionPanels.append(distributionPanel); m_distributionPlots.append(distributionPlot); connect(distributionCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(onVariableParamChanged())); connect(numSpin, SIGNAL(valueChanged(int)), this, SLOT(onVariableParamChanged())); connect(minSpin, SIGNAL(textChanged(QString)), this, SLOT(onVariableParamChanged())); connect(maxSpin, SIGNAL(textChanged(QString)), this, SLOT(onVariableParamChanged())); connect(minSpin, SIGNAL(editingFinished()), this, SLOT(onSamplingEditingFinished())); connect(maxSpin, SIGNAL(editingFinished()), this, SLOT(onSamplingEditingFinished())); connect(modeCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(onVariableParamChanged())); connect(logCheck, SIGNAL(toggled(bool)), this, SLOT(onVariableParamChanged())); connect(valuesTable, SIGNAL(cellClicked(int,int)), this, SLOT(onTableCellClicked(int,int))); return page; } void nmWxSensitive::updateValuesForPage(int idx) { if (idx < 0 || idx >= m_numSpins.size()) return; const bool monteCarlo = calculationTypeCombo->currentIndex() == nmDataSensitive::CALC_MONTE_CARLO; m_modeCombos[idx]->setVisible(!monteCarlo); m_logChecks[idx]->setVisible(!monteCarlo); m_numLabels[idx]->setVisible(!monteCarlo); m_numSpins[idx]->setVisible(!monteCarlo); m_minLabels[idx]->setVisible(!monteCarlo); m_maxLabels[idx]->setVisible(!monteCarlo); m_minSpins[idx]->setVisible(!monteCarlo); m_maxSpins[idx]->setVisible(!monteCarlo); m_valueTables[idx]->parentWidget()->setVisible(!monteCarlo); m_distributionCombos[idx]->setVisible(monteCarlo); m_distributionPanels[idx]->setVisible(monteCarlo); m_distributionPlots[idx]->setVisible(monteCarlo); // Monte-Carlo 参数区只占所需宽度,窗口增宽时优先扩展分布图。 QHBoxLayout* contentLayout = static_cast(m_distributionPlots[idx]->parentWidget()->layout()); contentLayout->setStretch(0, monteCarlo ? 0 : 1); if(monteCarlo) { updateDistributionPage(idx); return; } // 自动模式由范围生成只读表格;手动模式保留每行值,不因选中行而重算。 QTableWidget* table = m_valueTables[idx]; const bool automatic = m_modeCombos[idx]->currentIndex() == 0; m_minSpins[idx]->setEnabled(automatic); m_maxSpins[idx]->setEnabled(automatic); m_logChecks[idx]->setEnabled(automatic); const int number = m_numSpins[idx]->value(); table->setRowCount(number); nmDataSensitive::VariableSampling variable; for (int i = 0; i < m_sensitiveData.getVariableCount(); ++i) { if (findVariableIndex(m_sensitiveData.getVariable(i).getVarName()) == idx) { variable = m_sensitiveData.getVariable(i); break; } } QVector samples; const QString name = variable.getVarName(); const QString unit = m_unitLabels[idx]->text(); if (automatic) { bool minOk = false, maxOk = false; double minimum = samplingEditValue(m_minSpins[idx], &minOk); double maximum = samplingEditValue(m_maxSpins[idx], &maxOk); if (minOk && maxOk && minimum <= maximum && convertSamplingValue(name, minimum, unit, samplingBaseUnit(name), minimum) && convertSamplingValue(name, maximum, unit, samplingBaseUnit(name), maximum) && validSamplingValue(name, minimum) && validSamplingValue(name, maximum)) { variable.setMode(nmDataSensitive::VariableSampling::MODE_AUTOMATIC); variable.setNumber(number); variable.setLog(m_logChecks[idx]->isChecked()); variable.getMinValue().setValue(minimum); variable.getMaxValue().setValue(maximum); samples = variable.samplingValues(); } } for (int row = 0; row < number; ++row) { QTableWidgetItem* arrow = table->item(row, 0); if (!arrow) { arrow = new QTableWidgetItem(); table->setItem(row, 0, arrow); } arrow->setFlags(Qt::ItemIsEnabled); arrow->setIcon(row == m_selectedValueIndex ? createTriangleIcon() : QIcon()); QTableWidgetItem* item = table->item(row, 1); if (!item) { item = new QTableWidgetItem(); table->setItem(row, 1, item); } item->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); item->setFlags(automatic ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsEditable); if (automatic) { double display = row < samples.size() ? samples[row] : 0.0; const bool valid = row < samples.size() && convertSamplingValue(name, display, samplingBaseUnit(name), unit, display); item->setData(Qt::UserRole, valid ? QVariant(display) : QVariant()); item->setText(valid ? formatSamplingValue(display, 3) : QString()); } } } bool nmWxSensitive::readDistributionPage(int idx, nmDataSensitive::VariableSampling& variable) const { // 字段转换沿用现有参数单位,仅校验当前分布实际使用的字段。 variable.m_distribution = static_cast( m_distributionCombos[idx]->currentIndex()); variable.m_modelValue.setUnit(m_unitLabels[idx]->text()); double* fields[] = {&variable.m_mean, &variable.m_stdDev, &variable.m_peak, &variable.m_distributionMin, &variable.m_distributionMax}; const int distribution = variable.m_distribution; for(int field = 0; field < 5; ++field) { const bool used = field < 2 ? distribution < nmDataSensitive::VariableSampling::UNIFORM : field == 2 ? distribution == nmDataSensitive::VariableSampling::TRIANGULAR : distribution >= nmDataSensitive::VariableSampling::TRUNCATED_NORMAL; if(!used) continue; bool ok = false; double value = samplingEditValue(m_distributionPanels[idx]->findChild(distributionFields[field]), &ok); if(!ok || !qIsFinite(value) || !convertSamplingValue(variable.m_varName, value, m_unitLabels[idx]->text(), samplingBaseUnit(variable.m_varName), *fields[field])) return false; } return true; } void nmWxSensitive::updateDistributionPage(int idx) { // 只隐藏所选分布不用的字段,保留各行位置;编辑时更新曲线,不提前抽样。 const int distribution = m_distributionCombos[idx]->currentIndex(); for(int field = 0; field < 5; ++field) { QWidget* edit = m_distributionPanels[idx]->findChild(distributionFields[field]); const bool used = field < 2 ? distribution < nmDataSensitive::VariableSampling::UNIFORM : field == 2 ? distribution == nmDataSensitive::VariableSampling::TRIANGULAR : distribution >= nmDataSensitive::VariableSampling::TRUNCATED_NORMAL; edit->setVisible(used); m_distributionPanels[idx]->findChild(QString(distributionFields[field]) + "Label")->setVisible(used); } for(int i = 0; i < m_sensitiveData.getVariableCount(); ++i) { nmDataSensitive::VariableSampling variable = m_sensitiveData.getVariable(i); if(findVariableIndex(variable.m_varName) != idx) continue; const bool valid = readDistributionPage(idx, variable); m_distributionPlots[idx]->setVariable(variable, valid); break; } } void nmWxSensitive::onSamplingEditingFinished() { // 输入结束后截断越界范围,再按六位有效数字显示,计算仍使用原始精度。 QLineEdit* edit = qobject_cast(sender()); if (m_loading || !edit) return; bool ok = false; double value = samplingEditValue(edit, &ok); if (!ok || !qIsFinite(value)) return; for (int idx = 0; idx < m_minSpins.size(); ++idx) { if (edit != m_minSpins[idx] && edit != m_maxSpins[idx]) continue; for (int i = 0; i < m_sensitiveData.getVariableCount(); ++i) { const QString name = m_sensitiveData.getVariable(i).getVarName(); if (findVariableIndex(name) != idx) continue; double lower = 0.0, upper = 0.0; const QString unit = m_unitLabels[idx]->text(); const QString baseUnit = samplingBaseUnit(name); if (!samplingPhysicalRange(name, lower, upper) || !convertSamplingValue(name, value, unit, baseUnit, value)) return; value = qBound(lower, value, upper); if (!convertSamplingValue(name, value, baseUnit, unit, value)) return; setSamplingEditValue(edit, value); updateValuesForPage(idx); return; } } } void nmWxSensitive::onVariableParamChanged() { // 数据加载时不响应控件逐项赋值,编辑时只刷新所属参数页。 if (m_loading) return; // 分布设置或次数改变后,生成必须重新抽样,不能使用旧预览。 m_sensitiveData.clearMonteCarloSamples(); for (int i = 0; i < m_numSpins.size(); ++i) { if (sender() == m_numSpins[i] || sender() == m_minSpins[i] || sender() == m_maxSpins[i] || sender() == m_modeCombos[i] || sender() == m_logChecks[i] || sender() == m_distributionCombos[i] || sender()->parent() == m_distributionPanels[i]) { m_selectedValueIndex = qMin(m_selectedValueIndex, m_numSpins[i]->value() - 1); updateValuesForPage(i); break; } } updateModelCount(); } void nmWxSensitive::onTableCellClicked(int row, int column) { Q_UNUSED(column); // 选中标记不改写表格内容,避免自动重算或丢失手动输入。 m_selectedValueIndex = row; QTableWidget* table = qobject_cast(sender()); if (!table) return; for (int i = 0; i < table->rowCount(); ++i) if (table->item(i, 0)) table->item(i, 0)->setIcon(i == row ? createTriangleIcon() : QIcon()); } void nmWxSensitive::onTreeItemChanged(QTreeWidgetItem* item, int column) { Q_UNUSED(column); if (m_loading) return; // 勾选变量改变会改变整批组合;初始化树状态时保留已有样本。 if(item) m_sensitiveData.clearMonteCarloSamples(); // 分组选择与子项选择双向同步,禁用的参数不参与全选和总次数。 m_loading = true; variablesTree->blockSignals(true); if (item && item->childCount() > 0) { const Qt::CheckState state = item->checkState(0); for (int i = 0; i < item->childCount(); ++i) if (!item->child(i)->isDisabled()) item->child(i)->setCheckState(0, state); } for (int group = 0; group < variablesTree->topLevelItemCount(); ++group) { QTreeWidgetItem* parent = variablesTree->topLevelItem(group); int available = 0, checked = 0; for (int i = 0; i < parent->childCount(); ++i) { if (parent->child(i)->isDisabled()) continue; ++available; if (parent->child(i)->checkState(0) == Qt::Checked) ++checked; } parent->setCheckState(0, checked == 0 ? Qt::Unchecked : checked == available ? Qt::Checked : Qt::PartiallyChecked); parent->setDisabled(available == 0); } variablesTree->blockSignals(false); m_loading = false; updateModelCount(); } void nmWxSensitive::onGenerateClicked() { if(m_calculationRunning || !m_pDataManager || !saveDataFromUI()) return; QString error; QScopedPointer task; try { // 优先使用预览的整批样本;未预览或设置改变后才重新采样。 if(m_sensitiveData.getCalculationType() == nmDataSensitive::CALC_MONTE_CARLO) { QVector values; if(!m_sensitiveData.getRunValues(0, values) && !m_sensitiveData.resampleMonteCarlo(&error)) { QMessageBox::warning(this, tr("Sensitivity"), error); return; } } // 从最后成功结果恢复完整输入,当前编辑参数和全局拟压力配置不参与本次分析。 nmCalculationDllPebiSolverTask source(QString(), m_pDataManager, QString(), nullptr, false, true); task.reset(source.createSensitivityBatch(m_sensitiveData, &error)); } catch(const std::bad_alloc&) { error = tr("Insufficient memory to prepare solver input."); } if(!task) { QMessageBox::warning(this, tr("Sensitivity"), error); return; } m_pDataManager->updateSensitiveData(m_sensitiveData); // 新任务准备成功后才替换上次临时结果,准备失败仍可保留旧结果。 delete m_pSensitivityTask; m_pSensitivityTask = task.take(); m_pSensitivityTask->setParent(this); // 进度框和停止按钮由主窗口统一管理;接管失败时不启动本次任务。 emit sigSensitivityTaskReady(this, m_pSensitivityTask); if(!m_calculationRunning) { delete m_pSensitivityTask; m_pSensitivityTask = nullptr; } } void nmWxSensitive::startSensitivityCalculation() { // 读取和清理必须等 QThread 真正结束,不能由 run() 内的提前通知触发。 connect(m_pSensitivityTask, SIGNAL(finished()), this, SLOT(onSensitivityFinished()), Qt::QueuedConnection); setCalculationRunning(true); // 仅关闭参数界面;控制器及任务仍存活,结束后继续保留临时计算结果。 hide(); m_pSensitivityTask->start(); } void nmWxSensitive::onCancelClicked() { if(m_calculationRunning && m_pSensitivityTask) { // 关闭或 Esc 也走主窗口现有停止路径,保持进度框的等待状态一致。 emit sigSensitivityCancelRequested(); return; } reject(); } void nmWxSensitive::setCalculationRunning(bool running) { // 计算过程中固定采样输入,仅保留停止操作;结束后恢复编辑与再次生成。 m_calculationRunning = running; calculationTypeCombo->setEnabled(!running); variablesTree->setEnabled(!running); detailStack->setEnabled(!running); m_monteCarloCount->setEnabled(!running); previewButton->setEnabled(!running); cancelButton->setEnabled(!running); if(running) { generateButton->setEnabled(false); } else { updateModelCount(); } } void nmWxSensitive::onSensitivityFinished() { if(sender() != m_pSensitivityTask) return; m_pSensitivityTask->wait(); // 数据层接管曲线后释放计算任务及网格输入,结果窗口不依赖任务存活。 QSharedPointer batch = m_pSensitivityTask->getSensitivityBatchResult(); delete m_pSensitivityTask; m_pSensitivityTask = nullptr; setCalculationRunning(false); m_sensitiveData.setResults(batch); m_pDataManager->publishSensitiveResults(m_sensitiveData); const std::vector& results = batch->m_runs; QStringList errors; for(size_t i = 0; i < results.size() && errors.size() < 5; ++i) { const nmDataSensitive::RunResult& result = results[i]; if(!result.m_bSucceeded && !result.m_bCancelled) errors.append(tr("Run %1: %2").arg(result.m_nRunIndex + 1).arg(result.m_sFailureReason)); } const QString fatalError = batch->m_sFailureReason; // 实际计算错误仍需提示原因,取消组不作为错误报告。 if(fatalError.isEmpty() && errors.isEmpty()) return; QString message = fatalError; if(!errors.isEmpty()) { if(!message.isEmpty()) message += "\n\n"; message += errors.join("\n"); } QWidget* ownerWindow = parentWidget() ? parentWidget()->window() : nullptr; QMessageBox::warning(ownerWindow, tr("Sensitivity"), message); } void nmWxSensitive::onResultsChanged() { // 同步当前批次,新模型清除旧结果时也清除本地引用。 QSharedPointer results; if(m_pDataManager && m_pDataManager->getSensitiveData()) results = m_pDataManager->getSensitiveData()->getResults(); m_sensitiveData.setResults(results); } void nmWxSensitive::onCalculationTypeChanged(int index) { Q_UNUSED(index); // 复用现有参数页,切换方法只改变可见控件,不覆盖另一种方法的设置。 if(m_loading) return; for(int i = 0; i < m_numSpins.size(); ++i) updateValuesForPage(i); updateModelCount(); } void nmWxSensitive::onTreeItemClicked(QTreeWidgetItem* item, int column) { Q_UNUSED(column); if (item->childCount() > 0) { detailStack->setCurrentIndex(0); return; } const int pageIndex = item->data(0, Qt::UserRole).toInt(); if (pageIndex <= 0) return; detailStack->setCurrentIndex(pageIndex); // 页面切换只更新箭头,保留用户正在编辑的采样表。 m_selectedValueIndex = 0; QTableWidget* table = m_valueTables[pageIndex - 1]; for (int row = 0; row < table->rowCount(); ++row) if (table->item(row, 0)) table->item(row, 0)->setIcon(row == 0 ? createTriangleIcon() : QIcon()); } void nmWxSensitive::onPreviewClicked() { if (!saveDataFromUI()) return; if(m_sensitiveData.getCalculationType() == nmDataSensitive::CALC_MONTE_CARLO) { QString error; if(!m_sensitiveData.resampleMonteCarlo(&error)) { QMessageBox::warning(this, tr("Sensitivity"), error); return; } // 样本已在允许范围内生成,直接显示预览。 } // Saphir 风格的连续运行列表,组合数再大也不显示分页控件。 QDialog dialog(this); dialog.setWindowTitle(tr("Sensitivity Runs")); dialog.setWindowFlags(dialog.windowFlags() & ~Qt::WindowContextHelpButtonHint); dialog.setMinimumSize(420, 360); dialog.resize(470, 650); QVBoxLayout* layout = new QVBoxLayout(&dialog); layout->setContentsMargins(16, 16, 16, 12); layout->setSpacing(8); m_previewView = new SensitivityRunsView(m_sensitiveData, createTriangleIcon(), &dialog); layout->addWidget(m_previewView); QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Close, Qt::Horizontal, &dialog); buttons->setCenterButtons(true); buttons->button(QDialogButtonBox::Close)->setMinimumWidth(110); layout->addWidget(buttons); connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); connect(m_previewView->verticalScrollBar(), SIGNAL(actionTriggered(int)), this, SLOT(onPreviewScrollAction(int))); dialog.exec(); m_previewView = nullptr; } void nmWxSensitive::onPreviewScrollAction(int action) { // 将原生滚动条的单步、整屏和拖动操作转交连续列表。 if (m_previewView) m_previewView->scrollAction(action); }