Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: named parts #60

Merged
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# this size should be irrelevant
FROM python:3.9 as build
FROM python:3.12 as build
ModischFabrications marked this conversation as resolved.
Show resolved Hide resolved
# exporting here is a lot safer than depending on the dev environment. Pipenv is kept out of the container by design.
COPY ./Pipfile /Pipfile
RUN pip install pipenv
Expand All @@ -9,7 +9,7 @@ RUN pipenv lock && pipenv requirements > dev-requirements.txt
# certifi+httpie allows healthchecks with tiny installation size (#37)
RUN pip install --user --no-cache-dir --no-warn-script-location -r dev-requirements.txt

FROM python:3.9-slim
FROM python:3.12-slim
ModischFabrications marked this conversation as resolved.
Show resolved Hide resolved
# https://github.com/opencontainers/image-spec/blob/master/annotations.md
LABEL "org.opencontainers.image.title"="CutSolver"
LABEL "org.opencontainers.image.vendor"="Modisch Fabrications"
Expand Down
17 changes: 9 additions & 8 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]
pytest = "==7.1.3"
requests = "==2.28.1"
pre-commit = "==2.20.0"
GitPython = "==3.1.9"
flake8 = "==5.0.4"
pytest = "==7.4.4"
requests = "==2.31.0"
pre-commit = "==3.6.0"
GitPython = "==3.1.41"
flake8 = "==7.0.0"
httpx = "==0.26.0"

[packages]
fastapi = "==0.85.1"
uvicorn = "==0.18.3"
fastapi = "==0.109.0"
uvicorn = "==0.26.0"

[requires]
python_version = "3.9"
python_version = "3.11"
682 changes: 415 additions & 267 deletions Pipfile.lock

Large diffs are not rendered by default.

17 changes: 7 additions & 10 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,28 @@
import platform
from contextlib import asynccontextmanager

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import HTMLResponse, PlainTextResponse

from app.constants import version, n_max_precise, n_max

# don't mark /app as a sources root or pycharm will delete the "app." prefix
# that's needed for pytest to work correctly
from app.solver.data.Job import Job
from app.solver.data.Result import Result
from app.solver.solver import distribute

app = FastAPI(
title="CutSolverBackend",
version=version
)


@app.on_event("startup")
async def on_startup():
@asynccontextmanager
async def lifespan(app: FastAPI):
print(f"Starting CutSolver {version}...")
yield
print("Shutting down CutSolver...")


@app.on_event("shutdown")
async def on_shutdown():
print(f"Shutting down CutSolver...")
app = FastAPI(title="CutSolverBackend", version=version, lifespan=lifespan)


# needs to be before CORS!
Expand Down
60 changes: 34 additions & 26 deletions app/solver/data/Job.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from typing import Iterator, Dict, List
from typing import Iterator, List, Optional, Tuple

from pydantic import BaseModel


class TargetSize(BaseModel):
length: int
quantity: int
name: Optional[str] = ""

