Refactor transport calculation into shared domain

This commit is contained in:
2026-09-24 13:11:18 +02:00
parent fa3000b325
commit defbf20202
10 changed files with 582 additions and 132 deletions
+8 -8
View File
@@ -21,6 +21,7 @@ Backend:
- `/api/user` returns the authenticated user.
- `/api/calculations/roll` executes a `CalculationRequest` through the deterministic core.
- `/api/calculations/roll/modify` applies controlled changes to `CalculationState` and recalculates.
- `/api/calculations/transport` executes the shared transport-capacity service.
- `/api/reports/roll-calculation.pdf` recalculates a request and renders its authoritative result.
- `/api/conversations` creates a process-local conversational session.
- `/api/conversations/<id>/messages` runs constrained NLU followed by the existing deterministic calculation functions.
@@ -40,7 +41,8 @@ Frontend:
Domain service:
- `roll_calculation.py` owns `CalculationRequest`, `CalculationState`, `ArticleRepository`, `calculate_roll()`, and `modify_calculation()`.
- `roll_calculation.py` owns `CalculationRequest`, `CalculationState`, `ArticleRepository`, `get_article()`, `calculate_roll()`, and `modify_calculation()`.
- `transport_calculation.py` owns `analyze_transport()` and the canonical active transport presets. The template calls the protected transport API and retains only UI rendering and SVG drawing.
- Direct diameter ranges, optional weight, and direct-flow forklift warnings originate there.
- Request parsing rejects calculated diameter, weight, warning, and calculation fields.
- Natural-language clients may describe or modify a calculation request, but engineering results are generated exclusively by the deterministic Roll Calculator core.
@@ -246,14 +248,12 @@ That file includes localized messages and additional equipment requirements. Bef
## Load Optimizer
The Load Optimizer is implemented inline in `templates/roll_calculator.html`.
`transport_calculation.py` is authoritative for transport presets and capacity
analysis through `analyze_transport()`. The active template calls the protected
transport endpoint and only collects inputs, renders returned values, and draws
the SVG front view.
Classes:
- `LoadOptimizer`
- `LoadOptimizerUI`
It uses calculated roll data from the Direct Calculation tab:
It uses displayed roll data from the Direct Calculation tab:
- Roll diameter.
- Core diameter.
+7 -5
View File
@@ -56,6 +56,7 @@ The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.
├── ollama_nlu.py
├── pdf_report.py
├── roll_calculation.py
├── transport_calculation.py
├── rollcalc_chat.py
├── integrations/
│ └── openwebui/
@@ -105,6 +106,7 @@ Implemented routes:
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
| `/api/calculations/roll` | `POST` | Basic Auth | Resolves, validates, and executes a direct roll-diameter request. |
| `/api/calculations/roll/modify` | `POST` | Basic Auth | Applies explicit changes to structured calculation state and recalculates. |
| `/api/calculations/transport` | `POST` | Basic Auth | Executes the shared transport-capacity analysis. |
| `/api/reports/roll-calculation.pdf` | `POST` | Basic Auth | Recalculates a request authoritatively and returns a one-page PDF. |
| `/api/conversations` | `POST` | Basic Auth | Creates an in-memory conversation session. |
| `/api/conversations/<id>/messages` | `POST` | Basic Auth | Interprets one utterance and executes the deterministic workflow. |
@@ -409,12 +411,12 @@ There is also a richer `static/config.json` with localized messages and more det
### Load Optimizer
The Load Optimizer is implemented by inline classes in `templates/roll_calculator.html`:
`transport_calculation.py` is the authoritative capacity calculation. It owns
the active transport presets and `analyze_transport()`. The browser collects
inputs, calls `POST /api/calculations/transport`, renders the returned analysis,
and draws the SVG front view; it does not calculate capacity itself.
- `LoadOptimizer`
- `LoadOptimizerUI`
It uses calculated roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions.
It uses the displayed roll data from the Direct Calculation tab and estimates loading capacity for transport presets or custom dimensions. In particular, the direct tab writes the roll weight with one decimal place before the optimizer consumes it; this behavior is intentionally preserved.
Preset transport units include:
+15 -1
View File
@@ -33,6 +33,7 @@ from pdf_report import (
safe_report_filename,
)
from roll_calculation import calculate_roll, modify_calculation
from transport_calculation import analyze_transport, transport_presets
app = Flask(__name__)
auth = HTTPBasicAuth()
@@ -155,7 +156,11 @@ def log_access(username, endpoint, method, status=200):
def index():
"""Main calculator page - requires authentication"""
log_access(auth.current_user(), "/", "GET")
return render_template("roll_calculator.html", core_presets=CORE_PRESETS)
return render_template(
"roll_calculator.html",
core_presets=CORE_PRESETS,
transport_presets=transport_presets(),
)
@app.route("/static/<path:filename>", methods=["GET"])
@@ -212,6 +217,15 @@ def modify_roll_calculation():
return jsonify(result), 200
@app.route("/api/calculations/transport", methods=["POST"])
@auth.login_required
def create_transport_calculation():
"""Execute the shared transport-capacity calculation."""
result = analyze_transport(request.get_json(silent=True))
log_access(auth.current_user(), "/api/calculations/transport", "POST")
return jsonify(result), 200
def _create_pdf(calculation_request):
result = calculate_roll(calculation_request, build_info=BUILD_INFO)
report = report_from_calculation_result(result)
+22
View File
@@ -301,6 +301,28 @@ class ArticleRepository:
}
def get_article(
article_number: str | None = None,
article_name_hint: str | None = None,
*,
article_repository: ArticleRepository | None = None,
) -> dict[str, Any]:
"""Resolve one article through the canonical article repository."""
for field, value in (
("article_number", article_number),
("article_name_hint", article_name_hint),
):
if value is not None and not isinstance(value, str):
return {
"status": "invalid_parameter",
"invalid": [{"field": field, "message": f"{field} must be text"}],
}
article_number = article_number.strip() if article_number else None
article_name_hint = article_name_hint.strip() if article_name_hint else None
repository = article_repository or ArticleRepository.load()
return repository.resolve(article_number, article_name_hint)
def _canonical_name(value: str) -> str:
value = value.casefold().replace("×", " x ")
tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value)
+73 -118
View File
@@ -769,11 +769,9 @@
<div class="lo-presets-section">
<h4>🚚 Preset Transport Type:</h4>
<div class="lo-preset-buttons">
<button class="lo-preset-btn" data-preset="container_20ft">20ft Container</button>
<button class="lo-preset-btn" data-preset="container_40ft">40ft Container</button>
<button class="lo-preset-btn" data-preset="container_40ft_hc">40ft High Cube</button>
<button class="lo-preset-btn" data-preset="lkw_sattelzug">LKW Sattelzug</button>
<button class="lo-preset-btn" data-preset="lkw_tandem">LKW Tandem</button>
{% for preset in transport_presets %}
<button class="lo-preset-btn" data-preset="{{ preset.key }}" data-length="{{ preset.length_m }}" data-width="{{ preset.width_m }}" data-height="{{ preset.height_m }}" data-max-weight="{{ preset.max_weight_kg }}">{{ preset.name }}</button>
{% endfor %}
<button class="lo-preset-btn" data-preset="custom">Custom</button>
</div>
</div>
@@ -1360,83 +1358,11 @@ document.getElementById('d-pdf').addEventListener('click', async () => {
}
});
// =====================================================
// LOAD OPTIMIZER CORE
// =====================================================
class LoadOptimizer {
constructor() {
this.state = {
rollDiameter: 0, coreDiameter: 150, rollWidth: 0,
rollWeight: 0, m2PerRoll: 0, productLength: 0
};
this.presets = {
container_20ft: { name:"20ft Container", length:5.90, width:2.35, height:2.39, maxWeight:21600 },
container_40ft: { name:"40ft Container", length:12.19, width:2.35, height:2.39, maxWeight:30480 },
container_40ft_hc:{ name:"40ft High Cube", length:12.19, width:2.35, height:2.69, maxWeight:30480 },
lkw_sattelzug: { name:"LKW Sattelzug", length:13.50, width:2.50, height:2.70, maxWeight:24000 },
lkw_tandem: { name:"LKW Tandem", length:13.50, width:2.50, height:2.70, maxWeight:25000 }
};
}
setRollData(data) {
this.state = {
rollDiameter: data.rollDiameter || 0,
coreDiameter: data.coreDiameter || 150,
rollWidth: data.rollWidth || 0,
rollWeight: data.rollWeight || 0,
m2PerRoll: data.m2PerRoll || 0,
productLength: data.productLength || 0
};
}
calculateLoadUtilization(dim, maxLoadWeight, marginSide, marginCeiling) {
marginSide = marginSide || 0;
marginCeiling = marginCeiling || 0;
const { rollDiameter, coreDiameter, rollWidth, rollWeight, m2PerRoll } = this.state;
const D = rollDiameter / 1000, d = coreDiameter / 1000;
const effWidth = dim.width - marginSide;
const effHeight = dim.height - marginCeiling;
const rollsAlongLength = Math.floor(dim.length / rollWidth);
const rollsAlongWidth = Math.floor(effWidth / D);
const rollsPerLayer = rollsAlongLength * rollsAlongWidth;
const maxStackHeight = Math.floor(effHeight / D);
const maxByGeometry = rollsPerLayer * maxStackHeight;
const maxByWeight = rollWeight > 0 ? Math.floor(maxLoadWeight / rollWeight) : Infinity;
const volPerRoll = (Math.PI / 4) * (D*D - d*d) * rollWidth;
const containerVol = dim.length * dim.width * dim.height;
const effVol = dim.length * effWidth * effHeight;
const maxByVolume = volPerRoll > 0 ? Math.floor(effVol / volPerRoll) : Infinity;
const scenarios = [
{ name:'Weight', rolls: maxByWeight, weight: maxByWeight * rollWeight, type:'weight' },
{ name:'Volume', rolls: maxByVolume, weight: maxByVolume * rollWeight, type:'volume' },
{ name:'Geometry', rolls: maxByGeometry, weight: maxByGeometry * rollWeight, type:'geometry' }
];
const limiting = scenarios.reduce((mn, c) => c.rolls < mn.rolls ? c : mn);
const finalRolls = limiting.rolls === Infinity ? 0 : limiting.rolls;
const finalWeight = finalRolls * rollWeight;
const finalM2 = finalRolls * m2PerRoll;
return {
scenarios, limiting, finalRolls, finalWeight, finalM2,
utilizationWeight: maxLoadWeight > 0 ? (finalWeight / maxLoadWeight) * 100 : 0,
utilizationVolume: containerVol > 0 ? (finalRolls * volPerRoll / containerVol) * 100 : 0,
rollsPerLayer, rollsAlongLength, rollsAlongWidth, maxStackHeight,
containerVol, volPerRoll, effWidth, effHeight,
marginSide, marginCeiling
};
}
fmt(n, dec=1) { return isFinite(n) ? n.toFixed(dec) : '–'; }
}
// =====================================================
// LOAD OPTIMIZER UI
// =====================================================
class LoadOptimizerUI {
constructor() {
this.optimizer = new LoadOptimizer();
this.currentPreset = null;
this.init();
}
@@ -1487,12 +1413,12 @@ class LoadOptimizerUI {
}
loadPreset(id) {
if (id === 'custom') { this.clearPresets(); return; }
const p = this.optimizer.presets[id];
if (!p) return;
document.getElementById('customLength').value = p.length;
document.getElementById('customWidth').value = p.width;
document.getElementById('customHeight').value = p.height;
document.getElementById('customMaxWeight').value = p.maxWeight;
const button = document.querySelector('[data-preset="'+id+'"]');
if (!button) return;
document.getElementById('customLength').value = button.dataset.length;
document.getElementById('customWidth').value = button.dataset.width;
document.getElementById('customHeight').value = button.dataset.height;
document.getElementById('customMaxWeight').value = button.dataset.maxWeight;
this.updatePresetBtns(id);
this.currentPreset = id;
this.updateEffectiveHints();
@@ -1509,7 +1435,7 @@ class LoadOptimizerUI {
return { sideM, ceilM };
}
calculate() {
async calculate() {
const L = parseFloat(document.getElementById('customLength').value);
const W = parseFloat(document.getElementById('customWidth').value);
const H = parseFloat(document.getElementById('customHeight').value);
@@ -1524,10 +1450,37 @@ class LoadOptimizerUI {
return;
}
const { sideM, ceilM } = this.getMargins();
this.optimizer.setRollData(rollData);
const res = this.optimizer.calculateLoadUtilization({ length:L, width:W, height:H }, MW, sideM, ceilM);
this.showResults(res, MW);
this.drawSideView(res, { length:L, width:W, height:H });
let response;
try {
response = await fetch('/api/calculations/transport', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transport_preset: this.currentPreset,
length_m: L,
width_m: W,
height_m: H,
max_weight_kg: MW,
margin_side_m: sideM,
margin_ceiling_m: ceilM,
roll_diameter_mm: rollData.rollDiameter,
core_diameter_mm: rollData.coreDiameter,
roll_width_m: rollData.rollWidth,
roll_weight_kg: rollData.rollWeight,
product_length_m: rollData.productLength
})
});
} catch (_error) {
alert('Transport calculation service failed');
return;
}
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.status !== 'success') {
alert(payload?.invalid?.[0]?.message || 'Transport calculation failed');
return;
}
this.showResults(payload.analysis);
this.drawSideView(payload.analysis);
}
getRollData() {
@@ -1537,30 +1490,29 @@ class LoadOptimizerUI {
const L = parseFloat(document.getElementById('d-le')?.value || 0);
const d = parseFloat(document.getElementById('d-co')?.value || 150);
if (!D || !W || !GW) return null;
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, m2PerRoll:L*W, productLength:L };
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, productLength:L };
}
showResults(res, maxW) {
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
document.getElementById('totalRollsValue').textContent = res.finalRolls;
showResults(analysis) {
document.getElementById('limitingFactorValue').textContent = analysis.limiting.name;
document.getElementById('totalRollsValue').textContent = analysis.final_rolls;
document.getElementById('rollsDetailValue').textContent =
'(' + res.rollsAlongLength + ' along length × ' + res.rollsAlongWidth + ' across, ' + res.maxStackHeight + ' layers)';
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
'(' + analysis.layout.rolls_along_length + ' along length × ' + analysis.layout.rolls_along_width + ' across, ' + analysis.layout.max_stack_height + ' layers)';
document.getElementById('weightUtilValue').textContent = this.fmt(analysis.utilization_weight);
document.getElementById('volumeUtilValue').textContent = this.fmt(analysis.utilization_volume);
document.getElementById('productM2Value').textContent = this.fmt(analysis.final_m2, 0);
const tbody = document.querySelector('#comparisonTable tbody');
tbody.innerHTML = '';
res.scenarios.forEach(sc => {
analysis.scenarios.forEach(sc => {
const tr = document.createElement('tr');
const lim = (sc.type === res.limiting.type);
const lim = (sc.type === analysis.limiting.type);
if (lim) tr.classList.add('lo-recommended');
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
const util = this.fmt(sc.utilization_weight);
const scM2 = sc.m2 === null ? '–' : Math.round(sc.m2);
tr.innerHTML =
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
'<td>'+ (sc.rolls === null ? '–' : sc.rolls) +'</td>'+
'<td>'+ (sc.weight === null ? '–' : Math.round(sc.weight)) +'</td>'+
'<td>'+ scM2 +'</td>'+
'<td>'+ util +'%</td>';
tbody.appendChild(tr);
@@ -1568,26 +1520,29 @@ class LoadOptimizerUI {
document.getElementById('resultsContainer').classList.add('show');
}
drawSideView(res, dim) {
fmt(n, dec=1) { return Number.isFinite(n) ? n.toFixed(dec) : '–'; }
drawSideView(analysis) {
const svg = document.getElementById('sideViewSVG');
const section = document.getElementById('sideViewSection');
if (!svg || !res || res.finalRolls === 0) return;
if (!svg || !analysis || analysis.final_rolls === 0) return;
const D_m = this.optimizer.state.rollDiameter / 1000;
const aW = res.rollsAlongWidth;
const aL = res.rollsAlongLength;
const layers = res.maxStackHeight;
const total = res.finalRolls;
const marginSide = res.marginSide || 0;
const marginCeil = res.marginCeiling || 0;
const D_m = analysis.roll.diameter_mm / 1000;
const aW = analysis.layout.rolls_along_width;
const aL = analysis.layout.rolls_along_length;
const layers = analysis.layout.max_stack_height;
const total = analysis.final_rolls;
const marginSide = analysis.layout.margin_side_m || 0;
const marginCeil = analysis.layout.margin_ceiling_m || 0;
const dim = analysis.transport;
const PAD = 52, W_SVG = 800, H_SVG = 300;
const scaleX = (W_SVG - 2*PAD) / dim.width;
const scaleY = (H_SVG - 2*PAD) / dim.height;
const scaleX = (W_SVG - 2*PAD) / dim.width_m;
const scaleY = (H_SVG - 2*PAD) / dim.height_m;
const scale = Math.min(scaleX, scaleY);
const cW = dim.width * scale;
const cH = dim.height * scale;
const cW = dim.width_m * scale;
const cH = dim.height_m * scale;
const cX = PAD + ((W_SVG - 2*PAD) - cW) / 2;
const cY = PAD + ((H_SVG - 2*PAD) - cH) / 2;
const rD = D_m * scale;
@@ -1638,12 +1593,12 @@ class LoadOptimizerUI {
const dimY = cY + cH + 20;
s += '<line x1="'+cX.toFixed(1)+'" y1="'+dimY.toFixed(1)+'" x2="'+(cX+cW).toFixed(1)+'" y2="'+dimY.toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width+' m</text>';
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width_m+' m</text>';
const dimX = cX - 22;
const midY = (cY + cH/2).toFixed(1);
s += '<line x1="'+dimX.toFixed(1)+'" y1="'+cY.toFixed(1)+'" x2="'+dimX.toFixed(1)+'" y2="'+(cY+cH).toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height+' m</text>';
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height_m+' m</text>';
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(cY-8).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#666" font-style="italic">'+aL+' rows deep (into page)</text>';
s += '<text x="'+(cX+cW-4).toFixed(1)+'" y="'+(cY+15).toFixed(1)+'" text-anchor="end" font-size="10" fill="#555" font-style="italic">'+aW+' wide × '+layers+' high × '+aL+' deep · '+total+' rolls</text>';
+37
View File
@@ -2,6 +2,7 @@ import base64
import os
import tempfile
import unittest
from unittest.mock import patch
import app as rollcalc_app
@@ -62,6 +63,42 @@ class CalculationApiTests(unittest.TestCase):
self.assertEqual(result["state"]["request"]["roll_length_m"], 80.0)
self.assertEqual(result["calculation"]["effective_roll_length_m"], 80.0)
def test_transport_endpoint_delegates_to_shared_domain_service(self):
payload = {
"length_m": 10,
"width_m": 5,
"height_m": 5,
"max_weight_kg": 10000,
"roll_diameter_mm": 1000,
"core_diameter_mm": 150,
"roll_width_m": 1,
"roll_weight_kg": 1000,
}
with patch.object(
rollcalc_app,
"analyze_transport",
return_value={"status": "success", "analysis": {"final_rolls": 10}},
) as analyze:
response = self.client.post(
"/api/calculations/transport",
json=payload,
headers=self.headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()["analysis"]["final_rolls"], 10)
analyze.assert_called_once_with(payload)
def test_transport_endpoint_returns_structured_domain_validation(self):
response = self.client.post(
"/api/calculations/transport",
json={"length_m": 0},
headers=self.headers,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()["status"], "invalid_parameter")
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -8,6 +8,7 @@ from roll_calculation import (
CalculationState,
_canonical_name,
calculate_roll,
get_article,
modify_calculation,
)
@@ -90,6 +91,35 @@ class RollCalculationTests(unittest.TestCase):
def calculate_fixture(self, payload):
return calculate_roll(payload, article_repository=self.repository)
def test_public_get_article_uses_repository_resolution_and_projection(self):
result = get_article("00123", article_repository=self.repository)
self.assertEqual(result["status"], "resolved")
self.assertEqual(result["article"]["number"], "00123")
self.assertEqual(result["article"]["width_m"], 4.9)
self.assertEqual(result["article"]["area_weight_g_m2"], 1000.0)
def test_public_get_article_preserves_not_found_ambiguous_and_conflict_results(self):
self.assertEqual(
get_article("does-not-exist", article_repository=self.repository)["status"],
"article_not_found",
)
self.assertEqual(
get_article(
article_name_hint="Duplicated Product",
article_repository=self.repository,
)["status"],
"article_ambiguous",
)
self.assertEqual(
get_article(
"00123",
"Other product",
article_repository=self.repository,
)["status"],
"article_conflict",
)
def test_headless_matches_representative_existing_ui_values(self):
result = calculate_roll(representative_request())
calculation = result["calculation"]
+6
View File
@@ -39,6 +39,12 @@ class TemplatePdfIntegrationTests(unittest.TestCase):
self.assertIn("const warning = result.warnings?.[0]", self.template)
self.assertIn("const note = result.notes?.[0]", self.template)
def test_transport_ui_uses_shared_api_without_a_browser_capacity_calculator(self):
self.assertIn("fetch('/api/calculations/transport'", self.template)
self.assertIn("this.showResults(payload.analysis)", self.template)
self.assertNotIn("calculateLoadUtilization", self.template)
self.assertNotIn("class LoadOptimizer {", self.template)
if __name__ == "__main__":
unittest.main()
+152
View File
@@ -0,0 +1,152 @@
import math
import unittest
from transport_calculation import analyze_transport, transport_presets
def request(**changes):
payload = {
"length_m": 10.0,
"width_m": 5.0,
"height_m": 5.0,
"max_weight_kg": 10_000.0,
"margin_side_m": 0.0,
"margin_ceiling_m": 0.0,
"roll_diameter_mm": 1000.0,
"core_diameter_mm": 150.0,
"roll_width_m": 1.0,
"roll_weight_kg": 1000.0,
"product_length_m": 10.0,
}
payload.update(changes)
return payload
class TransportCalculationTests(unittest.TestCase):
def analysis(self, **changes):
result = analyze_transport(request(**changes))
self.assertEqual(result["status"], "success")
return result["analysis"]
def test_active_presets_match_golden_browser_results(self):
expected = {
"container_20ft": (8, "Geometry", [21, 19, 8], (2, 2, 2)),
"container_40ft": (24, "Geometry", [30, 40, 24], (6, 2, 2)),
"container_40ft_hc": (24, "Geometry", [30, 46, 24], (6, 2, 2)),
"lkw_sattelzug": (24, "Weight", [24, 54, 24], (6, 2, 2)),
"lkw_tandem": (24, "Geometry", [25, 54, 24], (6, 2, 2)),
}
for preset in transport_presets():
with self.subTest(preset=preset.key):
result = analyze_transport({
"transport_preset": preset.key,
"margin_side_m": 0.0,
"margin_ceiling_m": 0.2,
"roll_diameter_mm": 1000.0,
"core_diameter_mm": 150.0,
"roll_width_m": 2.0,
"roll_weight_kg": 1000.0,
"product_length_m": 50.0,
})
analysis = result["analysis"]
rolls, limiting, scenarios, layout = expected[preset.key]
self.assertEqual(analysis["final_rolls"], rolls)
self.assertEqual(analysis["limiting"]["name"], limiting)
self.assertEqual(
[scenario["rolls"] for scenario in analysis["scenarios"]],
scenarios,
)
self.assertEqual(
(
analysis["layout"]["rolls_along_length"],
analysis["layout"]["rolls_along_width"],
analysis["layout"]["max_stack_height"],
),
layout,
)
def test_custom_weight_governing_case_matches_browser_semantics(self):
analysis = self.analysis()
self.assertEqual(analysis["limiting"]["name"], "Weight")
self.assertEqual(analysis["final_rolls"], 10)
self.assertEqual(analysis["remaining_payload_kg"], 0.0)
self.assertEqual(analysis["final_m2"], 100.0)
def test_geometry_governing_and_impossible_geometry_are_preserved(self):
geometry = self.analysis(max_weight_kg=1_000_000.0)
self.assertEqual(geometry["limiting"]["name"], "Geometry")
self.assertEqual(geometry["final_rolls"], 250)
impossible = self.analysis(
length_m=2.0,
width_m=2.0,
height_m=2.0,
roll_diameter_mm=3000.0,
max_weight_kg=100_000.0,
)
self.assertEqual(impossible["limiting"]["name"], "Geometry")
self.assertEqual(impossible["final_rolls"], 0)
self.assertEqual(impossible["layout"]["rolls_along_width"], 0)
def test_volume_governing_case_preserves_unvalidated_browser_sign_behavior(self):
# Browser getRollData() rejects zero but not a negative diameter. This
# non-physical case characterizes its otherwise reachable volume limit.
analysis = self.analysis(
roll_diameter_mm=-2400.0,
max_weight_kg=100_000.0,
)
self.assertEqual(analysis["limiting"]["name"], "Volume")
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [100, 55, 90])
def test_weight_volume_geometry_tie_order_is_weight_volume_geometry(self):
analysis = self.analysis(max_weight_kg=250_000.0)
self.assertEqual([s["rolls"] for s in analysis["scenarios"]], [250, 325, 250])
self.assertEqual(analysis["limiting"]["name"], "Weight")
def test_floor_and_clearance_behavior_matches_browser_math_floor(self):
below_boundary = self.analysis(max_weight_kg=999.9)
at_boundary = self.analysis(max_weight_kg=1000.0)
self.assertEqual(below_boundary["final_rolls"], 0)
self.assertEqual(at_boundary["final_rolls"], 1)
clearance = self.analysis(margin_side_m=0.5, margin_ceiling_m=1.0)
self.assertEqual(clearance["layout"]["effective_width_m"], 4.5)
self.assertEqual(clearance["layout"]["effective_height_m"], 4.0)
self.assertEqual(clearance["layout"]["rolls_along_width"], 4)
self.assertEqual(clearance["layout"]["max_stack_height"], 4)
def test_displayed_one_decimal_roll_weight_is_consumed_without_recalculation(self):
# The direct UI writes d-wo using toFixed(1), then transport reads it.
displayed = self.analysis(roll_weight_kg=1000.0)
unrounded = self.analysis(roll_weight_kg=1000.04)
self.assertEqual(displayed["final_rolls"], 10)
self.assertEqual(unrounded["final_rolls"], 9)
def test_existing_ui_validation_boundary_is_structured_for_the_api(self):
for field, value in (("length_m", 0), ("width_m", -1), ("max_weight_kg", 0)):
with self.subTest(field=field):
result = analyze_transport(request(**{field: value}))
self.assertEqual(result["status"], "invalid_parameter")
self.assertEqual(result["invalid"][0]["field"], field)
result = analyze_transport(request(roll_weight_kg=0))
self.assertEqual(result["status"], "invalid_parameter")
self.assertEqual(result["invalid"][0]["field"], "roll_weight_kg")
def test_missing_product_length_preserves_browser_zero_square_meter_behavior(self):
payload = request()
del payload["product_length_m"]
result = analyze_transport(payload)
self.assertEqual(result["status"], "success")
self.assertEqual(result["analysis"]["final_m2"], 0.0)
def test_volume_and_utilization_values_are_golden(self):
analysis = self.analysis()
self.assertAlmostEqual(analysis["volume_per_roll_m3"], 0.7677267047210057)
self.assertAlmostEqual(analysis["utilization_weight"], 100.0)
self.assertAlmostEqual(analysis["utilization_volume"], 3.0709068188840227)
if __name__ == "__main__":
unittest.main()
+232
View File
@@ -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}