refactor(buffer): MIN_MAX tier binning, remove MEAN lod mode

- TieredBuffer: replace MEAN binning with MIN_MAX (min+max per bin).
  Mathematically composable: MIN(bin_mins)=global_MIN, MAX(bin_maxes)=global_MAX.
  Eliminates tier-boundary visual artifacts. TIER1/TIER2 capacity doubled (2 samples/bin).
- LodMode: remove MEAN. Only MIN_MAX and LTTB remain.
  MEAN introduced values never present in raw signal — disinformation for sensor data.
- ChartConfig: default lodMode changed to MIN_MAX.
- Version: 0.4.1-SNAPSHOT → 0.5.0-SNAPSHOT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dtrentin 2026-05-21 08:57:58 +02:00
parent b9ee5ddfa3
commit 554b67e630
5 changed files with 27 additions and 37 deletions

View file

@ -7,7 +7,7 @@ plugins {
} }
group = "dev.dtrentin" group = "dev.dtrentin"
version = "0.4.1-SNAPSHOT" version = "0.5.0-SNAPSHOT"
kotlin { kotlin {
androidTarget { androidTarget {

View file

@ -44,25 +44,11 @@ class LodDecimator(
} }
return when (mode) { return when (mode) {
LodMode.MEAN -> decimateMean(n, pixelWidth, outX, outY)
LodMode.MIN_MAX -> decimateMinMax(n, pixelWidth, outX, outY) LodMode.MIN_MAX -> decimateMinMax(n, pixelWidth, outX, outY)
LodMode.LTTB -> decimateLttb(n, pixelWidth, outX, outY) LodMode.LTTB -> decimateLttb(n, pixelWidth, outX, outY)
} }
} }
private fun decimateMean(n: Int, pixelWidth: Int, outX: FloatArray, outY: FloatArray): Int {
for (col in 0 until pixelWidth) { bucketSumY[col] = 0.0; bucketCountArr[col] = 0 }
for (i in 0 until n) { bucketSumY[colArr[i]] += valArr[i]; bucketCountArr[colArr[i]]++ }
var outIdx = 0
for (col in 0 until pixelWidth) {
if (bucketCountArr[col] == 0) continue
outX[outIdx] = col + 0.5f
outY[outIdx] = (bucketSumY[col] / bucketCountArr[col]).toFloat()
outIdx++
}
return outIdx
}
private fun decimateMinMax(n: Int, pixelWidth: Int, outX: FloatArray, outY: FloatArray): Int { private fun decimateMinMax(n: Int, pixelWidth: Int, outX: FloatArray, outY: FloatArray): Int {
for (col in 0 until pixelWidth) { colMin[col] = Float.MAX_VALUE; colMax[col] = -Float.MAX_VALUE; colHit[col] = false } for (col in 0 until pixelWidth) { colMin[col] = Float.MAX_VALUE; colMax[col] = -Float.MAX_VALUE; colHit[col] = false }
for (i in 0 until n) { for (i in 0 until n) {

View file

@ -7,12 +7,12 @@ class TieredBuffer {
private val tier2 = CircularBuffer(TIER2_CAPACITY) private val tier2 = CircularBuffer(TIER2_CAPACITY)
private var tier1BinStartMs = -1L private var tier1BinStartMs = -1L
private var tier1BinSum = 0.0 private var tier1BinMin = Float.MAX_VALUE
private var tier1BinCount = 0 private var tier1BinMax = -Float.MAX_VALUE
private var tier2BinStartMs = -1L private var tier2BinStartMs = -1L
private var tier2BinSum = 0.0 private var tier2BinMin = Float.MAX_VALUE
private var tier2BinCount = 0 private var tier2BinMax = -Float.MAX_VALUE
private val t0Ts = LongArray(TIER0_CAPACITY) private val t0Ts = LongArray(TIER0_CAPACITY)
private val t0Vs = FloatArray(TIER0_CAPACITY) private val t0Vs = FloatArray(TIER0_CAPACITY)
@ -32,15 +32,17 @@ class TieredBuffer {
tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS
} }
if (ts >= tier1BinStartMs + TIER1_BIN_MS) { if (ts >= tier1BinStartMs + TIER1_BIN_MS) {
if (tier1BinCount > 0) { if (tier1BinMin != Float.MAX_VALUE) {
tier1.push(tier1BinStartMs + TIER1_BIN_MS / 2L, (tier1BinSum / tier1BinCount).toFloat()) val midTs = tier1BinStartMs + TIER1_BIN_MS / 2L
tier1.push(midTs, tier1BinMin)
tier1.push(midTs, tier1BinMax)
} }
tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS tier1BinStartMs = (ts / TIER1_BIN_MS) * TIER1_BIN_MS
tier1BinSum = value.toDouble() tier1BinMin = value
tier1BinCount = 1 tier1BinMax = value
} else { } else {
tier1BinSum += value if (value < tier1BinMin) tier1BinMin = value
tier1BinCount++ if (value > tier1BinMax) tier1BinMax = value
} }
} }
@ -49,15 +51,17 @@ class TieredBuffer {
tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS
} }
if (ts >= tier2BinStartMs + TIER2_BIN_MS) { if (ts >= tier2BinStartMs + TIER2_BIN_MS) {
if (tier2BinCount > 0) { if (tier2BinMin != Float.MAX_VALUE) {
tier2.push(tier2BinStartMs + TIER2_BIN_MS / 2L, (tier2BinSum / tier2BinCount).toFloat()) val midTs = tier2BinStartMs + TIER2_BIN_MS / 2L
tier2.push(midTs, tier2BinMin)
tier2.push(midTs, tier2BinMax)
} }
tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS tier2BinStartMs = (ts / TIER2_BIN_MS) * TIER2_BIN_MS
tier2BinSum = value.toDouble() tier2BinMin = value
tier2BinCount = 1 tier2BinMax = value
} else { } else {
tier2BinSum += value if (value < tier2BinMin) tier2BinMin = value
tier2BinCount++ if (value > tier2BinMax) tier2BinMax = value
} }
} }
@ -115,8 +119,8 @@ class TieredBuffer {
fun clear() { fun clear() {
tier0.clear(); tier1.clear(); tier2.clear() tier0.clear(); tier1.clear(); tier2.clear()
tier1BinStartMs = -1L; tier1BinSum = 0.0; tier1BinCount = 0 tier1BinStartMs = -1L; tier1BinMin = Float.MAX_VALUE; tier1BinMax = -Float.MAX_VALUE
tier2BinStartMs = -1L; tier2BinSum = 0.0; tier2BinCount = 0 tier2BinStartMs = -1L; tier2BinMin = Float.MAX_VALUE; tier2BinMax = -Float.MAX_VALUE
} }
companion object { companion object {
@ -132,8 +136,8 @@ class TieredBuffer {
const val TIER2_BIN_MS = 1000L / TIER2_HZ const val TIER2_BIN_MS = 1000L / TIER2_HZ
val TIER0_CAPACITY = ((TIER0_DURATION_MS / 1000L) * TIER0_MAX_HZ).toInt() val TIER0_CAPACITY = ((TIER0_DURATION_MS / 1000L) * TIER0_MAX_HZ).toInt()
val TIER1_CAPACITY = ((TIER1_DURATION_MS / 1000L) * TIER1_HZ).toInt() val TIER1_CAPACITY = ((TIER1_DURATION_MS / 1000L) * TIER1_HZ).toInt() * 2
val TIER2_CAPACITY = ((TIER2_DURATION_MS / 1000L) * TIER2_HZ).toInt() val TIER2_CAPACITY = ((TIER2_DURATION_MS / 1000L) * TIER2_HZ).toInt() * 2
val TOTAL_CAPACITY = TIER0_CAPACITY + TIER1_CAPACITY + TIER2_CAPACITY val TOTAL_CAPACITY = TIER0_CAPACITY + TIER1_CAPACITY + TIER2_CAPACITY
} }
} }

View file

@ -22,5 +22,5 @@ data class ChartConfig(
val yLabelMode: AxisLabelMode = AxisLabelMode.INSIDE, val yLabelMode: AxisLabelMode = AxisLabelMode.INSIDE,
val yLabelDecimals: Int = 2, val yLabelDecimals: Int = 2,
val xLabelDecimals: Int = 1, val xLabelDecimals: Int = 1,
val lodMode: LodMode = LodMode.LTTB, val lodMode: LodMode = LodMode.MIN_MAX,
) )

View file

@ -1,3 +1,3 @@
package dev.dtrentin.chart.model package dev.dtrentin.chart.model
enum class LodMode { MEAN, MIN_MAX, LTTB } enum class LodMode { MIN_MAX, LTTB }