diff --git a/.github/DISCUSSION_TEMPLATE/q-a.yml b/.github/DISCUSSION_TEMPLATE/q-a.yml index 5dfb56e..1c94bf5 100644 --- a/.github/DISCUSSION_TEMPLATE/q-a.yml +++ b/.github/DISCUSSION_TEMPLATE/q-a.yml @@ -10,7 +10,7 @@ body: id: package-version attributes: label: Package and version - placeholder: "packvium 1.9.0 (Python)" + placeholder: "packvium 1.10.0 (Python)" validations: required: true - type: textarea diff --git a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml index dfb5b0b..5b71630 100644 --- a/.github/DISCUSSION_TEMPLATE/show-and-tell.yml +++ b/.github/DISCUSSION_TEMPLATE/show-and-tell.yml @@ -16,7 +16,7 @@ body: id: package-version attributes: label: Package and version - placeholder: "packvium 1.9.0 (Python)" + placeholder: "packvium 1.10.0 (Python)" validations: required: false - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f7cc97c..1bad155 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -33,8 +33,8 @@ body: id: version attributes: label: Package version - description: The exact version installed, for example 1.9.0. - placeholder: "1.9.0" + description: The exact version installed, for example 1.10.0. + placeholder: "1.10.0" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 90059b4..def1855 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -29,7 +29,7 @@ body: id: version attributes: label: Package version - placeholder: "1.9.0" + placeholder: "1.10.0" validations: required: true - type: textarea diff --git a/CHANGELOG.md b/CHANGELOG.md index 592537c..70cbf8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,53 @@ What changed in `packvium` on PyPI, release by release. The format follows [Keep a Changelog](https://keepachangelog.com/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.10.0] + +New request fields, a bound under three more objectives, and the intelligence modules join the +frozen API. One stricter refusal: a `units` key other than `length` (see *Changed*). + +### Added + +- **`container.pallet_overhang_limit`** (`OverhangLimit`): a load may pass the deck by the + stated length at each end and width at each side. Only the deck bears weight, and the packed + pallet reports `achieved_dimensions`. +- **Gusseted bags:** `deformable_boundary.kind: "gusseted_bag"` with `side_gusset_depth`; + `Container` gains `side_gusset_depth` and `side_gusset_ticks`. +- **24 rotations for a `convex_hull` item:** 18 signed codes such as `-L-W+H` beside the six; + `Rotation.every()`, `is_signed`, `is_upright`. `Rotation.all()` and `upright()` are unchanged. +- **`placement_id` on every placement**, which `fixed_placements`, revision events, + `FixedPlacement` and `PlacementLock` may quote; `validate` checks it. +- **`currency`** on the request and `rate_table`, echoed on the result; `PackingRequest`, + `Packer.pack` and `RateTable` take it. +- **`optimality.lower_bound` under `maximum_value`, `open_dimension_height` and + `maximum_volume`**, checked by `validate`. +- **`NestedPacker(level_beam_width=K)`** carries the K best carton sets up a level and lets the + outermost level's score choose; 1 (the default) is the greedy chain, answer for answer. +- **A made-to-measure box may keep a `void_fill_reserve_ratio`.** +- **`packvium.revisions.stock_cut_boxes`.** + +### Changed + +- **`units` refuses every key but `length`**: `invalid_request` / `not_allowed` at + `/units/`. 1.9 read a bare weight as grams whatever `units.weight` said. +- **`derive_revision` stocks a box already cut to measure** that holds a locked or verified + placement, as `@#`; `verify_revision_chain` then needs that + revision's artifact and reports `artifact_required` without it. +- **`packvium.simulation`, `recommendations`, `outcomes`, `holdout`, `pareto` and `locks` are + signature-frozen**, like the packing API. + +### Fixed + +- **An explicit rotation list emptied by `keep_upright` is refused at `allowed_rotations`**, + with the same named request error the independent validator checks. +- **Stocking cut boxes indexes artifact lookups once** and reports malformed consumed + machine fields as structured `invalid_revision` errors. +- **`resolve_with_locks` reported a held lock as `missing`** when the re-solve opened the same + containers in another order; a lock from `locks_from_plan` is now matched by `placement_id`. +- **An artifact export refuses an integer past `2^53 - 1`** with `number_out_of_range` + rather than printing it. +- **A compressible item no longer joins a rigid item's grid lattice.** + ## [1.9.0] No API change, but admission is stricter: some requests 1.8 accepted are now refused (see the diff --git a/CITATION.cff b/CITATION.cff index cdab3e8..42f9c9a 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ type: software authors: - name: "Packvium contributors" website: "https://packvium.com" -version: "1.9.0" +version: "1.10.0" license: MIT url: "https://packvium.com" repository-code: "https://github.com/toxakara/packvium-python" diff --git a/README.md b/README.md index bb361f3..2b4bec6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ coordinates and rotations for each item, with a reason for anything that did not Full documentation, the constraint reference and benchmarks live at [packvium.com](https://packvium.com). -> **Version 1.9.0 — the public API is frozen.** Field names, status codes and the +> **Version 1.10.0 — the public API is frozen.** Field names, status codes and the > objective vector do not change without a major version, so any `1.x` is a safe upgrade > from any earlier `1.x`. > Read [docs/GUARANTEES.md](https://github.com/toxakara/packvium-python/blob/main/docs/GUARANTEES.md) before relying on a result. @@ -72,7 +72,7 @@ Item.create("mug", Dimensions.mm("100", "100", "120"), "12 oz") A JSON request refuses a float measure rather than rounding it: send `"160.5"`, not `160.5`. Its `units` sets only the length unit; a weight takes its unit from the measure or is -grams, and `units.weight` is ignored today. See +grams; any other `units` key, `weight` included, is refused. See [docs/UNITS-AND-NUMERICS.md](https://github.com/toxakara/packvium-python/blob/main/docs/UNITS-AND-NUMERICS.md). ## Getting the same answer every time @@ -210,22 +210,22 @@ in `constraints.py` and `limits.py` have failed you. | File | What it shows | | --- | --- | | [`basic.py`](https://github.com/toxakara/packvium-python/blob/main/examples/basic.py) | The smallest useful call: items in, placements out — and the three details in it that are easy to miss. | -| [`objectives.py`](https://github.com/toxakara/packvium-python/blob/main/examples/objectives.py) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree, including the rate card that makes the heavier shipment the cheaper one. | -| [`constraints.py`](https://github.com/toxakara/packvium-python/blob/main/examples/constraints.py) | Upright-only, floor-only, non-stackable, top-load limits, and tags that keep two items out of the same box — plus how to read the reason an item was refused. | +| [`objectives.py`](https://github.com/toxakara/packvium-python/blob/main/examples/objectives.py) | Six of the seven objectives (all but `maximum_volume`) on scenes where they genuinely disagree, including the rate card that makes the heavier shipment the cheaper one, a rate card's `currency`, and the lower bound that proves the value left behind is the least possible. | +| [`constraints.py`](https://github.com/toxakara/packvium-python/blob/main/examples/constraints.py) | Upright-only, floor-only, non-stackable, top-load limits, and tags that keep two items out of the same box — plus how to read the reason an item was refused, and a crate allowed past the pallet deck by `pallet_overhang_limit`. | | [`limits.py`](https://github.com/toxakara/packvium-python/blob/main/examples/limits.py) | A courier fleet: a van with a fridge unit and wheel arches as obstacles, an item cap per bike, one dangerous-goods item per van, frozen goods only in a refrigerated vehicle, and a cap on vehicles. | | [`units.py`](https://github.com/toxakara/packvium-python/blob/main/examples/units.py) | Why there are no floats anywhere: fractional inches, exact ticks, and the one-tick difference between a fit and a refusal. | | [`serialization.py`](https://github.com/toxakara/packvium-python/blob/main/examples/serialization.py) | The same request as JSON, the result in full, and exactly which mistakes are refused and which are silently ignored. | | [`errors.py`](https://github.com/toxakara/packvium-python/blob/main/examples/errors.py) | A web handler: refused requests turned into a 422 with a pointer and a message a person can act on, fixed placements that cannot hold, and a partial fit that is not an error. | | [`reproducibility.py`](https://github.com/toxakara/packvium-python/blob/main/examples/reproducibility.py) | Why a clock-limited search answers differently on a busy machine, how an effort budget fixes it, and how to read `termination`. | | [`trucking.py`](https://github.com/toxakara/packvium-python/blob/main/examples/trucking.py) | A three-stop delivery van within its axle ratings: loading and unloading order, what the driver can reach at each stop, and the same goods packed without a route. | -| [`fixed_placements.py`](https://github.com/toxakara/packvium-python/blob/main/examples/fixed_placements.py) | A trailer that arrives partly loaded: new pallets packed around the ones already on board, and a record of what is on board that cannot be true. | +| [`fixed_placements.py`](https://github.com/toxakara/packvium-python/blob/main/examples/fixed_placements.py) | A trailer that arrives partly loaded: new pallets packed around the ones already on board, a record of what is on board that cannot be true, and a loaded pallet fixed by quoting its `placement_id`. | | [`rebalancing.py`](https://github.com/toxakara/packvium-python/blob/main/examples/rebalancing.py) | Two pallets of very different weight evened out after packing, one validated move at a time. | -| [`shapes.py`](https://github.com/toxakara/packvium-python/blob/main/examples/shapes.py) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, and a cushion that compresses under load until the crush limit refuses it. | -| [`nested.py`](https://github.com/toxakara/packvium-python/blob/main/examples/nested.py) | Units into cartons, cartons onto a pallet, in one call. | +| [`shapes.py`](https://github.com/toxakara/packvium-python/blob/main/examples/shapes.py) | Items that are not their box: complementary wedges sharing one crate as `convex_hull`, two copies of one wedge that need a signed rotation such as `-L-W+H`, a cushion that compresses under load until the crush limit refuses it, and a gusseted bag that holds a layer more than a flat one. | +| [`nested.py`](https://github.com/toxakara/packvium-python/blob/main/examples/nested.py) | Units into cartons, cartons onto a pallet, in one call, and `level_beam_width` letting the pallet pick a dearer carton set that packs better. | | [`commerce.py`](https://github.com/toxakara/packvium-python/blob/main/examples/commerce.py) | Rate a shipment, apply an eligibility rule, and pin a catalog version. | | [`execution.py`](https://github.com/toxakara/packvium-python/blob/main/examples/execution.py) | Turn a result into dock instructions: solver facts kept apart from screen text, a step order that is injected or honestly absent, and an operator lock that yields a second plan rather than editing the approved one. | | [`artifacts.py`](https://github.com/toxakara/packvium-python/blob/main/examples/artifacts.py) | Hand a result to a system with no engine: one document with the plan, geometry and the request that produced it, exported as CSV and a printable work order, with an honest replay level. | -| [`revisions.py`](https://github.com/toxakara/packvium-python/blob/main/examples/revisions.py) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, and a hash-chained record that notices an edit. | +| [`revisions.py`](https://github.com/toxakara/packvium-python/blob/main/examples/revisions.py) | Replan a half-loaded job: a missing item and a locked placement recorded against the approved plan, a replan that keeps the locked item in place, a hash-chained record that notices an edit, and a box already cut to measure (with a void-fill reserve) stocked for the replan. | | [`intelligence.py`](https://github.com/toxakara/packvium-python/blob/main/examples/intelligence.py) | Prove a carton change is worth publishing: two scenarios compared order by order, a proposal that refuses to exist on thin evidence, and a replay against held-out history where a cheaper packing your validator rejects still counts as a regression. | | [`extensions.py`](https://github.com/toxakara/packvium-python/blob/main/examples/extensions.py) | A rule the schema has no field for — and an honest account of what you give up by writing one. | @@ -245,20 +245,24 @@ instead: `PYTHONPATH=src python3 examples/trucking.py`. - **Real constraints.** Weight and payload limits, permitted rotations, keep-upright, floor-only, non-stackable, top-load limits, minimum support ratio, tag incompatibility, eligible container tags, per-container item and tag limits, clearance, obstacles, two-axle - load limits and multi-stop route order. + load limits, multi-stop route order, pallet overhang past the deck, and flat or gusseted + bags. - **A solver portfolio, not one algorithm.** Regular-grid, layer, extreme-point, maximal-space and bounded exact search, selected by problem shape and profile. - **Answers you can check.** Every solution is re-validated by logic independent of the - search. Unplaced items come back with a reason code, not silently missing. -- **A proof, or the distance to one.** Under the `default` and `lowest_cost` objectives every - result states `optimality.lower_bound`. A complete result that attains it reports `optimal`, + search. Unplaced items come back with a reason code, not silently missing. Every placement + carries a `placement_id` that a fixed placement, a revision or a lock can quote. +- **A proof, or the distance to one.** Under the `default`, `lowest_cost`, `maximum_value`, + `open_dimension_height` and `maximum_volume` objectives every result states + `optimality.lower_bound`. A complete result that attains it reports `optimal`, which proves no packing obeying the request's rules scores better; any other carries the gap to the bound (`gap_key`, `absolute_gap`, `relative_gap`). - **Deterministic when you ask for it.** The same request with an `effort_budget` gives the same result on every run and machine. A search stopped by `time_limit_ms` keeps what it reached, which can differ between runs; `termination` says which happened. - **Multi-container and nested.** Split across containers, or pack containers into - containers. `rebalance_weight` evens out the payload across containers afterwards. + containers; `NestedPacker(level_beam_width=K)` carries the K best carton sets up a level, + so the pallet decides between them. `rebalance_weight` evens out the payload across containers afterwards. - **Extensible.** Register your own constraints, item orderings, candidate scorers, container selectors or complete solvers. - **Work orders, not just coordinates.** `packvium.execution` turns a validated result @@ -279,7 +283,7 @@ instead: `PYTHONPATH=src python3 examples/trucking.py`. - **Decide before you publish.** `packvium.simulation` and `packvium.recommendations` compare two catalog scenarios order by order and propose a change only when the paired cohort supports it; `packvium.holdout` replays that proposal against history it has not - seen. Python-only, and supported but not yet signature-frozen — see + seen. Python-only, and signature-frozen since 1.10.0 like the packing surface — see [PUBLIC-API.md](https://github.com/toxakara/packvium-python/blob/main/docs/PUBLIC-API.md). ## Documentation @@ -310,7 +314,7 @@ Documentation, the constraint reference and the benchmarks are at | --- | --- | --- | | Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) | | PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) | -| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.9"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | +| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "1.10"` | [packvium-rust](https://github.com/toxakara/packvium-rust) | | Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) | | Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) | | PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) | @@ -332,7 +336,7 @@ CodeMeta form. @software{packvium_python, author = {{Packvium contributors}}, title = {Packvium for Python}, - version = {1.9.0}, + version = {1.10.0}, license = {MIT}, url = {https://packvium.com} } diff --git a/codemeta.json b/codemeta.json index 743104f..ee02588 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,7 +3,7 @@ "@type": "SoftwareSourceCode", "name": "Packvium for Python", "description": "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results.", - "version": "1.9.0", + "version": "1.10.0", "license": "https://spdx.org/licenses/MIT", "author": [ { diff --git a/docs/GUARANTEES.md b/docs/GUARANTEES.md index 0d40bf9..f0b05e9 100644 --- a/docs/GUARANTEES.md +++ b/docs/GUARANTEES.md @@ -61,7 +61,7 @@ silently — if you need them, they belong in your own layer above this library. ## Status of this release -Version `1.9.0` freezes the public API. Field names, status codes, the objective +Version `1.10.0` freezes the public API. Field names, status codes, the objective vector, the numeric policy and the validation rules do not change without a major version, so any `1.x` is a safe upgrade from any earlier `1.x`. A caret or tilde constraint on `1.0` is enough; an exact pin is no longer required. diff --git a/docs/PUBLIC-API.md b/docs/PUBLIC-API.md index 7eb418e..c0f98e6 100644 --- a/docs/PUBLIC-API.md +++ b/docs/PUBLIC-API.md @@ -32,6 +32,15 @@ pack validly but less densely; any such difference is pinned in `conformance/native-quality-budget.json` rather than left to drift. + **24 rotations for a hull (1.10).** `allowed_rotations` and a placement's `orientation` + take 24 codes: the six legacy codes, unchanged and still the default list, then 18 signed + codes such as `-L-W+H` -- per output axis a sign and the declared side -- that only a + `convex_hull` may list (elsewhere `invalid_request` / `invalid_value` at + `/items//allowed_rotations/`). `keep_upright` keeps the codes whose z entry is `+H`. + Python's `Rotation` enum gains the 18 members in canonical table order (`NL_NW_PH = + "-L-W+H"`, ...), `Rotation.every()` for all 24 and the `is_signed` / `is_upright` + properties; `Rotation.all()` and `Rotation.upright()` keep returning the legacy six and two. + Where they are implemented: a `convex_hull` item's collisions are decided by an exact integer separating-axis test rather than by its box, and its occupied volume is the hull's own — two complementary wedges share a crate that fits one of their bounding boxes. A @@ -54,7 +63,8 @@ statics, rejected as a constraint rather than merely reported), and an optional `rate_table` carrier tariff (ascending `weight_brackets_g` paired with `prices_minor`, an optional `minimum_charge_minor` floor and an optional - `fuel_surcharge_permille`). The rate card is request data rather than a registered + `fuel_surcharge_permille`, and since 1.10 an optional `currency`, which must equal the + request's). The rate card is request data rather than a registered scorer precisely so all four engines can be held to the same price; only the `lowest_landed_cost` objective reads it, and a request that omits it behaves exactly as it did before the field existed. @@ -75,6 +85,14 @@ `billedDimensions()`, Rust `Container::deformable_boundary: Option` with `PackedContainer::achieved_dimensions()` / `billed_dimensions()`, and the TypeScript request type. +- **Gusseted bags (1.10).** `deformable_boundary: {"kind": "gusseted_bag", "side_gusset_depth": G}` + is a flat bag whose side gussets open to depth `G`, a length measure in the request's units, + required for this kind, positive and at most `inner_dimensions.width`; a `flat_bag` refuses the + key as an unknown field. The width girth becomes `b + c <= W + G`; the frame (`b <= W`), the + length girth, billing on `achieved_dimensions`, the refused fields, the solver fallbacks and + the validator issue `bag_girth_exceeded` are the flat bag's. Bottom gussets are not modelled. + Python's `Container` keeps `deformable_boundary: str` (`"gusseted_bag"`) and gains a trailing + `side_gusset_depth: Length | None = None`, refused unless the kind is `gusseted_bag`. - **Made-to-measure containers.** `made_to_measure` (absent or `null`: a stock container) makes the container an on-demand box machine whose largest box is `inner_dimensions`: `minimum` and `increment` per axis (required; `minimum` equal to @@ -87,12 +105,33 @@ and `tare_weight` (declared tare plus the board, rounded up the same way), both inside every objective and `gross_weight`; `achieved_dimensions` reports the extent before rounding. `outer_dimensions` or `deformable_boundary` next to it is `invalid_request` / `not_allowed`; - `axles`, non-empty `obstacles`, a positive `void_fill_reserve_ratio` or a fixed placement in - the container is `unsupported_feature`; a largest box priced above `2^53 - 1` is + `axles`, non-empty `obstacles` or a fixed placement in the container is + `unsupported_feature`. A positive `void_fill_reserve_ratio` is kept in the box that ships: when + the box cut tight to the load would break it, the box is the solving envelope instead. + A largest box priced above `2^53 - 1` is `invalid_value`. The engines expose it as Python `Container.made_to_measure` (a `packvium.MadeToMeasure`, last field), PHP the trailing constructor parameter `?MadeToMeasure $madeToMeasure` (`Packvium\Domain\MadeToMeasure`), Rust `Container::made_to_measure: Option`, and the TypeScript request type. +- **Pallet overhang.** `pallet_overhang_limit: {length, width}` (both required, + non-negative lengths, no default; zero forbids overhang) lets a load pass the deck, the + container's `inner_dimensions`, by `length` at each end and `width` at each side. Positions + are measured from the corner of the overhang zone, so the deck lies at `[length, length + L]` + by `[width, width + W]`, fixed placements included. Only the deck bears weight: a floor + placement's `support_ratio` is the share of its base on the deck, held to + `minimum_support_ratio` and the centre-of-gravity rule like any support. A load the deck + holds is never overhung; one that needs the overhang is seated with the least of it. The + packed pallet reports its declared `inner_dimensions` and `outer_dimensions` (else inner), + `achieved_dimensions` -- the deck grown by the overhang used, at the load's height -- and its + `volume_utilization` against the widened frame; it is billed, and handed to an outer nesting + level, at the larger of its declared footprint and `achieved_dimensions` per horizontal axis + and its declared height. Beside non-empty `access_directions`, `axles`, `made_to_measure`, + non-empty `obstacles`, a positive `void_fill_reserve_ratio` or any item with a `stop_index` + it is `unsupported_feature` (`container.pallet_overhang_limit+`); beside + `deformable_boundary` it is `invalid_request` / `not_allowed`; a frame past the admission + ceiling is `above_maximum`. Python `Container.pallet_overhang_limit` (a + `packvium.OverhangLimit`, last field); the object APIs refuse the same combinations as + `not_allowed`. - `PackingConfig`: solver profile, deadline, alternatives, deterministic seed, clearance, support threshold (`minimum_support_ratio`, default 0: support is not required, and an item may rest on nothing above the floor; set it above 0 to require that share of the @@ -135,7 +174,18 @@ search with `invalid_fixed_placement`. Python `Packer.pack(items, containers, fixed_placements)` and PHP `Packer::pack($items, $containers, $fixedPlacements)` take `FixedPlacement` values; Rust takes them in `PackingRequest.fixed_placements`, and every - JSON entry point reads the field; all four engines implement it. + JSON entry point reads the field; all four engines implement it. Since 1.10 an entry may + restate the result placement's `placement_id`, the canonical JSON of + `["#", x, y, z, "", ""]` with the + position in ticks; any other string is refused as `invalid_fixed_placement` / `invalid_value` at + `/fixed_placements//placement_id`, and `null` is absent. Python's `FixedPlacement` takes it + as a trailing `placement_id: str | None = None`. +- `currency` (1.10): optional ISO 4217 code, three capital letters, that every money + field of the request is in. It is echoed by the result and never priced, converted or + ranked by; a `rate_table.currency` must equal it. Python takes it as the trailing + `currency=None` of `Packer.pack(items, containers, fixed_placements, currency)` and of + `PackingRequest`, and `RateTable` gains a trailing `currency=None`; the object path refuses + it with the JSON path's reason and field. - `catalog_versions_used`: optional immutable catalog references resolved before packing. Each reference has exactly `catalog_id`, positive `version`, non-negative `published_at_epoch_ms`, and `content_digest`; catalog IDs are unique. @@ -163,7 +213,8 @@ request field. extension point remains open: closing the object would make a previously valid producer fail a validator, which is a breaking change. Every engine emits these fields since, on the primary result and on each alternative, for the `default` and - `lowest_cost` objectives; a complete result that attains its bound reports `status: + `lowest_cost` objectives, and since 1.10.0 also for `maximum_value`, + `open_dimension_height` and `maximum_volume`; a complete result that attains its bound reports `status: optimal` and `code: proven_optimal`, and nothing else does; - legacy `status`: `optimal`, `feasible`, `best_found`, `time_limit`, `infeasible`, or `invalid_result`; - packed containers with exact coordinates, dimensions, rotations, and a @@ -179,6 +230,14 @@ request field. - objective score — a five-key lexicographic vector of exact integers, lower is better, computed by the same formula in every implementation — and solver statistics. Under the default objective the keys are `unpacked_count`, `container_count`, `total_cost_minor`, `unused_volume_ppm` (per container `floor((V - used) x 10^6 / V)` of inner volume `V`) and `stack_height_ppm` (per container `floor(top x 10^6 / H)` of inner height `H`); `configuration.objective` selects one of seven orderings (`default`, `lowest_cost`, `shipping_cost`, `lowest_landed_cost`, `open_dimension_height`, `maximum_value`, `maximum_volume`), and the result echoes it in `objective`; - the exact `catalog_versions_used` lineage from the request (an empty array when no catalog was involved), preserved by Python, PHP, Rust and JavaScript; +- a `placement_id` on every placement of the answer and every alternative (1.10): + the canonical JSON of `[container id, x, y, z, item_type, orientation]` with the position in + ticks, for example `["truck#1",0,0,1600000,"crate","LWH"]`, a pure function of the placement; + `validate` reports a missing or wrong one as `placement_id_mismatch` and a repeat as + `duplicate_placement_id`; +- the request's `currency`, present exactly when the request declares one, on the answer and + every alternative (1.10); `validate` reports a missing, extra or different one as + `currency_mismatch`; - warnings and top-K alternatives. With `minimum_support_ratio` at its default 0, a result with a placement resting on nothing above the floor carries one `unsupported_placements: N placement(s) rest on nothing above the container floor; ...` @@ -246,7 +305,10 @@ Metrics are diagnostics: they never participate in feasibility, ranking or the objective vector. `algorithm.time_limit_reached` and `algorithm.effort_limit_reached` identify distinct, -mutually exclusive causes. The former is only a wall-clock deadline. The latter means +mutually exclusive causes. The former is only a wall-clock deadline, read when the start +ends: it says the deadline had passed by then, not that the clock cut work, so a start that +finished its search just as the clock passed reads it too, in every engine, as +`global_deadline_reached`, which requires it, does. The latter means the selected start was stopped by one of the request's counted-work boundaries: the search had more work to do and the budget refused it. A start that finished having used exactly its budget was not stopped, and reports neither flag. No counter in `algorithm.metrics` ends @@ -645,7 +707,10 @@ otherwise refuse**, and the strongest thing it can do is reserve its own slot. A solve cannot honour comes back on `LockedResolve` as unpreserved and names the lock — it is an outcome, not an exception — while a lock set that contradicts itself, by overlapping or by naming an item the request does not contain, is refused with `LockSetError` before any -solve runs. +solve runs. Since 1.10 a lock read by `locks_from_plan` carries its reference's +`placement_id` (`PlacementLock` gains a trailing `placement_id: str | None = None`), and +preservation is checked by it rather than by `container_index`, the emitted container order +a re-solve need not repeat. Python only, and deliberately so: a lock has no representation in the request schema, since that field is held for the next contract freeze, so there is nothing to hand another engine. @@ -702,14 +767,15 @@ approved operational artifact and its events, and calls no solver or clock. | Language | Surface | | --- | --- | -| Python | `packvium.revisions.root_revision(request)`, `.derive_revision(parent, approved_artifact, events)`, `.verify_revision_chain(revisions, artifacts=None)`, `.document_digest(document)`, `.canonical_revision_json(document)`; `packvium.revision_outcomes.record_revision(ledger, revision, decision_id, recorded_at)` | +| Python | `packvium.revisions.root_revision(request)`, `.derive_revision(parent, approved_artifact, events)`, `.apply_events(request, events)`, `.stock_cut_boxes(request, approved_artifact, revision)`, `.verify_revision_chain(revisions, artifacts=None)`, `.document_digest(document)`, `.canonical_revision_json(document)`; `packvium.revision_outcomes.record_revision(ledger, revision, decision_id, recorded_at)` | | Rust | `packvium_core::revisions::root_revision_json(request)`, `derive_revision_json(parent, artifact, events)`, `apply_events_json(request, events)`, `verify_revision_chain_json(revisions, artifacts)`, `document_digest_json(document)`, `canonical_revision_json(document)`; SHA-256 from the `sha2` crate, so the WASM package hashes too | | JavaScript | `rootRevision`, `deriveRevision`, `applyEvents`, `verifyRevisionChain`, `documentDigest`, `canonicalRevisionJson` from `@packvium/engine`'s `revisions.js`; SHA-256 from `node:crypto` | | PHP | `Packvium\Revisions\PlanRevision::root($request)`, `::derive($parent, $approvedArtifact, $events)`, `::verifyChain($revisions, $artifacts)`, `::digest($document)`, `::canonicalJson($document)`, `::parse($json)`. Documents are `stdClass` trees, so `{}` and `[]` keep their digests | Digests are SHA-256 over the RFC 8785 canonical bytes. The audit reports `revision_number`, -`parent_mismatch`, `sequence_gap`, `request_mismatch` and `artifact_mismatch` without -stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_conflict`, +`parent_mismatch`, `sequence_gap`, `request_mismatch`, `artifact_mismatch` and +`artifact_required` (a revision that stocks a made-to-measure box, audited without its +artifact) without stopping at the first. Refusals use `invalid_revision`, `invalid_event`, `event_conflict`, `invalid_artifact` and the canonical-form codes. ## Scenario and recommendation API @@ -746,12 +812,10 @@ credited to the treatment. paired cohort is too small. Neither writes to a registry: publishing goes only through `approve_catalog` / `approve_policy`, and a proposal is handed no registry to write to. -**Supported, and not yet frozen.** This surface and `packvium.locks` are reached by -importing the submodule, which puts them outside the frozen API snapshot the packing and -commerce surfaces are held to. They work, they are tested, and they will not be withdrawn -inside 1.x — but a parameter may be renamed or a returned value gain a field in a minor -release, announced in the changelog rather than blocked by a gate. Pin the version if you -depend on the exact shape. +**Frozen since 1.10.0.** The six modules `packvium.simulation`, `packvium.recommendations`, +`packvium.outcomes`, `packvium.holdout`, `packvium.pareto` and `packvium.locks` each declare +`__all__` and are held to the API snapshot, as the packing and commerce surfaces are. From 1.2.0 +to 1.9.0 they were supported but provisional, so a signature could change in a minor release. ## JSON API @@ -762,7 +826,6 @@ Python, PHP, Rust and the JavaScript fallback accept the same top-level keys: `u `units` holds one key, `length`: the unit of every length written without one, millimetres when `units`, or `units.length`, is absent or `null`. There is no request-wide weight unit. A weight takes its unit from the measure itself (`"2.5 kg"`, `{"value": "2.5", "unit": "kg"}`), and a -bare number is grams. `units.weight` is not a field: today every engine ignores it without an -error, so `{"length": "cm", "weight": "kg"}` still reads a bare weight `2` as 2 g. Write the unit -on each weight instead. A future release that changes the request schema will refuse unknown -keys in `units`, as it already refuses them in `configuration`. +bare number is grams. `units` is closed: since 1.10.0 a key other than `length` is refused as +`not_allowed` at `/units/` (docs/ERRORS.md), so `{"length": "cm", "weight": "kg"}`, which +used to read a bare weight `2` as 2 g without a word, is an error. Write the unit on each weight. diff --git a/examples/constraints.py b/examples/constraints.py index 78ac8b4..88c7dba 100644 --- a/examples/constraints.py +++ b/examples/constraints.py @@ -17,6 +17,7 @@ Dimensions, Item, Length, + OverhangLimit, Packer, PackingConfig, Rotation, @@ -207,3 +208,32 @@ def compare(rule: str, without: list[Item], with_rule: list[Item], containers: l # Every reason code above is a fact about the request, not a solver failure -- which is # why `explain_unpacked_item` can turn it into a sentence a customer is allowed to read. + + +# ------------------------------------------------------------ a load past the deck +# +# A pallet is not a box: goods may stand a little past its deck edge, and logistics +# practice allows a stated amount. `pallet_overhang_limit` says how much -- `length` at each +# end and `width` at each side. The crate below is 50 mm longer and 50 mm wider than the +# euro pallet's deck, so without a limit it fits nowhere; with 50 mm allowed it overhangs +# 25 mm all round. +# +# Only the deck bears weight, so the crate's `support_ratio` is the share of its base on +# the deck, and a `minimum_support_ratio` still applies to it. Coordinates are measured from +# the corner of the overhang zone, so the deck starts at (50, 50) here, and the packed +# pallet reports `achieved_dimensions` -- the deck grown by the overhang actually used -- +# beside its declared ones. A load the deck holds is never overhung. +print("\npallet_overhang_limit") +oversized = [Item.create("crate", Dimensions.mm("1250", "850", "400"), "10 kg")] +for label, limit in (("no limit", None), ("50 mm each way", OverhangLimit(Length.mm("50"), Length.mm("50")))): + pallet = Container.create("euro", Dimensions.mm("1200", "800", "1000"), max_payload="1000 kg", + pallet_overhang_limit=limit) + outcome = Packer(PackingConfig.balanced(time_limit_ms=SAFETY_FUSE_MS, minimum_support_ratio=0.75)).pack( + oversized, [pallet]) + print(f" {label + ':':16s} {len(outcome.containers)} pallet(s), {len(outcome.unpacked)} refused") + for packed in outcome.to_dict()["containers"]: + achieved = packed["achieved_dimensions"] + placement = packed["placements"][0] + print(f" achieved {achieved['length']['value']} x {achieved['width']['value']} mm," + f" crate at ({placement['position']['x']['value']}, {placement['position']['y']['value']})," + f" support_ratio {placement['support_ratio']}") diff --git a/examples/execution.py b/examples/execution.py index d221708..9333870 100644 --- a/examples/execution.py +++ b/examples/execution.py @@ -148,8 +148,8 @@ print(" It does not follow that four engines packing the same *request* agree. Python") print(" and PHP are held to identical placements and do produce this exact form; Rust") print(" and JavaScript are held to a valid answer at or above the objective floor, and") -print(" `examples/execution.mjs` prints 1280 bytes here rather than 1877 for that") -print(" reason. Whose packing is better is a question the plan never answers.") +print(" `examples/execution.mjs` prints another byte count here for that reason.") +print(" Whose packing is better is a question the plan never answers.") print() @@ -161,9 +161,11 @@ locks = locks_from_plan(plan, container_index=0, item_types=["printer"]) lock = locks[0] print(f" locked: {lock.item_type} {lock.orientation} at {lock.position_ticks}") +print(f" by id: {lock.placement_id}") print(" The lock is read out of the plan's own placement reference. That is the only") print(" reason the two can be trusted to mean the same box: there is no second address") -print(" format to drift.") +print(" format to drift. It is checked by `placement_id` alone, so a re-solve that opens") +print(" the same containers in another order still finds it.") print() resolved = resolve_with_locks(REQUEST, locks) diff --git a/examples/fixed_placements.py b/examples/fixed_placements.py index 225f4bc..bb66e8a 100644 --- a/examples/fixed_placements.py +++ b/examples/fixed_placements.py @@ -114,3 +114,33 @@ def section(title: str) -> None: Nothing is solved when the starting point is impossible: a plan built on a collision or an overweight trailer would be wrong before the first pallet moved. `cannot_hold` names the fixed set as a whole; the detail says what is wrong.""") + +# -------------------------------------------------------------------------------------- +section("4. A store pallet is loaded, and the next plan quotes it by its id") + +first_plan = solve() +loaded = next(p for p in first_plan["containers"][0]["placements"] if not p.get("fixed")) +print(f" placement_id: {loaded['placement_id']}") + + +def quote_loaded(placement_id: str) -> dict: + """The loaded pallet as a fixed placement: the result's own fields, and its id.""" + return {"item_type": loaded["item_type"], "container_type": "trailer", + "position": {axis: loaded["position"][axis]["value"] for axis in ("x", "y", "z")}, + "orientation": loaded["orientation"], "placement_id": placement_id} + + +replanned = solve(lambda r: r["fixed_placements"].append(quote_loaded(loaded["placement_id"]))) +kept = [p["placement_id"] for p in replanned["containers"][0]["placements"] if p.get("fixed")] +print(f" the replan keeps it: {loaded['placement_id'] in kept}") +try: + solve(lambda r: r["fixed_placements"].append( + quote_loaded(loaded["placement_id"].replace(loaded["orientation"], "LWH")))) +except FixedPlacementError as refusal: + print(f" an id that names another orientation: {refusal.reason}, {refusal.field}") +print(""" + Every placement carries `placement_id`, the canonical JSON of its container id, + position in ticks, item type and orientation, so it is unique within a result and + any engine computes the same one. A fixed placement may quote it; it must then + name exactly the box the other fields describe, so a copy-paste slip is refused + rather than packed around.""") diff --git a/examples/nested.py b/examples/nested.py index ee43ce6..d372794 100644 --- a/examples/nested.py +++ b/examples/nested.py @@ -12,8 +12,9 @@ The levels stay independent on purpose. Level two does not reach back and repack level one to get a better pallet, because that would make the carton contents depend on the -pallet, and a carton you already taped shut cannot be repacked. If you want that -trade-off explored, run the packer yourself with different carton sets and compare. +pallet, and a carton you already taped shut cannot be repacked. To explore that +trade-off, `NestedPacker(level_beam_width=3)` carries the three best carton sets up a +level and keeps the one whose pallet scores best. """ from packvium import ( @@ -92,3 +93,19 @@ cartons = len(result.levels[0].containers) pallets = len(result.levels[-1].containers) print(f"{cartons} carton(s) travelling on {pallets} pallet(s)") + +# The run above is greedy: level one keeps its own best carton set and level two packs +# whatever it was handed. `level_beam_width=K` carries the K best carton sets up instead, +# and the pallet's score -- the outermost level's -- picks the winner. A width of 1 is the +# greedy chain, answer for answer. +print() +for width in (1, 3): + chain = NestedPacker(level_beam_width=width).pack(items, levels) + carton_level, pallet_level = chain.levels + cartons_used = ", ".join(packed.container.id for packed in carton_level.containers) + print(f"level_beam_width={width}: cartons {cartons_used} " + f"(cost {carton_level.score[2]}), pallet unused volume {pallet_level.score[3]} ppm") + +# Under a width of 3 the carton level is dearer on its own -- two large boxes instead of a +# large and a small one -- and it wins anyway, because the pallet built from those cartons +# wastes less space. That is the trade the greedy chain cannot see. diff --git a/examples/objectives.py b/examples/objectives.py index b79bb20..0f3e888 100644 --- a/examples/objectives.py +++ b/examples/objectives.py @@ -14,7 +14,7 @@ Ratios are parts per million. """ -from packvium import Container, Dimensions, Item, Packer, PackingConfig +from packvium import Container, Dimensions, InvalidRequestError, Item, Packer, PackingConfig from packvium.models import RateTable, UnratedWeightError #: An example must not change answer merely because the host was busy. These solves need @@ -101,6 +101,21 @@ def solve(config: PackingConfig, containers) -> tuple[str, tuple[int, ...]]: except UnratedWeightError as refusal: print("landed_cost* ", "refused:", refusal) +# A price means nothing without its currency, so a rate card may say which one it is in, +# and the request must then say the same: the result echoes it as `currency`. The engine +# never converts and never ranks by it -- it refuses a card priced in another currency +# rather than add euros to dollars. +in_euros = Container.create( + "roomy", Dimensions.mm("400", "400", "400"), max_payload="20 kg", + rate_table=RateTable(weight_brackets_g=(6_000, 20_000), prices_minor=(900, 1_500), currency="EUR"), +) +quoted = Packer(by_money).pack(WIDGETS, [in_euros], currency="EUR") +print("landed_cost EUR ", quoted.score[1], quoted.currency) +try: + Packer(by_money).pack(WIDGETS, [in_euros], currency="USD") +except InvalidRequestError as refusal: + print("landed_cost USD ", "refused:", refusal.reason, refusal.field) + # --------------------------------------------------------------------------------- # `open_dimension_height` -- pack into the shortest stack. For a container with no lid, # or a pallet whose height you are trying to keep under a doorway. @@ -109,8 +124,8 @@ def solve(config: PackingConfig, containers) -> tuple[str, tuple[int, ...]]: # --------------------------------------------------------------------------------- # `maximum_value` -- when not everything fits, leave the *cheap* things behind. Ranked by -# value forgone, after unpacked count. Note the honest limitation: this orders by value, -# it does not solve the knapsack problem to optimality. +# value forgone, after unpacked count. The search orders by value; it does not solve the +# knapsack problem, so the result says how far from the best it might be (below). # --------------------------------------------------------------------------------- # `quantity=1` is what makes this a choice at all: with an unlimited supply of boxes the # packer simply opens a second one and nothing is left behind. @@ -123,3 +138,11 @@ def solve(config: PackingConfig, containers) -> tuple[str, tuple[int, ...]]: kept = sorted(p.instance.item.id for c in result.containers for p in c.placements) left = sorted(u.instance.item.id for u in result.unpacked) print("maximum_value ", "packed:", kept, "left behind:", left) + +# Every objective above except the two weight-priced ones states a lower bound on its score: +# no packing that obeys the request can score below it. Here the bound equals the score, so +# no packing could leave less value behind. The status stays `best_found` rather than +# `optimal` only because two items were left out; a result short of its bound carries the +# gap instead (`gap_key`, `absolute_gap`, `relative_gap`). +print("maximum_value ", result.optimality.code, "bound:", result.optimality.to_dict()["lower_bound"], + "score:", list(result.score)) diff --git a/examples/revisions.py b/examples/revisions.py index 0880b1a..2316d3a 100644 --- a/examples/revisions.py +++ b/examples/revisions.py @@ -77,7 +77,10 @@ def codes(issues: list[RevisionIssue]) -> str: {"sequence": 1, "type": "placement_locked", "placement": { "item_type": "cube", "container_type": "tote", "container_instance": 1, "position": {axis: loaded["position"][axis]["value"] for axis in ("x", "y", "z")}, - "orientation": loaded["orientation"]}}, + "orientation": loaded["orientation"], + # Optional: the result's own id for this box. Quoting it ties the lock to exactly + # the placement the dock saw; an id that disagrees with the fields is refused. + "placement_id": loaded["placement_id"]}}, {"sequence": 2, "type": "item_missing", "item_type": "slab", "quantity": 1}, ] revision = derive_revision(root, approved, events) @@ -123,3 +126,54 @@ def codes(issues: list[RevisionIssue]) -> str: An edited request no longer equals what its parent's request and its own events produce, and any later revision would stop naming it as a parent. An event that contradicts the request is refused by name rather than applied as a best guess.""") + +# -------------------------------------------------------------------------------------- +section("5. A box cut to measure is already cut: the replan stocks it") + +MACHINE = { + "id": "machine", "quantity": 3, "cost_minor": 40, "tare_weight": "50", + "inner_dimensions": {"length": "610", "width": "610", "height": "610"}, + "made_to_measure": {"minimum": {"length": "60", "width": "60", "height": "60"}, + "increment": {"length": "25", "width": "25", "height": "25"}, + "cost_per_square_metre_minor": 1000, "tare_per_square_metre": "500", + "outer_allowance": {"length": "3", "width": "3", "height": "3"}}, + # A quarter of the box that ships is left for void fill. Cut tight to the two cubes the + # box would be nearly full, so the machine cuts a larger one instead. + "void_fill_reserve_ratio": 0.25, +} +CUT = {**REQUEST, "items": [{**REQUEST["items"][0], "quantity": 2}], "containers": [MACHINE]} + + +def cut_size(plan: dict) -> str: + sides = plan["containers"][0]["inner_dimensions"] + return " x ".join(sides[axis]["value"] for axis in ("length", "width", "height")) + " mm" + + +tight = {**CUT, "containers": [{key: value for key, value in MACHINE.items() if key != "void_fill_reserve_ratio"}]} +print(f" cut with no reserve: {cut_size(pack_from_dict(copy.deepcopy(tight)))}") + +cut_root = root_revision(CUT) +cut_plan = pack_from_dict(copy.deepcopy(CUT)) +cut_artifact = build_operational_artifact(CUT, cut_plan) +box = cut_plan["containers"][0] +print(f" cut with a 25% reserve: {cut_size(cut_plan)}, {box['made_to_measure']['cost_minor']} minor units") + +cube = box["placements"][0] +lock = {"sequence": 1, "type": "placement_locked", "placement": { + "item_type": "cube", "container_type": "machine", "container_instance": 1, + "position": {axis: cube["position"][axis]["value"] for axis in ("x", "y", "z")}, + "orientation": cube["orientation"]}} +stocked = derive_revision(cut_root, cut_artifact, [lock]) +for container in stocked["request"]["containers"]: + print(f" container {container['id']}: quantity {container['quantity']}," + f" {'cut to measure' if 'made_to_measure' in container else 'stock, cost ' + str(container['cost_minor'])}") +print(f" the cube is locked in: {stocked['request']['fixed_placements'][0]['container_type']}") +print(f" audit without the artifact: {codes(verify_revision_chain([cut_root, stocked]))}") +print(f" audit with it: {codes(verify_revision_chain([cut_root, stocked], [None, cut_artifact]))}") +print(""" + A cube locked in a box the machine already cut cannot ask for a new cut: the box + exists. The derived request turns it into a stock container named + `@#`, at the size, price and tare the approved artifact + records, takes it from the machine's quantity, and fixes the cube inside it. New + boxes are still cut to measure. The size comes from the artifact, so the chain can + only be checked with it; without it the audit says `artifact_required`.""") diff --git a/examples/serialization.py b/examples/serialization.py index f5fe8f7..17b52b3 100644 --- a/examples/serialization.py +++ b/examples/serialization.py @@ -135,12 +135,14 @@ # And a field the schema reserves but this engine has not implemented yet is refused # by name, so a request written for a newer engine fails loudly instead of being -# half-honoured. The list is the engine's own constant. +# half-honoured. The list is the engine's own constant, empty while it is caught up; a +# combination of implemented fields that has no defined meaning is refused the same way. print("fields this engine refuses by name:", {scope: fields for scope, fields in UNSUPPORTED_FIELDS.items() if fields} or "none") ahead = json.loads(json.dumps(request)) ahead["containers"][0]["pallet_overhang_limit"] = {"length": "50", "width": "50"} +ahead["containers"][0]["axles"] = [{"position": "0"}, {"position": "100"}] try: pack_from_dict(ahead) except UnsupportedFeatureError as refusal: diff --git a/examples/shapes.py b/examples/shapes.py index ab17e17..e88cdd8 100644 --- a/examples/shapes.py +++ b/examples/shapes.py @@ -14,7 +14,10 @@ packed a hull as its bounding box would return a plan that validates and does not physically fit, so the value must be asked for. -Both are written here through `pack_from_dict`, the request contract the four engines +The last section is a container rather than an item: a bag whose sides fold out +(`gusseted_bag`) holds a thicker load than a flat one of the same width. + +All of it is written here through `pack_from_dict`, the request contract the four engines share. That is deliberate: the shape fields are part of the JSON contract, so the same request runs unchanged against the Python, PHP, Rust and JavaScript engines. """ @@ -23,7 +26,7 @@ # package supports 3.9, so the annotation is deferred rather than evaluated. from __future__ import annotations -from packvium import pack_from_dict +from packvium import Rotation, pack_from_dict def summarise(label: str, request: dict) -> None: @@ -149,3 +152,62 @@ def load(label: str, kilograms: int) -> None: # exactly -- a hull on a route, a hull under a configured clearance, a compressible item # with `nesting_height`. A wrong answer that validates is worse than a refusal that does # not, which is the whole reason these are opt-in. + + +# ------------------------------------------------------- 24 rotations for a hull +# +# The two wedges above are mirror images of each other, so the six rotations a box knows +# -- each one a permutation of the axes -- can turn the lower wedge into the upper one. +# Two *copies of the same wedge* are a different problem: the second copy must be turned +# half a revolution about the vertical axis to meet the first along the diagonal, and that +# is a signed rotation, `-L-W+H`, which no permutation of the axes reaches. +# +# A `convex_hull` item may list all 24 proper rotations of a cube. The six legacy codes keep +# their names and stay the default list, so a request that names no signed code is answered +# exactly as before; a signed code on anything but a hull is refused. + +def two_copies(rotations: list[str] | None) -> dict: + item = wedge("wedge", LOWER_WEDGE) + item["quantity"] = 2 + if rotations is not None: + item["allowed_rotations"] = rotations + return {**MM, "items": [item], "containers": crate("100", "100", "100")} + + +def orientations(request: dict) -> str: + result = pack_from_dict({**request, **SAFETY_FUSE}) + return ", ".join(placement["orientation"] + for container in result["containers"] + for placement in container["placements"]) + + +EVERY_ROTATION = [rotation.value for rotation in Rotation.every()] + +print("\nconvex_hull -- two copies of one wedge") +summarise("six rotations", two_copies(None)) +summarise("all 24 rotations", two_copies(EVERY_ROTATION)) +print(f" {'orientations used':22s} {orientations(two_copies(EVERY_ROTATION))}") + + +# ---------------------------------------------------------------- gusseted bags +# +# A flat bag (`deformable_boundary.kind: flat_bag`) is a pouch with no sides: whatever +# goes in pushes its two faces apart, and the bag's width pays for that thickness. Its +# rule is `b + c <= W` -- the load's width plus its thickness within the bag's flat width. +# A gusseted bag folds extra material into its sides, `side_gusset_depth` of it, so the same +# rule reads `b + c <= W + G`. Everything else about the bag is the flat bag's. +# +# The books are 100 mm wide and 20 mm thick, and the pouch is 150 mm wide. Flat, it holds +# 50 mm of thickness, so two books; a 25 mm gusset lets it hold 75 mm, so three. + +def pouch(boundary: dict) -> dict: + return {**MM, + "items": [{"id": "book", "quantity": 4, "weight": "300 g", "allowed_rotations": ["LWH"], + "dimensions": {"length": "200", "width": "100", "height": "20"}}], + "containers": [{"id": "pouch", "quantity": 1, "deformable_boundary": boundary, + "inner_dimensions": {"length": "300", "width": "150", "height": "100"}}]} + + +print("\ndeformable_boundary -- one pouch, flat and gusseted") +summarise("flat_bag", pouch({"kind": "flat_bag"})) +summarise("gusseted_bag, 25 mm", pouch({"kind": "gusseted_bag", "side_gusset_depth": "25"})) diff --git a/pyproject.toml b/pyproject.toml index 6bcc82d..0b768eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "packvium" -version = "1.9.0" +version = "1.10.0" description = "Deterministic 3D bin-packing and cartonization engine for pallets, containers, trucks and shipping cartons. Exact integer geometry, reproducible results." readme = "README.md" requires-python = ">=3.9" diff --git a/src/packvium/__init__.py b/src/packvium/__init__.py index b8a1688..6a7e0ad 100644 --- a/src/packvium/__init__.py +++ b/src/packvium/__init__.py @@ -16,6 +16,7 @@ from .models import (Axle, Container, FixedPlacement, Item, ItemInstance, Obstacle, PackedContainer, PackingRequest, Placement, ReasonProof, RejectionObservation, UnpackedItem) from .made_to_measure import MadeToMeasure +from .pallet_overhang import OverhangLimit from .nested import NestedPacker, NestedPackingResult, PackingLevel from .packer import Packer from .packing_sequence import ( @@ -60,7 +61,7 @@ "EffortBudget", "ExtensionRegistry", "FixedPlacement", "FixedPlacementError", "InvalidRequestError", "PolicyError", "UnknownObjectiveError", "UnknownSolverError", "UnsupportedFeatureError", "IndependentSolutionValidator", "InvalidDirectionError", "Issue", "Item", "ItemInstance", "LEVEL_PREFIXES", "Length", - "LoadingDependencyGraph", "MadeToMeasure", "MalformedResult", "NestedPacker", + "LoadingDependencyGraph", "MadeToMeasure", "MalformedResult", "NestedPacker", "OverhangLimit", "NestedPackingResult", "Obstacle", "PackedContainer", "Packer", "PackingConfig", "PackingLevel", "PackingRequest", "PackingResult", "PackingStatus", "Placement", "Point", "REASON_MESSAGES", "Reachability", "ReasonProof", "RebalanceResult", "RejectionObservation", "ResultFact", "Rotation", diff --git a/src/packvium/_canonical_json.py b/src/packvium/_canonical_json.py index b923b46..df04d8b 100644 --- a/src/packvium/_canonical_json.py +++ b/src/packvium/_canonical_json.py @@ -44,6 +44,22 @@ def canonical_json(value: Any) -> str: return "".join(parts) +def canonical_identifier(values: List[Any]) -> str: + """`canonical_json` of `values`, except that a lone UTF-16 surrogate in a string is written + as its lowercase `\\udxxx` escape, as ECMAScript's `JSON.stringify` writes it, instead of + refused. For an identifier built from caller-chosen ids (`placement_id`, 1.10): a request + may name a container that way, and an answer must still name its placements. Every string + PHP and Rust can hold has no lone surrogate, so for those this is `canonical_json` exactly.""" + return "[" + ",".join(_identifier_part(value) for value in values) + "]" + + +def _identifier_part(value: Any) -> str: + if not isinstance(value, str): + return canonical_json(value) + return '"' + "".join(f"\\u{ord(c):04x}" if 0xD800 <= ord(c) <= 0xDFFF else _string(c)[1:-1] + for c in value) + '"' + + def json_integer(value: Any) -> Optional[int]: """The integer a JSON value is, judged by value as every engine can judge it. diff --git a/src/packvium/artifact_exports.py b/src/packvium/artifact_exports.py index 208d7c2..9727992 100644 --- a/src/packvium/artifact_exports.py +++ b/src/packvium/artifact_exports.py @@ -19,6 +19,7 @@ import json from typing import Any, Iterable, List, Mapping, Optional, Sequence +from ._canonical_json import MAX_EXACT_MAGNITUDE from .artifacts import FORMAT, OperationalArtifactError, canonical_artifact_json __all__ = ["CSV_COLUMNS", "export_csv", "export_json", "export_work_order_html"] @@ -183,6 +184,9 @@ def _text(value: Any) -> str: if isinstance(value, str): return value if isinstance(value, int) and not isinstance(value, bool): + # Beyond 2^53 - 1 JavaScript has already rounded the integer, so its decimal would differ. + if abs(value) > MAX_EXACT_MAGNITUDE: + raise OperationalArtifactError("number_out_of_range", f"{value} is beyond what every engine holds exactly") return str(value) raise OperationalArtifactError("invalid_value", f"a {type(value).__name__} has no single rendering in a work order") @@ -192,7 +196,7 @@ def _ordinal(index: Any) -> str: `0.5 + 1` would print `1.5` in one engine and be refused in another.""" if isinstance(index, bool) or not isinstance(index, int): raise OperationalArtifactError("invalid_value", f"container index {index!r} is not an integer") - return str(index + 1) + return _text(index + 1) def _escape(value: Any) -> str: diff --git a/src/packvium/artifacts.py b/src/packvium/artifacts.py index 6e5f967..2291e23 100644 --- a/src/packvium/artifacts.py +++ b/src/packvium/artifacts.py @@ -33,7 +33,7 @@ #: The suite version of this builder, the same string in all four engines of one release. #: The engine's own name is deliberately not recorded: four correct builders naming themselves #: would emit four different documents. `make version-set` rewrites this line. -SUITE_VERSION = "1.9.0" +SUITE_VERSION = "1.10.0" #: The deterministic part of `result.algorithm`. `duration_ms` is wall-clock time and never #: enters an artifact. @@ -72,7 +72,7 @@ def build_operational_artifact( raise OperationalArtifactError("invalid_result", "a result is a JSON object") _require_objects(result) try: - plan = build_execution_plan(request, result, loading_orders=loading_orders) + plan = build_execution_plan(request, _without_placement_ids(result), loading_orders=loading_orders) except ExecutionPlanError as error: raise OperationalArtifactError("invalid_plan_input", str(error)) from error @@ -167,6 +167,25 @@ def _replay(solver: Optional[Mapping[str, Any]]) -> Dict[str, Any]: return {"level": "exact", "because": None} +def _without_placement_ids(result: Mapping[str, Any]) -> Mapping[str, Any]: + """The result as the v1 artifact reads it: every placement without its 1.10 `placement_id`. + + v1 is closed, and its embedded plan and placement references predate the id, so a plan + built from a 1.10 result would otherwise gain a field inside a closed document. The id is + recoverable from what v1 does carry: the canonical JSON of the result's + `containers[container_index].id` and the reference's `position_ticks`, `item_type` and + `orientation` (docs/OPERATIONAL-ARTIFACTS.md). Read after `_require_objects`, so + every entry is an object; copies only what it changes, O(P).""" + containers = _list(result.get("containers"), "result.containers") + return {**result, "containers": [ + {**container, "placements": [ + {key: value for key, value in placement.items() if key != "placement_id"} + for placement in _placements(index, container) + ]} + for index, container in enumerate(containers) + ]} + + # -------------------------------------------------------------------------------- geometry diff --git a/src/packvium/bounds.py b/src/packvium/bounds.py index f5712b3..7c5b6ea 100644 --- a/src/packvium/bounds.py +++ b/src/packvium/bounds.py @@ -43,9 +43,10 @@ Objective key order. `compute` returns the bounds in the *default* objective's order, `(unpacked_count, container_count, total_cost_minor, unused_volume_ppm, stack_height_ppm)`. `for_request` returns them in the order of the objective a result is scored with: -`lowest_cost` reads `(L0, L2, L1, L3, L4)`, which the document shows is still a bound, and -every other objective gets none, because a vector compared against a score that ranks other -quantities means nothing. +`lowest_cost` reads `(L0, L2, L1, L3, L4)`, which the document shows is still a bound; +`maximum_value`, `open_dimension_height` and `maximum_volume` each add one term for the key +only they rank by; `shipping_cost` and `lowest_landed_cost` get none, because a +vector compared against a score that ranks other quantities means nothing. Which instances. The bound is a property of the request, so it is taken over every instance the request declares, fixed placements included. A bound over the free instances @@ -59,6 +60,7 @@ from __future__ import annotations +import heapq from typing import Dict, Iterable, Sequence, Tuple from ._compat import dataclass @@ -89,11 +91,15 @@ MAX_BOUND_VALUE = 2 ** 53 - 1 -#: The objectives a bound is stated for, and where each reads the five bounds: the position -#: in `compute`'s default-order vector of each of the objective's own score keys. -OBJECTIVE_ORDERS = { +#: The objectives a bound is stated for, and where each reads its bounds: the position in +#: `compute`'s default-order vector of each of the objective's own score keys, or the name of +#: the one key only that objective ranks by (docs/OPTIMALITY-GAP.md § 1.4). +OBJECTIVE_ORDERS: Dict[str, Tuple["int | str", ...]] = { "default": (0, 1, 2, 3, 4), "lowest_cost": (0, 2, 1, 3, 4), + "maximum_value": (0, "value_forgone", 1, 2, 3), + "open_dimension_height": (0, "achieved_height", 1, 2, 3), + "maximum_volume": ("unpacked_volume_ppm", 0, 1, 2, 3), } @@ -269,16 +275,89 @@ def for_request(instances: Sequence[ItemInstance], containers: Sequence[Containe A function of the request alone, computed independently of the solver and deadline. Emitting its prefix also checks the result's score and the portable gap ceiling. """ - order = OBJECTIVE_ORDERS.get(objective) - if order is None: + if objective not in OBJECTIVE_ORDERS: return None if any(container.made_to_measure is not None for container in containers): return None try: - vector = compute(instances, containers).as_tuple() + return objective_vector(instances, containers, objective) except BoundOverflowError: return None - return tuple(vector[position] for position in order) + + +def objective_vector(instances: Sequence[ItemInstance], containers: Sequence[Container], + objective: str) -> Tuple[int, ...]: + """The bound in a covered `objective`'s score order, raising `BoundOverflowError` where a + ceiling withholds it -- the predicate's or the objective's own term's.""" + vector = compute(instances, containers).as_tuple() + return tuple(vector[position] if isinstance(position, int) + else _objective_term(position, instances, containers, vector[0]) + for position in OBJECTIVE_ORDERS[objective]) + + +def _objective_term(name: str, instances: Sequence[ItemInstance], containers: Sequence[Container], + unpacked: int) -> int: + """The bound on the one key an objective ranks by and the default does not. + + Each is conditional on the keys before it, as `compute`'s are: the value and height terms + hold for every packing that leaves exactly `unpacked` instances behind, and the volume term + leads its objective and holds for every packing. + + No term sorts: `compute` already paid for the only sorts (finding F6 doubled that cost + once), and each term needs at most the `unpacked` extreme values, which a heap selects in + `O(n log L0)`. With `O(c)` over the container types, the request bound stays + `O(n log n + c log c)`. + """ + if name == "value_forgone": + return _value_forgone_bound(instances, unpacked) + volumes = [instance.item.dimensions.volume for instance in instances] + nests = any(_occupies_less_than_its_box(instance.item) for instance in instances) + if name == "achieved_height": + areas = [container.inner_dimensions.base_area for container in containers] + return _achieved_height_bound(volumes, nests, unpacked, areas) + usable = [usable_volume(container) for container in containers] + return _unpacked_volume_bound(volumes, nests, usable, [c.quantity for c in containers]) + + +def _value_forgone_bound(instances: Sequence[ItemInstance], unpacked: int) -> int: + """`maximum_value`'s second key: whichever `unpacked` instances are left behind, their + value is at least that of the `unpacked` least valuable. Values are non-negative, so the + running sum only grows, and it is refused as soon as it passes the portable ceiling.""" + total = 0 + for value in heapq.nsmallest(unpacked, (instance.item.value or 0 for instance in instances)): + total = _guard_output(total + value, "value forgone") + return total + + +def _achieved_height_bound(volumes, nests, unpacked, areas) -> int: + """`open_dimension_height`'s second key, the summed tops in ticks. + + A container holding volume `v` on floor `A` stands at least `v / A` tall, because its + placements are disjoint boxes inside `A × top`. Summed over the opened containers that is + at least the placed volume over the widest floor, and the score is an integer. The placed + volume is at least everything but the `unpacked` largest. An instance that occupies less + than its box breaks the disjoint-boxes step, so the term is dropped. + """ + floors = [area for area in areas if area > 0] + if nests or unpacked >= len(volumes) or not floors: + return 0 + placed = sum(volumes) - sum(heapq.nlargest(unpacked, volumes)) + return _guard_output(_ceil_div(placed, max(floors)), "achieved height") + + +def _unpacked_volume_bound(volumes, nests, usable, quantities) -> int: + """`maximum_volume`'s first key: the volume no inventory can hold, as the score's own + rounded-up share of the requested volume. + + The packed volume is at most the summed usable capacity, so at least the rest is left + behind whatever the geometry does. Unlimited inventory holds everything, and an instance + that occupies less than its box stops nominal volumes summing, so both give 0. + """ + requested = sum(volumes) + capacity = _capacity_total(usable, quantities, unbounded_when_value_infinite=False) + if nests or requested == 0 or capacity is None: + return 0 + return _ceil_div(max(0, requested - capacity) * PPM, requested) def compute(instances: Sequence[ItemInstance], containers: Sequence[Container]) -> Bounds: diff --git a/src/packvium/constraints.py b/src/packvium/constraints.py index 9544b79..8f407d4 100644 --- a/src/packvium/constraints.py +++ b/src/packvium/constraints.py @@ -8,6 +8,7 @@ from .geometry import (ALL_DIRECTIONS, AxisAlignedBox, Dimensions, InvalidDirectionError, Point, Rotation, sweep_intersects, swept_volume) from .models import Container, Item, ItemInstance, Placement +from .pallet_overhang import deck_contact_area from .support_polygon import contact_hull_points, convex_hull, doubled_centroid, point_in_hull from .compression import applied_pressure from .units import Length, Weight @@ -749,7 +750,9 @@ def applies_to(self, item: ItemInstance) -> bool: return rule not in (None, "free") or required > 0 def evaluate(self, context: ConstraintContext) -> ConstraintResult: - if context.point.z == 0 or not self.applies_to(context.item): + if context.point.z == 0: + return self._on_floor(context) + if not self.applies_to(context.item): return ConstraintResult.allow() candidate = context.envelope_box support = direct_support_view(context.placements, context.item, candidate) @@ -762,10 +765,24 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: return ConstraintResult.reject("ground_contact_violation", f"single: rests on {len(supporters)} item(s)") if rule == "multiple" and len(supporters) < 2: return ConstraintResult.reject("ground_contact_violation", f"multiple: rests on {len(supporters)} item(s)") + return self._ratio_and_centroid(context, candidate, supporters, support.supporting_area) + + def _on_floor(self, context: ConstraintContext) -> ConstraintResult: + """A floor is whole support unless it is a pallet deck: then the deck is + the one supporter, judged by the same ratio and centre-of-gravity rule. The ground + contact rules name supporting items, and the floor is none, so they do not apply.""" + deck = context.container.deck + if deck is None: + return ConstraintResult.allow() + candidate = context.envelope_box + return self._ratio_and_centroid(context, candidate, [deck.as_box()], + deck_contact_area(deck, candidate)) + + def _ratio_and_centroid(self, context: ConstraintContext, candidate: AxisAlignedBox, + supporters: list[AxisAlignedBox], supporting_area: int) -> ConstraintResult: required = max(self.global_minimum, context.item.item.minimum_support_ratio) if required <= 0: return ConstraintResult.allow() - supporting_area = support.supporting_area base_area = context.envelope_dimensions.base_area if supporting_area < required_area(base_area, scaled_ratio(required)): return ConstraintResult.reject("insufficient_support", f"{supporting_area}/{base_area} < {required:.6f}") @@ -804,13 +821,42 @@ class PlacedSupportBase: refers to it. """ - __slots__ = ("_placements", "_base", "_base_units", "_hint") + __slots__ = ( + "_placements", + "_base", + "_base_units", + "_hint", + "_base_loads", + "_base_stacked_counts", + "_base_has_max_top", + "_base_has_crush", + "_base_has_max_stacked", + "_base_bearing_holds", + "_base_density_checked", + "_base_density_holds", + "_base_stops", + "_base_routed", + "_base_max_stop_above", + "_base_route_holds", + ) def __init__(self) -> None: self._placements: tuple[Placement, ...] | None = None self._base: LoadSupportGraph | None = None self._base_units: tuple[LoadUnit, ...] = () self._hint = 1 + self._base_loads: list[int] | None = None + self._base_stacked_counts: list[int] | None = None + self._base_has_max_top = False + self._base_has_crush = False + self._base_has_max_stacked = False + self._base_bearing_holds: bool | None = None + self._base_density_checked: int | None = None + self._base_density_holds: bool | None = None + self._base_stops: list[float] | None = None + self._base_routed = False + self._base_max_stop_above: list[float] | None = None + self._base_route_holds: bool | None = None def _base_for(self, placements: tuple[Placement, ...], footprint: int): """The support graph over `placements` alone, rebuilt only when it cannot serve. @@ -823,42 +869,238 @@ def _base_for(self, placements: tuple[Placement, ...], footprint: int): coarse, and a cell far larger than the boxes collapses the spatial hash back into the all-pairs scan it exists to avoid. """ - if self._placements is placements and footprint <= self._hint: + same = type(placements) is tuple and self._placements is placements + if same and footprint <= self._hint: return self._base, self._base_units - hint = max(self._hint if self._placements is placements else 1, footprint) - units = load_units(placements) + hint = max(self._hint if same else 1, footprint) + units = self._base_units if same else load_units(placements) self._placements = placements self._hint = hint self._base_units = units self._base = LoadSupportGraph(units, cell_hint=hint) + if not same: + self._base_loads = None + self._base_stacked_counts = None + self._base_bearing_holds = None + self._base_density_checked = None + self._base_density_holds = None + self._base_stops = None + self._base_routed = False + self._base_max_stop_above = None + self._base_route_holds = None return self._base, units - def with_candidate(self, placements: tuple[Placement, ...], - unit: LoadUnit) -> tuple[tuple[LoadUnit, ...], LoadSupportGraph]: - """`placements`' units and graph with `unit` appended at the last index.""" - box = unit.box - footprint = max(box.x2 - box.origin.x, box.y2 - box.origin.y) - base, base_units = self._base_for(placements, footprint) - return base_units + (unit,), base.with_unit(unit, cell_hint=self._hint) - - -class TopLoadConstraint: - """Rejects a placement that would rest on something unable to carry it. - - Bearing limits are checked against the cumulative load of the whole stack, not - only the box directly underneath, so a tower of light items cannot crush its base. - The graph comes from a `PlacedSupportBase`, so it is not rebuilt per candidate. - """ - - __slots__ = ("_support",) - - def __init__(self) -> None: - self._support = PlacedSupportBase() + def _settle_bearing(self, density_limit: int | None) -> bool: + """Settle base loads, stacked counts and base validity once per placement state.""" + assert self._base is not None + base_units = self._base_units + if self._base_bearing_holds is None: + self._base_has_max_top = any(u.max_top_load_ticks is not None for u in base_units) + self._base_has_crush = any(u.max_compression_pressure_kpa is not None for u in base_units) + self._base_has_max_stacked = any(u.max_stacked_items is not None for u in base_units) + loads = top_loads(base_units, self._base) + self._base_loads = loads + holds = True + if self._base_has_max_top: + for u, load in zip(base_units, loads): + if u.max_top_load_ticks is not None and load > u.max_top_load_ticks: + holds = False + break + if holds and self._base_has_crush: + for u, load in zip(base_units, loads): + limit = u.max_compression_pressure_kpa + if limit is None: + continue + fp = u.footprint_ticks2 if u.footprint_ticks2 is not None else (u.box.x2 - u.box.origin.x) * (u.box.y2 - u.box.origin.y) + if applied_pressure(Weight(load), fp).exceeds_kpa(limit): + holds = False + break + if self._base_has_max_stacked: + counts = stacked_counts(base_units, self._base) + self._base_stacked_counts = counts + if holds: + for u, count in zip(base_units, counts): + if u.max_stacked_items is not None and count > u.max_stacked_items: + holds = False + break + self._base_bearing_holds = holds + if not self._base_bearing_holds: + return False + if density_limit is None: + return True + if self._base_density_checked != density_limit or self._base_density_holds is None: + assert self._base_loads is not None + self._base_density_checked = density_limit + self._base_density_holds = all( + (u.weight_ticks + load) * SQUARE_METRE_TICKS <= density_limit * u.box.dimensions.base_area + for u, load in zip(base_units, self._base_loads) + ) + return self._base_density_holds - def evaluate(self, context: ConstraintContext) -> ConstraintResult: - if not context.stack_sensitive: return ConstraintResult.allow() + def evaluate_top_load(self, context: ConstraintContext) -> ConstraintResult: + """Evaluate bearing and stacking limits against the settled base without cloning the graph.""" candidate = context.envelope_box item = context.item.item + footprint = max(candidate.x2 - candidate.origin.x, candidate.y2 - candidate.origin.y) + base, base_units = self._base_for(context.placements, footprint) + density_limit = None if context.container.max_stack_density is None else context.container.max_stack_density.ticks + if not base._nested and item.nesting_height is None and self._settle_bearing(density_limit): + below = base._face.contacts_below(candidate) + above = base._face.contacts_above(candidate) + upper_group = item.stacking_group + placements = context.placements + for edge in below: + lower = placements[edge.index].instance.item + code = stacking_refusal(lower, upper_group) + if code is not None: + return ConstraintResult.reject(code, lower.id) + if not item.stackable or item.accepts_stacking_groups: + for edge in above: + code = stacking_refusal(item, placements[edge.index].instance.item.stacking_group) + if code is not None: + return ConstraintResult.reject(code, item.id) + has_max_top = self._base_has_max_top or item.max_top_load is not None + has_crush = self._base_has_crush or item.max_compression_pressure_kpa is not None + has_max_stacked = self._base_has_max_stacked or item.max_stacked_items is not None + if not (has_max_top or has_crush or has_max_stacked or density_limit is not None): + return ConstraintResult.allow() + if not below and not above: + if density_limit is not None and context.item.weight.ticks * SQUARE_METRE_TICKS > density_limit * candidate.dimensions.base_area: + return ConstraintResult.reject("stack_density_exceeded", context.item.id) + return ConstraintResult.allow() + if not above: + base_supporters = base._face._supporters + base_loads = self._base_loads + assert base_loads is not None + # Fast path when every direct supporter rests on the floor (no further + # downward edges): `below` is already in ascending index order, so load + # shares and stacked counts can be checked in a single pass with zero + # set/dict/sort allocations. + if all(not base_supporters[edge.index] for edge in below): + downward = context.item.weight.ticks + total_area = sum(edge.area for edge in below) + assigned = 0 + last_pos = len(below) - 1 + shares: list[int] = [] + for pos, edge in enumerate(below): + if total_area > 0 and downward > 0: + share = downward - assigned if pos == last_pos else downward * edge.area // total_area + assigned += share + else: + share = 0 + shares.append(share) + if self._base_has_max_top: + for pos, edge in enumerate(below): + u = edge.index + limit_ticks = base_units[u].max_top_load_ticks + if limit_ticks is not None and base_loads[u] + shares[pos] > limit_ticks: + return ConstraintResult.reject("top_load_exceeded", base_units[u].label) + if self._base_has_crush: + for pos, edge in enumerate(below): + unit_u = base_units[edge.index] + limit_kpa = unit_u.max_compression_pressure_kpa + if limit_kpa is None: + continue + fp = unit_u.footprint_ticks2 if unit_u.footprint_ticks2 is not None else (unit_u.box.x2 - unit_u.box.origin.x) * (unit_u.box.y2 - unit_u.box.origin.y) + if applied_pressure(Weight(base_loads[edge.index] + shares[pos]), fp).exceeds_kpa(limit_kpa): + return ConstraintResult.reject("crush_violation", unit_u.label) + if self._base_has_max_stacked: + assert self._base_stacked_counts is not None + for edge in below: + u = edge.index + max_stacked = base_units[u].max_stacked_items + if max_stacked is not None and self._base_stacked_counts[u] + 1 > max_stacked: + return ConstraintResult.reject("stacked_item_limit_exceeded", base_units[u].label) + if density_limit is not None: + for pos, edge in enumerate(below): + u = edge.index + unit_u = base_units[u] + if (unit_u.weight_ticks + base_loads[u] + shares[pos]) * SQUARE_METRE_TICKS > density_limit * unit_u.box.dimensions.base_area: + return ConstraintResult.reject("stack_density_exceeded", unit_u.label) + if downward * SQUARE_METRE_TICKS > density_limit * candidate.dimensions.base_area: + return ConstraintResult.reject("stack_density_exceeded", context.item.id) + return ConstraintResult.allow() + + pending = [edge.index for edge in below] + seen_affected: set[int] = set(pending) + idx = 0 + while idx < len(pending): + u = pending[idx] + idx += 1 + for edge in base_supporters[u]: + if edge.index not in seen_affected: + seen_affected.add(edge.index) + pending.append(edge.index) + delta_loads: dict[int, int] = {} + if has_max_top or has_crush or density_limit is not None: + downward = context.item.weight.ticks + total_area = sum(edge.area for edge in below) + if total_area > 0 and downward > 0: + assigned = 0 + last_pos = len(below) - 1 + for pos, edge in enumerate(below): + share = downward - assigned if pos == last_pos else downward * edge.area // total_area + assigned += share + delta_loads[edge.index] = delta_loads.get(edge.index, 0) + share + order = sorted( + seen_affected, + key=lambda u: (-base_units[u].box.z2, -base_units[u].box.origin.z, u), + ) + for u in order: + d_load = delta_loads.get(u, 0) + if d_load == 0: + continue + supports = base_supporters[u] + if not supports: + continue + total_area = sum(edge.area for edge in supports) + old_downward = base_units[u].weight_ticks + base_loads[u] + new_downward = old_downward + d_load + old_assigned = 0 + new_assigned = 0 + last_pos = len(supports) - 1 + for pos, edge in enumerate(supports): + if pos == last_pos: + old_share = old_downward - old_assigned + new_share = new_downward - new_assigned + else: + old_share = old_downward * edge.area // total_area + new_share = new_downward * edge.area // total_area + old_assigned += old_share + new_assigned += new_share + diff = new_share - old_share + if diff != 0: + delta_loads[edge.index] = delta_loads.get(edge.index, 0) + diff + ascending = sorted(seen_affected) + if self._base_has_max_top: + for u in ascending: + limit_ticks = base_units[u].max_top_load_ticks + if limit_ticks is not None and base_loads[u] + delta_loads.get(u, 0) > limit_ticks: + return ConstraintResult.reject("top_load_exceeded", base_units[u].label) + if self._base_has_crush: + for u in ascending: + unit_u = base_units[u] + limit_kpa = unit_u.max_compression_pressure_kpa + if limit_kpa is None: + continue + fp = unit_u.footprint_ticks2 if unit_u.footprint_ticks2 is not None else (unit_u.box.x2 - unit_u.box.origin.x) * (unit_u.box.y2 - unit_u.box.origin.y) + if applied_pressure(Weight(base_loads[u] + delta_loads.get(u, 0)), fp).exceeds_kpa(limit_kpa): + return ConstraintResult.reject("crush_violation", unit_u.label) + if self._base_has_max_stacked: + assert self._base_stacked_counts is not None + for u in ascending: + max_stacked = base_units[u].max_stacked_items + if max_stacked is not None and self._base_stacked_counts[u] + 1 > max_stacked: + return ConstraintResult.reject("stacked_item_limit_exceeded", base_units[u].label) + if density_limit is not None: + for u in ascending: + unit_u = base_units[u] + if (unit_u.weight_ticks + base_loads[u] + delta_loads.get(u, 0)) * SQUARE_METRE_TICKS > density_limit * unit_u.box.dimensions.base_area: + return ConstraintResult.reject("stack_density_exceeded", unit_u.label) + if context.item.weight.ticks * SQUARE_METRE_TICKS > density_limit * candidate.dimensions.base_area: + return ConstraintResult.reject("stack_density_exceeded", context.item.id) + return ConstraintResult.allow() + unit = LoadUnit(candidate, context.item.weight.ticks, None if item.max_top_load is None else item.max_top_load.ticks, item.max_stacked_items, context.item.id, @@ -866,13 +1108,12 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: None if item.nesting_height is None else item.nesting_height.ticks, item.compression_ratio_ppm, item.max_compression_pressure_kpa, context.dimensions.length.ticks * context.dimensions.width.ticks) - units, graph = self._support.with_candidate(context.placements, unit) + units, graph = base_units + (unit,), base.with_unit(unit, cell_hint=self._hint) failure = non_stackable_failure( context.placements, context.item, graph, len(units) - 1 ) if failure is not None: return failure - density_limit = None if context.container.max_stack_density is None else context.container.max_stack_density.ticks failure = ( overloaded(units, graph) or crushed(units, graph) @@ -883,6 +1124,118 @@ def evaluate(self, context: ConstraintContext) -> ConstraintResult: return ConstraintResult.reject(*failure) return ConstraintResult.allow() + def _settle_route(self, placements: tuple[Placement, ...]) -> None: + assert self._base is not None + if self._base_stops is not None: + return + stops = [ + RIDES_THE_WHOLE_ROUTE if p.instance.item.stop_index is None else float(p.instance.item.stop_index) + for p in placements + ] + self._base_stops = stops + self._base_routed = any(stop != RIDES_THE_WHOLE_ROUTE for stop in stops) + max_stop_above = [_NO_STOP_ABOVE] * len(stops) + if self._base_routed: + for index in self._base._descending_indices(): + best = _NO_STOP_ABOVE + for child in self._base.children(index): + child_stop = stops[child] + if child_stop > best: + best = child_stop + child_above = max_stop_above[child] + if child_above > best: + best = child_above + max_stop_above[index] = best + self._base_route_holds = all(max_stop_above[i] <= stops[i] for i in range(len(stops))) + else: + self._base_route_holds = True + self._base_max_stop_above = max_stop_above + + def evaluate_route_order(self, context: ConstraintContext) -> ConstraintResult: + """Evaluate route unloading order against the settled base without cloning the graph.""" + box = context.envelope_box + footprint = max(box.x2 - box.origin.x, box.y2 - box.origin.y) + base, base_units = self._base_for(context.placements, footprint) + self._settle_route(context.placements) + assert self._base_stops is not None and self._base_max_stop_above is not None + item_stop = context.item.item.stop_index + candidate_stop = RIDES_THE_WHOLE_ROUTE if item_stop is None else float(item_stop) + if not self._base_routed and candidate_stop == RIDES_THE_WHOLE_ROUTE: + return ConstraintResult.allow() + if not base._nested and context.item.item.nesting_height is None and self._base_route_holds: + below = base._face.contacts_below(box) + above = base._face.contacts_above(box) + if not below and not above: + return ConstraintResult.allow() + base_stops = self._base_stops + base_max_above = self._base_max_stop_above + violated = False + for edge in below: + if candidate_stop > base_stops[edge.index]: + violated = True + break + if not violated: + for edge in above: + c = edge.index + if base_stops[c] > candidate_stop or base_max_above[c] > candidate_stop: + violated = True + break + if not violated: + return ConstraintResult.allow() + if not above: + # Candidate has no children, so it is the sole new node above its + # supporter closure, and `base_route_holds` guarantees no existing + # node above any supporter violated that supporter's stop. + base_supporters = base._face._supporters + pending = [edge.index for edge in below] + seen: set[int] = set(pending) + idx = 0 + offender: int | None = None + while idx < len(pending): + u = pending[idx] + idx += 1 + if base_stops[u] < candidate_stop and (offender is None or u < offender): + offender = u + for edge in base_supporters[u]: + if edge.index not in seen: + seen.add(edge.index) + pending.append(edge.index) + assert offender is not None + return ConstraintResult.reject( + "unloading_order_violation", + f"{base_units[offender].label} blocked by {context.item.id}", + ) + + candidate = LoadUnit( + box, context.item.weight.ticks, None, None, context.item.id, + None if context.item.item.nesting_height is None else context.item.item.id, + None if context.item.item.nesting_height is None else context.item.item.nesting_height.ticks, + ) + stops = [*self._base_stops, candidate_stop] + units, graph = base_units + (candidate,), base.with_unit(candidate, cell_hint=self._hint) + failure = route_order_violated(units, stops, graph) + if failure is not None: + return ConstraintResult.reject(*failure) + return ConstraintResult.allow() + + +class TopLoadConstraint: + """Rejects a placement that would rest on something unable to carry it. + + Bearing limits are checked against the cumulative load of the whole stack, not + only the box directly underneath, so a tower of light items cannot crush its base. + The graph comes from a `PlacedSupportBase`, so it is not rebuilt per candidate. + """ + + __slots__ = ("_support",) + + def __init__(self) -> None: + self._support = PlacedSupportBase() + + def evaluate(self, context: ConstraintContext) -> ConstraintResult: + if not context.stack_sensitive: return ConstraintResult.allow() + return self._support.evaluate_top_load(context) + class RouteOrderConstraint: """Rejects a placement that would bury an item due off at an earlier stop. @@ -901,22 +1254,7 @@ def __init__(self) -> None: def evaluate(self, context: ConstraintContext) -> ConstraintResult: if not context.route_sensitive: return ConstraintResult.allow() - candidate = LoadUnit( - context.envelope_box, context.item.weight.ticks, None, None, context.item.id, - None if context.item.item.nesting_height is None else context.item.item.id, - None if context.item.item.nesting_height is None else context.item.item.nesting_height.ticks, - ) - stops = [ - RIDES_THE_WHOLE_ROUTE if p.instance.item.stop_index is None else float(p.instance.item.stop_index) - for p in context.placements - ] - item_stop = context.item.item.stop_index - stops.append(RIDES_THE_WHOLE_ROUTE if item_stop is None else float(item_stop)) - units, graph = self._support.with_candidate(context.placements, candidate) - failure = route_order_violated(units, stops, graph) - if failure is not None: - return ConstraintResult.reject(*failure) - return ConstraintResult.allow() + return self._support.evaluate_route_order(context) def _stop_of(placement: Placement) -> int | float: diff --git a/src/packvium/contact.py b/src/packvium/contact.py index c5008bd..4bcef39 100644 --- a/src/packvium/contact.py +++ b/src/packvium/contact.py @@ -171,28 +171,19 @@ def with_box(self, box: AxisAlignedBox) -> "ContactGraph": children = list(self._children) # What the new box rests on: boxes whose top plane is its bottom plane. - own_supporters: list[ContactEdge] = [] - for other_index, area in sorted( - (other_index, other.overlap_area_xy(box)) - for other_index, other in self._near(self._by_top, self._top_indexes, box.origin.z, box) - ): - if area > 0: - own_supporters.append(ContactEdge(other_index, area)) - children[other_index] = children[other_index] + (index,) + own_supporters = self.contacts_below(box) + for edge in own_supporters: + children[edge.index] = children[edge.index] + (index,) # What now rests on it: boxes whose bottom plane is its top plane. Their # supporter tuples gain the new index, which is larger than every index already # in them, so ascending order is preserved by appending. own_children: list[int] = [] - for other_index, area in sorted( - (other_index, other.overlap_area_xy(box)) - for other_index, other in self._near(self._by_bottom, self._bottom_indexes, box.z2, box) - ): - if area > 0: - own_children.append(other_index) - supporters[other_index] = supporters[other_index] + (ContactEdge(index, area),) + for edge in self.contacts_above(box): + own_children.append(edge.index) + supporters[edge.index] = supporters[edge.index] + (ContactEdge(index, edge.area),) - supporters.append(tuple(own_supporters)) + supporters.append(own_supporters) children.append(tuple(own_children)) # The by-plane buckets are carried forward rather than rederived: one box joins @@ -231,14 +222,67 @@ def _from_parts(cls, boxes, cell, supporters, children, graph._bottom_indexes = bottom_indexes return graph - def _near(self, buckets, cache, plane: int, box: AxisAlignedBox): - """Every box on `plane` that could overlap `box` in XY, deduped, via the hash.""" - entries = buckets.get(plane) + def contacts_below(self, box: AxisAlignedBox) -> tuple[ContactEdge, ...]: + """Direct face supporters of `box` on `box.origin.z`, in ascending index order.""" + entries = self._by_top.get(box.origin.z) if not entries: return () + if len(entries) <= 32: + bx1, by1, bx2, by2 = box.origin.x, box.origin.y, box.x2, box.y2 + out: list[ContactEdge] = [] + for other_index, other in entries: + ox1, oy1 = other.origin.x, other.origin.y + ox2 = ox1 + other.dimensions.length.ticks + oy2 = oy1 + other.dimensions.width.ticks + dx = (bx2 if bx2 < ox2 else ox2) - (bx1 if bx1 > ox1 else ox1) + if dx <= 0: + continue + dy = (by2 if by2 < oy2 else oy2) - (by1 if by1 > oy1 else oy1) + if dy > 0: + out.append(ContactEdge(other_index, dx * dy)) + return tuple(out) + matches = [ + (other_index, area) + for other_index, other in self._near(self._by_top, self._top_indexes, box.origin.z, box) + if (area := other.overlap_area_xy(box)) > 0 + ] + if len(matches) > 1: + matches.sort() + return tuple(ContactEdge(other_index, area) for other_index, area in matches) + + def contacts_above(self, box: AxisAlignedBox) -> tuple[ContactEdge, ...]: + """Boxes resting directly on `box.z2`, in ascending index order.""" + entries = self._by_bottom.get(box.z2) + if not entries: + return () + if len(entries) <= 32: + bx1, by1, bx2, by2 = box.origin.x, box.origin.y, box.x2, box.y2 + out: list[ContactEdge] = [] + for other_index, other in entries: + ox1, oy1 = other.origin.x, other.origin.y + ox2 = ox1 + other.dimensions.length.ticks + oy2 = oy1 + other.dimensions.width.ticks + dx = (bx2 if bx2 < ox2 else ox2) - (bx1 if bx1 > ox1 else ox1) + if dx <= 0: + continue + dy = (by2 if by2 < oy2 else oy2) - (by1 if by1 > oy1 else oy1) + if dy > 0: + out.append(ContactEdge(other_index, dx * dy)) + return tuple(out) + matches = [ + (other_index, area) + for other_index, other in self._near(self._by_bottom, self._bottom_indexes, box.z2, box) + if (area := other.overlap_area_xy(box)) > 0 + ] + if len(matches) > 1: + matches.sort() + return tuple(ContactEdge(other_index, area) for other_index, area in matches) + + def _near(self, buckets, cache, plane: int, box: AxisAlignedBox): + """Every box on `plane` that could overlap `box` in XY, deduped, via the hash.""" level = cache.get(plane) if level is None: - level = _LevelIndex(entries, self._cell) + level = _LevelIndex(buckets[plane], self._cell) cache[plane] = level return level.near(box) diff --git a/src/packvium/execution.py b/src/packvium/execution.py index cb0fe50..beb9658 100644 --- a/src/packvium/execution.py +++ b/src/packvium/execution.py @@ -81,6 +81,19 @@ def placement_reference(container_index: int, placement: Mapping[str, Any]) -> d ) from error +def _step_reference(container_index: int, placement: Mapping[str, Any]) -> dict[str, Any]: + """`placement_reference` plus the result placement's `placement_id` (1.10), copied rather + than derived, when the result carries one. A canonical projection (a golden) has no ids, + so its plan keeps the bytes it always had. `locks_from_plan` reads the id from here.""" + reference = placement_reference(container_index, placement) + if "placement_id" not in placement: + return reference + identifier = placement["placement_id"] + if not isinstance(identifier, str) or not identifier: + raise ExecutionPlanError(f"placement_id {identifier!r} is not a non-empty string") + return {**reference, "placement_id": identifier} + + def _steps(container_index: int, container: Mapping[str, Any], loading_order: Optional[Sequence[int]]) -> dict[str, Any]: """The operator sequence for one container, or an honest absence of one. @@ -96,7 +109,7 @@ def _steps(container_index: int, container: Mapping[str, Any], return { "order": "unavailable", "steps": [ - {"placement": placement_reference(container_index, placement)} + {"placement": _step_reference(container_index, placement)} for placement in placements ], } @@ -108,7 +121,7 @@ def _steps(container_index: int, container: Mapping[str, Any], return { "order": "loading", "steps": [ - {"sequence": step, "placement": placement_reference(container_index, placements[index])} + {"sequence": step, "placement": _step_reference(container_index, placements[index])} for step, index in enumerate(loading_order, start=1) ], } diff --git a/src/packvium/fixed_placements.py b/src/packvium/fixed_placements.py index 375ef2c..b3d2f86 100644 --- a/src/packvium/fixed_placements.py +++ b/src/packvium/fixed_placements.py @@ -15,15 +15,17 @@ from __future__ import annotations from collections import defaultdict -from typing import Any, Callable, List, Mapping +from typing import AbstractSet, Any, Callable, FrozenSet, List, Mapping -from ._canonical_json import json_integer, json_spelling +from ._canonical_json import MAX_EXACT_MAGNITUDE, json_integer, json_spelling from ._compat import dataclass -from .request_errors import InvalidRequestError, pointer +from .orientations import vocabulary +from .request_errors import InvalidRequestError, pointer, refuse_orientation from .constraints import direct_support_view, load_units, top_loads from .geometry import AxisAlignedBox, Point from .models import (Container, FixedPlacement, Item, ItemInstance, PackedContainer, - PackingRequest, Placement) + PackingRequest, Placement, placement_id) +from .pallet_overhang import Deck, floor_support_ratio from .units import Length, Weight from .validation import IndependentSolutionValidator @@ -41,35 +43,58 @@ def _message(self) -> str: return f"{self.code}: {self.detail}" -ORIENTATIONS = ("LWH", "LHW", "WLH", "WHL", "HLW", "HWL") _REQUIRED = ("item_type", "container_type", "orientation") -_FIELDS = (*_REQUIRED, "container_instance", "position") +_FIELDS = (*_REQUIRED, "container_instance", "position", "placement_id") _AXES = ("x", "y", "z") -def require_fixed_placement_shapes(raw: Any, unit: str = "mm") -> List[Mapping[str, Any]]: +def require_fixed_placement_shapes(raw: Any, unit: str = "mm", + hull_types: AbstractSet[str] = frozenset()) -> List[Mapping[str, Any]]: """The request's `fixed_placements` as JSON gave them, or a refusal naming the first entry that is not the schema's shape. Nothing is coerced: `"1"` and `true` are not instances, and a - list is not a position. An absent or null field is no fixed placements.""" + list is not a position. An absent or null field is no fixed placements. `hull_types` names + the item types that are hulls, the only ones a signed orientation may fix (1.10).""" if raw is None: return [] if not isinstance(raw, list): raise _malformed("fixed_placements is a list", "/fixed_placements") for index, entry in enumerate(raw): - _require_entry_shape(entry, f"fixed_placements[{index}]", pointer("fixed_placements", index), unit) + _require_entry_shape(entry, f"fixed_placements[{index}]", pointer("fixed_placements", index), unit, + hull_types) return raw -def orientation_rule() -> str: +def hull_item_types(items: Any) -> FrozenSet[str]: + """The ids of the requested item types that are `convex_hull`, read from JSON items as + written; anything that is not an item object names no hull. O(items).""" + if not isinstance(items, list): + return frozenset() + return frozenset(item["id"] for item in items if isinstance(item, Mapping) + and item.get("shape_type") == "convex_hull" and isinstance(item.get("id"), str)) + + +def require_orientation(value: Any, hull: bool, where: str, field: str) -> None: + """A fixed orientation from its item's vocabulary (1.10): an unknown code is `malformed` with + the vocabulary listed -- six codes for a box, byte-identical to every release before -- and + a signed code fixing a non-hull is `invalid_value`. Whether the item allows it is + `admit_fixed_placements`' rule. O(1).""" + refuse_orientation(value, hull, field, + lambda reason, at, detail: FixedPlacementError( + detail, "malformed" if reason == "not_allowed" else reason, at), + f"{where}.orientation") + + +def orientation_rule(hull: bool = False) -> str: """The orientation rule as the request-error tables word a closed vocabulary.""" - return f"must be one of {json_spelling(list(ORIENTATIONS))}" + return f"must be one of {json_spelling(list(vocabulary(hull)))}" def _malformed(detail: str, field: str) -> FixedPlacementError: return FixedPlacementError(detail, "malformed", field) -def _require_entry_shape(entry: Any, where: str, field: str, unit: str) -> None: +def _require_entry_shape(entry: Any, where: str, field: str, unit: str, + hull_types: AbstractSet[str]) -> None: if not isinstance(entry, Mapping): raise _malformed(f"{where} is an object", field) unknown = sorted(set(entry) - set(_FIELDS)) @@ -81,8 +106,7 @@ def _require_entry_shape(entry: Any, where: str, field: str, unit: str) -> None: for name in ("item_type", "container_type"): if not isinstance(entry[name], str) or not entry[name]: raise _malformed(f"{where}.{name} is a non-empty string", f"{field}/{name}") - if entry["orientation"] not in ORIENTATIONS: - raise _malformed(f"{where}.orientation {orientation_rule()}", f"{field}/orientation") + require_orientation(entry["orientation"], entry["item_type"] in hull_types, where, f"{field}/orientation") instance = json_integer(entry.get("container_instance", 1)) if instance is None or instance < 1: raise _malformed(f"{where}.container_instance counts from 1", f"{field}/container_instance") @@ -91,6 +115,30 @@ def _require_entry_shape(entry: Any, where: str, field: str, unit: str) -> None: for axis in _AXES: if axis in position: _require_coordinate(position[axis], f"{where}.position.{axis}", f"{field}/position/{axis}", unit) + ticks = Point(*(Length.parse(position.get(axis, 0), unit).ticks for axis in _AXES)) + require_placement_id(entry.get("placement_id"), f"{entry['container_type']}#{instance}", ticks, + entry["item_type"], entry["orientation"], where, field) + + +def require_placement_id(value: Any, container_id: str, position: Point, item_type: str, + orientation: str, where: str, field: str) -> None: + """A fixed placement's optional `placement_id` (1.10) restates the id its result placement + will carry, the canonical JSON of `[#, x, y, z, + , ]` with the position in ticks; null is absent. + A non-string is `malformed`, like every other shape error here; a string naming another + placement is `invalid_value`, the reason a well-typed value outside its rule carries. O(1).""" + if value is None: + return + at = f"{field}/placement_id" + if not isinstance(value, str): + raise _malformed(f"{where}.placement_id is a string", at) + # A coordinate past 2^53 - 1 has no canonical spelling, and no container holds it: the + # admission check refuses such an entry as `outside_container`, so the id is not judged. + if max(position.x, position.y, position.z) > MAX_EXACT_MAGNITUDE: + return + expected = placement_id(container_id, position, item_type, orientation) + if value != expected: + raise FixedPlacementError(f"{where}.placement_id must be {json_spelling(expected)}", "invalid_value", at) def _require_coordinate(value: Any, where: str, field: str, unit: str) -> None: @@ -149,7 +197,8 @@ def admit_fixed_placements( grouped = _group_by_container(request, instances, clearance) _require_contiguous_instances(grouped, containers, max_containers) packed = tuple( - PackedContainer(containers[container_id], sequence, _with_support_and_loads(placements)) + PackedContainer(containers[container_id], sequence, + _with_support_and_loads(placements, containers[container_id].deck)) for (container_id, sequence), placements in grouped ) _require_valid_alone(request, packed, minimum_support_ratio, clearance) @@ -239,7 +288,7 @@ def _require_contiguous_instances( ) -def _with_support_and_loads(placements: list[Placement]) -> tuple[Placement, ...]: +def _with_support_and_loads(placements: list[Placement], deck: Deck | None) -> tuple[Placement, ...]: """Each fixed item's support ratio from the fixed items under it, and its top load. Support is measured against every other fixed item in the container, not only the ones @@ -251,7 +300,7 @@ def _with_support_and_loads(placements: list[Placement]) -> tuple[Placement, ... supported.append(Placement( placement.instance, placement.position, placement.rotation, placement.dimensions, placement.envelope_origin, placement.envelope_dimensions, - _support_ratio(others, placement), fixed=True, + _support_ratio(others, placement, deck), fixed=True, )) loads = top_loads(load_units(supported)) return tuple( @@ -261,10 +310,10 @@ def _with_support_and_loads(placements: list[Placement]) -> tuple[Placement, ... ) -def _support_ratio(others: list[Placement], placement: Placement) -> float: - if placement.envelope_origin.z == 0: - return 1.0 +def _support_ratio(others: list[Placement], placement: Placement, deck: Deck | None) -> float: box = AxisAlignedBox(placement.envelope_origin, placement.envelope_dimensions) + if placement.envelope_origin.z == 0: + return floor_support_ratio(deck, box) support = direct_support_view(others, placement.instance, box) return support.supporting_area / placement.envelope_dimensions.base_area diff --git a/src/packvium/flat_bag.py b/src/packvium/flat_bag.py index 273c842..b2cb7ac 100644 --- a/src/packvium/flat_bag.py +++ b/src/packvium/flat_bag.py @@ -9,6 +9,11 @@ The extents only grow as boxes are added, so a verdict reached for a candidate never flips when more items follow, and any subset of an admitted set is admitted. + +A `gusseted_bag` (1.10) has side gussets of depth `G` folded into each long edge: the tube +round the package's `y-z` section is `2(W + G)` long instead of `2W`, so the width sum becomes +`b + c <= W + G`. The length sum is unchanged (no bottom gusset in 1.10), and `G = 0` is the +flat bag. Every function here takes the gusset depth in ticks, `0` for a flat bag. """ from __future__ import annotations @@ -19,18 +24,19 @@ from .units import Length FLAT_BAG = "flat_bag" -DEFORMABLE_BOUNDARY_KINDS = (FLAT_BAG,) +GUSSETED_BAG = "gusseted_bag" +DEFORMABLE_BOUNDARY_KINDS = (FLAT_BAG, GUSSETED_BAG) Extent = tuple[int, int, int] -def girth_admits(inner: Dimensions, a: int, b: int, c: int) -> bool: +def girth_admits(inner: Dimensions, a: int, b: int, c: int, gusset: int = 0) -> bool: """Whether a package of extents `(a, b, c)` passes both girth inequalities. The rigid boundary (`a <= L`, `b <= W`, `c <= H`) is the caller's own check and stays where it already is; only the two sums are new. """ - return a + c <= inner.length.ticks and b + c <= inner.width.ticks + return a + c <= inner.length.ticks and b + c <= inner.width.ticks + gusset def grown_extent(extent: Extent, x2: int, y2: int, z2: int) -> Extent: @@ -46,10 +52,10 @@ def extent_of(boxes: Iterable[tuple[int, int, int]]) -> Extent: return extent -def fits_alone(inner: Dimensions, dimensions: Dimensions) -> bool: - """Whether one box of these dimensions, alone at the origin, fits a flat bag.""" +def fits_alone(inner: Dimensions, dimensions: Dimensions, gusset: int = 0) -> bool: + """Whether one box of these dimensions, alone at the origin, fits a bag.""" a, b, c = dimensions.length.ticks, dimensions.width.ticks, dimensions.height.ticks - return dimensions.fits_inside(inner) and girth_admits(inner, a, b, c) + return dimensions.fits_inside(inner) and girth_admits(inner, a, b, c, gusset) def as_dimensions(extent: Extent) -> Dimensions: diff --git a/src/packvium/geometry.py b/src/packvium/geometry.py index b728597..6e04ccb 100644 --- a/src/packvium/geometry.py +++ b/src/packvium/geometry.py @@ -6,34 +6,76 @@ from itertools import product from typing import Iterable +from .orientations import LEGACY_CODES, SOURCE_AXES, UPRIGHT_CODES from .units import Length, Rounding, Weight from ._request_error import refused class Rotation(str, Enum): + """The 24 proper rotations in canonical table order (`orientations`, 1.10). + + The six legacy codes come first and are the whole vocabulary of a box; the 18 signed codes + after them are reachable only by a `convex_hull`. Member order is the tie-break order. + """ + LWH = "LWH" LHW = "LHW" WLH = "WLH" WHL = "WHL" HLW = "HLW" HWL = "HWL" + PL_NW_NH = "+L-W-H" + NL_PW_NH = "-L+W-H" + NL_NW_PH = "-L-W+H" + PL_NH_PW = "+L-H+W" + PL_PH_NW = "+L+H-W" + NL_NH_NW = "-L-H-W" + PW_NL_PH = "+W-L+H" + PW_PL_NH = "+W+L-H" + NW_NL_NH = "-W-L-H" + PW_NH_NL = "+W-H-L" + NW_PH_NL = "-W+H-L" + NW_NH_PL = "-W-H+L" + PH_NL_NW = "+H-L-W" + NH_PL_NW = "-H+L-W" + NH_NL_PW = "-H-L+W" + PH_NW_PL = "+H-W+L" + PH_PW_NL = "+H+W-L" + NH_NW_NL = "-H-W-L" @classmethod def all(cls) -> tuple["Rotation", ...]: - return tuple(cls) + """The six legacy codes: an item's default list, and every box a cuboid can make.""" + return _LEGACY @classmethod def upright(cls) -> tuple["Rotation", ...]: - return (cls.LWH, cls.WLH) + """The legacy codes `keep_upright` leaves of the default list.""" + return _LEGACY_UPRIGHT + + @classmethod + def every(cls) -> tuple["Rotation", ...]: + """All 24 proper rotations, in table order; the 18 past `all()` need a hull.""" + return tuple(cls) + + @property + def is_signed(self) -> bool: + return self.value not in LEGACY_CODES + + @property + def is_upright(self) -> bool: + """Whether the item's own height stays vertical and pointing up (z takes `+H`).""" + return self.value in UPRIGHT_CODES + +_LEGACY: tuple[Rotation, ...] = tuple(Rotation(code) for code in LEGACY_CODES) +_LEGACY_UPRIGHT: tuple[Rotation, ...] = (Rotation.LWH, Rotation.WLH) # Which declared side lands on each axis under a rotation. A module-level table rather # than a dict literal inside `rotated`: that method runs once per rotation per candidate -# search, and rebuilding six tuples there was measurable. +# search, and rebuilding the tuples there was measurable. A box ignores the signs. _ROTATION_AXES: dict[Rotation, tuple[int, int, int]] = { - Rotation.LWH: (0, 1, 2), Rotation.LHW: (0, 2, 1), - Rotation.WLH: (1, 0, 2), Rotation.WHL: (1, 2, 0), - Rotation.HLW: (2, 0, 1), Rotation.HWL: (2, 1, 0), + rotation: SOURCE_AXES[rotation.value] for rotation in Rotation } diff --git a/src/packvium/hull.py b/src/packvium/hull.py index 5d21dea..7da53eb 100644 --- a/src/packvium/hull.py +++ b/src/packvium/hull.py @@ -48,6 +48,7 @@ from typing import Iterable, Sequence, Tuple from ._compat import dataclass +from .orientations import AXIS_SIGNS, SOURCE_AXES #: A point in an item's local tick frame. Deliberately not `geometry.Point`, which forbids #: negative coordinates: a hull is authored around whatever origin its author chose, and is @@ -299,8 +300,9 @@ def projection(self, axis: Axis) -> Tuple[int, int]: #: How many rotated hulls stay resident. A request is bounded by its distinct hull items times -#: the six orientations, so this holds far more than any single request; the bound is what -#: stops a long-lived process packing many catalogues from accumulating shapes forever. +#: the orientations they allow (at most 24), so this holds far more than any single request; the +#: bound is what stops a long-lived process packing many catalogues from accumulating shapes +#: forever. #: #: Measured full, the ceiling it buys: 3.8 MB at six vertices per hull, 5.2 MB at eight, 13.6 #: MB at twenty. Eviction order depends on call order and nothing observable does -- the @@ -378,43 +380,22 @@ def collide(left: HullShape, left_origin: Vertex, right: HullShape, right_origin return True -#: Which local axis each container axis takes its extent from, per `Rotation`. Read off -#: `Dimensions.rotated`, which is the definition every other part of the engine already -#: follows: `LHW` means x spans the item's length, y its height, z its width. -_SOURCE_AXES = { - "LWH": (0, 1, 2), "LHW": (0, 2, 1), - "WLH": (1, 0, 2), "WHL": (1, 2, 0), - "HLW": (2, 0, 1), "HWL": (2, 1, 0), -} - - -def _permutation_is_odd(axes: Tuple[int, int, int]) -> bool: - return sum( - 1 for first in range(3) for second in range(first + 1, 3) if axes[first] > axes[second] - ) % 2 == 1 - - def rotate(vertices: Sequence[Vertex], rotation) -> Tuple[Vertex, ...]: - """Reorient a hull the way `Dimensions.rotated` reorients its box. - - Three of the six rotations are odd permutations of the coordinate axes. On a cuboid that - is invisible -- a mirrored box is the same box. On a hull it is not: a bare permutation - would hand back the item's mirror image, a shape the caller does not own. So the sign of - one axis is flipped whenever the permutation is odd, which makes every one of the six a - proper rotation with determinant +1 and never a reflection. + """Reorient a hull by one of the 24 proper rotations (1.10). - The scope limit that follows is worth stating rather than discovering: `allowed_rotations` - distinguishes six orientations because six is all a box has, and a hull has 24. These six - are pinned, deterministic and physically real; the other 18 are simply not expressible in - today's vocabulary, and widening it is a contract change rather than a fix here. + `v'[i] = s[i] * v[src[i]]` with the code's source axes and signs from `orientations`. A + bare permutation would hand back the item's mirror image for an odd permutation -- a + shape the caller does not own -- which is why the legacy odd codes negate x and every + signed code has determinant +1: no code is ever a reflection. Vertices come back translated so the rotated hull's own bounding box starts at the origin, which is the frame every placement position is expressed in. """ - axes = _SOURCE_AXES[rotation.value if hasattr(rotation, "value") else rotation] - sign = -1 if _permutation_is_odd(axes) else 1 + code = rotation.value if hasattr(rotation, "value") else rotation + axes = SOURCE_AXES[code] + signs = AXIS_SIGNS[code] turned = [ - (sign * vertex[axes[0]], vertex[axes[1]], vertex[axes[2]]) for vertex in vertices + tuple(signs[axis] * vertex[axes[axis]] for axis in range(3)) for vertex in vertices ] lower, _ = bounding_extent(turned) return tuple( diff --git a/src/packvium/locks.py b/src/packvium/locks.py index 56c0f7f..f7c0f24 100644 --- a/src/packvium/locks.py +++ b/src/packvium/locks.py @@ -79,23 +79,30 @@ ## The one thing a constraint cannot see -A lock names a container index; a constraint runs inside a search that has no index yet -- -`container_index` is a position in the *result's* sorted container order, which does not -exist until the solve finishes. The constraint therefore applies the outstanding rule in -every container the locked box could physically occupy, and `resolve_with_locks` verifies -preservation at the exact index afterwards. Over-applying can only *forbid*, never place, -so the failure mode is a reported `missing` lock and never a placement the engine would have -refused. +A lock names a container; a constraint runs inside a search that has not numbered its +containers yet. The constraint therefore applies the outstanding rule in every container the +locked box could physically occupy, and `resolve_with_locks` verifies preservation afterwards. +Over-applying can only *forbid*, never place, so the failure mode is a reported `missing` +lock and never a placement the engine would have refused. + +Since 1.10 a lock read from a plan carries the placement's `placement_id`, the canonical JSON +of `[container id, x, y, z, item type, orientation]`, and preservation is checked by it. Before, it was checked at +the plan's `container_index`: the position in the *emitted* container list, which the +contract does not fix, so a re-solve that opened the same containers in another order +reported a held lock as `missing`. A lock built without an id keeps the index rule. """ from __future__ import annotations +import json from ._compat import dataclass from typing import Any, Mapping, Optional, Sequence +from ._canonical_json import canonical_identifier from .constraints import ConstraintContext, ConstraintResult from .extensions import ExtensionRegistry from .geometry import AxisAlignedBox, Point, Rotation +from .orientations import CODES __all__ = [ "LOCK_VIOLATED", @@ -145,13 +152,16 @@ class PlacementLock: item_type: str orientation: str position_ticks: tuple[int, int, int] + #: The plan reference's `placement_id` (1.10). When set, preservation is checked by it + #: rather than by `container_index`; its last five entries must be this lock's own. + placement_id: Optional[str] = None def __post_init__(self) -> None: if self.container_index < 0: raise ValueError("a lock must name a container index") if not self.item_type: raise ValueError("a lock must name an item type") - if self.orientation not in Rotation.__members__: + if self.orientation not in CODES: raise ValueError(f"{self.orientation!r} is not an orientation this engine emits") if len(self.position_ticks) != 3: raise ValueError("a lock's position must have three axes") @@ -159,6 +169,19 @@ def __post_init__(self) -> None: raise ValueError("a lock's position must be exact integer ticks") if any(t < 0 for t in self.position_ticks): raise ValueError("a lock's position cannot be negative") + if self.placement_id is not None and not self._names_this_placement(self.placement_id): + raise ValueError(f"{self.placement_id!r} is not the placement id of this lock") + + def _names_this_placement(self, identifier: str) -> bool: + """The id is `[container id, x, y, z, item type, orientation]` in canonical JSON and its + last five entries are this lock's own (1.10).""" + try: + parts = json.loads(str(identifier)) + except ValueError: + return False + return (isinstance(parts, list) and len(parts) == 6 and isinstance(parts[0], str) + and bool(parts[0]) and parts[1:] == [*self.position_ticks, self.item_type, self.orientation] + and canonical_identifier(parts) == identifier) @property def slot(self) -> _Slot: @@ -188,6 +211,7 @@ def locks_from_plan(plan: Mapping[str, Any], *, container_index: int, item_type=str(reference["item_type"]), orientation=str(reference["orientation"]), position_ticks=(int(ticks["x"]), int(ticks["y"]), int(ticks["z"])), + placement_id=reference.get("placement_id"), )) return tuple(locks) @@ -228,7 +252,7 @@ def _applicable(self, lock: PlacementLock, context: ConstraintContext) -> bool: it exists to drop locks that plainly cannot apply here, and a lock this admits is still judged by the ordinary geometry rules. """ - box = context.item.dimensions.rotated(Rotation[lock.orientation]) + box = context.item.dimensions.rotated(Rotation(lock.orientation)) inner = context.container.inner_dimensions x, y, z = lock.position_ticks return (x + box.length.ticks <= inner.length.ticks @@ -243,7 +267,7 @@ def _reserved_box(self, lock: PlacementLock, ordinary clearance rule already enforces around whatever ends up here, and reserving it twice would refuse neighbours the engine is willing to place. """ - dimensions = context.item.dimensions.rotated(Rotation[lock.orientation]) + dimensions = context.item.dimensions.rotated(Rotation(lock.orientation)) return AxisAlignedBox(Point(*lock.position_ticks), dimensions) def evaluate(self, context: ConstraintContext) -> ConstraintResult: @@ -319,13 +343,15 @@ def __post_init__(self) -> None: raise ValueError("an unpreserved resolve must name the locks it could not honour") -def _placed(result: Mapping[str, Any]) -> set[tuple[int, str, str, tuple[int, int, int]]]: - """Every placement in the result, keyed the way a lock addresses one. +def _placed(result: Mapping[str, Any]) -> set[tuple[Any, ...]]: + """Every placement in the result, keyed both ways a lock addresses one (`_lock_key`). - `container_index` is the container's position in the emitted list, which is the same - order `packvium.execution` indexes and the order the plan's references carry. + `container_index` is the container's position in the emitted list, the order + `packvium.execution` indexes. `placement_id` names the container by id instead, and is + unique in a valid result, so it finds the placement whatever order the re-solve emitted + its containers in. O(P). """ - placed = set() + placed: set[tuple[Any, ...]] = set() for index, container in enumerate(result.get("containers") or ()): for placement in container.get("placements") or (): position = placement.get("position") or {} @@ -333,11 +359,18 @@ def _placed(result: Mapping[str, Any]) -> set[tuple[int, str, str, tuple[int, in ticks = tuple(int(position[axis]["ticks"]) for axis in ("x", "y", "z")) except (KeyError, TypeError): continue - placed.add((index, str(placement.get("item_type")), - str(placement.get("orientation")), ticks)) + placed.add((index, str(placement.get("item_type")), str(placement.get("orientation")), ticks)) + if isinstance(placement.get("placement_id"), str): + placed.add((placement["placement_id"],)) return placed +def _lock_key(lock: PlacementLock) -> tuple[Any, ...]: + if lock.placement_id is not None: + return (lock.placement_id,) + return (lock.container_index, lock.item_type, lock.orientation, tuple(lock.position_ticks)) + + def _refuse_a_contradictory_set(locks: Sequence[PlacementLock], items: Mapping[str, Any]) -> None: """Every way a lock set can be wrong without a request being consulted. @@ -351,7 +384,7 @@ def _refuse_a_contradictory_set(locks: Sequence[PlacementLock], item = items.get(lock.item_type) if item is None: raise LockSetError(f"the request has no item type {lock.item_type!r} to lock") - dimensions = item.dimensions.rotated(Rotation[lock.orientation]) + dimensions = item.dimensions.rotated(Rotation(lock.orientation)) boxes.append((lock, AxisAlignedBox(Point(*lock.position_ticks), dimensions))) for index, (lock, box) in enumerate(boxes): @@ -386,9 +419,5 @@ def resolve_with_locks(request: Mapping[str, Any], locks: Sequence[PlacementLock }) result = pack_from_dict(dict(request), extensions=lock_registry(locks, base)) placed = _placed(result) - missing = tuple( - lock for lock in locks - if (lock.container_index, lock.item_type, lock.orientation, - tuple(lock.position_ticks)) not in placed - ) + missing = tuple(lock for lock in locks if _lock_key(lock) not in placed) return LockedResolve(locks=locks, result=result, preserved=not missing, missing=missing) diff --git a/src/packvium/models.py b/src/packvium/models.py index adff74f..3ad4066 100644 --- a/src/packvium/models.py +++ b/src/packvium/models.py @@ -5,14 +5,18 @@ from ._compat import dataclass from typing import Any, Iterable, Mapping +from ._canonical_json import canonical_identifier from .centre_of_mass import centre_of_mass_offset_ppm from . import compression, hull -from .flat_bag import DEFORMABLE_BOUNDARY_KINDS, as_dimensions, extent_of +from .flat_bag import DEFORMABLE_BOUNDARY_KINDS, GUSSETED_BAG, as_dimensions, extent_of +from .pallet_overhang import Deck, OverhangLimit +from .pallet_overhang import achieved_dimensions as pallet_achieved, billed_dimensions as pallet_billed from .geometry import (ALL_DIRECTIONS, AxisAlignedBox, Dimensions, InvalidDirectionError, Point, Rotation, ShapeType) from .lattice_summary import LatticeSummary from .made_to_measure import MadeToMeasure, as_triple from .nesting import used_volume as nesting_used_volume +from .orientations import SIGNED_CODE_NEEDS_A_HULL from .units import Length, Weight from ._request_error import refused, refused_type @@ -149,7 +153,10 @@ def __post_init__(self) -> None: raise refused("not_allowed", f"ground_contact_rule must be one of {sorted(GROUND_CONTACT_RULES)}") self._validate_shape() _require_booleans(self, ("keep_upright", "stackable", "must_be_on_floor")) - rotations = tuple(r for r in self.allowed_rotations if not self.keep_upright or r in Rotation.upright()) + requested = tuple(Rotation(r) for r in self.allowed_rotations) + if self.shape_type is not ShapeType.CONVEX_HULL and any(r.is_signed for r in requested): + raise refused("invalid_value", SIGNED_CODE_NEEDS_A_HULL) + rotations = tuple(r for r in requested if not self.keep_upright or r.is_upright) if not rotations: raise refused("invalid_value", "at least one rotation must be allowed") object.__setattr__(self, "allowed_rotations", rotations) object.__setattr__(self, "tags", frozenset(self.tags)) @@ -264,6 +271,9 @@ class RateTable: prices_minor: tuple[int, ...] minimum_charge_minor: int = 0 fuel_surcharge_permille: int = 0 + #: The ISO 4217 code the prices are in, or `None` for the request's own (1.10). Checked + #: where the table sits in a request, against the request's currency, by `Packer.pack`. + currency: str | None = None def __post_init__(self) -> None: if not self.weight_brackets_g: @@ -363,10 +373,21 @@ class Container: # Cut to measure by an on-demand box machine whose largest box is `inner_dimensions` # (`made_to_measure.py`). A packed result carries the fabricated box instead. made_to_measure: MadeToMeasure | None = None + # How far a load may pass this deck's edges (`pallet_overhang.py`). + pallet_overhang_limit: OverhangLimit | None = None + # The deck inside a solving frame. Set only by `solving_frame`, which widens + # `inner_dimensions` to the frame and drops the limit, so a frame is never widened twice. + deck: Deck | None = None + # A `"gusseted_bag"`'s side gusset depth G: its width girth is W + G (1.10, `flat_bag.py`). + # Required for that kind and refused for every other container. Last, as above. + side_gusset_depth: Length | None = None def __post_init__(self) -> None: if not self.id: raise refused("invalid_value", "container id is required") if self.deformable_boundary is not None: self._require_bag_fields() + elif self.side_gusset_depth is not None: + raise refused("not_allowed", "side_gusset_depth requires deformable_boundary gusseted_bag") + if self.pallet_overhang_limit is not None: self._require_pallet_fields() if self.made_to_measure is not None: self.made_to_measure.admits(as_triple(self.inner_dimensions)) if self.quantity is not None and self.quantity <= 0: raise refused("below_minimum", "container quantity must be positive") if self.cost_minor < 0: raise refused("below_minimum", "container cost cannot be negative") @@ -387,7 +408,7 @@ def __post_init__(self) -> None: raise InvalidDirectionError(direction) object.__setattr__(self, "access_directions", tuple(d for d in ALL_DIRECTIONS if d in set(self.access_directions))) - if self.outer_dimensions and not self.inner_dimensions.fits_inside(self.outer_dimensions): raise refused("invalid_value", "outer dimensions cannot be smaller than inner dimensions") + if self.outer_dimensions and not self.declared_inner.fits_inside(self.outer_dimensions): raise refused("invalid_value", "outer dimensions cannot be smaller than inner dimensions") boundary = AxisAlignedBox(Point(0, 0, 0), self.inner_dimensions) for obstacle in self.obstacles: if not all(boundary.contains(box) for box in obstacle.boxes): @@ -402,15 +423,55 @@ def _require_bag_fields(self) -> None: contents. The JSON path names the same fields as request errors first.""" if self.deformable_boundary not in DEFORMABLE_BOUNDARY_KINDS: raise refused("not_allowed", f"unknown deformable_boundary kind {self.deformable_boundary!r}") + self._require_gusset_rule() fields = [name for name, present in ( ("obstacles", bool(self.obstacles)), ("axles", self.axles is not None), ("access_directions", bool(self.access_directions)), ("max_stack_density", self.max_stack_density is not None), ("outer_dimensions", self.outer_dimensions is not None), - ("void_fill_reserve_ratio", self.void_fill_reserve_ratio > 0)) if present] + ("void_fill_reserve_ratio", self.void_fill_reserve_ratio > 0), + ("pallet_overhang_limit", self.pallet_overhang_limit is not None)) if present] if fields: raise refused("not_allowed", f"{fields[0]} cannot be combined with deformable_boundary") + def _require_gusset_rule(self) -> None: + """A gusseted bag's side gusset is required, positive and no deeper than the bag is + wide: a deeper fold would have no flat sheet left to fold from. A flat bag takes + none. O(1).""" + gusseted = self.deformable_boundary == GUSSETED_BAG + if self.side_gusset_depth is None: + if gusseted: raise refused("missing_field", "a gusseted_bag requires side_gusset_depth") + return + if not gusseted: + raise refused("not_allowed", "side_gusset_depth requires deformable_boundary gusseted_bag") + depth = self.side_gusset_depth.ticks + if depth <= 0: raise refused("invalid_value", "side_gusset_depth must be positive") + if depth > self.inner_dimensions.width.ticks: + raise refused("invalid_value", "side_gusset_depth exceeds inner_dimensions width") + + @property + def side_gusset_ticks(self) -> int: + """The gusset depth G in ticks; 0 for a flat bag and every rigid container.""" + return 0 if self.side_gusset_depth is None else self.side_gusset_depth.ticks + + def _require_pallet_fields(self) -> None: + """The fields a pallet with an overhang limit refuses: each reads the declared frame + or a wall the widened frame moves (docs/PALLET-AND-ROBOT-RULES.md ""). The JSON + path refuses the same combinations as `unsupported_feature` first.""" + fields = [name for name, present in ( + ("obstacles", bool(self.obstacles)), ("axles", self.axles is not None), + ("access_directions", bool(self.access_directions)), + ("void_fill_reserve_ratio", self.void_fill_reserve_ratio > 0), + ("made_to_measure", self.made_to_measure is not None)) if present] + if fields: + raise refused("not_allowed", f"{fields[0]} cannot be combined with pallet_overhang_limit") + + @property + def declared_inner(self) -> Dimensions: + """The inner size the caller declared: a solving frame's deck, else `inner_dimensions`.""" + if self.deck is None: return self.inner_dimensions + return Dimensions(Length(self.deck.length), Length(self.deck.width), self.inner_dimensions.height) + @property def is_flat_bag(self) -> bool: return self.deformable_boundary is not None @@ -542,6 +603,17 @@ def _box_shape(box: AxisAlignedBox) -> "hull.HullShape": box.dimensions.height.ticks) +def placement_id(container_id: str, position: Point, item_type: str, orientation: str) -> str: + """A placement's cross-language id (1.10): the RFC 8785 canonical JSON text of + `[container id, x, y, z, item type, orientation]`, with the physical position in ticks as + integers. Two valid placements never share that tuple -- the same solid in the same place + would coincide -- and canonical JSON spells any id with no grammar of its own to escape. + A position is below its container's side, itself at most 2^53 - 1 ticks, so every integer + has a canonical spelling. A pure function of the placement, so a validator recomputes it. + O(length of the ids).""" + return canonical_identifier([container_id, position.x, position.y, position.z, item_type, orientation]) + + @dataclass(frozen=True, slots=True) class PackedContainer: container: Container @@ -600,14 +672,20 @@ def expand_placements(self) -> tuple[Placement, ...]: def achieved_dimensions(self) -> Dimensions | None: """The largest envelope x2, y2 and z2 of its placements from its origin: a flat bag's conformed package, or a made-to-measure box's extent before it is rounded - onto the machine grid. None for any other container. O(p).""" + onto the machine grid; a pallet's deck grown by the overhang it used, at its + load's height. None for any other container. O(p).""" + if self.container.deck is not None: + return pallet_achieved(self.container.deck, (p.envelope_box for p in self.placements)) if not (self.container.is_flat_bag or self.container.made_to_measure is not None): return None return as_dimensions(extent_of((box.x2, box.y2, box.z2) for box in (p.envelope_box for p in self.placements))) @property def billed_dimensions(self) -> Dimensions: - """What a carrier measures: a bag's achieved package, else the declared outer size.""" + """What a carrier measures: a bag's achieved package, a pallet's footprint grown by its + overhang, else the declared outer size.""" + if self.container.deck is not None: + return pallet_billed(self.container, self.container.deck, (p.envelope_box for p in self.placements)) achieved = self.achieved_dimensions if achieved is not None and self.container.is_flat_bag: return achieved return self.container.outer_dimensions or self.container.inner_dimensions @@ -681,6 +759,9 @@ class FixedPlacement: position: Point rotation: Rotation container_instance: int = 1 + #: Optional restatement of the placement's id, as a result reports it; when given it must + #: equal `derived_placement_id` (1.10). + placement_id: str | None = None # Every rule on these fields is checked where the placement sits in a request, by # `Packer.pack` (`request_objects.check_objects`), with the JSON path's refusal and pointer. @@ -688,12 +769,18 @@ class FixedPlacement: def packed_container_id(self) -> str: return f"{self.container_id}#{self.container_instance}" + @property + def derived_placement_id(self) -> str: + return placement_id(self.packed_container_id, self.position, self.item_id, self.rotation.value) + @dataclass(frozen=True, slots=True) class PackingRequest: items: tuple[Item, ...] containers: tuple[Container, ...] fixed_placements: tuple[FixedPlacement, ...] = () + #: Declared by the caller and echoed by the result; never priced, converted or ranked by. + currency: str | None = None def __post_init__(self) -> None: if not self.items: raise refused("invalid_value", "at least one item is required") diff --git a/src/packvium/nested.py b/src/packvium/nested.py index 083cfd1..3919f1f 100644 --- a/src/packvium/nested.py +++ b/src/packvium/nested.py @@ -20,11 +20,66 @@ class NestedPackingResult: class NestedPacker: + """Packs levels in order, each level's packed containers becoming the next level's items. + + `level_beam_width` K carries the K best chains from one level to the next. A + chain is a sequence of level results; it extends by packing its handoff against the + level's whole container list and, when K > 1 and the level offers more than one, against + each container alone. Chains rank by their outermost result's score, then each earlier + level's outward in, then generation order; two chains handing up the same multiset of + (container type, billed dimensions, gross weight) keep only the better. A level with no + complete candidate ends every chain, and the best incomplete one is the answer. K = 1 is + exactly the greedy chain. At most + L * K * (T + 1) level packs for L levels offering at most T containers each. + """ + + def __init__(self, level_beam_width: int = 1) -> None: + if isinstance(level_beam_width, bool) or not isinstance(level_beam_width, int) or level_beam_width < 1: + raise ValueError("level_beam_width must be a positive integer") + self.level_beam_width = level_beam_width + def pack(self, items, levels) -> NestedPackingResult: - current = tuple(items); results = [] + beam: list[tuple[PackingResult, ...]] = [()] + handoffs: list[tuple] = [tuple(items)] for level in levels: - result = Packer(level.config or PackingConfig.balanced()).pack(current, level.containers) - results.append(result) - if not result.complete: break - current = tuple(c.as_item() for c in result.containers) - return NestedPackingResult(tuple(results)) + ranked = self._ranked_extensions(level, beam, handoffs) + complete = [chain for chain in ranked if chain[-1].complete] + if not complete: + return NestedPackingResult(ranked[0]) + beam = self._distinct_handoffs(complete) + handoffs = [tuple(c.as_item() for c in chain[-1].containers) for chain in beam] + return NestedPackingResult(beam[0]) + + def _ranked_extensions(self, level: PackingLevel, beam, handoffs) -> list[tuple[PackingResult, ...]]: + packer = Packer(level.config or PackingConfig.balanced()) + extensions = [chain + (result,) + for chain, items in zip(beam, handoffs) + for result in self._candidates(packer, items, tuple(level.containers))] + # Sorting is stable, so generation order is the final tie-break. + return sorted(extensions, key=lambda chain: tuple(result.score for result in reversed(chain))) + + def _candidates(self, packer: Packer, items: tuple, containers: tuple) -> list[PackingResult]: + offers = [containers] + if self.level_beam_width > 1 and len(containers) > 1: + offers.extend((container,) for container in containers) + return [packer.pack(items, offer) for offer in offers] + + def _distinct_handoffs(self, ranked: list[tuple[PackingResult, ...]]) -> list[tuple[PackingResult, ...]]: + kept: list[tuple[PackingResult, ...]] = [] + seen: set[tuple] = set() + for chain in ranked: + handoff = self._handoff_key(chain[-1]) + if handoff in seen: + continue + seen.add(handoff) + kept.append(chain) + if len(kept) == self.level_beam_width: + break + return kept + + @staticmethod + def _handoff_key(result: PackingResult) -> tuple: + # A multiset: instance numbers and emission order are not part of a packing's + # contract (conformance/canonical.py), so two engines must collapse the same chains. + return tuple(sorted((c.container.id, c.billed_dimensions.length.ticks, c.billed_dimensions.width.ticks, + c.billed_dimensions.height.ticks, c.gross_weight.ticks) for c in result.containers)) diff --git a/src/packvium/nesting.py b/src/packvium/nesting.py index 60fd084..e306d74 100644 --- a/src/packvium/nesting.py +++ b/src/packvium/nesting.py @@ -134,7 +134,7 @@ def item_occupied_volume(instance: "ItemInstance") -> int: """The volume `occupied_volume` counts for this item before it is placed, in any orientation. A hull occupies its authored solid, and a proper rotation keeps that volume, so one - orientation answers for all six through the memoised shape: `O(1)` after the first call. + orientation answers for all 24 through the memoised shape: `O(1)` after the first call. A compressible item keeps its declared box: collision and support use the uncompressed box, so placed boxes never share space and their declared volumes stay additive. """ diff --git a/src/packvium/orientations.py b/src/packvium/orientations.py new file mode 100644 index 0000000..f69240c --- /dev/null +++ b/src/packvium/orientations.py @@ -0,0 +1,96 @@ +"""The 24 proper rotations an item may be packed in, in canonical table order (1.10). + +A box has six distinguishable orientations and a convex hull has 24, so the vocabulary is the +six legacy codes followed by 18 signed ones. A legacy code names the declared side each +container axis takes (`LHW`: x spans the length, y the height, z the width) and keeps the +matrix it has always had: an even permutation carries no sign, an odd one negates x, so all +six are proper rotations. A signed code spells the sign out per axis -- `-L-W+H` is the +half-turn about the vertical -- and only a `convex_hull` may ask for one, because on a box it +is a legacy box under another name. + +The table order is the tie-break order everywhere: the six legacy codes, then for each +permutation in that order its three remaining determinant +1 sign patterns, even permutations +in the order `+++, +--, -+-, --+` and odd ones in `-++, +-+, ++-, ---`, the legacy pattern +skipped. Codes are never compared as strings. This module is a leaf: the hull geometry and +the box geometry both read it, so the two can never disagree on what a code means. +""" + +from __future__ import annotations + +from typing import Dict, Optional, Tuple + +Axes = Tuple[int, int, int] +Signs = Tuple[int, int, int] + +#: The declared sides in the order a source axis index names them. +SIDES = "LWH" + +LEGACY_CODES = ("LWH", "LHW", "WLH", "WHL", "HLW", "HWL") + +_EVEN_PATTERNS: Tuple[Signs, ...] = ((1, 1, 1), (1, -1, -1), (-1, 1, -1), (-1, -1, 1)) +_ODD_PATTERNS: Tuple[Signs, ...] = ((-1, 1, 1), (1, -1, 1), (1, 1, -1), (-1, -1, -1)) + + +def _axes_of(legacy: str) -> Axes: + first, second, third = (SIDES.index(side) for side in legacy) + return first, second, third + + +def _is_odd(axes: Axes) -> bool: + return sum(1 for first in range(3) for second in range(first + 1, 3) + if axes[first] > axes[second]) % 2 == 1 + + +def _legacy_signs(axes: Axes) -> Signs: + return (-1, 1, 1) if _is_odd(axes) else (1, 1, 1) + + +def _signed_code(axes: Axes, signs: Signs) -> str: + return "".join(("+" if sign > 0 else "-") + SIDES[axis] for axis, sign in zip(axes, signs)) + + +def _build() -> Tuple[Tuple[str, ...], Dict[str, Axes], Dict[str, Signs]]: + codes = list(LEGACY_CODES) + axes_of = {code: _axes_of(code) for code in LEGACY_CODES} + signs_of = {code: _legacy_signs(axes_of[code]) for code in LEGACY_CODES} + for legacy in LEGACY_CODES: + axes = axes_of[legacy] + for signs in _ODD_PATTERNS if _is_odd(axes) else _EVEN_PATTERNS: + if signs == signs_of[legacy]: + continue + code = _signed_code(axes, signs) + codes.append(code) + axes_of[code] = axes + signs_of[code] = signs + return tuple(codes), axes_of, signs_of + + +#: Every code in canonical table order; `SOURCE_AXES[code][i]` is the declared side container +#: axis `i` takes its extent from and `AXIS_SIGNS[code][i]` the sign it carries. +CODES, SOURCE_AXES, AXIS_SIGNS = _build() + +SIGNED_CODES = CODES[len(LEGACY_CODES):] + +#: The codes that keep the item's own height vertical and pointing up: z takes `+H`. +UPRIGHT_CODES = tuple(code for code in CODES if SOURCE_AXES[code][2] == 2 and AXIS_SIGNS[code][2] == 1) + +#: What a signed code on anything but a hull is refused with, at its own pointer. +SIGNED_CODE_NEEDS_A_HULL = "a signed rotation code requires shape_type convex_hull" + + +def vocabulary(hull: bool) -> Tuple[str, ...]: + """The codes an item may name: all 24 for a hull, the six legacy codes for anything else.""" + return CODES if hull else LEGACY_CODES + + +def refusal_reason(value: object, hull: bool) -> Optional[str]: + """Why `value` cannot orient an item of this shape, or None when it can. + + `invalid_value` for a signed code beside a non-hull -- a rule broken, not an unknown word -- + and `not_allowed` for anything outside the item's vocabulary. Every field that names an + orientation of a requested item (`allowed_rotations`, `fixed_placements[].orientation`, a + revision's `placement.orientation`) reads it through here. O(1). + """ + if not hull and value in SIGNED_CODES: + return "invalid_value" + return None if value in vocabulary(hull) else "not_allowed" diff --git a/src/packvium/outcomes.py b/src/packvium/outcomes.py index a064bab..930ef4f 100644 --- a/src/packvium/outcomes.py +++ b/src/packvium/outcomes.py @@ -39,6 +39,17 @@ from enum import Enum from typing import Any, Mapping, Optional +__all__ = [ + "ALLOWED_FIELDS", + "DuplicateEventMismatchError", + "OutcomeError", + "OutcomeEvent", + "OutcomeEventNotFoundError", + "OutcomeEventType", + "OutcomeLedger", + "UnsupportedOutcomeFieldError", +] + # --------------------------------------------------------------------------------- errors diff --git a/src/packvium/packer.py b/src/packvium/packer.py index 195958e..d726721 100644 --- a/src/packvium/packer.py +++ b/src/packvium/packer.py @@ -9,6 +9,7 @@ resolve_objective_scorer, unpriceable_container) from .fixed_placements import admit_fixed_placements from .models import Container, Item, PackingRequest, UnratedWeightError +from .pallet_overhang import solving_frame from .request_objects import check_objects from .result import AlgorithmReport, PackingResult, PackingStatus, ResultFact from .result import aggregate_termination, derive_result_facts, unsupported_placements_warning @@ -45,18 +46,22 @@ def _objective_bound(self, request: PackingRequest) -> "tuple[int, ...] | None": return None return bounds.for_request(request.instances, request.containers, self.config.objective) - def pack(self, items, containers, fixed_placements=()) -> PackingResult: + def pack(self, items, containers, fixed_placements=(), currency: str | None = None) -> PackingResult: items, containers, fixed_placements = tuple(items), tuple(containers), tuple(fixed_placements) # The JSON path's rules, with the JSON path's reason and field: a request # built from objects is refused exactly where the same request as JSON would be. - check_objects(items, containers, self.config, fixed_placements) - return self._pack_checked(items, containers, fixed_placements) + check_objects(items, containers, self.config, fixed_placements, currency) + return self._pack_checked(items, containers, fixed_placements, currency) - def _pack_checked(self, items: tuple, containers: tuple, fixed_placements: tuple) -> PackingResult: + def _pack_checked(self, items: tuple, containers: tuple, fixed_placements: tuple, + currency: str | None = None) -> PackingResult: """`pack` once the request's rules have passed. `pack_from_dict` calls it directly: `check_request` has walked that request already, and `check_objects` refuses only what those rules refuse, so a second walk could never fire there.""" - request = PackingRequest(items, containers, fixed_placements) + # A pallet with an overhang limit is packed as its widened frame from here on, so no + # solver, bound or validator needs to know the limit exists. + request = PackingRequest(items, tuple(solving_frame(c) for c in containers), fixed_placements, + currency) # Both weight objectives price the same billed weight, so both need the divisor # up front -- a wrong guess would silently misprice every shipment. And rating # some containers while others carry no tariff would rank a priced packing @@ -181,12 +186,13 @@ def _pack_checked(self, items: tuple, containers: tuple, fixed_placements: tuple termination=termination, optimality=optimality, objective=self.config.objective, + currency=request.currency, )) if not ranked: return PackingResult( PackingStatus.INFEASIBLE, (), (), AlgorithmReport(self.config.profile.value, "none", deadline.elapsed_ms, self.config.seed), - (0,), objective=self.config.objective, + (0,), objective=self.config.objective, currency=request.currency, ) ranked.sort(key=lambda r: (r.status is PackingStatus.INVALID_RESULT, r.score, r.algorithm.solver)) valid_ranked = [result for result in ranked if result.status is not PackingStatus.INVALID_RESULT] @@ -224,6 +230,7 @@ def _pack_checked(self, items: tuple, containers: tuple, fixed_placements: tuple best.termination, best.optimality, best.objective, + currency=request.currency, ) diff --git a/src/packvium/pallet_overhang.py b/src/packvium/pallet_overhang.py new file mode 100644 index 0000000..f10cb6b --- /dev/null +++ b/src/packvium/pallet_overhang.py @@ -0,0 +1,139 @@ +"""Pallet overhang (docs/PALLET-AND-ROBOT-RULES.md "container.pallet_overhang_limit"). + +A container with `pallet_overhang_limit` `(l, w)` is a deck `L x W`, its `inner_dimensions`, +that a load may overhang by at most `l` past each end and `w` past each side. Every solver +packs the solving frame `(L + 2l) x (W + 2w) x H`, whose origin is the corner of the overhang +zone, so the deck lies on `[l, l + L] x [w, w + W]` and every coordinate stays non-negative. + +Only the deck carries weight. A placement on the floor rests on its overlap with the deck, +which `minimum_support_ratio` and the centre-of-gravity rule judge exactly as they judge a +supporting box; at a ratio of 0 nothing is enforced, as everywhere else. The packed pallet +is billed and handed up at the size it reached: its declared footprint (outer if stated, +else inner) grown by the overhang actually used past each edge. Exact integers throughout. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import replace +from typing import TYPE_CHECKING + +from ._compat import dataclass +from .geometry import AxisAlignedBox, Dimensions, Point +from .units import Length + +if TYPE_CHECKING: + from .models import Container + + +@dataclass(frozen=True, slots=True) +class OverhangLimit: + """How far a load may pass the deck edge, per end along `length` and per side along `width`. + Zero forbids overhang on that axis; it is not the same as the field being absent.""" + + length: Length + width: Length + + +@dataclass(frozen=True, slots=True) +class Deck: + """The weight-bearing footprint inside a solving frame, in frame ticks.""" + + x: int + y: int + length: int + width: int + + @property + def x2(self) -> int: return self.x + self.length + @property + def y2(self) -> int: return self.y + self.width + + def as_box(self) -> AxisAlignedBox: + """The deck as a one-tick slab under the floor: only its footprint is ever read.""" + return AxisAlignedBox(Point(self.x, self.y, 0), Dimensions(Length(self.length), Length(self.width), Length(1))) + + +def solving_frame(container: "Container") -> "Container": + """The container every solver packs: itself without a limit, else the widened frame + carrying its deck. The frame states no limit, so a second call returns it unchanged.""" + limit = container.pallet_overhang_limit + if limit is None: + return container + inner = container.inner_dimensions + overhang_x, overhang_y = limit.length.ticks, limit.width.ticks + frame = Dimensions(Length(inner.length.ticks + 2 * overhang_x), + Length(inner.width.ticks + 2 * overhang_y), inner.height) + deck = Deck(overhang_x, overhang_y, inner.length.ticks, inner.width.ticks) + return replace(container, inner_dimensions=frame, pallet_overhang_limit=None, deck=deck) + + +def deck_alone(frame: "Container") -> "Container": + """A solving frame's container as declared, without the overhang zone: the rung a pallet + trial packs first (`SolverOrchestrator._pallet_trial`).""" + deck = frame.deck + assert deck is not None + inner = Dimensions(Length(deck.length), Length(deck.width), frame.inner_dimensions.height) + return replace(frame, inner_dimensions=inner, deck=None) + + +def centring_offset(deck: Deck, boxes: Iterable[AxisAlignedBox]) -> tuple[int, int]: + """The shift that seats a frame trial's load with the least overhang. Per axis: flush with + the deck's near edge when the load is no longer than the deck, else centred on the deck + with an odd tick past the far edge. Either way the load stays inside the frame, since its + span is at most the deck plus both allowances. O(p); `(0, 0)` for none.""" + boxes = tuple(boxes) + if not boxes: + return 0, 0 + return (_seat(deck.x, deck.length, min(b.origin.x for b in boxes), max(b.x2 for b in boxes)), + _seat(deck.y, deck.width, min(b.origin.y for b in boxes), max(b.y2 for b in boxes))) + + +def _seat(start: int, length: int, low: int, high: int) -> int: + span = high - low + target = start if span <= length else start - (span - length) // 2 + return target - low + + +def deck_contact_area(deck: Deck, box: AxisAlignedBox) -> int: + """The area of a floor placement's footprint that rests on the deck. O(1).""" + dx = min(box.x2, deck.x2) - max(box.origin.x, deck.x) + dy = min(box.y2, deck.y2) - max(box.origin.y, deck.y) + return dx * dy if dx > 0 and dy > 0 else 0 + + +def floor_support_ratio(deck: "Deck | None", box: AxisAlignedBox) -> float: + """The support ratio a placement on the floor reports: whole without a deck.""" + if deck is None: + return 1.0 + return deck_contact_area(deck, box) / box.dimensions.base_area + + +def overhang_used(deck: Deck, boxes: Iterable[AxisAlignedBox]) -> tuple[int, int]: + """How far the boxes pass the deck in total along each axis: both ends, then both sides. + O(p); `(0, 0)` for none.""" + low_x, low_y, high_x, high_y = deck.x, deck.y, deck.x2, deck.y2 + for box in boxes: + low_x, low_y = min(low_x, box.origin.x), min(low_y, box.origin.y) + high_x, high_y = max(high_x, box.x2), max(high_y, box.y2) + return (deck.x - low_x) + (high_x - deck.x2), (deck.y - low_y) + (high_y - deck.y2) + + +def achieved_dimensions(deck: Deck, boxes: Iterable[AxisAlignedBox]) -> Dimensions: + """The deck grown by the overhang used, at the load's height: the footprint and height + the loaded pallet reaches. A packed container holds at least one box. O(p).""" + boxes = tuple(boxes) + over_x, over_y = overhang_used(deck, boxes) + return Dimensions(Length(deck.length + over_x), Length(deck.width + over_y), + Length(max(box.z2 for box in boxes))) + + +def billed_dimensions(container: "Container", deck: Deck, boxes: Iterable[AxisAlignedBox]) -> Dimensions: + """Per horizontal axis the larger of the declared footprint, outer if stated, and the deck + grown by the overhang used; the declared height. An outer margin is not positioned + against the deck, so it is never added to the overhang. O(p).""" + base = container.outer_dimensions or Dimensions(Length(deck.length), Length(deck.width), + container.inner_dimensions.height) + over_x, over_y = overhang_used(deck, boxes) + return Dimensions(Length(max(base.length.ticks, deck.length + over_x)), + Length(max(base.width.ticks, deck.width + over_y)), base.height) diff --git a/src/packvium/pareto.py b/src/packvium/pareto.py index 4676519..45a4166 100644 --- a/src/packvium/pareto.py +++ b/src/packvium/pareto.py @@ -27,6 +27,16 @@ from ._compat import dataclass from typing import Mapping, Optional, Sequence +__all__ = [ + "CandidateResult", + "InconsistentAxesError", + "NonFiniteMetricError", + "ParetoReportError", + "ProfileReport", + "dominates", + "generate_report", +] + class ParetoReportError(Exception): """Base class for every error raised by this module.""" diff --git a/src/packvium/rebalance.py b/src/packvium/rebalance.py index 2ae1316..f6d144e 100644 --- a/src/packvium/rebalance.py +++ b/src/packvium/rebalance.py @@ -12,6 +12,7 @@ UnratedWeightError) from .solvers import (ContainerState, Deadline, SearchStats, TimeLimitReached, default_constraints, find_candidates) +from .pallet_overhang import Deck, floor_support_ratio from .units import Weight from .validation import IndependentSolutionValidator @@ -47,7 +48,7 @@ def _refresh_top_loads(placements: tuple[Placement, ...]) -> tuple[Placement, .. def _support_ratio( existing: tuple[Placement, ...], instance: ItemInstance, origin: Point, - envelope_dimensions: Dimensions, + envelope_dimensions: Dimensions, deck: Deck | None, ) -> float: """Fraction of the base resting on something already in the container. @@ -56,9 +57,9 @@ def _support_ratio( depending on `solvers.py`'s private placement-time bookkeeping, only on the same public contact-area rule it uses. """ - if origin.z == 0: - return 1.0 box = AxisAlignedBox(origin, envelope_dimensions) + if origin.z == 0: + return floor_support_ratio(deck, box) return direct_support_view(existing, instance, box).supporting_area / envelope_dimensions.base_area @@ -105,7 +106,8 @@ def _attempt_move( new_dest_placement = Placement( moving.instance, candidate.position, candidate.rotation, candidate.dimensions, candidate.point, candidate.envelope_dimensions, - _support_ratio(dest.placements, moving.instance, candidate.point, candidate.envelope_dimensions), + _support_ratio(dest.placements, moving.instance, candidate.point, candidate.envelope_dimensions, + dest.container.deck), ) new_source_placements = source.placements[:placement_index] + source.placements[placement_index + 1:] new_dest_placements = (*dest.placements, new_dest_placement) diff --git a/src/packvium/recommendations.py b/src/packvium/recommendations.py index f580fe9..91951d7 100644 --- a/src/packvium/recommendations.py +++ b/src/packvium/recommendations.py @@ -28,6 +28,17 @@ ) from .simulation import MismatchedOrderCorpusError, ScenarioResult, compare_scenarios +__all__ = [ + "ApprovalRecord", + "ExpectedDelta", + "Recommendation", + "RecommendationError", + "approve", + "approve_catalog", + "approve_policy", + "propose_recommendation", +] + class RecommendationError(Exception): """Base class for every recommendation-domain error raised by this module.""" diff --git a/src/packvium/request_errors.py b/src/packvium/request_errors.py index e6b3a2e..2b710f1 100644 --- a/src/packvium/request_errors.py +++ b/src/packvium/request_errors.py @@ -12,8 +12,8 @@ - `detail`: the fixed text for the reason, so the message is identical in every engine. `check_request` walks the schema's rules over the raw JSON before any model is built, and the -first violation wins, in a fixed order every engine follows: units, output, configuration, -items, containers. The floors are the ones the request schema declares, and a cross-engine suite +first violation wins, in a fixed order every engine follows: units, output, currency, +configuration, items, containers. The floors are the ones the request schema declares, and a cross-engine suite holds all four engines to the same reason, field and message. """ @@ -23,9 +23,10 @@ from ._canonical_json import MAX_EXACT_MAGNITUDE, json_integer, json_spelling from ._request_error import InvalidRequestError -from .flat_bag import DEFORMABLE_BOUNDARY_KINDS +from .flat_bag import DEFORMABLE_BOUNDARY_KINDS, GUSSETED_BAG from .units import LENGTH_UNIT_CODES, WEIGHT_UNIT_CODES, Length, ParsedMeasures, Weight from .made_to_measure import box_cost_minor_for +from .orientations import CODES, UPRIGHT_CODES, SIGNED_CODE_NEEDS_A_HULL, refusal_reason, vocabulary from .bounds import MAX_BOUND_SUM REASONS = ("missing_field", "wrong_type", "below_minimum", "above_maximum", "negative_measure", @@ -37,7 +38,8 @@ ACCESS_DIRECTIONS = ("+x", "-x", "+y", "-y", "+z", "-z") #: The schema's closed vocabularies, each refused as `not_allowed` like the two above. SOLVERS = ("grid", "extreme_points", "homogeneous_blocks", "layer", "maximal_spaces", "exact_small") -ROTATIONS = ("LWH", "LHW", "WLH", "WHL", "HLW", "HWL") +#: All 24 proper rotations in table order (1.10); the 18 signed ones need a `convex_hull`. +ROTATIONS = CODES LENGTH_UNITS = LENGTH_UNIT_CODES WEIGHT_UNITS = WEIGHT_UNIT_CODES GROUND_CONTACT_RULES = ("free", "covered", "single", "multiple") @@ -195,6 +197,8 @@ def check_request(data: Any) -> ParsedMeasures: request = _object(data, "") unit = _check_units(request.get("units")) _check_output(request.get("output")) + currency = request.get("currency") + _optional(request, "currency", "", currency_code) _check_configuration(request.get("configuration"), unit, measures) items = _required_list(request, "items", "") for index, raw in enumerate(items): @@ -202,7 +206,7 @@ def check_request(data: Any) -> ParsedMeasures: _require_unique_ids(items, "items") containers = _required_list(request, "containers", "") for index, raw in enumerate(containers): - _check_container(raw, pointer("containers", index), unit, measures) + _check_container(raw, pointer("containers", index), unit, measures, currency) _require_unique_ids(containers, "containers") _total_weight(items, containers, measures) return measures @@ -212,6 +216,8 @@ def _check_units(raw: Any) -> str: if raw is None: return "mm" units = _object(raw, "/units") + # `{"length": "cm", "weight": "kg"}` used to read every bare weight as grams. + _known_fields(units, "/units", ("length",)) length = units.get("length") if length is None: return "mm" @@ -273,11 +279,38 @@ def _check_item(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> No _optional(item, "compression_ratio", where, lambda v, f: _ratio(v, f, None)) _optional(item, "stacking_group", where, _stacking_group_name) _optional(item, "accepts_stacking_groups", where, _stacking_group_names) - _optional(item, "allowed_rotations", where, lambda v, f: _each_one_of(v, f, ROTATIONS)) + _optional(item, "allowed_rotations", where, lambda v, f: _allowed_rotations(v, f, item)) _optional(item, "ground_contact_rule", where, lambda v, f: _one_of(v, f, GROUND_CONTACT_RULES)) _optional(item, "shape_type", where, lambda v, f: _one_of(v, f, SHAPE_TYPES)) for name in ITEM_BOOLEANS: _optional(item, name, where, _boolean) + listed = item.get("allowed_rotations") + if listed is not None and not any(not item.get("keep_upright") or code in UPRIGHT_CODES for code in listed): + raise InvalidRequestError("invalid_value", pointer_join(where, "allowed_rotations"), + "must allow at least one rotation after keep_upright") + + +def _allowed_rotations(raw: Any, where: str, item: Mapping[str, Any]) -> None: + """Each code from the item's vocabulary (1.10): all 24 for a hull, the six legacy codes + for anything else, where a signed code is a rule broken rather than an unknown word. The + shape is read as written, so a shape no engine knows is not a hull. O(len(raw)).""" + hull = item.get("shape_type") == "convex_hull" + for index, value in enumerate(_list(raw, where)): + refuse_orientation(value, hull, pointer_join(where, index), InvalidRequestError) + + +def refuse_orientation(value: Any, hull: bool, field: str, + error: Callable[[str, str, str], Exception], where: str = "") -> None: + """Raise `error(reason, field, detail)` when `value` cannot orient an item of this shape + (`orientations.refusal_reason`). `where` names the value inside the detail, the way a fixed + placement or a revision event words its own refusals. Shared by every field that names a + requested item's orientation. O(1).""" + reason = refusal_reason(value, hull) + if reason == "invalid_value": + raise error(reason, field, f"{where}: {SIGNED_CODE_NEEDS_A_HULL}" if where else SIGNED_CODE_NEEDS_A_HULL) + if reason is not None: + rule = f"must be one of {json_spelling(list(vocabulary(hull)))}" + raise error(reason, field, f"{where} {rule}" if where else rule) def _nesting_below_height(value: Any, field: str, item: Mapping[str, Any], unit: str, @@ -289,7 +322,8 @@ def _nesting_below_height(value: Any, field: str, item: Mapping[str, Any], unit: raise InvalidRequestError("invalid_value", field, "must be less than the item's height") -def _check_container(raw: Any, where: str, unit: str, measures: ParsedMeasures) -> None: +def _check_container(raw: Any, where: str, unit: str, measures: ParsedMeasures, + currency: Optional[str]) -> None: container = _object(raw, where) _required_string(container, "id", where) _optional(container, "quantity", where, lambda v, f: _integer(v, f, 1)) @@ -302,30 +336,75 @@ def _check_container(raw: Any, where: str, unit: str, measures: ParsedMeasures) _optional(container, "void_fill_reserve_ratio", where, _ratio) _optional(container, "access_directions", where, _access_directions) _optional(container, "tag_limits", where, _tag_limits) - _optional(container, "rate_table", where, _rate_table) + _optional(container, "rate_table", where, lambda v, f: _rate_table(v, f, currency)) _optional(container, "obstacles", where, lambda v, f: _obstacles( v, f, unit, measures, _ticks(container["inner_dimensions"], unit, measures))) _optional(container, "deformable_boundary", where, - lambda v, f: _deformable_boundary(container, v, f, where)) + lambda v, f: _deformable_boundary(container, v, f, where, unit, measures)) _optional(container, "made_to_measure", where, lambda v, f: _made_to_measure(container, v, f, where, unit, measures)) + _optional(container, "pallet_overhang_limit", where, + lambda v, f: _pallet_overhang_limit(container, v, f, unit, measures)) + + +def _pallet_overhang_limit(container: Mapping[str, Any], raw: Any, field: str, unit: str, + measures: ParsedMeasures) -> None: + """Both axes, each a non-negative length, and a solving frame inside the admission + ceiling. O(1).""" + limit = _object(raw, field) + _known_fields(limit, field, PALLET_OVERHANG_FIELDS) + for axis in PALLET_OVERHANG_FIELDS: + _measure(_required(limit, axis, field), pointer_join(field, axis), Length, unit, measures) + inner = _ticks(container["inner_dimensions"], unit, measures) + pallet_frame(inner, measures.read(Length, limit["length"], unit).ticks, + measures.read(Length, limit["width"], unit).ticks, field) + + +#: The two axes `pallet_overhang_limit` requires, in the order it is checked. +PALLET_OVERHANG_FIELDS = ("length", "width") + + +def pallet_frame(inner: Tuple[int, int, int], overhang_x: int, overhang_y: int, field: str) -> None: + """The widened frame a pallet is packed in, held to the same ceiling as any box. + Shared with `request_objects`.""" + box_ticks((inner[0] + 2 * overhang_x, inner[1] + 2 * overhang_y, inner[2]), field) #: Container fields a flat bag refuses, in reporting order (docs/DEFORMABLE-CONTAINERS.md). BAG_EXCLUSIONS = ("obstacles", "axles", "access_directions", "max_stack_density", - "outer_dimensions", "void_fill_reserve_ratio", "made_to_measure") + "outer_dimensions", "void_fill_reserve_ratio", "made_to_measure", + "pallet_overhang_limit") -def _deformable_boundary(container: Mapping[str, Any], raw: Any, field: str, where: str) -> None: +def _deformable_boundary(container: Mapping[str, Any], raw: Any, field: str, where: str, + unit: str, measures: ParsedMeasures) -> None: + """The boundary object top to bottom -- kind, unknown keys, the gusset -- then the + container fields a bag refuses. O(1).""" boundary = _object(raw, field) - _one_of(_required(boundary, "kind", field), pointer_join(field, "kind"), DEFORMABLE_BOUNDARY_KINDS) - _known_fields(boundary, field, ("kind",)) + kind = _required(boundary, "kind", field) + _one_of(kind, pointer_join(field, "kind"), DEFORMABLE_BOUNDARY_KINDS) + gusseted = kind == GUSSETED_BAG + _known_fields(boundary, field, ("kind", "side_gusset_depth") if gusseted else ("kind",)) + if gusseted: + _side_gusset_depth(container, _required(boundary, "side_gusset_depth", field), + pointer_join(field, "side_gusset_depth"), unit, measures) for name in BAG_EXCLUSIONS: if _set_beside_a_bag(name, container.get(name)): raise InvalidRequestError("not_allowed", pointer_join(where, name), "cannot be combined with deformable_boundary") +def _side_gusset_depth(container: Mapping[str, Any], value: Any, field: str, unit: str, + measures: ParsedMeasures) -> None: + """A length, positive once read in ticks, and no deeper than the bag's inner width.""" + _measure(value, field, Length, unit, measures) + depth = measures.read(Length, value, unit).ticks + if depth == 0: + raise InvalidRequestError("invalid_value", field, "must be positive") + if depth > _ticks(container["inner_dimensions"], unit, measures)[1]: + raise InvalidRequestError("invalid_value", field, "exceeds inner_dimensions width") + + #: Every key the request schema's `made_to_measure` declares; it sets `additionalProperties: false`. MADE_TO_MEASURE_FIELDS = ("cost_per_square_metre_minor", "increment", "minimum", "outer_allowance", "tare_per_square_metre") @@ -468,7 +547,7 @@ def _tag_limits(raw: Any, where: str) -> None: _integer(limits[tag], pointer_join(where, tag), 1) -def _rate_table(raw: Any, where: str) -> None: +def _rate_table(raw: Any, where: str, currency: Optional[str]) -> None: table = _object(raw, where) for name, minimum in (("weight_brackets_g", 1), ("prices_minor", 0)): values = table.get(name) @@ -477,6 +556,30 @@ def _rate_table(raw: Any, where: str) -> None: _integer(value, pointer_join(where, name, index), minimum) _optional(table, "minimum_charge_minor", where, lambda v, f: _integer(v, f, 0)) _optional(table, "fuel_surcharge_permille", where, lambda v, f: _integer(v, f, 0)) + _optional(table, "currency", where, lambda v, f: rate_table_currency(v, f, currency)) + + +def currency_code(value: Any, field: str) -> None: + """An ISO 4217 shape, `^[A-Z]{3}$`, with no code list: the engines never convert, so a + code they do not know is still one they can echo (docs/SERIALIZATION.md). ASCII letters + only, and no trailing newline, which a `$` would let through in some regex dialects. O(1).""" + if not isinstance(value, str): + raise InvalidRequestError("wrong_type", field, "must be a string") + if len(value) != 3 or any(not "A" <= letter <= "Z" for letter in value): + raise InvalidRequestError("invalid_value", field, "must be three capital letters (ISO 4217)") + + +def rate_table_currency(value: Any, field: str, currency: Optional[str]) -> None: + """A rate table prices in the request's currency or says nothing: a table in another one + would be ranked as though its minor units were the request's. Shared with + `request_objects`. O(1).""" + currency_code(value, field) + if currency is None: + raise InvalidRequestError("missing_field", "/currency", + "is required when a rate_table declares a currency") + if value != currency: + raise InvalidRequestError("not_allowed", field, + f"must equal the request currency {json_spelling(currency)}") def _obstacles(raw: Any, where: str, unit: str, measures: ParsedMeasures, diff --git a/src/packvium/request_objects.py b/src/packvium/request_objects.py index 4dabf96..e69f9d4 100644 --- a/src/packvium/request_objects.py +++ b/src/packvium/request_objects.py @@ -2,8 +2,8 @@ `check_request` walks a JSON request; `Packer.pack` takes objects, whose own constructors already refuse most of the same values but cannot know where in a request they will sit. This walks the -objects in the JSON rules' order -- configuration, items, item ids, containers, container ids, -fixed placements -- with the same primitives, so a value the JSON path refuses is refused here with the same reason +objects in the JSON rules' order -- currency, configuration, items, item ids, containers, +container ids, fixed placements -- with the same primitives, so a value the JSON path refuses is refused here with the same reason and the same RFC 6901 field. Only rules `check_request` also applies are checked, so on the JSON path, which runs `check_request` first, this never refuses anything. @@ -18,14 +18,15 @@ from ._canonical_json import MAX_EXACT_MAGNITUDE, json_integer, json_spelling from .config import PackingConfig -from .fixed_placements import FixedPlacementError, orientation_rule +from .fixed_placements import FixedPlacementError, orientation_rule, require_orientation, require_placement_id from .flat_bag import DEFORMABLE_BOUNDARY_KINDS -from .geometry import Point, Rotation +from .geometry import Point, Rotation, ShapeType from .made_to_measure import MadeToMeasure, as_triple, box_cost_minor_for from .models import Container, FixedPlacement, Item, RateTable from .request_errors import (BAG_EXCLUSIONS, InvalidRequestError, _integer, _one_of, _ratio, _set_beside_a_bag, _stacking_group_name, box_ticks, - length_ticks, pointer, pointer_join, total_weight_ticks, weight_ticks) + currency_code, length_ticks, pallet_frame, pointer, pointer_join, + rate_table_currency, total_weight_ticks, weight_ticks) #: (JSON name, PackingConfig attribute, floor), in `check_request`'s order. _CONFIGURATION_INTEGERS = (("time_limit_ms", "time_limit_ms", 1), ("alternatives", "top_k", 1), @@ -42,19 +43,35 @@ def check_objects(items: Sequence[Item], containers: Sequence[Container], config: PackingConfig, - fixed_placements: Sequence[FixedPlacement] = ()) -> None: + fixed_placements: Sequence[FixedPlacement] = (), currency: Optional[str] = None) -> None: """Refuse the request with its first violation, or return.""" + if currency is not None: + currency_code(currency, "/currency") _check_configuration(config) for index, item in enumerate(items): _check_item(item, pointer("items", index)) _require_unique_ids(items, "items") for index, container in enumerate(containers): - _check_container(container, pointer("containers", index)) + _check_container(container, pointer("containers", index), currency) _require_unique_ids(containers, "containers") + _check_routes_beside_pallets(items, containers) total_weight_ticks(((item.weight.ticks, item.quantity) for item in items), (container.tare_weight.ticks for container in containers)) + hull_types = frozenset(item.id for item in items if item.shape_type is ShapeType.CONVEX_HULL) for index, placement in enumerate(fixed_placements): - _check_fixed_placement(placement, f"fixed_placements[{index}]", pointer("fixed_placements", index)) + _check_fixed_placement(placement, f"fixed_placements[{index}]", pointer("fixed_placements", index), + hull_types) + + +def _check_routes_beside_pallets(items: Sequence[Item], containers: Sequence[Container]) -> None: + """A routed item beside a pallet that allows overhang: the JSON path refuses it + as `container.pallet_overhang_limit+item.stop_index`, this one at the first routed item.""" + if not any(container.pallet_overhang_limit is not None for container in containers): + return + for index, item in enumerate(items): + if item.stop_index is not None: + raise InvalidRequestError("not_allowed", pointer_join(pointer("items", index), "stop_index"), + "cannot be combined with pallet_overhang_limit") def _check_configuration(config: PackingConfig) -> None: @@ -90,7 +107,7 @@ def _check_item(item: Item, where: str) -> None: _stacking_group_name(name, pointer_join(accepted, index)) -def _check_container(container: Container, where: str) -> None: +def _check_container(container: Container, where: str, currency: Optional[str]) -> None: _string_id(container.id, where) _optional(container.quantity, pointer_join(where, "quantity"), 1) box_ticks(as_triple(container.inner_dimensions), pointer_join(where, "inner_dimensions")) @@ -104,11 +121,17 @@ def _check_container(container: Container, where: str) -> None: for tag in _code_point_order(container.tag_limits): _integer(container.tag_limits[tag], pointer_join(limits, tag), 1) if container.rate_table is not None: - _check_rate_table(container.rate_table, pointer_join(where, "rate_table")) + _check_rate_table(container.rate_table, pointer_join(where, "rate_table"), currency) if container.deformable_boundary is not None: _check_flat_bag(container, where) if container.made_to_measure is not None: _check_made_to_measure(container, container.made_to_measure, where) + if container.pallet_overhang_limit is not None: + limit = container.pallet_overhang_limit + field = pointer_join(where, "pallet_overhang_limit") + for axis in ("length", "width"): + length_ticks(getattr(limit, axis).ticks, pointer_join(field, axis)) + pallet_frame(as_triple(container.inner_dimensions), limit.length.ticks, limit.width.ticks, field) def _weights(holder: object, where: str, names: Sequence[str]) -> None: @@ -118,12 +141,14 @@ def _weights(holder: object, where: str, names: Sequence[str]) -> None: weight_ticks(weight.ticks, pointer_join(where, name)) -def _check_rate_table(table: RateTable, where: str) -> None: +def _check_rate_table(table: RateTable, where: str, currency: Optional[str]) -> None: for name, minimum in (("weight_brackets_g", 1), ("prices_minor", 0)): for index, value in enumerate(getattr(table, name)): _integer(value, pointer_join(where, name, index), minimum) _integer(table.minimum_charge_minor, pointer_join(where, "minimum_charge_minor"), 0) _integer(table.fuel_surcharge_permille, pointer_join(where, "fuel_surcharge_permille"), 0) + if table.currency is not None: + rate_table_currency(table.currency, pointer_join(where, "currency"), currency) def _check_flat_bag(container: Container, where: str) -> None: @@ -151,7 +176,8 @@ def _check_made_to_measure(container: Container, machine: MadeToMeasure, where: raise InvalidRequestError("invalid_value", price, f"prices the largest box above {MAX_EXACT_MAGNITUDE}") -def _check_fixed_placement(placement: FixedPlacement, where: str, field: str) -> None: +def _check_fixed_placement(placement: FixedPlacement, where: str, field: str, + hull_types: frozenset[str]) -> None: """`fixed_placements.require_fixed_placement_shapes`' rules that an object can break, in its order and with its refusal; whether the set can hold is `admit_fixed_placements`' to say.""" for name, value in (("item_type", placement.item_id), ("container_type", placement.container_id)): @@ -159,6 +185,8 @@ def _check_fixed_placement(placement: FixedPlacement, where: str, field: str) -> raise _malformed_placement(f"{where}.{name} is a non-empty string", pointer_join(field, name)) if not isinstance(placement.rotation, Rotation): raise _malformed_placement(f"{where}.orientation {orientation_rule()}", pointer_join(field, "orientation")) + require_orientation(placement.rotation.value, placement.item_id in hull_types, where, + pointer_join(field, "orientation")) instance = json_integer(placement.container_instance) if instance is None or instance < 1: raise _malformed_placement(f"{where}.container_instance counts from 1", @@ -172,6 +200,8 @@ def _check_fixed_placement(placement: FixedPlacement, where: str, field: str) -> value = getattr(placement.position, axis) if isinstance(value, bool) or not isinstance(value, int): raise _malformed_placement(f"{where}.position.{axis} is a measure", pointer_join(position, axis)) + require_placement_id(placement.placement_id, f"{placement.container_id}#{instance}", placement.position, + placement.item_id, placement.rotation.value, where, field) def _malformed_placement(detail: str, field: str) -> FixedPlacementError: diff --git a/src/packvium/result.py b/src/packvium/result.py index ab31ad0..cc2b4c7 100644 --- a/src/packvium/result.py +++ b/src/packvium/result.py @@ -8,7 +8,7 @@ from .axle_load import axle_reactions from .constraints import load_units, reserved_volume -from .models import PackedContainer, UnpackedItem +from .models import PackedContainer, UnpackedItem, placement_id from .units import Length @@ -195,6 +195,9 @@ class PackingResult: optimality: ResultFact | None = None objective: str = "default" catalog_versions_used: tuple[dict, ...] = () + #: The request's ISO 4217 code, echoed only when the request declares one, by the answer + #: and by every alternative alike (1.10). + currency: str | None = None def __post_init__(self) -> None: derived = derive_result_facts(self.status, not self.unpacked, self.algorithm.time_limit_reached) @@ -220,8 +223,9 @@ def complete(self) -> bool: return not self.unpacked def packed_item_count(self) -> int: return sum(c.placement_count for c in self.containers) def to_dict(self, length_unit: str = "mm", weight_unit: str = "g", include_alternatives: bool = True) -> dict: - def placement_dict(p): + def placement_dict(p, container_id): return { + "placement_id": placement_id(container_id, p.position, p.instance.item.id, p.rotation.value), "item_id": p.instance.id, "item_type": p.instance.item.id, "position": p.position.to_dict(length_unit), @@ -299,8 +303,8 @@ def axle_dict(container): "score": list(self.score), "containers": [{ "id": c.id, "container_type": c.container.id, - "inner_dimensions": c.container.inner_dimensions.to_dict(length_unit), - "outer_dimensions": (c.container.outer_dimensions or c.container.inner_dimensions).to_dict(length_unit), + "inner_dimensions": c.container.declared_inner.to_dict(length_unit), + "outer_dimensions": (c.container.outer_dimensions or c.container.declared_inner).to_dict(length_unit), **achieved_dict(c), **made_to_measure_dict(c.container), "payload_weight": c.payload_weight.to_dict(weight_unit), @@ -315,7 +319,7 @@ def axle_dict(container): # empty and an added `lattice_summary` key carries the same information in # O(1)/O(r) instead of one entry per instance. **lattice_summary_dict(c.lattice_summary), - "placements": [placement_dict(p) for p in c.placements], + "placements": [placement_dict(p, c.id) for p in c.placements], } for c in self.containers], "unpacked_items": [{ "item_id": u.instance.id, @@ -325,6 +329,8 @@ def axle_dict(container): "proof": u.proof.to_dict(), } for u in self.unpacked], "catalog_versions_used": [dict(reference) for reference in self.catalog_versions_used], + # Absent unless the request declared one, so every other request keeps its bytes. + **({} if self.currency is None else {"currency": self.currency}), "warnings": list(self.warnings), "alternatives": [a.to_dict(length_unit, weight_unit, False) for a in self.alternatives] if include_alternatives else [], } diff --git a/src/packvium/result_validation.py b/src/packvium/result_validation.py index 212c2cc..5136203 100644 --- a/src/packvium/result_validation.py +++ b/src/packvium/result_validation.py @@ -54,14 +54,40 @@ "tick": 1, "ticks": 1, } +#: The six legacy orientation codes: every box a cuboid can make, and an item's default list. +BOX_ROTATIONS = ("LWH", "LHW", "WLH", "WHL", "HLW", "HWL") + +#: Each of the 24 proper rotations, in canonical table order (1.10): the declared side each +#: container axis takes its extent from, and the sign it carries. Written out rather than +#: derived, so this table is a second statement of the contract and not a copy of an engine's. +#: The legacy codes negate x exactly when their permutation is odd. +ROTATION_TABLE = ( + ("LWH", (0, 1, 2), (1, 1, 1)), ("LHW", (0, 2, 1), (-1, 1, 1)), + ("WLH", (1, 0, 2), (-1, 1, 1)), ("WHL", (1, 2, 0), (1, 1, 1)), + ("HLW", (2, 0, 1), (1, 1, 1)), ("HWL", (2, 1, 0), (-1, 1, 1)), + ("+L-W-H", (0, 1, 2), (1, -1, -1)), ("-L+W-H", (0, 1, 2), (-1, 1, -1)), + ("-L-W+H", (0, 1, 2), (-1, -1, 1)), + ("+L-H+W", (0, 2, 1), (1, -1, 1)), ("+L+H-W", (0, 2, 1), (1, 1, -1)), + ("-L-H-W", (0, 2, 1), (-1, -1, -1)), + ("+W-L+H", (1, 0, 2), (1, -1, 1)), ("+W+L-H", (1, 0, 2), (1, 1, -1)), + ("-W-L-H", (1, 0, 2), (-1, -1, -1)), + ("+W-H-L", (1, 2, 0), (1, -1, -1)), ("-W+H-L", (1, 2, 0), (-1, 1, -1)), + ("-W-H+L", (1, 2, 0), (-1, -1, 1)), + ("+H-L-W", (2, 0, 1), (1, -1, -1)), ("-H+L-W", (2, 0, 1), (-1, 1, -1)), + ("-H-L+W", (2, 0, 1), (-1, -1, 1)), + ("+H-W+L", (2, 1, 0), (1, -1, 1)), ("+H+W-L", (2, 1, 0), (1, 1, -1)), + ("-H-W-L", (2, 1, 0), (-1, -1, -1)), +) + #: Axis permutation each orientation code applies to (length, width, height). -ROTATIONS = { - "LWH": (0, 1, 2), "LHW": (0, 2, 1), "WLH": (1, 0, 2), - "WHL": (1, 2, 0), "HLW": (2, 0, 1), "HWL": (2, 1, 0), -} +ROTATIONS = {code: axes for code, axes, _ in ROTATION_TABLE} -#: Orientations that keep the item's own height vertical. -UPRIGHT_ROTATIONS = ("LWH", "WLH") +#: The sign each container axis carries under each code; a box ignores it, a hull does not. +ROTATION_SIGNS = {code: signs for code, _, signs in ROTATION_TABLE} + +#: Orientations that keep the item's own height vertical and pointing up: z takes `+H`. +UPRIGHT_ROTATIONS = tuple(code for code, axes, signs in ROTATION_TABLE + if axes[2] == 2 and signs[2] == 1) #: One square metre in length ticks squared -- the reference area a #: `Container.max_stack_density` limit is expressed per. @@ -246,11 +272,17 @@ class ContainerSpec: #: A flat bag, admitted only when a + c <= L and b + c <= W over the largest #: envelope x2, y2 and z2 of its placements. flat_bag: bool = False + #: 1.10: a gusseted bag's side gusset depth G in ticks, which widens the width girth to + #: b + c <= W + G; 0 for a flat bag and every rigid container. + side_gusset: int = 0 #: Whether a packed instance must report `achieved_dimensions`: a flat bag or a #: made-to-measure box. Either way it is the tight extent of the placements. reports_achieved_extent: bool = False #: The on-demand box machine this container is cut by, or None for stock. made_to_measure: "MachineSpec | None" = None + #: The weight-bearing deck `(x, y, length, width)` inside a pallet's solving + #: frame, which `inner` then is; None for every other container. + deck: tuple[int, int, int, int] | None = None def _flag(raw: dict, key: str, default: bool, where: str) -> bool: @@ -266,13 +298,17 @@ def _flag(raw: dict, key: str, default: bool, where: str) -> bool: def _length_unit(request: dict) -> str: """`units.length` as every engine reads it: `units` or `units.length` absent or - null is millimetres; `units` that is not an object, or a length unit the engines do not - know, is refused naming the field rather than failing inside the arithmetic.""" + null is millimetres; `units` that is not an object, a key other than `length` (, + the first in code-point order), or a length unit the engines do not know, is refused + naming the field rather than failing inside the arithmetic.""" units = request.get("units") if units is None: return "mm" if not isinstance(units, dict): raise MalformedResult("units must be an object") + unknown = sorted(set(units) - {"length"}) + if unknown: + raise MalformedResult(f"units.{unknown[0]} is not a known field") length = units.get("length") if length is None: return "mm" @@ -282,6 +318,19 @@ def _length_unit(request: dict) -> str: return REQUEST_LENGTH_UNITS[key.lower()] +def _allowed_rotations(raw: dict, keep_upright: bool) -> tuple[str, ...]: + """The codes an item may be placed in: its list, or the six legacy codes, narrowed to the + upright ones under `keep_upright`. A signed code counts only on a hull -- the engines refuse + it anywhere else -- so a box placed in one reads as a forbidden rotation (1.10).""" + listed = raw.get("allowed_rotations") + rotations = BOX_ROTATIONS if listed is None else tuple(listed) + if raw.get("shape_type") != "convex_hull": + rotations = tuple(code for code in rotations if code in BOX_ROTATIONS) + if keep_upright: + rotations = tuple(code for code in rotations if code in UPRIGHT_ROTATIONS) + return rotations + + def _parse_items(request: dict, unit: str) -> dict[str, ItemSpec]: items = {} for index, raw in enumerate(request["items"]): @@ -289,15 +338,15 @@ def _parse_items(request: dict, unit: str) -> dict[str, ItemSpec]: keep_upright = _flag(raw, "keep_upright", False, where) stackable = _flag(raw, "stackable", True, where) must_be_on_floor = _flag(raw, "must_be_on_floor", False, where) - rotations = raw.get("allowed_rotations") - if rotations is None: - rotations = UPRIGHT_ROTATIONS if keep_upright else tuple(ROTATIONS) + rotations = _allowed_rotations(raw, keep_upright) + if not rotations: + raise MalformedResult(f"{where}.allowed_rotations must allow at least one rotation after keep_upright") items[raw["id"]] = ItemSpec( id=raw["id"], dimensions=dimensions_to_ticks(raw["dimensions"], unit), weight=to_ticks(raw.get("weight", 0), "g", WEIGHT_TICKS), quantity=raw.get("quantity", 1), - allowed_rotations=tuple(rotations), + allowed_rotations=rotations, stackable=stackable, must_be_on_floor=must_be_on_floor, max_top_load=( @@ -360,9 +409,11 @@ def _parse_containers(request: dict, unit: str) -> dict[str, ContainerSpec]: ) for axle in raw_axles ) + inner, deck = _frame_and_deck(dimensions_to_ticks(raw["inner_dimensions"], unit), + raw.get("pallet_overhang_limit"), unit) containers[raw["id"]] = ContainerSpec( id=raw["id"], - inner=dimensions_to_ticks(raw["inner_dimensions"], unit), + inner=inner, access_directions=tuple( direction for direction in _EXIT_DIRECTIONS if direction in set(raw.get("access_directions", ())) @@ -390,13 +441,58 @@ def _parse_containers(request: dict, unit: str) -> dict[str, ContainerSpec]: ), rate_table=raw.get("rate_table"), flat_bag=raw.get("deformable_boundary") is not None, + side_gusset=_side_gusset(raw.get("deformable_boundary"), unit), reports_achieved_extent=(raw.get("deformable_boundary") is not None - or raw.get("made_to_measure") is not None), + or raw.get("made_to_measure") is not None + or deck is not None), made_to_measure=_parse_machine(raw.get("made_to_measure"), unit), + deck=deck, ) return containers +def _side_gusset(boundary: dict | None, unit: str) -> int: + """A gusseted bag's side gusset depth in ticks, else 0.""" + if boundary is None or boundary.get("side_gusset_depth") is None: + return 0 + return to_ticks(boundary["side_gusset_depth"], unit, LENGTH_TICKS) + + +def _frame_and_deck(declared: tuple[int, int, int], limit: dict | None, + unit: str) -> tuple[tuple[int, int, int], tuple[int, int, int, int] | None]: + """A pallet's solving frame and the deck inside it, else the declared size.""" + if limit is None: + return declared, None + overhang_x = to_ticks(limit["length"], unit, LENGTH_TICKS) + overhang_y = to_ticks(limit["width"], unit, LENGTH_TICKS) + frame = (declared[0] + 2 * overhang_x, declared[1] + 2 * overhang_y, declared[2]) + return frame, (overhang_x, overhang_y, declared[0], declared[1]) + + +def _pallet_footprint(packed: dict, deck: tuple[int, int, int, int], + clearance: int) -> tuple[int, int]: + """The deck grown by the overhang the placements use past each of its edges.""" + x, y, length, width = deck + envelopes = [_envelope(placement, clearance) for placement in packed["placements"]] + low_x = min([x, *(box.x for box in envelopes)]) + low_y = min([y, *(box.y for box in envelopes)]) + high_x = max([x + length, *(box.x2 for box in envelopes)]) + high_y = max([y + width, *(box.y2 for box in envelopes)]) + return high_x - low_x, high_y - low_y + + +def _billed_size(packed: dict, spec: ContainerSpec, clearance: int) -> tuple[int, int, int]: + """What a carrier measures: a bag's package; a pallet's declared footprint or + its grown deck, the larger per axis; else the declared outer, else inner size.""" + if spec.flat_bag: + return _achieved_extent(packed, clearance) + if spec.deck is None: + return spec.outer if spec.outer is not None else spec.inner + length, width = _pallet_footprint(packed, spec.deck, clearance) + base = spec.outer if spec.outer is not None else (spec.deck[2], spec.deck[3], spec.inner[2]) + return max(base[0], length), max(base[1], width), base[2] + + def _parse_machine(raw: dict | None, unit: str) -> MachineSpec | None: if raw is None: return None @@ -599,6 +695,16 @@ def _check_structure(result: dict, issues: list[Issue]) -> None: f"complete={result['complete']} with {unpacked} unpacked")) +def _check_currency(request: dict, result: dict, issues: list[Issue]) -> None: + """The result echoes the request's currency, and states none the request did not declare. + + A null is the field left out, on either side. O(1).""" + requested = request.get("currency") + reported = result.get("currency") + if reported != requested: + issues.append(Issue("currency_mismatch", f"reported {reported!r}, requested {requested!r}")) + + def _resolve_container_tag_rules(request: dict) -> tuple[dict, ...]: """Participating `require_container_tag` rules, resolved from the request alone. @@ -693,7 +799,7 @@ def _fits_empty(container: ContainerSpec, size: tuple[int, int, int]) -> bool: if any(size[index] > container.inner[index] for index in range(3)): return False return not container.flat_bag or (size[0] + size[2] <= container.inner[0] - and size[1] + size[2] <= container.inner[1]) + and size[1] + size[2] <= container.inner[1] + container.side_gusset) def _achieved_extent(packed: dict, clearance: int) -> tuple[int, int, int]: @@ -715,15 +821,18 @@ def _check_achieved_extent(packed: dict, spec: ContainerSpec, clearance: int, f"{packed['id']}: reported for a container of fixed size")) return a, b, c = _achieved_extent(packed, clearance) + if spec.deck is not None: + a, b = _pallet_footprint(packed, spec.deck, clearance) stated = None if not isinstance(reported, dict) else tuple( reported.get(side, {}).get("ticks") for side in ("length", "width", "height")) if stated != (a, b, c): issues.append(Issue("achieved_dimensions_mismatch", f"{packed['id']}: reported {stated}, computed {(a, b, c)}")) - if spec.flat_bag and (a + c > spec.inner[0] or b + c > spec.inner[1]): + if spec.flat_bag and (a + c > spec.inner[0] or b + c > spec.inner[1] + spec.side_gusset): issues.append(Issue("bag_girth_exceeded", f"{packed['id']}: a + c = {a + c}, b + c = {b + c}, " - f"flat size {spec.inner[0]} x {spec.inner[1]}")) + f"flat size {spec.inner[0]} x {spec.inner[1]}" + + (f", side gusset {spec.side_gusset}" if spec.side_gusset else ""))) def _fabricated_side(achieved: int, minimum: int, increment: int) -> int: @@ -745,6 +854,30 @@ def _reported_ticks(value: Any, keys: tuple[str, ...]) -> tuple[int, ...] | None return tuple((value.get(key) or {}).get("ticks") for key in keys) +def _reserve_cut(packed: dict, spec: ContainerSpec, machine: MachineSpec, + tight: tuple[int, int, int], reported: tuple | None) -> bool: + """Whether a reported box larger than the tight cut is a reserve cut: the tight + box breaks the void-fill reserve, and the reported one is on the machine grid between the + tight box and the maximum. That it keeps the reserve is the ordinary reserve check's. + Reads the reported used volume, which `used_volume_mismatch` holds to the placements.""" + if spec.void_fill_reserve_ratio == 0 or reported is None: + return False + if not all(isinstance(side, int) and not isinstance(side, bool) for side in reported): + return False + on_grid = all( + tight[axis] <= reported[axis] <= spec.inner[axis] + and (reported[axis] - machine.minimum[axis]) % machine.increment[axis] == 0 + for axis in range(3) + ) + used = packed.get("used_volume_ticks3") + if not on_grid or not isinstance(used, str) or not used.isdigit(): + return False + volume = tight[0] * tight[1] * tight[2] + scaled = _ratio_ppm(spec.void_fill_reserve_ratio) + reserved = (volume // SCORE_SCALE) * scaled + ((volume % SCORE_SCALE) * scaled) // SCORE_SCALE + return int(used) > volume - reserved + + def _check_made_to_measure(packed: dict, compact: bool, spec: ContainerSpec, items: dict[str, ItemSpec], clearance: int, issues: list[Issue]) -> ContainerSpec: @@ -775,6 +908,8 @@ def _check_made_to_measure(packed: dict, compact: bool, spec: ContainerSpec, issues.append(Issue("fabricated_dimensions_exceed_machine", f"{container_id}: {fabricated} beyond {spec.inner}")) reported_inner = _reported_ticks(packed.get("inner_dimensions"), sides) + if reported_inner != fabricated and _reserve_cut(packed, spec, machine, fabricated, reported_inner): + fabricated = reported_inner if reported_inner != fabricated: issues.append(Issue("fabricated_dimensions_mismatch", f"{container_id}: reported {reported_inner}, computed {fabricated}")) @@ -863,7 +998,7 @@ def _check_reason_proof( _fits_empty(container, tuple(spec.dimensions[axis] + 2 * clearance for axis in ROTATIONS[rotation])) for container in containers.values() - for rotation in ROTATIONS + for rotation in BOX_ROTATIONS ) if fits_any_rotation: issues.append(Issue( @@ -887,7 +1022,7 @@ def _check_reason_proof( _fits_empty(container, tuple(spec.dimensions[axis] + 2 * clearance for axis in ROTATIONS[rotation])) for container in containers.values() - for rotation in ROTATIONS + for rotation in BOX_ROTATIONS ) if not fits_any_rotation: issues.append(Issue( @@ -968,21 +1103,17 @@ def _check_reason_proof( def _rotate_hull( vertices: tuple[tuple[int, int, int], ...], orientation: str ) -> tuple[tuple[int, int, int], ...]: - """Reorient a hull the way `ROTATIONS` reorients its box, without ever mirroring it. + """Reorient a hull by one of the 24 proper rotations, without ever mirroring it. - Three of the six orientations are odd permutations of the coordinate axes. A bare - permutation would hand back the item's mirror image -- invisible on a cuboid, wrong on a - hull -- so the sign of one axis flips when the permutation is odd and every orientation - stays a proper rotation. The result is translated so the rotated hull's bounding box + `v'[i] = s[i] * v[src[i]]` from `ROTATION_TABLE`, where every code has determinant +1: + a bare permutation would hand back the item's mirror image for an odd one -- invisible on + a cuboid, wrong on a hull. The result is translated so the rotated hull's bounding box starts at the origin, which is the frame a placement position is expressed in. """ axes = ROTATIONS[orientation] - inversions = sum( - 1 for first in range(3) for second in range(first + 1, 3) if axes[first] > axes[second] - ) - sign = -1 if inversions % 2 else 1 + signs = ROTATION_SIGNS[orientation] turned = [ - (sign * vertex[axes[0]], vertex[axes[1]], vertex[axes[2]]) for vertex in vertices + tuple(signs[axis] * vertex[axes[axis]] for axis in range(3)) for vertex in vertices ] low = tuple(min(vertex[axis] for vertex in turned) for axis in range(3)) return tuple( @@ -1008,7 +1139,7 @@ def _placed_hull( reported placement rather than trusted from the engine. """ if (spec is None or spec.shape_type != "convex_hull" or spec.hull_vertices is None - or spec.stop_index is not None + or spec.stop_index is not None or placement["orientation"] not in ROTATIONS or (envelope.length, envelope.width, envelope.height) != tuple(spec.dimensions[axis] for axis in ROTATIONS[placement["orientation"]])): return _box_vertices(envelope) @@ -1089,8 +1220,9 @@ def _occupied_volume( return length * width * _compressed_height(height, length * width, spec, load) if spec is not None and spec.shape_type == "convex_hull" and spec.hull_vertices is not None: # Collision intentionally uses the box for routes and clearance. Volume is a property - # of the physical hull and must not inherit that conservative collision fallback. - return _hull_volume(_rotate_hull(spec.hull_vertices, placement["orientation"])) + # of the physical hull and must not inherit that conservative collision fallback. A + # proper rotation keeps it, so the authored vertices answer for every orientation. + return _hull_volume(spec.hull_vertices) solid = _placed_hull(envelope, placement, spec) if len(solid) == 8 and solid == _box_vertices(envelope): return (placement["dimensions"]["length"]["ticks"] @@ -1283,6 +1415,8 @@ def _check_container( _check_support_and_load( packed, envelopes, supporters, direct_above, global_support, issues ) + if spec.deck is not None: + _check_deck_support(envelopes, spec.deck, global_support, issues) loads = _propagate_load(envelopes, supporters) _check_stack_top_load(envelopes, loads, issues) _check_stack_density(packed, envelopes, loads, spec.max_stack_density, issues) @@ -1559,6 +1693,34 @@ def _centroid_supported(envelope: Box, surfaces: Sequence[Box]) -> bool: ) +def _check_deck_support( + envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], + deck: tuple[int, int, int, int], + global_support: Fraction, + issues: list[Issue], +) -> None: + """A floor placement on a pallet rests on its overlap with the deck alone, + judged by the ratio and centre-of-gravity rule a supporting item would be.""" + x, y, length, width = deck + surface = Box(x, y, -1, length, width, 1) + for item_id, (envelope, _, spec) in envelopes.items(): + if spec is None or envelope.z != 0: + continue + required = max(global_support, spec.minimum_support_ratio) + if required <= 0: + continue + dx = min(envelope.x2, x + length) - max(envelope.x, x) + dy = min(envelope.y2, y + width) - max(envelope.y, y) + area = dx * dy if dx > 0 and dy > 0 else 0 + if area < _required_support_area(envelope.base_area, _ratio_ppm(required)): + ratio = Fraction(area, envelope.base_area) + issues.append(Issue("insufficient_support", + f"{item_id}: {float(ratio):.6f} < {float(required):.6f} on the deck")) + elif area * 2 <= envelope.base_area and not _centroid_supported(envelope, [surface]): + issues.append(Issue("centre_of_gravity_unsupported", + f"{item_id}: centroid outside the deck")) + + def _check_support_and_load( packed: dict, envelopes: dict[str, tuple[Box, dict, ItemSpec | None]], @@ -1913,6 +2075,7 @@ def validate(request: dict, result: dict) -> list[Issue]: coordinates_required, result, items, containers, clearance, issues ) _check_structure(validated_result, issues) + _check_currency(request, validated_result, issues) _check_algorithm_metrics(validated_result, issues) _check_effort_budget(request, validated_result, issues) _check_result_facts(validated_result, issues) @@ -1959,6 +2122,7 @@ def validate(request: dict, result: dict) -> list[Issue]: f"{opened} containers opened, limit {max_containers}")) _check_fixed_placements(request, validated_result, unit, issues) + _check_placement_ids(result, issues) _check_groups(request, validated_result, issues) _check_solver_selection(request, validated_result, issues) score = _check_objective(request, validated_result, containers, items, clearance, issues, @@ -2011,6 +2175,52 @@ def _check_fixed_placements(request: dict, result: dict, unit: str, issues: list issues.append(Issue("unexpected_fixed_placement", f"{key[1]} in {key[0]} at {key[2:5]} {key[5]}")) +#: How a placement id spells a string's characters: RFC 8785's escapes, and a lone UTF-16 +#: surrogate as its lowercase escape, as ECMAScript's `JSON.stringify` writes it. +_ID_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"} + + +def _id_string(text: str) -> str: + return '"' + "".join( + _ID_ESCAPES.get(character) + or (f"\\u{ord(character):04x}" if ord(character) < 0x20 or 0xD800 <= ord(character) <= 0xDFFF + else character) + for character in text + ) + '"' + + +def _expected_placement_id(container_id: Any, placement: dict) -> str: + """The canonical JSON text of `[container id, x, y, z, item type, orientation]` (1.10), + written here rather than imported so the validator shares no code with the engine.""" + position = placement["position"] + ticks = [str(position[axis]["ticks"]) for axis in "xyz"] + names = [_id_string(str(value)) for value in (container_id, placement["item_type"], placement["orientation"])] + return "[" + ",".join([names[0], *ticks, names[1], names[2]]) + "]" + + +def _check_placement_ids(result: dict, issues: list[Issue]) -> None: + """Every reported placement carries its `placement_id`, the canonical JSON of + `[container id, x, y, z, item type, orientation]` with the position in ticks (1.10), and no + two carry the same one. Read from the result as reported: a lattice summary has no + placement objects, so it has no ids to check. O(placements). + """ + seen: set[str] = set() + for container in result["containers"]: + container_id = container.get("id", "") + for placement in container["placements"]: + expected = _expected_placement_id(container_id, placement) + reported = placement.get("placement_id") + where = f"{placement['item_id']} in {container_id}" + if not isinstance(reported, str): + issues.append(Issue("placement_id_mismatch", f"{where}: no placement_id, expected {expected}")) + continue + if reported != expected: + issues.append(Issue("placement_id_mismatch", f"{where}: reported {reported}, expected {expected}")) + if reported in seen: + issues.append(Issue("duplicate_placement_id", reported)) + seen.add(reported) + + def _check_algorithm_metrics(result: dict, issues: list[Issue]) -> None: algorithm = result["algorithm"] if not isinstance(algorithm, dict): @@ -2337,8 +2547,7 @@ def objective( if name in ("shipping_cost", "lowest_landed_cost") and dimensional_weight_divisor is not None: gross = int(packed.get("gross_weight", {}).get("ticks", 0)) # A flat bag is billed on the package it conformed to. - billed_size = (_achieved_extent(packed, clearance) if spec.flat_bag - else spec.outer if spec.outer is not None else spec.inner) + billed_size = _billed_size(packed, spec, clearance) dimensional = _dimensional_weight_ticks( billed_size, dimensional_weight_divisor, dimensional_weight_length_unit, dimensional_weight_weight_unit, @@ -2493,10 +2702,14 @@ def _check_groups(request: dict, result: dict, issues: list[Issue]) -> None: # ------------------------------------------------------------- optimality bound #: Where each covered objective reads `(L0, L1, L2, L3, L4)` in its own score order -#: (docs/OPTIMALITY-CERTIFICATES.md, "Per-objective vectors"). Any other objective has no bound. -BOUND_ORDERS: dict[str, tuple[int, ...]] = { +#: (docs/OPTIMALITY-CERTIFICATES.md, "Per-objective vectors"), a name standing for the one key +#: only that objective ranks by. Any other objective has no bound. +BOUND_ORDERS: dict[str, tuple[int | str, ...]] = { "default": (0, 1, 2, 3, 4), "lowest_cost": (0, 2, 1, 3, 4), + "maximum_value": (0, "value_forgone", 1, 2, 3), + "open_dimension_height": (0, "achieved_height", 1, 2, 3), + "maximum_volume": ("unpacked_volume_ppm", 0, 1, 2, 3), } #: The one refusal predicate's two ceilings. @@ -2725,10 +2938,38 @@ def _stated_bound(request: dict, status: Any, items: dict[str, ItemSpec], return None, "status is 'infeasible'" if status == "invalid_result": return None, "status is 'invalid_result'" - bound = lower_bound(*_bound_inputs(items, containers)) + volumes, weights, shrinks, bound_containers = _bound_inputs(items, containers) + bound = lower_bound(volumes, weights, shrinks, bound_containers) if bound is None: return None, "the request is past a declared ceiling" - return tuple(bound[position] for position in order), "" + stated = tuple(bound[position] if isinstance(position, int) + else _objective_term(position, items, volumes, shrinks, bound_containers, bound[0]) + for position in order) + if any(value > MAX_BOUND_VALUE for value in stated): + return None, f"the {objective_name} key is past the portable ceiling" + return stated, "" + + +def _objective_term(name: str, items: dict[str, ItemSpec], volumes: Runs, shrinks: bool, + containers: Sequence[BoundContainer], unpacked: int) -> int: + """The key only one objective ranks by, each conditional on `L0` like the five: + the least value forgone and the least summed top of a packing leaving `unpacked` behind, + and the least unpacked share of requested volume any packing scores. O(t + c).""" + if name == "value_forgone": + return _smallest(_runs([(spec.value or 0, spec.quantity) for spec in items.values()]), + unpacked) + if shrinks: + return 0 + if name == "achieved_height": + placed = _run_count(volumes) - unpacked + floors = [container.base_area for container in containers if container.base_area > 0] + return _ceil_div(_smallest(volumes, placed), max(floors)) if placed > 0 and floors else 0 + requested = sum(value * count for value, count in volumes) + capacity = _capacity_total([container.usable for container in containers], + [container.quantity for container in containers]) + if requested == 0 or capacity is None: + return 0 + return _ceil_div(max(0, requested - capacity) * SCORE_SCALE, requested) def _departure(score: Sequence[int], bound: Sequence[int]) -> tuple[int, int] | None: diff --git a/src/packvium/revisions.py b/src/packvium/revisions.py index 6f5cf48..b0904af 100644 --- a/src/packvium/revisions.py +++ b/src/packvium/revisions.py @@ -19,7 +19,10 @@ from ._compat import dataclass from .artifacts import FORMAT as ARTIFACT_FORMAT from .artifacts import SUITE_VERSION -from .fixed_placements import require_point_shape +from .fixed_placements import hull_item_types, require_point_shape +from .made_to_measure import per_square_metre, surface_area +from .request_errors import refuse_orientation +from .units import Length, ParsedMeasures, Weight __all__ = [ "FORMAT", @@ -31,6 +34,7 @@ "derive_revision", "document_digest", "root_revision", + "stock_cut_boxes", "verify_revision_chain", ] @@ -38,8 +42,6 @@ EVENT_TYPES = ("item_missing", "container_substituted", "placement_locked", "placement_verified") -ORIENTATIONS = ("LWH", "LHW", "WLH", "WHL", "HLW", "HWL") - _EVENT_FIELDS = { "item_missing": ("item_type", "quantity"), "container_substituted": ("container_type", "replacement"), @@ -47,7 +49,8 @@ "placement_verified": ("placement",), } -_PLACEMENT_FIELDS = ("item_type", "container_type", "container_instance", "position", "orientation") +_PLACEMENT_FIELDS = ("item_type", "container_type", "container_instance", "position", "orientation", + "placement_id") class PlanRevisionError(ValueError): @@ -108,13 +111,14 @@ def derive_revision( if isinstance(events, (str, bytes)) or not isinstance(events, Sequence) or not events: raise PlanRevisionError("invalid_event", "a revision records at least one event") first = _last_sequence(parent) + 1 - recorded = [_event(event, first + offset) for offset, event in enumerate(events)] - request = apply_events(parent["request"], recorded) + hulls = _hull_types(parent["request"]) + recorded = [_event(event, first + offset, hulls) for offset, event in enumerate(events)] + number = json_integer(parent["revision"]) + 1 + request = stock_cut_boxes(apply_events(parent["request"], recorded), approved_artifact, number) approved = { "artifact": document_digest(approved_artifact), "replay": copy.deepcopy(dict(approved_artifact["provenance"]["replay"])), } - number = json_integer(parent["revision"]) + 1 document = _document(number, document_digest(parent), approved, recorded, request) canonical_revision_json(document) return document @@ -128,11 +132,12 @@ def apply_events(request: Mapping[str, Any], events: Sequence[Mapping[str, Any]] if isinstance(events, (str, bytes, Mapping)) or not isinstance(events, Sequence): raise PlanRevisionError("invalid_event", "events are a JSON array") derived = copy.deepcopy(dict(request)) + hulls = _hull_types(request) placement_index = _PlacementIndex() apply = {**_APPLY, "placement_locked": placement_index.apply, "placement_verified": placement_index.apply} for event in events: - _require_shape(event) + _require_shape(event, hulls) if json_integer(event.get("sequence")) is None: raise PlanRevisionError( "invalid_event", f"event sequence {json_spelling(event.get('sequence'))} is not an integer") @@ -140,6 +145,173 @@ def apply_events(request: Mapping[str, Any], events: Sequence[Mapping[str, Any]] return derived +def stock_cut_boxes(request: Mapping[str, Any], approved_artifact: Mapping[str, Any], + revision: int) -> Dict[str, Any]: + """`request` with every made-to-measure box that holds a fixed placement made a stock + container. The box was cut for the approved plan, so a replan cannot recut it: + instance `n` of made-to-measure type `T` -- the `n`-th container of type `T` in the + artifact, in result order -- becomes container `T@#`, a copy of `T` without + `made_to_measure`, quantity 1, at the cut inner and outer size, price and tare, each in + ticks; it follows `T` in the container list. Its fixed placements move to its instance 1 + and `T`'s own `quantity`, if stated, loses the boxes taken (`T` is dropped at zero). New + boxes are still cut to measure. Pure; O(C + F + A) for containers, fixed placements and + the artifact's containers. + """ + derived = copy.deepcopy(dict(request)) + fixed = derived.get("fixed_placements") + containers = derived.get("containers") + if not isinstance(fixed, list) or not isinstance(containers, list): + return derived + machines = {entry["id"]: entry for entry in containers + if isinstance(entry, Mapping) and isinstance(entry.get("id"), str) + and entry.get("made_to_measure") is not None} + stocked: Dict[str, List[Dict[str, Any]]] = {machine_id: [] for machine_id in machines} + named: Dict[str, Dict[str, Any]] = {} + cuts = None + for entry in fixed: + if not isinstance(entry, dict) or entry.get("container_type") not in machines: + continue + machine_id = entry["container_type"] + instance = json_integer(entry.get("container_instance", 1)) + stock_id = f"{machine_id}@{revision}#{instance}" + if stock_id not in named: + if cuts is None: + cuts = _cut_boxes(approved_artifact) + named[stock_id] = _stock_box(derived, machines[machine_id], stock_id, + _cut_inner(cuts, machine_id, instance)) + stocked[machine_id].append(named[stock_id]) + entry["container_type"] = stock_id + entry["container_instance"] = 1 + # A stated id named the cut box's instance of the machine, which this request no + # longer has; admission would refuse it as a mismatch. + entry.pop("placement_id", None) + if not named: + return derived + clash = min(set(named) & {c.get("id") for c in containers if isinstance(c, Mapping)}, default=None) + if clash is not None: + raise PlanRevisionError("event_conflict", f"a container is already named {json_spelling(clash)}") + rebuilt: List[Any] = [] + for container in containers: + boxes = stocked.get(container.get("id")) if isinstance(container, Mapping) else None + if not boxes: + rebuilt.append(container) + continue + quantity = json_integer(container.get("quantity")) if container.get("quantity") is not None else None + if quantity is None or quantity > len(boxes): + if quantity is not None: + container["quantity"] = quantity - len(boxes) + rebuilt.append(container) + rebuilt.extend(boxes) + derived["containers"] = rebuilt + return derived + + +def _cut_boxes(artifact: Mapping[str, Any]) -> Dict[str, List[Any]]: + """Index raw shapes in artifact order; unselected dimensions are never parsed. O(A).""" + work = artifact.get("work_order") if isinstance(artifact, Mapping) else None + geometry = artifact.get("geometry") if isinstance(artifact, Mapping) else None + orders = work.get("containers") if isinstance(work, Mapping) else None + shapes = geometry.get("containers") if isinstance(geometry, Mapping) else None + cuts: Dict[str, List[Any]] = {} + if isinstance(orders, list) and isinstance(shapes, list): + for order, shape in zip(orders, shapes): + machine_id = order.get("container_type") if isinstance(order, Mapping) else None + if isinstance(machine_id, str): + cuts.setdefault(machine_id, []).append(shape) + return cuts + + +def _cut_inner(cuts: Dict[str, List[Any]], machine_id: str, instance: Optional[int]) -> List[int]: + """The selected box's ticks, read only when it is stocked. Expected O(1).""" + shapes = cuts.get(machine_id, ()) + if instance is not None and 1 <= instance <= len(shapes): + return [_artifact_ticks(shapes[instance - 1], axis) for axis in ("length", "width", "height")] + raise PlanRevisionError("invalid_artifact", + f"the approved plan holds no {machine_id} instance {json_spelling(instance)}") + + +def _artifact_ticks(shape: Any, axis: str) -> int: + sides = shape.get("inner_dimensions") if isinstance(shape, Mapping) else None + value = sides.get(axis) if isinstance(sides, Mapping) else None + ticks = json_integer(int(value)) if isinstance(value, str) and value.isascii() and value.isdigit() else None + if ticks is None: + raise PlanRevisionError("invalid_artifact", f"a cut box's {axis} is not a tick count") + return ticks + + +def _stock_box(request: Mapping[str, Any], machine: Mapping[str, Any], stock_id: str, + inner: List[int]) -> Dict[str, Any]: + """The stock container a cut box becomes, priced as `made_to_measure.fabricate` prices it.""" + units = _stock_object(request.get("units"), "units", optional=True) + unit = units.get("length") if units.get("length") is not None else "mm" + if not isinstance(unit, str): + raise PlanRevisionError("invalid_revision", "a made-to-measure container's units.length is not a string") + measures = ParsedMeasures() + spec = _stock_object(machine["made_to_measure"], "made_to_measure") + + def read(kind: type, value: Any, default_unit: Any, name: str) -> int: + try: + return measures.read(kind, value, default_unit).ticks + except (ValueError, TypeError, KeyError, ArithmeticError) as error: + raise PlanRevisionError("invalid_revision", + f"a made-to-measure container's {name} is not a measure") from error + + def length(value: Any) -> int: + return read(Length, value, unit, "outer_allowance") + + def weight(value: Any, name: str) -> int: + return read(Weight, value, "g", name) + + allowance = _stock_object(spec.get("outer_allowance"), "outer_allowance", optional=True) + outer = [inner[index] + (length(allowance[axis]) if allowance.get(axis) is not None else 0) + for index, axis in enumerate(("length", "width", "height"))] + area = surface_area(tuple(inner)) + price = _stock_integer(spec.get("cost_per_square_metre_minor"), "cost_per_square_metre_minor") + tare_rate = (0 if spec.get("tare_per_square_metre") is None + else weight(spec["tare_per_square_metre"], "tare_per_square_metre")) + declared_tare = 0 if machine.get("tare_weight") is None else weight(machine["tare_weight"], "tare_weight") + box = {key: copy.deepcopy(value) for key, value in machine.items() if key != "made_to_measure"} + box.update({ + "id": stock_id, + "quantity": 1, + "inner_dimensions": _tick_box(inner), + "outer_dimensions": _tick_box(outer), + "cost_minor": _stock_integer(machine.get("cost_minor"), "cost_minor") + per_square_metre(price, area), + "tare_weight": {"value": str(declared_tare + per_square_metre(tare_rate, area)), "unit": "ticks"}, + }) + return box + + +def _stock_object(value: Any, name: str, optional: bool = False) -> Mapping[str, Any]: + if value is None and optional: + return {} + if not isinstance(value, Mapping): + raise PlanRevisionError("invalid_revision", f"a made-to-measure container's {name} is not an object") + return value + + +def _stock_integer(value: Any, name: str) -> int: + integer = json_integer(0 if value is None else value) + if integer is None: + raise PlanRevisionError("invalid_revision", f"a made-to-measure container's {name} is not an integer") + return integer + + +def _tick_box(sides: List[int]) -> Dict[str, Dict[str, str]]: + return {axis: {"value": str(side), "unit": "ticks"} for axis, side in zip(("length", "width", "height"), sides)} + + +def _holds_cut_boxes(request: Mapping[str, Any]) -> bool: + """Whether a fixed placement names a made-to-measure container type.""" + containers = request.get("containers") + fixed = request.get("fixed_placements") + if not isinstance(containers, list) or not isinstance(fixed, list): + return False + machines = {entry.get("id") for entry in containers + if isinstance(entry, Mapping) and entry.get("made_to_measure") is not None} + return any(isinstance(entry, Mapping) and entry.get("container_type") in machines for entry in fixed) + + def verify_revision_chain( revisions: Sequence[Mapping[str, Any]], artifacts: Optional[Sequence[Optional[Mapping[str, Any]]]] = None, @@ -170,9 +342,9 @@ def verify_revision_chain( issues.append(RevisionIssue("parent_mismatch", position, f"parent {_plain(recorded_parent)}, expected {_plain(expected_parent)}")) last_sequence = _check_sequences(revision, position, last_sequence, issues) - if parent is not None: - _check_request(revision, parent, position, issues) artifact = None if artifacts is None or position >= len(artifacts) else artifacts[position] + if parent is not None: + _check_request(revision, parent, artifact, position, issues) if artifact is not None and parent is not None: _check_artifact(revision, parent, artifact, position, issues) return issues @@ -232,8 +404,8 @@ def _chain_start(revision: Mapping[str, Any]) -> int: return 0 -def _event(raw: Any, sequence: int) -> Dict[str, Any]: - _require_shape(raw) +def _event(raw: Any, sequence: int, hulls: frozenset[str]) -> Dict[str, Any]: + _require_shape(raw, hulls) if json_integer(raw.get("sequence")) != sequence: raise PlanRevisionError( "invalid_event", @@ -242,7 +414,12 @@ def _event(raw: Any, sequence: int) -> Dict[str, Any]: return copy.deepcopy(dict(raw)) -def _require_shape(raw: Any) -> None: +def _hull_types(request: Mapping[str, Any]) -> frozenset[str]: + """The request's hull item types, which alone may be placed in a signed orientation (1.10).""" + return hull_item_types(request.get("items")) if isinstance(request, Mapping) else frozenset() + + +def _require_shape(raw: Any, hulls: frozenset[str]) -> None: if not isinstance(raw, Mapping): raise PlanRevisionError("invalid_event", "an event is a JSON object") kind = raw.get("type") @@ -255,17 +432,17 @@ def _require_shape(raw: Any) -> None: missing = [field for field in _EVENT_FIELDS[kind] if field not in raw] if missing: raise PlanRevisionError("invalid_event", f"{kind} needs {json_spelling(missing)}") - _VALIDATE[kind](raw) + _VALIDATE[kind](raw, hulls) -def _validate_item_missing(event: Mapping[str, Any]) -> None: +def _validate_item_missing(event: Mapping[str, Any], _hulls: frozenset[str]) -> None: _require_name(event["item_type"], "item_type") quantity = json_integer(event["quantity"]) if quantity is None or quantity < 1: raise PlanRevisionError("invalid_event", "item_missing.quantity is a positive integer") -def _validate_container_substituted(event: Mapping[str, Any]) -> None: +def _validate_container_substituted(event: Mapping[str, Any], _hulls: frozenset[str]) -> None: _require_name(event["container_type"], "container_type") replacement = event["replacement"] if not isinstance(replacement, Mapping): @@ -273,7 +450,7 @@ def _validate_container_substituted(event: Mapping[str, Any]) -> None: _require_name(replacement.get("id"), "replacement.id") -def _validate_placement(event: Mapping[str, Any]) -> None: +def _validate_placement(event: Mapping[str, Any], hulls: frozenset[str]) -> None: placement = event["placement"] if not isinstance(placement, Mapping): raise PlanRevisionError("invalid_event", "a placement is a fixed-placement object") @@ -282,13 +459,18 @@ def _validate_placement(event: Mapping[str, Any]) -> None: raise PlanRevisionError("invalid_event", f"a placement does not carry {json_spelling(unknown)}") _require_name(placement.get("item_type"), "placement.item_type") _require_name(placement.get("container_type"), "placement.container_type") - if placement.get("orientation") not in ORIENTATIONS: - raise PlanRevisionError("invalid_event", - f"placement.orientation must be one of {json_spelling(list(ORIENTATIONS))}") + refuse_orientation(placement.get("orientation"), placement.get("item_type") in hulls, "", + lambda _reason, _field, detail: PlanRevisionError("invalid_event", detail), + "placement.orientation") instance = json_integer(placement.get("container_instance", 1)) if instance is None or instance < 1: raise PlanRevisionError("invalid_event", "placement.container_instance counts from 1") require_point_shape(placement.get("position", {}), "placement.position", "", _invalid_event) + # Only its type is the event's to judge: whether it names this placement needs the + # request's unit, and is the engine's admission rule when the revision is solved (1.10). + identifier = placement.get("placement_id") + if identifier is not None and not isinstance(identifier, str): + raise PlanRevisionError("invalid_event", "placement.placement_id is a string") def _invalid_event(message: str, _field: str) -> PlanRevisionError: @@ -300,6 +482,7 @@ def _require_name(value: Any, field: str) -> None: raise PlanRevisionError("invalid_event", f"{field} is a non-empty string") +#: Each event type's own rule, given the request's hull item types (only a placement reads them). _VALIDATE = { "item_missing": _validate_item_missing, "container_substituted": _validate_container_substituted, @@ -367,11 +550,10 @@ def apply(self, request: Dict[str, Any], event: Mapping[str, Any]) -> None: self.fixed = _fixed_entries(request) request["fixed_placements"] = self.fixed try: - self.keys = {canonical_revision_json({"container_instance": 1, **entry}) - for entry in self.fixed} + self.keys = {canonical_revision_json(_identity(entry)) for entry in self.fixed} except PlanRevisionError: self.indexable = False - placement = {"container_instance": 1, **copy.deepcopy(dict(event["placement"]))} + placement = _identity(copy.deepcopy(dict(event["placement"]))) if self.indexable: try: key = canonical_revision_json(placement) @@ -385,11 +567,17 @@ def apply(self, request: Dict[str, Any], event: Mapping[str, Any]) -> None: return # An uncanonicalizable value may occur in a direct apply_events call. Preserve the # old first-match/error order rather than making index construction an earlier error. - if any(_same({"container_instance": 1, **entry}, placement) for entry in self.fixed): + if any(_same(_identity(entry), placement) for entry in self.fixed): return self.fixed.append(dict(event["placement"])) +def _identity(entry: Mapping[str, Any]) -> Dict[str, Any]: + """What makes two fixed entries one box: everything but `placement_id`, which only restates + the container and position (1.10), with the default instance spelled out.""" + return {"container_instance": 1, **{key: value for key, value in entry.items() if key != "placement_id"}} + + _APPLY = { "item_missing": _apply_item_missing, "container_substituted": _apply_container_substituted, @@ -441,10 +629,18 @@ def _check_sequences(revision: Mapping[str, Any], position: int, last: int, return last -def _check_request(revision: Mapping[str, Any], parent: Mapping[str, Any], position: int, +def _check_request(revision: Mapping[str, Any], parent: Mapping[str, Any], + artifact: Optional[Mapping[str, Any]], position: int, issues: List[RevisionIssue]) -> None: try: derived = apply_events(parent["request"], revision["events"]) + if _holds_cut_boxes(derived): + # The cut sizes live only in the approved artifact. + if artifact is None: + issues.append(RevisionIssue("artifact_required", position, + "fixed items in a made-to-measure box are checked against the approved artifact")) + return + derived = stock_cut_boxes(derived, artifact, json_integer(revision["revision"])) except PlanRevisionError as error: issues.append(RevisionIssue("request_mismatch", position, f"the events do not apply: {error}")) return diff --git a/src/packvium/serialization.py b/src/packvium/serialization.py index 1503559..7837ffa 100644 --- a/src/packvium/serialization.py +++ b/src/packvium/serialization.py @@ -11,9 +11,10 @@ from .geometry import AxisAlignedBox, Dimensions, Point, Rotation, ShapeType from .models import Axle, Container, FixedPlacement, Item, Obstacle, RateTable from .made_to_measure import MadeToMeasure +from .pallet_overhang import OverhangLimit from ._canonical_json import json_integer from .extensions import ExtensionRegistry -from .fixed_placements import require_fixed_placement_shapes +from .fixed_placements import hull_item_types, require_fixed_placement_shapes from ._request_error import ObjectRefusal from .request_errors import InvalidRequestError, check_request from .packer import Packer @@ -117,7 +118,7 @@ def _box(raw: dict, unit: str, measures: ParsedMeasures) -> AxisAlignedBox: ) -def _fixed_placements(raw: object, unit: str) -> list[FixedPlacement]: +def _fixed_placements(raw: object, unit: str, items: object) -> list[FixedPlacement]: return [ FixedPlacement( item_id=entry["item_type"], @@ -125,8 +126,9 @@ def _fixed_placements(raw: object, unit: str) -> list[FixedPlacement]: position=Point(*_point_ticks(entry.get("position", {}), unit)), rotation=Rotation(entry["orientation"]), container_instance=json_integer(entry.get("container_instance", 1)), + placement_id=entry.get("placement_id"), ) - for entry in require_fixed_placement_shapes(raw, unit) + for entry in require_fixed_placement_shapes(raw, unit, hull_item_types(items)) ] @@ -138,6 +140,7 @@ def _rate_table(raw: dict | None) -> RateTable | None: prices_minor=tuple(int(price) for price in raw["prices_minor"]), minimum_charge_minor=int(raw.get("minimum_charge_minor", 0)), fuel_surcharge_permille=int(raw.get("fuel_surcharge_permille", 0)), + currency=raw.get("currency"), ) @@ -164,9 +167,23 @@ def _container(raw: dict, unit: str, measures: Optional[ParsedMeasures] = None) access_directions=tuple(str(d) for d in raw.get("access_directions", ())), deformable_boundary=None if raw.get("deformable_boundary") is None else raw["deformable_boundary"]["kind"], made_to_measure=_made_to_measure(raw.get("made_to_measure"), unit, measures), + pallet_overhang_limit=_pallet_overhang_limit(raw.get("pallet_overhang_limit"), unit, measures), + side_gusset_depth=_side_gusset_depth(raw.get("deformable_boundary"), unit, measures), ) +def _side_gusset_depth(raw: dict | None, unit: str, measures: ParsedMeasures) -> Length | None: + if raw is None or raw.get("side_gusset_depth") is None: + return None + return measures.read(Length, raw["side_gusset_depth"], unit) + + +def _pallet_overhang_limit(raw: dict | None, unit: str, measures: ParsedMeasures) -> OverhangLimit | None: + if raw is None: + return None + return OverhangLimit(measures.read(Length, raw["length"], unit), measures.read(Length, raw["width"], unit)) + + def _made_to_measure(raw: dict | None, unit: str, measures: ParsedMeasures) -> MadeToMeasure | None: if raw is None: return None @@ -214,12 +231,10 @@ class UnsupportedFeatureError(ValueError): # in, when Python gained both the solver behaviour and the independent validation # the staged rollout requires. PHP, Rust and the JavaScript fallback still carry them. "item": (), - # `pallet_overhang_limit` was reserved in the schema by at the 1.1.0 contract - # freeze and is refused everywhere until an engine implements it from a request: a field - # a caller can set and the solver ignores is worse than a refusal. # `access_directions` left this list in, which wired the reserved field through - # to `StopAccessibilityConstraint` in all four engines at once. - "container": ("pallet_overhang_limit",), + # to `StopAccessibilityConstraint` in all four engines at once, and + # `pallet_overhang_limit` in, which did the same for the pallet deck. + "container": (), } #: `item.shape_type` values this engine does not implement. @@ -266,6 +281,7 @@ def reject_unsupported( if isinstance(entry, dict) and entry.get("shape_type") in shape_types: found.add(f"item.shape_type={entry['shape_type']}") found.update(made_to_measure_combinations(data)) + found.update(pallet_overhang_combinations(data)) if not found: return raise UnsupportedFeatureError( @@ -276,7 +292,8 @@ def reject_unsupported( def made_to_measure_combinations(data: Mapping[str, Any]) -> set[str]: - """The made-to-measure combinations 1.6 refuses (D10, docs/MADE-TO-MEASURE-CONTAINERS.md). + """The made-to-measure combinations still refused (D10, docs/MADE-TO-MEASURE-CONTAINERS.md); + `void_fill_reserve_ratio` left the list in. Each reads a container dimension or position the fabricated box does not keep, so none has a defined meaning for a carton cut to measure. Checked on the raw request, before any @@ -294,9 +311,6 @@ def made_to_measure_combinations(data: Mapping[str, Any]) -> set[str]: obstacles = entry.get("obstacles") if isinstance(obstacles, list) and obstacles: found.add("container.made_to_measure+obstacles") - reserve = entry.get("void_fill_reserve_ratio") - if isinstance(reserve, (int, float)) and not isinstance(reserve, bool) and reserve > 0: - found.add("container.made_to_measure+void_fill_reserve_ratio") fixed = data.get("fixed_placements") if measured and isinstance(fixed, list) and any( isinstance(entry, Mapping) and isinstance(entry.get("container_type"), str) @@ -306,6 +320,39 @@ def made_to_measure_combinations(data: Mapping[str, Any]) -> set[str]: return found +def pallet_overhang_combinations(data: Mapping[str, Any]) -> set[str]: + """The combinations a pallet with an overhang limit refuses. + + Obstacles and axles are positioned in the declared frame the widened frame moves; doors + and route corridors would need a wall the overhang crosses; a reserve is a share of a + volume the limit leaves undefined; a made-to-measure box has no deck. A flat bag is + refused beside it as a request error instead, with every other wall-anchored field. + Checked on the raw request like every other unsupported feature. O(items + containers). + """ + found: set[str] = set() + pallets = False + for entry in data.get("containers") or (): + if not isinstance(entry, Mapping) or entry.get("pallet_overhang_limit") is None: + continue + pallets = True + directions = entry.get("access_directions") + obstacles = entry.get("obstacles") + reserve = entry.get("void_fill_reserve_ratio") + for name, present in ( + ("access_directions", isinstance(directions, list) and bool(directions)), + ("axles", entry.get("axles") is not None), + ("made_to_measure", entry.get("made_to_measure") is not None), + ("obstacles", isinstance(obstacles, list) and bool(obstacles)), + ("void_fill_reserve_ratio", isinstance(reserve, (int, float)) + and not isinstance(reserve, bool) and reserve > 0)): + if present: + found.add(f"container.pallet_overhang_limit+{name}") + if pallets and any(isinstance(item, Mapping) and item.get("stop_index") is not None + for item in data.get("items") or ()): + found.add("container.pallet_overhang_limit+item.stop_index") + return found + + def pack_from_dict(data: dict, *, extensions: ExtensionRegistry | None = None) -> dict: """Solve a JSON request. A request that is not one any engine may answer raises @@ -322,8 +369,9 @@ def pack_from_dict(data: dict, *, extensions = _extensions(data, extensions) items = [_item(i, unit, measures) for i in data["items"]] containers = [_container(c, unit, measures) for c in data["containers"]] - fixed = _fixed_placements(data.get("fixed_placements"), unit) - result = Packer(config, extensions)._pack_checked(tuple(items), tuple(containers), tuple(fixed)) + fixed = _fixed_placements(data.get("fixed_placements"), unit, data["items"]) + result = Packer(config, extensions)._pack_checked(tuple(items), tuple(containers), tuple(fixed), + data.get("currency")) result = replace(result, catalog_versions_used=references) output = data.get("output", {}) return result.to_dict(output.get("length_unit", output_length_unit(unit)), output.get("weight_unit", "g")) diff --git a/src/packvium/simulation.py b/src/packvium/simulation.py index eab765e..4588df5 100644 --- a/src/packvium/simulation.py +++ b/src/packvium/simulation.py @@ -26,6 +26,17 @@ from .pareto import CandidateResult, ProfileReport, generate_report +__all__ = [ + "MismatchedOrderCorpusError", + "OrderEvaluator", + "OrderRunResult", + "ScenarioError", + "ScenarioResult", + "ScenarioVersionPin", + "compare_scenarios", + "run_scenario", +] + class ScenarioError(Exception): """Base class for every scenario-domain error raised by this module.""" diff --git a/src/packvium/solvers.py b/src/packvium/solvers.py index c33bc39..3949282 100644 --- a/src/packvium/solvers.py +++ b/src/packvium/solvers.py @@ -26,6 +26,8 @@ from .geometry import (AxisAlignedBox, Dimensions, Point, Rotation, ShapeType, dimensional_weight) from .flat_bag import as_dimensions, fits_alone +from .pallet_overhang import centring_offset, deck_alone, floor_support_ratio +from .pallet_overhang import billed_dimensions as pallet_billed from .lattice_summary import LatticeSummary from .models import (Container, ItemInstance, PackedContainer, Placement, UnpackedItem, carries_itself, hull_collision_is_exact, is_stack_sensitive) @@ -415,23 +417,19 @@ def add_direct(self, placement: Placement) -> None: GridSolver constructs a proven non-overlapping lattice directly. Updating the general solver's occupied-box and projected-point indexes for that path turned an otherwise linear placement loop into quadratic work. + + Only the lattice and the block loader place directly, and both admit rigid boxes + alone (`_lattice_profile` keys on the shape), so no direct placement is ever + compressed by the load above it and its volume is its own. """ box = placement.envelope_box - compression_sensitive = ( - self.compression_sensitive - or placement.instance.item.shape_type is ShapeType.COMPRESSIBLE - ) - if compression_sensitive: - self.used_volume_ticks = _used_volume_with_current_loads((*self.placements, placement)) - else: - self.used_volume_ticks += used_volume_delta(self.placements, placement) + self.used_volume_ticks += used_volume_delta(self.placements, placement) self.placements.append(placement) self._placed = None self.payload_ticks += placement.instance.weight.ticks item = placement.instance.item self.stack_sensitive = self.stack_sensitive or is_stack_sensitive(item) self.route_sensitive = self.route_sensitive or item.stop_index is not None - self.compression_sensitive = compression_sensitive self._extend_extent(box.x2, box.y2, box.z2) def _extend_extent(self, x2: int, y2: int, z2: int) -> None: @@ -634,10 +632,18 @@ def cached(context: ConstraintContext) -> ConstraintResult: return cached -def find_candidates(state: ContainerState, item: ItemInstance, config: PackingConfig, constraints: Sequence[PlacementConstraint], stats: SearchStats, deadline: Deadline, max_candidates: int | None = None, points: Sequence[Point] | None = None) -> list[Candidate]: +def _prepare_candidate_search( + state: ContainerState, + item: ItemInstance, + config: PackingConfig, + constraints: Sequence[PlacementConstraint], +) -> tuple[Any, ...] | None: + """Prepare the item-invariant rotation forms, bounds and constraint chain once per (state, item).""" container = state.container - if container.max_items is not None and len(state.placements) >= container.max_items: return [] - if container.max_payload is not None and state.payload_ticks + item.weight.ticks > container.max_payload.ticks: return [] + if container.max_items is not None and len(state.placements) >= container.max_items: + return None + if container.max_payload is not None and state.payload_ticks + item.weight.ticks > container.max_payload.ticks: + return None compression_sensitive = ( state.compression_sensitive or item.item.shape_type is ShapeType.COMPRESSIBLE ) @@ -647,13 +653,16 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo or compression_sensitive ) if container.void_fill_reserve_ratio > 0 and not reserve_needs_candidate: - if state.used_volume_ticks + item.dimensions.volume > usable_volume(container): return [] + if state.used_volume_ticks + item.dimensions.volume > usable_volume(container): + return None inner = container.inner_dimensions limit_x, limit_y, limit_z = inner.length.ticks, inner.width.ticks, inner.height.ticks - # A flat bag also needs a + c <= L and b + c <= W over the running extents grown by the - # candidate: O(1), and the extents only grow, so no admitted verdict flips. + # A bag also needs a + c <= L and b + c <= W + G over the running extents grown by the + # candidate (G is a gusseted bag's side gusset, 0 for a flat one): O(1), and + # the extents only grow, so no admitted verdict flips. W + G is summed once per call. bag = container.is_flat_bag bag_x, bag_y, bag_z = state.max_x, state.max_y, state.max_z + girth_y = limit_y + container.side_gusset_ticks clearance = config.clearance.ticks # Envelope and extents depend only on the rotation, so they are built once instead # of once per (point, rotation) pair. @@ -704,17 +713,27 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo for constraint in chain) inert_support_checks = len(chain) tracing = trace.active() - if points is None: - if item.item.nesting_height is None: - ordered = state.ordered_points[:config.max_candidate_points] - else: - merged = {(point.x, point.y, point.z): point - for point in (*state.ordered_points, *_nesting_points(state, item))} - ordered = sorted(merged.values(), key=lambda point: (point.z, point.y, point.x))[:config.max_candidate_points] - if container.axles is not None: - ordered = _append_unseen(ordered, _axle_balanced_points(state, item, forms, limit_x)) - else: - ordered = list(points) if len(points) <= 1 else sorted(points, key=lambda p: (p.z, p.y, p.x)) + return ( + state, container, item, config, forms, placed, stack_sensitive, route_sensitive, + bounds, hull_shapes, index, nesting, placement_offset, reserve_check, usable, + compression_sensitive, active, inert_chain, inert_support_checks, tracing, + limit_x, limit_y, limit_z, bag, bag_x, bag_y, bag_z, girth_y, clearance, + ) + + +def _scan_candidate_points( + prepared: tuple[Any, ...], + ordered: Sequence[Point], + max_candidates: int | None, + stats: SearchStats, + deadline: Deadline, +) -> list[Candidate]: + ( + state, container, item, config, forms, placed, stack_sensitive, route_sensitive, + bounds, hull_shapes, index, nesting, placement_offset, reserve_check, usable, + compression_sensitive, active, inert_chain, inert_support_checks, tracing, + limit_x, limit_y, limit_z, bag, bag_x, bag_y, bag_z, girth_y, clearance, + ) = prepared candidates: list[Candidate] = [] best: Candidate | None = None retained: list[tuple[tuple[int, ...], int, Candidate]] = [] @@ -754,7 +773,7 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo # four times dearer per bag candidate (75 ns against 309 ns, CPython 3.11). if x2 > limit_x or y2 > limit_y or z2 > limit_z or ( bag and ((x2 if x2 > bag_x else bag_x) + (thickness := z2 if z2 > bag_z else bag_z) > limit_x - or (y2 if y2 > bag_y else bag_y) + thickness > limit_y)): + or (y2 if y2 > bag_y else bag_y) + thickness > girth_y)): if tracing: trace.emit({"type": "filter", "item_id": item.id, "point": {"x": x1, "y": y1, "z": z1}, "rotation": rotation.value, "reason": "boundary"}) continue @@ -882,10 +901,31 @@ def find_candidates(state: ContainerState, item: ItemInstance, config: PackingCo return candidates +def find_candidates(state: ContainerState, item: ItemInstance, config: PackingConfig, constraints: Sequence[PlacementConstraint], stats: SearchStats, deadline: Deadline, max_candidates: int | None = None, points: Sequence[Point] | None = None) -> list[Candidate]: + prepared = _prepare_candidate_search(state, item, config, constraints) + if prepared is None: + return [] + forms = prepared[4] + limit_x = prepared[20] + container = state.container + if points is None: + if item.item.nesting_height is None: + ordered = state.ordered_points[:config.max_candidate_points] + else: + merged = {(point.x, point.y, point.z): point + for point in (*state.ordered_points, *_nesting_points(state, item))} + ordered = sorted(merged.values(), key=lambda point: (point.z, point.y, point.x))[:config.max_candidate_points] + if container.axles is not None: + ordered = _append_unseen(ordered, _axle_balanced_points(state, item, forms, limit_x)) + else: + ordered = list(points) if len(points) <= 1 else sorted(points, key=lambda p: (p.z, p.y, p.x)) + return _scan_candidate_points(prepared, ordered, max_candidates, stats, deadline) + + def _support_ratio(state: ContainerState, item: ItemInstance, candidate: Candidate) -> float: - if candidate.point.z == 0: return 1.0 box = AxisAlignedBox(candidate.point, candidate.envelope_dimensions) - support = direct_support_view(state.placements, item, box) + if candidate.point.z == 0: return floor_support_ratio(state.container.deck, box) + support = direct_support_view(state.frozen_placements, item, box) return support.supporting_area / candidate.envelope_dimensions.base_area @@ -1346,6 +1386,10 @@ def _lattice_profile(item) -> tuple: # they share the stacking rule as well. item.stacking_group, item.accepts_stacking_groups, + # `pack_one` refuses a prototype that is not a rigid box, so the shape must be in the + # key too: otherwise a compressible type of the same size and weight joins a rigid + # prototype's lattice and is stacked with no crush check at all. + item.shape_type, ) @@ -1390,6 +1434,10 @@ def pack_one(self, container, sequence, items, config, stats, deadline): # The lattice counts cells against the rigid frame alone; a bag's girth rule # couples the axes, which the per-candidate check applies exactly. return ExtremePointSolver().pack_one(container, sequence, items, config, stats, deadline) + if container.deck is not None: + # The lattice rests every floor cell on the whole floor; a pallet's frame bears + # weight only on its deck, which the per-candidate support check reads. + return ExtremePointSolver().pack_one(container, sequence, items, config, stats, deadline) if container.max_stack_density is not None: # The single-layer heuristic below only ever reasons about one item # resting on one other, never the cumulative load a tall column presses @@ -1528,25 +1576,6 @@ class Space: def box(self) -> AxisAlignedBox: return AxisAlignedBox(self.origin, self.dimensions) -def _subtract(space: Space, box: AxisAlignedBox) -> list[Space]: - """The six maximal slabs of `space` that survive carving `box` out of it.""" - source = space.box - if not source.intersects(box): return [space] - out: list[Space] = [] - - def push(x1: int, y1: int, z1: int, x2: int, y2: int, z2: int) -> None: - if x2 > x1 and y2 > y1 and z2 > z1: - out.append(Space(Point(x1, y1, z1), Dimensions(Length(x2 - x1), Length(y2 - y1), Length(z2 - z1)))) - - push(source.origin.x, source.origin.y, source.origin.z, min(source.x2, box.origin.x), source.y2, source.z2) - push(max(source.origin.x, box.x2), source.origin.y, source.origin.z, source.x2, source.y2, source.z2) - push(source.origin.x, source.origin.y, source.origin.z, source.x2, min(source.y2, box.origin.y), source.z2) - push(source.origin.x, max(source.origin.y, box.y2), source.origin.z, source.x2, source.y2, source.z2) - push(source.origin.x, source.origin.y, source.origin.z, source.x2, source.y2, min(source.z2, box.origin.z)) - push(source.origin.x, source.origin.y, max(source.origin.z, box.z2), source.x2, source.y2, source.z2) - return out - - #: Adversarial item mixes can carve far more surviving maximal spaces than any real #: request needs -- growth is roughly linear in placement count once size variety #: defeats the dominance filter below, and every extra space multiplies the per-item @@ -1556,6 +1585,27 @@ def push(x1: int, y1: int, z1: int, x2: int, y2: int, z2: int) -> None: MAX_MAXIMAL_SPACES = 256 +def _slabs( + s: tuple[int, int, int, int, int, int], + bx1: int, by1: int, bz1: int, bx2: int, by2: int, bz2: int, +) -> list[tuple[tuple[int, int, int, int, int, int], Space | None]]: + x1, y1, z1, x2, y2, z2 = s + out: list[tuple[tuple[int, int, int, int, int, int], Space | None]] = [] + if (nx2 := bx1 if bx1 < x2 else x2) > x1: + out.append(((x1, y1, z1, nx2, y2, z2), None)) + if x2 > (nx1 := bx2 if bx2 > x1 else x1): + out.append(((nx1, y1, z1, x2, y2, z2), None)) + if (ny2 := by1 if by1 < y2 else y2) > y1: + out.append(((x1, y1, z1, x2, ny2, z2), None)) + if y2 > (ny1 := by2 if by2 > y1 else y1): + out.append(((x1, ny1, z1, x2, y2, z2), None)) + if (nz2 := bz1 if bz1 < z2 else z2) > z1: + out.append(((x1, y1, z1, x2, y2, nz2), None)) + if z2 > (nz1 := bz2 if bz2 > z1 else z1): + out.append(((x1, y1, nz1, x2, y2, z2), None)) + return out + + def subtract_all( spaces: Sequence[Space], boxes: Sequence[AxisAlignedBox], @@ -1566,33 +1616,50 @@ def subtract_all( # carve can therefore neither contain nor be contained by another survivor, and a # new slab cannot contain it either (the slab's own parent could not), so only # new slabs need the dominance check below: O(new * s) instead of O(s^2). - current: list[tuple[Space, bool]] = [(space, False) for space in spaces] + # + # Slabs stay integer extents until they survive dominance filtering; a `Space` + # is constructed only for a new slab that is kept. + current: list[tuple[tuple[int, int, int, int, int, int], Space | None]] = [ + ((space.origin.x, space.origin.y, space.origin.z, + space.origin.x + space.dimensions.length.ticks, + space.origin.y + space.dimensions.width.ticks, + space.origin.z + space.dimensions.height.ticks), space) + for space in spaces + ] for box in boxes: if stats is not None: stats.space_partitions += len(current) - carved: list[tuple[Space, bool]] = [] - for space, is_new in current: - if space.box.intersects(box): - carved.extend((part, True) for part in _subtract(space, box)) + bx1, by1, bz1, bx2, by2, bz2 = box.origin.x, box.origin.y, box.origin.z, box.x2, box.y2, box.z2 + carved: list[tuple[tuple[int, int, int, int, int, int], Space | None]] = [] + for entry in current: + e = entry[0] + if e[0] < bx2 and e[3] > bx1 and e[1] < by2 and e[4] > by1 and e[2] < bz2 and e[5] > bz1: + carved.extend(_slabs(e, bx1, by1, bz1, bx2, by2, bz2)) else: - carved.append((space, is_new)) + carved.append(entry) current = carved - unique: dict[tuple[int, ...], tuple[Space, bool]] = {} - for space, is_new in current: - unique[(space.origin.x, space.origin.y, space.origin.z, space.dimensions.length.ticks, - space.dimensions.width.ticks, space.dimensions.height.ticks)] = (space, is_new) - kept = sorted(unique.values(), - key=lambda pair: (pair[0].origin.z, pair[0].origin.y, pair[0].origin.x, - -pair[0].dimensions.volume)) + unique: dict[tuple[int, int, int, int, int, int], tuple[tuple[int, int, int, int, int, int], Space | None]] = {} + for entry in current: + unique[entry[0]] = entry + kept = sorted( + unique.values(), + key=lambda pair: ( + pair[0][2], pair[0][1], pair[0][0], + -(pair[0][3] - pair[0][0]) * (pair[0][4] - pair[0][1]) * (pair[0][5] - pair[0][2]), + ), + ) # Without this filter every placement multiplies the space list and the solver # degenerates into an exponential scan of overlapping duplicates. - extents = [(space.origin.x, space.origin.y, space.origin.z, - space.origin.x + space.dimensions.length.ticks, - space.origin.y + space.dimensions.width.ticks, - space.origin.z + space.dimensions.height.ticks) - for space, _ in kept] - result = [space for index, (space, is_new) in enumerate(kept) - if not (is_new and _is_contained(extents, index))] + extents = [pair[0] for pair in kept] + result: list[Space] = [] + for index, (e, space) in enumerate(kept): + if space is not None: + result.append(space) + elif not _is_contained(extents, index): + result.append(Space( + Point(e[0], e[1], e[2]), + Dimensions(Length(e[3] - e[0]), Length(e[4] - e[1]), Length(e[5] - e[2])), + )) if len(result) > MAX_MAXIMAL_SPACES: result = sorted(result, key=lambda s: -s.dimensions.volume)[:MAX_MAXIMAL_SPACES] result.sort(key=lambda s: (s.origin.z, s.origin.y, s.origin.x)) @@ -1674,9 +1741,16 @@ def pack_one(self, container, sequence, items, config, stats, deadline): @staticmethod def _choose(state, spaces, item, config, constraints, stats, deadline) -> Candidate | None: + if not spaces: + return None + prepared = _prepare_candidate_search(state, item, config, constraints) + if prepared is None: + for _ in spaces: + deadline.check() + return None for space in spaces: deadline.check() - for candidate in find_candidates(state, item, config, constraints, stats, deadline, None, [space.origin]): + for candidate in _scan_candidate_points(prepared, (space.origin,), None, stats, deadline): if candidate.envelope_dimensions.fits_inside(space.dimensions): return candidate return None @@ -1818,7 +1892,7 @@ def _node_limit(self, config: PackingConfig) -> int: def _supports(self, container, items, config) -> bool: if (self.constraints or container.obstacles or container.preloaded or container.axles is not None or config.minimum_support_ratio > 0 - or container.is_flat_bag): + or container.is_flat_bag or container.deck is not None): return False if ( container.tag_limits @@ -2366,7 +2440,7 @@ def _fits_alone(container: Container, dimensions: Dimensions) -> bool: """Whether one box of these dimensions fits this container empty: the frame, and for a flat bag the girth rule too, so a bag too thin for an item proves it unplaceable.""" if container.is_flat_bag: - return fits_alone(container.inner_dimensions, dimensions) + return fits_alone(container.inner_dimensions, dimensions, container.side_gusset_ticks) return dimensions.fits_inside(container.inner_dimensions) @@ -2374,14 +2448,19 @@ def _billed_dimensional_weight(state: ContainerState, config: PackingConfig) -> """The dimensional weight, in weight ticks, a carrier bills for this container as filled. A flat bag is billed on its running extents, the parcel a carrier measures; an empty bag - has no parcel yet and bills nothing by volume. A rigid container is billed on its - declared outer size, else its inner size. O(1). + has no parcel yet and bills nothing by volume. A pallet is billed at the footprint its load + reached. A rigid container is billed on its declared outer size, else its inner size. O(1) + but for a pallet, O(p). """ container = state.container if container.is_flat_bag: if not state.placements: return 0 dimensions = as_dimensions((state.max_x, state.max_y, state.max_z)) + elif container.deck is not None: + # A pallet is billed at the footprint its load reached. O(p): the state + # tracks no lowest corner, and pallets are deck-first trials of few boxes each. + dimensions = pallet_billed(container, container.deck, (p.envelope_box for p in state.placements)) else: dimensions = container.outer_dimensions or container.inner_dimensions return dimensional_weight( @@ -2406,18 +2485,64 @@ def _used_volume_with_current_loads(placements: Sequence[Placement]) -> int: return nesting_used_volume(_with_top_loads(placements)) -def _fabricated(declared: Container, one: SingleContainerSolution) -> SingleContainerSolution: +def _fabricated(declared: Container, one: SingleContainerSolution, + rung: "made_to_measure.Triple | None" = None) -> SingleContainerSolution: """A rung trial as the box cut for it: the achieved extent of its placements, envelope - far faces with clearance, rounded up onto the machine grid (D14). O(p).""" + far faces with clearance, rounded up onto the machine grid (D14); or, given `rung`, the + rung itself, which is already on the grid. O(p).""" state = one.state.copy() achieved = (0, 0, 0) for placement in state.placements: box = placement.envelope_box achieved = (max(achieved[0], box.x2), max(achieved[1], box.y2), max(achieved[2], box.z2)) - state.container = made_to_measure.fabricate(declared, achieved) + state.container = made_to_measure.fabricate(declared, achieved if rung is None else rung) return replace(one, state=state, exhaustive=False, dominant_lattice=False) +def _moved(frame: Container, one: SingleContainerSolution, dx: int, dy: int) -> SingleContainerSolution: + """A trial's whole load moved by `(dx, dy)` on the frame, rebuilt so the state's points and + index describe the frame. Relative geometry is unchanged, so only a floor placement's + support -- its overlap with the deck -- is measured again. O(p) adds.""" + state = ContainerState(frame, one.state.sequence) + for placement in one.state.placements: + moved = replace(placement, position=_shifted(placement.position, dx, dy), + envelope_origin=_shifted(placement.envelope_origin, dx, dy)) + if moved.envelope_origin.z == 0: + moved = replace(moved, support_ratio=floor_support_ratio(frame.deck, moved.envelope_box)) + state.add(moved) + return replace(one, state=state, dominant_lattice=False) + + +def _seated(frame: Container, one: SingleContainerSolution, config: PackingConfig) -> SingleContainerSolution: + """A frame trial moved to the least overhang (`centring_offset`), kept where the solver + left it if the move would leave a floor placement short of support. O(p).""" + dx, dy = centring_offset(frame.deck, (placement.envelope_box for placement in one.state.placements)) + if dx == 0 and dy == 0: + return one + seated = _moved(frame, one, dx, dy) + support = SupportConstraint(config.minimum_support_ratio) + for placement in seated.state.placements: + if placement.envelope_origin.z: + continue + context = ConstraintContext(frame, (), placement.instance, placement.envelope_origin, placement.rotation, + placement.dimensions, placement.envelope_dimensions) + if not support.evaluate(context).allowed: + return one + return seated + + +def _shifted(point: Point, dx: int, dy: int) -> Point: + return Point(point.x + dx, point.y + dy, point.z) + + +def _keeps_reserve(state: ContainerState) -> bool: + """Whether a fabricated box keeps its void-fill reserve. The reserve is a share of + the box that ships, so a box cut tight to its load can break a reserve the rung kept: the + trial then cuts the rung itself, on the grid by construction and solved under its reserve.""" + container = state.container + return container.void_fill_reserve_ratio == 0 or state.used_volume_ticks <= usable_volume(container) + + class DefaultContainerSelector: """Prefers the container that holds the most items, then the cheapest, then the tightest.""" @@ -3367,9 +3492,37 @@ def _trial(self, solver, container, sequence, items, config, stats, deadline): A declared container is one solve. A made-to-measure one runs the rung chain and returns its trial already fabricated (docs/MADE-TO-MEASURE-CONTAINERS.md). """ - if container.made_to_measure is None: - return solver.pack_one(container, sequence, items, config, stats, deadline), 1 - return self._made_to_measure_trial(solver, container, sequence, items, config, stats, deadline) + if container.made_to_measure is not None: + return self._made_to_measure_trial(solver, container, sequence, items, config, stats, deadline) + if container.deck is not None and not container.preloaded: + return self._pallet_trial(solver, container, sequence, items, config, stats, deadline) + return solver.pack_one(container, sequence, items, config, stats, deadline), 1 + + def _pallet_trial(self, solver, container, sequence, items, config, stats, deadline): + """Pack the deck alone first and use the overhang only when it places more. + + Every solver starts at its frame's origin, the corner of the overhang zone, so a + frame solve overhangs even a load the deck holds, and is billed for it. The deck + rung is the container as declared; its trial moves onto the frame unchanged. At most + two solves; a deck rung that did not place everything proves nothing about the + frame, so a trial that needed both is never exhaustive. A frame with fixed items + skips the rung, since they may already overhang. + """ + rung_config = config if config.require_placement_coordinates else replace( + config, require_placement_coordinates=True) + deck = container.deck + on_deck = solver.pack_one(deck_alone(container), sequence, items, rung_config, stats, deadline) + if on_deck.state.placement_count == len(items) or on_deck.time_limit_reached or deadline.expired: + return _moved(container, on_deck, deck.x, deck.y), 1 + try: + framed = solver.pack_one(container, sequence, items, rung_config, stats, deadline) + except TimeLimitReached: + return replace(_moved(container, on_deck, deck.x, deck.y), exhaustive=False, + time_limit_reached=True), 1 + if framed.state.placement_count > on_deck.state.placement_count: + return replace(_seated(container, framed, config), exhaustive=False), 2 + return replace(_moved(container, on_deck, deck.x, deck.y), exhaustive=False, + time_limit_reached=framed.time_limit_reached), 2 def _made_to_measure_trial(self, solver, container, sequence, items, config, stats, deadline): """Try each solving envelope in ascending order and keep the first that places every @@ -3401,6 +3554,8 @@ def _made_to_measure_trial(self, solver, container, sequence, items, config, sta last = one if one.state.placement_count: trial = _fabricated(container, one) + if not _keeps_reserve(trial.state): + trial = _fabricated(container, one, rung) if one.state.placement_count == len(items): return trial, trials key = self._round_score(trial, items, config) diff --git a/src/packvium/validation.py b/src/packvium/validation.py index 4c51612..43fc6f9 100644 --- a/src/packvium/validation.py +++ b/src/packvium/validation.py @@ -6,11 +6,11 @@ from .constraints import (CompatibilityConstraint, ConstraintContext, LoadSupportGraph, SupportConstraint, TagCountConstraint, load_units, crushed, non_stackable_failure, overloaded, stack_density_exceeded, - stack_limit_exceeded) + stack_limit_exceeded, usable_volume) from .flat_bag import extent_of, girth_admits from .geometry import AxisAlignedBox, Point from . import made_to_measure -from .models import (PackedContainer, PackingRequest, UnpackedItem, is_stack_sensitive, +from .models import (Container, PackedContainer, PackingRequest, UnpackedItem, is_stack_sensitive, placement_hits_box, placements_collide) from .nesting import is_valid_nesting as _is_valid_nesting from .packing_sequence import RouteSequenceError, safe_route_removal_order @@ -29,6 +29,21 @@ class ValidationReport: issues: tuple[ValidationIssue, ...] +def _reserve_cut(packed: PackedContainer, tight: Container, maximum: tuple[int, int, int]) -> bool: + """A box larger than the tight cut is admitted when the tight box breaks the void-fill + reserve and the larger one is on the machine grid within the maximum; whether + it keeps the reserve is checked with every other container's reserve.""" + spec = tight.made_to_measure + if tight.void_fill_reserve_ratio == 0 or spec is None: + return False + cut = made_to_measure.as_triple(packed.container.inner_dimensions) + low = made_to_measure.as_triple(tight.inner_dimensions) + if not all(low[axis] <= cut[axis] <= maximum[axis] + and (cut[axis] - spec.minimum[axis]) % spec.increment[axis] == 0 for axis in range(3)): + return False + return packed.used_volume > usable_volume(tight) + + class IndependentSolutionValidator: """Re-derives every guarantee from the placements alone. @@ -58,6 +73,8 @@ def validate( stack_sensitive = any(is_stack_sensitive(p.instance.item) for p in placements) units = load_units(placements) stack_graph = LoadSupportGraph(units) if stack_sensitive else None + compatibility_constraints = (CompatibilityConstraint(), TagCountConstraint()) if compatibility_sensitive else () + support_constraint = SupportConstraint(minimum_support_ratio) if support_sensitive else None for left, right in self._collision_pairs(placements): issues.append(ValidationIssue( "collision", @@ -78,20 +95,25 @@ def validate( if eligible_tags and not (eligible_tags & packed.container.tags): issues.append(ValidationIssue("container_ineligible", item_id)) # The common lattice case has no pair-dependent rules. Avoid building - # an O(n) "all other placements" tuple for every item in that case. - if compatibility_sensitive or support_sensitive: - others = placements[:index] + placements[index + 1:] - context = ConstraintContext(packed.container, others, placement.instance, placement.envelope_origin, - placement.rotation, placement.dimensions, placement.envelope_dimensions) - else: - context = None - constraints = () + # an O(n) "all other placements" tuple unless tag rules need it; + # support queries only inspect z2 == origin.z or origin.z < box.origin.z, + # which a positive-height box never satisfies against itself, so passing + # `placements` reuses `_TOP_PLANE_CACHE` across the container in O(n). if compatibility_sensitive: - constraints += (CompatibilityConstraint(), TagCountConstraint()) - if support_sensitive: - constraints += (SupportConstraint(minimum_support_ratio),) - for constraint in constraints: - result = constraint.evaluate(context) + others = placements[:index] + placements[index + 1:] + compat_context = ConstraintContext( + packed.container, others, placement.instance, placement.envelope_origin, + placement.rotation, placement.dimensions, placement.envelope_dimensions, + ) + for constraint in compatibility_constraints: + result = constraint.evaluate(compat_context) + if not result.allowed: issues.append(ValidationIssue(result.code, f"{item_id}: {result.detail}")) + if support_constraint is not None: + support_context = ConstraintContext( + packed.container, placements, placement.instance, placement.envelope_origin, + placement.rotation, placement.dimensions, placement.envelope_dimensions, + ) + result = support_constraint.evaluate(support_context) if not result.allowed: issues.append(ValidationIssue(result.code, f"{item_id}: {result.detail}")) if stack_sensitive: assert stack_graph is not None @@ -160,6 +182,9 @@ def _check_made_to_measure(request: PackingRequest, containers: tuple[PackedCont if machine is None or machine.made_to_measure is None or achieved is None: continue expected = made_to_measure.fabricate(machine, made_to_measure.as_triple(achieved)) + if packed.container.inner_dimensions != expected.inner_dimensions and _reserve_cut( + packed, expected, made_to_measure.as_triple(machine.inner_dimensions)): + expected = made_to_measure.fabricate(machine, made_to_measure.as_triple(packed.container.inner_dimensions)) for code, field in (("fabricated_dimensions_mismatch", "inner_dimensions"), ("outer_dimensions_mismatch", "outer_dimensions"), ("made_to_measure_cost_mismatch", "cost_minor"), @@ -231,9 +256,10 @@ def _collision_pairs(placements) -> list[tuple[int, int]]: @staticmethod def _bag_admits(packed: PackedContainer) -> bool: - """The girth rule over the envelopes alone; the frame is checked above.""" + """The girth rule over the envelopes alone, widened by a side gusset (1.10); + the frame is checked above.""" a, b, c = extent_of((box.x2, box.y2, box.z2) for box in (p.envelope_box for p in packed.placements)) - return girth_admits(packed.container.inner_dimensions, a, b, c) + return girth_admits(packed.container.inner_dimensions, a, b, c, packed.container.side_gusset_ticks) @staticmethod def _unloading_order_violation(packed: PackedContainer) -> "ValidationIssue | None": diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index f606699..ebeaefc 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -315,6 +315,26 @@ def test_a_hand_edited_container_index_is_refused_rather_than_numbered(index): assert _code(export_work_order_html, artifact) == "invalid_value" +@pytest.mark.parametrize("index", [2**53 - 1, 2**64 - 1]) +def test_a_container_number_javascript_cannot_hold_is_refused_rather_than_printed(index): + """The sheet shows the index plus one, so 2^53 - 1 is already one past the ceiling.""" + artifact = _artifact() + artifact["work_order"]["containers"][0]["container_index"] = index + assert _code(export_work_order_html, artifact) == "number_out_of_range" + + +def test_the_last_container_index_every_engine_numbers_is_printed(): + artifact = _artifact() + artifact["work_order"]["containers"][0]["container_index"] = 2**53 - 2 + assert "

Container 9007199254740991: " in export_work_order_html(artifact) + + +def test_any_printed_integer_javascript_cannot_hold_is_refused(): + artifact = _artifact() + artifact["work_order"]["containers"][0]["lines"][0]["sequence"] = -(2**53) + assert _code(export_csv, artifact) == "number_out_of_range" + + def test_the_csv_has_a_header_one_row_per_step_in_order_and_one_per_unplaced_item(): unpacked = [{"item_id": "jack#1", "item_type": "jack", "reason": "no_compatible_container_dimensions", "details": [], "proof": {"level": "proven"}}] diff --git a/tests/test_constraints.py b/tests/test_constraints.py index 7bf0b56..05133a6 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -297,6 +297,34 @@ def test_the_first_crushed_unit_is_reported_by_name(): assert overloaded(units) == ("top_load_exceeded", "base") +def _cushion_under(kilograms: int, footprint_ticks2: int | None) -> tuple[LoadUnit, LoadUnit]: + """A 100 mm compressible cube rated for 100 kPa, with `kilograms` resting on it.""" + cube = Dimensions.mm(100, 100, 100) + cushion = LoadUnit(AxisAlignedBox(Point(0, 0, 0), cube), 0, None, None, "cushion", + compression_ratio_ppm=250_000, max_compression_pressure_kpa=100, + footprint_ticks2=footprint_ticks2) + load = LoadUnit(AxisAlignedBox(Point(0, 0, cube.height.ticks), cube), + Weight.parse(f"{kilograms}kg").ticks, None, None, "load") + return cushion, load + + +@pytest.mark.parametrize("kilograms,crushes", [(101, False), (102, True)]) +def test_a_load_unit_without_a_footprint_is_pressed_over_its_own_box(kilograms, crushes): + """A `LoadUnit` built by hand need not state the item's footprint; the load then spreads + over the box's base, 0.01 m² here, which puts the 100 kPa boundary between 101 and + 102 kg -- the same boundary the full load-propagation path draws for this cushion.""" + failure = constraints.crushed(_cushion_under(kilograms, None)) + assert failure == (("crush_violation", "cushion") if crushes else None) + + +def test_a_stated_footprint_takes_precedence_over_the_box(): + """Half the footprint doubles the pressure, so a load the box's base would bear crushes + the item it actually rests on.""" + full = Dimensions.mm(100, 100, 100).base_area + assert constraints.crushed(_cushion_under(101, full)) is None + assert constraints.crushed(_cushion_under(101, full // 2)) == ("crush_violation", "cushion") + + def test_a_limit_met_exactly_is_not_exceeded(): units = ( unit(0, 0, 0, 10, 10, 10, limit=100, label="base"), @@ -1868,3 +1896,439 @@ def test_a_nesting_predecessor_is_listed_after_a_lower_indexed_face_supporter(): support = direct_support_view(placements, candidate, resting.envelope_box) assert [entry.index for entry in support.supporters] == [0, 1] + + +# --------------------- delta fast-path vs full-rebuild oracle & pathological cases + + +def _oracle_top_load(context: ConstraintContext) -> constraints.ConstraintResult: + """Reference full-rebuild evaluation of `TopLoadConstraint` without delta shortcuts.""" + candidate = context.envelope_box + item = context.item.item + unit = LoadUnit( + candidate, + context.item.weight.ticks, + None if item.max_top_load is None else item.max_top_load.ticks, + item.max_stacked_items, + context.item.id, + None if item.nesting_height is None else item.id, + None if item.nesting_height is None else item.nesting_height.ticks, + item.compression_ratio_ppm, + item.max_compression_pressure_kpa, + context.dimensions.length.ticks * context.dimensions.width.ticks, + ) + units = constraints.load_units(context.placements, unit) + graph = LoadSupportGraph(units) + failure = constraints.non_stackable_failure( + context.placements, context.item, graph, len(units) - 1 + ) + if failure is not None: + return failure + density_limit = ( + None + if context.container.max_stack_density is None + else context.container.max_stack_density.ticks + ) + raw_failure = ( + overloaded(units, graph) + or constraints.crushed(units, graph) + or stack_limit_exceeded(units, graph) + or stack_density_exceeded(units, density_limit, graph) + ) + if raw_failure is not None: + return constraints.ConstraintResult.reject(*raw_failure) + return constraints.ConstraintResult.allow() + + +def _oracle_route_order(context: ConstraintContext) -> constraints.ConstraintResult: + """Reference full-rebuild evaluation of `RouteOrderConstraint` without delta shortcuts.""" + candidate = LoadUnit( + context.envelope_box, + context.item.weight.ticks, + None, + None, + context.item.id, + None if context.item.item.nesting_height is None else context.item.item.id, + None if context.item.item.nesting_height is None else context.item.item.nesting_height.ticks, + ) + stops = [ + RIDES_THE_WHOLE_ROUTE if p.instance.item.stop_index is None else float(p.instance.item.stop_index) + for p in context.placements + ] + item_stop = context.item.item.stop_index + stops.append(RIDES_THE_WHOLE_ROUTE if item_stop is None else float(item_stop)) + units = constraints.load_units(context.placements, candidate) + graph = LoadSupportGraph(units) + failure = route_order_violated(units, stops, graph) + if failure is not None: + return constraints.ConstraintResult.reject(*failure) + return constraints.ConstraintResult.allow() + + +def test_diamond_dag_single_tick_remainder_shift_matches_full_rebuild_oracle(): + """In a 3-layer diamond DAG, adding 1 tick of weight at the apex can flip floor-division + remainders on intermediate bridges. The delta propagator must match the full-rebuild + `top_loads` oracle down to a single tick.""" + b0 = Placement( + Item.create("b0", Dimensions(Length(6), Length(10), Length(10)), weight=Weight(100)).instances()[0], + Point(0, 0, 0), Rotation.LWH, Dimensions(Length(6), Length(10), Length(10)), + Point(0, 0, 0), Dimensions(Length(6), Length(10), Length(10)), + ) + b1 = Placement( + Item.create("b1", Dimensions(Length(14), Length(10), Length(10)), weight=Weight(100)).instances()[0], + Point(6, 0, 0), Rotation.LWH, Dimensions(Length(14), Length(10), Length(10)), + Point(6, 0, 0), Dimensions(Length(14), Length(10), Length(10)), + ) + # m0 bridges b0 (3x10=30) and b1 (7x10=70); m1 bridges b0 (3x10=30) and b1 (8x10=80). + m0 = Placement( + Item.create("m0", Dimensions(Length(10), Length(5), Length(10)), weight=Weight(17)).instances()[0], + Point(3, 0, 10), Rotation.LWH, Dimensions(Length(10), Length(5), Length(10)), + Point(3, 0, 10), Dimensions(Length(10), Length(5), Length(10)), + ) + m1 = Placement( + Item.create("m1", Dimensions(Length(11), Length(5), Length(10)), weight=Weight(23)).instances()[0], + Point(3, 5, 10), Rotation.LWH, Dimensions(Length(11), Length(5), Length(10)), + Point(3, 5, 10), Dimensions(Length(11), Length(5), Length(10)), + ) + for apex_weight in (0, 1, 2, 3, 7, 13, 97): + apex, = Item.create( + "apex", Dimensions(Length(8), Length(10), Length(10)), weight=Weight(apex_weight) + ).instances() + apex_box = AxisAlignedBox(Point(4, 0, 20), apex.dimensions) + full_units = constraints.load_units((b0, b1, m0, m1), LoadUnit(apex_box, apex_weight, None, None, apex.id)) + exact_loads = top_loads(full_units) + for target_idx in range(4): + exact = exact_loads[target_idx] + for delta in (-1, 0, 1): + limit = exact + delta + if limit < 0: + continue + rebuilt_placements = [] + for idx, p in enumerate((b0, b1, m0, m1)): + lim = Weight(limit) if idx == target_idx else None + inst, = Item.create( + p.instance.item.id, p.dimensions, weight=p.instance.weight, max_top_load=lim + ).instances() + rebuilt_placements.append(Placement( + inst, p.position, p.rotation, p.dimensions, p.envelope_origin, p.envelope_dimensions + )) + scene = ConstraintContext( + BOX, tuple(rebuilt_placements), apex, Point(4, 0, 20), + Rotation.LWH, apex.dimensions, apex.dimensions, + ) + got = TopLoadConstraint().evaluate(scene) + expected = _oracle_top_load(scene) + assert got == expected, f"apex_weight={apex_weight} target={target_idx} delta={delta}" + + +def test_diamond_dag_stacked_item_count_deduplicates_shared_candidate(): + """An apex bridging two middle boxes that both rest on one floor base adds 1 item + above the base (total 3), not 2 (total 4).""" + for max_stacked, should_allow in ((3, True), (2, False)): + base, = Item.create("base", Dimensions.mm(20, 10, 10), max_stacked_items=max_stacked).instances() + left, = Item.create("left", Dimensions.mm(10, 10, 10)).instances() + right, = Item.create("right", Dimensions.mm(10, 10, 10)).instances() + apex, = Item.create("apex", Dimensions.mm(20, 10, 10)).instances() + placements = ( + placed(base, x=0, y=0, z=0), + placed(left, x=0, y=0, z=_mm(10)), + placed(right, x=_mm(10), y=0, z=_mm(10)), + ) + scene = context(apex, z=_mm(20), placements=placements) + got = TopLoadConstraint().evaluate(scene) + assert got.allowed is should_allow + assert got == _oracle_top_load(scene) + + +def test_candidate_slid_underneath_existing_upper_box_checks_bearing_stacking_and_route(): + """When a candidate is placed at z=0 directly beneath an already-placed box at z=10, + `above` is non-empty; every bearing, stacking-group, non-stackable, and route rule + must match the full-rebuild oracle.""" + upper_heavy = placed(instance_of("upper", weight="10 kg", stacking_group="B", stop_index=2), z=_mm(10)) + # 1. Candidate is non-stackable -> rejected + c_non_stackable = instance_of("c-ns", stackable=False) + scene_ns = context(c_non_stackable, z=0, placements=(upper_heavy,)) + assert TopLoadConstraint().evaluate(scene_ns) == _oracle_top_load(scene_ns) + assert not TopLoadConstraint().evaluate(scene_ns).allowed + # 2. Candidate only accepts group A, upper is group B -> rejected + c_group = instance_of("c-grp", accepts_stacking_groups=("A",)) + scene_grp = context(c_group, z=0, placements=(upper_heavy,)) + assert TopLoadConstraint().evaluate(scene_grp) == _oracle_top_load(scene_grp) + assert TopLoadConstraint().evaluate(scene_grp).code == "stacking_group_incompatible" + # 3. Candidate has max_top_load="5 kg" under 10 kg upper -> rejected + c_weak = instance_of("c-weak", max_top_load="5 kg") + scene_weak = context(c_weak, z=0, placements=(upper_heavy,)) + assert TopLoadConstraint().evaluate(scene_weak) == _oracle_top_load(scene_weak) + assert TopLoadConstraint().evaluate(scene_weak).code == "top_load_exceeded" + # 4. Candidate has stop_index=1 under stop_index=2 upper -> rejected by RouteOrderConstraint + c_early = instance_of("c-early", stop_index=1) + scene_route = context(c_early, z=0, placements=(upper_heavy,)) + assert RouteOrderConstraint().evaluate(scene_route) == _oracle_route_order(scene_route) + assert not RouteOrderConstraint().evaluate(scene_route).allowed + + +def test_weightless_candidate_on_zero_top_load_vs_one_stacked_items_and_non_stackable(): + """Zero weight on max_top_load=0 is allowed (0 <= 0), whereas max_stacked_items=1 + with one already stacked or stackable=False still rejects a weightless item.""" + zero_top = placed(instance_of("zero-top", max_top_load="0 kg"), z=0) + feather = instance_of("feather", weight="0 kg") + one_tick, = Item.create("one-tick", Dimensions.mm(10, 10, 10), weight=Weight(1)).instances() + + s_allow = context(feather, z=_mm(10), placements=(zero_top,)) + assert TopLoadConstraint().evaluate(s_allow).allowed + assert TopLoadConstraint().evaluate(s_allow) == _oracle_top_load(s_allow) + + s_reject = context(one_tick, z=_mm(10), placements=(zero_top,)) + assert not TopLoadConstraint().evaluate(s_reject).allowed + assert TopLoadConstraint().evaluate(s_reject) == _oracle_top_load(s_reject) + + one_stack = placed(instance_of("one-stack", max_stacked_items=1), z=0) + mid_feather = placed(instance_of("mid-feather", weight="0 kg"), z=_mm(10)) + s_stack = context(feather, z=_mm(20), placements=(one_stack, mid_feather)) + assert TopLoadConstraint().evaluate(s_stack).code == "stacked_item_limit_exceeded" + assert TopLoadConstraint().evaluate(s_stack) == _oracle_top_load(s_stack) + + +def test_mutated_list_placements_in_constraint_context_never_returns_stale_verdict(): + """Even if a caller passes a mutable list instead of a tuple to ConstraintContext and + mutates it in place between calls, PlacedSupportBase must not serve a stale verdict.""" + base = placed(instance_of("base", max_top_load="10 kg", stop_index=1), z=0) + mid = placed(instance_of("mid", weight="8 kg", stop_index=1), z=_mm(10)) + mutable_placements = [base] + top_constraint = TopLoadConstraint() + route_constraint = RouteOrderConstraint() + + cand_load = instance_of("cand", weight="5 kg", stop_index=2) + ctx1 = ConstraintContext( + BOX, mutable_placements, cand_load, Point(0, 0, _mm(10)), + Rotation.LWH, cand_load.item.dimensions, cand_load.item.dimensions, + ) + assert top_constraint.evaluate(ctx1).allowed + assert not route_constraint.evaluate(ctx1).allowed + + # Mutate the list in place and place candidate at z=20 on top of `mid`: now total top load is 8+5=13 > 10. + mutable_placements.append(mid) + ctx2 = ConstraintContext( + BOX, mutable_placements, cand_load, Point(0, 0, _mm(20)), + Rotation.LWH, cand_load.item.dimensions, cand_load.item.dimensions, + ) + assert not top_constraint.evaluate(ctx2).allowed + assert top_constraint.evaluate(ctx2).code == "top_load_exceeded" + + +def test_contact_graph_plane_bucket_threshold_32_vs_33_and_knife_edge_contacts(): + """Crossing the `<= 32` direct plane scan vs `> 32` spatial hash threshold must yield + identical contact edges and areas, while 0-area edge touches are excluded.""" + from packvium.contact import ContactEdge, ContactGraph + + for count in (0, 1, 31, 32, 33, 34, 65): + boxes = [ + AxisAlignedBox(Point(i * 10, 0, 0), Dimensions(Length(10), Length(10), Length(10))) + for i in range(count) + ] + # Add a box at z=10 that touches box 0 only along x=0 (0 area) and overlaps all others by 1 tick in y. + query = AxisAlignedBox(Point(0, 9, 10), Dimensions(Length(max(10, count * 10)), Length(2), Length(10))) + graph = ContactGraph(boxes, cell_hint=max(10, count * 10)) + below = graph.contacts_below(query) + expected = tuple( + ContactEdge(i, 10 * 1) for i in range(count) + ) + assert below == expected + appended = graph.with_box(query) + rebuilt = ContactGraph([*boxes, query], cell_hint=max(10, count * 10)) + assert [appended.supporters(i) for i in range(count + 1)] == [ + rebuilt.supporters(i) for i in range(count + 1) + ] + assert [appended.children(i) for i in range(count + 1)] == [ + rebuilt.children(i) for i in range(count + 1) + ] + + +@pytest.mark.parametrize("seed", range(30)) +def test_randomized_multilayer_top_load_and_route_order_fast_path_matches_full_rebuild(seed): + """Randomized multi-layer scenes with bearing limits, stack limits, density, and route stops: + the incremental delta evaluator in `PlacedSupportBase` must agree with the full-rebuild + oracle on every candidate.""" + rng = random.Random(91_000 + seed) + placements: list[Placement] = [] + for i in range(12): + lx, ly, lz = rng.choice((10, 20)), rng.choice((10, 20)), 10 + x = rng.choice((0, 10, 20, 30)) + y = rng.choice((0, 10, 20, 30)) + z = rng.choice((0, 10, 20)) + max_top = f"{rng.randint(1, 30)} kg" if rng.random() < 0.5 else None + max_stacked = rng.randint(1, 4) if rng.random() < 0.4 else None + stop = rng.choice((None, 0, 1, 2, 3)) + inst = instance_of( + f"p{i}", length=lx, width=ly, height=lz, + weight=f"{rng.randint(0, 10)} kg", + max_top_load=max_top, + max_stacked_items=max_stacked, + stop_index=stop, + ) + placements.append(placed(inst, x=_mm(x), y=_mm(y), z=_mm(z))) + + density = f"{rng.randint(50, 5000)} kg" if rng.random() < 0.5 else None + container = Container.create("c", Dimensions.mm(100, 100, 100), max_stack_density=density) + frozen = tuple(placements) + top_constraint = TopLoadConstraint() + route_constraint = RouteOrderConstraint() + + for c_idx in range(10): + lx, ly = rng.choice((10, 20, 40)), rng.choice((10, 20, 40)) + cx, cy, cz = rng.choice((0, 10, 20)), rng.choice((0, 10, 20)), rng.choice((0, 10, 20, 30)) + cand = instance_of( + f"cand{c_idx}", length=lx, width=ly, height=10, + weight=f"{rng.randint(0, 12)} kg", + max_top_load=f"{rng.randint(1, 20)} kg" if rng.random() < 0.3 else None, + max_stacked_items=rng.randint(1, 3) if rng.random() < 0.3 else None, + stop_index=rng.choice((None, 0, 1, 2, 4)), + ) + scene = ConstraintContext( + container, frozen, cand, Point(_mm(cx), _mm(cy), _mm(cz)), + Rotation.LWH, cand.item.dimensions, cand.item.dimensions, + ) + assert top_constraint.evaluate(scene) == _oracle_top_load(scene) + assert route_constraint.evaluate(scene) == _oracle_route_order(scene) + + + +@pytest.mark.parametrize("seed", range(40)) +@pytest.mark.parametrize("routed", [True, False]) +def test_randomized_crush_density_and_stack_limits_on_columns_match_full_rebuild(seed, routed): + """Columns of crushable, stack-limited items under a floor density near their own loads, + probed by candidates on one column's top, across two equal columns and on the bare floor: + every shortcut that rejects (crush, stacked count, density at a supporter and at the + candidate) and the unrouted base that lets a route-less candidate through must agree + with the full-rebuild oracle.""" + from packvium.geometry import ShapeType + + rng = random.Random(92_000 + seed) + placements: list[Placement] = [] + heights: list[int] = [] + for column in range(8): + x, y = (column % 4) * 20, (column // 4) * 20 + levels = rng.randint(1, 3) + heights.append(levels * 10) + for level in range(levels): + crushable = rng.random() < 0.5 + inst = instance_of( + f"p{column}-{level}", length=20, width=20, height=10, + weight=f"{rng.randint(1, 20)} kg", + max_stacked_items=rng.randint(1, 3) if rng.random() < 0.5 else None, + stop_index=rng.choice((None, 0, 1, 2)) if routed else None, + **({"shape_type": ShapeType.COMPRESSIBLE, "compression_ratio_ppm": 0, + "max_compression_pressure_kpa": rng.randint(200, 1500)} if crushable else {}), + ) + placements.append(placed(inst, x=_mm(x), y=_mm(y), z=_mm(level * 10))) + + density = f"{rng.choice((40_000, 120_000, 300_000, 900_000))} kg" if rng.random() < 0.8 else None + container = Container.create("c", Dimensions.mm(100, 100, 100), max_stack_density=density) + frozen = tuple(placements) + for c_idx in range(16): + column = rng.randrange(8) + x, y, z, length = (column % 4) * 20, (column // 4) * 20, heights[column], 20 + shape = rng.random() + if shape < 0.3 and column % 4 < 3 and heights[column + 1] == heights[column]: + length = 40 + elif shape < 0.45: + x, y, z = rng.choice((0, 40)), 60, 0 + cand = instance_of( + f"cand{c_idx}", length=length, width=20, height=10, + weight=f"{rng.randint(0, 60)} kg", + stop_index=rng.choice((None, 0, 3)) if routed else None, + ) + scene = ConstraintContext( + container, frozen, cand, Point(_mm(x), _mm(y), _mm(z)), + Rotation.LWH, cand.item.dimensions, cand.item.dimensions, + ) + assert TopLoadConstraint().evaluate(scene) == _oracle_top_load(scene) + assert RouteOrderConstraint().evaluate(scene) == _oracle_route_order(scene) + + +def _density_scene(placements, candidate, x, y, z, density=None) -> ConstraintContext: + container = Container.create("c", Dimensions.mm(100, 100, 100), max_stack_density=density) + return ConstraintContext(container, tuple(placements), candidate, Point(_mm(x), _mm(y), _mm(z)), + Rotation.LWH, candidate.item.dimensions, candidate.item.dimensions) + + +def _wide_floor(**kwargs) -> list[Placement]: + """A 40 x 20 floor box with a 20 x 20 box on its left half, its right half free.""" + floor = instance_of("floor", length=40, width=20, weight="10 kg", **kwargs) + upper = instance_of("upper", length=20, width=20, weight="10 kg", stop_index=1) + return [placed(floor), placed(upper, z=_mm(10))] + + +@pytest.mark.parametrize("name,placements,candidate,at,density,code", [ + ("stack count at a floor supporter", _wide_floor(max_stacked_items=1), + instance_of("cand", length=20, width=20, weight="1 kg"), (20, 0, 10), None, + "stacked_item_limit_exceeded"), + ("density at a floor supporter", _wide_floor(), + instance_of("cand", length=20, width=20, weight="20 kg"), (20, 0, 10), "30000 kg", + "stack_density_exceeded"), + ("density at the candidate on a floor supporter", _wide_floor(), + instance_of("cand", length=10, width=10, weight="5 kg"), (20, 0, 10), "40000 kg", + "stack_density_exceeded"), + ("density at the candidate on a tier", [ + placed(instance_of("floor", length=40, width=20, weight="1 kg")), + placed(instance_of("middle", length=40, width=20, weight="1 kg"), z=_mm(10)), + ], instance_of("cand", length=10, width=10, weight="5 kg"), (0, 0, 20), "20000 kg", + "stack_density_exceeded"), +]) +def test_each_shortcut_rejection_matches_the_full_rebuild(name, placements, candidate, at, density, code): + """The rejections the randomized scenes cannot reach on their own: a floor supporter that + already carries a box, and a candidate whose own footprint breaks the density on it or on + a tier.""" + scene = _density_scene(placements, candidate, *at, density=density) + verdict = TopLoadConstraint().evaluate(scene) + assert verdict == _oracle_top_load(scene) + assert verdict.code == code, name + + +def test_a_base_that_already_breaks_route_order_is_judged_by_the_full_rebuild(): + """A stop-0 box under a stop-1 box leaves the base out of order, so no shortcut may vouch + for it: the candidate is judged against the whole rebuilt route and refused like it.""" + first = instance_of("first", length=20, width=20, weight="1 kg", stop_index=0) + later = instance_of("later", length=20, width=20, weight="1 kg", stop_index=1) + base = [placed(first), placed(later, z=_mm(10))] + scene = _density_scene(base, instance_of("cand", length=20, width=20, weight="1 kg", stop_index=2), + 40, 0, 0) + verdict = RouteOrderConstraint().evaluate(scene) + assert verdict == _oracle_route_order(scene) + assert verdict.code == "unloading_order_violation" + + +def test_a_nesting_candidate_in_route_order_is_allowed_by_the_full_rebuild(): + """A nesting candidate skips every shortcut; when the rebuilt route holds it is allowed.""" + base = [placed(instance_of("first", length=20, width=20, weight="1 kg", stop_index=1))] + candidate = instance_of("cup", length=20, width=20, weight="1 kg", stop_index=0, + nesting_height=Length.mm(4)) + scene = _density_scene(base, candidate, 0, 0, 10) + verdict = RouteOrderConstraint().evaluate(scene) + assert verdict == _oracle_route_order(scene) + assert verdict.allowed + + +@pytest.mark.parametrize("count", [33, 40]) +def test_contact_graph_hashed_planes_answer_one_match_and_contacts_above(count): + """Past 32 boxes on a plane the hashed lookup serves both directions: a query touching a + single box gets that one edge, and `contacts_above` finds the boxes resting on a query no + wider than the cell hint, in index order.""" + from packvium.contact import ContactEdge, ContactGraph + + floor = [ + AxisAlignedBox(Point(i * 10, 0, 10), Dimensions(Length(10), Length(10), Length(10))) + for i in range(count) + ] + lids = [ + AxisAlignedBox(Point(i * 10, 0, 20), Dimensions(Length(10), Length(10), Length(10))) + for i in range(count) + ] + graph = ContactGraph([*floor, *lids], cell_hint=20) + one = AxisAlignedBox(Point(52, 2, 20), Dimensions(Length(4), Length(4), Length(4))) + assert graph.contacts_below(one) == (ContactEdge(5, 16),) + beneath = AxisAlignedBox(Point(52, 2, 16), Dimensions(Length(4), Length(4), Length(4))) + assert graph.contacts_above(beneath) == (ContactEdge(count + 5, 16),) + pair = AxisAlignedBox(Point(100, 0, 0), Dimensions(Length(20), Length(10), Length(10))) + assert graph.contacts_above(pair) == (ContactEdge(10, 100), ContactEdge(11, 100)) + assert graph.contacts_above(AxisAlignedBox(Point(0, 0, 90), Dimensions(Length(10), Length(10), Length(10)))) == () diff --git a/tests/test_currency.py b/tests/test_currency.py new file mode 100644 index 0000000..fe91e2b --- /dev/null +++ b/tests/test_currency.py @@ -0,0 +1,104 @@ +"""1.10: a request may declare its currency, a rate table may say which it prices in, and the +result echoes the request's. Nothing converts, prices or ranks by it (docs/SERIALIZATION.md).""" + +from __future__ import annotations + +import copy +from typing import Any, Callable + +import pytest + +from packvium import (Container, Dimensions, InvalidRequestError, Item, Packer, PackingConfig, + pack_from_dict, validate) +from packvium.models import PackingRequest, RateTable + +Json = dict[str, Any] + +THREE_LETTERS = "must be three capital letters (ISO 4217)" + + +def json_request(currency: Any = None, table: Any = None) -> Json: + request: Json = { + "items": [{"id": "cube", "quantity": 2, "weight": "1000", + "dimensions": {"length": "100", "width": "100", "height": "100"}}], + "containers": [{"id": "box", "inner_dimensions": {"length": "200", "width": "100", "height": "200"}, + "rate_table": {"weight_brackets_g": [5000], "prices_minor": [900]}}], + "configuration": {"solver_profile": "fast"}, + } + if currency is not None: + request["currency"] = currency + if table is not None: + request["containers"][0]["rate_table"]["currency"] = table + return request + + +def json_refusal(request: Json) -> InvalidRequestError: + with pytest.raises(InvalidRequestError) as caught: + pack_from_dict(copy.deepcopy(request)) + return caught.value + + +def object_refusal(currency: Any = None, table: Any = None) -> InvalidRequestError: + with pytest.raises(InvalidRequestError) as caught: + objects(currency, table)() + return caught.value + + +def objects(currency: Any = None, table: Any = None) -> Callable[[], Any]: + rates = RateTable(weight_brackets_g=(5000,), prices_minor=(900,), currency=table) + items = [Item.create("cube", Dimensions.mm(100, 100, 100), weight=1000, quantity=2)] + containers = [Container.create("box", Dimensions.mm(200, 100, 200), rate_table=rates)] + return lambda: Packer(PackingConfig.fast()).pack(items, containers, (), currency) + + +def test_the_result_echoes_the_request_currency_on_the_answer_and_every_alternative() -> None: + request = json_request("EUR", "EUR") + request["configuration"]["alternatives"] = 3 + result = pack_from_dict(request) + assert result["currency"] == "EUR" + assert all(alternative["currency"] == "EUR" for alternative in result["alternatives"]) + assert validate(request, result) == [] + + +def test_a_request_without_a_currency_keeps_its_bytes() -> None: + assert "currency" not in pack_from_dict(json_request()) + assert "currency" not in pack_from_dict({**json_request(), "currency": None}) + + +def test_the_currency_changes_nothing_but_the_echo() -> None: + plain, declared = pack_from_dict(json_request()), pack_from_dict(json_request("JPY")) + for result in (plain, declared): + result["algorithm"]["duration_ms"] = 0 + assert {key: value for key, value in declared.items() if key != "currency"} == plain + + +@pytest.mark.parametrize("currency, table, reason, field, detail", [ + (978, None, "wrong_type", "/currency", "must be a string"), + ("usd", None, "invalid_value", "/currency", THREE_LETTERS), + ("USD\n", None, "invalid_value", "/currency", THREE_LETTERS), + ("ÜSD", None, "invalid_value", "/currency", THREE_LETTERS), + ("USD", 840, "wrong_type", "/containers/0/rate_table/currency", "must be a string"), + ("USD", "EURO", "invalid_value", "/containers/0/rate_table/currency", THREE_LETTERS), + (None, "USD", "missing_field", "/currency", "is required when a rate_table declares a currency"), + ("USD", "EUR", "not_allowed", "/containers/0/rate_table/currency", + 'must equal the request currency "USD"'), +]) +def test_both_paths_refuse_a_bad_currency_alike(currency: Any, table: Any, reason: str, field: str, + detail: str) -> None: + for error in (json_refusal(json_request(currency, table)), object_refusal(currency, table)): + assert (error.reason, error.field, error.detail) == (reason, field, detail) + + +def test_a_rate_table_may_leave_its_currency_to_the_request() -> None: + assert objects("USD")().currency == "USD" + assert objects("USD", "USD")().currency == "USD" + assert objects()().currency is None + + +def test_the_object_api_carries_the_currency_last_and_optional() -> None: + rates = RateTable((1000,), (500,)) + assert rates.currency is None + request = PackingRequest((Item.create("cube", Dimensions.mm(1, 1, 1)),), + (Container.create("box", Dimensions.mm(2, 2, 2)),)) + assert request.currency is None + assert PackingRequest(request.items, request.containers, (), "EUR").currency == "EUR" diff --git a/tests/test_flat_bag.py b/tests/test_flat_bag.py index 8620b44..00e9690 100644 --- a/tests/test_flat_bag.py +++ b/tests/test_flat_bag.py @@ -132,7 +132,7 @@ class TestTheRequest: ({"deformable_boundary": "flat_bag"}, "/deformable_boundary", "wrong_type", "must be an object"), ({"deformable_boundary": {}}, "/deformable_boundary/kind", "missing_field", "is required"), ({"deformable_boundary": {"kind": "pouch"}}, "/deformable_boundary/kind", "not_allowed", - 'must be one of ["flat_bag"]'), + 'must be one of ["flat_bag","gusseted_bag"]'), ({**BAG, "deformable_boundary": {"kind": "flat_bag", "seam": 1}}, "/deformable_boundary/seam", "not_allowed", "is not a known field"), ({**BAG, "made_to_measure": {}}, "/made_to_measure", "not_allowed", diff --git a/tests/test_gusseted_bag.py b/tests/test_gusseted_bag.py new file mode 100644 index 0000000..16b599a --- /dev/null +++ b/tests/test_gusseted_bag.py @@ -0,0 +1,175 @@ +"""1.10: gusseted bags, admitted by the flat-bag rule with the width girth b + c <= W + G.""" + +from __future__ import annotations + +import pytest + +from packvium import PackingConfig +from packvium.config import SolverProfile +from packvium.extensions import LandedCostSolutionScorer +from packvium.fixed_placements import FixedPlacementError +from packvium.flat_bag import fits_alone, girth_admits +from packvium.geometry import Dimensions, Point, Rotation +from packvium.models import (Container, Item, ItemInstance, PackedContainer, PackingRequest, + Placement, RateTable) +from packvium.request_errors import InvalidRequestError +from packvium.serialization import pack_from_dict +from packvium.solvers import (ContainerState, Deadline, GridSolver, HomogeneousBlockSolver, + SearchStats, default_constraints, find_candidates) +from packvium.units import Length, Weight +from packvium.validation import IndependentSolutionValidator + + +def ticks(millimetres: int) -> int: + return Length.of(millimetres, "mm").ticks + + +def mm(length: int, width: int, height: int) -> Dimensions: + return Dimensions.mm(length, width, height) + + +def pouch(gusset: int = 10, length: int = 300, width: int = 150, height: int = 100, **fields) -> Container: + return Container("pouch", mm(length, width, height), deformable_boundary="gusseted_bag", + side_gusset_depth=Length(ticks(gusset)), **fields) + + +def book(quantity: int = 1) -> Item: + return Item("book", mm(200, 100, 20), Weight(300), quantity=quantity, allowed_rotations=(Rotation.LWH,)) + + +def placed(item: Item, index: int, z: int) -> Placement: + origin = Point(0, 0, ticks(z)) + return Placement(ItemInstance(item, index), origin, Rotation.LWH, item.dimensions, origin, item.dimensions) + + +def request(quantity: int, boundary: dict, **container) -> dict: + return {"items": [{"id": "book", "quantity": quantity, "weight": "300 g", "allowed_rotations": ["LWH"], + "dimensions": {"length": "200", "width": "100", "height": "20"}}], + "containers": [{"id": "pouch", "quantity": 1, "deformable_boundary": boundary, + "inner_dimensions": {"length": "300", "width": "150", "height": "100"}, **container}], + "configuration": {"solver_profile": "fast", "time_limit_ms": 60000, + "effort_budget": {"max_search_nodes": 100000}}} + + +def gusseted(depth: object) -> dict: + return {"kind": "gusseted_bag", "side_gusset_depth": depth} + + +class TestTheRule: + def test_the_gusset_widens_only_the_width_girth(self): + inner = mm(300, 150, 100) + assert not girth_admits(inner, ticks(200), ticks(100), ticks(60)) + assert girth_admits(inner, ticks(200), ticks(100), ticks(60), ticks(10)) + assert not girth_admits(inner, ticks(200), ticks(100), ticks(60) + 1, ticks(10)) + assert not girth_admits(inner, ticks(241), 0, ticks(60), ticks(150)) + + def test_a_single_box_fits_a_gusseted_bag_its_flat_twin_refuses(self): + inner = mm(200, 150, 100) + assert not fits_alone(inner, mm(100, 100, 60)) + assert fits_alone(inner, mm(100, 100, 60), ticks(10)) + assert not fits_alone(inner, mm(100, 160, 10), ticks(150)) + + +class TestTheModel: + def test_the_depth_is_kept_and_read_in_ticks(self): + assert pouch(25).side_gusset_ticks == ticks(25) and pouch().is_flat_bag + assert Container("box", mm(1, 1, 1)).side_gusset_ticks == 0 + + @pytest.mark.parametrize("fields, reason, detail", [ + ({"deformable_boundary": "gusseted_bag"}, "missing_field", "a gusseted_bag requires side_gusset_depth"), + ({"deformable_boundary": "flat_bag", "side_gusset_depth": Length(1)}, "not_allowed", + "side_gusset_depth requires deformable_boundary gusseted_bag"), + ({"side_gusset_depth": Length(1)}, "not_allowed", + "side_gusset_depth requires deformable_boundary gusseted_bag"), + ({"deformable_boundary": "gusseted_bag", "side_gusset_depth": Length(0)}, "invalid_value", + "side_gusset_depth must be positive"), + ({"deformable_boundary": "gusseted_bag", "side_gusset_depth": Length(ticks(150) + 1)}, "invalid_value", + "side_gusset_depth exceeds inner_dimensions width"), + ]) + def test_a_malformed_gusset_is_refused(self, fields, reason, detail): + with pytest.raises(InvalidRequestError) as caught: + Container("pouch", mm(300, 150, 100), **fields) + assert (caught.value.reason, caught.value.detail) == (reason, detail) + + def test_a_gusset_as_deep_as_the_bag_is_wide_is_admitted(self): + assert pouch(150).side_gusset_ticks == ticks(150) + + def test_a_gusseted_bag_refuses_the_wall_anchored_fields_after_its_gusset(self): + with pytest.raises(InvalidRequestError, match="outer_dimensions cannot be combined"): + pouch(outer_dimensions=mm(310, 160, 110)) + + +class TestTheRequest: + def test_the_json_path_reads_the_depth_in_the_request_unit(self): + result = pack_from_dict({**request(3, gusseted({"value": "1", "unit": "cm"})), "units": {"length": "mm"}}) + assert result["unpacked_items"] == [] + + def test_a_flat_bag_refuses_a_gusset_as_an_unknown_field(self): + with pytest.raises(InvalidRequestError) as caught: + pack_from_dict(request(1, {"kind": "flat_bag", "side_gusset_depth": "10"})) + assert (caught.value.field, caught.value.reason) == ( + "/containers/0/deformable_boundary/side_gusset_depth", "not_allowed") + + +class TestThePacking: + def test_the_gusset_admits_the_layer_the_flat_bag_refuses(self): + flat = pack_from_dict(request(4, {"kind": "flat_bag"})) + wide = pack_from_dict(request(4, gusseted("25"))) + assert [len(c["placements"]) for c in flat["containers"]] == [2] + assert [len(c["placements"]) for c in wide["containers"]] == [3] + assert wide["containers"][0]["achieved_dimensions"]["height"]["value"] == "60" + + def test_the_lattice_and_block_solvers_defer_to_the_candidate_check(self): + items = [ItemInstance(book(), index) for index in range(1, 5)] + config = PackingConfig(profile=SolverProfile.FAST) + solution = GridSolver().pack_one(pouch(), 1, items, config, SearchStats(), Deadline(60_000)) + assert solution.state.lattice_summary is None + assert len(solution.state.placements) == 3 + assert not HomogeneousBlockSolver()._supports(pouch(), items, config) + + def test_a_candidate_one_tick_past_the_widened_girth_is_never_proposed(self): + state = ContainerState(pouch(), 1) + for level in range(3): + state.add(placed(book(), level + 1, 20 * level)) + config = PackingConfig() + assert find_candidates(state, ItemInstance(book(), 4), config, default_constraints(config), + SearchStats(), Deadline(60_000)) == [] + + def test_an_item_only_the_gusset_admits_is_not_proven_unfit(self): + brick = {"id": "brick", "allowed_rotations": ["LWH"], + "dimensions": {"length": "100", "width": "100", "height": "60"}} + base = request(1, {"kind": "flat_bag"}) + base["items"] = [brick] + base["containers"][0]["inner_dimensions"]["length"] = "200" + assert pack_from_dict(base)["unpacked_items"][0]["reason"] == "no_compatible_container_dimensions" + base["containers"][0]["deformable_boundary"] = gusseted("10") + assert pack_from_dict(base)["unpacked_items"] == [] + + +class TestBillingValidationAndFixedPlacements: + def test_a_gusseted_bag_is_billed_on_its_achieved_package(self): + table = RateTable((500, 2000), (400, 1100)) + packed = PackedContainer(pouch(rate_table=table), 1, (placed(book(), 1, 0),)) + assert packed.billed_dimensions == mm(200, 100, 20) + config = PackingConfig(objective="lowest_landed_cost", dimensional_weight_divisor=5000, + dimensional_weight_length_unit="cm", dimensional_weight_weight_unit="kg") + assert LandedCostSolutionScorer.from_config(config).score_containers([packed], [])[1] == 400 + + def test_the_validator_reads_the_gusset(self): + three = tuple(placed(book(3), i, 20 * (i - 1)) for i in (1, 2, 3)) + validator = IndependentSolutionValidator() + assert validator.validate(PackingRequest((book(3),), (pouch(),)), + (PackedContainer(pouch(), 1, three),)).issues == () + narrow = pouch(9) + issues = validator.validate(PackingRequest((book(3),), (narrow,)), + (PackedContainer(narrow, 1, three),)).issues + assert [(issue.code, issue.detail) for issue in issues] == [("bag_girth_exceeded", "pouch#1")] + + def test_a_fixed_set_one_tick_past_the_widened_girth_cannot_hold(self): + fixed = [{"item_type": "book", "container_type": "pouch", "orientation": "LWH", + "position": {"x": "0", "y": "0", "z": str(20 * level)}} for level in range(3)] + past = {**request(3, gusseted({"value": str(ticks(10) - 1), "unit": "ticks"})), "fixed_placements": fixed} + with pytest.raises(FixedPlacementError) as caught: + pack_from_dict(past) + assert str(caught.value) == "invalid_fixed_placement: bag_girth_exceeded: pouch#1" + assert pack_from_dict({**request(3, gusseted("10")), "fixed_placements": fixed})["unpacked_items"] == [] diff --git a/tests/test_made_to_measure.py b/tests/test_made_to_measure.py index 87e521c..3930266 100644 --- a/tests/test_made_to_measure.py +++ b/tests/test_made_to_measure.py @@ -16,10 +16,11 @@ fabricate, icbrt_ceil, instance_edges, per_square_metre, solving_envelopes) from packvium import bounds -from packvium.models import PackedContainer, PackingRequest, Placement +from packvium.models import ItemInstance, PackedContainer, PackingRequest, Placement from packvium.solvers import (ContainerState, Deadline, ExtremePointSolver, SearchStats, SingleContainerSolution, SolverOrchestrator, TimeLimitReached) -from packvium.units import Length +from packvium.result_validation import validate +from packvium.units import Length, Weight from packvium.validation import IndependentSolutionValidator MM = Length.TICKS_PER_MM @@ -296,7 +297,6 @@ def test_a_load_larger_than_the_machine_opens_a_second_compact_box(): @pytest.mark.parametrize("fields, name", [ ({"axles": [{"position": "0"}, {"position": "1510"}]}, "container.made_to_measure+axles"), ({"obstacles": [{"id": "post", "dimensions": mm(10, 10, 10)}]}, "container.made_to_measure+obstacles"), - ({"void_fill_reserve_ratio": 0.5}, "container.made_to_measure+void_fill_reserve_ratio"), ]) def test_a_field_that_reads_the_declared_frame_is_refused(fields, name): with pytest.raises(UnsupportedFeatureError, match=name.replace("+", r"\+")): @@ -394,3 +394,63 @@ def test_the_engine_validator_recuts_the_box_from_the_declared_machine(): codes = [issue.code for issue in validator.validate(request_, (PackedContainer(stale, 1, (placement,)),)).issues] assert codes == ["made_to_measure_cost_mismatch"] + + +# ------------------------------------------------------------------------------ void-fill reserve + + +def _cut(result: dict) -> tuple[int, ...]: + sides = result["containers"][0]["inner_dimensions"] + return tuple(sides[side]["ticks"] // MM for side in ("length", "width", "height")) + + +def test_a_reserve_the_tight_box_keeps_leaves_the_box_tight(): + result = pack_from_dict(request(void_fill_reserve_ratio=0.1)) + assert _cut(result) == (210, 210, 210) + + +def test_a_reserve_the_tight_box_breaks_cuts_the_rung_the_trial_solved_in(): + data = request(void_fill_reserve_ratio=0.5) + result = pack_from_dict(data) + assert result["status"] == "feasible" + assert _cut(result) == (260, 260, 260) + assert validate(data, result) == [] + + +def test_the_validator_admits_a_larger_box_only_when_the_tight_one_breaks_the_reserve(): + loose = request(void_fill_reserve_ratio=0.5) + result = pack_from_dict(loose) + tight = request(void_fill_reserve_ratio=0.1) + assert "fabricated_dimensions_mismatch" in [issue.code for issue in validate(tight, result)] + + +def test_a_cut_off_the_machine_grid_is_refused_even_under_a_reserve(): + data = request(void_fill_reserve_ratio=0.5) + forged = copy.deepcopy(pack_from_dict(data)) + forged["containers"][0]["inner_dimensions"]["length"]["ticks"] += MM + assert "fabricated_dimensions_mismatch" in [issue.code for issue in validate(data, forged)] + + +def test_the_internal_validator_admits_a_reserve_cut_only_on_the_grid_and_with_a_reserve(): + from packvium import validation + from packvium.made_to_measure import fabricate + declared = Container("machine", Dimensions.mm(1510, 1510, 1510), made_to_measure=machine(), + void_fill_reserve_ratio=0.5) + cube = Item("cube", Dimensions.mm(100, 100, 100), Weight(1)) + placement = Placement(ItemInstance(cube, 0), Point(0, 0, 0), Rotation.LWH, cube.dimensions, + Point(0, 0, 0), cube.dimensions) + tight = fabricate(declared, ticks(100, 100, 100)) + maximum = ticks(1510, 1510, 1510) + on_grid = PackedContainer(fabricate(declared, ticks(160, 160, 160)), 1, (placement,)) + off_grid = PackedContainer(replace(declared, inner_dimensions=Dimensions.mm(161, 161, 161), + made_to_measure=None), 1, (placement,)) + assert validation._reserve_cut(on_grid, tight, maximum) + assert not validation._reserve_cut(off_grid, tight, maximum) + assert not validation._reserve_cut(on_grid, replace(tight, void_fill_reserve_ratio=0.0), maximum) + + +def test_a_reported_side_that_is_not_an_integer_is_no_reserve_cut(): + data = request(void_fill_reserve_ratio=0.5) + forged = copy.deepcopy(pack_from_dict(data)) + forged["containers"][0]["inner_dimensions"]["length"]["ticks"] = "4160000" + assert "fabricated_dimensions_mismatch" in [issue.code for issue in validate(data, forged)] diff --git a/tests/test_nested.py b/tests/test_nested.py index ebdb6a9..fbf6c77 100644 --- a/tests/test_nested.py +++ b/tests/test_nested.py @@ -6,6 +6,8 @@ from __future__ import annotations +import pytest + from packvium import (Dimensions, NestedPacker, PackingConfig, PackingLevel, Weight) from support import container, item @@ -88,3 +90,51 @@ def test_a_single_level_behaves_like_an_ordinary_pack(): assert len(result.levels) == 1 assert result.levels[0].complete + + +def test_a_level_beam_width_of_one_is_the_greedy_chain(): + cartons = [container("long", 200, 100, 100), container("single", 100, 100, 100)] + pallets = [container("tower", 100, 100, 200)] + items = [item("cube", 100, 100, 100, quantity=2)] + levels = [PackingLevel("carton", cartons), PackingLevel("pallet", pallets)] + + def shape(result): + return [(level.complete, [c.id for c in level.containers], level.score) for level in result.levels] + + assert shape(NestedPacker(1).pack(items, levels)) == shape(NestedPacker().pack(items, levels)) + + +def test_a_wider_beam_finds_the_carton_set_that_fits_the_pallet(): + """The single long carton is the better carton level and cannot stand on the tower.""" + cartons = [container("long", 200, 100, 100), container("single", 100, 100, 100)] + pallets = [container("tower", 100, 100, 200)] + levels = [PackingLevel("carton", cartons), PackingLevel("pallet", pallets)] + items = [item("cube", 100, 100, 100, quantity=2)] + + greedy = NestedPacker(1).pack(items, levels) + beam = NestedPacker(2).pack(items, levels) + + assert [level.complete for level in greedy.levels] == [True, False] + assert [level.complete for level in beam.levels] == [True, True] + assert [c.container.id for c in beam.levels[0].containers] == ["single", "single"] + + +def test_handoffs_that_are_the_same_items_occupy_one_beam_slot(): + """Packing against every carton and against `long` alone both hand up one `long#1`; + counting that twice would fill a width of two and drop the chain that fits.""" + cartons = [container("long", 200, 100, 100), container("single", 100, 100, 100)] + pallets = [container("tower", 100, 100, 200)] + result = NestedPacker(2).pack([item("cube", 100, 100, 100, quantity=2)], + [PackingLevel("carton", cartons), PackingLevel("pallet", pallets)]) + + assert all(level.complete for level in result.levels) + + +def test_no_levels_is_an_empty_answer_at_any_width(): + assert NestedPacker(3).pack([item("cube", 100, 100, 100)], []).levels == () + + +@pytest.mark.parametrize("width", [0, -1, True, 1.0, "2"]) +def test_the_level_beam_width_is_a_positive_integer(width): + with pytest.raises(ValueError, match="level_beam_width must be a positive integer"): + NestedPacker(width) diff --git a/tests/test_optimality_bounds.py b/tests/test_optimality_bounds.py index ebb49a6..20cd3c7 100644 --- a/tests/test_optimality_bounds.py +++ b/tests/test_optimality_bounds.py @@ -122,11 +122,31 @@ def test_lowest_cost_gets_the_same_bound_in_its_own_order(monkeypatch): @pytest.mark.parametrize("objective", ["maximum_value", "maximum_volume", "open_dimension_height"]) -def test_an_objective_without_a_bound_gets_none(monkeypatch, objective): - """Their score positions rank other quantities, so a vector compared against them would - line up different things -- recording nothing is the honest answer.""" - _, recorded, _ = _pack(monkeypatch, {"solver_profile": "exact_small", "objective": objective}) - assert recorded == [None] +def test_an_objective_with_its_own_key_gets_a_bound_never_above_the_packing(monkeypatch, objective): + """The five bounds plus the one key each ranks by, in its own score order.""" + result, recorded, _ = _pack(monkeypatch, {"solver_profile": "exact_small", "objective": objective}) + assert len(recorded) == 1 and recorded[0] is not None + assert tuple(int(c) for c in result["score"]) >= recorded[0] + + +def test_the_height_key_abstains_where_no_volume_argument_holds(): + """A nesting item breaks the disjoint-boxes step, so the height term is 0 while the + packing still stands some height: the statement is a gap at key 1 against 0.""" + result = packvium.pack_from_dict({ + "items": [{"id": "cup", "quantity": 3, "nesting_height": "1", + "dimensions": {"length": "10", "width": "10", "height": "6"}}], + "containers": CONTAINERS, "configuration": {"objective": "open_dimension_height"}}) + assert result["optimality"]["lower_bound"][:2] == [0, 0] + + +@pytest.mark.parametrize("objective", ["shipping_cost", "lowest_landed_cost"]) +def test_an_objective_without_a_bound_gets_none(objective): + """Their second keys go through billed weight and caller-declared tariffs, for which no + bound is designed -- recording nothing is the honest answer.""" + request = packvium.PackingRequest( + tuple(packvium.Item.create(f"i{n}", packvium.Dimensions.mm(10, 10, 10)) for n in range(2)), + (packvium.Container.create("box", packvium.Dimensions.mm(30, 30, 30)),)) + assert bounds.for_request(request.instances, request.containers, objective) is None def test_a_custom_solution_scorer_gets_no_bound(): @@ -251,8 +271,9 @@ def test_a_statement_stops_at_the_first_missed_position(): def test_an_uncovered_objective_states_nothing(): """§ 1.5: absence is "no statement", never "attained".""" - result = packvium.pack_from_dict({"containers": CONTAINERS, "items": ITEMS, - "configuration": {"objective": "maximum_value"}}) + result = packvium.pack_from_dict({"containers": CONTAINERS, "items": ITEMS, "configuration": { + "objective": "shipping_cost", "dimensional_weight_divisor": 5000, + "dimensional_weight_length_unit": "cm", "dimensional_weight_weight_unit": "kg"}}) assert set(result["optimality"]) == {"code"} diff --git a/tests/test_packing_sequence.py b/tests/test_packing_sequence.py index c301f7d..80bbb14 100644 --- a/tests/test_packing_sequence.py +++ b/tests/test_packing_sequence.py @@ -895,3 +895,53 @@ def test_safe_route_removal_order_deadlock_reports_all_stuck_boxes(): safe_route_removal_order([b0, b1], stops=[1, 0], container=container, directions=("-x",)) assert excinfo.value.stop == 0 assert 1 in excinfo.value.stuck + + +def test_out_of_z_order_floor_box_sliding_under_elevated_box_deactivates_isolated_floor_shortcut(): + """When an elevated box at z=5mm is already in the loading prefix before a floor box + of height 5mm slides underneath it at step 1, `elevated_bases` contains 5mm, so the + isolated-floor shortcut must not skip the load/stacking check on the newly formed column.""" + heavy_upper = Item.create("upper", Dimensions.mm(10, 10, 5), weight="5kg", stacking_group="C") + fragile_floor = Item.create("floor-fragile", Dimensions.mm(10, 10, 5), weight="1kg", max_top_load="1kg") + non_stack_floor = Item.create("floor-ns", Dimensions.mm(10, 10, 5), stackable=False) + group_floor = Item.create("floor-grp", Dimensions.mm(10, 10, 5), accepts_stacking_groups=["A"]) + container = Container.create("c", Dimensions.mm(10, 10, 20)) + + for base_item, expected_reason in ( + (fragile_floor, "top_load_exceeded: floor-fragile#1"), + (non_stack_floor, "non_stackable_item_has_load: floor-ns#1"), + (group_floor, "stacking_group_incompatible: floor-grp"), + ): + placements = [ + stacked_placement(base_item, 0, 0, 0), + stacked_placement(heavy_upper, 0, 0, 5), + ] + # Order [1, 0] loads the elevated box at z=5mm first, then the floor box at z=0. + with pytest.raises(SequenceReplayError) as excinfo: + verify_loading_prefix_business_rules(placements, [1, 0], container) + assert excinfo.value.index == 0 + assert excinfo.value.step == 1 + assert excinfo.value.reason == expected_reason + + +def test_isolated_floor_step_among_many_floor_boxes_catches_exact_density_offender(): + """Even when 40 boxes rest on the floor with no elevated items, the isolated-floor + fast path checks `max_stack_density` on each step and pinpoints the exact offending step.""" + light = Item.create("light", Dimensions.mm(10, 10, 5), weight="1g", quantity=40) + anvil = Item.create("anvil", Dimensions.mm(10, 10, 5), weight="50kg") + placements = [ + Placement( + inst, Point(i * 10 * MM, 0, 0), Rotation.LWH, inst.dimensions, + Point(i * 10 * MM, 0, 0), inst.dimensions, + ) + for i, inst in enumerate(light.instances()) + ] + placements.insert(27, stacked_placement(anvil, 400, 0, 0)) + container = Container.create("c", Dimensions.mm(500, 20, 20), max_stack_density="1000kg") + order = list(range(len(placements))) + with pytest.raises(SequenceReplayError) as excinfo: + verify_loading_prefix_business_rules(placements, order, container) + assert excinfo.value.index == 27 + assert excinfo.value.step == 27 + assert "stack_density_exceeded" in excinfo.value.reason + diff --git a/tests/test_pallet_overhang.py b/tests/test_pallet_overhang.py new file mode 100644 index 0000000..dfe076c --- /dev/null +++ b/tests/test_pallet_overhang.py @@ -0,0 +1,373 @@ +"""A pallet whose load may pass its deck edge by `pallet_overhang_limit` per side.""" + +from __future__ import annotations + +import copy + +import pytest + +from packvium import OverhangLimit, Packer, PackingConfig +from packvium._request_error import ObjectRefusal +from packvium.geometry import AxisAlignedBox, Dimensions, Point +from packvium.models import Axle, Container, Item, ItemInstance +from packvium.nested import NestedPacker, PackingLevel +from packvium.pallet_overhang import (Deck, achieved_dimensions, billed_dimensions, deck_alone, + deck_contact_area, floor_support_ratio, overhang_used, + solving_frame) +from packvium.request_errors import InvalidRequestError +from packvium.result_validation import validate +from packvium.serialization import UnsupportedFeatureError, pack_from_dict +from packvium.solvers import Deadline, GridSolver, HomogeneousBlockSolver, SearchStats +from packvium.units import Length, Weight + + +def ticks(millimetres: int) -> int: + return Length.of(millimetres, "mm").ticks + + +def box(x: int, y: int, z: int, length: int, width: int, height: int) -> AxisAlignedBox: + return AxisAlignedBox(Point(ticks(x), ticks(y), ticks(z)), Dimensions.mm(length, width, height)) + + +DECK = Deck(ticks(50), ticks(50), ticks(1200), ticks(800)) + + +def pallet(length: int = 50, width: int = 50, **fields) -> Container: + return Container("pallet", Dimensions.mm(1200, 800, 1000), + pallet_overhang_limit=OverhangLimit(Length.of(length, "mm"), Length.of(width, "mm")), + **fields) + + +def request(items: list[dict], limit: dict | None = None, ratio: float = 0, **container) -> dict: + declared = {"id": "pallet", "inner_dimensions": {"length": "1200", "width": "800", "height": "1000"}, + **container} + if limit is not None: + declared["pallet_overhang_limit"] = limit + return {"units": {"length": "mm"}, "items": items, "containers": [declared], + "configuration": {"minimum_support_ratio": ratio, "time_limit_ms": 60000}} + + +def carton(length: int, width: int, height: int, quantity: int = 1, **fields) -> dict: + return {"id": "carton", "quantity": quantity, "allowed_rotations": ["LWH"], + "dimensions": {"length": str(length), "width": str(width), "height": str(height)}, **fields} + + +FIFTY = {"length": "50", "width": "50"} + + +def positions(result: dict) -> list[tuple[int, int, int]]: + return sorted((p["position"]["x"]["ticks"] // 16000, p["position"]["y"]["ticks"] // 16000, + p["position"]["z"]["ticks"] // 16000) for c in result["containers"] for p in c["placements"]) + + +def achieved(result: dict) -> tuple[int, ...]: + sides = result["containers"][0]["achieved_dimensions"] + return tuple(sides[side]["ticks"] // 16000 for side in ("length", "width", "height")) + + +# ------------------------------------------------------------------- the frame and deck + +def test_the_frame_widens_both_horizontal_axes_by_twice_the_limit_and_keeps_the_deck(): + frame = solving_frame(pallet(50, 30)) + assert frame.inner_dimensions == Dimensions.mm(1300, 860, 1000) + assert frame.deck == Deck(ticks(50), ticks(30), ticks(1200), ticks(800)) + assert frame.pallet_overhang_limit is None + assert frame.declared_inner == Dimensions.mm(1200, 800, 1000) + assert solving_frame(frame) is frame + assert deck_alone(frame).inner_dimensions == Dimensions.mm(1200, 800, 1000) + assert deck_alone(frame).deck is None + + +def test_a_container_without_a_limit_is_its_own_frame(): + plain = Container("box", Dimensions.mm(10, 10, 10)) + assert solving_frame(plain) is plain + + +def test_only_the_deck_under_a_footprint_counts_as_floor_support(): + assert deck_contact_area(DECK, box(0, 0, 0, 100, 100, 10)) == ticks(50) * ticks(50) + assert deck_contact_area(DECK, box(0, 0, 0, 50, 100, 10)) == 0 + assert floor_support_ratio(DECK, box(0, 0, 0, 100, 100, 10)) == 0.25 + assert floor_support_ratio(None, box(0, 0, 0, 100, 100, 10)) == 1.0 + + +def test_the_overhang_used_is_the_sum_past_both_edges_of_each_axis(): + boxes = [box(0, 50, 0, 600, 400, 10), box(650, 450, 0, 600, 420, 10)] + assert overhang_used(DECK, boxes) == (ticks(50), ticks(20)) + assert overhang_used(DECK, []) == (0, 0) + assert achieved_dimensions(DECK, boxes) == Dimensions.mm(1250, 820, 10) + + +def test_billing_takes_the_larger_of_the_declared_footprint_and_the_grown_deck_per_axis(): + boxes = [box(0, 50, 0, 1250, 800, 10)] + frame = solving_frame(pallet()) + assert billed_dimensions(frame, DECK, boxes) == Dimensions.mm(1250, 800, 1000) + outer = solving_frame(pallet(outer_dimensions=Dimensions.mm(1210, 840, 1150))) + assert billed_dimensions(outer, DECK, boxes) == Dimensions.mm(1250, 840, 1150) + + +# ------------------------------------------------------------------- packing + +def test_a_load_the_deck_holds_never_overhangs(): + result = pack_from_dict(request([carton(400, 400, 300, quantity=6)], FIFTY)) + assert result["summary"]["unpacked_item_count"] == 0 + assert all(x >= 50 and y >= 50 for x, y, _ in positions(result)) + assert achieved(result) == (1200, 800, 300) + + +def test_overhang_is_used_when_it_places_more(): + result = pack_from_dict(request([carton(1250, 850, 100)], FIFTY, ratio=0.75)) + assert result["summary"]["packed_item_count"] == 1 + placement = result["containers"][0]["placements"][0] + assert placement["support_ratio"] == f"{(1200 * 800) / (1250 * 850):.6f}" + assert achieved(result) == (1250, 850, 100) + assert validate(request([carton(1250, 850, 100)], FIFTY, ratio=0.75), result) == [] + + +def test_the_deck_alone_bears_weight_so_a_high_ratio_refuses_the_overhang(): + """Alone, the crate opens no pallet, so there is no layout to diagnose support against; + beside a carton that opens one, the same refusal is named (feature-pallet-overhang- + support-binds.json).""" + alone = pack_from_dict(request([carton(1250, 850, 100)], FIFTY, ratio=0.95)) + assert [item["reason"] for item in alone["unpacked_items"]] == ["no_feasible_placement"] + beside = pack_from_dict(request([carton(1250, 850, 100), carton(400, 300, 200) | {"id": "small"}], + FIFTY, ratio=0.95)) + assert [item["reason"] for item in beside["unpacked_items"]] == ["insufficient_support"] + + +def test_a_zero_limit_forbids_overhang_and_still_reports_the_achieved_extent(): + zero = {"length": "0", "width": "0"} + result = pack_from_dict(request([carton(1250, 850, 100), carton(400, 300, 200, quantity=1) | {"id": "small"}], zero)) + assert [item["reason"] for item in result["unpacked_items"]] == ["no_compatible_container_dimensions"] + assert achieved(result) == (1200, 800, 200) + + +def test_a_limit_beyond_the_need_is_not_spent(): + tight = pack_from_dict(request([carton(1250, 850, 100)], FIFTY)) + loose = pack_from_dict(request([carton(1250, 850, 100)], {"length": "500", "width": "500"})) + assert achieved(tight) == achieved(loose) == (1250, 850, 100) + + +def test_the_result_reports_the_declared_container_and_the_frame_utilization(): + result = pack_from_dict(request([carton(1250, 850, 100)], FIFTY)) + container = result["containers"][0] + assert {side: v["ticks"] // 16000 for side, v in container["inner_dimensions"].items()} == { + "length": 1200, "width": 800, "height": 1000} + assert container["outer_dimensions"] == container["inner_dimensions"] + assert container["volume_utilization"] == f"{(1250 * 850 * 100) / (1300 * 900 * 1000):.6f}" + + +def test_the_lattice_and_block_fast_paths_defer_for_a_deck(): + frame = solving_frame(pallet()) + assert not HomogeneousBlockSolver()._supports(frame, (), PackingConfig()) + item = Item("cube", Dimensions.mm(100, 100, 100), Weight(1)) + instances = [ItemInstance(item, index) for index in range(4)] + solution = GridSolver().pack_one(frame, 1, instances, PackingConfig(), SearchStats(), Deadline(60000)) + assert solution.dominant_lattice is False + assert solution.state.lattice_summary is None + assert len(solution.state.placements) == 4 + + +def test_a_fixed_placement_past_the_deck_reports_its_deck_support(): + payload = request([carton(1250, 850, 100)], FIFTY) + payload["fixed_placements"] = [{"item_type": "carton", "container_type": "pallet", "container_instance": 1, + "orientation": "LWH", + "position": {"x": "0", "y": "0", "z": "0"}}] + result = pack_from_dict(payload) + assert result["containers"][0]["placements"][0]["support_ratio"] == f"{(1200 * 800) / (1250 * 850):.6f}" + assert validate(payload, result) == [] + + +def test_a_nested_level_hands_up_the_overhanging_pallet_at_its_reached_size(): + crate = Item("crate", Dimensions.mm(1250, 850, 400), Weight.parse("10 kg")) + truck = Container("truck", Dimensions.mm(1300, 900, 1100)) + result = NestedPacker().pack([crate], [PackingLevel("pallet", (pallet(),)), PackingLevel("truck", (truck,))]) + handed = result.levels[1].containers[0].placements[0] + assert handed.dimensions == Dimensions.mm(1250, 850, 1000) + + +# ------------------------------------------------------------------- refusals + +@pytest.mark.parametrize(("field", "value", "name"), [ + ("access_directions", ["+x"], "access_directions"), + ("axles", [{"position": "0"}, {"position": "1200"}], "axles"), + ("obstacles", [{"id": "post", "dimensions": {"length": "10", "width": "10", "height": "10"}}], "obstacles"), + ("void_fill_reserve_ratio", 0.25, "void_fill_reserve_ratio"), + ("made_to_measure", {"minimum": {"length": "100", "width": "100", "height": "100"}, + "increment": {"length": "100", "width": "100", "height": "100"}}, "made_to_measure"), +]) +def test_a_combination_without_a_defined_meaning_is_refused_by_name(field, value, name): + with pytest.raises(UnsupportedFeatureError, match=rf"container\.pallet_overhang_limit\+{name}"): + pack_from_dict(request([carton(100, 100, 100)], FIFTY, **{field: value})) + + +def test_an_empty_list_and_a_zero_reserve_beside_a_pallet_change_nothing(): + result = pack_from_dict(request([carton(100, 100, 100)], FIFTY, access_directions=[], obstacles=[], + void_fill_reserve_ratio=0)) + assert result["status"] == "feasible" + + +def test_a_routed_item_is_refused_beside_any_pallet_that_allows_overhang(): + with pytest.raises(UnsupportedFeatureError, match=r"container\.pallet_overhang_limit\+item\.stop_index"): + pack_from_dict(request([carton(100, 100, 100, stop_index=1)], FIFTY)) + assert pack_from_dict(request([carton(100, 100, 100, stop_index=1)]))["status"] == "feasible" + + +@pytest.mark.parametrize(("limit", "reason", "field"), [ + ("50", "wrong_type", "/containers/0/pallet_overhang_limit"), + ({"width": "50"}, "missing_field", "/containers/0/pallet_overhang_limit/length"), + ({"length": "50", "width": "50", "height": "1"}, "not_allowed", "/containers/0/pallet_overhang_limit/height"), + ({"length": "-1", "width": "0"}, "negative_measure", "/containers/0/pallet_overhang_limit/length"), + ({"length": {"value": str(2**52), "unit": "ticks"}, "width": "0"}, "above_maximum", + "/containers/0/pallet_overhang_limit/length"), +]) +def test_a_malformed_limit_is_a_request_error_at_its_own_pointer(limit, reason, field): + with pytest.raises(InvalidRequestError) as refusal: + pack_from_dict(request([carton(100, 100, 100)], limit)) + assert (refusal.value.reason, refusal.value.field) == (reason, field) + + +def test_a_flat_bag_refuses_the_limit_last_among_its_exclusions(): + with pytest.raises(InvalidRequestError) as refusal: + pack_from_dict(request([carton(100, 100, 100)], FIFTY, deformable_boundary={"kind": "flat_bag"})) + assert refusal.value.field == "/containers/0/pallet_overhang_limit" + with pytest.raises(ObjectRefusal, match="pallet_overhang_limit cannot be combined with deformable_boundary"): + Container("bag", Dimensions.mm(100, 100, 10), deformable_boundary="flat_bag", + pallet_overhang_limit=OverhangLimit(Length(0), Length(0))) + + +@pytest.mark.parametrize(("fields", "name"), [ + ({"axles": (Axle(Length(0)), Axle(Length.of(1200, "mm")))}, "axles"), + ({"access_directions": ("+x",)}, "access_directions"), + ({"void_fill_reserve_ratio": 0.5}, "void_fill_reserve_ratio"), +]) +def test_the_object_path_refuses_the_same_combinations(fields, name): + with pytest.raises(ObjectRefusal, match=f"{name} cannot be combined with pallet_overhang_limit"): + pallet(**fields) + + +def test_the_object_path_holds_the_frame_to_the_admission_ceiling(): + huge = Container("pallet", Dimensions.mm(1200, 800, 1000), + pallet_overhang_limit=OverhangLimit(Length(2**52), Length(0))) + with pytest.raises(InvalidRequestError) as refusal: + Packer().pack([Item("cube", Dimensions.mm(10, 10, 10), Weight(1))], [huge]) + assert refusal.value.field == "/containers/0/pallet_overhang_limit/length" + + +# ------------------------------------------------------------------- the validator + +def test_the_validator_recomputes_the_achieved_extent(): + payload = request([carton(1250, 850, 100)], FIFTY) + result = pack_from_dict(payload) + forged = copy.deepcopy(result) + forged["containers"][0]["achieved_dimensions"]["length"]["ticks"] -= 1 + assert "achieved_dimensions_mismatch" in [issue.code for issue in validate(payload, forged)] + del forged["containers"][0]["achieved_dimensions"] + assert "achieved_dimensions_mismatch" in [issue.code for issue in validate(payload, forged)] + + +def test_the_validator_refuses_a_floor_placement_the_deck_does_not_carry(): + payload = request([carton(400, 400, 100)], {"length": "300", "width": "300"}, ratio=0.5) + result = pack_from_dict(payload) + assert validate(payload, result) == [] + moved = copy.deepcopy(result) + where = moved["containers"][0]["placements"][0]["position"] + where["x"]["ticks"], where["y"]["ticks"] = 0, 0 + codes = [issue.code for issue in validate(payload, moved)] + assert "insufficient_support" in codes + + +def test_the_validator_refuses_a_centroid_off_the_deck_below_half_support(): + payload = request([carton(400, 400, 100)], {"length": "300", "width": "300"}, ratio=0.1) + result = pack_from_dict(payload) + moved = copy.deepcopy(result) + where = moved["containers"][0]["placements"][0]["position"] + where["x"]["ticks"], where["y"]["ticks"] = ticks(50), ticks(300) + codes = [issue.code for issue in validate(payload, moved)] + assert "centre_of_gravity_unsupported" in codes + + +def test_the_object_path_refuses_a_routed_item_beside_a_pallet(): + routed = Item("cube", Dimensions.mm(10, 10, 10), Weight(1), stop_index=1) + with pytest.raises(InvalidRequestError) as refusal: + Packer().pack([routed], [pallet()]) + assert (refusal.value.reason, refusal.value.field) == ("not_allowed", "/items/0/stop_index") + + +# ------------------------------------------------------------------- edges of the trial + +from packvium.geometry import Rotation # noqa: E402 +from packvium.pallet_overhang import centring_offset # noqa: E402 +from packvium.models import Placement # noqa: E402 +from packvium.solvers import (ContainerState, ExtremePointSolver, SingleContainerSolution, # noqa: E402 + SolverOrchestrator, TimeLimitReached, _seated) + + +def test_no_boxes_need_no_seat(): + assert centring_offset(DECK, []) == (0, 0) + + +def test_a_load_spanning_the_frame_is_already_seated(): + result = pack_from_dict(request([carton(1300, 900, 100)], FIFTY)) + assert positions(result) == [(0, 0, 0)] + + +def _plank(index: int, x: int) -> Placement: + item = Item("plank", Dimensions.mm(100, 100, 100), Weight(1)) + origin = Point(ticks(x), 0 + ticks(350), 0) + return Placement(ItemInstance(item, index), origin, Rotation.LWH, item.dimensions, origin, item.dimensions) + + +def test_a_seat_that_would_starve_a_floor_placement_is_not_taken(): + frame = solving_frame(pallet()) + state = ContainerState(frame, 1) + for placement in (_plank(0, 0), _plank(1, 1150)): + state.add(placement) + trial = SingleContainerSolution(state, ()) + assert _seated(frame, trial, PackingConfig(minimum_support_ratio=0.8)) is trial + seated = _seated(frame, trial, PackingConfig(minimum_support_ratio=0.5)) + assert [p.position.x for p in seated.state.placements] == [ticks(25), ticks(1175)] + + +def test_stacked_cartons_ride_the_seat(): + payload = request([carton(1250, 850, 400) | {"id": "crate"}, carton(400, 300, 200, quantity=4)], FIFTY, + ratio=0.75) + result = pack_from_dict(payload) + assert result["summary"]["unpacked_item_count"] == 0 + assert validate(payload, result) == [] + + +class _DeckThenTimeout: + def __init__(self): + self.calls = 0 + + def pack_one(self, container, sequence, items, config, stats, deadline): + self.calls += 1 + if self.calls == 2: + raise TimeLimitReached() + return ExtremePointSolver().pack_one(container, sequence, items, config, stats, deadline) + + +def test_a_frame_solve_that_runs_out_of_time_keeps_the_deck_rung(): + frame = solving_frame(pallet()) + crate = Item("crate", Dimensions.mm(1250, 850, 100), Weight(1)) + small = Item("small", Dimensions.mm(100, 100, 100), Weight(1)) + items = [ItemInstance(crate, 0), ItemInstance(small, 0)] + one, trials = SolverOrchestrator()._pallet_trial(_DeckThenTimeout(), frame, 1, items, PackingConfig(), + SearchStats(), Deadline(60000)) + assert (trials, one.time_limit_reached, one.exhaustive) == (1, True, False) + assert [p.instance.item.id for p in one.state.placements] == ["small"] + assert one.state.placements[0].position.x == ticks(50) + + +def test_the_validator_bills_a_pallet_at_its_reached_size(): + payload = request([carton(1250, 850, 400)], FIFTY, + outer_dimensions={"length": "1210", "width": "810", "height": "1150"}, + rate_table={"weight_brackets_g": [100000, 1000000], "prices_minor": [1000, 2500]}) + payload["configuration"].update(objective="shipping_cost", dimensional_weight_divisor=5000, + dimensional_weight_length_unit="cm", dimensional_weight_weight_unit="kg") + result = pack_from_dict(payload) + assert result["score"][1] == 1955000000000 + assert validate(payload, result) == [] + forged = copy.deepcopy(result) + forged["score"][1] = 1000 * 10**9 + assert "score_mismatch" in [issue.code for issue in validate(payload, forged)] diff --git a/tests/test_placement_id.py b/tests/test_placement_id.py new file mode 100644 index 0000000..a2a75de --- /dev/null +++ b/tests/test_placement_id.py @@ -0,0 +1,253 @@ +"""`placement_id` (1.10): `@,,`, a pure function of a placement. + +Emitted on every result placement, recomputed by the validator, restated optionally by a fixed +placement, copied into the execution plan and used there to resolve operator locks. +""" + +from __future__ import annotations + +import copy +import json + +import pytest + +from packvium import Packer, PackingConfig, pack_from_dict, validate +from packvium.artifacts import build_operational_artifact +from packvium.execution import build_execution_plan, placement_reference +from packvium.fixed_placements import FixedPlacementError +from packvium.geometry import Dimensions, Point, Rotation +from packvium.locks import PlacementLock, locks_from_plan, resolve_with_locks +from packvium.models import Container, FixedPlacement, Item +from packvium.revisions import PlanRevisionError, derive_revision, root_revision +from packvium.units import Length + +MM = 16_000 + + +def dims(length: int, width: int, height: int) -> dict: + return {"length": str(length), "width": str(width), "height": str(height)} + + +def request(container_id: str = "box", quantity: int = 4, **extra) -> dict: + return { + "configuration": {"solver_profile": "balanced", "time_limit_ms": 300000, "alternatives": 3}, + "items": [{"id": "cube", "quantity": quantity, "dimensions": dims(100, 100, 100)}], + "containers": [{"id": container_id, "quantity": 3, "inner_dimensions": dims(200, 100, 100)}], + **extra, + } + + +def ident(container_id: str, x: int, y: int, z: int, item_type: str = "cube", orientation: str = "LWH") -> str: + return json.dumps([container_id, x, y, z, item_type, orientation], ensure_ascii=False, separators=(",", ":")) + + +def expected_id(container: dict, placement: dict) -> str: + return ident(container["id"], *(placement["position"][axis]["ticks"] for axis in "xyz"), + placement["item_type"], placement["orientation"]) + + +def every_placement(result: dict): + for document in (result, *result["alternatives"]): + for container in document["containers"]: + for placement in container["placements"]: + yield container, placement + + +@pytest.mark.parametrize("container_id", ["box", "a@b#c", "böx-箱", 'q"uo\\te\n\u2028']) +def test_every_placement_of_the_answer_and_its_alternatives_carries_its_id(container_id): + result = pack_from_dict(request(container_id)) + placements = list(every_placement(result)) + assert len(placements) >= 4 + for container, placement in placements: + assert placement["placement_id"] == expected_id(container, placement) + assert validate(request(container_id), result) == [] + + +def test_a_tampered_id_is_a_mismatch_naming_both(): + result = pack_from_dict(request()) + placement = result["containers"][0]["placements"][0] + placement["placement_id"] = ident("box#1", 1, 2, 3) + issues = validate(request(), result) + assert [(issue.code, issue.detail) for issue in issues] == [ + ("placement_id_mismatch", + f"{placement['item_id']} in box#1: reported {ident('box#1', 1, 2, 3)}, " + f"expected {expected_id(result['containers'][0], placement)}"), + ] + + +def test_a_missing_id_is_a_mismatch(): + result = pack_from_dict(request()) + placement = result["containers"][0]["placements"][0] + del placement["placement_id"] + issues = validate(request(), result) + assert [issue.code for issue in issues] == ["placement_id_mismatch"] + assert 'no placement_id, expected ["box#1",' in issues[0].detail + + +def test_a_repeated_id_is_a_duplicate(): + result = pack_from_dict(request()) + first, second = result["containers"][0]["placements"][:2] + second["placement_id"] = first["placement_id"] + assert [issue.code for issue in validate(request(), result)] == [ + "placement_id_mismatch", "duplicate_placement_id"] + + +def test_a_lone_surrogate_in_an_id_is_written_as_its_escape(): + result = pack_from_dict(request("b\udc00x")) + placement = result["containers"][0]["placements"][0] + assert placement["placement_id"].startswith('["b\\udc00x#1",') + assert validate(request("b\udc00x"), result) == [] + + +def test_a_lattice_summary_has_no_placements_and_so_no_ids(): + compact = request(quantity=2) + compact["configuration"] = {"solver_profile": "fast", "require_placement_coordinates": False} + result = pack_from_dict(compact) + assert all(container["placements"] == [] for container in result["containers"]) + assert validate(compact, result) == [] + + +# ------------------------------------------------------------------ fixed placements + + +def fixed_request(**placement) -> dict: + entry = {"item_type": "cube", "container_type": "box", "position": {"x": "100"}, + "orientation": "LWH", **placement} + return request(fixed_placements=[entry]) + + +def test_a_fixed_placement_may_quote_the_id_its_result_placement_carries(): + result = pack_from_dict(fixed_request(placement_id=ident("box#1", 100 * MM, 0, 0))) + fixed = [p for p in result["containers"][0]["placements"] if p.get("fixed")] + assert [p["placement_id"] for p in fixed] == [ident("box#1", 100 * MM, 0, 0)] + + +def test_a_null_fixed_id_is_absent(): + assert pack_from_dict(fixed_request(placement_id=None))["status"] == "feasible" + + +def test_a_fixed_id_naming_another_place_is_refused(): + with pytest.raises(FixedPlacementError) as refusal: + pack_from_dict(fixed_request(placement_id=ident("box#1", 0, 0, 0))) + error = refusal.value + assert (error.code, error.reason, error.field) == ( + "invalid_fixed_placement", "invalid_value", "/fixed_placements/0/placement_id") + assert str(error) == ("invalid_fixed_placement: fixed_placements[0].placement_id must be " + + json.dumps(ident("box#1", 100 * MM, 0, 0))) + + +def test_a_fixed_id_counts_the_instance_and_defaults_it_to_one(): + with pytest.raises(FixedPlacementError) as refusal: + pack_from_dict(fixed_request(container_instance=2, position={}, placement_id=ident("box#1", 0, 0, 0))) + assert refusal.value.detail.endswith(json.dumps(ident("box#2", 0, 0, 0))) + + +def test_a_fixed_id_past_every_exact_integer_is_left_to_admission(): + """A coordinate past 2^53 - 1 has no canonical spelling and no container holds it, so the + entry is refused as outside its container, not for its id.""" + with pytest.raises(FixedPlacementError) as refusal: + pack_from_dict(fixed_request(position={"x": {"value": str(2**53), "unit": "ticks"}}, + placement_id=ident("box#1", 0, 0, 0))) + assert refusal.value.reason == "cannot_hold" and "outside_container" in refusal.value.detail + + +def test_a_fixed_id_that_is_not_a_string_is_malformed(): + with pytest.raises(FixedPlacementError) as refusal: + pack_from_dict(fixed_request(placement_id=7)) + assert (refusal.value.reason, refusal.value.field, refusal.value.detail) == ( + "malformed", "/fixed_placements/0/placement_id", "fixed_placements[0].placement_id is a string") + + +def object_pack(placement_id): + item = Item("cube", Dimensions(Length.parse("100"), Length.parse("100"), Length.parse("100")), quantity=2) + container = Container("box", Dimensions(Length.parse("200"), Length.parse("100"), Length.parse("100"))) + fixed = FixedPlacement("cube", "box", Point(100 * MM, 0, 0), Rotation.LWH, placement_id=placement_id) + return Packer(PackingConfig()).pack([item], [container], [fixed]) + + +def test_the_object_api_holds_a_fixed_id_to_the_same_rule(): + result = object_pack(ident("box#1", 100 * MM, 0, 0)) + assert result.complete + with pytest.raises(FixedPlacementError) as refusal: + object_pack(ident("box#1", 100 * MM, 0, 0, "cube", "WLH")) + assert (refusal.value.reason, refusal.value.field) == ("invalid_value", "/fixed_placements/0/placement_id") + with pytest.raises(FixedPlacementError, match="is a string"): + object_pack(5) + + +# ------------------------------------------------------------------ execution plan and locks + + +def test_the_plan_copies_each_id_and_a_projection_without_ids_keeps_its_bytes(): + result = pack_from_dict(request()) + plan = build_execution_plan(request(), result) + references = [step["placement"] for container in plan["containers"] for step in container["steps"]] + assert [r["placement_id"] for r in references] == [ + p["placement_id"] for c in result["containers"] for p in c["placements"]] + projection = copy.deepcopy(result) + for container in projection["containers"]: + for placement in container["placements"]: + del placement["placement_id"] + bare = build_execution_plan(request(), projection) + assert all("placement_id" not in step["placement"] + for container in bare["containers"] for step in container["steps"]) + assert placement_reference(0, result["containers"][0]["placements"][0]).keys() == { + "container_index", "item_type", "orientation", "position_ticks"} + + +def test_the_artifact_v1_carries_no_id(): + result = pack_from_dict(request()) + artifact = build_operational_artifact(request(), result) + assert "placement_id" not in repr(artifact) + projection = copy.deepcopy(result) + for container in projection["containers"]: + for placement in container["placements"]: + del placement["placement_id"] + assert artifact == build_operational_artifact(request(), projection) + + +def test_a_plan_with_a_non_string_id_is_refused(): + from packvium.execution import ExecutionPlanError + result = pack_from_dict(request()) + result["containers"][0]["placements"][0]["placement_id"] = 3 + with pytest.raises(ExecutionPlanError, match="placement_id"): + build_execution_plan(request(), result) + + +def test_a_lock_read_from_a_plan_is_resolved_by_its_id_not_the_emitted_index(): + approved = pack_from_dict(request()) + plan = build_execution_plan(request(), approved) + lock, *_ = locks_from_plan(plan, container_index=0, item_types=["cube"]) + assert lock.placement_id == approved["containers"][0]["placements"][0]["placement_id"] + # The index is the emitted container order, which the contract does not fix; a lock that + # names its placement by id is found wherever the re-solve listed that container. + moved = PlacementLock(7, lock.item_type, lock.orientation, lock.position_ticks, lock.placement_id) + assert resolve_with_locks(request(), [moved]).preserved + + +@pytest.mark.parametrize("identifier", [ + '["box#1",1,0,0,"cube","LWH"]', '["box#1",0,0,0,"slab","LWH"]', '["box#1",0,0,0,"cube","WLH"]', + '["",0,0,0,"cube","LWH"]', '["box#1", 0,0,0,"cube","LWH"]', '{"box#1":0}', "box#1@0,0,0", 5]) +def test_a_lock_id_must_be_this_locks_own(identifier): + with pytest.raises(ValueError, match="not the placement id of this lock"): + PlacementLock(0, "cube", "LWH", (0, 0, 0), identifier) + assert PlacementLock(0, "cube", "LWH", (0, 0, 0), ident("box#1", 0, 0, 0)).placement_id + + +# ------------------------------------------------------------------ revisions + + +def test_a_lock_event_may_carry_the_id_and_is_one_box_with_the_same_entry_without_it(): + base = request() + root = root_revision(base) + artifact = build_operational_artifact(base, pack_from_dict(copy.deepcopy(base))) + entry = {"item_type": "cube", "container_type": "box", "position": {"x": "0", "y": "0", "z": "0"}, + "orientation": "LWH"} + quoted = {**entry, "placement_id": ident("box#1", 0, 0, 0)} + events = [{"sequence": 1, "type": "placement_locked", "placement": quoted}, + {"sequence": 2, "type": "placement_verified", "placement": entry}] + revision = derive_revision(root, artifact, events) + assert revision["request"]["fixed_placements"] == [quoted] + with pytest.raises(PlanRevisionError, match="placement.placement_id is a string"): + derive_revision(root, artifact, [{"sequence": 1, "type": "placement_locked", + "placement": {**entry, "placement_id": 1}}]) diff --git a/tests/test_request_errors.py b/tests/test_request_errors.py index 44a993d..d1687eb 100644 --- a/tests/test_request_errors.py +++ b/tests/test_request_errors.py @@ -39,6 +39,12 @@ def refusal(edit) -> InvalidRequestError: (lambda d: d["items"][0].update(quantity=-2**60), "below_minimum", "/items/0/quantity", "must be at least 1"), (lambda d: d["items"][0]["dimensions"].update(width="-1"), "negative_measure", "/items/0/dimensions/width", "cannot be negative"), + # Past 2^53 - 1 a weight must be a string, but a negative one is still refused as + # negative first, whether its parser stored the JSON number as an integer or a float. + (lambda d: d["items"][0].update(weight=-2**53), "negative_measure", "/items/0/weight", + "cannot be negative"), + (lambda d: d["items"][0].update(weight=-1e16), "negative_measure", "/items/0/weight", + "cannot be negative"), (lambda d: d["items"][0]["dimensions"].update(width={"value": "1", "unit": "furlong"}), "invalid_unit", "/items/0/dimensions/width", 'has an unknown unit "furlong"'), (lambda d: d["items"][0]["dimensions"].update(width={"unit": "mm"}), "wrong_type", @@ -116,11 +122,11 @@ def test_a_request_that_is_not_an_object_is_refused_as_a_whole(): assert (caught.value.field, str(caught.value)) == ("", "invalid_request: must be an object") -def test_what_the_rules_do_not_name_still_arrives_as_a_request_error(): - # A model rule the schema does not state: an item needs at least one rotation. (An unknown - # shape type was this case until made it a `not_allowed` rule.) +def test_an_empty_rotation_list_names_the_request_rule_it_breaks(): error = refusal(lambda d: d["items"][0].update(allowed_rotations=[])) - assert (error.code, error.reason, error.field) == ("invalid_request", "invalid_value", "") + assert (error.code, error.reason, error.field, error.detail) == ( + "invalid_request", "invalid_value", "/items/0/allowed_rotations", + "must allow at least one rotation after keep_upright") def test_a_fixed_placement_refusal_is_a_request_error_with_its_own_code(): diff --git a/tests/test_request_objects_corpus.py b/tests/test_request_objects_corpus.py index da44abd..be004e9 100644 --- a/tests/test_request_objects_corpus.py +++ b/tests/test_request_objects_corpus.py @@ -69,6 +69,9 @@ "container-made-to-measure-not-object": "a machine that is not an object is not a MadeToMeasure", "container-deformable-boundary-not-object": "the object holds the bag's kind, not a block", "container-deformable-boundary-kind-missing": "the object holds the bag's kind, not a block", + "container-pallet-overhang-limit-not-object": "a limit that is not an object is not an OverhangLimit", + "container-pallet-overhang-limit-length-missing": "OverhangLimit requires both axes", + "container-pallet-overhang-limit-width-missing": "OverhangLimit requires both axes", "item-id-missing": "Item requires an id", "item-dimensions-missing": "Item requires dimensions", "item-side-missing": "Dimensions requires all three sides", @@ -83,6 +86,9 @@ "configuration-keys-unknown-first-in-code-point-order": _UNKNOWN_KEY, "effort-key-unknown": _UNKNOWN_KEY, "container-deformable-boundary-key-unknown": _UNKNOWN_KEY, + "container-gusseted-bag-key-unknown": _UNKNOWN_KEY, + "an-unknown-boundary-key-before-a-missing-gusset": _UNKNOWN_KEY, + "container-pallet-overhang-limit-key-unknown": _UNKNOWN_KEY, "container-made-to-measure-key-unknown": _UNKNOWN_KEY, "unknown-configuration-key-before-its-values": _UNKNOWN_KEY, "unsupported-before-units": "an unsupported field has no object form", @@ -100,15 +106,25 @@ "item-allowed-rotation-unknown": "Rotation has no such member", "item-allowed-rotation-lowercase": "Rotation has no such member", "item-shape-type-unknown": "ShapeType has no such member", + "item-allowed-rotation-reflection-on-a-hull": "Rotation has no such member", + "item-allowed-rotation-legacy-spelled-signed-on-a-hull": "Rotation has no such member", + "item-allowed-rotation-signed-beside-an-unknown-shape": "ShapeType has no such member", + "fixed-orientation-reflection-on-a-hull": "Rotation has no such member", "fixed-orientation-unknown": "Rotation has no such member", "configuration-solvers-not-list": _AS_LETTERS, "output-not-object": _OUTPUT, "output-length-unit-unknown": _OUTPUT, "output-weight-unit-unknown": _OUTPUT, "output-before-configuration": _OUTPUT, + "output-before-currency": _OUTPUT, # The request's length unit is an argument of each measure, not an object of its own. "units-not-object": _UNITS, "units-list": _UNITS, + "units-weight": _UNITS, + "units-unknown-key": _UNITS, + "units-unknown-keys-first-in-code-point-order": _UNITS, + "units-unknown-key-null": _UNITS, + "unknown-units-key-before-its-length": _UNITS, "units-length-object": _UNIT_KEY, "units-length-list": _UNIT_KEY, } @@ -122,12 +138,14 @@ "an-item-id-before-its-quantity": _CONSTRUCTOR_FIRST, "item-ids-before-containers": _CONSTRUCTOR_FIRST, "container-ids-before-fixed-placements": _CONSTRUCTOR_FIRST, + "currency-before-configuration": _CONSTRUCTOR_FIRST, **{name: _CEILING_SECOND for name in ( "item.nesting_height=2^53 ticks", *(f"container.made_to_measure.minimum.{axis}=2^53 ticks" for axis in ("length", "width", "height")), *(f"container.made_to_measure.increment.{axis}=2^53 ticks" for axis in ("length", "height")), *(f"container.obstacles.origin.{axis}=2^53 ticks" for axis in "xyz"), "container.obstacles.dimensions.length=2^53 ticks", + "container-gusseted-bag-side-gusset-depth-past-the-ceiling", )}, "configuration-boolean-before-effort-budget": ("two faults: the EffortBudget is built, and refuses " "its own value, before the PackingConfig that holds it"), @@ -138,7 +156,7 @@ } #: Pinned, so the skip set cannot grow without this line changing too. -SKIPPED = 87 +SKIPPED = 105 @dataclass(frozen=True) @@ -172,7 +190,8 @@ def fixed_placement(raw: dict[str, Any], unit: str) -> FixedPlacement: return FixedPlacement(item_id=raw["item_type"], container_id=raw["container_type"], position=Point(*_point_ticks(raw.get("position", {}), unit)), rotation=Rotation(raw["orientation"]), - container_instance=raw.get("container_instance", 1)) + container_instance=raw.get("container_instance", 1), + placement_id=raw.get("placement_id")) def object_refusal(request: Any) -> Refusal: @@ -186,7 +205,7 @@ def object_refusal(request: Any) -> Refusal: except Exception as error: # what the objects could not be built from is the finding return Refusal(error, "constructor") try: - Packer(config).pack(items, containers, fixed) + Packer(config).pack(items, containers, fixed, data.get("currency")) except Exception as error: return Refusal(error, "pack") return Refusal(None, "pack") diff --git a/tests/test_result_validation.py b/tests/test_result_validation.py index cb7de44..92b480c 100644 --- a/tests/test_result_validation.py +++ b/tests/test_result_validation.py @@ -49,7 +49,9 @@ def test_two_items_in_one_place_are_a_collision() -> None: result = pack_from_dict(REQUEST) placements = result["containers"][0]["placements"] placements[1]["position"] = copy.deepcopy(placements[0]["position"]) - assert [issue.code for issue in validate(REQUEST, result)] == ["collision"] + # One place is one id (1.10): the moved item restates it, and shares it. + placements[1]["placement_id"] = placements[0]["placement_id"] + assert [issue.code for issue in validate(REQUEST, result)] == ["collision", "duplicate_placement_id"] def test_a_result_of_the_wrong_shape_is_refused_rather_than_judged() -> None: @@ -57,6 +59,15 @@ def test_a_result_of_the_wrong_shape_is_refused_rather_than_judged() -> None: validate(REQUEST, {"containers": "none"}) +@pytest.mark.parametrize("units, key", [({"length": "mm", "weight": "kg"}, "weight"), + ({"zeta": None, "alpha": "x", "length": "mm"}, "alpha")]) +def test_a_units_key_other_than_length_is_a_contract_break(units, key) -> None: + """The first unknown key in code-point order is named, as every engine's refusal does.""" + result = pack_from_dict(REQUEST) + with pytest.raises(MalformedResult, match=rf"^units\.{key} is not a known field$"): + validate({**REQUEST, "units": units}, result) + + def test_the_conformance_suite_runs_this_same_module() -> None: import sys @@ -177,12 +188,14 @@ def test_a_wrong_bound_value_is_an_optimality_bound_mismatch() -> None: def test_a_bound_where_none_is_stated_is_an_optimality_bound_mismatch() -> None: - request = {**GAP, "configuration": {"objective": "maximum_value"}} + request = {**GAP, "configuration": { + "objective": "shipping_cost", "dimensional_weight_divisor": 5000, + "dimensional_weight_length_unit": "cm", "dimensional_weight_weight_unit": "kg"}} result = pack_from_dict(request) result["optimality"]["lower_bound"] = [0] assert [(issue.code, issue.detail) for issue in validate(request, result)] == [( "optimality_bound_mismatch", - "lower_bound is reported where no bound is stated: objective 'maximum_value' has no bound", + "lower_bound is reported where no bound is stated: objective 'shipping_cost' has no bound", )] @@ -250,6 +263,45 @@ def test_lowest_cost_reads_the_same_bound_in_its_own_order() -> None: assert objective_lower_bound({**priced, "configuration": {"objective": "lowest_cost"}}) == (0, 7, 1, 500000, 500000) +def test_each_objective_key_of_its_own_is_recomputed() -> None: + """The oracle's first worked example, as a request -- three 600-volume items, one + slot; the two cheapest values forgone, one item's 6 mm (96000 ticks), 800 of 1800 left out.""" + from packvium.result_validation import objective_lower_bound + + request = {"items": [{"id": f"i{value}", "value": value, + "dimensions": {"length": "10", "width": "10", "height": "6"}} + for value in (50, 10, 90)], + "containers": [{"id": "c", "quantity": 1, + "inner_dimensions": {"length": "10", "width": "10", "height": "10"}}]} + expected = {"maximum_value": (2, 60, 1, 0, 400000), "open_dimension_height": (2, 96000, 1, 0, 400000), + "maximum_volume": (444445, 2, 1, 0, 400000)} + for objective, vector in expected.items(): + assert objective_lower_bound({**request, "configuration": {"objective": objective}}) == vector + + +def test_an_objective_key_of_its_own_abstains_or_withholds_alone() -> None: + """Nesting drops both volume terms; unlimited stock forces no volume out; a value sum past + 2^53 - 1 withholds `maximum_value` and leaves every other objective stated.""" + from packvium.result_validation import objective_lower_bound + + def under(objective: str, request: dict) -> Any: + return objective_lower_bound({**request, "configuration": {"objective": objective}}) + + box = {"length": "10", "width": "10", "height": "6"} + one_slot = [{"id": "c", "quantity": 1, "inner_dimensions": {"length": "10", "width": "10", "height": "10"}}] + nesting = {"items": [{"id": "cup", "quantity": 3, "dimensions": box, "nesting_height": "1"}], + "containers": one_slot} + assert under("maximum_volume", nesting)[0] == 0 + assert under("open_dimension_height", nesting)[1] == 0 + unlimited = {"items": [{"id": "i", "quantity": 3, "dimensions": box}], + "containers": [{**one_slot[0], "quantity": None}]} + assert under("maximum_volume", unlimited)[0] == 0 + precious = {"items": [{"id": "i", "quantity": 3, "value": 2 ** 53 - 1, "dimensions": box}], + "containers": one_slot} + assert under("maximum_value", precious) is None + assert under("open_dimension_height", precious) == (2, 96000, 1, 0, 400000) + + def test_no_bound_is_stated_past_the_declared_ceiling_or_for_a_made_to_measure_container() -> None: from packvium.result_validation import objective_lower_bound @@ -355,3 +407,69 @@ def test_no_bound_is_stated_for_an_invalid_result() -> None: result["optimality"] = {"code": "not_proven", "lower_bound": [0]} assert ("lower_bound is reported where no bound is stated: status is 'invalid_result'" in [issue.detail for issue in validate(GAP, result)]) + + +#: Counted work decides these packs; the wall clock is only a fuse. +COUNTED = {"time_limit_ms": 60_000, "effort_budget": {"max_candidates_evaluated": 100_000}} + + +def _cups(container: dict, configuration: dict) -> tuple[dict, dict]: + """Two nesting cups of one type, packed by the engine.""" + request = { + "items": [{"id": "cup", "quantity": 2, "nesting_height": "2", + "dimensions": {"length": "10", "width": "10", "height": "10"}}], + "containers": [{"id": "c", "inner_dimensions": container}], + "configuration": {**COUNTED, **configuration}, + } + return request, pack_from_dict(request) + + +def test_nesting_cups_side_by_side_are_two_whole_volumes() -> None: + """Same type, same nesting height -- but different footprints, so they do not nest.""" + request, result = _cups({"length": "20", "width": "10", "height": "10"}, {}) + packed, = result["containers"] + assert {p["position"]["z"]["ticks"] for p in packed["placements"]} == {0} + assert int(packed["used_volume_ticks3"]) == 2 * 160_000 ** 3 + assert validate(request, result) == [] + + +def test_a_nesting_overlap_the_clearance_uses_up_deducts_no_volume() -> None: + """The envelopes sink by the nesting height, but a 1 mm clearance on each side of a + 2 mm nest leaves the items themselves no overlap, so both count whole.""" + request, result = _cups({"length": "12", "width": "12", "height": "40"}, {"clearance": "1"}) + packed, = result["containers"] + lower, upper = sorted(p["position"]["z"]["ticks"] for p in packed["placements"]) + assert upper - lower == 160_000 + assert int(packed["used_volume_ticks3"]) == 2 * 160_000 ** 3 + assert validate(request, result) == [] + + packed["used_volume_ticks3"] = str(2 * 160_000 ** 3 - 1) + assert [issue.code for issue in validate(request, result)] == ["used_volume_mismatch"] + + +def test_a_covered_item_resting_on_a_full_top_is_accepted() -> None: + """Every base corner of the lid lies on the box beneath it, so `covered` holds.""" + side = {"length": "10", "width": "10"} + request = { + "items": [{"id": "base", "dimensions": {**side, "height": "10"}, "must_be_on_floor": True}, + {"id": "lid", "dimensions": {**side, "height": "5"}, "ground_contact_rule": "covered"}], + "containers": [{"id": "c", "inner_dimensions": {**side, "height": "15"}}], + "configuration": COUNTED, + } + result = pack_from_dict(request) + lid, = [p for p in result["containers"][0]["placements"] if p["item_type"] == "lid"] + assert lid["position"]["z"]["ticks"] == 160_000 + assert validate(request, result) == [] + + +def test_a_container_without_volume_counts_but_adds_no_unused_or_height_share() -> None: + """A flat container cannot be divided by. It still counts as opened, and the score is + recomputed without it in the ratio terms rather than failing on a zero divisor.""" + packed = {**REQUEST, "configuration": COUNTED} + request = copy.deepcopy(packed) + request["containers"].append({"id": "flat", "inner_dimensions": {"length": "20", "width": "10", "height": "0"}}) + result = pack_from_dict(packed) + result["containers"].append({**copy.deepcopy(result["containers"][0]), "id": "flat#1", + "container_type": "flat", "placements": [], "used_volume_ticks3": "0"}) + assert [str(issue) for issue in validate(request, result) if issue.code == "score_mismatch"] == [ + "score_mismatch: reported [0, 1, 0, 0, 1000000], recomputed [0, 2, 0, 0, 1000000]"] diff --git a/tests/test_revisions.py b/tests/test_revisions.py index 9cb2b2c..15d7bf6 100644 --- a/tests/test_revisions.py +++ b/tests/test_revisions.py @@ -494,3 +494,176 @@ def test_a_value_with_no_json_spelling_is_named_by_what_it_is(): with pytest.raises(PlanRevisionError) as caught: apply_events(cube_request(), [event]) assert str(caught.value) == "the request has no item an unspellable value" + + +# ------------------------------------------------------------------ made-to-measure boxes + + +def machine_request(quantity: int | None = 3) -> dict: + machine = {"id": "machine", "cost_minor": 40, "tare_weight": "50", + "inner_dimensions": {"length": "310", "width": "310", "height": "610"}, + "made_to_measure": {"minimum": {"length": "60", "width": "60", "height": "60"}, + "increment": {"length": "25", "width": "25", "height": "25"}, + "cost_per_square_metre_minor": 1000, "tare_per_square_metre": "500", + "outer_allowance": {"length": "3", "width": "3", "height": "3"}}} + if quantity is not None: + machine["quantity"] = quantity + return {"units": {"length": "mm"}, "configuration": {"time_limit_ms": 60000}, + "items": [{"id": "cube", "quantity": 3, "weight": "100", + "dimensions": {"length": "300", "width": "300", "height": "300"}}], + "containers": [machine]} + + +def lock_in(result: dict, container_type: str, instance: int, sequence: int) -> dict: + packed = [c for c in result["containers"] if c["container_type"] == container_type][instance - 1] + placement = packed["placements"][0] + return {"sequence": sequence, "type": "placement_locked", "placement": { + "item_type": placement["item_type"], "container_type": container_type, "container_instance": instance, + "position": {axis: {"value": str(placement["position"][axis]["ticks"]), "unit": "ticks"} for axis in "xyz"}, + "orientation": placement["orientation"]}} + + +def test_a_box_cut_for_the_approved_plan_becomes_a_stock_container(): + request = machine_request() + result = pack_from_dict(request) + artifact = build_operational_artifact(request, result) + cut = result["containers"][0] + revision = derive_revision(root_revision(request), artifact, [lock_in(result, "machine", 1, 1)]) + derived = revision["request"] + machine, stock = derived["containers"] + assert machine["id"] == "machine" and machine["quantity"] == 2 + assert stock["id"] == "machine@1#1" and stock["quantity"] == 1 and "made_to_measure" not in stock + assert {axis: int(side["value"]) for axis, side in stock["inner_dimensions"].items()} == { + axis: side["ticks"] for axis, side in cut["inner_dimensions"].items()} + assert {axis: int(side["value"]) for axis, side in stock["outer_dimensions"].items()} == { + axis: side["ticks"] for axis, side in cut["outer_dimensions"].items()} + assert stock["cost_minor"] == cut["made_to_measure"]["cost_minor"] + assert int(stock["tare_weight"]["value"]) == cut["made_to_measure"]["tare_weight"]["ticks"] + assert derived["fixed_placements"][0]["container_type"] == "machine@1#1" + assert derived["fixed_placements"][0]["container_instance"] == 1 + assert pack_from_dict(derived)["status"] == "feasible" + + +def test_a_lock_quoting_its_placement_id_in_a_cut_box_drops_the_id_once_stocked(): + request = machine_request() + result = pack_from_dict(request) + event = lock_in(result, "machine", 1, 1) + event["placement"]["placement_id"] = result["containers"][0]["placements"][0]["placement_id"] + revision = derive_revision(root_revision(request), build_operational_artifact(request, result), [event]) + fixed = revision["request"]["fixed_placements"][0] + assert fixed["container_type"] == "machine@1#1" and "placement_id" not in fixed + assert pack_from_dict(revision["request"])["status"] == "feasible" + + +def test_a_chain_of_cut_boxes_stays_derivable_and_needs_its_artifacts_to_verify(): + request = machine_request() + root = root_revision(request) + first = pack_from_dict(request) + first_artifact = build_operational_artifact(request, first) + one = derive_revision(root, first_artifact, [lock_in(first, "machine", 1, 1)]) + second = pack_from_dict(one["request"]) + second_artifact = build_operational_artifact(one["request"], second) + two = derive_revision(one, second_artifact, [lock_in(second, "machine", 1, 2)]) + assert [c["id"] for c in two["request"]["containers"]] == ["machine", "machine@2#1", "machine@1#1"] + assert verify_revision_chain([root, one, two], [None, first_artifact, second_artifact]) == [] + assert [issue.code for issue in verify_revision_chain([root, one, two])] == [ + "artifact_required", "artifact_required"] + + +def test_the_last_box_of_a_limited_machine_drops_the_machine(): + request = machine_request(quantity=1) + request["items"][0]["quantity"] = 1 + result = pack_from_dict(request) + revision = derive_revision(root_revision(request), build_operational_artifact(request, result), + [lock_in(result, "machine", 1, 1)]) + assert [c["id"] for c in revision["request"]["containers"]] == ["machine@1#1"] + + +def test_an_unlimited_machine_keeps_no_quantity(): + request = machine_request(quantity=None) + result = pack_from_dict(request) + revision = derive_revision(root_revision(request), build_operational_artifact(request, result), + [lock_in(result, "machine", 1, 1)]) + assert "quantity" not in revision["request"]["containers"][0] + + +def test_a_placement_in_a_box_the_plan_never_cut_is_refused(): + request = machine_request() + result = pack_from_dict(request) + event = lock_in(result, "machine", 1, 1) + event["placement"]["container_instance"] = 9 + with pytest.raises(PlanRevisionError) as refusal: + derive_revision(root_revision(request), build_operational_artifact(request, result), [event]) + assert refusal.value.code == "invalid_artifact" + + +def test_a_stock_name_already_taken_is_a_conflict(): + request = machine_request() + request["containers"].append({"id": "machine@1#1", "inner_dimensions": {"length": "10", "width": "10", "height": "10"}}) + result = pack_from_dict(request) + with pytest.raises(PlanRevisionError) as refusal: + derive_revision(root_revision(request), build_operational_artifact(request, result), + [lock_in(result, "machine", 1, 1)]) + assert refusal.value.code == "event_conflict" + + +def test_a_request_without_lists_is_stocked_unchanged(): + from packvium.revisions import _holds_cut_boxes, stock_cut_boxes + assert stock_cut_boxes({"containers": []}, {}, 1) == {"containers": []} + assert not _holds_cut_boxes({"containers": {}, "fixed_placements": []}) + + +def test_two_placements_in_one_cut_box_share_one_stock_container(): + request = machine_request() + result = pack_from_dict(request) + packed = result["containers"][0] + events = [] + for sequence, placement in enumerate(packed["placements"][:2], start=1): + events.append({"sequence": sequence, "type": "placement_locked", "placement": { + "item_type": placement["item_type"], "container_type": "machine", "container_instance": 1, + "position": {axis: {"value": str(placement["position"][axis]["ticks"]), "unit": "ticks"} for axis in "xyz"}, + "orientation": placement["orientation"]}}) + revision = derive_revision(root_revision(request), build_operational_artifact(request, result), events) + assert [c["id"] for c in revision["request"]["containers"]] == ["machine", "machine@1#1"] + assert {entry["container_type"] for entry in revision["request"]["fixed_placements"]} == {"machine@1#1"} + + +@pytest.mark.parametrize("damage, message", [ + (lambda a: a.update(work_order={}), "holds no machine instance 1"), + (lambda a: a["geometry"]["containers"][0]["inner_dimensions"].update(length="12a"), "is not a tick count"), + (lambda a: a["geometry"]["containers"][0]["inner_dimensions"].update(length="12"), "is not a tick count"), +]) +def test_an_artifact_that_cannot_describe_the_cut_box_is_refused(damage, message): + request = machine_request() + result = pack_from_dict(request) + artifact = build_operational_artifact(request, result) + damage(artifact) + from packvium.revisions import stock_cut_boxes + derived = apply_events(request, [lock_in(result, "machine", 1, 1)]) + with pytest.raises(PlanRevisionError, match=message) as refusal: + stock_cut_boxes(derived, artifact, 1) + assert refusal.value.code == "invalid_artifact" + + +def test_a_machine_whose_tare_is_not_a_measure_cannot_be_stocked(): + request = machine_request() + result = pack_from_dict(request) + artifact = build_operational_artifact(request, result) + derived = apply_events(request, [lock_in(result, "machine", 1, 1)]) + derived["containers"][0]["tare_weight"] = "heavy" + from packvium.revisions import stock_cut_boxes + with pytest.raises(PlanRevisionError, match="tare_weight is not a measure") as refusal: + stock_cut_boxes(derived, artifact, 1) + assert refusal.value.code == "invalid_revision" + + +def test_an_audit_reports_a_stocking_that_no_longer_applies(): + request = machine_request() + result = pack_from_dict(request) + artifact = build_operational_artifact(request, result) + root = root_revision(request) + one = derive_revision(root, artifact, [lock_in(result, "machine", 1, 1)]) + broken = copy.deepcopy(artifact) + broken["work_order"] = {} + codes = [issue.code for issue in verify_revision_chain([root, one], [None, broken])] + assert "request_mismatch" in codes diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 07d21ce..42c5f42 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -148,8 +148,10 @@ def test_a_normal_pack_reports_verifiable_per_start_termination(): def test_a_placement_reports_everything_a_caller_needs_to_reproduce_it(): result = pack_from_dict(request([cube("a", 50)], [box("c")])) placement, = result["containers"][0]["placements"] - assert set(placement) == {"item_id", "item_type", "position", "dimensions", "orientation", - "support_ratio", "top_load"} + assert set(placement) == {"placement_id", "item_id", "item_type", "position", "dimensions", + "orientation", "support_ratio", "top_load"} + assert json.loads(placement["placement_id"]) == [ + "c#1", *(placement["position"][axis]["ticks"] for axis in "xyz"), "a", placement["orientation"]] assert placement["item_id"] == "a#1" assert placement["item_type"] == "a" assert placement["orientation"] in {"LWH", "LHW", "WLH", "WHL", "HLW", "HWL"} diff --git a/tests/test_signed_rotations.py b/tests/test_signed_rotations.py new file mode 100644 index 0000000..588c802 --- /dev/null +++ b/tests/test_signed_rotations.py @@ -0,0 +1,223 @@ +"""1.10: the 24 proper rotations a convex hull may take, and the six a box keeps. + +The legacy six keep their matrices, so every answer that lists none of the 18 signed codes is +unchanged; the signed codes are refused on anything but a hull, and `keep_upright` keeps the +codes whose z entry is `+H`. +""" + +from __future__ import annotations + +from itertools import permutations + +import pytest + +from packvium import Dimensions, Item, PackingConfig, Rotation +from packvium.geometry import ShapeType +from packvium.hull import HullShape, rotate, shape_for +from packvium.locks import PlacementLock +from packvium.orientations import AXIS_SIGNS, CODES, LEGACY_CODES, SOURCE_AXES, UPRIGHT_CODES +from packvium.request_errors import InvalidRequestError, check_request +from support import assert_sound, container, pack + +MM = 16_000 +SIDE = 10 * MM +WEDGE = ((0, 0, 0), (SIDE, 0, 0), (0, SIDE, 0), (0, 0, SIDE), (SIDE, 0, SIDE), (0, SIDE, SIDE)) + +#: The table every engine and validator shares, spelled out (docs/IRREGULAR-ITEMS.md). +TABLE = ( + ("LWH", (0, 1, 2), (1, 1, 1)), ("LHW", (0, 2, 1), (-1, 1, 1)), + ("WLH", (1, 0, 2), (-1, 1, 1)), ("WHL", (1, 2, 0), (1, 1, 1)), + ("HLW", (2, 0, 1), (1, 1, 1)), ("HWL", (2, 1, 0), (-1, 1, 1)), + ("+L-W-H", (0, 1, 2), (1, -1, -1)), ("-L+W-H", (0, 1, 2), (-1, 1, -1)), + ("-L-W+H", (0, 1, 2), (-1, -1, 1)), ("+L-H+W", (0, 2, 1), (1, -1, 1)), + ("+L+H-W", (0, 2, 1), (1, 1, -1)), ("-L-H-W", (0, 2, 1), (-1, -1, -1)), + ("+W-L+H", (1, 0, 2), (1, -1, 1)), ("+W+L-H", (1, 0, 2), (1, 1, -1)), + ("-W-L-H", (1, 0, 2), (-1, -1, -1)), ("+W-H-L", (1, 2, 0), (1, -1, -1)), + ("-W+H-L", (1, 2, 0), (-1, 1, -1)), ("-W-H+L", (1, 2, 0), (-1, -1, 1)), + ("+H-L-W", (2, 0, 1), (1, -1, -1)), ("-H+L-W", (2, 0, 1), (-1, 1, -1)), + ("-H-L+W", (2, 0, 1), (-1, -1, 1)), ("+H-W+L", (2, 1, 0), (1, -1, 1)), + ("+H+W-L", (2, 1, 0), (1, 1, -1)), ("-H-W-L", (2, 1, 0), (-1, -1, -1)), +) + + +def _determinant(axes, signs) -> int: + matrix = [[signs[row] if column == axes[row] else 0 for column in range(3)] for row in range(3)] + a, b, c = matrix + return (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + + a[2] * (b[0] * c[1] - b[1] * c[0])) + + +def wedge(id: str = "wedge", **kwargs) -> Item: + return Item.create(id, Dimensions.mm(10, 10, 10), shape_type=ShapeType.CONVEX_HULL, + hull_vertices=WEDGE, **kwargs) + + +def test_the_table_is_the_canonical_order_and_every_entry_is_a_proper_rotation(): + assert tuple((code, SOURCE_AXES[code], AXIS_SIGNS[code]) for code in CODES) == TABLE + assert tuple(rotation.value for rotation in Rotation) == CODES + assert all(_determinant(axes, signs) == 1 for _, axes, signs in TABLE) + # Every proper rotation of the cube's axes appears exactly once. + assert len({(axes, signs) for _, axes, signs in TABLE}) == 24 + assert {axes for _, axes, _ in TABLE} == set(permutations(range(3))) + + +def test_the_legacy_vocabulary_and_its_defaults_are_unchanged(): + assert tuple(r.value for r in Rotation.all()) == LEGACY_CODES + assert Rotation.upright() == (Rotation.LWH, Rotation.WLH) + assert Rotation.every() == tuple(Rotation) + assert UPRIGHT_CODES == ("LWH", "WLH", "-L-W+H", "+W-L+H") + assert [r.value for r in Rotation.every() if r.is_upright] == list(UPRIGHT_CODES) + assert not any(r.is_signed for r in Rotation.all()) + assert all(r.is_signed for r in Rotation.every()[6:]) + + +@pytest.mark.parametrize("rotation", list(Rotation)) +def test_a_box_ignores_the_signs(rotation): + dims = Dimensions.mm(1, 2, 3) + legacy = Rotation(LEGACY_CODES[[axes for _, axes, _ in TABLE[:6]].index(SOURCE_AXES[rotation.value])]) + assert dims.rotated(rotation) == dims.rotated(legacy) + + +@pytest.mark.parametrize("code, axes, signs", TABLE) +def test_a_hull_turns_by_its_signed_matrix_then_back_to_the_origin(code, axes, signs): + turned = [tuple(signs[i] * vertex[axes[i]] for i in range(3)) for vertex in WEDGE] + low = [min(vertex[i] for vertex in turned) for i in range(3)] + assert rotate(WEDGE, code) == tuple(tuple(v[i] - low[i] for i in range(3)) for v in turned) + assert HullShape.of(rotate(WEDGE, code)).volume == HullShape.of(WEDGE).volume + + +def test_the_24_rotations_of_a_chiral_hull_are_24_shapes_cached_apart(): + shapes = {code: shape_for(WEDGE, code) for code in CODES} + # The wedge has a mirror plane, so pairs coincide; what matters is that the cache keys on + # the full code and that the half-turn about z is its complement, not itself. + assert shapes["-L-W+H"] != shapes["LWH"] + assert all(shape_for(WEDGE, code) is shapes[code] for code in CODES) + + +def test_a_signed_code_is_a_hull_only_rule_on_the_object_path(): + with pytest.raises(InvalidRequestError, match="requires shape_type convex_hull"): + Item.create("box", Dimensions.mm(1, 2, 3), allowed_rotations=(Rotation.NL_NW_PH,)) + assert wedge(allowed_rotations=(Rotation.NL_NW_PH,)).allowed_rotations == (Rotation.NL_NW_PH,) + + +def test_keep_upright_keeps_the_codes_whose_z_entry_is_plus_h(): + kept = wedge(allowed_rotations=Rotation.every(), keep_upright=True).allowed_rotations + assert [r.value for r in kept] == list(UPRIGHT_CODES) + + +def test_a_box_with_keep_upright_still_gets_the_legacy_pair(): + assert Item.create("a", Dimensions.mm(1, 2, 3), keep_upright=True).allowed_rotations == Rotation.upright() + + +def _request(item: dict) -> dict: + return {"items": [{"id": "a", "dimensions": {"length": "10", "width": "10", "height": "10"}, + **item}], + "containers": [{"id": "c", "inner_dimensions": {"length": "10", "width": "10", + "height": "10"}}]} + + +@pytest.mark.parametrize("item, reason, field", [ + ({"allowed_rotations": ["LWH", "-L-W+H"]}, "invalid_value", "/items/0/allowed_rotations/1"), + ({"allowed_rotations": ["+L+W+H"]}, "not_allowed", "/items/0/allowed_rotations/0"), + ({"shape_type": "convex_hull", "hull_vertices": [], "allowed_rotations": ["-L+W+H"]}, + "not_allowed", "/items/0/allowed_rotations/0"), +]) +def test_the_json_path_names_the_code_it_refuses(item, reason, field): + with pytest.raises(InvalidRequestError) as caught: + check_request(_request(item)) + assert (caught.value.reason, caught.value.field) == (reason, field) + + +def test_the_legacy_vocabulary_is_what_a_box_is_told(): + with pytest.raises(InvalidRequestError, match=r'must be one of \["LWH","LHW","WLH","WHL","HLW","HWL"\]$'): + check_request(_request({"allowed_rotations": ["XYZ"]})) + + +def test_two_chiral_wedges_share_a_crate_only_under_a_signed_code(): + config = PackingConfig(max_containers=1) + crates = [container("crate", 10, 10, 10)] + legacy = pack([wedge(quantity=2)], crates, config) + assert len(legacy.unpacked) == 1 + items = [wedge(quantity=2, allowed_rotations=Rotation.every())] + every = pack(items, crates, config) + assert_sound(every, items, crates) + assert not every.unpacked + assert [p.rotation.value for p in every.containers[0].placements] == ["LWH", "-L-W+H"] + + +def test_a_lock_may_name_a_signed_orientation(): + assert PlacementLock(0, "wedge", "-L-W+H", (0, 0, 0)).orientation == "-L-W+H" + with pytest.raises(ValueError): + PlacementLock(0, "wedge", "-L+W+H", (0, 0, 0)) + + +def _chiral(**fixed) -> dict: + corners = [{"x": str(x), "y": str(y), "z": str(z)} + for x, y, z in ((0, 0, 0), (10, 0, 0), (0, 10, 0), (0, 0, 10), (10, 0, 10), (0, 10, 10))] + return {"items": [{"id": "wedge", "quantity": 2, "dimensions": {"length": "10", "width": "10", "height": "10"}, + "shape_type": "convex_hull", "hull_vertices": corners, + "allowed_rotations": list(CODES)}], + "containers": [{"id": "crate", "quantity": 1, + "inner_dimensions": {"length": "10", "width": "10", "height": "10"}}], + "fixed_placements": [{"item_type": "wedge", "container_type": "crate", **fixed}]} + + +def test_a_hull_may_be_fixed_in_a_signed_orientation(): + from packvium.serialization import pack_from_dict + + result = pack_from_dict(_chiral(orientation="-L-W+H")) + assert [(p["orientation"], p.get("fixed", False)) for p in result["containers"][0]["placements"]] == [ + ("-L-W+H", True), ("LWH", False)] + + +@pytest.mark.parametrize("orientation, reason, detail", [ + ("-L+W+H", "malformed", "must be one of [\"LWH\""), + ("+L-W-H", "cannot_hold", "may not be placed in orientation +L-W-H"), +]) +def test_a_fixed_hull_orientation_is_read_from_its_vocabulary_and_its_list(orientation, reason, detail): + from packvium.fixed_placements import FixedPlacementError + from packvium.serialization import pack_from_dict + + request = _chiral(orientation=orientation) + request["items"][0]["allowed_rotations"] = ["LWH", "-L-W+H"] + with pytest.raises(FixedPlacementError) as caught: + pack_from_dict(request) + assert caught.value.reason == reason and detail in str(caught.value) + + +def test_a_signed_orientation_cannot_fix_a_box_on_either_path(): + from packvium import FixedPlacement, Packer, Point + from packvium.fixed_placements import FixedPlacementError + from packvium.serialization import pack_from_dict + + request = _chiral(orientation="-L-W+H") + for key in ("shape_type", "hull_vertices", "allowed_rotations"): + del request["items"][0][key] + with pytest.raises(FixedPlacementError) as from_json: + pack_from_dict(request) + assert (from_json.value.reason, from_json.value.field) == ("invalid_value", "/fixed_placements/0/orientation") + box = Item.create("wedge", Dimensions.mm(10, 10, 10), quantity=2) + with pytest.raises(FixedPlacementError) as from_objects: + Packer(PackingConfig()).pack([box], [container("crate", 10, 10, 10)], + [FixedPlacement("wedge", "crate", Point(0, 0, 0), Rotation.NL_NW_PH)]) + assert str(from_objects.value) == str(from_json.value) + + +def test_a_revision_locks_a_hull_in_a_signed_orientation_and_refuses_one_on_a_box(): + from packvium.revisions import PlanRevisionError, apply_events + + request = _chiral(orientation="LWH") + del request["fixed_placements"] + lock = {"sequence": 1, "type": "placement_locked", + "placement": {"item_type": "wedge", "container_type": "crate", "orientation": "-L-W+H"}} + assert apply_events(request, [lock])["fixed_placements"] == [lock["placement"]] + request["items"][0]["shape_type"] = "rigid_cuboid" + with pytest.raises(PlanRevisionError, match="placement.orientation: a signed rotation code requires"): + apply_events(request, [lock]) + + +def test_a_request_with_no_item_list_names_no_hull(): + from packvium.fixed_placements import hull_item_types + + assert hull_item_types(None) == frozenset() + assert hull_item_types([{"id": "w", "shape_type": "convex_hull"}, "x", {"id": 5, "shape_type": "convex_hull"}]) == {"w"} diff --git a/tests/test_solver_edges.py b/tests/test_solver_edges.py index 68b8e1a..083ed77 100644 --- a/tests/test_solver_edges.py +++ b/tests/test_solver_edges.py @@ -17,15 +17,16 @@ from packvium import (AxisAlignedBox, Container, Dimensions, EffortBudget, ExtensionRegistry, FixedPlacement, Item, Length, Packer, PackingConfig, PackingStatus, Placement, Point, Rotation, - use_trace) + pack_from_dict, use_trace, validate) from packvium.geometry import ShapeType from packvium.solvers import (ContainerState, Deadline, DefaultContainerSelector, DeterministicRandom, ExtremePointSolver, GridSolver, HomogeneousBlockSolver, LayerSolver, PortfolioRun, RawSolution, SearchStats, SingleContainerSolution, SolverOrchestrator, Space, TimeLimitReached, _ContainerPlan, - _nesting_points, _run_concurrent_start, _subtract, beam_pack, - default_constraints, find_candidates) + _nesting_points, _run_concurrent_start, beam_pack, + default_constraints, find_candidates, subtract_all) from packvium import bounds +from packvium.units import Weight from support import assert_sound, container, item GENEROUS_MS = 60_000 @@ -509,7 +510,7 @@ def test_the_candidate_beam_skips_the_volume_bound_while_nesting_items_are_still def test_carving_a_box_that_misses_the_space_leaves_the_space_whole(): space = Space(Point(0, 0, 0), Dimensions.mm(10, 10, 10)) far = AxisAlignedBox(Point(Length.mm(20).ticks, 0, 0), Dimensions.mm(10, 10, 10)) - assert _subtract(space, far) == [space] + assert subtract_all([space], [far]) == [space] def test_a_draw_in_the_biased_tail_is_redrawn_rather_than_folded(): @@ -563,3 +564,91 @@ def test_the_block_solver_leaves_a_request_that_asks_for_support_to_the_per_item minimum_support_ratio=1.0) assert solution.state.placements assert {_resting_share(solution.state.placements, p) for p in solution.state.placements} == {1.0} + + +# ------------------------------------------------- rarely-walked solver branches + + +def _cushion(id: str) -> Item: + """A 50 mm compressible cube, of the same lattice profile as `item(id, 50, 50, 50)`.""" + return Item.create(id, Dimensions.mm(50, 50, 50), "1kg", shape_type=ShapeType.COMPRESSIBLE, + compression_ratio_ppm=500_000, max_compression_pressure_kpa=100) + + +def test_a_compressible_type_never_joins_a_rigid_lattice(): + """Same size and weight once made one lattice profile, so a cushion joined a brick's + lattice and was stacked with no crush check: the packer returned an `invalid_result` + where a valid plan existed. The shape is part of the profile now.""" + brick, cushion = item("brick", 50, 50, 50, weight="1kg"), _cushion("cushion") + + assert not GridSolver().supports(items_of((brick, cushion))) + assert GridSolver().supports(items_of((brick, item("alias", 50, 50, 50, weight="1kg")))) + + +def test_a_cushion_that_would_be_crushed_is_left_out_rather_than_stacked(): + request = { + "units": {"length": "mm"}, + "items": [ + {"id": "rigid", "dimensions": {"length": "50", "width": "50", "height": "50"}, + "weight": {"value": "1", "unit": "kg"}}, + {"id": "soft", "quantity": 2, "dimensions": {"length": "50", "width": "50", "height": "50"}, + "weight": {"value": "1", "unit": "kg"}, "shape_type": "compressible", + "compression_ratio": 0.5, "max_compression_pressure_kpa": 1}, + ], + "containers": [{"id": "c", "inner_dimensions": {"length": "50", "width": "50", "height": "150"}}], + "configuration": {"max_containers": 1, "time_limit_ms": 60000, "effort_budget": { + "max_candidates_evaluated": 1000000, "max_placement_attempts": 1000000, + "max_search_nodes": 1000000}}, + } + + result = pack_from_dict(request) + + assert result["status"] == "best_found" + assert [u["item_type"] for u in result["unpacked_items"]] == ["soft"] + assert validate(request, result) == [] + + +def test_a_wider_candidate_list_on_a_one_plan_beam_ranks_its_children(): + """`max_candidates_per_item` keeps several children of the single plan a width-one beam + holds, ordered by the node key, and that is what recovers the third item here: the + greedy first corner for the first item leaves no room for the last one.""" + instances = items_of([item("i0", 6, 3, 5), item("i1", 4, 6, 4), item("i2", 5, 4, 5)]) + box = container("c", 8, 9, 6) + + def solve(candidates: int) -> SingleContainerSolution: + config = PackingConfig.balanced(max_candidates_per_item=candidates) + assert config.container_plan_beam_width == 1 + return ExtremePointSolver().pack_one(box, 1, instances, config, SearchStats(), generous()) + + assert [one.id for one in solve(1).unpacked] == ["i2#1"] + wide = solve(3) + assert wide.unpacked == () + layout = [(p.instance.id, p.position) for p in wide.state.placements] + assert layout == [(p.instance.id, p.position) for p in solve(3).state.placements] + + +def test_an_item_no_single_container_admits_on_every_count_keeps_no_feasible_placement(): + """One container is big enough but too weak, the other strong enough but too small. Each + structural count passes somewhere, so no proof applies, and no container limit explains + it either: the reason stays `no_feasible_placement` instead of blaming the inventory.""" + heavy = item("heavy", 50, 50, 50, weight="5kg") + weak = container("weak", 100, 100, 100, max_payload=Weight.parse("1kg")) + tiny = container("tiny", 40, 40, 40) + result = Packer(counted()).pack([heavy], [weak, tiny]) + assert [(u.instance.id, u.reason) for u in result.unpacked] == [("heavy#1", "no_feasible_placement")] + + +def test_a_plan_beam_out_of_nodes_mid_round_keeps_its_best_complete_plan(): + """Two plans of width two over two container types use the four-node floor after the + first plan's trials, so the second plan stops before its first trial. The incumbent + already holds every item, and a node limit is not reported as a time limit.""" + instances = items_of([item("a", 50, 50, 50, quantity=3)]) + containers = (container("small", 50, 50, 50, cost_minor=1), + container("tall", 50, 50, 100, cost_minor=3)) + config = PackingConfig.quality(time_limit_ms=GENEROUS_MS, container_plan_beam_width=2, + container_plan_node_limit=3) + packed, unpacked, proven, reached, _ = SolverOrchestrator()._across_containers( + ExtremePointSolver(), instances, containers, config, SearchStats(), generous()) + assert sorted((one.container.id, len(one.placements)) for one in packed) == [("small", 1), ("tall", 2)] + assert unpacked == () + assert (proven, reached) == (False, False) diff --git a/tests/test_solvers.py b/tests/test_solvers.py index a7a2913..c88bb7e 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -1728,3 +1728,50 @@ def test_late_type_neighbourhoods_weigh_one_start_together_in_the_time_slicing() assert frozen.share(2, 8).limit_ns == 250_000_000 assert frozen.share(3, 3).limit_ns == 1_000_000_000 assert frozen.slice(4).limit_ns == frozen.share(1, 4).limit_ns + + +def test_subtract_all_pinhole_corner_swallow_and_swiss_cheese_cap(): + """Pathological space carving: + - Miss preserves exact `Space` object identity; + - Full swallow leaves 0 spaces; + - 1-tick pinhole right through the center produces all 6 maximal slabs; + - Swiss-cheese grid of 60 pillars exceeding `MAX_MAXIMAL_SPACES=256` caps at 256, + orders by `(z, y, x)`, and leaves no dominated (contained) space.""" + from packvium.solvers import MAX_MAXIMAL_SPACES, Space, subtract_all + + whole = Space(Point(0, 0, 0), Dimensions(Length(100), Length(100), Length(100))) + # 1. Complete miss preserves identity + outside = AxisAlignedBox(Point(100, 0, 0), Dimensions(Length(10), Length(10), Length(10))) + untouched = subtract_all([whole], [outside]) + assert len(untouched) == 1 and untouched[0] is whole + + # 2. Complete swallow leaves nothing + swallow = AxisAlignedBox(Point(0, 0, 0), Dimensions(Length(100), Length(100), Length(100))) + assert subtract_all([whole], [swallow]) == [] + + # 3. 1-tick pinhole in the exact center produces 6 slabs + pinhole = AxisAlignedBox(Point(50, 50, 50), Dimensions(Length(1), Length(1), Length(1))) + six = subtract_all([whole], [pinhole]) + assert len(six) == 6 + for s in six: + assert not s.box.intersects(pinhole) + + # 4. Swiss-cheese sequence of 65 diagonal 1-tick pillars forces > 256 surviving slabs + stats = SearchStats() + capped = [whole] + for i in range(65): + pillar = AxisAlignedBox( + Point((i * 13) % 97 + 1, (i * 29) % 97 + 1, (i * 43) % 97 + 1), + Dimensions(Length(1), Length(1), Length(1)), + ) + capped = subtract_all(capped, [pillar], stats) + assert len(capped) <= MAX_MAXIMAL_SPACES + assert len(capped) == MAX_MAXIMAL_SPACES + assert stats.space_partitions > 0 + assert capped == sorted(capped, key=lambda s: (s.origin.z, s.origin.y, s.origin.x)) + for i, a in enumerate(capped): + ba = a.box + for j, b in enumerate(capped): + if i != j: + assert not b.box.contains(ba) + diff --git a/tests/test_stock_cut_errors.py b/tests/test_stock_cut_errors.py new file mode 100644 index 0000000..3481c55 --- /dev/null +++ b/tests/test_stock_cut_errors.py @@ -0,0 +1,56 @@ +import copy + +import pytest + +from packvium.artifacts import build_operational_artifact +from packvium.revisions import PlanRevisionError, derive_revision, root_revision, stock_cut_boxes +from packvium.serialization import pack_from_dict + + +def approved(): + sides = {"length": "10", "width": "10", "height": "10"} + request = {"items": [{"id": "cube", "dimensions": sides}], "containers": [ + {"id": "machine", "inner_dimensions": sides, "made_to_measure": {"minimum": sides, "increment": sides}}]} + return request, build_operational_artifact(request, pack_from_dict(request)) + + +@pytest.mark.parametrize("field,value,detail", [ + ("made_to_measure", True, "made_to_measure is not an object"), + ("cost_minor", "price", "cost_minor is not an integer"), + ("tare_weight", "heavy", "tare_weight is not a measure"), +]) +def test_a_replacement_and_lock_refuse_unreadable_stock_fields(field, value, detail): + request, artifact = approved() + replacement = copy.deepcopy(request["containers"][0]) + replacement[field] = value + events = [{"sequence": 1, "type": "container_substituted", "container_type": "machine", "replacement": replacement}, + {"sequence": 2, "type": "placement_locked", "placement": {"item_type": "cube", "container_type": "machine", "orientation": "LWH"}}] + with pytest.raises(PlanRevisionError) as refusal: + derive_revision(root_revision(request), artifact, events) + assert (refusal.value.code, str(refusal.value)) == ("invalid_revision", "a made-to-measure container's " + detail) + + +def test_stocking_still_defers_fixed_placement_physics(): + request, artifact = approved() + event = {"sequence": 1, "type": "placement_locked", "placement": {"item_type": "cube", "container_type": "machine", + "orientation": "LWH", "position": {"x": "999999"}}} + revision = derive_revision(root_revision(request), artifact, [event]) + assert revision["request"]["fixed_placements"][0]["position"] == {"x": "999999"} + + +@pytest.mark.parametrize("units,detail", [([], "units is not an object"), ({"length": []}, "units.length is not a string")]) +def test_stocking_refuses_an_unreadable_measure_context(units, detail): + request, artifact = approved() + request["units"] = units + request["fixed_placements"] = [{"container_type": "machine"}] + with pytest.raises(PlanRevisionError) as refusal: + stock_cut_boxes(request, artifact, 1) + assert str(refusal.value) == "a made-to-measure container's " + detail + + +@pytest.mark.parametrize("cost", [None, 0, 1, 1.0, -1]) +def test_stock_costs_keep_exact_integers_and_null_defaults(cost): + request, artifact = approved() + request["containers"][0]["cost_minor"] = cost + request["fixed_placements"] = [{"container_type": "machine"}] + assert stock_cut_boxes(request, artifact, 1)["containers"][1]["cost_minor"] == (0 if cost is None else cost) diff --git a/tests/test_stock_cut_index.py b/tests/test_stock_cut_index.py new file mode 100644 index 0000000..0fa510c --- /dev/null +++ b/tests/test_stock_cut_index.py @@ -0,0 +1,39 @@ +"""Stocking many approved boxes must not rescan the artifact for every lock.""" +from packvium.revisions import stock_cut_boxes + + +class CountedOrder(dict): + reads = 0 + + def get(self, key, *args): + if key == "container_type": + type(self).reads += 1 + return super().get(key, *args) + + +def test_stocking_indexes_artifact_rows_once_and_keeps_first_lock_order(): + count = 100 + request = {"containers": [{"id": "machine", "quantity": count, "made_to_measure": {}}], + "fixed_placements": [{"container_type": "machine", "container_instance": instance} + for instance in range(count, 0, -1)]} + artifact = {"work_order": {"containers": [CountedOrder(container_type="machine") for _ in range(count)]}, + "geometry": {"containers": [{"inner_dimensions": {"length": str(instance), "width": "2", "height": "3"}} + for instance in range(1, count + 1)]}} + CountedOrder.reads = 0 + stocked = stock_cut_boxes(request, artifact, 7) + assert CountedOrder.reads <= count * 2 + assert [box["id"] for box in stocked["containers"]] == [f"machine@7#{i}" for i in range(count, 0, -1)] + assert stocked["containers"][0]["inner_dimensions"]["length"]["value"] == "100" + assert stocked["containers"][-1]["inner_dimensions"]["length"]["value"] == "1" + + +def test_stocking_joins_by_type_and_does_not_parse_unselected_geometry(): + request = {"containers": [{"id": "machine", "made_to_measure": {}, "cost_minor": 1}, + {"id": "machine", "made_to_measure": {}, "cost_minor": 9}], + "fixed_placements": [{"container_type": "machine", "container_instance": 2}]} + artifact = {"work_order": {"containers": [{"container_type": "machine"}, {"container_type": "other"}, + {"container_type": "machine"}, {"container_type": "machine"}]}, + "geometry": {"containers": [None, None, {"inner_dimensions": {"length": "1", "width": "2", "height": "3"}}]}} + stocked = stock_cut_boxes(request, artifact, 1) + assert stocked["containers"][1]["cost_minor"] == 9 + assert stocked["containers"][1]["inner_dimensions"]["length"]["value"] == "1"