scoptimize.network

  1import pulp
  2import type_enforced
  3from .utils import large_m, Error
  4
  5
  6@type_enforced.Enforcer
  7class NetworkStructure(Error):
  8    def __init__(
  9        self,
 10        name: str,
 11        cashflow_for_use: [int, float] = 0,
 12        cashflow_per_unit: [int, float] = 0,
 13        min_units: [int, float] = 0,
 14        max_units: [int, float] = 0,
 15    ):
 16        """
 17        Initialize a generic network structure object.
 18
 19        Requires:
 20
 21        - `name`:
 22            - Type: str
 23            - What: The name of this network object
 24        - `cashflow_for_use`:
 25            - Type: int | float
 26            - What: The fixed cashflow that occurs if a non zero number of units flow through this network structure
 27        - `cashflow_per_unit`:
 28            - Type: int | float
 29            - What: The cashflow that occurs for each unit that flows through this network structure
 30        - `min_units`:
 31            - Type: int | float
 32            - What: The minimum units that must flow through this network structure
 33        - `max_units`:
 34            - Type: int | float
 35            - What: The maximum units that must flow through this network structure
 36            - Note: `max_units` must be larger than `min_units`
 37        """
 38        self.name = name
 39        self.min_units = min_units
 40        if max_units == 0 and min_units > 0:
 41            max_units = min_units
 42        self.max_units = max_units
 43        if min_units > max_units:
 44            self.warn(
 45                f"`min_units` is larger than `max_units` for `{self.name}`. This creates an infeasible constraint."
 46            )
 47        self.cashflow_per_unit = cashflow_per_unit
 48
 49        self.cashflow_for_use = cashflow_for_use
 50        if self.cashflow_for_use != 0:
 51            self.use = pulp.LpVariable(name=f"{self.name}_use", cat="Binary")
 52
 53        self.inflows = []
 54        self.outflows = []
 55        self.reflows_in = []
 56        self.reflows_out = []
 57
 58    def sum_flows(self, flow_list: list):
 59        """
 60        Returns the sum of flows in a provided `flow_list` of `Flow` object as a float value.
 61
 62        Requires:
 63
 64        - `flow_list`:
 65            - Type: list
 66            - What: A list of `flow` objects
 67        """
 68        return float(sum([i.flow.value() for i in flow_list]))
 69
 70    def lp_sum_flows(self, flow_list: list):
 71        """
 72        Returns a pulp pulp function to sum of flows in a provided `flow_list` of `Flow` object.
 73
 74        Requires:
 75
 76        - `flow_list`:
 77            - Type: list
 78            - What: A list of flow objects
 79        """
 80        return pulp.lpSum([i.flow for i in flow_list])
 81
 82    def add_constraints(self, model):
 83        """
 84        Updates a provided model with all the constraints needed for this network structure
 85
 86        Requires:
 87
 88        - `model`:
 89            - Type: Model object
 90            - What: The relevant model object that will get the constraints from this network structure
 91        """
 92        # Enforce max unit constraint
 93        model += self.lp_sum_flows(self.outflows) <= self.max_units
 94        model += self.lp_sum_flows(self.outflows) >= self.min_units
 95        # Enforce binary open constraint if cashflow_for_use is not 0
 96        if self.cashflow_for_use != 0:
 97            model += self.lp_sum_flows(self.outflows) <= large_m * self.use
 98
 99    def get_objective_fn(self):
