From ee054f480972f2d58547ead3b20eb82fb233afa2 Mon Sep 17 00:00:00 2001 From: Mark Liversedge Date: Sun, 15 Jan 2012 12:52:57 +0000 Subject: [PATCH] Support Wattbike TXT data exports Thanks to Peter Norberg for the sample data export files. This support has been written against the two files supplied. if the export has imperial/metric conversion options then this support will need to be adjusted to cater for different units. Fixes #322. --- src/TxtRideFile.cpp | 501 ++++++++++++++++++++++++++++++-------------- 1 file changed, 338 insertions(+), 163 deletions(-) diff --git a/src/TxtRideFile.cpp b/src/TxtRideFile.cpp index 577af2350..099c173a5 100644 --- a/src/TxtRideFile.cpp +++ b/src/TxtRideFile.cpp @@ -28,7 +28,7 @@ static int txtFileReaderRegistered = RideFileFactory::instance().registerReader( - "txt","Racermate/Ergvideo", new TxtFileReader()); + "txt","Racermate/Ergvideo/Wattbike", new TxtFileReader()); // Racermate Workout Data Export File Format (.TXT) // ------------------------------------------------ @@ -70,183 +70,358 @@ RideFile *TxtFileReader::openRideFile(QFile &file, QStringList &errors, QListsetDeviceType("Computrainer/Velotron"); + // Now we need to determine if this is a Wattbike export + // or a Racermate export. We can do this by looking at + // the first line of the file. + // + // For it to be a wattbike, the first line is made up + // of multiple tokens, separated by a tab, which match + // the pattern "name [units]" + bool isWattBike = false; + QStringList tokens = in.readLine().split(QRegExp("[\t\n\r]"), QString::SkipEmptyParts); - while (!is.atEnd()) { - - // the readLine() method doesn't handle old Macintosh CR line endings - // this workaround will load the the entire file if it has CR endings - // then split and loop through each line - // otherwise, there will be nothing to split and it will read each line as expected. - QString linesIn = is.readLine(); - QStringList lines = linesIn.split('\r'); - if(lines.isEmpty()) continue; - - // loop through the lines we got - foreach (QString line, lines) { - - QRegExp sectionPattern("^\\[.*\\]$"); - QRegExp unitsPattern("^UNITS += +\\(.*\\)$"); - QRegExp sepPattern("( +|,)"); - - // ignore blank lines - if (line == "") continue; - - // begin or end of section - if (sectionPattern.exactMatch(line)) { - deviceInfo += line; - deviceInfo += "\n"; - - if (section == "") section = line; - else section = ""; - continue; - } - - // section Data - if (section != "") { - // save it away - deviceInfo += line; - deviceInfo += "\n"; - - // look for UNITS line - if (unitsPattern.exactMatch(line)) { - if (unitsPattern.cap(1) != "METRIC") metric = false; - else metric = true; + if (tokens.count() > 1) { + // ok, so we have a bunch of tokens, thats a good sign this + // may be a wattbike export, now are all the tokens of the + // form "name [units]", or just "Index" or "name []" + isWattBike=true; + QRegExp wattToken("^[^[]+ \\[[^]]+\\]$"); + foreach(QString token, tokens) { + if (wattToken.exactMatch(token) != true) { + QRegExp noUnits("^[^[]+ \\[\\]$"); + if (token != "Index" && !noUnits.exactMatch(token)) { + isWattBike = false; } - continue; } - - // number of records, jsut ignore it - if (line.startsWith("number of")) continue; - - // either a data line, or a headings line - if (headings.count() == 0) { - headings = line.split(sepPattern, QString::SkipEmptyParts); - - // where are the values stored? - timeIndex = headings.indexOf("ms"); - wattsIndex = headings.indexOf("watts"); - cadIndex = headings.indexOf("rpm"); - hrIndex = headings.indexOf("hr"); - kmIndex = headings.indexOf("KM"); - milesIndex = headings.indexOf("miles"); - kphIndex = headings.indexOf("speed"); - headwindIndex = headings.indexOf("wind"); - continue; - } - // right! we now have a record - QStringList values = line.split(sepPattern, QString::SkipEmptyParts); - - // mmm... didn't get much data - if (values.count() < 2) continue; - - // extract out each value - double secs = timeIndex > -1 ? values[timeIndex].toDouble() / (double) 1000 : 0.0; - double watts = wattsIndex > -1 ? values[wattsIndex].toDouble() : 0.0; - double cad = cadIndex > -1 ? values[cadIndex].toDouble() : 0.0; - double hr = hrIndex > -1 ? values[hrIndex].toDouble() : 0.0; - double km = kmIndex > -1 ? values[kmIndex].toDouble() : 0.0; - double kph = kphIndex > -1 ? values[kphIndex].toDouble() : 0.0; - double miles = milesIndex > -1 ? values[milesIndex].toDouble() : 0.0; - double headwind = headwindIndex > -1 ? values[headwindIndex].toDouble() : 0.0; - - if (miles != 0) { - // imperial! - kph *= KM_PER_MILE; - km = miles * KM_PER_MILE; - } - rideFile->appendPoint(secs, cad, hr, km, kph, 0.0, watts, 0.0, 0.0, 0.0, headwind, 0.0, RideFile::noTemp, 0); - } } - file.close(); - rideFile->setTag("Device Info", deviceInfo); + if (!isWattBike) { - // - // To estimate the recording interval, take the median of the - // first 1000 samples and round to nearest millisecond. - // - int n = rideFile->dataPoints().size(); - n = qMin(n, 1000); - if (n >= 2) { + // RACERMATE STYLE - QVector secs(n-1); - for (int i = 0; i < n-1; ++i) { - double now = rideFile->dataPoints()[i]->secs; - double then = rideFile->dataPoints()[i+1]->secs; - secs[i] = then - now; + file.close(); // start again (seek did weird things on Linux, bug (?) + if (!file.open(QFile::ReadOnly)) { + errors << ("Could not open ride file: \"" + file.fileName() + "\""); + return NULL; } - std::sort(secs.begin(), secs.end()); - int mid = n / 2 - 1; - double recint = round(secs[mid] * 1000.0) / 1000.0; - rideFile->setRecIntSecs(recint); + QTextStream is(&file); + + // Lets construct our rideFile + RideFile *rideFile = new RideFile(); + rideFile->setDeviceType("Computrainer/Velotron"); + + while (!is.atEnd()) { + + // the readLine() method doesn't handle old Macintosh CR line endings + // this workaround will load the the entire file if it has CR endings + // then split and loop through each line + // otherwise, there will be nothing to split and it will read each line as expected. + QString linesIn = is.readLine(); + QStringList lines = linesIn.split('\r'); + if(lines.isEmpty()) continue; + + // loop through the lines we got + foreach (QString line, lines) { + + QRegExp sectionPattern("^\\[.*\\]$"); + QRegExp unitsPattern("^UNITS += +\\(.*\\)$"); + QRegExp sepPattern("( +|,)"); + + // ignore blank lines + if (line == "") continue; + + // begin or end of section + if (sectionPattern.exactMatch(line)) { + deviceInfo += line; + deviceInfo += "\n"; + + if (section == "") section = line; + else section = ""; + continue; + } + + // section Data + if (section != "") { + // save it away + deviceInfo += line; + deviceInfo += "\n"; + + // look for UNITS line + if (unitsPattern.exactMatch(line)) { + if (unitsPattern.cap(1) != "METRIC") metric = false; + else metric = true; + } + continue; + } + + // number of records, jsut ignore it + if (line.startsWith("number of")) continue; + + // either a data line, or a headings line + if (headings.count() == 0) { + headings = line.split(sepPattern, QString::SkipEmptyParts); + + // where are the values stored? + timeIndex = headings.indexOf("ms"); + wattsIndex = headings.indexOf("watts"); + cadIndex = headings.indexOf("rpm"); + hrIndex = headings.indexOf("hr"); + kmIndex = headings.indexOf("KM"); + milesIndex = headings.indexOf("miles"); + kphIndex = headings.indexOf("speed"); + headwindIndex = headings.indexOf("wind"); + continue; + } + // right! we now have a record + QStringList values = line.split(sepPattern, QString::SkipEmptyParts); + + // mmm... didn't get much data + if (values.count() < 2) continue; + + // extract out each value + double secs = timeIndex > -1 ? values[timeIndex].toDouble() / (double) 1000 : 0.0; + double watts = wattsIndex > -1 ? values[wattsIndex].toDouble() : 0.0; + double cad = cadIndex > -1 ? values[cadIndex].toDouble() : 0.0; + double hr = hrIndex > -1 ? values[hrIndex].toDouble() : 0.0; + double km = kmIndex > -1 ? values[kmIndex].toDouble() : 0.0; + double kph = kphIndex > -1 ? values[kphIndex].toDouble() : 0.0; + double miles = milesIndex > -1 ? values[milesIndex].toDouble() : 0.0; + double headwind = headwindIndex > -1 ? values[headwindIndex].toDouble() : 0.0; + + if (miles != 0) { + // imperial! + kph *= KM_PER_MILE; + km = miles * KM_PER_MILE; + } + rideFile->appendPoint(secs, cad, hr, km, kph, 0.0, watts, 0.0, 0.0, 0.0, headwind, 0.0, RideFile::noTemp, 0); + + } + } + file.close(); + + rideFile->setTag("Device Info", deviceInfo); + + // + // To estimate the recording interval, take the median of the + // first 1000 samples and round to nearest millisecond. + // + int n = rideFile->dataPoints().size(); + n = qMin(n, 1000); + if (n >= 2) { + + QVector secs(n-1); + for (int i = 0; i < n-1; ++i) { + double now = rideFile->dataPoints()[i]->secs; + double then = rideFile->dataPoints()[i+1]->secs; + secs[i] = then - now; + } + std::sort(secs.begin(), secs.end()); + int mid = n / 2 - 1; + double recint = round(secs[mid] * 1000.0) / 1000.0; + rideFile->setRecIntSecs(recint); + + } else { + + // less than 2 data points is not a valid ride file + errors << "Insufficient valid data in file \"" + file.fileName() + "\"."; + delete rideFile; + file.close(); + return NULL; + } + + // + // Get date time from standard GC name + // + QRegExp gcPattern("^.*/(\\d\\d\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)\\.txt$"); + gcPattern.setCaseSensitivity(Qt::CaseInsensitive); + + // Racemermate uses name-mode-yyyy-mm-dd-hh-mm-ss.CDF.txt + QRegExp rmPattern("^.*/[^-]*-[^-]*-(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)-(\\d\\d)-(\\d\\d)-(\\d\\d)\\.cdf.txt$"); + rmPattern.setCaseSensitivity(Qt::CaseInsensitive); + + // Ergvideo uses name_ergvideo_ridename_yyyy-mm-dd@hh-mm-ss.txt + QRegExp evPattern("^.*/[^_]*_[^_]*_[^_]*_(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)@(\\d\\d)-(\\d\\d)-(\\d\\d)\\.txt$"); + evPattern.setCaseSensitivity(Qt::CaseInsensitive); + + // It is a GC Filename + if (gcPattern.exactMatch(file.fileName())) { + QDateTime datetime(QDate(gcPattern.cap(1).toInt(), + gcPattern.cap(2).toInt(), + gcPattern.cap(3).toInt()), + QTime(gcPattern.cap(4).toInt(), + gcPattern.cap(5).toInt(), + gcPattern.cap(6).toInt())); + rideFile->setStartTime(datetime); + } + + // It is an Ergvideo Filename + if (evPattern.exactMatch(file.fileName())) { + QDateTime datetime(QDate(evPattern.cap(1).toInt(), + evPattern.cap(2).toInt(), + evPattern.cap(3).toInt()), + QTime(evPattern.cap(4).toInt(), + evPattern.cap(5).toInt(), + evPattern.cap(6).toInt())); + rideFile->setStartTime(datetime); + } + + // It is an Racermate Filename + if (rmPattern.exactMatch(file.fileName())) { + QDateTime datetime(QDate(rmPattern.cap(1).toInt(), + rmPattern.cap(2).toInt(), + rmPattern.cap(3).toInt()), + QTime(rmPattern.cap(4).toInt(), + rmPattern.cap(5).toInt(), + rmPattern.cap(6).toInt())); + rideFile->setStartTime(datetime); + } + + return rideFile; } else { + // WATTBIKE STYLE - // less than 2 data points is not a valid ride file - errors << "Insufficient valid data in file \"" + file.fileName() + "\"."; - delete rideFile; - file.close(); - return NULL; + // Lets construct our rideFile + RideFile *rideFile = new RideFile(); + rideFile->setDeviceType("Wattbike"); + rideFile->setRecIntSecs(1); + + // We need to work out which column represents + // which data series. The heading tokens specify + // both the series and units, but for now we will + // assume the units are standardised until we have + // seen files with different values for units. + + // Here are the known heading tokens: + // "Elapsed time total [s]" + // "Cadence [1/min]" + // "Velocity [km/h]" + // "Distance total [m]" + // "Heart rate [1/min]" + // "Torque per revolution [Nm]" + // "Power per revolution [W]" + // + // Note: no "spinscan" type data is provided. + + int timeIndex = -1; + int kmIndex = -1; + int rpmIndex = -1; + int kphIndex = -1; + int bpmIndex = -1; + int torqIndex = -1; + int wattsIndex = -1; + + // lets initialise the indexes for all the values + QRegExp wattToken("^([^[]+) \\[([^]]+)\\]$"); + int i=0; + int columns = tokens.count(); + + foreach(QString token, tokens) { + + if (wattToken.exactMatch(token)) { + QString name = wattToken.cap(1); + QString unit = wattToken.cap(2); + + if (name == "Elapsed time total") timeIndex = i; + if (name == "Distance total") kmIndex = i; + if (name == "Cadence") rpmIndex = i; + if (name == "Velocity") kphIndex = i; + if (name == "Heart rate") bpmIndex = i; + if (name == "Torque per revolution") torqIndex = i; + if (name == "Power per revolution") wattsIndex = i; + + } + i++; + } + + // lets loop through each row of data adding a sample + // using the indexes we set above + double rsecs = 0; + while (!in.atEnd()) { + + QString line = in.readLine(); + QStringList tokens = line.split(QRegExp("[\r\n\t]"), QString::SkipEmptyParts); + + // do we have as many columns as we expected? + if (tokens.count() == columns) { + + double secs = 0.00f; + double km = 0.00f; + double rpm = 0.00f; + double kph = 0.00f; + double bpm = 0.00f; + double torq = 0.00f; + double watts = 0.00f; + + if (timeIndex >= 0) { + QTime time = QTime::fromString(tokens.at(timeIndex), "mm:ss:00"); + secs = QTime::fromString("00:00:00", "mm:ss:00").secsTo(time); + + // its a bit shit, but the format appears to wrap round + // on the hour; 59:59:00 is followed by 00:00:00 + // so we have a problem, since if there are gaps in + // recording then we don't know which hour this is for + // and if we assume largely contiguous data we may as + // well just use a counter instead. + // + // for expediency, we use a counter for now: + secs = rsecs++; + } + if (kmIndex >= 0) km = tokens.at(kmIndex).toDouble() / 1000; + if (rpmIndex >= 0) rpm = tokens.at(rpmIndex).toDouble(); + if (kphIndex >= 0) kph = tokens.at(kphIndex).toDouble(); + if (bpmIndex >= 0) bpm = tokens.at(bpmIndex).toDouble(); + if (torqIndex >= 0) torq = tokens.at(torqIndex).toDouble(); + if (wattsIndex >= 0) watts = tokens.at(wattsIndex).toDouble(); + + rideFile->appendPoint(secs, rpm, bpm, km, kph, torq, watts, 0.0, 0.0, 0.0, 0.0, 0.0, RideFile::noTemp, 0); + } + } + + QRegExp gcPattern("^.*/(\\d\\d\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)\\.txt$"); + gcPattern.setCaseSensitivity(Qt::CaseInsensitive); + + // Only filename we have seen is name_yyymmdd or name_yyymmdd_all.txt + QRegExp wb1Pattern("^.*/[^_]*_(\\d\\d\\d\\d)(\\d\\d)(\\d\\d).txt$"); + wb1Pattern.setCaseSensitivity(Qt::CaseInsensitive); + QRegExp wb2Pattern("^.*/[^_]*_(\\d\\d\\d\\d)(\\d\\d)(\\d\\d)_all.txt$"); + wb2Pattern.setCaseSensitivity(Qt::CaseInsensitive); + + // It is a GC Filename + if (gcPattern.exactMatch(file.fileName())) { + QDateTime datetime(QDate(gcPattern.cap(1).toInt(), + gcPattern.cap(2).toInt(), + gcPattern.cap(3).toInt()), + QTime(gcPattern.cap(4).toInt(), + gcPattern.cap(5).toInt(), + gcPattern.cap(6).toInt())); + rideFile->setStartTime(datetime); + } + + // It is an Wattbike Filename + if (wb1Pattern.exactMatch(file.fileName())) { + QDateTime datetime(QDate(wb1Pattern.cap(1).toInt(), + wb1Pattern.cap(2).toInt(), + wb1Pattern.cap(3).toInt()), + QTime(0, 0, 0)); + rideFile->setStartTime(datetime); + } + if (wb2Pattern.exactMatch(file.fileName())) { + QDateTime datetime(QDate(wb2Pattern.cap(1).toInt(), + wb2Pattern.cap(2).toInt(), + wb2Pattern.cap(3).toInt()), + QTime(0, 0, 0)); + rideFile->setStartTime(datetime); + } + + return rideFile; } - - // - // Get date time from standard GC name - // - QRegExp gcPattern("^.*/(\\d\\d\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)_(\\d\\d)\\.txt$"); - gcPattern.setCaseSensitivity(Qt::CaseInsensitive); - - // Racemermate uses name-mode-yyyy-mm-dd-hh-mm-ss.CDF.txt - QRegExp rmPattern("^.*/[^-]*-[^-]*-(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)-(\\d\\d)-(\\d\\d)-(\\d\\d)\\.cdf.txt$"); - rmPattern.setCaseSensitivity(Qt::CaseInsensitive); - - // Ergvideo uses name_ergvideo_ridename_yyyy-mm-dd@hh-mm-ss.txt - QRegExp evPattern("^.*/[^_]*_[^_]*_[^_]*_(\\d\\d\\d\\d)-(\\d\\d)-(\\d\\d)@(\\d\\d)-(\\d\\d)-(\\d\\d)\\.txt$"); - evPattern.setCaseSensitivity(Qt::CaseInsensitive); - - // It is a GC Filename - if (gcPattern.exactMatch(file.fileName())) { - QDateTime datetime(QDate(gcPattern.cap(1).toInt(), - gcPattern.cap(2).toInt(), - gcPattern.cap(3).toInt()), - QTime(gcPattern.cap(4).toInt(), - gcPattern.cap(5).toInt(), - gcPattern.cap(6).toInt())); - rideFile->setStartTime(datetime); - } - - // It is an Ergvideo Filename - if (evPattern.exactMatch(file.fileName())) { - QDateTime datetime(QDate(evPattern.cap(1).toInt(), - evPattern.cap(2).toInt(), - evPattern.cap(3).toInt()), - QTime(evPattern.cap(4).toInt(), - evPattern.cap(5).toInt(), - evPattern.cap(6).toInt())); - rideFile->setStartTime(datetime); - } - - // It is an Racermate Filename - if (rmPattern.exactMatch(file.fileName())) { - QDateTime datetime(QDate(rmPattern.cap(1).toInt(), - rmPattern.cap(2).toInt(), - rmPattern.cap(3).toInt()), - QTime(rmPattern.cap(4).toInt(), - rmPattern.cap(5).toInt(), - rmPattern.cap(6).toInt())); - rideFile->setStartTime(datetime); - } - - return rideFile; }