ecode-cutting 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ """输入校验、精度处理和合作式取消;不保存跨任务可变状态。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import fields, replace
6
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN
7
+ from math import isfinite
8
+ from sys import float_info
9
+ from time import monotonic
10
+ from typing import Callable, Sequence
11
+
12
+ from .models import Options, Part, Progress, Stock, ValidationError
13
+
14
+
15
+ def number(value: object, name: str, *, positive: bool = False) -> float:
16
+ if isinstance(value, (str, bytes, bool)):
17
+ raise ValidationError(f"{name} 必须是有限数值")
18
+ try:
19
+ result = float(value)
20
+ except (TypeError, ValueError, OverflowError) as exc:
21
+ raise ValidationError(f"{name} 必须是有限数值") from exc
22
+ if not isfinite(result) or result < 0 or (positive and result == 0):
23
+ raise ValidationError(f"{name} 必须是{'正' if positive else '非负'}有限数值")
24
+ return result
25
+
26
+
27
+ def integer(value: object, name: str, minimum: int = 0) -> int:
28
+ if type(value) is not int or value < minimum:
29
+ raise ValidationError(f"{name} 必须是大于等于 {minimum} 的整数")
30
+ return value
31
+
32
+
33
+ def quantize(value: float, precision: int) -> int:
34
+ """与 .NET 默认舍入一致,避免二进制浮点数在锯缝边界积累误差。"""
35
+ try:
36
+ return int((Decimal(str(value)) * (10 ** precision)).to_integral_value(rounding=ROUND_HALF_EVEN))
37
+ except (InvalidOperation, ValueError, OverflowError) as exc:
38
+ raise ValidationError("尺寸超出可表示范围") from exc
39
+
40
+
41
+ def matches(stock_material: int, part_material: int) -> bool:
42
+ # -1 原料是通配材料;指定材料的原料不能接收未指定材料的零件。
43
+ return stock_material == -1 or stock_material == part_material
44
+
45
+
46
+ def normalize_options(options: Options) -> Options:
47
+ """计算边界统一为有限 float,避免 Decimal 与内部浮点计时混算。"""
48
+ if not isinstance(options, Options):
49
+ raise ValidationError("options 必须是 Options")
50
+ values = {}
51
+ for f in fields(options):
52
+ if isinstance(f.default, float):
53
+ value = getattr(options, f.name)
54
+ values[f.name] = None if f.name == "time_limit" and value is None else number(value, f.name)
55
+ return replace(options, **values)
56
+
57
+
58
+ def validate(stocks: Sequence[Stock], parts: Sequence[Part], options: Options, *, linear: bool) -> None:
59
+ if not isinstance(options, Options):
60
+ raise ValidationError("options 必须是 Options")
61
+ for name in ("saw_width", "trim_left", "trim_right", "trim_top", "trim_bottom", "waste_size_min", "cross_section"):
62
+ number(getattr(options, name), name)
63
+ for name in ("max_cut_level", "max_layout_size", "linear_max_size_per_stock"):
64
+ integer(getattr(options, name), name)
65
+ integer(options.max_size_per_stock, "max_size_per_stock", -1)
66
+ integer(options.max_decimal_point, "max_decimal_point")
67
+ if options.max_decimal_point > 6:
68
+ raise ValidationError("max_decimal_point 必须在 0 到 6 之间")
69
+ integer(options.max_attempts, "max_attempts", 1)
70
+ integer(options.seed, "seed")
71
+ threshold = number(options.linear_max_size_per_stock_threshold, "linear_max_size_per_stock_threshold")
72
+ if threshold > 1:
73
+ raise ValidationError("linear_max_size_per_stock_threshold 必须在 0 到 1 之间")
74
+ if options.cut_direction not in ("auto", "horizontal", "vertical"):
75
+ raise ValidationError("cut_direction 必须是 auto、horizontal 或 vertical")
76
+ if options.roll_mode and (linear or options.cut_direction == "auto"):
77
+ raise ValidationError("卷材模式必须显式选择二维横向或纵向切割")
78
+ if options.time_limit is not None:
79
+ number(options.time_limit, "time_limit")
80
+ for f in fields(options):
81
+ if isinstance(f.default, bool) and type(getattr(options, f.name)) is not bool:
82
+ raise ValidationError(f"{f.name} 必须是布尔值")
83
+ requested = sum(integer(p.count, "parts.count") for p in parts if isinstance(p, Part))
84
+ for kind, items in (("stocks", stocks), ("parts", parts)):
85
+ total_area = total_quantized_area = 0
86
+ for i, item in enumerate(items):
87
+ if not isinstance(item, Stock if kind == "stocks" else Part):
88
+ raise ValidationError(f"{kind}[{i}] 类型错误")
89
+ dimensions, quantized_dimensions = [], []
90
+ for dim in ("width", "height"):
91
+ value = number(getattr(item, dim), f"{kind}[{i}].{dim}", positive=True)
92
+ rounded = quantize(value, options.max_decimal_point)
93
+ if rounded <= 0:
94
+ raise ValidationError(f"{kind}[{i}].{dim} 在当前精度下为零")
95
+ dimensions.append(value)
96
+ quantized_dimensions.append(rounded)
97
+ integer(item.count, f"{kind}[{i}].count")
98
+ integer(item.material, f"{kind}[{i}].material", -1)
99
+ # 按实际浮点表示能力限制,而不规定业务尺寸上限;包含精度缩放后的中间量。
100
+ quantity = item.count or (max(1, requested) if kind == "stocks" else 0)
101
+ area = dimensions[0] * dimensions[1]
102
+ quantized_area = quantized_dimensions[0] * quantized_dimensions[1]
103
+ if linear and options.cross_section:
104
+ area = max(area, dimensions[0] * number(options.cross_section, "cross_section"))
105
+ quantized_area = max(quantized_area, quantized_dimensions[0] * quantize(options.cross_section, options.max_decimal_point))
106
+ try:
107
+ total_area += area * quantity
108
+ total_quantized_area += quantized_area * quantity
109
+ except OverflowError as exc:
110
+ raise ValidationError(f"{kind} 总材料尺寸超出浮点表示范围") from exc
111
+ if (not isfinite(area) or not isfinite(total_area)
112
+ or max(quantized_dimensions) > float_info.max
113
+ or total_quantized_area > float_info.max):
114
+ raise ValidationError(f"{kind} 面积或精度缩放后的总材料尺寸超出浮点表示范围")
115
+ if not isinstance(item.id, str):
116
+ raise ValidationError(f"{kind}[{i}].id 必须是字符串")
117
+ if isinstance(item, Part):
118
+ if type(item.rotatable) is not bool:
119
+ raise ValidationError("rotatable 必须是布尔值")
120
+ for angle in (item.angle_start, item.angle_end):
121
+ number(angle, "angle")
122
+ if angle != 0 and not 10 <= angle <= 170:
123
+ raise ValidationError("切角必须是 0 或 10 到 170 度")
124
+ if not linear and angle != 0:
125
+ raise ValidationError("斜角仅适用于一维型材")
126
+ elif type(item.waste) is not bool:
127
+ raise ValidationError("waste 必须是布尔值")
128
+ if linear and any(p.angle_start or p.angle_end for p in parts) and options.cross_section <= 0:
129
+ raise ValidationError("斜角切割必须设置正的 cross_section")
130
+
131
+
132
+ class Runtime:
133
+ """回调返回 True 时取消;超时或取消后仍返回已经得到的可行解。"""
134
+
135
+ def __init__(self, options: Options, requested: int, callback: Callable[[Progress], bool] | None = None):
136
+ self.started = monotonic()
137
+ self.deadline = None if options.time_limit is None else self.started + number(options.time_limit, "time_limit")
138
+ self.callback = callback
139
+ self.requested = requested
140
+ self.cancelled = False
141
+ self.timed_out = False
142
+ self.last_callback = -1.0
143
+
144
+ @property
145
+ def elapsed(self) -> float:
146
+ return monotonic() - self.started
147
+
148
+ def stopped(self, placed: int = 0, attempt: int = 0) -> bool:
149
+ if self.cancelled or self.timed_out:
150
+ return True
151
+ now = monotonic()
152
+ if self.deadline is not None and now >= self.deadline:
153
+ self.timed_out = True
154
+ return True
155
+ if self.callback is not None and now - self.last_callback >= 0.02:
156
+ self.last_callback = now
157
+ self.cancelled = bool(self.callback(Progress(now - self.started, placed, self.requested, attempt)))
158
+ return self.cancelled
@@ -0,0 +1,231 @@
1
+ """直端批量订单的容量索引;复杂约束交还完整的一维求解器。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_left, insort
6
+ from collections import Counter, defaultdict
7
+ from dataclasses import replace
8
+ from math import hypot
9
+ from random import Random
10
+ from typing import Sequence
11
+
12
+ from ._common import Runtime, matches, quantize
13
+ from .linear import _Bar, solve_linear
14
+ from .models import (Cut, Layout, Options, Part, Placement, Remnant, Result,
15
+ Stock, StockComponent, Unplaced, ValidationError)
16
+
17
+
18
+ def solve_linear_fast(stocks: Sequence[Stock], parts: Sequence[Part], options: Options,
19
+ runtime: Runtime) -> Result:
20
+ """保持原启发式的候选顺序,仅替换逐棒扫描;输入校验由引擎负责。"""
21
+ requested = sum(p.count for p in parts)
22
+ if (requested <= 64 or options.waste_size_min or options.linear_max_size_per_stock
23
+ or options.max_size_per_stock
24
+ or any(p.angle_start not in (0, 90) or p.angle_end not in (0, 90) for p in parts)):
25
+ return solve_linear(stocks, parts, options, runtime)
26
+
27
+ scale = 10 ** options.max_decimal_point
28
+ q = lambda n: quantize(n, options.max_decimal_point)
29
+ widths, stock_widths = [q(p.width) for p in parts], [q(s.width) for s in stocks]
30
+ left, right, kerf = map(q, (options.trim_left, options.trim_right, options.saw_width))
31
+ keys = [(w, 0, 0) for w in widths]
32
+ instances = [(i, j) for i, p in enumerate(parts) for j in range(p.count)]
33
+ stock_order = sorted(range(len(stocks)), key=lambda j: (
34
+ not stocks[j].waste, -stock_widths[j] if options.use_large_stock_first else stock_widths[j], j))
35
+ best, best_score, attempts = [], None, 0
36
+
37
+ def score(bars):
38
+ cut_count = sum(len(b.items) + 1
39
+ - (b.items[0][2] in (0, left))
40
+ - (b.end in (b.width, b.width - right))
41
+ + bool(left) + bool(right) for b in bars)
42
+ patterns = (len({(b.width, tuple(sorted(b.types.items()))) for b in bars})
43
+ if options.use_layout_minimization else 0)
44
+ return (-sum(len(b.items) for b in bars), len(bars), sum(not b.waste for b in bars),
45
+ patterns, sum(b.width for b in bars), cut_count)
46
+
47
+ def remember(bars):
48
+ nonlocal best, best_score
49
+ candidate = score(bars)
50
+ if best_score is None or candidate < best_score:
51
+ best, best_score = [b.copy() for b in bars], candidate
52
+
53
+ def append(bar, i, instance):
54
+ x = bar.end + kerf if bar.items else left
55
+ bar.items.append((i, instance, x, 0, 0, False, False))
56
+ bar.end = x + widths[i]
57
+ bar.types[keys[i]] += 1
58
+
59
+ if not runtime.stopped() and options.complete_mode:
60
+ for i, part in enumerate(parts):
61
+ if part.count and not any(matches(s.material, part.material)
62
+ and stock_widths[j] - left - right >= widths[i]
63
+ for j, s in enumerate(stocks)):
64
+ if runtime.stopped():
65
+ break
66
+ raise ValidationError(f"parts[{i}] 无法放入任何可用原料(含修边、余料和组合约束)")
67
+
68
+ rng = Random(options.seed)
69
+ for attempt in range(1 if options.linear_sort_ascending else options.max_attempts):
70
+ if runtime.stopped(-best_score[0] if best_score else 0, attempt):
71
+ break
72
+ attempts += 1
73
+ order = instances.copy()
74
+ if options.linear_sort_ascending or attempt == 1:
75
+ order.sort(key=lambda item: widths[item[0]])
76
+ elif attempt == 0:
77
+ order.sort(key=lambda item: widths[item[0]], reverse=True)
78
+ else:
79
+ rng.shuffle(order)
80
+ bars, used = [], Counter()
81
+ buckets = defaultdict(list)
82
+ for position, (i, instance) in enumerate(order):
83
+ if position % 32 == 0 and runtime.stopped(position, attempt + 1):
84
+ break
85
+ materials = (-1,) if parts[i].material == -1 else (-1, parts[i].material)
86
+ candidate = None
87
+ for waste in (True, False):
88
+ for material in materials:
89
+ bucket = buckets[material, waste]
90
+ index = bisect_left(bucket, (widths[i], -1))
91
+ if index < len(bucket):
92
+ capacity, j = bucket[index]
93
+ choice = (capacity, j, material, waste, index)
94
+ if candidate is None or choice[:2] < candidate[:2]:
95
+ candidate = choice
96
+ if candidate is not None:
97
+ break
98
+ if candidate is not None:
99
+ _, j, material, waste, index = candidate
100
+ buckets[material, waste].pop(index)
101
+ bar = bars[j]
102
+ else:
103
+ stock_i = next((j for j in stock_order
104
+ if (not stocks[j].count or used[j] < stocks[j].count)
105
+ and matches(stocks[j].material, parts[i].material)
106
+ and stock_widths[j] - left - right >= widths[i]), None)
107
+ if stock_i is None:
108
+ continue
109
+ stock = stocks[stock_i]
110
+ bar = _Bar(((stock_i, used[stock_i]),), stock_widths[stock_i],
111
+ stock.material, stock.waste, end=left)
112
+ used[stock_i] += 1
113
+ j = len(bars)
114
+ bars.append(bar)
115
+ append(bar, i, instance)
116
+ capacity = bar.width - right - bar.end - kerf
117
+ if capacity >= 0:
118
+ # ponytail: bisect 定位后列表移动仍为 O(B),避免逐棒执行 Python fit 循环。
119
+ insort(buckets[bar.material, bar.waste], (capacity, j))
120
+ remember(bars)
121
+
122
+ # 与原求解器同样的小组重排;多材料大订单也可能含一个小材料组。
123
+ capacity = max(stock_widths, default=0) - left - right + kerf
124
+ lower_bound = ((sum((widths[i] + kerf) * p.count for i, p in enumerate(parts))
125
+ + capacity - 1) // capacity if capacity > 0 else 0)
126
+ nodes, changed = 0, True
127
+ while (changed and best and -best_score[0] == requested and best_score[1] != lower_bound
128
+ and not runtime.stopped(requested, attempts)):
129
+ changed = False
130
+ for removed in sorted(range(len(best)), key=lambda j: best[j].end):
131
+ group = [j for j, b in enumerate(best) if b.material == best[removed].material]
132
+ items = [(item[0], item[1]) for j in group for item in best[j].items]
133
+ if len(group) < 2 or len(group) > 8 or len(items) > 64:
134
+ continue
135
+ targets = [replace(best[j], items=[], types=Counter(), end=left)
136
+ for j in group if j != removed]
137
+ if sum(widths[i] + kerf for i, _ in items) > sum(b.width - left - right + kerf for b in targets):
138
+ continue
139
+ items.sort(key=lambda item: widths[item[0]], reverse=not options.linear_sort_ascending)
140
+ seen, local_nodes = set(), 0
141
+
142
+ def assign(position):
143
+ nonlocal nodes, local_nodes
144
+ nodes += 1
145
+ local_nodes += 1
146
+ if local_nodes > 20000 or nodes > 100000:
147
+ return False
148
+ if nodes % 64 == 0 and runtime.stopped(requested, attempts):
149
+ return False
150
+ if position == len(items):
151
+ return all(b.items for b in targets)
152
+ state = (position, tuple((b.end, tuple(sorted(b.types.items()))) for b in targets))
153
+ if state in seen:
154
+ return False
155
+ seen.add(state)
156
+ i, instance = items[position]
157
+ symmetric = set()
158
+ for j in sorted(range(len(targets)), key=lambda j: targets[j].width - targets[j].end):
159
+ bar = targets[j]
160
+ signature = (bar.width, bar.waste, bar.end, tuple(sorted(bar.types.items())))
161
+ if signature in symmetric:
162
+ continue
163
+ symmetric.add(signature)
164
+ x = bar.end + kerf if bar.items else left
165
+ if not matches(bar.material, parts[i].material) or x + widths[i] > bar.width - right:
166
+ continue
167
+ saved = bar.copy()
168
+ append(bar, i, instance)
169
+ if assign(position + 1):
170
+ return True
171
+ targets[j] = saved
172
+ return False
173
+
174
+ if assign(0):
175
+ remember([b for j, b in enumerate(best) if j not in group] + targets)
176
+ changed = True
177
+ break
178
+ if nodes > 100000 or runtime.stopped(requested, attempts):
179
+ changed = False
180
+ break
181
+
182
+ layouts, placed_counts, stock_instances = [], Counter(), Counter()
183
+ for bar in best:
184
+ stock_i = bar.components[0][0]
185
+ stock, instance = stocks[stock_i], stock_instances[stock_i]
186
+ stock_instances[stock_i] += 1
187
+ height = options.cross_section or stock.height
188
+ placements, cuts = [], []
189
+ previous_end = None
190
+ for local_index, (i, _, integer_x, *_rest) in enumerate(bar.items):
191
+ part = parts[i]
192
+ x, width = integer_x / scale, widths[i] / scale
193
+ placements.append(Placement(i, placed_counts[i], width, height, x, id=part.id,
194
+ material=part.material, angle_start=part.angle_start,
195
+ angle_end=part.angle_end))
196
+ placed_counts[i] += 1
197
+ for is_start, top, angle in ((True, x, part.angle_start),
198
+ (False, x + width, part.angle_end)):
199
+ if abs(top) < 1e-9 or abs(top - bar.width / scale) < 1e-9:
200
+ continue
201
+ if is_start and local_index == 0 and abs(top - left / scale) < 1e-9 and left:
202
+ continue
203
+ if not is_start and abs(top - (bar.width - right) / scale) < 1e-9 and right:
204
+ continue
205
+ allowance = kerf / scale * hypot(height, 0) / (2 * height)
206
+ shift = -allowance if is_start else allowance
207
+ cut = Cut(top + shift, 0, top + shift, height, angle=angle or 90,
208
+ part_indices=(local_index,))
209
+ if is_start and previous_end is not None and abs(cuts[previous_end].x1 - cut.x1) <= 1e-9:
210
+ old = cuts[previous_end]
211
+ cuts[previous_end] = replace(old, x1=(old.x1 + cut.x1) / 2,
212
+ x2=(old.x2 + cut.x2) / 2,
213
+ part_indices=old.part_indices + (local_index,))
214
+ else:
215
+ cuts.append(cut)
216
+ if not is_start:
217
+ previous_end = len(cuts) - 1
218
+ remnant_x = min(bar.width - right, bar.end + kerf) if bar.end < bar.width - right else bar.end
219
+ remnant_width = max(0, bar.width - right - remnant_x)
220
+ remnants = ((Remnant(remnant_width / scale, height, remnant_x / scale, material=bar.material),)
221
+ if remnant_width else ())
222
+ trim_cuts = tuple(Cut(x / scale, 0, x / scale, height)
223
+ for x, trim in ((left - kerf / 2, left),
224
+ (bar.width - right + kerf / 2, right)) if trim)
225
+ component = StockComponent(stock_i, instance, stock_widths[stock_i] / scale,
226
+ stock.id, stock.waste, stock.material)
227
+ layouts.append(Layout(stock_i, instance, bar.width / scale, height, stock.id, bar.material,
228
+ bar.waste, tuple(placements), remnants, tuple(cuts), trim_cuts, (component,)))
229
+ return Result(tuple(layouts), tuple(Unplaced(i, p.count - placed_counts[i], p.id)
230
+ for i, p in enumerate(parts) if p.count > placed_counts[i]),
231
+ runtime.elapsed, True, runtime.cancelled, runtime.timed_out, attempts)