Skip to content

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

Draft
hnil wants to merge 68 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 68 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>
hnil and others added 17 commits August 24, 2026 10:52
newtonProductionNodePressures() is the production counterpart of the injection
adapter: build a ProductionSystem from the replicated schedule, take each well's
per-phase implicit IPR off the well Jacobian summed once by the owning rank, and
hand back node pressures. The well state holds q = b*bhp - a in opm's signed
rates where production is negative; the system wants production positive and
falling with bhp, which is the same line negated.

Three things had to be fixed to make it agree with the relaxed update it now
runs alongside.

The implicit IPR was refreshed only for injectors, on the grounds that the well
solve maintains it for producers. It maintains it only where its own control
logic happens to need it: 103 of 376 production network solves arrived with a
well they could not linearise and handed the whole network back. Refreshing it
for every prediction well takes that to zero.

thpPotential() bracketed between the bhp limit and the bhp at which *oil* stops.
Water and gas have their own zero crossings and are still flowing there, so the
tubing still has something to lift, the bracket did not contain the crossing, and
the well read as unable to produce at all. It now brackets to the last phase to
stop.

A zero from thpPotential() means the well cannot lift against this node pressure
-- its table does not reach that high. That makes thp *unavailable*, not a
control allowing nothing: as an allowance of zero it won "most restrictive" every
time, and the thp row it then imposed says nothing about a rate, so the well
produced whatever the tubing crossing happened to be. On NETWORK-01, whose wells
are tabulated for a thp of 20 bar while the node sits near 90, that was every
well on every step: 3730 sm3/d against a 1000 limit, and a node 5 bar high.

Wells not under a network-held group target are pinned at the rate they already
have rather than at the deck's WCONPROD limit. Re-deriving the operating point
would overwrite one the group and the well solve have already agreed on, and a
limit that does not bind in the simulator would become one here.

  NETWORK-01           365 solved, 0 fell back, no deviation from the relaxed
  NETWORK-01_STANDARD  365 / 0,     no deviation
  NETWORK-01-REROUTE   212 / 346,   no deviation; every fallback is BRANPROP
                                    re-routing giving the network more than one
                                    root, which this formulation does not model
  injection            gas 151 / 0, water 364 / 0, unchanged

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

A network with several roots is a forest of independent trees -- every node has
one parent, so the trees share nothing. The adapters now take a root and solve
each tree; whichever converges replaces the relaxed answer for its own nodes.
That was the only cause of fallback on seven decks: both REROUTE variants (the
BRANPROP re-route leaves the old root behind), both MULTIROOT variants, WTEST,
6_UDA_MODEL5 and both GSATPROD decks all reported "the network has more than one
root" and nothing else.

Being able to converge is not the same as being right, so every deck with a
network was then compared against the relaxed update, which found four node
features the system silently mishandles. Each is now refused by name instead:

