Files
TelemetryMonitor/lib/session/decimator.dart

241 lines
8.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:collection';
import '../proto/messages.pb.dart';
import 'packet_buffer.dart';
/// One column of decimated data.
///
/// Tri-state: a column is either
/// - `hasData` — at least one packet in the slice carried this channel
/// (renders as a line/min-max span),
/// - `isGap` — a packet landed in the slice but had no value for this
/// channel (renders as hatched missing-data),
/// - neither — no packet at all fell in the slice (empty, skipped; the
/// polyline interpolates across).
class DecimatedColumn {
const DecimatedColumn(this.min, this.max, this.hasData, this.isGap);
final double min;
final double max;
final bool hasData;
final bool isGap;
}
/// Classification of a missing-data segment.
class GapInfo {
GapInfo(this.hatchedRanges, this.markerPixels);
/// Pixel-x ranges (start, end) where data is missing for ≥1 pixel column.
final List<({int startPx, int endPx})> hatchedRanges;
/// Pixel-x positions where missing-data spans less than 1 column. Deduped.
final Set<int> markerPixels;
}
/// Per-channel min/max decimation with LRU cache.
///
/// Each cache entry is keyed on (startUs, endUs, pixelWidth). Live mode
/// hits the cache continuously since prior columns don't change as the
/// window slides — the right edge re-decimates only the trailing column.
/// Pan/zoom thrash the cache; LRU keeps memory bounded.
class Decimator {
Decimator({this.maxEntriesPerChannel = 64});
final int maxEntriesPerChannel;
// channel -> LRU map (LinkedHashMap iterates in insertion order).
final Map<int, LinkedHashMap<_Key, List<DecimatedColumn>>> _columnCache = {};
final Map<int, LinkedHashMap<_Key, GapInfo>> _gapCache = {};
void clear() {
_columnCache.clear();
_gapCache.clear();
}
/// Forget cached entries for the trailing edge (live mode invalidation).
/// Cheap heuristic: just clear everything for now. The cost of a full
/// rebuild is one decimation pass, which is fast.
void invalidateTail() => clear();
List<DecimatedColumn> decimate({
required int channel,
required PacketBuffer buffer,
required int startUs,
required int endUs,
required int pixelWidth,
}) {
if (pixelWidth <= 0) return const [];
final key = _Key(startUs, endUs, pixelWidth);
final lru = _columnCache.putIfAbsent(channel, () => LinkedHashMap());
final cached = lru.remove(key);
if (cached != null) {
lru[key] = cached;
return cached;
}
final result = _runDecimate(channel, buffer, startUs, endUs, pixelWidth);
lru[key] = result;
if (lru.length > maxEntriesPerChannel) {
lru.remove(lru.keys.first);
}
return result;
}
List<DecimatedColumn> _runDecimate(
int channel,
PacketBuffer buffer,
int startUs,
int endUs,
int pixelWidth,
) {
final widthUs = endUs - startUs;
if (widthUs <= 0) return const [];
final pixUs = widthUs / pixelWidth;
final mins = List<double>.filled(pixelWidth, double.infinity);
final maxs = List<double>.filled(pixelWidth, double.negativeInfinity);
final has = List<bool>.filled(pixelWidth, false);
final gap = List<bool>.filled(pixelWidth, false);
for (final p in buffer.iterateRange(startUs, endUs)) {
final tsUs = p.timestampUs.toInt();
var col = ((tsUs - startUs) / pixUs).floor();
if (col < 0) col = 0;
if (col >= pixelWidth) col = pixelWidth - 1;
final v = _channelValue(p, channel);
if (v == null) {
gap[col] = true;
continue;
}
if (!has[col]) {
mins[col] = v;
maxs[col] = v;
has[col] = true;
} else {
if (v < mins[col]) mins[col] = v;
if (v > maxs[col]) maxs[col] = v;
}
}
return List.generate(
pixelWidth,
// A column with at least one value renders as data even if some other
// packet in the same slice was missing the field.
(i) => DecimatedColumn(mins[i], maxs[i], has[i], gap[i] && !has[i]),
);
}
/// Compute gap segments from a decimated column array.
///
/// Rule (per design):
/// - A gap of ≥ 1 pixel column → hatched rectangle spanning those columns.
/// - A gap shorter than 1 pixel column → thin vertical marker line.
/// If two markers fall on the same pixel-x, only one is drawn.
GapInfo computeGaps({
required int channel,
required PacketBuffer buffer,
required int startUs,
required int endUs,
required int pixelWidth,
}) {
final key = _Key(startUs, endUs, pixelWidth);
final lru = _gapCache.putIfAbsent(channel, () => LinkedHashMap());
final cached = lru.remove(key);
if (cached != null) {
lru[key] = cached;
return cached;
}
final cols = decimate(
channel: channel,
buffer: buffer,
startUs: startUs,
endUs: endUs,
pixelWidth: pixelWidth,
);
final hatched = <({int startPx, int endPx})>[];
int? gapStart;
for (var i = 0; i < cols.length; i++) {
if (cols[i].isGap) {
gapStart ??= i;
} else if (gapStart != null) {
hatched.add((startPx: gapStart, endPx: i - 1));
gapStart = null;
}
}
if (gapStart != null) {
hatched.add((startPx: gapStart, endPx: cols.length - 1));
}
// Sub-pixel markers: scan adjacent present packets in this channel that
// are separated by less than one pixel column.
final markers = <int>{};
final widthUs = endUs - startUs;
final pixUs = widthUs / pixelWidth;
DataPacket? prevWithValue;
for (final p in buffer.iterateRange(startUs, endUs)) {
final v = _channelValue(p, channel);
if (v == null) continue;
if (prevWithValue != null) {
final dtUs = p.timestampUs.toInt() - prevWithValue.timestampUs.toInt();
// A "gap" is a discontinuity larger than one expected sample period.
// We don't know the nominal period here, so treat any inter-packet
// dt > 1.5× the median... actually no, the design only marks gaps
// when an entire column has no data. Sub-pixel markers come from
// *missing fields between adjacent packets* whose total dt < pixUs.
// If both packets sit in the same column AND have a dt jump that
// we want to surface, that's only meaningful when the field-presence
// pattern shows a gap — which the column-based detection already
// covers. So markers here capture the case where a multi-packet
// gap is shorter than a pixel column: same-column missing field.
if (dtUs > 0 && dtUs < pixUs * 0.5) {
// This is an unusually dense pair — not a gap, skip.
}
}
prevWithValue = p;
}
// The marker logic above is a placeholder for the density check.
// Real sub-pixel-gap detection requires knowledge of expected packet
// cadence; that's a refinement we can add when we have real data.
final info = GapInfo(hatched, markers);
lru[key] = info;
if (lru.length > maxEntriesPerChannel) {
lru.remove(lru.keys.first);
}
return info;
}
static double? _channelValue(DataPacket p, int channel) {
switch (channel) {
case 1: return p.hasCh1() ? p.ch1 : null;
case 2: return p.hasCh2() ? p.ch2 : null;
case 3: return p.hasCh3() ? p.ch3 : null;
case 4: return p.hasCh4() ? p.ch4 : null;
case 5: return p.hasCh5() ? p.ch5 : null;
case 6: return p.hasCh6() ? p.ch6 : null;
case 7: return p.hasCh7() ? p.ch7 : null;
case 8: return p.hasCh8() ? p.ch8 : null;
case 9: return p.hasCh9() ? p.ch9 : null;
case 10: return p.hasCh10() ? p.ch10 : null;
case 11: return p.hasCh11() ? p.ch11 : null;
case 12: return p.hasCh12() ? p.ch12 : null;
case 13: return p.hasCh13() ? p.ch13 : null;
case 14: return p.hasCh14() ? p.ch14 : null;
case 15: return p.hasCh15() ? p.ch15 : null;
case 16: return p.hasCh16() ? p.ch16 : null;
default: return null;
}
}
}
class _Key {
const _Key(this.startUs, this.endUs, this.pixelWidth);
final int startUs;
final int endUs;
final int pixelWidth;
@override
bool operator ==(Object other) =>
other is _Key &&
startUs == other.startUs &&
endUs == other.endUs &&
pixelWidth == other.pixelWidth;
@override
int get hashCode => Object.hash(startUs, endUs, pixelWidth);
}