125 lines
4.2 KiB
Python
125 lines
4.2 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
|
|
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,
|
|
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, 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])
|