100        """
101        Gets the objective function (in pulp variables) for this network structure
102        """
103        variable_cashflow = self.lp_sum_flows(self.outflows) * self.cashflow_per_unit
104        if self.cashflow_for_use != 0:
105            fixed_cashflow = self.use * self.cashflow_for_use
106        else:
107            fixed_cashflow = 0
108        return variable_cashflow + fixed_cashflow
109
110
111@type_enforced.Enforcer
112class Flow(NetworkStructure):
113    def __init__(
114        self, start: str, end: str, *args, reflow: bool = False, cat: str = "Continuous", **kwargs
115    ):
116        """
117        Extends the NetworkStructure initialization to initialize a new Flow object.
118
119        Requires:
120
121        - `start`:
122            - Type: str
123            - What: The start Node name for this flow
124        - `end`:
125            - Type: str
126            - What: The end Node name for this flow
127
128        Optional:
129
130        - `reflow`:
131            - Type: bool
132            - What: Indicate if this flow is treated as a reflow
133            - Default: False
134            - Note: Reflows do not impact max_units or min_units for attached nodes
135            - Note: Reflows would normally be used to capture the idea of inventory in a multi time period model
136        -  `cat`:
137            - Type: str
138            - What: The type of flow variable to create
139            - Options: ['Continuous','Binary','Integer']
140            - Default: Continuous
141        """
142        super().__init__(*args, **kwargs)
143        self.start = start
144        self.end = end
145        self.flow = pulp.LpVariable(name=f"{self.name}", cat=cat)
146        self.outflows.append(self)
147        self.reflow = reflow
148
149    def add_flows(self, objects: dict):
150        """
151        Adds this flow object to the `start` and `end` nodes for the purpose of calculating node throughput.
152
153        Requires:
154
155        - `objects`:
156            - Type: dict
157            - What: An object dictionary (key=Object.name, Value=Object) for all nodes in the current model
158        """
159        start_entity = objects.get(self.start)
160        if start_entity == None:
161            self.exception(
162                f"`start` entity ({self.start}) not found in current objects list. Did you forget to add it to your nodes? This should be done prior to adding flows. See the `add_nodes` method."
163            )
164        end_entity = objects.get(self.end)
165        if end_entity == None:
166            self.exception(
167                f"`end` entity ({self.end}) not found in current objects list. Did you forget to add it to your nodes? This should be done prior to adding flows. See the `add_nodes` method."
168            )
169        start_entity.add_outflow(self)
170        end_entity.add_inflow(self)
171
172    def get_stats(self):
173        """
174        Get the stats relevant to this flow object
175        """
176        # Dont recalculate stats for this object if it has already been calculated
177        if hasattr(self, "stats"):
178            return self.stats
179        self.stats = {
180            "name": self.name,
181            "class": self.__class__.__name__,
182            "reflow": self.reflow,
183            "start": self.start,
184            "end": self.end,
185            "flow": self.sum_flows(self.outflows),
186            "use": 1.0,
187        }
188        if self.cashflow_for_use != 0:
189            self.stats["use"] = self.use.value()
190        self.stats = {
191            **self.stats,
192            "variable_cashflow": self.stats["flow"] * self.cashflow_per_unit,
193            "fixed_cashflow": self.stats["use"] * self.cashflow_for_use,
194        }
195        return self.stats
196
197
198@type_enforced.Enforcer
199class Node(NetworkStructure):
200    def __init__(self, *args, origin: bool = False, destination: bool = False, **kwargs):
201        """
202        Extends the NetworkStructure initialization process to initialize a new Node object.
203
204        Optional:
205
206        - `origin`:
207            - Type: bool
208            - What: Indicate if this node is an origin
209            - Default: False
210            - Note: Origin nodes do not have a preservation of flow constraint added and new flows be started in them them (from nothingness)
211        - `origin`:
212            - Type: bool
213            - What: Indicate if this node is a destination
214            - Default: False
215            - Note: Destination nodes do not have a preservation of flow constraint added and flows can be terminated in them (into nothingness)
216        """
217        super().__init__(*args, **kwargs)
218        self.origin = origin
219        self.destination = destination
220
221        if origin and destination:
222            self.exception(
223                f"`origin` and `destination` can not both be `true` for any node but are for node `{self.name}`"
224            )
225
226        if self.destination:
227            # Create a link from inflows to outflows to allow general
228            # NetworkStructure class logic to propagate destination nodes
229            self.outflows = self.inflows
230
231    def add_constraints(self, model):
232        """
233        Extends the add_constraints function in NetworkStructure to include preservation of flow to the passed model.
234
235        This only applies if the this node is not an `origin` or `destination` node.
236
237        Preservation of flow fn: `inflows + reflows_in = outflows + reflows_out`
238
239        Requires:
240
241        - `model`:
242            - Type: Model object
243            - What: The relevant model object that will get the constraints from this network structure
244        """
245        super().add_constraints(model)
246        if not (self.origin or self.destination):
247            # Balance Inflows + Reflows_In with Outflows + Reflows_Out
248            model += (self.lp_sum_flows(self.inflows) + self.lp_sum_flows(self.reflows_in)) == (
249                self.lp_sum_flows(self.outflows) + self.lp_sum_flows(self.reflows_out)
250            )
251
252    def add_inflow(self, obj: Flow):
253        """
254        Adds an inflow to this node
255
256        Requires:
257
258        - `obj`:
259            - Type: Flow object
260            - What: A flow that is entering this node
261        """
262        if obj.reflow:
263            self.reflows_in.append(obj)
264        else:
265            self.inflows.append(obj)
266
267    def add_outflow(self, obj: Flow):
268        """
269        Adds an outflow to this node
270
271        Requires:
272
273        - `obj`:
274            - Type: Flow object
275            - What: A flow that is leaving this node
276        """
277        if obj.reflow:
278            self.reflows_out.append(obj)
279        else:
280            self.outflows.append(obj)
281
282    def get_stats(self):
283        """
284        Get the stats relevant to this node object
285        """
286        # Dont recalculate stats for this object if it has already been calculated
287        if hasattr(self, "stats"):
288            return self.stats
289        self.stats = {
290            "name": self.name,
291            "class": self.__class__.__name__,
292            "origin": self.origin,
293            "destination": self.destination,
294            "inflows": self.sum_flows(self.inflows),
295            "outflows": self.sum_flows(self.outflows),
296            "reflows_in": self.sum_flows(self.reflows_in),
297            "reflows_out": self.sum_flows(self.reflows_out),
298            "use": 1.0,
299        }
300        if self.cashflow_for_use != 0:
301            self.stats["use"] = self.use.value()
302        self.stats = {
303            **self.stats,
304            "variable_cashflow": self.stats["outflows"] * self.cashflow_per_unit,
305            "fixed_cashflow": self.stats["use"] * self.cashflow_for_use,
306        }
307        # Fix the special outflows logic post solve to undo the special
308        # logic in self.__init__() above
309        if self.destination:
310            self.stats["outflows"] = 0
311        return self.stats
312
313
314@type_enforced.Enforcer
315class Model(Error):
316    def __init__(self, name: str, objects: [dict, type(None)] = None):
317        """
318        Initialize a new model object to be solved
319
320        Requires:
321
322        - `name`:
323            - Type: str
324            - What: The name of this model object
325
326        Optional:
327
328        - `objects`:
329            - Type: dict
330            - What: A dictionary that contains any pre aggregated `Node`s or `Flow`s.
331            - Note: This should normally only be used for internal testing. Unless you need to custom functionaility, use the Model.add_object function instead of this.
332            - Default: {}
333        """
334        if objects == None:
335            objects = {}
336        self.name = name
337        self.objects = objects
338
339    def solve(self, pulp_log: bool = False, except_on_infeasible: bool = True):
340        """
341        Solve this model given all of the Nodes and Flows
342
343        Optional:
344
345        - `pulp_log`:
346            - Type: bool
347            - What: Indicate if the pulp log should be shown in the terminal
348            - Default: False
349        - `except_on_infeasible`:
350            - Type: bool
351            - What: Indicate if the model should throw an exception if it is infeasible
352            - Note: If False, the model will relax constraints until a solution is found
353            - Default: True
354        """
355        # Create PuLP Model
356        self.model = pulp.LpProblem(name=self.name, sense=pulp.LpMaximize)
357        # Set objective function
358        self.model += pulp.lpSum([i.get_objective_fn() for i in self.objects.values()])
359        # Add constraints
360        [i.add_constraints(self.model) for i in self.objects.values()]
361        # Solve the model
362        self.model.solve(pulp.PULP_CBC_CMD(msg=(3 if pulp_log else 0)))
363
364        if self.model.status == -1:
365            if except_on_infeasible:
366                self.exception("The current model is infeasible and can not be solved.")
367            else:
368                self.warn(
369                    "The current model is infeasible and can not be solved. Constraints have been relaxed to provide a solution anyway."
370                )
371
372        # Parse the objective value
373        self.objective = self.model.objective.value()
374
375    def get_object_stats(self):
376        """
377        Get the statistics for every `Node` and `Flow` in this `Model`.
378        """
379        return {key: value.get_stats() for key, value in self.objects.items()}
380
381    def add_object(self, obj: [Node, Flow]):
382        """
383        Adds a `Node` or `Flow` to this model
384
385        Requires:
386
387        - `obj`:
388            - Type: Flow object | Node object
389            - What: A `Node` or `Flow` to be added to this model
390        """
391        if obj.name in self.objects.keys():
392            self.exception(f"Duplicate name detected: `{obj.name}`")
393        if isinstance(obj, (Flow)):
394            obj.add_flows(self.objects)
395        self.objects[obj.name] = obj
@type_enforced.Enforcer
class NetworkStructure(scoptimize.utils.Error):
  7@type_enforced.Enforcer
  8class NetworkStructure(Error):
  9    def __init__(
 10        self,
 11        name: str,
 12        cashflow_for_use: [int, float] = 0,
 13        cashflow_per_unit: [int, float] = 0,
 14        min_units: [int, float] = 0,
 15        max_units: [int, float] = 0,
 16    ):
 17        """
 18        Initialize a generic network structure object.
 19
 20        Requires:
 21
 22        - `name`:
 23            - Type: str
 24            - What: The name of this network object
 25        - `cashflow_for_use`:
 26            - Type: int | float
 27            - What: The fixed cashflow that occurs if a non zero number of units flow through this network structure
 28        - `cashflow_per_unit`:
 29            - Type: int | float
 30            - What: The cashflow that occurs for each unit that flows through this network structure
 31        - `min_units`:
 32            - Type: int | float
 33            - What: The minimum units that must flow through this network structure
 34        - `max_units`:
 35            - Type: int | float
 36            - What: The maximum units that must flow through this network structure
 37            - Note: `max_units` must be larger than `min_units`
 38        """
 39        self.name = name
 40        self.min_units = min_units
 41        if max_units == 0 and min_units > 0:
 42            max_units = min_units
 43        self.max_units = max_units
 44        if min_units > max_units:
 45            self.warn(
 46                f"`min_units` is larger than `max_units` for `{self.name}`. This creates an infeasible constraint."
 47            )
 48        self.cashflow_per_unit = cashflow_per_unit
 49
 50        self.cashflow_for_use = cashflow_for_use
 51        if self.cashflow_for_use != 0:
 52            self.use = pulp.LpVariable(name=f"{self.name}_use", cat="Binary")
 53
 54        self.inflows = []
 55        self.outflows = []
 56        self.reflows_in = []
 57        self.reflows_out = []
 58
 59    def sum_flows(self, flow_list: list):
 60        """
 61        Returns the sum of flows in a provided `flow_list` of `Flow` object as a float value.
 62
 63        Requires:
 64
 65        - `flow_list`:
 66            - Type: list
 67            - What: A list of `flow` objects
 68        """
 69        return float(sum([i.flow.value() for i in flow_list]))
 70
 71    def lp_sum_flows(self, flow_list: list):
 72        """
 73        Returns a pulp pulp function to sum of flows in a provided `flow_list` of `Flow` object.
 74
 75        Requires:
 76
 77        - `flow_list`:
 78            - Type: list
 79            - What: A list of flow objects
 80        """
 81        return pulp.lpSum([i.flow for i in flow_list])
 82
 83    def add_constraints(self, model):
 84        """
 85        Updates a provided model with all the constraints needed for this network structure
 86
 87        Requires:
 88
 89        - `model`:
 90            - Type: Model object
 91            - What: The relevant model object that will get the constraints from this network structure
 92        """
 93        # Enforce max unit constraint
 94        model += self.lp_sum_flows(self.outflows) <= self.max_units
 95        model += self.lp_sum_flows(self.outflows) >= self.min_units
 96        # Enforce binary open constraint if cashflow_for_use is not 0
 97        if self.cashflow_for_use != 0:
 98            model += self.lp_sum_flows(self.outflows) <= large_m * self.use
 99
