from datetime import UTC, datetime, timedelta, timezone import pytest from production_analytics.calculations import ( MaterialConsumptionIntegrator, MaterialIntegratorConfig, MaterialSample, ) START = datetime(2026, 8, 31, tzinfo=UTC) def sample(seconds, rate=3600.0, gate=1.0): return MaterialSample(START + timedelta(seconds=seconds), rate, gate) def integrator(gap=20.0): return MaterialConsumptionIntegrator(MaterialIntegratorConfig(0.5, gap)) def test_irregular_intervals_use_previous_rate_and_no_tail(): core = integrator() first = core.process(sample(0)) assert first.cumulative_consumption_kg == 0 state = core.process_many([sample(7, 7200), sample(20, 9999)]) assert state.cumulative_consumption_kg == pytest.approx(33) assert state.integrated_running_seconds == 20 assert first.cumulative_consumption_kg == 0 # snapshots remain stable assert state.last_processed_timestamp == START + timedelta(seconds=20) assert state.last_material_rate_kg_per_hour == 9999 assert state.last_gate_value == 1 assert state.integration_active def test_inactive_gate_and_strict_threshold(): state = integrator().process_many([sample(0, gate=0.5), sample(10, gate=-1), sample(20)]) assert state.cumulative_consumption_kg == 0 assert state.integrated_running_seconds == 0 def test_gate_transitions_use_previous_gate(): state = integrator().process_many( [sample(0, gate=0), sample(10), sample(20, gate=0), sample(30), sample(40)] ) assert state.cumulative_consumption_kg == pytest.approx(20) assert state.integrated_running_seconds == 20 def test_duplicate_updates_baseline_without_consumption(): core = integrator() core.process_many([sample(0), sample(10)]) duplicate = core.process(sample(10, 7200)) assert duplicate.cumulative_consumption_kg == 10 assert core.process(sample(20)).cumulative_consumption_kg == 30 core.process(sample(20, gate=0)) assert core.process(sample(30)).cumulative_consumption_kg == 30 def test_backwards_timestamp_rejected_without_state_change(): core = integrator() before = core.process(sample(10)) with pytest.raises(ValueError, match="backwards"): core.process(sample(0)) assert core.state == before def test_gap_skipped_and_resumed_with_new_rate(): core = integrator() core.process_many([sample(0), sample(20)]) # exactly max gap is accepted state = core.process(sample(41, 7200)) assert state.cumulative_consumption_kg == 20 assert state.integration_active state = core.process(sample(51)) assert state.cumulative_consumption_kg == 40 assert state.integrated_running_seconds == 30 def test_incremental_chunks_and_replay_match_manual_intervals(): samples = [ sample(0), sample(3, 1800), sample(11, gate=0), sample(16), sample(50, 900), sample(60), ] manual_seconds = sum( (b.timestamp - a.timestamp).total_seconds() for a, b in zip(samples, samples[1:]) if a.gate_value > 0.5 and (b.timestamp - a.timestamp).total_seconds() <= 20 ) manual_kg = sum( a.material_rate_kg_per_hour * (b.timestamp - a.timestamp).total_seconds() / 3600 for a, b in zip(samples, samples[1:]) if a.gate_value > 0.5 and (b.timestamp - a.timestamp).total_seconds() <= 20 ) core = integrator() core.process_many(samples[:2]) core.process(samples[2]) core.process_many(samples[3:]) assert core.process_many([]) == integrator().process_many(samples) assert core.state.cumulative_consumption_kg == pytest.approx(manual_kg) assert core.state.integrated_running_seconds == manual_seconds def test_timezone_normalization_and_naive_rejection(): core = integrator() core.process(sample(0)) local = (START + timedelta(seconds=10)).astimezone(timezone(timedelta(hours=2))) state = core.process(MaterialSample(local, 3600, 1)) assert state.cumulative_consumption_kg == 10 assert state.last_processed_timestamp.tzinfo is UTC with pytest.raises(ValueError, match="timezone-aware"): core.process(MaterialSample(START.replace(tzinfo=None), 3600, 1)) assert core.state == state @pytest.mark.parametrize("rate,gate", [(float("nan"), 1), (1, float("inf"))]) def test_nonfinite_samples_rejected(rate, gate): core = integrator() before = core.state with pytest.raises(ValueError, match="finite"): core.process(sample(0, rate, gate)) assert core.state == before @pytest.mark.parametrize( "threshold,gap", [(float("nan"), 20), (0.5, 0), (0.5, -1), (0.5, float("inf"))] ) def test_invalid_configuration(threshold, gap): with pytest.raises(ValueError): MaterialIntegratorConfig(threshold, gap)