118 lines
4.3 KiB
Python
118 lines
4.3 KiB
Python
# Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
|
|
# Copyright (C) 2026 Association Exergie <association.exergie@gmail.com>
|
|
# 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
|