100    def get_objective_fn(self):
101        """
102        Gets the objective function (in pulp variables) for this network structure
103        """
104        variable_cashflow = self.lp_sum_flows(self.outflows) * self.cashflow_per_unit
105        if self.cashflow_for_use != 0:
106            fixed_cashflow = self.use * self.cashflow_for_use
107        else:
108            fixed_cashflow = 0
109        return variable_cashflow + fixed_cashflow
NetworkStructure( name: str, cashflow_for_use: [<class 'int'>, <class 'float'>] = 0, cashflow_per_unit: [<class 'int'>, <class 'float'>] = 0, min_units: [<class 'int'>, <class 'float'>] = 0, max_units: [<class 'int'>, <class 'float'>] = 0)
 9    def __init__(
10        self,
11        name: str,
12        cashflow_for_use: [int, float] = 0,
13        cashflow_per_unit: [int, float] = 0,
14        min_units: [int, float] = 0,
15        max_units: [int, float] = 0,
16    ):
17        """
18        Initialize a generic network structure object.
19
20        Requires:
21
22        - `name`:
23            - Type: str
24            - What: The name of this network object
25        - `cashflow_for_use`:
26            - Type: int | float
27            - What: The fixed cashflow that occurs if a non zero number of units flow through this network structure
28        - `cashflow_per_unit`:
29            - Type: int | float
30            - What: The cashflow that occurs for each unit that flows through this network structure
31        - `min_units`:
32            - Type: int | float
33            - What: The minimum units that must flow through this network structure
34        - `max_units`:
35            - Type: int | float
36            - What: The maximum units that must flow through this network structure
37            - Note: `max_units` must be larger than `min_units`
38        """
39        self.name = name
40        self.min_units = min_units
41        if max_units == 0 and min_units > 0:
42            max_units = min_units
43        self.max_units = max_units
44        if min_units > max_units:
45            self.warn(
46                f"`min_units` is larger than `max_units` for `{self.name}`. This creates an infeasible constraint."
47            )
48        self.cashflow_per_unit = cashflow_per_unit
49
50        self.cashflow_for_use = cashflow_for_use
51        if self.cashflow_for_use != 0:
52            self.use = pulp.LpVariable(name=f"{self.name}_use", cat="Binary")
53
54        self.inflows = []
55        self.outflows = []
56        self.reflows_in = []
57        self.reflows_out = []

