Add generic production context helpers

This commit is contained in:
2026-09-06 05:16:25 +02:00
parent 9fea9cb6ba
commit 0856629e77
3 changed files with 188 additions and 3 deletions
+51 -3
View File
@@ -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 It represents the latest ERP feedback, and the ERP export is delayed relative to
live process data; the adapter makes no freshness inference. live process data; the adapter makes no freshness inference.
This milestone adds no kg/m² or material-efficiency calculation, ERP-to-ENLYZE Automated ERP tests use mocks and require no live connectivity.
order mapping, persistence, polling, CLI, or Grafana integration. 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 ## 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]
+75
View File
@@ -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