From 280c995d0f81119bd03f0dbbad87cc7b2b91e950 Mon Sep 17 00:00:00 2001 From: lvjunjie Date: Tue, 20 Oct 2026 15:09:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(nmNum):=20=E5=AE=9E=E7=8E=B0=E5=8D=95?= =?UTF-8?q?=E4=BA=95=E5=8D=95=E7=9B=B8=E5=88=86=E6=AE=B5=E4=BA=A7=E9=87=8F?= =?UTF-8?q?=E9=A2=84=E6=B5=8B=E5=8F=8A=E4=B8=BB=E7=95=8C=E9=9D=A2=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nmCalculationDllPebiSolverTask.h | 5 + Include/nmNum/nmSubWnd/nmSubWndMain.h | 9 + Include/nmNum/nmSubWxs/nmWxForecast.h | 25 +- Include/nmNum/nmSubWxs/nmWxNumericalDesign.h | 4 + .../nmCalculationDllPebiSolverTask.cpp | 126 +++++++++- Src/nmNum/nmSubWnd/nmSubWndMain.cpp | 160 +++++++++++++ Src/nmNum/nmSubWnd/nmSubWndUtils.cpp | 7 + Src/nmNum/nmSubWxs/nmWxForecast.cpp | 222 ++++++++++++++---- Src/nmNum/nmSubWxs/nmWxNumericalDesign.cpp | 9 +- 9 files changed, 518 insertions(+), 49 deletions(-) diff --git a/Include/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.h b/Include/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.h index 4333ae2d..69b37865 100644 --- a/Include/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.h +++ b/Include/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.h @@ -78,6 +78,11 @@ class NMCALCULATION_EXPORT nmCalculationDllPebiSolverTask : public QThread { /** @brief 返回自动拟合目标井的原始压力结果副本。 */ QVector > getAutoFitResultPressure() const; + /** @brief 启动前设置单井单相预测制度;单位为 MPa、hr、m^3/D,仅修改任务副本。 */ + bool configureRateForecast(double initialPressure, const QVector& durations, + const QVector& rates); + /** @brief 线程结束后读取预测时间(hr)和井底压力(MPa),不生成试井诊断曲线。 */ + QVector > getRateForecastPressure() const; /** @brief 返回自动拟合目标井的双对数结果副本。 */ QVector > getAutoFitResultLogLog() const; /** @brief 返回自动拟合目标井的半对数结果副本。 */ diff --git a/Include/nmNum/nmSubWnd/nmSubWndMain.h b/Include/nmNum/nmSubWnd/nmSubWndMain.h index de6084f2..5f11d09e 100644 --- a/Include/nmNum/nmSubWnd/nmSubWndMain.h +++ b/Include/nmNum/nmSubWnd/nmSubWndMain.h @@ -91,6 +91,8 @@ class NM_SUB_WND_EXPORT nmSubWndMain : public iSubWndBaseFit { protected: + bool eventFilter(QObject* watched, QEvent* event); + // 绑定图形信号 virtual void bindChartSignals(iGuiPlot* pWxPlot); @@ -168,6 +170,13 @@ class NM_SUB_WND_EXPORT nmSubWndMain : public iSubWndBaseFit { void onSensitivityTaskDestroyed(); /** @brief 在框架已有的敏感性子窗口叠加显示本分析的双对数结果。 */ void onShowSensitiveResults(); + // 预测结果复用所属分析内的 MDI 子窗口,模型变化时移除旧结果。 + void onShowForecastResult(const QString& wellName, const QVector& times, + const QVector& pressures, const QVector& durations, + const QVector& rates); + void clearForecastResult(); + void syncForecastTimeAxis(double minimum, double maximum, bool recalcTicks, bool adjustZoom); + void fitForecastViews(); void onSensitiveFilterChanged(); void onSensitiveShowClicked(); /** @brief 刷新主界面的误差直方图,修改分箱数时复用当前批次结果。 */ diff --git a/Include/nmNum/nmSubWxs/nmWxForecast.h b/Include/nmNum/nmSubWxs/nmWxForecast.h index 40fd93dc..a5ff15d5 100644 --- a/Include/nmNum/nmSubWxs/nmWxForecast.h +++ b/Include/nmNum/nmSubWxs/nmWxForecast.h @@ -13,17 +13,30 @@ #include #include #include +#include #include "nmDataForecast.h" #include "nmGUIComponentLineEdit.h" #include "nmDataAnalyzeManager.h" +class nmCalculationDllPebiSolverTask; +class QProgressDialog; + class nmWxForecast : public iDlgBase { Q_OBJECT public: - explicit nmWxForecast(QWidget *parent = 0); + explicit nmWxForecast(QWidget *parent = 0, nmDataAnalyzeManager* manager = 0); ~nmWxForecast(); +signals: + // 计算完成后由所属分析主界面承载结果,输入窗口不持有结果窗口。 + void sigForecastReady(const QString& wellName, const QVector& times, + const QVector& pressures, const QVector& durations, + const QVector& rates); + +public slots: + void reject(); + private slots: // Forecast option slots void onConstantPressureSelected(); @@ -41,6 +54,8 @@ public: void onLoadDataClicked(); void onGenerateClicked(); void onCancelClicked(); + void onForecastFinished(); + void onModelChanged(); // Table edit void onPressureCellChanged(int row, int column); @@ -60,6 +75,7 @@ private: void updateUIFromData(); void updateDataFromUI(); void saveDataToManager(); + bool readRateSchedule(QVector& durations, QVector& rates, QString& error) const; QWidget* buildExtEmptyPage(const QString& text = QString()); QWidget* buildExtRampPage(); @@ -71,6 +87,13 @@ private: private: nmDataForecast m_forecastData; + QPointer m_manager; ///< 固定所属分析窗口,不跟随全局当前窗口。 + nmCalculationDllPebiSolverTask* m_task; + QProgressDialog* m_progress; + QString m_sourceSnapshotId; + QString m_wellName; + QVector m_durations; ///< 本次计算的分段时长(hr)。 + QVector m_rates; ///< 本次计算的分段产量(m^3/D)。 // Main layout QVBoxLayout *m_mainLayout; diff --git a/Include/nmNum/nmSubWxs/nmWxNumericalDesign.h b/Include/nmNum/nmSubWxs/nmWxNumericalDesign.h index f33ec6c7..0963728c 100644 --- a/Include/nmNum/nmSubWxs/nmWxNumericalDesign.h +++ b/Include/nmNum/nmSubWxs/nmWxNumericalDesign.h @@ -70,6 +70,10 @@ public: void activateCurrentInstance(); signals: + // 将预测结果转交给本面板所属的分析主界面。 + void sigForecastReady(const QString& wellName, const QVector& times, + const QVector& pressures, const QVector& durations, + const QVector& rates); void sigGenerateClicked(); // 点击生成按钮时发出的信号 // 敏感性批量任务交给所属主窗口,复用普通求解进度框。 void sigSensitivityTaskReady(nmWxSensitive* dialog, nmCalculationDllPebiSolverTask* task); diff --git a/Src/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.cpp b/Src/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.cpp index f9de1922..91fd8f78 100644 --- a/Src/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.cpp +++ b/Src/nmNum/nmCalculation/nmCalculationDllPebiSolverTask.cpp @@ -90,6 +90,7 @@ struct nmPebiSolverInputSnapshot m_bGridResultNeedsCommit(false), m_bMayUseCachedGrid(false), m_bAutoFitTargetOnly(false), + m_bRateForecast(false), m_bResultMetadataCaptured(false) { } @@ -114,6 +115,7 @@ struct nmPebiSolverInputSnapshot bool m_bGridResultNeedsCommit; ///< 完整求解成功后是否需在主线程提交新网格。 bool m_bMayUseCachedGrid; ///< 捕获时是否存在同版本网格缓存。 bool m_bAutoFitTargetOnly; ///< true 时只构造目标井临时曲线,不提交成果。 + bool m_bRateForecast; ///< 产量预测只返回原始压力,不计算双对数和半对数。 bool m_bResultMetadataCaptured; ///< 正式结果元数据是否已在主线程完整冻结。 }; @@ -1795,6 +1797,90 @@ bool nmCalculationDllPebiSolverTask::captureInputSnapshot( return true; } +bool nmCalculationDllPebiSolverTask::configureRateForecast( + double initialPressure, const QVector& durations, + const QVector& rates) +{ + // 使用已有模型的任务副本,从零时刻重新施加制度,绝不改写正式模型及历史数据。 + if(isRunning() || !m_bInputSnapshotValid || m_pInputSnapshot == NULL || + !m_pInputSnapshot->m_bAutoFitTargetOnly) { + return fail(tr("Forecast input is unavailable.")); + } + m_bInputSnapshotValid = false; + if(m_pDataManager == NULL || !m_pDataManager->isPebiResultSnapshotCurrent() || + hasInputChanged(m_pDataManager)) { + return fail(tr("Generate the current model before forecasting.")); + } + nmPebiGridInputSnapshot& grid = m_pInputSnapshot->m_oGridInput; + nmDataBinaryTools::NM_PEBI_SCENE& scene = grid.m_oScene; + const int model = scene.solverType; + if(model < SMT_Oil_ConstPvt || model > SMT_Gas_PseudoPressure) { + return fail(tr("Rate forecast currently supports single-phase models only.")); + } + int target = -1; + int wellCount = 0; + for(int i = 0; i < grid.m_vecWellInputs.size(); ++i) { + const nmPebiWellInputSnapshot& well = grid.m_vecWellInputs[i]; + if(!well.m_bRealWell) continue; + ++wellCount; + if(well.m_sWellCode == m_sAutoFitTargetWellCode && well.m_bRateControlled) target = i; + } + if(wellCount != 1 || target < 0) { + return fail(tr("Rate forecast currently requires one rate-controlled well.")); + } + if(!isFiniteSolverNumber(initialPressure) || initialPressure <= 0.0 || + durations.isEmpty() || durations.size() != rates.size()) { + return fail(tr("Enter a positive initial pressure and a complete rate schedule.")); + } + double totalTime = 0.0; + double cumulative = 0.0; + for(int i = 0; i < durations.size(); ++i) { + const double nextTime = totalTime + durations[i]; + cumulative += rates[i] * durations[i] / 24.0; + if(!isFiniteSolverNumber(durations[i]) || durations[i] <= 0.0 || + !isFiniteSolverNumber(rates[i]) || rates[i] < 0.0 || + !isFiniteSolverNumber(nextTime) || nextTime <= totalTime || + !isFiniteSolverNumber(cumulative)) { + return fail(tr("Each duration must be positive and each production rate non-negative.")); + } + totalTime = nextTime; + } + + // 当前 DLL 除油单相常量 PVT 外,单段制度会抛出 vector too long。 + // 拆为两个同产量的等长段,仅满足 DLL 输入要求,不改变用户的生产制度。 + QVector solverDurations = durations; + QVector solverRates = rates; + if(durations.size() == 1 && model != SMT_Oil_ConstPvt) { + solverDurations[0] = durations[0] / 2.0; + solverDurations.append(durations[0] - solverDurations[0]); + solverRates.append(rates[0]); + } + // 外层数组保持网格槽位顺序;手工裂缝不施加流量,单相流量只填入对应相。 + const int slotCount = grid.m_vecWellInputs.size(); + scene.Rate.t.assign(slotCount, std::vector()); + scene.Rate.qo.assign(slotCount, std::vector()); + scene.Rate.qg.assign(slotCount, std::vector()); + scene.Rate.qw.assign(slotCount, std::vector()); + scene.Rate.t[target].assign(solverDurations.constBegin(), solverDurations.constEnd()); + scene.Rate.qo[target].assign(solverRates.size(), 0.0); + scene.Rate.qg[target].assign(solverRates.size(), 0.0); + scene.Rate.qw[target].assign(solverRates.size(), 0.0); + std::vector& phaseRates = model <= SMT_Oil_VariablePvt ? scene.Rate.qo[target] + : model <= SMT_Water_VariablePvt ? scene.Rate.qw[target] : scene.Rate.qg[target]; + phaseRates.assign(solverRates.constBegin(), solverRates.constEnd()); + scene.Base.Pi = initialPressure; + m_pInputSnapshot->m_bRateForecast = true; + m_bInputSnapshotValid = true; + return true; +} + +QVector > nmCalculationDllPebiSolverTask::getRateForecastPressure() const +{ + // 沿用目标井临时结果容器,预测结果不进入正式成果提交路径。 + return m_pInputSnapshot != NULL && m_pInputSnapshot->m_bRateForecast + ? m_autoFitResultPressure : QVector >(); +} + bool nmCalculationDllPebiSolverTask::prepareManualInput() { if(m_pInputSnapshot == nullptr) { @@ -1911,7 +1997,8 @@ bool nmCalculationDllPebiSolverTask::execPebiMode() } const QAtomicInt* pCancelRequested = - m_pInputSnapshot->m_bAutoFitTargetOnly && m_pSensitivitySampling.isNull() + m_pInputSnapshot->m_bAutoFitTargetOnly && !m_pInputSnapshot->m_bRateForecast && + m_pSensitivitySampling.isNull() ? NULL : &m_nCancelRequested; // 第一步:清空本次任务自己的输出。旧成果仍保留在 DataManager 中,只有主线程 @@ -2166,6 +2253,43 @@ bool nmCalculationDllPebiSolverTask::buildPebiModeResult( QVector> vvecPressure; QVector> vvecLogLog; QVector> vvecSemiLog; + if(m_pInputSnapshot->m_bRateForecast) { + // 预测允许单段产量和全程关井,不依赖历史流动段或试井诊断曲线。 + const nmPebiGridInputSnapshot& grid = m_pInputSnapshot->m_oGridInput; + int target = -1; + for(int i = 0; i < grid.m_vecWellInputs.size(); ++i) { + if(grid.m_vecWellInputs[i].m_bRealWell && + grid.m_vecWellInputs[i].m_sWellCode == m_sAutoFitTargetWellCode) target = i; + } + if(target < 0 || target >= static_cast(p1.pw.size()) || p1.t.empty() || + p1.pw[target].size() != p1.t.size()) { + return fail(tr("The solver returned incomplete forecast pressure data.")); + } + double endTime = 0.0; + for(size_t i = 0; i < grid.m_oScene.Rate.t[target].size(); ++i) + endTime += grid.m_oScene.Rate.t[target][i]; + const double tolerance = qMax(1.0, endTime) * 1.0e-8; + QVector > pressure(2); + if(p1.t[0] > 0.0) { + pressure[0].append(0.0); + pressure[1].append(grid.m_oScene.Base.Pi); + } + for(size_t i = 0; i < p1.t.size(); ++i) { + if(isCancelRequested()) return false; + if(!isFiniteSolverNumber(p1.t[i]) || p1.t[i] < 0.0 || + p1.t[i] > endTime + tolerance || (i > 0 && p1.t[i] <= p1.t[i - 1]) || + !isFiniteSolverNumber(p1.pw[target][i]) || p1.pw[target][i] <= 0.0) { + return fail(tr("Invalid forecast pressure or time. Check the rate schedule and model.")); + } + pressure[0].append(p1.t[i]); + pressure[1].append(p1.pw[target][i]); + } + if(qAbs(p1.t.back() - endTime) > tolerance) { + return fail(tr("The solver did not reach the end of the forecast schedule.")); + } + m_autoFitResultPressure = pressure; + return true; + } // p1.pw 是求解器返回的原始井底压力, 后续仍按 MPa 保存到 vvecPressure. // 只有气单相变化 PVT 模型的双对数和半对数计算需要改用拟压力. const bool usePseudoPressure = (modelType == static_cast(SMT_Gas_VariablePvt)); diff --git a/Src/nmNum/nmSubWnd/nmSubWndMain.cpp b/Src/nmNum/nmSubWnd/nmSubWndMain.cpp index 764af731..8b0d857c 100644 --- a/Src/nmNum/nmSubWnd/nmSubWndMain.cpp +++ b/Src/nmNum/nmSubWnd/nmSubWndMain.cpp @@ -1901,6 +1901,166 @@ void nmSubWndMain::onSensitivityTaskDestroyed() m_pProgressDlg->hide(); } +void nmSubWndMain::onShowForecastResult(const QString& wellName, const QVector& times, + const QVector& pressures, const QVector& durations, + const QVector& rates) +{ + // 使用面板绑定的分析页面,切换活动分析不会改变预测结果的归属。 + iSubWndFitting* fitting = m_pResultRefreshFitting.data(); + nmDataAnalyzeManager* manager = m_pResultRefreshDataManager.data(); + if(!fitting || !fitting->m_pMdiArea || !manager || + nmDataAnalyzeManager::findManagerByFitting(fitting) != manager || + !manager->isPebiResultSnapshotCurrent()) return; + ZxMdiArea* area = fitting->m_pMdiArea; + QMdiSubWindow* window = area->findChild("nmForecastResult"); + const bool created = window == nullptr; + if(created) { + // 结果页面由 MDI 承载,关闭预测输入对话框不销毁结果。 + QWidget* page = new QWidget; + QVBoxLayout* layout = new QVBoxLayout(page); + layout->setContentsMargins(0, 0, 0, 0); + QSplitter* charts = new QSplitter(Qt::Vertical, page); + charts->setObjectName("nmForecastCharts"); + charts->setChildrenCollapsible(false); + layout->addWidget(charts); + // 布局完成后统一适配画布,避免小窗口下两个绘图区缩放比例不同。 + charts->installEventFilter(this); + connect(charts, SIGNAL(splitterMoved(int,int)), this, SLOT(fitForecastViews()), Qt::QueuedConnection); + for(int i = 0; i < 2; ++i) { + iGuiPlot* plot = new iGuiPlot(true, charts); + plot->setObjectName(i == 0 ? "nmForecastRate" : "nmForecastPressure"); + plot->initUI(i == 0 ? tr("Production rate") : tr("Bottomhole pressure")); + plot->m_pPlotView->setSimpleMode(true); + plot->setDropEnabled(false); + plot->setAxisX(tr("Time"), "hr"); + plot->setAxisY(i == 0 ? tr("Production rate") : tr("Bottomhole pressure"), i == 0 ? "m^3/D" : "MPa"); + plot->setAxisY(i == 0 ? tr("Cumulative production") : QString(), i == 0 ? "m^3" : "", true); + for(int axis = 0; axis < 3; ++axis) plot->setAxisXYLog(false, axis); + plot->getAxisOf(2)->setVisible(true); + // 下图右侧保留空轴位,确保两张图的时间刻度在水平方向对齐。 + if(i == 1) { + plot->getAxisOf(2)->setColor(Qt::transparent); + } + plot->m_pPlot->setBKColor(Qt::white); + plot->m_pPlot->setLegendVisible(false); + charts->addWidget(plot); + charts->setStretchFactor(i, 1); + connect(plot->getAxisOf(0), SIGNAL(sigRangeChanged(double,double,bool,bool)), + this, SLOT(syncForecastTimeAxis(double,double,bool,bool))); + } + window = area->addSubWindow(page); + window->setObjectName("nmForecastResult"); + window->setAttribute(Qt::WA_DeleteOnClose, false); + } + window->setWindowTitle(tr("Forecast - %1").arg(wellName)); + + // 压力直接取求解器结果;阶梯产量及累计量按本次输入制度精确构造。 + QVector rateTimes, rateValues, cumulativeTimes, cumulativeValues; + double time = 0.0, cumulative = 0.0; + cumulativeTimes.append(0.0); + cumulativeValues.append(0.0); + for(int i = 0; i < durations.size(); ++i) { + rateTimes << time << time + durations[i]; + rateValues << rates[i] << rates[i]; + time += durations[i]; + cumulative += rates[i] * durations[i] / 24.0; + cumulativeTimes.append(time); + cumulativeValues.append(cumulative); + } + const QStringList names = QStringList() << tr("Bottomhole pressure") << tr("Production rate") << tr("Cumulative production"); + QVector curves; + for(int i = 0; i < 3; ++i) { + iCurveDesc curve; + curve.m_sCurveName = names[i]; + curve.m_oCurveType = POT_CurveDiscrete; + curve.m_bLineVisible = true; + curve.m_bPointVisible = false; + curve.m_bUseY2 = i == 2; + curve.m_oPen = QPen(i == 0 ? Qt::red : i == 1 ? Qt::darkGreen : Qt::gray, 0.35); + curve.m_vecX = i == 0 ? times : i == 1 ? rateTimes : cumulativeTimes; + curve.m_vecY = i == 0 ? pressures : i == 1 ? rateValues : cumulativeValues; + curves.append(curve); + } + iGuiPlot* plots[] = {window->findChild("nmForecastRate"), + window->findChild("nmForecastPressure")}; + for(int i = 0; i < 2; ++i) { + iGuiPlot* plot = plots[i]; + // 重复生成只替换曲线,清理旧图元和撤销记录,保留上下图窗口。 + for(int j = plot->m_pPlot->getObjCount() - 1; j >= 0; --j) + if(qobject_cast(plot->m_pPlot->getObjByIndex(j))) plot->m_pPlot->removeObjByIndex(j); + plot->clearUndoStack(); + plot->setCurveData(i == 0 ? curves.mid(1, 2) : curves.mid(0, 1)); + plot->runUpdate(); + plot->setReadOnly(true); + plot->freshAxisScales(true, true); + // 自动定标会压缩没有曲线的右轴,下图需要保留这块空白以对齐上图。 + plot->getAxisOf(2)->setMinimizeAxis(false); + // 两张图都以完整预测时长为基准,缩放复原时也回到同一时间范围。 + VecDouble ticks; + for(int tick = 0; tick <= 5; ++tick) ticks.append(time * tick / 5.0); + plot->setAxisScaleX(0.0, time, ticks); + // 时间刻度及标题只在下图显示,上图仍保留横轴数据与同步关系。 + plot->getAxisOf(0)->setMinimizeAxis(i == 0); + } + const bool restore = window->isHidden(); + window->widget()->show(); + window->show(); + // 隐藏的分析页交给框架显示事件布局,避免此时重排把结果重新隐藏。 + if((created || restore) && area->isVisible()) area->relayoutSubWnds(); + area->setActiveSubWindow(window); + QTimer::singleShot(0, this, SLOT(fitForecastViews())); +} + +bool nmSubWndMain::eventFilter(QObject* watched, QEvent* event) +{ + // 子窗口缩放、重新显示均在内部控件完成尺寸更新后再适配。 + if(watched->objectName() == "nmForecastCharts" && + (event->type() == QEvent::Resize || event->type() == QEvent::Show)) + QTimer::singleShot(0, this, SLOT(fitForecastViews())); + return iSubWndBaseFit::eventFilter(watched, event); +} + +void nmSubWndMain::fitForecastViews() +{ + // 只适配图形画布的显示尺寸,不改变用户选定的时间和压力范围。 + iSubWndFitting* fitting = m_pResultRefreshFitting.data(); + if(!fitting || !fitting->m_pMdiArea) return; + QMdiSubWindow* window = fitting->m_pMdiArea->findChild("nmForecastResult"); + if(!window) return; + foreach(iGuiPlot* plot, window->findChildren()) + plot->m_pPlotView->setZoomMode(ZxRenderView::eZoomFitWindow, true); +} + +void nmSubWndMain::syncForecastTimeAxis(double minimum, double maximum, bool recalcTicks, bool adjustZoom) +{ + // 只同步本分析预测图的横轴;阻断回传,避免两张图相互触发范围更新。 + iSubWndFitting* fitting = m_pResultRefreshFitting.data(); + if(!fitting || !fitting->m_pMdiArea) return; + QMdiSubWindow* window = fitting->m_pMdiArea->findChild("nmForecastResult"); + if(!window) return; + iGuiPlot* rate = window->findChild("nmForecastRate"); + iGuiPlot* pressure = window->findChild("nmForecastPressure"); + if(!rate || !pressure) return; + iGuiPlot* target = sender() == rate->getAxisOf(0) ? pressure : rate; + ZxSubAxisBase* axis = target->getAxisOf(0); + const bool blocked = axis->blockSignals(true); + axis->setRangeMinMax(minimum, maximum, recalcTicks, adjustZoom); + axis->blockSignals(blocked); + target->m_pPlot->update(); +} + +void nmSubWndMain::clearForecastResult() +{ + // 由数据管理器通知主界面清场,不依赖预测输入对话框是否仍然打开。 + iSubWndFitting* fitting = m_pResultRefreshFitting.data(); + if(sender() != m_pResultRefreshDataManager.data() || !fitting || !fitting->m_pMdiArea) return; + QMdiSubWindow* window = fitting->m_pMdiArea->findChild("nmForecastResult"); + if(window) { + fitting->m_pMdiArea->removeSubWindow(window); + delete window; + } +} + void nmSubWndMain::onShowSensitiveResults() { // 使用固定所属分析的窗口与数据,不能把结果画到当前活动的其他分析中。 diff --git a/Src/nmNum/nmSubWnd/nmSubWndUtils.cpp b/Src/nmNum/nmSubWnd/nmSubWndUtils.cpp index 3e506944..0930c556 100644 --- a/Src/nmNum/nmSubWnd/nmSubWndUtils.cpp +++ b/Src/nmNum/nmSubWnd/nmSubWndUtils.cpp @@ -481,6 +481,8 @@ void nmSubWndUtils::setupNumericalDockWidgets(nmDataAnalyzeManager* pDataManager // 数值设计面板只发出操作请求,具体处理仍由主窗口完成。 connect(pAnalWx, SIGNAL(sigGenerateClicked()), pSubWndMain, SLOT(onGenerateButtonClicked())); + connect(pAnalWx, SIGNAL(sigForecastReady(QString,QVector,QVector,QVector,QVector)), + pSubWndMain, SLOT(onShowForecastResult(QString,QVector,QVector,QVector,QVector))); connect(pAnalWx, SIGNAL(sigSensitivityTaskReady(nmWxSensitive*,nmCalculationDllPebiSolverTask*)), pSubWndMain, SLOT(onSensitivityTaskReady(nmWxSensitive*,nmCalculationDllPebiSolverTask*))); connect(pAnalWx, SIGNAL(sigResultWellChanged(QString)), @@ -488,6 +490,11 @@ void nmSubWndUtils::setupNumericalDockWidgets(nmDataAnalyzeManager* pDataManager // 手工求解和自动拟合都发布同一种正式快照,统一复用主窗口结果刷新逻辑。 pSubWndMain->bindPebiResultRefreshContext(pDataManager, pSubWndFitting); + // 输入窗口关闭后,主界面仍负责清理因模型变化而失效的预测。 + connect(pDataManager, SIGNAL(sigPebiResultSnapshotChanged()), + pSubWndMain, SLOT(clearForecastResult()), Qt::UniqueConnection); + connect(pDataManager, SIGNAL(sigPebiResultStateChanged()), + pSubWndMain, SLOT(clearForecastResult()), Qt::UniqueConnection); connect(pDataManager, SIGNAL(sigSensitiveResultsChanged()), pSubWndMain, SLOT(onShowSensitiveResults()), static_cast(Qt::QueuedConnection | Qt::UniqueConnection)); diff --git a/Src/nmNum/nmSubWxs/nmWxForecast.cpp b/Src/nmNum/nmSubWxs/nmWxForecast.cpp index 92369d80..d415bb05 100644 --- a/Src/nmNum/nmSubWxs/nmWxForecast.cpp +++ b/Src/nmNum/nmSubWxs/nmWxForecast.cpp @@ -4,9 +4,17 @@ #include #include #include +#include +#include +#include +#include "nmCalculationDllPebiSolverTask.h" +#include "nmPebiResultSnapshot.h" -nmWxForecast::nmWxForecast(QWidget *parent) +nmWxForecast::nmWxForecast(QWidget *parent, nmDataAnalyzeManager* manager) : iDlgBase(parent) + , m_manager(manager ? manager : nmDataAnalyzeManager::getCurrentInstance()) + , m_task(NULL) + , m_progress(NULL) , m_initialPressureComponent(nullptr) , m_abandonmentRateComponent(nullptr) , m_maximumRateComponent(nullptr) @@ -26,6 +34,7 @@ nmWxForecast::nmWxForecast(QWidget *parent) { setWindowTitle("Forecast"); setFixedSize(520, 560); + setAttribute(Qt::WA_DeleteOnClose); PressureRow tempDefaults; m_currentDurationUnit = tempDefaults.duration.getUnit(); @@ -43,44 +52,41 @@ nmWxForecast::nmWxForecast(QWidget *parent) // 根据加载的数据更新界面 updateParametersForOption(); + if(m_manager) { + connect(m_manager, SIGNAL(sigPebiResultSnapshotChanged()), this, SLOT(onModelChanged())); + connect(m_manager, SIGNAL(sigPebiResultStateChanged()), this, SLOT(onModelChanged())); + connect(m_manager, SIGNAL(destroyed()), this, SLOT(close())); + } } nmWxForecast::~nmWxForecast() { + // DLL 内部不能强行中断,析构前等待任务退出,避免悬空回调。 + if(m_task) { + m_task->requestCancel(); + m_task->wait(); + delete m_task; + } } void nmWxForecast::loadDataFromManager() { - // 从数据中心获取预测数据的副本 - nmDataAnalyzeManager* pManager = nmDataAnalyzeManager::getCurrentInstance(); - - if(pManager) { - m_forecastData = pManager->getForecastDataCopy(); - - // 根据数据设置单选按钮 - nmDataForecast::ForecastOption option = m_forecastData.getForecastOption(); - - switch(option) { - case nmDataForecast::ConstantPressureOption: - m_constantPressureRadio->setChecked(true); - break; - - case nmDataForecast::DecliningPressureOption: - m_decliningPressureRadio->setChecked(true); - break; - - case nmDataForecast::MultiplePressuresOption: - m_multiplePressuresRadio->setChecked(true); - break; - case nmDataForecast::MultipleRatesOption: - m_multipleRatesRadio->setChecked(true); - break; + // 当前先开放分段产量,初始压力默认取已生成模型,输入只保留在内存中。 + if(m_manager) m_forecastData = m_manager->getForecastDataCopy(); + nmDataAttribute& pressure = m_forecastData.getMultipleRatesData().getInitialPressure(); + if(m_manager && m_forecastData.getForecastOption() != nmDataForecast::MultipleRatesOption) { + const QSharedPointer solved = m_manager->getPebiResultSnapshot(); + if(!solved.isNull()) { + pressure.setValue(pressure.convertValueByUnitType( + solved->getReservoirParameters().m_dInitialPressure, "MPa", pressure.getUnit())); } - } else { - // 如果数据中心不存在,使用默认数据 - m_constantPressureRadio->setChecked(true); - m_forecastData.setForecastOption(nmDataForecast::ConstantPressureOption); } + // 本页使用绝对压力,避免通用单位控件把表压当作绝压换算。 + pressure.setListUnitSelections(QStringList() << "MPa" << "psia" << "kPa" << "bara"); + m_forecastData.setForecastOption(nmDataForecast::MultipleRatesOption); + m_multipleRatesRadio->blockSignals(true); + m_multipleRatesRadio->setChecked(true); + m_multipleRatesRadio->blockSignals(false); } void nmWxForecast::saveDataToManager() @@ -89,7 +95,7 @@ void nmWxForecast::saveDataToManager() updateDataFromUI(); // 保存到数据中心 - nmDataAnalyzeManager* pManager = nmDataAnalyzeManager::getCurrentInstance(); + nmDataAnalyzeManager* pManager = m_manager.data(); if(pManager) { pManager->updateForecastData(m_forecastData); @@ -108,6 +114,10 @@ void nmWxForecast::setupUI() m_decliningPressureRadio = new QRadioButton("Declining pressure (ramp)", this); m_multiplePressuresRadio = new QRadioButton("Multiple pressures", this); m_multipleRatesRadio = new QRadioButton("Multiple rates", this); + // 求解器目前仅接通产量控制;压力控制保留入口但不允许生成。 + m_constantPressureRadio->setEnabled(false); + m_decliningPressureRadio->setEnabled(false); + m_multiplePressuresRadio->setEnabled(false); m_forecastButtonGroup = new QButtonGroup(this); m_forecastButtonGroup->addButton(m_constantPressureRadio, 0); @@ -390,7 +400,7 @@ QWidget* nmWxForecast::buildExtMultiRatePage() // 创建表格 m_rateTableWidget = new QTableWidget(0, 2); // 根据截图设置表头 - m_rateTableWidget->setHorizontalHeaderLabels(QStringList() << "Duration" << "Qo"); + m_rateTableWidget->setHorizontalHeaderLabels(QStringList() << "Duration" << "Rate"); m_rateTableWidget->verticalHeader()->setVisible(false); m_rateTableWidget->horizontalHeader()->setResizeMode(QHeaderView::Stretch); @@ -799,7 +809,7 @@ void nmWxForecast::updateUIFromData() QString basePresUnit = rowData.pressure.getUnit(); double displayPresValue = tempDefaults.pressure.convertValueByUnitType(basePresValue, basePresUnit, m_currentPressureUnit); - QTableWidgetItem* durationItem = new QTableWidgetItem(QString::number(displayDurValue)); + QTableWidgetItem* durationItem = new QTableWidgetItem(QString::number(displayDurValue, 'g', 15)); durationItem->setTextAlignment(Qt::AlignCenter); m_pressureTableWidget->setItem(tableRow, 0, durationItem); @@ -855,11 +865,11 @@ void nmWxForecast::updateUIFromData() QString baseRateUnit = rowData.q0.getUnit(); double displayRateValue = tempDefaults.q0.convertValueByUnitType(baseRateValue, baseRateUnit, m_currentRateUnit); - QTableWidgetItem* durationItem = new QTableWidgetItem(QString::number(displayDurValue)); + QTableWidgetItem* durationItem = new QTableWidgetItem(QString::number(displayDurValue, 'g', 15)); durationItem->setTextAlignment(Qt::AlignCenter); m_rateTableWidget->setItem(tableRow, 0, durationItem); - QTableWidgetItem* rateItem = new QTableWidgetItem(QString::number(displayRateValue)); + QTableWidgetItem* rateItem = new QTableWidgetItem(QString::number(displayRateValue, 'g', 15)); rateItem->setTextAlignment(Qt::AlignCenter); m_rateTableWidget->setItem(tableRow, 1, rateItem); } @@ -992,22 +1002,138 @@ void nmWxForecast::onLoadDataClicked() void nmWxForecast::onGenerateClicked() { - // 确保最新的界面数据写回到模型 - updateDataFromUI(); - - // 保存数据到数据中心 + // 先校验原始输入,避免空单元格被旧的数据同步逻辑补成默认值。 + if(m_task) return; + if(!m_manager || !m_manager->isPebiResultSnapshotCurrent()) { + QMessageBox::warning(this, tr("Forecast"), tr("Generate the current model before forecasting.")); + return; + } + const QSharedPointer solved = m_manager->getPebiResultSnapshot(); + if(solved->getWellCount() != 1 || solved->getSolverSettings().m_nSolverModelType < 1 || + solved->getSolverSettings().m_nSolverModelType > 6) { + QMessageBox::warning(this, tr("Forecast"), tr("Rate forecast currently supports single-well, single-phase models only.")); + return; + } + QString error; + QVector durations, rates; + if(!readRateSchedule(durations, rates, error)) { + QMessageBox::warning(this, tr("Forecast"), error); + return; + } + nmDataAttribute& pressure = m_forecastData.getMultipleRatesData().getInitialPressure(); + if(!m_initialPressureComponent->updateValueToOrigin()) { + QMessageBox::warning(this, tr("Forecast"), tr("Enter a positive initial pressure.")); + return; + } + const double pi = pressure.convertValueByUnitType(pressure.getValue().toDouble(), pressure.getUnit(), "MPa"); + m_wellName = solved->getWellAt(0)->m_sWellName; + m_task = new nmCalculationDllPebiSolverTask(QString(), m_manager, m_wellName, this); + if(!m_task->configureRateForecast(pi, durations, rates)) { + error = m_task->getFailureReason(); + delete m_task; + m_task = NULL; + QMessageBox::warning(this, tr("Forecast"), error); + return; + } saveDataToManager(); - - // TODO: 使用数据中心的数据进行预测计算 - - accept(); + m_durations = durations; + m_rates = rates; + m_sourceSnapshotId = solved->getSnapshotId(); + m_parametersGroup->setEnabled(false); + m_generateButton->setEnabled(false); + m_progress = new QProgressDialog(tr("Calculating forecast..."), tr("Cancel"), 0, 0, this); + m_progress->setWindowTitle(tr("Forecast")); + m_progress->setWindowModality(Qt::ApplicationModal); + m_progress->setMinimumDuration(0); + m_progress->setAutoClose(false); + connect(m_progress, SIGNAL(canceled()), this, SLOT(onCancelClicked())); + connect(m_task, SIGNAL(finished()), this, SLOT(onForecastFinished())); + m_progress->show(); + m_task->start(); } void nmWxForecast::onCancelClicked() { + // 关闭按钮和进度框共用协作式停止,不在 DLL 内部强制终止线程。 reject(); } +void nmWxForecast::reject() +{ + // 正在求解时保留窗口,等线程结束后恢复输入,避免关闭操作阻塞界面。 + if(m_task) { + m_task->requestCancel(); + m_progress->setLabelText(tr("Stopping after the current solver call finishes...")); + m_progress->setCancelButton(NULL); + m_progress->show(); + return; + } + iDlgBase::reject(); +} + +bool nmWxForecast::readRateSchedule(QVector& durations, QVector& rates, QString& error) const +{ + // 求解器统一使用小时及标准体积产量,完整空行跳过,半空行必须补全。 + durations.clear(); + rates.clear(); + FlowRow units; + for(int row = 1; row < m_rateTableWidget->rowCount(); ++row) { + const QTableWidgetItem* durationItem = m_rateTableWidget->item(row, 0); + const QTableWidgetItem* rateItem = m_rateTableWidget->item(row, 1); + const QString durationText = durationItem ? durationItem->text().trimmed() : QString(); + const QString rateText = rateItem ? rateItem->text().trimmed() : QString(); + if(durationText.isEmpty() && rateText.isEmpty()) continue; + bool durationOk = false, rateOk = false; + double duration = durationText.toDouble(&durationOk); + double rate = rateText.toDouble(&rateOk); + duration = units.duration.convertValueByUnitType(duration, m_currentDurationUnit, "hr"); + rate = units.q0.convertValueByUnitType(rate, m_currentRateUnit, "m^3/D"); + if(!durationOk || !rateOk || !qIsFinite(duration) || duration <= 0.0 || + !qIsFinite(rate) || rate < 0.0) { + error = tr("Row %1: enter a positive duration and a non-negative production rate (0 for shut-in).").arg(row); + return false; + } + durations.append(duration); + rates.append(rate); + } + if(durations.isEmpty()) { + error = tr("Enter at least one duration and production rate."); + return false; + } + return true; +} + +void nmWxForecast::onForecastFinished() +{ + // finished 后才读取及销毁任务;输入变化或模型重新生成时丢弃本次预测。 + m_task->wait(); + const bool cancelled = m_task->wasCancelled() || m_progress->wasCanceled(); + const bool current = m_manager && m_manager->isPebiResultSnapshotCurrent() && + !m_task->hasInputChanged(m_manager) && + m_manager->getPebiResultSnapshot()->getSnapshotId() == m_sourceSnapshotId; + const bool success = m_task->wasSuccessful(); + const QString error = m_task->getFailureReason(); + const QVector > pressure = m_task->getRateForecastPressure(); + delete m_task; + m_task = NULL; + delete m_progress; + m_progress = NULL; + m_parametersGroup->setEnabled(true); + m_generateButton->setEnabled(true); + if(cancelled) return; + if(!current || !success) { + QMessageBox::warning(this, tr("Forecast"), current ? error : tr("The model changed. Generate the forecast again.")); + return; + } + emit sigForecastReady(m_wellName, pressure[0], pressure[1], m_durations, m_rates); +} + +void nmWxForecast::onModelChanged() +{ + // 模型或输入改变时停止本次计算;已显示的结果由所属主界面清理。 + if(m_task) m_task->requestCancel(); +} + void nmWxForecast::onPressureCellChanged(int row, int column) { Q_UNUSED(column); @@ -1131,10 +1257,12 @@ void nmWxForecast::onSharedDurationUnitChanged(const QString& unit) QTableWidgetItem* dataItem = m_rateTableWidget->item(i, 0); // 第0列 (Duration) if(dataItem && !dataItem->text().isEmpty()) { - double oldDisplayValue = dataItem->text().toDouble(); + bool ok = false; + double oldDisplayValue = dataItem->text().toDouble(&ok); + if(!ok || !qIsFinite(oldDisplayValue)) continue; // 转换值 double newDisplayValue = tempDefaults.duration.convertValueByUnitType(oldDisplayValue, oldUnit, unit); - dataItem->setText(QString::number(newDisplayValue)); + dataItem->setText(QString::number(newDisplayValue, 'g', 15)); } } m_rateTableWidget->blockSignals(false); @@ -1170,10 +1298,12 @@ void nmWxForecast::onRateUnitChanged(const QString& unit) QTableWidgetItem* dataItem = m_rateTableWidget->item(i, 1); // 第1列 if(dataItem && !dataItem->text().isEmpty()) { - double oldDisplayValue = dataItem->text().toDouble(); + bool ok = false; + double oldDisplayValue = dataItem->text().toDouble(&ok); + if(!ok || !qIsFinite(oldDisplayValue)) continue; // 使用 q0 (FlowRate) 进行单位转换 double newDisplayValue = tempDefaults.q0.convertValueByUnitType(oldDisplayValue, oldUnit, unit); - dataItem->setText(QString::number(newDisplayValue)); + dataItem->setText(QString::number(newDisplayValue, 'g', 15)); } } diff --git a/Src/nmNum/nmSubWxs/nmWxNumericalDesign.cpp b/Src/nmNum/nmSubWxs/nmWxNumericalDesign.cpp index 7f83ebcb..9c4990f3 100644 --- a/Src/nmNum/nmSubWxs/nmWxNumericalDesign.cpp +++ b/Src/nmNum/nmSubWxs/nmWxNumericalDesign.cpp @@ -1445,7 +1445,12 @@ void nmWxNumericalDesign::onAutomaticFittingClicked() void nmWxNumericalDesign::onForecastClicked() { - nmWxForecast* dlg = new nmWxForecast(); + // 预测固定绑定当前分析窗口,并要求先生成基础模型。 + updateSensitiveButtonEnabled(); + if(!m_pForecastBtn->isEnabled()) return; + nmWxForecast* dlg = new nmWxForecast(this, m_pDataManager); + connect(dlg, SIGNAL(sigForecastReady(QString,QVector,QVector,QVector,QVector)), + this, SIGNAL(sigForecastReady(QString,QVector,QVector,QVector,QVector))); dlg->show(); } @@ -1457,6 +1462,8 @@ void nmWxNumericalDesign::updateSensitiveButtonEnabled() const bool available = !solved.isNull(); m_pSensitiveBtn->setEnabled(available); m_pAutomaticfittingBtn->setEnabled(available); + m_pForecastBtn->setEnabled(available); + m_pForecastBtn->setToolTip(available ? QString() : tr("Generate the model to enable forecast.")); m_pSensitiveBtn->setToolTip(available ? QString() : tr("Generate the model to enable sensitivity analysis.")); }