From 3d7d382394495cdf349c05080f19f338347ed382 Mon Sep 17 00:00:00 2001 From: Divij Kohli <134753730+divijsinghkohli@users.noreply.github.com> Date: Tue, 14 Oct 2025 19:01:35 -0500 Subject: [PATCH 1/4] update dev (#18) * Dev (#14) * chore: add PR and issue templates * docs: added contribution guidelines * feature: initial app structure for FESMobile (#3) * updated README * Features/react native setup (#6) * chore: add PR and issue templates (#1) * docs: add CONTRIBUTING guide (#2) * chore: add PR and issue templates * docs: added contribution guidelines * updated readme (#5) * Set up react native --------- Co-authored-by: Divij Kohli <134753730+divijsinghkohli@users.noreply.github.com> * Features/react native setup (#7) * chore: add PR and issue templates (#1) * docs: add CONTRIBUTING guide (#2) * chore: add PR and issue templates * docs: added contribution guidelines * updated readme (#5) * Set up react native * added realtime button --------- Co-authored-by: Divij Kohli <134753730+divijsinghkohli@users.noreply.github.com> * Fixed Home Screen * Added TodoList * Feature/login screen (#11) * added login + signupscreen + functional nav * mvp auth logic * reintegrated mayank's gyro logic * Server file created (#13) * Made changes to font for visibility (#15) --------- Co-authored-by: mjain533 Co-authored-by: Dev Menon <68741023+devmenon23@users.noreply.github.com> Co-authored-by: mehtabsandhu * improved sensor accuracy --------- Co-authored-by: mjain533 Co-authored-by: Dev Menon <68741023+devmenon23@users.noreply.github.com> Co-authored-by: mehtabsandhu --- FES-app/FES/app.json | 11 +- FES-app/FES/screens/RealTimeData.tsx | 345 ++++++++++++++++++++++----- 2 files changed, 289 insertions(+), 67 deletions(-) diff --git a/FES-app/FES/app.json b/FES-app/FES/app.json index b862b56..446b3cc 100644 --- a/FES-app/FES/app.json +++ b/FES-app/FES/app.json @@ -9,7 +9,10 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "infoPlist": { + "NSMotionUsageDescription": "This app uses motion sensors to detect foot movement for FES device control." + } }, "android": { "adaptiveIcon": { @@ -19,7 +22,11 @@ "monochromeImage": "./assets/images/android-icon-monochrome.png" }, "edgeToEdgeEnabled": true, - "predictiveBackGestureEnabled": false + "predictiveBackGestureEnabled": false, + "permissions": [ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION" + ] }, "web": { "output": "static", diff --git a/FES-app/FES/screens/RealTimeData.tsx b/FES-app/FES/screens/RealTimeData.tsx index 375d493..991a5df 100644 --- a/FES-app/FES/screens/RealTimeData.tsx +++ b/FES-app/FES/screens/RealTimeData.tsx @@ -1,83 +1,269 @@ import { ThemedText } from '@/components/themed-text'; import React from 'react'; -import { StyleSheet, View, Text, ScrollView } from 'react-native'; -import { Gyroscope, DeviceMotion } from 'expo-sensors'; -import { useEffect, useState } from 'react'; +import { StyleSheet, View, ScrollView, Alert } from 'react-native'; +import { Gyroscope, DeviceMotion, Accelerometer } from 'expo-sensors'; +import { useEffect, useState, useRef } from 'react'; import { TouchableOpacity } from 'react-native'; import { ThemedView } from '@/components/themed-view'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// Safe number formatter +const formatNumber = (value: number | undefined | null): string => { + if (value === undefined || value === null || isNaN(value)) { + return '0.000'; + } + return value.toFixed(3); +}; + +// Safe object property getter +const getSafeValue = (obj: any, prop: string): number => { + if (!obj || typeof obj !== 'object' || obj[prop] === undefined || obj[prop] === null) { + return 0; + } + return typeof obj[prop] === 'number' ? obj[prop] : 0; +}; + export default function RealTimeData() { - const router = useRouter(); - const insets = useSafeAreaInsets(); - const [data, setData] = useState({ x: 0, y: 0, z: 0 }); - const [motionData, setMotionData] = useState({}); + const router = useRouter(); + const insets = useSafeAreaInsets(); + + // Raw sensor data state + const [gyroData, setGyroData] = useState({ x: 0, y: 0, z: 0 }); + const [accelData, setAccelData] = useState({ x: 0, y: 0, z: 0 }); + const [angleData, setAngleData] = useState({ x: 0, y: 0, z: 0 }); + + // Data smoothing refs + const gyroHistory = useRef>([]); + const accelHistory = useRef>([]); + const angleHistory = useRef>([]); + + // Data smoothing function + const smoothSensorData = (newData: {x: number, y: number, z: number}, history: React.MutableRefObject>, maxHistory: number = 5) => { + // Add new data to history + history.current.push(newData); + + // Keep only recent history + if (history.current.length > maxHistory) { + history.current.shift(); + } + + // Calculate average + const avg = history.current.reduce((acc, curr) => ({ + x: acc.x + curr.x, + y: acc.y + curr.y, + z: acc.z + curr.z + }), { x: 0, y: 0, z: 0 }); + + return { + x: avg.x / history.current.length, + y: avg.y / history.current.length, + z: avg.z / history.current.length + }; + }; + + // Convert accelerometer data to angles (degrees) + const convertAccelToAngles = (accelData: {x: number, y: number, z: number}) => { + // Calculate angles from accelerometer data + const toDegrees = (radians: number) => radians * (180 / Math.PI); + + // Calculate pitch (X-axis rotation) - forward/back tilt + const pitch = Math.atan2(accelData.y, accelData.z); + + // Calculate roll (Y-axis rotation) - left/right tilt + const roll = Math.atan2(-accelData.x, Math.sqrt(accelData.y * accelData.y + accelData.z * accelData.z)); + + // Calculate yaw (Z-axis rotation) - rotation around vertical axis + const yaw = Math.atan2(accelData.x, accelData.y); + + return { + x: toDegrees(pitch), + y: toDegrees(roll), + z: toDegrees(yaw) + }; + }; + // Gyroscope listener (for rotation rate only) useEffect(() => { - const subscription = Gyroscope.addListener(setData); - Gyroscope.setUpdateInterval(100); // update every 100ms - return () => subscription.remove(); + let gyroSubscription: any = null; + + const startGyroscope = async () => { + try { + console.log('Starting gyroscope...'); + const isAvailable = await Gyroscope.isAvailableAsync(); + console.log('Gyroscope available:', isAvailable); + + if (isAvailable) { + Gyroscope.setUpdateInterval(50); // Very fast updates + gyroSubscription = Gyroscope.addListener((data) => { + if (data && typeof data === 'object') { + const rawData = { + x: getSafeValue(data, 'x'), + y: getSafeValue(data, 'y'), + z: getSafeValue(data, 'z') + }; + + // Apply smoothing + const smoothedData = smoothSensorData(rawData, gyroHistory, 3); + setGyroData(smoothedData); + } + }); + console.log('Gyroscope listener added'); + } else { + Alert.alert('Gyroscope Not Available', 'This device does not have a gyroscope or it is not accessible.'); + } + } catch (error) { + console.error('Gyroscope error:', error); + Alert.alert('Gyroscope Error', `Failed to access gyroscope: ${error}`); + } + }; + + startGyroscope(); + + return () => { + if (gyroSubscription) { + gyroSubscription.remove(); + console.log('Gyroscope listener removed'); + } + }; }, []); + + // Accelerometer listener useEffect(() => { - // Subscribe to device motion updates - const subscription = DeviceMotion.addListener((data) => { - setMotionData(data); - }); - DeviceMotion.setUpdateInterval(100); // every 100ms + let accelSubscription: any = null; + + const startAccelerometer = async () => { + try { + console.log('Starting accelerometer...'); + const isAvailable = await Accelerometer.isAvailableAsync(); + console.log('Accelerometer available:', isAvailable); + + if (isAvailable) { + Accelerometer.setUpdateInterval(50); // Very fast updates + accelSubscription = Accelerometer.addListener((data) => { + if (data && typeof data === 'object') { + const rawData = { + x: getSafeValue(data, 'x'), + y: getSafeValue(data, 'y'), + z: getSafeValue(data, 'z') + }; + + // Apply smoothing + const smoothedData = smoothSensorData(rawData, accelHistory, 3); + setAccelData(smoothedData); + + // Convert accelerometer to angles + const angleData = convertAccelToAngles(smoothedData); + const smoothedAngles = smoothSensorData(angleData, angleHistory, 3); + setAngleData(smoothedAngles); + } + }); + console.log('Accelerometer listener added'); + } else { + Alert.alert('Accelerometer Not Available', 'This device does not have an accelerometer or it is not accessible.'); + } + } catch (error) { + console.error('Accelerometer error:', error); + Alert.alert('Accelerometer Error', `Failed to access accelerometer: ${error}`); + } + }; - return () => subscription.remove(); + startAccelerometer(); + + return () => { + if (accelSubscription) { + accelSubscription.remove(); + console.log('Accelerometer listener removed'); + } + }; }, []); - const { acceleration, accelerationIncludingGravity, rotation, rotationRate, orientation } = motionData; - return ( - - - Gyroscope Data - - X: {data.x.toFixed(2)} - Y: {data.y.toFixed(2)} - Z: {data.z.toFixed(2)} - - - Device Motion Data - - Orientation: {orientation || 'N/A'} - - - Acceleration (no gravity):{" "} - {acceleration ? `${acceleration.x.toFixed(2)}, ${acceleration.y.toFixed(2)}, ${acceleration.z.toFixed(2)}` : "N/A"} - - - - Acceleration (with gravity):{" "} - {accelerationIncludingGravity ? `${accelerationIncludingGravity.x.toFixed(2)}, ${accelerationIncludingGravity.y.toFixed(2)}, ${accelerationIncludingGravity.z.toFixed(2)}` : "N/A"} - - - - Rotation (quaternion):{" "} - {rotation ? `${rotation.alpha.toFixed(2)}, ${rotation.beta.toFixed(2)}, ${rotation.gamma.toFixed(2)}` : "N/A"} - - - - Rotation rate:{" "} - {rotationRate ? `${rotationRate.alpha.toFixed(2)}, ${rotationRate.beta.toFixed(2)}, ${rotationRate.gamma.toFixed(2)}` : "N/A"} - - - - - - { - router.replace('/functional/home'); - }} - > - Back to Home - + + // Calculate magnitudes + const gyroMagnitude = Math.sqrt(gyroData.x * gyroData.x + gyroData.y * gyroData.y + gyroData.z * gyroData.z); + const accelMagnitude = Math.sqrt(accelData.x * accelData.x + accelData.y * accelData.y + accelData.z * accelData.z); + + return ( + + + Phone Angle (Degrees) - From Accelerometer + + + X-Axis (Forward/Back Tilt): {formatNumber(angleData.x)}° + 10 ? '#FF3B30' : '#34C759' }]} /> - - + + Y-Axis (Left/Right Tilt): {formatNumber(angleData.y)}° + 10 ? '#FF3B30' : '#34C759' }]} /> + + + Z-Axis (Rotation): {formatNumber(angleData.z)}° + 10 ? '#FF3B30' : '#34C759' }]} /> + + + + Total Tilt: {formatNumber(Math.sqrt(angleData.x * angleData.x + angleData.y * angleData.y))}° + + + + + Accelerometer Data (Movement) + + + X (Forward/Back): {formatNumber(accelData.x)} + 0.5 ? '#FF3B30' : '#34C759' }]} /> + + + Y (Up/Down): {formatNumber(accelData.y)} + 0.5 ? '#FF3B30' : '#34C759' }]} /> + + + Z (Side/Side): {formatNumber(accelData.z)} + 0.5 ? '#FF3B30' : '#34C759' }]} /> + + + + Movement Magnitude: {formatNumber(accelMagnitude)} + + + + + Instructions + + + • Tilt phone FORWARD/BACK - X-axis should change + + + • Tilt phone LEFT/RIGHT - Y-axis should change + + + • Rotate phone clockwise/counterclockwise - Z-axis should change + + + • 0° = phone perfectly vertical + + + • 45° = phone tilted 45 degrees + + + • 90° = phone horizontal + + + + + + { + router.replace('/functional/home'); + }} + > + Back to Home + + + ); } + const styles = StyleSheet.create({ container: { flex: 1, @@ -97,11 +283,40 @@ const styles = StyleSheet.create({ borderRadius: 12, padding: 16, marginBottom: 20, + borderWidth: 1, + borderColor: '#2C2C2E', }, dataLabel: { fontSize: 16, marginBottom: 8, lineHeight: 22, + color: '#FFFFFF', + fontWeight: '500', + flex: 1, + }, + dataRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + indicator: { + width: 12, + height: 12, + borderRadius: 6, + marginLeft: 10, + }, + magnitudeContainer: { + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#2C2C2E', + }, + magnitudeLabel: { + fontSize: 16, + color: '#FFFFFF', + fontWeight: '600', + textAlign: 'center', }, footer: { position: 'absolute', @@ -124,4 +339,4 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, -}); +}); \ No newline at end of file From 215ca4113dc3c4f09bfaf54b5d334c68a52ad088 Mon Sep 17 00:00:00 2001 From: SohanV97 Date: Tue, 21 Oct 2025 19:03:18 -0500 Subject: [PATCH 2/4] Made calibration --- FES-app/FES/app/functional/calibration.tsx | 282 ----------- FES-app/FES/app/functional/calibration_UI.tsx | 456 ++++++++++++++++++ FES-app/FES/app/functional/home.tsx | 2 +- FES-app/FES/screens/RealTimeData.tsx | 6 + FES-app/FES/services/backend/calibration.tsx | 214 ++++++++ 5 files changed, 677 insertions(+), 283 deletions(-) delete mode 100644 FES-app/FES/app/functional/calibration.tsx create mode 100644 FES-app/FES/app/functional/calibration_UI.tsx create mode 100644 FES-app/FES/services/backend/calibration.tsx diff --git a/FES-app/FES/app/functional/calibration.tsx b/FES-app/FES/app/functional/calibration.tsx deleted file mode 100644 index 52d1168..0000000 --- a/FES-app/FES/app/functional/calibration.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { StyleSheet, View, TouchableOpacity, ActivityIndicator, ScrollView } from 'react-native'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { useRouter } from 'expo-router'; -import { Ionicons } from '@expo/vector-icons'; -import { useState, useEffect } from 'react'; - -export default function CalibrationScreen() { - const router = useRouter(); - const [currentStep, setCurrentStep] = useState(1); - const [isCalibrating, setIsCalibrating] = useState(false); - const [progress, setProgress] = useState(0); - - useEffect(() => { - let interval: ReturnType; - - if (isCalibrating) { - interval = setInterval(() => { - setProgress(prev => { - if (prev >= 100) { - clearInterval(interval); - setCurrentStep(2); - setIsCalibrating(false); - return 100; - } - return prev + 10; - }); - }, 300); - } - - return () => clearInterval(interval); - }, [isCalibrating]); - - const handleStartCalibration = () => { - setProgress(0); - setIsCalibrating(true); - }; - - const handleComplete = () => { - // Navigate back to home after calibration - router.back(); - }; - - return ( - - - router.back()} style={styles.backButton}> - - - Device Calibration - - - - - {currentStep === 1 && ( - - - - 1 - - - 1 && styles.stepActive]}> - 2 - - - - Prepare for Calibration - - Please make sure the device is properly attached and you're in a comfortable position. - - - - - - Ensure the electrodes are properly placed on your skin and the device is turned on. - - - - {isCalibrating ? ( - - - - Calibrating... {progress}% - - - - - - ) : ( - - - {isCalibrating ? 'Calibrating...' : 'Start Calibration'} - - - )} - - )} - - {currentStep === 2 && ( - - - - - - - - 2 - - - - Calibration Complete - - Your device has been successfully calibrated and is ready to use. - - - - - - - Calibration Successful! - - - - Start Session - - - )} - - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: 20, - borderBottomWidth: 1, - borderBottomColor: '#E5E5EA', - }, - backButton: { - padding: 8, - }, - title: { - fontSize: 20, - fontWeight: '600', - }, - content: { - padding: 20, - }, - stepContainer: { - alignItems: 'center', - }, - stepIndicator: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 30, - }, - stepCircle: { - width: 30, - height: 30, - borderRadius: 15, - borderWidth: 2, - borderColor: '#E5E5EA', - justifyContent: 'center', - alignItems: 'center', - }, - stepActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - stepComplete: { - backgroundColor: '#34C759', - borderColor: '#34C759', - }, - stepLine: { - flex: 1, - height: 2, - backgroundColor: '#E5E5EA', - marginHorizontal: 10, - }, - stepNumber: { - color: '#8E8E93', - fontWeight: '600', - }, - stepTitle: { - fontSize: 22, - fontWeight: '600', - marginBottom: 10, - textAlign: 'center', - }, - stepDescription: { - fontSize: 16, - color: '#8E8E93', - textAlign: 'center', - marginBottom: 30, - lineHeight: 24, - }, - instructionCard: { - flexDirection: 'row', - backgroundColor: '#F2F2F7', - borderRadius: 12, - padding: 16, - marginBottom: 30, - width: '100%', - }, - instructionIcon: { - marginRight: 12, - }, - instructionText: { - flex: 1, - fontSize: 15, - lineHeight: 22, - }, - primaryButton: { - backgroundColor: '#007AFF', - borderRadius: 12, - padding: 16, - alignItems: 'center', - width: '100%', - marginTop: 20, - }, - primaryButtonText: { - color: '#fff', - fontSize: 17, - fontWeight: '600', - }, - buttonDisabled: { - opacity: 0.5, - }, - successContainer: { - alignItems: 'center', - marginVertical: 40, - }, - successCircle: { - width: 100, - height: 100, - borderRadius: 50, - backgroundColor: 'rgba(52, 199, 89, 0.1)', - justifyContent: 'center', - alignItems: 'center', - marginBottom: 20, - }, - successText: { - fontSize: 16, - textAlign: 'center', - marginTop: 16, - }, - loadingContainer: { - alignItems: 'center', - padding: 20, - width: '100%', - }, - loadingText: { - marginTop: 12, - fontSize: 16, - }, - progressBar: { - height: 10, - width: '100%', - backgroundColor: '#E0E0E0', - borderRadius: 5, - marginTop: 20, - overflow: 'hidden', - }, - progressFill: { - height: '100%', - backgroundColor: '#007AFF', - borderRadius: 5, - }, -}); diff --git a/FES-app/FES/app/functional/calibration_UI.tsx b/FES-app/FES/app/functional/calibration_UI.tsx new file mode 100644 index 0000000..bc6524d --- /dev/null +++ b/FES-app/FES/app/functional/calibration_UI.tsx @@ -0,0 +1,456 @@ +import { StyleSheet, View, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'; +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useState, useEffect } from 'react'; +import { useColorScheme } from 'react-native'; +import { calibrationService, CalibrationProgress, CalibrationResult } from '../../services/backend/calibration'; + +export default function CalibrationScreen() { + const router = useRouter(); + const colorScheme = useColorScheme(); + const [currentStep, setCurrentStep] = useState(1); + const [isCalibrating, setIsCalibrating] = useState(false); + const [progress, setProgress] = useState(0); + const [calibrationProgress, setCalibrationProgress] = useState({ + currentStep: 0, + totalSteps: 5, + isValidating: false, + validationMessage: '' + }); + const [calibrationResults, setCalibrationResults] = useState([]); + const [calibrationComplete, setCalibrationComplete] = useState(false); + + // Dynamic colors based on color scheme + const colors = { + instructionCard: colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7', + warningCard: colorScheme === 'dark' ? '#1C3A5C' : '#E3F2FD', + resultsContainer: colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7', + finalResultsContainer: colorScheme === 'dark' ? '#2C2C2E' : '#F2F2F7', + progressBar: colorScheme === 'dark' ? '#3A3A3C' : '#E0E0E0', + borderColor: colorScheme === 'dark' ? '#3A3A3C' : '#E5E5EA', + headerBorder: colorScheme === 'dark' ? '#3A3A3C' : '#E5E5EA', + }; + + const handleStartCalibration = async () => { + try { + setIsCalibrating(true); + setCalibrationComplete(false); + setCalibrationResults([]); + + await calibrationService.startCalibration( + (progress) => { + setCalibrationProgress(progress); + setProgress((progress.currentStep / progress.totalSteps) * 100); + }, + (results) => { + setCalibrationResults(results); + setCalibrationComplete(true); + setIsCalibrating(false); + setCurrentStep(2); + + Alert.alert( + 'Calibration Complete', + `Calibration successful! All 5 foot angles captured.`, + [{ text: 'OK' }] + ); + } + ); + } catch (error) { + console.error('Calibration failed:', error); + Alert.alert( + 'Calibration Failed', + 'An error occurred during calibration. Please try again.', + [{ text: 'OK' }] + ); + setIsCalibrating(false); + } + }; + + + const handleComplete = () => { + // Navigate back to home after calibration + router.back(); + }; + + return ( + + + router.back()} style={styles.backButton}> + + + Device Calibration + + + + + {currentStep === 1 && ( + + + + 1 + + + 1 && styles.stepActive]}> + 2 + + + + Foot Drop Calibration + + We'll capture the angle when your foot gets "stuck" during walking to detect future foot drop episodes. + + + + + + Walk normally and let your foot get stuck at its natural "drop" position. We'll capture that angle 5 times for 5 seconds each. + + + + + + + The phone should be attached to your leg/foot to measure the angle when foot drop occurs. + + + + {isCalibrating ? ( + + + + {calibrationProgress.validationMessage} + + {calibrationProgress.currentAngle !== undefined && ( + + Foot Angle: {calibrationProgress.currentAngle.toFixed(2)}° + + )} + + Capturing foot angle {calibrationProgress.currentStep} of {calibrationProgress.totalSteps} ({progress.toFixed(0)}%) + + + + + + {/* Show results as they come in */} + {calibrationResults.length > 0 && ( + + Foot Angles Captured: + {calibrationResults.map((result, index) => ( + + + Capture {index + 1}: {result.angle.toFixed(2)}° + + + ))} + {calibrationResults.length > 0 && ( + + Target Foot Drop Angle: {calibrationService.getAverageCalibrationAngle().toFixed(2)}° + + )} + + )} + + ) : ( + + + {isCalibrating ? 'Calibrating...' : 'Start Calibration'} + + + )} + + )} + + {currentStep === 2 && ( + + + + + + + + 2 + + + + Calibration Complete + + Your device has been successfully calibrated and is ready to use. + + + + + + + Calibration Successful! + + + {calibrationResults.length > 0 && ( + + Final Calibration Results + + Average Foot Drop Angle: {calibrationService.getAverageCalibrationAngle().toFixed(2)}° + + + {calibrationResults.map((result, index) => ( + + + Capture {index + 1}: {result.angle.toFixed(2)}° + + + ))} + + + )} + + + Start Session + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 20, + borderBottomWidth: 1, + }, + backButton: { + padding: 8, + }, + title: { + fontSize: 20, + fontWeight: '600', + }, + content: { + padding: 20, + }, + stepContainer: { + alignItems: 'center', + }, + stepIndicator: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 30, + }, + stepCircle: { + width: 30, + height: 30, + borderRadius: 15, + borderWidth: 2, + borderColor: '#E5E5EA', + justifyContent: 'center', + alignItems: 'center', + }, + stepActive: { + backgroundColor: '#007AFF', + borderColor: '#007AFF', + }, + stepComplete: { + backgroundColor: '#34C759', + borderColor: '#34C759', + }, + stepLine: { + flex: 1, + height: 2, + backgroundColor: '#E5E5EA', + marginHorizontal: 10, + }, + stepNumber: { + color: '#8E8E93', + fontWeight: '600', + }, + stepTitle: { + fontSize: 22, + fontWeight: '600', + marginBottom: 10, + textAlign: 'center', + }, + stepDescription: { + fontSize: 16, + color: '#8E8E93', + textAlign: 'center', + marginBottom: 30, + lineHeight: 24, + }, + instructionCard: { + flexDirection: 'row', + borderRadius: 12, + padding: 16, + marginBottom: 15, + width: '100%', + }, + warningCard: { + flexDirection: 'row', + borderRadius: 12, + padding: 16, + marginBottom: 30, + width: '100%', + }, + instructionIcon: { + marginRight: 12, + }, + instructionText: { + flex: 1, + fontSize: 15, + lineHeight: 22, + }, + primaryButton: { + backgroundColor: '#007AFF', + borderRadius: 12, + padding: 16, + alignItems: 'center', + width: '100%', + marginTop: 20, + }, + primaryButtonText: { + color: '#fff', + fontSize: 17, + fontWeight: '600', + }, + buttonDisabled: { + opacity: 0.5, + }, + successContainer: { + alignItems: 'center', + marginVertical: 40, + }, + successCircle: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: 'rgba(52, 199, 89, 0.1)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + }, + successText: { + fontSize: 16, + textAlign: 'center', + marginTop: 16, + }, + loadingContainer: { + alignItems: 'center', + padding: 20, + width: '100%', + }, + loadingText: { + marginTop: 12, + fontSize: 16, + }, + progressBar: { + height: 10, + width: '100%', + borderRadius: 5, + marginTop: 20, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: '#007AFF', + borderRadius: 5, + }, + angleText: { + fontSize: 18, + fontWeight: '600', + color: '#007AFF', + marginTop: 8, + }, + progressText: { + fontSize: 14, + color: '#8E8E93', + marginTop: 8, + }, + resultsContainer: { + marginTop: 20, + width: '100%', + borderRadius: 8, + padding: 12, + }, + resultsTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + textAlign: 'center', + }, + resultRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + resultText: { + fontSize: 14, + flex: 1, + }, + statusIndicator: { + width: 8, + height: 8, + borderRadius: 4, + marginLeft: 8, + }, + averageText: { + fontSize: 16, + fontWeight: '600', + color: '#007AFF', + textAlign: 'center', + marginTop: 8, + paddingTop: 8, + borderTopWidth: 1, + }, + finalResultsContainer: { + borderRadius: 12, + padding: 16, + marginVertical: 20, + width: '100%', + }, + finalResultsTitle: { + fontSize: 18, + fontWeight: '600', + textAlign: 'center', + marginBottom: 12, + }, + finalResultsText: { + fontSize: 16, + textAlign: 'center', + marginBottom: 8, + fontWeight: '500', + }, + finalResultsList: { + marginTop: 12, + }, + finalResultRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 6, + }, + finalResultText: { + fontSize: 14, + flex: 1, + }, + finalStatusIndicator: { + width: 10, + height: 10, + borderRadius: 5, + marginLeft: 8, + }, +}); diff --git a/FES-app/FES/app/functional/home.tsx b/FES-app/FES/app/functional/home.tsx index e85d78a..a192ea3 100644 --- a/FES-app/FES/app/functional/home.tsx +++ b/FES-app/FES/app/functional/home.tsx @@ -70,7 +70,7 @@ export default function HomeScreen() { router.push('/RealTimeData' as any)} + onPress={() => router.push('/functional/calibration' as any)} color="#FF9500" /> diff --git a/FES-app/FES/screens/RealTimeData.tsx b/FES-app/FES/screens/RealTimeData.tsx index 82024ba..18fec9f 100644 --- a/FES-app/FES/screens/RealTimeData.tsx +++ b/FES-app/FES/screens/RealTimeData.tsx @@ -7,6 +7,7 @@ import { TouchableOpacity } from 'react-native'; import { ThemedView } from '@/components/themed-view'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { calibrationService } from '../services/backend/calibration'; // Safe number formatter const formatNumber = (value: number | undefined | null): string => { @@ -178,6 +179,11 @@ export default function RealTimeData() { }; }, []); + // Update calibration service with current y angle + useEffect(() => { + calibrationService.updateAngleData(angleData.y); + }, [angleData.y]); + // Calculate magnitudes const gyroMagnitude = Math.sqrt(gyroData.x * gyroData.x + gyroData.y * gyroData.y + gyroData.z * gyroData.z); const accelMagnitude = Math.sqrt(accelData.x * accelData.x + accelData.y * accelData.y + accelData.z * accelData.z); diff --git a/FES-app/FES/services/backend/calibration.tsx b/FES-app/FES/services/backend/calibration.tsx new file mode 100644 index 0000000..b2eab40 --- /dev/null +++ b/FES-app/FES/services/backend/calibration.tsx @@ -0,0 +1,214 @@ +import { Accelerometer } from 'expo-sensors'; +import React from 'react'; + +export interface CalibrationResult { + angle: number; + timestamp: number; + isValid: boolean; +} + +export interface CalibrationProgress { + currentStep: number; + totalSteps: number; + currentAngle?: number; + isValidating: boolean; + validationMessage: string; +} + +export class CalibrationService { + private static instance: CalibrationService; + private angleDataRef: React.MutableRefObject; + private isMonitoring = false; + private calibrationResults: CalibrationResult[] = []; + private progressCallback?: (progress: CalibrationProgress) => void; + private completionCallback?: (results: CalibrationResult[]) => void; + + private constructor() { + this.angleDataRef = { current: 0 }; + } + + static getInstance(): CalibrationService { + if (!CalibrationService.instance) { + CalibrationService.instance = new CalibrationService(); + } + return CalibrationService.instance; + } + + // Method to update the current y angle from RealTimeData component + updateAngleData(yAngle: number) { + this.angleDataRef.current = yAngle; + } + + // Start the calibration process + async startCalibration( + onProgress?: (progress: CalibrationProgress) => void, + onComplete?: (results: CalibrationResult[]) => void + ): Promise { + this.progressCallback = onProgress; + this.completionCallback = onComplete; + this.calibrationResults = []; + this.isMonitoring = true; + + try { + // Start accelerometer monitoring + const isAvailable = await Accelerometer.isAvailableAsync(); + if (!isAvailable) { + throw new Error('Accelerometer not available'); + } + + Accelerometer.setUpdateInterval(50); // Fast updates for real-time monitoring + + // Start accelerometer listener to get real-time data + const accelSubscription = Accelerometer.addListener((data) => { + if (data && typeof data === 'object') { + // Convert accelerometer data to angles (same as RealTimeData) + const angleData = this.convertAccelToAngles({ + x: data.x || 0, + y: data.y || 0, + z: data.z || 0 + }); + + // Update the angle data reference + this.angleDataRef.current = angleData.y; + } + }); + + // Perform 5 calibration steps + for (let step = 1; step <= 5; step++) { + if (!this.isMonitoring) break; // Allow cancellation + + this.updateProgress({ + currentStep: step, + totalSteps: 5, + isValidating: true, + validationMessage: `Capturing foot angle ${step}/5... Walk normally for 5 seconds` + }); + + const result = await this.performCalibrationStep(step); + this.calibrationResults.push(result); + + this.updateProgress({ + currentStep: step, + totalSteps: 5, + isValidating: false, + validationMessage: `Foot angle ${step} captured: ${result.angle.toFixed(1)}°` + }); + + // Wait a bit between steps + await this.delay(1000); + } + + // Clean up accelerometer listener + accelSubscription.remove(); + + // Calibration complete + this.isMonitoring = false; + this.completionCallback?.(this.calibrationResults); + + } catch (error) { + console.error('Calibration error:', error); + this.isMonitoring = false; + throw error; + } + } + + private async performCalibrationStep(stepNumber: number): Promise { + const monitoringDuration = 5000; // 5 seconds + const startTime = Date.now(); + + let angleSum = 0; + let sampleCount = 0; + + // Monitor for 5 seconds and collect all angle readings + while (Date.now() - startTime < monitoringDuration) { + const currentAngle = this.angleDataRef.current; + + angleSum += currentAngle; + sampleCount++; + + // Update progress with current angle + this.updateProgress({ + currentStep: stepNumber, + totalSteps: 5, + currentAngle: currentAngle, + isValidating: true, + validationMessage: `Capturing foot angle... Current: ${currentAngle.toFixed(2)}°` + }); + + await this.delay(50); // Check every 50ms + } + + const averageAngle = angleSum / sampleCount; + + return { + angle: averageAngle, + timestamp: Date.now(), + isValid: true // Always valid since we're just capturing averages + }; + } + + // Convert accelerometer data to angles (same as RealTimeData) + private convertAccelToAngles(accelData: {x: number, y: number, z: number}) { + const toDegrees = (radians: number) => radians * (180 / Math.PI); + + // Calculate pitch (X-axis rotation) - forward/back tilt + const pitch = Math.atan2(accelData.y, accelData.z); + + // Calculate roll (Y-axis rotation) - left/right tilt + const roll = Math.atan2(-accelData.x, Math.sqrt(accelData.y * accelData.y + accelData.z * accelData.z)); + + // Calculate yaw (Z-axis rotation) - rotation around vertical axis + const yaw = Math.atan2(accelData.x, accelData.y); + + return { + x: toDegrees(pitch), + y: toDegrees(roll), + z: toDegrees(yaw) + }; + } + + private updateProgress(progress: CalibrationProgress) { + this.progressCallback?.(progress); + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // Stop calibration process + stopCalibration(): void { + this.isMonitoring = false; + } + + // Get calibration results + getCalibrationResults(): CalibrationResult[] { + return [...this.calibrationResults]; + } + + // Get valid calibration results only + getValidCalibrationResults(): CalibrationResult[] { + return this.calibrationResults.filter(result => result.isValid); + } + + // Calculate average of all valid calibration angles + getAverageCalibrationAngle(): number { + const validResults = this.getValidCalibrationResults(); + if (validResults.length === 0) return 0; + + const sum = validResults.reduce((acc, result) => acc + result.angle, 0); + return sum / validResults.length; + } + + // Check if calibration is complete and valid + isCalibrationComplete(): boolean { + return this.calibrationResults.length >= 5; + } + + // Check if calibration has enough valid results + hasValidCalibration(): boolean { + return this.getValidCalibrationResults().length >= 4; // At least 4 out of 5 should be valid + } +} + +// Export singleton instance +export const calibrationService = CalibrationService.getInstance(); From dcfef1d87f0b79cd4ebf6226861fd8e82d23f737 Mon Sep 17 00:00:00 2001 From: SohanV97 Date: Tue, 21 Oct 2025 19:23:35 -0500 Subject: [PATCH 3/4] Changed calibration_UI.tsx to calibration.tsx --- .../FES/app/functional/{calibration_UI.tsx => calibration.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename FES-app/FES/app/functional/{calibration_UI.tsx => calibration.tsx} (100%) diff --git a/FES-app/FES/app/functional/calibration_UI.tsx b/FES-app/FES/app/functional/calibration.tsx similarity index 100% rename from FES-app/FES/app/functional/calibration_UI.tsx rename to FES-app/FES/app/functional/calibration.tsx From 54d4fa0a84106f0410c1a2a1b9a5a46f196467de Mon Sep 17 00:00:00 2001 From: SohanV97 Date: Tue, 4 Nov 2025 19:41:10 -0600 Subject: [PATCH 4/4] stuff --- FES-app/FES/services/backend/calibration.tsx | 131 ++++++++++++++----- 1 file changed, 99 insertions(+), 32 deletions(-) diff --git a/FES-app/FES/services/backend/calibration.tsx b/FES-app/FES/services/backend/calibration.tsx index b2eab40..63a56f1 100644 --- a/FES-app/FES/services/backend/calibration.tsx +++ b/FES-app/FES/services/backend/calibration.tsx @@ -1,5 +1,6 @@ import { Accelerometer } from 'expo-sensors'; -import React from 'react'; +import * as Haptics from 'expo-haptics'; +import { Vibration } from 'react-native'; export interface CalibrationResult { angle: number; @@ -17,15 +18,13 @@ export interface CalibrationProgress { export class CalibrationService { private static instance: CalibrationService; - private angleDataRef: React.MutableRefObject; + private currentAngle: number = 0; private isMonitoring = false; private calibrationResults: CalibrationResult[] = []; private progressCallback?: (progress: CalibrationProgress) => void; private completionCallback?: (results: CalibrationResult[]) => void; - private constructor() { - this.angleDataRef = { current: 0 }; - } + private constructor() {} static getInstance(): CalibrationService { if (!CalibrationService.instance) { @@ -36,7 +35,7 @@ export class CalibrationService { // Method to update the current y angle from RealTimeData component updateAngleData(yAngle: number) { - this.angleDataRef.current = yAngle; + this.currentAngle = yAngle; } // Start the calibration process @@ -59,43 +58,84 @@ export class CalibrationService { Accelerometer.setUpdateInterval(50); // Fast updates for real-time monitoring // Start accelerometer listener to get real-time data + let hasReceivedData = false; + let dataCount = 0; const accelSubscription = Accelerometer.addListener((data) => { - if (data && typeof data === 'object') { + if (data && typeof data === 'object' && data.x !== undefined && data.y !== undefined && data.z !== undefined) { // Convert accelerometer data to angles (same as RealTimeData) const angleData = this.convertAccelToAngles({ - x: data.x || 0, - y: data.y || 0, - z: data.z || 0 + x: data.x, + y: data.y, + z: data.z }); - // Update the angle data reference - this.angleDataRef.current = angleData.y; + // Update the current angle + this.currentAngle = angleData.y; + hasReceivedData = true; + dataCount++; } }); - // Perform 5 calibration steps + // Wait for initial accelerometer data to arrive + let waitCount = 0; + while (!hasReceivedData && waitCount < 20) { + await this.delay(50); + waitCount++; + } + + if (!hasReceivedData) { + // No accelerometer data received - continue anyway + } + + // Initial vibration: Signal to get ready for first step + await this.triggerHaptic(); + this.updateProgress({ + currentStep: 0, + totalSteps: 5, + isValidating: false, + validationMessage: 'Get ready! Calibration starting...' + }); + await this.delay(1000); // Brief pause before starting + + // Perform 5 calibration steps with haptic feedback for (let step = 1; step <= 5; step++) { if (!this.isMonitoring) break; // Allow cancellation + // Vibration 1: Signal to start step + await this.triggerHaptic(); this.updateProgress({ currentStep: step, totalSteps: 5, isValidating: true, - validationMessage: `Capturing foot angle ${step}/5... Walk normally for 5 seconds` + validationMessage: `Step ${step}/5 - Start walking now! Capture in progress...` }); + // Capture angle for 5 seconds const result = await this.performCalibrationStep(step); this.calibrationResults.push(result); + // Vibration 2: Signal step is done + await this.triggerHaptic(); this.updateProgress({ currentStep: step, totalSteps: 5, isValidating: false, - validationMessage: `Foot angle ${step} captured: ${result.angle.toFixed(1)}°` + validationMessage: `Step ${step} complete! Captured: ${result.angle.toFixed(1)}°` }); - // Wait a bit between steps - await this.delay(1000); + // Grace period: 3 seconds to return foot to normal position (only if not last step) + if (step < 5) { + await this.delay(3000); + + // Vibration 3: Signal to start next step + await this.triggerHaptic(); + this.updateProgress({ + currentStep: step, + totalSteps: 5, + isValidating: false, + validationMessage: `Get ready for step ${step + 1}/5...` + }); + } } // Clean up accelerometer listener @@ -114,36 +154,48 @@ export class CalibrationService { private async performCalibrationStep(stepNumber: number): Promise { const monitoringDuration = 5000; // 5 seconds + const threshold = -15; // degrees - only capture angles less than -15 degrees (foot drop) const startTime = Date.now(); let angleSum = 0; let sampleCount = 0; + let lastDisplayedAngle = 0; - // Monitor for 5 seconds and collect all angle readings + // Monitor for 5 seconds and collect ONLY angle readings less than -15 degrees (foot drop) while (Date.now() - startTime < monitoringDuration) { - const currentAngle = this.angleDataRef.current; + const currentAngle = this.currentAngle; - angleSum += currentAngle; - sampleCount++; - - // Update progress with current angle - this.updateProgress({ - currentStep: stepNumber, - totalSteps: 5, - currentAngle: currentAngle, - isValidating: true, - validationMessage: `Capturing foot angle... Current: ${currentAngle.toFixed(2)}°` - }); + // Always show current angle in UI (even if above threshold) + if (Math.abs(currentAngle - lastDisplayedAngle) > 0.5 || sampleCount === 0) { + const remainingTime = Math.max(0, Math.ceil((monitoringDuration - (Date.now() - startTime)) / 1000)); + this.updateProgress({ + currentStep: stepNumber, + totalSteps: 5, + currentAngle: currentAngle, + isValidating: true, + validationMessage: currentAngle < threshold + ? `Capturing foot drop... ${remainingTime}s remaining - Current: ${currentAngle.toFixed(1)}°` + : `Walking... ${remainingTime}s remaining - Current: ${currentAngle.toFixed(1)}° (waiting for foot drop)` + }); + lastDisplayedAngle = currentAngle; + } + + // Only include angles less than -15 degrees in the calculation (foot drop) + if (currentAngle < threshold) { + angleSum += currentAngle; + sampleCount++; + } await this.delay(50); // Check every 50ms } - const averageAngle = angleSum / sampleCount; + // Calculate average only from angles below threshold + const averageAngle = sampleCount > 0 ? angleSum / sampleCount : 0; return { angle: averageAngle, timestamp: Date.now(), - isValid: true // Always valid since we're just capturing averages + isValid: sampleCount > 0 // Valid only if we captured some data below threshold }; } @@ -175,6 +227,21 @@ export class CalibrationService { return new Promise(resolve => setTimeout(resolve, ms)); } + // Trigger haptic feedback with fallback to vibration - 1 second, one long buzz + private async triggerHaptic(): Promise { + try { + // Use heavy impact for more noticeable feedback + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + // One long continuous vibration for 1 second + Vibration.vibrate(1000); + await this.delay(1000); + } catch (error) { + // Fallback to vibration if haptics not available - 1 second continuous + Vibration.vibrate(1000); + await this.delay(1000); + } + } + // Stop calibration process stopCalibration(): void { this.isMonitoring = false;