Files
production-analytics/tests/test_material_consumption.py
T

134 lines
4.7 KiB
Python

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)