"""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])