Search code examples
pythonpyomo

Cumulative constraints applied to sub-set from double indexed sparse set in Pyomo


This is a follow up to a previous post. However, I have revised things and thought the change warranted a separate post for clarity's sake. Apologies if this is poor form, I'll happily amend/delete as required.

In the following minimal example, I use a sparse set (WEEK_PROD_flat) to formulate my decision variable X:

Initial Model

weekly_products = {
    1: ['Q24'],
    2: ['Q24', 'J24'],
    3: ['Q24', 'J24','F24'],
    4: ['J24', 'F24'],
    5: ['F24']
}

from pyomo.environ import *
model = ConcreteModel()

model.WEEKS = Set(initialize = [1,2,3,4,5])
model.PRODS = Set(initialize = ['Q24','J24','F24'])
model.WEEK_PROD = Set(model.WEEKS, initialize = weekly_products)
model.WEEK_PROD_flat = Set(initialize=[(w, p) for w in model.WEEKS for p in model.WEEK_PROD[w]])

model.X = Var(model.WEEK_PROD_flat)

I am attempting to apply constraints to a sub-set of the indices from WEEK_PROD_flat. To do so, I first formulate the following min and max parameters:

Parameters

week_min = {
    (2,'J24'):10,
    (3,'J24'):20,
    (3,'F24'):10,
    (4,'J24'):30,
    (4,'F24'):20,
    (5,'F24'):30  
}
week_max = {
    (2,'J24'):30,
    (3,'J24'):40,
    (3,'F24'):30,
    (4,'J24'):50,
    (4,'F24'):40,
    (5,'F24'):50 
}
model.weekMin = Param(model.WEEK_PROD_flat, within = NonNegativeIntegers, initialize = week_min, default = 0) 
model.weekMax = Param(model.WEEK_PROD_flat, within = NonNegativeIntegers, initialize = week_max, default = 0)

I am attempting to use these parameters to create a constraint that is cumulative for each (week, product) indices with the following specification:

  • Each week also includes the summation of previous weeks
  • 'J..' and 'F..' products should be summed separately
  • 'Q..' products are excluded from the index but summed along with the associated 'J..' and 'F..' products respectively.

The output should look like this:

Desired Output

(2, 'J24'): 10 : X[1,Q24] + X[2,Q24] + X[2,J24] : 30 
(3, 'J24'): 20 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[2,J24]  + X[3,J24] : 40
(3, 'F24'): 10 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[3,F24] : 30
(4, 'J24'): 30 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[2,J24]  + X[3,J24] + X[4,J24] :50
(4, 'F24'): 20 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[3,F24] + X[4,F24] : 40
(5, 'F24'): 30 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[3,F24] + X[4,F24] + X[5,F24] : 50

In a previous iteration of the problem double indices were not used in the formulation of parameters or the decision variable and sparse sets were not used. I was able to create the desired output by specifying multiple rules and creating constraints for 'J' and 'F' separately.

This would be unwieldly in my actual use case and I would like to be able to create the desired output with a single constraint declaration.

Guidance appreciated!


Current Attempts
So far, the closest I have been able to get to the desired results is below. However, this is far from ideal for a couple of reasons:

1.) A similar rule will obviously have to be called to define constraints for each product element in model.PRODS that is not 'Q24'. While this is only 2 in this instance, in the actual use case, this would simply not be efficient.
2.) Because the constraint is indexed using the single indices from model.WEEKS, it was necessary to apply static bounds (0,1000 in the example shown) as the double indexed bounds from weekMin and weekMax could not be applied.

Code

def cum_limit_rule(model,w):
    subset = {x for x in model.WEEKS if x <= w}
    return(0,
          sum(model.X[w,p] for w in subset for p in model.WEEK_PROD[w] if p[0] == 'J' or p[0] == 'Q'),
          1000)

model.cum_limit = Constraint(model.WEEKS, rule= cum_limit_rule)

Output

cum_limit : Size=5, Index=WEEKS, Active=True
    Key : Lower : Body                                                            : Upper  : Active
      1 :   0.0 :                                                        X[1,Q24] : 1000.0 :   True
      2 :   0.0 :                                  X[1,Q24] + X[2,Q24] + X[2,J24] : 1000.0 :   True
      3 :   0.0 :            X[1,Q24] + X[2,Q24] + X[2,J24] + X[3,Q24] + X[3,J24] : 1000.0 :   True
      4 :   0.0 : X[1,Q24] + X[2,Q24] + X[2,J24] + X[3,Q24] + X[3,J24] + X[4,J24] : 1000.0 :   True
      5 :   0.0 : X[1,Q24] + X[2,Q24] + X[2,J24] + X[3,Q24] + X[3,J24] + X[4,J24] : 1000.0 :   True