- a fixed-pressure node below the root (MULTIROOT's GRPB at 82 bar): a boundary
  the extended network holds fixed, which this would compute from the branch
  table -- worth 7 % on field rates;
- gas lift added at a node (NODEPROP item 4) and wells under WLIFTOPT: the alq
  is the wells' own, not the branch constant -- GASLIFT-13 got a simulation 128 %
  off with 4x fewer Newton iterations, which is not a faster solve but a
  different problem;
- satellite production (GSATPROD): rates that arrive without wells -- 9-22 % off;
- a well efficiency factor: scales the branch contribution but not the well's
  own rate, and the system has one rate per well.

With the guards, every deck in the regression suite that the solve accepts
agrees with the relaxed update to 0.5 % at every report time -- the seven
production decks exactly, water within its known day-92 transient, gas closer to
the E100 reference than the relaxed update is (8 deviating pairs against 44,
which include its node-pressure collapse to 1 bar). The guarded decks run
unchanged, every fallback naming its reason.

Of the ten regression-covered network decks: seven solve outright with zero
fallbacks, two are refused entirely (gas lift at a node, satellites), one
(GASLIFT-13-style) partially. Nothing is silently wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A review before taking on gas lift and autochoke, which turned up that most of
what the adapter was refusing is a constant inside one network solve and only
needed a slot:

- WEFAC and NEFAC. A well's own rate is q; the branch above it sees
  efficiency * q, and a node passes on efficiency * what it collects. Both
  systems carry the factors in their balance rows, the injection Jacobian in
  its, and the dump format. The injection adapter had been ignoring them
  silently; the production one refused. The well factor is the network one
  (WEFAC item 3) times the per-rank scaling, summed once by the owner.
- Lift gas. Added to the gas stream at the leaf when NODEPROP item 4 says so,
  scaled by the well's efficiency, and not part of q -- it goes up the tubing,
  it is not produced. Each well carries its own; a satellite's is the node's.
- Satellite production. A constant triple at the node; the group sum returns it
  instead of the group's wells, so those wells are left out to match.
- A well's alq is what its own tubing table sees, which is alq_state, not the
  WCONPROD item -- the same number unless an optimiser has moved it.

And one defect in the production control rule: thpPotential() reported exactly
what the bhp limit allows when thp did not bind, which tied, and the tie went to
thp -- whose row then settled the bhp below the limit the deck set. It now
reports more than the limit allows, and the well lands on bhp control at its
limit.

                         before            after            vs relaxed
  GSATPROD6 (regression)   0 / 1156 refused  1194 / 0        no deviation
  GSATPROD5                0 / 1146          1184 / 0        no deviation
  6_UDA_MODEL5 (regr.)     0 /  440          528 / 0         FOPR identical,
                                                             FWPR 0.015 sm3/d
  NETWORK-01-WEFAC       303 / 0 (factors 1) 303 / 0         no deviation

Wells under gas lift optimisation stay refused. With the alq taken as a
constant the solves converge, and the runs then diverge from the relaxed update
by 100 % and more over the schedule (GASLIFT-13/14): the optimiser reacts to the
node pressures it sees between passes, and that coupling is the real gas lift
work, not the constant. The guard names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NETWORK_MODEL5_STDW_AUTOCHK aborted at day 3.2, and NETWORK_MODEL5_STDW_AUTOCHK
with GROUPGUIDERATES with it. The autochoke block decides whether the target is
the group's own or an ancestor's from the *deck* control (ORAT here), then asks
the group *state* for the target -- and the state says NONE for a group under
its limit, which getProductionGroupTargetForMode_ throws on. One layer down the
TargetCalculator was built from the same state cmode and asserted on NONE in
calcModeRateFromRates. A group under its limit is simply a choke that ends up
open; read the target off the control already in hand and build the calculator
on it.

Also guard the upstream-node lookup on the first pass of a run, when the node
pressure map is still empty: dereferencing end() there handed the group a
garbage pressure.

The deck now runs to the end (790 Newton iterations); the GROUPGUIDERATES
variant gets much further and then loses its timestep, which is a separate
problem. Both fail identically on upstream master without this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--network-autochoke=true, with --network-solver=newton, hands autochoke nodes to
the simultaneous network solve instead of the legacy search. Defaults stay as
they were; without the option a tree with a choke node is refused by name and
the legacy block keeps the node.

In the system a choke is one row and one flag. The node's pass-through row
becomes "oil collected here equals the target", and its pressure is the group's
common thp -- the multiplier, with no lambda. Closed when the wells behind it
could deliver more than the target with the valve open, at the upstream
pressure; open otherwise. Because the wells' controls have to be chosen at the
pressure the choke settles at and not at the iterate's, the selection first
finds that pressure from the wells' allowances alone -- the cheap model the
control rule already runs on -- and the Newton only polishes it. No well solves
anywhere: the legacy search calls iterateWellEqWithSwitching on every well in
the group per sample of a brute-force bracket, up to 1300 samples a pass.

Getting there exposed four things the production system lacked, each found by
comparing with the legacy answer at a converged point:

- the hydrostatic correction between a tubing table's datum and the well's
  reference depth, which every well's own thp evaluation applies -- 13 bar on
  these wells, and the reason the tubing looked twice as permeable as it is.
  Both systems carry it now, computed on the typed side with the well's density
  and shared through the owner-summed entries;
- the liquid-loading hump: at low rates a tubing table needs *more* pressure
  than at moderate rates, so h(bhp) is negative at both ends of the bracket and
  positive between, and a bisection sees "cannot lift" for a well that lifts
  fine. thpPotential scans for the crossing where h turns positive with rising
  bhp, the one the well settles on; the other is the loading point;
- a start consistent with the control rule: opening every well at the bhp limit
  put a well whose limit is the 1 atm default at a rate off every table;
- a margin in the control choice, so the marginal well a choke leaves exactly
  where its tubing passes its own limit does not flip every iteration.

Two rules about what the network may decide. A well it is not deciding for --
not under a choke, not under a network-held group target -- is pinned at its
current rate and not offered thp; with every well pinned a tree has nothing to
decide and the solve is skipped, because it would only reproduce the relaxed
evaluation and walk the wells down a different path to it, which a well test
downstream can turn into a different decision. And a well the well model has
at zero rate is dead at its thp: the linearised inflow with the table still
finds a flowing crossing for it, and more back-pressure cannot revive it.

Nodes a tree solve has placed go through the bracketing update rather than the
damped one: the damped update creeps ten per cent per sub-iteration toward an
answer that does not change and runs out the cap -- a hundred solves a Newton
iteration -- while handing the wells the whole jump at once is how one gets
shut as inoperable in a transient.

  NETWORK_MODEL5_STDW_AUTOCHK, new path vs legacy
    B1 oil  day 1   5929 / 6019    day 31  5997 / 6001
            day 60  6001 / 6016    day 91  4904 / 4846
    node pressure within 0.4 bar throughout; 4118 solves, 34 fell back, 43 s
    against 790 Newton iterations and 895 brute-force brackets in 17 s.

  Open: B-1H is shut by the well model's operability test at day 91 (day 152
  on the legacy path), after which the two diverge. Slower than legacy for now:
  the scan costs 96 table lookups per well per control selection.

  Every other deck is unchanged to the digit -- the seven NETWORK-01 variants,
  6_UDA_MODEL5, the MSW deck -- and the injection decks keep their E100 match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inside the network sub-loop the wells are frozen, so the system the
