Skip to content

Commit f544d8b

Browse files
authored
feat: enrich FIT file with zones_target, training effect and session stats (#4169)
1 parent b902555 commit f544d8b

1 file changed

Lines changed: 160 additions & 26 deletions

File tree

src/qfit.cpp

Lines changed: 160 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "fit_developer_field.hpp"
1919
#include "fit_mesg_broadcaster.hpp"
2020
#include "fit_timestamp_correlation_mesg.hpp"
21+
#include "fit_zones_target_mesg.hpp"
2122

2223
#ifdef _WIN32
2324
#include <io.h>
@@ -111,13 +112,26 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
111112
}
112113
fileIdMesg.SetTimeCreated(session.at(firstRealIndex).time.toSecsSinceEpoch() - 631065600L);
113114

115+
// Compute user physiology once — shared by userMesg, zones_target, and training effect.
116+
bool user_max_hr_override = settings.value(QZSettings::heart_max_override_enable,
117+
QZSettings::default_heart_max_override_enable).toBool();
118+
uint8_t user_max_hr = user_max_hr_override
119+
? (uint8_t)settings.value(QZSettings::heart_max_override_value,
120+
QZSettings::default_heart_max_override_value).toUInt()
121+
: (uint8_t)(220 - settings.value(QZSettings::age, QZSettings::default_age).toUInt());
122+
uint8_t user_resting_hr = settings.value(QZSettings::heart_rate_resting,
123+
QZSettings::default_heart_rate_resting).toUInt();
124+
114125
fit::UserProfileMesg userMesg;
115126
userMesg.SetWeight(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat());
116127
userMesg.SetAge(settings.value(QZSettings::age, QZSettings::default_age).toUInt());
117128
userMesg.SetGender(settings.value(QZSettings::sex, QZSettings::default_sex).toString().startsWith(QZSettings::default_sex) ? FIT_GENDER_MALE
118129
: FIT_GENDER_FEMALE);
119130
userMesg.SetFriendlyName(
120131
settings.value(QZSettings::user_nickname, QZSettings::default_user_nickname).toString().toStdWString());
132+
userMesg.SetHeight(settings.value(QZSettings::height, QZSettings::default_height).toFloat() / 100.0f);
133+
userMesg.SetDefaultMaxHeartRate(user_max_hr);
134+
userMesg.SetRestingHeartRate(user_resting_hr);
121135

122136
fit::FileCreatorMesg fileCreatorMesg;
123137
if(fit_file_garmin_device_training_effect) {
@@ -169,10 +183,14 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
169183
double watt_sum = 0;
170184
int watt_count = 0;
171185

172-
// Variables for jump rope cadence
186+
// Cadence, power, and speed summaries (all device types)
173187
double cadence_sum = 0;
174188
int cadence_count = 0;
175189
uint8_t max_cadence = 0;
190+
uint16_t max_watt = 0;
191+
uint32_t total_work_joules = 0;
192+
double max_speed_ms = 0;
193+
std::vector<double> np_power_samples;
176194

177195
// Variables for core temperature summaries used by Garmin Connect.
178196
double core_temp_sum = 0;
@@ -217,21 +235,29 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
217235
}
218236
}
219237

220-
// Collect power data for TSS calculation
238+
// Collect power data for TSS / NP / session stats
221239
if (session.at(i).watt > 0) {
222240
watt_sum += session.at(i).watt;
223241
watt_count++;
242+
if ((uint16_t)session.at(i).watt > max_watt)
243+
max_watt = (uint16_t)session.at(i).watt;
244+
total_work_joules += (uint32_t)session.at(i).watt;
224245
}
246+
np_power_samples.push_back(session.at(i).watt > 0 ? session.at(i).watt : 0.0);
225247

