Skip to content

[WIP] Simultaneous network solve: injection and production networks, autochoke, gas-lift response - #7348

Draft
hnil wants to merge 79 commits into
OPM:masterfrom
hnil:simultaneous-network
Draft

[WIP] Simultaneous network solve: injection and production networks, autochoke, gas-lift response#7348
hnil wants to merge 79 commits into
OPM:masterfrom
hnil:simultaneous-network

Conversation

@hnil

@hnil hnil commented Aug 24, 2026

Copy link
Copy Markdown
Member

Draft, not for merge as one piece — pushed so the work can be seen and tested. Builds on #7345, whose commits are included here rebased on master.

Everything new is opt-in; with default flags the answers are unchanged, except for the injection-network robustness fixes at the bottom of the branch, which are what make GNETINJE correct at all.

It splits into roughly: injection-network robustness (VFP axis clamping, bracketing node-pressure update, per-phase leaf rates, GPRG/GPRW) · the simultaneous solve --network-solver=newton, injection then production · --network-autochoke · --gas-lift-network-response · two legacy autochoke bugfixes that make NETWORK_MODEL5_STDW_AUTOCHK run at all.

Needs OPM/opm-common#5314.

Trying it

Numbers are from this branch on an M4 Pro. --debug-verbosity-level=2 puts the counts in the .DBG: grep -c 'inner iterations' = well solves, grep -c 'Network: solved the' = simultaneous solves, grep -c 'is not possible' = fallbacks to the relaxed update.

Injection network — opm-tests/network/GNETINJE_GAS-01.DATA

flow $DECK --output-dir=/tmp/a
flow $DECK --output-dir=/tmp/b --network-solver=newton --network-analytic-jacobian=true

Compare GPRG:M5N and WGIR:G-3H. The relaxed update collapses M5N to 1.1 bar at day 121 and takes both G injectors to zero; the simultaneous solve holds 202.7 bar with all four injectors on.

Autochoke — opm-tests/network/NETWORK_MODEL5_STDW_AUTOCHK.DATA

flow $DECK --output-dir=/tmp/c
flow $DECK --output-dir=/tmp/d --network-solver=newton --network-autochoke=true --network-analytic-jacobian=true
well solves Newton wall
legacy 475 143 790 15.0 s
simultaneous 3 774 123 2.1 s

B1 holds its 6000 sm³/d target both ways (GOPR:B1 at day 1/31/60/91: 6019/6001/6016/4846 legacy, 6000/6003/5900/4866 new). They part late, where B-1H is shut at day 106 instead of 152 — open, and legacy's own answer there moves with its bracket sample count. On master this deck aborts at day 3.2 (Invalid Group::ProductionCMode ... B1 cmode: NONE); the two legacy bugfixes in this branch are what make it run at all.

Gas lift — opm-tests/gaslift/GASLIFT-13.DATA

flow $DECK --output-dir=/tmp/e
flow $DECK --output-dir=/tmp/f --network-solver=newton --gas-lift-network-response=true
FOPT (sm³) lift gas (sm³) well solves Newton wall
legacy 1 029 703 1 120 162 72 919 2 733 53.7 s
simultaneous 1 049 109 85 430 7 456 302 6 s

Leave --network-analytic-jacobian off here: on this deck it changes the optimiser's path, not just its cost (302 -> 1151 Newton and a different lift-gas allocation), which is an open question rather than a recommendation.

Only the optimiser's trial evaluation changes; its algorithm, increments, weights and limits are untouched. The trial is answered by the network solve instead of at a frozen node pressure, which is why it stops paying for lift gas the network takes straight back.

Production network

No deck in opm-tests exercises this on its own — in NETWORK-01 and its variants every well is a pinned source, so the solve declines the tree and the relaxed evaluation runs unchanged. It is exercised through the two recipes above (236 and 451 simultaneous solves respectively). Directly:

ctest -R networksolve

tests/test_networksolve.cpp covers the production formulation, the control rule and the group split against the rule-based allocation; --log_level=message on the binary prints the comparison tables.

Atgeirr Flø Rasmussen and others added 30 commits August 24, 2026 10:52
A network branch lookup clamped the rate and upstream pressure to nothing:
VFPHelpers::findInterpData extrapolates linearly past the axis ends, and the
flow-line tables are zero-filled where the line cannot deliver the rate, so an
injection network with rates beyond the table axis produced node pressures of
-260 bar, and a THP below the axis start gave 0. Both were then handed to the
wells as THP limits.

The branch calculator now clamps the lookup point to the table axes (rates
scaled uniformly so prod WFR/GFR are kept) and treats a result <= 1 atm as
'no solution'. Such nodes, and their descendants, are reported by
NetworkPressureComputation::invalidNodes(); updatePressures() keeps their
previous pressure, counts the network as unbalanced (max update bound), warns
once per report step, and does not push a dynamic THP to wells under them.

Also make updateActiveStateImpl accumulate instead of clearing active_ on an
inactive domain (unreachable today, but wrong).

Tests: gas_injection_rate_beyond_flow_axis, gas_injection_zero_cell_region and
gas_injection_thp_below_axis in test_networkpressure.cpp; on 0996672 they
give -213 bar, 0 bar and 28.9 bar respectively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The explicit node-pressure iteration P <- P + w (P_computed - P) cannot
converge an injection network: with BHP pinned by the reservoir the injectors'
THP-mode rate is very steep in THP (~6e4 sm3/d/bar on GNETINJE_GAS-01) and the
flow-line pressure falls ~0.5 bar per 1000 sm3/d, a loop gain of ~30, so with
w=0.1 the iteration falls into a period-2 limit cycle (leaf inflow 0 <-> 1.5e6,
pressure 500 <-> 0). Wells stopped at the wrong moment were then shut for good.

NodePressureUpdater (new header) replaces the update per node: keep a
sign-change bracket on r(P) = P_computed(P) - P and take Illinois regula-falsi
steps inside it; before a bracket exists take a secant step clipped to
[P, P_computed], capped at max(25% P, 10 bar), and, when the leaf's wells are on
group/rate control (flat response down to their own THP), go straight to just
below that kink. Stale bracket ends (from wells that were momentarily stopped,
or from the other leaves moving) are dropped when contradicted, when the bracket
collapses, or when their Illinois weight is halved away. Convergence is judged
on the remaining pressure uncertainty (bracket width), not on the residual,
which is amplified by the well response. Enabled by default; the old damped
update is the fallback and is kept behind --network-pressure-update-secant=false.

Well side: the injector's dynamic THP is clamped to its VFPINJ THP axis instead
of being silently left stale; injectors get the previous step's node pressure at
report-step start like producers (starting at the WCONINJE THP made them inject
at their rate limit and starve the other leaves); the 'Option B' potentials
refresh is removed (it wrote well_potentials = surface_rates, so a well that had
been stopped restarted every THP solve at zero rate and was stopped again).

