feat(plot): add eight markers and high-rate timestamps
This commit is contained in:
@@ -3,7 +3,8 @@ package ru.setcorp.setprotocol.trends
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
enum class TrendMarker(val title: String, val horizontal: Boolean) {
|
enum class TrendMarker(val title: String, val horizontal: Boolean) {
|
||||||
A("A", false), B("B", false), C("C", true), D("D", true)
|
A("A", false), B("B", false), C("C", false), D("D", false),
|
||||||
|
E("E", true), F("F", true), G("G", true), H("H", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class TrendMarkers(
|
data class TrendMarkers(
|
||||||
@@ -14,18 +15,28 @@ data class TrendMarkers(
|
|||||||
val b: Double? = null,
|
val b: Double? = null,
|
||||||
val c: Double? = null,
|
val c: Double? = null,
|
||||||
val d: Double? = null,
|
val d: Double? = null,
|
||||||
|
val e: Double? = null,
|
||||||
|
val f: Double? = null,
|
||||||
|
val g: Double? = null,
|
||||||
|
val h: Double? = null,
|
||||||
) {
|
) {
|
||||||
fun value(marker: TrendMarker): Double? = when (marker) {
|
fun value(marker: TrendMarker): Double? = when (marker) {
|
||||||
TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d
|
TrendMarker.A -> a; TrendMarker.B -> b; TrendMarker.C -> c; TrendMarker.D -> d
|
||||||
|
TrendMarker.E -> e; TrendMarker.F -> f; TrendMarker.G -> g; TrendMarker.H -> h
|
||||||
}
|
}
|
||||||
fun move(marker: TrendMarker, value: Double): TrendMarkers = when (marker) {
|
fun move(marker: TrendMarker, value: Double): TrendMarkers = when (marker) {
|
||||||
TrendMarker.A -> copy(a = value); TrendMarker.B -> copy(b = value)
|
TrendMarker.A -> copy(a = value); TrendMarker.B -> copy(b = value)
|
||||||
TrendMarker.C -> copy(c = value); TrendMarker.D -> copy(d = value)
|
TrendMarker.C -> copy(c = value); TrendMarker.D -> copy(d = value)
|
||||||
|
TrendMarker.E -> copy(e = value); TrendMarker.F -> copy(f = value)
|
||||||
|
TrendMarker.G -> copy(g = value); TrendMarker.H -> copy(h = value)
|
||||||
}
|
}
|
||||||
fun positioned(bounds: PlotBounds): TrendMarkers = copy(
|
fun positioned(bounds: PlotBounds): TrendMarkers = copy(
|
||||||
a = a ?: bounds.value(1.0 / 3, false), b = b ?: bounds.value(2.0 / 3, false),
|
a = a ?: bounds.value(0.2, false), b = b ?: bounds.value(0.4, false),
|
||||||
c = c ?: bounds.value(2.0 / 3, true), d = d ?: bounds.value(1.0 / 3, true))
|
c = c ?: bounds.value(0.6, false), d = d ?: bounds.value(0.8, false),
|
||||||
fun reset(bounds: PlotBounds): TrendMarkers = copy(a = null, b = null, c = null, d = null).positioned(bounds)
|
e = e ?: bounds.value(0.2, true), f = f ?: bounds.value(0.4, true),
|
||||||
|
g = g ?: bounds.value(0.6, true), h = h ?: bounds.value(0.8, true))
|
||||||
|
fun reset(bounds: PlotBounds): TrendMarkers = copy(
|
||||||
|
a = null, b = null, c = null, d = null, e = null, f = null, g = null, h = null).positioned(bounds)
|
||||||
fun fraction(marker: TrendMarker, bounds: PlotBounds): Double = bounds.fraction(requireNotNull(value(marker)), marker.horizontal)
|
fun fraction(marker: TrendMarker, bounds: PlotBounds): Double = bounds.fraction(requireNotNull(value(marker)), marker.horizontal)
|
||||||
fun drag(marker: TrendMarker, delta: Double, length: Double, bounds: PlotBounds): TrendMarkers =
|
fun drag(marker: TrendMarker, delta: Double, length: Double, bounds: PlotBounds): TrendMarkers =
|
||||||
move(marker, bounds.drag(requireNotNull(value(marker)), delta, length, marker.horizontal)).copy(selected = marker)
|
move(marker, bounds.drag(requireNotNull(value(marker)), delta, length, marker.horizontal)).copy(selected = marker)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ data class TrendSignal(
|
|||||||
val deviceType: Int = 7,
|
val deviceType: Int = 7,
|
||||||
val device: Int = 13,
|
val device: Int = 13,
|
||||||
val byteOffset: Int = 0,
|
val byteOffset: Int = 0,
|
||||||
|
val multiplier: Double = 1.0,
|
||||||
|
val iq: Int = 0,
|
||||||
val extended: Boolean = true,
|
val extended: Boolean = true,
|
||||||
) {
|
) {
|
||||||
fun validationError(profile: TrendProfile): String? = when {
|
fun validationError(profile: TrendProfile): String? = when {
|
||||||
@@ -68,12 +70,24 @@ data class TrendSignal(
|
|||||||
source == TrendSource.CAN_RAW && parseTrendAddress(address, if (extended) 0x1FFF_FFFF else 0x7FF) == null ->
|
source == TrendSource.CAN_RAW && parseTrendAddress(address, if (extended) 0x1FFF_FFFF else 0x7FF) == null ->
|
||||||
"CAN ID вне диапазона ${if (extended) "29" else "11"} бит"
|
"CAN ID вне диапазона ${if (extended) "29" else "11"} бит"
|
||||||
source == TrendSource.CAN_RAW && byteOffset !in 0..6 -> "Смещение слова: 0…6 байт"
|
source == TrendSource.CAN_RAW && byteOffset !in 0..6 -> "Смещение слова: 0…6 байт"
|
||||||
|
!multiplier.isFinite() -> "Множитель должен быть конечным числом"
|
||||||
|
iq !in 0..30 -> "IQ должен быть в диапазоне 0…30"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun displayValue(rawValue: Double): Double =
|
||||||
|
rawValue * multiplier / (1L shl iq).toDouble()
|
||||||
|
|
||||||
/** Only source changes invalidate the historical series. */
|
/** Only source changes invalidate the historical series. */
|
||||||
fun sameInput(other: TrendSignal): Boolean =
|
fun sameInput(other: TrendSignal): Boolean =
|
||||||
copy(order = other.order, name = other.name, color = other.color, visible = other.visible) == other
|
copy(
|
||||||
|
order = other.order,
|
||||||
|
name = other.name,
|
||||||
|
color = other.color,
|
||||||
|
visible = other.visible,
|
||||||
|
multiplier = other.multiplier,
|
||||||
|
iq = other.iq,
|
||||||
|
) == other
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val COLORS = listOf("#2F91FF", "#FFB547", "#52D6A4", "#F8798D", "#B79AFF", "#4AD9E8", "#E8DA68", "#E7ECF3")
|
val COLORS = listOf("#2F91FF", "#FFB547", "#52D6A4", "#F8798D", "#B79AFF", "#4AD9E8", "#E8DA68", "#E7ECF3")
|
||||||
@@ -105,7 +119,16 @@ fun parseTrendAddress(value: String, max: Long): Long? {
|
|||||||
|
|
||||||
fun normalizeRom(value: String): String = value.trim().replace("-", "").replace(" ", "").uppercase()
|
fun normalizeRom(value: String): String = value.trim().replace("-", "").replace(" ", "").uppercase()
|
||||||
|
|
||||||
data class TrendPoint(val timestampMs: Long, val value: Double)
|
data class TrendPoint(
|
||||||
|
val timestampMs: Long,
|
||||||
|
val value: Double,
|
||||||
|
/** Nanoseconds inside [timestampMs], preserving spacing above 1 kHz without overflowing epoch nanoseconds. */
|
||||||
|
val subMillisecondNanos: Int = 0,
|
||||||
|
) {
|
||||||
|
init { require(subMillisecondNanos in 0..999_999) }
|
||||||
|
fun nanosSince(other: TrendPoint): Long =
|
||||||
|
(timestampMs - other.timestampMs) * 1_000_000L + subMillisecondNanos - other.subMillisecondNanos
|
||||||
|
}
|
||||||
|
|
||||||
data class TrendUiState(
|
data class TrendUiState(
|
||||||
val section: TrendSection = TrendSection.SIGNALS,
|
val section: TrendSection = TrendSection.SIGNALS,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ object TrendSettingsJson {
|
|||||||
put("deviceType", signal.deviceType)
|
put("deviceType", signal.deviceType)
|
||||||
put("device", signal.device)
|
put("device", signal.device)
|
||||||
put("byteOffset", signal.byteOffset)
|
put("byteOffset", signal.byteOffset)
|
||||||
|
put("multiplier", signal.multiplier)
|
||||||
|
put("iq", signal.iq)
|
||||||
put("extended", signal.extended)
|
put("extended", signal.extended)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,10 @@ object TrendSettingsJson {
|
|||||||
color = item.strictString("color"), visible = item.strictBoolean("visible"),
|
color = item.strictString("color"), visible = item.strictBoolean("visible"),
|
||||||
valueType = TrendValueType.valueOf(item.strictString("valueType")),
|
valueType = TrendValueType.valueOf(item.strictString("valueType")),
|
||||||
deviceType = item.strictInt("deviceType"), device = item.strictInt("device"),
|
deviceType = item.strictInt("deviceType"), device = item.strictInt("device"),
|
||||||
byteOffset = item.strictInt("byteOffset"), extended = item.strictBoolean("extended"),
|
byteOffset = item.strictInt("byteOffset"),
|
||||||
|
multiplier = if (item.has("multiplier")) item.strictDouble("multiplier") else 1.0,
|
||||||
|
iq = if (item.has("iq")) item.strictInt("iq") else 0,
|
||||||
|
extended = item.strictBoolean("extended"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +80,12 @@ object TrendSettingsJson {
|
|||||||
return number.toInt()
|
return number.toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.strictDouble(key: String): Double {
|
||||||
|
val value = get(key)
|
||||||
|
require(value is Number) { "$key должен быть числом" }
|
||||||
|
return value.toDouble().also { require(it.isFinite()) { "$key должен быть конечным числом" } }
|
||||||
|
}
|
||||||
|
|
||||||
fun validate(settings: Map<TrendProfile, List<TrendSignal>>) {
|
fun validate(settings: Map<TrendProfile, List<TrendSignal>>) {
|
||||||
val allIds = settings.values.flatten().map { it.id }
|
val allIds = settings.values.flatten().map { it.id }
|
||||||
require(allIds.distinct().size == allIds.size) { "Идентификаторы трендов не должны повторяться" }
|
require(allIds.distinct().size == allIds.size) { "Идентификаторы трендов не должны повторяться" }
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ object SpectrumAnalyzer {
|
|||||||
if (!NativeSetProtocol.available) return TrendSpectrum(error = "Модуль FFT недоступен")
|
if (!NativeSetProtocol.available) return TrendSpectrum(error = "Модуль FFT недоступен")
|
||||||
val count = minOf(points.size, options.maxSize)
|
val count = minOf(points.size, options.maxSize)
|
||||||
val start = points.size - count
|
val start = points.size - count
|
||||||
val origin = points[start].timestampMs.toDouble()
|
val origin = points[start]
|
||||||
val times = DoubleArray(count) { (points[start + it].timestampMs.toDouble() - origin) / 1000.0 }
|
val times = DoubleArray(count) { points[start + it].nanosSince(origin) / 1_000_000_000.0 }
|
||||||
val values = DoubleArray(count) { points[start + it].value }
|
val values = DoubleArray(count) { points[start + it].value }
|
||||||
val output = NativeSetProtocol.nativeSpectrum(times, values, options.maxSize,
|
val output = NativeSetProtocol.nativeSpectrum(times, values, options.maxSize,
|
||||||
options.window.ordinal, options.filter.ordinal, options.lowHz, options.highHz, options.removeMean)
|
options.window.ordinal, options.filter.ordinal, options.lowHz, options.highHz, options.removeMean)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class PlotContractTest {
|
|||||||
val dragged = markers.drag(TrendMarker.A, 50.0, 500.0, zoomed)
|
val dragged = markers.drag(TrendMarker.A, 50.0, 500.0, zoomed)
|
||||||
assertEquals(markers.a!! + 50, dragged.a!!, 1e-9)
|
assertEquals(markers.a!! + 50, dragged.a!!, 1e-9)
|
||||||
assertEquals(markers.b, dragged.b)
|
assertEquals(markers.b, dragged.b)
|
||||||
assertEquals(TrendMarker.A, dragged.hit(100.0, 50.0, 500.0, 100.0, 100.0, zoomed))
|
assertEquals(TrendMarker.A, dragged.hit(0.0, 50.0, 500.0, 100.0, 20.0, zoomed))
|
||||||
val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0)
|
val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0)
|
||||||
assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0)
|
assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ class Marker(IntEnum):
|
|||||||
B = 1
|
B = 1
|
||||||
C = 2
|
C = 2
|
||||||
D = 3
|
D = 3
|
||||||
|
E = 4
|
||||||
|
F = 5
|
||||||
|
G = 6
|
||||||
|
H = 7
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def horizontal(self) -> bool:
|
def horizontal(self) -> bool:
|
||||||
return self in (Marker.C, Marker.D)
|
return self in (Marker.E, Marker.F, Marker.G, Marker.H)
|
||||||
|
|
||||||
|
|
||||||
class PlotMath:
|
class PlotMath:
|
||||||
@@ -103,6 +107,10 @@ class Markers:
|
|||||||
b: Optional[float] = None
|
b: Optional[float] = None
|
||||||
c: Optional[float] = None
|
c: Optional[float] = None
|
||||||
d: Optional[float] = None
|
d: Optional[float] = None
|
||||||
|
e: Optional[float] = None
|
||||||
|
f: Optional[float] = None
|
||||||
|
g: Optional[float] = None
|
||||||
|
h: Optional[float] = None
|
||||||
|
|
||||||
def value(self, marker: Marker) -> Optional[float]:
|
def value(self, marker: Marker) -> Optional[float]:
|
||||||
return getattr(self, marker.name.lower())
|
return getattr(self, marker.name.lower())
|
||||||
@@ -115,13 +123,14 @@ class Markers:
|
|||||||
|
|
||||||
def positioned(self, core: PlotMath, bounds: Bounds) -> "Markers":
|
def positioned(self, core: PlotMath, bounds: Bounds) -> "Markers":
|
||||||
result = self
|
result = self
|
||||||
for marker, fraction in ((Marker.A, 1/3), (Marker.B, 2/3), (Marker.C, 2/3), (Marker.D, 1/3)):
|
for marker, fraction in ((Marker.A, .2), (Marker.B, .4), (Marker.C, .6), (Marker.D, .8),
|
||||||
|
(Marker.E, .2), (Marker.F, .4), (Marker.G, .6), (Marker.H, .8)):
|
||||||
if result.value(marker) is None:
|
if result.value(marker) is None:
|
||||||
result = result.move(marker, bounds.value(core, fraction, marker.horizontal))
|
result = result.move(marker, bounds.value(core, fraction, marker.horizontal))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def reset(self, core: PlotMath, bounds: Bounds) -> "Markers":
|
def reset(self, core: PlotMath, bounds: Bounds) -> "Markers":
|
||||||
return replace(self, a=None, b=None, c=None, d=None).positioned(core, bounds)
|
return replace(self, a=None, b=None, c=None, d=None, e=None, f=None, g=None, h=None).positioned(core, bounds)
|
||||||
|
|
||||||
def fraction(self, core: PlotMath, marker: Marker, bounds: Bounds) -> float:
|
def fraction(self, core: PlotMath, marker: Marker, bounds: Bounds) -> float:
|
||||||
value = self.value(marker)
|
value = self.value(marker)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class PlotTests(unittest.TestCase):
|
|||||||
moved = markers.drag(self.core, Marker.A, 50, 500, zoomed)
|
moved = markers.drag(self.core, Marker.A, 50, 500, zoomed)
|
||||||
self.assertAlmostEqual(moved.a, markers.a + 50)
|
self.assertAlmostEqual(moved.a, markers.a + 50)
|
||||||
self.assertEqual(markers.b, moved.b)
|
self.assertEqual(markers.b, moved.b)
|
||||||
self.assertEqual(Marker.A, moved.hit(self.core, 100, 50, 500, 100, 100, zoomed))
|
self.assertEqual(Marker.A, moved.hit(self.core, 0, 50, 500, 100, 20, zoomed))
|
||||||
crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100)
|
crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100)
|
||||||
self.assertEqual(-800, self.core.delta(crossed.a, crossed.b))
|
self.assertEqual(-800, self.core.delta(crossed.a, crossed.b))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user