Use rotational discharge for Bento consumption

This commit is contained in:
2026-09-07 07:59:48 +02:00
parent be8d76507a
commit 0210ced9e3
10 changed files with 400 additions and 92 deletions
+55 -14
View File
@@ -80,6 +80,31 @@ def width_provider():
)
@pytest.mark.parametrize('configured,returned', [
('Bento 1', 'BENTO 1'),
('Bento 1', ' \tBENTO 1\n'),
(' \tBento 1\n', 'BENTO 1'),
('K7', ' k7 '),
])
def test_width_context_normalizes_workplace(configured, returned):
provider = width_provider()
provider.workplace = configured
provider.gateway.get_current_workplace_status.return_value.workplace = returned
assert provider('Bento 1-12026000814') == 5.0
@pytest.mark.parametrize('production_order', [
'Bento 1-12026000815',
'BENTO 1-12026000814',
' Bento 1-12026000814 ',
])
def test_width_context_keeps_production_order_strict(production_order):
provider = width_provider()
provider.gateway.get_current_workplace_status.return_value.workplace = 'BENTO 1'
with pytest.raises(ValueError, match='ERP context does not match the production order'):
provider(production_order)
@pytest.mark.parametrize('field,value', [
('production_order', '12026000815'), ('workplace', 'K7'),
('article_description', None), ('article_description', '180305'),
@@ -91,10 +116,11 @@ def test_width_context_fails_closed(field, value):
provider('Bento 1-12026000814')
def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
@pytest.mark.parametrize('inter_run_gap_seconds', [5, 3761])
def test_bento_rpm_bootstrap_and_persistent_resume(tmp_path, inter_run_gap_seconds):
"""Synthetic aggregate fixture, not recorded ENLYZE samples.
Uses reported active time/area/application within the actual run boundaries.
Uses realistic rpm values and synthetic active time within actual run boundaries.
"""
config = load_material_calculation('config/bento1-material-consumption.yaml')
order = 'Bento 1-12026000814'
@@ -106,7 +132,11 @@ def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
('25d67424-8346-42d4-b844-75b4097382bd',
'2026-08-26T05:43:39Z', '2026-08-26T05:54:05Z'),
]]
speed = 31294.6 / (5 * 1256)
runs[1] = replace(
runs[1], start=runs[0].end + timedelta(seconds=inter_run_gap_seconds),
end=runs[0].end + timedelta(seconds=inter_run_gap_seconds + 626),
)
speed = 2.0
client = Mock()
def response(path, body):
@@ -122,8 +152,8 @@ def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
if start <= stop <= end:
times.add(stop)
return SimpleNamespace(body={'data': {
'columns': ['time', *config.application_signal_refs, config.gate_signal_ref],
'records': [[t.isoformat(), 2300, 2398, speed if t < stop else 0]
'columns': ['time', *config.rotational_speed_signal_refs, config.gate_signal_ref],
'records': [[t.isoformat(), 3.739, 3.956, speed if t < stop else 0]
for t in sorted(times)],
}})
@@ -139,35 +169,44 @@ def test_bento_reference_aggregate_bootstrap_and_persistent_resume(tmp_path):
rate_variable_id=config.rate_signal_ref, gate_variable_id=config.gate_signal_ref,
gate_threshold=config.gate_threshold,
max_sample_gap_seconds=config.max_sample_gap_seconds,
application_variable_ids=config.application_signal_refs,
rotational_speed_variable_ids=config.rotational_speed_signal_refs,
specific_discharge_kg_per_rev_m=config.specific_discharge_kg_per_rev_m,
nominal_width_provider=width_provider(),
)
partial = service().poll_once(now=runs[1].start + timedelta(seconds=300))
result = service().poll_once(now=runs[1].end)
assert result.state.cumulative_consumption_kg - partial.state.cumulative_consumption_kg == (
pytest.approx((3.739 + 3.956) * 5 * 3.12 * 326 / 60)
)
assert result.state.integrated_running_seconds / 60 == pytest.approx(1256)
assert result.state.cumulative_consumption_kg == pytest.approx(147021.9, abs=0.2)
assert result.state.integrated_running_seconds / 60 * speed * 5 == pytest.approx(31294.6)
assert result.state.cumulative_consumption_kg == pytest.approx(
(3.739 + 3.956) * 5 * 3.12 * 1256,
)
assert service().poll_once(now=runs[1].end).state == result.state
assert gateway.get_production_runs.call_count == 1
assert [(timestamp(call.args[1]['start']), timestamp(call.args[1]['end']))
for call in client.post_json.call_args_list[:2]] == [
(run.start, run.end) for run in runs]
(runs[0].start, runs[0].end),
(runs[1].start, runs[1].start + timedelta(seconds=300)),
]
def test_k7_config_defaults_remain_direct():
config = load_material_calculation('config/k7-material-consumption.yaml')
assert config.source_mode == 'direct_mass_rate'
assert config.rotational_speed_signal_refs == ()
assert config.application_signal_refs == ()
assert config.gate_threshold == 0.5
assert config.rate_signal_ref == 'c9d06af5-f6d6-4ede-b6c4-5a98bac77129'
@pytest.mark.parametrize('field,value', [
('source_mode', 'unknown'), ('application_signal_refs', []),
('application_signal_refs', ['same', 'same']), ('erp_workplace', ''),
('source_mode', 'unknown'), ('rotational_speed_signal_refs', []),
('rotational_speed_signal_refs', ['same', 'same']), ('erp_workplace', ''),
('production_order_format', '{wrong}'), ('rate_signal_ref', 'direct'),
])
def test_invalid_area_configuration(tmp_path, field, value):
def test_invalid_rotational_configuration(tmp_path, field, value):
with open('config/bento1-material-consumption.yaml') as stream:
document = yaml.safe_load(stream)
document['calculations'][0][field] = value
@@ -177,7 +216,7 @@ def test_invalid_area_configuration(tmp_path, field, value):
load_material_calculation(path)
def test_area_context_failure_does_not_touch_persistent_state(tmp_path):
def test_rotational_context_failure_does_not_touch_persistent_state(tmp_path):
config = load_material_calculation('config/bento1-material-consumption.yaml')
gateway = Mock()
now = timestamp('2026-08-25T05:27:18Z')
@@ -191,7 +230,9 @@ def test_area_context_failure_does_not_touch_persistent_state(tmp_path):
gateway=gateway, state_store=store, machine_id=config.machine_ref,
rate_variable_id=config.rate_signal_ref, gate_variable_id=config.gate_signal_ref,
gate_threshold=config.gate_threshold, max_sample_gap_seconds=20,
application_variable_ids=config.application_signal_refs, nominal_width_provider=provider,
rotational_speed_variable_ids=config.rotational_speed_signal_refs,
specific_discharge_kg_per_rev_m=config.specific_discharge_kg_per_rev_m,
nominal_width_provider=provider,
)
with pytest.raises(ValueError, match='context'):
service.poll_once(now=now)
@@ -0,0 +1,113 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import Mock, patch
import pytest
import yaml
from production_analytics.calculations.config import (
CalculationConfigError,
load_material_calculation,
)
from production_analytics.calculations.material_consumption import (
MaterialConsumptionIntegrator,
MaterialIntegratorConfig,
rotational_discharge_rate_kg_per_hour,
)
from production_analytics.enlyze.gateway import EnlyzeApiGateway
from production_analytics.service.material_runtime import build_material_runner
@pytest.mark.parametrize('speeds,width,factor,expected', [
([3.739, 3.956], 5, 3.12, 7202.52),
([3.739, 3.956], 4.85, 3.12, 6986.4444),
([2], 4, 1.5, 720),
([2, 3, 4], 4, 1.5, 3240),
])
def test_conversion(speeds, width, factor, expected):
assert rotational_discharge_rate_kg_per_hour(speeds, width, factor) == pytest.approx(expected)
@pytest.mark.parametrize('speeds,width,factor', [
([], 5, 3.12), ([float('nan')], 5, 3.12), ([1], 0, 3.12),
([1], float('inf'), 3.12), ([1], 5, 0), ([1], 5, float('nan')),
([1e308], 5, 3.12),
])
def test_invalid_conversion(speeds, width, factor):
with pytest.raises(ValueError):
rotational_discharge_rate_kg_per_hour(speeds, width, factor)
@pytest.mark.parametrize('gate_id', ['speed', None])
def test_gate_and_gap(gate_id):
start = datetime(2026, 8, 25, tzinfo=UTC)
times = [0, 10, 20, 40, 61, 71, 81]
gates = [2, 0.3, 0, 8, 1, 1, 1]
client = Mock()
client.post_json.return_value.body = {'data': {
'columns': ['time', 'right', 'left'] + ([gate_id] if gate_id else []),
'records': [[(start + timedelta(seconds=t)).isoformat(), 3.739, 3.956]
+ ([gate] if gate_id else []) for t, gate in zip(times, gates, strict=True)],
}}
samples = EnlyzeApiGateway(client).get_material_samples(
machine_id='m', rate_variable_id='unused', gate_variable_id=gate_id,
rotational_speed_variable_ids=('right', 'left'), nominal_width_m=5,
specific_discharge_kg_per_rev_m=3.12, start=start, end=start + timedelta(seconds=81),
)
assert all(s.material_rate_kg_per_hour == pytest.approx(7202.52) for s in samples)
state = MaterialConsumptionIntegrator(MaterialIntegratorConfig(0.3, 20)).process_many(samples)
seconds = 30 if gate_id else 60
assert state.integrated_running_seconds == seconds
assert state.cumulative_consumption_kg == pytest.approx(120.042 * seconds / 60)
assert client.post_json.call_args.args[1]['variables'] == [
{'uuid': ref} for ref in ['right', 'left'] + ([gate_id] if gate_id else [])]
@pytest.mark.parametrize('field,value', [
('specific_discharge_kg_per_rev_m', None), ('specific_discharge_kg_per_rev_m', 0),
('specific_discharge_kg_per_rev_m', True), ('specific_discharge_kg_per_rev_m', float('inf')),
('application_signal_refs', ['set']), ('rotational_speed_signal_refs', ['']),
])
def test_invalid_config(tmp_path, field, value):
document = yaml.safe_load(open('config/bento1-material-consumption.yaml'))
document['calculations'][0][field] = value
path = tmp_path / 'config.yaml'
path.write_text(yaml.safe_dump(document))
with pytest.raises(CalculationConfigError):
load_material_calculation(path)
def test_optional_gate_config(tmp_path):
document = yaml.safe_load(open('config/bento1-material-consumption.yaml'))
del document['calculations'][0]['gate_signal_ref']
del document['calculations'][0]['gate_threshold']
path = tmp_path / 'config.yaml'
path.write_text(yaml.safe_dump(document))
config = load_material_calculation(path)
assert config.gate_signal_ref is None
assert config.gate_threshold == 0
def test_bento_runtime_wiring(tmp_path, monkeypatch):
monkeypatch.setenv('ENLYZE_BASE_URL', 'https://example.invalid/api/')
for key, value in dict(HOST='localhost', PORT='5432', DB='analytics',
USER='user', PASSWORD='test').items():
monkeypatch.setenv(f'POSTGRES_{key}', value)
secrets = tmp_path / 'secret.env'
secrets.write_text('ENLYZE_API_KEY=test\n')
config = load_material_calculation('config/bento1-material-consumption.yaml')
with patch('production_analytics.service.material_runtime.ErpSettings') as erp_settings:
runner = build_material_runner(
config, poll_interval_seconds=10, state_directory=tmp_path / 'state',
secrets_file=secrets, erp_secrets_file=tmp_path / 'erp.env',
)
service = runner.service
assert config.source_mode == 'rotational_discharge'
assert service._rotational_speed_variable_ids == (
'6e5d2d94-98f9-4cc1-8a88-7987c6282525', 'cd7385c4-337b-4759-ab32-45d65beaf190',
)
assert service._application_variable_ids == ()
assert service._specific_discharge == 3.12
assert service._gate_variable_id == 'fef41976-1103-4090-b780-eaeecc02fdfa'
assert service._config == MaterialIntegratorConfig(0.3, 20)
assert service._nominal_width_provider.workplace == 'Bento 1'
erp_settings.from_secret_file.assert_called_once_with(tmp_path / 'erp.env')