diff --git a/src/ergtable.h b/src/ergtable.h index 5cf2cd27b..8a2e6b3a5 100644 --- a/src/ergtable.h +++ b/src/ergtable.h @@ -6,24 +6,82 @@ #include #include #include +#include +#include #include "qzsettings.h" struct ergDataPoint { - uint16_t cadence = 0; // RPM - uint16_t wattage = 0; // Watts - uint16_t resistance = 0; // Some unit + uint16_t cadence = 0; + uint16_t wattage = 0; + uint16_t resistance = 0; ergDataPoint() = default; - ergDataPoint(uint16_t c, uint16_t w, uint16_t r) : cadence(c), wattage(w), resistance(r) {} }; Q_DECLARE_METATYPE(ergDataPoint) +struct CadenceResistancePair { + uint16_t cadence; + uint16_t resistance; + + bool operator<(const CadenceResistancePair& other) const { + if (resistance != other.resistance) return resistance < other.resistance; + return cadence < other.cadence; + } +}; + +class WattageStats { + public: + static const int MAX_SAMPLES = 100; + static const int MIN_SAMPLES_REQUIRED = 10; + + void addSample(uint16_t wattage) { + samples.append(wattage); + if (samples.size() > MAX_SAMPLES) { + samples.removeFirst(); + } + medianNeedsUpdate = true; + } + + uint16_t getMedian() { + if (!medianNeedsUpdate) return cachedMedian; + if (samples.isEmpty()) return 0; + + QList sortedSamples = samples; + std::sort(sortedSamples.begin(), sortedSamples.end()); + + int middle = sortedSamples.size() / 2; + if (sortedSamples.size() % 2 == 0) { + cachedMedian = (sortedSamples[middle-1] + sortedSamples[middle]) / 2; + } else { + cachedMedian = sortedSamples[middle]; + } + + medianNeedsUpdate = false; + return cachedMedian; + } + + int sampleCount() const { + return samples.size(); + } + + void clear() { + samples.clear(); + cachedMedian = 0; + medianNeedsUpdate = true; + } + + private: + QList samples; + uint16_t cachedMedian = 0; + bool medianNeedsUpdate = true; +}; + class ergTable : public QObject { Q_OBJECT -public: + public: ergTable(QObject *parent = nullptr) : QObject(parent) { loadSettings(); } @@ -32,126 +90,139 @@ class ergTable : public QObject { saveSettings(); } + void reset() { + wattageData.clear(); + consolidatedData.clear(); + lastResistanceValue = 0xFFFF; + lastResistanceTime = QDateTime::currentDateTime(); + + // Clear the settings completely + QSettings settings; + settings.remove(QZSettings::ergDataPoints); + settings.sync(); + } + void collectData(uint16_t cadence, uint16_t wattage, uint16_t resistance, bool ignoreResistanceTiming = false) { - if(resistance != lastResistanceValue) { + if (resistance != lastResistanceValue) { qDebug() << "resistance changed"; lastResistanceTime = QDateTime::currentDateTime(); lastResistanceValue = resistance; } - if(lastResistanceTime.msecsTo(QDateTime::currentDateTime()) < 1000 && ignoreResistanceTiming == false) { + + if (lastResistanceTime.msecsTo(QDateTime::currentDateTime()) < 1000 && !ignoreResistanceTiming) { qDebug() << "skipping collecting data due to resistance changing too fast"; return; } - if (wattage > 0 && cadence > 0 && !ergDataPointExists(cadence, wattage, resistance)) { - qDebug() << "newPointAdded" << "C" << cadence << "W" << wattage << "R" << resistance; - ergDataPoint point(cadence, wattage, resistance); - dataTable.append(point); - saveergDataPoint(point); // Save each new point to QSettings - } else { - qDebug() << "discarded" << "C" << cadence << "W" << wattage << "R" << resistance; + + if (wattage > 0 && cadence > 0) { + CadenceResistancePair pair{cadence, resistance}; + wattageData[pair].addSample(wattage); + + if (wattageData[pair].sampleCount() >= WattageStats::MIN_SAMPLES_REQUIRED) { + updateDataTable(pair); + } } } double estimateWattage(uint16_t givenCadence, uint16_t givenResistance) { - QList filteredByResistance; - double minResDiff = std::numeric_limits::max(); - - // Initial filtering by resistance - for (const ergDataPoint& point : dataTable) { - double resDiff = std::abs(point.resistance - givenResistance); - if (resDiff < minResDiff) { - filteredByResistance.clear(); - filteredByResistance.append(point); - minResDiff = resDiff; - } else if (resDiff == minResDiff) { - filteredByResistance.append(point); + if (consolidatedData.isEmpty()) return 0; + + // Get all points with matching resistance + QList sameResPoints; + for (const auto& point : consolidatedData) { + if (point.resistance == givenResistance) { + sameResPoints.append(point); } } - // Fallback search if no close resistance match is found - if (filteredByResistance.isEmpty()) { - double minSimilarity = std::numeric_limits::max(); - ergDataPoint closestPoint; - - for (const ergDataPoint& point : dataTable) { - double cadenceDiff = std::abs(point.cadence - givenCadence); - double resDiff = std::abs(point.resistance - givenResistance); - // Weighted similarity measure: Giving more weight to resistance - double similarity = resDiff * 2 + cadenceDiff; + // If no exact resistance match, find closest resistance + if (sameResPoints.isEmpty()) { + uint16_t minResDiff = UINT16_MAX; + uint16_t closestRes = 0; - if (similarity < minSimilarity) { - minSimilarity = similarity; - closestPoint = point; + for (const auto& point : consolidatedData) { + uint16_t resDiff = abs(int(point.resistance) - int(givenResistance)); + if (resDiff < minResDiff) { + minResDiff = resDiff; + closestRes = point.resistance; } } - qDebug() << "case1" << closestPoint.wattage; - // Use the wattage of the closest match based on similarity - return closestPoint.wattage; + for (const auto& point : consolidatedData) { + if (point.resistance == closestRes) { + sameResPoints.append(point); + } + } } - // Find lower and upper points based on cadence within the filtered list - double lowerDiff = std::numeric_limits::max(); - double upperDiff = std::numeric_limits::max(); - ergDataPoint lowerPoint, upperPoint; - - for (const ergDataPoint& point : filteredByResistance) { - double cadenceDiff = std::abs(point.cadence - givenCadence); + // Find points for interpolation + double lowerWatts = 0, upperWatts = 0; + uint16_t lowerCadence = 0, upperCadence = 0; - if (point.cadence <= givenCadence && cadenceDiff < lowerDiff) { - lowerDiff = cadenceDiff; - lowerPoint = point; - } else if (point.cadence > givenCadence && cadenceDiff < upperDiff) { - upperDiff = cadenceDiff; - upperPoint = point; + for (const auto& point : sameResPoints) { + if (point.cadence <= givenCadence && point.cadence > lowerCadence) { + lowerWatts = point.wattage; + lowerCadence = point.cadence; + } + if (point.cadence >= givenCadence && (upperCadence == 0 || point.cadence < upperCadence)) { + upperWatts = point.wattage; + upperCadence = point.cadence; } } - double r; - - // Estimate wattage - if (lowerDiff != std::numeric_limits::max() && upperDiff != std::numeric_limits::max() && lowerDiff !=0 && upperDiff != 0) { - // Interpolation between lower and upper points - double cadenceRatio = 1.0; - if (upperPoint.cadence != lowerPoint.cadence) { // Avoid division by zero - cadenceRatio = (givenCadence - lowerPoint.cadence) / (double)(upperPoint.cadence - lowerPoint.cadence); - } - r = lowerPoint.wattage + (upperPoint.wattage - lowerPoint.wattage) * cadenceRatio; - //qDebug() << "case2" << r << lowerPoint.wattage << upperPoint.wattage << lowerPoint.cadence << upperPoint.cadence << cadenceRatio << lowerDiff << upperDiff; - return r; - } else if (lowerDiff == 0) { - //qDebug() << "case3" << lowerPoint.wattage; - return lowerPoint.wattage; - } else if (upperDiff == 0) { - //qDebug() << "case4" << upperPoint.wattage; - return upperPoint.wattage; - } else { - r = (lowerDiff < upperDiff) ? lowerPoint.wattage : upperPoint.wattage; - //qDebug() << "case5" << r; - // Use the closest point if only one match is found - return r; + // Interpolate or use closest value + if (lowerCadence != 0 && upperCadence != 0 && lowerCadence != upperCadence) { + double ratio = (givenCadence - lowerCadence) / double(upperCadence - lowerCadence); + return lowerWatts + ratio * (upperWatts - lowerWatts); + } else if (lowerCadence != 0) { + return lowerWatts; + } else if (upperCadence != 0) { + return upperWatts; } + + // Fallback to closest point + return sameResPoints.first().wattage; + } + + QList getConsolidatedData() const { + return consolidatedData; } + QMap getWattageData() const { + return wattageData; + } -private: - QList dataTable; + private: + QMap wattageData; + QList consolidatedData; uint16_t lastResistanceValue = 0xFFFF; QDateTime lastResistanceTime = QDateTime::currentDateTime(); - bool ergDataPointExists(uint16_t cadence, uint16_t wattage, uint16_t resistance) { - for (const ergDataPoint& point : dataTable) { - if (point.cadence == cadence && point.resistance == resistance && cadence != 0 && wattage != 0) { - return true; // Found duplicate + void updateDataTable(const CadenceResistancePair& pair) { + uint16_t medianWattage = wattageData[pair].getMedian(); + + // Remove existing point if it exists + for (int i = consolidatedData.size() - 1; i >= 0; --i) { + if (consolidatedData[i].cadence == pair.cadence && + consolidatedData[i].resistance == pair.resistance) { + consolidatedData.removeAt(i); + break; } } - return false; // No duplicate + + // Add new point + consolidatedData.append(ergDataPoint(pair.cadence, medianWattage, pair.resistance)); + qDebug() << "Added/Updated point:" + << "C:" << pair.cadence + << "W:" << medianWattage + << "R:" << pair.resistance; } void loadSettings() { QSettings settings; - QString data = settings.value(QZSettings::ergDataPoints, QZSettings::default_ergDataPoints).toString(); - QStringList dataList = data.split(";"); + QString data = settings.value(QZSettings::ergDataPoints, + QZSettings::default_ergDataPoints).toString(); + QStringList dataList = data.split(";", Qt::SkipEmptyParts); for (const QString& triple : dataList) { QStringList fields = triple.split("|"); @@ -159,29 +230,22 @@ class ergTable : public QObject { uint16_t cadence = fields[0].toUInt(); uint16_t wattage = fields[1].toUInt(); uint16_t resistance = fields[2].toUInt(); - - //qDebug() << "inputs.append(ergDataPoint(" << cadence << ", " << wattage << ", "<< resistance << "));"; - - dataTable.append(ergDataPoint(cadence, wattage, resistance)); + consolidatedData.append(ergDataPoint(cadence, wattage, resistance)); } } } void saveSettings() { QSettings settings; - QString data; - for (const ergDataPoint& point : dataTable) { - QString triple = QString::number(point.cadence) + "|" + QString::number(point.wattage) + "|" + QString::number(point.resistance); - data += triple + ";"; + QStringList dataStrings; + + for (const ergDataPoint& point : consolidatedData) { + dataStrings.append(QString("%1|%2|%3").arg(point.cadence) + .arg(point.wattage) + .arg(point.resistance)); } - settings.setValue(QZSettings::ergDataPoints, data); - } - void saveergDataPoint(const ergDataPoint& point) { - QSettings settings; - QString data = settings.value(QZSettings::ergDataPoints, QZSettings::default_ergDataPoints).toString(); - data += QString::number(point.cadence) + "|" + QString::number(point.wattage) + "|" + QString::number(point.resistance) + ";"; - settings.setValue(QZSettings::ergDataPoints, data); + settings.setValue(QZSettings::ergDataPoints, dataStrings.join(";")); } }; diff --git a/src/ergtable_test.h b/src/ergtable_test.h new file mode 100644 index 000000000..900f2c419 --- /dev/null +++ b/src/ergtable_test.h @@ -0,0 +1,321 @@ +#ifndef ERGTABLE_TEST_H +#define ERGTABLE_TEST_H + +#include "ergtable.h" +#include +#include +#include +#include +#include +#include + +struct ResistanceStats { + int samples = 0; + double totalWatts = 0; + uint16_t minWatts = UINT16_MAX; + uint16_t maxWatts = 0; + uint16_t minCadence = UINT16_MAX; + uint16_t maxCadence = 0; +}; + +class ergTableTester { + public: + struct TrainingDataPoint { + QDateTime timestamp; + uint16_t cadence; + uint16_t wattage; + uint16_t resistance; + bool isResistanceChange; + + TrainingDataPoint(const QDateTime& ts, uint16_t c, uint16_t w, uint16_t r, bool isResChange = false) + : timestamp(ts), cadence(c), wattage(w), resistance(r), isResistanceChange(isResChange) {} + }; + + static bool runAllTests() { + ergTableTester tester; + tester.testTrainingSession(); + return tester.testPowerEstimationTable(); + } + + private: + std::vector loadTrainingData(const QString& filename) { + std::vector data; + QFile file(filename); + + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "Error opening file:" << filename; + return data; + } + + QTextStream in(&file); + uint16_t lastCadence = 0; + uint16_t lastWattage = 0; + uint16_t currentResistance = 18; // Initial resistance + QDateTime lastTimestamp; + + QRegularExpression timeRegex("(\\d{2}:\\d{2}:\\d{2}) (\\d{4}) (\\d+)"); + QRegularExpression cadenceRegex("Cadence: (\\d+)"); + QRegularExpression wattRegex("watt: (\\d+)"); + QRegularExpression resistanceRegex("resistance: (\\d+)"); + + while (!in.atEnd()) { + QString line = in.readLine(); + + // Extract timestamp + QRegularExpressionMatch timeMatch = timeRegex.match(line); + if (timeMatch.hasMatch()) { + QString timeStr = timeMatch.captured(1); + QString yearStr = timeMatch.captured(2); + qint64 msecs = timeMatch.captured(3).toLongLong(); + lastTimestamp = QDateTime::fromString(timeStr + " " + yearStr, "hh:mm:ss yyyy"); + lastTimestamp = lastTimestamp.addMSecs(msecs % 1000); + } + + // Extract resistance changes + QRegularExpressionMatch resistanceMatch = resistanceRegex.match(line); + if (resistanceMatch.hasMatch()) { + uint16_t newResistance = resistanceMatch.captured(1).toUInt(); + if (newResistance != currentResistance) { + // Add resistance change point + data.emplace_back(lastTimestamp, lastCadence, lastWattage, newResistance, true); + currentResistance = newResistance; + qDebug() << "Resistance changed to:" << currentResistance + << "at" << lastTimestamp.toString("hh:mm:ss.zzz"); + } + } + + // Extract cadence + QRegularExpressionMatch cadenceMatch = cadenceRegex.match(line); + if (cadenceMatch.hasMatch()) { + lastCadence = cadenceMatch.captured(1).toUInt(); + } + + // Extract wattage + QRegularExpressionMatch wattMatch = wattRegex.match(line); + if (wattMatch.hasMatch()) { + lastWattage = wattMatch.captured(1).toUInt(); + if (lastTimestamp.isValid()) { + data.emplace_back(lastTimestamp, lastCadence, lastWattage, currentResistance, false); + } + } + } + + file.close(); + + // Sort by timestamp + std::sort(data.begin(), data.end(), + [](const TrainingDataPoint& a, const TrainingDataPoint& b) { + return a.timestamp < b.timestamp; + }); + + return data; + } + + bool testPowerEstimationTable() { + qDebug() << "\nTesting power estimation table..."; + ergTable table; + table.reset(); + + // First load and process the training data to populate the table + auto trainingData = loadTrainingData("c:/powertraining.txt"); + QDateTime lastResistanceChange; + QDateTime lastTimestamp; + + // Process all training data to populate the table + for (const auto& point : trainingData) { + // Calculate and simulate real time delta + if (!lastTimestamp.isNull()) { + qint64 msSinceLastSample = lastTimestamp.msecsTo(point.timestamp); + QThread::msleep(std::min(static_cast(10), msSinceLastSample)); + } + lastTimestamp = point.timestamp; + + if (point.isResistanceChange) { + lastResistanceChange = point.timestamp; + continue; + } + + // Skip data points too close to resistance changes + if (!lastResistanceChange.isNull()) { + qint64 msSinceResistanceChange = lastResistanceChange.msecsTo(point.timestamp); + if (msSinceResistanceChange < 1000) { + continue; + } + } + + // Populate the table with training data + table.collectData(point.cadence, point.wattage, point.resistance); + } + + // Now create formatted table header + qDebug() << "\nPower Estimation Table (Watts)"; + qDebug() << "Cadence | R25 | R26 | R27 | R28 | R29 | R30 | R31 | R32"; + qDebug() << "--------|-------|-------|-------|-------|-------|-------|-------|-------"; + + // Generate table for cadences 50-80 + for (uint16_t cadence = 50; cadence <= 80; cadence += 2) { + QString line = QString("%1").arg(cadence, 7); + line += " |"; + + // Test each resistance level + for (uint16_t resistance = 25; resistance <= 32; resistance++) { + double watts = table.estimateWattage(cadence, resistance); + // Format with 1 decimal place and right-aligned in 6 chars + separator + line += QString(" %1 |").arg(QString::number(watts, 'f', 1), 6); + } + qDebug().noquote() << line; + } + + // Analyze some specific power targets + qDebug() << "\nPower Target Analysis:"; + QVector powerTargets = {200, 250, 300, 350, 400}; + + qDebug() << "Target | Cadence 60 | Cadence 70 | Cadence 80"; + qDebug() << "-------|--------------|--------------|-------------"; + + for (int targetPower : powerTargets) { + QString line = QString("%1W").arg(targetPower, 6); + line += " |"; + + // Find closest resistance for different cadences + for (uint16_t cadence : {60, 70, 80}) { + uint16_t bestResistance = 25; + double closestPower = table.estimateWattage(cadence, bestResistance); + double minDiff = std::abs(closestPower - targetPower); + + // Try each resistance level + for (uint16_t r = 26; r <= 32; r++) { + double power = table.estimateWattage(cadence, r); + double diff = std::abs(power - targetPower); + if (diff < minDiff) { + minDiff = diff; + closestPower = power; + bestResistance = r; + } + } + + // Add to output: "R28 (245W)" + line += QString(" R%1 (%2W) |") + .arg(bestResistance) + .arg(qRound(closestPower)); + } + qDebug().noquote() << line; + } + + // Print some stats about the collected data + auto consolidatedData = table.getConsolidatedData(); + qDebug() << "\nTable populated with" << consolidatedData.size() << "data points"; + + // Print min/max values found in consolidated data + if (!consolidatedData.isEmpty()) { + uint16_t minCadence = UINT16_MAX, maxCadence = 0; + uint16_t minResistance = UINT16_MAX, maxResistance = 0; + uint16_t minWatts = UINT16_MAX, maxWatts = 0; + + for (const auto& point : consolidatedData) { + minCadence = std::min(minCadence, point.cadence); + maxCadence = std::max(maxCadence, point.cadence); + minResistance = std::min(minResistance, point.resistance); + maxResistance = std::max(maxResistance, point.resistance); + minWatts = std::min(minWatts, point.wattage); + maxWatts = std::max(maxWatts, point.wattage); + } + + qDebug() << "Data ranges:"; + qDebug() << " Cadence:" << minCadence << "-" << maxCadence << "RPM"; + qDebug() << " Resistance:" << minResistance << "-" << maxResistance; + qDebug() << " Power:" << minWatts << "-" << maxWatts << "W"; + } + + return true; + } + + bool testTrainingSession() { + qDebug() << "Testing with real training session data..."; + ergTable table; + table.reset(); + + auto trainingData = loadTrainingData("c:/powertraining.txt"); + qDebug() << "Loaded" << trainingData.size() << "data points"; + + std::map resistanceStats; + QDateTime lastTimestamp; + QDateTime lastResistanceChange; + + for (const auto& point : trainingData) { + // Calculate real time delta and simulate it + if (!lastTimestamp.isNull()) { + qint64 msSinceLastSample = lastTimestamp.msecsTo(point.timestamp); + if (msSinceLastSample > 2000) { + qDebug() << "Time gap:" << msSinceLastSample << "ms at" + << point.timestamp.toString("hh:mm:ss.zzz"); + } + + // If this is a resistance change, update the timestamp + if (point.isResistanceChange) { + lastResistanceChange = point.timestamp; + } + + // Check if enough time has passed since last resistance change + if (!point.isResistanceChange && !lastResistanceChange.isNull()) { + qint64 msSinceResistanceChange = lastResistanceChange.msecsTo(point.timestamp); + if (msSinceResistanceChange < 1000) { + qDebug() << "Skipping data point due to recent resistance change" + << "Time since change:" << msSinceResistanceChange << "ms"; + continue; + } + } + + // Simulate the actual delay + QThread::msleep(std::min(static_cast(10), msSinceLastSample)); + } + lastTimestamp = point.timestamp; + + // Process the data point + table.collectData(point.cadence, point.wattage, point.resistance); + + // Update statistics + if (point.wattage > 0 && !point.isResistanceChange) { + auto& stats = resistanceStats[point.resistance]; + stats.samples++; + stats.totalWatts += point.wattage; + stats.minWatts = std::min(stats.minWatts, point.wattage); + stats.maxWatts = std::max(stats.maxWatts, point.wattage); + stats.minCadence = std::min(stats.minCadence, point.cadence); + stats.maxCadence = std::max(stats.maxCadence, point.cadence); + + if (stats.samples > 10) { + double estimated = table.estimateWattage(point.cadence, point.resistance); + double error = std::abs(estimated - point.wattage); + double errorPercent = (error / point.wattage) * 100.0; + if (errorPercent > 20) { + qDebug() << "High error at" << point.timestamp.toString("hh:mm:ss.zzz") + << "R:" << point.resistance + << "C:" << point.cadence + << "Est:" << estimated + << "Act:" << point.wattage + << "Error:" << QString::number(errorPercent, 'f', 1) << "%"; + } + } + } + } + + // Print final statistics + qDebug() << "\nResistance level statistics:"; + for (const auto& [resistance, stats] : resistanceStats) { + double avgWatts = stats.totalWatts / stats.samples; + qDebug() << "Resistance" << resistance << ":"; + qDebug() << " Samples:" << stats.samples; + qDebug() << " Power range:" << stats.minWatts << "-" << stats.maxWatts + << "W (avg:" << QString::number(avgWatts, 'f', 1) << "W)"; + qDebug() << " Cadence range:" << stats.minCadence << "-" << stats.maxCadence << "RPM"; + } + + auto finalData = table.getConsolidatedData(); + qDebug() << "\nFinal consolidated data points:" << finalData.size(); + + return true; + } +}; + +#endif // ERGTABLE_TEST_H diff --git a/src/main.cpp b/src/main.cpp index 0e8d63a52..0bb6f98c0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -41,6 +41,7 @@ #include "ios/lockscreen.h" #endif +#include "ergtable_test.h" #include "handleurl.h" bool logs = true; @@ -336,6 +337,8 @@ int main(int argc, char *argv[]) { app->setOrganizationDomain(QStringLiteral("robertoviola.cloud")); app->setApplicationName(QStringLiteral("qDomyos-Zwift")); + ergTableTester::runAllTests(); + QSettings settings; #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) diff --git a/src/qdomyos-zwift.pro b/src/qdomyos-zwift.pro index 7ee740969..1360e27ac 100644 --- a/src/qdomyos-zwift.pro +++ b/src/qdomyos-zwift.pro @@ -1 +1,4 @@ include(qdomyos-zwift.pri) + +HEADERS += \ + ergtable_test.h