simultaneous solve is handed comes back unchanged sub-iteration after
sub-iteration -- and was being solved again each time, from a different guess:
at best a repeat (1490 of 4118 solves converged in two iterations, the guess
already the answer), at worst a different root, which then kept the bracketing
update busy for a dozen sub-iterations. Keep the inputs and the answer per tree
root and hand the answer back while the inputs match.

NETWORK_MODEL5_STDW_AUTOCHK, same build, before and after, against legacy:

                          legacy     before      after
  well solves             467054      11636       7612
  network solves               -       4118        870
  reservoir Newton its       790        313        288
  fell back                    -         34          0
  wall clock                15 s       43 s       12 s

Within 1 % of the legacy answer to day 91; B-1H is shut by the well model at
day 106 against day 152, after which they diverge -- still the open item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--gas-lift-network-response=true, with --network-solver=newton, answers the gas
lift optimiser's trial evaluations from the simultaneous network solve instead
of a well solve at a fixed thp. The optimiser is untouched: same increments,
same economic gradient, same weights and limits, same stage-2 redistribution.
Only the oracle changes -- which is the whole point, since every gradient it
takes is currently at a node pressure that does not respond to the lift gas
being tried.

A trial copies the last solved tree, sets the well's alq, re-solves, and reads
the well's potential off the node pressure that state gives. The well keeps its
limits while the network is solved, so the state is one the field could be in;
the potential is then taken at that pressure, because the optimiser asks for a
potential and applies the limits itself. Freeing the well instead -- the first
thing I tried -- lets it flow at a rate it is never allowed, moves the node
pressure somewhere it never sits, and the optimiser then applies its limit on
top of a potential taken at the wrong pressure.

What it costs, GASLIFT-13, against the legacy oracle:

                        legacy    network-answered
  well solves            72 920             10 773
  reservoir Newton its    2 733                446
  wall clock               61 s                6 s

