Add generic production context helpers
This commit is contained in:
@@ -249,9 +249,57 @@ semantics. `feedback_timestamp` is preserved exactly, including a naive timezone
|
||||
It represents the latest ERP feedback, and the ERP export is delayed relative to
|
||||
live process data; the adapter makes no freshness inference.
|
||||
|
||||
This milestone adds no kg/m² or material-efficiency calculation, ERP-to-ENLYZE
|
||||
order mapping, persistence, polling, CLI, or Grafana integration. Automated ERP
|
||||
tests use mocks and require no live connectivity.
|
||||
Automated ERP tests use mocks and require no live connectivity.
|
||||
|
||||
## ERP context normalization
|
||||
|
||||
Two pure helpers in `production_analytics.context` prepare context for later KPIs:
|
||||
|
||||
- `build_enlyze_production_order(erp_production_order, format_template) -> str`
|
||||
uses an explicit template configured by the caller per machine/workplace.
|
||||
The template requires exactly one literal `{production_order}` placeholder and
|
||||
no other braces; invalid templates raise `ValueError`.
|
||||
Outer ERP-order whitespace is stripped and the
|
||||
remaining order must contain only ASCII digits. Leading zeros are preserved.
|
||||
Empty/invalid orders raise `ValueError`. There is no default machine rule.
|
||||
- `extract_nominal_width_m(article_description: str | None) -> float | None`
|
||||
is generic across machines and conservatively reads a single
|
||||
`<width> x <length> m` pair, accepting decimal
|
||||
comma/point and variable whitespace. For example, `Stex R 1501 C (PR) 5,80 x 50 m`
|
||||
yields `5.8`. Earlier article numbers and trailing descriptive text are ignored.
|
||||
Missing, malformed, multiple or chained dimension patterns return `None`, as do
|
||||
non-positive/non-finite dimensions. Signed/scientific notation and other units
|
||||
are deliberately unsupported; both dimensions must be positive plain numbers.
|
||||
|
||||
K7 currently uses the explicit template `K 7-{production_order}`:
|
||||
|
||||
```python
|
||||
k7_order_template = "K 7-{production_order}"
|
||||
enlyze_order = build_enlyze_production_order("12026000815", k7_order_template)
|
||||
# "K 7-12026000815"
|
||||
```
|
||||
|
||||
Future machines may supply different verified templates without changing the
|
||||
generic helper. Template selection belongs to machine/workplace configuration
|
||||
or adapters; the helper contains no workplace lookup or machine-specific branch.
|
||||
No other machine rule is introduced here.
|
||||
|
||||
ENLYZE production-order identifiers remain opaque everywhere else, with exact
|
||||
comparison. These helpers never reverse-parse or split ENLYZE orders, including
|
||||
combined identifiers such as `K 7-12025000074-K 7-12025000075`; passing such an
|
||||
identifier to the ERP mapping function is rejected.
|
||||
|
||||
Nominal finished-product width currently comes from ERP article-description
|
||||
parsing because neither verified DWH view (`dbo.GRAFANA_WORKPLACE_STATUS` and
|
||||
`dbo.GRAFANA_PRODUCTION_CONFIRMATION`) has a dedicated width column. ENLYZE
|
||||
`wLg1MeasuringWidth` (`Produktbreite`) is explicitly not used as K7 nominal
|
||||
finished-product width:
|
||||
it belongs to the MAHLO measurement system and observed values differ from nominal
|
||||
article widths. Structured ERP product master data would be preferred when available.
|
||||
|
||||
These helpers are independent of database access and are not yet wired into live
|
||||
processing. No normalized context model, kg/m² or material-efficiency calculation,
|
||||
persistence, polling, runner, timeseries, or Grafana changes are introduced here.
|
||||
|
||||
## Peak-cycle detection
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Deterministic ERP context helpers, independent of database and timeseries access."""
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
__all__ = ["build_enlyze_production_order", "extract_nominal_width_m"]
|
||||
|
||||
# Capture malformed numeric tokens too, so a valid-looking suffix cannot become width.
|
||||
_NUMBER_TOKEN = r"[+-]?(?:[0-9]+(?:[.,][0-9]+)*|inf(?:inity)?|nan)(?:[eE][+-]?[0-9]+)?"
|
||||
_DIMENSIONS = re.compile(
|
||||
rf"(?<![\w.,+\-/])(?P<width>{_NUMBER_TOKEN})\s*x\s*"
|
||||
rf"(?P<length>{_NUMBER_TOKEN})\s*m(?![\w/²³^])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PLAIN_NUMBER = re.compile(r"[0-9]+(?:[.,][0-9]+)?")
|
||||
|
||||
|
||||
def build_enlyze_production_order(erp_production_order: str, format_template: str) -> str:
|
||||
"""Format a single ERP order using explicit caller-supplied configuration.
|
||||
|
||||
The template must contain exactly one literal {production_order} placeholder
|
||||
and no other braces. Outer order whitespace is stripped; the order
|
||||
must otherwise contain ASCII digits only (leading zeros are preserved).
|
||||
This is not a parser for ENLYZE identifiers, combined or otherwise.
|
||||
"""
|
||||
order = erp_production_order.strip()
|
||||
if not order or not order.isascii() or not order.isdigit():
|
||||
raise ValueError("ERP production order must be a non-empty ASCII digit string")
|
||||
placeholder = "{production_order}"
|
||||
literal = format_template.replace(placeholder, "")
|
||||
if format_template.count(placeholder) != 1 or "{" in literal or "}" in literal:
|
||||
raise ValueError("Format template must contain exactly one {production_order} placeholder")
|
||||
return format_template.replace(placeholder, order)
|
||||
|
||||
|
||||
def extract_nominal_width_m(article_description: str | None) -> float | None:
|
||||
"""Read one unambiguous positive '<width> x <length> m' pair from ERP text.
|
||||
|
||||
Decimal comma/point and variable whitespace are supported. Multiple pairs,
|
||||
dimension chains, signed/scientific notation and invalid dimensions fail
|
||||
closed.
|
||||
"""
|
||||
if article_description is None:
|
||||
return None
|
||||
matches = list(_DIMENSIONS.finditer(article_description))
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
match = matches[0]
|
||||
# Do not mistake the tail of a three-dimensional expression for a width pair.
|
||||
if re.search(r"[x×]\s*$", article_description[:match.start()], re.IGNORECASE):
|
||||
return None
|
||||
if re.match(r"\s*[x×]", article_description[match.end():], re.IGNORECASE):
|
||||
return None
|
||||
values = []
|
||||
for token in (match["width"], match["length"]):
|
||||
if _PLAIN_NUMBER.fullmatch(token) is None:
|
||||
return None
|
||||
value = float(token.replace(",", "."))
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return None
|
||||
values.append(value)
|
||||
return values[0]
|
||||
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from production_analytics.context import (
|
||||
build_enlyze_production_order,
|
||||
extract_nominal_width_m,
|
||||
)
|
||||
|
||||
K7_ORDER_TEMPLATE = "K 7-{production_order}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order, expected", [
|
||||
("12026000815", "K 7-12026000815"),
|
||||
(" \t12026000815\n", "K 7-12026000815"),
|
||||
("00123", "K 7-00123"),
|
||||
])
|
||||
def test_k7_mapping(order, expected):
|
||||
assert build_enlyze_production_order(order, K7_ORDER_TEMPLATE) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", [
|
||||
"", " \t", "K 7-12026000815", "K 7-12025000074-K 7-12025000075",
|
||||
"12025000074-12025000075", "prefix12026000815", "12026000815suffix",
|
||||
"12026 000815", "123", "-123",
|
||||
])
|
||||
def test_mapping_rejects_non_erp_identifiers(order):
|
||||
with pytest.raises(ValueError, match="ERP production order"):
|
||||
build_enlyze_production_order(order, K7_ORDER_TEMPLATE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template, expected", [
|
||||
("EXAMPLE/{production_order}/finished", "EXAMPLE/00123/finished"),
|
||||
("{production_order}", "00123"),
|
||||
("{production_order}-example", "00123-example"),
|
||||
])
|
||||
def test_mapping_uses_explicit_template(template, expected):
|
||||
assert build_enlyze_production_order(" 00123 ", template) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template", [
|
||||
"", "fixed-order", "{order}", "{production_order}-{production_order}",
|
||||
"{production_order:>20}", "{production_order!r}", "{production_order}{unknown}",
|
||||
"{{production_order}}",
|
||||
])
|
||||
def test_mapping_rejects_invalid_template(template):
|
||||
with pytest.raises(ValueError, match="Format template"):
|
||||
build_enlyze_production_order("12026000815", template)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("description, expected", [
|
||||
("Stex R 1501 C (PR) 5,80 x 50 m", 5.8),
|
||||
("Stex R 401, 6,00 x 90 m", 6.0),
|
||||
("Stex R 1501 5.80 x 50 m", 5.8),
|
||||
("5,80x50 m", 5.8),
|
||||
("5,80x50m", 5.8),
|
||||
("Article 212520 grade 1501 5,80\t x\t50 m", 5.8),
|
||||
("5,80 x 50 m coated (batch 42)", 5.8),
|
||||
("(5,80 x 50 m), coated", 5.8),
|
||||
("6 x 90 m", 6.0),
|
||||
])
|
||||
def test_width(description, expected):
|
||||
assert extract_nominal_width_m(description) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("description", [
|
||||
None, "", "Stex R 1501", "5,80 m", "5,80 x 50", "5,80 x 50 cm",
|
||||
"5,80 x 50 mm", "5,80 x 50 m²", "5,80 x 50 m2", "5,80 x 50 m/min",
|
||||
"5,80 x 50 metres", "5,80,2 x 50 m", "5.80.2 x 50 m",
|
||||
"5,80 x ? m", "5,80 x 50 m or 6,00 x 90 m", "2 x 5,80 x 50 m",
|
||||
"5,80 x 50 m x 2", "0 x 50 m", "-5,80 x 50 m", "+5,80 x 50 m",
|
||||
"nan x 50 m", "inf x 50 m", "Infinity x 50 m", "1e309 x 50 m",
|
||||
"9" * 400 + " x 50 m", "5,80 x 0 m", "5,80 x -50 m", "5,80 x nan m",
|
||||
"1/5,80 x 50 m", "abc5,80 x 50 m",
|
||||
])
|
||||
def test_width_fails_safely(description):
|
||||
assert extract_nominal_width_m(description) is None
|
||||
Reference in New Issue
Block a user