Skip to content

Commit f0d9216

Browse files
EDA (#53)
* Initial commit Real-time EDA decomposition and SCR detection. * Changed object name
1 parent 99251db commit f0d9216

File tree

3 files changed

+280
-0
lines changed

3 files changed

+280
-0
lines changed

CMakeLists.txt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ avnd_score_plugin_add(
116116
NAMESPACE puara_gestures::objects
117117
)
118118

119+
avnd_score_plugin_add(
120+
BASE_TARGET score_addon_puara
121+
SOURCES
122+
Puara/EdaRtFeatures.hpp
123+
Puara/EdaRtFeatures.cpp
124+
TARGET puara_eda
125+
MAIN_CLASS EdaRtFeatures
126+
NAMESPACE puara_gestures::objects
127+
)
128+
119129
avnd_score_plugin_add(
120130
BASE_TARGET score_addon_puara
121131
SOURCES

Puara/EdaRtFeatures.cpp

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#include "EdaRtFeatures.hpp"
2+
3+
namespace puara_gestures::objects
4+
{
5+
6+
float EdaRtFeatures::onepole_lp(float y, float x, double dt, float fc)
7+
{
8+
if(fc <= 0.0f || dt <= 0.0) return x;
9+
const double a = std::exp(-2.0 * M_PI * static_cast<double>(fc) * dt);
10+
return static_cast<float>(a * y + (1.0 - a) * x);
11+
}
12+
13+
void EdaRtFeatures::prepare(halp::setup info)
14+
{
15+
setup = info;
16+
_have_input = false;
17+
_last_x = -1e9f;
18+
_time = 0.0;
19+
_scl = 0.0f;
20+
_baseline = 0.0f;
21+
22+
_state = State::Idle;
23+
_onset_base = 0.0f;
24+
_onset_time = 0.0;
25+
_peak_val = 0.0f;
26+
_peak_time = 0.0;
27+
_confirm_acc = 0.0;
28+
_last_peak_t = -1e9;
29+
_rec_target = 0.0f;
30+
31+
for(int i=0; i<VALLEY_WIN; ++i){ _val_p[i]=0.0f; _val_t[i]=0.0; }
32+
_val_i = 0;
33+
_h_amp = 0.0f;
34+
_h_rise = 0.0f;
35+
_h_dur = 0.0f;
36+
37+
outputs.scl = 0.0f;
38+
outputs.scr = 0.0f;
39+
outputs.scr_event = 0;
40+
outputs.scr_amplitude_us = 0.0f;
41+
outputs.scr_rise_time_s = 0.0f;
42+
outputs.scr_duration_s = 0.0f;
43+
}
44+
45+
void EdaRtFeatures::operator()(halp::tick t)
46+
{
47+
const double dt = (setup.rate > 0.0)
48+
? ((t.frames > 0) ? static_cast<double>(t.frames) / setup.rate : 1.0 / setup.rate)
49+
: 1.0 / 50.0;
50+
51+
outputs.scr_event = 0;
52+
53+
if(inputs.reset) { prepare(setup); return; }
54+
55+
const float x = inputs.signal;
56+
57+
// ── startup: wait for first nonzero sample ────────────────────────────────
58+
if(!_have_input)
59+
{
60+
if(x == 0.0f) { return; }
61+
_have_input = true;
62+
_scl = x;
63+
_baseline = 0.0f;
64+
_time = 0.0;
65+
}
66+
67+
// ── always write held outputs────────────
68+
outputs.scr_amplitude_us = _h_amp;
69+
outputs.scr_rise_time_s = _h_rise;
70+
outputs.scr_duration_s = _h_dur;
71+
72+
// ── per new sample: update tonic, phasic, valley buffer, baseline ──────────
73+
const bool new_sample = (x != _last_x);
74+
if(new_sample)
75+
{
76+
_last_x = x;
77+
_time += dt;
78+
79+
_scl = onepole_lp(_scl, x, dt, 0.05f);
80+
}
81+
const float phasic = x - _scl;
82+
83+
outputs.scl = _scl;
84+
outputs.scr = phasic;
85+
86+
if(new_sample)
87+
{
88+
// push to valley ring buffer
89+
_val_p[_val_i % VALLEY_WIN] = phasic;
90+
_val_t[_val_i % VALLEY_WIN] = _time;
91+
++_val_i;
92+
93+
// slow baseline EMA
94+
const float ba = static_cast<float>(1.0 - std::exp(-dt / 10.0));
95+
_baseline += (phasic - _baseline) * ba;
96+
}
97+
98+
// ── detection thresholds ──────────────────────────────────────────────────
99+
static constexpr float MIN_AMP = 0.04f; // µS
100+
static constexpr float ONSET_TH = 0.05f; // above baseline to start rising
101+
static constexpr float DROP_TH = 0.012f; // fall below peak to accumulate confirm
102+
static constexpr double CONFIRM_S = 0.30; // seconds of falling to confirm peak
103+
static constexpr double MIN_DIST = 1.0; // refractory seconds
104+
static constexpr double MAX_REC = 10.0; // max recovery seconds
105+
106+
if(!new_sample) return;
107+
108+
// re-arm when phasic settles near baseline
109+
if(phasic <= _baseline + 0.3f * ONSET_TH)
110+
_rearmed = true;
111+
112+
// ── always emit held values ───────────────────────────────────────────────
113+
outputs.scr_amplitude_us = _h_amp;
114+
outputs.scr_rise_time_s = _h_rise;
115+
outputs.scr_duration_s = _h_dur;
116+
117+
// ── state machine ─────────────────────────────────────────────────────────
118+
switch(_state)
119+
{
120+
case State::Idle:
121+
{
122+
const bool refract_ok = (_time - _last_peak_t >= MIN_DIST);
123+
if(_rearmed && refract_ok && (phasic - _baseline) >= ONSET_TH)
124+
{
125+
_rearmed = false;
126+
_state = State::Rising;
127+
// onset = valley (minimum phasic) in lookback window
128+
_onset_base = phasic; _onset_time = _time;
129+
for(int i=0; i<VALLEY_WIN; ++i)
130+
if(_val_p[i] < _onset_base){ _onset_base=_val_p[i]; _onset_time=_val_t[i]; }
131+
_peak_val = phasic;
132+
_peak_time = _time;
133+
_confirm_acc = 0.0;
134+
}
135+
break;
136+
}
137+
138+
case State::Rising:
139+
{
140+
if(phasic > _peak_val)
141+
{
142+
_peak_val = phasic;
143+
_peak_time = _time;
144+
_confirm_acc = 0.0;
145+
}
146+
else if(phasic <= _peak_val - DROP_TH)
147+
{
148+
_confirm_acc += dt;
149+
}
150+
else
151+
{
152+
_confirm_acc = 0.0;
153+
}
154+
155+
if(_confirm_acc >= CONFIRM_S)
156+
{
157+
// peak confirmed
158+
const float amp = _peak_val - _onset_base;
159+
const double rise_t = _peak_time - _onset_time;
160+
161+
if(amp >= MIN_AMP)
162+
{
163+
_h_amp = amp;
164+
_h_rise = std::min(static_cast<float>(rise_t), 3.0f);
165+
166+
outputs.scr_event = 1;
167+
outputs.scr_amplitude_us = _h_amp;
168+
outputs.scr_rise_time_s = _h_rise;
169+
170+
_last_peak_t = _peak_time;
171+
_rec_target = _onset_base + amp * 0.5f; // 50% recovery
172+
_state = State::Recovering;
173+
}
174+
else
175+
{
176+
_state = State::Idle;
177+
}
178+
}
179+
break;
180+
}
181+
182+
case State::Recovering:
183+
{
184+
if(phasic <= _rec_target || _time - _peak_time > MAX_REC)
185+
{
186+
_h_dur = static_cast<float>(_time - _onset_time);
187+
outputs.scr_duration_s = _h_dur;
188+
_state = State::Idle;
189+
}
190+
break;
191+
}
192+
}
193+
}
194+
195+
} // namespace puara_gestures::objects

Puara/EdaRtFeatures.hpp

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#pragma once
2+
#include "halp_utils.hpp"
3+
#include <halp/controls.hpp>
4+
#include <halp/meta.hpp>
5+
6+
#include <algorithm>
7+
#include <cmath>
8+
9+
namespace puara_gestures::objects
10+
{
11+
12+
class EdaRtFeatures
13+
{
14+
public:
15+
halp_meta(name, "EDA")
16+
halp_meta(category, "Analysis/Biodata")
17+
halp_meta(c_name, "puara_eda_rt")
18+
halp_meta(author, "Luana Belinsky")
19+
halp_meta(description, "Real-time EDA decomposition and SCR detection.")
20+
halp_meta(uuid, "f0f6b04e-0e11-4acf-96bd-bf54b78078ca")
21+
22+
struct
23+
{
24+
halp::data_port<"EDA", "Input EDA in µS.", float> signal;
25+
halp::toggle<"Reset"> reset;
26+
} inputs;
27+
28+
struct
29+
{
30+
halp::data_port<"SCL", "Tonic.", float> scl;
31+
halp::data_port<"SCR", "Phasic.", float> scr;
32+
halp::data_port<"SCR event", "1 for one tick at SCR peak.", int> scr_event;
33+
halp::data_port<"SCR amplitude", "Peak amplitude µS.", float> scr_amplitude_us;
34+
halp::data_port<"SCR rise time", "Rise time s.", float> scr_rise_time_s;
35+
halp::data_port<"SCR duration", "Duration s.", float> scr_duration_s;
36+
} outputs;
37+
38+
halp::setup setup;
39+
void prepare(halp::setup info);
40+
using tick = halp::tick;
41+
void operator()(halp::tick t);
42+
43+
private:
44+
static float onepole_lp(float y, float x, double dt, float fc);
45+
46+
enum class State { Idle = 0, Rising = 1, Recovering = 2 };
47+
48+
bool _have_input = false;
49+
float _last_x = -1e9f;
50+
double _time = 0.0;
51+
float _scl = 0.0f;
52+
float _baseline = 0.0f;
53+
bool _rearmed = true;
54+
55+
State _state = State::Idle;
56+
float _onset_base = 0.0f;
57+
double _onset_time = 0.0;
58+
float _peak_val = 0.0f;
59+
double _peak_time = 0.0;
60+
double _confirm_acc = 0.0;
61+
double _last_peak_t = -1e9;
62+
float _rec_target = 0.0f;
63+
64+
// ring buffer: phasic + time, 0.75s lookback for valley detection
65+
static constexpr int VALLEY_WIN = 38; // 0.75s at 50 Hz equivalent
66+
float _val_p[VALLEY_WIN]{};
67+
double _val_t[VALLEY_WIN]{};
68+
int _val_i = 0;
69+
70+
float _h_amp = 0.0f;
71+
float _h_rise = 0.0f;
72+
float _h_dur = 0.0f;
73+
};
74+
75+
} // namespace puara_gestures::objects

0 commit comments

Comments
 (0)