From fef237f138a5fd83972a1e0dc6f37fe4eb19499a Mon Sep 17 00:00:00 2001 From: Mark Liversedge Date: Fri, 6 Aug 2010 19:48:51 +0100 Subject: [PATCH] PWX Ride file support Support for Training peaks new .pwx file format. This is an XML format (and is particularly verbose). Support has been added to enable interoperability with WKO+ v3, TrainingPeaks.com and Device Agent. --- src/MainWindow.cpp | 25 ++- src/MainWindow.h | 1 + src/PwxRideFile.cpp | 515 ++++++++++++++++++++++++++++++++++++++++++++ src/PwxRideFile.h | 31 +++ src/src.pro | 2 + 5 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 src/PwxRideFile.cpp create mode 100644 src/PwxRideFile.h diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 0f8237c14..6517bda65 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -29,6 +29,7 @@ #include "ConfigDialog.h" #include "CriticalPowerWindow.h" #include "GcRideFile.h" +#include "PwxRideFile.h" #include "LTMWindow.h" #include "PfPvWindow.h" #include "DownloadRideDialog.h" @@ -371,8 +372,10 @@ MainWindow::MainWindow(const QDir &home) : rideMenu->addSeparator (); rideMenu->addAction(tr("&Export to CSV..."), this, SLOT(exportCSV()), tr("Ctrl+E")); - rideMenu->addAction(tr("&Export to GC..."), this, + rideMenu->addAction(tr("Export to GC..."), this, SLOT(exportGC())); + rideMenu->addAction(tr("Export to PWX..."), this, + SLOT(exportPWX())); rideMenu->addSeparator (); rideMenu->addAction(tr("&Save ride"), this, SLOT(saveRide()), tr("Ctrl+S")); @@ -670,6 +673,26 @@ MainWindow::currentRide() return ((RideItem*) treeWidget->selectedItems().first())->ride(); } +void +MainWindow::exportPWX() +{ + if ((treeWidget->selectedItems().size() != 1) + || (treeWidget->selectedItems().first()->type() != RIDE_TYPE)) { + QMessageBox::critical(this, tr("Select Ride"), tr("No ride selected!")); + return; + } + + QString fileName = QFileDialog::getSaveFileName( + this, tr("Export PWX"), QDir::homePath(), tr("PWX (*.pwx)")); + if (fileName.length() == 0) + return; + + QString err; + QFile file(fileName); + PwxFileReader reader; + reader.writeRideFile(home.dirName() /* cyclist name */, currentRide(), file); +} + void MainWindow::exportGC() { diff --git a/src/MainWindow.h b/src/MainWindow.h index 5ac74da2b..aa2be7544 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -129,6 +129,7 @@ class MainWindow : public QMainWindow void openCyclist(); void downloadRide(); void manualRide(); + void exportPWX(); void exportCSV(); void exportGC(); void manualProcess(QString); diff --git a/src/PwxRideFile.cpp b/src/PwxRideFile.cpp new file mode 100644 index 000000000..14c3db2ee --- /dev/null +++ b/src/PwxRideFile.cpp @@ -0,0 +1,515 @@ +/* + * Copyright (c) 2010 Mark Liversedge (liversedge@gmail.com) + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "PwxRideFile.h" +#include "Settings.h" +#include +#include +#include + +#include + +static int pwxFileReaderRegistered = + RideFileFactory::instance().registerReader( + "pwx", "Training Peaks PWX", new PwxFileReader()); + +RideFile * +PwxFileReader::openRideFile(QFile &file, QStringList &errors) const +{ + QDomDocument doc("Training Peaks PWX"); + if (!file.open(QIODevice::ReadOnly)) { + errors << "Could not open file."; + return NULL; + } + + bool parsed = doc.setContent(&file); + file.close(); + if (!parsed) { + errors << "Could not parse file."; + return NULL; + } + + return PwxFromDomDoc(doc, errors); +} + +RideFile * +PwxFileReader::PwxFromDomDoc(QDomDocument doc, QStringList &errors) const +{ + RideFile *rideFile = new RideFile(); + QDomElement root = doc.documentElement(); + QDomNode workout = root.firstChildElement("workout"); + QDomNode node = workout.firstChild(); + + // can arrive at any time, so lets cache them + // and sort out at the end + QDateTime rideDate; + QString rideNotes; + + int intervals = 0; + int samples = 0; + + // in case we need to calculate distance + double rtime = 0; + double rdist = 0; + + while (!node.isNull()) { + + // athlete + if (node.nodeName() == "athlete") { + + QDomElement name = node.firstChildElement("name"); + if (!name.isNull()) { + rideFile->setTag("Athlete Name", name.text()); + } + + QDomElement weight = node.firstChildElement("weight"); + if (!weight.isNull()) { + rideFile->setTag("Weight", weight.text()); + } + + // workout code + } else if (node.nodeName() == "code") { + + QDomElement code = node.toElement(); + rideFile->setTag("Workout Code", code.text()); + + // goal / objective + } else if (node.nodeName() == "goal") { + + QDomElement goal = node.toElement(); + rideFile->setTag("Objective", goal.text()); + + // sport + } else if (node.nodeName() == "sportType") { + + QDomElement sport = node.toElement(); + rideFile->setTag("Sport", sport.text()); + + // notes + } else if (node.nodeName() == "cmt") { + + QDomElement notes = node.toElement(); + rideNotes = notes.text(); + + // device type and info + } else if (node.nodeName() == "device") { + + QString devicetype; + // make and model + QDomElement make = node.firstChildElement("make"); + if (!make.isNull()) devicetype = make.text(); + QDomElement model = node.firstChildElement("model"); + if (!model.isNull()) { + if (devicetype != "") devicetype += " "; + devicetype += model.text(); + } + rideFile->setDeviceType(devicetype); + + // device settings data + QString deviceinfo; + QDomElement extension = node.firstChildElement("extension"); + if (!extension.isNull()) { + for (QDomElement info=extension.firstChildElement(); + !info.isNull(); + info = info.nextSiblingElement()) { + deviceinfo += info.tagName(); + deviceinfo += ": "; + deviceinfo += info.text(); + deviceinfo += '\n'; + } + } + rideFile->setTag("Device Info", deviceinfo); + + // start date/time + } else if (node.nodeName() == "time") { + QDomElement date = node.toElement(); + rideDate = QDateTime::fromString(date.text(), Qt::ISODate); + rideFile->setStartTime(rideDate); + + // interval data + } else if (node.nodeName() == "segment") { + RideFileInterval add; + + // name + QDomElement name = node.firstChildElement("name"); + if (!name.isNull()) add.name = name.text(); + else add.name = QString("Interval #%1").arg(++intervals); + + QDomElement summary = node.firstChildElement("summarydata"); + if (!summary.isNull()) { + + // start + QDomElement beginning = summary.firstChildElement("beginning"); + if (!beginning.isNull()) add.start = beginning.text().toDouble(); + else add.start = -1; + + // duration - convert to end + QDomElement duration = summary.firstChildElement("duration"); + if (!duration.isNull() && add.start != -1) + add.stop = beginning.text().toDouble() + add.start; + else + add.stop = -1; + + // add interval + if (add.start != -1 && add.stop != -1) { + rideFile->addInterval(add.start, add.stop, add.name); + } + } + + // data points: offset, hr, spd, pwr, torq, cad, dist, lat, lon, alt (ignored: temp, time) + } else if (node.nodeName() == "sample") { + RideFilePoint add; + + // offset (secs) + QDomElement off = node.firstChildElement("timeoffset"); + if (!off.isNull()) add.secs = off.text().toDouble(); + else add.secs = 0.0; + // hr + QDomElement hr = node.firstChildElement("hr"); + if (!hr.isNull()) add.hr = hr.text().toDouble(); + else add.hr = 0.0; + // spd in meters per second converted to kph + QDomElement spd = node.firstChildElement("spd"); + if (!spd.isNull()) add.kph = spd.text().toDouble() * 3.6; + else add.kph = 0.0; + // pwr + QDomElement pwr = node.firstChildElement("pwr"); + if (!pwr.isNull()) { + add.watts = pwr.text().toDouble(); + // XXX undo the fudge to set zero values to + // 1 in the writer (below). This is to keep + // the TP upload web-service happy + if (add.watts == 1) add.watts = 0.0; + } else add.watts = 0.0; + // torq + QDomElement torq = node.firstChildElement("torq"); + if (!torq.isNull()) add.nm = torq.text().toDouble(); + else add.nm = 0.0; + // cad + QDomElement cad = node.firstChildElement("cad"); + if (!cad.isNull()) add.cad = cad.text().toDouble(); + else add.cad = 0.0; + // dist + QDomElement dist = node.firstChildElement("dist"); + if (!dist.isNull()) add.km = dist.text().toDouble() /1000; + else add.km = 0.0; + + // lat + QDomElement lat = node.firstChildElement("lat"); + if (!lat.isNull()) add.lat = lat.text().toDouble(); + else add.lat = 0.0; + // lon + QDomElement lon = node.firstChildElement("lon"); + if (!lon.isNull()) add.lon = lon.text().toDouble(); + else add.lon = 0.0; + // alt + QDomElement alt = node.firstChildElement("alt"); + if (!alt.isNull()) add.alt = alt.text().toDouble(); + else add.alt = 0.0; + + // do we need to calculate distance? + if (add.km == 0.0 && samples) { + // delta secs * kph/3600 + add.km = rdist + ((add.secs - rtime) * (add.kph/3600)); + } + + // running totals + samples++; + rtime = add.secs; + rdist = add.km; + + // add the data point + rideFile->appendPoint(add.secs, add.cad, add.hr, add.km, add.kph, + add.nm, add.watts, add.alt, add.lon, add.lat, add.headwind, add.interval); + + + // ignored for now + } else if (node.nodeName() == "summarydata") { + } else if (node.nodeName() == "extension") { + } + + node = node.nextSibling(); + } + + // post-process and check + if (samples < 2) { + errors << "Not enough samples present"; + delete rideFile; + return NULL; + } else { + // need to determine the recIntSecs - first - second sample? + rideFile->setRecIntSecs(rideFile->dataPoints()[1]->secs - + rideFile->dataPoints()[0]->secs); + } + return rideFile; +} + +bool +PwxFileReader::writeRideFile(const QString cyclist, const RideFile *ride, QFile &file) const +{ + QDomText text; // used all over + QDomDocument doc; + QDomProcessingInstruction hdr = doc.createProcessingInstruction("xml","version=\"1.0\""); + doc.appendChild(hdr); + + // pwx + QDomElement pwx = doc.createElementNS("http://www.peaksware.com/PWX/1/0", "pwx"); + pwx.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"); + pwx.setAttribute("xmlns:xsd", "http://www.w3.org/2001/XMLSchema"); + pwx.setAttribute("xsi:schemaLocation", "http://www.peaksware.com/PWX/1/0 http://www.peaksware.com/PWX/1/0/pwx.xsd"); + pwx.setAttribute("version", "1.0"); + doc.appendChild(pwx); + + // workouts... we just serialise 1 at a time + QDomElement root = doc.createElement("workout"); + pwx.appendChild(root); + + // athlete details + QDomElement athlete = doc.createElement("athlete"); + QDomElement name = doc.createElement("name"); + text = doc.createTextNode(cyclist); name.appendChild(text); + athlete.appendChild(name); + root.appendChild(athlete); + + // sport + QString sport = ride->getTag("Sport", "Bike"); + QDomElement sportType = doc.createElement("sportType"); + text = doc.createTextNode(sport); sportType.appendChild(text); + root.appendChild(sportType); + + // workout code + if (ride->getTag("Workout Code", "") != "") { + QString wcode = ride->getTag("Workout Code", ""); + QDomElement code = doc.createElement("code"); + text = doc.createTextNode(wcode); code.appendChild(text); + root.appendChild(code); + } + + // goal + if (ride->getTag("Objective", "") != "") { + QString obj = ride->getTag("Objective", ""); + QDomElement goal = doc.createElement("goal"); + text = doc.createTextNode(obj); goal.appendChild(text); + root.appendChild(goal); + } + + + // notes + // XXX todo + // device type + if (ride->deviceType() != "") { + QDomElement device = doc.createElement("device"); + device.setAttribute("id", ride->deviceType()); + QDomElement make = doc.createElement("make"); + text = doc.createTextNode(ride->deviceType()); + make.appendChild(text); + device.appendChild(make); + root.appendChild(device); + } + + // time + QDomElement time = doc.createElement("time"); + text = doc.createTextNode(ride->startTime().toString(Qt::ISODate)); + time.appendChild(text); + root.appendChild(time); + + // summary data + QDomElement summarydata = doc.createElement("summarydata"); + root.appendChild(summarydata); + QDomElement beginning = doc.createElement("beginning"); + text = doc.createTextNode(QString("%1").arg(ride->dataPoints().first()->secs)); + beginning.appendChild(text); + summarydata.appendChild(beginning); + + QDomElement duration = doc.createElement("duration"); + text = doc.createTextNode(QString("%1").arg(ride->dataPoints().last()->secs)); + duration.appendChild(text); + summarydata.appendChild(duration); + + // the channels - min max avg get set by TP anyway + // so we leave them blank to save time on calculating them + if (ride->areDataPresent()->hr) { + QDomElement s = doc.createElement("hr"); + s.setAttribute("max", "0"); + s.setAttribute("min", "0"); + s.setAttribute("avg", "0"); + summarydata.appendChild(s); + } + if (ride->areDataPresent()->kph) { + QDomElement s = doc.createElement("spd"); + s.setAttribute("max", "0"); + s.setAttribute("min", "0"); + s.setAttribute("avg", "0"); + summarydata.appendChild(s); + } + if (ride->areDataPresent()->watts) { + QDomElement s = doc.createElement("pwr"); + s.setAttribute("max", "0"); + s.setAttribute("min", "0"); + s.setAttribute("avg", "0"); + summarydata.appendChild(s); + } + if (ride->areDataPresent()->nm) { + QDomElement s = doc.createElement("torq"); + s.setAttribute("max", "0"); + s.setAttribute("min", "0"); + s.setAttribute("avg", "0"); + summarydata.appendChild(s); + } + if (ride->areDataPresent()->cad) { + QDomElement s = doc.createElement("cad"); + s.setAttribute("max", "0"); + s.setAttribute("min", "0"); + s.setAttribute("avg", "0"); + summarydata.appendChild(s); + } + QDomElement dist = doc.createElement("dist"); + text = doc.createTextNode(QString("%1").arg((int)(ride->dataPoints().last()->km * 1000))); + dist.appendChild(text); + summarydata.appendChild(dist); + + if (ride->areDataPresent()->alt) { + QDomElement s = doc.createElement("alt"); + s.setAttribute("max", "0"); + s.setAttribute("min", "0"); + s.setAttribute("avg", "0"); + summarydata.appendChild(s); + } + + // interval "segments" + if (!ride->intervals().empty()) { + foreach (RideFileInterval i, ride->intervals()) { + QDomElement segment = doc.createElement("segment"); + root.appendChild(segment); + + // name + QDomElement name = doc.createElement("name"); + text = doc.createTextNode(i.name); name.appendChild(text); + segment.appendChild(name); + + // summarydata + QDomElement summarydata = doc.createElement("summarydata"); + segment.appendChild(summarydata); + + // beginning + QDomElement beginning = doc.createElement("beginning"); + text = doc.createTextNode(QString("%1").arg(i.start)); + beginning.appendChild(text); + summarydata.appendChild(beginning); + + // duration + QDomElement duration = doc.createElement("duration"); + text = doc.createTextNode(QString("%1").arg(i.stop - i.start)); + duration.appendChild(text); + summarydata.appendChild(duration); + } + } + + // samples + // data points: timeoffset, dist, hr, spd, pwr, torq, cad, lat, lon, alt + if (!ride->dataPoints().empty()) { + foreach (const RideFilePoint *point, ride->dataPoints()) { + QDomElement sample = doc.createElement("sample"); + root.appendChild(sample); + + // time + QDomElement timeoffset = doc.createElement("timeoffset"); + text = doc.createTextNode(QString("%1").arg((int)point->secs)); + timeoffset.appendChild(text); + sample.appendChild(timeoffset); + + // hr + if (ride->areDataPresent()->hr) { + QDomElement hr = doc.createElement("hr"); + text = doc.createTextNode(QString("%1").arg((int)point->hr)); + hr.appendChild(text); + sample.appendChild(hr); + } + // spd - meters per second + if (ride->areDataPresent()->kph) { + QDomElement spd = doc.createElement("spd"); + text = doc.createTextNode(QString("%1").arg(point->kph / 3.6)); + spd.appendChild(text); + sample.appendChild(spd); + } + // pwr + if (ride->areDataPresent()->watts) { + // TrainingPeaks.com file upload rejects rides + // with excessive power of zero for some reason + // looks like they expect some smoothing or something? + // we set 0 to 1 to at least get an upload + // and do the reverse in the reader above + int watts = point->watts ? point->watts : 1; + QDomElement pwr = doc.createElement("pwr"); + text = doc.createTextNode(QString("%1").arg(watts)); + pwr.appendChild(text); + sample.appendChild(pwr); + } + // torq + if (ride->areDataPresent()->nm) { + QDomElement torq = doc.createElement("torq"); + text = doc.createTextNode(QString("%1").arg(point->nm)); + torq.appendChild(text); + sample.appendChild(torq); + } + // cad + if (ride->areDataPresent()->cad) { + QDomElement cad = doc.createElement("cad"); + text = doc.createTextNode(QString("%1").arg((int)(point->cad))); + cad.appendChild(text); + sample.appendChild(cad); + } + + // distance - meters + QDomElement dist = doc.createElement("dist"); + text = doc.createTextNode(QString("%1").arg((int)(point->km*1000))); + dist.appendChild(text); + sample.appendChild(dist); + + + // lat + if (ride->areDataPresent()->lat && point->lat > -90.0 && point->lat < 90.0) { + QDomElement lat = doc.createElement("lat"); + text = doc.createTextNode(QString("%1").arg(point->lat)); + lat.appendChild(text); + sample.appendChild(lat); + } + // lon + if (ride->areDataPresent()->lon && point->lon > -180.00 && point->lon < 180.00) { + QDomElement lon = doc.createElement("lon"); + text = doc.createTextNode(QString("%1").arg(point->lon)); + lon.appendChild(text); + sample.appendChild(lon); + } + + // alt + if (ride->areDataPresent()->alt) { + QDomElement alt = doc.createElement("alt"); + text = doc.createTextNode(QString("%1").arg(point->alt)); + alt.appendChild(text); + sample.appendChild(alt); + } + } + } + + QByteArray xml = doc.toByteArray(4); + if (!file.open(QIODevice::WriteOnly)) return(false); + if (file.write(xml) != xml.size()) return(false); + file.close(); + return(true); +} diff --git a/src/PwxRideFile.h b/src/PwxRideFile.h new file mode 100644 index 000000000..053914d15 --- /dev/null +++ b/src/PwxRideFile.h @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010 Mark Liversedge (liversedge@gmail.com) + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., 51 + * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef _PwxRideFile_h +#define _PwxRideFile_h + +#include "RideFile.h" +#include + +struct PwxFileReader : public RideFileReader { + virtual RideFile *openRideFile(QFile &file, QStringList &errors) const; + virtual bool writeRideFile(const QString cyclist, const RideFile *ride, QFile &file) const; + virtual RideFile *PwxFromDomDoc(QDomDocument doc, QStringList &errors) const; +}; + +#endif // _PwxRideFile_h diff --git a/src/src.pro b/src/src.pro index 8a109fe2b..cffa5a97b 100644 --- a/src/src.pro +++ b/src/src.pro @@ -117,6 +117,7 @@ HEADERS += \ PowerHist.h \ PowerTapDevice.h \ PowerTapUtil.h \ + PwxRideFile.h \ QuarqdClient.h \ QuarqParser.h \ QuarqRideFile.h \ @@ -226,6 +227,7 @@ SOURCES += \ PowerHist.cpp \ PowerTapDevice.cpp \ PowerTapUtil.cpp \ + PwxRideFile.cpp \ QuarqdClient.cpp \ QuarqParser.cpp \ QuarqRideFile.cpp \