ESPectre SDK 2.8.0-280-gac7af68
Wi-Fi CSI motion sensing for ESP32 firmware
Loading...
Searching...
No Matches
csi_features.h
Go to the documentation of this file.
1/*
2 * ESPectre - Shared Feature Support
3 *
4 * Shared L1-delta constants plus C++ feature extraction helpers for the
5 * production scale-invariant ML feature set, and for the two members Lightweight
6 * reads directly. Every feature is a ratio, a correlation, or a crossing rate:
7 * the per-packet CSI scaling factor is never recorded, so anything carrying
8 * absolute magnitude carries the link's noise floor with it.
9 * Port of src/python/micro_espectre/csi_features.py.
10 *
11 * Author: Francesco Pace <francesco.pace@gmail.com>
12 * SPDX-License-Identifier: GPL-3.0-only
13 * Commercial licensing available under separate agreement; see LICENSING.md.
14 */
15#pragma once
16
17#include <algorithm>
18#include <cmath>
19#include <cstdint>
20
21#include "utils.h"
22
23namespace espectre {
24
25constexpr uint8_t L1_DELTA_LAG = 10;
26constexpr float L1_DELTA_STARTUP_THRESHOLD_FACTOR = 1.1f;
27constexpr uint8_t TURB_IQR_AGGREGATION_WIDTH = 5U;
28
29// Canonical ML feature identifiers, shared with the exporter in
30// tools/train_ml_model.py (CPP_FEATURE_IDS).
41
42// Where a feature's value comes from. Ids carry no ordering: a new turbulence
43// feature may take any free number, so the mapping is spelled out rather than
44// inferred from magnitude.
51
53 switch (id) {
66 }
67 // No default label above, so -Wswitch reports a new enumerator here
68 // instead of letting it inherit a neighbour's buffers. An id the enum does
69 // not know needs nothing beyond the turbulence series.
71}
72
73// True when the id needs the L1 profile rings running: the delta-series
74// members do, and so does the lag ratio, which the tracker derives itself.
75inline bool ml_feature_needs_l1_tracker(uint8_t id) {
76 const MLFeatureSource source =
77 ml_feature_source(static_cast<MLFeatureId>(id));
78 return source == MLFeatureSource::L1_TRACKER;
79}
80
85
90
91inline float median_from_sorted(const float* sorted_values, uint16_t count) {
92 if (count == 0 || sorted_values == nullptr) return 0.0f;
93 if (count % 2 == 0) {
94 return (sorted_values[count / 2 - 1] + sorted_values[count / 2]) / 2.0f;
95 }
96 return sorted_values[count / 2];
97}
98
99inline float percentile_from_sorted(const float* sorted_values, uint16_t count,
100 float quantile) {
101 if (sorted_values == nullptr || count == 0U) return 0.0f;
102 const float position = static_cast<float>(count - 1U) * quantile;
103 const uint16_t lower = static_cast<uint16_t>(position);
104 if (lower >= count - 1U) return sorted_values[count - 1U];
105 const float fraction = position - static_cast<float>(lower);
106 return sorted_values[lower] * (1.0f - fraction) +
107 sorted_values[lower + 1U] * fraction;
108}
109
110inline float order_statistic_in_place(float* values, uint16_t count,
111 uint16_t index) {
112 std::nth_element(values, values + index, values + count);
113 return values[index];
114}
115
116inline float percentile_in_place(float* values, uint16_t count,
117 float quantile) {
118 if (values == nullptr || count == 0U) return 0.0f;
119 const float position = static_cast<float>(count - 1U) * quantile;
120 const uint16_t lower = static_cast<uint16_t>(position);
121 const float lower_value = order_statistic_in_place(values, count, lower);
122 if (lower >= count - 1U) return lower_value;
123 const float upper_value = order_statistic_in_place(
124 values, count, static_cast<uint16_t>(lower + 1U));
125 const float fraction = position - static_cast<float>(lower);
126 return lower_value * (1.0f - fraction) + upper_value * fraction;
127}
128
129inline float calc_autocorrelation(const float* values, uint16_t count, float mean,
130 float variance, uint16_t lag = 1) {
131 if (count < lag + 2 || variance < 1e-10f) return 0.0f;
132
133 float autocovariance = 0.0f;
134 uint16_t pairs = 0U;
135 for (uint16_t i = 0; i < count - lag; i++) {
136 if (!std::isfinite(values[i]) || !std::isfinite(values[i + lag])) {
137 continue;
138 }
139 autocovariance += (values[i] - mean) * (values[i + lag] - mean);
140 ++pairs;
141 }
142 if (pairs == 0U) return 0.0f;
143 autocovariance /= pairs;
144
145 return autocovariance / variance;
146}
147// Crossing rate of the series around `center`. Shift and scale invariant when
148// `center` tracks the window; matches the Python `calc_zero_crossing_rate`,
149// whose zcr center is the upper median `sorted[count / 2]`.
150inline float calc_zero_crossing_rate(const float* values, uint16_t count, float center) {
151 if (count < 2 || values == nullptr) return 0.0f;
152
153 uint16_t crossings = 0;
154 uint16_t pairs = 0U;
155 for (uint16_t i = 1; i < count; i++) {
156 if (!std::isfinite(values[i - 1U]) || !std::isfinite(values[i])) {
157 continue;
158 }
159 const bool prev_above = values[i - 1U] >= center;
160 bool curr_above = values[i] >= center;
161 if (curr_above != prev_above) {
162 crossings++;
163 }
164 ++pairs;
165 }
166 return pairs > 0U ? static_cast<float>(crossings) / pairs : 0.0f;
167}
168
170 uint16_t count = 0;
171 float mean = 0.0f;
172 float variance = 0.0f;
173 float iqr = 0.0f;
174 float autocorr = 0.0f;
175 float zcr = 0.0f;
176 float mean_denom = 1e-6f; // max(|mean|, 1e-6), matches Python
177};
178
179// Which per-series statistics one feature set actually references. Lets the
180// hot path skip unused passes over the window.
182 bool mean = false;
183 bool variance = false;
184 bool sorted = false; // iqr and/or zcr share one sort
185 bool iqr = false;
186 bool zcr = false;
187 bool autocorr = false;
188};
189
190// Caller-owned working memory for the sorted statistics. The detectors size
191// it to their window and keep it alive for their lifetime, so no feature
192// helper allocates on the CSI callback stack.
193//
194// One sorted view is reused by the turbulence and aggregated-turbulence
195// series. MLSeriesStats contains only scalars, so each result is materialised
196// before the next call overwrites the view.
198 float* sorted_values = nullptr;
199 uint16_t capacity = 0U;
200
201 bool holds(uint16_t count) const {
202 return sorted_values != nullptr && capacity >= count;
203 }
204};
205
206// Derive the statistics needed by one production series from the exported ids.
207inline MLStatNeeds ml_series_needs(const uint8_t *feature_ids,
208 uint8_t num_features,
209 MLFeatureSource wanted) {
210 MLStatNeeds needs;
211 for (uint8_t i = 0; i < num_features; i++) {
212 const uint8_t id = feature_ids[i];
213 if (ml_feature_source(static_cast<MLFeatureId>(id)) != wanted) {
214 continue;
215 }
216 switch (id) {
218 needs.mean = true;
219 needs.sorted = true;
220 needs.iqr = true;
221 break;
222 case ML_FEAT_TURB_ZCR:
223 needs.sorted = true;
224 needs.zcr = true;
225 break;
227 needs.mean = true;
228 needs.variance = true;
229 needs.autocorr = true;
230 break;
231 default: // mean / std features need no extra statistic
232 break;
233 }
234 }
235 return needs;
236}
237
238inline void compute_ml_series_stats(const float* values, uint16_t count,
239 MLSeriesStats* out, const MLStatNeeds& needs,
240 const MLSeriesScratch& scratch) {
241 *out = MLSeriesStats{};
242 if (values == nullptr || count < 2) {
243 return;
244 }
245 uint16_t valid_count = 0U;
246 float sum = 0.0f;
247 for (uint16_t i = 0U; i < count; ++i) {
248 if (!std::isfinite(values[i])) continue;
249 sum += values[i];
250 ++valid_count;
251 }
252 if (valid_count < 2U) return;
253 out->count = valid_count;
254
255 if (needs.mean || needs.variance) {
256 out->mean = sum / valid_count;
257 }
258 if (needs.variance) {
259 float variance_sum = 0.0f;
260 for (uint16_t i = 0U; i < count; ++i) {
261 if (!std::isfinite(values[i])) continue;
262 const float diff = values[i] - out->mean;
263 variance_sum += diff * diff;
264 }
265 out->variance = variance_sum / valid_count;
266 }
267 if (needs.mean) {
268 out->mean_denom = std::max(std::fabs(out->mean), 1e-6f);
269 }
270
271 // Select only the order statistics each feature consumes. A full sort
272 // produces the same values but orders the other window elements needlessly.
273 if (needs.sorted && scratch.holds(valid_count)) {
274 uint16_t sorted_count = 0U;
275 for (uint16_t i = 0; i < count; i++) {
276 if (std::isfinite(values[i])) {
277 scratch.sorted_values[sorted_count++] = values[i];
278 }
279 }
280 if (needs.iqr) {
282 scratch.sorted_values, valid_count, 0.75f) -
283 percentile_in_place(scratch.sorted_values, valid_count, 0.25f);
284 }
285 if (needs.zcr) {
286 // Python zcr centers on the upper median (sorted[count // 2]).
287 const float center = order_statistic_in_place(
288 scratch.sorted_values, valid_count,
289 static_cast<uint16_t>(valid_count / 2U));
291 values, count, center);
292 }
293 }
294 if (needs.autocorr) {
295 out->autocorr = calc_autocorrelation(values, count, out->mean, out->variance, 1);
296 }
297}
298
299/**
300 * Resolve one exported ML feature from precomputed series and tracker stats.
301 *
302 * @param id Exported `MLFeatureId` value to resolve.
303 * @param turb Statistics for the packet-level turbulence series.
304 * @param aggregated_turb Statistics for the aggregated turbulence series.
305 * @param l1_delta_lag_ratio Preprocessed tracker metric for
306 * ML_FEAT_L1_DELTA_LAG_RATIO. Deliberately without a default: the
307 * no-motion value of the ratio is 1.0, so a forgotten argument would
308 * read as a plausible measurement rather than as an error. The Python
309 * extractor raises for the same reason; here the compiler does it.
310 * @param chan_shape_spread_subband Current physical-time subband spread.
311 * @param chan_shape_coherent_innovation_energy Current coherent innovation
312 * energy from the channel-shape trajectory tracker.
313 * @param chan_shape_excess_path Current channel-shape excess-path metric.
314 * @param chan_shape_subband_kendall_lag_excess Current guarded Kendall
315 * lag-excess of the same eight-subband trajectory.
316 * @return The requested feature value, or `0.0f` for an unknown identifier.
317 */
318inline float ml_feature_value_from_stats(uint8_t id, const MLSeriesStats& turb,
319 const MLSeriesStats& aggregated_turb,
320 float l1_delta_lag_ratio,
321 float chan_shape_spread_subband,
322 float chan_shape_coherent_innovation_energy,
323 float chan_shape_excess_path,
324 float chan_shape_subband_kendall_lag_excess) {
325 switch (id) {
326 case ML_FEAT_TURB_AUTOCORR: return turb.autocorr;
328 return aggregated_turb.iqr / aggregated_turb.mean_denom;
329 case ML_FEAT_TURB_ZCR: return turb.zcr;
330 case ML_FEAT_L1_DELTA_LAG_RATIO: return l1_delta_lag_ratio;
332 return chan_shape_spread_subband;
334 return chan_shape_coherent_innovation_energy;
335 case ML_FEAT_CHAN_SHAPE_EXCESS_PATH: return chan_shape_excess_path;
337 return chan_shape_subband_kendall_lag_excess;
338 default: return 0.0f;
339 }
340}
341
342inline void extract_ml_features_by_id(const float* turb_buffer, uint16_t turb_count,
343 const float* aggregated_turb_buffer,
344 uint16_t aggregated_turb_count,
345 const uint8_t* feature_ids, uint8_t num_features,
346 float* features_out,
347 const MLSeriesScratch& series_scratch,
348 float l1_delta_lag_ratio,
349 float chan_shape_spread_subband,
350 float chan_shape_coherent_innovation_energy,
351 float chan_shape_excess_path,
352 float chan_shape_subband_kendall_lag_excess) {
353 // The aggregated chronological view may alias `series_scratch`; consume it
354 // first so the normal turbulence sort can reuse the same block afterwards.
355 MLSeriesStats aggregated_turb;
357 aggregated_turb_buffer, aggregated_turb_count, &aggregated_turb,
359 feature_ids, num_features,
361 series_scratch);
362
363 MLSeriesStats turb;
364 compute_ml_series_stats(turb_buffer, turb_count, &turb,
366 feature_ids, num_features,
368 series_scratch);
369
370 for (uint8_t i = 0; i < num_features; i++) {
371 features_out[i] = ml_feature_value_from_stats(
372 feature_ids[i], turb, aggregated_turb,
373 l1_delta_lag_ratio, chan_shape_spread_subband,
374 chan_shape_coherent_innovation_energy, chan_shape_excess_path,
375 chan_shape_subband_kendall_lag_excess);
376 }
377}
378
379} // namespace espectre
@ ML_FEAT_TURB_IQR_OVER_MEAN_AGGR
@ ML_FEAT_TURB_AUTOCORR
@ ML_FEAT_CHAN_SHAPE_SUBBAND_KENDALL_LAG_EXCESS
@ ML_FEAT_CHAN_SHAPE_EXCESS_PATH
@ ML_FEAT_TURB_ZCR
@ ML_FEAT_CHAN_SHAPE_SPREAD_SUBBAND
@ ML_FEAT_CHAN_SHAPE_COHERENT_INNOVATION_ENERGY
@ ML_FEAT_L1_DELTA_LAG_RATIO
MLFeatureSource ml_feature_source(MLFeatureId id)
float calc_autocorrelation(const float *values, uint16_t count, float mean, float variance, uint16_t lag=1)
MLStatNeeds ml_series_needs(const uint8_t *feature_ids, uint8_t num_features, MLFeatureSource wanted)
constexpr uint8_t L1_DELTA_LAG
float median_from_sorted(const float *sorted_values, uint16_t count)
constexpr float L1_DELTA_STARTUP_THRESHOLD_FACTOR
float percentile_in_place(float *values, uint16_t count, float quantile)
float ml_feature_value_from_stats(uint8_t id, const MLSeriesStats &turb, const MLSeriesStats &aggregated_turb, float l1_delta_lag_ratio, float chan_shape_spread_subband, float chan_shape_coherent_innovation_energy, float chan_shape_excess_path, float chan_shape_subband_kendall_lag_excess)
Resolve one exported ML feature from precomputed series and tracker stats.
bool ml_feature_needs_l1_tracker(uint8_t id)
float order_statistic_in_place(float *values, uint16_t count, uint16_t index)
bool ml_feature_needs_aggregated_turbulence(uint8_t id)
constexpr uint8_t TURB_IQR_AGGREGATION_WIDTH
void compute_ml_series_stats(const float *values, uint16_t count, MLSeriesStats *out, const MLStatNeeds &needs, const MLSeriesScratch &scratch)
void extract_ml_features_by_id(const float *turb_buffer, uint16_t turb_count, const float *aggregated_turb_buffer, uint16_t aggregated_turb_count, const uint8_t *feature_ids, uint8_t num_features, float *features_out, const MLSeriesScratch &series_scratch, float l1_delta_lag_ratio, float chan_shape_spread_subband, float chan_shape_coherent_innovation_energy, float chan_shape_excess_path, float chan_shape_subband_kendall_lag_excess)
bool ml_feature_needs_channel_shape_trajectory_tracker(uint8_t id)
float percentile_from_sorted(const float *sorted_values, uint16_t count, float quantile)
float calc_zero_crossing_rate(const float *values, uint16_t count, float center)
bool holds(uint16_t count) const