Result on opm-tests/network/GNETINJE_GAS-01 and _WAT-01: 0 unconverged network
messages, no wells shut, and rates/THP/control modes match the E100 reference
(opm-tests/eclref) to within 1% / 0.2 bar at every report step.

Tests: NodePressureUpdaterTests in test_networkpressure.cpp (stiff response
converges where the damped update does not, stale end dropped, plateau floor,
step cap, invalid evaluation moves down).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The physical-shut path is meant for wells that cannot operate under their own
THP/BHP limits. An injector whose THP is a network node pressure may be unable
to inject at this iteration's pressure and yet inject fine once the network is
balanced (the pressure depends on the other wells' rates); shutting it makes the
network converge to a wrong state with the remaining wells carrying the whole
group target. Keep such wells stopped for the step; the local solve reopens
them when the pressure allows. Injectors only for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GAS and WATER injection networks were written into the same leaf-rate map, so
a group that is a leaf of both would see the second network's rates, and the
injection phase threaded into NetworkPressureComputation was ignored. Key the
map by (Phase, group) and use the phase in the calculator; the mock in
test_networkpressure.cpp now returns only the network's own phase and its
phase-less lookup returns nothing, so the gas/water cases fail without this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
assignNodeAndBranchValues() only exported the production network. Fill the
gas/water injection maps of data::GroupAndNetworkValues (opm-common
c4b9da0a4) from the per-domain node pressures and branch data as well.

On GNETINJE_GAS-01, GPRG now matches the E100 reference: 340.0 / 209.4 /
209.4 / 204.3 / 204.3 bar (PLAT-A / M5S / G1 / M5N / F1) at day 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… default

--network-pressure-update-secant becomes a string: injection (default), all,
none. With the bracketing update also on production networks NETWORK-01 gives
a slightly different (better converged) solution than the reference (BPR
0.04 bar at one step); keep production networks on the damped update until the
references are regenerated. Production-network results are now identical to
the pre-change scheme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…upported

Take GNETINJE off the unsupported-keyword list (it made the decks fail at the
default parsing strictness) and register the two opm-tests injection network
decks as regression tests (--solver-max-time-step-in-days=1). The references
are generated with this branch; both cases agree with the E100 runs in
opm-tests/eclref within 2% on node pressures and 3% on well rates at every
report step (tools/plot_injection_network.py --check).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An injection group target (GCONINJE VREP/REIN) is a function of the produced
voidage, so within a network sub-iteration the injectors must see this
iteration's production, not the previous one's. The well loop ran in deck
order, so that was a matter of luck.

Producers are now re-solved first, then the injectors. The group data is
refreshed in between only when a deck has both a production and an injection
network -- the only case where the producers re-solved here can feed an
injection target in the same sub-iteration -- so nothing else pays for the
extra update. details::activeNetworks() already returns the production
network first, so the pressure computation was in the right order already.

No deck in opm-tests has both network kinds, so this is a correctness guard
rather than a fix for an observed failure: NETWORK-01, NETWORK-01_STANDARD,
NETWORK-01-WTEST, GNETINJE_GAS-01 and GNETINJE_WAT-01 are all bit-identical
(compareECL -t SMRY at 1e-9) before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t help yet)

An experiment against the per-node bracketing update: Anderson uses the last few
(P, G(P)) pairs of the whole pressure vector of a network, so it sees the coupling
between nodes that a per-node update cannot, without needing any derivative.

Kept deliberately isolated: NetworkAndersonAcceleration.hpp is self-contained (no
OPM dependencies, own tiny least-squares solve), there is one call site in
updatePressures(), and it is off unless
--network-pressure-update-acceleration=anderson is given
(--network-anderson-depth sets the history length, default 4). With the default
the results are bit-identical to the previous commit on all five network decks.

Measured on the opm-tests decks, against the E100 reference:

  GNETINJE_WAT-01   bracketing: 3 unconverged steps   anderson(4): 0
  GNETINJE_GAS-01   bracketing: 0 unconverged steps   anderson(4): 18
  GNETINJE_GAS-01 WGIR:G-3H vs E100, over all reported steps
                    bracketing: median 0.24 %, p90 3.4 %, 86 % within 2 %
                    anderson(4): median 0.02 %, p90 100 %, 63 % within 2 %

So it is better where the response is smooth and much worse where it is not: the
gas case fails the report-step comparison with Eclipse that the bracketing update
passes. That is the expected failure of unsafeguarded extrapolation across the
kinks where wells switch control -- Anderson replaces the bracket, the step caps
and the plateau rule that the per-node updater relies on. It should not be
enabled as it stands; a variant that keeps those safeguards (limit the Anderson
step to the same bound, reject it when the residual grows) is the obvious next
thing to try.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… yet)

Balance the injection networks against each injector's linearised rate response
instead of re-solving the well equations, so a residual evaluation costs a VFP
lookup. --network-well-proxy=ipr, default none; nothing changes when it is off.

Two things had to be fixed before this could even be measured.

The explicit IPR (ipr_a_/ipr_b_, WellInterface::updateIPR) is identically zero
for injectors: its crossflow guard drops every connection with
pressure_diff = p_r - h_perf > 0, which for an injector is the ordinary case.
Nothing noticed because the only consumers are the producer operability checks.

The implicit IPR is the usable one -- it differentiates the converged well
equation through the well Jacobian -- but updateIPRImplicit hard-coded the
producer control swap, while the rhs picks out the control equation and so
requires that equation to be the bhp one. Made symmetric here; the producer path
is unchanged.

