diff --git a/c/set-protocol/docs/GUI_PLOT.md b/c/set-protocol/docs/GUI_PLOT.md index d57233b..97f9eda 100644 --- a/c/set-protocol/docs/GUI_PLOT.md +++ b/c/set-protocol/docs/GUI_PLOT.md @@ -3,7 +3,7 @@ Численная логика находится в `include/set_plot.h` и `src/set_plot.c`. Это модуль C99 без Qt, Android, транспорта, динамической памяти и глобального состояния. Он собирается в существующую библиотеку SETProtocol; отдельная DLL -для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 1`. +для графиков не требуется. Версия ABI графиков — `set_plot_abi_version() == 2`. | Общее в templates | Адаптер приложения | |---|---| @@ -13,6 +13,7 @@ | Перевод значения в долю экрана и обратно, инверсия Y | Canvas/QPainter и оформление шкал | | Перемещение маркера от начальной координаты, ограничение видимой областью | Захват линии пальцем/мышью, редактор положения | | Разность B−A, D−C и множитель единиц, шаги шкалы 1/2/5 | Подписи, цвета, миллисекунды/герцы/единицы сигнала | +| Проверка абсолютных границ X/Y для фиксации осей | Диалог ввода и хранение отдельных границ времени/FFT | | Модели маркеров и их размещение в Kotlin/Python-портах | Жизненный цикл экрана, очистка и выбор источника | Модель маркеров: A/B — координаты X и вертикальные линии во всю высоту поля; @@ -59,8 +60,9 @@ SETGUI: `ui/plot_interaction.py` адаптирует общий модуль к используются исходные метки приёма. Дискретные дорожки имеют общую X-шкалу, а маркеры уровня Y относятся к аналоговому полю. -Расчёт FFT не является частью этого модуля. Android вычисляет спектр через -`set_spectrum.c`; вкладка спектра SETGUI получает готовые уровни в дБмВ от +Расчёт FFT не является частью этого модуля. Android и SETGUI вычисляют спектр через +`set_spectrum.c`; там же находится общий поиск доминирующего узкополосного пика, +а трёхсекундный таймер его отображения остаётся состоянием GUI. Вкладка спектра SETGUI получает готовые уровни в дБмВ от прибора. Общая интерактивная часть не меняет эти данные или единицы. ## Проверка и сборка diff --git a/c/set-protocol/docs/GUI_TRENDS.md b/c/set-protocol/docs/GUI_TRENDS.md index 6a3819b..2898b89 100644 --- a/c/set-protocol/docs/GUI_TRENDS.md +++ b/c/set-protocol/docs/GUI_TRENDS.md @@ -12,7 +12,7 @@ | `include/set_trends.h`, `src/set_trends.c` | C99: фильтрация GAS/raw CAN, signed/unsigned word, payload подписки SET GUI; использует `pcan_id` и ABI export macro | | `ports/android/kotlin/ru/setcorp/setprotocol/trends/` | Модель, валидация JSON, ограниченная история, GAS_WATCH; JVM + org.json, без Android/Compose | | `ports/android/setprotocol_jni.c` | Только преобразование JNI-аргументов | -| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends`; Python 3.9+, stdlib, без Qt | +| `python/protocan/trends.py` | Модель, JSON, история и `NativeTrends` (word/CAN/GAS request/ack/data); Python 3.9+, stdlib, без Qt | | `tests/fixtures/trends-v1.json` | Один образец для тестов обоих GUI и обмена настройками | ## Формат файла @@ -44,6 +44,8 @@ UTF-8 JSON: `format = "setflash-trends"`, `version = 1`, `profiles` — слов CAN — пассивный приём, без записи GAS и автоматической отправки запросов. GAS принимает только входящие FROM_DEVICE; TX/RTR/ошибки исключены. +Полный `GAS_WATCH_DATA`, включая 32-битную метку прибора, разбирается функцией +`set_trend_watch_decode`; UI не знает смещений полей и endian. SET GUI оформляет подписку в порядке адресов. ACK должен подтвердить весь список: при частичном принятии нельзя определить пропущенные адреса, поэтому строить график по смещённым индексам запрещено. При паузе порт приложения diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index 2ae6847..df7cc60 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -17,10 +17,11 @@ enum set_plot_operation { SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */ SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */ SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */ - SET_PLOT_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */ + SET_PLOT_DB_DELTA = 7, /* A,B -> 20*log10(abs(B/A)); zero is invalid */ + SET_PLOT_LIMITS = 8 /* xMin,xMax,yMin,yMax -> validated unchanged limits */ }; -/** Version of this plot ABI, independently of the transport ABI. */ +/** Version of this plot ABI, independently of the transport ABI (currently 2). */ PCAN_ABI_API uint32_t set_plot_abi_version(void); /** Evaluates one operation. Returns output count, or 0 for invalid arguments. * TRANSFORM rejects malformed viewports; invalid gesture values return the diff --git a/c/set-protocol/include/set_spectrum.h b/c/set-protocol/include/set_spectrum.h index 554c1a5..8747924 100644 --- a/c/set-protocol/include/set_spectrum.h +++ b/c/set-protocol/include/set_spectrum.h @@ -28,6 +28,15 @@ PCAN_ABI_API int set_spectrum_analyze(const double *times, const double *values, size_t max_size, int window, int filter, double low_hz, double high_hz, int remove_mean, double *amplitudes, size_t capacity, double *meta); +/** Find the strongest non-DC local maximum above both the absolute floor and + * relative_threshold * median(non-DC amplitudes). The caller supplies scratch + * storage of at least count-1 doubles. peak = {frequency_hz, amplitude}. + * Returns 1 when found, 0 when no narrow-band peak exists, -1 on invalid input. + */ +PCAN_ABI_API int set_spectrum_dominant_peak(const double *amplitudes, size_t count, + double bin_hz, double relative_threshold, double absolute_floor, + double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity); + #ifdef __cplusplus } #endif diff --git a/c/set-protocol/include/set_trends.h b/c/set-protocol/include/set_trends.h index 1693d4a..b187f0c 100644 --- a/c/set-protocol/include/set_trends.h +++ b/c/set-protocol/include/set_trends.h @@ -48,6 +48,13 @@ PCAN_ABI_API int set_trend_watch_ack( PCAN_ABI_API int set_trend_watch_values( const uint8_t *payload, size_t size, uint16_t *words, size_t capacity); +/** Decode the complete GAS_WATCH_DATA payload including its device timestamp. + * This is the preferred ABI for GUI ports; the older values-only symbol remains + * available for binary compatibility. + */ +PCAN_ABI_API int set_trend_watch_decode(const uint8_t *payload, size_t size, + uint32_t *timestamp_ms, uint16_t *words, size_t capacity); + #ifdef __cplusplus } #endif diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt index 8aa7255..bc11653 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt @@ -16,12 +16,18 @@ object NativeSetProtocol { /** {status, N, Fs, jitter, amplitudes...}; status != 0 has no amplitudes. */ external fun nativeSpectrum(times: DoubleArray, values: DoubleArray, maxSize: Int, window: Int, filter: Int, lowHz: Double, highHz: Double, removeMean: Boolean): DoubleArray? + /** {frequencyHz, amplitude}, empty when no narrow-band peak is present. */ + external fun nativeSpectrumPeak(amplitudes: DoubleArray, binHz: Double, + relativeThreshold: Double, absoluteFloor: Double): DoubleArray? external fun nativeTrendCanValue( source: Int, address: Long, deviceType: Int, device: Int, byteOffset: Int, extended: Boolean, signed: Boolean, canId: Long, flags: Int, input: ByteArray, ): Int external fun nativeTrendWatchRequest(period: Int, addresses: IntArray): ByteArray? + external fun nativeTrendWatchAck(input: ByteArray, period: Int, count: Int): Boolean external fun nativeTrendWatchValues(input: ByteArray): IntArray? + /** {unsigned timestampMs, word0, ...}. */ + external fun nativeTrendWatchDecode(input: ByteArray): LongArray? external fun nativePackId( priority: Int, route: Int, diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt index 5a814ad..924ed9c 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch.kt @@ -10,37 +10,28 @@ object GuiGasWatch { fun request(periodMs: Int, addresses: List): ByteArray { require(periodMs in 0..65535 && addresses.size <= 64 && addresses.all { it in 0..65535 }) - if (NativeSetProtocol.available) return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray())) - return ByteArray(4 + addresses.size * 2).also { output -> - put16(output, 0, periodMs) - put16(output, 2, addresses.size) - addresses.forEachIndexed { index, address -> put16(output, 4 + index * 2, address) } - } + check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } + return requireNotNull(NativeSetProtocol.nativeTrendWatchRequest(periodMs, addresses.toIntArray())) } fun validateAck(payload: ByteArray, periodMs: Int, count: Int) { - require(payload.size == 4 && read16(payload, 0) == periodMs && read16(payload, 2) == count) { + require(NativeSetProtocol.available && NativeSetProtocol.nativeTrendWatchAck(payload, periodMs, count)) { "Прибор принял не все адреса GAS. Проверьте карту регистров; отображение по неполной подписке невозможно" } } fun values(payload: ByteArray, expectedCount: Int): List { - val values = if (NativeSetProtocol.available) { - requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList() - } else { - require(payload.size >= 6) { "GAS_WATCH_DATA короче заголовка" } - val count = read16(payload, 4) - require(count <= 64 && payload.size == 6 + count * 2) { "Неверная длина GAS_WATCH_DATA" } - List(count) { read16(payload, 6 + it * 2) } - } + check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } + val values = requireNotNull(NativeSetProtocol.nativeTrendWatchValues(payload)) { "Повреждён GAS_WATCH_DATA" }.toList() require(values.size == expectedCount) { "Число значений GAS не соответствует подписке" } return values } - private fun read16(data: ByteArray, offset: Int): Int = - (data[offset].toInt() and 0xFF) or ((data[offset + 1].toInt() and 0xFF) shl 8) - private fun put16(data: ByteArray, offset: Int, value: Int) { - data[offset] = value.toByte() - data[offset + 1] = (value ushr 8).toByte() + data class Sample(val timestampMs: Long, val values: List) + fun decode(payload: ByteArray, expectedCount: Int): Sample { + check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } + val decoded = requireNotNull(NativeSetProtocol.nativeTrendWatchDecode(payload)) { "Повреждён GAS_WATCH_DATA" } + require(decoded.size == expectedCount + 1) { "Число значений GAS не соответствует подписке" } + return Sample(decoded[0], decoded.drop(1).map(Long::toInt)) } } diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt index 92f264b..1ce5a53 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt @@ -32,3 +32,10 @@ fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, ran fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0] fun plotDbDelta(a: Double, b: Double): Double? = runCatching { NativePlot.call(7, a, b)[0] }.getOrNull() + +data class PlotLimits(val xMin: Double, val xMax: Double, val yMin: Double, val yMax: Double) { + fun validated(): PlotLimits { + val values = NativePlot.call(8, xMin, xMax, yMin, yMax) + return PlotLimits(values[0], values[1], values[2], values[3]) + } +} diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt index 5f02bd3..cf8679c 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/TrendSpectrum.kt @@ -34,6 +34,8 @@ data class TrendSpectrum( val binHz: Double get() = if (size > 0) sampleRate / size else 0.0 } +data class SpectrumPeak(val frequencyHz: Double, val amplitude: Double) + /** Math is implemented once in C and used unchanged by JNI and ctypes. */ object SpectrumAnalyzer { fun analyze(points: List, options: SpectrumOptions): TrendSpectrum { @@ -64,4 +66,12 @@ object SpectrumAnalyzer { return TrendSpectrum(output[1].toInt(), output[2], output[3], if (error == null) output.drop(4) else emptyList(), error) } + + fun dominantPeak(spectrum: TrendSpectrum, relativeThreshold: Double = 3.0, + absoluteFloor: Double = 1e-6): SpectrumPeak? { + if (spectrum.error != null || spectrum.size <= 0 || spectrum.amplitudes.size < 3) return null + val result = NativeSetProtocol.nativeSpectrumPeak(spectrum.amplitudes.toDoubleArray(), + spectrum.binHz, relativeThreshold, absoluteFloor) ?: return null + return result.takeIf { it.size == 2 }?.let { SpectrumPeak(it[0], it[1]) } + } } diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c index 7f916f7..57da780 100644 --- a/c/set-protocol/ports/android/setprotocol_jni.c +++ b/c/set-protocol/ports/android/setprotocol_jni.c @@ -276,6 +276,30 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendCanValue( (uint8_t)flags, (const uint8_t *)data, (size_t)size); } +JNIEXPORT jdoubleArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeSpectrumPeak( + JNIEnv *env, jobject self, jdoubleArray input, jdouble bin_hz, + jdouble relative_threshold, jdouble absolute_floor) +{ + (void)self; + jsize count = input == NULL ? 0 : (*env)->GetArrayLength(env, input); + if (count < 3 || count > (jsize)(SET_SPECTRUM_MAX / 2U + 1U)) return NULL; + double *buffer = (double *)malloc(sizeof(double) * (size_t)(count * 2 - 1)); + if (buffer == NULL) return NULL; + double *amplitudes = buffer, *scratch = buffer + count, peak[2]; + (*env)->GetDoubleArrayRegion(env, input, 0, count, amplitudes); + int status = (*env)->ExceptionCheck(env) ? -1 : set_spectrum_dominant_peak( + amplitudes, (size_t)count, bin_hz, relative_threshold, absolute_floor, + scratch, (size_t)count - 1U, peak, 2U); + jdoubleArray result = NULL; + if (status >= 0) { + result = (*env)->NewDoubleArray(env, status == 1 ? 2 : 0); + if (result != NULL && status == 1) (*env)->SetDoubleArrayRegion(env, result, 0, 2, peak); + } + free(buffer); + return result; +} + JNIEXPORT jbyteArray JNICALL Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest( JNIEnv *env, jobject self, jint period, jintArray input) @@ -298,6 +322,20 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchRequest( return result; } +JNIEXPORT jboolean JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchAck( + JNIEnv *env, jobject self, jbyteArray input, jint period, jint count) +{ + (void)self; + if (input == NULL || period < 0 || period > 65535 || count < 0) return JNI_FALSE; + jsize size = (*env)->GetArrayLength(env, input); + if (size != 4) return JNI_FALSE; + jbyte payload[4]; + (*env)->GetByteArrayRegion(env, input, 0, size, payload); + return set_trend_watch_ack((const uint8_t *)payload, (size_t)size, + (uint16_t)period, (size_t)count) ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT jintArray JNICALL Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues( JNIEnv *env, jobject self, jbyteArray input) @@ -317,6 +355,28 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchValues( return result; } +JNIEXPORT jlongArray JNICALL +Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchDecode( + JNIEnv *env, jobject self, jbyteArray input) +{ + (void)self; + jsize size = input == NULL ? 0 : (*env)->GetArrayLength(env, input); + if (size < 6 || size > (jsize)(6U + 2U * SET_TREND_WATCH_MAX)) return NULL; + jbyte payload[6U + 2U * SET_TREND_WATCH_MAX]; + uint16_t words[SET_TREND_WATCH_MAX]; + uint32_t timestamp = 0U; + jlong values[1U + SET_TREND_WATCH_MAX]; + (*env)->GetByteArrayRegion(env, input, 0, size, payload); + int count = set_trend_watch_decode((const uint8_t *)payload, (size_t)size, + ×tamp, words, SET_TREND_WATCH_MAX); + if (count < 0) return NULL; + values[0] = (jlong)timestamp; + for (int i = 0; i < count; ++i) values[i + 1] = (jlong)words[i]; + jlongArray result = (*env)->NewLongArray(env, count + 1); + if (result != NULL) (*env)->SetLongArrayRegion(env, result, 0, count + 1, values); + return result; +} + typedef struct { uint8_t *storage; size_t storage_size; diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt index d9918d2..af6962b 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotViewportTest.kt @@ -36,4 +36,12 @@ class PlotViewportTest { } SpectrumOptions().validate() } + @Test fun absoluteLimitsAndDominantPeakUseNativeCore() { + assertEquals(PlotLimits(0.0, 500.0, -1.0, 1.0), + PlotLimits(0.0, 500.0, -1.0, 1.0).validated()) + assertTrue(runCatching { PlotLimits(1.0, 1.0, -1.0, 1.0).validated() }.isFailure) + val spectrum = TrendSpectrum(12, 60.0, 0.0, + listOf(10.0, .01, .02, .8, .03, .4, .02)) + assertEquals(15.0, SpectrumAnalyzer.dominantPeak(spectrum)!!.frequencyHz, 0.0) + } } diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 66ed13c..368275b 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -8,13 +8,13 @@ static int finite_values(const double *v, size_t n) { return 1; } -uint32_t set_plot_abi_version(void) { return 1U; } +uint32_t set_plot_abi_version(void) { return 2U; } size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap) { - static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2}; + static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2, 4}; double span, fraction; - if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] || - cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0; + if (op > SET_PLOT_LIMITS || !v || !out || n != sizes[op] || + cap < (op == SET_PLOT_TRANSFORM || op == SET_PLOT_LIMITS ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; if (!finite_values(v, 4) || v[2] < 1.0/128 || v[2] > 1 || @@ -69,6 +69,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t if (v[0] == 0 || v[1] == 0) return 0; out[0] = 20 * log10(fabs(v[1] / v[0])); break; + case SET_PLOT_LIMITS: + if (v[0] >= v[1] || v[2] >= v[3]) return 0; + out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3]; + return 4; default: return 0; } return isfinite(out[0]) ? 1 : 0; diff --git a/c/set-protocol/src/set_spectrum.c b/c/set-protocol/src/set_spectrum.c index a73003e..259f0ec 100644 --- a/c/set-protocol/src/set_spectrum.c +++ b/c/set-protocol/src/set_spectrum.c @@ -4,6 +4,13 @@ #define PI 3.14159265358979323846 +static int compare_double(const void *left, const void *right) +{ + const double a = *(const double *)left; + const double b = *(const double *)right; + return (a > b) - (a < b); +} + static double window_value(int window, size_t index, size_t n) { double phase = 2.0 * PI * (double)index / (double)n; @@ -124,3 +131,35 @@ int set_spectrum_analyze(const double *times, const double *values, size_t count free(scratch); return result; } + +int set_spectrum_dominant_peak(const double *amplitudes, size_t count, + double bin_hz, double relative_threshold, double absolute_floor, + double *scratch, size_t scratch_capacity, double *peak, size_t peak_capacity) +{ + size_t i, usable = 0U, peak_bin = 0U; + double peak_amplitude = -1.0, median, threshold; + if (amplitudes == NULL || scratch == NULL || peak == NULL || count < 3U || + scratch_capacity < count - 1U || peak_capacity < 2U || !isfinite(bin_hz) || + bin_hz <= 0.0 || !isfinite(relative_threshold) || relative_threshold <= 0.0 || + !isfinite(absolute_floor) || absolute_floor < 0.0) return -1; + for (i = 1U; i < count; ++i) { + if (isfinite(amplitudes[i]) && amplitudes[i] >= 0.0) + scratch[usable++] = amplitudes[i]; + } + if (usable < 2U) return 0; + qsort(scratch, usable, sizeof(double), compare_double); + median = scratch[usable / 2U]; + for (i = 1U; i + 1U < count; ++i) { + const double value = amplitudes[i]; + if (isfinite(value) && isfinite(amplitudes[i - 1U]) && isfinite(amplitudes[i + 1U]) && + value >= amplitudes[i - 1U] && value > amplitudes[i + 1U] && value > peak_amplitude) { + peak_bin = i; + peak_amplitude = value; + } + } + threshold = fmax(absolute_floor, median * relative_threshold); + if (peak_bin == 0U || peak_amplitude < threshold) return 0; + peak[0] = (double)peak_bin * bin_hz; + peak[1] = peak_amplitude; + return 1; +} diff --git a/c/set-protocol/src/set_trends.c b/c/set-protocol/src/set_trends.c index c1c2203..4c3da43 100644 --- a/c/set-protocol/src/set_trends.c +++ b/c/set-protocol/src/set_trends.c @@ -6,6 +6,12 @@ static uint16_t get16(const uint8_t *p) return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); } +static uint32_t get32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + static void put16(uint8_t *p, uint16_t value) { p[0] = (uint8_t)value; @@ -63,6 +69,12 @@ int set_trend_watch_ack(const uint8_t *payload, size_t size, uint16_t period_ms, } int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, size_t capacity) +{ + return set_trend_watch_decode(payload, size, NULL, words, capacity); +} + +int set_trend_watch_decode(const uint8_t *payload, size_t size, uint32_t *timestamp_ms, + uint16_t *words, size_t capacity) { size_t i, count; if (payload == NULL || size < 6U) return -1; @@ -70,5 +82,6 @@ int set_trend_watch_values(const uint8_t *payload, size_t size, uint16_t *words, if (count > SET_TREND_WATCH_MAX || size != 6U + count * 2U || count > capacity || (count > 0U && words == NULL)) return -1; for (i = 0U; i < count; ++i) words[i] = get16(payload + 6U + i * 2U); + if (timestamp_ms != NULL) *timestamp_ms = get32(payload); return (int)count; } diff --git a/c/set-protocol/tests/test_plot.c b/c/set-protocol/tests/test_plot.c index e606956..6615548 100644 --- a/c/set-protocol/tests/test_plot.c +++ b/c/set-protocol/tests/test_plot.c @@ -9,7 +9,7 @@ int main(void) { const double y[] = {15, 10, 30, 1}; const double drag[] = {0, 100, 500, -10, 10, 1}; double back[4], bad[] = {NAN, 10, 8}; - assert(set_plot_abi_version() == 1); + assert(set_plot_abi_version() == 2); assert(set_plot_eval(SET_PLOT_TRANSFORM, zoom, 10, output, 4) == 4); assert(output[0] == .125 && output[1] == 0 && output[2] == .5 && output[3] == 1); assert(output[4] == 12345); @@ -26,6 +26,13 @@ int main(void) { assert(output[0] == y[0]); assert(set_plot_eval(SET_PLOT_DRAG, drag, 6, output, 4) == 1); assert(output[0] == -4); + { + const double limits[] = {0, 500, -2, 2}; + const double reversed[] = {1, 1, -2, 2}; + assert(set_plot_eval(SET_PLOT_LIMITS, limits, 4, output, 4) == 4); + assert(output[0] == 0 && output[1] == 500 && output[2] == -2 && output[3] == 2); + assert(set_plot_eval(SET_PLOT_LIMITS, reversed, 4, output, 4) == 0); + } puts("shared plot: OK"); return 0; } diff --git a/c/set-protocol/tests/test_spectrum.c b/c/set-protocol/tests/test_spectrum.c index 0de2e94..64a36b7 100644 --- a/c/set-protocol/tests/test_spectrum.c +++ b/c/set-protocol/tests/test_spectrum.c @@ -17,6 +17,15 @@ int main(void) assert(set_spectrum_analyze(times, values, 32, 32, 0, SET_FILTER_LOW_PASS, 0, 16, 1, output, 17, meta) == SET_SPECTRUM_CUTOFF); times[12] = times[11]; assert(set_spectrum_analyze(times, values, 32, 32, 0, 0, 0, 0, 1, output, 17, meta) == SET_SPECTRUM_TIMING); + { + const double amplitudes[] = {10.0, .01, .02, .8, .03, .4, .02}; + const double noise[] = {0, .10, .12, .11, .09, .10}; + double scratch[6], peak[2]; + assert(set_spectrum_dominant_peak(amplitudes, 7, 5, 3, 1e-6, scratch, 6, peak, 2) == 1); + assert(peak[0] == 15 && peak[1] == .8); + assert(set_spectrum_dominant_peak(noise, 6, 1, 3, 1e-6, scratch, 6, peak, 2) == 0); + assert(set_spectrum_dominant_peak(amplitudes, 7, 0, 3, 1e-6, scratch, 6, peak, 2) == -1); + } puts("shared spectrum: OK"); return 0; } diff --git a/c/set-protocol/tests/test_trends.c b/c/set-protocol/tests/test_trends.c index 97f10ee..f357bd2 100644 --- a/c/set-protocol/tests/test_trends.c +++ b/c/set-protocol/tests/test_trends.c @@ -34,8 +34,11 @@ int main(void) assert(!set_trend_watch_ack(expected, 4, 1000, 3)); const uint8_t packet[] = {1, 2, 3, 4, 2, 0, 52, 18, 255, 255}; uint16_t words[64]; + uint32_t timestamp = 0; assert(set_trend_watch_values(packet, sizeof(packet), words, 64) == 2); assert(words[0] == 0x1234 && words[1] == 0xFFFF); + assert(set_trend_watch_decode(packet, sizeof(packet), ×tamp, words, 64) == 2); + assert(timestamp == 0x04030201UL); assert(set_trend_watch_values(packet, sizeof(packet) - 1, words, 64) == -1); assert(set_trend_watch_values(packet, sizeof(packet), words, 1) == -1); puts("Shared trend tests passed"); diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 67248f5..967638d 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -37,7 +37,7 @@ class PlotMath: self.library = library library.set_plot_abi_version.restype = ctypes.c_uint32 library.set_plot_abi_version.argtypes = [] - if library.set_plot_abi_version() != 1: + if library.set_plot_abi_version() != 2: raise RuntimeError("Unsupported plot ABI") library.set_plot_eval.argtypes = [ctypes.c_uint32, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t, ctypes.POINTER(ctypes.c_double), ctypes.c_size_t] @@ -67,6 +67,10 @@ class PlotMath: except ValueError: return None + def limits(self, left: float, right: float, bottom: float, top: float) -> "Bounds": + """Validate absolute axis limits in the shared core.""" + return Bounds(*self.call(8, left, right, bottom, top)) + @dataclass(frozen=True) class Viewport: @@ -92,6 +96,9 @@ class Bounds: bottom: float top: float + def validated(self, core: PlotMath) -> "Bounds": + return core.limits(self.left, self.right, self.bottom, self.top) + def fraction(self, core: PlotMath, value: float, horizontal: bool) -> float: return core.call(2, value, self.bottom if horizontal else self.left, self.top if horizontal else self.right, int(horizontal))[0] diff --git a/python/protocan/spectrum.py b/python/protocan/spectrum.py index 1ce87ec..07b1eb5 100644 --- a/python/protocan/spectrum.py +++ b/python/protocan/spectrum.py @@ -37,6 +37,12 @@ class Spectrum: return tuple(i * self.sample_rate / self.size for i in range(len(self.amplitudes))) +@dataclass(frozen=True) +class SpectrumPeak: + frequency_hz: float + amplitude: float + + class NativeSpectrum: def __init__(self, library: ctypes.CDLL): self.lib = library @@ -46,6 +52,11 @@ class NativeSpectrum: ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, ctypes.c_int, pointer, ctypes.c_size_t, pointer] self._analyze.restype = ctypes.c_int + self._peak = library.set_spectrum_dominant_peak + self._peak.argtypes = [pointer, ctypes.c_size_t, ctypes.c_double, + ctypes.c_double, ctypes.c_double, pointer, ctypes.c_size_t, + pointer, ctypes.c_size_t] + self._peak.restype = ctypes.c_int def analyze(self, times, values, *, max_size=4096, window=Window.HANN, filter=Filter.NONE, low_hz=10.0, high_hz=100.0, remove_mean=True) -> Spectrum: @@ -69,3 +80,15 @@ class NativeSpectrum: raise ValueError(message.get(status, "FFT failed")) n = int(meta[0]) return Spectrum(n, meta[1], meta[2], tuple(output[:n // 2 + 1])) + + def dominant_peak(self, spectrum: Spectrum, *, relative_threshold: float = 3.0, + absolute_floor: float = 1e-6) -> SpectrumPeak | None: + amplitudes = (ctypes.c_double * len(spectrum.amplitudes))(*spectrum.amplitudes) + scratch = (ctypes.c_double * max(1, len(spectrum.amplitudes) - 1))() + output = (ctypes.c_double * 2)() + status = self._peak(amplitudes, len(spectrum.amplitudes), + spectrum.sample_rate / spectrum.size, relative_threshold, absolute_floor, + scratch, len(scratch), output, 2) + if status < 0: + raise ValueError("Invalid spectrum peak input") + return SpectrumPeak(output[0], output[1]) if status else None diff --git a/python/protocan/trends.py b/python/protocan/trends.py index e047e1b..94c67fa 100644 --- a/python/protocan/trends.py +++ b/python/protocan/trends.py @@ -99,12 +99,6 @@ class TrendSignal: if self.source == "CAN_RAW" and not 0 <= self.byteOffset <= 6: raise ValueError("Word offset must be 0..6") - def word_value(self, word: int) -> float: - if not 0 <= word <= 65535: - raise ValueError("Not a 16-bit word") - return float(word - 65536 if self.valueType == "INT16" and word >= 32768 else word) - - def validate_settings(settings: Mapping[str, list[TrendSignal]]) -> None: ids = set() for profile, signals in settings.items(): @@ -178,6 +172,20 @@ class NativeTrends: ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint32, ctypes.c_uint8, ctypes.c_void_p, ctypes.c_size_t] self.decode.restype = ctypes.c_int32 + self._word = library.set_trend_word_value + self._word.argtypes = [ctypes.c_uint16, ctypes.c_uint8] + self._word.restype = ctypes.c_int32 + self._watch_request = library.set_trend_watch_request + self._watch_request.argtypes = [ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16), + ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t] + self._watch_request.restype = ctypes.c_size_t + self._watch_ack = library.set_trend_watch_ack + self._watch_ack.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint16, ctypes.c_size_t] + self._watch_ack.restype = ctypes.c_int + self._watch_decode = library.set_trend_watch_decode + self._watch_decode.argtypes = [ctypes.c_void_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t] + self._watch_decode.restype = ctypes.c_int def can_value(self, signal: TrendSignal, can_id: int, flags: int, data: bytes) -> float | None: if signal.source not in ("CAN_GAS", "CAN_RAW"): @@ -191,3 +199,33 @@ class NativeTrends: signal.byteOffset, signal.extended, signal.valueType == "INT16", can_id, flags, payload, len(data)) return None if value == -2147483648 else float(value) + + def word_value(self, signal: TrendSignal, word: int) -> float: + if not 0 <= word <= 65535: + raise ValueError("Not a 16-bit word") + return float(self._word(word, signal.valueType == "INT16")) + + def watch_request(self, period_ms: int, addresses: list[int]) -> bytes: + if not 0 <= period_ms <= 65535 or len(addresses) > MAX_SIGNALS or any( + type(address) is not int or not 0 <= address <= 65535 for address in addresses): + raise ValueError("Invalid GAS watch request") + source = (ctypes.c_uint16 * len(addresses))(*addresses) + output = (ctypes.c_uint8 * (4 + len(addresses) * 2))() + size = self._watch_request(period_ms, source, len(addresses), output, len(output)) + if not size: + raise ValueError("Invalid GAS watch request") + return bytes(output[:size]) + + def validate_watch_ack(self, payload: bytes, period_ms: int, count: int) -> None: + data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload) + if not self._watch_ack(data, len(payload), period_ms, count): + raise ValueError("Device did not accept the complete GAS subscription") + + def watch_values(self, payload: bytes, expected_count: int | None = None) -> tuple[int, list[int]]: + data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload) + timestamp = ctypes.c_uint32() + words = (ctypes.c_uint16 * MAX_SIGNALS)() + count = self._watch_decode(data, len(payload), ctypes.byref(timestamp), words, MAX_SIGNALS) + if count < 0 or expected_count is not None and count != expected_count: + raise ValueError("Invalid GAS watch data") + return timestamp.value, list(words[:count]) diff --git a/python/tests/test_spectrum.py b/python/tests/test_spectrum.py index febddd0..60be5ac 100644 --- a/python/tests/test_spectrum.py +++ b/python/tests/test_spectrum.py @@ -35,6 +35,9 @@ class SpectrumTests(unittest.TestCase): peak = max(range(len(result.amplitudes)), key=result.amplitudes.__getitem__) self.assertEqual(64, result.frequencies[peak]) self.assertAlmostEqual(3.25, result.amplitudes[peak], places=9) + detected = self.core.dominant_peak(result) + self.assertAlmostEqual(64, detected.frequency_hz, places=9) + self.assertAlmostEqual(3.25, detected.amplitude, places=9) def test_dc_and_nyquist_are_not_doubled(self): times, _ = self.sample() diff --git a/python/tests/test_trends.py b/python/tests/test_trends.py index b18474b..9a82963 100644 --- a/python/tests/test_trends.py +++ b/python/tests/test_trends.py @@ -57,7 +57,6 @@ class TrendTests(unittest.TestCase): for value in ("-1", "+1", "FF", "0x", "1.0", "256"): with self.assertRaises(ValueError): parse_address(value, 255) - self.assertEqual(-2.0, TrendSignal("s", valueType="INT16").word_value(65534)) def test_bounded_history(self): history = TrendHistory() @@ -81,6 +80,13 @@ class TrendTests(unittest.TestCase): self.assertIsNone(core.can_value(signal, frame_id ^ 0x08000000, 1, data)) raw = replace(signal, source="CAN_RAW", address="0x321", extended=False, byteOffset=2) self.assertEqual(-2.0, core.can_value(raw, 0x321, 0, data)) + self.assertEqual(-2.0, core.word_value(TrendSignal("s", valueType="INT16"), 65534)) + self.assertEqual(bytes([232, 3, 2, 0, 52, 18, 255, 255]), + core.watch_request(1000, [0x1234, 0xFFFF])) + core.validate_watch_ack(bytes([232, 3, 2, 0]), 1000, 2) + timestamp, words = core.watch_values(bytes([1, 2, 3, 4, 2, 0, 52, 18, 255, 255]), 2) + self.assertEqual(0x04030201, timestamp) + self.assertEqual([0x1234, 0xFFFF], words) if __name__ == "__main__":