Solution

  • Here's a cut at that constraint....

    A couple things. You don't mention in your post if you have multiple F and J products, but I assume so from context. Otherwise, this is really overly complicated.

    Be careful with your defaults for the parameter. You had zeros for max, which would make the model infeasible. If you had a max of 30 in week 3 and a cumulative max of 0 in week 4.... A more sophisticated approach might be to use the prior week's values if there is no allowed changes week-to-week, but that's problem dependent. I just tossed in 1000.

    There's probably a couple other ways to do this, but this approach is flexible and accommodates changes to the weekly products.

    Code:

    from pyomo.environ import ConcreteModel, Set, Var, Constraint, Param, NonNegativeIntegers
    from itertools import chain
    from collections import defaultdict
    
    weekly_products = {
        1: ['Q24'],
        2: ['Q24', 'J24', 'J17', 'J44'],
        3: ['Q24', 'J24', 'F24'],
        4: ['Q87', 'J24', 'F24'],
        5: ['F24', 'F77']
    }
    
    # let's group up the products...
    
    # pyomo prefers lists for initializers to preserve order, so we can sort the set
    all_products = sorted(set(chain(*weekly_products.values())))
    products_by_type = defaultdict(list)
    for p in all_products:
        products_by_type[p[0]].append(p)
    # print(products_by_type)
    
    min_max_groups = ['J', 'F']
    all_groups = ['Q', 'J', 'F']
    
    week_min = {
        (2,'J'):10,
        (3,'J'):20,
        (3,'F'):10,
        (4,'J'):30,
        (4,'F'):20,
        (5,'F'):30  
    }
    week_max = {
        (2,'J'):30,
        (3,'J'):40,
        (3,'F'):30,
        (4,'J'):50,
        (4,'F'):40,
        (5,'F'):50 
    }
    
    model = ConcreteModel()
    
    ### SETS
    
    model.WEEKS = Set(initialize = weekly_products.keys())
    model.PRODS = Set(initialize = all_products)
    model.GROUP_PROD = Set(all_groups, initialize=products_by_type)
    
    # this is an "indexed set" which is a set of sets, indexed in this case by WEEKS
    model.WEEK_PROD = Set(model.WEEKS, initialize=weekly_products)
    model.WEEK_PROD_flat = Set(initialize=[(w, p) for w in model.WEEKS for p in model.WEEK_PROD[w]])
    
    ### VARS
    
    # initializing X with this reduced flat set will reduce errors and build a more compact
    # model instead of |weeks| * |products| if there are only sparse legal combinations to consider:
    model.X = Var(model.WEEK_PROD_flat)
    
    ### PARAMS
    
    model.weekMin = Param(model.WEEKS * min_max_groups, within = NonNegativeIntegers, initialize = week_min, default = 0) 
    model.weekMax = Param(model.WEEKS * min_max_groups, within = NonNegativeIntegers, initialize = week_max, default = 1000)  # BE CAREFUL WITH DEFAULTS
    
    def weekly_min_max(model, limit_week, group):
        q_prod_sum = sum(model.X[week, prod] 
            for prod in model.GROUP_PROD['Q'] 
            for week in model.WEEKS 
            if week <= limit_week
            if prod in model.WEEK_PROD[week])
        prod_sum   = sum(model.X[week, prod] 
            for prod in model.GROUP_PROD[group] 
            for week in model.WEEKS 
            if week <= limit_week
            if prod in model.WEEK_PROD[week])
        return model.weekMin[limit_week, group], q_prod_sum + prod_sum, model.weekMax[limit_week, group]
    
    model.C1 = Constraint(model.WEEKS, min_max_groups, rule=weekly_min_max)
    
    model.pprint()
    

    Partial Output:

    1 Constraint Declarations
        C1 : Size=10, Index=C1_index, Active=True
            Key      : Lower : Body                                                                                             : Upper  : Active
            (1, 'F') :   0.0 :                                                                                         X[1,Q24] : 1000.0 :   True
            (1, 'J') :   0.0 :                                                                                         X[1,Q24] : 1000.0 :   True
            (2, 'F') :   0.0 :                                                                              X[1,Q24] + X[2,Q24] : 1000.0 :   True
            (2, 'J') :  10.0 :                                             X[1,Q24] + X[2,Q24] + X[2,J17] + X[2,J24] + X[2,J44] :   30.0 :   True
            (3, 'F') :  10.0 :                                                        X[1,Q24] + X[2,Q24] + X[3,Q24] + X[3,F24] :   30.0 :   True
            (3, 'J') :  20.0 :                       X[1,Q24] + X[2,Q24] + X[3,Q24] + X[2,J17] + X[2,J24] + X[3,J24] + X[2,J44] :   40.0 :   True
            (4, 'F') :  20.0 :                                  X[1,Q24] + X[2,Q24] + X[3,Q24] + X[4,Q87] + X[3,F24] + X[4,F24] :   40.0 :   True
            (4, 'J') :  30.0 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[4,Q87] + X[2,J17] + X[2,J24] + X[3,J24] + X[4,J24] + X[2,J44] :   50.0 :   True
            (5, 'F') :  30.0 :            X[1,Q24] + X[2,Q24] + X[3,Q24] + X[4,Q87] + X[3,F24] + X[4,F24] + X[5,F24] + X[5,F77] :   50.0 :   True
            (5, 'J') :   0.0 : X[1,Q24] + X[2,Q24] + X[3,Q24] + X[4,Q87] + X[2,J17] + X[2,J24] + X[3,J24] + X[4,J24] + X[2,J44] : 1000.0 :   True