226-
// Collect cadence data for jump rope
227-
if (type == JUMPROPE && session.at(i).cadence > 0) {
248+
// Collect cadence data (all device types)
249+
if (session.at(i).cadence > 0) {
228250
cadence_sum += session.at(i).cadence;
229251
cadence_count++;
230-
if (session.at(i).cadence > max_cadence) {
252+
if (session.at(i).cadence > max_cadence)
231253
max_cadence = session.at(i).cadence;
232-
}
233254
}
234255

256+
// Max speed (m/s — session.speed is in km/h)
257+
double speed_ms = session.at(i).speed / 3.6;
258+
if (speed_ms > max_speed_ms)
259+
max_speed_ms = speed_ms;
260+
235261
if (session.at(i).coreTemp > 0) {
236262
double coreTemp = session.at(i).coreTemp;
237263
core_temp_sum += coreTemp;
@@ -282,22 +308,6 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
282308
double avg_hr = hr_sum / hr_count;
283309
uint32_t duration_minutes = duration_seconds / 60;
284310

285-
// Get max HR: use override if enabled, otherwise calculate from age
286-
bool max_hr_override_enabled = settings.value(QZSettings::heart_max_override_enable,
287-
QZSettings::default_heart_max_override_enable).toBool();
288-
uint8_t max_hr;
289-
if (max_hr_override_enabled) {
290-
max_hr = settings.value(QZSettings::heart_max_override_value,
291-
QZSettings::default_heart_max_override_value).toUInt();
292-
} else {
293-
uint8_t user_age = settings.value(QZSettings::age, QZSettings::default_age).toUInt();
294-
max_hr = 220 - user_age;
295-
}
296-
297-
// Get resting HR from settings
298-
uint8_t resting_hr = settings.value(QZSettings::heart_rate_resting,
299-
QZSettings::default_heart_rate_resting).toUInt();
300-
301311
// Bannister's TRIMP formula: D * HR_ratio * exp(b * HR_ratio)
302312
// where HR_ratio = (avg_hr - resting_hr) / (max_hr - resting_hr)
303313
//
@@ -308,8 +318,8 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
308318
// We use b = 1.67 for everyone to ensure compatibility with Garmin Connect's
309319
// acute training load and training status features.
310320
double hr_ratio = 0;
311-
if (max_hr > resting_hr) {
312-
hr_ratio = (avg_hr - resting_hr) / (double)(max_hr - resting_hr);
321+
if (user_max_hr > user_resting_hr) {
322+
hr_ratio = (avg_hr - user_resting_hr) / (double)(user_max_hr - user_resting_hr);
313323
}
314324

315325
// Use coefficient 1.67 (matches Garmin implementation)
@@ -321,15 +331,113 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
321331
qDebug() << "Training Load (TRIMP) calculated:" << training_load
322332
<< "Duration:" << duration_minutes << "min"
323333
<< "Avg HR:" << avg_hr
324-
<< "Max HR:" << max_hr << (max_hr_override_enabled ? "(override)" : "(calculated)")
325-
<< "Resting HR:" << resting_hr;
334+
<< "Max HR:" << user_max_hr << (user_max_hr_override ? "(override)" : "(calculated)")
335+
<< "Resting HR:" << user_resting_hr;
326336
}
327337
}
328338

339+
// Normalized Power: 30-second rolling average → 4th-power mean → 4th root
340+
uint16_t normalized_power = 0;
341+
if (np_power_samples.size() >= 30) {
342+
std::vector<double> rolling30;
343+
rolling30.reserve(np_power_samples.size());
344+
for (size_t idx = 0; idx < np_power_samples.size(); idx++) {
345+
double sum = 0;
346+
size_t start = idx >= 29 ? idx - 29 : 0;
347+
for (size_t j = start; j <= idx; j++)
348+
sum += np_power_samples[j];
349+
rolling30.push_back(sum / (idx - start + 1));
350+
}
351+
double sum4 = 0;
352+
for (double v : rolling30)
353+
sum4 += std::pow(v, 4.0);
354+
normalized_power = (uint16_t)std::pow(sum4 / rolling30.size(), 0.25);
355+
}
356+
357+
// Training Effect (aerobic + anaerobic) from HR zones and power
358+
float aerobic_te = 0.0f;
359+
float anaerobic_te = 0.0f;
360+
if (hr_count > 0 && user_max_hr > user_resting_hr) {
361+
float zone1_pct = settings.value(QZSettings::heart_rate_zone1, QZSettings::default_heart_rate_zone1).toFloat();
362+
float zone2_pct = settings.value(QZSettings::heart_rate_zone2, QZSettings::default_heart_rate_zone2).toFloat();
363+
float zone3_pct = settings.value(QZSettings::heart_rate_zone3, QZSettings::default_heart_rate_zone3).toFloat();
364+
float zone4_pct = settings.value(QZSettings::heart_rate_zone4, QZSettings::default_heart_rate_zone4).toFloat();
365+
double z1 = zone1_pct / 100.0 * user_max_hr;
366+
double z2 = zone2_pct / 100.0 * user_max_hr;
367+
double z3 = zone3_pct / 100.0 * user_max_hr;
368+
double z4 = zone4_pct / 100.0 * user_max_hr;
369+
370+
double time_in_zone[5] = {0, 0, 0, 0, 0};
371+
for (int i = firstRealIndex; i < session.length(); i++) {
372+
double hr = session.at(i).heart;
373+
if (hr <= 0) continue;
374+
if (hr < z1) time_in_zone[0] += 1.0;
375+
else if (hr < z2) time_in_zone[1] += 1.0;
376+
else if (hr < z3) time_in_zone[2] += 1.0;
377+
else if (hr < z4) time_in_zone[3] += 1.0;
378+
else time_in_zone[4] += 1.0;
379+
}
380+
381+
// Aerobic TE: weighted zone time / duration, scaled to 0-5 Garmin range
382+
const double zone_weights[5] = {0.2, 0.5, 1.0, 1.5, 2.0};
383+
double weighted = 0;
384+
for (int z = 0; z < 5; z++)
385+
weighted += (time_in_zone[z] / 60.0) * zone_weights[z];
386+
double dur_min = duration_seconds / 60.0;
387+
if (dur_min > 0) {
388+
aerobic_te = std::min(5.0f, (float)((weighted / dur_min) * 2.0));
389+
qDebug() << "Aerobic TE:" << aerobic_te
390+
<< "Z1-Z5 min:" << time_in_zone[0]/60 << time_in_zone[1]/60
391+
<< time_in_zone[2]/60 << time_in_zone[3]/60 << time_in_zone[4]/60;
392+
}
393+
394+
// Anaerobic TE: from power if available, else from time in Z4+Z5
395+
if (watt_count > 0) {
396+
float ftp = settings.value(QZSettings::ftp, QZSettings::default_ftp).toFloat();
397+
if (ftp > 0) {
398+
double threshold_w = ftp * 1.05;
399+
double time_above = 0, intensity_above = 0;
400+
for (int i = firstRealIndex; i < session.length(); i++) {
401+
if (session.at(i).watt > threshold_w) {
402+
time_above += 1.0;
403+
intensity_above += session.at(i).watt / threshold_w;
404+
}
405+
}
406+
if (time_above > 0 && dur_min > 0) {
407+
double pct = (time_above / duration_seconds) * 100.0;
408+
anaerobic_te = std::min(5.0f, (float)((pct / 20.0) * (intensity_above / time_above)));
409+
}
410+
}
411+
} else {
412+
double high_intensity = time_in_zone[3] + time_in_zone[4];
413+
if (high_intensity > 0 && dur_min > 0) {
414+
double pct = (high_intensity / duration_seconds) * 100.0;
415+
anaerobic_te = std::min(5.0f, (float)((pct / 25.0) * 2.0));
416+
if (time_in_zone[4] > time_in_zone[3])
417+
anaerobic_te = std::min(5.0f, anaerobic_te * 1.3f);
418+
}
419+
}
420+
qDebug() << "Anaerobic TE:" << anaerobic_te;
421+
}
422+
329423
encode.Open(file);
330424
encode.Write(fileIdMesg);
331425
encode.Write(userMesg);
332426

427+
// zones_target: gives Garmin Connect the user's FTP and HR zones for load calculations
428+
{
429+
fit::ZonesTargetMesg zonesTargetMesg;
430+
zonesTargetMesg.SetMaxHeartRate(user_max_hr);
431+
float zone3_pct = settings.value(QZSettings::heart_rate_zone3, QZSettings::default_heart_rate_zone3).toFloat();
432+
zonesTargetMesg.SetThresholdHeartRate((uint8_t)(zone3_pct / 100.0f * user_max_hr));
433+
uint16_t ftp = (uint16_t)settings.value(QZSettings::ftp, QZSettings::default_ftp).toFloat();
434+
if (ftp > 0)
435+
zonesTargetMesg.SetFunctionalThresholdPower(ftp);
436+
zonesTargetMesg.SetHrCalcType(FIT_HR_ZONE_CALC_PERCENT_MAX_HR);
437+
zonesTargetMesg.SetPwrCalcType(FIT_PWR_ZONE_CALC_PERCENT_FTP);
438+
encode.Write(zonesTargetMesg);
439+
}
440+
333441
// Declare developer field descriptions (but don't write them yet)
334442
fit::FieldDescriptionMesg activityTitle;
335443
activityTitle.SetDeveloperDataIndex(0);
@@ -523,6 +631,32 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
523631
qDebug() << "overriding FIT sport to" << overrideSport << "keeping subsport from device type";
524632
}
525633

