Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions components/espectre/base_detector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ BaseDetector::BaseDetector(uint16_t window_size)
// Initialize filters (disabled by default)
lowpass_filter_init(&lowpass_state_, LOWPASS_CUTOFF_DEFAULT, LOWPASS_SAMPLE_RATE, false);
hampel_turbulence_init(&hampel_state_, HAMPEL_TURBULENCE_WINDOW_DEFAULT, HAMPEL_TURBULENCE_THRESHOLD_DEFAULT, false);
breathing_filter_init(&breathing_filter_);
}

BaseDetector::~BaseDetector() {
Expand All @@ -74,8 +75,9 @@ BaseDetector::BaseDetector(BaseDetector&& other) noexcept
, packet_index_(other.packet_index_)
, lowpass_state_(other.lowpass_state_)
, hampel_state_(other.hampel_state_)
, use_cv_normalization_(other.use_cv_normalization_) {

, use_cv_normalization_(other.use_cv_normalization_)
, breathing_filter_(other.breathing_filter_) {

// Copy amplitude buffer
std::memcpy(amplitude_buffer_, other.amplitude_buffer_, sizeof(amplitude_buffer_));

Expand All @@ -100,7 +102,8 @@ BaseDetector& BaseDetector::operator=(BaseDetector&& other) noexcept {
lowpass_state_ = other.lowpass_state_;
hampel_state_ = other.hampel_state_;
use_cv_normalization_ = other.use_cv_normalization_;

breathing_filter_ = other.breathing_filter_;

// Copy amplitude buffer
std::memcpy(amplitude_buffer_, other.amplitude_buffer_, sizeof(amplitude_buffer_));

Expand Down Expand Up @@ -151,6 +154,11 @@ void BaseDetector::process_packet(const int8_t* csi_data, size_t csi_len,
num_amplitudes_, use_cv_normalization_);
}

// Breathing bandpass: filter amplitude_sum at packet rate
float amp_sum = 0.0f;
for (uint8_t i = 0; i < num_amplitudes_; i++) amp_sum += amplitude_buffer_[i];
breathing_filter_apply(&breathing_filter_, amp_sum);

// Add to buffer with filtering
add_turbulence_to_buffer(turbulence);
}
Expand Down Expand Up @@ -193,9 +201,10 @@ void BaseDetector::clear_buffer() {

// Reset filters
lowpass_filter_reset(&lowpass_state_);
hampel_turbulence_init(&hampel_state_, hampel_state_.window_size,
hampel_turbulence_init(&hampel_state_, hampel_state_.window_size,
hampel_state_.threshold, hampel_state_.enabled);

breathing_filter_init(&breathing_filter_);

ESP_LOGD(TAG, "Buffer cleared");
}

Expand Down Expand Up @@ -238,5 +247,9 @@ void BaseDetector::add_turbulence_to_buffer(float turbulence) {
total_packets_++;
}

float BaseDetector::get_breathing_score() const {
return breathing_filter_get_score(&breathing_filter_);
}

} // namespace espectre
} // namespace esphome
3 changes: 3 additions & 0 deletions components/espectre/base_detector.h
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ class BaseDetector {
*/
bool is_hampel_enabled() const { return hampel_state_.enabled; }

float get_breathing_score() const;

protected:
/**
* Add turbulence value to buffer (with filtering)
Expand Down Expand Up @@ -259,6 +261,7 @@ class BaseDetector {
// Default false: raw std is more sensitive and matches ML model training
// Set true only for chips without gain lock (e.g., ESP32)
bool use_cv_normalization_{false};
breathing_filter_state_t breathing_filter_{};
};

} // namespace espectre
Expand Down
46 changes: 46 additions & 0 deletions components/espectre/csi_filters.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,51 @@ float hampel_filter_turbulence(hampel_turbulence_state_t *state, float turbulenc
return turbulence;
}

// =============================================================================
// Breathing Bandpass Filter
// =============================================================================

static constexpr float BREATH_HP_B0 = 0.99749f;
static constexpr float BREATH_HP_A1 = -0.99498f;
static constexpr float BREATH_LP_B0 = 0.01850f;
static constexpr float BREATH_LP_A1 = -0.96300f;
static constexpr float BREATH_ENERGY_ALPHA = 0.00333f;

void breathing_filter_init(breathing_filter_state_t *state) {
if (!state) return;
state->hp_x_prev = 0.0f;
state->hp_y_prev = 0.0f;
state->lp_x_prev = 0.0f;
state->lp_y_prev = 0.0f;
state->energy = 0.0f;
state->initialized = false;
}

float breathing_filter_apply(breathing_filter_state_t *state, float amplitude_sum) {
if (!state) return 0.0f;
if (!state->initialized) {
state->hp_x_prev = amplitude_sum;
state->hp_y_prev = 0.0f;
state->lp_x_prev = 0.0f;
state->lp_y_prev = 0.0f;
state->energy = 0.0f;
state->initialized = true;
return 0.0f;
}
float hp_out = BREATH_HP_B0 * (amplitude_sum - state->hp_x_prev) - BREATH_HP_A1 * state->hp_y_prev;
state->hp_x_prev = amplitude_sum;
state->hp_y_prev = hp_out;
float lp_out = BREATH_LP_B0 * (hp_out + state->lp_x_prev) - BREATH_LP_A1 * state->lp_y_prev;
state->lp_x_prev = hp_out;
state->lp_y_prev = lp_out;
float sq = lp_out * lp_out;
state->energy = BREATH_ENERGY_ALPHA * sq + (1.0f - BREATH_ENERGY_ALPHA) * state->energy;
return std::sqrt(state->energy);
}

float breathing_filter_get_score(const breathing_filter_state_t *state) {
return (state && state->initialized) ? std::sqrt(state->energy) : 0.0f;
}

} // namespace espectre
} // namespace esphome
17 changes: 17 additions & 0 deletions components/espectre/filters.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,22 @@ float hampel_filter(const float *window, size_t window_size,
float current_value, float threshold);
float hampel_filter_turbulence(hampel_turbulence_state_t *state, float turbulence);

// =============================================================================
// Breathing Bandpass Filter (cascaded HP 0.08Hz + LP 0.6Hz at ~100Hz sample rate)
// =============================================================================

struct breathing_filter_state_t {
float hp_x_prev;
float hp_y_prev;
float lp_x_prev;
float lp_y_prev;
float energy;
bool initialized;
};

void breathing_filter_init(breathing_filter_state_t *state);
float breathing_filter_apply(breathing_filter_state_t *state, float amplitude_sum);
float breathing_filter_get_score(const breathing_filter_state_t *state);

} // namespace espectre
} // namespace esphome
82 changes: 82 additions & 0 deletions micro-espectre/src/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,85 @@ def reset(self):
self.count = 0
self.index = 0
# No need to clear pre-allocated buffers - they'll be overwritten


class BreathingFilter:
"""
Breathing bandpass filter (cascaded HP 0.08 Hz + LP 0.6 Hz)

Isolates the breathing frequency band (0.08-0.6 Hz = 5-36 BPM) from
CSI amplitude sum, then tracks RMS energy via exponential moving average.

Elevated score indicates periodic amplitude variation consistent with
breathing, useful for detecting stationary presence (sitting/sleeping).

Coefficients are pre-computed for 100 Hz sample rate using bilinear
transform of 1st-order Butterworth prototypes. Must match C++ exactly.

Signal flow:
amplitude_sum → HP(0.08Hz) → LP(0.6Hz) → square → EMA → sqrt → score
"""

# Pre-computed filter coefficients (must match csi_filters.cpp)
HP_B0 = 0.99749
HP_A1 = -0.99498
LP_B0 = 0.01850
LP_A1 = -0.96300
ENERGY_ALPHA = 0.00333 # ~3 second time constant at 100 Hz

def __init__(self):
"""Initialize breathing filter with zeroed state"""
self.reset()

def filter(self, amplitude_sum):
"""
Apply breathing bandpass filter to amplitude sum

Args:
amplitude_sum: Sum of subcarrier amplitudes for current packet

Returns:
float: Current breathing score (RMS of bandpassed energy)
"""
if not self.initialized:
self.hp_x_prev = amplitude_sum
self.hp_y_prev = 0.0
self.lp_x_prev = 0.0
self.lp_y_prev = 0.0
self.energy = 0.0
self.initialized = True
return 0.0

# High-pass filter (removes DC / slow drift, passes > 0.08 Hz)
hp_out = self.HP_B0 * (amplitude_sum - self.hp_x_prev) - self.HP_A1 * self.hp_y_prev
self.hp_x_prev = amplitude_sum
self.hp_y_prev = hp_out

# Low-pass filter (removes fast noise, passes < 0.6 Hz)
lp_out = self.LP_B0 * (hp_out + self.lp_x_prev) - self.LP_A1 * self.lp_y_prev
self.lp_x_prev = hp_out
self.lp_y_prev = lp_out

# Energy estimation (EMA of squared bandpassed signal)
sq = lp_out * lp_out
self.energy = self.ENERGY_ALPHA * sq + (1.0 - self.ENERGY_ALPHA) * self.energy

return math.sqrt(self.energy)

def get_score(self):
"""
Get current breathing score

Returns:
float: RMS of bandpassed energy (0.0 if not initialized)
"""
return math.sqrt(self.energy) if self.initialized else 0.0

def reset(self):
"""Reset filter to initial state"""
self.hp_x_prev = 0.0
self.hp_y_prev = 0.0
self.lp_x_prev = 0.0
self.lp_y_prev = 0.0
self.energy = 0.0
self.initialized = False
48 changes: 44 additions & 4 deletions micro-espectre/src/segmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@ class SegmentationContext:
STATE_IDLE = MotionState.IDLE
STATE_MOTION = MotionState.MOTION

def __init__(self,
def __init__(self,
window_size=75,
threshold=1.0,
enable_lowpass=False,
lowpass_cutoff=11.0,
enable_hampel=True,
hampel_window=7,
hampel_threshold=5.0):
hampel_threshold=5.0,
enable_breathing=False):
"""
Initialize segmentation context

Expand All @@ -56,6 +57,8 @@ def __init__(self,
enable_hampel: Enable Hampel filter for outlier removal (default: True)
hampel_window: Hampel filter window size (default: 7)
hampel_threshold: Hampel filter threshold in MAD units (default: 5.0)
enable_breathing: Enable breathing bandpass filter for stationary
presence detection (default: False)
"""
self.window_size = window_size
self.threshold = threshold
Expand Down Expand Up @@ -115,6 +118,19 @@ def __init__(self,
except Exception as e:
print(f"[ERROR] Failed to initialize HampelFilter: {e}")
self.hampel_filter = None

# Initialize breathing bandpass filter if enabled
self.breathing_filter = None
if enable_breathing:
try:
try:
from src.filters import BreathingFilter
except ImportError:
from filters import BreathingFilter
self.breathing_filter = BreathingFilter()
except Exception as e:
print(f"[ERROR] Failed to initialize BreathingFilter: {e}")
self.breathing_filter = None


@staticmethod
Expand Down Expand Up @@ -279,7 +295,15 @@ def add_turbulence(self, turbulence):
print(f"[ERROR] LowPass filter failed: {e}")

self.last_turbulence = filtered_turbulence


# Apply breathing bandpass filter on amplitude sum (mirrors C++ process_packet)
if self.breathing_filter is not None and self.last_amplitudes is not None:
try:
amp_sum = sum(self.last_amplitudes)
self.breathing_filter.filter(amp_sum)
except Exception as e:
print(f"[ERROR] BreathingFilter failed: {e}")

# Store value in circular buffer
self.turbulence_buffer[self.buffer_index] = filtered_turbulence
self.buffer_index = (self.buffer_index + 1) % self.window_size
Expand Down Expand Up @@ -315,18 +339,32 @@ def update_state(self):

return self.get_metrics()

def get_breathing_score(self):
"""
Get current breathing score (RMS of bandpass-filtered amplitude energy)

Returns:
float: Breathing score (0.0 if filter disabled or not initialized)
"""
if self.breathing_filter is not None:
return self.breathing_filter.get_score()
return 0.0

def get_state(self):
"""Get current state (IDLE or MOTION)"""
return self.state

def get_metrics(self):
"""Get current metrics as dict"""
return {
metrics = {
'moving_variance': self.current_moving_variance,
'threshold': self.threshold,
'turbulence': self.last_turbulence,
'state': self.state
}
if self.breathing_filter is not None:
metrics['breathing_score'] = self.get_breathing_score()
return metrics

def reset(self, full=False):
"""
Expand All @@ -352,3 +390,5 @@ def reset(self, full=False):
self.lowpass_filter.reset()
if self.hampel_filter is not None:
self.hampel_filter.reset()
if self.breathing_filter is not None:
self.breathing_filter.reset()
Loading
Loading