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
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
## 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]]```
```

### 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
================================================
{{ mode_data.childMessage }}
{{ mode_data.childErrors }}
| # | Width | Height | Quantity | ||
| {{ index + 1 }} |
x
|
{{ mode_data.parentMessage }}
{{ mode_data.parentErrors }}
| # | Width | Height | Quantity | |
| {{ index + 1 }} |
x
|
Stock required = {{ mode_data.result.solutions.length }}
| Stock | Usage | Width of Cuts |
| {{ index + 1 }} | {{ getPercentageUtilization(bigRoll[0]) }} % | {{ bigRoll[1].join(",") }} |