Version 0.2.1

Version 0.2.1 now has derive unit context for derivative calculus
This commit is contained in:
h3cx committed 2026-10-19 19:35:10 +02:00
1 parent 3d960b74c8
commit db5d509b49
10 files changed
+473 -36

No files matched your search

+117
View File
@@ -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