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,230 @@
1
+ """公开数据模型。尺寸使用调用方统一的单位,内部计算按精度转换为整数。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ class ValidationError(ValueError):
10
+ """输入或计算参数不合法。"""
11
+
12
+
13
+ class UnsupportedFeatureError(NotImplementedError):
14
+ """输入格式或平台专属接口尚不能在本实现中表达。"""
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Material:
19
+ id: str = ""
20
+ description: str = ""
21
+ thickness: float = 0.0
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Part:
26
+ width: float
27
+ height: float = 1.0
28
+ count: int = 1
29
+ rotatable: bool = True
30
+ id: str = ""
31
+ material: int = -1
32
+ angle_start: float = 0.0
33
+ angle_end: float = 0.0
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Stock:
38
+ width: float
39
+ height: float = 1.0
40
+ count: int = 1
41
+ id: str = ""
42
+ waste: bool = False
43
+ material: int = -1
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class Options:
48
+ saw_width: float = 0.0
49
+ trim_left: float = 0.0
50
+ trim_top: float = 0.0
51
+ trim_right: float = 0.0
52
+ trim_bottom: float = 0.0
53
+ complete_mode: bool = True
54
+ waste_size_min: float = 0.0
55
+ use_layout_minimization: bool = False
56
+ max_cut_level: int = 2_147_483_647
57
+ cross_section: float = 0.0
58
+ linear_allow_rotate: bool = False
59
+ linear_exact_angle: bool = True
60
+ linear_allow_flipping: bool = False
61
+ minimize_sheet_rotation: bool = False
62
+ max_layout_size: int = 0
63
+ linear_max_size_per_stock: int = 0
64
+ linear_max_size_per_stock_threshold: float = 0.5
65
+ max_decimal_point: int = 3
66
+ linear_sort_ascending: bool = False
67
+ max_size_per_stock: int = 0
68
+ allow_combine_stock_regular_and_waste: bool = False
69
+ use_large_stock_first: bool = False
70
+ cut_direction: str = "auto"
71
+ roll_mode: bool = False
72
+ # 新增的显式预算控制;None 表示不限制时间,取消回调仍有效。
73
+ time_limit: float | None = 10.0
74
+ max_attempts: int = 4
75
+ seed: int = 0
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class Placement:
80
+ input_index: int
81
+ instance_index: int
82
+ width: float
83
+ height: float
84
+ x: float
85
+ y: float = 0.0
86
+ id: str = ""
87
+ material: int = -1
88
+ rotated: bool = False
89
+ flipped: bool = False
90
+ angle_start: float = 0.0
91
+ angle_end: float = 0.0
92
+ # 斜角零件使用真实轮廓;普通矩形留空并由宽高表示。
93
+ polygon: tuple[tuple[float, float], ...] = ()
94
+
95
+ @property
96
+ def area(self) -> float:
97
+ """真实轮廓面积;先平移原点,避免远离零点的坐标乘积互相消减。"""
98
+ if not self.polygon:
99
+ return self.width * self.height
100
+ x0, y0 = self.polygon[0]
101
+ points = tuple((x - x0, y - y0) for x, y in self.polygon)
102
+ return abs(sum(x * points[(i + 1) % len(points)][1]
103
+ - points[(i + 1) % len(points)][0] * y
104
+ for i, (x, y) in enumerate(points))) / 2
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Remnant:
109
+ width: float
110
+ height: float
111
+ x: float
112
+ y: float = 0.0
113
+ material: int = -1
114
+ angle: float = 0.0
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class Cut:
119
+ x1: float
120
+ y1: float
121
+ x2: float
122
+ y2: float
123
+ level: int = 0
124
+ angle: float = 90.0
125
+ part_indices: tuple[int, ...] = ()
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class StockComponent:
130
+ input_index: int
131
+ instance_index: int
132
+ width: float
133
+ id: str = ""
134
+ waste: bool = False
135
+ material: int = -1
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class Layout:
140
+ input_index: int
141
+ instance_index: int
142
+ width: float
143
+ height: float
144
+ id: str = ""
145
+ material: int = -1
146
+ waste: bool = False
147
+ parts: tuple[Placement, ...] = ()
148
+ remnants: tuple[Remnant, ...] = ()
149
+ cuts: tuple[Cut, ...] = ()
150
+ trim_cuts: tuple[Cut, ...] = ()
151
+ components: tuple[StockComponent, ...] = ()
152
+ # 净板以外的原卷尾段,坐标相对于输入原料;不计入本次已用净板面积。
153
+ roll_remnants: tuple[Remnant, ...] = ()
154
+
155
+ @property
156
+ def all_remnants(self) -> tuple[Remnant, ...]:
157
+ return self.remnants + self.roll_remnants
158
+
159
+ @property
160
+ def fill_ratio(self) -> float:
161
+ area = self.width * self.height
162
+ return sum(p.area for p in self.parts) / area if area else 0.0
163
+
164
+ @property
165
+ def nominal_fill_ratio(self) -> float:
166
+ area = self.width * self.height
167
+ return sum(p.width * p.height for p in self.parts) / area if area else 0.0
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class Unplaced:
172
+ input_index: int
173
+ count: int
174
+ id: str = ""
175
+ reason: str = "没有可用且满足约束的原料"
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class LayoutGroup:
180
+ first_layout_index: int
181
+ layout_count: int
182
+
183
+
184
+ @dataclass(frozen=True, slots=True)
185
+ class Result:
186
+ layouts: tuple[Layout, ...] = ()
187
+ unplaced: tuple[Unplaced, ...] = ()
188
+ elapsed: float = 0.0
189
+ linear: bool = False
190
+ cancelled: bool = False
191
+ timed_out: bool = False
192
+ attempts: int = 0
193
+ layout_groups: tuple[LayoutGroup, ...] = ()
194
+
195
+ @property
196
+ def complete(self) -> bool:
197
+ return not self.unplaced
198
+
199
+ @property
200
+ def placed_part_count(self) -> int:
201
+ return sum(len(layout.parts) for layout in self.layouts)
202
+
203
+ @property
204
+ def used_stock_count(self) -> int:
205
+ return sum(len(layout.components) or 1 for layout in self.layouts)
206
+
207
+ @property
208
+ def fill_ratio(self) -> float:
209
+ area = sum(s.width * s.height for s in self.layouts)
210
+ return sum(p.area for s in self.layouts for p in s.parts) / area if area else 0.0
211
+
212
+ @property
213
+ def nominal_fill_ratio(self) -> float:
214
+ area = sum(s.width * s.height for s in self.layouts)
215
+ return sum(p.width * p.height for s in self.layouts for p in s.parts) / area if area else 0.0
216
+
217
+ def to_dict(self) -> dict[str, Any]:
218
+ """只输出 JSON 可序列化数据,方便 Web 接口直接返回。"""
219
+ return {**asdict(self), "complete": self.complete,
220
+ "placed_part_count": self.placed_part_count,
221
+ "used_stock_count": self.used_stock_count, "fill_ratio": self.fill_ratio,
222
+ "nominal_fill_ratio": self.nominal_fill_ratio}
223
+
224
+
225
+ @dataclass(frozen=True, slots=True)
226
+ class Progress:
227
+ elapsed: float
228
+ placed: int = 0
229
+ requested: int = 0
230
+ attempt: int = 0
@@ -0,0 +1,234 @@
1
+ """在同一总预算内搜索并比较独立的可行候选。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, replace
6
+ from math import isfinite
7
+ from typing import Any, Callable, Sequence
8
+
9
+ from ._common import Runtime, integer, normalize_options, number, validate
10
+ from .models import Layout, Options, Part, Progress, Result, Stock, ValidationError
11
+
12
+
13
+ def _json_value(value):
14
+ if isinstance(value, float) and not isfinite(value):
15
+ return None
16
+ if isinstance(value, dict):
17
+ return {key: _json_value(item) for key, item in value.items()}
18
+ if isinstance(value, (tuple, list)):
19
+ return [_json_value(item) for item in value]
20
+ return value
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class PlanMetrics:
25
+ requested_parts: int
26
+ placed_parts: int
27
+ unplaced_parts: int
28
+ completion_ratio: float
29
+ used_stock_count: int
30
+ used_layout_count: int
31
+ total_stock_size: float
32
+ total_part_size: float
33
+ utilization: float
34
+ utilization_percent: float
35
+ cut_count: int
36
+ layout_count: int
37
+ unit: str
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class OptimizationPlan:
42
+ id: str
43
+ label: str
44
+ strategy: dict[str, Any]
45
+ result: Result
46
+ metrics: PlanMetrics
47
+ pareto_optimal: bool = False
48
+
49
+ @property
50
+ def complete(self) -> bool:
51
+ return self.result.complete
52
+
53
+ @property
54
+ def partial(self) -> bool:
55
+ return not self.complete
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ # 只遍历一次大布局,避免 asdict(self) 后又重复构造同一 Result。
59
+ return _json_value({"id": self.id, "label": self.label, "strategy": dict(self.strategy),
60
+ "result": self.result.to_dict(), "metrics": asdict(self.metrics),
61
+ "pareto_optimal": self.pareto_optimal,
62
+ "complete": self.complete, "partial": self.partial})
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class OptimizationPortfolio:
67
+ plans: tuple[OptimizationPlan, ...]
68
+ elapsed: float
69
+ searches_started: int
70
+ searches_completed: int
71
+ cancelled: bool
72
+ timed_out: bool
73
+ diagnostics: tuple[str, ...] = ()
74
+
75
+ @property
76
+ def best(self) -> OptimizationPlan | None:
77
+ return self.plans[0] if self.plans else None
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ return {"plans": [plan.to_dict() for plan in self.plans], "elapsed": self.elapsed,
81
+ "searches_started": self.searches_started, "searches_completed": self.searches_completed,
82
+ "cancelled": self.cancelled, "timed_out": self.timed_out,
83
+ "diagnostics": list(self.diagnostics)}
84
+
85
+
86
+ def _ring(points):
87
+ if not points:
88
+ return ()
89
+ points = tuple(points)
90
+ reverse = points[::-1]
91
+ return min(*(points[i:] + points[:i] for i in range(len(points))),
92
+ *(reverse[i:] + reverse[:i] for i in range(len(points))))
93
+
94
+
95
+ def _layout_key(layout: Layout) -> tuple:
96
+ # 忽略实例编号和标签;切割顺序和真实轮廓仍是方案的一部分。
97
+ parts = tuple(sorted((p.width, p.height, p.x, p.y, p.material,
98
+ p.angle_start, p.angle_end, _ring(p.polygon))
99
+ for p in layout.parts))
100
+ def cuts(items):
101
+ return tuple((*min((c.x1, c.y1, c.x2, c.y2), (c.x2, c.y2, c.x1, c.y1)),
102
+ c.level, c.angle) for c in items)
103
+ components = tuple((c.width, c.material, c.waste) for c in layout.components)
104
+ roll_remnants = tuple((r.width, r.height, r.x, r.y, r.material) for r in layout.roll_remnants)
105
+ return (layout.width, layout.height, layout.material, layout.waste, components,
106
+ parts, cuts(layout.cuts), cuts(layout.trim_cuts), roll_remnants)
107
+
108
+
109
+ def _result_key(result: Result) -> tuple:
110
+ return tuple(sorted(_layout_key(layout) for layout in result.layouts))
111
+
112
+
113
+ def _metrics(result: Result, requested: int, linear: bool) -> PlanMetrics:
114
+ stock_size = sum(s.width if linear else s.width * s.height for s in result.layouts)
115
+ part_size = sum(p.area / s.height if linear else p.area
116
+ for s in result.layouts for p in s.parts)
117
+ utilization = part_size / stock_size if stock_size else 0.0
118
+ placed = result.placed_part_count
119
+ return PlanMetrics(requested, placed, sum(u.count for u in result.unplaced),
120
+ placed / requested if requested else 1.0,
121
+ result.used_stock_count, len(result.layouts), stock_size, part_size,
122
+ utilization, utilization * 100,
123
+ sum(len(s.cuts) + len(s.trim_cuts) for s in result.layouts),
124
+ len(set(_result_key(result))), "length" if linear else "area")
125
+
126
+
127
+ def _candidates(options: Options, maximum: int):
128
+ yield options
129
+ protected = options.use_layout_minimization and options.max_layout_size > 0
130
+ for index in range(1, maximum):
131
+ # 仅修改搜索偏好,不能改变任何加工约束。
132
+ yield replace(options, seed=options.seed + index,
133
+ use_large_stock_first=(not options.use_large_stock_first
134
+ if index % 2 else options.use_large_stock_first),
135
+ use_layout_minimization=(True if protected else
136
+ (not options.use_layout_minimization
137
+ if index % 4 >= 2 else options.use_layout_minimization)),
138
+ minimize_sheet_rotation=(not options.minimize_sheet_rotation
139
+ if index % 8 >= 4 else options.minimize_sheet_rotation),
140
+ linear_sort_ascending=(options.linear_sort_ascending or index % 6 >= 4),
141
+ max_attempts=1 + (index // 2) % max(4, options.max_attempts))
142
+
143
+
144
+ def _objective(plan: OptimizationPlan) -> tuple:
145
+ m = plan.metrics
146
+ return m.total_stock_size, m.used_stock_count, m.cut_count, m.layout_count
147
+
148
+
149
+ def search_portfolio(stocks: Sequence[Stock], parts: Sequence[Part], options: Options, *,
150
+ linear: bool, max_plans: int = 3, time_limit: float = 5.0,
151
+ max_searches: int = 12,
152
+ callback: Callable[[Progress], bool] | None = None) -> OptimizationPortfolio:
153
+ """返回最高完成件数的不同方案;预算为合作式检查,可能少量超时。
154
+
155
+ searches_completed 计入正常返回的求解调用,包括已返回部分解的中断调用。
156
+ pareto_optimal 相对于本次已搜索候选,以用料、原料数、切数及版式数评价。
157
+ """
158
+ from .engine import group_layouts
159
+ from .linear import solve_linear
160
+ from .rectangular import solve_rectangular
161
+
162
+ integer(max_plans, "max_plans", 1)
163
+ integer(max_searches, "max_searches", 1)
164
+ if callback is not None and not callable(callback):
165
+ raise ValidationError("callback 必须可调用")
166
+ budget = number(time_limit, "time_limit")
167
+ options = normalize_options(options)
168
+ stocks, parts = tuple(stocks), tuple(parts)
169
+ validate(stocks, parts, options, linear=linear)
170
+ if options.time_limit is not None:
171
+ budget = min(budget, options.time_limit)
172
+ requested = sum(p.count for p in parts)
173
+ total = Runtime(replace(options, time_limit=budget), requested, callback)
174
+ candidates: list[OptimizationPlan] = []
175
+ seen = set()
176
+ started = completed = 0
177
+ highest = -1
178
+ diagnostics = []
179
+ solver = solve_linear if linear else solve_rectangular
180
+ solver_name = "linear" if linear else "rectangular"
181
+ if (linear and requested > 64 and not options.waste_size_min
182
+ and not options.linear_max_size_per_stock and not options.max_size_per_stock
183
+ and all(p.angle_start in (0, 90) and p.angle_end in (0, 90) for p in parts)):
184
+ from ._linear_fast import solve_linear_fast
185
+ solver, solver_name = solve_linear_fast, "linear_fast"
186
+ for candidate in _candidates(options, max_searches):
187
+ if total.stopped(max(highest, 0), started):
188
+ break
189
+ remaining = max(0.0, budget - total.elapsed)
190
+ # 给首解更大份额,仍为之后的独立候选保留预算。
191
+ share = remaining if max_searches == started + 1 else remaining / min(4, max_searches - started)
192
+ runtime = Runtime(replace(candidate, time_limit=share), requested,
193
+ lambda progress: total.stopped(progress.placed, started))
194
+ started += 1
195
+ result = group_layouts(solver(stocks, parts, candidate, runtime), candidate)
196
+ completed += 1
197
+ metrics = _metrics(result, requested, linear)
198
+ if metrics.placed_parts > highest:
199
+ highest = metrics.placed_parts
200
+ candidates.clear()
201
+ seen.clear()
202
+ key = _result_key(result)
203
+ if metrics.placed_parts == highest and key not in seen:
204
+ seen.add(key)
205
+ strategy = {name: getattr(candidate, name) for name in
206
+ ("seed", "use_large_stock_first", "use_layout_minimization",
207
+ "minimize_sheet_rotation", "max_attempts", "linear_sort_ascending")}
208
+ strategy["solver"] = solver_name
209
+ candidates.append(OptimizationPlan("", "", strategy, result, metrics))
210
+ if runtime.timed_out:
211
+ diagnostics.append(f"候选 {started} 用尽分配预算,保留已返回结果")
212
+ if total.stopped(max(highest, 0), started):
213
+ break
214
+
215
+ candidates.sort(key=_objective)
216
+ frontier = [not any(all(a <= b for a, b in zip(_objective(other), _objective(plan)))
217
+ and _objective(other) != _objective(plan) for other in candidates)
218
+ for plan in candidates]
219
+ chosen = []
220
+ for index, plan in enumerate(candidates[:max_plans]):
221
+ m = plan.metrics
222
+ advantages = []
223
+ for field, label in (("total_stock_size", "用料较少"), ("cut_count", "切数较少"),
224
+ ("layout_count", "版式较少")):
225
+ if getattr(m, field) == min(getattr(p.metrics, field) for p in candidates):
226
+ advantages.append(label)
227
+ label = "候选内" + "、".join(advantages) if advantages else "备选方案"
228
+ if plan.partial:
229
+ label += "(部分完成)"
230
+ chosen.append(replace(plan, id=f"plan-{index + 1}", label=label, pareto_optimal=frontier[index]))
231
+ if len(chosen) < max_plans:
232
+ diagnostics.append(f"本次搜索得到 {len(chosen)} 个不同的最高完成度方案")
233
+ return OptimizationPortfolio(tuple(chosen), total.elapsed, started, completed,
234
+ total.cancelled, total.timed_out, tuple(diagnostics))
ecode_cutting/py.typed ADDED
File without changes