Refactor transport calculation into shared domain
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user