Files
RollCalcPython/transport_calculation.py
2026-09-24 21:00:48 +02:00

242 lines
8.7 KiB
Python

"""Shared transport-capacity calculation extracted from the active UI."""
from __future__ import annotations
from dataclasses import dataclass
import math
from typing import Any, Literal
TransportPresetKey = Literal[
"container_20ft",
"container_40ft",
"container_40ft_hc",
"lkw_sattelzug",
"lkw_tandem",
]
@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}