With that, the response is exact at the point of linearisation (it reproduces
each injector's solved rate to four figures), and the proxy is still not good
enough: on GNETINJE_GAS-01 and _WAT-01 it takes unconverged network steps from
0/3 to 0/1 but the deviation from the E100 reference goes from 44/1 violations
to 620/646, because from about two thirds through both runs it drives every
injector to zero rate. dq/dbhp is large enough here that the intersection bhp
moves 0.04 bar for a 292 bar change in applied thp, so any node-pressure error
lands on the steep part, the linear rate goes negative and the well shuts. A
Newton on this Jacobian will need globalisation and the well's own rate/group
limits, not a full step.

Kept because the linearisation itself is sound and is the building block for
that Newton. See injection_network_findings.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/test_networksolve.cpp extracts the GNETINJE_GAS-01 network with the wells
replaced by their inflow performance plus the control logic, so solution methods
can be compared without a reservoir or a well solve. The three VFPINJ tables are
the deck's, verbatim; the wells are calibrated to the Eclipse 100 operating point
at day 31, and the bench then reproduces it to 0.1 bar.

Table 9999 means "no table", so G1 carries M5S's pressure and F1 carries M5N's
and the whole problem has two unknowns. That is small enough to look at a
Jacobian by hand.

From the wells' WCONINJE THP, to 0.01 bar:

  damped (omega 0.1)     FAILED  (limit cycle -- the original branch's method)
  bracketing (shipped)   42
  anderson (depth 4)     21
  newton (full step)     FAILED  (overshoots off the plateau to 450/506 bar)
  newton + line search   14

and over dq/dbhp = 1e4 .. 1e6 sm3/d/bar, bracketing 29/42/30/38 against
newton+ls 10/14/21/22.

So a Newton on this problem is worth doing and must be globalised: the full step
diverges on its own, and the same line search that fixes it also keeps the
advantage as the wells stiffen.

Note for anyone extending this: VFPInjProperties::addTable stores a
reference_wrapper, so the tables have to outlive the properties object and must
not be moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Newton direction was already there; what was missing was any measurement of
what to do with it. Four strategies now share one Newton driver -- full step,
step cap, backtracking line search (plain or Armijo), trust region -- so the only
thing that varies between them is how the step is accepted.

Iteration count from one good start says little about a globalisation, so the
bench sweeps a 23x23 grid of starting pressures across the tables' THP axis and
counts how many reach the right answer:

  bracketing (shipped)   529/529   mean 31 iterations
  newton, full step        3/529   mean  5
  newton, capped step     54/529   mean  8
  newton, line search    529/529   mean 12
  newton, trust region   529/529   mean 13

Two things worth having measured. Capping the step -- which is what
--network-max-pressure-update-in-bars already does -- is not a globalisation:
it recovers from a tenth of the space. And a real one costs nothing in
robustness, so the choice between the line search and the trust region is a
choice about code, and the line search is the smaller piece of code.

The trust region needed its collapse case handled to get there: when the
Jacobian is taken across a control switch the radius halves away to nothing, and
returning the unchanged point stalls the solver. Taking the smallest step and
reopening the region fixes it (83% -> 100%).

Also covers the group-target branch of the well control logic, which the earlier
tests left dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one

The bench solved the network in the node pressures alone, with every rate
recovered from them by an inner solve. This adds the same network without the
eliminations -- node pressures, branch rates and each well's (rate, bhp) as
unknowns, with the eliminations restated as node balances, branch drops, inflow
performance and one control equation per well -- and lets the same Newton solve
either. 16 unknowns against 2.

Over the grid of 529 starting pressures, at the measured stiffness:

  eliminated, plain newton     3/529   mean  5 iterations
  eliminated, line search    529/529   mean 12
  full,       plain newton   529/529   mean  9
  full,       line search    529/529   mean  9

The eliminated residual needs globalising because the control clamps put kinks
in it. The full system holds its controls fixed while the step is taken, so
there are no kinks and an unglobalised Newton is already fully robust -- and
faster. That is the case for carrying the rates.

Three things had to be right for that to hold, and each is a finding in its own
right:

Controls must be chosen by most-restrictive-wins, matching the clamp the
eliminated form applies. A fixed priority chain makes the active set chatter and
the Newton never terminates.

Globalisation must not veto an active-set change. The residual jumps when a
control switches and that is not a failure to make progress; letting the line
search reject it stalls the switch instead of resolving it (363/529 -> 529/529).

The table limits want to be bounds on the unknowns, not a clamp on the lookup.
Outside the tables' box the zero-filled cells extrapolate away and the full
system has a spurious root there -- at the softest wells a plain Newton reaches
it from a third of the grid, ending with every well at its rate limit and node
pressures of -683 and -10975 bar. Clamping the lookup to the axes, which is what
the simulator's pressure computation does, removes that root and replaces it
with a flat residual the Newton cannot descend (383/529 -> 23/529). Neither is
right; the limits belong in the active set beside the well controls.
table_bounds_want_to_be_constraints pins both halves down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… build

Three things, of which the first two were the open questions.

The table limits belong on the unknowns. Clamping the lookups to the axes, which
is what the simulator's pressure computation does, flattens the residual and
leaves a Newton nothing to descend (22/529 starts). Leaving the tables to
extrapolate keeps the derivatives but admits a root outside them (412/529).
Holding the branch flows inside the box does best (421/529), and only by
projecting the offending components -- a scalar fraction-to-boundary lets one
binding rate throttle the pressure updates as well, which is worse than doing
nothing (299/529).

The group target is now an equation with a multiplier, so wells the group does
not bind stay on their own controls. It converges in 4 iterations and lands on
the target exactly, with the network at higher pressure than it runs free, which
is what curtailment should do. One trap: the activation test has to include
equality, because at the solution a well's rate is its share exactly and a strict
test flips the control every iteration.

The case is now data rather than code. A NetworkCase is nodes with a parent and a
table, wells hanging off nodes, a terminal pressure and an optional group target;
gnetinjeGas() is one instance of it, assembled the way a deck reader would --
topology from GRUPTREE/GNETINJE, tables from the VFPINJ includes, limits from
WCONINJE, and the calibration point from the reference summary. Both problems and
all the solvers work off that, with no node names or unknown indices written into
them, so a second case is a builder function rather than a rewrite.

The refactor reproduces every previously measured number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A case needs a reference operating point per well -- the rate it takes and the
pressure at its node -- because that is what its IPR is a linearisation about.
Both ways of supplying one are now there.

Reference::set() writes it down. That is what the built-in GNETINJE_GAS-01 case
uses, so the bench still needs no files and runs everywhere, and it is also how
you would pose a case no reference run covers.

Reference::fromSummary() reads it out of a reference run at a given time, paired
with fromDeck(), which takes the topology from GRUPTREE/GNETINJE, the tables from
the VFPINJ keywords and the limits from WCONINJE. It reads the keywords off the
parsed Deck rather than building a Schedule, so it stays usable from a test.

built_from_deck_matches_the_builtin_case checks the two agree, node for node and
well for well, and that the deck-built case solves to the same place. It skips
when opm-tests is absent.

Two things that cost time and are worth knowing:

WCONINJE's RATE is a UDA item whose unit depends on the injected phase, so the
parser leaves it dimensionless and getSI() hands back the raw deck number. Taken
at face value the rate limit came out 86400x too large, which silently removes
the rate control -- the case still looked right and simply stopped converging.

The operating point has to be read at a report step. Off the ministep vectors the
same nominal time gives a slightly different state, which is a different case.

Correcting the built-in reference against the summary at day 31 moved it 0.6% on
the G wells, and the bench now lands on (209.405, 204.240) against E100's
(209.409, 204.244) -- previously (209.30, 204.19).

Rates are carried as an (aqua, liquid, vapour) triple rather than one number,
which is what a production network will need: there the branch rate splits and
VFPPROD wants WFR and GFR fractions and an ALQ, and those fractions are extra
unknowns per branch with their own mixing equations at the nodes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Until now the bench solved one hand-picked operating point. This runs the full
formulation at every report step of both GNETINJE decks, calibrated at each step
to what the Eclipse reference says the wells were doing, with the field target
applied where the reference has them on group control.

  GNETINJE_GAS-01   4/4 report steps, worst 0.05 bar, 4-16 iterations
  GNETINJE_WAT-01   6/6 report steps, worst 0.39 bar, 4-8 iterations

Both group-controlled steps land on the reference exactly. Those are the steps
where the method we ship is weakest -- day 91 of the gas case is where it spends
two reported steps at the validity floor.

Getting there needed two corrections, both of which matter beyond the bench.

A well must be calibrated against its own bhp, not the pressure at its node. The
two agree only while it is on THP control. At day 91 the wells sit 162 bar below
their node (WTHP 202 against GPRG 365), and calibrating there against the node
pressure builds a well that does not exist -- which is why that step would not
converge at all before.

The stiffness knob only transfers between cases as a fraction of the well's own
rate. 6e4 sm3/d/bar is reasonable for a gas injector taking 5e5 sm3/d and
meaningless for a water injector taking 700; carrying it across is what made
three of the water steps diverge. setRelativeStiffness(0.12) reproduces the gas
figure and works for both.

fromDeck also had to learn that GNETINJE spells the phase WAT where WCONINJE
spells it WATER.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
opm/simulators/wells/NetworkSystem.hpp now holds the injection network solved
simultaneously in its pressures and its rates: the branch drops, node balances,
inflow performance and per-well control equation, an optional group multiplier,
the active-set control selection, the branch-flow bounds and the Newton.

The bench is now a caller of it rather than a separate implementation. All it
still owns is where a well's inflow performance comes from -- a linearisation
about a reference operating point, where the simulator will use the well
Jacobian -- plus the eliminated formulation and the fixed-point methods it is
compared against. Every measured number is unchanged, which is the check that
the extraction is faithful.

setClampToAxes is on the system only so the comparison that rules it out can
still be made, and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…newton

The simulator now fills in the same NetworkSolve::System the bench does. The
only thing that differs is where a well's rate response comes from: the bench
linearises about a reference operating point, the simulator reads the implicit
IPR out of the well state. Everything downstream -- the equations, the
active-set control selection, the branch-flow bounds, the Newton -- is shared.

Default is unchanged (fixedpoint), and both GNETINJE references still compare
clean. With --network-solver=newton, both decks at 1-day steps:

                  unconverged network steps    deviations from E100
  gas   fixedpoint          0                          44
  gas   newton              0                          10
  water fixedpoint          3                           4
  water newton              0                           4

so the gas case lands four times closer to Eclipse and the water case stops
taking unconverged steps, in slightly fewer Newton iterations either way.

The node pressures come back already at the fixed point, so the relaxation that
follows sees no imbalance and stops; the branch data from the ordinary
evaluation is kept for the output. Anything the solve cannot handle -- more than
one root, a well with no usable inflow performance, a network that does not
converge -- returns nullopt and leaves the fixed point in charge.

The group multiplier the system can carry is not used here. The simulator's own
group machinery has already decided each well's share by the time the network is
solved, so a well on GRUP enters as one held at that rate.

updateIPRImplicit is refreshed for injectors before the solve; the well solve
only maintains it for producers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--network-analytic-jacobian assembles the Jacobian from the derivatives the VFP
interpolation already computes and throws away, instead of differencing the
residual n+1 times. Everything but the two table lookups is constant. Checked
against the difference quotient in four states -- the ordinary THP one, a
converged one, one with the group holding the wells, and one driven onto the
bhp and rate limits -- and agreeing to about 1e-7 relative. On both decks it
gives the same answer as differencing, which is the point: it is a cost, not an
accuracy, change.

--network-group-control hands the network the group's injection total and lets
it place the split, so a well that runs into its own limit is taken up by the
others rather than the total quietly dropping. On these two decks it changes
nothing, because the group machinery has already made the split and no well is
near a limit, so the multiplier has nothing to redistribute. It is off by
default and unproven; a case where a group-controlled well hits its bhp limit is
what would show whether it earns its place.

Both decks, 1-day steps, against the Eclipse reference:

                            unconverged   deviations
  gas   fixedpoint               0            44
  gas   newton                   0            10
  gas   newton + analytic        0            10
  gas   newton + analytic + grp  0            10
  water fixedpoint               3             4
  water newton                   0             4
  water newton + analytic        0             4
  water newton + analytic + grp  0             4

Also fixes two things in the previous commit's well setup. A group-controlled
well is held at the rate the group gave it, which had been lost; without it the
gas case went from 10 deviations to 252. And a well with neither a rate nor a
target now takes the whole network back to the fixed point instead of entering
with an invented limit -- std::max(rate, 1) is 86400 sm3/d in SI, which is no
limit at all for a water injector taking 700, and cost the water case 4
deviations against 240.

The solve is now started from the wells' current rates where the caller knows
them, so the first control selection is made on the state the well is actually
in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A well's share of a group total should be proportional to what it can inject at
the pressure the network gives it. The previous version used its current rate,
which is the split being decided -- so the allocation reproduced whatever it
already was, which is why --network-group-control changed nothing.

System::thpPotential() meets a well's inflow performance with its tubing curve
at a given node pressure. The guides are refreshed from it each iteration and
have to settle before the solve is called converged, the same way the controls
do.

It fixes the node pressures at the gas case's day 61 -- GPRG:M5N/F1 were 2.6 %
high and are now inside tolerance, taking the gas deviations from 10 to 8 -- and
leaves the well rates bit-identical.

That last part is the useful half of the result. Under group control the network
writes nothing to a well but a dynamic THP limit, and for a group-held well that
limit does not bind, so the network cannot move the split however good its guide
rates are. The rates come from the group allocation, which runs before the
network and does not see it. Closing that needs the allocation to become part of
the same solve, not a better guide handed to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The simultaneous solve read the rank-local well container and communicated
nothing, so under MPI each rank would assemble a network out of its own wells
and get its own answer. It was latent because the solver is opt-in and had only
been run serially.

The relaxed computation stays right by working from group and node quantities,
which are the same everywhere by the time it runs. This does the same thing one
level down. The well list and everything static about a well -- its node, its
vfp table, its bhp and rate limits -- now come from the schedule, which is
replicated. Only what a well is currently doing is rank-local: its inflow
performance, its rate and its control mode are contributed once by the rank that
owns it and summed, after which every rank holds the same numbers and reaches
the same decisions, including the decision to give up and leave it to the fixed
point.

Contributed by the owner rather than by every holder because a distributed well
appears on several ranks carrying the same values; summing those would count it
twice.

On GNETINJE_GAS-01 the node pressures now agree across 1, 2 and 4 ranks to about
1e-5 relative, and are identical to six figures on the group-controlled steps.
The deviation count against the Eclipse reference is 8 on all three, and 4 on
the water case serial and on two ranks. What is left between serial and parallel
is a connection total on a shut well that the default solver differs on in
exactly the same way, so it is not this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@atgeirr atgeirr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Starting with a few quick comments. Nowhere near finished of course!

I will also add here that the NetworkSystem.hpp file is huge, maybe better to split it into more files if a logical organization can be found, consider putting it in a network/ subdir.

Comment thread opm/simulators/wells/GroupState.hpp Outdated
std::map<std::string, std::vector<Scalar>> m_production_rates;
std::map<std::string, std::vector<Scalar>> m_network_leaf_node_injection_rates;
// Injection networks are per phase (GNETINJE GAS / WAT); a group can be a leaf of both.
std::map<std::pair<Phase, std::string>, std::vector<Scalar>> m_network_leaf_node_injection_rates;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems unnecessary to have separate storage per phase, since the value objects in the map are small vectors they can store all phase injection rates in a single such object.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, done in b5a50cb. The value already covers every phase and the writer fills it the same way whichever network asks, so the pair key stored the same vector twice; the per-phase masking only existed in the test mock. Back to your node-keyed map, both GNETINJE decks unchanged to the digit.

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
int node = 0;
int vfp_table = 0;
/// Inflow performance, q = ipr_a + ipr_b * bhp. The simulator's implicit
/// IPR stores it as q = b*bhp - a, so ipr_a is the negated one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is a different convention used than in the existing code? Would prefer that conventions are the same unless for a good reason.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, done in 1b32904: q = ipr_b*bhp - ipr_a as in WellState, and the negation in the adapter is gone. Sign-only, results identical.

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
/// Dense square system. The networks this solves have tens of unknowns, so
/// Gaussian elimination with partial pivoting is the whole story.
template<class Scalar>
class DenseMatrix

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This class reimplements functionality already available in Dune, prefer to use existing library functionality if equivalent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in ace7891 — it now wraps Dune::DynamicMatrix. I kept a thin wrapper for (i,j) indexing and to return false on a singular matrix rather than throw, since that case just hands the network back to the relaxed update. Counts identical everywhere; AUTOCHK FOPT moves 1.7e-5 relative over 182 days from the different elimination order.

hnil and others added 3 commits August 24, 2026 12:19
The injection leaf rates were keyed on (phase, node), but the value is already a
vector over all phases and the writer fills it the same way whichever network
asks -- so a node that is a leaf of both the gas and the water network stored
the same vector twice.

Nothing needed the split. The VFP injection table picks the phase its FLO type
names, so handing it every phase's rate is what the lookup already expects; the
per-phase masking only ever existed in the test's mock, which is now a plain
pass-through. Restores atgeirr's original node-keyed map, keeping the has_()
accessor added since.

Both GNETINJE decks are unchanged to the digit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The network system stored q = ipr_a + ipr_b * bhp while WellState stores
q = implicit_ipr_b * bhp - implicit_ipr_a, so the adapter negated one term on
the way in and the struct carried a comment explaining it. No reason for the
difference; use OPM's convention throughout and drop the negation.

Sign-only, exact in floating point. The dumped-system fixture in the bench
carries the same column, so its ipr_a values are negated with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DenseMatrix reimplemented Gaussian elimination with partial pivoting, which
Dune::DynamicMatrix already provides. Keep the class as a thin wrapper for two
reasons only: (i,j) indexing at the assembly sites, and a bool for a singular
matrix instead of an exception -- a singular Jacobian means this network hands
back to the relaxed update, and throwing out of the well model would be worse.

GNETINJE_GAS-01 and GASLIFT-13 are unchanged to the digit, iteration and
well-solve counts identical everywhere. AUTOCHK's FOPT moves 1.7e-5 relative
over 182 days at identical counts -- Dune's elimination orders the arithmetic
differently, and this is the only change in the series that can move a bit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
/// Solve any of the systems in this file. They differ in what a rate is -- one
/// number for an injection network, three for a production one -- but not in how
/// the Newton, the active set or the bounds work.
template<class Sys, class Globalisation = FullStep>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please remove default template and function arguments. Avoid them, except as a means to reduce the need to change existing code, and even then avoid unless it leads to very large changes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 4bad252. Tolerance and iteration cap are now a Parameters struct and the globalisation is always named; two overloads instead of defaults, the two-argument one spelling out FullStep{}. The bench is the only caller that varies any of it.

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
}
}

