From 993b357f827d49027ecf2ef95ef17d75b76414ee Mon Sep 17 00:00:00 2001 From: Roberto Viola Date: Mon, 20 Jul 2026 16:51:00 +0200 Subject: [PATCH] feat: enrich FIT file with zones_target, training effect and session stats Fixes Garmin Connect Endurance Score incorrectly decreasing after QZ sessions. Root cause: Garmin Connect couldn't classify the load because the FIT file lacked user physiology context and calculated metrics. Changes: - Add zones_target message (FTP, max HR, threshold HR, calc types) so Garmin Connect knows the user's HR zones and power zones - Add SetDefaultMaxHeartRate, SetRestingHeartRate, SetHeight to userMesg - Calculate aerobic + anaerobic Training Effect from HR zone distribution and power vs FTP; write to session as total_training_effect / total_anaerobic_training_effect - Write avg/max HR, avg/max power, Normalized Power, total work, avg/max cadence, max speed to session message - Extend cadence and power stats collection from JUMPROPE-only to all device types; compute NP via 30-second rolling 4th-power mean - Refactor TRIMP block to reuse shared user_max_hr / user_resting_hr variables (no functional change to TRIMP calculation) Co-Authored-By: Claude Sonnet 4.6 --- src/qfit.cpp | 186 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 160 insertions(+), 26 deletions(-) diff --git a/src/qfit.cpp b/src/qfit.cpp index c349d85a09..6fe88cd360 100644 --- a/src/qfit.cpp +++ b/src/qfit.cpp @@ -18,6 +18,7 @@ #include "fit_developer_field.hpp" #include "fit_mesg_broadcaster.hpp" #include "fit_timestamp_correlation_mesg.hpp" +#include "fit_zones_target_mesg.hpp" #ifdef _WIN32 #include @@ -111,6 +112,16 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T } fileIdMesg.SetTimeCreated(session.at(firstRealIndex).time.toSecsSinceEpoch() - 631065600L); + // Compute user physiology once — shared by userMesg, zones_target, and training effect. + bool user_max_hr_override = settings.value(QZSettings::heart_max_override_enable, + QZSettings::default_heart_max_override_enable).toBool(); + uint8_t user_max_hr = user_max_hr_override + ? (uint8_t)settings.value(QZSettings::heart_max_override_value, + QZSettings::default_heart_max_override_value).toUInt() + : (uint8_t)(220 - settings.value(QZSettings::age, QZSettings::default_age).toUInt()); + uint8_t user_resting_hr = settings.value(QZSettings::heart_rate_resting, + QZSettings::default_heart_rate_resting).toUInt(); + fit::UserProfileMesg userMesg; userMesg.SetWeight(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat()); userMesg.SetAge(settings.value(QZSettings::age, QZSettings::default_age).toUInt()); @@ -118,6 +129,9 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T : FIT_GENDER_FEMALE); userMesg.SetFriendlyName( settings.value(QZSettings::user_nickname, QZSettings::default_user_nickname).toString().toStdWString()); + userMesg.SetHeight(settings.value(QZSettings::height, QZSettings::default_height).toFloat() / 100.0f); + userMesg.SetDefaultMaxHeartRate(user_max_hr); + userMesg.SetRestingHeartRate(user_resting_hr); fit::FileCreatorMesg fileCreatorMesg; if(fit_file_garmin_device_training_effect) { @@ -169,10 +183,14 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T double watt_sum = 0; int watt_count = 0; - // Variables for jump rope cadence + // Cadence, power, and speed summaries (all device types) double cadence_sum = 0; int cadence_count = 0; uint8_t max_cadence = 0; + uint16_t max_watt = 0; + uint32_t total_work_joules = 0; + double max_speed_ms = 0; + std::vector np_power_samples; // Variables for core temperature summaries used by Garmin Connect. double core_temp_sum = 0; @@ -217,21 +235,29 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T } } - // Collect power data for TSS calculation + // Collect power data for TSS / NP / session stats if (session.at(i).watt > 0) { watt_sum += session.at(i).watt; watt_count++; + if ((uint16_t)session.at(i).watt > max_watt) + max_watt = (uint16_t)session.at(i).watt; + total_work_joules += (uint32_t)session.at(i).watt; } + np_power_samples.push_back(session.at(i).watt > 0 ? session.at(i).watt : 0.0); - // Collect cadence data for jump rope - if (type == JUMPROPE && session.at(i).cadence > 0) { + // Collect cadence data (all device types) + if (session.at(i).cadence > 0) { cadence_sum += session.at(i).cadence; cadence_count++; - if (session.at(i).cadence > max_cadence) { + if (session.at(i).cadence > max_cadence) max_cadence = session.at(i).cadence; - } } + // Max speed (m/s — session.speed is in km/h) + double speed_ms = session.at(i).speed / 3.6; + if (speed_ms > max_speed_ms) + max_speed_ms = speed_ms; + if (session.at(i).coreTemp > 0) { double coreTemp = session.at(i).coreTemp; core_temp_sum += coreTemp; @@ -282,22 +308,6 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T double avg_hr = hr_sum / hr_count; uint32_t duration_minutes = duration_seconds / 60; - // Get max HR: use override if enabled, otherwise calculate from age - bool max_hr_override_enabled = settings.value(QZSettings::heart_max_override_enable, - QZSettings::default_heart_max_override_enable).toBool(); - uint8_t max_hr; - if (max_hr_override_enabled) { - max_hr = settings.value(QZSettings::heart_max_override_value, - QZSettings::default_heart_max_override_value).toUInt(); - } else { - uint8_t user_age = settings.value(QZSettings::age, QZSettings::default_age).toUInt(); - max_hr = 220 - user_age; - } - - // Get resting HR from settings - uint8_t resting_hr = settings.value(QZSettings::heart_rate_resting, - QZSettings::default_heart_rate_resting).toUInt(); - // Bannister's TRIMP formula: D * HR_ratio * exp(b * HR_ratio) // where HR_ratio = (avg_hr - resting_hr) / (max_hr - resting_hr) // @@ -308,8 +318,8 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T // We use b = 1.67 for everyone to ensure compatibility with Garmin Connect's // acute training load and training status features. double hr_ratio = 0; - if (max_hr > resting_hr) { - hr_ratio = (avg_hr - resting_hr) / (double)(max_hr - resting_hr); + if (user_max_hr > user_resting_hr) { + hr_ratio = (avg_hr - user_resting_hr) / (double)(user_max_hr - user_resting_hr); } // Use coefficient 1.67 (matches Garmin implementation) @@ -321,15 +331,113 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T qDebug() << "Training Load (TRIMP) calculated:" << training_load << "Duration:" << duration_minutes << "min" << "Avg HR:" << avg_hr - << "Max HR:" << max_hr << (max_hr_override_enabled ? "(override)" : "(calculated)") - << "Resting HR:" << resting_hr; + << "Max HR:" << user_max_hr << (user_max_hr_override ? "(override)" : "(calculated)") + << "Resting HR:" << user_resting_hr; } } + // Normalized Power: 30-second rolling average → 4th-power mean → 4th root + uint16_t normalized_power = 0; + if (np_power_samples.size() >= 30) { + std::vector rolling30; + rolling30.reserve(np_power_samples.size()); + for (size_t idx = 0; idx < np_power_samples.size(); idx++) { + double sum = 0; + size_t start = idx >= 29 ? idx - 29 : 0; + for (size_t j = start; j <= idx; j++) + sum += np_power_samples[j]; + rolling30.push_back(sum / (idx - start + 1)); + } + double sum4 = 0; + for (double v : rolling30) + sum4 += std::pow(v, 4.0); + normalized_power = (uint16_t)std::pow(sum4 / rolling30.size(), 0.25); + } + + // Training Effect (aerobic + anaerobic) from HR zones and power + float aerobic_te = 0.0f; + float anaerobic_te = 0.0f; + if (hr_count > 0 && user_max_hr > user_resting_hr) { + float zone1_pct = settings.value(QZSettings::heart_rate_zone1, QZSettings::default_heart_rate_zone1).toFloat(); + float zone2_pct = settings.value(QZSettings::heart_rate_zone2, QZSettings::default_heart_rate_zone2).toFloat(); + float zone3_pct = settings.value(QZSettings::heart_rate_zone3, QZSettings::default_heart_rate_zone3).toFloat(); + float zone4_pct = settings.value(QZSettings::heart_rate_zone4, QZSettings::default_heart_rate_zone4).toFloat(); + double z1 = zone1_pct / 100.0 * user_max_hr; + double z2 = zone2_pct / 100.0 * user_max_hr; + double z3 = zone3_pct / 100.0 * user_max_hr; + double z4 = zone4_pct / 100.0 * user_max_hr; + + double time_in_zone[5] = {0, 0, 0, 0, 0}; + for (int i = firstRealIndex; i < session.length(); i++) { + double hr = session.at(i).heart; + if (hr <= 0) continue; + if (hr < z1) time_in_zone[0] += 1.0; + else if (hr < z2) time_in_zone[1] += 1.0; + else if (hr < z3) time_in_zone[2] += 1.0; + else if (hr < z4) time_in_zone[3] += 1.0; + else time_in_zone[4] += 1.0; + } + + // Aerobic TE: weighted zone time / duration, scaled to 0-5 Garmin range + const double zone_weights[5] = {0.2, 0.5, 1.0, 1.5, 2.0}; + double weighted = 0; + for (int z = 0; z < 5; z++) + weighted += (time_in_zone[z] / 60.0) * zone_weights[z]; + double dur_min = duration_seconds / 60.0; + if (dur_min > 0) { + aerobic_te = std::min(5.0f, (float)((weighted / dur_min) * 2.0)); + qDebug() << "Aerobic TE:" << aerobic_te + << "Z1-Z5 min:" << time_in_zone[0]/60 << time_in_zone[1]/60 + << time_in_zone[2]/60 << time_in_zone[3]/60 << time_in_zone[4]/60; + } + + // Anaerobic TE: from power if available, else from time in Z4+Z5 + if (watt_count > 0) { + float ftp = settings.value(QZSettings::ftp, QZSettings::default_ftp).toFloat(); + if (ftp > 0) { + double threshold_w = ftp * 1.05; + double time_above = 0, intensity_above = 0; + for (int i = firstRealIndex; i < session.length(); i++) { + if (session.at(i).watt > threshold_w) { + time_above += 1.0; + intensity_above += session.at(i).watt / threshold_w; + } + } + if (time_above > 0 && dur_min > 0) { + double pct = (time_above / duration_seconds) * 100.0; + anaerobic_te = std::min(5.0f, (float)((pct / 20.0) * (intensity_above / time_above))); + } + } + } else { + double high_intensity = time_in_zone[3] + time_in_zone[4]; + if (high_intensity > 0 && dur_min > 0) { + double pct = (high_intensity / duration_seconds) * 100.0; + anaerobic_te = std::min(5.0f, (float)((pct / 25.0) * 2.0)); + if (time_in_zone[4] > time_in_zone[3]) + anaerobic_te = std::min(5.0f, anaerobic_te * 1.3f); + } + } + qDebug() << "Anaerobic TE:" << anaerobic_te; + } + encode.Open(file); encode.Write(fileIdMesg); encode.Write(userMesg); + // zones_target: gives Garmin Connect the user's FTP and HR zones for load calculations + { + fit::ZonesTargetMesg zonesTargetMesg; + zonesTargetMesg.SetMaxHeartRate(user_max_hr); + float zone3_pct = settings.value(QZSettings::heart_rate_zone3, QZSettings::default_heart_rate_zone3).toFloat(); + zonesTargetMesg.SetThresholdHeartRate((uint8_t)(zone3_pct / 100.0f * user_max_hr)); + uint16_t ftp = (uint16_t)settings.value(QZSettings::ftp, QZSettings::default_ftp).toFloat(); + if (ftp > 0) + zonesTargetMesg.SetFunctionalThresholdPower(ftp); + zonesTargetMesg.SetHrCalcType(FIT_HR_ZONE_CALC_PERCENT_MAX_HR); + zonesTargetMesg.SetPwrCalcType(FIT_PWR_ZONE_CALC_PERCENT_FTP); + encode.Write(zonesTargetMesg); + } + // Declare developer field descriptions (but don't write them yet) fit::FieldDescriptionMesg activityTitle; activityTitle.SetDeveloperDataIndex(0); @@ -523,6 +631,32 @@ void qfit::save(const QString &filename, QList session, BLUETOOTH_T qDebug() << "overriding FIT sport to" << overrideSport << "keeping subsport from device type"; } + // Session statistics derived from record data + if (hr_count > 0) { + sessionMesg.SetAvgHeartRate((uint8_t)(hr_sum / hr_count)); + sessionMesg.SetMaxHeartRate(max_hr); + sessionMesg.SetMinHeartRate(min_hr); + } + if (cadence_count > 0 && type != ROWING) { + sessionMesg.SetAvgCadence((uint8_t)(cadence_sum / cadence_count)); + sessionMesg.SetMaxCadence(max_cadence); + } + if (watt_count > 0) { + sessionMesg.SetAvgPower((uint16_t)(watt_sum / watt_count)); + sessionMesg.SetMaxPower(max_watt); + if (normalized_power > 0) + sessionMesg.SetNormalizedPower(normalized_power); + if (total_work_joules > 0) + sessionMesg.SetTotalWork(total_work_joules); + } + if (max_speed_ms > 0) { + sessionMesg.SetEnhancedMaxSpeed((float)max_speed_ms); + } + if (aerobic_te > 0) { + sessionMesg.SetTotalTrainingEffect(aerobic_te); + sessionMesg.SetTotalAnaerobicTrainingEffect(anaerobic_te); + } + fit::DeveloperDataIdMesg devIdMesg; // QZ companion app // 3746ce54-a9e9-42b0-9be9-b867f8d20f7d