Add incremental material consumption integrator

This commit is contained in:
2026-09-05 04:27:27 +02:00
parent 231e18082c
commit b308b4f545
5 changed files with 342 additions and 0 deletions
@@ -1,10 +1,20 @@
"""Versioned, pure calculation operators and their contracts."""
from .base import Calculation
from .material_consumption import (
MaterialConsumptionIntegrator,
MaterialIntegrationState,
MaterialIntegratorConfig,
MaterialSample,
)
from .peak_cycles import DetectedPeak, PeakCycleDetector, PeakDetectorConfig, TimeSeriesSample
__all__ = [
"Calculation",
"MaterialConsumptionIntegrator",
"MaterialIntegrationState",
"MaterialIntegratorConfig",
"MaterialSample",
"DetectedPeak",
"PeakCycleDetector",
"PeakDetectorConfig",
@@ -0,0 +1,100 @@
"""Incremental, previous-value integration shared by live input and replay."""
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from math import isfinite
@dataclass(frozen=True, slots=True)
class MaterialSample:
"""An aware timestamp, material rate in kg/h, and numeric gate value."""
timestamp: datetime
material_rate_kg_per_hour: float
gate_value: float
@dataclass(frozen=True, slots=True)
class MaterialIntegratorConfig:
gate_threshold: float
max_sample_gap_seconds: float
def __post_init__(self) -> None:
if not isfinite(self.gate_threshold):
raise ValueError("gate_threshold must be finite")
if not isfinite(self.max_sample_gap_seconds) or self.max_sample_gap_seconds <= 0:
raise ValueError("max_sample_gap_seconds must be finite and greater than 0")
@dataclass(frozen=True, slots=True)
class MaterialIntegrationState:
"""Immutable snapshot; running time excludes inactive and rejected gap intervals."""
cumulative_consumption_kg: float = 0.0
integrated_running_seconds: float = 0.0
last_processed_timestamp: datetime | None = None
last_material_rate_kg_per_hour: float | None = None
last_gate_value: float | None = None
integration_active: bool = False
class MaterialConsumptionIntegrator:
"""Integrate each observed interval using its preceding rate and gate.
The first sample only establishes a baseline. Equal timestamps replace that
baseline without adding time. Gaps exceeding the configured maximum also
establish a new baseline without integration. No tail interval is inferred.
Finite negative material rates are currently accepted without clamping and
decrease cumulative consumption during affected integrated intervals. This
is intentional generic behavior for now; machine-specific validation or
clamping may be added later at the input/adapter layer if process semantics
require it. Invalid samples raise before modifying state. State timestamps
are always UTC.
"""
def __init__(self, config: MaterialIntegratorConfig) -> None:
self._config = config
self._state = MaterialIntegrationState()
@property
def config(self) -> MaterialIntegratorConfig:
return self._config
@property
def state(self) -> MaterialIntegrationState:
return self._state
def process(self, sample: MaterialSample) -> MaterialIntegrationState:
"""Consume one sample in arrival order and return the resulting snapshot."""
if sample.timestamp.tzinfo is None or sample.timestamp.utcoffset() is None:
raise ValueError("sample timestamp must be timezone-aware")
timestamp = sample.timestamp.astimezone(UTC)
if not isfinite(sample.material_rate_kg_per_hour) or not isfinite(sample.gate_value):
raise ValueError("sample rate and gate value must be finite")
previous = self.state
consumption = previous.cumulative_consumption_kg
running_seconds = previous.integrated_running_seconds
if previous.last_processed_timestamp is not None:
elapsed = (timestamp - previous.last_processed_timestamp).total_seconds()
if elapsed < 0:
raise ValueError("sample timestamps must not move backwards")
if 0 < elapsed <= self.config.max_sample_gap_seconds and previous.integration_active:
assert previous.last_material_rate_kg_per_hour is not None
consumption += previous.last_material_rate_kg_per_hour * (elapsed / 3600.0)
running_seconds += elapsed
self._state = MaterialIntegrationState(
cumulative_consumption_kg=consumption,
integrated_running_seconds=running_seconds,
last_processed_timestamp=timestamp,
last_material_rate_kg_per_hour=sample.material_rate_kg_per_hour,
last_gate_value=sample.gate_value,
integration_active=sample.gate_value > self.config.gate_threshold,
)
return self.state
def process_many(self, samples: Iterable[MaterialSample]) -> MaterialIntegrationState:
"""Consume an iterable without sorting; previous successful calls remain applied."""
for sample in samples:
self.process(sample)
return self.state