Files
templates/python/set_devices/plot_processing.py

153 lines
5.4 KiB
Python

# Контракт обработки снимков графика: входные ряды, запрос расчёта и результирующие кривые.
# Вычисленные данные отделены от исходных измерений; численная обработка делегируется общему
# ядру C99.
"""Renderer-independent processing contract. Numerical work stays in C99.
Adapters publish immutable snapshots in the displayed units. Calculated curves
are separate objects, never channels in the acquisition or measurement model.
"""
from __future__ import annotations
import csv
from bisect import bisect_left, bisect_right
from operator import itemgetter
from dataclasses import dataclass
from datetime import datetime, timezone
from .signal_reconstruction import METHODS, reconstruct
class FrozenPoints(tuple):
"""Normalized immutable samples, reusable across processing requests."""
def __new__(cls, points):
if isinstance(points, cls):
return points
result = super().__new__(cls, ((float(x), float(y)) for x, y in points))
result.ordered = all(result[i - 1][0] <= result[i][0] for i in range(1, len(result)))
return result
def between(self, left, right):
if self.ordered:
start = bisect_left(self, left, key=itemgetter(0))
stop = bisect_right(self, right, key=itemgetter(0))
if start == 0 and stop == len(self):
return self
result = tuple.__new__(type(self), self[start:stop])
result.ordered = True
return result
return FrozenPoints((x, y) for x, y in self if left <= x <= right)
@dataclass(frozen=True)
class Axis:
label: str = "Время"
unit: str = "мс"
encoding: str = "numeric" # numeric or unix_ms; never infer from magnitude
def __post_init__(self):
if self.encoding not in ("numeric", "unix_ms"):
raise ValueError("Неизвестное представление оси X")
@dataclass(frozen=True)
class Series:
key: str
label: str
points: tuple
visible: bool = True
discrete: bool = False
y_unit: str = ""
def __post_init__(self):
# A source may reuse mutable buffers immediately after publication.
object.__setattr__(self, "points", FrozenPoints(self.points))
@dataclass(frozen=True)
class Snapshot:
series: tuple = ()
axis: Axis = Axis()
source: str = ""
x_range: tuple | None = None
blocked_reason: str = ""
def __post_init__(self):
object.__setattr__(self, "series", tuple(self.series))
if len({s.key for s in self.series}) != len(self.series):
raise ValueError("Ключи каналов должны быть уникальны")
if self.x_range is not None:
left, right = self.x_range
if not left <= right:
raise ValueError("Неверные границы окна")
object.__setattr__(self, "x_range", (left, right))
@property
def analogs(self):
return tuple(s for s in self.series if s.visible and not s.discrete)
@dataclass(frozen=True)
class Request:
series: Series
axis: Axis
source: str
method: str
output_count: int
degree: int
x_range: tuple | None
@dataclass(frozen=True)
class Curve:
request: Request
points: tuple
input_count: int
unique_count: int
rmse: float
pieces: tuple = ()
def piece_at(self, x):
for piece in self.pieces:
if piece.left <= x <= piece.right:
return piece
return None
@property
def label(self):
return f"{self.request.series.label} · расчёт ({METHODS[self.request.method]})"
def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
"""Return a comparable request, or None when the source is not processable."""
if snapshot.blocked_reason:
return None
selected = next((s for s in snapshot.analogs if s.key == key), None)
if selected is None:
return None
if snapshot.x_range is not None:
left, right = snapshot.x_range
selected = Series(selected.key, selected.label,
selected.points.between(left, right),
y_unit=selected.y_unit)
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range)
def process(request):
if request is None:
raise ValueError("Нет доступного аналогового канала")
result = reconstruct(request.series.points, request.method, request.output_count, request.degree, with_model=True)
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse, result.pieces)
def write_csv(curve, stream):
"""CSV preserves the X domain. Relative time/frequency are numeric, not dates."""
axis = curve.request.axis
writer = csv.writer(stream)
x_title = "timestamp" if axis.encoding == "unix_ms" else axis.label + (f" [{axis.unit}]" if axis.unit else "")
y_title = curve.label + (f" [{curve.request.series.y_unit}]" if curve.request.series.y_unit else "")
writer.writerow([x_title, y_title])
for x, y in curve.points:
value = (datetime.fromtimestamp(x / 1000, timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
if axis.encoding == "unix_ms" else x)
writer.writerow([value, y])