Initialize a generic network structure object.

Requires:

  • name:
    • Type: str
    • What: The name of this network object
  • cashflow_for_use:
    • Type: int | float
    • What: The fixed cashflow that occurs if a non zero number of units flow through this network structure
  • cashflow_per_unit:
    • Type: int | float
    • What: The cashflow that occurs for each unit that flows through this network structure
  • min_units:
    • Type: int | float
    • What: The minimum units that must flow through this network structure
  • max_units:
    • Type: int | float
    • What: The maximum units that must flow through this network structure
    • Note: max_units must be larger than min_units
name
min_units
max_units
cashflow_per_unit
cashflow_for_use
inflows
outflows
reflows_in
reflows_out
def sum_flows(self, flow_list: list):
59    def sum_flows(self, flow_list: list):
60        """
61        Returns the sum of flows in a provided `flow_list` of `Flow` object as a float value.
62
63        Requires:
64
65        - `flow_list`:
66            - Type: list
67            - What: A list of `flow` objects
68        """
69        return float(sum([i.flow.value() for i in flow_list]))

Returns the sum of flows in a provided flow_list of Flow object as a float value.

Requires:

  • flow_list:
    • Type: list
    • What: A list of flow objects
def lp_sum_flows(self, flow_list: list):
71    def lp_sum_flows(self, flow_list: list):
72        """
73        Returns a pulp pulp function to sum of flows in a provided `flow_list` of `Flow` object.
74
75        Requires:
76
77        - `flow_list`:
78            - Type: list
79            - What: A list of flow objects
80        """
81        return pulp.lpSum([i.flow for i in flow_list])

Returns a pulp pulp function to sum of flows in a provided flow_list of Flow object.

Requires:

  • flow_list:
    • Type: list
    • What: A list of flow objects
def add_constraints(self, model):
83    def add_constraints(self, model):
84        """
85        Updates a provided model with all the constraints needed for this network structure
86
87        Requires:
88
89        - `model`:
90            - Type: Model object
91            - What: The relevant model object that will get the constraints from this network structure
92        """
93        # Enforce max unit constraint
94        model += self.lp_sum_flows(self.outflows) <= self.max_units
95        model += self.lp_sum_flows(self.outflows) >= self.min_units
96        # Enforce binary open constraint if cashflow_for_use is not 0
97        if self.cashflow_for_use != 0:
98            model += self.lp_sum_flows(self.outflows) <= large_m * self.use

Updates a provided model with all the constraints needed for this network structure

Requires:

  • model:
    • Type: Model object
    • What: The relevant model object that will get the constraints from this network structure
def get_objective_fn(self):
100    def get_objective_fn(self):
101        """
102        Gets the objective function (in pulp variables) for this network structure
103        """
104        variable_cashflow = self.lp_sum_flows(self.outflows) * self.cashflow_per_unit
105        if self.cashflow_for_use != 0:
106            fixed_cashflow = self.use * self.cashflow_for_use
107        else:
108            fixed_cashflow = 0
109        return variable_cashflow + fixed_cashflow

Gets the objective function (in pulp variables) for this network structure

