Add incremental material consumption integrator
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user