From d57f6b785bd37e9e70acab88725c1a1e990a0c6c Mon Sep 17 00:00:00 2001 From: Kawin Pechetratanapanit <39807451+kawinie@users.noreply.github.com> Date: Sat, 16 May 2026 06:08:33 -0700 Subject: [PATCH 1/2] refactor(v2): extract Filter::ema helper for EMA smoothing Both NormalStage and EnergyStage carried the same `(prev*(den-1) + sample) / den` inline arithmetic. Lifted into `Filter::ema` so the call site reads its intent. --- include/v2/stages/energy_stage.h | 7 ++++--- include/v2/stages/normal_stage.h | 9 ++++----- include/v2/util/filter.h | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 include/v2/util/filter.h diff --git a/include/v2/stages/energy_stage.h b/include/v2/stages/energy_stage.h index 31cea2c..d09a822 100644 --- a/include/v2/stages/energy_stage.h +++ b/include/v2/stages/energy_stage.h @@ -23,6 +23,7 @@ #include "v2/images.h" #include "v2/stages/energy/energy_view.h" #include "v2/pocketpd.h" +#include "v2/util/filter.h" namespace pocketpd { @@ -115,9 +116,9 @@ namespace pocketpd { m_snapshot_seeded = true; return; } - const uint32_t a = SENSOR_EMA_DEN - 1; - m_snapshot.vbus_mv = (m_snapshot.vbus_mv * a + s.vbus_mv) / SENSOR_EMA_DEN; - m_snapshot.current_ma = (m_snapshot.current_ma * a + s.current_ma) / SENSOR_EMA_DEN; + m_snapshot.vbus_mv = Filter::ema(m_snapshot.vbus_mv, s.vbus_mv, SENSOR_EMA_DEN); + m_snapshot.current_ma = + Filter::ema(m_snapshot.current_ma, s.current_ma, SENSOR_EMA_DEN); m_snapshot.timestamp_ms = s.timestamp_ms; } diff --git a/include/v2/stages/normal_stage.h b/include/v2/stages/normal_stage.h index 9f9d269..ee5e815 100644 --- a/include/v2/stages/normal_stage.h +++ b/include/v2/stages/normal_stage.h @@ -18,6 +18,7 @@ #include "v2/stages/normal/fixed_mode.h" #include "v2/stages/normal/normal_view.h" #include "v2/stages/normal/pps_mode.h" +#include "v2/util/filter.h" namespace pocketpd { @@ -199,11 +200,9 @@ namespace pocketpd { return; } - // For SENSOR_EMA_DEN = 4: new = 0.75 * new + 0.25 * old - - const uint32_t a = SENSOR_EMA_DEN - 1; - m_snapshot.vbus_mv = (m_snapshot.vbus_mv * a + s.vbus_mv) / SENSOR_EMA_DEN; - m_snapshot.current_ma = (m_snapshot.current_ma * a + s.current_ma) / SENSOR_EMA_DEN; + m_snapshot.vbus_mv = Filter::ema(m_snapshot.vbus_mv, s.vbus_mv, SENSOR_EMA_DEN); + m_snapshot.current_ma = + Filter::ema(m_snapshot.current_ma, s.current_ma, SENSOR_EMA_DEN); m_snapshot.timestamp_ms = s.timestamp_ms; } diff --git a/include/v2/util/filter.h b/include/v2/util/filter.h new file mode 100644 index 0000000..c514579 --- /dev/null +++ b/include/v2/util/filter.h @@ -0,0 +1,22 @@ +/** + * @file filter.h + * @brief Scalar filtering helpers. + */ +#pragma once + +#include + +namespace pocketpd { + + class Filter { + public: + /** + * @brief One EMA step. `new = ((den-1)*prev + sample) / den`. + * For `den = 4`: weights 0.75 prev / 0.25 sample. + */ + static uint32_t ema(uint32_t prev, uint32_t sample, uint32_t den) { + return (prev * (den - 1) + sample) / den; + } + }; + +} // namespace pocketpd From eb4c20336bd8b98ed45a2177d68597505aa12657 Mon Sep 17 00:00:00 2001 From: Kawin Pechetratanapanit <39807451+kawinie@users.noreply.github.com> Date: Sat, 16 May 2026 06:11:50 -0700 Subject: [PATCH 2/2] refactor(v2): rename SensorSnapshot, move EMA into Filter NormalStage and EnergyStage were each filtering vbus_mv and current_ma by hand. Folded the per-field EMA into a Filter::ema(LoadReading, ...) overload so both call sites read like single-shot smoothing. Rename frames the data as a load-side measurement, not a generic sensor blob. --- include/v2/events.h | 4 ++-- include/v2/stages/energy/energy_view.h | 9 ++++---- include/v2/stages/energy_stage.h | 29 ++++++++------------------ include/v2/stages/normal/normal_view.h | 10 ++++++--- include/v2/stages/normal_stage.h | 29 ++++++++------------------ include/v2/tasks/energy_task.h | 4 ++-- include/v2/tasks/sensor_task.h | 2 +- include/v2/util/filter.h | 13 +++++++++++- test/test_v2_energy/test.cpp | 2 +- test/test_v2_normal/test.cpp | 4 ++-- test/test_v2_sensor/test.cpp | 6 +++--- 11 files changed, 53 insertions(+), 59 deletions(-) diff --git a/include/v2/events.h b/include/v2/events.h index 99376ff..13ab053 100644 --- a/include/v2/events.h +++ b/include/v2/events.h @@ -67,7 +67,7 @@ namespace pocketpd { /** * @brief Latest sensor reading from INA226. */ - struct SensorSnapshot { + struct LoadReading { uint32_t timestamp_ms = 0; uint32_t vbus_mv = 0; uint32_t current_ma = 0; @@ -77,7 +77,7 @@ namespace pocketpd { * @brief Published by SensorTask. Carries one bus reading. */ struct SensorEvent { - SensorSnapshot snapshot; + LoadReading load; }; /** diff --git a/include/v2/stages/energy/energy_view.h b/include/v2/stages/energy/energy_view.h index 7d34db8..7d57f60 100644 --- a/include/v2/stages/energy/energy_view.h +++ b/include/v2/stages/energy/energy_view.h @@ -21,7 +21,7 @@ namespace pocketpd { * @brief Frozen snapshot of everything EnergyView needs to draw one frame. */ struct EnergyViewModel { - SensorSnapshot snapshot{}; + LoadReading load_reading{}; double accumulated_wh = 0.0; double accumulated_ah = 0.0; uint32_t total_seconds = 0; @@ -72,10 +72,10 @@ namespace pocketpd { display.set_font(tempo::Font::BASE); // Mid — Voltage Current - format_milli(buf, vm.snapshot.vbus_mv, 'V'); + format_milli(buf, vm.load_reading.vbus_mv, 'V'); display.draw_text(V_X, ROW2_Y - 10, buf.data()); - format_milli(buf, vm.snapshot.current_ma, 'A'); + format_milli(buf, vm.load_reading.current_ma, 'A'); display.draw_text(A_X, ROW2_Y + 5, buf.data()); // Bottom row — Wh Time Ah @@ -90,7 +90,8 @@ namespace pocketpd { // The big W label in the middle display.set_font(tempo::Font::XL); - const double watts = (vm.snapshot.vbus_mv * vm.snapshot.current_ma) / 1'000'000.0; + const double watts = + (vm.load_reading.vbus_mv * vm.load_reading.current_ma) / 1'000'000.0; format_auto(buf, watts); draw_value(display, COL1_X, ROW2_Y, buf.data(), "W"); diff --git a/include/v2/stages/energy_stage.h b/include/v2/stages/energy_stage.h index d09a822..3dd8591 100644 --- a/include/v2/stages/energy_stage.h +++ b/include/v2/stages/energy_stage.h @@ -33,8 +33,8 @@ namespace pocketpd { Display& m_display; OutputGate& m_output_gate; - SensorSnapshot m_snapshot{}; - bool m_snapshot_seeded = false; + LoadReading m_load_reading{}; + bool m_load_init = false; EnergyEvent m_energy{}; int8_t m_active_pdo_index = -1; tempo::IntervalTimer m_render_interval{40}; @@ -97,7 +97,12 @@ namespace pocketpd { conductor.request(m_active_pdo_index); } }, - [&](const SensorEvent& evt) { ema_filter(evt.snapshot); }, + [&](const SensorEvent& evt) { + m_load_reading = m_load_init + ? Filter::ema(m_load_reading, evt.load, SENSOR_EMA_DEN) + : evt.load; + m_load_init = true; + }, [&](const EnergyEvent& evt) { m_energy = evt; }, [](const auto&) {}, }; @@ -106,25 +111,9 @@ namespace pocketpd { } private: - /** - * @brief Apply EMA smoothing to displayed mV / mA. Same shape as - * NormalStage so the readout is consistent across screens. - */ - void ema_filter(const SensorSnapshot& s) { - if (!m_snapshot_seeded) { - m_snapshot = s; - m_snapshot_seeded = true; - return; - } - m_snapshot.vbus_mv = Filter::ema(m_snapshot.vbus_mv, s.vbus_mv, SENSOR_EMA_DEN); - m_snapshot.current_ma = - Filter::ema(m_snapshot.current_ma, s.current_ma, SENSOR_EMA_DEN); - m_snapshot.timestamp_ms = s.timestamp_ms; - } - EnergyViewModel build_view_model() const { return EnergyViewModel{ - .snapshot = m_snapshot, + .load_reading = m_load_reading, .accumulated_wh = m_energy.accumulated_wh, .accumulated_ah = m_energy.accumulated_ah, .total_seconds = m_energy.total_seconds, diff --git a/include/v2/stages/normal/normal_view.h b/include/v2/stages/normal/normal_view.h index 3446809..fdc2303 100644 --- a/include/v2/stages/normal/normal_view.h +++ b/include/v2/stages/normal/normal_view.h @@ -28,7 +28,7 @@ namespace pocketpd { bool readout_visible = true; bool locked = false; uint8_t arrow_frame = 0; - SensorSnapshot snapshot{}; + LoadReading load_reading{}; // —— PPS branch (valid when has_profile && is_pps) @@ -79,8 +79,12 @@ namespace pocketpd { std::array buf{}; d.set_font(tempo::Font::XL); - draw_measured(d, "V", V_MEASURED_Y, vm.snapshot.vbus_mv, buf, vm.readout_visible); - draw_measured(d, "A", A_MEASURED_Y, vm.snapshot.current_ma, buf, vm.readout_visible); + draw_measured( + d, "V", V_MEASURED_Y, vm.load_reading.vbus_mv, buf, vm.readout_visible + ); + draw_measured( + d, "A", A_MEASURED_Y, vm.load_reading.current_ma, buf, vm.readout_visible + ); d.set_font(tempo::Font::BASE); std::snprintf(buf.data(), buf.size(), "[%u]", vm.active_pdo_index); diff --git a/include/v2/stages/normal_stage.h b/include/v2/stages/normal_stage.h index ee5e815..74910a2 100644 --- a/include/v2/stages/normal_stage.h +++ b/include/v2/stages/normal_stage.h @@ -33,8 +33,8 @@ namespace pocketpd { PdSinkController& m_pd_sink; OutputGate& m_output_gate; - SensorSnapshot m_snapshot{}; - bool m_snapshot_init = false; + LoadReading m_load_reading{}; + bool m_load_init = false; int8_t m_active_pdo_index = -1; int8_t m_last_active_index = -1; @@ -182,7 +182,12 @@ namespace pocketpd { log.error(msg, m_active_pdo_index, pps->target_mv, pps->target_ma); } }, - [&](const SensorEvent& evt) { ema_filter(evt.snapshot); }, + [&](const SensorEvent& evt) { + m_load_reading = m_load_init + ? Filter::ema(m_load_reading, evt.load, SENSOR_EMA_DEN) + : evt.load; + m_load_init = true; + }, [](const auto&) {}, }; @@ -190,22 +195,6 @@ namespace pocketpd { } private: - /** - * @brief Apply EMA smoothing to displayed mV / mA. - */ - void ema_filter(const SensorSnapshot& s) { - if (!m_snapshot_init) { - m_snapshot = s; - m_snapshot_init = true; - return; - } - - m_snapshot.vbus_mv = Filter::ema(m_snapshot.vbus_mv, s.vbus_mv, SENSOR_EMA_DEN); - m_snapshot.current_ma = - Filter::ema(m_snapshot.current_ma, s.current_ma, SENSOR_EMA_DEN); - m_snapshot.timestamp_ms = s.timestamp_ms; - } - /** * @brief Enter (or re-enter) a PPS profile. Construct fresh state on profile change or * mode switch; otherwise preserve the user's prior target edits and reissue. @@ -251,7 +240,7 @@ namespace pocketpd { .readout_visible = m_blink_visible, .locked = m_locked, .arrow_frame = m_arrow_frame, - .snapshot = m_snapshot, + .load_reading = m_load_reading, }; if (!vm.has_profile) { diff --git a/include/v2/tasks/energy_task.h b/include/v2/tasks/energy_task.h index 6432a3e..91fff6a 100644 --- a/include/v2/tasks/energy_task.h +++ b/include/v2/tasks/energy_task.h @@ -52,7 +52,7 @@ namespace pocketpd { void on_event(const Event& event, uint32_t) override { if (const auto sensor = std::get_if(&event)) { - integrate(sensor->snapshot); + integrate(sensor->load); } } @@ -67,7 +67,7 @@ namespace pocketpd { } private: - void integrate(const SensorSnapshot& s) { + void integrate(const LoadReading& s) { if (!m_output_gate.is_enabled()) { m_session_active = false; return; diff --git a/include/v2/tasks/sensor_task.h b/include/v2/tasks/sensor_task.h index 232d268..bac42b1 100644 --- a/include/v2/tasks/sensor_task.h +++ b/include/v2/tasks/sensor_task.h @@ -33,7 +33,7 @@ namespace pocketpd { if (!reading.valid) { return; } - publish(SensorEvent{SensorSnapshot{now_ms, reading.mv, reading.ma}}); + publish(SensorEvent{LoadReading{now_ms, reading.mv, reading.ma}}); } }; diff --git a/include/v2/util/filter.h b/include/v2/util/filter.h index c514579..294d7e9 100644 --- a/include/v2/util/filter.h +++ b/include/v2/util/filter.h @@ -1,11 +1,13 @@ /** * @file filter.h - * @brief Scalar filtering helpers. + * @brief Stateless EMA scalar + snapshot overloads. */ #pragma once #include +#include "v2/events.h" + namespace pocketpd { class Filter { @@ -17,6 +19,15 @@ namespace pocketpd { static uint32_t ema(uint32_t prev, uint32_t sample, uint32_t den) { return (prev * (den - 1) + sample) / den; } + + /** @brief Per-field EMA across a `LoadReading`. `timestamp_ms` tracks the latest. */ + static LoadReading ema(const LoadReading& prev, const LoadReading& sample, uint32_t den) { + return LoadReading{ + .timestamp_ms = sample.timestamp_ms, + .vbus_mv = ema(prev.vbus_mv, sample.vbus_mv, den), + .current_ma = ema(prev.current_ma, sample.current_ma, den), + }; + } }; } // namespace pocketpd diff --git a/test/test_v2_energy/test.cpp b/test/test_v2_energy/test.cpp index cd5813f..145cde6 100644 --- a/test/test_v2_energy/test.cpp +++ b/test/test_v2_energy/test.cpp @@ -36,7 +36,7 @@ using TestConductor = App::Conductor; namespace { SensorEvent make_sensor(uint32_t ts_ms, uint32_t mv, uint32_t ma) { - return SensorEvent{SensorSnapshot{ts_ms, mv, ma}}; + return SensorEvent{LoadReading{ts_ms, mv, ma}}; } const EnergyEvent* drain_last_energy(TestQueue& q) { diff --git a/test/test_v2_normal/test.cpp b/test/test_v2_normal/test.cpp index 36d1a4a..e6a6813 100644 --- a/test/test_v2_normal/test.cpp +++ b/test/test_v2_normal/test.cpp @@ -355,7 +355,7 @@ TEST(NormalStage, OnEnterPdoBranchRendersVAReadoutAndPdoIndex) { TestConductor conductor; conductor.register_stage(normal); normal.prepare(2); - normal.on_event(conductor, SensorEvent{SensorSnapshot{0, 5000, 1234}}, 0); + normal.on_event(conductor, SensorEvent{LoadReading{0, 5000, 1234}}, 0); conductor.start(); } @@ -420,7 +420,7 @@ TEST(NormalView, ReadoutHiddenKeepsLabelsHidesValues) { vm.output_enabled = false; vm.readout_visible = false; vm.active_pdo_index = 0; - vm.snapshot = SensorSnapshot{0, 5000, 1234}; + vm.load_reading = LoadReading{0, 5000, 1234}; NormalView::render(display, vm); } diff --git a/test/test_v2_sensor/test.cpp b/test/test_v2_sensor/test.cpp index bd077f9..0f27774 100644 --- a/test/test_v2_sensor/test.cpp +++ b/test/test_v2_sensor/test.cpp @@ -47,9 +47,9 @@ TEST(SensorTask, OnTickPublishesSensorEventWithTimestampAndValues) { const auto* evt = pop_sensor(q); ASSERT_NE(evt, nullptr); - EXPECT_EQ(evt->snapshot.timestamp_ms, 42u); - EXPECT_EQ(evt->snapshot.vbus_mv, 5000u); - EXPECT_EQ(evt->snapshot.current_ma, 1234u); + EXPECT_EQ(evt->load.timestamp_ms, 42u); + EXPECT_EQ(evt->load.vbus_mv, 5000u); + EXPECT_EQ(evt->load.current_ma, 1234u); } TEST(SensorTask, InvalidReadingDoesNotPublish) {