From 0856629e77b324677653a1b61a99ae995fbc9e2e Mon Sep 17 00:00:00 2001 From: Martin Tazl Date: Sun, 6 Sep 2026 05:16:25 +0200 Subject: [PATCH] Add generic production context helpers --- README.md | 54 +++++++++++++- src/production_analytics/context/__init__.py | 62 ++++++++++++++++ tests/test_context.py | 75 ++++++++++++++++++++ 3 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 src/production_analytics/context/__init__.py create mode 100644 tests/test_context.py diff --git a/README.md b/README.md index 5bdfdf5..87db174 100644 --- a/README.md +++ b/README.md @@ -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 + ` x 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 diff --git a/src/production_analytics/context/__init__.py b/src/production_analytics/context/__init__.py new file mode 100644 index 0000000..d9f5971 --- /dev/null +++ b/src/production_analytics/context/__init__.py @@ -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"(?{_NUMBER_TOKEN})\s*x\s*" + rf"(?P{_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 ' x 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] diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..83c2afe --- /dev/null +++ b/tests/test_context.py @@ -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