@type_enforced.Enforcer
class Flow(NetworkStructure):
112@type_enforced.Enforcer
113class Flow(NetworkStructure):
114    def __init__(
115        self, start: str, end: str, *args, reflow: bool = False, cat: str = "Continuous", **kwargs
116    ):
117        """
118        Extends the NetworkStructure initialization to initialize a new Flow object.
119
120        Requires:
121
122        - `start`:
123            - Type: str
124            - What: The start Node name for this flow
125        - `end`:
126            - Type: str
127            - What: The end Node name for this flow
128
129        Optional:
130
131        - `reflow`:
132            - Type: bool
133            - What: Indicate if this flow is treated as a reflow
134            - Default: False
135            - Note: Reflows do not impact max_units or min_units for attached nodes
136            - Note: Reflows would normally be used to capture the idea of inventory in a multi time period model
137        -  `cat`:
138            - Type: str
139            - What: The type of flow variable to create
140            - Options: ['Continuous','Binary','Integer']
141            - Default: Continuous
142        """
143        super().__init__(*args, **kwargs)
144        self.start = start
145        self.end = end
146        self.flow = pulp.LpVariable(name=f"{self.name}", cat=cat)
147        self.outflows.append(self)
148        self.reflow = reflow
149
150    def add_flows(self, objects: dict):
151        """
152        Adds this flow object to the `start` and `end` nodes for the purpose of calculating node throughput.
153
154        Requires:
155
156        - `objects`:
157            - Type: dict
158            - What: An object dictionary (key=Object.name, Value=Object) for all nodes in the current model
159        """
160        start_entity = objects.get(self.start)
161        if start_entity == None:
162            self.exception(
163                f"`start` entity ({self.start}) not found in current objects list. Did you forget to add it to your nodes? This should be done prior to adding flows. See the `add_nodes` method."
164            )
165        end_entity = objects.get(self.end)
166        if end_entity == None:
167            self.exception(
168                f"`end` entity ({self.end}) not found in current objects list. Did you forget to add it to your nodes? This should be done prior to adding flows. See the `add_nodes` method."
169            )
170        start_entity.add_outflow(self)
171        end_entity.add_inflow(self)
172
173    def get_stats(self):
174        """
175        Get the stats relevant to this flow object
176        """
177        # Dont recalculate stats for this object if it has already been calculated
178        if hasattr(self, "stats"):
179            return self.stats
180        self.stats = {
181            "name": self.name,
182            "class": self.__class__.__name__,
183            "reflow": self.reflow,
184            "start": self.start,
185            "end": self.end,
186            "flow": self.sum_flows(self.outflows),
187            "use": 1.0,
188        }
189        if self.cashflow_for_use != 0:
190            self.stats["use"] = self.use.value()
191        self.stats = {
192            **self.stats,
193            "variable_cashflow": self.stats["flow"] * self.cashflow_per_unit,
194            "fixed_cashflow": self.stats["use"] * self.cashflow_for_use,
195        }
196        return self.stats
Flow( start: str, end: str, *args, reflow: bool = False, cat: str = 'Continuous', **kwargs)
114    def __init__(
115        self, start: str, end: str, *args, reflow: bool = False, cat: str = "Continuous", **kwargs
116    ):
117        """
118        Extends the NetworkStructure initialization to initialize a new Flow object.
119
120        Requires:
121
122        - `start`:
123            - Type: str
124            - What: The start Node name for this flow
125        - `end`:
126            - Type: str
127            - What: The end Node name for this flow
128
129        Optional:
130
131        - `reflow`:
132            - Type: bool
133            - What: Indicate if this flow is treated as a reflow
134            - Default: False
135            - Note: Reflows do not impact max_units or min_units for attached nodes
136            - Note: Reflows would normally be used to capture the idea of inventory in a multi time period model
137        -  `cat`:
138            - Type: str
139            - What: The type of flow variable to create
140            - Options: ['Continuous','Binary','Integer']
141            - Default: Continuous
142        """
143        super().__init__(*args, **kwargs)
144        self.start = start
145        self.end = end
146        self.flow = pulp.LpVariable(name=f"{self.name}", cat=cat)
147        self.outflows.append(self)
148        self.reflow = reflow

Extends the NetworkStructure initialization to initialize a new Flow object.

Requires:

  • start:
    • Type: str
    • What: The start Node name for this flow
  • end:
    • Type: str
    • What: The end Node name for this flow

Optional:

  • reflow:
    • Type: bool
    • What: Indicate if this flow is treated as a reflow
    • Default: False
    • Note: Reflows do not impact max_units or min_units for attached nodes
    • Note: Reflows would normally be used to capture the idea of inventory in a multi time period model
  • cat:
    • Type: str
    • What: The type of flow variable to create
    • Options: ['Continuous','Binary','Integer']
    • Default: Continuous
start
end
flow
reflow
def add_flows(self, objects: dict):
150    def add_flows(self, objects: dict):
151        """
152        Adds this flow object to the `start` and `end` nodes for the purpose of calculating node throughput.
153
154        Requires:
155
156        - `objects`:
157            - Type: dict
158            - What: An object dictionary (key=Object.name, Value=Object) for all nodes in the current model
159        """
160        start_entity = objects.get(self.start)
161        if start_entity == None:
162            self.exception(
163                f"`start` entity ({self.start}) not found in current objects list. Did you forget to add it to your nodes? This should be done prior to adding flows. See the `add_nodes` method."
164            )
165        end_entity = objects.get(self.end)
166        if end_entity == None:
167            self.exception(
168                f"`end` entity ({self.end}) not found in current objects list. Did you forget to add it to your nodes? This should be done prior to adding flows. See the `add_nodes` method."
169            )
170        start_entity.add_outflow(self)
171        end_entity.add_inflow(self)

