Files
templates/python/set_devices/plot_processing.py

118 lines
4.0 KiB
Python

"""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 dataclasses import dataclass
from datetime import datetime, timezone
from .signal_reconstruction import METHODS, reconstruct
@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", tuple((float(x), float(y)) for x, y in 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
@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,
tuple((x, y) for x, y in selected.points if left <= x <= 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)
return Curve(request, tuple(result.points), result.input_count, result.unique_count, result.rmse)
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])