def __lt__(self, other):
"""
Expand All @@ -20,59 +21,66 @@ def __str__(self):
class Job(BaseModel):
max_length: int
cut_width: int = 0
target_sizes: Dict[int, int]
target_sizes: List[TargetSize]

# utility

def iterate_sizes(self) -> Iterator[int]:
def iterate_sizes(self) -> Iterator[Tuple[int, str | None]]:
"""
yields all lengths, sorted descending
"""

# sort descending to favor combining larger sizes first
for size, quantity in sorted(self.target_sizes.items(), reverse=True):
for _ in range(quantity):
yield size
for target in sorted(self.target_sizes, key=lambda x: x.length, reverse=True):
for _ in range(target.quantity):
yield (target.length, target.name)

# NOTE: Not used, so not really refactored at the moment
def sizes_from_list(self, sizes_list: List[TargetSize]):
known_sizes = {}

# list to dict to make them unique
for size in sizes_list:
if size.length in known_sizes:
known_sizes[size.length] += size.quantity
else:
known_sizes[size.length] = size.quantity

self.target_sizes = known_sizes

# known_sizes = {}
#
# # list to dict to make them unique
# for size in sizes_list:
# if size.length in known_sizes:
# known_sizes[size.length] += size.quantity
# else:
# known_sizes[size.length] = size.quantity

self.target_sizes = sizes_list

# NOTE: Can eventually be removed as it does nothing anymore
def sizes_as_list(self) -> List[TargetSize]:
"""
Compatibility function
"""
# back to list again for compatibility
return [TargetSize(length=l, quantity=q) for (l, q) in self.target_sizes.items()]
return self.target_sizes

def assert_valid(self):
if self.max_length <= 0:
raise ValueError(f"Job has invalid max_length {self.max_length}")
if self.cut_width < 0:
raise ValueError(f"Job has invalid cut_width {self.cut_width}")
if len(self.target_sizes) <= 0:
raise ValueError(f"Job is missing target_sizes")
if any(size > (self.max_length - self.cut_width) for size in self.target_sizes.keys()):
raise ValueError(f"Job has target sizes longer than the stock")
raise ValueError("Job is missing target_sizes")
if any(
target.length > (self.max_length - self.cut_width)
for target in self.target_sizes
):
raise ValueError("Job has target sizes longer than the stock")

def __len__(self) -> int:
"""
Number of target sizes in job
"""
return sum(self.target_sizes.values())
return sum([target.quantity for target in self.target_sizes])

def __eq__(self, other):
return self.max_length == other.max_length and \
self.cut_width == other.cut_width and \
self.target_sizes == other.target_sizes
return (
self.max_length == other.max_length
and self.cut_width == other.cut_width
and self.target_sizes == other.target_sizes
)

def __hash__(self) -> int:
return hash((self.max_length, self.cut_width, str(sorted(self.target_sizes.items()))))
return hash((self.max_length, self.cut_width, str(sorted(self.target_sizes))))
18 changes: 9 additions & 9 deletions app/solver/data/Result.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import unique, Enum
from typing import List
from typing import List, Tuple

from pydantic import BaseModel

Expand All @@ -18,23 +18,23 @@ class Result(BaseModel):
job: Job
solver_type: SolverType
time_us: int = -1
lengths: List[List[int]]
lengths: List[List[Tuple[int, str]]]

# no trimmings as they can be inferred from difference to job

def __eq__(self, other):
return (
self.job == other.job
and self.solver_type == other.solver_type
and self.lengths == other.lengths
self.job == other.job
and self.solver_type == other.solver_type
and self.lengths == other.lengths
)

def exactly(self, other):
return (
self.job == other.job
and self.solver_type == other.solver_type
and self.time_us == other.time_us
and self.lengths == other.lengths
self.job == other.job
and self.solver_type == other.solver_type
and self.time_us == other.time_us
and self.lengths == other.lengths
)

def assert_valid(self):
Expand Down
58 changes: 32 additions & 26 deletions app/solver/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
from typing import Collection, Tuple, List

from app.constants import n_max_precise, n_max
from app.solver.data.Job import Job
from app.solver.data.Job import Job, TargetSize
from app.solver.data.Result import SolverType, Result


def distribute(job: Job) -> Result:
time: float = perf_counter()

lengths: List[List[int]]
lengths: List[List[Tuple[int, str]]]
solver_type: SolverType

if len(job) <= n_max_precise:
Expand All @@ -31,7 +31,7 @@ def distribute(job: Job) -> Result:

# CPU-bound
# O(n!)
def _solve_bruteforce(job: Job) -> List[List[int]]:
def _solve_bruteforce(job: Job) -> List[List[Tuple[int, str | None]]]:
# failsafe
if len(job) > 12:
raise OverflowError("Input too large")
Expand All @@ -42,41 +42,45 @@ def _solve_bruteforce(job: Job) -> List[List[int]]:

# "infinity"
minimal_trimmings = len(job) * job.max_length
best_stock: List[List[int]] = []
best_stock: List[List[Tuple[int, str | None]]] = []

# possible improvement: Distribute combinations to multiprocessing worker threads
for combination in all_orderings:
stocks, trimmings = _split_combination(combination, job.max_length, job.cut_width)
stocks, trimmings = _split_combination(
combination, job.max_length, job.cut_width
)
if trimmings < minimal_trimmings:
best_stock = stocks
minimal_trimmings = trimmings

return best_stock


def _split_combination(combination: Tuple[int], max_length: int, cut_width: int):
def _split_combination(
combination: Tuple[Tuple[int, str | None]], max_length: int, cut_width: int
):
"""
Collects sizes until length is reached, then starts another stock
:param combination:
:param max_length:
:param cut_width:
:return:
"""
stocks: List[List[int]] = []
stocks: List[List[Tuple[int, str | None]]] = []
trimmings = 0

current_size = 0
current_stock: List[int] = []
for size in combination:
current_stock: List[Tuple[int, str | None]] = []
for size, name in combination:
if (current_size + size + cut_width) > max_length:
# start next stock
stocks.append(current_stock)
trimmings += _get_trimming(max_length, current_stock, cut_width)
current_size = 0
current_stock: List[int] = []
current_stock: List[Tuple[int, str | None]] = []

current_size += size + cut_width
current_stock.append(size)
current_stock.append((size, name))
# catch leftovers
if current_stock:
stocks.append(current_stock)
Expand All @@ -86,7 +90,7 @@ def _split_combination(combination: Tuple[int], max_length: int, cut_width: int)

# this might actually be worse than FFD (both in runtime and solution), disabled for now
# O(n^2) ??
def _solve_gapfill(job: Job) -> List[List[int]]:
def _solve_gapfill(job: Job) -> List[List[Tuple[int, str | None]]]:
# 1. Sort by magnitude (largest first)
# 2. stack until limit is reached
# 3. try smaller as long as possible
Expand All @@ -100,11 +104,10 @@ def _solve_gapfill(job: Job) -> List[List[int]]:
stocks = []

current_size = 0
current_stock = []
current_stock: List[Tuple[int, str | None]] = []

i_target = 0
while len(targets) > 0:

# nothing fit, next stock
if i_target >= len(targets):
# add local result
Expand All @@ -115,10 +118,10 @@ def _solve_gapfill(job: Job) -> List[List[int]]:
current_size = 0
i_target = 0

current_target = targets[i_target]
current_target: TargetSize = targets[i_target]
# target fits inside current stock, transfer to results
if (current_size + current_target.length + job.cut_width) < job.max_length:
current_stock.append(current_target.length)
current_stock.append((current_target.length, current_target.name))
current_size += current_target.length + job.cut_width

# remove empty entries
Expand All @@ -139,8 +142,8 @@ def _solve_gapfill(job: Job) -> List[List[int]]:


# textbook solution, guaranteed to need <= double of perfect solution
# TODO this has ridiculous execution times, check why
def _solve_FFD(job: Job) -> List[List[int]]:
# TODO: this has ridiculous execution times, check why
def _solve_FFD(job: Job) -> List[List[Tuple[int, str | None]]]:
# iterate over list of stocks
# put into first stock that it fits into

Expand All @@ -153,23 +156,24 @@ def _solve_FFD(job: Job) -> List[List[int]]:
mutable_sizes = copy.deepcopy(job.sizes_as_list())
sizes = sorted(mutable_sizes, reverse=True)

stocks: List[List[int]] = [[]]
stocks: List[List[Tuple[int, str | None]]] = [[]]

i_target = 0

while i_target < len(sizes):
current_size = sizes[i_target]

for i, stock in enumerate(stocks):
for stock in stocks:
# calculate current stock length
stock_length = sum(stock) + (len(stock) - 1) * job.cut_width
stock_length = (
sum([size[0] for size in stock]) + (len(stock) - 1) * job.cut_width
)
# step through existing stocks until current size fits
if (job.max_length - stock_length) > current_size.length:
# add size
stock.append(current_size.length)
stock.append((current_size.length, current_size.name))
break
else: # nothing fit, opening next bin
stocks.append([current_size.length])
stocks.append([(current_size.length, current_size.name)])

# decrease/get next
if current_size.quantity <= 1:
Expand All @@ -180,8 +184,10 @@ def _solve_FFD(job: Job) -> List[List[int]]:
return stocks


def _get_trimming(max_length: int, lengths: Collection[int], cut_width: int) -> int:
sum_lengths = sum(lengths)
def _get_trimming(
max_length: int, lengths: Collection[Tuple[int, str | None]], cut_width: int
) -> int:
sum_lengths = sum([length[0] for length in lengths])
sum_cuts = len(lengths) * cut_width

trimmings = max_length - (sum_lengths + sum_cuts)
Expand Down
Loading
Loading