Adds this flow object to the start and end nodes for the purpose of calculating node throughput.

Requires:

  • objects:
    • Type: dict
    • What: An object dictionary (key=Object.name, Value=Object) for all nodes in the current model
def get_stats(self):
173    def get_stats(self):
174        """
175        Get the stats relevant to this flow object
176        """
177        # Dont recalculate stats for this object if it has already been calculated
178        if hasattr(self, "stats"):
179            return self.stats
180        self.stats = {
181            "name": self.name,
182            "class": self.__class__.__name__,
183            "reflow": self.reflow,
184            "start": self.start,
185            "end": self.end,
186            "flow": self.sum_flows(self.outflows),
187            "use": 1.0,
188        }
189        if self.cashflow_for_use != 0:
190            self.stats["use"] = self.use.value()
191        self.stats = {
192            **self.stats,
193            "variable_cashflow": self.stats["flow"] * self.cashflow_per_unit,
194            "fixed_cashflow": self.stats["use"] * self.cashflow_for_use,
195        }
196        return self.stats

Get the stats relevant to this flow object

@type_enforced.Enforcer
class Node(NetworkStructure):
199@type_enforced.Enforcer
200class Node(NetworkStructure):
201    def __init__(self, *args, origin: bool = False, destination: bool = False, **kwargs):
202        """
203        Extends the NetworkStructure initialization process to initialize a new Node object.
204
205        Optional:
206
207        - `origin`:
208            - Type: bool
209            - What: Indicate if this node is an origin
210            - Default: False
211            - Note: Origin nodes do not have a preservation of flow constraint added and new flows be started in them them (from nothingness)
212        - `origin`:
213            - Type: bool
214            - What: Indicate if this node is a destination
215            - Default: False
216            - Note: Destination nodes do not have a preservation of flow constraint added and flows can be terminated in them (into nothingness)
217        """
218        super().__init__(*args, **kwargs)
219        self.origin = origin
220        self.destination = destination
221
222        if origin and destination:
223            self.exception(
224                f"`origin` and `destination` can not both be `true` for any node but are for node `{self.name}`"
225            )
226
227        if self.destination:
228            # Create a link from inflows to outflows to allow general
229            # NetworkStructure class logic to propagate destination nodes
230            self.outflows = self.inflows
231
232    def add_constraints(self, model):
233        """
234        Extends the add_constraints function in NetworkStructure to include preservation of flow to the passed model.
235
236        This only applies if the this node is not an `origin` or `destination` node.
237
238        Preservation of flow fn: `inflows + reflows_in = outflows + reflows_out`
239
240        Requires:
241
242        - `model`:
243            - Type: Model object
244            - What: The relevant model object that will get the constraints from this network structure
245        """
246        super().add_constraints(model)
247        if not (self.origin or self.destination):
248            # Balance Inflows + Reflows_In with Outflows + Reflows_Out
249            model += (self.lp_sum_flows(self.inflows) + self.lp_sum_flows(self.reflows_in)) == (
250                self.lp_sum_flows(self.outflows) + self.lp_sum_flows(self.reflows_out)
251            )
252
253    def add_inflow(self, obj: Flow):
254        """
255        Adds an inflow to this node
256
257        Requires:
258
259        - `obj`:
260            - Type: Flow object
261            - What: A flow that is entering this node
262        """
263        if obj.reflow:
264            self.reflows_in.append(obj)
265        else:
266            self.inflows.append(obj)
267
268    def add_outflow(self, obj: Flow):
269        """
270        Adds an outflow to this node
271
272        Requires:
273
274        - `obj`:
275            - Type: Flow object
276            - What: A flow that is leaving this node
277        """
278        if obj.reflow:
279            self.reflows_out.append(obj)
280        else:
281            self.outflows.append(obj)
282
283    def get_stats(self):
284        """
285        Get the stats relevant to this node object
286        """
287        # Dont recalculate stats for this object if it has already been calculated
288        if hasattr(self, "stats"):
289            return self.stats
290        self.stats = {
291            "name": self.name,
292            "class": self.__class__.__name__,
293            "origin": self.origin,
294            "destination": self.destination,
295            "inflows": self.sum_flows(self.inflows),
296            "outflows": self.sum_flows(self.outflows),
297            "reflows_in": self.sum_flows(self.reflows_in),
298            "reflows_out": self.sum_flows(self.reflows_out),
299            "use": 1.0,
300        }
301        if self.cashflow_for_use != 0:
302            self.stats["use"] = self.use.value()
303        self.stats = {
304            **self.stats,
305            "variable_cashflow": self.stats["outflows"] * self.cashflow_per_unit,
306            "fixed_cashflow": self.stats["use"] * self.cashflow_for_use,
307        }
308        # Fix the special outflows logic post solve to undo the special
309        # logic in self.__init__() above
310        if self.destination:
311            self.stats["outflows"] = 0
312        return self.stats
Node(*args, origin: bool = False, destination: bool = False, **kwargs)
201    def __init__(self, *args, origin: bool = False, destination: bool = False, **kwargs):
202        """
203        Extends the NetworkStructure initialization process to initialize a new Node object.
204
205        Optional:
206
207        - `origin`:
208            - Type: bool
209            - What: Indicate if this node is an origin
210            - Default: False
211            - Note: Origin nodes do not have a preservation of flow constraint added and new flows be started in them them (from nothingness)
212        - `origin`:
213            - Type: bool
214            - What: Indicate if this node is a destination
215            - Default: False
216            - Note: Destination nodes do not have a preservation of flow constraint added and flows can be terminated in them (into nothingness)
217        """
218        super().__init__(*args, **kwargs)
219        self.origin = origin
220        self.destination = destination
221
222        if origin and destination:
223            self.exception(
224                f"`origin` and `destination` can not both be `true` for any node but are for node `{self.name}`"
225            )
226
227        if self.destination:
228            # Create a link from inflows to outflows to allow general
229            # NetworkStructure class logic to propagate destination nodes
230            self.outflows = self.inflows

