Skip to content

Reservoir coupling: make a master group's injection target reflect what the slaves are actually producing - #7366

Open
hakonhagland wants to merge 6 commits into
OPM:masterfrom
hakonhagland:fix_new_well_presolve_rates
Open

Reservoir coupling: make a master group's injection target reflect what the slaves are actually producing#7366
hakonhagland wants to merge 6 commits into
OPM:masterfrom
hakonhagland:fix_new_well_presolve_rates

Conversation

@hakonhagland

Copy link
Copy Markdown
Contributor

The first two commits are about the same thing from two sides: a master group under a GCONINJE mode that turns production into an injection target — REIN, SALE or VREP — 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 its WCONPROD target as an initial guess for the solve, which WellInterface::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 REIN target past the GCONINJE maximum, and the field's gas injection control switches to RATE. In RATE mode 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. The NEW_WELL event 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 ... REIN the 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 of calculateSlaveGroupConstraints_() is split out as calculateSlaveGroupInjectionTargets_() 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 in RescoupConstraintsCalculator was left behind. It is a single deleted declaration and changes nothing at run time.

Effect on rescoup/rc_m1 in opm-tests

rc_m1 has GCONINJE 'FIELD' 'GAS' 'REIN' 4.0E6 1* 1.0 with GCONSUMP '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 slave mod2 during the run, E-1H on 01-Jan-2019 and E-2H on 01-Feb-2019, each with a 4.0e6 sm3/day WCONPROD gas target.

Tight coupling, all 38 report steps, master against this PR:

master this PR
peak field gas injection FGIR (deck GCONINJE maximum 4.0e6) 4.500e6 4.000e6
minimum field gas sales rate FGSR −2.437e6 −1,446
time steps with FGSR below −1e5 2 / 260 0 / 260
peak FGSR (deck GCONSALE maximum 1.05e6) 1.208e6 1.189e6
mean 2021 field gas rate (deck implies 5.2e6) 5.199e6 5.197e6
cumulative field oil FOPT 1.3405e7 1.3416e7

The 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-4H sitting on its own WCONINJE limit — 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 GCONINJE maximum, so the spikes are plainly above the limit the deck sets. Everywhere else the curves lie on top of each other.

FGIR

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 the GCONSALE sales target: the change does not disturb the sales control.

FGSR

The FGSR peak and the 2021 gas rate barely move, as expected: they are governed by the GCONSALE limit 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_m1 those 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 residue FGPR − GCONSUMP − FGIR, the whole lag lands in the sales rate: on rc_m1 the 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 moment GCONSALE takes 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 — on rc_m1 the master takes one per step where mod1 takes 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 at BlackoilWellModel_impl.hpp runs before rescoupSyncSummaryData(), the call that brings in the slaves' converged end-of-step rates, so it evaluates the sales rate against the lagged production described above. On rc_m1 it computes exactly the 1.0e6 sales target on every step through 2020 while the summary reports 1.065e6 to 1.078e6, and the GCONSALE maximum 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 the GCONINJE maximum the two controls imply each other for any production rate — the GRAT target is sales target + injection + consumption, and the REIN/SALE injection target is production − consumption − sales target — so the pair has no unique fixed point and switching to GRAT simply pins the field wherever it happens to be. Reacting to the violation earlier freezes it earlier and lower. Only when injection saturates at the GCONINJE maximum does the GRAT target become a definite number. Raising the deck's GCONSALE maximum 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.

…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.
@hakonhagland hakonhagland added the manual:irrelevant This PR is a minor fix and should not appear in the manual label Aug 28, 2026
@hakonhagland

Copy link
Copy Markdown
Contributor Author

jenkins build this serial please

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

Comment on lines +433 to +435
const auto& well = schedule.getWell(wname, report_step);
const auto efficiency = well.getEfficiencyFactor(network);
const auto well_rates = this->groupStateHelper_.getWellRateVector(wname);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@totto82

totto82 commented Aug 28, 2026

Copy link
Copy Markdown
Member

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.
@hakonhagland

Copy link
Copy Markdown
Contributor Author

jenkins build this serial please

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

manual:irrelevant This PR is a minor fix and should not appear in the manual

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants