From 7d2076b99ed58d10536b13b9aad138b01d0a8d91 Mon Sep 17 00:00:00 2001 From: lvjunjie Date: Wed, 14 Oct 2026 16:07:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(nmNum):=20=E6=96=B0=E5=A2=9E=20Monte-Carlo?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0=E9=87=87=E6=A0=B7=E4=B8=8E=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 Monte-Carlo 入口及六种概率分布设置,采用拉丁超立方采样,支持自定义总次数和每次预览重新抽样。 - 显示累计概率与归一化概率密度曲线,复用运行预览列表,切换方法时保留确定性参数设置。 - 放大分析窗口,调整绘图样式、参数固定行布局及 Values 表格宽度,三角分布 Mode 与 Mean 共用位置。 - 本次仅开放采样和预览,暂不接入 Monte-Carlo 求解、统计结果及保存逻辑。 --- Include/nmNum/nmData/nmDataSensitive.h | 15 +- Include/nmNum/nmSubWxs/nmWxSensitive.h | 8 + Src/nmNum/nmData/nmDataSensitive.cpp | 123 ++++++++++ Src/nmNum/nmSubWxs/nmWxSensitive.cpp | 307 +++++++++++++++++++++++-- 4 files changed, 438 insertions(+), 15 deletions(-) diff --git a/Include/nmNum/nmData/nmDataSensitive.h b/Include/nmNum/nmData/nmDataSensitive.h index 492c8002..056f58e8 100644 --- a/Include/nmNum/nmData/nmDataSensitive.h +++ b/Include/nmNum/nmData/nmDataSensitive.h @@ -21,7 +21,8 @@ public: enum CalculationType { CALC_DETERMINISTIC = 0, - CALC_DETERMINISTIC_MULTIVARIATE = 1 + CALC_DETERMINISTIC_MULTIVARIATE = 1, + CALC_MONTE_CARLO = 2 }; struct NM_DATA_EXPORT VariableSampling @@ -50,6 +51,12 @@ public: nmDataAttribute m_maxValue; // Maximum // 表格中的数值列表 QVector m_values; + // Monte-Carlo 分布设置与确定性取值独立,仅保存在内存中。 + enum Distribution { NORMAL, LOGNORMAL, TRUNCATED_NORMAL, TRUNCATED_LOGNORMAL, UNIFORM, TRIANGULAR }; + Distribution m_distribution; + bool m_distributionInitialized; + double m_mean, m_stdDev, m_peak, m_distributionMin, m_distributionMax; + bool distributionQuantile(double probability, double& value) const; VariableSampling(); // JSON 序列化 / 反序列化 @@ -149,6 +156,10 @@ public: quint64 calculateModelCount(bool* overflow = nullptr) const; // 返回与变量列表对应的基准单位值,未改变的参数保持模型值。 bool getRunValues(quint64 runIndex, QVector& values) const; + // 每次打开 Monte-Carlo 预览重新分层抽样,预览滚动只读取本批结果。 + bool resampleMonteCarlo(QString* error = nullptr); + int getMonteCarloCount() const; + void setMonteCarloCount(int count); bool usesBaseUnits() const; void setUsesBaseUnits(bool enabled); // 记录采样范围对应的求解快照,成功重新求解后再初始化范围。 @@ -172,6 +183,8 @@ private: bool m_usesBaseUnits; QString m_modelSnapshotId; QList m_variables; + int m_monteCarloCount; + QVector > m_monteCarloValues; QSharedPointer m_results; }; diff --git a/Include/nmNum/nmSubWxs/nmWxSensitive.h b/Include/nmNum/nmSubWxs/nmWxSensitive.h index ae551f91..c5b984ba 100644 --- a/Include/nmNum/nmSubWxs/nmWxSensitive.h +++ b/Include/nmNum/nmSubWxs/nmWxSensitive.h @@ -24,6 +24,7 @@ class nmCalculationDllPebiSolverTask; class QCloseEvent; class QDialog; class SensitivityResultsModel; +class SensitivityDistributionPlot; class NM_SUB_WXS_EXPORT nmWxSensitive : public iDlgBase { @@ -73,6 +74,8 @@ private: void createDetailPages(); QWidget *createVariablePage(const QString &varName); void updateValuesForPage(int idx); + void updateDistributionPage(int idx); + bool readDistributionPage(int idx, nmDataSensitive::VariableSampling& variable) const; QIcon createTriangleIcon(); // 创建三角箭头图标 void loadDataToUI(bool refreshRangesOnly = false); // 从数据加载到界面 @@ -87,6 +90,7 @@ private: QTreeWidget *variablesTree; QStackedWidget *detailStack; QLabel *modelCountLabel; + QSpinBox *m_monteCarloCount; QPushButton *generateButton; QPushButton *cancelButton; QPushButton *previewButton; @@ -103,6 +107,10 @@ private: QList m_maxSpins; QList m_valueTables; QList m_unitLabels; + QList m_numLabels, m_minLabels, m_maxLabels; + QList m_distributionCombos; + QList m_distributionPanels; + QList m_distributionPlots; // 跟踪当前选中的值索引 int m_selectedValueIndex; diff --git a/Src/nmNum/nmData/nmDataSensitive.cpp b/Src/nmNum/nmData/nmDataSensitive.cpp index 290556a0..ff645df9 100644 --- a/Src/nmNum/nmData/nmDataSensitive.cpp +++ b/Src/nmNum/nmData/nmDataSensitive.cpp @@ -1,10 +1,33 @@ #include "nmDataSensitive.h" #include #include +#include +#include #include namespace { +double normalCdf(double z) +{ + // 标准正态累积分布近似,最大绝对误差约 7.5e-8,兼容 VS2010。 + const double x = std::fabs(z), t = 1.0 / (1.0 + 0.2316419 * x); + const double tail = 0.3989422804014327 * std::exp(-0.5 * x * x) * t * + (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))); + return z < 0.0 ? tail : 1.0 - tail; +} + +double normalQuantile(double probability) +{ + // 在累积分布上二分求逆,避免引入新的数学库。 + double low = -10.0, high = 10.0; + for(int i = 0; i < 60; ++i) { + const double mid = (low + high) * 0.5; + if(normalCdf(mid) < probability) low = mid; + else high = mid; + } + return (low + high) * 0.5; +} + rapidjson::Value sensitiveNumbersToJson(const QVector& values, rapidjson::Document::AllocatorType& allocator) { @@ -164,6 +187,8 @@ nmDataSensitive::VariableSampling::VariableSampling() , m_mode(MODE_AUTOMATIC) , m_log(false) , m_number(0) + , m_distribution(NORMAL), m_distributionInitialized(false), m_mean(0.0), m_stdDev(1.0), m_peak(0.0) + , m_distributionMin(0.0), m_distributionMax(1.0) { m_modelValue = nmDataAttribute("Model value", "", ""); m_minValue = nmDataAttribute("Minimum", "", ""); @@ -384,11 +409,52 @@ QVector nmDataSensitive::VariableSampling::samplingValues() const } +bool nmDataSensitive::VariableSampling::distributionQuantile(double probability, double& value) const +{ + // 六种分布都由分位数取值;对数正态的均值、标准差仍使用参数原单位。 + if(!qIsFinite(probability) || probability <= 0.0 || probability >= 1.0) return false; + const bool bounded = m_distribution >= TRUNCATED_NORMAL; + const double low = m_distributionMin, high = m_distributionMax; + if(bounded && (!qIsFinite(low) || !qIsFinite(high) || low >= high)) return false; + if(m_distribution == UNIFORM) value = low + probability * (high - low); + else if(m_distribution == TRIANGULAR) { + if(!qIsFinite(m_peak) || m_peak < low || m_peak > high) return false; + const double split = (m_peak - low) / (high - low); + value = probability < split + ? low + std::sqrt(probability * (high - low) * (m_peak - low)) + : high - std::sqrt((1.0 - probability) * (high - low) * (high - m_peak)); + } else { + if(m_distribution < NORMAL || m_distribution > TRUNCATED_LOGNORMAL || + !qIsFinite(m_mean) || !qIsFinite(m_stdDev) || m_stdDev <= 0.0) return false; + const bool logarithmic = m_distribution == LOGNORMAL || m_distribution == TRUNCATED_LOGNORMAL; + if(logarithmic && (m_mean <= 0.0 || (bounded && low < 0.0))) return false; + double mean = m_mean, sigma = m_stdDev; + if(logarithmic) { + const double ratio = m_stdDev / m_mean; + const double variance = std::log(1.0 + ratio * ratio); + mean = std::log(m_mean) - variance * 0.5; + sigma = std::sqrt(variance); + } + if(!qIsFinite(mean) || !qIsFinite(sigma) || sigma <= 0.0) return false; + if(bounded) { + const double a = logarithmic && low == 0.0 ? 0.0 + : normalCdf(((logarithmic ? std::log(low) : low) - mean) / sigma); + const double b = normalCdf(((logarithmic ? std::log(high) : high) - mean) / sigma); + if(!(b > a)) return false; + probability = a + probability * (b - a); + } + value = mean + sigma * normalQuantile(probability); + if(logarithmic) value = std::exp(value); + } + return qIsFinite(value); +} + nmDataSensitive::nmDataSensitive() : m_calcType(CALC_DETERMINISTIC) , m_totalModelCount(0) , m_usesBaseUnits(false) , m_variables() + , m_monteCarloCount(50) { } @@ -407,6 +473,8 @@ nmDataSensitive& nmDataSensitive::operator=(const nmDataSensitive& other) m_usesBaseUnits = other.m_usesBaseUnits; m_modelSnapshotId = other.m_modelSnapshotId; m_variables = other.m_variables; + m_monteCarloCount = other.m_monteCarloCount; + m_monteCarloValues = other.m_monteCarloValues; m_results = other.m_results; } return *this; @@ -513,6 +581,11 @@ quint64 nmDataSensitive::calculateModelCount(bool* overflow) const { // 未选择变量时为零;两种确定性方法分别累计和、积。 if (overflow) *overflow = false; + if(m_calcType == CALC_MONTE_CARLO) { + for(int i = 0; i < m_variables.size(); ++i) + if(m_variables[i].m_enabled) return qMax(0, m_monteCarloCount); + return 0; + } quint64 count = 0; bool selected = false; for (int i = 0; i < m_variables.size(); ++i) { @@ -540,6 +613,11 @@ bool nmDataSensitive::getRunValues(quint64 runIndex, QVector& values) co bool overflow = false; const quint64 count = calculateModelCount(&overflow); if (overflow || runIndex >= count) return false; + if(m_calcType == CALC_MONTE_CARLO) { + if(m_monteCarloValues.size() != int(count)) return false; + values = m_monteCarloValues[int(runIndex)]; + return true; + } values.clear(); for (int i = 0; i < m_variables.size(); ++i) values.append(m_variables[i].m_modelValue.getValue().toDouble()); @@ -570,6 +648,51 @@ bool nmDataSensitive::getRunValues(quint64 runIndex, QVector& values) co return false; } +int nmDataSensitive::getMonteCarloCount() const +{ + // Monte-Carlo 总次数由用户指定,不使用单参数次数的和或积。 + return m_monteCarloCount; +} + +void nmDataSensitive::setMonteCarloCount(int count) +{ + // 次数变化后旧预览失效,下一次预览统一重新抽样。 + if(count != m_monteCarloCount) m_monteCarloValues.clear(); + m_monteCarloCount = count; +} + +bool nmDataSensitive::resampleMonteCarlo(QString* error) +{ + // 各参数的累积概率均分为 N 层,每层抽一点,再独立打乱,组成 N 组参数。 + if(error) error->clear(); + m_monteCarloValues.clear(); + const int count = int(calculateModelCount()); + if(m_calcType != CALC_MONTE_CARLO || count <= 0) return false; + std::mt19937 random(uint(QDateTime::currentMSecsSinceEpoch()) ^ uint(qrand())); + std::uniform_real_distribution uniform(0.0, 1.0); + QVector > runs(count, QVector(m_variables.size())); + for(int column = 0; column < m_variables.size(); ++column) { + const VariableSampling& variable = m_variables[column]; + QVector samples(count, variable.m_modelValue.getValue().toDouble()); + if(variable.m_enabled) { + for(int row = 0; row < count; ++row) { + const double jitter = qBound(1e-12, uniform(random), 1.0 - 1e-12); + if(!variable.distributionQuantile((row + jitter) / count, samples[row])) { + if(error) *error = tr("%1: invalid distribution parameters.").arg(variable.m_varName); + return false; + } + } + for(int row = count - 1; row > 0; --row) { + std::uniform_int_distribution index(0, row); + qSwap(samples[row], samples[index(random)]); + } + } + for(int row = 0; row < count; ++row) runs[row][column] = samples[row]; + } + m_monteCarloValues.swap(runs); + return true; +} + bool nmDataSensitive::usesBaseUnits() const { return m_usesBaseUnits; diff --git a/Src/nmNum/nmSubWxs/nmWxSensitive.cpp b/Src/nmNum/nmSubWxs/nmWxSensitive.cpp index 1c89e79e..f61631f5 100644 --- a/Src/nmNum/nmSubWxs/nmWxSensitive.cpp +++ b/Src/nmNum/nmSubWxs/nmWxSensitive.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +67,8 @@ namespace { +const char* distributionFields[] = {"mean", "stdDev", "peak", "minimum", "maximum"}; + QString formatSamplingValue(double value, int digits) { // 仅对显示值取有效数字,较大或较小的数自动使用科学计数法。 @@ -292,6 +295,119 @@ private: QMap m_pendingVisibility; }; +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; + valid = m_variable.distributionQuantile(qBound(1e-10, cumulativeValues[i], 1.0 - 1e-10), x[i]); + 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; + const double axisMin = std::floor(x.first() / step) * step; + const double axisMax = std::ceil(x.last() / 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 int intervals = qRound((axisMax - axisMin) / step); + for(int i = 0; i <= intervals * 5; ++i) { + const double value = axisMin + i * step / 5.0; + 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.setPen(QPen(Qt::blue, 1.5)); + painter.drawPolyline(cumulative); + painter.setPen(QPen(Qt::red, 1, Qt::DashLine)); + painter.drawPolyline(probability); + 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 { @@ -768,6 +884,21 @@ void nmWxSensitive::refreshModelValues() 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; + // Monte-Carlo 的 Skin 分布允许负值,不能沿用求解器截断后的范围。 + if(name == "Skin") { + variable.m_distributionMin = model == 0.0 ? -1.0 : qMin(model * 0.9, model * 1.1); + variable.m_distributionMax = model == 0.0 ? 1.0 : qMax(model * 0.9, model * 1.1); + } + 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); @@ -796,6 +927,7 @@ 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(); @@ -833,6 +965,14 @@ void nmWxSensitive::loadDataToUI(bool refreshRangesOnly) 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); @@ -888,11 +1028,16 @@ void nmWxSensitive::updateModelCount() } } 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); - generateButton->setEnabled(m_pDataManager && !overflow && count > 0 && !m_calculationRunning); + const bool monteCarlo = m_sensitiveData.getCalculationType() == nmDataSensitive::CALC_MONTE_CARLO; + modelCountLabel->setVisible(!monteCarlo); + m_monteCarloCount->setVisible(monteCarlo); + generateButton->setEnabled(!monteCarlo && m_pDataManager && !overflow && count > 0 && !m_calculationRunning); + generateButton->setToolTip(monteCarlo ? tr("Monte-Carlo sampling preview is available; calculation is not yet available.") : QString()); } bool nmWxSensitive::saveDataFromUI() @@ -906,6 +1051,16 @@ bool nmWxSensitive::saveDataFromUI() const QString name = variable.getVarName(); const int idx = findVariableIndex(name); if (idx < 0 || !isSupportedSamplingVariable(name)) continue; + if(candidate.getCalculationType() == nmDataSensitive::CALC_MONTE_CARLO) { + double median = 0.0; + const bool 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; + } + continue; + } variable.setMode(static_cast(m_modeCombos[idx]->currentIndex())); variable.setLog(m_logChecks[idx]->isChecked()); const QString unit = m_unitLabels[idx]->text(); @@ -965,6 +1120,9 @@ 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); @@ -974,7 +1132,7 @@ void nmWxSensitive::setupUI() calculationTypeCombo = new QComboBox(this); calculationTypeCombo->addItem(tr("Deterministic")); calculationTypeCombo->addItem(tr("Deterministic multivariate")); - //calculationTypeCombo->addItem(tr("Monte-Carlo")); + calculationTypeCombo->addItem(tr("Monte-Carlo")); //calculationTypeCombo->addItem(tr("Monte-Carlo + Improve")); calcTypeLayout->addWidget(calcTypeLabel); @@ -1061,6 +1219,7 @@ void nmWxSensitive::setupUI() // 左边宽度基本固定,右边自适应 centerSplitter->setStretchFactor(0, 0); centerSplitter->setStretchFactor(1, 1); + centerSplitter->setSizes(QList() << 260 << 960); mainLayout->addWidget(centerSplitter, 1); @@ -1069,9 +1228,16 @@ void nmWxSensitive::setupUI() 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")); @@ -1094,6 +1260,7 @@ void nmWxSensitive::setupUI() void nmWxSensitive::createConnections() { + connect(m_monteCarloCount, SIGNAL(valueChanged(int)), this, SLOT(onVariableParamChanged())); connect(generateButton, SIGNAL(clicked()), this, SLOT(onGenerateClicked())); connect(cancelButton, SIGNAL(clicked()), @@ -1267,6 +1434,12 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) 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(); @@ -1285,9 +1458,9 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) paramLayout->addWidget(numSpin, row, 1); ++row; - // 空行(Number 和 Model value 之间) + // 模式、数量与下方采样参数之间留出约三行空白。 int gapRow = row; - paramLayout->setRowMinimumHeight(gapRow, 12); + paramLayout->setRowMinimumHeight(gapRow, numSpin->sizeHint().height() * 3); ++row; // Model value + 单位 @@ -1310,6 +1483,34 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) 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); @@ -1359,12 +1560,11 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) valuesTable->setHorizontalHeaderLabels(headers); valuesTable->verticalHeader()->setVisible(false); - valuesTable->setColumnWidth(0, 15); - valuesTable->setColumnWidth(1, 130); - QHeaderView *hh = valuesTable->horizontalHeader(); hh->setResizeMode(QHeaderView::Fixed); hh->setHighlightSections(false); + // 箭头列固定,数值列在下方设置为填满加宽后的表格。 + valuesTable->setColumnWidth(0, 15); valuesTable->setStyleSheet( "QHeaderView::section {" " border-top: 0px;" @@ -1380,7 +1580,7 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) "}" ); - valuesTable->horizontalHeader()->setStretchLastSection(false); + valuesTable->horizontalHeader()->setStretchLastSection(true); valuesTable->setEditTriggers(QAbstractItemView::DoubleClicked); valuesTable->setSelectionBehavior(QAbstractItemView::SelectRows); valuesTable->setSelectionMode(QAbstractItemView::NoSelection); @@ -1390,10 +1590,8 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) valuesTable->setItemDelegateForColumn(1, new RightAlignDelegate(valuesTable)); - int tableWidth = valuesTable->frameWidth() * 2 - + valuesTable->verticalHeader()->sizeHint().width() - + valuesTable->columnWidth(0) - + valuesTable->columnWidth(1); + // 初始空表的列宽尚未稳定,直接固定容器宽度,不从未显示的表头反算。 + const int tableWidth = 190; valuesTable->setFixedWidth(tableWidth); QWidget *tableContainer = new QWidget(content); @@ -1406,6 +1604,8 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) 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); @@ -1420,6 +1620,13 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) 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())); @@ -1445,6 +1652,23 @@ QWidget *nmWxSensitive::createVariablePage(const QString &varName) 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; @@ -1498,6 +1722,49 @@ void nmWxSensitive::updateValuesForPage(int idx) } } +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() { // 输入结束后截断越界范围,再按六位有效数字显示,计算仍使用原始精度。 @@ -1532,7 +1799,8 @@ void nmWxSensitive::onVariableParamChanged() 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_logChecks[i] || sender() == m_distributionCombos[i] || + sender()->parent() == m_distributionPanels[i]) { m_selectedValueIndex = qMin(m_selectedValueIndex, m_numSpins[i]->value() - 1); updateValuesForPage(i); break; @@ -1584,6 +1852,8 @@ void nmWxSensitive::onTreeItemChanged(QTreeWidgetItem* item, int column) void nmWxSensitive::onGenerateClicked() { + // 本阶段只开放 Monte-Carlo 参数采样与预览,尚不启动数值计算。 + if(calculationTypeCombo->currentIndex() == nmDataSensitive::CALC_MONTE_CARLO) return; if(m_calculationRunning || !m_pDataManager || !saveDataFromUI()) return; QString error; QScopedPointer task; @@ -1787,7 +2057,9 @@ void nmWxSensitive::onHideAllResults() void nmWxSensitive::onCalculationTypeChanged(int index) { Q_UNUSED(index); - // 两种确定性方法共用参数设置,仅切换总次数及组合规则。 + // 复用现有参数页,切换方法只改变可见控件,不覆盖另一种方法的设置。 + if(m_loading) return; + for(int i = 0; i < m_numSpins.size(); ++i) updateValuesForPage(i); updateModelCount(); } @@ -1809,6 +2081,13 @@ void nmWxSensitive::onTreeItemClicked(QTreeWidgetItem* item, int column) 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"));