packvium 0.1.0__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.
- packvium/__init__.py +70 -0
- packvium/__main__.py +18 -0
- packvium/_compat.py +42 -0
- packvium/axle_load.py +128 -0
- packvium/centre_of_mass.py +41 -0
- packvium/config.py +85 -0
- packvium/constraints.py +648 -0
- packvium/contact.py +124 -0
- packvium/effort.py +31 -0
- packvium/explain.py +127 -0
- packvium/extensions.py +285 -0
- packvium/geometry.py +159 -0
- packvium/lattice_summary.py +133 -0
- packvium/models.py +385 -0
- packvium/nested.py +30 -0
- packvium/nesting.py +77 -0
- packvium/packer.py +146 -0
- packvium/packing_sequence.py +675 -0
- packvium/policy.py +302 -0
- packvium/py.typed +0 -0
- packvium/rebalance.py +214 -0
- packvium/result.py +284 -0
- packvium/serialization.py +202 -0
- packvium/solvers.py +2297 -0
- packvium/spatial_index.py +93 -0
- packvium/support_polygon.py +77 -0
- packvium/trace.py +52 -0
- packvium/units.py +182 -0
- packvium/validation.py +234 -0
- packvium-0.1.0.dist-info/METADATA +108 -0
- packvium-0.1.0.dist-info/RECORD +35 -0
- packvium-0.1.0.dist-info/WHEEL +5 -0
- packvium-0.1.0.dist-info/entry_points.txt +2 -0
- packvium-0.1.0.dist-info/licenses/LICENSE +21 -0
- packvium-0.1.0.dist-info/top_level.txt +1 -0
packvium/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from .config import PackingConfig, SolverProfile
|
|
2
|
+
from .effort import EffortBudget
|
|
3
|
+
from .explain import (
|
|
4
|
+
LEVEL_PREFIXES,
|
|
5
|
+
REASON_MESSAGES,
|
|
6
|
+
UnknownReasonError,
|
|
7
|
+
explain_reason,
|
|
8
|
+
explain_unpacked_item,
|
|
9
|
+
explain_unpacked_items,
|
|
10
|
+
)
|
|
11
|
+
from .extensions import ContainerSelector, DefaultSolutionScorer, ExtensionRegistry, ItemOrderStrategy, SolutionScorer
|
|
12
|
+
from .geometry import AxisAlignedBox, Dimensions, Point, Rotation, dimensional_weight
|
|
13
|
+
from .models import (Axle, Container, Item, ItemInstance, Obstacle, PackedContainer, PackingRequest,
|
|
14
|
+
Placement, ReasonProof, RejectionObservation, UnpackedItem)
|
|
15
|
+
from .nested import NestedPacker, NestedPackingResult, PackingLevel
|
|
16
|
+
from .packer import Packer
|
|
17
|
+
from .packing_sequence import (
|
|
18
|
+
ALL_DIRECTIONS,
|
|
19
|
+
InvalidDirectionError,
|
|
20
|
+
LoadingDependencyGraph,
|
|
21
|
+
Reachability,
|
|
22
|
+
RouteSequenceError,
|
|
23
|
+
SequenceError,
|
|
24
|
+
SequenceReplayError,
|
|
25
|
+
SequenceStep,
|
|
26
|
+
SequenceWarning,
|
|
27
|
+
UnloadingDependencyGraph,
|
|
28
|
+
placement_reachability,
|
|
29
|
+
replay_loading_order,
|
|
30
|
+
replay_removal_order,
|
|
31
|
+
safe_loading_order,
|
|
32
|
+
safe_loading_order_with_evidence,
|
|
33
|
+
safe_removal_order,
|
|
34
|
+
safe_removal_order_with_evidence,
|
|
35
|
+
safe_route_removal_order,
|
|
36
|
+
)
|
|
37
|
+
from .rebalance import RebalanceResult, WeightMove, rebalance_weight
|
|
38
|
+
from .result import (
|
|
39
|
+
AlgorithmReport,
|
|
40
|
+
PackingResult,
|
|
41
|
+
PackingStatus,
|
|
42
|
+
ResultFact,
|
|
43
|
+
SolverMetrics,
|
|
44
|
+
StartRecord,
|
|
45
|
+
aggregate_termination,
|
|
46
|
+
)
|
|
47
|
+
from .serialization import pack_from_dict
|
|
48
|
+
from .trace import TraceSink, use_trace
|
|
49
|
+
from .units import Length, Rounding, Weight
|
|
50
|
+
from .validation import IndependentSolutionValidator, ValidationIssue, ValidationReport
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ALL_DIRECTIONS", "AlgorithmReport", "Axle", "AxisAlignedBox", "Container", "DefaultSolutionScorer", "Dimensions",
|
|
54
|
+
"EffortBudget", "ExtensionRegistry",
|
|
55
|
+
"IndependentSolutionValidator", "InvalidDirectionError", "Item", "ItemInstance", "LEVEL_PREFIXES", "Length",
|
|
56
|
+
"LoadingDependencyGraph", "NestedPacker",
|
|
57
|
+
"NestedPackingResult", "Obstacle", "PackedContainer", "Packer", "PackingConfig",
|
|
58
|
+
"PackingLevel", "PackingRequest", "PackingResult", "PackingStatus", "Placement", "Point",
|
|
59
|
+
"REASON_MESSAGES", "Reachability", "ReasonProof", "RebalanceResult", "RejectionObservation", "ResultFact", "Rotation",
|
|
60
|
+
"Rounding", "RouteSequenceError",
|
|
61
|
+
"SequenceError",
|
|
62
|
+
"SequenceReplayError", "SequenceStep", "SequenceWarning", "SolutionScorer",
|
|
63
|
+
"SolverMetrics", "StartRecord", "TraceSink", "UnknownReasonError", "UnloadingDependencyGraph",
|
|
64
|
+
"aggregate_termination",
|
|
65
|
+
"SolverProfile", "UnpackedItem", "ValidationIssue", "ValidationReport", "WeightMove",
|
|
66
|
+
"Weight", "dimensional_weight", "explain_reason", "explain_unpacked_item", "explain_unpacked_items",
|
|
67
|
+
"pack_from_dict", "placement_reachability", "rebalance_weight", "replay_loading_order",
|
|
68
|
+
"replay_removal_order", "safe_loading_order", "safe_loading_order_with_evidence", "safe_removal_order",
|
|
69
|
+
"safe_removal_order_with_evidence", "safe_route_removal_order", "use_trace",
|
|
70
|
+
]
|
packvium/__main__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse, json, sys
|
|
3
|
+
from .serialization import pack_from_dict
|
|
4
|
+
|
|
5
|
+
def main() -> int:
|
|
6
|
+
parser = argparse.ArgumentParser(description="Pack rigid cuboids into rectangular containers")
|
|
7
|
+
parser.add_argument("input", nargs="?", help="JSON input file; stdin when omitted")
|
|
8
|
+
parser.add_argument("-o", "--output")
|
|
9
|
+
args = parser.parse_args()
|
|
10
|
+
with (open(args.input, encoding="utf-8") if args.input else sys.stdin) as source:
|
|
11
|
+
result = pack_from_dict(json.load(source))
|
|
12
|
+
encoded = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
|
|
13
|
+
if args.output:
|
|
14
|
+
with open(args.output, "w", encoding="utf-8") as target: target.write(encoded + "\n")
|
|
15
|
+
else: print(encoded)
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
if __name__ == "__main__": raise SystemExit(main())
|
packvium/_compat.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Small runtime compatibility helpers for the public Python package.
|
|
2
|
+
|
|
3
|
+
The canonical models use ``dataclass(slots=True)`` on Python 3.10+ to reduce
|
|
4
|
+
per-instance memory. Python 3.9 has the same dataclass semantics but does not
|
|
5
|
+
accept the ``slots`` keyword. Keeping the version branch in one lower-layer
|
|
6
|
+
module lets every model retain one definition and one public API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass as _stdlib_dataclass
|
|
13
|
+
from typing import Any, Callable, TypeVar, overload
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_T = TypeVar("_T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dataclass_options(options: dict[str, Any], version: tuple[int, int]) -> dict[str, Any]:
|
|
20
|
+
compatible = dict(options)
|
|
21
|
+
if version < (3, 10):
|
|
22
|
+
compatible.pop("slots", None)
|
|
23
|
+
return compatible
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@overload
|
|
27
|
+
def dataclass(cls: type[_T]) -> type[_T]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@overload
|
|
31
|
+
def dataclass(**options: Any) -> Callable[[type[_T]], type[_T]]: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dataclass(cls: type[_T] | None = None, **options: Any) -> Any:
|
|
35
|
+
"""Delegate to stdlib dataclass, omitting only unsupported 3.10 keywords."""
|
|
36
|
+
|
|
37
|
+
compatible = _dataclass_options(options, sys.version_info[:2])
|
|
38
|
+
|
|
39
|
+
def decorate(target: type[_T]) -> type[_T]:
|
|
40
|
+
return _stdlib_dataclass(target, **compatible)
|
|
41
|
+
|
|
42
|
+
return decorate if cls is None else decorate(cls)
|
packvium/axle_load.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Sequence
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .constraints import LoadUnit
|
|
7
|
+
from .models import Axle
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def axle_reactions(
|
|
11
|
+
axles: tuple["Axle", "Axle"],
|
|
12
|
+
units: Sequence["LoadUnit"],
|
|
13
|
+
tare_weight_ticks: int = 0,
|
|
14
|
+
tare_doubled_center_x: int = 0,
|
|
15
|
+
) -> tuple[int, int, int]:
|
|
16
|
+
"""Return ``(denominator, front_numerator, rear_numerator)`` exactly.
|
|
17
|
+
|
|
18
|
+
Axle limits are gross-load limits. Payload contributes at each item's physical
|
|
19
|
+
centre; tare contributes at the container's geometric longitudinal centre (the
|
|
20
|
+
caller passes its doubled coordinate, preserving an odd half-tick).
|
|
21
|
+
"""
|
|
22
|
+
front, rear = axles
|
|
23
|
+
total_weight = tare_weight_ticks + sum(unit.weight_ticks for unit in units)
|
|
24
|
+
doubled_weighted_x = tare_weight_ticks * tare_doubled_center_x + sum(
|
|
25
|
+
unit.weight_ticks * (2 * unit.box.origin.x + unit.box.dimensions.length.ticks)
|
|
26
|
+
for unit in units
|
|
27
|
+
)
|
|
28
|
+
denominator = 2 * (rear.position.ticks - front.position.ticks)
|
|
29
|
+
return (
|
|
30
|
+
denominator,
|
|
31
|
+
2 * total_weight * rear.position.ticks - doubled_weighted_x,
|
|
32
|
+
doubled_weighted_x - 2 * total_weight * front.position.ticks,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def axle_load_exceeded(
|
|
37
|
+
axles: tuple["Axle", "Axle"],
|
|
38
|
+
units: Sequence["LoadUnit"],
|
|
39
|
+
tare_weight_ticks: int = 0,
|
|
40
|
+
tare_doubled_center_x: int = 0,
|
|
41
|
+
) -> tuple[str, str] | None:
|
|
42
|
+
"""Whether either axle bears more than its own limit, as (code, detail).
|
|
43
|
+
|
|
44
|
+
Two-point beam statics: taking moments about each axle in turn gives an exact
|
|
45
|
+
fraction for what the other axle carries, with no assumption weaker than "the
|
|
46
|
+
container behaves like a rigid beam resting on exactly two supports" -- true by
|
|
47
|
+
construction since `Container.axles` only ever has two entries.
|
|
48
|
+
|
|
49
|
+
Takes `LoadUnit`s, the same "box + weight, real or hypothetical" abstraction
|
|
50
|
+
`top_loads`/`stack_density_exceeded` already share, rather than requiring a real
|
|
51
|
+
`Placement` -- a placement-time candidate does not have one yet. A unit's own box
|
|
52
|
+
is already its envelope; the envelope's centre coincides exactly with the
|
|
53
|
+
physical item's centre because clearance pads both sides of every axis equally,
|
|
54
|
+
so no separate physical/envelope distinction is needed here.
|
|
55
|
+
|
|
56
|
+
Never rounds an intermediate axle load. Both loads are compared to their limits
|
|
57
|
+
by cross-multiplying the same exact fraction (numerator over `2 * (rear - front)`
|
|
58
|
+
as the shared denominator), the same discipline `stack_density_exceeded` and
|
|
59
|
+
`SupportConstraint` already use -- a rounded intermediate could hide a real
|
|
60
|
+
overload or manufacture one that was not there.
|
|
61
|
+
"""
|
|
62
|
+
front, rear = axles
|
|
63
|
+
denominator, numerator_front, numerator_rear = axle_reactions(
|
|
64
|
+
axles, units, tare_weight_ticks, tare_doubled_center_x
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if front.max_load is not None and numerator_front > front.max_load.ticks * denominator:
|
|
68
|
+
return ("axle_overloaded", "front")
|
|
69
|
+
if rear.max_load is not None and numerator_rear > rear.max_load.ticks * denominator:
|
|
70
|
+
return ("axle_overloaded", "rear")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def axle_balanced_origins(
|
|
75
|
+
axles: tuple["Axle", "Axle"],
|
|
76
|
+
other_units: Sequence["LoadUnit"],
|
|
77
|
+
tare_weight_ticks: int,
|
|
78
|
+
tare_doubled_center_x: int,
|
|
79
|
+
item_weight_ticks: int,
|
|
80
|
+
item_length_ticks: int,
|
|
81
|
+
) -> list[int]:
|
|
82
|
+
"""The tightest x-origins that seat this item exactly on either axle's limit.
|
|
83
|
+
|
|
84
|
+
Candidate-point generation (`find_candidates`) only ever proposes positions
|
|
85
|
+
flush against the container wall or another placed box's own corner -- correct
|
|
86
|
+
for plain volume packing, where nothing is ever gained by leaving a gap, but
|
|
87
|
+
incomplete once axle limits are in play: sometimes the only feasible spot for
|
|
88
|
+
an item is floating away from every wall and every other box, specifically to
|
|
89
|
+
keep this item's own moment from tipping one axle over its limit. A floor-level
|
|
90
|
+
item needs no lateral contact for support (see `_support_ratio`), so that
|
|
91
|
+
position is otherwise unreachable by any point this module already generates.
|
|
92
|
+
|
|
93
|
+
Solving `axle_reactions`'s own boundary equation for this item's centre --
|
|
94
|
+
"where would the front/rear reaction land exactly on its limit if this item's
|
|
95
|
+
centre were here" -- turns the two axle limits into two extra x-origins worth
|
|
96
|
+
trying, on top of the ordinary extreme points. Both are exact integer ticks,
|
|
97
|
+
biased toward the safe side of their own limit (never past it) since a
|
|
98
|
+
placement that lands exactly on the boundary is still allowed by
|
|
99
|
+
`axle_load_exceeded`'s own strict `>` comparison; the caller runs every
|
|
100
|
+
candidate through the same collision and constraint checks regardless, so a
|
|
101
|
+
boundary that turns out unreachable (blocked, out of bounds, or infeasible for
|
|
102
|
+
the other axle) is simply rejected same as any other candidate.
|
|
103
|
+
|
|
104
|
+
Never assumes there is room: the caller clamps and discards out-of-range
|
|
105
|
+
results.
|
|
106
|
+
"""
|
|
107
|
+
if item_weight_ticks <= 0:
|
|
108
|
+
return []
|
|
109
|
+
front, rear = axles
|
|
110
|
+
denominator = 2 * (rear.position.ticks - front.position.ticks)
|
|
111
|
+
other_total = tare_weight_ticks + sum(unit.weight_ticks for unit in other_units)
|
|
112
|
+
other_doubled_x = tare_weight_ticks * tare_doubled_center_x + sum(
|
|
113
|
+
unit.weight_ticks * (2 * unit.box.origin.x + unit.box.dimensions.length.ticks)
|
|
114
|
+
for unit in other_units
|
|
115
|
+
)
|
|
116
|
+
total = other_total + item_weight_ticks
|
|
117
|
+
origins: list[int] = []
|
|
118
|
+
if front.max_load is not None:
|
|
119
|
+
# Smallest doubled centre for which numerator_front <= front limit * denominator.
|
|
120
|
+
required = 2 * total * rear.position.ticks - front.max_load.ticks * denominator - other_doubled_x
|
|
121
|
+
doubled_centre = -(-required // item_weight_ticks) # ceil: never understate what front needs
|
|
122
|
+
origins.append(-(-(doubled_centre - item_length_ticks) // 2)) # ceil: stay on the safe side
|
|
123
|
+
if rear.max_load is not None:
|
|
124
|
+
# Largest doubled centre for which numerator_rear <= rear limit * denominator.
|
|
125
|
+
allowed = rear.max_load.ticks * denominator + 2 * total * front.position.ticks - other_doubled_x
|
|
126
|
+
doubled_centre = allowed // item_weight_ticks # floor: never overstate what rear allows
|
|
127
|
+
origins.append((doubled_centre - item_length_ticks) // 2) # floor: stay on the safe side
|
|
128
|
+
return origins
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Sequence
|
|
4
|
+
|
|
5
|
+
from .geometry import Dimensions
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models import Placement
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def centre_of_mass_offset_ppm(inner_dimensions: Dimensions, placements: Sequence[Placement]) -> int:
|
|
12
|
+
"""How far the weighted centre of mass sits from the container's own centre.
|
|
13
|
+
|
|
14
|
+
Reported as parts per million of the half-extent along whichever of the two
|
|
15
|
+
horizontal axes (length, width) is worse -- the Chebyshev, not Euclidean, offset,
|
|
16
|
+
so the result stays exact: a Euclidean distance would need a square root, and
|
|
17
|
+
this library's whole premise is exact fixed-point arithmetic. 0% means
|
|
18
|
+
centred; 100% means the mass sits at the very edge along that axis. Needed for
|
|
19
|
+
axle load and side-to-side balance, both of which care about the worst axis, not
|
|
20
|
+
a single blended number that could hide either one.
|
|
21
|
+
|
|
22
|
+
Every item contributes its own physical centre, weighted by its own mass -- an
|
|
23
|
+
item's clearance envelope is packing buffer, not part of what it weighs, so
|
|
24
|
+
`position`/`dimensions` are used rather than the (possibly larger) envelope.
|
|
25
|
+
|
|
26
|
+
Kept exact throughout via one combined numerator/denominator per axis and a
|
|
27
|
+
single final floor division, rather than rounding an intermediate centroid --
|
|
28
|
+
the same discipline `required_area`/`unused_volume_ppm` already use elsewhere.
|
|
29
|
+
"""
|
|
30
|
+
total_weight = sum(p.instance.weight.ticks for p in placements)
|
|
31
|
+
if total_weight == 0:
|
|
32
|
+
return 0
|
|
33
|
+
length = inner_dimensions.length.ticks
|
|
34
|
+
width = inner_dimensions.width.ticks
|
|
35
|
+
doubled_weighted_x = sum(p.instance.weight.ticks * (2 * p.position.x + p.dimensions.length.ticks) for p in placements)
|
|
36
|
+
doubled_weighted_y = sum(p.instance.weight.ticks * (2 * p.position.y + p.dimensions.width.ticks) for p in placements)
|
|
37
|
+
numerator_x = doubled_weighted_x - total_weight * length
|
|
38
|
+
numerator_y = doubled_weighted_y - total_weight * width
|
|
39
|
+
offset_x_ppm = abs(numerator_x) * 1_000_000 // (total_weight * length)
|
|
40
|
+
offset_y_ppm = abs(numerator_y) * 1_000_000 // (total_weight * width)
|
|
41
|
+
return max(offset_x_ppm, offset_y_ppm)
|
packvium/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._compat import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from .effort import EffortBudget
|
|
7
|
+
from .units import Length
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SolverProfile(str, Enum):
|
|
11
|
+
FAST = "fast"
|
|
12
|
+
BALANCED = "balanced"
|
|
13
|
+
QUALITY = "quality"
|
|
14
|
+
EXACT_SMALL = "exact_small"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class PackingConfig:
|
|
19
|
+
profile: SolverProfile = SolverProfile.BALANCED
|
|
20
|
+
time_limit_ms: int = 1000
|
|
21
|
+
top_k: int = 3
|
|
22
|
+
seed: int = 42
|
|
23
|
+
max_containers: int | None = None
|
|
24
|
+
clearance: Length = Length(0)
|
|
25
|
+
minimum_support_ratio: float = 0.0
|
|
26
|
+
exact_item_limit: int = 7
|
|
27
|
+
multi_start_orders: int = 8
|
|
28
|
+
validate_result: bool = True
|
|
29
|
+
max_candidates_per_item: int = 1
|
|
30
|
+
max_candidate_points: int = 4096
|
|
31
|
+
solvers: tuple[str, ...] = ()
|
|
32
|
+
objective: str = "default"
|
|
33
|
+
effort_budget: EffortBudget | None = None
|
|
34
|
+
#: A carrier's published dimensional-weight divisor. Required only when
|
|
35
|
+
#: `objective` is `"shipping_cost"` -- see `extensions.ShippingCostSolutionScorer`.
|
|
36
|
+
dimensional_weight_divisor: int | None = None
|
|
37
|
+
dimensional_weight_length_unit: str = "in"
|
|
38
|
+
dimensional_weight_weight_unit: str = "lb"
|
|
39
|
+
# Default True reproduces every existing result byte-for-byte. False lets
|
|
40
|
+
# GridSolver's regular-lattice fast path skip materializing one `Placement`
|
|
41
|
+
# object per instance -- see `lattice_summary.py` -- when a caller
|
|
42
|
+
# only needs to know the packing fits, not each item's own coordinates.
|
|
43
|
+
require_placement_coordinates: bool = True
|
|
44
|
+
#: Opt-in worker-process ceiling for the multi-start portfolio. `1`
|
|
45
|
+
#: (the default) reproduces the original fully sequential orchestration
|
|
46
|
+
#: exactly, with zero behavioural change. A value above `1` lets
|
|
47
|
+
#: `SolverOrchestrator` run starts after the first concurrently in separate
|
|
48
|
+
#: processes -- see docs/ALGORITHMS-AND-COMPLEXITY.md's "Multi-start
|
|
49
|
+
#: orchestration" section for the eligibility rules and determinism argument.
|
|
50
|
+
parallel_starts: int = 1
|
|
51
|
+
#: Width of the deterministic beam over partial container plans. The default
|
|
52
|
+
#: keeps the historical greedy path; ``quality()`` opts into the bounded search.
|
|
53
|
+
container_plan_beam_width: int = 1
|
|
54
|
+
#: Hard counted-work ceiling for container-plan nodes, independent of wall time.
|
|
55
|
+
container_plan_node_limit: int = 1
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if (self.time_limit_ms <= 0 or self.top_k <= 0 or self.exact_item_limit <= 0
|
|
59
|
+
or self.multi_start_orders <= 0 or self.max_candidates_per_item <= 0
|
|
60
|
+
or self.parallel_starts <= 0 or self.container_plan_beam_width <= 0
|
|
61
|
+
or self.container_plan_node_limit <= 0):
|
|
62
|
+
raise ValueError("positive configuration values required")
|
|
63
|
+
if self.max_candidate_points < 16:
|
|
64
|
+
raise ValueError("max_candidate_points must be at least 16")
|
|
65
|
+
if not 0 <= self.minimum_support_ratio <= 1: raise ValueError("minimum_support_ratio must be between 0 and 1")
|
|
66
|
+
if self.dimensional_weight_divisor is not None and self.dimensional_weight_divisor <= 0:
|
|
67
|
+
raise ValueError("dimensional_weight_divisor must be positive")
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def fast(cls, time_limit_ms: int = 200, **kwargs) -> "PackingConfig":
|
|
71
|
+
return cls(profile=SolverProfile.FAST, time_limit_ms=time_limit_ms, top_k=1, multi_start_orders=1, **kwargs)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def balanced(cls, time_limit_ms: int = 1000, top_k: int = 3, **kwargs) -> "PackingConfig":
|
|
75
|
+
return cls(profile=SolverProfile.BALANCED, time_limit_ms=time_limit_ms, top_k=top_k, **kwargs)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def quality(cls, time_limit_ms: int = 5000, top_k: int = 5, **kwargs) -> "PackingConfig":
|
|
79
|
+
kwargs.setdefault("container_plan_beam_width", 16)
|
|
80
|
+
kwargs.setdefault("container_plan_node_limit", 100_000)
|
|
81
|
+
return cls(profile=SolverProfile.QUALITY, time_limit_ms=time_limit_ms, top_k=top_k, multi_start_orders=24, max_candidates_per_item=16, **kwargs)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def exact_small(cls, time_limit_ms: int = 10000, **kwargs) -> "PackingConfig":
|
|
85
|
+
return cls(profile=SolverProfile.EXACT_SMALL, time_limit_ms=time_limit_ms, **kwargs)
|