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)