Operability and Controllability Screening#
difflow.operability asks a question a steady-state flowsheet can answer but is
almost never asked of it: do the manipulated variables have enough independent
influence on the controlled variables to hold them against the disturbances the
plant will actually see?
Everything follows from two Jacobians of a converged flowsheet,
which are one jax.jacobian call each. Obtained the usual way — finite
differences through a sequential-modular simulator — they cost 2n re-solves
and carry the simulator’s convergence tolerance as noise. That expense is why
controllability screening is normally done on a linear model fitted separately
from the design model, after the design is frozen. With AD the gains are exact,
cost a constant multiple of one model evaluation, and are a differentiable
function of the design variables, so the screen can live inside the design
loop rather than after it.
Table of Contents#
Quick start#
import jax.numpy as jnp
from difflow.operability import Scaling, screen
# The LV distillation column of Skogestad and Postlethwaite, Example 3.11.
def column(u, d):
L, V = u
return jnp.array([0.878 * L - 0.864 * V + 0.394 * d[0],
1.082 * L - 1.096 * V + 0.586 * d[0]])
sc = Scaling(u_span=[1.0, 1.0], # available move in reflux and boilup
y_span=[0.01, 0.01], # 1% composition is the largest
# acceptable control error
d_span=[0.2], # the feed wanders by 20%
note="LV column, 1% composition tolerance")
rep = screen(column, jnp.zeros(2), jnp.zeros(1), scaling=sc,
u_names=["L", "V"], y_names=["x_D", "x_B"], d_names=["feed"])
print(rep.summary())
operability screen: 2 outputs, 2 inputs, 1 disturbances
scaling: LV column, 1% composition tolerance
sigma_min(G) = 1.391 cond(G) = 141.7 rank(G) = 2/2
RGA number = 136.3 (pairing x_D-L, x_B-V)
RGA:
L V
x_D 35.07 -34.07
x_B -34.07 35.07
scaled disturbance gain (|.| > 1 needs control):
feed
x_D 7.88
x_B 11.72
per disturbance: feed: gamma_d=11.7, max input move=0.643
4 findings (0 error, 4 warning)
[warning] directional: plant: condition number 142 > 10: ...
[warning] rga_interaction: plant: largest paired relative gain is 35.1 > 5: ...
[warning] disturbance_gain: feed: scaled disturbance gain 11.7 > 1: ...
[warning] disturbance_direction: feed: disturbance condition number 11.7 > 10: ...
lambda_11 = 35.1 and cond(G) = 141.7 are the numbers quoted for this column
in the reference; tests/test_operability.py pins both.
Scaling comes first#
Every measure in this package except the RGA is a statement about magnitudes,
and a magnitude in mixed engineering units is not a magnitude at all. A gain of
1e5 K/(mol/s) and a gain of 0.02 mol/mol cannot be compared, ranked, or
handed to an SVD — but jax.jacobian returns exactly that matrix, and the SVD
will happily return a number for it. This is where these metrics are misused,
and it is why screen has no default scaling and will not run without one.
Scaling takes three engineering judgements, in the convention of Skogestad and
Postlethwaite section 1.4:
Span |
Meaning |
Common mistake |
|---|---|---|
|
The largest change in each manipulated variable that is actually available — valve shut to open, usable duty turndown |
Using the full design range when the plant sits at one end of it |
|
The largest acceptable control error in each controlled variable |
Using the operating value, or the measurement noise, instead of the deviation at which the loop has failed |
|
The largest expected excursion of each disturbance |
Using the nominal value rather than the excursion |
With Du = diag(u_span), De = diag(y_span) and Dd = diag(d_span),
and every entry of both is dimensionless with the same meaning: how many
allowable control errors of output i does a full move of input (or
disturbance) j produce. That is what makes 1 the threshold every rule of
thumb is stated against.
Three constructors:
import jax.numpy as jnp
from difflow.planning import Block
Scaling(u_span=[1.0, 1.0], y_span=[0.01, 0.01], d_span=[0.5]) # the honest one
Scaling.from_bounds([0.0, 0.0], [1.0, 1.0], [0.01, 0.01], # from operating bounds
d_lb=[-0.5], d_ub=[0.5])
planning_block = Block("column", lambda u: jnp.array([u[0] - u[1], u[0] + u[1]]),
u_names=["L", "V"], y_names=["x_D", "x_B"],
lb=[0.0, 0.0], ub=[1.0, 1.0])
Scaling.from_block(planning_block, y_tol=[0.01, 0.01]) # reuse a planning Block
Scaling.unscaled(2, 2) # the recorded refusal
Scaling.unscaled is the explicit opt-out — use it only when the variables are
already dimensionless. It stamps the report scaled=False, prints a caveat
banner, adds an unscaled finding, and suppresses every threshold comparison
that would otherwise be meaningless. Calling a metric with no Scaling at all
raises an OperabilityWarning; assume_scaled=True is the silent, deliberate
way to say the matrix is already dimensionless.
The measures#
Relative gain array#
rga(G) returns G * pinv(G).T elementwise. Entry [i, j] is the ratio of the
open-loop gain from input j to output i to the gain that remains when every
other loop is perfectly controlled:
1 — the pairing is unaffected by the other loops.
0 — input
jdoes nothing for outputionce the others close.large positive — the loops fight each other and the pairing is very sensitive to model error (
|RGA| > 5raisesrga_interaction).negative — the gain changes sign when the other loops close. Pairing here gives a system that is unstable with all loops closed, with that loop alone, or whenever another loop saturates. Under integral control that is structural, not a tuning problem, and it is reported as an error.
The RGA is invariant to diagonal input and output scaling, so it is the one
measure here that may be read off a raw AD Jacobian. negative_pairings,
rga_number (distance from the pairing permutation) and suggest_pairing
(greedy, positive relative gains nearest 1) all read from it.
Singular values#
min_singular_value(G, scaling) is the smallest output move the inputs can
produce per unit input move, over all directions — the plant’s worst
direction, not its typical one. In the scaling convention above the threshold
is 1: sigma_min >= 1 says that in every direction the available inputs can
cover the range that has to be covered. Below 1 there is a direction in which
they cannot, and no controller design recovers gain the steady state does not
have.
condition_number(G, scaling) is sigma_max / sigma_min — how directional
the plant is. Above roughly 10 the plant responds strongly to some input
combinations and weakly to others; that is not necessarily hard to control, but
it is hard to control with decentralised loops and it is sensitive to model
error in the weak direction. Unlike the RGA it is not scaling-invariant,
which is a reason to state the scaling, not a reason to distrust the measure.
Disturbances#
disturbance_gain(model, u0, d0, scaling=sc) is the most directly useful matrix
in the package once scaled: entry [i, k] is how many allowable control errors
of output i a full-size excursion of disturbance k produces. Below 1 is a
disturbance the process absorbs on its own; above 1 is one control has to
reject.
Two further questions then matter, and neither is visible in the gain alone:
required_input_move(G, Gd)=pinv(G) @ Gd. Entry[j, k]is the fraction of inputj’s available range needed to cancel a full excursion of disturbancek. Greater than 1 means the plant cannot reject it, whatever the controller (disturbance_infeasible, an error).disturbance_condition_number(G, Gd)=sigma_max(G) * ||pinv(G) y_d||withy_dthe unit vector along the disturbance’s output direction. It measures alignment and lies between 1 (the disturbance pushes exactly where the plant is strongest) andcond(G)(exactly where it is weakest). A largegamma_don a disturbance whose scaled gain is under 1 is harmless. A largegamma_don one whose gain exceeds 1 is the combination that makes a design uncontrollable, and it is invisible to either measure by itself.
Reading a screen#
screen returns an OperabilityReport. Its numeric fields (G, Gd, RGA,
svals, msv, cond, rga_num, rga_pairs, rank, dist_cond,
u_required) are JAX arrays, and the class is a registered pytree. Its
interpretation is computed lazily and needs concrete values:
rep.findings # list[Finding], errors first
rep.errors, rep.warnings
rep.ok # True when nothing was flagged
rep.summary() # the text above
rep.warn() # re-emit every finding as an OperabilityWarning
rep.suggested_pairing() # [('x_D', 'L'), ('x_B', 'V')]
Finding is the same dataclass difflow.planning.health uses, and the
reporting style is deliberately the same: each finding names the measured value
and the remedy, and nothing raises during a solve.
Finding |
Severity |
Trigger |
|---|---|---|
|
error |
A non-finite entry in |
|
error |
|
|
error |
Fewer inputs than outputs |
|
warning |
More inputs than outputs; steady-state freedom left over |
|
error |
A paired relative gain is negative |
|
warning |
Largest paired |RGA| > 5 |
|
warning |
Scaled |
|
warning |
|
|
error |
Rejecting a disturbance needs more than an input’s full range |
|
warning |
Scaled |Gd| > 1: must be rejected by control, not absorbed |
|
warning |
A significant disturbance with |
|
warning |
The report was built on unit spans |
On a real flowsheet#
screen takes any pure JAX callable fn(u) -> y or fn(u, d) -> y. A difflow
flowsheet qualifies: its flash, recycle and unit solves are implicitly
differentiated, so what comes back is the converged steady-state gain, not a
gain through one Newton iteration.
import jax.numpy as jnp
from difflow import (CSTR, CSTRParams, IdealThermo, SpeciesData, get_flows,
make_stream)
from difflow.units.flash import Flash, FlashParams
from difflow.operability import Scaling, screen
thermo = IdealThermo({
"Light": SpeciesData(name="Light", MW=72.0, Cp_coeffs=(120.0, 0.0, 0.0, 0.0),
Hvap_coeffs=(26000.0, 0.38, 470.0),
antoine_coeffs=(10.422, 1687.537, -38.44), Hf=0.0),
"Heavy": SpeciesData(name="Heavy", MW=114.0, Cp_coeffs=(190.0, 0.0, 0.0, 0.0),
Hvap_coeffs=(35000.0, 0.38, 570.0),
antoine_coeffs=(10.186, 2004.68, -60.53), Hf=0.0),
})
def rate_fn(C, T, params):
k = params["A"] * jnp.exp(-params["Ea"] / (8.314 * T))
return jnp.array([k * C["Light"]])
cstr = CSTR(CSTRParams(V=jnp.asarray(2.0), rate_fn=rate_fn,
stoich=jnp.array([[-1.0], [1.0]]),
rate_params={"A": jnp.array(1e3), "Ea": jnp.array(50000.0)},
species_order=["Light", "Heavy"]),
thermo=thermo, mode="isothermal")
flash = Flash(FlashParams(species_order=["Light", "Heavy"]), thermo=thermo)
def fn(u, d):
inlet = make_stream({"Light": d[0], "Heavy": 0.1}, T=320.0, P=101325.0)
reacted, _ = cstr(inlet, T_spec=u[0]) # inner steady-state solve
liquid, vapor, _ = flash(reacted, T=u[1], P=101325.0) # Rachford-Rice
return jnp.array([get_flows(vapor)["Light"], get_flows(liquid)["Heavy"]])
sc = Scaling(u_span=[10.0, 10.0], # 10 K of usable swing on each unit
y_span=[0.2, 0.2], # 0.2 mol/s of product flow
d_span=[1.0]) # the feed wanders by 1 mol/s
rep = screen(fn, jnp.array([350.0, 380.0]), jnp.array([10.0]), scaling=sc,
u_names=["T_reactor", "T_flash"],
y_names=["light_vapor", "heavy_liquid"], d_names=["feed"])
sigma_min(G) = 0.9895 cond(G) = 9.973 rank(G) = 2/2
RGA number = 8.18 (pairing light_vapor-T_reactor, heavy_liquid-T_flash)
RGA:
T_reactor T_flash
light_vapor -1.045 2.045
heavy_liquid 2.045 -1.045
4 findings (3 error, 1 warning)
[error] rga_negative: light_vapor-T_reactor: relative gain -1.05 is negative ...
[error] rga_negative: heavy_liquid-T_flash: relative gain -1.05 is negative ...
[error] disturbance_infeasible: feed: rejecting a full-size excursion of 'feed'
needs 2.57 times the available range of 'T_flash' ...
[warning] weak_direction: plant: sigma_min of the scaled gain is 0.989 < 1 ...
Two structural results, neither of which a steady-state simulation reports on its own: the obvious pairing is sign-reversed under closed loop and must be swapped, and a 1 mol/s feed excursion cannot be rejected with ±10 K on the two temperatures. Both are conclusions about the flowsheet, not about a controller.
These gains are verified against central differences through the inner solves in
tests/test_operability.py::test_gain_matrix_matches_central_differences — the
AD Jacobian and a perturbation study agree to better than 1e-6 relative.
Inside a design loop#
This is the point of the module. screen is a pure function of the design
variables: two jax.jacobian calls and a handful of SVDs, with no Python
branching on the numbers. So it is jit-, vmap- and grad-safe, and a
controllability term can go straight into an economic objective:
import jax
u0, d0 = jnp.array([350.0, 380.0]), jnp.array([10.0])
w = 10.0
profit = lambda design: -0.01 * design ** 2 # stand-in economics
def build_flowsheet(design): # design = a reactor volume scale
return lambda u, d: fn(u, d) * design
def objective(design):
rep = screen(build_flowsheet(design), u0, d0, scaling=sc)
penalty = jax.nn.relu(1.0 - rep.msv) # want sigma_min >= 1
return -profit(design) + w * penalty ** 2
grad_obj = jax.grad(objective) # differentiates *through* the SVD
grad_obj(jnp.asarray(1.0))
and a whole batch of candidate designs can be screened at once, because the report is a pytree:
U = jnp.array([[350.0, 380.0], [345.0, 375.0], [355.0, 385.0]]) # candidate operating points
reports = jax.jit(jax.vmap(lambda u: screen(fn, u, d0, scaling=sc)))(U)
reports.msv.shape # (n_candidates,)
This makes it possible to ask whether the economically optimal structure is also a controllable one — the central question of integrated design and control, normally too expensive to pose.
Two practical cautions:
Singular values are differentiable only where they are distinct, and the two families of measures fail differently there.
min_singular_value,condition_numberandsingular_valuesusecompute_uv=Falseand stay finite: at a crossingsigma_minhas a kink and AD silently returns one arm’s slope, so an optimiser stalls rather than diverges. Anything needing the singular vectors —pinv, and thereforerga,required_input_moveanddisturbance_condition_number— carries a1 / (s_i^2 - s_j^2)term and returnsnanat an exactly repeated singular value. Prefermsvas the quantity you differentiate. A plant with two exactly equal gain directions is contrived; a symmetric test case reaches it.rep.findings,rep.summary()andrep.okneed Python branching on the values, so they raise insidejit/vmap. Pullmsv,cond,RGAout of the trace and interpret them afterwards.
Non-square and singular plants#
Nothing here requires a square G; the pseudo-inverse gives the non-square RGA
of Chang and Yu. Exactly one of the two sum rules survives, and which one is
determined by the shape — row sums are diag(G G+) and column sums
diag(G+ G):
Shape |
||
|---|---|---|
Square, nonsingular |
rows sum to 1 |
columns sum to 1 |
Wide ( |
rows sum to 1 |
columns do not |
Tall ( |
rows do not |
columns sum to 1 |
For a wide plant a small column entry means that input carries little of the job, not that it is a bad pairing. For a tall plant a row summing to well under 1 names an output that no input combination really controls.
A rank-deficient G does not raise. It produces an RGA whose rows do not sum to
1 — which is itself the diagnosis — plus a singular error finding, and
min_singular_value goes to zero and condition_number to infinity, which are
the honest answers. The pseudo-inverse uses an explicit relative cutoff
(RCOND, 8 * eps by default, exposed as an rcond argument everywhere) so an
exactly singular plant yields finite numbers rather than nan.
What this does not tell you#
Steady state only. A design that passes this screen can still be undone by right-half-plane zeros, dead time, actuator dynamics or a bandwidth limit. What the screen gives is the other half of the implication: a design that fails it cannot be rescued by any controller, which is what makes it worth running first and worth running early.
Pairing suggestions are suggestions. suggest_pairing is a greedy rule on
the steady-state RGA and ignores dynamics entirely — the standing limitation of
every steady-state pairing rule.
One operating point. G and G_d are local. A plant whose gains change
sign over the operating envelope needs the screen run at several points; that is
cheap here, and vmap is the way to do it.
Thresholds are conventions. MSV_TOL = 1, COND_TOL = 10, RGA_TOL = 5
and GD_TOL = 1 are the usual rules of thumb, exported so they can be read and
argued with. Only the first and last are pinned by the scaling convention; the
middle two are judgement.
API summary#
Object |
Purpose |
|---|---|
|
The three spans that make every measure dimensionless |
|
Constructors; the last is the recorded refusal to scale |
|
Apply and invert the scaling |
|
|
|
|
|
|
|
Distance of the RGA from a pairing permutation |
|
Pairings whose relative gain is negative |
|
Greedy pairing on positive relative gains near 1 |
|
The plant’s worst and best directions |
|
Directionality of the scaled gain |
|
Rank and pseudo-inverse with an explicit |
|
|
|
Alignment of each disturbance with the plant’s strong direction |
|
All of the above in one traceable call |
|
The result; pytree of arrays plus |
|
Raised when a measure is requested in a way that makes it misleading |
|
The thresholds, exported to be argued with |
See also difflow.planning.health.check_delta_health, which applies the same
reporting pattern to the delta vectors of a planning LP. The two are asking
related questions of the same Jacobians.
References#
Bristol, E. H. On a new measure of interaction for multivariable process control. IEEE Trans. Automatic Control 11(1), 133–134, 1966. doi:10.1109/TAC.1966.1098266
Morari, M. Design of resilient processing plants — III. A general framework for the assessment of dynamic resilience. Chem. Eng. Sci. 38(11), 1881–1891, 1983. doi:10.1016/0009-2509(83)85044-1 (Part I, which introduces the resilience framing, is Chem. Eng. Sci. 37(2), 245–258, 1982, doi:10.1016/0009-2509(82)80159-0.)
Skogestad, S. and Postlethwaite, I. Multivariable Feedback Control: Analysis and Design, 2nd ed. Wiley, 2005. Sections 1.4, 3.5, 6.10 and chapter 10.
Chang, J.-W. and Yu, C.-C. The relative gain for non-square multivariable systems. Chem. Eng. Sci. 45(5), 1309–1323, 1990. doi:10.1016/0009-2509(90)87123-a
Sakizlis, V., Perkins, J. D. and Pistikopoulos, E. N. Recent advances in optimization-based simultaneous process and control design. Comput. Chem. Eng. 28(10), 2069–2086, 2004. doi:10.1016/j.compchemeng.2004.03.018
Yuan, Z., Chen, B., Sin, G. and Gani, R. State-of-the-art and progress in the optimization-based simultaneous design and control for chemical processes. AIChE Journal 58(6), 1640–1659, 2012. doi:10.1002/aic.13786