Gas transmission networks as differentiable flowsheets (difflow_gas)

Gas transmission networks as differentiable flowsheets (difflow_gas)#

This example walks through the difflow_gas plugin on a small meshed network: define the network, let the plugin compute the sequential decomposition from the topology, solve the tears, verify against the full equation set, differentiate through the converged solution, and use the gradients in a small compressor-power optimization.

The physics is the benchmark-standard steady-state isothermal model: squared-pressure Weymouth pipes (\(p_f^2 - p_t^2 = \beta\, q\,|q|\), \(q\) in kg/s, signed), compressor stations as controllable pressure ratios, and a nomination that fixes every boundary flow, with one slack node providing the pressure level.

import jax
import jax.numpy as jnp

import difflow_gas as dg

# a source feeding two sinks through a compressor and a looped grid
net = dg.GasNetwork(
    arcs={
        "p1":  ("src", "a", "pipe"),
        "cs1": ("a", "b", "compressor"),
        "p2":  ("b", "c", "pipe"),
        "p3":  ("b", "d", "pipe"),
        "p4":  ("c", "d", "pipe"),   # closes the loop b-c-d-b
    },
    beta={aid: dg.weymouth_beta(length_m=L, diameter_m=0.6,
                                roughness_m=1e-4)
          for aid, L in [("p1", 20e3), ("p2", 40e3),
                         ("p3", 60e3), ("p4", 80e3)]},
    supply_kg_s={"src": 120.0, "c": -50.0, "d": -70.0},
    pressure_bounds_bar={n: (30.0, 80.0)
                         for n in ["src", "a", "b", "c", "d"]},
)
print(f"{len(net.nodes)} nodes, {len(net.arcs)} arcs, "
      f"cycle rank {net.cycle_rank}")
5 nodes, 5 arcs, cycle rank 1

The computed decomposition#

decompose picks a spanning tree (compressors and other non-invertible arcs forced in-tree; the most resistive pipe of each loop becomes the chord/tear), then schedules leaf-to-root mass balances and root-to-leaf pressure propagation. One tear per loop.

dec = dg.decompose(net, root="src")
print("tree arcs :", dec.tree_arc_ids)
print("chords    :", dec.chord_ids)
print("BFS order :", dec.order)
tree arcs : ['cs1', 'p1', 'p2', 'p3']
chords    : ['p4']
BFS order : ['src', 'a', 'b', 'c', 'd']

Build and solve#

The builder translates the schedule into difflow units. Two solvers: Anderson-accelerated tears (eager, robust) and a damped fixed-point iteration that is jax.jit- and jax.grad-safe (gas tear maps have negative eigenvalues, so the raw map oscillates; damping \(x \leftarrow x + \alpha(g(x) - x)\) makes it contract while implicit differentiation keeps gradients exact).

fs, dec = dg.build_network_flowsheet(
    net, root="src", p_slack_pa=60e5, ratios={"cs1": 1.3}, dec=dec,
)
streams = fs.solve(tol=1e-8, max_iter=200)   # Anderson, signed flows
print("tear iterations:", fs.last_solve_iterations)

q = dg.verify.arc_flows_kg_s(streams, dec)
p = dg.verify.node_pressures_bar(streams, dec)
print({k: round(v, 3) for k, v in q.items()})
print({k: round(v, 3) for k, v in p.items()})
tear iterations: 46
{'p1': 120.0, 'cs1': 120.0, 'p2': 64.612, 'p3': 55.388, 'p4': 14.612}
{'src': 60.0, 'a': 51.624, 'b': 67.111, 'c': 62.942, 'd': 62.5}

Verification#

A sequential solve satisfies most equations by construction; the meaningful check evaluates every equation-oriented residual (all nodal balances, every pipe law) on the solved state.

rep = dg.residual_report(streams, net, dec)
print(f"max node imbalance : {rep.max_node_imbalance_kg_s:.2e} kg/s")
print(f"max pipe residual  : {rep.max_resistance_residual_bar2:.2e} bar^2")
assert rep.ok
max node imbalance : 3.85e-13 kg/s
max pipe residual  : 9.09e-13 bar^2

Exact gradients through the converged tears#

obj = fs.make_objective_fn(
    lambda s: dg.total_compressor_power_w(s, dec, net.gas_temp_k))

params = {"cs_cs1.ratio": 1.3, "src_src.P_set": 60e5}
g = jax.grad(obj)(params)
print(f"W                = {float(obj(params))/1e6:.3f} MW")
print(f"dW/dratio        = {float(g['cs_cs1.ratio'])/1e6:.3f} MW per ratio")
print(f"dW/dp_slack      = {float(g['src_src.P_set'])*1e5:.1f} W per bar")
W                = 5.832 MW
dW/dratio        = 17.622 MW per ratio
dW/dp_slack      = -0.0 W per bar

A reduced-space optimization#

Minimize shaft power over the compressor ratio subject to a delivery pressure requirement at sink d. Following the GasLib benchmark lesson, the constraint is posed in squared pressure, where the network response is nearly linear (in plain pressure the sqrt makes low-pressure constraints violently nonlinear near their bounds and SQP steps overshoot).

import numpy as np
from scipy.optimize import minimize

P_MIN_D = 55.0  # bar, required delivery pressure at d


def solve_at(ratio):
    return fs._apply_params({"cs_cs1.ratio": ratio}).solve_differentiable()


power_mw = jax.jit(jax.value_and_grad(
    lambda r: dg.total_compressor_power_w(
        solve_at(r), dec, net.gas_temp_k) / 1e6))
margin = jax.jit(lambda r: (solve_at(r)["node_d"]["P"] / 1e5) ** 2
                 - P_MIN_D**2)
margin_grad = jax.jit(jax.grad(margin))


def fun(x):
    v, g = power_mw(x[0])
    return float(v), np.atleast_1d(np.asarray(g))


res = minimize(
    fun,
    x0=[1.05], jac=True, method="SLSQP", bounds=[(1.0, 2.0)],
    constraints=[{
        "type": "ineq",
        "fun": lambda x: np.atleast_1d(np.asarray(margin(x[0]))),
        "jac": lambda x: np.atleast_2d(np.asarray(margin_grad(x[0]))),
    }],
    options={"ftol": 1e-8},
)
r_opt = float(res.x[0])
s_opt = solve_at(r_opt)
print(f"optimal ratio = {r_opt:.6f}, "
      f"W = {float(res.fun)*1e6/1e6:.4f} MW, "
      f"p(d) = {float(s_opt['node_d']['P'])/1e5:.4f} bar")
assert res.success
optimal ratio = 1.165896, W = 3.3690 MW, p(d) = 55.0000 bar

The optimizer pushes the ratio to the point where the delivery constraint is exactly active: the classic shape of gas network power minimization (the same structure the GasLib-40 study found, where one station held a terminal sink at its lower bound and all others idled).