Extends the NetworkStructure initialization process to initialize a new Node object.

Optional:

  • origin:
    • Type: bool
    • What: Indicate if this node is an origin
    • Default: False
    • Note: Origin nodes do not have a preservation of flow constraint added and new flows be started in them them (from nothingness)
  • origin:
    • Type: bool
    • What: Indicate if this node is a destination
    • Default: False
    • Note: Destination nodes do not have a preservation of flow constraint added and flows can be terminated in them (into nothingness)
origin
destination
def add_constraints(self, model):
232    def add_constraints(self, model):
233        """
234        Extends the add_constraints function in NetworkStructure to include preservation of flow to the passed model.
235
236        This only applies if the this node is not an `origin` or `destination` node.
237
238        Preservation of flow fn: `inflows + reflows_in = outflows + reflows_out`
239
240        Requires:
241
242        - `model`:
243            - Type: Model object
244            - What: The relevant model object that will get the constraints from this network structure
245        """
246        super().add_constraints(model)
247        if not (self.origin or self.destination):
248            # Balance Inflows + Reflows_In with Outflows + Reflows_Out
249            model += (self.lp_sum_flows(self.inflows) + self.lp_sum_flows(self.reflows_in)) == (
250                self.lp_sum_flows(self.outflows) + self.lp_sum_flows(self.reflows_out)
251            )

Extends the add_constraints function in NetworkStructure to include preservation of flow to the passed model.

This only applies if the this node is not an origin or destination node.

Preservation of flow fn: inflows + reflows_in = outflows + reflows_out

Requires:

  • model:
    • Type: Model object
    • What: The relevant model object that will get the constraints from this network structure
def add_inflow(self, obj: Flow):
253    def add_inflow(self, obj: Flow):
254        """
255        Adds an inflow to this node
256
257        Requires:
258
259        - `obj`:
260            - Type: Flow object
261            - What: A flow that is entering this node
262        """
263        if obj.reflow:
264            self.reflows_in.append(obj)
265        else:
266            self.inflows.append(obj)

Adds an inflow to this node

Requires:

  • obj:
    • Type: Flow object
    • What: A flow that is entering this node
def add_outflow(self, obj: Flow):
268    def add_outflow(self, obj: Flow):
269        """
270        Adds an outflow to this node
271
272        Requires:
273
274        - `obj`:
275            - Type: Flow object
276            - What: A flow that is leaving this node
277        """
278        if obj.reflow:
279            self.reflows_out.append(obj)
280        else:
281            self.outflows.append(obj)

Adds an outflow to this node

Requires:

  • obj:
    • Type: Flow object
    • What: A flow that is leaving this node
def get_stats(self):
283    def get_stats(self):
284        """
285        Get the stats relevant to this node object
286        """
287        # Dont recalculate stats for this object if it has already been calculated
288        if hasattr(self, "stats"):
289            return self.stats
290        self.stats = {
291            "name": self.name,
292            "class": self.__class__.__name__,
293            "origin": self.origin,
294            "destination": self.destination,
295            "inflows": self.sum_flows(self.inflows),
296            "outflows": self.sum_flows(self.outflows),
297            "reflows_in": self.sum_flows(self.reflows_in),
298            "reflows_out": self.sum_flows(self.reflows_out),
299            "use": 1.0,
300        }
301        if self.cashflow_for_use != 0:
302            self.stats["use"] = self.use.value()
303        self.stats = {
304            **self.stats,
305            "variable_cashflow": self.stats["outflows"] * self.cashflow_per_unit,
306            "fixed_cashflow": self.stats["use"] * self.cashflow_for_use,
307        }
308        # Fix the special outflows logic post solve to undo the special
309        # logic in self.__init__() above
310        if self.destination:
311            self.stats["outflows"] = 0
312        return self.stats

Get the stats relevant to this node object

