/* * Copyright (c) 2009 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 "ErgFile.h" #include "Athlete.h" #include #include "Units.h" // Supported file types static QStringList supported; static bool setSupported() { ::supported << ".erg"; ::supported << ".mrc"; ::supported << ".crs"; ::supported << ".pgmf"; return true; } static bool isinit = setSupported(); bool ErgFile::isWorkout(QString name) { foreach(QString extension, supported) { if (name.endsWith(extension, Qt::CaseInsensitive)) return true; } return false; } ErgFile::ErgFile(QString filename, int &mode, Context *context) : filename(filename), context(context), mode(mode) { if (context->athlete->zones(false)) { int zonerange = context->athlete->zones(false)->whichRange(QDateTime::currentDateTime().date()); if (zonerange >= 0) CP = context->athlete->zones(false)->getCP(zonerange); } reload(); } ErgFile::ErgFile(Context *context) : context(context), mode(nomode) { if (context->athlete->zones(false)) { int zonerange = context->athlete->zones(false)->whichRange(QDateTime::currentDateTime().date()); if (zonerange >= 0) CP = context->athlete->zones(false)->getCP(zonerange); } else { CP = 300; } filename =""; } ErgFile * ErgFile::fromContent(QString contents, Context *context) { ErgFile *p = new ErgFile(context); p->parseComputrainer(contents); return p; } void ErgFile::reload() { // which parser to call? NOTE: we should look at moving to an ergfile factory // like we do with ride files if we end up with lots of different formats if (filename.endsWith(".pgmf", Qt::CaseInsensitive)) parseTacx(); else parseComputrainer(); } void ErgFile::parseTacx() { // Initialise Version = ""; Units = ""; Filename = ""; Name = ""; Duration = -1; Ftp = 0; // FTP this file was targetted at MaxWatts = 0; // maxWatts in this ergfile (scaling) valid = false; // did it parse ok? rightPoint = leftPoint = 0; format = CRS; // default to couse until we know Points.clear(); Laps.clear(); // running totals double rdist = 0; // running total for distance double ralt = 200; // always start at 200 meters just to prettify the graph // open the file for binary reading and open a datastream QFile pgmfFile(filename); if (pgmfFile.open(QIODevice::ReadOnly) == false) return; QDataStream input(&pgmfFile); input.setByteOrder(QDataStream::LittleEndian); input.setVersion(QDataStream::Qt_4_0); // 32 bit floats not 64 bit. bool happy = true; // are we ok to continue reading? // // BASIC DATA STRUCTURES // struct { uint16_t fingerprint; uint16_t version; uint32_t blocks; } header; // file header struct { uint16_t type; uint16_t version; uint32_t records; uint32_t recordSize; } info; // tells us what to read struct { quint32 checksum; // 4 // we don't use an array for the filename since C++ arrays are prepended by a 16bit size quint8 name[34]; qint32 wattSlopePulse; // 42 qint32 timeDist; // 46 double totalTimeDist; // 54 double energyCons; // 62 float altStart; // 66 qint32 brakeCategory; // 70 } general; // type 1010 struct { float distance; float slope; float friction; } program; // type 1020 // // FILE HEADER // int rc = input.readRawData((char*)&header, sizeof(header)); if (rc == sizeof(header)) { if (header.fingerprint == 1000 && header.version == 100) happy = true; else happy = false; } else happy = false; unsigned int block = 0; // keep track of how many blocks we have read // // READ THE BLOCKS INSIDE THE FILE // while (happy && block < header.blocks) { // read the info for this block rc = input.readRawData((char*)&info, sizeof(info)); if (rc == sizeof(info)) { // okay now read tha block switch (info.type) { case 1010 : // general { // read it but mostly ignore -- for now // we read member by member to avoid struct word alignment problem caused // by the filename being 34 bytes long (why didn't they use 32 or 36?) input>>general.checksum; input.readRawData((char*)&general.name[0], 34); input>>general.wattSlopePulse; input>>general.timeDist; input>>general.totalTimeDist; input>>general.energyCons; input>>general.altStart; input>>general.brakeCategory; switch (general.wattSlopePulse) { case 0 : mode = format = ERG; break; case 1 : mode = format = CRS; break; default: happy = false; break; } ralt = general.altStart; } break; case 1020 : // program { // read in the program records for (unsigned int record=0; record < info.records; record++) { // get the next record if (sizeof(program) != input.readRawData((char*)&program, sizeof(program))) { happy = false; break; } ErgFilePoint add; if (format == CRS) { // distance guff add.x = rdist; double distance = program.distance; // in meters rdist += distance; // gradient and altitude add.val = program.slope; add.y = ralt; ralt += (distance * add.val) / 100; } else { add.x = rdist; rdist += program.distance * 1000; // 1000ths of a second add.val = add.y = program.slope; // its watts now } Points.append(add); if (add.y > MaxWatts) MaxWatts=add.y; } } break; default: // unexpected block type happy = false; break; } block++; } else happy = false; } // done pgmfFile.close(); // if we got here and are still happy then it // must have been a valid file. if (happy) { valid = true; // set ErgFile duration Duration = Points.last().x; // last is the end point in msecs leftPoint = 0; rightPoint = 1; // calculate climbing etc calculateMetrics(); } } void ErgFile::parseComputrainer(QString p) { QFile ergFile(filename); int section = NOMANSLAND; // section 0=init, 1=header data, 2=course data leftPoint=rightPoint=0; MaxWatts = Ftp = 0; int lapcounter = 0; format = ERG; // either ERG or MRC Points.clear(); // start by assuming the input file is Metric bool bIsMetric = true; // running totals for CRS file format double rdist = 0; // running total for distance double ralt = 200; // always start at 200 meters just to prettify the graph // open the file if (p == "" && ergFile.open(QIODevice::ReadOnly | QIODevice::Text) == false) { valid = false; return; } // Section markers QRegExp startHeader("^.*\\[COURSE HEADER\\].*$", Qt::CaseInsensitive); QRegExp endHeader("^.*\\[END COURSE HEADER\\].*$", Qt::CaseInsensitive); QRegExp startData("^.*\\[COURSE DATA\\].*$", Qt::CaseInsensitive); QRegExp endData("^.*\\[END COURSE DATA\\].*$", Qt::CaseInsensitive); // ignore whitespace and support for ';' comments (a GC extension) QRegExp ignore("^(;.*|[ \\t\\n]*)$", Qt::CaseInsensitive); // workout settings QRegExp settings("^([^=]*)=[ \\t]*([^=\\n\\r\\t]*).*$", Qt::CaseInsensitive); // format setting for ergformat QRegExp ergformat("^[;]*(MINUTES[ \\t]+WATTS).*$", Qt::CaseInsensitive); QRegExp mrcformat("^[;]*(MINUTES[ \\t]+PERCENT).*$", Qt::CaseInsensitive); QRegExp crsformat("^[;]*(DISTANCE[ \\t]+GRADE[ \\t]+WIND).*$", Qt::CaseInsensitive); // time watts records QRegExp absoluteWatts("^[ \\t]*([0-9\\.]+)[ \\t]*([0-9\\.]+)[ \\t\\n]*$", Qt::CaseInsensitive); QRegExp relativeWatts("^[ \\t]*([0-9\\.]+)[ \\t]*([0-9\\.]+)%[ \\t\\n]*$", Qt::CaseInsensitive); // distance slope wind records QRegExp absoluteSlope("^[ \\t]*([0-9\\.]+)[ \\t]*([-0-9\\.]+)[ \\t\\n]*([-0-9\\.]+)[ \\t\\n]*$", Qt::CaseInsensitive); // Lap marker in an ERG/MRC file QRegExp lapmarker("^[ \\t]*([0-9\\.]+)[ \\t]*LAP[ \\t\\n]*(.*)$", Qt::CaseInsensitive); QRegExp crslapmarker("^[ \\t]*LAP[ \\t\\n]*(.*)$", Qt::CaseInsensitive); // ok. opened ok lets parse. QTextStream inputStream(&ergFile); QTextStream stringStream(&p); if (p != "") inputStream.setString(&p); // use a string not a file! while ((p=="" && !inputStream.atEnd()) || (p!="" && !stringStream.atEnd())) { // Code plagiarised from CsvRideFile. // 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 = (p != "" ? stringStream.readLine() : ergFile.readLine()); QStringList lines = linesIn.split('\r'); // workaround for empty lines if(lines.isEmpty()) { continue; } for (int li = 0; li < lines.size(); ++li) { QString line = lines[li]; // so what we go then? if (startHeader.exactMatch(line)) { section = SETTINGS; } else if (endHeader.exactMatch(line)) { section = NOMANSLAND; } else if (startData.exactMatch(line)) { section = DATA; } else if (endData.exactMatch(line)) { section = END; } else if (ergformat.exactMatch(line)) { // save away the format mode = format = ERG; } else if (mrcformat.exactMatch(line)) { // save away the format mode = format = MRC; } else if (crsformat.exactMatch(line)) { // save away the format mode = format = CRS; } else if (lapmarker.exactMatch(line)) { // lap marker found ErgFileLap add; add.x = lapmarker.cap(1).toDouble() * 60000; // from mins to 1000ths of a second add.LapNum = ++lapcounter; add.name = lapmarker.cap(2).simplified(); Laps.append(add); } else if (crslapmarker.exactMatch(line)) { // new distance lapmarker ErgFileLap add; add.x = rdist; add.LapNum = ++lapcounter; add.name = lapmarker.cap(2).simplified(); Laps.append(add); } else if (settings.exactMatch(line)) { // we have name = value setting QRegExp pversion("^VERSION *", Qt::CaseInsensitive); if (pversion.exactMatch(settings.cap(1))) Version = settings.cap(2); QRegExp pfilename("^FILE NAME *", Qt::CaseInsensitive); if (pfilename.exactMatch(settings.cap(1))) Filename = settings.cap(2); QRegExp pname("^DESCRIPTION *", Qt::CaseInsensitive); if (pname.exactMatch(settings.cap(1))) Name = settings.cap(2); QRegExp sname("^SOURCE *", Qt::CaseInsensitive); if (sname.exactMatch(settings.cap(1))) Source = settings.cap(2); QRegExp punit("^UNITS *", Qt::CaseInsensitive); if (punit.exactMatch(settings.cap(1))) { Units = settings.cap(2); // UNITS can be ENGLISH or METRIC (miles/km) QRegExp penglish(" ENGLISH$", Qt::CaseInsensitive); if (penglish.exactMatch(Units)) { // Units <> METRIC //qDebug("Setting conversion to ENGLISH"); bIsMetric = false; } } QRegExp pftp("^FTP *", Qt::CaseInsensitive); if (pftp.exactMatch(settings.cap(1))) Ftp = settings.cap(2).toInt(); } else if (absoluteWatts.exactMatch(line)) { // we have mins watts line ErgFilePoint add; add.x = absoluteWatts.cap(1).toDouble() * 60000; // from mins to 1000ths of a second add.val = add.y = round(absoluteWatts.cap(2).toDouble()); // plain watts switch (format) { case ERG: // its an absolute wattage if (Ftp) { // adjust if target FTP is set. // if ftp is set then convert to the users CP double watts = add.y; double ftp = Ftp; watts *= CP/ftp; add.y = add.val = watts; } break; case MRC: // its a percent relative to CP (mrc file) add.y *= CP; add.y /= 100.00; add.val = add.y; break; } Points.append(add); if (add.y > MaxWatts) MaxWatts=add.y; } else if (relativeWatts.exactMatch(line)) { // we have a relative watts match ErgFilePoint add; add.x = relativeWatts.cap(1).toDouble() * 60000; // from mins to 1000ths of a second add.val = add.y = (relativeWatts.cap(2).toDouble() /100.00) * CP; Points.append(add); if (add.y > MaxWatts) MaxWatts=add.y; } else if (absoluteSlope.exactMatch(line)) { // dist, grade, wind strength ErgFilePoint add; // distance guff add.x = rdist; int distance = absoluteSlope.cap(1).toDouble() * 1000; // convert to meters if (!bIsMetric) distance *= KM_PER_MILE; rdist += distance; // gradient and altitude add.val = absoluteSlope.cap(2).toDouble(); add.y = ralt; ralt += distance * add.val / 100; /* paused */ Points.append(add); if (add.y > MaxWatts) MaxWatts=add.y; } else if (ignore.exactMatch(line)) { // do nothing for this line } else { // ignore bad lines for now. just bark. //qDebug()<<"huh?" << line; } } } // done. if (p=="") ergFile.close(); if (section == END && Points.count() > 0) { valid = true; // add the last point for a crs file if (mode == CRS) { ErgFilePoint add; add.x = rdist; add.val = 0.0; add.y = ralt; Points.append(add); if (add.y > MaxWatts) MaxWatts=add.y; } // add a start point if it doesn't exist if (Points.at(0).x > 0) { ErgFilePoint add; add.x = 0; add.y = Points.at(0).y; add.val = Points.at(0).val; Points.insert(0, add); } // set ErgFile duration Duration = Points.last().x; // last is the end point in msecs leftPoint = 0; rightPoint = 1; calculateMetrics(); } else { valid = false; } } ErgFile::~ErgFile() { Points.clear(); } bool ErgFile::isValid() { return valid; } int ErgFile::wattsAt(long x, int &lapnum) { // workout what wattage load should be set for any given // point in time in msecs. if (!isValid()) return -100; // not a valuid ergfile // is it in bounds? if (x < 0 || x > Duration) return -100; // out of bounds!!! // do we need to return the Lap marker? if (Laps.count() > 0) { int lap=0; for (int i=0; i=Laps.at(i).x) lap += 1; } lapnum = lap; } else lapnum = 0; // find right section of the file while (x < Points.at(leftPoint).x || x > Points.at(rightPoint).x) { if (x < Points.at(leftPoint).x) { leftPoint--; rightPoint--; } else if (x > Points.at(rightPoint).x) { leftPoint++; rightPoint++; } } // two different points in time but the same watts // at both, it doesn't really matter which value // we use if (Points.at(leftPoint).val == Points.at(rightPoint).val) return Points.at(rightPoint).val; // the erg file will list the point in time twice // to show a jump from one wattage to another // at this point in ime (i.e x=100 watts=100 followed // by x=100 watts=200) if (Points.at(leftPoint).x == Points.at(rightPoint).x) return Points.at(rightPoint).val; // so this point in time between two points and // we are ramping from one point and another // the steps in the calculation have been explicitly // listed for code clarity double deltaW = Points.at(rightPoint).val - Points.at(leftPoint).val; double deltaT = Points.at(rightPoint).x - Points.at(leftPoint).x; double offT = x - Points.at(leftPoint).x; double factor = offT / deltaT; double nowW = Points.at(leftPoint).val + (deltaW * factor); return nowW; } double ErgFile::gradientAt(long x, int &lapnum) { // workout what wattage load should be set for any given // point in time in msecs. if (!isValid()) return -100; // not a valid ergfile // is it in bounds? if (x < 0 || x > Duration) return -100; // out of bounds!!! (-10 through +15 are valid return vals) // do we need to return the Lap marker? if (Laps.count() > 0) { int lap=0; for (int i=0; i=Laps.at(i).x) lap += 1; } lapnum = lap; } else lapnum = 0; // find right section of the file while (x < Points.at(leftPoint).x || x > Points.at(rightPoint).x) { if (x < Points.at(leftPoint).x) { leftPoint--; rightPoint--; } else if (x > Points.at(rightPoint).x) { leftPoint++; rightPoint++; } } return Points.at(leftPoint).val; } int ErgFile::nextLap(long x) { if (!isValid()) return -1; // not a valid ergfile // do we need to return the Lap marker? if (Laps.count() > 0) { for (int i=0; i maxY) maxY= p.y; if (first == true) { first = false; } else if (p.y > last.y) { ELEDIST += p.x - last.x; ELE += p.y - last.y; } last = p; } if (ELE == 0 || ELEDIST == 0) GRADE = 0; else GRADE = ELE/ELEDIST * 100; } else { QVector rolling(30); rolling.fill(0.0f); int index = 0; double sum = 0; // 30s rolling average double apsum = 0; // average power used in VI calculation double total = 0; double count = 0; long nextSecs = 0; static const double EPSILON = 0.1; static const double NEGLIGIBLE = 0.1; double secsDelta = 1; double sampsPerWindow = 25.0; double attenuation = sampsPerWindow / (sampsPerWindow + secsDelta); double sampleWeight = secsDelta / (sampsPerWindow + secsDelta); double lastSecs = 0.0; double weighted = 0.0; double sktotal = 0.0; int skcount = 0; ErgFilePoint last; foreach (ErgFilePoint p, Points) { // set the maximum Y value if (p.y > maxY) maxY= p.y; while (nextSecs < p.x) { // CALCULATE NP apsum += last.y; sum += last.y; sum -= rolling[index]; // update 30s circular buffer rolling[index] = last.y; if (index == 29) index=0; else index++; total += pow(sum/30, 4); count ++; // CALCULATE XPOWER while ((weighted > NEGLIGIBLE) && ((nextSecs / 1000) > (lastSecs/1000) + 1000 + EPSILON)) { weighted *= attenuation; lastSecs += 1000; sktotal += pow(weighted, 4.0); skcount++; } weighted *= attenuation; weighted += sampleWeight * last.y; lastSecs = p.x; sktotal += pow(weighted, 4.0); skcount++; nextSecs += 1000; } last = p; } // XP, NP and AP XP = pow(sktotal / skcount, 0.25); NP = pow(total / count, 0.25); AP = apsum / count; // CP if (context->athlete->zones(false)) { int zonerange = context->athlete->zones(false)->whichRange(QDateTime::currentDateTime().date()); if (zonerange >= 0) CP = context->athlete->zones(false)->getCP(zonerange); } // IF if (CP) { IF = NP / CP; RI = XP / CP; } // TSS double normWork = NP * (Duration / 1000); // msecs double rawTSS = normWork * IF; double workInAnHourAtCP = CP * 3600; TSS = rawTSS / workInAnHourAtCP * 100.0; // BS double xWork = XP * (Duration / 1000); // msecs double rawBS = xWork * RI; BS = rawBS / workInAnHourAtCP * 100.0; // VI and RI if (AP) { VI = NP / AP; SVI = XP / AP; } } }