diff options
| -rw-r--r-- | CONTRIBUTING | 1 | ||||
| -rw-r--r-- | README.rst | 84 | ||||
| -rw-r--r-- | VERSION | 2 | ||||
| -rw-r--r-- | peru.yaml | 12 | ||||
| -rw-r--r-- | requirements.pip.runtime.txt | 6 | ||||
| -rw-r--r-- | schemas/opcut.yaml | 54 | ||||
| -rw-r--r-- | schemas/openapi.yaml | 13 | ||||
| -rw-r--r-- | src_js/common.js | 9 | ||||
| -rw-r--r-- | src_js/states.js | 1 | ||||
| -rw-r--r-- | src_js/vt.js | 13 | ||||
| -rw-r--r-- | src_py/opcut/calculate.py (renamed from src_py/opcut/csp.py) | 18 | ||||
| -rw-r--r-- | src_py/opcut/common.py | 4 | ||||
| -rw-r--r-- | src_py/opcut/generate.py (renamed from src_py/opcut/output.py) | 14 | ||||
| -rw-r--r-- | src_py/opcut/main.py | 229 | ||||
| -rw-r--r-- | src_py/opcut/server.py | 44 |
15 files changed, 312 insertions, 192 deletions
diff --git a/CONTRIBUTING b/CONTRIBUTING new file mode 100644 index 0000000..1333ed7 --- /dev/null +++ b/CONTRIBUTING @@ -0,0 +1 @@ +TODO @@ -37,26 +37,70 @@ Install `https://github.com/bozokopic/opcut/releases` -Run ---- +Usage +----- -Running server (default listening address http://0.0.0.0:8080):: +`opcut` command is interface for execution of three distinct actions: - $ opcut server + * `opcut calculate ...` + + Calculation of cutting stock problem. Input parameters and result is + formated as JSON data (JSON, YAML or TOML). + + * `opcut generate ...` + + Generate output representation (SVG, PDF, ...) based on calculation + result. + + * `opcut server ...` + + Run HTTP server providing single-page web application interface and + OpenAPI interface. + (default listening address is http://0.0.0.0:8080). + +For additional command line arguments, run ``opcut --help`` or +``opcut <action> --help``. + + +`opcut calculate` +''''''''''''''''' + +Example:: -Running command line utility:: + $ opcut calculate --input-format yaml --output result.json << EOF + cut_width: 1 + panels: + panel1: + width: 100 + height: 100 + items: + item1: + width: 10 + height: 10 + can_rotate: false + EOF - $ opcut calculate ... - $ opcut generate_output ... -Additional command line arguments:: +`opcut generate` +'''''''''''''''' - $ opcut --help +Example:: + + $ opcut generate --output output.pdf result.json + + +`opcut server` +'''''''''''''' + +Example:: + + $ opcut server Development requirements ------------------------ +* C99 compiler (gcc, clang, ...) * nodejs >=7 * yarn @@ -80,28 +124,26 @@ Default task:: creates wheel package inside `build` directory. -TODO ----- - -* global - - * create CONTRIBUTING +JSON Schema +----------- -* optimizer +.. literalinclude:: schemas/opcut.yaml + :language: yaml - * add additional algorithms - * evaluate python implementations and do native rewrites if needed -* back-end +OpenAPI +------- - * additional output formats +.. literalinclude:: schemas/openapi.yaml + :language: yaml License ------- opcut - cutting stock problem optimizer -Copyright (C) 2017-2022 Bozo Kopic + +Copyright (C) 2017-2022 Bozo Kopic This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -1 +1 @@ -0.3.2 +0.4.0-dev @@ -1,18 +1,6 @@ imports: - jsmn: deps/jsmn - argparse: deps/argparse hat-util: deps/hat-util -git module jsmn: - url: https://github.com/zserge/jsmn - pick: jsmn.h - -git module argparse: - url: https://github.com/cofyc/argparse - pick: - - argparse.c - - argparse.h - git module hat-util: url: https://github.com/hat-open/hat-util rev: 7f56bb0572aee5a33654118838f999eec04fbd86 diff --git a/requirements.pip.runtime.txt b/requirements.pip.runtime.txt index 4499a99..db3c695 100644 --- a/requirements.pip.runtime.txt +++ b/requirements.pip.runtime.txt @@ -1,6 +1,4 @@ aiohttp ~= 3.8.1 -appdirs ~= 1.4.4 -click ~= 8.1.3 -hat-aio ~= 0.6.0 -hat-json ~= 0.5.5 +hat-aio ~= 0.6.1 +hat-json ~= 0.5.6 pycairo ~= 1.21.0 diff --git a/schemas/opcut.yaml b/schemas/opcut.yaml index 47e0a7f..8dadb93 100644 --- a/schemas/opcut.yaml +++ b/schemas/opcut.yaml @@ -4,6 +4,8 @@ id: "opcut://opcut.yaml#" definitions: params: type: object + description: | + calculation input parameters independent of calculation method required: - cut_width - panels @@ -11,20 +13,30 @@ definitions: properties: cut_width: type: number + description: | + width of the guillotine cut min_initial_usage: type: boolean + description: | + minimize usage of initial panels panels: type: object + description: | + input panels (keys represent unique panel identifiers) patternProperties: ".+": "$ref": "opcut://opcut.yaml#/definitions/panel" items: type: object + description: | + required items (keys represent unique item identifiers) patternProperties: ".+": "$ref": "opcut://opcut.yaml#/definitions/item" result: type: object + description: | + calculation result required: - params - used @@ -34,24 +46,36 @@ definitions: "$ref": "opcut://opcut.yaml#" used: type: array + description: | + resulting panels associated with required items items: "$ref": "opcut://opcut.yaml#/definitions/used" unused: type: array + description: | + resulting unused panels items: "$ref": "opcut://opcut.yaml#/definitions/unused" panel: type: object + description: | + single input panel required: - width - height properties: width: type: number + description: | + panel's initial width height: type: number + description: | + panel's initial height item: type: object + description: | + single required item required: - width - height @@ -59,12 +83,20 @@ definitions: properties: width: type: number + description: | + items's width height: type: number + description: | + items's height can_rotate: type: boolean + description: | + can item be rotated (is grain direction irrelevant) used: type: object + description: | + single resulting panels associated with required item required: - panel - item @@ -74,16 +106,28 @@ definitions: properties: panel: type: string + description: | + input panel identifier item: type: string + description: | + matching required item identifier x: type: number + description: | + used panel location based on input panel's width offset y: type: number + description: | + used panel location based on input panel's height offset rotate: type: boolean + description: | + is resulting panel rotated unused: type: object + description: | + single unused resulting panel required: - panel - width @@ -93,12 +137,22 @@ definitions: properties: panel: type: string + description: | + input panel identifier width: type: number + description: | + unused resulting panel's width height: type: number + description: | + unused resulting panel's height x: type: number + description: | + used panel location based on input panel's width offset y: type: number + description: | + used panel location based on input panel's height offset ... diff --git a/schemas/openapi.yaml b/schemas/openapi.yaml index 442f685..e459030 100644 --- a/schemas/openapi.yaml +++ b/schemas/openapi.yaml @@ -2,16 +2,11 @@ openapi: 3.1.0 info: title: opcut - version: 0.0.1 + version: 0.0.2 paths: '/calculate': post: parameters: - - name: native - in: query - required: false - schema: - type: boolean - name: method in: query required: true @@ -19,6 +14,8 @@ paths: enum: - greedy - forward_greedy + - greedy_native + - forward_greedy_native requestBody: content: application/json: @@ -34,10 +31,10 @@ paths: content: text/plain: description: error message - '/generate_output': + '/generate': post: parameters: - - name: output_type + - name: output_format in: query required: true schema: diff --git a/src_js/common.js b/src_js/common.js index 7d4213c..b489914 100644 --- a/src_js/common.js +++ b/src_js/common.js @@ -10,7 +10,7 @@ import * as states from './states'; const calculateUrl = URI.resolve(window.location.href, './calculate'); -const generateOutputUrl = URI.resolve(window.location.href, './generate_output'); +const generateUrl = URI.resolve(window.location.href, './generate'); let panelCounter = 0; let itemCounter = 0; @@ -20,9 +20,8 @@ export async function calculate() { r.set('calculating', true); try { const method = r.get('form', 'method'); - const native = r.get('form', 'native'); const params = createCalculateParams(); - const res = await fetch(`${calculateUrl}?method=${method}&native=${native}`, { + const res = await fetch(`${calculateUrl}?method=${method}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(params) @@ -45,10 +44,10 @@ export async function calculate() { } -export async function generateOutput() { +export async function generate() { try { const result = r.get('result'); - const res = await fetch(`${generateOutputUrl}?output_type=pdf`, { + const res = await fetch(`${generateUrl}?output_format=pdf`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(result) diff --git a/src_js/states.js b/src_js/states.js index efc31b9..bf3584e 100644 --- a/src_js/states.js +++ b/src_js/states.js @@ -4,7 +4,6 @@ export const main = { method: 'forward_greedy', cut_width: 0.3, min_initial_usage: false, - native: false, panels: [], items: [] }, diff --git a/src_js/vt.js b/src_js/vt.js index b016018..84d80a0 100644 --- a/src_js/vt.js +++ b/src_js/vt.js @@ -62,7 +62,9 @@ function leftPanel() { ['label', 'Method'], selectInput(r.get('form', 'method'), [['forward_greedy', 'Forward greedy'], - ['greedy', 'Greedy']], + ['greedy', 'Greedy'], + ['forward_greedy_native', 'Forward greedy (native)'], + ['greedy_native', 'Greedy (native)'],], val => r.set(['form', 'method'], val)), ['label', 'Cut width'], numberInput(r.get('form', 'cut_width'), @@ -71,11 +73,7 @@ function leftPanel() { ['label'], checkboxInput('Minimize initial panel usage', r.get('form', 'min_initial_usage'), - val => r.set(['form', 'min_initial_usage'], val)), - ['label'], - checkboxInput('Use native implementation (experimental)', - r.get('form', 'native'), - val => r.set(['form', 'native'], val)) + val => r.set(['form', 'min_initial_usage'], val)) ], ['div.content', leftPanelPanels(), @@ -308,7 +306,7 @@ function rightPanel() { ['div.toolbar', ['button', { on: { - click: common.generateOutput + click: common.generate }}, ['span.fa.fa-file-pdf-o'], ' PDF' @@ -465,7 +463,6 @@ function textInput(value, validator, onChange) { function numberInput(value, validator, onChange) { - return ['input', { props: { type: 'number', diff --git a/src_py/opcut/csp.py b/src_py/opcut/calculate.py index eb08314..7ae14ce 100644 --- a/src_py/opcut/csp.py +++ b/src_py/opcut/calculate.py @@ -3,8 +3,8 @@ import itertools from opcut import common -def calculate(params: common.Params, - method: common.Method +def calculate(method: common.Method, + params: common.Params ) -> common.Result: """Calculate cutting stock problem""" unused = [common.Unused(panel=panel, @@ -23,6 +23,12 @@ def calculate(params: common.Params, elif method == common.Method.FORWARD_GREEDY: return _calculate_forward_greedy(result) + elif method == common.Method.GREEDY_NATIVE: + return _calculate_greedy_native(result) + + elif method == common.Method.FORWARD_GREEDY_NATIVE: + return _calculate_forward_greedy_native(result) + raise ValueError('unsupported method') @@ -62,6 +68,14 @@ def _calculate_forward_greedy(result): return result +def _calculate_greedy_native(result): + raise NotImplementedError() + + +def _calculate_forward_greedy_native(result): + raise NotImplementedError() + + def _get_next_results(result): selected_item = None used_items = {used.item.id for used in result.used} diff --git a/src_py/opcut/common.py b/src_py/opcut/common.py index 9fbb7dd..5f6f26b 100644 --- a/src_py/opcut/common.py +++ b/src_py/opcut/common.py @@ -67,9 +67,11 @@ class OutputSettings(typing.NamedTuple): class Method(enum.Enum): GREEDY = 'greedy' FORWARD_GREEDY = 'forward_greedy' + GREEDY_NATIVE = 'greedy_native' + FORWARD_GREEDY_NATIVE = 'forward_greedy_native' -class OutputType(enum.Enum): +class OutputFormat(enum.Enum): PDF = 'pdf' SVG = 'svg' diff --git a/src_py/opcut/output.py b/src_py/opcut/generate.py index 65f8096..82c8d7b 100644 --- a/src_py/opcut/output.py +++ b/src_py/opcut/generate.py @@ -6,18 +6,18 @@ import cairo from opcut import common -def generate_output(result: common.Result, - output_type: common.OutputType, - panel_id: typing.Optional[str] = None, - settings: common.OutputSettings = common.OutputSettings() - ) -> bytes: +def generate(result: common.Result, + output_format: common.OutputFormat, + panel_id: typing.Optional[str] = None, + settings: common.OutputSettings = common.OutputSettings() + ) -> bytes: """Generate output""" ret = io.BytesIO() - if output_type == common.OutputType.PDF: + if output_format == common.OutputFormat.PDF: surface_cls = cairo.PDFSurface - elif output_type == common.OutputType.SVG: + elif output_format == common.OutputFormat.SVG: surface_cls = cairo.SVGSurface else: diff --git a/src_py/opcut/main.py b/src_py/opcut/main.py index b1d66a8..f09fe71 100644 --- a/src_py/opcut/main.py +++ b/src_py/opcut/main.py @@ -1,4 +1,5 @@ from pathlib import Path +import argparse import asyncio import contextlib import logging.config @@ -7,11 +8,10 @@ import typing from hat import aio from hat import json -import click from opcut import common -import opcut.csp -import opcut.output +import opcut.calculate +import opcut.generate import opcut.server @@ -19,107 +19,153 @@ params_schema_id: str = 'opcut://opcut.yaml#/definitions/params' result_schema_id: str = 'opcut://opcut.yaml#/definitions/result' -def _doc_enum_values(enum_cls): - return ', '.join(str(i.value) for i in enum_cls) +def create_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='action') + + def enum_values(enum_cls): + return ', '.join(str(i.value) for i in enum_cls) + + calculate = subparsers.add_parser('calculate') + calculate.add_argument( + '--method', metavar='METHOD', type=common.Method, + default=common.Method.FORWARD_GREEDY, + help=f"calculate method ({enum_values(common.Method)})") + calculate.add_argument( + '--input-format', metavar='FORMAT', type=json.Format, default=None, + help=f"input params format ({enum_values(json.Format)})") + calculate.add_argument( + '--output-format', metavar='FORMAT', type=json.Format, default=None, + help=f"output result format ({enum_values(json.Format)})") + calculate.add_argument( + '--output', metavar='PATH', type=Path, default=Path('-'), + help=f"output result file path or - for stdout ({result_schema_id})") + calculate.add_argument( + 'params', type=Path, default=Path('-'), + help=f"input params file path or - for stdin ({params_schema_id})") + + generate = subparsers.add_parser('generate') + generate.add_argument( + '--input-format', metavar='FORMAT', type=json.Format, default=None, + help=f"input result format ({enum_values(json.Format)})") + generate.add_argument( + '--output-format', metavar='FORMAT', type=common.OutputFormat, + default=common.OutputFormat.PDF, + help=f"output format ({enum_values(common.OutputFormat)})") + generate.add_argument( + '--panel', metavar='PANEL', default=None, + help="panel identifier") + generate.add_argument( + '--output', metavar='PATH', type=Path, default=Path('-'), + help="output file path or - for stdout") + generate.add_argument( + 'result', type=Path, default=Path('-'), + help=f"input result file path or - for stdin ({result_schema_id})") + + server = subparsers.add_parser('server') + server.add_argument( + '--host', metavar='HOST', default='0.0.0.0', + help="listening host name (default 0.0.0.0)") + server.add_argument( + '--port', metavar='PORT', type=int, default=8080, + help="listening TCP port (default 8080)") + server.add_argument( + '--log-level', metavar='LEVEL', default='info', + choices=['critical', 'error', 'warning', 'info', 'debug', 'notset'], + help="log level (default info)") + + return parser -@click.group() def main(): - """Application main entry point""" - - -@main.command() -@click.option('--method', - default=common.Method.FORWARD_GREEDY, - type=common.Method, - help=f"calculate method ({_doc_enum_values(common.Method)})") -@click.option('--output', - default=None, - metavar='PATH', - type=Path, - help=f"result file path ({result_schema_id})") -@click.argument('params', - required=False, - default=None, - metavar='PATH', - type=Path) + parser = create_argument_parser() + args = parser.parse_args() + + if args.action == 'calculate': + calculate(method=args.method, + input_format=args.input_format, + output_format=args.output_format, + result_path=args.output, + params_path=args.params) + + elif args.action == 'generate': + generate(input_format=args.input_format, + output_format=args.output_format, + panel_id=args.panel, + output_path=args.output, + result_path=args.result) + + elif args.action == 'server': + server(host=args.host, + port=args.port, + log_level=args.log_level) + + else: + raise ValueError('unsupported action') + + def calculate(method: common.Method, - output: typing.Optional[Path], - params: typing.Optional[Path]): - """Calculate result based on parameters JSON""" - params = (json.decode_file(params) if params and params != Path('-') - else json.decode_stream(sys.stdin)) - common.json_schema_repo.validate(params_schema_id, params) - params = common.params_from_json(params) + input_format: typing.Optional[json.Format], + output_format: typing.Optional[json.Format], + result_path: Path, + params_path: Path): + if input_format is None and params_path == Path('-'): + input_format = json.Format.JSON + + if output_format is None and result_path == Path('-'): + output_format = json.Format.JSON + + params_json = (json.decode_stream(sys.stdin, input_format) + if params_path == Path('-') + else json.decode_file(params_path, input_format)) + + common.json_schema_repo.validate(params_schema_id, params_json) + params = common.params_from_json(params_json) try: - res = opcut.csp.calculate(params, method) + result = opcut.calculate.calculate(method=method, + params=params) except common.UnresolvableError: sys.exit(42) - res = common.result_to_json(res) + result_json = common.result_to_json(result) - if output and output != Path('-'): - json.encode_file(res, output) - else: - json.encode_stream(res, sys.stdout) - - -@main.command() -@click.option('--output-type', - default=common.OutputType.PDF, - type=common.OutputType, - help=f"output type ({_doc_enum_values(common.OutputType)})") -@click.option('--panel', - default=None, - help="panel identifier") -@click.option('--output', - default=None, - metavar='PATH', - type=Path, - help="result file path") -@click.argument('result', - required=False, - default=None, - metavar='PATH', - type=Path) -def generate_output(output_type: common.OutputType, - panel: typing.Optional[str], - output: typing.Optional[Path], - result: typing.Optional[Path]): - """Generate output based on result JSON""" - result = (json.decode_file(result) if result and result != Path('-') - else json.decode_stream(sys.stdin)) - common.json_schema_repo.validate(result_schema_id, result) - result = common.result_from_json(result) - - out = opcut.output.generate_output(result, output_type, panel) - - if output and output != Path('-'): - output.write_bytes(out) + if result_path == Path('-'): + json.encode_stream(result_json, sys.stdout, output_format) else: + json.encode_file(result_json, result_path, output_format) + + +def generate(input_format: typing.Optional[json.Format], + output_format: common.OutputFormat, + panel_id: typing.Optional[str], + output_path: Path, + result_path: Path): + if input_format is None and result_path == Path('-'): + input_format = json.Format.JSON + + result_json = (json.decode_stream(sys.stdin, input_format) + if result_path == Path('-') + else json.decode_file(result_path, input_format)) + + common.json_schema_repo.validate(result_schema_id, result_json) + result = common.result_from_json(result_json) + + data = opcut.generate.generate(result=result, + output_format=output_format, + panel_id=panel_id) + + if output_path == Path('-'): stdout, sys.stdout = sys.stdout.detach(), None - stdout.write(out) - - -@main.command() -@click.option('--host', - default='0.0.0.0', - help="listening host name") -@click.option('--port', - default=8080, - type=int, - help="listening TCP port") -@click.option('--log-level', - default='INFO', - type=click.Choice(['CRITICAL', 'ERROR', 'WARNING', 'INFO', - 'DEBUG', 'NOTSET']), - help="log level") + stdout.write(data) + else: + output_path.write_bytes(data) + + def server(host: str, port: int, log_level: str): - """Run server""" logging.config.dictConfig({ 'version': 1, 'formatters': { @@ -129,14 +175,15 @@ def server(host: str, 'console': { 'class': 'logging.StreamHandler', 'formatter': 'console', - 'level': log_level}}, + 'level': log_level.upper()}}, 'root': { - 'level': log_level, + 'level': log_level.upper(), 'handlers': ['console']}, 'disable_existing_loggers': False}) async def run(): - server = await opcut.server.create(host, port) + server = await opcut.server.create(host=host, + port=port) try: await server.wait_closing() diff --git a/src_py/opcut/server.py b/src_py/opcut/server.py index cd7d048..939222c 100644 --- a/src_py/opcut/server.py +++ b/src_py/opcut/server.py @@ -1,6 +1,5 @@ from pathlib import Path import asyncio -import platform import subprocess import sys @@ -24,7 +23,7 @@ async def create(host: str, app.add_routes([ aiohttp.web.get('/', server._root_handler), aiohttp.web.post('/calculate', server._calculate_handler), - aiohttp.web.post('/generate_output', server._generate_output_handler), + aiohttp.web.post('/generate', server._generate_handler), aiohttp.web.static('/', static_dir)]) runner = aiohttp.web.AppRunner(app) @@ -65,19 +64,18 @@ class Server(aio.Resource): return aiohttp.web.Response(status=400, text="Invalid request") - native = request.query.get('native') == 'true' method = common.Method(request.query['method']) params = common.params_from_json(data) try: - result = await _calculate(native, method, params) + result = await _calculate(method, params) return aiohttp.web.json_response(result) except common.UnresolvableError: return aiohttp.web.Response(status=400, text='Result is not solvable') - async def _generate_output_handler(self, request): + async def _generate_handler(self, request): try: data = await request.json() common.json_schema_repo.validate( @@ -87,16 +85,16 @@ class Server(aio.Resource): return aiohttp.web.Response(status=400, text="Invalid request") - output_type = common.OutputType(request.query['output_type']) + output_format = common.OutputFormat(request.query['output_format']) panel = request.query.get('panel') result = common.result_from_json(data) - output = await _generate_output(output_type, panel, result) + output = await _generate(output_format, panel, result) - if output_type == common.OutputType.PDF: + if output_format == common.OutputType.PDF: content_type = 'application/pdf' - elif output_type == common.OutputType.SVG: + elif output_format == common.OutputType.SVG: content_type = 'image/svg+xml' else: @@ -106,8 +104,9 @@ class Server(aio.Resource): content_type=content_type) -async def _calculate(native, method, params): - args = [*_get_calculate_cmd(native), '--method', method.value] +async def _calculate(method, params): + args = [sys.executable, '-m', 'opcut', 'calculate', + '--method', method.value] stdint_data = json.encode(common.params_to_json(params)).encode('utf-8') p = await asyncio.create_subprocess_exec(*args, @@ -125,8 +124,9 @@ async def _calculate(native, method, params): raise Exception(stderr_data.decode('utf-8')) -async def _generate_output(output_type, panel, result): - args = [*_get_generate_output_cmd(), '--output-type', output_type.value, +async def _generate(output_format, panel, result): + args = [sys.executable, '-m', 'opcut', 'generate', + '--output-format', output_format.value, *(['--panel', panel] if panel else [])] stdint_data = json.encode(common.result_to_json(result)).encode('utf-8') @@ -140,21 +140,3 @@ async def _generate_output(output_type, panel, result): return stdout_data raise Exception(stderr_data.decode('utf-8')) - - -def _get_calculate_cmd(native): - if native and sys.platform == 'linux': - if platform.machine() == 'x86_64': - return [str(common.package_path / - 'bin/linux_x86_64-opcut-calculate')] - - elif native and sys.platform == 'win32': - if platform.machine() == 'amd64': - return [str(common.package_path / - 'bin/windows_amd64-opcut-calculate.exe')] - - return [sys.executable, '-m', 'opcut', 'calculate'] - - -def _get_generate_output_cmd(): - return [sys.executable, '-m', 'opcut', 'generate-output'] |
