diff --git a/CHANGELOG.md b/CHANGELOG.md index 463a0c9d0..ca6c56be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to Bambuddy will be documented in this file. - **API keys can log maintenance — new "Manage Maintenance" scope for HA-style automations (#1832 follow-up, reporter @MorganMLGman)** — Follow-up on the closed #1832 thread: reporter noted that maintenance had no checkbox in the API-key permissions UI and wasn't clearly denied in the wiki, unlike the other admin-only surfaces. Root cause was that `MAINTENANCE_CREATE / _UPDATE / _DELETE` sat on `_APIKEY_DENIED_PERMISSIONS` in `backend/app/core/auth.py`, so every API-key call to `POST /maintenance/items/{id}/perform` (mark maintenance as done — the useful endpoint for a "cleaned nozzle every N hours" Home Assistant automation) returned `403 "API keys cannot be used for administrative operations."` **Fix.** New `can_manage_maintenance` scope flag on `api_keys`, following the same shape as `can_manage_library` and `can_manage_inventory`. Wires the three maintenance write permissions into `_APIKEY_SCOPE_BY_PERMISSION` under the new flag; drops them from the denylist. Covers the per-printer maintenance CRUD (assign/remove items, edit intervals, log completion), the type-catalog CRUD (system types + custom types), and — via `MAINTENANCE_UPDATE` on the perform endpoint — the load-bearing "reset counter" call. `MAINTENANCE_READ` stays under `can_read_status`. **Backfill choice.** Distinct from the `can_manage_library` / `can_manage_inventory` migrations, which mirrored `can_queue` to preserve existing capability from the prior denylist-model. Maintenance writes were EXPLICITLY denied for every API key pre-migration, so no existing integration relies on them — backfilling to `can_queue` would silently widen scope on upgrade for every queue-capable key without unlocking any real use case. Existing rows backfill to **FALSE**; new keys created via the Settings UI default to **TRUE** (matches the safe-on-by-default pattern for the two prior scopes). Users opt in per key from Settings → API Keys → Edit. **CLI.** Bundled SpoolBuddy kiosk key gets `can_manage_maintenance=False` — kiosks don't need it, keep them minimally scoped. **Tests.** RBAC matrix in `backend/tests/integration/test_auth_apikey_rbac.py` extended: `valid_flags` set + `_each_scope_flag_has_at_least_one_permission` parametrize decorator + `_FakeApiKey.__init__` all pick up the new flag; three new `_SCOPE_CASES` for `MAINTENANCE_CREATE / _UPDATE / _DELETE`; the cross-scope leakage guard (`other_flags` set at line 355) widened from four flags to six so a `can_manage_library` toggle can't accidentally allow a `MAINTENANCE_UPDATE` call (and vice versa). 78/78 in `test_auth_apikey_rbac.py` + adjacent auth suites green. **Frontend.** New checkbox in the Settings → API Keys create form (between Manage Inventory and Allow Cloud Access), new teal badge in the key list, TS types extended (`APIKey`, `APIKeyCreate`, `APIKeyUpdate`). **i18n.** Three new keys (`settings.manageMaintenance`, `settings.manageMaintenanceDescription`, `settings.maintenanceBadge`) × 11 locales (de/en/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW), real translations everywhere per convention. **Wiki.** `bambuddy-wiki/docs/features/api-keys.md` — new row in the permissions table, new bullet in the Principle-of-Least-Privilege list ("Home Assistant maintenance-log automation: Read Status + Manage Maintenance"), and a paragraph in Upgrade Notes explaining the FALSE backfill so existing users don't see a silent scope widening. **Migration.** Idempotent `_safe_execute` `ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE` + one-shot `UPDATE api_keys SET can_manage_maintenance = FALSE` guarded by column-existed-before check, mirroring the two prior scope migrations. Works on both SQLite and Postgres — no dialect-specific column type needed since it's just a BOOLEAN. ### Fixed +- **AMS same-material runout/backup switch: Spoolman now splits usage across origin + backup spool (#1793, reporter @ojimpo)** — Reporter's H2S ran an AMS backup switch mid-print (single-slot 72.56g job, tray 0 depleted at layer 37 → auto-switched to tray 1 → finished). Bambuddy's Spoolman writer charged the ENTIRE 72.56g to the origin spool via the `(via tag)` path and separately credited a small `~30g via remain-delta` to the backup — net double-count plus origin spool driven past its `initial_weight`. Reporter's forensic trace on 0.2.5b2 confirmed: `Tray change during print: tray=1 at layer=37` fires at `bambu_mqtt.py:1863` and gets appended to `state.tray_change_log`, but `spoolman_tracking.report_usage` never consulted it — every mid-print switch fell through to single-tray attribution. #1771's fix (`a53dc20ca`) corrected the split *math* inside the tray-switch branch of the internal `usage_tracker` writer; the branch itself was **never ported to `spoolman_tracking`**, so users on Spoolman have never had a working split even after #1771 shipped. **Fix — extract the shared split math and use it from both inventory writers so the two paths cannot drift.** New pure-logic helper `backend/app/utils/tray_split.py::compute_tray_split_grams` — takes `tray_changes`, `total_weight`, `slot_id`, optional `layer_usage`, density/diameter/total_layers/last_layer_num, returns per-segment `[(seg_idx, global_tray_id, grams)]`. Preference order matches usage_tracker exactly: (1) G-code cumulative extrusion between segment boundaries, (2) linear layer-ratio with `denom = total_layers or last_layer_num` (P1S firmware resets `total_layer_num` to 0 at completion — `last_layer_num` is the durable denominator, #1771's cascade), (3) equal-split when no denominator survives. Last segment always absorbs rounding drift so the sum equals `total_weight` exactly, no phantom grams created or lost. `usage_tracker.py:1085-1150`'s inline split loop now calls the helper; the caller-side "convert global_tray_id → (ams_id, tray_id) → resolve spool → charge grams" side effect stays where it was. `spoolman_tracking.report_usage` gains a new `_report_spool_usage_split_by_tray_changes` peer that mirrors `_report_spool_usage_for_slots`'s Spoolman side effects (tag → `find_spool_by_tag` → slot-assignment fallback → `use_spool`) but iterates SEGMENTS instead of raw slots. Split path activates when `len(state.tray_change_log) > 1` at completion AND the print used exactly one nonzero slot (`len(nonzero_slots) == 1`) — same gate as `usage_tracker.py:1002`. Multi-colour prints naturally cycle trays for every colour change, so splitting each slot's grams across every `tray_change_log` entry would attribute slot 1's usage to segments where slot 2's tray was loaded and vice versa. Multi-slot prints fall through to the existing single-tray path with its stable `slot_to_tray` mapping. Single-entry log (start-of-print seed only, no mid-print switch) also falls through, so nothing changes for prints that didn't traverse an AMS switch. **Double-count fix.** After the split path attributes segment N to `global_tray_id`, that ID enters `handled_global_tray_ids` — so Path 2 (remain-delta fallback for slots 3MF didn't cover) skips it. Pre-fix, the OP's backup spool got a redundant `remain-delta` credit on top of the origin overcharge; post-fix, remain-delta only fires for trays the split path genuinely didn't touch. **Sample A math end-to-end.** Reporter's 72.56g single-slot print with `tray_change_log=[(0, 0), (1, 37)]` and no gcode layer usage: seg 0 (tray 0, layers 0-37) gets `72.56 × 37/100 = 26.85g` → spool 8; seg 1 (tray 1, layers 37-end) gets `72.56 - 26.85 = 45.71g` → spool 7. Origin no longer exceeds `initial_weight`; backup carries the segment actually printed from it. Sample B (paused-then-switched print at layer 371 on a 263.47g single-slot job) uses the identical code path — pause/resume doesn't flip the `self._was_running and not self._completion_triggered` gate at `bambu_mqtt.py:1860`, so the tray-change log survives the pause window. One fix covers both. **Tests.** 4 integration cases in `backend/tests/unit/services/test_spoolman_tray_split.py` pin the Sample A shape, the Path 2 double-count guard, the multi-slot no-split gate, and the single-tray-change-entry fallthrough. Plus 8 pure-math cases in `backend/tests/unit/utils/test_tray_split.py` pinning the algorithm — empty log → empty result, single segment → charge everything to that tray, two-segment linear split, gcode-preferred-over-linear branch (with a regression guard on `slot_id → filament_id = slot_id - 1` because dropping the -1 sends the split to a filament_id that isn't in the gcode and dumps everything onto the last segment — the exact #1771 shape), three-segment last-absorbs-rounding, `total_layers=0 & last_layer_num>0` cascade, equal-split when no denominator, and cross-validation that captured-layer scenarios match the total_layers path. 4 integration cases in `backend/tests/unit/services/test_spoolman_tray_split.py` pinning the Sample A shape end-to-end — the seamless-switch case (two `use_spool` calls, sum=72.56, origin gets 26-28g, backup gets 44-47g), the double-count guard (Path 2 gets valid slot-assignment IDs for both trays so a broken guard would surface as 4 `use_spool` calls instead of 2), the multi-slot no-split gate (multi-colour print with 5 tray-changes must fall through to single-tray attribution, not fan slot 1's grams across slot 2's segments), and the single-tray-change-entry fallthrough (must NOT split a normal single-tray print). Extended existing `test_usage_tracker` regressions still pass 112/112 — refactoring the inline split path to use the shared helper is behaviour-preserving. `pytest -n 30 backend/tests/ -k "spoolman or usage_tracker or tray_split or partial"`: 798/798 green. **Compat.** `report_usage` uses `getattr(tracking, "layer_usage", None) or {}` and `getattr(tracking, "filament_properties", None) or {}` — attribute-safe against SimpleNamespace stubs in tests AND against pre-migration ORM rows loaded without those columns. **Scope.** Backend-only. Two-file port (usage_tracker refactor + spoolman_tracking split path) + one new helper module + 11 new tests. No DB migration (fields already existed on `ActivePrintSpoolman` from earlier work). No new permission. No new i18n key. No frontend change — the `spool_usage_logged` WebSocket already carries per-segment results, so the UI updates without a client-side change. + - **P1S / P1P printer card no longer shows a bogus "Door Closed" badge (#1866, reporter @MartinNYHC)** — The enclosure-door badge in `frontend/src/pages/PrintersPage.tsx` gated visibility on a model whitelist that included `P1S` and `P1P`, but neither has a door sensor: P1S has an enclosure door with no hall sensor behind it, P1P has no enclosure at all. Backend `bambu_mqtt.py:2876-2907` unconditionally parses bit 23 of the `stat` field for every non-X1 model, and the bit stays 0 on P1S/P1P firmware — so the card rendered a permanent green "Door Closed" chip that couldn't reflect real state. **Fix.** Drop `P1S` and `P1P` from the frontend whitelist. Whitelist now reads `['X1C', 'X1', 'X1E', 'X2D', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S']` — the models that ship with a hall sensor for the door (and, on X1 family, top glass). Corrected the stale `X1/P1S/P2S/H2*` comment in `frontend/src/api/client.ts:473` (TypeScript `PrinterStatus` interface) and `backend/app/services/bambu_mqtt.py:295` (`PrinterState` dataclass) to match reality — those comments were what led the whitelist astray in the first place. Backend parsing left as-is: the bit read is cheap and if Bambu ever ships firmware that wires the P-series enclosure into a door sensor, the state gets picked up for free without a Bambuddy change. **Scope.** Two-line frontend whitelist change plus two comment corrections. No test change (no unit tests existed for the frontend gate). No i18n key change (`printers.door.open`/`printers.door.closed` still used for the models that keep the badge). No DB migration. No new permission. No backend behavioural change. - **Bambu Cloud custom filament preset lookup restored — SpoolBuddy "Assign to AMS" now surfaces the real custom profile in BambuStudio (#1815, reporter @Bgabor997)** — Symptom: SpoolBuddy scans a tag for a spool whose slicer_filament is a Bambu Cloud user preset (PFUS-prefix, "PFUS12f68b29a18aa4" etc.), Bambuddy applies the assignment, and BambuStudio's AMS panel shows "Generic PETG" / "Generic PLA" instead of the user's custom profile. Manual AMS-card configure with the same profile worked. **Root cause.** `BambuCloudService.get_setting_detail` at `backend/app/services/bambu_cloud.py:393` hits `GET /v1/iot-service/api/slicer/setting/{setting_id}` without the `?version=XX.YY.ZZ.WW` query parameter — Bambu's API answers `HTTP 400 "field 'version' is not set"` for every call. The plural GET five methods above (`get_slicer_settings`, line 362) *does* send `params={"version": _SLICER_API_VERSION}` and the source comment at line 69-77 documents precisely this contract for the endpoint subtree; the singular GET and the sibling DELETE (`delete_setting`, line 545) were left uncovered when the `_SLICER_API_VERSION` placeholder landed in #1013's compliance rework 2026-05-12. `slicer_filament_resolver.resolve_slicer_filament` swallowed the 400 as a warning and fell through to `normalize_slicer_filament`; the caller in `inventory.py:146-165` then generic-material-fell-back tray_info_idx to `GFL99`/`GFG99`, and BambuStudio's AMS panel reads the printer's `tray_info_idx` echo → Generic. **Why nobody caught this in 50 days.** Two rescue paths mask the bug in 99% of real assigns: (1) if the target slot already carries a P-prefix filament_id from any prior configure, `current_tray_info_idx` reuse at `inventory.py:147-156` reuses it; (2) if the spool has a stored `spool_k_profile` matching a live `state.kprofiles` entry on the printer, `printer_kp.filament_id` realigns tray_info_idx at line 216-230 (`Spool assign: realigning tray_info_idx 'GFL99' → 'P…' (source=printer)`). Reporter's spool 54 → tray 2 assign at 2026-06-30 09:21:18 was the rare case with neither rescue — fresh spool, no prior K-profile calibration, slot didn't hold a valid P-prefix — and fell all the way to generic. Every other PFUS assign in his same log shipped a valid P-prefix. Owner reproduced the WARNING line in his own log on the H2D after a Reset-Slot + rescan cycle; his display was rescued by the K-profile realign path, not by cloud. **Fix.** `get_setting_detail` and `delete_setting` now both send `params={"version": _SLICER_API_VERSION}` — same neutral `"1.0.0.0"` placeholder the plural GET has always used (Bambu accepts any XX.YY.ZZ.WW value, doesn't validate against a release manifest, so no impersonation). Once cloud returns 200, the resolver reads `detail["filament_id"]` at line 108-113 and the P-prefix lands on tray_info_idx directly — no reliance on slot reuse or K-profile realign. The endpoint-subtree comment at line 69-77 updated to name the singular GET, DELETE, and POST variants explicitly so the next sibling method added to this class doesn't silently regress. `get_setting_detail` also includes the truncated response body in the raised `BambuCloudError` message, so a future contract change is self-diagnostic from support-bundle logs (this bug cost 50 days precisely because the error was an opaque `"400"`). **Adjacent surfaces that were also silently broken and are now fixed.** `preset_resolver.resolve_preset`'s cloud branch, `cloud.py`'s three UI-facing routes (`get_setting_detail` at `:534`, `import_setting` chain at `:784`, forecast at `:1120`), `cloud.py`'s delete-cloud-preset route (`:1016`), and internally `BambuCloudService.update_setting` at `:483` (which calls `get_setting_detail` then `delete_setting` then POSTs a replacement — every UI-driven edit of a cloud preset was 400ing at step 1). **Tests.** Three new cases in `TestSlicerSettingVersionParam` (`backend/tests/unit/services/test_bambu_cloud.py`) pinning the contract as a unit-testable invariant so the next sibling method can't silently omit the param: `test_get_setting_detail_sends_version_param` asserts `params.get("version")` is truthy on the GET call; `test_get_setting_detail_error_includes_response_body` pins the reporter's exact 400 body shape (`"field 'version' is not set"`) making it into the raised exception message; `test_delete_setting_sends_version_param` mirrors the invariant for DELETE. 26/26 in `test_bambu_cloud.py` (was 23 + 3 new). `ruff check backend/app/services/bambu_cloud.py backend/tests/unit/services/test_bambu_cloud.py` clean. **Scope.** Backend-only. Two API-call edits, one comment edit, three tests. No DB migration. No new permission. No frontend change. No new i18n key. Users on 0.2.5b1 and earlier who hit this: the fix takes effect on the next Bambuddy restart with no data migration required — reassigning the affected spool via SpoolBuddy after upgrade lands the correct tray_info_idx and the AMS panel in BambuStudio updates to the actual custom profile name. diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index 1ce18a154..87c7d8a87 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/backend/app/services/spoolman_tracking.py @@ -569,6 +569,135 @@ async def _report_spool_usage_for_slots( return spools_updated +async def _report_spool_usage_split_by_tray_changes( + client, + filament_usage: list[dict], + tray_changes: list[tuple[int, int]], + ams_trays: dict[int, dict], + layer_usage: dict[int, dict[int, float]] | None, + filament_properties: dict | None, + total_layers: int, + last_layer_num: int, + method_label: str, + printer_serial: str, + printer_id: int, + slot_colors_out: dict[int, str] | None = None, +) -> tuple[int, set[int]]: + """Split each slot's grams across ``tray_changes`` and charge per-segment. + + Mirrors ``usage_tracker`` Path 1's tray-switch branch so Spoolman and + the internal Spool inventory attribute mid-print AMS-backup switches + identically (#1793 — reporter's origin spool was over-charged the + whole print because this path didn't exist). ``compute_tray_split_grams`` + holds the shared segment-math; this function wraps the per-segment + spool resolution + ``use_spool`` sink for the Spoolman side. + + Returns ``(spools_updated, handled_global_tray_ids)`` — the caller + passes ``handled_global_tray_ids`` into the remain-delta fallback so + a tray attributed here is not double-charged there. + """ + from backend.app.utils.tray_split import compute_tray_split_grams + + spools_updated = 0 + handled_global_tray_ids: set[int] = set() + + for usage in filament_usage: + slot_id = usage.get("slot_id", 0) + total_weight = usage.get("used_g", 0) + if total_weight <= 0 or slot_id <= 0: + continue + + props = (filament_properties or {}).get(str(slot_id)) or (filament_properties or {}).get(slot_id) or {} + segments = compute_tray_split_grams( + tray_changes=tray_changes, + total_weight=float(total_weight), + slot_id=slot_id, + layer_usage=layer_usage, + density=float(props.get("density", 1.24)), + diameter=float(props.get("diameter", 1.75)), + total_layers=total_layers, + last_layer_num=last_layer_num, + ) + + for seg_idx, tray_global, segment_grams in segments: + if segment_grams <= 0: + continue + + # Mark this tray as handled BEFORE the resolution attempt so + # remain-delta doesn't double-charge it, even if we fail to + # find a spool below. Matches usage_tracker behaviour: the + # tray was physically fed from during this print, whether or + # not Spoolman happens to have a matching row. + handled_global_tray_ids.add(tray_global) + + tray_info = ams_trays.get(tray_global) or {} + spool_id_to_use: int | None = None + resolution_path = "" + spool_color_hex: str | None = None + + spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else "" + if spool_tag: + spool = await client.find_spool_by_tag(spool_tag) + if spool: + spool_id_to_use = spool["id"] + resolution_path = "tag" + spool_color_hex = (spool.get("filament") or {}).get("color_hex") + + if spool_id_to_use is None: + seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global) + spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, seg_ams_id, seg_tray_id) + if spool_id_to_use is not None: + resolution_path = "slot-assignment" + + if spool_id_to_use is None: + logger.info( + "[SPOOLMAN] Split slot %s seg %s tray=%d: no spool resolved — %.2fg lost from split accounting", + slot_id, + seg_idx, + tray_global, + segment_grams, + ) + continue + + # Colour rewrite (#1494) — first segment for a slot wins. The + # UI displays a single colour per slot, so later segments on the + # same slot don't overwrite (a backup swap can be a different + # colour but the archive card stays consistent with the origin). + if slot_colors_out is not None and slot_id not in slot_colors_out: + if spool_color_hex is None: + try: + full_spool = await client.get_spool(spool_id_to_use) + spool_color_hex = (full_spool.get("filament") or {}).get("color_hex") + except Exception as exc: # noqa: BLE001 — colour is non-critical + logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool colour: %s", slot_id, exc) + if spool_color_hex: + slot_colors_out[slot_id] = spool_color_hex + + try: + await client.use_spool(spool_id_to_use, round(segment_grams, 2)) + logger.info( + "[SPOOLMAN] %s: slot %s seg %s tray=%d: %.2fg -> spool %s (via %s)", + method_label, + slot_id, + seg_idx, + tray_global, + segment_grams, + spool_id_to_use, + resolution_path, + ) + spools_updated += 1 + except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc: + logger.warning( + "[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s", + slot_id, + seg_idx, + spool_id_to_use, + exc, + ) + + return spools_updated, handled_global_tray_ids + + async def _report_partial_usage( printer_id: int, tracking, @@ -789,6 +918,13 @@ async def report_usage(printer_id: int, archive_id: int): ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()} slot_to_tray = tracking.slot_to_tray tray_remain_start = tracking.tray_remain_start or {} + # ``layer_usage`` and ``filament_properties`` were added later than + # the base tracking fields; use ``getattr`` so tests that stub + # ``tracking`` as a lightweight SimpleNamespace stay valid, and + # historic ORM rows loaded without these columns can't AttributeError + # on read. + layer_usage_raw = getattr(tracking, "layer_usage", None) or {} + filament_properties = getattr(tracking, "filament_properties", None) or {} printer_serial = await _get_printer_serial(printer_id) # Delete tracking row (we're done with it) @@ -809,29 +945,104 @@ async def report_usage(printer_id: int, archive_id: int): logger.warning("[SPOOLMAN] Not reachable for usage reporting") return + # Consult the live printer state for the tray-change log written by + # ``bambu_mqtt.py`` on every mid-print ``tray_now`` change (#957). + # When there's more than one entry, the print traversed >1 AMS tray + # and the split path attributes each segment to the tray that was + # loaded at the time — matches the internal Spool inventory writer + # in ``usage_tracker.py``. Without this, an AMS-backup runout switch + # charges the whole slot to the origin spool and pushes it past + # ``initial_weight`` (#1793). + # + # Split only for SINGLE-slot prints — same gate as + # ``usage_tracker.py:1002``. Multi-slot (multi-colour) prints + # naturally cycle trays for every colour change, so splitting each + # slot's grams across ALL tray_change_log entries would attribute + # slot 1's grams to the segments where slot 2's tray was loaded and + # vice versa. Multi-slot prints fall through to the existing + # single-tray path (which uses the stable ``slot_to_tray`` mapping). + nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0] + tray_changes: list[tuple[int, int]] = [] + _state = None + if len(nonzero_slots) == 1: + from backend.app.services.printer_manager import printer_manager as _pm + + _state = _pm.get_status(printer_id) + if _state is not None: + tray_changes = list(getattr(_state, "tray_change_log", []) or []) + _total_layers = int(getattr(_state, "total_layers", 0) or 0) if _state else 0 + _current_layer = int(getattr(_state, "layer_num", 0) or 0) if _state else 0 + # For the linear-fallback denominator when total_layers is 0 (P1S + # firmware resets it at print end). At completion the current layer + # is the print's last valid layer. + _layer_denom_hint = _total_layers or _current_layer + slot_colors: dict[int, str] = {} handled_global_tray_ids: set[int] = set() spools_updated = 0 # --- Path 1: 3MF per-slot estimates ----------------------------- if filament_usage: - logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id) - usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage] - spools_updated = await _report_spool_usage_for_slots( - client, - usage_items, - ams_trays, - slot_to_tray, - f"Archive {archive_id}", - printer_serial, - printer_id=printer_id, - slot_colors_out=slot_colors, - ) - # Track which physical slots the 3MF path already covered so - # Path 2 doesn't double-charge them. - for u in filament_usage: - slot_id = u.get("slot_id", 0) - handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)) + if len(tray_changes) > 1: + # Tray-split path — attribute per-segment to the tray that + # was loaded at that time. + logger.info( + "[SPOOLMAN] Reporting per-filament usage for archive %s with tray-split " + "(tray_change_log=%s, denom_layers=%d)", + archive_id, + tray_changes, + _layer_denom_hint, + ) + # ``tracking.layer_usage`` was serialized to JSON so int keys + # come back as strings. Restore them for the split math. + layer_usage = None + if layer_usage_raw: + try: + layer_usage = { + int(layer): {int(fid): mm for fid, mm in filaments.items()} + for layer, filaments in layer_usage_raw.items() + } + except (TypeError, ValueError, AttributeError): + # AttributeError catches ``inner.items()`` when the + # inner value isn't dict-shaped (corrupt JSON row). + # Missing gcode falls through to the linear-ratio + # branch inside ``compute_tray_split_grams`` — still + # gives a correct split, just less precise. + layer_usage = None + split_updated, split_handled = await _report_spool_usage_split_by_tray_changes( + client, + filament_usage, + tray_changes, + ams_trays, + layer_usage, + filament_properties, + _total_layers, + _layer_denom_hint, + f"Archive {archive_id}", + printer_serial, + printer_id=printer_id, + slot_colors_out=slot_colors, + ) + spools_updated += split_updated + handled_global_tray_ids |= split_handled + else: + logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id) + usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage] + spools_updated = await _report_spool_usage_for_slots( + client, + usage_items, + ams_trays, + slot_to_tray, + f"Archive {archive_id}", + printer_serial, + printer_id=printer_id, + slot_colors_out=slot_colors, + ) + # Track which physical slots the 3MF path already covered so + # Path 2 doesn't double-charge them. + for u in filament_usage: + slot_id = u.get("slot_id", 0) + handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays)) # --- Path 2: AMS remain%-delta for slots 3MF didn't cover ------- # Triggered for no-3MF "Untitled" prints (#1820) AND for partial diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index 02578192c..500d8d2e8 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -1083,6 +1083,10 @@ async def _track_from_3mf( continue # --- Mid-print tray switch: split weight across trays --- + # Split math is shared with the Spoolman writer via + # ``utils.tray_split.compute_tray_split_grams`` (#1793) — both + # inventory backends must attribute segments identically or a + # user running dual-mode sees divergent totals. if len(tray_changes) > 1: # Compute total weight for this slot (same logic as normal path) if layer_grams and slot_id in layer_grams: @@ -1100,8 +1104,6 @@ async def _track_from_3mf( from backend.app.utils.threemf_tools import ( extract_filament_properties_from_3mf, extract_layer_filament_usage_from_3mf, - get_cumulative_usage_at_layer, - mm_to_grams, ) split_layer_usage = extract_layer_filament_usage_from_3mf(file_path) @@ -1110,46 +1112,20 @@ async def _track_from_3mf( except Exception: pass # Fall back to linear splitting - density = split_props.get("density", 1.24) - diameter = split_props.get("diameter", 1.75) - filament_id = slot_id - 1 # 0-based for gcode + from backend.app.utils.tray_split import compute_tray_split_grams - sum_previous = 0.0 - for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes): - is_last = seg_idx + 1 >= len(tray_changes) + segments = compute_tray_split_grams( + tray_changes=tray_changes, + total_weight=total_weight, + slot_id=slot_id, + layer_usage=split_layer_usage, + density=split_props.get("density", 1.24), + diameter=split_props.get("diameter", 1.75), + total_layers=(state.total_layers if state else 0) or 0, + last_layer_num=last_layer_num, + ) - if is_last: - # Last segment: remainder to avoid rounding drift - segment_grams = total_weight - sum_previous - elif split_layer_usage: - seg_end_layer = tray_changes[seg_idx + 1][1] - mm_at_start = get_cumulative_usage_at_layer(split_layer_usage, seg_start_layer).get(filament_id, 0) - mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0) - segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density) - else: - # No per-layer data: linear fallback by layer ratio (#1771). - # Cascade denominators because firmware on some models (P1S - # observed) resets `total_layer_num` to 0 at print end — - # `last_layer_num` is the print's last-valid layer captured - # mid-print and survives that reset (same shape as the - # `last_progress` fallback at line 1040). Equal-split is the - # last-resort fence: still wrong, but bounded — never dumps - # the entire print onto the last segment, which was the - # original #1771 symptom for the reporter (P1S, AMS Backup - # fed from spool 1 then spool 2, all 260 g credited to - # spool 2 even though spool 1 had given up its 180 g). - seg_end_layer = tray_changes[seg_idx + 1][1] - denom = (state.total_layers if state else 0) or last_layer_num - if denom > 0: - segment_grams = total_weight * (seg_end_layer - seg_start_layer) / denom - else: - # No layer information available from any source — - # spread evenly across segments. The last segment will - # get the rounding remainder via the `is_last` branch - # above on its own iteration. - segment_grams = total_weight / len(tray_changes) - - sum_previous += segment_grams + for seg_idx, tray_global, segment_grams in segments: if segment_grams <= 0: continue @@ -1168,6 +1144,8 @@ async def _track_from_3mf( if seg_key in handled_trays: continue + seg_start_layer = tray_changes[seg_idx][1] + is_last = seg_idx + 1 >= len(tray_changes) logger.info( "[UsageTracker] 3MF split: segment %d tray=%d (AMS%d-T%d) layers %d-%s -> %.1fg", seg_idx, diff --git a/backend/app/utils/tray_split.py b/backend/app/utils/tray_split.py new file mode 100644 index 000000000..e02c0d406 --- /dev/null +++ b/backend/app/utils/tray_split.py @@ -0,0 +1,94 @@ +"""Weight-split math for prints that traversed >1 AMS tray mid-print. + +`state.tray_change_log` records `(global_tray_id, layer_num)` tuples every +time `tray_now` changes during a print (see `bambu_mqtt.py:1861`). At +completion, both the internal Spool inventory (`usage_tracker.py`) and the +Spoolman writer (`spoolman_tracking.py`) need to split a slot's total +weight across the segments those changes define — one call site per +inventory backend, one identical splitting algorithm. + +The algorithm lives here so the two callers cannot drift: #1793 came from +`spoolman_tracking` never carrying the split at all, while `usage_tracker` +had shipped it since #957 and refined it in #1771. Sharing the helper is +the structural fix; each caller wraps its own "resolve segment tray → +charge N grams" side effect. +""" + +from __future__ import annotations + +# Qualified-name access (``threemf_tools.mm_to_grams(...)`` rather than +# ``from … import mm_to_grams``) so ``unittest.mock.patch`` on the +# threemf_tools module lands in the helper too — the pre-refactor +# ``usage_tracker`` call site imported at call-time inside a try block, +# which had the same testability property. +from backend.app.utils import threemf_tools + + +def compute_tray_split_grams( + tray_changes: list[tuple[int, int]], + total_weight: float, + slot_id: int, + layer_usage: dict[int, dict[int, float]] | None, + density: float, + diameter: float, + total_layers: int, + last_layer_num: int, +) -> list[tuple[int, int, float]]: + """Split ``total_weight`` for a single slice slot across tray segments. + + ``tray_changes`` is the ordered list ``[(global_tray_id, seg_start_layer), ...]`` + exactly as it appears in ``state.tray_change_log``. The last segment + runs to the end of the print; every other segment ends at the next + entry's ``seg_start_layer``. + + Preference order for per-segment grams — matches ``usage_tracker`` so + both inventory backends split identically: + + 1. **G-code cumulative extrusion** (``layer_usage``, indexed by 0-based + filament id). Precise: uses the mm actually consumed between + ``seg_start_layer`` and the next segment's start, then converts via + Spoolman-authoritative ``density`` / ``diameter``. + 2. **Linear layer-ratio** — ``total_weight * segment_layers / denom``, + with ``denom = total_layers or last_layer_num``. Firmware on P1S + (observed) resets ``total_layer_num`` to 0 at print end, so the + captured ``last_layer_num`` is the durable denominator (see + ``usage_tracker.py:1132``). #1771 addressed the pre-fix behaviour + of dumping everything onto the last segment. + 3. **Equal-split** — when neither denominator is available (server + restart mid-print, missing state). Wrong but bounded — the last + segment absorbs any rounding drift via the ``is_last`` branch. + + Returns ``[(seg_idx, global_tray_id, segment_grams)]``. Empty when + ``tray_changes`` is empty; the caller decides whether to fall through + to single-tray attribution (``len(tray_changes) <= 1``). + """ + if not tray_changes: + return [] + + filament_id = slot_id - 1 + n_segments = len(tray_changes) + denom = total_layers or last_layer_num + results: list[tuple[int, int, float]] = [] + sum_previous = 0.0 + + for seg_idx, (tray_global, seg_start_layer) in enumerate(tray_changes): + is_last = seg_idx + 1 >= n_segments + + if is_last: + segment_grams = total_weight - sum_previous + elif layer_usage: + seg_end_layer = tray_changes[seg_idx + 1][1] + mm_at_start = threemf_tools.get_cumulative_usage_at_layer(layer_usage, seg_start_layer).get(filament_id, 0) + mm_at_end = threemf_tools.get_cumulative_usage_at_layer(layer_usage, seg_end_layer).get(filament_id, 0) + segment_grams = threemf_tools.mm_to_grams(mm_at_end - mm_at_start, diameter, density) + else: + seg_end_layer = tray_changes[seg_idx + 1][1] + if denom > 0: + segment_grams = total_weight * (seg_end_layer - seg_start_layer) / denom + else: + segment_grams = total_weight / n_segments + + sum_previous += segment_grams + results.append((seg_idx, tray_global, segment_grams)) + + return results diff --git a/backend/tests/unit/services/test_spoolman_tray_split.py b/backend/tests/unit/services/test_spoolman_tray_split.py new file mode 100644 index 000000000..ea1c40541 --- /dev/null +++ b/backend/tests/unit/services/test_spoolman_tray_split.py @@ -0,0 +1,350 @@ +"""Spoolman-side mid-print tray-split accounting (#1793). + +Reporter (@ojimpo) shipped the OP shape: +- H2S, AMS filament backup ON, two same-material spools loaded +- Single-slot print (72.56g on slot 1) +- Origin ran dry at layer 37, AMS auto-switched to backup, print finished +- Pre-fix: whole 72.56g charged to origin (via tag path) + separate 30g to + backup (via remain-delta) — origin exceeded initial_weight, backup double-count + +The fix ports usage_tracker's split path to spoolman_tracking so both +inventory backends attribute segments identically. These tests pin the +OP's shape plus the Path 2 (remain-delta) skip guarantee so it can't +double-charge tray IDs the split path already covered. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class _AsyncCtx: + """async_session() shim — same shape as test_spoolman_no3mf_remain_fallback.""" + + def __init__(self, db): + self._db = db + + async def __aenter__(self): + return self._db + + async def __aexit__(self, *_): + return False + + +def _make_db(tracking): + db = AsyncMock() + select_result = MagicMock() + select_result.scalar_one_or_none.return_value = tracking + db.execute = AsyncMock(return_value=select_result) + db.delete = AsyncMock() + db.commit = AsyncMock() + return db + + +class TestReportUsageTraySplit: + """report_usage must consult state.tray_change_log and split per-segment.""" + + @pytest.mark.asyncio + async def test_op_sample_a_seamless_switch_splits_origin_to_backup(self): + """Sample A from the reporter, verbatim: 72.56g single-slot print, + AMS runout switch tray 0 → tray 1 at layer 37 of ~100 total. + + No gcode layer_usage is provided → linear-by-layer-ratio fallback: + - seg 0 (tray 0, layers 0-37) = 72.56 * 37/100 = 26.85g → spool 8 + - seg 1 (tray 1, layers 37-end) = 72.56 - 26.85 = 45.71g → spool 7 + Path 2 (remain-delta) must NOT run against either tray — the split + path already covered them. + """ + from backend.app.services.spoolman_tracking import report_usage + + tracking = SimpleNamespace( + filament_usage=[{"slot_id": 1, "used_g": 72.56}], + ams_trays={ + 0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"}, + 1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"}, + }, + slot_to_tray=[0], + tray_remain_start={ + "0-0": {"remain": 3, "tray_uuid": "AAAA"}, # origin near-empty at start-of-completion snapshot + "0-1": {"remain": 73, "tray_uuid": "BBBB"}, + }, + layer_usage={}, + filament_properties={}, + ) + + db = _make_db(tracking) + + client = AsyncMock() + + async def _find_spool_by_tag(tag): + return ( + {"id": 8, "filament": {"color_hex": "000000"}} + if tag == "AAAA" + else {"id": 7, "filament": {"color_hex": "000000"}} + if tag == "BBBB" + else None + ) + + client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag) + client.use_spool = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + tray_change_log=[(0, 0), (1, 37)], + total_layers=100, + layer_num=100, + raw_data={ + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_uuid": "AAAA", "remain": 0}, + {"id": 1, "tray_uuid": "BBBB", "remain": 70}, + ], + } + ] + }, + ) + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")), + patch( + "backend.app.services.spoolman_tracking._apply_spool_colors_to_archive", + AsyncMock(), + ), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=143) + + # Exactly two use_spool calls — one per segment. Origin (spool 8) + # gets the layers-0-37 slice, backup (spool 7) gets the remainder. + calls = client.use_spool.await_args_list + assert len(calls) == 2, f"expected 2 use_spool calls, got {len(calls)}: {calls}" + + by_spool = {c.args[0]: c.args[1] for c in calls} + assert set(by_spool.keys()) == {8, 7} + + # Sum must equal the OP's total — no phantom grams created or lost. + assert round(sum(by_spool.values()), 2) == 72.56 + + # Origin (spool 8) should carry roughly the layers-0-37 fraction. + # Linear: 72.56 * 37/100 = 26.85g. Allow small rounding wiggle. + assert 26.0 < by_spool[8] < 28.0 + # Backup (spool 7) carries the remainder. + assert 44.0 < by_spool[7] < 46.6 + + @pytest.mark.asyncio + async def test_path_2_remain_delta_skips_tray_handled_by_split(self): + """After the split path attributes segments to tray 0 AND tray 1, + the Path 2 remain-delta iterator must skip BOTH — otherwise backup + would get charged twice (~30g double-count in the OP's Sample A). + """ + from backend.app.services.spoolman_tracking import report_usage + + tracking = SimpleNamespace( + filament_usage=[{"slot_id": 1, "used_g": 100.0}], + ams_trays={ + 0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"}, + 1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"}, + }, + slot_to_tray=[0], + tray_remain_start={ + "0-0": {"remain": 20, "tray_uuid": "AAAA"}, + "0-1": {"remain": 80, "tray_uuid": "BBBB"}, + }, + layer_usage={}, + filament_properties={}, + ) + + db = _make_db(tracking) + client = AsyncMock() + + async def _find_spool_by_tag(tag): + return {"id": 8, "filament": {}} if tag == "AAAA" else {"id": 7, "filament": {}} + + client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag) + client.use_spool = AsyncMock() + # If Path 2 ever runs, it needs a filament.weight to compute grams. + # Making it valid means a failure to guard = extra use_spool calls, + # not a silent skip. Combined with a truthy slot-assignment result + # below, this is what actually proves the double-count guard works. + client.get_spool = AsyncMock(return_value={"filament": {"weight": 1000.0}}) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + tray_change_log=[(0, 0), (1, 50)], + total_layers=100, + layer_num=100, + raw_data={ + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_uuid": "AAAA", "remain": 0}, + {"id": 1, "tray_uuid": "BBBB", "remain": 60}, + ], + } + ] + }, + ) + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")), + patch( + # Path 2 uses this to resolve trays. Return valid IDs so + # the ONLY thing stopping Path 2 from double-charging is + # ``handled_global_tray_ids``. If the guard is broken, + # Path 2 would successfully call ``use_spool`` two more + # times and this test would fail with 4 calls, not 2. + "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment", + AsyncMock(side_effect=lambda pid, ams, tray: 999 if (ams, tray) == (0, 0) else 888), + ), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=200) + + # EXACTLY 2 — one per segment; Path 2 must not add a third. + assert client.use_spool.await_count == 2, ( + f"Path 2 leaked past the split — expected 2 use_spool calls, got " + f"{client.use_spool.await_count}: {client.use_spool.await_args_list}" + ) + + @pytest.mark.asyncio + async def test_multi_slot_print_does_not_activate_split_even_with_tray_changes(self): + """Multi-colour prints normally cycle trays every colour change, so + ``tray_change_log`` has many entries — but splitting each slot's + grams across all of them would attribute slot 1's usage to segments + where slot 2's tray was loaded (and vice versa). + + Mirrors ``usage_tracker.py:1002``'s gate: split only when there's + exactly one nonzero slot. Multi-slot prints fall through to the + existing single-tray path with its stable ``slot_to_tray`` mapping. + """ + from backend.app.services.spoolman_tracking import report_usage + + # Two nonzero slots — regular multi-colour print + tracking = SimpleNamespace( + filament_usage=[ + {"slot_id": 1, "used_g": 30.0}, + {"slot_id": 2, "used_g": 20.0}, + ], + ams_trays={ + 0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"}, + 1: {"tray_uuid": "BBBB", "tag_uid": "T2TAG", "tray_type": "PLA"}, + }, + slot_to_tray=[0, 1], + tray_remain_start={ + "0-0": {"remain": 90, "tray_uuid": "AAAA"}, + "0-1": {"remain": 80, "tray_uuid": "BBBB"}, + }, + layer_usage={}, + filament_properties={}, + ) + + db = _make_db(tracking) + client = AsyncMock() + + async def _find_spool_by_tag(tag): + return {"id": 100, "filament": {}} if tag == "AAAA" else {"id": 200, "filament": {}} + + client.find_spool_by_tag = AsyncMock(side_effect=_find_spool_by_tag) + client.use_spool = AsyncMock() + + printer_manager = MagicMock() + # Multi-colour print naturally cycles between trays many times. + # If we don't gate on single-slot, my split would attribute slot 1's + # grams to every segment — including segments where tray 1 was loaded. + printer_manager.get_status.return_value = SimpleNamespace( + tray_change_log=[(0, 0), (1, 10), (0, 20), (1, 30), (0, 40)], + total_layers=50, + layer_num=50, + raw_data={ + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_uuid": "AAAA", "remain": 87}, + {"id": 1, "tray_uuid": "BBBB", "remain": 78}, + ], + } + ] + }, + ) + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=42) + + # Split path must NOT engage. The single-tray path charges each + # slot to its stable slot_to_tray mapping: slot 1 → tray 0 → spool + # 100 (30g), slot 2 → tray 1 → spool 200 (20g). Two calls, exact + # weights from the 3MF (not split). + assert client.use_spool.await_count == 2 + by_spool = {c.args[0]: c.args[1] for c in client.use_spool.await_args_list} + assert by_spool == {100: 30.0, 200: 20.0} + + @pytest.mark.asyncio + async def test_single_tray_change_entry_uses_normal_path(self): + """Only ONE entry in tray_change_log (start-of-print seed, no + switch) must fall through to the existing single-tray charging + path — not accidentally split when there's nothing to split. + """ + from backend.app.services.spoolman_tracking import report_usage + + tracking = SimpleNamespace( + filament_usage=[{"slot_id": 1, "used_g": 50.0}], + ams_trays={0: {"tray_uuid": "AAAA", "tag_uid": "T1TAG", "tray_type": "PLA"}}, + slot_to_tray=[0], + tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}}, + layer_usage={}, + filament_properties={}, + ) + + db = _make_db(tracking) + client = AsyncMock() + client.find_spool_by_tag = AsyncMock(return_value={"id": 8, "filament": {}}) + client.use_spool = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + tray_change_log=[(0, 0)], # just the start-of-print seed + total_layers=100, + layer_num=100, + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 75}]}]}, + ) + + with ( + patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")), + patch( + "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback", + AsyncMock(return_value=client), + ), + patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SERIAL")), + patch("backend.app.services.printer_manager.printer_manager", printer_manager), + ): + await report_usage(printer_id=1, archive_id=42) + + # Single-tray path: exactly one use_spool call, all 50g to spool 8. + client.use_spool.assert_awaited_once_with(8, 50.0) diff --git a/backend/tests/unit/utils/test_tray_split.py b/backend/tests/unit/utils/test_tray_split.py new file mode 100644 index 000000000..cc43925b7 --- /dev/null +++ b/backend/tests/unit/utils/test_tray_split.py @@ -0,0 +1,179 @@ +"""Pure-logic tests for the mid-print tray-split math (#1793). + +The helper lives in ``backend/app/utils/tray_split.py`` and is exercised +by both inventory backends (``usage_tracker`` and ``spoolman_tracking``). +These tests pin the algorithm so a change in one caller can't silently +break the other — cross-inventory parity is a HARD RULE for this project. +""" + +from __future__ import annotations + +from backend.app.utils.tray_split import compute_tray_split_grams + + +class TestComputeTraySplitGrams: + """Segment-attribution algorithm — gcode preferred, linear fallback, equal split.""" + + def test_empty_tray_changes_returns_empty(self): + assert ( + compute_tray_split_grams( + tray_changes=[], + total_weight=100.0, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=200, + last_layer_num=200, + ) + == [] + ) + + def test_single_segment_charges_everything_to_that_tray(self): + segments = compute_tray_split_grams( + tray_changes=[(0, 0)], + total_weight=72.56, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=100, + last_layer_num=100, + ) + assert segments == [(0, 0, 72.56)] + + def test_two_segments_linear_split_by_layer_ratio(self): + # Runout at layer 37 of 100 total; no gcode available → linear. + # Segment 0 (tray 0, layers 0-37) = 100 * 37/100 = 37g + # Segment 1 (tray 1, layers 37-end) = 100 - 37 = 63g (remainder) + segments = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 37)], + total_weight=100.0, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=100, + last_layer_num=100, + ) + assert segments == [(0, 0, 37.0), (1, 1, 63.0)] + + def test_two_segments_gcode_preferred_over_linear(self): + # layer_usage stores mm of filament extruded per (layer, filament_id). + # Values are cumulative-per-key inside get_cumulative_usage_at_layer. + # 20 layers, filament_id=0 (slot_id=1 → filament_id 0): + # layer 10 → 100mm cumulative + # layer 20 → 300mm cumulative + # tray change at layer 10 → seg 0 spans layers 0-10 (mm 0 → 100), + # seg 1 spans layers 10-end. + # mm_to_grams(100, 1.75, 1.24) ≈ 0.298g; last segment absorbs the rest. + layer_usage = { + 5: {0: 50.0}, + 10: {0: 100.0}, + 15: {0: 200.0}, + 20: {0: 300.0}, + } + segments = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 10)], + total_weight=1.0, # sentinel — we assert the seg1 remainder + slot_id=1, + layer_usage=layer_usage, + density=1.24, + diameter=1.75, + total_layers=20, + last_layer_num=20, + ) + # Seg 0 charged from gcode delta (mm 0 → 100). + # Seg 1 gets total_weight - seg0 as remainder. + assert segments[0][0] == 0 + assert segments[0][1] == 0 # tray 0 + assert segments[0][2] > 0 # non-zero gcode contribution + assert segments[1][0] == 1 + assert segments[1][1] == 1 # tray 1 + # Sum equals the input total by construction (last segment absorbs). + assert round(segments[0][2] + segments[1][2], 6) == 1.0 + + def test_three_segments_last_absorbs_rounding_drift(self): + # 100g over three segments at layers 30 and 60 of 90; linear fallback. + # Seg 0: 100 * 30/90 = 33.3333... + # Seg 1: 100 * 30/90 = 33.3333... + # Seg 2: remainder = 100 - 66.6666... = 33.3333... — exact by construction + segments = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 30), (2, 60)], + total_weight=100.0, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=90, + last_layer_num=90, + ) + assert len(segments) == 3 + assert round(sum(g for _, _, g in segments), 6) == 100.0 + assert segments[0][1] == 0 + assert segments[1][1] == 1 + assert segments[2][1] == 2 + + def test_no_layer_info_at_all_falls_to_equal_split(self): + # Denominator 0 → last-resort equal-split; last segment absorbs remainder. + segments = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 50)], + total_weight=90.0, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=0, + last_layer_num=0, + ) + # 90g / 2 = 45g each; sum still 90 by remainder mechanic. + assert segments == [(0, 0, 45.0), (1, 1, 45.0)] + + def test_last_layer_num_used_when_total_layers_zero(self): + # P1S firmware-reset scenario: total_layers=0 at completion, but the + # captured last_layer_num survives. Should give the same linear split + # as if total_layers had held its value (#1771 cascade). + segments_captured = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 30)], + total_weight=100.0, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=0, + last_layer_num=100, + ) + segments_normal = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 30)], + total_weight=100.0, + slot_id=1, + layer_usage=None, + density=1.24, + diameter=1.75, + total_layers=100, + last_layer_num=100, + ) + assert segments_captured == segments_normal + + def test_slot_id_maps_to_zero_based_filament_id_in_gcode(self): + # slot_id 2 → filament_id 1 in layer_usage. If we mistakenly used + # slot_id as-is, we'd read filament_id 2 which is absent → 0mm delta + # → seg 0 gets 0, seg 1 (remainder) gets the whole total. Guard + # against that regression. + layer_usage = { + 5: {0: 0.0, 1: 40.0}, + 10: {0: 0.0, 1: 80.0}, + 20: {0: 0.0, 1: 160.0}, + } + segments = compute_tray_split_grams( + tray_changes=[(0, 0), (1, 10)], + total_weight=1.0, + slot_id=2, + layer_usage=layer_usage, + density=1.24, + diameter=1.75, + total_layers=20, + last_layer_num=20, + ) + # Seg 0 gcode delta on filament_id=1 is non-zero → not 0g. + assert segments[0][2] > 0