├── csp ├── __init__.py ├── read_lengths.py └── stock_cutter_1d.py ├── .gitignore ├── deployment ├── Procfile ├── requirements.txt ├── images │ ├── NOT SORTED.PNG │ ├── race condition..PNG │ ├── 1d-cutter-python.PNG │ ├── race condition 2.PNG │ ├── race condition 3.PNG │ ├── race condition 4.PNG │ ├── race condition 5.PNG │ ├── race-condition-1d.png │ ├── index out of range error.PNG │ ├── v2-race-condition-1(use top rods first).PNG │ └── v2-race-condition-2 (could have used less rods).PNG ├── frontend │ ├── babel.config.js │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ ├── .gitignore │ ├── README.md │ ├── src │ │ ├── App.vue │ │ ├── main.js │ │ ├── assets │ │ │ └── style.scss │ │ └── components │ │ │ └── CspTool.vue │ └── package.json ├── .editorconfig ├── .gitignore ├── server.py ├── csp.py ├── stock_cutter.py └── stock_cutter_1d.py ├── github ├── CSP-Tool.PNG ├── graph-1d.PNG ├── graph-1d-b.PNG └── video-thumb.jpg ├── tests └── basic_test.py ├── infile.txt ├── Pipfile ├── LICENSE ├── README.md └── Pipfile.lock /csp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .idea -------------------------------------------------------------------------------- /deployment/Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn server:app -------------------------------------------------------------------------------- /deployment/requirements.txt: -------------------------------------------------------------------------------- 1 | gunicorn 2 | Flask 3 | Flask-Cors 4 | ortools 5 | -------------------------------------------------------------------------------- /github/CSP-Tool.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/github/CSP-Tool.PNG -------------------------------------------------------------------------------- /github/graph-1d.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/github/graph-1d.PNG -------------------------------------------------------------------------------- /github/graph-1d-b.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/github/graph-1d-b.PNG -------------------------------------------------------------------------------- /github/video-thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/github/video-thumb.jpg -------------------------------------------------------------------------------- /deployment/images/NOT SORTED.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/NOT SORTED.PNG -------------------------------------------------------------------------------- /deployment/images/race condition..PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/race condition..PNG -------------------------------------------------------------------------------- /deployment/frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /deployment/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/frontend/public/favicon.ico -------------------------------------------------------------------------------- /deployment/images/1d-cutter-python.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/1d-cutter-python.PNG -------------------------------------------------------------------------------- /deployment/images/race condition 2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/race condition 2.PNG -------------------------------------------------------------------------------- /deployment/images/race condition 3.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/race condition 3.PNG -------------------------------------------------------------------------------- /deployment/images/race condition 4.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/race condition 4.PNG -------------------------------------------------------------------------------- /deployment/images/race condition 5.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/race condition 5.PNG -------------------------------------------------------------------------------- /deployment/images/race-condition-1d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/race-condition-1d.png -------------------------------------------------------------------------------- /deployment/images/index out of range error.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/index out of range error.PNG -------------------------------------------------------------------------------- /deployment/images/v2-race-condition-1(use top rods first).PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/v2-race-condition-1(use top rods first).PNG -------------------------------------------------------------------------------- /deployment/images/v2-race-condition-2 (could have used less rods).PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/emadehsan/csp/HEAD/deployment/images/v2-race-condition-2 (could have used less rods).PNG -------------------------------------------------------------------------------- /tests/basic_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from csp.read_lengths import get_data 3 | 4 | def test_get_data(): 5 | infile = "infile.txt" 6 | nrs = get_data(infile) 7 | print(nrs) 8 | assert nrs[0][1] == 38 9 | -------------------------------------------------------------------------------- /infile.txt: -------------------------------------------------------------------------------- 1 | 58 58 38 2 | 58 58 47.25 3 | 58 58 47.25 4 | 58 58 47 5 | 58 58 22.75 6 | 58 58 58.25 7 | 58.25 58.25 58.25 8 | 58.25 58.25 58.5 9 | 58 58 71 10 | 58 58 28.5 11 | 12 | 13 | 14 | 34.5 34.5 64.5 15 | 16.5 16.5 70 16 | 46.5 46.5 47 17 | 23 23 75 -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | ortools = "*" 8 | pytest = "*" 9 | matplotlib = "*" 10 | typer = "*" 11 | 12 | [dev-packages] 13 | 14 | [requires] 15 | python_version = "3.9" 16 | -------------------------------------------------------------------------------- /deployment/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /deployment/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | # source: https://stackoverflow.com/a/51398290/3578289 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /deployment/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | __pycache__ 3 | server/__pycache__/ 4 | *.csv 5 | 6 | .DS_Store 7 | frontend/dist/ 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Editor directories and files 19 | .idea 20 | .vscode 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw* 26 | -------------------------------------------------------------------------------- /deployment/frontend/README.md: -------------------------------------------------------------------------------- 1 | # frontend 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | npm run lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /deployment/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | 16 | 26 | -------------------------------------------------------------------------------- /csp/read_lengths.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from typing import List 3 | import re 4 | from math import ceil 5 | def get_data(infile:str)->List[float]: 6 | """ Reads a file of numbers and returns a list of (count, number) pairs.""" 7 | _p = pathlib.Path(infile) 8 | input_text = _p.read_text() 9 | numbers = [ceil(float(n)) for n in re.findall(r'[0-9.]+', _p.read_text())] 10 | quan = [] 11 | nr = [] 12 | for n in numbers: 13 | if n not in nr and n != 0: 14 | quan.append(numbers.count(n)) 15 | nr.append(n) 16 | return list(zip(quan,nr)) 17 | 18 | -------------------------------------------------------------------------------- /deployment/frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | 3 | // import Vue from 'vue' 4 | // import { BootstrapVue, IconsPlugin } from 'bootstrap-vue' 5 | // Import Bootstrap an BootstrapVue CSS files (order is important) 6 | import 'bootstrap/dist/css/bootstrap.css' 7 | // import 'bootstrap-vue/dist/bootstrap-vue.css' 8 | 9 | // Make BootstrapVue available throughout your project 10 | // Vue.use(BootstrapVue) 11 | // Optionally install the BootstrapVue icon components plugin 12 | // Vue.use(IconsPlugin) 13 | 14 | 15 | import App from './App.vue' 16 | 17 | createApp(App).mount('#app') 18 | -------------------------------------------------------------------------------- /deployment/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /deployment/frontend/src/assets/style.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | body { 4 | /* font-size: 0.9em; */ 5 | background-color: #ecf0f1; 6 | } 7 | 8 | .btn { 9 | padding: 2px 8px; 10 | } 11 | 12 | input { 13 | border: 0px solid #000; 14 | margin: 0; 15 | background: transparent; 16 | width: 100%; 17 | } 18 | table tr td { 19 | border-right: 1px solid #000; 20 | border-bottom: 1px solid #000; 21 | } 22 | table { 23 | background: #fff none repeat scroll 0 0; 24 | border-left: 1px solid #000; 25 | border-top: 1px solid #000; 26 | } 27 | table tr:nth-child(even) { 28 | background: #95a5a6; 29 | } 30 | table tr:nth-child(odd) { 31 | background: #bdc3c7; 32 | } 33 | .active { 34 | font-weight: bold; 35 | } 36 | 37 | .information { 38 | border-radius: 100%; 39 | background: #444; 40 | color: white; 41 | padding: 2px 5px; 42 | font-size: 80%; 43 | font-weight: bold; 44 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Emad Ehsan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /deployment/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "axios": "^0.21.1", 12 | "bootstrap": "^5.1.0", 13 | "bootstrap-vue": "^2.21.2", 14 | "core-js": "^3.6.5", 15 | "d3": "^7.0.1", 16 | "vue": "^3.2.6" 17 | }, 18 | "devDependencies": { 19 | "@types/d3": "^7.0.0", 20 | "@vue/cli-plugin-babel": "~4.5.0", 21 | "@vue/cli-plugin-eslint": "~4.5.0", 22 | "@vue/cli-service": "~4.5.0", 23 | "@vue/compiler-sfc": "^3.0.0", 24 | "babel-eslint": "^10.1.0", 25 | "eslint": "^6.7.2", 26 | "eslint-plugin-vue": "^7.0.0" 27 | }, 28 | "eslintConfig": { 29 | "root": true, 30 | "env": { 31 | "node": true 32 | }, 33 | "extends": [ 34 | "plugin:vue/vue3-essential", 35 | "eslint:recommended" 36 | ], 37 | "parserOptions": { 38 | "parser": "babel-eslint" 39 | }, 40 | "rules": {} 41 | }, 42 | "browserslist": [ 43 | "> 1%", 44 | "last 2 versions", 45 | "not dead" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /deployment/server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, json, request 2 | from flask_cors import CORS, cross_origin 3 | 4 | import stock_cutter # local module 5 | 6 | app = Flask(__name__) 7 | cors = CORS(app) 8 | app.config['CORS_HEADERS'] = 'Content-Type' 9 | 10 | @app.route('/', methods=['GET']) 11 | @cross_origin() 12 | def get_csp(): 13 | return 'Cutting Stock Problem' 14 | 15 | ''' 16 | route for receving data for 1D problem 17 | ''' 18 | @app.route('/stocks_1d', methods=['POST']) 19 | @cross_origin() 20 | def post_stocks_1d(): 21 | ''' 22 | expects two params to be present 23 | child_rolls: 24 | array of arrays. E.g [ [quantity, width], [quantity, width], ... ] 25 | 26 | parent_rolls: 27 | array of arrays. E.g [ [quantity, width], [quantity, width], ... ] 28 | ''' 29 | import stock_cutter_1d 30 | 31 | data = request.json 32 | print('data: ', data) 33 | 34 | child_rolls = data['child_rolls'] 35 | parent_rolls = data['parent_rolls'] 36 | 37 | ''' 38 | it can be 39 | exactCuts: cut exactly as many as specified by user 40 | minWaste: cut some items, more than specified, to avoid waste 41 | ''' 42 | cutStyle = data['cutStyle'] 43 | 44 | # output = stock_cutter_1d.StockCutter1D(child_rolls, parent_rolls, cutStyle=cutStyle) 45 | output = stock_cutter_1d.StockCutter1D(child_rolls, parent_rolls, large_model=False, cutStyle=cutStyle) 46 | 47 | return output 48 | 49 | 50 | 51 | ''' 52 | route for 2D 53 | ''' 54 | @app.route('/stocks_2d', methods=['POST']) 55 | @cross_origin() 56 | def post_stocks(): 57 | ''' 58 | expects two params to be present 59 | child_rects: 60 | array of arrays. Each inner array is like [w, h] i.e. width & height of rectangle 61 | 62 | parent_rects: 63 | array of arrays. Each inner array is like [w, h] i.e. width & height of rectangle 64 | ''' 65 | data = request.json 66 | print('data: ', data) 67 | 68 | child_rects = data['child_rects'] 69 | parent_rects = data['parent_rects'] 70 | 71 | output = stock_cutter.StockCutter(child_rects, parent_rects) 72 | 73 | return output 74 | 75 | 76 | 77 | if __name__ == '__main__': 78 | # app.run() 79 | app.run(threaded=True, port=5000) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cutting Stock Problem 2 | Cutting Stock Problem (CSP) deals with planning the cutting of items (rods / sheets) from given stock items (which are usually of fixed size). 3 | 4 | ## New to Cutting Stock Problem? Understand Visually 5 | 6 | Video Tutorial on Cutting Stock Problem 7 | 8 | 9 | 10 | This implementation of CSP tries to answer 11 | > How to minimize number of stock items used while cutting customer order 12 | 13 | 14 | while doing so, it also caters 15 | > How to cut the stock for customer orders so that waste is minimum 16 | 17 | 18 | The OR Tools also helps us in calculating the number of possible solutions for your problem. So in addition, we can also compute 19 | > In how many ways can we cut given order from fixed size Stock? 20 | 21 | 22 | ## Quick Usage 23 | This is how CSP Tools looks in action. Click [CSP Tool](https://emadehsan.com/csp/) to use it 24 | 25 | CSP Tool 26 | 27 | 28 | ## Libraries 29 | * [Google OR-Tools](https://developers.google.com/optimization) 30 | 31 | ## Quick Start 32 | Install [Pipenv](https://pipenv.pypa.io/en/latest/), if not already installed 33 | ```sh 34 | $ pip3 install --user pipenv 35 | ``` 36 | 37 | Clone this project and install packages 38 | ```sh 39 | $ git clone https://github.com/emadehsan/csp 40 | $ cd csp 41 | $ pipenv install 42 | 43 | # activate env 44 | $ pipenv shell 45 | ``` 46 | 47 | ## Run 48 | 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`. 49 | ```sh 50 | (csp) $ python csp/stock_cutter_1d.py 51 | ``` 52 | 53 | Output: 54 | 55 | ```sh 56 | numRollsUsed 5 57 | Status: OPTIMAL 58 | Roll #0: [0.0, [33, 33, 18, 18, 18]] 59 | Roll #1: [2.9999999999999925, [33, 30, 18, 18, 18]] 60 | Roll #2: [5.999999999999993, [30, 30, 18, 18, 18]] 61 | Roll #3: [2.9999999999999987, [33, 33, 33, 18]] 62 | Roll #4: [21.0, [33, 33, 33]]``` 63 | ``` 64 | 65 | ![Graph of Output](./github/graph-1d-b.PNG) 66 | 67 | 68 | ### Using input file 69 | If you want to describe your inputs in a file, [infile.txt](./infile.txt) describes the expected format 70 | 71 | ```sh 72 | (csp) $ python3 csp/stock_cutter_1d.py infile.txt 73 | ``` 74 | 75 | 76 | ## Thinks to keep in mind 77 | * 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). 78 | * 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. 79 | 80 | 81 | ## CSP 2D 82 | 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. 83 | 84 | ## Resources 85 | The whole code for this project is taken from Serge Kruk's 86 | * [Practical Python AI Projects: Mathematical Models of Optimization Problems with Google OR-Tools](https://amzn.to/3iPceJD) 87 | * [Repository of the code in Serge's book](https://github.com/sgkruk/Apress-AI/) 88 | -------------------------------------------------------------------------------- /deployment/csp.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import collections, json 3 | from ortools.sat.python import cp_model 4 | 5 | # to draw rectangles 6 | import matplotlib.pyplot as plt 7 | import matplotlib.patches as patches 8 | 9 | """ 10 | Cutting Stock problem 11 | params 12 | child_rects: 13 | lists of multiple rectangles' coords 14 | e.g.: [ [w, h], [w, h], ...] 15 | parent_rects: rectangle coords 16 | lists of multiple rectangles' coords 17 | e.g.: [ [w, h], [w, h], ...] 18 | """ 19 | def StockCutter(child_rects, parent_rects): 20 | 21 | # Create the model 22 | model = cp_model.CpModel() 23 | 24 | # parent rect (to cut from). horizon = [ width, height ] of parent sheet 25 | # for now, parent rectangle is just one 26 | # TODO: to add functionality of cutting from multiple parent sheets, start here: 27 | horizon = parent_rects[0] 28 | 29 | # Named Tuple to store information about created variables 30 | sheet_type = collections.namedtuple('sheet_type', 'x1 y1 x2 y2 x_interval y_interval') 31 | 32 | # Store for all model variables 33 | all_vars = {} 34 | 35 | # sum of to save area of all small rects, to cut from parent rect 36 | total_child_area = 0 37 | 38 | # hold the widths (x) and heights (y) interval vars of each sheet 39 | x_intervals = [] 40 | y_intervals = [] 41 | 42 | # create model vars and intervals 43 | for rect_id, rect in enumerate(child_rects): 44 | width = rect[0] 45 | height = rect[1] 46 | area = width * height 47 | total_child_area += area 48 | # print(f"Rect: {width}x{height}, Area: {area}") 49 | 50 | suffix = '_%i_%i' % (width, height) 51 | 52 | # interval to represent width. max value can be the width of parent rect 53 | x1_var = model.NewIntVar(0, horizon[0], 'x1' + suffix) 54 | x2_var = model.NewIntVar(0, horizon[0], 'x2' + suffix) 55 | x_interval_var = model.NewIntervalVar(x1_var, width, x2_var, 'x_interval' + suffix) 56 | 57 | # interval to represent height. max value can be the height of parent rect 58 | y1_var = model.NewIntVar(0, horizon[1], 'y1' + suffix) 59 | y2_var = model.NewIntVar(0, horizon[1], 'y2' + suffix) 60 | y_interval_var = model.NewIntervalVar(y1_var, height, y2_var, 'y_interval' + suffix) 61 | 62 | x_intervals.append(x_interval_var) 63 | y_intervals.append(y_interval_var) 64 | 65 | # store the variables for later use 66 | all_vars[rect_id] = sheet_type( 67 | x1=x1_var, 68 | y1=y1_var, 69 | x2=x2_var, 70 | y2=y2_var, 71 | x_interval=x_interval_var, 72 | y_interval=y_interval_var 73 | ) 74 | 75 | # add constraint: no over lap of rectangles allowed 76 | model.AddNoOverlap2D(x_intervals, y_intervals) 77 | 78 | # Solve model 79 | solver = cp_model.CpSolver() 80 | 81 | solution_printer = VarArraySolutionPrinter(all_vars) 82 | 83 | # Search for all solutions is only defined on satisfiability problems 84 | status = solver.SearchForAllSolutions(model, solution_printer) # use for satisfiability problem 85 | # status = solver.Solve(model) # use for Optimization Problem 86 | 87 | print('Status:', solver.StatusName(status)) 88 | print('Solutions found :', solution_printer.solution_count()) 89 | 90 | solutions = solution_printer.get_unique_solutions() 91 | 92 | # call draw methods here, if want to draw with matplotlib 93 | 94 | int_solutions = solutions_to_int(solutions) 95 | 96 | statusName = solver.StatusName(status) 97 | numSolutions = solution_printer.solution_count() 98 | numUniqueSolutions = len(solutions) 99 | 100 | output = { 101 | "statusName": statusName, 102 | "numSolutions": numSolutions, 103 | "numUniqueSolutions": numUniqueSolutions, 104 | "solutions": int_solutions # unique solutions 105 | } 106 | 107 | # return json.dumps(output) 108 | 109 | # draw 110 | for idx, sol in enumerate(solutions): 111 | # sol is string of coordinates of all rectangles in this solution 112 | # format: x1,y1,x2,y2-x1,y1,x2,y2 113 | print('Sol#', idx) 114 | rect_strs = sol.split('-') 115 | rect_coords = [ 116 | # [x1,y1,x2,y2], 117 | # [x1,y1,x2,y2], 118 | ] 119 | for rect_str in rect_strs: 120 | coords_str = rect_str.split(',') 121 | coords = [int(c) for c in coords_str] 122 | rect_coords.append(coords) 123 | print('rect_coords') 124 | # print(rect_coords) 125 | drawRectsFromCoords(rect_coords) 126 | 127 | def drawRectsFromCoords(rect_coords): 128 | # draw rectangle 129 | fig,ax = plt.subplots(1) 130 | plt.xlim(0,6) # todo 7 131 | plt.ylim(0,6) 132 | plt.gca().set_aspect('equal', adjustable='box') 133 | 134 | # print coords 135 | coords = [] 136 | colors = ['r', 'g', 'b', 'y', 'brown', 'black', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] 137 | for idx, coords in enumerate(rect_coords): 138 | x1=coords[0] 139 | y1=coords[1] 140 | x2=coords[2] 141 | y2=coords[3] 142 | # print(f"{x1}, {y1} -> {x2}, {y2}") 143 | 144 | width = abs(x1-x2) 145 | height = abs(y1-y2) 146 | # print(f"Rect#{idx}: {width}x{height}") 147 | 148 | # Create a Rectangle patch 149 | rect_shape = patches.Rectangle((x1,y1), width, height,facecolor=colors[idx]) 150 | # Add the patch to the Axes 151 | ax.add_patch(rect_shape) 152 | plt.show() 153 | 154 | 155 | 156 | 157 | 158 | """ 159 | TODO complete this, add to git 160 | params: 161 | str_solutions: list of strings. 1 string contains is solution 162 | """ 163 | def solutions_to_int(str_solutions): 164 | 165 | # list of solutions, each solution is a list of rectangle coords that look like [x1,y1,x2,y2] 166 | int_solutions = [] 167 | 168 | # go over all solutions and convert them to int>list>json 169 | for idx, sol in enumerate(str_solutions): 170 | # sol is string of coordinates of all rectangles in this solution 171 | # format: x1,y1,x2,y2-x1,y1,x2,y2 172 | 173 | rect_strs = sol.split('-') 174 | rect_coords = [ 175 | # [x1,y1,x2,y2], 176 | # [x1,y1,x2,y2], 177 | # ... 178 | ] 179 | 180 | # convert each rectangle's coords to int 181 | for rect_str in rect_strs: 182 | coords_str = rect_str.split(',') 183 | coords = [int(c) for c in coords_str] 184 | rect_coords.append(coords) 185 | 186 | # print('rect_coords', rect_coords) 187 | 188 | # call draw methods here, if want to draw individual solutions with matplotlib 189 | 190 | int_solutions.append(rect_coords) 191 | 192 | return int_solutions 193 | 194 | 195 | """ 196 | To get all the solutions of the problem, as they come up. 197 | https://developers.google.com/optimization/cp/cp_solver#all_solutions 198 | 199 | The solutions are all unique. But for the child rectangles that have same dimensions, 200 | some solution will be repetitive. Because for the algorithm, they are different solutions, 201 | but because of same size, they are merely permutations of the similar child rectangles - 202 | having other rectangles' positions fixed. 203 | 204 | We want to remove repetitive extra solutions. One way to do this is 205 | 1. Stringify every rectangle coords in a solution 206 | (1,2)->(2,3) becomes "1,2,2,3" 207 | 208 | # here the rectangles are stored as a string: "1,2,2,3" where x1=1, y1=2, x2=2, y2=3 209 | 210 | 2. Put all these string coords into a sorted list. This sorting is important. 211 | Because the rectangles (1,2)->(2,3) and (3,3)->(4,4) are actually same size (1x1) rectangles. 212 | And they can appear in 1st solution as 213 | [(1,2)->(2,3) , (3,3)->(4,4)] 214 | and in the 2nd solution as 215 | [(3,3)->(4,4) , (1,2)->(2,3)] 216 | 217 | but this sorted list of strings will ensure both solutions are represented as 218 | 219 | [..., "1,2,2,3", "3,3,4,4", ...] 220 | 221 | 3. Join the Set of "strings (rectangles)" in to one big string seperated by '-'. For every solution. 222 | So in resulting big strings (solutions), we will have two similar strings (solutions) 223 | that be similar and also contain 224 | 225 | "....1,2,2,3-3,3,4,4-...." 226 | 227 | 4. Now add all these "strings (solutions)" into a Set. this adding to the set 228 | will remove similar strings. And hence duplicate solutions will be removed. 229 | 230 | """ 231 | class VarArraySolutionPrinter(cp_model.CpSolverSolutionCallback): 232 | 233 | def __init__(self, variables): 234 | cp_model.CpSolverSolutionCallback.__init__(self) 235 | self.__variables = variables 236 | self.__solution_count = 0 237 | 238 | # hold the calculated solutions 239 | self.__solutions = [] 240 | self.__unique_solutions = set() 241 | 242 | def on_solution_callback(self): 243 | self.__solution_count += 1 244 | # print('Sol#: ', self.__solution_count) 245 | 246 | # using list to hold the coordinate strings of rectangles 247 | rect_strs = [] 248 | 249 | # extra coordinates of all rectangles for this solution 250 | for rect_id in self.__variables: 251 | rect = self.__variables[rect_id] 252 | x1 = self.Value(rect.x1) 253 | x2 = self.Value(rect.x2) 254 | y1 = self.Value(rect.y1) 255 | y2 = self.Value(rect.y2) 256 | 257 | rect_str = f"{x1},{y1},{x2},{y2}" 258 | # print(rect_str) 259 | 260 | rect_strs.append(rect_str) 261 | # print(f'Rect #{rect_id}: {x1},{y1} -> {x2},{y2}') 262 | 263 | # print(rect_strs) 264 | 265 | # sort the rectangles 266 | rect_strs = sorted(rect_strs) 267 | 268 | # single solution as a string 269 | solution_str = '-'.join(rect_strs) 270 | # print(solution_str) 271 | 272 | # store the solutions 273 | self.__solutions.append(solution_str) 274 | self.__unique_solutions.add(solution_str) # __unique_solutions is a set, so duplicates will get removed 275 | 276 | 277 | def solution_count(self): 278 | return self.__solution_count 279 | 280 | # returns all solutions 281 | def get_solutions(self): 282 | return self.__solutions 283 | 284 | """ 285 | returns unique solutions 286 | returns the permutation free list of solution strings 287 | """ 288 | def get_unique_solutions(self): 289 | return list(self.__unique_solutions) # __unique_solutions is a Set, convert to list 290 | 291 | 292 | 293 | # for testing 294 | if __name__ == '__main__': 295 | 296 | child_rects = [ 297 | # [2, 2], 298 | # [1, 3], 299 | # [4, 3], 300 | # [1, 1], 301 | # [2, 4], 302 | 303 | [3, 3], 304 | [3, 3], 305 | [3, 3], 306 | [3, 3], 307 | ] 308 | 309 | parent_rects = [[6,6]] 310 | 311 | StockCutter(child_rects, parent_rects) -------------------------------------------------------------------------------- /deployment/stock_cutter.py: -------------------------------------------------------------------------------- 1 | ''' 2 | @Author Emad Ehsan 3 | Cutting Stock problem 2D 4 | Not complete. 5 | What's remaining: Finding Optimized solution that minimizes the waste. 6 | ''' 7 | 8 | import collections, json 9 | from ortools.sat.python import cp_model 10 | 11 | """ 12 | params 13 | child_rects: 14 | lists of multiple rectangles' coords 15 | e.g.: [ [w, h], [w, h], ...] 16 | parent_rects: rectangle coords 17 | lists of multiple rectangles' coords 18 | e.g.: [ [w, h], [w, h], ...] 19 | """ 20 | def StockCutter(child_rects, parent_rects, output_json=True): 21 | 22 | # Create the model 23 | model = cp_model.CpModel() 24 | 25 | # parent rect (to cut from). horizon = [ width, height ] of parent sheet 26 | # for now, parent rectangle is just one 27 | # TODO: to add functionality of cutting from multiple parent sheets, start here: 28 | horizon = parent_rects[0] 29 | total_parent_area = horizon[0] * horizon[1] # width x height 30 | 31 | # Named Tuple to store information about created variables 32 | sheet_type = collections.namedtuple('sheet_type', 'x1 y1 x2 y2 x_interval y_interval is_extra') 33 | 34 | # Store for all model variables 35 | all_vars = {} 36 | 37 | # sum of to save area of all small rects, to cut from parent rect 38 | total_child_area = 0 39 | 40 | # hold the widths (x) and heights (y) interval vars of each sheet 41 | x_intervals = [] 42 | y_intervals = [] 43 | 44 | # create model vars and intervals 45 | for rect_id, rect in enumerate(child_rects): 46 | width = rect[0] 47 | height = rect[1] 48 | area = width * height 49 | total_child_area += area 50 | # print(f"Rect: {width}x{height}, Area: {area}") 51 | 52 | suffix = '_%i_%i' % (width, height) 53 | 54 | # interval to represent width. max value can be the width of parent rect 55 | x1_var = model.NewIntVar(0, horizon[0], 'x1' + suffix) 56 | x2_var = model.NewIntVar(0, horizon[0], 'x2' + suffix) 57 | x_interval_var = model.NewIntervalVar(x1_var, width, x2_var, 'x_interval' + suffix) 58 | 59 | # interval to represent height. max value can be the height of parent rect 60 | y1_var = model.NewIntVar(0, horizon[1], 'y1' + suffix) 61 | y2_var = model.NewIntVar(0, horizon[1], 'y2' + suffix) 62 | y_interval_var = model.NewIntervalVar(y1_var, height, y2_var, 'y_interval' + suffix) 63 | 64 | x_intervals.append(x_interval_var) 65 | y_intervals.append(y_interval_var) 66 | 67 | # store the variables for later use 68 | all_vars[rect_id] = sheet_type( 69 | x1=x1_var, 70 | y1=y1_var, 71 | x2=x2_var, 72 | y2=y2_var, 73 | x_interval=x_interval_var, 74 | y_interval=y_interval_var, 75 | is_extra=False # to keep track of 1x1 custom rects added in next step 76 | ) 77 | 78 | # model.Minimize(x1_var) 79 | # model.Minimize(y1_var) 80 | 81 | 82 | # TODO: Minimize (x1,y1) values. So that rectangles are placed at the start 83 | # this reduced the areas wasted by place rectangles in the middle / at the end 84 | # even though the space at the start is available. 85 | # > 86 | # for rect_id in range(len(child_rects)): 87 | # model.Minimize(all_vars[rect_id].x1 + all_vars[rect_id].y1) 88 | # model.Minimize(all_vars[rect_id].x2 + all_vars[rect_id].y2) 89 | # model.Minimize(all_vars[rect_id].x1) 90 | # model.Minimize(all_vars[rect_id].x2) 91 | # model.Minimize(all_vars[rect_id].y1) 92 | # model.Minimize(all_vars[rect_id].y2) 93 | 94 | 95 | ''' 96 | FIXME: experiment 97 | Experment: treat the remaining area as small units of 1x1 rectangles. Push these rects to higher x,y. 98 | ''' 99 | # leftover_area = total_parent_area - total_child_area 100 | # if leftover_area >= 0: 101 | # ''' 102 | # each unit of leftover_area can be represented by 1x1 rectangles. 103 | # For leftover_area = 4 (e.g. 2x2 originally), we can use 4 rects of 1x1. Why? Because 104 | # 1. leftover_area would not always be continous. It is possible it is in the form of two 105 | # separate 2x1 rects or one 2x2 or four rects of 1x1. So we need the simplest version, 106 | # that can cover all types of rects. And it is 1x1 107 | # 2. 1x1 can represent non-adjecent weirdly shaped locations in the parent area that were leftover. 108 | # ''' 109 | # num_1x1rects = leftover_area 110 | 111 | # for i in range(num_1x1rects): 112 | # print(f'{i}-th 1x1') 113 | # suffix = '_%i_%i' % (1, 1) 114 | 115 | # # interval to represent width. max value can be the width of parent rect 116 | # x1_var = model.NewIntVar(0, horizon[0], 'x1' + suffix) 117 | # x2_var = model.NewIntVar(0, horizon[0], 'x2' + suffix) 118 | # x_interval_var = model.NewIntervalVar(x1_var, 1, x2_var, 'x_interval' + suffix) 119 | 120 | # # interval to represent height. max value can be the height of parent rect 121 | # y1_var = model.NewIntVar(0, horizon[1], 'y1' + suffix) 122 | # y2_var = model.NewIntVar(0, horizon[1], 'y2' + suffix) 123 | # y_interval_var = model.NewIntervalVar(y1_var, 1, y2_var, 'y_interval' + suffix) 124 | 125 | # x_intervals.append(x_interval_var) 126 | # y_intervals.append(y_interval_var) 127 | 128 | # # store the variables for later use 129 | # all_vars[rect_id] = sheet_type( 130 | # x1=x1_var, 131 | # y1=y1_var, 132 | # x2=x2_var, 133 | # y2=y2_var, 134 | # x_interval=x_interval_var, 135 | # y_interval=y_interval_var, 136 | # is_extra=True 137 | # ) 138 | # model.Maximize(x1_var) 139 | # model.Maximize(y1_var) 140 | # else: 141 | # print(f'Problem identified: Area of small rects is larger than parent rect by {leftover_area}') 142 | 143 | 144 | 145 | 146 | # add constraint: no over lap of rectangles allowed 147 | model.AddNoOverlap2D(x_intervals, y_intervals) 148 | 149 | # Solve model 150 | solver = cp_model.CpSolver() 151 | 152 | ''' 153 | Search for all solutions is only defined on satisfiability problems 154 | ''' 155 | # solution_printer = VarArraySolutionPrinter(all_vars) 156 | # status = solver.SearchForAllSolutions(model, solution_printer) # use for satisfiability problem 157 | # solutions = solution_printer.get_unique_solutions() 158 | # int_solutions = str_solutions_to_int(solutions) 159 | # output = { 160 | # "statusName": solver.StatusName(status), 161 | # "numSolutions": solution_printer.solution_count(), 162 | # "numUniqueSolutions": len(solutions), 163 | # "solutions": int_solutions # unique solutions 164 | # } 165 | 166 | ''' 167 | for single solution 168 | ''' 169 | status = solver.Solve(model) # use for Optimization Problem 170 | singleSolution = getSingleSolution(solver, all_vars) 171 | int_solutions = [singleSolution] # convert to array 172 | output = { 173 | "statusName": solver.StatusName(status), 174 | "numSolutions": '1', 175 | "numUniqueSolutions": '1', 176 | "solutions": int_solutions # unique solutions 177 | } 178 | 179 | 180 | print('Time:', solver.WallTime()) 181 | print('Status:', output['statusName']) 182 | print('Solutions found :', output['numSolutions']) 183 | print('Unique solutions: ', output['numUniqueSolutions']) 184 | 185 | if output_json: 186 | return json.dumps(output) 187 | else: 188 | return int_solutions # integer representation of solutions 189 | 190 | ''' 191 | This method is used to extract the single solution from the solver. 192 | Because in the case where VarArraySolutionPrinter is not used, the answers are not 193 | yet extracted from the solver. Use this method to extract the solver. 194 | ''' 195 | def getSingleSolution(solver, all_vars): 196 | solution = [] 197 | # extra coordinates of all rectangles for this solution 198 | for rect_id in all_vars: 199 | rect = all_vars[rect_id] 200 | x1 = solver.Value(rect.x1) 201 | x2 = solver.Value(rect.x2) 202 | y1 = solver.Value(rect.y1) 203 | y2 = solver.Value(rect.y2) 204 | 205 | # rect_str = f"{x1},{y1},{x2},{y2}" 206 | coords = [x1, y1, x2, y2]; 207 | # print(rect_str) 208 | 209 | solution.append(coords) 210 | # print(f'Rect #{rect_id}: {x1},{y1} -> {x2},{y2}') 211 | 212 | # print(rect_strs) 213 | # sort the rectangles 214 | # rect_strs = sorted(rect_strs) 215 | # single solution as a string 216 | # solution_str = '-'.join(rect_strs) 217 | return solution 218 | 219 | 220 | """ 221 | converts from string format to integer values. String format, in previous step, was used 222 | to exclude duplicates. 223 | params: 224 | str_solutions: list of strings. 1 string contains is solution 225 | """ 226 | def str_solutions_to_int(str_solutions): 227 | 228 | # list of solutions, each solution is a list of rectangle coords that look like [x1,y1,x2,y2] 229 | int_solutions = [] 230 | 231 | # go over all solutions and convert them to int>list>json 232 | for idx, sol in enumerate(str_solutions): 233 | # sol is string of coordinates of all rectangles in this solution 234 | # format: x1,y1,x2,y2-x1,y1,x2,y2 235 | 236 | rect_strs = sol.split('-') 237 | rect_coords = [ 238 | # [x1,y1,x2,y2], 239 | # [x1,y1,x2,y2], 240 | # ... 241 | ] 242 | 243 | # convert each rectangle's coords to int 244 | for rect_str in rect_strs: 245 | coords_str = rect_str.split(',') 246 | coords = [int(c) for c in coords_str] 247 | rect_coords.append(coords) 248 | 249 | # print('rect_coords', rect_coords) 250 | 251 | int_solutions.append(rect_coords) 252 | 253 | return int_solutions 254 | 255 | 256 | """ 257 | To get all the solutions of the problem, as they come up. 258 | https://developers.google.com/optimization/cp/cp_solver#all_solutions 259 | 260 | The solutions are all unique. But for the child rectangles that have same dimensions, 261 | some solution will be repetitive. Because for the algorithm, they are different solutions, 262 | but because of same size, they are merely permutations of the similar child rectangles - 263 | having other rectangles' positions fixed. 264 | 265 | We want to remove repetitive extra solutions. One way to do this is 266 | 1. Stringify every rectangle coords in a solution 267 | (1,2)->(2,3) becomes "1,2,2,3" 268 | 269 | # here the rectangles are stored as a string: "1,2,2,3" where x1=1, y1=2, x2=2, y2=3 270 | 271 | 2. Put all these string coords into a sorted list. This sorting is important. 272 | Because the rectangles (1,2)->(2,3) and (3,3)->(4,4) are actually same size (1x1) rectangles. 273 | And they can appear in 1st solution as 274 | [(1,2)->(2,3) , (3,3)->(4,4)] 275 | and in the 2nd solution as 276 | [(3,3)->(4,4) , (1,2)->(2,3)] 277 | 278 | but this sorted list of strings will ensure both solutions are represented as 279 | 280 | [..., "1,2,2,3", "3,3,4,4", ...] 281 | 282 | 3. Join the Set of "strings (rectangles)" in to one big string seperated by '-'. For every solution. 283 | So in resulting big strings (solutions), we will have two similar strings (solutions) 284 | that be similar and also contain 285 | 286 | "....1,2,2,3-3,3,4,4-...." 287 | 288 | 4. Now add all these "strings (solutions)" into a Set. this adding to the set 289 | will remove similar strings. And hence duplicate solutions will be removed. 290 | 291 | """ 292 | class VarArraySolutionPrinter(cp_model.CpSolverSolutionCallback): 293 | 294 | def __init__(self, variables): 295 | cp_model.CpSolverSolutionCallback.__init__(self) 296 | self.__variables = variables 297 | self.__solution_count = 0 298 | 299 | # hold the calculated solutions 300 | self.__solutions = [] 301 | self.__unique_solutions = set() 302 | 303 | def on_solution_callback(self): 304 | self.__solution_count += 1 305 | # print('Sol#: ', self.__solution_count) 306 | 307 | # using list to hold the coordinate strings of rectangles 308 | rect_strs = [] 309 | 310 | # extra coordinates of all rectangles for this solution 311 | for rect_id in self.__variables: 312 | rect = self.__variables[rect_id] 313 | x1 = self.Value(rect.x1) 314 | x2 = self.Value(rect.x2) 315 | y1 = self.Value(rect.y1) 316 | y2 = self.Value(rect.y2) 317 | 318 | rect_str = f"{x1},{y1},{x2},{y2}" 319 | # print(rect_str) 320 | 321 | rect_strs.append(rect_str) 322 | # print(f'Rect #{rect_id}: {x1},{y1} -> {x2},{y2}') 323 | 324 | # print(rect_strs) 325 | 326 | # sort the rectangles 327 | rect_strs = sorted(rect_strs) 328 | 329 | # single solution as a string 330 | solution_str = '-'.join(rect_strs) 331 | # print(solution_str) 332 | 333 | # store the solutions 334 | self.__solutions.append(solution_str) 335 | self.__unique_solutions.add(solution_str) # __unique_solutions is a set, so duplicates will get removed 336 | 337 | 338 | def solution_count(self): 339 | return self.__solution_count 340 | 341 | # returns all solutions 342 | def get_solutions(self): 343 | return self.__solutions 344 | 345 | """ 346 | returns unique solutions 347 | returns the permutation free list of solution strings 348 | """ 349 | def get_unique_solutions(self): 350 | return list(self.__unique_solutions) # __unique_solutions is a Set, convert to list 351 | 352 | 353 | ''' 354 | non-API method. Used for testing and running locally / in a Notebook. 355 | Draws the rectangles 356 | ''' 357 | 358 | def drawRectsFromCoords(rect_coords, parent_rects): 359 | import matplotlib.pyplot as plt 360 | import matplotlib.patches as patches 361 | 362 | # TODO: to add support for multiple parent rects, update here 363 | xSize = parent_rects[0][0] 364 | ySize = parent_rects[0][1] 365 | 366 | # draw rectangle 367 | fig,ax = plt.subplots(1) 368 | plt.xlim(0,xSize) 369 | plt.ylim(0,ySize) 370 | plt.gca().set_aspect('equal', adjustable='box') 371 | 372 | # print coords 373 | coords = [] 374 | colors = ['r', 'g', 'b', 'y', 'brown', 'black', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] 375 | for idx, coords in enumerate(rect_coords): 376 | x1=coords[0] 377 | y1=coords[1] 378 | x2=coords[2] 379 | y2=coords[3] 380 | # print(f"{x1}, {y1} -> {x2}, {y2}") 381 | 382 | width = abs(x1-x2) 383 | height = abs(y1-y2) 384 | # print(f"Rect#{idx}: {width}x{height}") 385 | 386 | # Create a Rectangle patch 387 | rect_shape = patches.Rectangle((x1,y1), width, height,facecolor=colors[idx]) 388 | # Add the patch to the Axes 389 | ax.add_patch(rect_shape) 390 | plt.show() 391 | 392 | 393 | 394 | 395 | # for testing 396 | if __name__ == '__main__': 397 | 398 | child_rects = [ 399 | # [1, 1], 400 | # [2, 2], 401 | # [1, 3], 402 | # [4, 3], 403 | # [2, 4], 404 | # [2, 2], 405 | 406 | [27, 17], 407 | [27, 17], 408 | [18, 56], 409 | 410 | # [3, 3], 411 | # [3, 3], 412 | # [3, 3], 413 | # [3, 3], 414 | ] 415 | 416 | # parent_rects = [[6,6]] 417 | parent_rects = [[84,72]] 418 | 419 | solutions = StockCutter(child_rects, parent_rects, output_json=False) # get the integer solution 420 | 421 | for sol in solutions: 422 | print(sol) 423 | drawRectsFromCoords(sol, parent_rects) -------------------------------------------------------------------------------- /csp/stock_cutter_1d.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Original Author: Serge Kruk 3 | Original Version: https://github.com/sgkruk/Apress-AI/blob/master/cutting_stock.py 4 | 5 | Updated by: Emad Ehsan 6 | ''' 7 | from ortools.linear_solver import pywraplp 8 | from math import ceil 9 | from random import randint 10 | import json 11 | from read_lengths import get_data 12 | import typer 13 | from typing import Optional 14 | 15 | def newSolver(name,integer=False): 16 | return pywraplp.Solver(name,\ 17 | pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING \ 18 | if integer else \ 19 | pywraplp.Solver.GLOP_LINEAR_PROGRAMMING) 20 | 21 | ''' 22 | return a printable value 23 | ''' 24 | def SolVal(x): 25 | if type(x) is not list: 26 | return 0 if x is None \ 27 | else x if isinstance(x,(int,float)) \ 28 | else x.SolutionValue() if x.Integer() is False \ 29 | else int(x.SolutionValue()) 30 | elif type(x) is list: 31 | return [SolVal(e) for e in x] 32 | 33 | def ObjVal(x): 34 | return x.Objective().Value() 35 | 36 | 37 | def gen_data(num_orders): 38 | R=[] # small rolls 39 | # S=0 # seed? 40 | for i in range(num_orders): 41 | R.append([randint(1,12), randint(5,40)]) 42 | return R 43 | 44 | 45 | def solve_model(demands, parent_width=100): 46 | ''' 47 | demands = [ 48 | [1, 3], # [quantity, width] 49 | [3, 5], 50 | ... 51 | ] 52 | 53 | parent_width = integer 54 | ''' 55 | num_orders = len(demands) 56 | solver = newSolver('Cutting Stock', True) 57 | k,b = bounds(demands, parent_width) 58 | 59 | # array of boolean declared as int, if y[i] is 1, 60 | # then y[i] Big roll is used, else it was not used 61 | y = [ solver.IntVar(0, 1, f'y_{i}') for i in range(k[1]) ] 62 | 63 | # x[i][j] = 3 means that small-roll width specified by i-th order 64 | # must be cut from j-th order, 3 tmies 65 | x = [[solver.IntVar(0, b[i], f'x_{i}_{j}') for j in range(k[1])] \ 66 | for i in range(num_orders)] 67 | 68 | unused_widths = [ solver.NumVar(0, parent_width, f'w_{j}') \ 69 | for j in range(k[1]) ] 70 | 71 | # will contain the number of big rolls used 72 | nb = solver.IntVar(k[0], k[1], 'nb') 73 | 74 | # consntraint: demand fullfilment 75 | for i in range(num_orders): 76 | # small rolls from i-th order must be at least as many in quantity 77 | # as specified by the i-th order 78 | solver.Add(sum(x[i][j] for j in range(k[1])) >= demands[i][0]) 79 | 80 | # constraint: max size limit 81 | for j in range(k[1]): 82 | # total width of small rolls cut from j-th big roll, 83 | # must not exceed big rolls width 84 | solver.Add( \ 85 | sum(demands[i][1]*x[i][j] for i in range(num_orders)) \ 86 | <= parent_width*y[j] \ 87 | ) 88 | 89 | # width of j-th big roll - total width of all orders cut from j-th roll 90 | # must be equal to unused_widths[j] 91 | # So, we are saying that assign unused_widths[j] the remaining width of j'th big roll 92 | solver.Add(parent_width*y[j] - sum(demands[i][1]*x[i][j] for i in range(num_orders)) == unused_widths[j]) 93 | 94 | ''' 95 | Book Author's note from page 201: 96 | [the following constraint] breaks the symmetry of multiple solutions that are equivalent 97 | for our purposes: any permutation of the rolls. These permutations, and there are K! of 98 | them, cause most solvers to spend an exorbitant time solving. With this constraint, we 99 | tell the solver to prefer those permutations with more cuts in roll j than in roll j + 1. 100 | The reader is encouraged to solve a medium-sized problem with and without this 101 | symmetry-breaking constraint. I have seen problems take 48 hours to solve without the 102 | constraint and 48 minutes with. Of course, for problems that are solved in seconds, the 103 | constraint will not help; it may even hinder. But who cares if a cutting stock instance 104 | solves in two or in three seconds? We care much more about the difference between two 105 | minutes and three hours, which is what this constraint is meant to address 106 | ''' 107 | if j < k[1]-1: # k1 = total big rolls 108 | # total small rolls of i-th order cut from j-th big roll must be >= 109 | # totall small rolls of i-th order cut from j+1-th big roll 110 | solver.Add(sum(x[i][j] for i in range(num_orders)) >= sum(x[i][j+1] for i in range(num_orders))) 111 | 112 | # find & assign to nb, the number of big rolls used 113 | solver.Add(nb == solver.Sum(y[j] for j in range(k[1]))) 114 | 115 | ''' 116 | minimize total big rolls used 117 | let's say we have y = [1, 0, 1] 118 | here, total big rolls used are 2. 0-th and 2nd. 1st one is not used. So we want our model to use the 119 | earlier rolls first. i.e. y = [1, 1, 0]. 120 | The trick to do this is to define the cost of using each next roll to be higher. So the model would be 121 | forced to used the initial rolls, when available, instead of the next rolls. 122 | 123 | So instead of Minimize ( Sum of y ) or Minimize( Sum([1,1,0]) ) 124 | we Minimize( Sum([1*1, 1*2, 1*3]) ) 125 | ''' 126 | 127 | ''' 128 | Book Author's note from page 201: 129 | 130 | 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 131 | 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 132 | ''' 133 | 134 | Cost = solver.Sum((j+1)*y[j] for j in range(k[1])) 135 | 136 | solver.Minimize(Cost) 137 | 138 | status = solver.Solve() 139 | numRollsUsed = SolVal(nb) 140 | 141 | return status, \ 142 | numRollsUsed, \ 143 | rolls(numRollsUsed, SolVal(x), SolVal(unused_widths), demands), \ 144 | SolVal(unused_widths), \ 145 | solver.WallTime() 146 | 147 | def bounds(demands, parent_width=100): 148 | ''' 149 | b = [sum of widths of individual small rolls of each order] 150 | T = local var. stores sum of widths of adjecent small-rolls. When the width reaches 100%, T is set to 0 again. 151 | k = [k0, k1], k0 = minimum big-rolls requierd, k1: number of big rolls that can be consumed / cut from 152 | 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 153 | ''' 154 | num_orders = len(demands) 155 | b = [] 156 | T = 0 157 | k = [0,1] 158 | TT = 0 159 | 160 | for i in range(num_orders): 161 | # q = quantity, w = width; of i-th order 162 | quantity, width = demands[i][0], demands[i][1] 163 | # TODO Verify: why min of quantity, parent_width/width? 164 | # assumes widths to be entered as percentage 165 | # 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) 166 | # b.append( min(demands[i][0], int(round(parent_width / demands[i][1]))) ) 167 | b.append( min(quantity, int(round(parent_width / width))) ) 168 | 169 | # if total width of this i-th order + previous order's leftover (T) is less than parent_width 170 | # it's fine. Cut it. 171 | if T + quantity*width <= parent_width: 172 | T, TT = T + quantity*width, TT + quantity*width 173 | # else, the width exceeds, so we have to cut only as much as we can cut from parent_width width of the big roll 174 | else: 175 | while quantity: 176 | if T + width <= parent_width: 177 | T, TT, quantity = T + width, TT + width, quantity-1 178 | else: 179 | k[1],T = k[1]+1, 0 # use next roll (k[1] += 1) 180 | k[0] = int(round(TT/parent_width+0.5)) 181 | 182 | print('k', k) 183 | print('b', b) 184 | 185 | return k, b 186 | 187 | ''' 188 | nb: array of number of rolls to cut, of each order 189 | 190 | w: 191 | demands: [ 192 | [quantity, width], 193 | [quantity, width], 194 | [quantity, width], 195 | ] 196 | ''' 197 | def rolls(nb, x, w, demands): 198 | consumed_big_rolls = [] 199 | num_orders = len(x) 200 | # go over first row (1st order) 201 | # this row contains the list of all the big rolls available, and if this 1st (0-th) order 202 | # is cut from any big roll, that big roll's index would contain a number > 0 203 | for j in range(len(x[0])): 204 | # w[j]: width of j-th big roll 205 | # 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 206 | RR = [ abs(w[j])] + [ int(x[i][j])*[demands[i][1]] for i in range(num_orders) \ 207 | if x[i][j] > 0 ] # if i-th order has some cuts from j-th order, x[i][j] would be > 0 208 | consumed_big_rolls.append(RR) 209 | 210 | return consumed_big_rolls 211 | 212 | 213 | 214 | ''' 215 | this model starts with some patterns and then optimizes those patterns 216 | ''' 217 | def solve_large_model(demands, parent_width=100): 218 | num_orders = len(demands) 219 | iter = 0 220 | patterns = get_initial_patterns(demands) 221 | # print('method#solve_large_model, patterns', patterns) 222 | 223 | # list quantities of orders 224 | quantities = [demands[i][0] for i in range(num_orders)] 225 | print('quantities', quantities) 226 | 227 | while iter < 20: 228 | status, y, l = solve_master(patterns, quantities, parent_width=parent_width) 229 | iter += 1 230 | 231 | # list widths of orders 232 | widths = [demands[i][1] for i in range(num_orders)] 233 | new_pattern, objectiveValue = get_new_pattern(l, widths, parent_width=parent_width) 234 | 235 | # print('method#solve_large_model, new_pattern', new_pattern) 236 | # print('method#solve_large_model, objectiveValue', objectiveValue) 237 | 238 | for i in range(num_orders): 239 | # add i-th cut of new pattern to i-thp pattern 240 | patterns[i].append(new_pattern[i]) 241 | 242 | status, y, l = solve_master(patterns, quantities, parent_width=parent_width, integer=True) 243 | 244 | return status, \ 245 | patterns, \ 246 | y, \ 247 | rolls_patterns(patterns, y, demands, parent_width=parent_width) 248 | 249 | 250 | ''' 251 | Dantzig-Wolfe decomposition splits the problem into a Master Problem MP and a sub-problem SP. 252 | 253 | The Master Problem: provided a set of patterns, find the best combination satisfying the demand 254 | 255 | C: patterns 256 | b: demand 257 | ''' 258 | def solve_master(patterns, quantities, parent_width=100, integer=False): 259 | title = 'Cutting stock master problem' 260 | num_patterns = len(patterns) 261 | n = len(patterns[0]) 262 | # print('**num_patterns x n: ', num_patterns, 'x', n) 263 | # print('**patterns recived:') 264 | # for p in patterns: 265 | # print(p) 266 | 267 | constraints = [] 268 | 269 | solver = newSolver(title, integer) 270 | 271 | # y is not boolean, it's an integer now (as compared to y in approach used by solve_model) 272 | y = [ solver.IntVar(0, 1000, '') for j in range(n) ] # right bound? 273 | # minimize total big rolls (y) used 274 | Cost = sum(y[j] for j in range(n)) 275 | solver.Minimize(Cost) 276 | 277 | # for every pattern 278 | for i in range(num_patterns): 279 | # add constraint that this pattern (demand) must be met 280 | # there are m such constraints, for each pattern 281 | constraints.append(solver.Add( sum(patterns[i][j]*y[j] for j in range(n)) >= quantities[i]) ) 282 | 283 | status = solver.Solve() 284 | y = [int(ceil(e.SolutionValue())) for e in y] 285 | 286 | l = [0 if integer else constraints[i].DualValue() for i in range(num_patterns)] 287 | # sl = [0 if integer else constraints[i].name() for i in range(num_patterns)] 288 | # print('sl: ', sl) 289 | 290 | # l = [0 if integer else u[i].Ub() for i in range(m)] 291 | toreturn = status, y, l 292 | # l_to_print = [round(dd, 2) for dd in toreturn[2]] 293 | # print('l: ', len(l_to_print), '->', l_to_print) 294 | # print('l: ', toreturn[2]) 295 | return toreturn 296 | 297 | def get_new_pattern(l, w, parent_width=100): 298 | solver = newSolver('Cutting stock sub-problem', True) 299 | n = len(l) 300 | new_pattern = [ solver.IntVar(0, parent_width, '') for i in range(n) ] 301 | 302 | # maximizes the sum of the values times the number of occurrence of that roll in a pattern 303 | Cost = sum( l[i] * new_pattern[i] for i in range(n)) 304 | solver.Maximize(Cost) 305 | 306 | # ensuring that the pattern stays within the total width of the large roll 307 | solver.Add( sum( w[i] * new_pattern[i] for i in range(n)) <= parent_width ) 308 | 309 | status = solver.Solve() 310 | return SolVal(new_pattern), ObjVal(solver) 311 | 312 | 313 | ''' 314 | the initial patterns must be such that they will allow a feasible solution, 315 | one that satisfies all demands. 316 | Considering the already complex model, let’s keep it simple. 317 | Our initial patterns have exactly one roll per pattern, as obviously feasible as inefficient. 318 | ''' 319 | def get_initial_patterns(demands): 320 | num_orders = len(demands) 321 | return [[0 if j != i else 1 for j in range(num_orders)]\ 322 | for i in range(num_orders)] 323 | 324 | def rolls_patterns(patterns, y, demands, parent_width=100): 325 | R, m, n = [], len(patterns), len(y) 326 | 327 | for j in range(n): 328 | for _ in range(y[j]): 329 | RR = [] 330 | for i in range(m): 331 | if patterns[i][j] > 0: 332 | RR.extend( [demands[i][1]] * int(patterns[i][j]) ) 333 | used_width = sum(RR) 334 | R.append([parent_width - used_width, RR]) 335 | 336 | return R 337 | 338 | 339 | ''' 340 | checks if all small roll widths (demands) smaller than parent roll's width 341 | ''' 342 | def checkWidths(demands, parent_width): 343 | for quantity, width in demands: 344 | if width > parent_width: 345 | print(f'Small roll width {width} is greater than parent rolls width {parent_width}. Exiting') 346 | return False 347 | return True 348 | 349 | 350 | ''' 351 | params 352 | child_rolls: 353 | list of lists, each containing quantity & width of rod / roll to be cut 354 | e.g.: [ [quantity, width], [quantity, width], ...] 355 | parent_rolls: 356 | list of lists, each containing quantity & width of rod / roll to cut from 357 | e.g.: [ [quantity, width], [quantity, width], ...] 358 | ''' 359 | def StockCutter1D(child_rolls, parent_rolls, output_json=True, large_model=True): 360 | 361 | # at the moment, only parent one width of parent rolls is supported 362 | # quantity of parent rolls is calculated by algorithm, so user supplied quantity doesn't matter? 363 | # TODO: or we can check and tell the user the user when parent roll quantity is insufficient 364 | parent_width = parent_rolls[0][1] 365 | 366 | if not checkWidths(demands=child_rolls, parent_width=parent_width): 367 | return [] 368 | 369 | 370 | print('child_rolls', child_rolls) 371 | print('parent_rolls', parent_rolls) 372 | 373 | if not large_model: 374 | print('Running Small Model...') 375 | status, numRollsUsed, consumed_big_rolls, unused_roll_widths, wall_time = \ 376 | solve_model(demands=child_rolls, parent_width=parent_width) 377 | 378 | # convert the format of output of solve_model to be exactly same as solve_large_model 379 | print('consumed_big_rolls before adjustment: ', consumed_big_rolls) 380 | new_consumed_big_rolls = [] 381 | for big_roll in consumed_big_rolls: 382 | if len(big_roll) < 2: 383 | # sometimes the solve_model return a solution that contanis an extra [0.0] entry for big roll 384 | consumed_big_rolls.remove(big_roll) 385 | continue 386 | unused_width = big_roll[0] 387 | subrolls = [] 388 | for subitem in big_roll[1:]: 389 | if isinstance(subitem, list): 390 | # if it's a list, concatenate with the other lists, to make a single list for this big_roll 391 | subrolls = subrolls + subitem 392 | else: 393 | # if it's an integer, add it to the list 394 | subrolls.append(subitem) 395 | new_consumed_big_rolls.append([unused_width, subrolls]) 396 | print('consumed_big_rolls after adjustment: ', new_consumed_big_rolls) 397 | consumed_big_rolls = new_consumed_big_rolls 398 | 399 | else: 400 | print('Running Large Model...'); 401 | status, A, y, consumed_big_rolls = solve_large_model(demands=child_rolls, parent_width=parent_width) 402 | 403 | numRollsUsed = len(consumed_big_rolls) 404 | # print('A:', A, '\n') 405 | # print('y:', y, '\n') 406 | 407 | 408 | STATUS_NAME = ['OPTIMAL', 409 | 'FEASIBLE', 410 | 'INFEASIBLE', 411 | 'UNBOUNDED', 412 | 'ABNORMAL', 413 | 'NOT_SOLVED' 414 | ] 415 | 416 | output = { 417 | "statusName": STATUS_NAME[status], 418 | "numSolutions": '1', 419 | "numUniqueSolutions": '1', 420 | "numRollsUsed": numRollsUsed, 421 | "solutions": consumed_big_rolls # unique solutions 422 | } 423 | 424 | 425 | # print('Wall Time:', wall_time) 426 | print('numRollsUsed', numRollsUsed) 427 | print('Status:', output['statusName']) 428 | print('Solutions found :', output['numSolutions']) 429 | print('Unique solutions: ', output['numUniqueSolutions']) 430 | 431 | if output_json: 432 | return json.dumps(output) 433 | else: 434 | return consumed_big_rolls 435 | 436 | 437 | ''' 438 | Draws the big rolls on the graph. Each horizontal colored line represents one big roll. 439 | In each big roll (multi-colored horizontal line), each color represents small roll to be cut from it. 440 | If the big roll ends with a black color, that part of the big roll is unused width. 441 | 442 | TODO: Assign each child roll a unique color 443 | ''' 444 | def drawGraph(consumed_big_rolls, child_rolls, parent_width): 445 | import matplotlib.pyplot as plt 446 | import matplotlib.patches as patches 447 | 448 | # TODO: to add support for multiple different parent rolls, update here 449 | xSize = parent_width # width of big roll 450 | ySize = 10 * len(consumed_big_rolls) # one big roll will take 10 units vertical space 451 | 452 | # draw rectangle 453 | fig,ax = plt.subplots(1) 454 | plt.xlim(0, xSize) 455 | plt.ylim(0, ySize) 456 | plt.gca().set_aspect('equal', adjustable='box') 457 | 458 | # print coords 459 | coords = [] 460 | colors = ['r', 'g', 'b', 'y', 'brown', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] 461 | colorDict = {} 462 | i = 0 463 | for quantity, width in child_rolls: 464 | colorDict[width] = colors[i % 11] 465 | i+= 1 466 | 467 | # start plotting each big roll horizontly, from the bottom 468 | y1 = 0 469 | for i, big_roll in enumerate(consumed_big_rolls): 470 | ''' 471 | big_roll = [leftover_width, [small_roll_1_1, small_roll_1_2, other_small_roll_2_1]] 472 | ''' 473 | unused_width = big_roll[0] 474 | small_rolls = big_roll[1] 475 | 476 | x1 = 0 477 | x2 = 0 478 | y2 = y1 + 8 # the height of each big roll will be 8 479 | for j, small_roll in enumerate(small_rolls): 480 | x2 = x2 + small_roll 481 | print(f"{x1}, {y1} -> {x2}, {y2}") 482 | width = abs(x1-x2) 483 | height = abs(y1-y2) 484 | # print(f"Rect#{idx}: {width}x{height}") 485 | # Create a Rectangle patch 486 | rect_shape = patches.Rectangle((x1,y1), width, height, facecolor=colorDict[small_roll], label=f'{small_roll}') 487 | ax.add_patch(rect_shape) # Add the patch to the Axes 488 | x1 = x2 # x1 for next small roll in same big roll will be x2 of current roll 489 | 490 | # now that all small rolls have been plotted, check if a there is unused width in this big roll 491 | # set the unused width at the end as black colored rectangle 492 | if unused_width > 0: 493 | width = unused_width 494 | rect_shape = patches.Rectangle((x1,y1), width, height, facecolor='black', label='Unused') 495 | ax.add_patch(rect_shape) # Add the patch to the Axes 496 | 497 | y1 += 10 # next big roll will be plotted on top of current, a roll height is 8, so 2 will be margin between rolls 498 | 499 | plt.show() 500 | 501 | 502 | if __name__ == '__main__': 503 | 504 | # child_rolls = [ 505 | # [quantity, width], 506 | # ] 507 | app = typer.Typer() 508 | 509 | 510 | def main(infile_name: Optional[str] = typer.Argument(None)): 511 | 512 | if infile_name: 513 | child_rolls = get_data(infile_name) 514 | else: 515 | child_rolls = gen_data(3) 516 | parent_rolls = [[10, 120]] # 10 doesn't matter, itls not used at the moment 517 | 518 | consumed_big_rolls = StockCutter1D(child_rolls, parent_rolls, output_json=False, large_model=False) 519 | typer.echo(f"{consumed_big_rolls}") 520 | 521 | 522 | for idx, roll in enumerate(consumed_big_rolls): 523 | typer.echo(f"Roll #{idx}:{roll}") 524 | 525 | drawGraph(consumed_big_rolls, child_rolls, parent_width=parent_rolls[0][1]) 526 | 527 | if __name__ == "__main__": 528 | typer.run(main) 529 | -------------------------------------------------------------------------------- /deployment/stock_cutter_1d.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Original Author: Serge Kruk 3 | Original Version: https://github.com/sgkruk/Apress-AI/blob/master/cutting_stock.py 4 | 5 | Updated by: Emad Ehsan 6 | V2: https://github.com/emadehsan/Apress-AI/blob/master/my-models/custom_cutting_stock.py 7 | 8 | V3 is following: 9 | ''' 10 | from ortools.linear_solver import pywraplp 11 | from math import ceil 12 | from random import randint 13 | import json 14 | 15 | def newSolver(name,integer=False): 16 | return pywraplp.Solver(name,\ 17 | pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING \ 18 | if integer else \ 19 | pywraplp.Solver.GLOP_LINEAR_PROGRAMMING) 20 | 21 | ''' 22 | return a printable value 23 | ''' 24 | def SolVal(x): 25 | if type(x) is not list: 26 | return 0 if x is None \ 27 | else x if isinstance(x,(int,float)) \ 28 | else x.SolutionValue() if x.Integer() is False \ 29 | else int(x.SolutionValue()) 30 | elif type(x) is list: 31 | return [SolVal(e) for e in x] 32 | 33 | def ObjVal(x): 34 | return x.Objective().Value() 35 | 36 | 37 | def gen_data(num_orders): 38 | R=[] # small rolls 39 | # S=0 # seed? 40 | for i in range(num_orders): 41 | R.append([randint(1,12), randint(5,40)]) 42 | return R 43 | 44 | 45 | def solve_model(demands, parent_width=100, cutStyle='exactCuts'): 46 | ''' demands = [ 47 | [1, 3], # [quantity, width] 48 | [3, 5], 49 | ... 50 | ] 51 | 52 | parent_width = integer 53 | ''' 54 | num_orders = len(demands) 55 | solver = newSolver('Cutting Stock', True) 56 | k,b = bounds(demands, parent_width) 57 | 58 | # array of boolean declared as int, if y[i] is 1, 59 | # then y[i] Big roll is used, else it was not used 60 | y = [ solver.IntVar(0, 1, f'y_{i}') for i in range(k[1]) ] 61 | 62 | # x[i][j] = 3 means that small-roll width specified by i-th order 63 | # must be cut from j-th order, 3 tmies 64 | x = [[solver.IntVar(0, b[i], f'x_{i}_{j}') for j in range(k[1])] \ 65 | for i in range(num_orders)] 66 | 67 | unused_widths = [ solver.NumVar(0, parent_width, f'w_{j}') \ 68 | for j in range(k[1]) ] 69 | 70 | # will contain the number of big rolls used 71 | nb = solver.IntVar(k[0], k[1], 'nb') 72 | 73 | # consntraint: demand fullfilment 74 | for i in range(num_orders): 75 | # small rolls from i-th order must be at least as many in quantity 76 | # as specified by the i-th order 77 | if cutStyle == 'minWaste': 78 | solver.Add(sum(x[i][j] for j in range(k[1])) >= demands[i][0]) 79 | else: 80 | # probably cutStyle == exactCuts 81 | solver.Add(sum(x[i][j] for j in range(k[1])) == demands[i][0]) 82 | 83 | # constraint: max size limit 84 | for j in range(k[1]): 85 | # total width of small rolls cut from j-th big roll, 86 | # must not exceed big rolls width 87 | solver.Add( \ 88 | sum(demands[i][1]*x[i][j] for i in range(num_orders)) \ 89 | <= parent_width*y[j] \ 90 | ) 91 | 92 | # width of j-th big roll - total width of all orders cut from j-th roll 93 | # must be equal to unused_widths[j] 94 | # So, we are saying that assign unused_widths[j] the remaining width of j'th big roll 95 | solver.Add(parent_width*y[j] - sum(demands[i][1]*x[i][j] for i in range(num_orders)) == unused_widths[j]) 96 | 97 | ''' 98 | Book Author's note from page 201: 99 | [the following constraint] breaks the symmetry of multiple solutions that are equivalent 100 | for our purposes: any permutation of the rolls. These permutations, and there are K! of 101 | them, cause most solvers to spend an exorbitant time solving. With this constraint, we 102 | tell the solver to prefer those permutations with more cuts in roll j than in roll j + 1. 103 | The reader is encouraged to solve a medium-sized problem with and without this 104 | symmetry-breaking constraint. I have seen problems take 48 hours to solve without the 105 | constraint and 48 minutes with. Of course, for problems that are solved in seconds, the 106 | constraint will not help; it may even hinder. But who cares if a cutting stock instance 107 | solves in two or in three seconds? We care much more about the difference between two 108 | minutes and three hours, which is what this constraint is meant to address 109 | ''' 110 | if j < k[1]-1: # k1 = total big rolls 111 | # total small rolls of i-th order cut from j-th big roll must be >= 112 | # totall small rolls of i-th order cut from j+1-th big roll 113 | solver.Add(sum(x[i][j] for i in range(num_orders)) >= sum(x[i][j+1] for i in range(num_orders))) 114 | 115 | # find & assign to nb, the number of big rolls used 116 | solver.Add(nb == solver.Sum(y[j] for j in range(k[1]))) 117 | 118 | ''' 119 | minimize total big rolls used 120 | let's say we have y = [1, 0, 1] 121 | here, total big rolls used are 2. 0-th and 2nd. 1st one is not used. So we want our model to use the 122 | earlier rolls first. i.e. y = [1, 1, 0]. 123 | The trick to do this is to define the cost of using each next roll to be higher. So the model would be 124 | forced to used the initial rolls, when available, instead of the next rolls. 125 | 126 | So instead of Minimize ( Sum of y ) or Minimize( Sum([1,1,0]) ) 127 | we Minimize( Sum([1*1, 1*2, 1*3]) ) 128 | ''' 129 | 130 | ''' 131 | Book Author's note from page 201: 132 | 133 | 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 134 | 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 135 | ''' 136 | 137 | Cost = solver.Sum((j+1)*y[j] for j in range(k[1])) 138 | 139 | solver.Minimize(Cost) 140 | 141 | status = solver.Solve() 142 | numRollsUsed = SolVal(nb) 143 | 144 | return status, \ 145 | numRollsUsed, \ 146 | rolls(numRollsUsed, SolVal(x), SolVal(unused_widths), demands), \ 147 | SolVal(unused_widths), \ 148 | solver.WallTime() 149 | 150 | def bounds(demands, parent_width=100): 151 | ''' 152 | b = [sum of widths of individual small rolls of each order] 153 | T = local var. stores sum of widths of adjecent small-rolls. When the width reaches 100%, T is set to 0 again. 154 | k = [k0, k1], k0 = minimum big-rolls requierd, k1: number of big rolls that can be consumed / cut from 155 | 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 156 | ''' 157 | num_orders = len(demands) 158 | b = [] 159 | T = 0 160 | k = [0,1] 161 | TT = 0 162 | 163 | for i in range(num_orders): 164 | # q = quantity, w = width; of i-th order 165 | quantity, width = demands[i][0], demands[i][1] 166 | # TODO Verify: why min of quantity, parent_width/width? 167 | # assumes widths to be entered as percentage 168 | # 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) 169 | # b.append( min(demands[i][0], int(round(parent_width / demands[i][1]))) ) 170 | b.append( min(quantity, int(round(parent_width / width))) ) 171 | 172 | # if total width of this i-th order + previous order's leftover (T) is less than parent_width 173 | # it's fine. Cut it. 174 | if T + quantity*width <= parent_width: 175 | T, TT = T + quantity*width, TT + quantity*width 176 | # else, the width exceeds, so we have to cut only as much as we can cut from parent_width width of the big roll 177 | else: 178 | while quantity: 179 | if T + width <= parent_width: 180 | T, TT, quantity = T + width, TT + width, quantity-1 181 | else: 182 | k[1],T = k[1]+1, 0 # use next roll (k[1] += 1) 183 | k[0] = int(round(TT/parent_width+0.5)) 184 | 185 | print('k', k) 186 | print('b', b) 187 | 188 | return k, b 189 | 190 | ''' 191 | nb: array of number of rolls to cut, of each order 192 | 193 | w: 194 | demands: [ 195 | [quantity, width], 196 | [quantity, width], 197 | [quantity, width], 198 | ] 199 | ''' 200 | def rolls(nb, x, w, demands): 201 | consumed_big_rolls = [] 202 | num_orders = len(x) 203 | # go over first row (1st order) 204 | # this row contains the list of all the big rolls available, and if this 1st (0-th) order 205 | # is cut from any big roll, that big roll's index would contain a number > 0 206 | for j in range(len(x[0])): 207 | # w[j]: width of j-th big roll 208 | # 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 209 | RR = [ abs(w[j])] + [ int(x[i][j])*[demands[i][1]] for i in range(num_orders) \ 210 | if x[i][j] > 0 ] # if i-th order has some cuts from j-th order, x[i][j] would be > 0 211 | consumed_big_rolls.append(RR) 212 | 213 | return consumed_big_rolls 214 | 215 | 216 | 217 | ''' 218 | this model starts with some patterns and then optimizes those patterns 219 | ''' 220 | def solve_large_model(demands, parent_width=100, cutStyle='exactCuts'): 221 | num_orders = len(demands) 222 | iter = 0 223 | patterns = get_initial_patterns(demands) 224 | # print('method#solve_large_model, patterns', patterns) 225 | 226 | # list quantities of orders 227 | quantities = [demands[i][0] for i in range(num_orders)] 228 | print('quantities', quantities) 229 | 230 | while iter < 20: 231 | status, y, l = solve_master(patterns, quantities, parent_width=parent_width, cutStyle=cutStyle) 232 | iter += 1 233 | 234 | # list widths of orders 235 | widths = [demands[i][1] for i in range(num_orders)] 236 | new_pattern, objectiveValue = get_new_pattern(l, widths, parent_width=parent_width) 237 | 238 | # print('method#solve_large_model, new_pattern', new_pattern) 239 | # print('method#solve_large_model, objectiveValue', objectiveValue) 240 | 241 | for i in range(num_orders): 242 | # add i-th cut of new pattern to i-thp pattern 243 | patterns[i].append(new_pattern[i]) 244 | 245 | status, y, l = solve_master(patterns, quantities, parent_width=parent_width, integer=True, cutStyle=cutStyle) 246 | 247 | return status, \ 248 | patterns, \ 249 | y, \ 250 | rolls_patterns(patterns, y, demands, parent_width=parent_width) 251 | 252 | 253 | ''' 254 | Dantzig-Wolfe decomposition splits the problem into a Master Problem MP and a sub-problem SP. 255 | 256 | The Master Problem: provided a set of patterns, find the best combination satisfying the demand 257 | 258 | C: patterns 259 | b: demand 260 | ''' 261 | def solve_master(patterns, quantities, parent_width=100, integer=False, cutStyle='exactCuts'): 262 | title = 'Cutting stock master problem' 263 | num_patterns = len(patterns) 264 | n = len(patterns[0]) 265 | # print('**num_patterns x n: ', num_patterns, 'x', n) 266 | # print('**patterns recived:') 267 | # for p in patterns: 268 | # print(p) 269 | 270 | constraints = [] 271 | 272 | solver = newSolver(title, integer) 273 | 274 | # y is not boolean, it's an integer now (as compared to y in approach used by solve_model) 275 | y = [ solver.IntVar(0, 1000, '') for j in range(n) ] # right bound? 276 | # minimize total big rolls (y) used 277 | Cost = sum(y[j] for j in range(n)) 278 | solver.Minimize(Cost) 279 | 280 | # for every pattern 281 | for i in range(num_patterns): 282 | # add constraint that this pattern (demand) must be met 283 | # there are m such constraints, for each pattern 284 | 285 | if cutStyle == 'minWaste': 286 | constraints.append(solver.Add( sum(patterns[i][j]*y[j] for j in range(n)) >= quantities[i]) ) 287 | else: 288 | # probably cutStyle == exactCuts 289 | constraints.append(solver.Add( sum(patterns[i][j]*y[j] for j in range(n)) == quantities[i]) ) 290 | 291 | 292 | status = solver.Solve() 293 | y = [int(ceil(e.SolutionValue())) for e in y] 294 | 295 | l = [0 if integer else constraints[i].DualValue() for i in range(num_patterns)] 296 | # sl = [0 if integer else constraints[i].name() for i in range(num_patterns)] 297 | # print('sl: ', sl) 298 | 299 | # l = [0 if integer else u[i].Ub() for i in range(m)] 300 | toreturn = status, y, l 301 | # l_to_print = [round(dd, 2) for dd in toreturn[2]] 302 | # print('l: ', len(l_to_print), '->', l_to_print) 303 | # print('l: ', toreturn[2]) 304 | return toreturn 305 | 306 | 307 | ''' 308 | TODO Make sense of this: 309 | ''' 310 | def get_new_pattern(l, w, parent_width=100): 311 | solver = newSolver('Cutting stock sub-problem', True) 312 | n = len(l) 313 | new_pattern = [ solver.IntVar(0, parent_width, '') for i in range(n) ] 314 | 315 | # maximizes the sum of the values times the number of occurrence of that roll in a pattern 316 | Cost = sum( l[i] * new_pattern[i] for i in range(n)) 317 | solver.Maximize(Cost) 318 | 319 | # ensuring that the pattern stays within the total width of the large roll 320 | solver.Add( sum( w[i] * new_pattern[i] for i in range(n)) <= parent_width ) 321 | 322 | status = solver.Solve() 323 | return SolVal(new_pattern), ObjVal(solver) 324 | 325 | 326 | ''' 327 | the initial patterns must be such that they will allow a feasible solution, 328 | one that satisfies all demands. 329 | Considering the already complex model, let’s keep it simple. 330 | Our initial patterns have exactly one roll per pattern, as obviously feasible as inefficient. 331 | ''' 332 | def get_initial_patterns(demands): 333 | num_orders = len(demands) 334 | return [[0 if j != i else 1 for j in range(num_orders)]\ 335 | for i in range(num_orders)] 336 | 337 | def rolls_patterns(patterns, y, demands, parent_width=100): 338 | R, m, n = [], len(patterns), len(y) 339 | 340 | for j in range(n): 341 | for _ in range(y[j]): 342 | RR = [] 343 | for i in range(m): 344 | if patterns[i][j] > 0: 345 | RR.extend( [demands[i][1]] * int(patterns[i][j]) ) 346 | used_width = sum(RR) 347 | R.append([parent_width - used_width, RR]) 348 | 349 | return R 350 | 351 | 352 | ''' 353 | checks if all small roll widths (demands) smaller than parent roll's width 354 | ''' 355 | def checkWidths(demands, parent_width): 356 | for quantity, width in demands: 357 | if width > parent_width: 358 | print(f'Small roll width {width} is greater than parent rolls width {parent_width}. Exiting') 359 | return False 360 | return True 361 | 362 | 363 | ''' 364 | params 365 | child_rolls: 366 | list of lists, each containing quantity & width of rod / roll to be cut 367 | e.g.: [ [quantity, width], [quantity, width], ...] 368 | parent_rolls: 369 | list of lists, each containing quantity & width of rod / roll to cut from 370 | e.g.: [ [quantity, width], [quantity, width], ...] 371 | cutStyle: 372 | there are two types of cutting style 373 | 1. cut exactly as many items as specified: exactCuts 374 | 2. cut some items more than specified to minimize waste: minWaste 375 | ''' 376 | def StockCutter1D(child_rolls, parent_rolls, output_json=True, large_model=True, cutStyle='exactCuts'): 377 | 378 | # at the moment, only parent one width of parent rolls is supported 379 | # quantity of parent rolls is calculated by algorithm, so user supplied quantity doesn't matter? 380 | # TODO: or we can check and tell the user the user when parent roll quantity is insufficient 381 | parent_width = parent_rolls[0][1] 382 | 383 | if not checkWidths(demands=child_rolls, parent_width=parent_width): 384 | return [] 385 | 386 | 387 | print('child_rolls', child_rolls) 388 | print('parent_rolls', parent_rolls) 389 | 390 | if not large_model: 391 | print('Running Small Model...') 392 | status, numRollsUsed, consumed_big_rolls, unused_roll_widths, wall_time = \ 393 | solve_model(demands=child_rolls, parent_width=parent_width, cutStyle=cutStyle) 394 | 395 | # convert the format of output of solve_model to be exactly same as solve_large_model 396 | print('consumed_big_rolls before adjustment: ', consumed_big_rolls) 397 | new_consumed_big_rolls = [] 398 | for big_roll in consumed_big_rolls: 399 | if len(big_roll) < 2: 400 | # sometimes the solve_model return a solution that contanis an extra [0.0] entry for big roll 401 | consumed_big_rolls.remove(big_roll) 402 | continue 403 | unused_width = big_roll[0] 404 | subrolls = [] 405 | for subitem in big_roll[1:]: 406 | if isinstance(subitem, list): 407 | # if it's a list, concatenate with the other lists, to make a single list for this big_roll 408 | subrolls = subrolls + subitem 409 | else: 410 | # if it's an integer, add it to the list 411 | subrolls.append(subitem) 412 | new_consumed_big_rolls.append([unused_width, subrolls]) 413 | print('consumed_big_rolls after adjustment: ', new_consumed_big_rolls) 414 | consumed_big_rolls = new_consumed_big_rolls 415 | 416 | else: 417 | print('Running Large Model...'); 418 | status, A, y, consumed_big_rolls = solve_large_model(demands=child_rolls, parent_width=parent_width, cutStyle=cutStyle) 419 | 420 | numRollsUsed = len(consumed_big_rolls) 421 | # print('A:', A, '\n') 422 | # print('y:', y, '\n') 423 | 424 | 425 | STATUS_NAME = ['OPTIMAL', 426 | 'FEASIBLE', 427 | 'INFEASIBLE', 428 | 'UNBOUNDED', 429 | 'ABNORMAL', 430 | 'NOT_SOLVED' 431 | ] 432 | 433 | output = { 434 | "statusName": STATUS_NAME[status], 435 | "numSolutions": '1', 436 | "numUniqueSolutions": '1', 437 | "numRollsUsed": numRollsUsed, 438 | "solutions": consumed_big_rolls # unique solutions 439 | } 440 | 441 | 442 | # print('Wall Time:', wall_time) 443 | print('numRollsUsed', numRollsUsed) 444 | print('Status:', output['statusName']) 445 | print('Solutions found :', output['numSolutions']) 446 | print('Unique solutions: ', output['numUniqueSolutions']) 447 | 448 | if output_json: 449 | return json.dumps(output) 450 | else: 451 | return consumed_big_rolls 452 | 453 | 454 | ''' 455 | Draws the big rolls on the graph. Each horizontal colored line represents one big roll. 456 | In each big roll (multi-colored horizontal line), each color represents small roll to be cut from it. 457 | If the big roll ends with a black color, that part of the big roll is unused width. 458 | 459 | TODO: Assign each child roll a unique color 460 | ''' 461 | def drawGraph(consumed_big_rolls, child_rolls, parent_width): 462 | import matplotlib.pyplot as plt 463 | import matplotlib.patches as patches 464 | 465 | # TODO: to add support for multiple different parent rolls, update here 466 | xSize = parent_width # width of big roll 467 | ySize = 10 * len(consumed_big_rolls) # one big roll will take 10 units vertical space 468 | 469 | # draw rectangle 470 | fig,ax = plt.subplots(1) 471 | plt.xlim(0, xSize) 472 | plt.ylim(0, ySize) 473 | plt.gca().set_aspect('equal', adjustable='box') 474 | 475 | # print coords 476 | coords = [] 477 | colors = ['r', 'g', 'b', 'y', 'brown', 'violet', 'pink', 'gray', 'orange', 'b', 'y'] 478 | colorDict = {} 479 | i = 0 480 | for quantity, width in child_rolls: 481 | colorDict[width] = colors[i % 11] 482 | i+= 1 483 | 484 | # start plotting each big roll horizontly, from the bottom 485 | y1 = 0 486 | for i, big_roll in enumerate(consumed_big_rolls): 487 | ''' 488 | big_roll = [leftover_width, [small_roll_1_1, small_roll_1_2, other_small_roll_2_1]] 489 | ''' 490 | unused_width = big_roll[0] 491 | small_rolls = big_roll[1] 492 | 493 | x1 = 0 494 | x2 = 0 495 | y2 = y1 + 8 # the height of each big roll will be 8 496 | for j, small_roll in enumerate(small_rolls): 497 | x2 = x2 + small_roll 498 | print(f"{x1}, {y1} -> {x2}, {y2}") 499 | width = abs(x1-x2) 500 | height = abs(y1-y2) 501 | # print(f"Rect#{idx}: {width}x{height}") 502 | # Create a Rectangle patch 503 | rect_shape = patches.Rectangle((x1,y1), width, height, facecolor=colorDict[small_roll], label=f'{small_roll}') 504 | ax.add_patch(rect_shape) # Add the patch to the Axes 505 | x1 = x2 # x1 for next small roll in same big roll will be x2 of current roll 506 | 507 | # now that all small rolls have been plotted, check if a there is unused width in this big roll 508 | # set the unused width at the end as black colored rectangle 509 | if unused_width > 0: 510 | width = unused_width 511 | rect_shape = patches.Rectangle((x1,y1), width, height, facecolor='black', label='Unused') 512 | ax.add_patch(rect_shape) # Add the patch to the Axes 513 | 514 | y1 += 10 # next big roll will be plotted on top of current, a roll height is 8, so 2 will be margin between rolls 515 | 516 | plt.show() 517 | 518 | 519 | if __name__ == '__main__': 520 | 521 | child_rolls = [ 522 | # [quantity, width], 523 | # [6, 25], 524 | # [12, 21], 525 | # [7, 26], 526 | # [3, 23], 527 | # [8, 33], 528 | # [2, 15], 529 | # [2, 34], 530 | 531 | # [3, 3], 532 | # [3, 4], 533 | 534 | [3,3], 535 | [3,1], 536 | [2,4], 537 | [2,2] 538 | 539 | # [3,30], 540 | # [2,72], 541 | # [5,50] 542 | ] 543 | 544 | # child_rolls = gen_data(3) 545 | # parent_rolls = [[10, 120]] 546 | # parent_rolls = [[10, 8]] 547 | parent_rolls = [[10, 6]] 548 | # parent_rolls = [[10, 144]] 549 | 550 | 551 | consumed_big_rolls = StockCutter1D(child_rolls, parent_rolls, output_json=False, large_model=False) 552 | print (consumed_big_rolls) 553 | 554 | for idx, roll in enumerate(consumed_big_rolls): 555 | print(f'Roll #{idx}:', roll) 556 | 557 | 558 | drawGraph(consumed_big_rolls, child_rolls, parent_width=parent_rolls[0][1]) -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "a3fd796537e543d9025cfd9ce1ab78504af858c04ed185114aebaa40eb83613e" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "absl-py": { 20 | "hashes": [ 21 | "sha256:5d15f85b8cc859c6245bc9886ba664460ed96a6fee895416caa37d669ee74a9a", 22 | "sha256:f568809938c49abbda89826223c992b630afd23c638160ad7840cfe347710d97" 23 | ], 24 | "markers": "python_version >= '3.6'", 25 | "version": "==1.2.0" 26 | }, 27 | "attrs": { 28 | "hashes": [ 29 | "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6", 30 | "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c" 31 | ], 32 | "markers": "python_version >= '3.5'", 33 | "version": "==22.1.0" 34 | }, 35 | "click": { 36 | "hashes": [ 37 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 38 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 39 | ], 40 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 41 | "version": "==7.1.2" 42 | }, 43 | "cycler": { 44 | "hashes": [ 45 | "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3", 46 | "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f" 47 | ], 48 | "markers": "python_version >= '3.6'", 49 | "version": "==0.11.0" 50 | }, 51 | "iniconfig": { 52 | "hashes": [ 53 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 54 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 55 | ], 56 | "version": "==1.1.1" 57 | }, 58 | "kiwisolver": { 59 | "hashes": [ 60 | "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b", 61 | "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166", 62 | "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c", 63 | "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c", 64 | "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0", 65 | "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4", 66 | "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9", 67 | "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286", 68 | "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767", 69 | "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c", 70 | "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6", 71 | "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b", 72 | "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004", 73 | "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf", 74 | "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494", 75 | "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac", 76 | "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626", 77 | "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766", 78 | "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514", 79 | "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6", 80 | "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f", 81 | "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d", 82 | "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191", 83 | "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d", 84 | "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51", 85 | "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f", 86 | "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8", 87 | "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454", 88 | "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb", 89 | "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da", 90 | "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8", 91 | "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de", 92 | "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a", 93 | "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9", 94 | "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008", 95 | "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3", 96 | "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32", 97 | "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938", 98 | "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1", 99 | "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9", 100 | "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d", 101 | "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824", 102 | "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b", 103 | "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd", 104 | "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2", 105 | "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5", 106 | "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69", 107 | "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3", 108 | "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae", 109 | "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597", 110 | "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e", 111 | "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955", 112 | "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca", 113 | "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a", 114 | "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea", 115 | "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede", 116 | "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4", 117 | "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6", 118 | "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686", 119 | "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408", 120 | "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871", 121 | "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29", 122 | "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750", 123 | "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897", 124 | "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0", 125 | "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2", 126 | "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09", 127 | "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c" 128 | ], 129 | "markers": "python_version >= '3.7'", 130 | "version": "==1.4.4" 131 | }, 132 | "matplotlib": { 133 | "hashes": [ 134 | "sha256:09225edca87a79815822eb7d3be63a83ebd4d9d98d5aa3a15a94f4eee2435954", 135 | "sha256:0caa687fce6174fef9b27d45f8cc57cbc572e04e98c81db8e628b12b563d59a2", 136 | "sha256:27c9393fada62bd0ad7c730562a0fecbd3d5aaa8d9ed80ba7d3ebb8abc4f0453", 137 | "sha256:2c2c5041608cb75c39cbd0ed05256f8a563e144234a524c59d091abbfa7a868f", 138 | "sha256:2d31aff0c8184b05006ad756b9a4dc2a0805e94d28f3abc3187e881b6673b302", 139 | "sha256:3a4c3e9be63adf8e9b305aa58fb3ec40ecc61fd0f8fd3328ce55bc30e7a2aeb0", 140 | "sha256:5111d6d47a0f5b8f3e10af7a79d5e7eb7e73a22825391834734274c4f312a8a0", 141 | "sha256:5ed3d3342698c2b1f3651f8ea6c099b0f196d16ee00e33dc3a6fee8cb01d530a", 142 | "sha256:6ffd2d80d76df2e5f9f0c0140b5af97e3b87dd29852dcdb103ec177d853ec06b", 143 | "sha256:746897fbd72bd462b888c74ed35d812ca76006b04f717cd44698cdfc99aca70d", 144 | "sha256:756ee498b9ba35460e4cbbd73f09018e906daa8537fff61da5b5bf8d5e9de5c7", 145 | "sha256:7ad44f2c74c50567c694ee91c6fa16d67e7c8af6f22c656b80469ad927688457", 146 | "sha256:83e6c895d93fdf93eeff1a21ee96778ba65ef258e5d284160f7c628fee40c38f", 147 | "sha256:9b03722c89a43a61d4d148acfc89ec5bb54cd0fd1539df25b10eb9c5fa6c393a", 148 | "sha256:a4fe54eab2c7129add75154823e6543b10261f9b65b2abe692d68743a4999f8c", 149 | "sha256:b1b60c6476c4cfe9e5cf8ab0d3127476fd3d5f05de0f343a452badaad0e4bdec", 150 | "sha256:b26c472847911f5a7eb49e1c888c31c77c4ddf8023c1545e0e8e0367ba74fb15", 151 | "sha256:b2a5e1f637a92bb6f3526cc54cc8af0401112e81ce5cba6368a1b7908f9e18bc", 152 | "sha256:b7b09c61a91b742cb5460b72efd1fe26ef83c1c704f666e0af0df156b046aada", 153 | "sha256:b8ba2a1dbb4660cb469fe8e1febb5119506059e675180c51396e1723ff9b79d9", 154 | "sha256:c092fc4673260b1446b8578015321081d5db73b94533fe4bf9b69f44e948d174", 155 | "sha256:c586ac1d64432f92857c3cf4478cfb0ece1ae18b740593f8a39f2f0b27c7fda5", 156 | "sha256:d082f77b4ed876ae94a9373f0db96bf8768a7cca6c58fc3038f94e30ffde1880", 157 | "sha256:e71cdd402047e657c1662073e9361106c6981e9621ab8c249388dfc3ec1de07b", 158 | "sha256:eb6b6700ea454bb88333d98601e74928e06f9669c1ea231b4c4c666c1d7701b4" 159 | ], 160 | "index": "pypi", 161 | "version": "==3.3.3" 162 | }, 163 | "numpy": { 164 | "hashes": [ 165 | "sha256:004f0efcb2fe1c0bd6ae1fcfc69cc8b6bf2407e0f18be308612007a0762b4089", 166 | "sha256:09f6b7bdffe57fc61d869a22f506049825d707b288039d30f26a0d0d8ea05164", 167 | "sha256:0ea3f98a0ffce3f8f57675eb9119f3f4edb81888b6874bc1953f91e0b1d4f440", 168 | "sha256:17c0e467ade9bda685d5ac7f5fa729d8d3e76b23195471adae2d6a6941bd2c18", 169 | "sha256:1f27b5322ac4067e67c8f9378b41c746d8feac8bdd0e0ffede5324667b8a075c", 170 | "sha256:22d43376ee0acd547f3149b9ec12eec2f0ca4a6ab2f61753c5b29bb3e795ac4d", 171 | "sha256:2ad3ec9a748a8943e6eb4358201f7e1c12ede35f510b1a2221b70af4bb64295c", 172 | "sha256:301c00cf5e60e08e04d842fc47df641d4a181e651c7135c50dc2762ffe293dbd", 173 | "sha256:39a664e3d26ea854211867d20ebcc8023257c1800ae89773cbba9f9e97bae036", 174 | "sha256:51bf49c0cd1d52be0a240aa66f3458afc4b95d8993d2d04f0d91fa60c10af6cd", 175 | "sha256:78a63d2df1d947bd9d1b11d35564c2f9e4b57898aae4626638056ec1a231c40c", 176 | "sha256:7cd1328e5bdf0dee621912f5833648e2daca72e3839ec1d6695e91089625f0b4", 177 | "sha256:8355fc10fd33a5a70981a5b8a0de51d10af3688d7a9e4a34fcc8fa0d7467bb7f", 178 | "sha256:8c79d7cf86d049d0c5089231a5bcd31edb03555bd93d81a16870aa98c6cfb79d", 179 | "sha256:91b8d6768a75247026e951dce3b2aac79dc7e78622fc148329135ba189813584", 180 | "sha256:94c15ca4e52671a59219146ff584488907b1f9b3fc232622b47e2cf832e94fb8", 181 | "sha256:98dcbc02e39b1658dc4b4508442a560fe3ca5ca0d989f0df062534e5ca3a5c1a", 182 | "sha256:a64403f634e5ffdcd85e0b12c08f04b3080d3e840aef118721021f9b48fc1460", 183 | "sha256:bc6e8da415f359b578b00bcfb1d08411c96e9a97f9e6c7adada554a0812a6cc6", 184 | "sha256:bdc9febce3e68b697d931941b263c59e0c74e8f18861f4064c1f712562903411", 185 | "sha256:c1ba66c48b19cc9c2975c0d354f24058888cdc674bebadceb3cdc9ec403fb5d1", 186 | "sha256:c9f707b5bb73bf277d812ded9896f9512a43edff72712f31667d0a8c2f8e71ee", 187 | "sha256:d5422d6a1ea9b15577a9432e26608c73a78faf0b9039437b075cf322c92e98e7", 188 | "sha256:e5d5420053bbb3dd64c30e58f9363d7a9c27444c3648e61460c1237f9ec3fa14", 189 | "sha256:e868b0389c5ccfc092031a861d4e158ea164d8b7fdbb10e3b5689b4fc6498df6", 190 | "sha256:efd9d3abe5774404becdb0748178b48a218f1d8c44e0375475732211ea47c67e", 191 | "sha256:f8c02ec3c4c4fcb718fdf89a6c6f709b14949408e8cf2a2be5bfa9c49548fd85", 192 | "sha256:ffcf105ecdd9396e05a8e58e81faaaf34d3f9875f137c7372450baa5d77c9a54" 193 | ], 194 | "markers": "python_version >= '3.8'", 195 | "version": "==1.23.3" 196 | }, 197 | "ortools": { 198 | "hashes": [ 199 | "sha256:102f01502e087e872fb5d8b6c0bcf02b90898c7ed37c09966618f3fa53749acb", 200 | "sha256:299c5c55a25cb865f215c295d31c439ce0c28562d363556070d5a40a0203346f", 201 | "sha256:542a3f57fe64b3911be7dafb267f1a1bdcbf7bade8fedfa0cfb24d69b5ec8539", 202 | "sha256:57949f00fbcb96612812c4459149d01a3f51770bf42fd17521a173be63507687", 203 | "sha256:75ea6ce057b8663d3e04b29ebd8380a3805fc68577a84c0a99ccd33f6311a9c4", 204 | "sha256:812c23fdee2501cc4319f9b08bfdd9a156c704682831294a97dcb65ccfb0abfd", 205 | "sha256:86e325ed85767d8d4e1ebd6a0e8d7cbba24bbe93cbdebb78cdceef450427c0f9", 206 | "sha256:a6d9797b5842f3b0053c5b05367584dcc7a79f96758c79b17a99a22aeb21050f", 207 | "sha256:a74a6cb0425f5bbbcd724658425b09bd0a71939902c1ff3c670d3cdd23e0229b", 208 | "sha256:abb1f8abf2978e4d836ef52c4c3ef5891043ee0ed850a6d3a93673649c28739c", 209 | "sha256:c0f34a3bfb6900f40ffa10e1f48588ab0241e95a2aeac56b3f33056e8384d941", 210 | "sha256:c3d5cabb2e91852b2bd708040745d12c7a74432227880e7e6c3f0bd2e68ebab9" 211 | ], 212 | "index": "pypi", 213 | "version": "==8.1.8487" 214 | }, 215 | "packaging": { 216 | "hashes": [ 217 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 218 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 219 | ], 220 | "markers": "python_version >= '3.6'", 221 | "version": "==21.3" 222 | }, 223 | "pillow": { 224 | "hashes": [ 225 | "sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927", 226 | "sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14", 227 | "sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc", 228 | "sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58", 229 | "sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60", 230 | "sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76", 231 | "sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c", 232 | "sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac", 233 | "sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490", 234 | "sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1", 235 | "sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f", 236 | "sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d", 237 | "sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f", 238 | "sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069", 239 | "sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402", 240 | "sha256:336b9036127eab855beec9662ac3ea13a4544a523ae273cbf108b228ecac8437", 241 | "sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885", 242 | "sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e", 243 | "sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be", 244 | "sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff", 245 | "sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da", 246 | "sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004", 247 | "sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f", 248 | "sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20", 249 | "sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d", 250 | "sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c", 251 | "sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544", 252 | "sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3", 253 | "sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04", 254 | "sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c", 255 | "sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5", 256 | "sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4", 257 | "sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb", 258 | "sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4", 259 | "sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c", 260 | "sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467", 261 | "sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e", 262 | "sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421", 263 | "sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b", 264 | "sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8", 265 | "sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb", 266 | "sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3", 267 | "sha256:adabc0bce035467fb537ef3e5e74f2847c8af217ee0be0455d4fec8adc0462fc", 268 | "sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf", 269 | "sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1", 270 | "sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a", 271 | "sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28", 272 | "sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0", 273 | "sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1", 274 | "sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8", 275 | "sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd", 276 | "sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4", 277 | "sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8", 278 | "sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f", 279 | "sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013", 280 | "sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59", 281 | "sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc", 282 | "sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4" 283 | ], 284 | "markers": "python_version >= '3.7'", 285 | "version": "==9.2.0" 286 | }, 287 | "pluggy": { 288 | "hashes": [ 289 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 290 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 291 | ], 292 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 293 | "version": "==0.13.1" 294 | }, 295 | "protobuf": { 296 | "hashes": [ 297 | "sha256:07a0bb9cc6114f16a39c866dc28b6e3d96fa4ffb9cc1033057412547e6e75cb9", 298 | "sha256:308173d3e5a3528787bb8c93abea81d5a950bdce62840d9760effc84127fb39c", 299 | "sha256:4143513c766db85b9d7c18dbf8339673c8a290131b2a0fe73855ab20770f72b0", 300 | "sha256:49f88d56a9180dbb7f6199c920f5bb5c1dd0172f672983bb281298d57c2ac8eb", 301 | "sha256:6b1040a5661cd5f6e610cbca9cfaa2a17d60e2bb545309bc1b278bb05be44bdd", 302 | "sha256:77b355c8604fe285536155286b28b0c4cbc57cf81b08d8357bf34829ea982860", 303 | "sha256:7a6cc8842257265bdfd6b74d088b829e44bcac3cca234c5fdd6052730017b9ea", 304 | "sha256:80e6540381080715fddac12690ee42d087d0d17395f8d0078dfd6f1181e7be4c", 305 | "sha256:8f9e60f7d44592c66e7b332b6a7b4b6e8d8b889393c79dbc3a91f815118f8eac", 306 | "sha256:9666da97129138585b26afcb63ad4887f602e169cafe754a8258541c553b8b5d", 307 | "sha256:aa29113ec901281f29d9d27b01193407a98aa9658b8a777b0325e6d97149f5ce", 308 | "sha256:b6cea204865595a92a7b240e4b65bcaaca3ad5d2ce25d9db3756eba06041138e", 309 | "sha256:ba596b9ffb85c909fcfe1b1a23136224ed678af3faf9912d3fa483d5f9813c4e", 310 | "sha256:c7c864148a237f058c739ae7a05a2b403c0dfa4ce7d1f3e5213f352ad52d57c6" 311 | ], 312 | "index": "pypi", 313 | "version": "==4.21.6" 314 | }, 315 | "py": { 316 | "hashes": [ 317 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 318 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 319 | ], 320 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 321 | "version": "==1.11.0" 322 | }, 323 | "pyparsing": { 324 | "hashes": [ 325 | "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", 326 | "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc" 327 | ], 328 | "markers": "python_full_version >= '3.6.8'", 329 | "version": "==3.0.9" 330 | }, 331 | "pytest": { 332 | "hashes": [ 333 | "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8", 334 | "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306" 335 | ], 336 | "index": "pypi", 337 | "version": "==6.2.1" 338 | }, 339 | "python-dateutil": { 340 | "hashes": [ 341 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 342 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 343 | ], 344 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 345 | "version": "==2.8.2" 346 | }, 347 | "six": { 348 | "hashes": [ 349 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 350 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 351 | ], 352 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 353 | "version": "==1.16.0" 354 | }, 355 | "toml": { 356 | "hashes": [ 357 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 358 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 359 | ], 360 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 361 | "version": "==0.10.2" 362 | }, 363 | "typer": { 364 | "hashes": [ 365 | "sha256:5455d750122cff96745b0dec87368f56d023725a7ebc9d2e54dd23dc86816303", 366 | "sha256:ba58b920ce851b12a2d790143009fa00ac1d05b3ff3257061ff69dbdfc3d161b" 367 | ], 368 | "index": "pypi", 369 | "version": "==0.3.2" 370 | } 371 | }, 372 | "develop": {} 373 | } 374 | -------------------------------------------------------------------------------- /deployment/frontend/src/components/CspTool.vue: -------------------------------------------------------------------------------- 1 | 436 | 437 | 1570 | 1571 | 1572 | 1575 | --------------------------------------------------------------------------------