/// Solve any of the systems in this file. They differ in what a rate is -- one

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The comment should explain that this solves using a Newton-Raphson method. Do not refer to "this file". The rate comment is unneeded as nowhere in this function does the rate size matter.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d37d21e: it now says Newton-Raphson with an active set over the well controls, and the file reference and the rate remark are gone.

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
return out;
};

// Guide rates are explicit: the simulator sets them once per timestep, and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Check this comment and the call to refreshGuides(). Is it necessary, and if it is, when and why? It is my belief that as of now guide rates are explicit and there should not be a need to refresh.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You are right that OPM's guide rates are explicit and this does not touch them. This is a different quantity that unfortunately borrows the name: it only does anything under --network-group-control, where the network places the group split itself and a well's share is its own potential at the node pressure — not knowable until the starting pressures are, hence once here rather than at setup. Inside the Newton it cycles, which is why it is outside. Comment rewritten in d37d21e; happy to rename it if you have a better word.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added a note in ca143b4 that explicit is not meant as the end state: solving for the share — either as an unknown of the system, or by iterating an outer loop to a fixed point — is the way past the cycling, and it is also what would let the network correct a group split rather than only report pressures. Not in this PR.

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
// is moving, and a capped allowance ties with it. Now that the
// iterate is not transient, drop the cap and carry on from here;
// whoever is over the line goes on rate control and the rest take
// it up. Once round only.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not understand "Once round only", and I do not see any code that should make the enforcement happen only once. Please explain better.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It is the enforcing flag two lines down: set on the one pass that drops the cap, and tested in the same condition, so a well still over its limit afterwards converges as it is. Comment now names it (d37d21e).

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated


template<class Scalar>
class System

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please document this class, and rename it InjectionSystem (assuming that I am correct that this is the counterpart to ProductionSystem).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d37d21eInjectionSystem, with a class comment saying what the unknowns and equations are and that it is the counterpart to ProductionSystem.

hnil and others added 3 commits August 24, 2026 22:24
`System` is the counterpart to `ProductionSystem`, so call it `InjectionSystem`
and document what it holds and what closes it.

solve()'s comment now says it is a Newton-Raphson with an active set over the
well controls, rather than describing the file.

Two comments answered rather than changed, because the code was already right
and the comment was not:

- refreshGuides() is a no-op unless the network is placing a group's split
  itself (--network-group-control). It is not OPM's guide rates, which stay
  explicit and set once per timestep; it is each well's own potential at the
  node pressure, which cannot be known before the starting pressures are. Once
  before the Newton, not inside it.
- "Once round only" is enforced by the `enforcing` flag two lines below; the
  comment now names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tolerance and iteration cap move into a `Parameters` struct whose members carry
the values, and the globalisation is always named. Two overloads instead of
defaults: the two-argument one is what the simulator uses and spells out
`FullStep{}` itself; the bench, which is the only caller that varies any of
this, passes them explicitly.

Results unchanged -- both GNETINJE decks, AUTOCHK and GASLIFT-13 identical to
the digit, GASLIFT-13's summary byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment only. The share a group-controlled well is tested against is explicit,
for the same reason OPM's guide rates are, and the comment now says that
solving for it -- as an unknown of the system, or by iterating an outer loop to
a fixed point -- is the way past the cycling rather than something ruled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
// targets are not handled, and re-routing (BRANPROP changing the tree) is not
// modelled -- the tree is taken as given.
template<class Scalar>
class ProductionSystem

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please move ProductionSystem and InjectionSystem to separate files. Also consider if it makes sense to use inheritance-based polymorphism with virtual functions instead of taking these classes as template arguments to solve(), I am not convinced it would be better, but would like to explore the option.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Split in 04a77a3: NetworkSolve.hpp (Node, Result, DenseMatrix, shareByGuide, globalisations, Parameters, solve — 380 lines), NetworkInjectionSystem.hpp and NetworkProductionSystem.hpp. No code changed; your line comments above will have moved.