What it is not: validated. The two oracles disagree, and I have not established
which is right. Within one optimiser pass (same well, same ipr) C-1H reads
2492 / 2982 sm3/d oil at alq 12500 / 25000 from the network, against 2203 / 897
from a well solve -- the legacy answer collapsing by 60 % as the lift gas
doubles. It is not the alq axis running out: the table's axis is 0 to 219000 and
both trials sit inside its first interval. It could be the well genuinely going
unstable, or the legacy bracket landing on another root; judging it needs an
independent measurement -- fixed-alq runs, no trial machinery on either side --
which is the next step and wants no more guessing before it.

So: default off, and the flag's help says experimental. The simulation it
produces differs from the legacy one by more than a rounding, and until the
oracle is judged that difference is not evidence of anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--network-autochoke-bracket-samples, default 300, which is the value the search
has always used and so changes nothing. Each sample solves every well in the
group, and the search runs on every outer network iteration, so this count is
where the legacy autochoke's cost lives: on NETWORK_MODEL5_STDW_AUTOCHK,

  samples   well solves   reservoir Newton its   wall
    300        475 143            790            15 s
     12        151 033            492            11 s
      6         30 528            271             3 s

The answers differ -- by 29 % and 40 % in late-time field oil against the 300
run. That is the finding: the legacy autochoke's result depends on how finely
its bracket is sampled, so it is not a converged quantity and 300 is not more
right than 12, only the value everyone has been running. The new simultaneous
path tracked the 300 run to day 91 and then parted from it at B-1H's shut; the
legacy variants part from each other in the same region. The late-time
behaviour of this deck is fragile under every method, including legacy against
itself.

Also drops a temporary gas lift diagnostic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The choke's root-find asks for each well's thp allowance at dozens of pressures
per control selection, and each answer is a 96-sample scan of the tubing table.
Keep the answers on a quarter-bar grid and interpolate where the potential is
smooth; where it is not -- one side cannot lift, or is unbounded, or the two
grid points differ by more than five per cent, which is the stable crossing
jumping along the loading hump -- evaluate exactly. Without that last guard the
interpolation invented allowances across the jump, the choke pressure ran to
41 bar and every B well was shut by day 97.

With the guard the autochoke deck is unchanged to the digit (288 iterations,
7613 well solves, the same shut day) and no faster: with inflow slopes near
1000 sm3/d per bar the potential moves ten per cent per cell and the guard is
almost always taken. GASLIFT-13, whose wells are gentler, goes from 6 s to 4 s
at the same well-solve count. The remaining cost is the scan itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three rate derivatives of every table lookup come from differentiating the
lookup itself -- VFPProdProperties::bhp on DenseAd::Evaluation<Scalar, 3>, the
instantiation that exists -- so the chain rule through FLO, WFR and GFR types is
the library's, not a hand-written one per type. The thp derivative is one more
lookup a hundredth of a bar up: the table is piecewise linear in thp, so inside
an interval that is the derivative. Balance, inflow, control, group and choke
rows are structural. Checked against differences on a plain network, under a
group target and with a closed choke: largest mismatch 0.0056 on entries of
14400.

Opt-in through --network-analytic-jacobian, which already existed for the
injection side; the default still differences, so nothing changes by itself.

Measured on NETWORK_MODEL5_STDW_AUTOCHK: 265 reservoir Newton iterations and
6624 well solves against 288 and 7613 with differences, the same answer -- and
50 network solves that fail where none did, every one of them a well flipping
between thp and its own rate limit, TTTO / TOTO. The exact Jacobian lands the
well precisely on the tie; the differenced one was inaccurate enough to nudge it
off. Locking a tied well on its rate limit removed every fallback and gave the
best counts on both decks, and shut every B well by day 121; locking it on the
tubing gave the closest late-time answer with this Jacobian and doubled the gas
lift deck's iterations. Neither lock is in. A tie is a complementarity
condition -- both rows hold there -- and it wants a formulation, not a margin;
that is the next thing to do on the control rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, in the order the bench forced them.

