748 lines
26 KiB
Python
748 lines
26 KiB
Python
"""Deterministic headless domain service for direct roll-diameter calculations."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import asdict, dataclass
|
||
from difflib import SequenceMatcher
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
import re
|
||
from typing import Any
|
||
|
||
from core_presets import CORE_PRESETS, core_family, matching_core_presets
|
||
|
||
|
||
ARTICLE_DATA_FILE = Path(__file__).parent / "static" / "article-data.json"
|
||
FUZZY_CANDIDATE_THRESHOLD = 0.72
|
||
FUZZY_STRONG_THRESHOLD = 0.92
|
||
FUZZY_STRONG_MARGIN = 0.08
|
||
PRODUCT_FAMILY_ALIASES = {
|
||
"stex": "stex",
|
||
"secutex": "stex",
|
||
"bfix": "bfix",
|
||
"bentofix": "bfix",
|
||
"sgrid": "sgrid",
|
||
"secugrid": "sgrid",
|
||
}
|
||
|
||
REQUEST_FIELDS = {
|
||
"intent",
|
||
"article_number",
|
||
"article_name_hint",
|
||
"roll_length_m",
|
||
"width_m",
|
||
"thickness_mm",
|
||
"thickness_stddev_mm",
|
||
"area_weight_g_m2",
|
||
"core_diameter_mm",
|
||
"core_type",
|
||
"category",
|
||
"production_site",
|
||
"include_roll_weight",
|
||
}
|
||
|
||
FORBIDDEN_RESULT_FIELDS = {
|
||
"diameter_mm",
|
||
"diameters",
|
||
"minimum_diameter_mm",
|
||
"average_diameter_mm",
|
||
"maximum_diameter_mm",
|
||
"roll_weight_kg",
|
||
"warnings",
|
||
"calculation",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CalculationRequest:
|
||
"""Raw decision inputs. It deliberately has no engineering result fields."""
|
||
|
||
article_number: str | None = None
|
||
article_name_hint: str | None = None
|
||
roll_length_m: float | None = None
|
||
width_m: float | None = None
|
||
thickness_mm: float | None = None
|
||
thickness_stddev_mm: float | None = None
|
||
area_weight_g_m2: float | None = None
|
||
core_diameter_mm: float | None = None
|
||
core_type: str | None = None
|
||
category: str | None = None
|
||
production_site: str | None = None
|
||
include_roll_weight: bool = False
|
||
intent: str = "calculate_roll_diameter"
|
||
|
||
@classmethod
|
||
def from_dict(cls, payload: Any) -> "CalculationRequest":
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("request must be a JSON object")
|
||
forbidden = sorted(FORBIDDEN_RESULT_FIELDS.intersection(payload))
|
||
if forbidden:
|
||
raise ValueError(
|
||
"calculated result fields are not accepted: " + ", ".join(forbidden)
|
||
)
|
||
unknown = sorted(set(payload) - REQUEST_FIELDS)
|
||
if unknown:
|
||
raise ValueError("unknown request fields: " + ", ".join(unknown))
|
||
if payload.get("intent", "calculate_roll_diameter") != "calculate_roll_diameter":
|
||
raise ValueError("unsupported intent")
|
||
|
||
values = dict(payload)
|
||
if "article_number" in values and values["article_number"] is not None:
|
||
number = values["article_number"]
|
||
if not isinstance(number, str):
|
||
raise ValueError("article_number must be text")
|
||
values["article_number"] = number.strip() or None
|
||
|
||
for field in ("article_name_hint", "core_type", "category", "production_site"):
|
||
if field in values and values[field] is not None:
|
||
if not isinstance(values[field], str):
|
||
raise ValueError(f"{field} must be text")
|
||
values[field] = values[field].strip() or None
|
||
|
||
numeric_fields = (
|
||
"roll_length_m",
|
||
"width_m",
|
||
"thickness_mm",
|
||
"thickness_stddev_mm",
|
||
"area_weight_g_m2",
|
||
"core_diameter_mm",
|
||
)
|
||
for field in numeric_fields:
|
||
if field in values and values[field] is not None:
|
||
value = values[field]
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
raise ValueError(f"{field} must be a number")
|
||
values[field] = float(value)
|
||
|
||
if "include_roll_weight" in values and not isinstance(
|
||
values["include_roll_weight"], bool
|
||
):
|
||
raise ValueError("include_roll_weight must be boolean")
|
||
return cls(**values)
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return asdict(self)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CalculationState:
|
||
"""Structured conversational state that can be modified field by field."""
|
||
|
||
request: CalculationRequest
|
||
|
||
@classmethod
|
||
def from_dict(cls, payload: Any) -> "CalculationState":
|
||
if not isinstance(payload, dict) or "request" not in payload:
|
||
raise ValueError("state.request is required")
|
||
unknown = sorted(set(payload) - {"request"})
|
||
if unknown:
|
||
raise ValueError("unknown state fields: " + ", ".join(unknown))
|
||
return cls(request=CalculationRequest.from_dict(payload["request"]))
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return {"request": self.request.to_dict()}
|
||
|
||
def apply(self, changes: dict[str, Any]) -> "CalculationState":
|
||
if not isinstance(changes, dict) or not changes:
|
||
raise ValueError("changes must be a non-empty object")
|
||
merged = self.request.to_dict()
|
||
if "article_number" in changes and "article_name_hint" not in changes:
|
||
merged["article_name_hint"] = None
|
||
if "core_type" in changes and "core_diameter_mm" not in changes:
|
||
core_type = changes["core_type"]
|
||
presets = (
|
||
matching_core_presets(core_type)
|
||
if isinstance(core_type, str)
|
||
else ()
|
||
)
|
||
if len(presets) == 1:
|
||
changes = dict(changes)
|
||
changes["core_type"] = presets[0].label
|
||
changes["core_diameter_mm"] = presets[0].diameter_mm
|
||
else:
|
||
merged["core_diameter_mm"] = None
|
||
if "core_diameter_mm" in changes and "core_type" not in changes:
|
||
merged["core_type"] = None
|
||
merged.update(changes)
|
||
return CalculationState(CalculationRequest.from_dict(merged))
|
||
|
||
|
||
class ArticleRepository:
|
||
def __init__(self, articles: list[dict[str, Any]]):
|
||
self.articles = articles
|
||
self.by_number: dict[str, list[dict[str, Any]]] = {}
|
||
for article in articles:
|
||
number = str(article.get("nr", ""))
|
||
self.by_number.setdefault(number, []).append(article)
|
||
|
||
@classmethod
|
||
def load(cls, path: Path = ARTICLE_DATA_FILE) -> "ArticleRepository":
|
||
with path.open(encoding="utf-8") as handle:
|
||
articles = json.load(handle)
|
||
if not isinstance(articles, list):
|
||
raise ValueError("article data must contain a JSON array")
|
||
return cls(articles)
|
||
|
||
def resolve(
|
||
self, article_number: str | None, article_name_hint: str | None
|
||
) -> dict[str, Any]:
|
||
if article_number:
|
||
matches = self.by_number.get(article_number, [])
|
||
if not matches:
|
||
return {
|
||
"status": "article_not_found",
|
||
"article_number": article_number,
|
||
}
|
||
if len(matches) > 1:
|
||
candidates = [_article_candidate(item) for item in matches]
|
||
return {
|
||
"status": "article_ambiguous",
|
||
"field": "article",
|
||
"reason": "ambiguous_article",
|
||
"article_number": article_number,
|
||
"candidates": candidates,
|
||
"matches": [
|
||
{
|
||
"number": candidate["article_number"],
|
||
"name": candidate["name"],
|
||
}
|
||
for candidate in candidates
|
||
],
|
||
}
|
||
article = matches[0]
|
||
if article_name_hint and not _name_hint_matches(
|
||
article_name_hint, str(article.get("name", ""))
|
||
):
|
||
return {
|
||
"status": "article_conflict",
|
||
"article_number": article_number,
|
||
"article_name_hint": article_name_hint,
|
||
"resolved_name": article.get("name", ""),
|
||
}
|
||
return {"status": "resolved", "article": _public_article(article)}
|
||
|
||
if article_name_hint:
|
||
matches = self._matching_names(article_name_hint)
|
||
if len(matches) == 1:
|
||
return {"status": "resolved", "article": _public_article(matches[0])}
|
||
if len(matches) > 1:
|
||
return self._ambiguous_name_resolution(article_name_hint, matches)
|
||
|
||
fuzzy_matches = self._fuzzy_candidates(article_name_hint)
|
||
if fuzzy_matches:
|
||
best_score, best_article = fuzzy_matches[0]
|
||
second_score = fuzzy_matches[1][0] if len(fuzzy_matches) > 1 else 0.0
|
||
if (
|
||
best_score >= FUZZY_STRONG_THRESHOLD
|
||
and best_score - second_score >= FUZZY_STRONG_MARGIN
|
||
):
|
||
return {"status": "resolved", "article": _public_article(best_article)}
|
||
return self._ambiguous_name_resolution(
|
||
article_name_hint,
|
||
[article for _, article in fuzzy_matches],
|
||
reason="uncertain_article_match",
|
||
)
|
||
return {
|
||
"status": "article_not_found",
|
||
"article_name_hint": article_name_hint,
|
||
}
|
||
|
||
return {"status": "not_requested", "article": None}
|
||
|
||
def _matching_names(self, article_name_hint: str) -> list[dict[str, Any]]:
|
||
hint_keys = _article_name_keys(article_name_hint)
|
||
if not hint_keys:
|
||
return []
|
||
return [
|
||
article
|
||
for article in self.articles
|
||
if hint_keys & _article_name_keys(str(article.get("name", "")))
|
||
]
|
||
|
||
def _fuzzy_candidates(
|
||
self, article_name_hint: str
|
||
) -> list[tuple[float, dict[str, Any]]]:
|
||
hint = _base_article_name(article_name_hint)
|
||
hint_tokens = set(hint.split())
|
||
if len(hint_tokens) < 2:
|
||
return []
|
||
scored = []
|
||
for article in self.articles:
|
||
candidate = _base_article_name(str(article.get("name", "")))
|
||
candidate_tokens = set(candidate.split())
|
||
if not _model_designators_match(hint_tokens, candidate_tokens):
|
||
continue
|
||
overlap = len(hint_tokens & candidate_tokens) / len(hint_tokens)
|
||
similarity = SequenceMatcher(
|
||
None,
|
||
hint.replace(" ", ""),
|
||
candidate.replace(" ", ""),
|
||
).ratio()
|
||
score = (0.45 * overlap) + (0.55 * similarity)
|
||
if score >= FUZZY_CANDIDATE_THRESHOLD:
|
||
scored.append((score, article))
|
||
return sorted(scored, key=lambda item: (-item[0], str(item[1].get("nr", ""))))[:5]
|
||
|
||
@staticmethod
|
||
def _ambiguous_name_resolution(
|
||
article_name_hint: str,
|
||
matches: list[dict[str, Any]],
|
||
*,
|
||
reason: str = "ambiguous_article",
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"status": "article_ambiguous",
|
||
"field": "article",
|
||
"reason": reason,
|
||
"article_name_hint": article_name_hint,
|
||
"candidates": [_article_candidate(item) for item in matches],
|
||
"matches": [str(item.get("nr", "")) for item in matches],
|
||
}
|
||
|
||
|
||
def _canonical_name(value: str) -> str:
|
||
value = value.casefold().replace("×", " x ")
|
||
tokens = re.findall(r"\d+(?:[.,]\d+)?|[^\W\d_]+", value)
|
||
normalized = [
|
||
PRODUCT_FAMILY_ALIASES.get(
|
||
token.replace(",", "."), token.replace(",", ".")
|
||
)
|
||
for token in tokens
|
||
]
|
||
compacted = []
|
||
index = 0
|
||
while index < len(normalized):
|
||
token = normalized[index]
|
||
if (
|
||
len(token) == 1
|
||
and token.isalpha()
|
||
and index + 1 < len(normalized)
|
||
and normalized[index + 1].isdigit()
|
||
):
|
||
compacted.append(token + normalized[index + 1])
|
||
index += 2
|
||
continue
|
||
compacted.append(token)
|
||
index += 1
|
||
return " ".join(compacted)
|
||
|
||
|
||
def _article_name_keys(value: str) -> set[str]:
|
||
keys = {_canonical_name(value)}
|
||
without_dimensions = re.sub(
|
||
r"\s*,?\s*\d{1,2}[,.]\d+\s*[x×]\s*\d+(?:[,.]\d+)?\s*m\s*$",
|
||
"",
|
||
value,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
keys.add(_canonical_name(without_dimensions))
|
||
without_descriptors = re.sub(r"\([^)]*\)", " ", without_dimensions)
|
||
keys.add(_canonical_name(without_descriptors))
|
||
return {key for key in keys if key}
|
||
|
||
|
||
def _base_article_name(value: str) -> str:
|
||
return min(_article_name_keys(value), key=len, default="")
|
||
|
||
|
||
def _model_designators_match(
|
||
hint_tokens: set[str], candidate_tokens: set[str]
|
||
) -> bool:
|
||
models = {token for token in hint_tokens if re.fullmatch(r"[a-z]\d+", token)}
|
||
return not models or models <= candidate_tokens
|
||
|
||
|
||
def _name_hint_matches(hint: str, resolved_name: str) -> bool:
|
||
hint_tokens = _canonical_name(hint).split()
|
||
resolved_tokens = _canonical_name(resolved_name).split()
|
||
if not hint_tokens:
|
||
return True
|
||
return resolved_tokens[: len(hint_tokens)] == hint_tokens
|
||
|
||
|
||
def _article_candidate(article: dict[str, Any]) -> dict[str, str]:
|
||
return {
|
||
"article_number": str(article.get("nr", "")),
|
||
"name": str(article.get("name", "")),
|
||
}
|
||
|
||
|
||
def _article_width(name: str) -> float | None:
|
||
patterns = (
|
||
r"(?<!\d)(\d{1,2}[,.]\d{1,3})\s*[x×]\s*<?\s*\d+(?:[,.]\d+)?\s*m\b",
|
||
r"(?<!\d)(\d{1,2}[,.]\d{1,3})\s*m\s*breite\b",
|
||
)
|
||
widths = {
|
||
float(match.replace(",", "."))
|
||
for pattern in patterns
|
||
for match in re.findall(pattern, name, flags=re.IGNORECASE)
|
||
}
|
||
valid = {width for width in widths if 0 < width < 100}
|
||
return next(iter(valid)) if len(valid) == 1 else None
|
||
|
||
|
||
def _public_article(article: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"number": str(article.get("nr", "")),
|
||
"name": str(article.get("name", "")),
|
||
"thickness_mm": article.get("thickness"),
|
||
"thickness_stddev_mm": article.get("thickness_stddev"),
|
||
"area_weight_g_m2": article.get("area_weight"),
|
||
"core_diameter_mm": article.get("core_type"),
|
||
"width_m": _article_width(str(article.get("name", ""))),
|
||
}
|
||
|
||
|
||
def _issue_result(
|
||
status: str,
|
||
request: CalculationRequest,
|
||
*,
|
||
missing: list[str] | None = None,
|
||
ambiguous: list[str] | None = None,
|
||
invalid: list[dict[str, str]] | None = None,
|
||
article_resolution: dict[str, Any] | None = None,
|
||
) -> dict[str, Any]:
|
||
result = {
|
||
"status": status,
|
||
"request": request.to_dict(),
|
||
"state": {"request": request.to_dict()},
|
||
"missing": missing or [],
|
||
"ambiguous": ambiguous or [],
|
||
"invalid": invalid or [],
|
||
}
|
||
if article_resolution is not None:
|
||
result["article_resolution"] = article_resolution
|
||
return result
|
||
|
||
|
||
def _resolve_core(
|
||
diameter: float | None, core_type: str | None
|
||
) -> tuple[float | None, str | None, dict[str, Any] | None]:
|
||
if core_type:
|
||
material = core_family(core_type)
|
||
candidates = matching_core_presets(core_type)
|
||
if diameter is not None:
|
||
matching_diameter = [
|
||
preset
|
||
for preset in CORE_PRESETS
|
||
if math.isclose(preset.diameter_mm, diameter)
|
||
]
|
||
matching_candidates = [
|
||
preset
|
||
for preset in candidates
|
||
if math.isclose(preset.diameter_mm, diameter)
|
||
]
|
||
if len(matching_candidates) == 1:
|
||
preset = matching_candidates[0]
|
||
return preset.diameter_mm, preset.label, None
|
||
if candidates:
|
||
return None, None, {
|
||
"kind": "invalid",
|
||
"field": "core_type",
|
||
"message": "core type conflicts with core diameter",
|
||
}
|
||
if material and matching_diameter:
|
||
return None, None, {
|
||
"kind": "invalid",
|
||
"field": "core_type",
|
||
"message": "core type conflicts with core diameter",
|
||
}
|
||
normalized = re.sub(r"\s+", " ", core_type.casefold()).strip()
|
||
if normalized not in {"custom", "custom / not specified"}:
|
||
return None, None, {
|
||
"kind": "clarification",
|
||
"field": "core_type",
|
||
"message": "unknown core type; provide a known core or diameter",
|
||
"options": [preset.label for preset in CORE_PRESETS],
|
||
}
|
||
label = matching_diameter[0].label if matching_diameter else core_type
|
||
return diameter, label, None
|
||
if len(candidates) == 1:
|
||
return candidates[0].diameter_mm, candidates[0].label, None
|
||
return None, None, {
|
||
"kind": "clarification",
|
||
"field": "core_type",
|
||
"message": "core type does not identify one core diameter",
|
||
"options": [preset.label for preset in candidates or CORE_PRESETS],
|
||
}
|
||
|
||
if diameter is None:
|
||
return None, None, None
|
||
matching = [
|
||
preset
|
||
for preset in CORE_PRESETS
|
||
if math.isclose(preset.diameter_mm, diameter)
|
||
]
|
||
label = matching[0].label if matching else "Custom / not specified"
|
||
return diameter, label, None
|
||
|
||
|
||
def calculate_roll(
|
||
request: CalculationRequest | dict[str, Any],
|
||
*,
|
||
article_repository: ArticleRepository | None = None,
|
||
build_info: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Resolve, validate, and calculate one direct roll-diameter request."""
|
||
if not isinstance(request, CalculationRequest):
|
||
try:
|
||
request = CalculationRequest.from_dict(request)
|
||
except ValueError as error:
|
||
return {
|
||
"status": "invalid_parameter",
|
||
"missing": [],
|
||
"ambiguous": [],
|
||
"invalid": [{"field": "request", "message": str(error)}],
|
||
}
|
||
if not isinstance(request, CalculationRequest):
|
||
raise TypeError("request must be CalculationRequest or dict")
|
||
|
||
repository = article_repository or ArticleRepository.load()
|
||
resolution = repository.resolve(request.article_number, request.article_name_hint)
|
||
if resolution["status"] == "article_not_found":
|
||
return _issue_result(
|
||
"article_not_found", request, article_resolution=resolution
|
||
)
|
||
if resolution["status"] == "article_conflict":
|
||
return _issue_result(
|
||
"article_conflict", request, article_resolution=resolution
|
||
)
|
||
if resolution["status"] == "article_ambiguous":
|
||
ambiguous_field = (
|
||
"article_number"
|
||
if resolution.get("article_number")
|
||
else "article_name_hint"
|
||
)
|
||
result = _issue_result(
|
||
"needs_clarification",
|
||
request,
|
||
ambiguous=[ambiguous_field],
|
||
article_resolution=resolution,
|
||
)
|
||
result.update({
|
||
"field": "article",
|
||
"reason": "ambiguous_article",
|
||
"candidates": resolution.get("candidates", []),
|
||
})
|
||
return result
|
||
|
||
article = resolution.get("article")
|
||
effective: dict[str, dict[str, Any]] = {}
|
||
|
||
def choose(
|
||
field: str,
|
||
article_field: str | None = None,
|
||
*,
|
||
zero_is_missing: bool = True,
|
||
) -> Any:
|
||
value = getattr(request, field)
|
||
source = "user"
|
||
if value is None and article and article_field:
|
||
value = article.get(article_field)
|
||
if value == "" or (zero_is_missing and value in (0, 0.0)):
|
||
value = None
|
||
source = "article_master_data"
|
||
if value is not None:
|
||
effective[field] = {"value": value, "source": source}
|
||
return value
|
||
|
||
length = choose("roll_length_m")
|
||
thickness = choose("thickness_mm", "thickness_mm")
|
||
thickness_stddev = choose(
|
||
"thickness_stddev_mm",
|
||
"thickness_stddev_mm",
|
||
zero_is_missing=False,
|
||
)
|
||
if thickness_stddev is None:
|
||
thickness_stddev = 0.0
|
||
effective["thickness_stddev_mm"] = {"value": 0.0, "source": "not_available"}
|
||
width = choose("width_m", "width_m")
|
||
area_weight = choose("area_weight_g_m2", "area_weight_g_m2")
|
||
core_diameter = choose("core_diameter_mm", "core_diameter_mm")
|
||
core_diameter, core_label, core_issue = _resolve_core(
|
||
core_diameter, request.core_type
|
||
)
|
||
if core_issue and core_issue["kind"] == "clarification":
|
||
result = _issue_result(
|
||
"needs_clarification", request, ambiguous=[core_issue["field"]]
|
||
)
|
||
result["clarifications"] = [core_issue]
|
||
return result
|
||
if core_issue:
|
||
return _issue_result("invalid_parameter", request, invalid=[core_issue])
|
||
if core_diameter is not None:
|
||
source = effective.get("core_diameter_mm", {}).get("source", "core_type")
|
||
effective["core_diameter_mm"] = {"value": core_diameter, "source": source}
|
||
effective["core_type"] = {"value": core_label, "source": source}
|
||
|
||
missing = []
|
||
for field, value in (
|
||
("roll_length_m", length),
|
||
("thickness_mm", thickness),
|
||
("core_diameter_mm", core_diameter),
|
||
):
|
||
if value is None:
|
||
missing.append(field)
|
||
if request.include_roll_weight:
|
||
if width is None:
|
||
missing.append("width_m")
|
||
if area_weight is None:
|
||
missing.append("area_weight_g_m2")
|
||
if missing:
|
||
return _issue_result("needs_clarification", request, missing=missing)
|
||
|
||
invalid = []
|
||
positive_values = (
|
||
("roll_length_m", length),
|
||
("thickness_mm", thickness),
|
||
("core_diameter_mm", core_diameter),
|
||
)
|
||
if request.include_roll_weight:
|
||
positive_values += (("width_m", width), ("area_weight_g_m2", area_weight))
|
||
for field, value in positive_values:
|
||
if not math.isfinite(value) or value <= 0:
|
||
invalid.append({"field": field, "message": "must be greater than zero"})
|
||
if not math.isfinite(thickness_stddev) or thickness_stddev < 0:
|
||
invalid.append(
|
||
{"field": "thickness_stddev_mm", "message": "must be zero or greater"}
|
||
)
|
||
minimum_thickness = thickness - (2 * thickness_stddev)
|
||
if minimum_thickness <= 0:
|
||
invalid.append(
|
||
{
|
||
"field": "thickness_stddev_mm",
|
||
"message": "minus two standard deviations must remain positive",
|
||
}
|
||
)
|
||
if invalid:
|
||
return _issue_result("invalid_parameter", request, invalid=invalid)
|
||
|
||
try:
|
||
average = math.sqrt(
|
||
core_diameter**2 + (4 * length * 1000 * thickness) / math.pi
|
||
)
|
||
lower = math.sqrt(
|
||
core_diameter**2 + (4 * length * 1000 * minimum_thickness) / math.pi
|
||
)
|
||
upper = math.sqrt(
|
||
core_diameter**2
|
||
+ (4 * length * 1000 * (thickness + 2 * thickness_stddev))
|
||
/ math.pi
|
||
)
|
||
roll_weight = None
|
||
if request.include_roll_weight:
|
||
roll_weight = area_weight * length * width / 1000
|
||
except OverflowError:
|
||
return _issue_result(
|
||
"invalid_parameter",
|
||
request,
|
||
invalid=[
|
||
{
|
||
"field": "request",
|
||
"message": "values are outside the supported calculation range",
|
||
}
|
||
],
|
||
)
|
||
|
||
calculated_values = (average, lower, upper)
|
||
if roll_weight is not None:
|
||
calculated_values += (roll_weight,)
|
||
if not all(math.isfinite(value) and value > 0 for value in calculated_values):
|
||
return _issue_result(
|
||
"invalid_parameter",
|
||
request,
|
||
invalid=[
|
||
{
|
||
"field": "request",
|
||
"message": "values are outside the supported calculation range",
|
||
}
|
||
],
|
||
)
|
||
|
||
warnings: list[dict[str, str]] = []
|
||
notes: list[str] = []
|
||
if roll_weight is not None and (request.category or "").casefold() == "bentofix":
|
||
if roll_weight > 2750:
|
||
warnings.append(
|
||
{
|
||
"severity": "critical",
|
||
"title": "Weight Limit Exceeded",
|
||
"message": "Roll weight exceeds 2750 kg maximum",
|
||
}
|
||
)
|
||
elif roll_weight >= 1700:
|
||
core_message = (
|
||
"Core diameter OK"
|
||
if core_diameter >= 170
|
||
else f"Core diameter {core_diameter:g} mm (min 170 mm required)"
|
||
)
|
||
warnings.append(
|
||
{
|
||
"severity": "warning",
|
||
"title": "Heavy Roll – Special Equipment Required",
|
||
"message": (
|
||
"Special equipment required for rolls ≥ 1700 kg. "
|
||
f"{core_message}."
|
||
),
|
||
}
|
||
)
|
||
else:
|
||
notes.append(
|
||
f"Roll weight {roll_weight:.1f} kg is within standard limits. "
|
||
"No special equipment required."
|
||
)
|
||
|
||
calculation = {
|
||
"effective_roll_length_m": length,
|
||
"minimum_diameter_mm": min(lower, upper),
|
||
"average_diameter_mm": average,
|
||
"maximum_diameter_mm": max(lower, upper),
|
||
"roll_weight_kg": roll_weight,
|
||
}
|
||
return {
|
||
"status": "success",
|
||
"request": request.to_dict(),
|
||
"state": {"request": request.to_dict()},
|
||
"resolved_article": article,
|
||
"effective_inputs": effective,
|
||
"calculation": calculation,
|
||
"warnings": warnings,
|
||
"notes": notes,
|
||
"provenance": {
|
||
"calculator": "roll_calculation.calculate_roll",
|
||
"build": build_info or {},
|
||
},
|
||
}
|
||
|
||
|
||
def modify_calculation(
|
||
state: CalculationState | dict[str, Any],
|
||
changes: dict[str, Any],
|
||
*,
|
||
article_repository: ArticleRepository | None = None,
|
||
build_info: dict[str, str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Apply controlled changes to structured state and recalculate."""
|
||
try:
|
||
current = (
|
||
state
|
||
if isinstance(state, CalculationState)
|
||
else CalculationState.from_dict(state)
|
||
)
|
||
modified = current.apply(changes)
|
||
except ValueError as error:
|
||
return {
|
||
"status": "invalid_parameter",
|
||
"missing": [],
|
||
"ambiguous": [],
|
||
"invalid": [{"field": "changes", "message": str(error)}],
|
||
}
|
||
result = calculate_roll(
|
||
modified.request,
|
||
article_repository=article_repository,
|
||
build_info=build_info,
|
||
)
|
||
result["state"] = modified.to_dict()
|
||
return result
|