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.
This commit is contained in:
Mark Liversedge
2012-01-15 12:52:57 +00:00
parent cf8310b1b9
commit ee054f4809

View File

@@ -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, QList<Ri
bool metric = true; // are the values in metric or imperial?
QDateTime startTime; // parsed from filename
// lets get on with it then
// Lets make sure we can open the file
if (!file.open(QFile::ReadOnly)) {
errors << ("Could not open ride file: \""
+ file.fileName() + "\"");
errors << ("Could not open ride file: \"" + file.fileName() + "\"");
return NULL;
}
QTextStream is(&file);
QTextStream in(&file);
// Lets construct our rideFile
RideFile *rideFile = new RideFile();
rideFile->setDeviceType("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<double> 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<double> 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;
}