1D cut list optimiser — cut steel, timber, tube and extrusion with the least waste, fewest bars, or fewest saw setups.
Project description
linearcutting
1D cut list optimiser for Python. Work out how to cut a list of pieces from stock lengths with the least waste, the fewest bars, or the fewest saw setups — steel, timber, tube, extrusion, pipe. Any material that is long and gets cut across.
pip install linearcutting
import linearcutting as lc
plan = lc.optimise(parts="2400x5, 1800x3", stock=6000)
print(plan.bars_used) # 4
print(plan.optimal) # True — proved, not guessed
print(plan.summary())
That runs as written. No API key, no account, no signup. The free public tier allows 30 requests an hour and 250 a calendar month per IP, because you should be able to evaluate a thing before you pay for it.
4 bars, 6600mm waste (72.5% yield) — provably optimal, no better plan exists
3 distinct layouts, 4 saw setups
6000 (stock): 2400 + 1800 + 1800 [waste 0]
6000 (stock): 2400 + 2400 [waste 1200] ×2
6000 (stock): 1800 [waste 4200]
Zero dependencies. Nothing to conflict with, nothing to keep up to date.
Why not just sort longest-first?
Because that is provably not optimal, and often not close. Cutting a list of pieces from stock is the 1D cutting stock problem, and no greedy rule (longest-first, best-fit, first-fit-decreasing) reliably finds the best plan.
LinearCutting runs a branch-and-bound search against a mathematical lower bound. That gives you something a greedy heuristic never can:
plan.optimal # True: no better plan EXISTS. Not "none was found".
plan.bars_lower_bound # a relaxation bound — see the warning below
When a job is too big to prove in the time budget, optimal is False. That
means "not proved", not "not good" — it does not pretend either way.
optimalandbars_lower_bounddisagree on purpose. The bound is roughly total length ÷ bar length, and it is often not achievable. Five 2400s and three 1800s is 17,400mm — 2.9 bars of 6000 — so the bound says 3. You cannot cut those pieces from 3 bars; 4 is the true minimum, and the solver proves it. Sobars_used=4withbars_lower_bound=3andoptimal=Trueis not a contradiction: the bound was loose, not the plan. Trustoptimal. Treat the bound as a sanity check, never as a target.
Every plan is also verified before it is returned — re-audited against your inputs, so a plan that does not actually cut your list cannot reach you.
Mind the kerf
The blade destroys material. A 6000mm bar does not hold 2400 + 2400 + 1200 once you account for a 3mm blade, and a plan that ignores that fails at the saw.
plan = lc.optimise(parts="2400x2, 1200", stock=6000, kerf=3.2)
Get this number right. It is the single most common way a cut list goes wrong.
Optimise for the saw, not just the steel
Most optimisers minimise waste. Waste is not the only cost: every distinct layout is a fence reset, and on a manual saw the setups can cost more than the offcut ever did.
plan = lc.optimise(parts=cut_list, stock=6000, method="fewest_setups")
plan.setups # the number of measurements the operator dials in
On a real 960-piece job this took the setups from 150 down to 97 — a 35% cut in fence resets — for three extra bars. Whether that trade is worth taking depends on whether your bottleneck is steel or setup time, which is exactly why it is a choice and not a default.
| Method | What it minimises |
|---|---|
balanced |
Fewest bars, then least waste. The default, and right for most jobs. |
least_waste |
Material consumed. |
offcuts_first |
Buys new stock only after the remnant rack is used up. |
fewest_setups |
Saw setups — the times the operator moves the stop. |
Use the offcuts on your rack
plan = lc.optimise(
parts="2400x5, 1800x3",
stock=6000,
offcuts=[3200, 1900, (1200, 2)], # (length, quantity)
method="offcuts_first",
)
for layout in plan.layouts:
if layout.from_offcut:
print("from the rack:", layout)
plan.unused_offcuts # what is still on the rack afterwards
plan.usable_remnants # new offcuts this job creates, worth keeping
Writing a cut list
All of these mean the same thing. Use whichever suits the code you are in:
parts = "2400x5, 1800x3" # a string
parts = ["2400x5", "1800x3"] # a list of strings
parts = [(2400, 5), (1800, 3)] # (length, quantity)
parts = [(2400, 5, "rail"), (1800, 3, "stile")] # ...with labels
parts = [{"length": 2400, "quantity": 5}] # the long form
Stock is the same, and a third element is a price rather than a label:
stock = 6000 # this length, buy as many as needed
stock = [6000, 4000] # choose between lengths
stock = [(6000, 20, 45.50)] # (length, how many you have, price each)
Prices only take effect with method="cheapest", which minimises money
instead of millimetres — a different plan, because a 6000 bar is rarely 1.5× the
price of a 4000. Every other method ignores prices (and reports no cost), so
balanced can never quietly turn into a money optimiser behind your back.
plan = client.optimise(parts=parts, stock=stock, method="cheapest")
plan.total_cost
plan.cost_lower_bound
for buy in plan.purchases:
print(f"{buy.count} × {buy.length}mm = {buy.cost}")
With an API key
client = lc.Client(api_key="lk_live_...")
plan = client.optimise(parts="2400x5", stock=6000)
A key lifts the rate limit, raises the size caps, and gives you your own concurrency, so a busy afternoon on the public tier cannot slow you down. Plans start at $5/month for 500 requests and run to $99/month for 30,000 — about $3.30 per 1,000 requests. See linearcutting.com/pricing.
Cutting sheet as a PDF
sheet = client.pdf(parts="2400x5", stock=6000, job_name="Henderson balustrade")
open("cutting-sheet.pdf", "wb").write(sheet)
Errors
Error messages are written for people. Print them.
from linearcutting import ValidationError, RateLimited
try:
plan = lc.optimise(parts="7000", stock=6000)
except ValidationError as error:
print(error) # says what was wrong and what was expected
except RateLimited as error:
time.sleep(error.retry_after)
ValidationError, AuthError, RateLimited (with .retry_after) and
ServerError all inherit from LinearCuttingError. 429s and 503s are retried
automatically, honouring the server's Retry-After.
What this is not
1D only. Linear material, cut across its length. It does not do 2D sheet or panel nesting, plate, glass, or DXF and beam-saw export. If your material is flat and you cut shapes out of it, this is the wrong tool and you should use something else.
Benchmarks
Three real jobs, every method, with the input cut lists published so you can re-run them: linearcutting.com/benchmarks · raw dataset
A benchmark you cannot reproduce is marketing.
Links
MIT licensed.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file linearcutting-1.1.0.tar.gz.
File metadata
- Download URL: linearcutting-1.1.0.tar.gz
- Upload date:
- Size: 12.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efd37f23767aeca1112024e7c7a57661d54a2173698e24352160274ad0d30ba7
|
|
| MD5 |
0ad613548ad6bc941ed930e603a21619
|
|
| BLAKE2b-256 |
b917cb6e9da438993a59583b7636fb5698faff72add782c27e93d4f6a59f7ff7
|
File details
Details for the file linearcutting-1.1.0-py3-none-any.whl.
File metadata
- Download URL: linearcutting-1.1.0-py3-none-any.whl
- Upload date:
- Size: 11.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0971783f11f2816ded97b493c9c9a23987ab79b815736a5567d16b6b40ab6111
|
|
| MD5 |
4774abf3223e8fd55e2816460261dfd8
|
|
| BLAKE2b-256 |
1768e59bfbf9327e8eaa4bcbecad7bdafdadeabd547ef37a61a0306e9ad3fd62
|