Reservoir coupling: make a master group's injection target reflect what the slaves are actually producing - #7366
Conversation
…uction A reservoir-coupling slave ships its group rates to the master twice per sync step: once in beginTimeStep() before it solves its wells for the step, and once after. A well that opens in the report step is put on its WCONPROD target by SingleWellState::update_producer_targets() before that solve runs, as an initial guess, so on the first send the slave reports a target as achieved production. The master folds those rates into the field's reinjection base. With the field under GCONINJE ... REIN, a multi-million sm3/day gas target arriving that way pushes the reinjection target past the GCONINJE maximum, which switches the field's gas injection control to RATE. In RATE mode the target is the maximum itself, independent of production, so the correct values that arrive later in the same sync step cannot bring it back down: the injectors sit on their cap for the whole step. Track in ReservoirCouplingSlaveReportStep whether the wells have been solved this sync step -- alongside the other per-sync-step flags kept there -- and while they have not, leave a well that opens in this report step out of the group rates the slave sends. The event alone cannot be used as the condition: it stays set for the whole report step and so cannot tell the two sends apart. The exclusion covers both the surface production rates and the network surface rates -- the master uses the latter as the flow of its network's leaf nodes -- and descends into child groups applying their efficiency factors, since a group named in GRUPSLAV need not hold wells directly. The gas reinjection rate the slave reports is derived the same way and has the same defect, but the master stores that field without ever reading it, so it is left alone.
A master group under a GCONINJE mode that turns production into an injection target -- REIN, SALE or VREP -- gets that target from the production the master has been told about. The target is computed once per sync step, in beginTimeStep(), from the group rates the slaves report before they solve their wells for that step, so it is a target for the *previous* step's production. The targets are never revised, even though the master keeps receiving fresh slave rates all through the step. A step in which slave production changes therefore injects the wrong amount for its whole length. With a field under GCONINJE ... REIN, the gas not injected leaves the balance as sales gas, so the sales rate jumps for one step and then returns. Recompute the injection targets after each non-final slave-rate receive inside the master's network iteration and send them to the slaves, which now expect that message where they previously only sent. Production constraints are not resent: the slaves have already solved their wells against them, and they are what produced the rates the recomputation is based on. The recomputation is unconditional, so it also re-sends targets that cannot have changed -- a RATE target is a deck constant, and a RESV target moves only with the other phases' reservoir injection. A TODO on the new entry point records what a filter would have to check: not the group's own GCONINJE record, since the target comes from the controlling ancestor and that ancestor's mode changes during a run. The injection half of calculateSlaveGroupConstraints_() is split out as calculateSlaveGroupInjectionTargets_() so both entry points share it.
restoreMasterGroupControlsFromSchedule_() lost both its only caller and its definition in OPM#7203; the declaration in RescoupConstraintsCalculator was left behind.
|
jenkins build this serial please |
There was a problem hiding this comment.
Pull request overview
Improves reservoir-coupling injection targets using current slave production and filters unsolved new-well target rates.
Changes:
- Recalculates injection targets during network iterations.
- Tracks whether slave wells have been solved.
- Removes an obsolete declaration.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
RescoupSendSlaveGroupData.hpp |
Declares new-well rate filtering. |
RescoupSendSlaveGroupData.cpp |
Excludes unsolved new-well rates. |
RescoupConstraintsCalculator.hpp |
Adds injection-only recalculation API. |
RescoupConstraintsCalculator.cpp |
Recalculates and sends injection targets. |
BlackoilWellModelRescoup.hpp |
Declares refresh synchronization methods. |
BlackoilWellModelRescoup_impl.hpp |
Refreshes targets during network iterations. |
BlackoilWellModel_impl.hpp |
Tracks solve state and receives refreshed targets. |
ReservoirCouplingSlaveReportStep.hpp |
Stores per-sync-step solve state. |
ReservoirCouplingSlave.hpp |
Exposes solve-state forwarding methods. |
ReservoirCouplingSlave.cpp |
Implements solve-state forwarding. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // The wells have not been solved for this sync step yet, so a well that | ||
| // opens in this report step still carries its WCONPROD target as its rate. | ||
| // See RescoupSendSlaveGroupData::collectSlaveGroupSurfaceProductionRates_(). | ||
| this->reservoirCouplingSlave().setWellsSolvedThisSyncStep(false); |
There was a problem hiding this comment.
Thanks for the comment. I checked it on our coupled model and the premise does not hold: NEW_WELL is not still set on the later sync steps of a report step. We read the live schedule[report_step].wellgroup_events(), and that flag is cleared after the first time step of the report step — which is also why BlackoilWellModel keeps a separate report_step_start_events_ snapshot for the code that does need it to survive.
Measured on a report step with seven sync steps, probing every call: hasEvent(NEW_WELL) is 1 on the first sync step and 0 on the other six, and the amount actually subtracted is the well's target on the first and exactly 0.0 on each of the others. So the correction fires once per newly opened well and never removes a solved rate.
That said, you are pointing at something real. The correctness of the gate depends on that clearing, and nothing in the code said so. Someone reaching for reportStepStartEvents() — the more obviously correct-looking source, since it is a stable snapshot — would reintroduce exactly the behaviour you describe, because wellsSolvedThisSyncStep() is cleared before the pre-solve send of every sync step. I have written that down at the point where the events are read, in ae617f5 ("Say why the new-well correction reads the live schedule events").
| const auto& well = schedule.getWell(wname, report_step); | ||
| const auto efficiency = well.getEfficiencyFactor(network); | ||
| const auto well_rates = this->groupStateHelper_.getWellRateVector(wname); |
There was a problem hiding this comment.
Agreed on both counts, thank you — this is a real defect. I had mirrored the shape of sumWellPhaseRates(), the descent into child groups and the network efficiency factor, but not its per-well filters, and the two you name are exactly the gaps.
On the injector point specifically: getWellRateVector() returns currentWellRates(), which updateWellRates() fills as sign * surface_rates with sign = -1 for producers, so the values are positive magnitudes for producers and injectors alike. A newly opened injector would therefore have had its rate subtracted from the group's production. The model this was developed on only escapes it because its new injectors are GRUP-controlled and start from zero rather than from a target; one opening on RATE control would have hit it. And efficiency_scaling_factor was simply missing.
Two further mismatches turned up while fixing it: SHUT wells are skipped by the source sum but were not skipped by the correction, and getWellRateVector() reads the nupcol well state where sumWellPhaseRates() reads the live one.
Rather than restate the filters in a second place, I have pulled the per-well step of sumWellPhaseRates() out into GroupStateHelper::wellRateContributionToGroup() and built the correction from it, so it now removes precisely what the sum added — same filters, same efficiency factor, same sign. That is 8f64ead (pure refactor, no functional change) and 36d5269 (the correction).
One consequence worth a second pair of eyes: the correction is now rank-local, because wellRateContributionToGroup() keeps the wellIsOwned() filter. The two call sites therefore reduce it differently — the network surface rates apply it before their comm().sum(), while the surface production rates reduce it first because the group-state value they correct has already been reduced. Both are commented at the call site. I should also say plainly that our model does not exercise the injector or scaling-factor paths — which is why the bug survived — so the reruns only show that nothing regressed; I have no positive test for the corrected cases.
|
Thanks for the fix. I have tested it and can confirm that it fixes the spikes. If I understand the code, the problem is that the communicated rates used to calculate REIN / SALE, etc., are too high because new producers are initialized with a target. Is it possible to solve this well before the rate is communicated, rather than removing the rate from the group rate as suggested in this PR? |
sumWellPhaseRates() decides per well whether it belongs in the sum at all -- present in this rank's well state, owned by this rank, of the right type, not shut -- and with what efficiency factor and sign it enters. Callers that need to add or remove a single well's share of such a sum have to reproduce all of that, and get it wrong when they reproduce only part. Move the body of the well loop into wellRateContributionToGroup() and call it from the loop. No functional change.
The correction for a newly opened, not yet solved well reproduced only part of what sumWellPhaseRates() does per well: it applied the network efficiency factor but not the well's efficiency_scaling_factor, and it filtered on nothing but the NEW_WELL event. Two consequences. A newly opened injector would have had its rate subtracted from the group's *production*, because getWellRateVector() reports positive magnitudes for producers and injectors alike; the model this was developed on only escapes that because its new injectors are GRUP-controlled and start from zero rather than from a target. And a producer whose rates are dynamically scaled was only partially removed. Two further mismatches: shut wells are skipped by the sum but were not skipped here, and getWellRateVector() reads the nupcol well state where the sum reads the live one. Build the correction from wellRateContributionToGroup() instead, the same per-well step the sums use, so it removes precisely what they added. That makes it rank-local, like the sums: the network rates now apply it before their reduction, and the surface rates reduce it before subtracting it from the already-reduced group state value. Spotted by Copilot in review of OPM#7366.
The correction applies only while the wells of this sync step have not been solved, and only to wells the schedule flags as NEW_WELL. That it fires exactly once per newly opened well depends on the NEW_WELL flag being cleared after the first time step of the report step, which is a property of the live event object and not of the report-step snapshot BlackoilWellModel keeps beside it. Nothing in the code said so, and the snapshot is the more obvious source to reach for. Switching to it would reintroduce the very problem the correction exists to avoid, on every sync step after the first. Write that down. Prompted by a review comment on OPM#7366.
|
jenkins build this serial please |
The first two commits are about the same thing from two sides: a master group under a
GCONINJEmode that turns production into an injection target —REIN,SALEorVREP— has that target computed from the slave production the master has been told about, and today that figure can be either wrong or stale. The third is an unrelated one-line cleanup in a file the second one touches; see below.1. A newly opened well reports its target as achieved production
A coupling slave sends its group rates to the master twice per sync step: once in
beginTimeStep()before it solves its wells for the step, and once after. That ordering is deliberate — the master needs the slave's state to compute the constraints the slave is then solved against — and it is harmless while the pre-solve well state holds the previous step's converged rates.A well that opens in the report step has none.
SingleWellState::update_producer_targets()seeds it with itsWCONPRODtarget as an initial guess for the solve, whichWellInterface::initializeProducerWellState()then uses as a rate cap. So on the first send the slave reports a target as if it were achieved production, once per newly opened well.The consequence is not a one-step blip. The master folds the slave group rates into the field's reinjection base, so an inflated gas figure pushes the field's
REINtarget past theGCONINJEmaximum, and the field's gas injection control switches toRATE. InRATEmode the target is the maximum, independent of production, so the correct rates that arrive later in the same sync step cannot bring it back down — the injectors stay on their cap for the whole step.The commit tracks in
ReservoirCouplingSlaveReportStep, alongside the other per-sync-step flags kept there, whether the wells have been solved this sync step, and while they have not, leaves a well that opens in this report step out of the group rates the slave sends. TheNEW_WELLevent alone cannot serve as the condition: it stays set for the whole report step and so cannot tell the two sends apart. The exclusion covers the surface production rates and the network surface rates — the master uses the latter as the flow of its network's leaf nodes.2. The injection targets are computed once per sync step and never revised
calculateMasterGroupConstraintsAndSendToSlaves()has a single call site, on the first substep of a sync step, and the injection targets it produces are the ones the slaves use for the whole step. For one of the production-derived modes that means the target is built from the production the slaves reported before they solved their wells, i.e. the previous step's production. The per-iteration exchanges inside the master's network solve send node pressures and receive slave rates; they never re-send constraints.So a step in which slave production changes injects the wrong amount for its whole length, and with a field under
GCONINJE ... REINthe gas that is not injected leaves the balance as sales gas.The commit recomputes the injection targets after each non-final slave-rate receive inside the master's network iteration and sends them on; the slave gains one matching receive in
maybeSendSlaveGroupFlowToMaster_(), so the message pairing keeps its shape. Production constraints are deliberately not resent — the slaves have already solved their wells against them, and they are what produced the rates the recomputation is based on; the message carries a production count of zero and the slaves keep what they hold. The injection half ofcalculateSlaveGroupConstraints_()is split out ascalculateSlaveGroupInjectionTargets_()so both entry points share it.3. A declaration left without a definition
Unrelated to the above and included only because it is in a file the second commit already changes:
restoreMasterGroupControlsFromSchedule_()lost both its only caller and its definition in #7203, and the declaration inRescoupConstraintsCalculatorwas left behind. It is a single deleted declaration and changes nothing at run time.Effect on
rescoup/rc_m1in opm-testsrc_m1hasGCONINJE 'FIELD' 'GAS' 'REIN' 4.0E6 1* 1.0withGCONSUMP 'FIELD' 200000, so the field's gas injection target is its gas production minus consumption, capped at 4.0e6 sm3/day. Two wells open in slavemod2during the run,E-1Hon 01-Jan-2019 andE-2Hon 01-Feb-2019, each with a 4.0e6 sm3/dayWCONPRODgas target.Tight coupling, all 38 report steps, master against this PR:
FGIR(deckGCONINJEmaximum 4.0e6)FGSRFGSRbelow −1e5FGSR(deckGCONSALEmaximum 1.05e6)FOPTThe two steps in the first three rows are exactly the two dates a well opens, and they are what the two plots below show. On master the field injects 4.5e6 sm3/day of gas on each — above the 4.0e6 the deck allows, with the injector
C-4Hsitting on its ownWCONINJElimit — and the gas balance closes with a negative sales rate of −2.4e6 sm3/day, which is not physical: the field cannot sell less than nothing. With the PR neither step exceeds 4.0e6 and the sales rate stays at its ordinary magnitude.Field gas injection rate over the run, master (red) against this PR (blue) — see the FGIR plot below. The two red spikes to 4.5e6 sm3/day in January and February 2019 are the two steps a well opens; the plateau the two curves share from late 2020 onwards is the 4.0e6 sm3/day
GCONINJEmaximum, so the spikes are plainly above the limit the deck sets. Everywhere else the curves lie on top of each other.Field gas sales rate on the same dates — see the FGSR plot below. Sales gas is the residue of the gas balance,
FGPR − GCONSUMP − FGIR, so the two over-injection steps drive it to −2.44e6 and −2.36e6 sm3/day on master. The PR has neither excursion. Note also that both curves settle on 1.0e6 sm3/day from late 2020, which is theGCONSALEsales target: the change does not disturb the sales control.The
FGSRpeak and the 2021 gas rate barely move, as expected: they are governed by theGCONSALElimit and the reinjection cap, not by these two steps. Cumulative field oil rises by 0.08%.Not fixed here — two things we ran into and would like an opinion on
Neither is caused by these commits and neither is addressed by them, but both showed up while measuring, and they interact with the same keywords.
The reinjection target still lags production within a step. After this PR the injection target is recomputed as often as the master receives fresh slave rates, but on
rc_m1those refreshes all see the same numbers: the master's coupled-network loop converges inside its first Newton iteration and then stops exchanging, and a slave with no network of its own is not re-solved between exchanges. The target for a step therefore still corresponds to the production at the start of it. Since sales gas is the residueFGPR − GCONSUMP − FGIR, the whole lag lands in the sales rate: onrc_m1the overshoot above the sales target equals the production growth over the step, about 15,500 sm3/day per day of step length, so 5-day steps overshoot by ~78,000 and the 1-day step that closes a report step by ~13,000: This is visible in the FGSR plot above, and it is the one part of that plot this PR does not change: from the momentGCONSALEtakes effect on 1 July 2019 both curves sit just above the 1.0e6 sales target, creep up through 2019 (1.0011e6 in August, 1.0036e6 by January 2020) and then climb steeply through 2020 — 1.017e6 in July, 1.067e6 in October — until the reinjection cap binds in November 2020 and they peak at 1.208e6 (master) and 1.189e6 (this branch), both well above the 1.05e6 the deck allows. The two curves lie on top of each other throughout: the lag is untouched by these commits. Closing this properly seems to need an outer coupling iteration over the whole time step (slaves solve, report, master recomputes targets, slaves re-solve), which is a larger design question than we wanted to open in this PR. Note also that the master and its slaves do not take the same number of Newton iterations — onrc_m1the master takes one per step wheremod1takes two — so there is no existing common loop to hang more exchanges on.checkGconsaleLimits()runs before the end-of-step slave sync, and moving it does not help. On a master the sales check atBlackoilWellModel_impl.hppruns beforerescoupSyncSummaryData(), the call that brings in the slaves' converged end-of-step rates, so it evaluates the sales rate against the lagged production described above. Onrc_m1it computes exactly the 1.0e6 sales target on every step through 2020 while the summary reports 1.065e6 to 1.078e6, and theGCONSALEmaximum therefore fires exactly once in the whole run. We tried moving the call after the sync. It makes the sales cap hold almost perfectly — and makes everything else much worse: the field freezes at 3.88e6 sm3/day of gas for the rest of the run instead of developing to the 5.2e6 the deck implies, and cumulative field oil drops by 4.9%. The reason is that while injection is below theGCONINJEmaximum the two controls imply each other for any production rate — theGRATtarget issales target + injection + consumption, and theREIN/SALEinjection target isproduction − consumption − sales target— so the pair has no unique fixed point and switching toGRATsimply pins the field wherever it happens to be. Reacting to the violation earlier freezes it earlier and lower. Only when injection saturates at theGCONINJEmaximum does theGRATtarget become a definite number. Raising the deck'sGCONSALEmaximum from 1.05e6 to 1.1e6 — enough that the lag-driven overshoot no longer reaches it before the reinjection cap binds — makes the reordering behave well again, which confirms the mechanism but is a deck-side workaround for a simulator-side lag.