A Fischer-Burmeister row for a well on the tie between its rate limit and
its tubing, Control::Tied: a + b - sqrt(a^2 + b^2) on the scaled rate slack
and tubing slack, with the generalised derivative at the origin. Only with the
assembled Jacobian -- the row is not smooth there, and a difference straddling
the kink collapsed the differenced path from 0 fallbacks to 678. A tied well
whose lift gas is changed for a trial is released: a different alq is a
different tubing curve, and the tie went with it.

Production dump and replay. write()/readProduction() carry everything a solve
depends on -- sources, choke targets, efficiencies, the hydrostatic term, lift
gas, pinning, dead wells -- and the bench replays a directory of them against
the MODEL5 tables with both Jacobians and compares the answers. The adapter
writes tree failures, trial failures (which used to fall back to the well
solve without a trace), and with OPM_NETWORK_DUMP_ALL=N the first N solved
systems as well, because a converged answer can still be the wrong root.

What the replay said about GASLIFT-13, where the analytic Jacobian turns a
302-iteration run into a 592-iteration one with 51 % different late-time oil:
of 241 systems the analytic run solved, 240 replay identical with both
Jacobians (gaps under 0.02 bar) and the differenced one fails one; the
differenced run's four tree failures are the tie, which the tied row converges;
its three trial failures fail both ways because two wells tie at once, which
one row cannot resolve. So the two Jacobians do not reach different network
answers. They differ in seven solves that one converges and the other does
not, and seven trial answers are enough to send a greedy, discrete optimiser
down another path -- deterministically (592 twice). That is the optimiser's
sensitivity, not the Jacobian's error, and it is now measurable standalone.

Also a bench case proving both Jacobians reach the same solution on plain,
grouped, choked and tied systems from fourteen starts each (gaps 1e-10 bar).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--network-complementarity=true (with --network-analytic-jacobian=true) closes
every production well that has limits of its own with one row instead of an
active set: the box complementarity q in [0, limit] against the tubing slack
bhp - tubing(p, q) and the bhp slack bhp - bhp_limit, as nested
Fischer-Burmeister functions. q = 0 when the tubing needs more than the
reservoir gives, q = limit when both slacks are positive, a slack at zero in
between; nothing switches, and two wells tying at once need no treatment.
Eligibility is decided once per solve from the starting point -- deciding it
from the iterate re-introduced the switching the row exists to remove -- and
complementarity wells start from the rate the well model has, because a tubing
table with a loading hump gives a well two branches and the start chooses.

With it, bounds by projection for the production step (no node below an
atmosphere, no bhp below its limit, no pressure step over 50 bar), which the
active-set rows had not needed and the complementarity row did: its first full
step from a poor start ran a choke node to minus three thousand bar.

Standalone, in the bench:
  complementarity_agrees_with_the_active_set   5 shapes x 14 starts: converges
      from every start, agrees with the active set to 3e-2 % or better
  replay_production_failures, 7 GASLIFT-13 dumps: differenced 0/7, analytic
      active set 4/7, complementarity 7/7 -- the three two-well ties included
  40 autochoke failure dumps (earlier run): 33/40 in 9 iterations, to the
      physically right answer where the active set had three wells producing
      2500 sm3/d through tubing that cannot lift them.

In the simulator it is not yet better:
  NETWORK_MODEL5_STDW_AUTOCHK   134 Newton, 6208 well solves, 156 fell back
                                (active set + analytic: 223 / 5097 / 0)
  GASLIFT-13                    350 Newton, 8402 well solves, oil per lift gas
                                4.4 (active set, differenced: 302 / 7456 / 12.3)
Every remaining failure is one class, and the bench reproduces it: the choke
is decided closed by the allowance test while the complementarity wells sit on
their dead branch and cannot reach the target. The choke has to become a
complementarity too -- p_node - p_up >= 0 against target - sum q >= 0 -- rather
than a pre-solved state. That is the next item. Default off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Under --network-complementarity the choke node is one row,
fb(p_n - p_open, target - q_n), with p_open the branch's own table value
when the choke node has one; no open/closed pre-solve. The outer box on
the well row is dropped (degenerate when the bhp limit binds); whether a
well flows or is dead is the scan's answer at the current node pressure,
inside the row. Control::Shut (q = 0) for a bhp limit at or above shut-in,
in both formulations: the active set used to report injection there, and
production_thp_that_does_not_bind_leaves_the_well_on_bhp had asserted it.
limitStep caps a complementarity well's oil step and re-seeds a rate at or
below zero to its allowance. OPM_NETWORK_CM_TRACE prints the iteration.

