"""Shared transport-capacity calculation extracted from the active UI.""" from __future__ import annotations from dataclasses import dataclass import math from typing import Any @dataclass(frozen=True) class TransportPreset: key: str name: str length_m: float width_m: float height_m: float max_weight_kg: float # Preserve the active browser order and values exactly. TRANSPORT_PRESETS = ( TransportPreset("container_20ft", "20ft Container", 5.90, 2.35, 2.39, 21600), TransportPreset("container_40ft", "40ft Container", 12.19, 2.35, 2.39, 30480), TransportPreset("container_40ft_hc", "40ft High Cube", 12.19, 2.35, 2.69, 30480), TransportPreset("lkw_sattelzug", "LKW Sattelzug", 13.50, 2.50, 2.70, 24000), TransportPreset("lkw_tandem", "LKW Tandem", 13.50, 2.50, 2.70, 25000), ) PRESETS_BY_KEY = {preset.key: preset for preset in TRANSPORT_PRESETS} def transport_presets() -> tuple[TransportPreset, ...]: """Return the canonical active transport presets.""" return TRANSPORT_PRESETS def _invalid(message: str, field: str = "request") -> dict[str, Any]: return { "status": "invalid_parameter", "invalid": [{"field": field, "message": message}], } def _number(payload: dict[str, Any], field: str) -> float | None: value = payload.get(field) if value is None: return None if isinstance(value, bool) or not isinstance(value, (int, float)): raise ValueError(f"{field} must be a number") value = float(value) if not math.isfinite(value): raise ValueError(f"{field} must be finite") return value def _json_number(value: float) -> float | None: return value if math.isfinite(value) else None def analyze_transport(payload: Any) -> dict[str, Any]: """Calculate transport capacity using the active LoadOptimizer semantics. Roll weight is deliberately consumed as supplied. The browser supplies the one-decimal displayed weight from the direct-calculation tab, which is an observable part of the legacy transport behavior. """ if not isinstance(payload, dict): return _invalid("request must be a JSON object") allowed = { "transport_preset", "length_m", "width_m", "height_m", "max_weight_kg", "margin_side_m", "margin_ceiling_m", "roll_diameter_mm", "core_diameter_mm", "roll_width_m", "roll_weight_kg", "product_length_m", } unknown = sorted(set(payload) - allowed) if unknown: return _invalid("unknown request fields: " + ", ".join(unknown)) try: preset_key = payload.get("transport_preset") if preset_key is not None and not isinstance(preset_key, str): raise ValueError("transport_preset must be text") preset = PRESETS_BY_KEY.get(preset_key) if preset_key else None if preset_key and preset is None: return _invalid("unknown transport preset", "transport_preset") length = _number(payload, "length_m") width = _number(payload, "width_m") height = _number(payload, "height_m") max_weight = _number(payload, "max_weight_kg") if preset and all(value is None for value in (length, width, height, max_weight)): length = preset.length_m width = preset.width_m height = preset.height_m max_weight = preset.max_weight_kg margin_side = _number(payload, "margin_side_m") margin_ceiling = _number(payload, "margin_ceiling_m") margin_side = 0.0 if margin_side is None else margin_side margin_ceiling = 0.0 if margin_ceiling is None else margin_ceiling roll_diameter = _number(payload, "roll_diameter_mm") core_diameter = _number(payload, "core_diameter_mm") roll_width = _number(payload, "roll_width_m") roll_weight = _number(payload, "roll_weight_kg") product_length = _number(payload, "product_length_m") product_length = 0.0 if product_length is None else product_length except ValueError as error: return _invalid(str(error)) for field, value in ( ("length_m", length), ("width_m", width), ("height_m", height), ("max_weight_kg", max_weight), ): if value is None or value <= 0: return _invalid("must be greater than zero", field) # The browser's getRollData() rejects falsy values but does not otherwise # validate roll inputs. Preserve that boundary rather than improve it here. for field, value in ( ("roll_diameter_mm", roll_diameter), ("roll_width_m", roll_width), ("roll_weight_kg", roll_weight), ): if value is None or value == 0: return _invalid("required", field) if core_diameter is None: return _invalid("required", "core_diameter_mm") diameter_m = roll_diameter / 1000 core_m = core_diameter / 1000 effective_width = width - margin_side effective_height = height - margin_ceiling rolls_along_length = math.floor(length / roll_width) rolls_along_width = math.floor(effective_width / diameter_m) rolls_per_layer = rolls_along_length * rolls_along_width max_stack_height = math.floor(effective_height / diameter_m) max_by_geometry = rolls_per_layer * max_stack_height max_by_weight = math.floor(max_weight / roll_weight) if roll_weight > 0 else math.inf volume_per_roll = (math.pi / 4) * (diameter_m**2 - core_m**2) * roll_width container_volume = length * width * height effective_volume = length * effective_width * effective_height max_by_volume = ( math.floor(effective_volume / volume_per_roll) if volume_per_roll > 0 else math.inf ) # The list order and strict comparison preserve JavaScript reduce() ties: # Weight wins over Volume, which wins over Geometry. scenarios = [ {"name": "Weight", "rolls": max_by_weight, "type": "weight"}, {"name": "Volume", "rolls": max_by_volume, "type": "volume"}, {"name": "Geometry", "rolls": max_by_geometry, "type": "geometry"}, ] limiting = scenarios[0] for scenario in scenarios[1:]: if scenario["rolls"] < limiting["rolls"]: limiting = scenario final_rolls = 0 if math.isinf(limiting["rolls"]) else limiting["rolls"] m2_per_roll = product_length * roll_width final_weight = final_rolls * roll_weight final_m2 = final_rolls * m2_per_roll for scenario in scenarios: scenario["weight"] = scenario["rolls"] * roll_weight scenario["m2"] = scenario["rolls"] * m2_per_roll scenario["utilization_weight"] = ( (scenario["weight"] / max_weight) * 100 if max_weight > 0 else 0 ) scenario["rolls"] = _json_number(scenario["rolls"]) scenario["weight"] = _json_number(scenario["weight"]) scenario["m2"] = _json_number(scenario["m2"]) analysis = { "transport_preset": preset_key or None, "transport": { "length_m": length, "width_m": width, "height_m": height, "max_weight_kg": max_weight, }, "roll": { "diameter_mm": roll_diameter, "core_diameter_mm": core_diameter, "width_m": roll_width, "weight_kg": roll_weight, "product_length_m": product_length, }, "scenarios": scenarios, "limiting": { "name": limiting["name"], "type": limiting["type"], "rolls": _json_number(limiting["rolls"]), }, "final_rolls": final_rolls, "final_weight_kg": final_weight, "final_m2": final_m2, "remaining_payload_kg": max_weight - final_weight, "utilization_weight": (final_weight / max_weight) * 100 if max_weight > 0 else 0, "utilization_volume": ( (final_rolls * volume_per_roll / container_volume) * 100 if container_volume > 0 else 0 ), "layout": { "rolls_per_layer": rolls_per_layer, "rolls_along_length": rolls_along_length, "rolls_along_width": rolls_along_width, "max_stack_height": max_stack_height, "effective_width_m": effective_width, "effective_height_m": effective_height, "margin_side_m": margin_side, "margin_ceiling_m": margin_ceiling, }, "container_volume_m3": container_volume, "effective_volume_m3": effective_volume, "volume_per_roll_m3": volume_per_roll, } return {"status": "success", "analysis": analysis}