Build combined model from individual jump models

Suppose I have a two stage problem where I expect the user to provide (1) a first-stage model written in JuMP and (2) a function to evaluate the recourse. If the user’s recourse function evaluates subproblems also written in JuMP, then I would like to formulate the extensive form problem by copying the first-stage model and then copying variables/constraints/objectives from the second stage into the first stage.

Right now, I am able to copy objectives of models 1...n into a single model. But I am having trouble doing the same for the constraints. My attempt (below; initially here) was to collect the expressions of variables/objectives/constraints and try to remap them.

I’ve been able to extract the objectives as expressions and remap variables from the collection of models to a new monolithic model. However, I have trouble on constraints. E.g., if a model i has @variable(modeli, y[1] >=0) and @constraint(modeli, y[1] >= 1), my method is not sure how to handle this.

Is there a nice way of doing this for constraints? Or should I consider a different API for solving the extensive form model?

MWE:

using JuMP, OrderedCollections
#=
start utilities --------------------------------------------------------------------------------------------------------------
=#
function substitute_variables(expr, mapping::Dict{JuMP.VariableRef, JuMP.VariableRef})
    if expr isa JuMP.QuadExpr
        # Handle Quadratic Expressions
        new_quad_terms = OrderedCollections.OrderedDict(
            UnorderedPair(get(mapping, pair.a, pair.a), get(mapping, pair.b, pair.b)) => coef
            for (pair, coef) in expr.terms
        )
        # Recursively substitute the affine part
        new_aff_expr = substitute_variables(expr.aff, mapping)
        return JuMP.QuadExpr(new_aff_expr, new_quad_terms)
    elseif expr isa JuMP.AffExpr
        # Handle Affine Expressions
        new_terms = OrderedCollections.OrderedDict(
            get(mapping, var, var) => coef for (var, coef) in expr.terms
        )
        return JuMP.AffExpr(expr.constant, new_terms...)
    elseif expr isa JuMP.VariableRef
        # Handle Single Variables
        return get(mapping, expr, expr)
    else
        # nonlinear expressions...see: https://jump.dev/JuMP.jl/stable/manual/nonlinear/#Nonlinear-expressions-in-detail
        error("Unsupported expression type: $(typeof(expr))")
    end
end
#=
end utilities --------------------------------------------------------------------------------------------------------------
=#


# Step 1: Define individual models
n = 3  # Number of individual models
models = []
for i in 1:n
    model = Model()
    @variable(model, y[1:2] >= 0)  # Each model has a variable array `y` of size 2
    @objective(model, Min, y[1]^2 + i * y[2])  # Example objective
    @constraint(model, y[1] + i <= 10)  # Example constraint
    @constraint(model, y[2] + i >= 10)  # Example constraint
    push!(models, model)
end

# Step 2: Create the big model
big_model = Model()

# Step 3: Create variables for the big model and build a variable mapping
variable_mapping = Dict{JuMP.VariableRef, JuMP.VariableRef}()

for (model_idx, model) in enumerate(models)
    # Iterate over all variables in the model
    for var in all_variables(model)
        # Get the variable's symbolic name
        var_name = Symbol(var)  # e.g., "y[1]", "y[2]"

        # Create a unique name for the big model variable
        unique_var_name = Symbol("m$(model_idx)_$(var_name)")

        # Get the variable's properties
        lb = JuMP.has_lower_bound(var) ? JuMP.lower_bound(var) : -Inf
        ub = JuMP.has_upper_bound(var) ? JuMP.upper_bound(var) : Inf
        is_bin = JuMP.is_binary(var)
        is_int = JuMP.is_integer(var)

        # Create the variable in the big model with the same properties
        if JuMP.has_lower_bound(var) && JuMP.has_upper_bound(var)
            new_var = @variable(big_model, lower_bound=lb, upper_bound=ub, base_name=string(unique_var_name))
        elseif JuMP.has_lower_bound(var) && !JuMP.has_upper_bound(var)
            new_var = @variable(big_model, lower_bound=lb, base_name=string(unique_var_name))
        elseif !JuMP.has_lower_bound(var) && JuMP.has_upper_bound(var)
            new_var = @variable(big_model, upper_bound=ub, base_name=string(unique_var_name))
        else
            new_var = @variable(big_model, base_name=string(unique_var_name))
        end
        
        # Map the small model variable to the newly created big model variable
        variable_mapping[var] = new_var
    end
end

# Verify the variable mapping
println("Variable Mapping:")
for (small_var, big_var) in variable_mapping
    println("Small model variable $small_var -> Big model variable $big_var")
end

# -------------------------------------------------------------------------------------------------------------------------------
# Step 4: Combine objectives and constraints from all models
combined_objective = 0.0  # Initialize the combined objective

for (model_idx, model) in enumerate(models)
    # Extract the objective function
    obj_expr = JuMP.objective_function(model)

    # Perform substitution for variables using the variable_mapping
    substituted_obj_expr = substitute_variables(obj_expr, variable_mapping)

    # Add the substituted objective to the combined objective
    combined_objective += substituted_obj_expr
