Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 160 additions & 26 deletions src/qfit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <io.h>
Expand Down Expand Up @@ -111,13 +112,26 @@ void qfit::save(const QString &filename, QList<SessionLine> 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());
userMesg.SetGender(settings.value(QZSettings::sex, QZSettings::default_sex).toString().startsWith(QZSettings::default_sex) ? FIT_GENDER_MALE
: 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) {
Expand Down Expand Up @@ -169,10 +183,14 @@ void qfit::save(const QString &filename, QList<SessionLine> 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<double> np_power_samples;

// Variables for core temperature summaries used by Garmin Connect.
double core_temp_sum = 0;
Expand Down Expand Up @@ -217,21 +235,29 @@ void qfit::save(const QString &filename, QList<SessionLine> 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;
Expand Down Expand Up @@ -282,22 +308,6 @@ void qfit::save(const QString &filename, QList<SessionLine> 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)
//
Expand All @@ -308,8 +318,8 @@ void qfit::save(const QString &filename, QList<SessionLine> 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)
Expand All @@ -321,15 +331,113 @@ void qfit::save(const QString &filename, QList<SessionLine> 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<double> 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);
Expand Down Expand Up @@ -523,6 +631,32 @@ void qfit::save(const QString &filename, QList<SessionLine> 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
Expand Down
Loading