On virtual functions: I think it would be an improvement and I am happy to do it. The two systems already share ten methods with identical signatures (size, start, residual, jacobian, updateControls, limitStep, columnScale, pressures, wellRates, controlLetter) and State is std::vector in both, so a base is straightforward. What it would buy is that the five one-sided ones stop being duck-typed: solve() currently discovers refreshGuides / setEnforceRateLimits / rateLimitsViolated (injection) and wellPhaseRates / wellBhps (production) with if constexpr (requires ...), so renaming one silently disables it. Dispatch cost is irrelevant at tens of unknowns against VFP lookups. Say the word and I will do it as its own commit.

Comment thread opm/simulators/wells/NetworkSystem.hpp Outdated
/// outside the bench wants.
template<class Sys>
Result<typename Sys::ScalarType>
solve(Sys& system, const std::vector<typename Sys::ScalarType>& node_pressure_guess)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am not happy about this overload. This is functionally very similar to the default parameter approach. I want a single overload with no default arguments, not hiding the default parameters inside a Parameters structure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair — done properly in 76dcc46. One solve(), no overload, and Parameters has no in-class initialisers, so nothing supplies a value a caller did not ask for. The simulator names its three call sites' settings once as kNetworkSolveParams beside them, and the bench has its own kParams for the cases not varying them; both are ordinary constants in the file that uses them, not defaults.

hnil and others added 3 commits August 25, 2026 17:03
Drops the two-argument overload and the in-class initialisers on Parameters, so
there is one solve() and nothing supplies a value a caller did not ask for.

The simulator's three call sites share one named constant, kNetworkSolveParams,
defined next to them with the values in plain sight; the bench has its own
kParams for the cases that are not varying the settings, and the handful that
are pass their own. Neither is a default: both are spelled out at namespace
scope in the file that uses them.

Tests and all three decks unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NetworkSystem.hpp was 2527 lines holding both systems and the Newton around
them. Split three ways, no code changed:

  NetworkSolve.hpp             Node, Result, DenseMatrix, shareByGuide, the
                               globalisations, Parameters and solve() -- what
                               neither system owns. 380 lines.
  NetworkInjectionSystem.hpp   Well, Control, InjectionSystem, dump/replay.
  NetworkProductionSystem.hpp  ProductionSystem, dump/replay.

solve() is a template over the system, so the solver header knows nothing about
either; both system headers include it. A stale comment above systemUsesAnalytic
that had been left behind by an earlier edit is dropped.

Tests and all three decks unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sweeping for what the solve() comment was an instance of, rather than fixing
only where it was pointed out. Three, all added by this branch:

  updatePressures()  use_secant / secant_for_production -- upstream's signature
                     had neither, and the one caller passes both already
  thpPotential()     cap_by_rate_limit, which the comment above it calls "the
                     whole subtlety"; worth a caller stating it every time
  addNode()          alq on the production system

`update()`'s relax_network_tolerance is left alone: it is upstream's.

Tests and all three decks unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hnil

hnil commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Went through the rest of the network code the same way. Fourteen things worth doing, roughly: shareByGuide duplicates the recursion in #7220; the parallel gather is a hand-packed flat array with a manual stride; Node::efficiency and LineSearch use double in Scalar code; five OPM_NETWORK_* env vars switch behaviour next to a proper --network-dump-failures; the two Control enums disagree; Result carries production-only fields and guides_moving is dead; the rate/pressure scaling is duplicated in both systems; 1e6 is a dimensional sentinel for "no rate limit"; the solve memoises on float equality; a singular Jacobian is logged as ordinary non-convergence; and the injection system has no Shut. Happy to take them in any order you prefer — I would start with the last two and the sentinel.

Also swept for default arguments rather than only fixing the one you flagged: updatePressures(), thpPotential() and addNode() each had one this branch had added, all gone in fed7d15. update()'s relax_network_tolerance is left alone since it is upstream's.

, unit_system_(unit_system)
, report_step_idx_(report_step_idx)
, comm_(comm)
, injection_phase_(injection_phase)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This member, and its use in e.g. hasLeafNodeRates() does nothing. It should be removed, and also from functions such as computePressures() in BlackoilWellModelNetworkGeneric that only needed it to pass to this constructor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Right, it does nothing — done in 5cda052. Once the leaf rates stopped being keyed by phase it was only handed to hasLeafNodeRate()/leafNodeRate(), which ignore it, since the VFPINJ table picks the phase its FLO type names. Member, constructor argument, the computePressures() parameter and five test call sites all gone; the injection overload now has the same signature as the production one. The local in the caller stays because newtonNodePressures() still needs it.

