Add shared waveform processing, generation and firmware image support

This commit is contained in:
2026-09-27 01:45:08 +03:00
parent 2ad29e7ffd
commit cc22c803d1
36 changed files with 2591 additions and 61 deletions

View File

@@ -0,0 +1,117 @@
"""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])