# Copyright (C) 2026 Hector van der Aa # Copyright (C) 2026 Association Exergie # SPDX-License-Identifier: GPL-3.0-or-later """Causal sample history and numerical derivatives for DLUnit functions.""" from collections import deque from math import factorial, fsum, isfinite, prod from types import MappingProxyType from uuid import UUID from dynalab_core.protocols.packets.data import ValueDescriptor class DeriveContext: """Read-only history snapshot supplied by Core to one processing call.""" def __init__(self, samples: dict[UUID, tuple[tuple[int, float], ...]]) -> None: self._samples = MappingProxyType(dict(samples)) def derivative( self, value: ValueDescriptor, order: int = 1, *, max_gap_s: float | None = None, ) -> float | None: """Estimate d**order(value)/dt**order in seconds, or return None. Uses the last order + 1 distinct samples, including ``value``. Repeated queries do not advance history. No smoothing or future samples are used. ``max_gap_s`` optionally restarts warm-up across excessive sample gaps. """ if ( not isinstance(order, int) or isinstance(order, bool) or order not in (1, 2, 3) ): raise ValueError("Derivative order must be 1, 2, or 3") if max_gap_s is not None and (not isfinite(max_gap_s) or max_gap_s <= 0): raise ValueError("max_gap_s must be finite and positive") samples = self._samples.get(value.signal_id, ()) if len(samples) < order + 1 or samples[-1] != (value.timestamp, value.value): return None samples = samples[-(order + 1) :] if max_gap_s is not None and any( (right[0] - left[0]) / 1_000_000_000 > max_gap_s for left, right in zip(samples, samples[1:]) ): return None # The nth derivative of the degree-n interpolant is constant. Normalize # time to the window span and subtract the latest value to limit loss of # precision from large absolute timestamps and constant value offsets. span_ns = samples[-1][0] - samples[0][0] span_s = span_ns / 1_000_000_000 try: result = ( factorial(order) * fsum( (sample - samples[-1][1]) / prod( (timestamp - other_timestamp) / span_ns for j, (other_timestamp, _) in enumerate(samples) if i != j ) for i, (timestamp, sample) in enumerate(samples) ) / span_s**order ) except (ArithmeticError, ValueError): # Extreme value ranges can overflow even when each input is finite. return None return result if isfinite(result) else None class _SampleHistory: """Mutable history owned by a unit's router, or by one offline pass.""" def __init__(self) -> None: self._samples: dict[UUID, deque[tuple[int, float]]] = {} def update(self, value: ValueDescriptor) -> bool: samples = self._samples.setdefault(value.signal_id, deque(maxlen=4)) if samples and value.timestamp <= samples[-1][0]: return False if not isfinite(value.value): samples.clear() return False samples.append((value.timestamp, value.value)) return True def snapshot(self, timestamp: int) -> DeriveContext: return DeriveContext( { key: tuple(sample for sample in samples if sample[0] <= timestamp) for key, samples in self._samples.items() } ) def inputs( self, signal_ids: list[UUID], timestamp: int ) -> list[ValueDescriptor] | None: values = [] for key in signal_ids: sample = next( ( sample for sample in reversed(self._samples.get(key, ())) if sample[0] <= timestamp ), None, ) if sample is None: return None values.append(ValueDescriptor(key, sample[1], sample[0])) return values