end

# Add the combined objective to the big model
@objective(big_model, Min, combined_objective)

# Step 5: Extract and add constraints from individual models to the big model
for (model_idx, model) in enumerate(models)
    println("model=$model_idx")
    for (con_idx, con) in enumerate(JuMP.all_constraints(model, include_variable_in_set_constraints=true))
        println("con=$con, $con_idx")
        # Get the full constraint object
        constraint_obj = JuMP.constraint_object(con)

        # Access the left-hand side (expression) and right-hand side (set)
        con_expr = constraint_obj.func   # The function or expression on the LHS
        @show con_expr
        con_set = constraint_obj.set    # The set defining the constraint (e.g., `<=`, `>=`, `==`)
        @show con_set

        # Substitute variables in the constraint expression
        substituted_con_expr = substitute_variables(con_expr, variable_mapping)

        # Add the substituted constraint to the big model
        @constraint(big_model, substituted_con_expr in con_set)
    end
end

# Verify the big model
println("Big Model Objective:")
println(combined_objective)

println("Big Model Constraints:")
for con in JuMP.all_constraints(big_model)
    println(con)
end

gives an error

model=1
con=y[2] ≥ 9, 1
con_expr = y[2]
con_set = MathOptInterface.GreaterThan{Float64}(9.0)
con=y[1] ≤ 9, 2
con_expr = y[1]
con_set = MathOptInterface.LessThan{Float64}(9.0)
con=y[1] ≥ 0, 3
con_expr = y[1]
con_set = MathOptInterface.GreaterThan{Float64}(0.0)
ERROR: MathOptInterface.LowerBoundAlreadySet{MathOptInterface.GreaterThan{Float64}, MathOptInterface.GreaterThan{Float64}}: Cannot add `VariableIndex`-in-`MathOptInterface.GreaterThan{Float64}` constraint for variable MOI.VariableIndex(1) as a `VariableIndex`-in-`MathOptInterface.GreaterThan{Float64}` constraint was already set for this variable and both constraints set a lower bound.
Stacktrace:
  [1] _throw_if_lower_bound_set_inner(variable::MathOptInterface.VariableIndex, S2::Type, mask::UInt16, T::Type)
    @ MathOptInterface.Utilities ~/.julia/packages/MathOptInterface/gLl4d/src/Utilities/variables_container.jl:120
  [2] _throw_if_lower_bound_set
    @ ~/.julia/packages/MathOptInterface/gLl4d/src/Utilities/variables_container.jl:131 [inlined]
  [3] add_constraint
    @ ~/.julia/packages/MathOptInterface/gLl4d/src/Utilities/variables_container.jl:267 [inlined]
  [4] add_constraint
    @ ~/.julia/packages/MathOptInterface/gLl4d/src/Utilities/model.jl:348 [inlined]
  [5] add_constraint
    @ ~/.julia/packages/MathOptInterface/gLl4d/src/Utilities/universalfallback.jl:835 [inlined]
  [6] add_constraint(m::MathOptInterface.Utilities.CachingOptimizer{…}, func::MathOptInterface.VariableIndex, set::MathOptInterface.GreaterThan{…})
    @ MathOptInterface.Utilities ~/.julia/packages/MathOptInterface/gLl4d/src/Utilities/cachingoptimizer.jl:551
  [7] _moi_add_constraint(model::MathOptInterface.Utilities.CachingOptimizer{…}, f::MathOptInterface.VariableIndex, s::MathOptInterface.GreaterThan{…})
    @ JuMP ~/.julia/packages/JuMP/V9nZm/src/constraints.jl:1013
  [8] add_constraint(model::Model, con::ScalarConstraint{VariableRef, MathOptInterface.GreaterThan{Float64}}, name::String)
    @ JuMP ~/.julia/packages/JuMP/V9nZm/src/constraints.jl:1036
  [9] macro expansion
    @ ~/.julia/packages/JuMP/V9nZm/src/macros/@constraint.jl:173 [inlined]
 [10] macro expansion
    @ ~/.julia/packages/JuMP/V9nZm/src/macros.jl:393 [inlined]
 [11] top-level scope
    @ ./REPL[165]:18
Some type information was truncated. Use `show(err)` to see complete types.

I don’t condone this, and I would strongly encourage readers in the future to think about other ways in which they could accomplish their task without trying to combine multiple JuMP models.

But.

For the specific case of forming a deterministic equivalent from a decomposed model.

I have done this with SDDP.jl so I am guilty of breaking my own rule :smile:

You’e on the right track, but you don’t need to bother with setting the bounds etc. looping through the constraints is sufficient.

Here’s the code in SDDP.jl:

1 Like

Oh great, thanks! Will look to use this!

Makes sense! I was just trying to get a quick way to verify a solution :smile:

1 Like