Skip to content
Merged
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
16 changes: 16 additions & 0 deletions app/lib/features/walk/domain/estimators/default_step_length.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/// Estimates an initial step length from height and optional gender.
/// GPS calibration replaces this estimate during the walk.
double estimateDefaultStepLength({num? heightCm, String? gender}) {
// Older exports may lack a valid height.
if (heightCm == null || !heightCm.isFinite || heightCm <= 0) {
return 0.7;
}

var factor = 0.414;
if (gender == 'male') {
factor = 0.415;
} else if (gender == 'female') {
factor = 0.413;
}
return heightCm / 100 * factor;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ abstract class DistanceEstimator {
/// Implementations can report counters or settings; reset must clear run state.
Map<String, String> get additionalInfo => const {};

/// Supplies optional profile data before a new run. Fixed comparison
/// estimators keep their configured parameters unless they override this.
void configureProfile({num? heightCm, String? gender}) {}

void addSample(SensorSample sample);

void reset();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:six_minute_walk_test/core/domain/sensor_sample.dart';

import 'distance_estimator.dart';
import 'default_step_length.dart';

/// GPS distance with a phone-step fallback and a session-local stride estimate.
///
Expand Down Expand Up @@ -52,6 +53,7 @@ class GpsStepDistanceEstimator extends DistanceEstimator {
double _gpsDistance = 0;
double _totalDistance = 0;
double? _learnedStepLength;
double _initialStepLength = defaultStepLength;
int _acceptedGpsIntervals = 0;
int _rejectedGpsGap = 0;
int _rejectedGpsCoordinates = 0;
Expand All @@ -70,7 +72,15 @@ class GpsStepDistanceEstimator extends DistanceEstimator {

bool get hasLearnedStepLength => _learnedStepLength != null;

double get stepLength => _learnedStepLength ?? defaultStepLength;
double get stepLength => _learnedStepLength ?? _initialStepLength;

@override
void configureProfile({num? heightCm, String? gender}) {
_initialStepLength = estimateDefaultStepLength(
heightCm: heightCm,
gender: gender,
);
}

@override
Map<String, String> get additionalInfo => {
Expand Down
10 changes: 10 additions & 0 deletions app/lib/features/walk/domain/walk_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ class WalkSession {

int? profileId;

/// Configure the next test without changing a walk that is already running.
void setProfile({required int id, required num heightCm, String? gender}) {
if (_state.isRunning) return;
profileId = id;
_distanceEstimator.configureProfile(heightCm: heightCm, gender: gender);
for (final entry in _comparisonEstimators) {
entry.estimator.configureProfile(heightCm: heightCm, gender: gender);
}
}

WalkSessionState get state => _state;

// Emits the current state immediately, then every subsequent change.
Expand Down
16 changes: 12 additions & 4 deletions app/lib/features/walk/presentation/walk_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ class _WalkScreenState extends ConsumerState<WalkScreen> {
if (!mounted) return;

final session = ref.read(walkSessionProvider);
session.profileId = widget.profileId;
if (profile != null) {
session.setProfile(
id: profile.id,
heightCm: profile.height,
gender: profile.gender.name,
);
}

setState(() {
_profile = profile;
Expand Down Expand Up @@ -476,9 +482,11 @@ class _WalkScreenState extends ConsumerState<WalkScreen> {
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: () async {
await session.start();
},
onPressed: _profile == null
? null
: () async {
await session.start();
},
style: ElevatedButton.styleFrom(
backgroundColor: primaryBlue,
foregroundColor: Colors.white,
Expand Down
35 changes: 35 additions & 0 deletions app/test/features/walk/domain/default_step_length_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:six_minute_walk_test/features/walk/domain/estimators/default_step_length.dart';

void main() {
for (final (gender, expected) in <(String?, double)>[
('male', 0.747),
('female', 0.7434),
('other', 0.7452),
(null, 0.7452),
('unknown', 0.7452),
]) {
test('estimates metres per step at 180 cm with gender=$gender', () {
expect(
estimateDefaultStepLength(heightCm: 180, gender: gender),
closeTo(expected, 1e-9),
);
});
}

test('scales the neutral estimate with height', () {
expect(estimateDefaultStepLength(heightCm: 160), closeTo(0.6624, 1e-9));
expect(estimateDefaultStepLength(heightCm: 200), closeTo(0.828, 1e-9));
});

test('accepts positive heights outside the profile form range', () {
expect(estimateDefaultStepLength(heightCm: 49), closeTo(0.20286, 1e-9));
expect(estimateDefaultStepLength(heightCm: 251), closeTo(1.03914, 1e-9));
});

for (final height in <num?>[null, double.nan, double.infinity, -10, 0]) {
test('falls back to 0.7 m for missing or invalid height=$height', () {
expect(estimateDefaultStepLength(heightCm: height, gender: 'male'), 0.7);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ void main() {
estimator = GpsStepDistanceEstimator(stepSourceId: 'pedometer');
});

test('profile default is corrected by learning and survives reset', () {
estimator.configureProfile(heightCm: 180, gender: 'female');
estimator.addSample(_steps(0, 1000));
estimator.addSample(_steps(10, 1010));
expect(estimator.totalDistance, closeTo(7.434, 1e-6));
expect(estimator.hasLearnedStepLength, isFalse);
expect(estimator.additionalInfo['Calibration'], 'Default');

_walk(estimator, start: 10, initialSteps: 1010);
expect(estimator.stepLength, closeTo(1, 1e-6));
// Fifteen GPS metres plus the ten earlier steps corrected to one metre.
expect(estimator.totalDistance, closeTo(25, 1e-6));

estimator.reset();
expect(estimator.hasLearnedStepLength, isFalse);
expect(estimator.stepLength, closeTo(0.7434, 1e-9));
expect(estimator.totalDistance, 0);
});

test('good GPS supplies distance even without a step sensor', () {
for (var i = 0; i <= 20; i++) {
estimator.addSample(_gps(i, i.toDouble()));
Expand Down
79 changes: 79 additions & 0 deletions app/test/features/walk/domain/walk_session_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,85 @@ SensorSample _position(double lat, double lng) => SensorSample(
// ---------------------------------------------------------------------------

void main() {
test(
'profile default follows test profile changes but not an active walk',
() {
fakeAsync((async) {
final source = FakeSensorSource();
final estimator = GpsStepDistanceEstimator(stepSourceId: 'fake');
final session = WalkSession(
sources: [source],
distanceEstimator: estimator,
);
final firstProfile = Profile(
id: 1,
timestamp: DateTime.utc(2026),
height: 180,
age: 30,
weight: 70,
gender: Gender.other,
);
final secondProfile = Profile(
id: 2,
timestamp: DateTime.utc(2026),
height: 160,
age: 60,
weight: 90,
gender: Gender.female,
);
void steps(int second, double count) {
source.controller.add(
SensorSample(
timestamp: DateTime.utc(2026).add(Duration(seconds: second)),
sourceId: 'fake',
type: SampleType.steps,
values: {StepKeys.cumulativeSteps: count},
),
);
async.flushMicrotasks();
}

session.setProfile(
id: firstProfile.id,
heightCm: firstProfile.height,
gender: firstProfile.gender.name,
);
session.start();
async.flushMicrotasks();
steps(0, 1000);
steps(10, 1010);
expect(session.state.distance, closeTo(7.452, 1e-6));
expect(estimator.hasLearnedStepLength, isFalse);

session.setProfile(
id: secondProfile.id,
heightCm: secondProfile.height,
gender: secondProfile.gender.name,
);
expect(session.profileId, 1);
expect(estimator.stepLength, closeTo(0.7452, 1e-9));

session.abort();
async.flushMicrotasks();
session.setProfile(
id: secondProfile.id,
heightCm: secondProfile.height,
gender: secondProfile.gender.name,
);
session.start();
async.flushMicrotasks();
steps(20, 2000);
steps(30, 2002);
expect(session.profileId, 2);
expect(session.state.distance, closeTo(1.3216, 1e-6));
session.dispose();
async.flushMicrotasks();
source.controller.close();
async.flushMicrotasks();
});
},
);

group('deduplicatedSaves', () {
test('persists optional phone steps and a downward stride correction', () {
fakeAsync((async) {
Expand Down
80 changes: 80 additions & 0 deletions app/test/features/walk/presentation/walk_profile_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:async';

import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:six_minute_walk_test/core/data/database.dart';
import 'package:six_minute_walk_test/core/data/profile_repository.dart';
import 'package:six_minute_walk_test/core/data/providers.dart';
import 'package:six_minute_walk_test/features/walk/domain/estimators/gps_step_distance_estimator.dart';
import 'package:six_minute_walk_test/features/walk/domain/walk_session.dart';
import 'package:six_minute_walk_test/features/walk/domain/walk_session_provider.dart';
import 'package:six_minute_walk_test/features/walk/presentation/walk_screen.dart';

import '../domain/walk_session_test.dart' show InitialSampleFakeSensorSource;

class PendingProfileRepository extends ProfileRepository {
PendingProfileRepository(super.db, this.result);
final Future<Profile?> result;

@override
Future<Profile?> loadProfile(int profileId) => result;
}

void main() {
testWidgets(
'waits for profile before starting with its estimated step length',
(tester) async {
tester.view.physicalSize = const Size(1000, 2000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final db = AppDatabase(NativeDatabase.memory());
addTearDown(db.close);
final pending = Completer<Profile?>();
final estimator = GpsStepDistanceEstimator(stepSourceId: 'steps');
final session = WalkSession(
sources: [InitialSampleFakeSensorSource([])],
distanceEstimator: estimator,
now: () => tester.binding.clock.now(),
);
addTearDown(session.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
walkSessionProvider.overrideWithValue(session),
profileRepositoryProvider.overrideWithValue(
PendingProfileRepository(db, pending.future),
),
],
child: const MaterialApp(home: WalkScreen(profileId: 3)),
),
);
final startButton = find.widgetWithText(ElevatedButton, 'Start Test');
expect(tester.widget<ElevatedButton>(startButton).onPressed, isNull);
expect(session.state.isRunning, isFalse);

pending.complete(
Profile(
id: 3,
timestamp: DateTime.utc(2026),
height: 180,
age: 30,
weight: 70,
gender: Gender.female,
),
);
await tester.pumpAndSettle();
expect(tester.widget<ElevatedButton>(startButton).onPressed, isNotNull);
await tester.tap(startButton);
await tester.pump();
expect(session.profileId, 3);
expect(session.state.isRunning, isTrue);
expect(estimator.stepLength, closeTo(0.7434, 1e-9));
expect(estimator.hasLearnedStepLength, isFalse);
await session.abort();
await tester.pump();
},
);
}
4 changes: 4 additions & 0 deletions tools/6mwt_data_visualizer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Available for Linux, macOS and Windows. Open app JSON exports or folders of reco
- **Estimator Lab:** replay the app's actual algorithms, compare distance curves and inspect diagnostics. Algorithm selection and parameter variants are configured in code.
- **Multiple devices:** compare recordings from the same walk without combining their sensor streams.

The GPS/step estimator uses the recording's profile for its initial step length
before sensor calibration. See [Distance Estimators](../../wiki/Distance-Estimators.md#initial-step-length-from-profile-data)
for the formula and compatibility with older exports.

The [example data](../../data/README.md) is a useful starting point. Release archives contain the application but no recordings; satellite imagery requires internet access. Keep the entire extracted application bundle together.

![Full Recording view of the Uni 1 example recording](../../wiki/assets/visualizer-recording.png)
Expand Down
2 changes: 1 addition & 1 deletion tools/6mwt_data_visualizer/lib/core/domain/session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class Profile {
id: json['id'] as int,
name: json['name'] as String?,
timestamp: DateTime.parse(json['timestamp'] as String),
height: json['height'] as int,
height: (json['height'] as num?)?.toInt() ?? 0,
age: json['age'] as int,
weight: (json['weight'] as num?)?.toDouble(),
gender: json['gender'] as String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ReplayResult {
/// Reference samples are used for comparison only and never enter that stream.
class EstimatorReplay {
EstimatorReplay(Session session) {
_profile = session.profile;
// Stable tie-breaking preserves file order for simultaneous events.
final indexed = session.samples.indexed.toList();
indexed.sort((a, b) {
Expand Down Expand Up @@ -83,6 +84,7 @@ class EstimatorReplay {
}

late final List<app.SensorSample> samples;
late final Profile? _profile;
late final DateTime start;
late final double duration;

Expand All @@ -98,6 +100,10 @@ class EstimatorReplay {
: estimator.runtimeType.toString();
final points = <DistancePoint>[];
try {
estimator.configureProfile(
heightCm: _profile?.height,
gender: _profile?.gender,
);
estimator.reset();
for (final sample in samples) {
estimator.addSample(sample);
Expand Down
Loading
Loading