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 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)