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