Simple Example

This page walks through a simple example of using the Dulmage-Mendelsohn partition to debug a structural singularity.

Dulmage-Mendelsohn

We start with some imports and by creating a JuMP model we are interested in. Usually the model we are interested in debugging is much larger and more complicated than this. This particular system appeared when debugging a dynamic 1-D partial differential-algebraic equation (PDAE) model representing a chemical looping combustion reactor.

using JuMP
import MathProgIncidence

m = Model()
comps = [1, 2, 3]
@variable(m, x[comps], start=1/3.0)
@variable(m, flow_comp[comps], start=10.0)
@variable(m, flow, start=30.0)
@variable(m, rho, start=1.0)

@constraint(m, sum_comp_eqn, sum(x) == 1)
@constraint(m, comp_dens_eqn, x*rho .== [1.0, 1.1, 1.2])
@NLconstraint(m, bulk_dens_eqn, 1/rho - sum(1/x[j] for j in comps) == 0)
@constraint(m, comp_flow_eqn, x.*flow .== flow_comp)

To check this model for structural singularity, we apply the Dulmage-Mendelsohn partition.

igraph = MathProgIncidence.IncidenceGraphInterface(m)
con_dmp, var_dmp = MathProgIncidence.dulmage_mendelsohn(igraph)

If any variables or constraints are unmatched, the (Jacobian of the) model is structurally singular.

println("Unmatched constraints")
println("---------------------")
for con in con_dmp.unmatched
    println("  $con")
end
println("Unmatched variables")
println("-------------------")
for var in var_dmp.unmatched
    println("  $var")
end
Unmatched constraints
---------------------
(1.0 / rho - (1.0 / x[1] + 1.0 / x[2] + 1.0 / x[3])) - 0.0 = 0
Unmatched variables
-------------------
flow_comp[1]

This model has one unmatched constraint and one unmatched variable, so it is structurally singular. However, the unmatched constraint and variable are not unique. For example, flow_comp[2] could have been unmatched instead of flow_comp[1].

Unique subsets of variables and constraints that are useful when debugging a structural singularity are the underconstrained and overconstrained subsystems. The variables in the underconstrained subsystems are contained in the unmatched and underconstrained fields, while the constraints are contained in the underconstrained field. The variables in the overconstrained subsystem are contained in the overconstrained field, while the constraints are contained in the overconstrained and unmatched fields.

We now construct the underconstrained and overconstrained subsystems.

oc_con = cat(con_dmp.overconstrained, con_dmp.unmatched, dims = 1)
oc_var = var_dmp.overconstrained
uc_con = con_dmp.underconstrained
uc_var = cat(var_dmp.unmatched, var_dmp.underconstrained, dims = 1)

And display the constraints and variables contained in each.

println("Overconstrained subsystem")
println("-------------------------")
println("Constraints")
for con in oc_con
    println("  $con")
end
println("Variables")
for var in oc_var
    println("  $var")
end
println()

println("Underconstrained subsystem")
println("--------------------------")
println("Constraints")
for con in uc_con
    println("  $con")
end
println("Variables")
for var in uc_var
    println("  $var")
end
Overconstrained subsystem
--------------------------
Constraints
  sum_comp_eqn : x[1] + x[2] + x[3] = 1.0
  comp_dens_eqn : x[1]*rho = 1.0
  comp_dens_eqn : x[2]*rho = 1.1
  comp_dens_eqn : x[3]*rho = 1.2
  (1.0 / rho - (1.0 / x[1] + 1.0 / x[2] + 1.0 / x[3])) - 0.0 = 0
Variables
  x[1]
  rho
  x[2]
  x[3]

Underconstrained subsystem
--------------------------
Constraints
  comp_flow_eqn : x[1]*flow - flow_comp[1] = 0.0
  comp_flow_eqn : x[2]*flow - flow_comp[2] = 0.0
  comp_flow_eqn : x[3]*flow - flow_comp[3] = 0.0
Variables
  flow_comp[1]
  flow
  flow_comp[2]
  flow_comp[3]

At this point we must use our intuition about the system being modeled to identify "what is causing" the singularity. Looking at the under and over-constrained systems, it appears that we are missing an equation to calculate flow, the total flow rate, and that rho, the bulk density, is over-specified as it is computed by both the bulk density equation and one of the component density equations.

With this knowledge, we can eventually figure out that (a) we need an equation to calculate flow from density, and that (b) our "bulk density equation" is actually a skeletal density equation. Admittedly, this is difficult to figure out without the full context behind this particular system.

The following script constructs a new version of the model and checks it for structural singularity:

using JuMP
import MathProgIncidence

m = Model()
comps = [1, 2, 3]
@variable(m, x[comps], start=1/3.0)
@variable(m, flow_comp[comps], start=10.0)
@variable(m, flow, start=30.0)
@variable(m, rho_bulk, start=1.0)
@variable(m, rho_skel, start=1.0)
@variable(m, porosity, start=0.25)
velocity = 1.0

@constraint(m, sum_comp_eqn, sum(x) == 1)
@constraint(m, comp_dens_eqn, x*rho_bulk .== [1.0, 1.1, 1.2])
@NLconstraint(m, skel_dens_eqn, 1/rho_skel - sum(1/x[j] for j in comps) == 0)
@constraint(m, bulk_dens_eqn, rho_bulk == (1 - porosity)*rho_skel)
@constraint(m, flow_eqn, flow == velocity * rho_bulk)
@constraint(m, comp_flow_eqn, x.*flow .== flow_comp)

igraph = MathProgIncidence.IncidenceGraphInterface(m)
con_dmp, var_dmp = MathProgIncidence.dulmage_mendelsohn(igraph)
@assert isempty(con_dmp.unmatched) && isempty(var_dmp.unmatched)