openrowingmonitor/app/engine/WorkoutRecorder.js

334 lines
14 KiB
JavaScript

'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
This Module captures the metrics of a rowing session and persists them.
Todo: split this into multiple modules
*/
import log from 'loglevel'
import zlib from 'zlib'
import fs from 'fs/promises'
import xml2js from 'xml2js'
import config from '../tools/ConfigManager.js'
import { createVO2max } from './VO2max.js'
import { promisify } from 'util'
const gzip = promisify(zlib.gzip)
function createWorkoutRecorder () {
let strokes = []
let rotationImpulses = []
let postExerciseHR = []
let startTime
function recordRotationImpulse (impulse) {
if (startTime === undefined) {
startTime = new Date()
}
// impulse recordings a currently only used to create raw data files, so we can skip it
// if raw data file creation is disabled
if (config.createRawDataFiles) {
rotationImpulses.push(impulse)
}
}
function recordStroke (stroke) {
if (startTime === undefined) {
startTime = new Date()
}
strokes.push(stroke)
}
async function createRawDataFile () {
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${stringifiedStartTime}_raw.csv${config.gzipRawDataFiles ? '.gz' : ''}`
log.info(`saving session as raw data file ${filename}...`)
try {
await fs.mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
await createFile(rotationImpulses.join('\n'), filename, config.gzipRawDataFiles)
}
async function createRowingDataFile () {
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${stringifiedStartTime}_rowingData.csv`
let currentstroke
let trackPointTime
let timestamp
let i
log.info(`saving session as RowingData file ${filename}...`)
// Required file header, please note this includes a typo and odd spaces as the specification demands it!
let RowingData = ',index, Stroke Number, lapIdx,TimeStamp (sec), ElapsedTime (sec), HRCur (bpm),DistanceMeters, Cadence (stokes/min), Stroke500mPace (sec/500m), Power (watts), StrokeDistance (meters),' +
' DriveTime (ms), DriveLength (meters), StrokeRecoveryTime (ms),Speed, Horizontal (meters), Calories (kCal), DragFactor, PeakDriveForce (N), AverageDriveForce (N),' +
'Handle_Force_(N),Handle_Velocity_(m/s),Handle_Power_(W)\n'
// Add the strokes
i = 0
while (i < strokes.length) {
currentstroke = strokes[i]
trackPointTime = new Date(startTime.getTime() + currentstroke.totalMovingTime * 1000)
timestamp = trackPointTime.getTime() / 1000
RowingData += `${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.totalNumberOfStrokes.toFixed(0)},${currentstroke.intervalNumber.toFixed(0)},${timestamp.toFixed(5)},` +
`${currentstroke.totalMovingTime.toFixed(5)},${(currentstroke.heartrate > 30 ? currentstroke.heartrate.toFixed(0) : NaN)},${currentstroke.totalLinearDistance.toFixed(1)},` +
`${currentstroke.cycleStrokeRate.toFixed(1)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cyclePace.toFixed(2) : NaN)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cyclePower.toFixed(0) : NaN)},` +
`${currentstroke.cycleDistance.toFixed(2)},${(currentstroke.driveDuration * 1000).toFixed(0)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.driveLength.toFixed(2) : NaN)},${(currentstroke.recoveryDuration * 1000).toFixed(0)},` +
`${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.cycleLinearVelocity.toFixed(2) : 0)},${currentstroke.totalLinearDistance.toFixed(1)},${currentstroke.totalCalories.toFixed(1)},${currentstroke.dragFactor.toFixed(1)},` +
`${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.drivePeakHandleForce.toFixed(1) : NaN)},${(currentstroke.totalNumberOfStrokes > 0 ? currentstroke.driveAverageHandleForce.toFixed(1) : 0)},"${currentstroke.driveHandleForceCurve.map(value => value.toFixed(2))}",` +
`"${currentstroke.driveHandleVelocityCurve.map(value => value.toFixed(3))}","${currentstroke.driveHandlePowerCurve.map(value => value.toFixed(1))}"\n`
i++
}
try {
await fs.mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
await createFile(RowingData, `${filename}`, false)
}
async function createTcxFile () {
const tcxRecord = await activeWorkoutToTcx()
if (tcxRecord === undefined) {
log.error('error creating tcx file')
return
}
const directory = `${config.dataDirectory}/recordings/${startTime.getFullYear()}/${(startTime.getMonth() + 1).toString().padStart(2, '0')}`
const filename = `${directory}/${tcxRecord.filename}${config.gzipTcxFiles ? '.gz' : ''}`
log.info(`saving session as tcx file ${filename}...`)
try {
await fs.mkdir(directory, { recursive: true })
} catch (error) {
if (error.code !== 'EEXIST') {
log.error(`can not create directory ${directory}`, error)
}
}
await createFile(tcxRecord.tcx, `${filename}`, config.gzipTcxFiles)
}
async function activeWorkoutToTcx () {
// we need at least two strokes to generate a valid tcx file
if (strokes.length < 5) return
const stringifiedStartTime = startTime.toISOString().replace(/T/, '_').replace(/:/g, '-').replace(/\..+/, '')
const filename = `${stringifiedStartTime}_rowing.tcx`
const tcx = await workoutToTcx({
id: startTime.toISOString(),
startTime,
strokes
})
return {
tcx,
filename
}
}
async function workoutToTcx (workout) {
let versionArray = process.env.npm_package_version.split('.')
if (versionArray.length < 3) versionArray = ['0', '0', '0']
const lastStroke = workout.strokes[strokes.length - 1]
const drag = workout.strokes.reduce((sum, s) => sum + s.dragFactor, 0) / strokes.length
// VO2Max calculation for the remarks section
let VO2maxoutput = 'UNDEFINED'
const VO2max = createVO2max(config)
const VO2maxResult = VO2max.calculateVO2max(strokes)
if (VO2maxResult > 10 && VO2maxResult < 60) {
VO2maxoutput = `${VO2maxResult.toFixed(1)} mL/(kg*min)`
}
// Addition of HRR data
let hrrAdittion = ''
if (postExerciseHR.length > 1 && (postExerciseHR[0] > (0.7 * config.userSettings.maxHR))) {
// Recovery Heartrate is only defined when the last excercise HR is above 70% of the maximum Heartrate
if (postExerciseHR.length === 2) {
hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM)`
}
if (postExerciseHR.length === 3) {
hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM), HRR2: ${postExerciseHR[2] - postExerciseHR[0]} (${postExerciseHR[2]} BPM)`
}
if (postExerciseHR.length >= 4) {
hrrAdittion = `, HRR1: ${postExerciseHR[1] - postExerciseHR[0]} (${postExerciseHR[1]} BPM), HRR2: ${postExerciseHR[2] - postExerciseHR[0]} (${postExerciseHR[2]} BPM), HRR3: ${postExerciseHR[3] - postExerciseHR[0]} (${postExerciseHR[3]} BPM)`
}
}
const tcxObject = {
TrainingCenterDatabase: {
$: { xmlns: 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2', 'xmlns:ns2': 'http://www.garmin.com/xmlschemas/ActivityExtension/v2' },
Activities: {
Activity: {
$: { Sport: 'Other' },
Id: workout.id,
Lap: [
{
$: { StartTime: workout.startTime.toISOString() },
TotalTimeSeconds: lastStroke.totalMovingTime.toFixed(1),
DistanceMeters: lastStroke.totalLinearDistance.toFixed(1),
MaximumSpeed: (workout.strokes.map((stroke) => stroke.cycleLinearVelocity).reduce((acc, cycleLinearVelocity) => Math.max(acc, cycleLinearVelocity))).toFixed(2),
Calories: Math.round(lastStroke.totalCalories),
/* ToDo Fix issue with IF-statement not being accepted here?
if (lastStroke.heartrate !== undefined && lastStroke.heartrate > 30) {
AverageHeartRateBpm: VO2max.averageObservedHR(),
MaximumHeartRateBpm: VO2max.maxObservedHR,
//AverageHeartRateBpm: { Value: (workout.strokes.reduce((sum, s) => sum + s.heartrate, 0) / workout.strokes.length).toFixed(2) },
//MaximumHeartRateBpm: { Value: Math.round(workout.strokes.map((stroke) => stroke.power).reduce((acc, heartrate) => Math.max(acc, heartrate))) },
}
*/
Intensity: 'Active',
Cadence: Math.round(workout.strokes.reduce((sum, s) => sum + s.cycleStrokeRate, 0) / (workout.strokes.length - 1)),
TriggerMethod: 'Manual',
Track: {
Trackpoint: (() => {
return workout.strokes.map((stroke) => {
const trackPointTime = new Date(workout.startTime.getTime() + stroke.totalMovingTime * 1000)
const trackpoint = {
Time: trackPointTime.toISOString(),
DistanceMeters: stroke.totalLinearDistance.toFixed(2),
Cadence: Math.round(stroke.cycleStrokeRate),
Extensions: {
'ns2:TPX': {
'ns2:Speed': stroke.cycleLinearVelocity.toFixed(2),
'ns2:Watts': Math.round(stroke.cyclePower)
}
}
}
if (stroke.heartrate !== undefined && stroke.heartrate > 30) {
trackpoint.HeartRateBpm = { Value: stroke.heartrate }
}
return trackpoint
})
})()
},
Extensions: {
'ns2:LX': {
'ns2:Steps': lastStroke.totalNumberOfStrokes.toFixed(0),
// please note, the -1 is needed as we have a stroke 0, with a speed and power of 0. The - 1 corrects this.
'ns2:AvgSpeed': (workout.strokes.reduce((sum, s) => sum + s.cycleLinearVelocity, 0) / (workout.strokes.length - 1)).toFixed(2),
'ns2:AvgWatts': (workout.strokes.reduce((sum, s) => sum + s.cyclePower, 0) / (workout.strokes.length - 1)).toFixed(0),
'ns2:MaxWatts': Math.round(workout.strokes.map((stroke) => stroke.cyclePower).reduce((acc, cyclePower) => Math.max(acc, cyclePower)))
}
}
}
],
Notes: `Indoor Rowing, Drag factor: ${drag.toFixed(1)} 10-6 N*m*s2, Estimated VO2Max: ${VO2maxoutput}${hrrAdittion}`
}
},
Author: {
$: { 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', 'xsi:type': 'Application_t' },
Name: 'Open Rowing Monitor',
Build: {
Version: {
VersionMajor: versionArray[0],
VersionMinor: versionArray[1],
BuildMajor: versionArray[2],
BuildMinor: 0
},
LangID: 'en',
PartNumber: 'OPE-NROWI-NG'
}
}
}
}
const builder = new xml2js.Builder()
return builder.buildObject(tcxObject)
}
async function reset () {
await createRecordings()
strokes = []
rotationImpulses = []
postExerciseHR = []
startTime = undefined
}
async function createFile (content, filename, compress = false) {
if (compress) {
const gzipContent = await gzip(content)
try {
await fs.writeFile(filename, gzipContent)
} catch (err) {
log.error(err)
}
} else {
try {
await fs.writeFile(filename, content)
} catch (err) {
log.error(err)
}
}
}
function handlePause () {
createRecordings()
}
async function createRecordings () {
if (!config.createRawDataFiles && !config.createTcxFiles && !config.createRowingDataFiles) {
return
}
if (!minimumRecordingTimeHasPassed()) {
log.debug('workout is shorter than minimum workout time, skipping automatic creation of recordings...')
return
}
postExerciseHR = []
const parallelCalls = []
if (config.createRawDataFiles) {
parallelCalls.push(createRawDataFile())
}
if (config.createTcxFiles) {
parallelCalls.push(createTcxFile())
}
if (config.createRowingDataFiles) {
parallelCalls.push(createRowingDataFile())
}
await Promise.all(parallelCalls)
}
async function updateHRRecovery (hrmetrics) {
postExerciseHR = hrmetrics
createTcxFile()
}
function minimumRecordingTimeHasPassed () {
const minimumRecordingTimeInSeconds = 10
const rotationImpulseTimeTotal = rotationImpulses.reduce((acc, impulse) => acc + impulse, 0)
if (strokes.length > 0) {
const strokeTimeTotal = strokes[strokes.length - 1].totalMovingTime
return (Math.max(rotationImpulseTimeTotal, strokeTimeTotal) > minimumRecordingTimeInSeconds)
} else {
return (rotationImpulseTimeTotal > minimumRecordingTimeInSeconds)
}
}
return {
recordStroke,
recordRotationImpulse,
handlePause,
activeWorkoutToTcx,
writeRecordings: createRecordings,
updateHRRecovery,
reset
}
}
export { createWorkoutRecorder }