packvium 0.1.0__tar.gz

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.
Files changed (64) hide show
  1. packvium-0.1.0/LICENSE +21 -0
  2. packvium-0.1.0/PKG-INFO +108 -0
  3. packvium-0.1.0/README.md +85 -0
  4. packvium-0.1.0/pyproject.toml +37 -0
  5. packvium-0.1.0/setup.cfg +4 -0
  6. packvium-0.1.0/src/packvium/__init__.py +70 -0
  7. packvium-0.1.0/src/packvium/__main__.py +18 -0
  8. packvium-0.1.0/src/packvium/_compat.py +42 -0
  9. packvium-0.1.0/src/packvium/axle_load.py +128 -0
  10. packvium-0.1.0/src/packvium/centre_of_mass.py +41 -0
  11. packvium-0.1.0/src/packvium/config.py +85 -0
  12. packvium-0.1.0/src/packvium/constraints.py +648 -0
  13. packvium-0.1.0/src/packvium/contact.py +124 -0
  14. packvium-0.1.0/src/packvium/effort.py +31 -0
  15. packvium-0.1.0/src/packvium/explain.py +127 -0
  16. packvium-0.1.0/src/packvium/extensions.py +285 -0
  17. packvium-0.1.0/src/packvium/geometry.py +159 -0
  18. packvium-0.1.0/src/packvium/lattice_summary.py +133 -0
  19. packvium-0.1.0/src/packvium/models.py +385 -0
  20. packvium-0.1.0/src/packvium/nested.py +30 -0
  21. packvium-0.1.0/src/packvium/nesting.py +77 -0
  22. packvium-0.1.0/src/packvium/packer.py +146 -0
  23. packvium-0.1.0/src/packvium/packing_sequence.py +675 -0
  24. packvium-0.1.0/src/packvium/policy.py +302 -0
  25. packvium-0.1.0/src/packvium/py.typed +0 -0
  26. packvium-0.1.0/src/packvium/rebalance.py +214 -0
  27. packvium-0.1.0/src/packvium/result.py +284 -0
  28. packvium-0.1.0/src/packvium/serialization.py +202 -0
  29. packvium-0.1.0/src/packvium/solvers.py +2297 -0
  30. packvium-0.1.0/src/packvium/spatial_index.py +93 -0
  31. packvium-0.1.0/src/packvium/support_polygon.py +77 -0
  32. packvium-0.1.0/src/packvium/trace.py +52 -0
  33. packvium-0.1.0/src/packvium/units.py +182 -0
  34. packvium-0.1.0/src/packvium/validation.py +234 -0
  35. packvium-0.1.0/src/packvium.egg-info/PKG-INFO +108 -0
  36. packvium-0.1.0/src/packvium.egg-info/SOURCES.txt +62 -0
  37. packvium-0.1.0/src/packvium.egg-info/dependency_links.txt +1 -0
  38. packvium-0.1.0/src/packvium.egg-info/entry_points.txt +2 -0
  39. packvium-0.1.0/src/packvium.egg-info/top_level.txt +1 -0
  40. packvium-0.1.0/tests/test_axle_load.py +85 -0
  41. packvium-0.1.0/tests/test_centre_of_mass.py +69 -0
  42. packvium-0.1.0/tests/test_cli.py +100 -0
  43. packvium-0.1.0/tests/test_compat.py +52 -0
  44. packvium-0.1.0/tests/test_constraints.py +977 -0
  45. packvium-0.1.0/tests/test_decision_trace.py +120 -0
  46. packvium-0.1.0/tests/test_explain.py +102 -0
  47. packvium-0.1.0/tests/test_extensions.py +260 -0
  48. packvium-0.1.0/tests/test_geometry.py +202 -0
  49. packvium-0.1.0/tests/test_invariants.py +525 -0
  50. packvium-0.1.0/tests/test_models.py +274 -0
  51. packvium-0.1.0/tests/test_nested.py +90 -0
  52. packvium-0.1.0/tests/test_nesting.py +64 -0
  53. packvium-0.1.0/tests/test_objective.py +541 -0
  54. packvium-0.1.0/tests/test_packing.py +795 -0
  55. packvium-0.1.0/tests/test_packing_sequence.py +776 -0
  56. packvium-0.1.0/tests/test_policy.py +335 -0
  57. packvium-0.1.0/tests/test_rebalance.py +236 -0
  58. packvium-0.1.0/tests/test_serialization.py +450 -0
  59. packvium-0.1.0/tests/test_solvers.py +1306 -0
  60. packvium-0.1.0/tests/test_spatial_index.py +115 -0
  61. packvium-0.1.0/tests/test_support_polygon.py +104 -0
  62. packvium-0.1.0/tests/test_units.py +193 -0
  63. packvium-0.1.0/tests/test_unsupported_fields.py +77 -0
  64. packvium-0.1.0/tests/test_validation.py +680 -0
