Skip to content

Commit 5e08fcb

Browse files
cagnuleinclaude
andauthored
PID HR Pushy zone-limit/recovery-zone settings (follow-up to #4786) (#4787)
* pid hr on zone 1 #4480 (comment) * pid limited to 0.8 * trainprogram_pid_hr_pushy_zone_limit as configurable setting The 0.8 threshold controlling how far into the next zone the PID 'Pushy' mode can push was hardcoded. Exposed it as a QSettings entry (default 0.8) and added a TextField control under Training Program Options in settings.qml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: symmetric pushy slowdown near upper zone boundary (#4480) When HR enters the upper part of the zone (above the slowdown threshold), proactively reduce speed rather than waiting for the HR to cross into the next zone. Threshold is the midpoint between pushyZoneLimit and the zone top: e.g. pushy=0.8 -> slowdown at zone+0.9, leaving a neutral dead band [0.8, 0.9] that prevents oscillation. The outer condition guard is also relaxed from currentSpeed < maxSpeed to plain trainprogram_pid_pushy so the slowdown path can fire even when already at max speed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix pushy HR handling in zone 1 * feat: add configurable Recovery zone lower limit for Pushy mode (#4480) Replace the auto-computed zone 1 lower boundary (zone1Limit - zoneWidth) with a user-adjustable setting trainprogram_pid_hr_recovery_zone_limit (default 60% of max HR). Appears in UI above the existing Pushy Zone Limit setting. Allows users to fine-tune the lower boundary of zone 1 so the speed-up trigger adapts to their individual heart rate profile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: increment allSettingsCount to 964 for recovery zone limit setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: correct settingCount to match catalog entries (961 -> 962) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5868e38 commit 5e08fcb

5 files changed

Lines changed: 147 additions & 7 deletions

File tree

src/homeform.cpp

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7793,6 +7793,8 @@ void homeform::update() {
77937793
(bluetoothManager->device()->elapsedTime().hour() * 3600);
77947794
uint8_t delta = 10;
77957795
bool trainprogram_pid_pushy = settings.value(QZSettings::trainprogram_pid_pushy, QZSettings::default_trainprogram_pid_pushy).toBool();
7796+
double trainprogram_pid_hr_pushy_zone_limit = settings.value(QZSettings::trainprogram_pid_hr_pushy_zone_limit, QZSettings::default_trainprogram_pid_hr_pushy_zone_limit).toDouble();
7797+
double trainprogram_pid_hr_recovery_zone_limit = settings.value(QZSettings::trainprogram_pid_hr_recovery_zone_limit, QZSettings::default_trainprogram_pid_hr_recovery_zone_limit).toDouble();
77967798
bool fromTrainProgram = trainProgram && trainProgram->currentRow().zoneHR >= 0;
77977799
double maxSpeed = 30;
77987800
double minSpeed = 0;
@@ -7882,15 +7884,47 @@ void homeform::update() {
78827884
newSpeed,
78837885
((treadmill *)bluetoothManager->device())->currentInclination().value());
78847886
pid_heart_zone_small_inc_counter = 0;
7885-
} else if (currentSpeed < maxSpeed && trainprogram_pid_pushy) {
7886-
pid_heart_zone_small_inc_counter++;
7887-
if (fabs(((float)zone) - currentHRZone) < 0.5 && pid_heart_zone_small_inc_counter > (10 * fabs(((float)zone) - currentHRZone))) {
7888-
double newSpeed = std::min(currentSpeed + step, maxSpeed);
7887+
} else if (trainprogram_pid_pushy) {
7888+
double pushyZoneLimit = (double)zone + trainprogram_pid_hr_pushy_zone_limit;
7889+
// Slowdown threshold is symmetric: midpoint between pushyZoneLimit and zone top
7890+
// e.g. pushy=0.8: slowdown at zone+0.9, neutral band [0.8, 0.9]
7891+
double pushySlowdownThreshold = (double)zone + (1.0 + trainprogram_pid_hr_pushy_zone_limit) / 2.0;
7892+
double pushyHRZone = currentHRZone;
7893+
if (zone == 1) {
7894+
double zone1Limit =
7895+
settings.value(QZSettings::heart_rate_zone1, QZSettings::default_heart_rate_zone1)
7896+
.toDouble();
7897+
double zone1LowerLimit = qBound(0.0, trainprogram_pid_hr_recovery_zone_limit, zone1Limit - 1.0);
7898+
double effectiveZone1Width = zone1Limit - zone1LowerLimit;
7899+
if (effectiveZone1Width > 0.0) {
7900+
double maxHeartRate = heartRateMax();
7901+
double currentHRPercent =
7902+
(bluetoothManager->device()->currentHeart().value() * 100.0) / maxHeartRate;
7903+
pushyHRZone =
7904+
1.0 + ((currentHRPercent - zone1LowerLimit) / effectiveZone1Width);
7905+
pushyHRZone = qBound(1.0, pushyHRZone, 1.9999);
7906+
}
7907+
}
7908+
double distanceToNextZone = ((double)zone + 1.0) - pushyHRZone;
7909+
if (pushyHRZone > pushySlowdownThreshold && currentSpeed > minSpeed) {
7910+
double newSpeed = std::max(currentSpeed - step, minSpeed);
78897911
((treadmill *)bluetoothManager->device())
78907912
->changeSpeedAndInclination(
78917913
newSpeed,
78927914
((treadmill *)bluetoothManager->device())->currentInclination().value());
78937915
pid_heart_zone_small_inc_counter = 0;
7916+
} else if (pushyHRZone < pushyZoneLimit && distanceToNextZone > 0.0 && currentSpeed < maxSpeed) {
7917+
pid_heart_zone_small_inc_counter++;
7918+
if (pid_heart_zone_small_inc_counter > (10 * distanceToNextZone)) {
7919+
double newSpeed = std::min(currentSpeed + step, maxSpeed);
7920+
((treadmill *)bluetoothManager->device())
7921+
->changeSpeedAndInclination(
7922+
newSpeed,
7923+
((treadmill *)bluetoothManager->device())->currentInclination().value());
7924+
pid_heart_zone_small_inc_counter = 0;
7925+
}
7926+
} else {
7927+
pid_heart_zone_small_inc_counter++;
78947928
}
78957929
}
78967930
} else if (bluetoothManager->device()->deviceType() == BIKE) {

src/qzsettings.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,8 @@ const QString QZSettings::watt_bike_emulator = QStringLiteral("watt_bike_emulato
906906
const QString QZSettings::restore_specific_gear = QStringLiteral("restore_specific_gear");
907907
const QString QZSettings::skipLocationServicesDialog = QStringLiteral("skipLocationServicesDialog");
908908
const QString QZSettings::trainprogram_pid_pushy = QStringLiteral("trainprogram_pid_pushy");
909+
const QString QZSettings::trainprogram_pid_hr_pushy_zone_limit = QStringLiteral("trainprogram_pid_hr_pushy_zone_limit");
910+
const QString QZSettings::trainprogram_pid_hr_recovery_zone_limit = QStringLiteral("trainprogram_pid_hr_recovery_zone_limit");
909911
const QString QZSettings::min_inclination = QStringLiteral("min_inclination");
910912
const QString QZSettings::proform_performance_300i = QStringLiteral("proform_performance_300i");
911913
const QString QZSettings::proform_performance_400i = QStringLiteral("proform_performance_400i");
@@ -1245,7 +1247,7 @@ const QString QZSettings::default_shortcut_start_stop = QStringLiteral("");
12451247
const QString QZSettings::shortcut_stop = QStringLiteral("shortcut_stop");
12461248
const QString QZSettings::default_shortcut_stop = QStringLiteral("");
12471249

1248-
const uint32_t allSettingsCount = 972;
1250+
const uint32_t allSettingsCount = 974;
12491251

12501252
QVariant allSettings[allSettingsCount][2] = {
12511253
{QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles},
@@ -2001,6 +2003,8 @@ QVariant allSettings[allSettingsCount][2] = {
20012003
{QZSettings::restore_specific_gear, QZSettings::default_restore_specific_gear},
20022004
{QZSettings::skipLocationServicesDialog, QZSettings::default_skipLocationServicesDialog},
20032005
{QZSettings::trainprogram_pid_pushy, QZSettings::default_trainprogram_pid_pushy},
2006+
{QZSettings::trainprogram_pid_hr_pushy_zone_limit, QZSettings::default_trainprogram_pid_hr_pushy_zone_limit},
2007+
{QZSettings::trainprogram_pid_hr_recovery_zone_limit, QZSettings::default_trainprogram_pid_hr_recovery_zone_limit},
20042008
{QZSettings::min_inclination, QZSettings::default_min_inclination},
20052009
{QZSettings::proform_performance_300i, QZSettings::default_proform_performance_300i},
20062010
{QZSettings::proform_performance_400i, QZSettings::default_proform_performance_400i},

src/qzsettings.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2476,6 +2476,12 @@ class QZSettings {
24762476
static const QString trainprogram_pid_pushy;
24772477
static constexpr bool default_trainprogram_pid_pushy = true;
24782478

2479+
static const QString trainprogram_pid_hr_pushy_zone_limit;
2480+
static constexpr double default_trainprogram_pid_hr_pushy_zone_limit = 0.8;
2481+
2482+
static const QString trainprogram_pid_hr_recovery_zone_limit;
2483+
static constexpr double default_trainprogram_pid_hr_recovery_zone_limit = 60.0;
2484+
24792485
static const QString min_inclination;
24802486
static constexpr double default_min_inclination = -999.0;
24812487

src/settings-catalog.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://qdomyos-zwift.local/settings-catalog.schema.json",
33
"schemaVersion": 1,
44
"format": "qdomyos-zwift-settings-catalog",
5-
"settingCount": 960,
5+
"settingCount": 962,
66
"pages": [
77
{
88
"key": "page_custom_gear_table",
@@ -9228,6 +9228,32 @@
92289228
"defaultExpression": "true",
92299229
"options": null
92309230
},
9231+
{
9232+
"key": "trainprogram_pid_hr_recovery_zone_limit",
9233+
"name": "PID Recovery Zone Lower Limit",
9234+
"description": "Lower HR boundary (% of max HR) that defines the bottom of Zone 1 for 'Pushy' mode. Below this percentage the treadmill is at the bottom of the recovery area. Default: 60.",
9235+
"parent": "Training Program Options",
9236+
"type": "number",
9237+
"qmlType": "real",
9238+
"control": "textfield",
9239+
"visible": true,
9240+
"defaultValue": 60.0,
9241+
"defaultExpression": "60.0",
9242+
"options": null
9243+
},
9244+
{
9245+
"key": "trainprogram_pid_hr_pushy_zone_limit",
9246+
"name": "PID Pushy Zone Limit",
9247+
"description": "Fraction of zone above the target zone where 'Pushy' mode stops pushing. 0.8 means the PID stops pushing at zone+0.8. Default: 0.8.",
9248+
"parent": "Training Program Options",
9249+
"type": "number",
9250+
"qmlType": "real",
9251+
"control": "textfield",
9252+
"visible": true,
9253+
"defaultValue": 0.8,
9254+
"defaultExpression": "0.8",
9255+
"options": null
9256+
},
92319257
{
92329258
"key": "min_inclination",
92339259
"name": "Minimum Inclination",

src/settings.qml

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1703,9 +1703,11 @@ import AndroidStatusBar 1.0
17031703
property real trainprogram_rest_speed: 420
17041704
property bool trainprogram_sound_on_segment: false
17051705
property bool tile_watt_color_enabled: true
1706-
property bool tile_pace_color_enabled: true
1706+
property bool tile_pace_color_enabled: true
17071707
property bool treadmill_force_running_activity: false
17081708
property bool proform_treadmill_105_cst: false
1709+
property real trainprogram_pid_hr_pushy_zone_limit: 0.8
1710+
property real trainprogram_pid_hr_recovery_zone_limit: 60.0
17091711
}
17101712

17111713

@@ -8585,6 +8587,74 @@ import AndroidStatusBar 1.0
85858587
color: Material.color(Material.Lime)
85868588
}
85878589

8590+
RowLayout {
8591+
spacing: 10
8592+
Label {
8593+
text: qsTr("PID Recovery Zone Lower Limit (%):")
8594+
Layout.fillWidth: true
8595+
}
8596+
TextField {
8597+
id: pidHrRecoveryZoneLimitTextField
8598+
text: settings.trainprogram_pid_hr_recovery_zone_limit
8599+
horizontalAlignment: Text.AlignRight
8600+
Layout.fillHeight: false
8601+
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
8602+
onActiveFocusChanged: if(this.focus) this.cursorPosition = this.text.length
8603+
}
8604+
Button {
8605+
text: "OK"
8606+
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
8607+
onClicked: { settings.trainprogram_pid_hr_recovery_zone_limit = parseFloat(pidHrRecoveryZoneLimitTextField.text); toast.show("Setting saved!"); }
8608+
}
8609+
}
8610+
8611+
Label {
8612+
text: qsTr("Lower HR boundary (% of max HR) that defines the bottom of Zone 1 for 'Pushy' mode. Below this percentage the treadmill is at the bottom of the recovery area. Default: 60.")
8613+
font.bold: true
8614+
font.italic: true
8615+
font.pixelSize: Qt.application.font.pixelSize - 2
8616+
textFormat: Text.PlainText
8617+
wrapMode: Text.WordWrap
8618+
verticalAlignment: Text.AlignVCenter
8619+
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
8620+
Layout.fillWidth: true
8621+
color: Material.color(Material.Lime)
8622+
}
8623+
8624+
RowLayout {
8625+
spacing: 10
8626+
Label {
8627+
text: qsTr("PID Pushy Zone Limit:")
8628+
Layout.fillWidth: true
8629+
}
8630+
TextField {
8631+
id: pidHrPushyZoneLimitTextField
8632+
text: settings.trainprogram_pid_hr_pushy_zone_limit
8633+
horizontalAlignment: Text.AlignRight
8634+
Layout.fillHeight: false
8635+
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
8636+
onActiveFocusChanged: if(this.focus) this.cursorPosition = this.text.length
8637+
}
8638+
Button {
8639+
text: "OK"
8640+
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
8641+
onClicked: { settings.trainprogram_pid_hr_pushy_zone_limit = parseFloat(pidHrPushyZoneLimitTextField.text); toast.show("Setting saved!"); }
8642+
}
8643+
}
8644+
8645+
Label {
8646+
text: qsTr("Fraction of zone above the target zone where 'Pushy' mode stops pushing. 0.8 means the PID stops pushing at zone+0.8. Default: 0.8.")
8647+
font.bold: true
8648+
font.italic: true
8649+
font.pixelSize: Qt.application.font.pixelSize - 2
8650+
textFormat: Text.PlainText
8651+
wrapMode: Text.WordWrap
8652+
verticalAlignment: Text.AlignVCenter
8653+
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
8654+
Layout.fillWidth: true
8655+
color: Material.color(Material.Lime)
8656+
}
8657+
85888658
IndicatorOnlySwitch {
85898659
text: qsTr("PID Ignore Inclination")
85908660
spacing: 0

0 commit comments

Comments
 (0)