Version 0.2.1
Version 0.2.1 now has derive unit context for derivative calculus
This commit is contained in:
10 files changed
+473
-36
No files matched your search
@@ -0,0 +1,117 @@
|
||||
# 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
|
||||
Reference in new issue
Block a user