334 lines
14 KiB
JavaScript
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 }
|