634+
// Session statistics derived from record data
635+
if (hr_count > 0) {
636+
sessionMesg.SetAvgHeartRate((uint8_t)(hr_sum / hr_count));
637+
sessionMesg.SetMaxHeartRate(max_hr);
638+
sessionMesg.SetMinHeartRate(min_hr);
639+
}
640+
if (cadence_count > 0 && type != ROWING) {
641+
sessionMesg.SetAvgCadence((uint8_t)(cadence_sum / cadence_count));
642+
sessionMesg.SetMaxCadence(max_cadence);
643+
}
644+
if (watt_count > 0) {
645+
sessionMesg.SetAvgPower((uint16_t)(watt_sum / watt_count));
646+
sessionMesg.SetMaxPower(max_watt);
647+
if (normalized_power > 0)
648+
sessionMesg.SetNormalizedPower(normalized_power);
649+
if (total_work_joules > 0)
650+
sessionMesg.SetTotalWork(total_work_joules);
651+
}
652+
if (max_speed_ms > 0) {
653+
sessionMesg.SetEnhancedMaxSpeed((float)max_speed_ms);
654+
}
655+
if (aerobic_te > 0) {
656+
sessionMesg.SetTotalTrainingEffect(aerobic_te);
657+
sessionMesg.SetTotalAnaerobicTrainingEffect(anaerobic_te);
658+
}
659+
526660
fit::DeveloperDataIdMesg devIdMesg;
527661
// QZ companion app
528662
// 3746ce54-a9e9-42b0-9be9-b867f8d20f7d

0 commit comments

Comments
 (0)