@type_enforced.Enforcer
class Model(scoptimize.utils.Error):
315@type_enforced.Enforcer
316class Model(Error):
317    def __init__(self, name: str, objects: [dict, type(None)] = None):
318        """
319        Initialize a new model object to be solved
320
321        Requires:
322
323        - `name`:
324            - Type: str
325            - What: The name of this model object
326
327        Optional:
328
329        - `objects`:
330            - Type: dict
331            - What: A dictionary that contains any pre aggregated `Node`s or `Flow`s.
332            - Note: This should normally only be used for internal testing. Unless you need to custom functionaility, use the Model.add_object function instead of this.
333            - Default: {}
334        """
335        if objects == None:
336            objects = {}
337        self.name = name
338        self.objects = objects
339
340    def solve(self, pulp_log: bool = False, except_on_infeasible: bool = True):
341        """
342        Solve this model given all of the Nodes and Flows
343
344        Optional:
345
346        - `pulp_log`:
347            - Type: bool
348            - What: Indicate if the pulp log should be shown in the terminal
349            - Default: False
350        - `except_on_infeasible`:
351            - Type: bool
352            - What: Indicate if the model should throw an exception if it is infeasible
353            - Note: If False, the model will relax constraints until a solution is found
354            - Default: True
355        """
356        # Create PuLP Model
357        self.model = pulp.LpProblem(name=self.name, sense=pulp.LpMaximize)
358        # Set objective function
359        self.model += pulp.lpSum([i.get_objective_fn() for i in self.objects.values()])
360        # Add constraints
361        [i.add_constraints(self.model) for i in self.objects.values()]
362        # Solve the model
363        self.model.solve(pulp.PULP_CBC_CMD(msg=(3 if pulp_log else 0)))
364
365        if self.model.status == -1:
366            if except_on_infeasible:
367                self.exception("The current model is infeasible and can not be solved.")
368            else:
369                self.warn(
370                    "The current model is infeasible and can not be solved. Constraints have been relaxed to provide a solution anyway."
371                )
372
373        # Parse the objective value
374        self.objective = self.model.objective.value()
375
376    def get_object_stats(self):
377        """
378        Get the statistics for every `Node` and `Flow` in this `Model`.
379        """
380        return {key: value.get_stats() for key, value in self.objects.items()}
381
382    def add_object(self, obj: [Node, Flow]):
383        """
384        Adds a `Node` or `Flow` to this model
385
386        Requires:
387
388        - `obj`:
389            - Type: Flow object | Node object
390            - What: A `Node` or `Flow` to be added to this model
391        """
392        if obj.name in self.objects.keys():
393            self.exception(f"Duplicate name detected: `{obj.name}`")
394        if isinstance(obj, (Flow)):
395            obj.add_flows(self.objects)
396        self.objects[obj.name] = obj
Model(name: str, objects: [<class 'dict'>, <class 'NoneType'>] = None)
317    def __init__(self, name: str, objects: [dict, type(None)] = None):
318        """
319        Initialize a new model object to be solved
320
321        Requires:
322
323        - `name`:
324            - Type: str
325            - What: The name of this model object
326
327        Optional:
328
329        - `objects`:
330            - Type: dict
331            - What: A dictionary that contains any pre aggregated `Node`s or `Flow`s.
332            - Note: This should normally only be used for internal testing. Unless you need to custom functionaility, use the Model.add_object function instead of this.
333            - Default: {}
334        """
335        if objects == None:
336            objects = {}
337        self.name = name
338        self.objects = objects

Initialize a new model object to be solved

Requires:

  • name:
    • Type: str
    • What: The name of this model object

Optional:

  • objects:
    • Type: dict
    • What: A dictionary that contains any pre aggregated Nodes or Flows.
    • Note: This should normally only be used for internal testing. Unless you need to custom functionaility, use the Model.add_object function instead of this.
    • Default: {}
name
objects
def solve(self, pulp_log: bool = False, except_on_infeasible: bool = True):
340    def solve(self, pulp_log: bool = False, except_on_infeasible: bool = True):
341        """
342        Solve this model given all of the Nodes and Flows
343
344        Optional:
345
346        - `pulp_log`:
347            - Type: bool
348            - What: Indicate if the pulp log should be shown in the terminal
349            - Default: False
350        - `except_on_infeasible`:
351            - Type: bool
352            - What: Indicate if the model should throw an exception if it is infeasible
353            - Note: If False, the model will relax constraints until a solution is found
354            - Default: True
355        """
356        # Create PuLP Model
357        self.model = pulp.LpProblem(name=self.name, sense=pulp.LpMaximize)
358        # Set objective function
359        self.model += pulp.lpSum([i.get_objective_fn() for i in self.objects.values()])
360        # Add constraints
361        [i.add_constraints(self.model) for i in self.objects.values()]
362        # Solve the model
363        self.model.solve(pulp.PULP_CBC_CMD(msg=(3 if pulp_log else 0)))
364
365        if self.model.status == -1:
366            if except_on_infeasible:
367                self.exception("The current model is infeasible and can not be solved.")
368            else:
369                self.warn(
370                    "The current model is infeasible and can not be solved. Constraints have been relaxed to provide a solution anyway."
371                )
372
373        # Parse the objective value
374        self.objective = self.model.objective.value()

Solve this model given all of the Nodes and Flows

Optional:

  • pulp_log:
    • Type: bool
    • What: Indicate if the pulp log should be shown in the terminal
    • Default: False
  • except_on_infeasible:
    • Type: bool
    • What: Indicate if the model should throw an exception if it is infeasible
    • Note: If False, the model will relax constraints until a solution is found
    • Default: True
def get_object_stats(self):
376    def get_object_stats(self):
377        """
378        Get the statistics for every `Node` and `Flow` in this `Model`.
379        """
380        return {key: value.get_stats() for key, value in self.objects.items()}

Get the statistics for every Node and Flow in this Model.

def add_object( self, obj: [<class 'Node'>, <class 'Flow'>]):
382    def add_object(self, obj: [Node, Flow]):
383        """
384        Adds a `Node` or `Flow` to this model
385
386        Requires:
387
388        - `obj`:
389            - Type: Flow object | Node object
390            - What: A `Node` or `Flow` to be added to this model
391        """
392        if obj.name in self.objects.keys():
393            self.exception(f"Duplicate name detected: `{obj.name}`")
394        if isinstance(obj, (Flow)):
395            obj.add_flows(self.objects)
396        self.objects[obj.name] = obj

Adds a Node or Flow to this model

Requires:

  • obj:
    • Type: Flow object | Node object
    • What: A Node or Flow to be added to this model