diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart index 415a2791..080ad48b 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart @@ -41,13 +41,18 @@ class _AxisDisplayFilterCache { } class _AxisDisplayFilter { + static const int _initialIntervalCount = 8; + static const int _rateCheckIntervalCount = 16; + final _AxisFilterConfig config; final double timestampScale; final List<_IirFilterStage> _highPassStages; final List<_IirFilterStage> _lowPassStages; final List<_IirFilterStage> _notchStages; + final List _measuredIntervals = []; int? _previousTimestamp; + double? _designDt; _AxisDisplayFilter({ required this.config, @@ -61,7 +66,10 @@ class _AxisDisplayFilter { return input; } - final dt = _timeDeltaSeconds(timestamp); + final dt = _stableTimeDelta(_timeDeltaSeconds(timestamp)); + if (dt == null) { + return input; + } var output = input; for (final stage in _highPassStages) { output = stage.apply(output, dt); @@ -78,6 +86,12 @@ class _AxisDisplayFilter { void reset() { _previousTimestamp = null; + _designDt = null; + _measuredIntervals.clear(); + _resetStages(); + } + + void _resetStages() { for (final stage in _highPassStages) { stage.reset(); } @@ -89,6 +103,44 @@ class _AxisDisplayFilter { } } + double? _stableTimeDelta(double measuredDt) { + if (measuredDt <= 0 || !measuredDt.isFinite) { + return _designDt; + } + + _measuredIntervals.add(measuredDt); + final designDt = _designDt; + if (designDt == null) { + if (_measuredIntervals.length < _initialIntervalCount) { + return null; + } + _designDt = _medianInterval(_measuredIntervals); + _measuredIntervals.clear(); + return _designDt; + } + + if (_measuredIntervals.length >= _rateCheckIntervalCount) { + final recentDt = _medianInterval(_measuredIntervals); + _measuredIntervals.clear(); + // Keep IIR coefficients fixed through timestamp jitter. A sustained rate + // change needs new coefficients and a reset of the filter state. + if (recentDt < designDt * 0.8 || recentDt > designDt * 1.25) { + _designDt = recentDt; + _resetStages(); + } + } + return _designDt; + } + + static double _medianInterval(List intervals) { + final sorted = intervals.toList()..sort(); + final middle = sorted.length ~/ 2; + if (sorted.length.isOdd) { + return sorted[middle]; + } + return (sorted[middle - 1] + sorted[middle]) / 2; + } + double _timeDeltaSeconds(int timestamp) { final previousTimestamp = _previousTimestamp; _previousTimestamp = timestamp; diff --git a/open_wearable/test/widgets/sensors/values/sensor_chart_notch_test.dart b/open_wearable/test/widgets/sensors/values/sensor_chart_notch_test.dart new file mode 100644 index 00000000..bb67320a --- /dev/null +++ b/open_wearable/test/widgets/sensors/values/sensor_chart_notch_test.dart @@ -0,0 +1,185 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/view_models/sensor_data_provider.dart'; +import 'package:open_wearable/widgets/sensors/values/sensor_chart.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('50 Hz notch remains bounded with irregular sample intervals', + (tester) async { + final provider = _FakeSensorDataProvider(); + provider.addSamples(22); + + await _enableNotch(tester, provider); + + void expectStableOutput() { + final chart = tester.widget(find.byType(LineChart)); + expect( + chart.data.lineBarsData.single.spots.every( + (spot) => spot.y.isFinite && spot.y.abs() < 5, + ), + isTrue, + reason: 'A bounded input must not produce a growing notch output.', + ); + expect(tester.takeException(), isNull); + } + + expectStableOutput(); + for (var batch = 0; batch < 25; batch++) { + provider.addSamples(50); + await tester.pump(); + expectStableOutput(); + } + }); + + testWidgets('50 Hz notch still attenuates a regularly sampled 50 Hz tone', + (tester) async { + final provider = _FakeSensorDataProvider(intervalsMs: const [5]); + provider.addSamples(400); + + await _pumpChart(tester, provider); + final rawMean = _recentMeanAbsoluteValue(tester); + await _openNotchSettings(tester); + final filteredMean = _recentMeanAbsoluteValue(tester); + + expect(filteredMean, lessThan(rawMean * 0.5)); + expect(tester.takeException(), isNull); + }); + + testWidgets('50 Hz notch adapts when the sensor sampling rate changes', + (tester) async { + final provider = _FakeSensorDataProvider(intervalsMs: const [10]); + provider.addSamples(200); + await _enableNotch(tester, provider); + + provider.useIntervals(const [5]); + provider.addSamples(400); + await tester.pump(); + + expect(_recentMeanAbsoluteValue(tester), lessThan(0.3)); + expect(tester.takeException(), isNull); + }); +} + +Future _enableNotch( + WidgetTester tester, + _FakeSensorDataProvider provider, +) async { + await _pumpChart(tester, provider); + await _openNotchSettings(tester); +} + +Future _pumpChart( + WidgetTester tester, + _FakeSensorDataProvider provider, +) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: const MaterialApp( + home: Scaffold( + body: SizedBox(height: 400, child: SensorChart()), + ), + ), + ), + ); +} + +Future _openNotchSettings(WidgetTester tester) async { + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Notch filter')); + await tester.tap(find.byType(Switch).last); + await tester.pumpAndSettle(); +} + +double _recentMeanAbsoluteValue(WidgetTester tester) { + final chart = tester.widget(find.byType(LineChart)); + final spots = chart.data.lineBarsData.single.spots; + final recent = spots.skip(spots.length - 100); + return recent.fold(0, (sum, spot) => sum + spot.y.abs()) / 100; +} + +class _FakeSensor extends Sensor { + const _FakeSensor() + : super( + sensorName: 'Accelerometer', + chartTitle: 'Accelerometer', + shortChartTitle: 'Accel', + ); + + @override + List get axisNames => const ['X']; + + @override + List get axisUnits => const ['g']; + + @override + Stream get sensorStream => const Stream.empty(); +} + +class _FakeWearable implements Wearable { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeSensorDataProvider extends ChangeNotifier + implements SensorDataProvider { + List _intervalsMs; + + _FakeSensorDataProvider({ + List intervalsMs = const [5, 10, 15, 10], + }) : _intervalsMs = intervalsMs; + + int _latestTimestamp = 0; + int _sampleIndex = 0; + + @override + final Sensor sensor = const _FakeSensor(); + + @override + final Wearable wearable = _FakeWearable(); + + @override + final Queue sensorValues = Queue(); + + @override + int get timeWindow => 5; + + @override + int get displayTimestamp => sensorValues.last.timestamp; + + void useIntervals(List intervalsMs) { + _intervalsMs = intervalsMs; + _sampleIndex = 0; + } + + void addSamples(int count) { + for (var i = 0; i < count; i++) { + _latestTimestamp += _intervalsMs[_sampleIndex % _intervalsMs.length]; + _sampleIndex++; + sensorValues.add( + SensorDoubleValue( + values: [ + sin(2 * pi * 50 * _latestTimestamp / 1000) + + 0.2 * sin(2 * pi * 7 * _latestTimestamp / 1000), + ], + timestamp: _latestTimestamp, + ), + ); + } + while (sensorValues.isNotEmpty && + sensorValues.first.timestamp < _latestTimestamp - 5000) { + sensorValues.removeFirst(); + } + notifyListeners(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}