159 lines
5.9 KiB
Python
159 lines
5.9 KiB
Python
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from production_analytics.calculations.channel_config import load_channel_material_calculation
|
|
from production_analytics.calculations.channel_material import (
|
|
MATERIAL_AMBIGUOUS,
|
|
MATERIAL_UNAVAILABLE,
|
|
DosingChannelSample,
|
|
channel_rates,
|
|
validate_active_percentage_sum,
|
|
)
|
|
from production_analytics.enlyze.gateway import EnlyzeApiGateway, EnlyzeProductionRun
|
|
from production_analytics.service.channel_material import (
|
|
ChannelDefinition,
|
|
ChannelMaterialPollingService,
|
|
)
|
|
from production_analytics.service.channel_material_state_store import JsonChannelMaterialStateStore
|
|
|
|
NOW = datetime(2026, 9, 1, tzinfo=UTC)
|
|
E1_CONFIG = (
|
|
Path(__file__).resolve().parents[1] / "config/e1-channel-material-consumption.example.yaml"
|
|
)
|
|
|
|
|
|
def sample(*, on=True, percent=100, material=None, timestamp=NOW):
|
|
return DosingChannelSample(timestamp, 120, on, percent, 10, material)
|
|
|
|
|
|
def test_off_channel_with_nonzero_displayed_percentage_contributes_zero():
|
|
assert channel_rates((sample(on=False, percent=40),))[0].sample.material_rate_kg_per_hour == 0
|
|
|
|
|
|
def test_e1_config_dry_run_resolves_every_signal_through_gateway_without_sampling():
|
|
config = load_channel_material_calculation(E1_CONFIG)
|
|
origins = {
|
|
reference
|
|
for channel in config.channels
|
|
for reference in (
|
|
channel.total_rate_signal_ref,
|
|
channel.status_signal_ref,
|
|
channel.percentage_signal_ref,
|
|
channel.screw_speed_signal_ref,
|
|
)
|
|
}
|
|
client = Mock()
|
|
client.get.return_value.body = {
|
|
"data": [
|
|
{
|
|
"uuid": f"variable-{index}",
|
|
"details": {"origin_identifier": {"code": origin}},
|
|
}
|
|
for index, origin in enumerate(sorted(origins))
|
|
]
|
|
}
|
|
|
|
resolved = EnlyzeApiGateway(client).resolve_dosing_channel_signal_refs(
|
|
machine_id=config.machine_ref,
|
|
channels=config.channels,
|
|
)
|
|
|
|
assert config.machine_ref == "8302e3d1-b1e5-42f1-8540-615eb6c73e08"
|
|
assert len(config.channels) == 14
|
|
assert len(origins) == 44
|
|
assert len(resolved) == 14
|
|
assert all(len(refs) == 5 and refs[-1] is None for refs in resolved.values())
|
|
client.get.assert_called_once_with("/v2/variables", {"machine": config.machine_ref})
|
|
client.post_json.assert_not_called()
|
|
|
|
|
|
def test_active_percentages_sum_to_100_and_deviation_is_reported_not_normalized():
|
|
active = (sample(percent=40), sample(percent=60))
|
|
assert validate_active_percentage_sum(active, tolerance=0.1)
|
|
assert not validate_active_percentage_sum(
|
|
(sample(percent=40), sample(percent=50)), tolerance=0.1
|
|
)
|
|
assert [x.sample.material_rate_kg_per_hour for x in channel_rates(active)] == [48, 72]
|
|
|
|
|
|
def test_null_and_duplicate_material_assignments_are_not_inferred():
|
|
assert (
|
|
channel_rates((sample(material=None),))[0].material_mapping_status == MATERIAL_UNAVAILABLE
|
|
)
|
|
rates = channel_rates((sample(material="123"), sample(material="123")))
|
|
assert all(rate.material_mapping_status == MATERIAL_AMBIGUOUS for rate in rates)
|
|
assert all(rate.material_number is None and rate.material_name is None for rate in rates)
|
|
|
|
|
|
def test_run_change_and_json_restart_preserve_channel_total(tmp_path):
|
|
channels = (ChannelDefinition("Ex1", "C1", "rate", "on", "pct", "rpm"),)
|
|
store, gateway = JsonChannelMaterialStateStore(tmp_path), Mock()
|
|
gateway.get_production_runs.return_value = []
|
|
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
|
|
"r1", "m", None, "order", NOW, None
|
|
)
|
|
key = "C1\0Ex1"
|
|
gateway.get_dosing_channel_samples.return_value = {
|
|
key: [sample(timestamp=NOW), sample(timestamp=NOW + timedelta(seconds=10))]
|
|
}
|
|
first = ChannelMaterialPollingService(
|
|
gateway=gateway,
|
|
state_store=store,
|
|
machine_id="m",
|
|
channels=channels,
|
|
max_sample_gap_seconds=20,
|
|
)
|
|
assert first.poll_once(now=NOW + timedelta(seconds=10))[
|
|
0
|
|
].state.cumulative_consumption_kg == pytest.approx(10 / 30)
|
|
gateway.get_open_production_run.return_value = EnlyzeProductionRun(
|
|
"r2", "m", None, "order", NOW + timedelta(seconds=20), None
|
|
)
|
|
gateway.get_dosing_channel_samples.return_value = {
|
|
key: [
|
|
sample(timestamp=NOW + timedelta(seconds=20)),
|
|
sample(timestamp=NOW + timedelta(seconds=30)),
|
|
]
|
|
}
|
|
restarted = ChannelMaterialPollingService(
|
|
gateway=gateway,
|
|
state_store=store,
|
|
machine_id="m",
|
|
channels=channels,
|
|
max_sample_gap_seconds=20,
|
|
)
|
|
assert restarted.poll_once(now=NOW + timedelta(seconds=30))[
|
|
0
|
|
].state.cumulative_consumption_kg == pytest.approx(20 / 30)
|
|
|
|
|
|
def test_bootstrap_replays_closed_runs_without_integrating_the_inter_run_gap(tmp_path):
|
|
channels = (ChannelDefinition("Ex1", "C1", "rate", "on", "pct", "rpm"),)
|
|
closed = EnlyzeProductionRun("r1", "m", None, "order", NOW, NOW + timedelta(seconds=10))
|
|
current = EnlyzeProductionRun("r2", "m", None, "order", NOW + timedelta(seconds=20), None)
|
|
gateway = Mock()
|
|
gateway.get_open_production_run.return_value = current
|
|
gateway.get_production_runs.return_value = [closed, current]
|
|
key = "C1\0Ex1"
|
|
gateway.get_dosing_channel_samples.side_effect = [
|
|
{key: [sample(timestamp=NOW), sample(timestamp=NOW + timedelta(seconds=10))]},
|
|
{
|
|
key: [
|
|
sample(timestamp=NOW + timedelta(seconds=20)),
|
|
sample(timestamp=NOW + timedelta(seconds=30)),
|
|
]
|
|
},
|
|
]
|
|
result = ChannelMaterialPollingService(
|
|
gateway=gateway,
|
|
state_store=JsonChannelMaterialStateStore(tmp_path),
|
|
machine_id="m",
|
|
channels=channels,
|
|
max_sample_gap_seconds=20,
|
|
).poll_once(now=NOW + timedelta(seconds=30))
|
|
assert result is not None
|
|
assert result[0].state.cumulative_consumption_kg == pytest.approx(20 / 30)
|