Repository: emadehsan/csp Branch: master Commit: ece925f81e3a Files: 26 Total size: 121.5 KB Directory structure: gitextract_vyf0nlkm/ ├── .gitignore ├── LICENSE ├── Pipfile ├── README.md ├── csp/ │ ├── __init__.py │ ├── read_lengths.py │ └── stock_cutter_1d.py ├── deployment/ │ ├── .editorconfig │ ├── .gitignore │ ├── Procfile │ ├── csp.py │ ├── frontend/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── babel.config.js │ │ ├── package.json │ │ ├── public/ │ │ │ └── index.html │ │ └── src/ │ │ ├── App.vue │ │ ├── assets/ │ │ │ └── style.scss │ │ ├── components/ │ │ │ └── CspTool.vue │ │ └── main.js │ ├── requirements.txt │ ├── server.py │ ├── stock_cutter.py │ └── stock_cutter_1d.py ├── infile.txt └── tests/ └── basic_test.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ __pycache__ .idea ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Emad Ehsan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Pipfile ================================================ [[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [packages] ortools = "*" pytest = "*" matplotlib = "*" typer = "*" [dev-packages] [requires] python_version = "3.9" ================================================ FILE: README.md ================================================ # Cutting Stock Problem Cutting Stock Problem (CSP) deals with planning the cutting of items (rods / sheets) from given stock items (which are usually of fixed size). ## New to Cutting Stock Problem? Understand Visually Video Tutorial on Cutting Stock Problem This implementation of CSP tries to answer > How to minimize number of stock items used while cutting customer order while doing so, it also caters > How to cut the stock for customer orders so that waste is minimum The OR Tools also helps us in calculating the number of possible solutions for your problem. So in addition, we can also compute > In how many ways can we cut given order from fixed size Stock? ## Quick Usage This is how CSP Tools looks in action. Click [CSP Tool](https://emadehsan.com/csp/) to use it CSP Tool ## Libraries * [Google OR-Tools](https://developers.google.com/optimization) ## Quick Start Install [Pipenv](https://pipenv.pypa.io/en/latest/), if not already installed ```sh $ pip3 install --user pipenv ``` Clone this project and install packages ```sh $ git clone https://github.com/emadehsan/csp $ cd csp $ pipenv install # activate env $ pipenv shell ``` ## Run If you run the `stock_cutter_1d.py` file directly, it runs the example which uses 120 as length of stock Rod and generates some customer rods to cut. You can update these at the end of `stock_cutter_1d.py`. ```sh (csp) $ python csp/stock_cutter_1d.py ``` Output: ```sh numRollsUsed 5 Status: OPTIMAL Roll #0: [0.0, [33, 33, 18, 18, 18]] Roll #1: [2.9999999999999925, [33, 30, 18, 18, 18]] Roll #2: [5.999999999999993, [30, 30, 18, 18, 18]] Roll #3: [2.9999999999999987, [33, 33, 33, 18]] Roll #4: [21.0, [33, 33, 33]]``` ``` ![Graph of Output](./github/graph-1d-b.PNG) ### Using input file If you want to describe your inputs in a file, [infile.txt](./infile.txt) describes the expected format ```sh (csp) $ python3 csp/stock_cutter_1d.py infile.txt ``` ## Thinks to keep in mind * Works with integers only: IP (Integer Programming) problems working with integers only. If you have some values that have decimal part, you can multiply all of your inputs with some number that will make them integers (or close estimation). * You cannot specify units: Whether your input is in Inches or Meters, you have to keep a record of that yourself and conversions if any. ## CSP 2D Code for 2-dimensional Cutting Stock Problem is in [`deployment/stock_cutter.py`](deployment/stock_cutter.py) file. The `deployment` directory also contains code for the API server and deploying it on Heroku. ## Resources The whole code for this project is taken from Serge Kruk's * [Practical Python AI Projects: Mathematical Models of Optimization Problems with Google OR-Tools](https://amzn.to/3iPceJD) * [Repository of the code in Serge's book](https://github.com/sgkruk/Apress-AI/) ================================================ FILE: csp/__init__.py ================================================ ================================================ FILE: csp/read_lengths.py ================================================ import pathlib from typing import List import re from math import ceil def get_data(infile:str)->List[float]: """ Reads a file of numbers and returns a list of (count, number) pairs.""" _p = pathlib.Path(infile) input_text = _p.read_text() numbers = [ceil(float(n)) for n in re.findall(r'[0-9.]+', _p.read_text())] quan = [] nr = [] for n in numbers: if n not in nr and n != 0: quan.append(numbers.count(n)) nr.append(n) return list(zip(quan,nr)) ================================================ FILE: csp/stock_cutter_1d.py ================================================ ''' Original Author: Serge Kruk Original Version: https://github.com/sgkruk/Apress-AI/blob/master/cutting_stock.py Updated by: Emad Ehsan ''' from ortools.linear_solver import pywraplp from math import ceil from random import randint import json from read_lengths import get_data import typer from typing import Optional def newSolver(name,integer=False): return pywraplp.Solver(name,\ pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING \ if integer else \ pywraplp.Solver.GLOP_LINEAR_PROGRAMMING) ''' return a printable value ''' def SolVal(x): if type(x) is not list: return 0 if x is None \ else x if isinstance(x,(int,float)) \ else x.SolutionValue() if x.Integer() is False \ else int(x.SolutionValue()) elif type(x) is list: return [SolVal(e) for e in x] def ObjVal(x): return x.Objective().Value() def gen_data(num_orders): R=[] # small rolls # S=0 # seed? for i in range(num_orders): R.append([randint(1,12), randint(5,40)]) return R def solve_model(demands, parent_width=100): ''' demands = [ [1, 3], # [quantity, width] [3, 5], ... ] parent_width = integer ''' num_orders = len(demands) solver = newSolver('Cutting Stock', True) k,b = bounds(demands, parent_width) # array of boolean declared as int, if y[i] is 1, # then y[i] Big roll is used, else it was not used y = [ solver.IntVar(0, 1, f'y_{i}') for i in range(k[1]) ] # x[i][j] = 3 means that small-roll width specified by i-th order # must be cut from j-th order, 3 tmies x = [[solver.IntVar(0, b[i], f'x_{i}_{j}') for j in range(k[1])] \ for i in range(num_orders)] unused_widths = [ solver.NumVar(0, parent_width, f'w_{j}') \ for j in range(k[1]) ] # will contain the number of big rolls used nb = solver.IntVar(k[0], k[1], 'nb') # consntraint: demand fullfilment for i in range(num_orders): # small rolls from i-th order must be at least as many in quantity # as specified by the i-th order solver.Add(sum(x[i][j] for j in range(k[1])) >= demands[i][0]) # constraint: max size limit for j in range(k[1]): # total width of small rolls cut from j-th big roll, # must not exceed big rolls width solver.Add( \ sum(demands[i][1]*x[i][j] for i in range(num_orders)) \ <= parent_width*y[j] \ ) # width of j-th big roll - total width of all orders cut from j-th roll # must be equal to unused_widths[j] # So, we are saying that assign unused_widths[j] the remaining width of j'th big roll solver.Add(parent_width*y[j] - sum(demands[i][1]*x[i][j] for i in range(num_orders)) == unused_widths[j]) ''' Book Author's note from page 201: [the following constraint] breaks the symmetry of multiple solutions that are equivalent for our purposes: any permutation of the rolls. These permutations, and there are K! of them, cause most solvers to spend an exorbitant time solving. With this constraint, we tell the solver to prefer those permutations with more cuts in roll j than in roll j + 1. The reader is encouraged to solve a medium-sized problem with and without this symmetry-breaking constraint. I have seen problems take 48 hours to solve without the constraint and 48 minutes with. Of course, for problems that are solved in seconds, the constraint will not help; it may even hinder. But who cares if a cutting stock instance solves in two or in three seconds? We care much more about the difference between two minutes and three hours, which is what this constraint is meant to address ''' if j < k[1]-1: # k1 = total big rolls # total small rolls of i-th order cut from j-th big roll must be >= # totall small rolls of i-th order cut from j+1-th big roll solver.Add(sum(x[i][j] for i in range(num_orders)) >= sum(x[i][j+1] for i in range(num_orders))) # find & assign to nb, the number of big rolls used solver.Add(nb == solver.Sum(y[j] for j in range(k[1]))) ''' minimize total big rolls used let's say we have y = [1, 0, 1] here, total big rolls used are 2. 0-th and 2nd. 1st one is not used. So we want our model to use the earlier rolls first. i.e. y = [1, 1, 0]. The trick to do this is to define the cost of using each next roll to be higher. So the model would be forced to used the initial rolls, when available, instead of the next rolls. So instead of Minimize ( Sum of y ) or Minimize( Sum([1,1,0]) ) we Minimize( Sum([1*1, 1*2, 1*3]) ) ''' ''' Book Author's note from page 201: There are alternative objective functions. For example, we could have minimized the sum of the waste. This makes sense, especially if the demand constraint is formulated as an inequality. Then minimizing the sum of waste Chapter 7 advanCed teChniques will spend more CPU cycles trying to find more efficient patterns that over-satisfy demand. This is especially good if the demand widths recur regularly and storing cut rolls in inventory to satisfy future demand is possible. Note that the running time will grow quickly with such an objective function ''' Cost = solver.Sum((j+1)*y[j] for j in range(k[1])) solver.Minimize(Cost) status = solver.Solve() numRollsUsed = SolVal(nb) return status, \ numRollsUsed, \ rolls(numRollsUsed, SolVal(x), SolVal(unused_widths), demands), \ SolVal(unused_widths), \ solver.WallTime() def bounds(demands, parent_width=100): ''' b = [sum of widths of individual small rolls of each order] T = local var. stores sum of widths of adjecent small-rolls. When the width reaches 100%, T is set to 0 again. k = [k0, k1], k0 = minimum big-rolls requierd, k1: number of big rolls that can be consumed / cut from TT = local var. stores sum of widths of of all small-rolls. At the end, will be used to estimate lower bound of big-rolls ''' num_orders = len(demands) b = [] T = 0 k = [0,1] TT = 0 for i in range(num_orders): # q = quantity, w = width; of i-th order quantity, width = demands[i][0], demands[i][1] # TODO Verify: why min of quantity, parent_width/width? # assumes widths to be entered as percentage # int(round(parent_width/demands[i][1])) will always be >= 1, because widths of small rolls can't exceed parent_width (which is width of big roll) # b.append( min(demands[i][0], int(round(parent_width / demands[i][1]))) ) b.append( min(quantity, int(round(parent_width / width))) ) # if total width of this i-th order + previous order's leftover (T) is less than parent_width # it's fine. Cut it. if T + quantity*width <= parent_width: T, TT = T + quantity*width, TT + quantity*width # else, the width exceeds, so we have to cut only as much as we can cut from parent_width width of the big roll else: while quantity: if T + width <= parent_width: T, TT, quantity = T + width, TT + width, quantity-1 else: k[1],T = k[1]+1, 0 # use next roll (k[1] += 1) k[0] = int(round(TT/parent_width+0.5)) print('k', k) print('b', b) return k, b ''' nb: array of number of rolls to cut, of each order w: demands: [ [quantity, width], [quantity, width], [quantity, width], ] ''' def rolls(nb, x, w, demands): consumed_big_rolls = [] num_orders = len(x) # go over first row (1st order) # this row contains the list of all the big rolls available, and if this 1st (0-th) order # is cut from any big roll, that big roll's index would contain a number > 0 for j in range(len(x[0])): # w[j]: width of j-th big roll # int(x[i][j]) * [demands[i][1]] width of all i-th order's small rolls that are to be cut from j-th big roll RR = [ abs(w[j])] + [ int(x[i][j])*[demands[i][1]] for i in range(num_orders) \ if x[i][j] > 0 ] # if i-th order has some cuts from j-th order, x[i][j] would be > 0 consumed_big_rolls.append(RR) return consumed_big_rolls ''' this model starts with some patterns and then optimizes those patterns ''' def solve_large_model(demands, parent_width=100): num_orders = len(demands) iter = 0 patterns = get_initial_patterns(demands) # print('method#solve_large_model, patterns', patterns) # list quantities of orders quantities = [demands[i][0] for i in range(num_orders)] print('quantities', quantities) while iter < 20: status, y, l = solve_master(patterns, quantities, parent_width=parent_width) iter += 1 # list widths of orders widths = [demands[i][1] for i in range(num_orders)] new_pattern, objectiveValue = get_new_pattern(l, widths, parent_width=parent_width) # print('method#solve_large_model, new_pattern', new_pattern) # print('method#solve_large_model, objectiveValue', objectiveValue) for i in range(num_orders): # add i-th cut of new pattern to i-thp pattern patterns[i].append(new_pattern[i]) status, y, l = solve_master(patterns, quantities, parent_width=parent_width, integer=True) return status, \ patterns, \ y, \ rolls_patterns(patterns, y, demands, parent_width=parent_width) ''' Dantzig-Wolfe decomposition splits the problem into a Master Problem MP and a sub-problem SP. The Master Problem: provided a set of patterns, find the best combination satisfying the demand C: patterns b: demand ''' def solve_master(patterns, quantities, parent_width=100, integer=False): title = 'Cutting stock master problem' num_patterns = len(patterns) n = len(patterns[0]) # print('**num_patterns x n: ', num_patterns, 'x', n) # print('**patterns recived:') # for p in patterns: # print(p) constraints = [] solver = newSolver(title, integer) # y is not boolean, it's an integer now (as compared to y in approach used by solve_model) y = [ solver.IntVar(0, 1000, '') for j in range(n) ] # right bound? # minimize total big rolls (y) used Cost = sum(y[j] for j in range(n)) solver.Minimize(Cost) # for every pattern for i in range(num_patterns): # add constraint that this pattern (demand) must be met # there are m such constraints, for each pattern constraints.append(solver.Add( sum(patterns[i][j]*y[j] for j in range(n)) >= quantities[i]) ) status = solver.Solve() y = [int(ceil(e.SolutionValue())) for e in y] l = [0 if integer else constraints[i].DualValue() for i in range(num_patterns)] # sl = [0 if integer else constraints[i].name() for i in range(num_patterns)] # print('sl: ', sl) # l = [0 if integer else u[i].Ub() for i in range(m)] toreturn = status, y, l # l_to_print = [round(dd, 2) for dd in toreturn[2]] # print('l: ', len(l_to_print), '->', l_to_print) # print('l: ', toreturn[2]) return toreturn def get_new_pattern(l, w, parent_width=100): solver = newSolver('Cutting stock sub-problem', True) n = len(l) new_pattern = [ solver.IntVar(0, parent_width, '') for i in range(n) ] # maximizes the sum of the values times the number of occurrence of that roll in a pattern Cost = sum( l[i] * new_pattern[i] for i in range(n)) solver.Maximize(Cost) # ensuring that the pattern stays within the total width of the large roll solver.Add( sum( w[i] * new_pattern[i] for i in range(n)) <= parent_width ) status = solver.Solve() return SolVal(new_pattern), ObjVal(solver) ''' the initial patterns must be such that they will allow a feasible solution, one that satisfies all demands. Considering the already complex model, let’s keep it simple. Our initial patterns have exactly one roll per pattern, as obviously feasible as inefficient. ''' def get_initial_patterns(demands): num_orders = len(demands) return [[0 if j != i else 1 for j in range(num_orders)]\ for i in range(num_orders)] def rolls_patterns(patterns, y, demands, parent_width=100): R, m, n = [], len(patterns), len(y) for j in range(n): for _ in range(y[j]): RR = [] for i in range(m): if patterns[i][j] > 0: RR.extend( [demands[i][1]] * int(patterns[i][j]) ) used_width = sum(RR) R.append([parent_width - used_width, RR]) return R ''' checks if all small roll widths (demands) smaller than parent roll's width ''' def checkWidths(demands, parent_width): for quantity, width in demands: if width > parent_width: print(f'Small roll width {width} is greater than parent rolls width {parent_width}. Exiting') return False return True ''' params child_rolls: list of lists, each containing quantity & width of rod / roll to be cut e.g.: [ [quantity, width], [quantity, width], ...] parent_rolls: list of lists, each containing quantity & width of rod / roll to cut from e.g.: [ [quantity, width], [quantity, width], ...] ''' def StockCutter1D(child_rolls, parent_rolls, output_json=True, large_model=True): # at the moment, only parent one width of parent rolls is supported # quantity of parent rolls is calculated by algorithm, so user supplied quantity doesn't matter? # TODO: or we can check and tell the user the user when parent roll quantity is insufficient parent_width = parent_rolls[0][1] if not checkWidths(demands=child_rolls, parent_width=parent_width): return [] print('child_rolls', child_rolls) print('parent_rolls', parent_rolls) if not large_model: print('Running Small Model...') status, numRollsUsed, consumed_big_rolls, unused_roll_widths, wall_time = \ solve_model(demands=child_rolls, parent_width=parent_width) # convert the format of output of solve_model to be exactly same as solve_large_model print('consumed_big_rolls before adjustment: ', consumed_big_rolls) new_consumed_big_rolls = [] for big_roll in consumed_big_rolls: if len(big_roll) < 2: # sometimes the solve_model return a solution that contanis an extra [0.0] entry for big roll consumed_big_rolls.remove(big_roll) continue unused_width = big_roll[0] subrolls = [] for subitem in big_roll[1:]: if isinstance(subitem, list): # if it's a list, concatenate with the other lists, to make a single list for this big_roll subrolls = subrolls + subitem else: # if it's an integer, add it to the list subrolls.append(subitem) new_consumed_big_rolls.append([unused_width, subrolls]) print('consumed_big_rolls after adjustment: ', new_consumed_big_rolls) consumed_big_rolls = new_consumed_big_rolls else: print('Running Large Model...'); status, A, y, consumed_big_rolls = solve_large_model(demands=child_rolls, parent_width=parent_width) numRollsUsed = len(consumed_big_rolls) # print('A:', A, '\n') # print('y:', y, '\n') STATUS_NAME = ['OPTIMAL', 'FEASIBLE', 'INFEASIBLE', 'UNBOUNDED', 'ABNORMAL', 'NOT_SOLVED' ] output = { "statusName": STATUS_NAME[status], "numSolutions": '1', "numUniqueSolutions": '1', "numRollsUsed": numRollsUsed, "solutions": consumed_big_rolls # unique solutions } # print('Wall Time:', wall_time) print('numRollsUsed', numRollsUsed) print('Status:', output['statusName']) print('Solutions found :', output['numSolutions']) print('Unique solutions: ', output['numUniqueSolutions']) if output_json: return json.dumps(output) else: return consumed_big_rolls ''' Draws the big rolls on the graph. Each horizontal colored line represents one big roll. In each big roll (multi-colored horizontal line), each color represents small roll to be cut from it. If the big roll ends with a black color, that part of the big roll is unused width. TODO: Assign each child roll a unique color ''' def drawGraph(consumed_big_rolls, child_rolls, parent_width): import matplotlib.pyplot as plt import matplotlib.patches as patches # TODO: to add support for multiple different parent rolls, update here xSize = parent_width # width of big roll ySize = 10 * len(consumed_big_rolls) # one big roll will take 10 units vertical space # draw rectangle fig,ax = plt.subplots(1) plt.xlim(0, xSize) plt.ylim(0, ySize) plt.gca().set_aspect('equal', adjustable='box') # print coords coords = [] colors = ['r', 'g', 'b', 'y', 'brown', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] colorDict = {} i = 0 for quantity, width in child_rolls: colorDict[width] = colors[i % 11] i+= 1 # start plotting each big roll horizontly, from the bottom y1 = 0 for i, big_roll in enumerate(consumed_big_rolls): ''' big_roll = [leftover_width, [small_roll_1_1, small_roll_1_2, other_small_roll_2_1]] ''' unused_width = big_roll[0] small_rolls = big_roll[1] x1 = 0 x2 = 0 y2 = y1 + 8 # the height of each big roll will be 8 for j, small_roll in enumerate(small_rolls): x2 = x2 + small_roll print(f"{x1}, {y1} -> {x2}, {y2}") width = abs(x1-x2) height = abs(y1-y2) # print(f"Rect#{idx}: {width}x{height}") # Create a Rectangle patch rect_shape = patches.Rectangle((x1,y1), width, height, facecolor=colorDict[small_roll], label=f'{small_roll}') ax.add_patch(rect_shape) # Add the patch to the Axes x1 = x2 # x1 for next small roll in same big roll will be x2 of current roll # now that all small rolls have been plotted, check if a there is unused width in this big roll # set the unused width at the end as black colored rectangle if unused_width > 0: width = unused_width rect_shape = patches.Rectangle((x1,y1), width, height, facecolor='black', label='Unused') ax.add_patch(rect_shape) # Add the patch to the Axes y1 += 10 # next big roll will be plotted on top of current, a roll height is 8, so 2 will be margin between rolls plt.show() if __name__ == '__main__': # child_rolls = [ # [quantity, width], # ] app = typer.Typer() def main(infile_name: Optional[str] = typer.Argument(None)): if infile_name: child_rolls = get_data(infile_name) else: child_rolls = gen_data(3) parent_rolls = [[10, 120]] # 10 doesn't matter, itls not used at the moment consumed_big_rolls = StockCutter1D(child_rolls, parent_rolls, output_json=False, large_model=False) typer.echo(f"{consumed_big_rolls}") for idx, roll in enumerate(consumed_big_rolls): typer.echo(f"Roll #{idx}:{roll}") drawGraph(consumed_big_rolls, child_rolls, parent_width=parent_rolls[0][1]) if __name__ == "__main__": typer.run(main) ================================================ FILE: deployment/.editorconfig ================================================ # Editor configuration, see http://editorconfig.org # source: https://stackoverflow.com/a/51398290/3578289 root = true [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: deployment/.gitignore ================================================ node_modules/ __pycache__ server/__pycache__/ *.csv .DS_Store frontend/dist/ # local env files .env.local .env.*.local # Log files npm-debug.log* yarn-debug.log* yarn-error.log* # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw* ================================================ FILE: deployment/Procfile ================================================ web: gunicorn server:app ================================================ FILE: deployment/csp.py ================================================ from __future__ import print_function import collections, json from ortools.sat.python import cp_model # to draw rectangles import matplotlib.pyplot as plt import matplotlib.patches as patches """ Cutting Stock problem params child_rects: lists of multiple rectangles' coords e.g.: [ [w, h], [w, h], ...] parent_rects: rectangle coords lists of multiple rectangles' coords e.g.: [ [w, h], [w, h], ...] """ def StockCutter(child_rects, parent_rects): # Create the model model = cp_model.CpModel() # parent rect (to cut from). horizon = [ width, height ] of parent sheet # for now, parent rectangle is just one # TODO: to add functionality of cutting from multiple parent sheets, start here: horizon = parent_rects[0] # Named Tuple to store information about created variables sheet_type = collections.namedtuple('sheet_type', 'x1 y1 x2 y2 x_interval y_interval') # Store for all model variables all_vars = {} # sum of to save area of all small rects, to cut from parent rect total_child_area = 0 # hold the widths (x) and heights (y) interval vars of each sheet x_intervals = [] y_intervals = [] # create model vars and intervals for rect_id, rect in enumerate(child_rects): width = rect[0] height = rect[1] area = width * height total_child_area += area # print(f"Rect: {width}x{height}, Area: {area}") suffix = '_%i_%i' % (width, height) # interval to represent width. max value can be the width of parent rect x1_var = model.NewIntVar(0, horizon[0], 'x1' + suffix) x2_var = model.NewIntVar(0, horizon[0], 'x2' + suffix) x_interval_var = model.NewIntervalVar(x1_var, width, x2_var, 'x_interval' + suffix) # interval to represent height. max value can be the height of parent rect y1_var = model.NewIntVar(0, horizon[1], 'y1' + suffix) y2_var = model.NewIntVar(0, horizon[1], 'y2' + suffix) y_interval_var = model.NewIntervalVar(y1_var, height, y2_var, 'y_interval' + suffix) x_intervals.append(x_interval_var) y_intervals.append(y_interval_var) # store the variables for later use all_vars[rect_id] = sheet_type( x1=x1_var, y1=y1_var, x2=x2_var, y2=y2_var, x_interval=x_interval_var, y_interval=y_interval_var ) # add constraint: no over lap of rectangles allowed model.AddNoOverlap2D(x_intervals, y_intervals) # Solve model solver = cp_model.CpSolver() solution_printer = VarArraySolutionPrinter(all_vars) # Search for all solutions is only defined on satisfiability problems status = solver.SearchForAllSolutions(model, solution_printer) # use for satisfiability problem # status = solver.Solve(model) # use for Optimization Problem print('Status:', solver.StatusName(status)) print('Solutions found :', solution_printer.solution_count()) solutions = solution_printer.get_unique_solutions() # call draw methods here, if want to draw with matplotlib int_solutions = solutions_to_int(solutions) statusName = solver.StatusName(status) numSolutions = solution_printer.solution_count() numUniqueSolutions = len(solutions) output = { "statusName": statusName, "numSolutions": numSolutions, "numUniqueSolutions": numUniqueSolutions, "solutions": int_solutions # unique solutions } # return json.dumps(output) # draw for idx, sol in enumerate(solutions): # sol is string of coordinates of all rectangles in this solution # format: x1,y1,x2,y2-x1,y1,x2,y2 print('Sol#', idx) rect_strs = sol.split('-') rect_coords = [ # [x1,y1,x2,y2], # [x1,y1,x2,y2], ] for rect_str in rect_strs: coords_str = rect_str.split(',') coords = [int(c) for c in coords_str] rect_coords.append(coords) print('rect_coords') # print(rect_coords) drawRectsFromCoords(rect_coords) def drawRectsFromCoords(rect_coords): # draw rectangle fig,ax = plt.subplots(1) plt.xlim(0,6) # todo 7 plt.ylim(0,6) plt.gca().set_aspect('equal', adjustable='box') # print coords coords = [] colors = ['r', 'g', 'b', 'y', 'brown', 'black', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] for idx, coords in enumerate(rect_coords): x1=coords[0] y1=coords[1] x2=coords[2] y2=coords[3] # print(f"{x1}, {y1} -> {x2}, {y2}") width = abs(x1-x2) height = abs(y1-y2) # print(f"Rect#{idx}: {width}x{height}") # Create a Rectangle patch rect_shape = patches.Rectangle((x1,y1), width, height,facecolor=colors[idx]) # Add the patch to the Axes ax.add_patch(rect_shape) plt.show() """ TODO complete this, add to git params: str_solutions: list of strings. 1 string contains is solution """ def solutions_to_int(str_solutions): # list of solutions, each solution is a list of rectangle coords that look like [x1,y1,x2,y2] int_solutions = [] # go over all solutions and convert them to int>list>json for idx, sol in enumerate(str_solutions): # sol is string of coordinates of all rectangles in this solution # format: x1,y1,x2,y2-x1,y1,x2,y2 rect_strs = sol.split('-') rect_coords = [ # [x1,y1,x2,y2], # [x1,y1,x2,y2], # ... ] # convert each rectangle's coords to int for rect_str in rect_strs: coords_str = rect_str.split(',') coords = [int(c) for c in coords_str] rect_coords.append(coords) # print('rect_coords', rect_coords) # call draw methods here, if want to draw individual solutions with matplotlib int_solutions.append(rect_coords) return int_solutions """ To get all the solutions of the problem, as they come up. https://developers.google.com/optimization/cp/cp_solver#all_solutions The solutions are all unique. But for the child rectangles that have same dimensions, some solution will be repetitive. Because for the algorithm, they are different solutions, but because of same size, they are merely permutations of the similar child rectangles - having other rectangles' positions fixed. We want to remove repetitive extra solutions. One way to do this is 1. Stringify every rectangle coords in a solution (1,2)->(2,3) becomes "1,2,2,3" # here the rectangles are stored as a string: "1,2,2,3" where x1=1, y1=2, x2=2, y2=3 2. Put all these string coords into a sorted list. This sorting is important. Because the rectangles (1,2)->(2,3) and (3,3)->(4,4) are actually same size (1x1) rectangles. And they can appear in 1st solution as [(1,2)->(2,3) , (3,3)->(4,4)] and in the 2nd solution as [(3,3)->(4,4) , (1,2)->(2,3)] but this sorted list of strings will ensure both solutions are represented as [..., "1,2,2,3", "3,3,4,4", ...] 3. Join the Set of "strings (rectangles)" in to one big string seperated by '-'. For every solution. So in resulting big strings (solutions), we will have two similar strings (solutions) that be similar and also contain "....1,2,2,3-3,3,4,4-...." 4. Now add all these "strings (solutions)" into a Set. this adding to the set will remove similar strings. And hence duplicate solutions will be removed. """ class VarArraySolutionPrinter(cp_model.CpSolverSolutionCallback): def __init__(self, variables): cp_model.CpSolverSolutionCallback.__init__(self) self.__variables = variables self.__solution_count = 0 # hold the calculated solutions self.__solutions = [] self.__unique_solutions = set() def on_solution_callback(self): self.__solution_count += 1 # print('Sol#: ', self.__solution_count) # using list to hold the coordinate strings of rectangles rect_strs = [] # extra coordinates of all rectangles for this solution for rect_id in self.__variables: rect = self.__variables[rect_id] x1 = self.Value(rect.x1) x2 = self.Value(rect.x2) y1 = self.Value(rect.y1) y2 = self.Value(rect.y2) rect_str = f"{x1},{y1},{x2},{y2}" # print(rect_str) rect_strs.append(rect_str) # print(f'Rect #{rect_id}: {x1},{y1} -> {x2},{y2}') # print(rect_strs) # sort the rectangles rect_strs = sorted(rect_strs) # single solution as a string solution_str = '-'.join(rect_strs) # print(solution_str) # store the solutions self.__solutions.append(solution_str) self.__unique_solutions.add(solution_str) # __unique_solutions is a set, so duplicates will get removed def solution_count(self): return self.__solution_count # returns all solutions def get_solutions(self): return self.__solutions """ returns unique solutions returns the permutation free list of solution strings """ def get_unique_solutions(self): return list(self.__unique_solutions) # __unique_solutions is a Set, convert to list # for testing if __name__ == '__main__': child_rects = [ # [2, 2], # [1, 3], # [4, 3], # [1, 1], # [2, 4], [3, 3], [3, 3], [3, 3], [3, 3], ] parent_rects = [[6,6]] StockCutter(child_rects, parent_rects) ================================================ FILE: deployment/frontend/.gitignore ================================================ .DS_Store node_modules /dist # local env files .env.local .env.*.local # Log files npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # Editor directories and files .idea .vscode *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: deployment/frontend/README.md ================================================ # frontend ## Project setup ``` npm install ``` ### Compiles and hot-reloads for development ``` npm run serve ``` ### Compiles and minifies for production ``` npm run build ``` ### Lints and fixes files ``` npm run lint ``` ### Customize configuration See [Configuration Reference](https://cli.vuejs.org/config/). ================================================ FILE: deployment/frontend/babel.config.js ================================================ module.exports = { presets: [ '@vue/cli-plugin-babel/preset' ] } ================================================ FILE: deployment/frontend/package.json ================================================ { "name": "frontend", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "lint": "vue-cli-service lint" }, "dependencies": { "axios": "^0.21.1", "bootstrap": "^5.1.0", "bootstrap-vue": "^2.21.2", "core-js": "^3.6.5", "d3": "^7.0.1", "vue": "^3.2.6" }, "devDependencies": { "@types/d3": "^7.0.0", "@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-eslint": "~4.5.0", "@vue/cli-service": "~4.5.0", "@vue/compiler-sfc": "^3.0.0", "babel-eslint": "^10.1.0", "eslint": "^6.7.2", "eslint-plugin-vue": "^7.0.0" }, "eslintConfig": { "root": true, "env": { "node": true }, "extends": [ "plugin:vue/vue3-essential", "eslint:recommended" ], "parserOptions": { "parser": "babel-eslint" }, "rules": {} }, "browserslist": [ "> 1%", "last 2 versions", "not dead" ] } ================================================ FILE: deployment/frontend/public/index.html ================================================ <%= htmlWebpackPlugin.options.title %>
================================================ FILE: deployment/frontend/src/App.vue ================================================ ================================================ FILE: deployment/frontend/src/assets/style.scss ================================================ body { /* font-size: 0.9em; */ background-color: #ecf0f1; } .btn { padding: 2px 8px; } input { border: 0px solid #000; margin: 0; background: transparent; width: 100%; } table tr td { border-right: 1px solid #000; border-bottom: 1px solid #000; } table { background: #fff none repeat scroll 0 0; border-left: 1px solid #000; border-top: 1px solid #000; } table tr:nth-child(even) { background: #95a5a6; } table tr:nth-child(odd) { background: #bdc3c7; } .active { font-weight: bold; } .information { border-radius: 100%; background: #444; color: white; padding: 2px 5px; font-size: 80%; font-weight: bold; } ================================================ FILE: deployment/frontend/src/components/CspTool.vue ================================================ ================================================ FILE: deployment/frontend/src/main.js ================================================ import { createApp } from 'vue' // import Vue from 'vue' // import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' // Import Bootstrap an BootstrapVue CSS files (order is important) import 'bootstrap/dist/css/bootstrap.css' // import 'bootstrap-vue/dist/bootstrap-vue.css' // Make BootstrapVue available throughout your project // Vue.use(BootstrapVue) // Optionally install the BootstrapVue icon components plugin // Vue.use(IconsPlugin) import App from './App.vue' createApp(App).mount('#app') ================================================ FILE: deployment/requirements.txt ================================================ gunicorn Flask Flask-Cors ortools ================================================ FILE: deployment/server.py ================================================ from flask import Flask, json, request from flask_cors import CORS, cross_origin import stock_cutter # local module app = Flask(__name__) cors = CORS(app) app.config['CORS_HEADERS'] = 'Content-Type' @app.route('/', methods=['GET']) @cross_origin() def get_csp(): return 'Cutting Stock Problem' ''' route for receving data for 1D problem ''' @app.route('/stocks_1d', methods=['POST']) @cross_origin() def post_stocks_1d(): ''' expects two params to be present child_rolls: array of arrays. E.g [ [quantity, width], [quantity, width], ... ] parent_rolls: array of arrays. E.g [ [quantity, width], [quantity, width], ... ] ''' import stock_cutter_1d data = request.json print('data: ', data) child_rolls = data['child_rolls'] parent_rolls = data['parent_rolls'] ''' it can be exactCuts: cut exactly as many as specified by user minWaste: cut some items, more than specified, to avoid waste ''' cutStyle = data['cutStyle'] # output = stock_cutter_1d.StockCutter1D(child_rolls, parent_rolls, cutStyle=cutStyle) output = stock_cutter_1d.StockCutter1D(child_rolls, parent_rolls, large_model=False, cutStyle=cutStyle) return output ''' route for 2D ''' @app.route('/stocks_2d', methods=['POST']) @cross_origin() def post_stocks(): ''' expects two params to be present child_rects: array of arrays. Each inner array is like [w, h] i.e. width & height of rectangle parent_rects: array of arrays. Each inner array is like [w, h] i.e. width & height of rectangle ''' data = request.json print('data: ', data) child_rects = data['child_rects'] parent_rects = data['parent_rects'] output = stock_cutter.StockCutter(child_rects, parent_rects) return output if __name__ == '__main__': # app.run() app.run(threaded=True, port=5000) ================================================ FILE: deployment/stock_cutter.py ================================================ ''' @Author Emad Ehsan Cutting Stock problem 2D Not complete. What's remaining: Finding Optimized solution that minimizes the waste. ''' import collections, json from ortools.sat.python import cp_model """ params child_rects: lists of multiple rectangles' coords e.g.: [ [w, h], [w, h], ...] parent_rects: rectangle coords lists of multiple rectangles' coords e.g.: [ [w, h], [w, h], ...] """ def StockCutter(child_rects, parent_rects, output_json=True): # Create the model model = cp_model.CpModel() # parent rect (to cut from). horizon = [ width, height ] of parent sheet # for now, parent rectangle is just one # TODO: to add functionality of cutting from multiple parent sheets, start here: horizon = parent_rects[0] total_parent_area = horizon[0] * horizon[1] # width x height # Named Tuple to store information about created variables sheet_type = collections.namedtuple('sheet_type', 'x1 y1 x2 y2 x_interval y_interval is_extra') # Store for all model variables all_vars = {} # sum of to save area of all small rects, to cut from parent rect total_child_area = 0 # hold the widths (x) and heights (y) interval vars of each sheet x_intervals = [] y_intervals = [] # create model vars and intervals for rect_id, rect in enumerate(child_rects): width = rect[0] height = rect[1] area = width * height total_child_area += area # print(f"Rect: {width}x{height}, Area: {area}") suffix = '_%i_%i' % (width, height) # interval to represent width. max value can be the width of parent rect x1_var = model.NewIntVar(0, horizon[0], 'x1' + suffix) x2_var = model.NewIntVar(0, horizon[0], 'x2' + suffix) x_interval_var = model.NewIntervalVar(x1_var, width, x2_var, 'x_interval' + suffix) # interval to represent height. max value can be the height of parent rect y1_var = model.NewIntVar(0, horizon[1], 'y1' + suffix) y2_var = model.NewIntVar(0, horizon[1], 'y2' + suffix) y_interval_var = model.NewIntervalVar(y1_var, height, y2_var, 'y_interval' + suffix) x_intervals.append(x_interval_var) y_intervals.append(y_interval_var) # store the variables for later use all_vars[rect_id] = sheet_type( x1=x1_var, y1=y1_var, x2=x2_var, y2=y2_var, x_interval=x_interval_var, y_interval=y_interval_var, is_extra=False # to keep track of 1x1 custom rects added in next step ) # model.Minimize(x1_var) # model.Minimize(y1_var) # TODO: Minimize (x1,y1) values. So that rectangles are placed at the start # this reduced the areas wasted by place rectangles in the middle / at the end # even though the space at the start is available. # > # for rect_id in range(len(child_rects)): # model.Minimize(all_vars[rect_id].x1 + all_vars[rect_id].y1) # model.Minimize(all_vars[rect_id].x2 + all_vars[rect_id].y2) # model.Minimize(all_vars[rect_id].x1) # model.Minimize(all_vars[rect_id].x2) # model.Minimize(all_vars[rect_id].y1) # model.Minimize(all_vars[rect_id].y2) ''' FIXME: experiment Experment: treat the remaining area as small units of 1x1 rectangles. Push these rects to higher x,y. ''' # leftover_area = total_parent_area - total_child_area # if leftover_area >= 0: # ''' # each unit of leftover_area can be represented by 1x1 rectangles. # For leftover_area = 4 (e.g. 2x2 originally), we can use 4 rects of 1x1. Why? Because # 1. leftover_area would not always be continous. It is possible it is in the form of two # separate 2x1 rects or one 2x2 or four rects of 1x1. So we need the simplest version, # that can cover all types of rects. And it is 1x1 # 2. 1x1 can represent non-adjecent weirdly shaped locations in the parent area that were leftover. # ''' # num_1x1rects = leftover_area # for i in range(num_1x1rects): # print(f'{i}-th 1x1') # suffix = '_%i_%i' % (1, 1) # # interval to represent width. max value can be the width of parent rect # x1_var = model.NewIntVar(0, horizon[0], 'x1' + suffix) # x2_var = model.NewIntVar(0, horizon[0], 'x2' + suffix) # x_interval_var = model.NewIntervalVar(x1_var, 1, x2_var, 'x_interval' + suffix) # # interval to represent height. max value can be the height of parent rect # y1_var = model.NewIntVar(0, horizon[1], 'y1' + suffix) # y2_var = model.NewIntVar(0, horizon[1], 'y2' + suffix) # y_interval_var = model.NewIntervalVar(y1_var, 1, y2_var, 'y_interval' + suffix) # x_intervals.append(x_interval_var) # y_intervals.append(y_interval_var) # # store the variables for later use # all_vars[rect_id] = sheet_type( # x1=x1_var, # y1=y1_var, # x2=x2_var, # y2=y2_var, # x_interval=x_interval_var, # y_interval=y_interval_var, # is_extra=True # ) # model.Maximize(x1_var) # model.Maximize(y1_var) # else: # print(f'Problem identified: Area of small rects is larger than parent rect by {leftover_area}') # add constraint: no over lap of rectangles allowed model.AddNoOverlap2D(x_intervals, y_intervals) # Solve model solver = cp_model.CpSolver() ''' Search for all solutions is only defined on satisfiability problems ''' # solution_printer = VarArraySolutionPrinter(all_vars) # status = solver.SearchForAllSolutions(model, solution_printer) # use for satisfiability problem # solutions = solution_printer.get_unique_solutions() # int_solutions = str_solutions_to_int(solutions) # output = { # "statusName": solver.StatusName(status), # "numSolutions": solution_printer.solution_count(), # "numUniqueSolutions": len(solutions), # "solutions": int_solutions # unique solutions # } ''' for single solution ''' status = solver.Solve(model) # use for Optimization Problem singleSolution = getSingleSolution(solver, all_vars) int_solutions = [singleSolution] # convert to array output = { "statusName": solver.StatusName(status), "numSolutions": '1', "numUniqueSolutions": '1', "solutions": int_solutions # unique solutions } print('Time:', solver.WallTime()) print('Status:', output['statusName']) print('Solutions found :', output['numSolutions']) print('Unique solutions: ', output['numUniqueSolutions']) if output_json: return json.dumps(output) else: return int_solutions # integer representation of solutions ''' This method is used to extract the single solution from the solver. Because in the case where VarArraySolutionPrinter is not used, the answers are not yet extracted from the solver. Use this method to extract the solver. ''' def getSingleSolution(solver, all_vars): solution = [] # extra coordinates of all rectangles for this solution for rect_id in all_vars: rect = all_vars[rect_id] x1 = solver.Value(rect.x1) x2 = solver.Value(rect.x2) y1 = solver.Value(rect.y1) y2 = solver.Value(rect.y2) # rect_str = f"{x1},{y1},{x2},{y2}" coords = [x1, y1, x2, y2]; # print(rect_str) solution.append(coords) # print(f'Rect #{rect_id}: {x1},{y1} -> {x2},{y2}') # print(rect_strs) # sort the rectangles # rect_strs = sorted(rect_strs) # single solution as a string # solution_str = '-'.join(rect_strs) return solution """ converts from string format to integer values. String format, in previous step, was used to exclude duplicates. params: str_solutions: list of strings. 1 string contains is solution """ def str_solutions_to_int(str_solutions): # list of solutions, each solution is a list of rectangle coords that look like [x1,y1,x2,y2] int_solutions = [] # go over all solutions and convert them to int>list>json for idx, sol in enumerate(str_solutions): # sol is string of coordinates of all rectangles in this solution # format: x1,y1,x2,y2-x1,y1,x2,y2 rect_strs = sol.split('-') rect_coords = [ # [x1,y1,x2,y2], # [x1,y1,x2,y2], # ... ] # convert each rectangle's coords to int for rect_str in rect_strs: coords_str = rect_str.split(',') coords = [int(c) for c in coords_str] rect_coords.append(coords) # print('rect_coords', rect_coords) int_solutions.append(rect_coords) return int_solutions """ To get all the solutions of the problem, as they come up. https://developers.google.com/optimization/cp/cp_solver#all_solutions The solutions are all unique. But for the child rectangles that have same dimensions, some solution will be repetitive. Because for the algorithm, they are different solutions, but because of same size, they are merely permutations of the similar child rectangles - having other rectangles' positions fixed. We want to remove repetitive extra solutions. One way to do this is 1. Stringify every rectangle coords in a solution (1,2)->(2,3) becomes "1,2,2,3" # here the rectangles are stored as a string: "1,2,2,3" where x1=1, y1=2, x2=2, y2=3 2. Put all these string coords into a sorted list. This sorting is important. Because the rectangles (1,2)->(2,3) and (3,3)->(4,4) are actually same size (1x1) rectangles. And they can appear in 1st solution as [(1,2)->(2,3) , (3,3)->(4,4)] and in the 2nd solution as [(3,3)->(4,4) , (1,2)->(2,3)] but this sorted list of strings will ensure both solutions are represented as [..., "1,2,2,3", "3,3,4,4", ...] 3. Join the Set of "strings (rectangles)" in to one big string seperated by '-'. For every solution. So in resulting big strings (solutions), we will have two similar strings (solutions) that be similar and also contain "....1,2,2,3-3,3,4,4-...." 4. Now add all these "strings (solutions)" into a Set. this adding to the set will remove similar strings. And hence duplicate solutions will be removed. """ class VarArraySolutionPrinter(cp_model.CpSolverSolutionCallback): def __init__(self, variables): cp_model.CpSolverSolutionCallback.__init__(self) self.__variables = variables self.__solution_count = 0 # hold the calculated solutions self.__solutions = [] self.__unique_solutions = set() def on_solution_callback(self): self.__solution_count += 1 # print('Sol#: ', self.__solution_count) # using list to hold the coordinate strings of rectangles rect_strs = [] # extra coordinates of all rectangles for this solution for rect_id in self.__variables: rect = self.__variables[rect_id] x1 = self.Value(rect.x1) x2 = self.Value(rect.x2) y1 = self.Value(rect.y1) y2 = self.Value(rect.y2) rect_str = f"{x1},{y1},{x2},{y2}" # print(rect_str) rect_strs.append(rect_str) # print(f'Rect #{rect_id}: {x1},{y1} -> {x2},{y2}') # print(rect_strs) # sort the rectangles rect_strs = sorted(rect_strs) # single solution as a string solution_str = '-'.join(rect_strs) # print(solution_str) # store the solutions self.__solutions.append(solution_str) self.__unique_solutions.add(solution_str) # __unique_solutions is a set, so duplicates will get removed def solution_count(self): return self.__solution_count # returns all solutions def get_solutions(self): return self.__solutions """ returns unique solutions returns the permutation free list of solution strings """ def get_unique_solutions(self): return list(self.__unique_solutions) # __unique_solutions is a Set, convert to list ''' non-API method. Used for testing and running locally / in a Notebook. Draws the rectangles ''' def drawRectsFromCoords(rect_coords, parent_rects): import matplotlib.pyplot as plt import matplotlib.patches as patches # TODO: to add support for multiple parent rects, update here xSize = parent_rects[0][0] ySize = parent_rects[0][1] # draw rectangle fig,ax = plt.subplots(1) plt.xlim(0,xSize) plt.ylim(0,ySize) plt.gca().set_aspect('equal', adjustable='box') # print coords coords = [] colors = ['r', 'g', 'b', 'y', 'brown', 'black', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] for idx, coords in enumerate(rect_coords): x1=coords[0] y1=coords[1] x2=coords[2] y2=coords[3] # print(f"{x1}, {y1} -> {x2}, {y2}") width = abs(x1-x2) height = abs(y1-y2) # print(f"Rect#{idx}: {width}x{height}") # Create a Rectangle patch rect_shape = patches.Rectangle((x1,y1), width, height,facecolor=colors[idx]) # Add the patch to the Axes ax.add_patch(rect_shape) plt.show() # for testing if __name__ == '__main__': child_rects = [ # [1, 1], # [2, 2], # [1, 3], # [4, 3], # [2, 4], # [2, 2], [27, 17], [27, 17], [18, 56], # [3, 3], # [3, 3], # [3, 3], # [3, 3], ] # parent_rects = [[6,6]] parent_rects = [[84,72]] solutions = StockCutter(child_rects, parent_rects, output_json=False) # get the integer solution for sol in solutions: print(sol) drawRectsFromCoords(sol, parent_rects) ================================================ FILE: deployment/stock_cutter_1d.py ================================================ ''' Original Author: Serge Kruk Original Version: https://github.com/sgkruk/Apress-AI/blob/master/cutting_stock.py Updated by: Emad Ehsan V2: https://github.com/emadehsan/Apress-AI/blob/master/my-models/custom_cutting_stock.py V3 is following: ''' from ortools.linear_solver import pywraplp from math import ceil from random import randint import json def newSolver(name,integer=False): return pywraplp.Solver(name,\ pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING \ if integer else \ pywraplp.Solver.GLOP_LINEAR_PROGRAMMING) ''' return a printable value ''' def SolVal(x): if type(x) is not list: return 0 if x is None \ else x if isinstance(x,(int,float)) \ else x.SolutionValue() if x.Integer() is False \ else int(x.SolutionValue()) elif type(x) is list: return [SolVal(e) for e in x] def ObjVal(x): return x.Objective().Value() def gen_data(num_orders): R=[] # small rolls # S=0 # seed? for i in range(num_orders): R.append([randint(1,12), randint(5,40)]) return R def solve_model(demands, parent_width=100, cutStyle='exactCuts'): ''' demands = [ [1, 3], # [quantity, width] [3, 5], ... ] parent_width = integer ''' num_orders = len(demands) solver = newSolver('Cutting Stock', True) k,b = bounds(demands, parent_width) # array of boolean declared as int, if y[i] is 1, # then y[i] Big roll is used, else it was not used y = [ solver.IntVar(0, 1, f'y_{i}') for i in range(k[1]) ] # x[i][j] = 3 means that small-roll width specified by i-th order # must be cut from j-th order, 3 tmies x = [[solver.IntVar(0, b[i], f'x_{i}_{j}') for j in range(k[1])] \ for i in range(num_orders)] unused_widths = [ solver.NumVar(0, parent_width, f'w_{j}') \ for j in range(k[1]) ] # will contain the number of big rolls used nb = solver.IntVar(k[0], k[1], 'nb') # consntraint: demand fullfilment for i in range(num_orders): # small rolls from i-th order must be at least as many in quantity # as specified by the i-th order if cutStyle == 'minWaste': solver.Add(sum(x[i][j] for j in range(k[1])) >= demands[i][0]) else: # probably cutStyle == exactCuts solver.Add(sum(x[i][j] for j in range(k[1])) == demands[i][0]) # constraint: max size limit for j in range(k[1]): # total width of small rolls cut from j-th big roll, # must not exceed big rolls width solver.Add( \ sum(demands[i][1]*x[i][j] for i in range(num_orders)) \ <= parent_width*y[j] \ ) # width of j-th big roll - total width of all orders cut from j-th roll # must be equal to unused_widths[j] # So, we are saying that assign unused_widths[j] the remaining width of j'th big roll solver.Add(parent_width*y[j] - sum(demands[i][1]*x[i][j] for i in range(num_orders)) == unused_widths[j]) ''' Book Author's note from page 201: [the following constraint] breaks the symmetry of multiple solutions that are equivalent for our purposes: any permutation of the rolls. These permutations, and there are K! of them, cause most solvers to spend an exorbitant time solving. With this constraint, we tell the solver to prefer those permutations with more cuts in roll j than in roll j + 1. The reader is encouraged to solve a medium-sized problem with and without this symmetry-breaking constraint. I have seen problems take 48 hours to solve without the constraint and 48 minutes with. Of course, for problems that are solved in seconds, the constraint will not help; it may even hinder. But who cares if a cutting stock instance solves in two or in three seconds? We care much more about the difference between two minutes and three hours, which is what this constraint is meant to address ''' if j < k[1]-1: # k1 = total big rolls # total small rolls of i-th order cut from j-th big roll must be >= # totall small rolls of i-th order cut from j+1-th big roll solver.Add(sum(x[i][j] for i in range(num_orders)) >= sum(x[i][j+1] for i in range(num_orders))) # find & assign to nb, the number of big rolls used solver.Add(nb == solver.Sum(y[j] for j in range(k[1]))) ''' minimize total big rolls used let's say we have y = [1, 0, 1] here, total big rolls used are 2. 0-th and 2nd. 1st one is not used. So we want our model to use the earlier rolls first. i.e. y = [1, 1, 0]. The trick to do this is to define the cost of using each next roll to be higher. So the model would be forced to used the initial rolls, when available, instead of the next rolls. So instead of Minimize ( Sum of y ) or Minimize( Sum([1,1,0]) ) we Minimize( Sum([1*1, 1*2, 1*3]) ) ''' ''' Book Author's note from page 201: There are alternative objective functions. For example, we could have minimized the sum of the waste. This makes sense, especially if the demand constraint is formulated as an inequality. Then minimizing the sum of waste Chapter 7 advanCed teChniques will spend more CPU cycles trying to find more efficient patterns that over-satisfy demand. This is especially good if the demand widths recur regularly and storing cut rolls in inventory to satisfy future demand is possible. Note that the running time will grow quickly with such an objective function ''' Cost = solver.Sum((j+1)*y[j] for j in range(k[1])) solver.Minimize(Cost) status = solver.Solve() numRollsUsed = SolVal(nb) return status, \ numRollsUsed, \ rolls(numRollsUsed, SolVal(x), SolVal(unused_widths), demands), \ SolVal(unused_widths), \ solver.WallTime() def bounds(demands, parent_width=100): ''' b = [sum of widths of individual small rolls of each order] T = local var. stores sum of widths of adjecent small-rolls. When the width reaches 100%, T is set to 0 again. k = [k0, k1], k0 = minimum big-rolls requierd, k1: number of big rolls that can be consumed / cut from TT = local var. stores sum of widths of of all small-rolls. At the end, will be used to estimate lower bound of big-rolls ''' num_orders = len(demands) b = [] T = 0 k = [0,1] TT = 0 for i in range(num_orders): # q = quantity, w = width; of i-th order quantity, width = demands[i][0], demands[i][1] # TODO Verify: why min of quantity, parent_width/width? # assumes widths to be entered as percentage # int(round(parent_width/demands[i][1])) will always be >= 1, because widths of small rolls can't exceed parent_width (which is width of big roll) # b.append( min(demands[i][0], int(round(parent_width / demands[i][1]))) ) b.append( min(quantity, int(round(parent_width / width))) ) # if total width of this i-th order + previous order's leftover (T) is less than parent_width # it's fine. Cut it. if T + quantity*width <= parent_width: T, TT = T + quantity*width, TT + quantity*width # else, the width exceeds, so we have to cut only as much as we can cut from parent_width width of the big roll else: while quantity: if T + width <= parent_width: T, TT, quantity = T + width, TT + width, quantity-1 else: k[1],T = k[1]+1, 0 # use next roll (k[1] += 1) k[0] = int(round(TT/parent_width+0.5)) print('k', k) print('b', b) return k, b ''' nb: array of number of rolls to cut, of each order w: demands: [ [quantity, width], [quantity, width], [quantity, width], ] ''' def rolls(nb, x, w, demands): consumed_big_rolls = [] num_orders = len(x) # go over first row (1st order) # this row contains the list of all the big rolls available, and if this 1st (0-th) order # is cut from any big roll, that big roll's index would contain a number > 0 for j in range(len(x[0])): # w[j]: width of j-th big roll # int(x[i][j]) * [demands[i][1]] width of all i-th order's small rolls that are to be cut from j-th big roll RR = [ abs(w[j])] + [ int(x[i][j])*[demands[i][1]] for i in range(num_orders) \ if x[i][j] > 0 ] # if i-th order has some cuts from j-th order, x[i][j] would be > 0 consumed_big_rolls.append(RR) return consumed_big_rolls ''' this model starts with some patterns and then optimizes those patterns ''' def solve_large_model(demands, parent_width=100, cutStyle='exactCuts'): num_orders = len(demands) iter = 0 patterns = get_initial_patterns(demands) # print('method#solve_large_model, patterns', patterns) # list quantities of orders quantities = [demands[i][0] for i in range(num_orders)] print('quantities', quantities) while iter < 20: status, y, l = solve_master(patterns, quantities, parent_width=parent_width, cutStyle=cutStyle) iter += 1 # list widths of orders widths = [demands[i][1] for i in range(num_orders)] new_pattern, objectiveValue = get_new_pattern(l, widths, parent_width=parent_width) # print('method#solve_large_model, new_pattern', new_pattern) # print('method#solve_large_model, objectiveValue', objectiveValue) for i in range(num_orders): # add i-th cut of new pattern to i-thp pattern patterns[i].append(new_pattern[i]) status, y, l = solve_master(patterns, quantities, parent_width=parent_width, integer=True, cutStyle=cutStyle) return status, \ patterns, \ y, \ rolls_patterns(patterns, y, demands, parent_width=parent_width) ''' Dantzig-Wolfe decomposition splits the problem into a Master Problem MP and a sub-problem SP. The Master Problem: provided a set of patterns, find the best combination satisfying the demand C: patterns b: demand ''' def solve_master(patterns, quantities, parent_width=100, integer=False, cutStyle='exactCuts'): title = 'Cutting stock master problem' num_patterns = len(patterns) n = len(patterns[0]) # print('**num_patterns x n: ', num_patterns, 'x', n) # print('**patterns recived:') # for p in patterns: # print(p) constraints = [] solver = newSolver(title, integer) # y is not boolean, it's an integer now (as compared to y in approach used by solve_model) y = [ solver.IntVar(0, 1000, '') for j in range(n) ] # right bound? # minimize total big rolls (y) used Cost = sum(y[j] for j in range(n)) solver.Minimize(Cost) # for every pattern for i in range(num_patterns): # add constraint that this pattern (demand) must be met # there are m such constraints, for each pattern if cutStyle == 'minWaste': constraints.append(solver.Add( sum(patterns[i][j]*y[j] for j in range(n)) >= quantities[i]) ) else: # probably cutStyle == exactCuts constraints.append(solver.Add( sum(patterns[i][j]*y[j] for j in range(n)) == quantities[i]) ) status = solver.Solve() y = [int(ceil(e.SolutionValue())) for e in y] l = [0 if integer else constraints[i].DualValue() for i in range(num_patterns)] # sl = [0 if integer else constraints[i].name() for i in range(num_patterns)] # print('sl: ', sl) # l = [0 if integer else u[i].Ub() for i in range(m)] toreturn = status, y, l # l_to_print = [round(dd, 2) for dd in toreturn[2]] # print('l: ', len(l_to_print), '->', l_to_print) # print('l: ', toreturn[2]) return toreturn ''' TODO Make sense of this: ''' def get_new_pattern(l, w, parent_width=100): solver = newSolver('Cutting stock sub-problem', True) n = len(l) new_pattern = [ solver.IntVar(0, parent_width, '') for i in range(n) ] # maximizes the sum of the values times the number of occurrence of that roll in a pattern Cost = sum( l[i] * new_pattern[i] for i in range(n)) solver.Maximize(Cost) # ensuring that the pattern stays within the total width of the large roll solver.Add( sum( w[i] * new_pattern[i] for i in range(n)) <= parent_width ) status = solver.Solve() return SolVal(new_pattern), ObjVal(solver) ''' the initial patterns must be such that they will allow a feasible solution, one that satisfies all demands. Considering the already complex model, let’s keep it simple. Our initial patterns have exactly one roll per pattern, as obviously feasible as inefficient. ''' def get_initial_patterns(demands): num_orders = len(demands) return [[0 if j != i else 1 for j in range(num_orders)]\ for i in range(num_orders)] def rolls_patterns(patterns, y, demands, parent_width=100): R, m, n = [], len(patterns), len(y) for j in range(n): for _ in range(y[j]): RR = [] for i in range(m): if patterns[i][j] > 0: RR.extend( [demands[i][1]] * int(patterns[i][j]) ) used_width = sum(RR) R.append([parent_width - used_width, RR]) return R ''' checks if all small roll widths (demands) smaller than parent roll's width ''' def checkWidths(demands, parent_width): for quantity, width in demands: if width > parent_width: print(f'Small roll width {width} is greater than parent rolls width {parent_width}. Exiting') return False return True ''' params child_rolls: list of lists, each containing quantity & width of rod / roll to be cut e.g.: [ [quantity, width], [quantity, width], ...] parent_rolls: list of lists, each containing quantity & width of rod / roll to cut from e.g.: [ [quantity, width], [quantity, width], ...] cutStyle: there are two types of cutting style 1. cut exactly as many items as specified: exactCuts 2. cut some items more than specified to minimize waste: minWaste ''' def StockCutter1D(child_rolls, parent_rolls, output_json=True, large_model=True, cutStyle='exactCuts'): # at the moment, only parent one width of parent rolls is supported # quantity of parent rolls is calculated by algorithm, so user supplied quantity doesn't matter? # TODO: or we can check and tell the user the user when parent roll quantity is insufficient parent_width = parent_rolls[0][1] if not checkWidths(demands=child_rolls, parent_width=parent_width): return [] print('child_rolls', child_rolls) print('parent_rolls', parent_rolls) if not large_model: print('Running Small Model...') status, numRollsUsed, consumed_big_rolls, unused_roll_widths, wall_time = \ solve_model(demands=child_rolls, parent_width=parent_width, cutStyle=cutStyle) # convert the format of output of solve_model to be exactly same as solve_large_model print('consumed_big_rolls before adjustment: ', consumed_big_rolls) new_consumed_big_rolls = [] for big_roll in consumed_big_rolls: if len(big_roll) < 2: # sometimes the solve_model return a solution that contanis an extra [0.0] entry for big roll consumed_big_rolls.remove(big_roll) continue unused_width = big_roll[0] subrolls = [] for subitem in big_roll[1:]: if isinstance(subitem, list): # if it's a list, concatenate with the other lists, to make a single list for this big_roll subrolls = subrolls + subitem else: # if it's an integer, add it to the list subrolls.append(subitem) new_consumed_big_rolls.append([unused_width, subrolls]) print('consumed_big_rolls after adjustment: ', new_consumed_big_rolls) consumed_big_rolls = new_consumed_big_rolls else: print('Running Large Model...'); status, A, y, consumed_big_rolls = solve_large_model(demands=child_rolls, parent_width=parent_width, cutStyle=cutStyle) numRollsUsed = len(consumed_big_rolls) # print('A:', A, '\n') # print('y:', y, '\n') STATUS_NAME = ['OPTIMAL', 'FEASIBLE', 'INFEASIBLE', 'UNBOUNDED', 'ABNORMAL', 'NOT_SOLVED' ] output = { "statusName": STATUS_NAME[status], "numSolutions": '1', "numUniqueSolutions": '1', "numRollsUsed": numRollsUsed, "solutions": consumed_big_rolls # unique solutions } # print('Wall Time:', wall_time) print('numRollsUsed', numRollsUsed) print('Status:', output['statusName']) print('Solutions found :', output['numSolutions']) print('Unique solutions: ', output['numUniqueSolutions']) if output_json: return json.dumps(output) else: return consumed_big_rolls ''' Draws the big rolls on the graph. Each horizontal colored line represents one big roll. In each big roll (multi-colored horizontal line), each color represents small roll to be cut from it. If the big roll ends with a black color, that part of the big roll is unused width. TODO: Assign each child roll a unique color ''' def drawGraph(consumed_big_rolls, child_rolls, parent_width): import matplotlib.pyplot as plt import matplotlib.patches as patches # TODO: to add support for multiple different parent rolls, update here xSize = parent_width # width of big roll ySize = 10 * len(consumed_big_rolls) # one big roll will take 10 units vertical space # draw rectangle fig,ax = plt.subplots(1) plt.xlim(0, xSize) plt.ylim(0, ySize) plt.gca().set_aspect('equal', adjustable='box') # print coords coords = [] colors = ['r', 'g', 'b', 'y', 'brown', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] colorDict = {} i = 0 for quantity, width in child_rolls: colorDict[width] = colors[i % 11] i+= 1 # start plotting each big roll horizontly, from the bottom y1 = 0 for i, big_roll in enumerate(consumed_big_rolls): ''' big_roll = [leftover_width, [small_roll_1_1, small_roll_1_2, other_small_roll_2_1]] ''' unused_width = big_roll[0] small_rolls = big_roll[1] x1 = 0 x2 = 0 y2 = y1 + 8 # the height of each big roll will be 8 for j, small_roll in enumerate(small_rolls): x2 = x2 + small_roll print(f"{x1}, {y1} -> {x2}, {y2}") width = abs(x1-x2) height = abs(y1-y2) # print(f"Rect#{idx}: {width}x{height}") # Create a Rectangle patch rect_shape = patches.Rectangle((x1,y1), width, height, facecolor=colorDict[small_roll], label=f'{small_roll}') ax.add_patch(rect_shape) # Add the patch to the Axes x1 = x2 # x1 for next small roll in same big roll will be x2 of current roll # now that all small rolls have been plotted, check if a there is unused width in this big roll # set the unused width at the end as black colored rectangle if unused_width > 0: width = unused_width rect_shape = patches.Rectangle((x1,y1), width, height, facecolor='black', label='Unused') ax.add_patch(rect_shape) # Add the patch to the Axes y1 += 10 # next big roll will be plotted on top of current, a roll height is 8, so 2 will be margin between rolls plt.show() if __name__ == '__main__': child_rolls = [ # [quantity, width], # [6, 25], # [12, 21], # [7, 26], # [3, 23], # [8, 33], # [2, 15], # [2, 34], # [3, 3], # [3, 4], [3,3], [3,1], [2,4], [2,2] # [3,30], # [2,72], # [5,50] ] # child_rolls = gen_data(3) # parent_rolls = [[10, 120]] # parent_rolls = [[10, 8]] parent_rolls = [[10, 6]] # parent_rolls = [[10, 144]] consumed_big_rolls = StockCutter1D(child_rolls, parent_rolls, output_json=False, large_model=False) print (consumed_big_rolls) for idx, roll in enumerate(consumed_big_rolls): print(f'Roll #{idx}:', roll) drawGraph(consumed_big_rolls, child_rolls, parent_width=parent_rolls[0][1]) ================================================ FILE: infile.txt ================================================ 58 58 38 58 58 47.25 58 58 47.25 58 58 47 58 58 22.75 58 58 58.25 58.25 58.25 58.25 58.25 58.25 58.5 58 58 71 58 58 28.5 34.5 34.5 64.5 16.5 16.5 70 46.5 46.5 47 23 23 75 ================================================ FILE: tests/basic_test.py ================================================ import pytest from csp.read_lengths import get_data def test_get_data(): infile = "infile.txt" nrs = get_data(infile) print(nrs) assert nrs[0][1] == 38