packvium-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Packvium contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: packvium
3
+ Version: 0.1.0
4
+ Summary: Deterministic, extensible 3D cartonization and rectangular bin-packing library
5
+ Author: Packvium contributors
6
+ License-Expression: MIT
7
+ Keywords: 3d-bin-packing,cartonization,packaging,container-loading
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # Packvium for Python
25
+
26
+ Deterministic 3D cartonization and rectangular bin packing. Pure Python, **no runtime
27
+ dependencies**, exact integer geometry.
28
+
29
+ > **Version 0.1.0 — early release.** The public API is not frozen; pin an exact version.
30
+ > Read [docs/GUARANTEES.md](docs/GUARANTEES.md) before relying on a result.
31
+
32
+ ```bash
33
+ pip install packvium
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from packvium import Container, Dimensions, Item, Packer, PackingConfig
40
+
41
+ result = Packer(PackingConfig.balanced()).pack(
42
+ items=[Item.create("book", Dimensions.mm("210", "140", "30"), quantity=4)],
43
+ containers=[Container.create("box", Dimensions.mm("400", "300", "250"))],
44
+ )
45
+
46
+ print(result.status) # feasible
47
+ for container in result.containers:
48
+ for placement in container.placements:
49
+ print(placement.item_id, placement.position, placement.orientation)
50
+ ```
51
+
52
+ Fractional inches are exact, not approximated:
53
+
54
+ ```python
55
+ Dimensions.inches("12 3/8", "8 1/2", "3/4")
56
+ ```
57
+
58
+ There is also a CLI that reads a JSON request on standard input:
59
+
60
+ ```bash
61
+ echo '{"items":[{"id":"box","quantity":8,"dimensions":{"length":"50","width":"50","height":"50"}}],
62
+ "containers":[{"id":"carton","inner_dimensions":{"length":"100","width":"100","height":"100"}}]}' \
63
+ | python -m packvium
64
+ ```
65
+
66
+ ## What it does
67
+
68
+ - **Exact arithmetic.** Length is measured in ticks of 1/16000 mm and weight in 1/8 µg.
69
+ No coordinate is ever a float, so no placement decision depends on rounding.
70
+ - **Real constraints.** Weight and payload limits, permitted rotations, keep-upright,
71
+ floor-only, non-stackable, top-load limits, minimum support ratio, tag incompatibility,
72
+ clearance and rectangular obstacles.
73
+ - **A solver portfolio, not one algorithm.** Regular-grid, layer, extreme-point,
74
+ maximal-space and bounded exact search, selected by problem shape and profile.
75
+ - **Answers you can check.** Every solution is re-validated by logic independent of the
76
+ search. Unplaced items come back with a reason code, not silently missing.
77
+ - **Deterministic.** The same input and seed produce the same result, always.
78
+ - **Multi-container and nested.** Split across containers, or pack containers into
79
+ containers.
80
+ - **Extensible.** Register your own constraints, item orderings, candidate scorers,
81
+ container selectors or complete solvers.
82
+
83
+ ## Documentation
84
+
85
+ | Document | Covers |
86
+ | --- | --- |
87
+ | [docs/GUARANTEES.md](docs/GUARANTEES.md) | What is promised and what is not. Start here. |
88
+ | [docs/PUBLIC-API.md](docs/PUBLIC-API.md) | Inputs, outputs and status semantics. |
89
+ | [docs/UNITS-AND-NUMERICS.md](docs/UNITS-AND-NUMERICS.md) | Units, accepted input forms, rounding policy. |
90
+
91
+ ## Requirements
92
+
93
+ Python 3.10 or newer. No dependencies.
94
+
95
+ ## Other ports exist
96
+
97
+ The same request and result contract is implemented independently in PHP and Rust, and
98
+ all three are held to producing identical placements on a shared fixture set. If your
99
+ stack spans languages, you can compute a packing on any of them and get the same answer.
100
+
101
+ ## Contributing
102
+
103
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports go through the process in
104
+ [SECURITY.md](SECURITY.md), not public issues.
105
+
106
+ ## License
107
+
108
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,85 @@
1
+ # Packvium for Python
2
+
3
+ Deterministic 3D cartonization and rectangular bin packing. Pure Python, **no runtime
4
+ dependencies**, exact integer geometry.
5
+
6
+ > **Version 0.1.0 — early release.** The public API is not frozen; pin an exact version.
7
+ > Read [docs/GUARANTEES.md](docs/GUARANTEES.md) before relying on a result.
8
+
9
+ ```bash
10
+ pip install packvium
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from packvium import Container, Dimensions, Item, Packer, PackingConfig
17
+
18
+ result = Packer(PackingConfig.balanced()).pack(
19
+ items=[Item.create("book", Dimensions.mm("210", "140", "30"), quantity=4)],
20
+ containers=[Container.create("box", Dimensions.mm("400", "300", "250"))],
21
+ )
22
+
23
+ print(result.status) # feasible
24
+ for container in result.containers:
25
+ for placement in container.placements:
26
+ print(placement.item_id, placement.position, placement.orientation)
27
+ ```
28
+
29
+ Fractional inches are exact, not approximated:
30
+
31
+ ```python
32
+ Dimensions.inches("12 3/8", "8 1/2", "3/4")
33
+ ```
34
+
35
+ There is also a CLI that reads a JSON request on standard input:
36
+
37
+ ```bash
38
+ echo '{"items":[{"id":"box","quantity":8,"dimensions":{"length":"50","width":"50","height":"50"}}],
39
+ "containers":[{"id":"carton","inner_dimensions":{"length":"100","width":"100","height":"100"}}]}' \
40
+ | python -m packvium
41
+ ```
42
+
43
+ ## What it does
44
+
45
+ - **Exact arithmetic.** Length is measured in ticks of 1/16000 mm and weight in 1/8 µg.
46
+ No coordinate is ever a float, so no placement decision depends on rounding.
47
+ - **Real constraints.** Weight and payload limits, permitted rotations, keep-upright,
48
+ floor-only, non-stackable, top-load limits, minimum support ratio, tag incompatibility,
49
+ clearance and rectangular obstacles.
50
+ - **A solver portfolio, not one algorithm.** Regular-grid, layer, extreme-point,
51
+ maximal-space and bounded exact search, selected by problem shape and profile.
52
+ - **Answers you can check.** Every solution is re-validated by logic independent of the
53
+ search. Unplaced items come back with a reason code, not silently missing.
54
+ - **Deterministic.** The same input and seed produce the same result, always.
55
+ - **Multi-container and nested.** Split across containers, or pack containers into
56
+ containers.
57
+ - **Extensible.** Register your own constraints, item orderings, candidate scorers,
58
+ container selectors or complete solvers.
59
+
60
+ ## Documentation
61
+
62
+ | Document | Covers |
63
+ | --- | --- |
64
+ | [docs/GUARANTEES.md](docs/GUARANTEES.md) | What is promised and what is not. Start here. |
65
+ | [docs/PUBLIC-API.md](docs/PUBLIC-API.md) | Inputs, outputs and status semantics. |
66
+ | [docs/UNITS-AND-NUMERICS.md](docs/UNITS-AND-NUMERICS.md) | Units, accepted input forms, rounding policy. |
67
+
68
+ ## Requirements
69
+
70
+ Python 3.10 or newer. No dependencies.
71
+
72
+ ## Other ports exist
73
+
74
+ The same request and result contract is implemented independently in PHP and Rust, and
75
+ all three are held to producing identical placements on a shared fixture set. If your
76
+ stack spans languages, you can compute a packing on any of them and get the same answer.
77
+
78
+ ## Contributing
79
+
80
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports go through the process in
81
+ [SECURITY.md](SECURITY.md), not public issues.
82
+
83
+ ## License
84
+
85
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "packvium"
7
+ version = "0.1.0"
8
+ description = "Deterministic, extensible 3D cartonization and rectangular bin-packing library"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{name = "Packvium contributors"}]
13
+ keywords = ["3d-bin-packing", "cartonization", "packaging", "container-loading"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Operating System :: OS Independent",
25
+ "Typing :: Typed",
26
+ ]
27
+
28
+ [project.scripts]
29
+ packvium = "packvium.__main__:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ addopts = "-q"
36
+ testpaths = ["tests"]
37
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]
@@ -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())
@@ -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)
@@ -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)
@@ -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)