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