Deriving envelopes & relaxations with SymPy#
discopt can derive tight convex/concave envelopes for nonlinear atoms
symbolically, verify their soundness, and code-generate pure-JAX
relaxation closures. It is a design tool for studying an atom’s envelope before it
is implemented in the solver: it extends the McCormick construction
[McCormick, 1976, Tsoukalas and Mitsos, 2014] to terms arising in gas-network and
chemical-engineering optimization.
The toolkit lives in discopt._relax.symbolic and is a design-time dependency
(pip install 'discopt[sympy,jax]'): SymPy derives the formulas, and the generated
closures run on jax.numpy.
Note
No solver path runs these closures. The default relaxation engine is numpy and
relaxes each atom with its own envelopes (inventoried in
docs/design/relaxation-catalog.md); the McCormick-NLP route that generated closures
and the hand-written domain packs used to plug into was retired in #1873, along with
those packs, their registry and the learned-envelope prototype. What remains is the
derive -> code-generate -> verify loop shown here. A default solve imports no jax
module; this notebook does, because the generated closures are JAX.
Soundness — cv(x) <= f(x) <= cc(x) with cv convex and cc concave — is the
non-negotiable invariant, checked for every atom.
1. Deriving a univariate envelope#
For a function f(x) on a box [a, b], the engine classifies the curvature and
derives the envelope. For an odd cubic x^3 (concave then convex through an
inflection at 0) it finds the convex-underestimator tangent point in closed form.
import jax
jax.config.update('jax_enable_x64', True)
import sympy as sp
from discopt._relax.symbolic import derive_envelope, lambdify_envelope, verify_envelope
x = sp.Symbol('x', real=True)
res = derive_envelope(x**3, x)
print('curvature :', res.curvature.value)
print('inflection point :', res.inflection)
print('cv tangent point :', res.cv_tangent.point) # closed form in the lower bound a
curvature : concavo_convex
inflection point : 0
cv tangent point : -a/2
lambdify_envelope turns the result into a JAX closure (x, lb, ub) -> (cv, cc),
and verify_envelope certifies soundness over many randomized boxes.
relax = lambdify_envelope(res)
report = verify_envelope(relax, lambda t: t**3, domain=(-2.0, 2.0), n_boxes=400)
print('sound :', report.sound)
print('max lower viol :', f'{report.max_lower_violation:.2e}')
print('max upper viol :', f'{report.max_upper_violation:.2e}')
print('mean gap (tight) :', f'{report.mean_gap:.3f}')
sound : True
max lower viol : 0.00e+00
max upper viol : 0.00e+00
mean gap (tight) : 0.687
2. Transcendental atoms: numeric tangent solving#
When the tangent equation has no closed form (e.g. a sigmoid), code-gen solves
the tangent point per box with a JAX bisection baked into the closure — still
jit/vmap-compatible, still sound.
sig = 1 / (1 + sp.exp(-x))
res_sig = derive_envelope(sig, x)
print('curvature :', res_sig.curvature.value)
print('closed form :', res_sig.cv_tangent.point) # None -> numeric bisection
relax_sig = lambdify_envelope(res_sig)
rep = verify_envelope(relax_sig, lambda t: 1/(1+jax.numpy.exp(-t)), domain=(-5.0, 5.0))
print('sound :', rep.sound)
curvature : convexo_concave
closed form : None
sound : True
3. Domain atoms, derived from their formulas#
The same three calls handle atoms from application domains. Steady-state gas flow couples squared nodal pressures to flow through the Weymouth term \(f\,|f| = \operatorname{sign}(f) f^2\), concave for \(f<0\) and convex for \(f>0\) — a single-inflection atom. The Arrhenius rate \(\exp(-E/RT)\) and Langmuir/Monod saturation \(x/(K+x)\) come from reaction engineering.
import jax.numpy as jnp
atoms = [
("Weymouth f|f|", x * sp.Abs(x), lambda t: t * jnp.abs(t), (-10.0, 10.0)),
("Arrhenius, E/R=6000", sp.exp(-6000 / x), lambda t: jnp.exp(-6000.0 / t), (250.0, 900.0)),
("saturation, K=1", x / (1 + x), lambda t: t / (1.0 + t), (0.0, 20.0)),
]
for name, expr, f, dom in atoms:
r = derive_envelope(expr, x)
rep = verify_envelope(lambdify_envelope(r), f, domain=dom, n_boxes=200)
print(f"{name:22s} curvature={r.curvature.value:15s} sound={rep.sound} mean_gap={rep.mean_gap:.3f}")
Weymouth f|f| curvature=concavo_convex sound=True mean_gap=7.749
Arrhenius, E/R=6000 curvature=convex sound=True mean_gap=0.000
saturation, K=1 curvature=concave sound=True mean_gap=0.032
The derivation is honest about its limits: an atom with more than one inflection on the domain (for example \(\sin\) or \(\cos\) over a wide angle box, the trigonometric terms of AC power flow [Coffrin et al., 2016, Kröger et al., 2018]) is refused rather than given an envelope it cannot certify.
from discopt._relax.symbolic.envelope_deriver import EnvelopeDerivationError
try:
derive_envelope(sp.cos(x), x)
except EnvelopeDerivationError as exc:
print("refused:", exc)
refused: cos(x) has 2 inflection points; multi-inflection envelopes are handled by a later phase
Summary#
The SymPy engine derives convex/concave envelopes, code-generates them as JAX closures (
codegen.pyover the sympy-freeruntime.py), andverify_envelopecertifies soundness over randomized boxes.Single-inflection atoms use a tangent/secant construction with a closed-form or numeric tangent point and an automatic secant fallback on asymmetric boxes; multi-inflection atoms are refused.
It is a design tool: no solver path runs the generated closures. The solver’s own envelopes are numpy, listed in
docs/design/relaxation-catalog.md.
See design/symbolic-envelopes-plan.md for the original roadmap.