Bench: complementarity_agrees_with_the_active_set, 7 shapes x 14 starts
all agree (both chokes, ties, bhp above reservoir). AUTOCHK replay still
26/1396: the real systems hold a well at its hump tangency, which the FB
row cannot linearise. Decks not better; numbers in the findings note.
Four faults found on the AUTOCHK failure dumps with the new per-iteration
trace (OPM_NETWORK_CM_TRACE): the re-seed set the rate but not the bhp,
so the well never sat on its IPR; a well reviving as the pressure it
left behind fell made a system with no fixed point, so a well that
loses its crossing as the pressure rises stays shut for the solve; wells
the well model has dead were handed to the active set, which produced
2500 m3/d from each of them under a 6000 choke target, so under the flag
they are Shut; and one alpha for the whole step let a choke row's
pressure demand cut the shut rows to 0.1 % of their rate per iteration,
so pressures are clamped componentwise and rates move freely. The scan
starts where the table's flow axis ends, and a choke over wells that
cannot respond has no row.

Replay of 1396 AUTOCHK dumps: 1396/1396 in 2-5 iterations (was 26).
AUTOCHK deck: 0 fallbacks, 201 Newton, 8 % more oil than the active
set, but 2.5x the well solves on the one dying well -- the shut has to
be carried across the well model's iterations next.
A well the well model has at zero rate has no usable IPR, and the
production adapter refused the whole system for it -- every one of
GASLIFT-13's 280 fallbacks. Under --network-complementarity such a well
is Shut in the system (Well::shut; q = 0 with an IPR, bhp = limit
without). GASLIFT-13: 298 Newton / 6583 well solves / 0 fallbacks
against 424 / 8885 / 108 for the active set; the 4.6x lift gas meets
the deck's LIFTOPT gradient (0.036 vs 5e-3), the active set's does not.

Carrying a network shut across the well model's iterations was tried
per report step and per time step; both chop the step (AUTOCHK 27 ->
74-88 time steps). Kept behind OPM_NETWORK_CARRY_SHUT, off.

Tests: complementarity_shuts_dead_wells (a dying well, a choke over dead
wells) and the_dumps_behind_the_complementarity_fixes_converge (four
AUTOCHK dumps under tests/network_dumps, OPM_VFP_INCLUDE); both fail on
2e318a256.
The default path gives a well the well model has at zero rate bhp
control and produces from it. Asserted as it is, so a fix flips the
test instead of passing by.
Anderson acceleration (--network-pressure-update-acceleration,
--network-anderson-depth) and the well-index proxy balance
(--network-well-proxy, --network-well-proxy-max-iterations) were both added
measured and off by default. Neither is worth carrying further.

Anderson is better than the per-node bracketing update where the response is
smooth and much worse where it is not: on GNETINJE_WAT-01 it takes 3
unconverged network steps to 0, on GNETINJE_GAS-01 it takes 0 to 18 and fails
the report-step comparison with E100 that bracketing passes. It replaces the
bracket, the step cap and the plateau rule with unsafeguarded extrapolation,
which is exactly what the control switches punish. A safeguarded variant would
be new code, not this code.

The proxy balance is superseded: it linearised each injector against its
implicit IPR and relaxed the node pressures against that, and the same
linearisation is now solved simultaneously with the pressures in
NetworkSystem.hpp, which does converge. On its own it drove every injector to
zero rate two thirds of the way through both runs (E100 violations 44/1 ->
620/646).

What that commit fixed in updateIPRImplicit stays: it was producer-only by an
accident of the hard-coded control swap, and the simultaneous solve needs the
injector side of it.

Numbers and the full write-up are in injection_network_findings.md; the code
is on the wip/network-2026-08-24 tag.

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 2 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>
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