/// pressure, below it the wells become THP-limited and the response changes
/// abruptly. An unbracketed downward step is not taken past it (it stops just below,
/// so the next evaluation is on the THP branch close to the kink).
Scalar next(const Scalar applied,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can this function be replaced by, or rewritten to use the existing RegulaFalsi implementation in opm-common's RootFinders.hpp?

If necessary, suggest improvements for that code to make it usable here.


namespace Opm {

/// Per-node update of the applied network pressure towards the fixed point of

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It is not well explained what a per-node update means here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rewritten in a9b77c3: one of these per node, held by the caller in a map and stepped once per node per network sub-iteration, each with its own bracket — which is also the limitation, since the nodes are coupled through the wells below them and a bracket end has to be dropped when a sibling moving contradicts it. Solving every node at once instead is what NetworkSolve::solve() does.

@atgeirr

atgeirr commented Aug 26, 2026

Copy link
Copy Markdown
Member

On virtual functions: I think it would be an improvement and I am happy to do it. The two systems already share ten methods with identical signatures (size, start, residual, jacobian, updateControls, limitStep, columnScale, pressures, wellRates, controlLetter) and State is std::vector in both, so a base is straightforward. What it would buy is that the five one-sided ones stop being duck-typed: solve() currently discovers refreshGuides / setEnforceRateLimits / rateLimitsViolated (injection) and wellPhaseRates / wellBhps (production) with if constexpr (requires ...), so renaming one silently disables it. Dispatch cost is irrelevant at tens of unknowns against VFP lookups. Say the word and I will do it as its own commit

I think the arguments above are very much in favor of switching to inheritance and virtual function-based polymorphism for the NetworkInjectionSystem and NetworkProductionSystem classes and the solve() function that use them. Please also make that change.

hnil and others added 3 commits August 26, 2026 20:25
Once the leaf rates stopped being keyed by phase, NetworkPressureComputation's
injection_phase_ was only ever handed to hasLeafNodeRate() and leafNodeRate(),
which ignore it -- the VFPINJ table picks the phase its FLO type names. So the
member, the constructor argument, the computePressures() overload's parameter
and the five test call sites were all carrying it nowhere.

computePressures() for injection now has the same signature as the production
one. The local in the caller stays: newtonNodePressures() still needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One of these per node, stepped once per node per network sub-iteration, each
with its own bracket -- which is also the limitation, since the nodes are
coupled through the wells below them. Comment only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InjectionSystem and ProductionSystem now derive from SystemBase, and solve()
takes SystemBase<Scalar>& instead of any type that happens to have the right
methods. All five `if constexpr (requires ...)` blocks are gone, and with them
systemUsesAnalytic()/systemJacobian(), which existed only to ask a type whether
it knew how.

What this buys is that the interface is stated. The twelve methods both systems
implement are pure virtual; the five one-sided ones -- refreshGuides,
setEnforceRateLimits, rateLimitsViolated on injection, wellPhaseRates and
wellBhps on production -- are virtual with the default a system that does not
do them wants. Before, renaming any of the five silently disabled it; now the
compiler checks every override, which it did on the first build of this commit.

Dispatch cost is nothing at tens of unknowns against a VFP lookup per residual.
Both globalisations stay templates: they are policies, not the problem.

Tests and all three decks unchanged, GASLIFT-13's FOPT to the digit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hnil

hnil commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Base class done in 6a4df6cSystemBase<Scalar>, both systems derive, solve() takes SystemBase<Scalar>&. All five if constexpr (requires ...) are gone, and with them systemUsesAnalytic()/systemJacobian(), which existed only to ask a type whether it knew how. Twelve pure virtuals for what both implement, five virtual-with-default for the one-sided ones. It compiled first try with every override accepted, which is the check the old version could not make. Tests and all three decks unchanged.

Also in this push: the dead injection phase (5cda052) and the per-node comment (a9b77c3).

On RootFinders.hpp I would push back, and would like your view. RegulaFalsi::solve(f, a, b, ...) owns the iteration and calls f itself, and it wants a bracket up front. NodePressureUpdater cannot give it either: one evaluation of r(P) is a well-model and network pass over the whole tree on every rank, driven by the outer loop, so the class is a stepper — called once per node per sub-iteration, carrying its bracket between calls, building it as evidence arrives, dropping an end when the wells change state under it, and handling the no-VFP-solution and plateau cases. Wrapping opm-common's version would mean inverting that control flow. What would make it usable is splitting it: a state-holding stepper with a step(x, f(x)) -> next x, and solve() as a thin loop over that. I am happy to do that in opm-common if you think it is worth it — it is a bigger change than it looks, and it touches everything that uses RegulaFalsi today.


/// Result of a single network branch VFP lookup.
template<typename Scalar>
struct NetworkBranchPressure

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the changes to this file are ready to be merged to the master branch. Please extract the changes here, as well as follow-on changes in test_networkpressure.cpp and other files that adapt to the new return type NetworkBranchPressure and the invalid node concept to its own separate pull request.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Happy to — but the caller side does not stand alone on master, and I would rather ask than guess. In master computePressures() returns a pair and there is no network-domain concept at all; the invalid-node handling in this PR lives in the multi-domain BlackoilWellModelNetworkGeneric, which is your own base commits, not master. It also needs the two has_network_leaf_node_*_rates() accessors added to GroupState.

So either the new PR carries your nine base commits with it (which duplicates the first nine of this one), or I port the invalid-node handling onto master single-domain caller, which is a rewrite of that part rather than an extraction. Which would you prefer? The header itself and test_networkpressure.cpp are straightforward either way.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So either the new PR carries your nine base commits with it (which duplicates the first nine of this one), or I port the invalid-node handling onto master single-domain caller, which is a rewrite of that part rather than an extraction. Which would you prefer? The header itself and test_networkpressure.cpp are straightforward either way.

I see, I think then we should try to start from the nine base commits and include them. That means the first PR is a bit larger than ideal, but it should still be a consistent and understandable step forward. If there are parts of those commits that have been made obsolete or otherwise rewritten completely by later commits here, leave out those parts unless it makes the PR inconsistent or would lead to existing functionality (production network) failing where it succeeds in master.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done — #7368. It came out cleaner than expected: your nine commits plus the VFP one are already a contiguous prefix of this branch, so it is a straight cut rather than a port, with NetworkBranchPressure, the invalid-node concept and the six test cases.

I checked your condition on production explicitly, by building master and diffing the runs rather than trusting the test verdicts: NETWORK-01 and NETWORK-01_STANDARD agree with master on all 369 summary vectors except TCPU. Worth knowing that the seven compareECLFiles_flow+NETWORK-01* tests fail for me on plain master too — my opm-tests checkout predates GWGR/FWGR being added to the summary — so they are not a usable gate here.

Nothing from the base nine looked obsolete enough to drop; the only thing later work undid was our own per-phase leaf-rate keying, which is already back to your node-keyed map.

template<typename Scalar, typename IndexTraits>
std::optional<std::map<std::string, Scalar>>
BlackoilWellModelNetworkGeneric<Scalar, IndexTraits>::
newtonNodePressures(const Network::ExtNetwork& network,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rename this function newtonInjectionNodePressures() to parallel the production version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed in 7a8b7fa.

}
}
this->nodePressures(network.domain) = std::move(result.node_pressures);
this->branchData(network.domain) = std::move(result.branch_data);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The branch data will be stale if the newton solver was used: it is unchanged from the initial calls to computePressures(). Please update the data from the converged solution.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — fixed in 7a8b7fa. GPRB against GPR was out by 0.0037 bar on NETWORK_MODEL5_STDW_AUTOCHK at day 91; now consistent.

Two things I found doing it. The refresh has to go after the relaxed update rather than straight after the solve: that update moves the stored pressures again (it bounds the wells step towards what the solve placed), so refreshing any earlier just picks a different stale value — my first attempt did exactly that and was still 0.0037 bar out.

And it is not only the newton path. The default path has the same drift for the same reason — the damped update moves the pressure after computePressures() took the branch data — 0.00045 bar on that deck and step. I have left those nodes alone, since correcting them changes GPRB on every network deck; say if you would rather have it fixed everywhere and I will do it as its own commit. Default output here is byte-identical.

hnil and others added 6 commits August 27, 2026 18:02
computePressures() derives node pressures by walking the tree and records each
branch's drop as it goes. A simultaneous solve then replaces the node pressures,
so every branch below a node it placed reported a drop belonging to pressures
the node no longer had -- GPRB against GPR was out by 0.0037 bar on
NETWORK_MODEL5_STDW_AUTOCHK at day 91. Recompute those drops.

It has to happen after the relaxed update, not right after the solve: that
update moves the stored pressures again, bounding the wells' step towards what
the solve placed, so refreshing any earlier just picks a different stale value.

Only nodes the solve placed are touched. The same drift exists on the default
path -- 0.00045 bar on the same deck and step, because the damped update moves
the pressure after the branch data is taken -- but correcting that changes GPRB
on every network deck and is a separate decision. Default output here is
byte-identical.

newtonNodePressures() is renamed newtonInjectionNodePressures() to parallel the
production one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The regression tests compare the answer, and would pass a run that
reaches it by falling back to the relaxed update every step or by
re-solving one well thousands of times. Both have happened on these
decks. run-network-cost-test.sh runs a deck and bounds the network
fallbacks, the Newton count, the well solves, and the share of well
solves taken by any single well; two cases are registered, autochoke
and gas lift, under the label network_cost (20 s together).

Shown to discriminate: the same bounds against the legacy fixed-point
path on NETWORK_MODEL5_STDW_AUTOCHK report 746 Newton and 70040 well
solves against bounds of 260 and 6000, and the script exits 1.
Review feedback: the comments read as convincing but could not be
followed. They referred to "the scan", "the crossing" and "the hump"
without ever defining them, and narrated the bug each fix closed
rather than stating the rule the code follows.

The comment describing thpPotential() was also attached to hasTubing(),
so the two functions' documentation had merged onto the wrong one.
Split it, define the crossing and the liquid-loading hump once where
that function is declared, and let the later comments refer to them.

Records the improvement Stein pointed out, at the code it applies to:
thpPotential() scans and bisects (~136 lookups, approximate) where
VFPHelpers::intersectWithIPR would give the crossing exactly in ~21,
which is also why the allowance cache needs a jump guard.

Comments only.
--network-solver=newton declined every tree in the NETWORK-01 family
outright, and said nothing about it. The wells there are on thp
control, and the adapter pinned any well that was not under an
autochoke, under gas lift, or on group control -- so nothing was left
to place and the solve handed back before it started. A well on thp
control under a network node is the one the solve exists to place: its
thp is the node pressure.

Freeing them unconditionally costs more than it saves where wells are
already free (AUTOCHK 4603 -> 10446 well solves), so it happens only
when nothing else would give the solve something to do, which is
exactly the case that used to decline.

A declined tree now says so, and names the control modes that held its
wells, instead of being indistinguishable from a solve that ran and
changed nothing.

Reachable and in agreement with the relaxed update, at fewer well
solves (legacy vs newton, FOPT and well solves):
  NETWORK-01               538142 / 449   538842 / 379
  NETWORK-01_STANDARD      538142 / 449   538842 / 379
  NETWORK-01-REROUTE       543737 / 756   544171 / 493
  NETWORK-01-WTEST         674921 / 1016  675875 / 549
  NETWORK-01-WEFAC-...     128788 / 430   128772 / 365
  NETWORK-01-MULTIROOT     778152 / 407   778188 / 380
  6_UDA_MODEL5_STDW        317225 / 1781  317225 / 1788 (exact)
The simultaneous solve reaches the same balance by a different route, so
on a deck where the relaxed update converges the two must agree. That is
checkable without an external reference, which matters because these
decks have none -- until now the production formulation had no deck-level
correctness test at all, only the two cost tests.

Seven decks, every one where the solve engages and the relaxed update
also converges: the NETWORK-01 family (plain, standard, wtest,
wefac-gefac, multiroot, reroute) and 6_UDA_MODEL5_STDW. 26 s together.

The gas-lift decks are deliberately not here: their answers differ by
4-6 % by design, since the network-answered oracle is the point of that
feature. They stay on cost tests.

Two things the script guards. It fails if the simultaneous solve never
ran, so a test cannot pass by comparing the relaxed update with itself
-- demonstrated by pointing the second run at --network-solver=fixedpoint,
which exits 1. And it compares report steps only (-d): the two paths
place ministeps differently, which on NETWORK-01-WEFAC-GEFAC-ITEM3NO
made the summary vectors different lengths outright.
The other half of "split it, and consider a network/ subdir" -- the split
landed, the subdir did not. Four headers move to opm/simulators/wells/network/,
following wells/rescoup/: prefixes kept, listed after the flat wells headers in
CMakeLists_files.cmake.

NetworkNodePressureUpdater.hpp moves with them. It belongs to the relaxed path
rather than the simultaneous one, but leaving the only other network header
outside a network/ directory is worse than the small inconsistency of it being
there.

Paths only. Tests, both GNETINJE regressions and both cost tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hnil

hnil commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Picked up the other half of your first review — the split landed but not the network/ subdir. NetworkSolve.hpp, NetworkInjectionSystem.hpp, NetworkProductionSystem.hpp and NetworkNodePressureUpdater.hpp are now in opm/simulators/wells/network/, following wells/rescoup/: prefixes kept, listed after the flat wells headers. Paths only (a2f4989).

I took the node pressure updater along even though it belongs to the relaxed path rather than the simultaneous one — leaving the one remaining network header outside a network/ directory seemed worse than that small inconsistency. Easy to move back if you disagree.

Also: I had not been running the ctest regressions until now, only the unit tests — flow was not being built here. Both GNETINJE cases pass, as do the two network_cost tests and the parallel NETWORK-01 case. The seven NETWORK-01* comparisons fail in my checkout for an unrelated reason: my opm-tests is on a branch from 11 May, before GWGR/FWGR were added to the summary on 27 May, so the run emits keywords the references do not have. Nothing to do with this PR, but worth saying out loud since I had been quoting my own hand comparisons up to now.

Three from the review on OPM#7368.

`details::domainForWell()` already existed but two callers still open-coded the
producer/gas/water mapping; they use the helper now, so a new injector type is
one edit rather than three.

The per-node pressure and inflow trace in NetworkPressureComputation::run()
built a string for every node on every sub-iteration of every domain whether or
not the log kept it. It is behind OPM_NETWORK_PRESSURE_TRACE now, undefined by
default, as atgeirr suggested -- these lines are for someone debugging the tree,
not for a production run to pay for.

And the && chain in operator== is indented like the lines above it.

Tests and both GNETINJE regressions unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hnil

hnil commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

Carried the three Copilot fixes from #7368 onto this branch too (71efe14), so the two do not drift apart: details::domainForWell() at all three call sites, the per-node pressure/inflow trace behind OPM_NETWORK_PRESSURE_TRACE, and the && indentation. Tests, both GNETINJE regressions and both cost tests pass.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants