diff --git a/src/Core/Settings.h b/src/Core/Settings.h index 104b974b4..52c0f6d5b 100644 --- a/src/Core/Settings.h +++ b/src/Core/Settings.h @@ -232,6 +232,7 @@ #define TRAIN_AUTOHIDE "train/autohide" #define TRAIN_LAPALERT "train/lapalert" #define TRAIN_USESIMULATEDSPEED "train/usesimulatedspeed" +#define TRAIN_USESIMULATEDHYPOXIA "train/usesimulatedhypoxia" #define GC_REMOTE_START "remote/start" #define GC_REMOTE_STOP "remote/stop" #define GC_REMOTE_LAP "remote/lap" @@ -340,6 +341,7 @@ #define GC_SIM_BICYCLE_Cd "sim_bicycle/Cd" #define GC_SIM_BICYCLE_Am2 "sim_bicycle/Am2" #define GC_SIM_BICYCLE_Tk "sim_bicycle/Tk" +#define GC_SIM_BICYCLE_ACTUALTRAINERALTITUDEM "sim_bicycle/ActualTrainerAltitudeM" #define GC_RWGPSUSER "rwgps/user" #define GC_RWGPSPASS "rwgps/pass" diff --git a/src/Gui/Pages.cpp b/src/Gui/Pages.cpp index 34be4545e..6d7c9524e 100644 --- a/src/Gui/Pages.cpp +++ b/src/Gui/Pages.cpp @@ -738,6 +738,9 @@ TrainOptionsPage::TrainOptionsPage(QWidget *parent, Context *context) : QWidget( useSimulatedSpeed = new QCheckBox(tr("Use simulated Speed in slope mode"), this); useSimulatedSpeed->setChecked(appsettings->value(this, TRAIN_USESIMULATEDSPEED, false).toBool()); + useSimulatedHypoxia = new QCheckBox(tr("Simulate Relative Hypoxia"), this); + useSimulatedHypoxia->setChecked(appsettings->value(this, TRAIN_USESIMULATEDHYPOXIA, false).toBool()); + autoConnect = new QCheckBox(tr("Auto-connect devices in Train View"), this); autoConnect->setChecked(appsettings->value(this, TRAIN_AUTOCONNECT, false).toBool()); @@ -755,6 +758,7 @@ TrainOptionsPage::TrainOptionsPage(QWidget *parent, Context *context) : QWidget( QVBoxLayout *all = new QVBoxLayout(this); all->addWidget(useSimulatedSpeed); + all->addWidget(useSimulatedHypoxia); all->addWidget(multiCheck); all->addWidget(autoConnect); all->addWidget(autoHide); @@ -768,6 +772,7 @@ TrainOptionsPage::saveClicked() { // Save the train view settings... appsettings->setValue(TRAIN_USESIMULATEDSPEED, useSimulatedSpeed->isChecked()); + appsettings->setValue(TRAIN_USESIMULATEDHYPOXIA, useSimulatedHypoxia->isChecked()); appsettings->setValue(TRAIN_MULTI, multiCheck->isChecked()); appsettings->setValue(TRAIN_AUTOCONNECT, autoConnect->isChecked()); appsettings->setValue(TRAIN_AUTOHIDE, autoHide->isChecked()); @@ -913,7 +918,8 @@ const SimBicyclePartEntry& SimBicyclePage::GetSimBicyclePartEntry(int e) { tr("Coefficient of power train loss" ) , GC_SIM_BICYCLE_Cm, 1.0, 3, tr("Power train loss between reported watts and wheel. For direct drive trainer like kickr there is no relevant loss and value shold be 1.0.")}, // Cm { tr("Coefficient of drag" ) , GC_SIM_BICYCLE_Cd, (1.0 - 0.0045), 5, tr("Coefficient of drag of rider and bicycle")}, // Cd { tr("Frontal Area (m^2)" ) , GC_SIM_BICYCLE_Am2, 0.5, 2, tr("Effective frontal area of rider and bicycle")}, // Am2 - { tr("Temperature (K)" ) , GC_SIM_BICYCLE_Tk, 293.15, 2, tr("Temperature in kelvin, used with altitude to compute air density")} // Tk + { tr("Temperature (K)" ) , GC_SIM_BICYCLE_Tk, 293.15, 2, tr("Temperature in kelvin, used with altitude to compute air density")}, // Tk + { tr("ActualTrainerAltitude (M)" ) , GC_SIM_BICYCLE_ACTUALTRAINERALTITUDEM, 0., 0, tr("Actual altitude of indoor trainer, in meters")} // ActualTrainerAltitudeM }; if (e < 0 || e >= LastPart) e = 0; @@ -1002,7 +1008,8 @@ SimBicyclePage::SetStatsLabelArray(double ) m_SpinBoxArr[SimBicyclePage::Cm] ->value(), m_SpinBoxArr[SimBicyclePage::Cd] ->value(), m_SpinBoxArr[SimBicyclePage::Am2]->value(), - m_SpinBoxArr[SimBicyclePage::Tk] ->value()); + m_SpinBoxArr[SimBicyclePage::Tk] ->value(), + 1.); Bicycle bicycle(NULL, constants, riderMassKG, bicycleMassWithoutWheelsG / 1000., frontWheel, rearWheel); diff --git a/src/Gui/Pages.h b/src/Gui/Pages.h index 29644a5be..bbb392b0c 100644 --- a/src/Gui/Pages.h +++ b/src/Gui/Pages.h @@ -230,6 +230,7 @@ class TrainOptionsPage : public QWidget private: Context *context; QCheckBox *useSimulatedSpeed; + QCheckBox *useSimulatedHypoxia; QCheckBox *multiCheck; QCheckBox *autoConnect; QCheckBox *autoHide; @@ -275,6 +276,7 @@ public: FrontWheelG, FrontSpokeCount, FrontSpokeNippleG, FrontRimG, FrontRotorG, FrontSkewerG, FrontTireG, FrontTubeSealantG, FrontOuterRadiusM, FrontRimInnerRadiusM, RearWheelG, RearSpokeCount, RearSpokeNippleG, RearRimG, RearRotorG, RearSkewerG, RearTireG, RearTubeSealantG, RearOuterRadiusM, RearRimInnerRadiusM, CassetteG, CRR, Cm, Cd, Am2, Tk, + ActualTrainerAltitudeM, LastPart }; diff --git a/src/Train/BicycleSim.cpp b/src/Train/BicycleSim.cpp index 0adf82cd6..446703e23 100644 --- a/src/Train/BicycleSim.cpp +++ b/src/Train/BicycleSim.cpp @@ -20,8 +20,143 @@ #include "Settings.h" #include "Pages.h" #include "ErgFile.h" +#include "MultiRegressionizer.h" #include "Integrator.h" +const PolyFit* GetAltitudeFit(unsigned maxOrder) { + // Aerobic Power Adjustment for Altitude + T_MultiRegressionizer> fit(0, maxOrder); + +#if 1 + // Data from : Prediction of Critical Powerand W? in Hypoxia : Application to Work - Balance Modelling + // by Nathan E.Townsend1, David S.Nichols, Philip F.Skiba, Sebastien Racinais and Julien D.Périard + // + // Data in paper doesn't exceed 4250m. Cubic equation in paper decreases slope after 5000m, which doesn't + // make sense, so using best fit 3rd order rational instead. Our equation continues at constant slope from + // 5000 - 7000m. + // + // Someday in the future we'll have more data and can simply add it to this table to automatically + // generate a new fit. + static const std::vector> NathenTownsendAltitudeData = + { + std::make_tuple(0 ,1 ), + std::make_tuple(0.01 ,1 ), + std::make_tuple(0.02 ,1 ), + std::make_tuple(0.03 ,1 ), + std::make_tuple(0.04 ,1 ), + std::make_tuple(0.05 ,1 ), + std::make_tuple(0.06 ,1 ), + std::make_tuple(0.07 ,1 ), + std::make_tuple(0.08 ,1 ), + std::make_tuple(0.09 ,1 ), + std::make_tuple(0.1 ,0.9996446), + std::make_tuple(0.2 ,0.9964848), + std::make_tuple(0.3 ,0.9930302), + std::make_tuple(0.4 ,0.9892904), + std::make_tuple(0.5 ,0.985275 ), + std::make_tuple(0.6 ,0.9809936), + std::make_tuple(0.7 ,0.9764558), + std::make_tuple(0.8 ,0.9716712), + std::make_tuple(0.9 ,0.9666494), + std::make_tuple(1 ,0.9614 ), + std::make_tuple(1.1 ,0.9559326), + std::make_tuple(1.2 ,0.9502568), + std::make_tuple(1.3 ,0.9443822), + std::make_tuple(1.4 ,0.9383184), + std::make_tuple(1.5 ,0.932075 ), + std::make_tuple(1.6 ,0.9256616), + std::make_tuple(1.7 ,0.9190878), + std::make_tuple(1.8 ,0.9123632), + std::make_tuple(1.9 ,0.9054974), + std::make_tuple(2 ,0.8985 ), + std::make_tuple(2.1 ,0.8913806), + std::make_tuple(2.2 ,0.8841488), + std::make_tuple(2.3 ,0.8768142), + std::make_tuple(2.4 ,0.8693864), + std::make_tuple(2.5 ,0.861875 ), + std::make_tuple(2.6 ,0.8542896), + std::make_tuple(2.7 ,0.8466398), + std::make_tuple(2.8 ,0.8389352), + std::make_tuple(2.9 ,0.8311854), + std::make_tuple(3 ,0.8234 ), + std::make_tuple(3.1 ,0.8155886), + std::make_tuple(3.2 ,0.8077608), + std::make_tuple(3.3 ,0.7999262), + std::make_tuple(3.4 ,0.7920944), + std::make_tuple(3.5 ,0.784275 ), + std::make_tuple(3.6 ,0.7764776), + std::make_tuple(3.7 ,0.7687118), + std::make_tuple(3.8 ,0.7609872), + std::make_tuple(3.9 ,0.7533134), + std::make_tuple(4 ,0.7457 ), + std::make_tuple(4.1 ,0.7381566), + std::make_tuple(4.2 ,0.7306928), + + // Below this line data is projected as linear. + std::make_tuple(4.3 ,0.722483 ), + std::make_tuple(4.4 ,0.714783 ), + std::make_tuple(4.5 ,0.707083 ), + std::make_tuple(4.6 ,0.699383 ), + std::make_tuple(4.7 ,0.691683 ), + std::make_tuple(4.8 ,0.683983 ), + std::make_tuple(4.9 ,0.676283 ), + std::make_tuple(5 ,0.668583 ), + std::make_tuple(5.1 ,0.660883 ), + std::make_tuple(5.2 ,0.653183 ), + std::make_tuple(5.3 ,0.645483 ), + std::make_tuple(5.4 ,0.637783 ), + std::make_tuple(5.5 ,0.630083 ), + std::make_tuple(5.6 ,0.622383 ), + std::make_tuple(5.7 ,0.614683 ), + std::make_tuple(5.8 ,0.606983 ), + std::make_tuple(5.9 ,0.599283 ), + std::make_tuple(6 ,0.591583 ), + std::make_tuple(6.1 ,0.583883 ), + std::make_tuple(6.2 ,0.576183 ), + std::make_tuple(6.3 ,0.568483 ), + std::make_tuple(6.4 ,0.560783 ), + std::make_tuple(6.5 ,0.553083 ), + std::make_tuple(6.6 ,0.545383 ), + std::make_tuple(6.7 ,0.537683 ), + std::make_tuple(6.8 ,0.529983 ), + std::make_tuple(6.9 ,0.522283 ), + std::make_tuple(7 ,0.514583 ) + }; + + for (int i = 0; i < NathenTownsendAltitudeData.size(); i++) { + const std::tuple& v = NathenTownsendAltitudeData.at(i); + + fit.Push({ std::get<0>(v), std::get<1>(v) }); + } + + const PolyFit* pf = fit.AsPolyFit(); + +#else + // Closed form for current rational polynomial fit - avoids solving for least squares + // For if this construction is ever on a hot path. + const PolyFit* pf = PolyFitGenerator::GetRationalPolyFit( + { 1.0009048349382108, 0.37214585400953015, -0.039387977741974042, 0.00069424347367219726 }, + { 1., 0.38762014584456522 }, 1.); +#endif + + // Print table for debug or graphing + //for (double a = 0.; a < 7.; a += 0.1) { + // qDebug() << a << "," << pf->Fit(a)<< ", " << pf->Slope(a); + //} + + return pf; +} + +double GetAltitudeAdjustmentFactor(double altitudeKM) +{ + // Third order rational fit to Townsend data. + static const PolyFit* s_pf3 = GetAltitudeFit(3); + + if (!s_pf3) return 1.; + + return s_pf3->Fit(altitudeKM); +} + BicycleWheel::BicycleWheel(double outerR, // outer radius in meters (for circumference) double innerR, // inner rim radius double massKG, // total wheel mass @@ -43,7 +178,7 @@ BicycleWheel::BicycleWheel(double outerR, // outer radius in meters (for m_equivalentMassKG = m_I / (outerR * outerR); } -void Bicycle::Init(BicycleConstants constants, double riderMassKG, double bicycleMassWithoutWheelsKG, BicycleWheel frontWheel, BicycleWheel rearWheel) +void Bicycle::Init(bool fUseSimulatedHypoxia, BicycleConstants constants, double riderMassKG, double bicycleMassWithoutWheelsKG, BicycleWheel frontWheel, BicycleWheel rearWheel) { m_constants = constants; m_riderMassKG = riderMassKG; @@ -54,6 +189,8 @@ void Bicycle::Init(BicycleConstants constants, double riderMassKG, double bicycl // Precompute effective mass for KE calc here since it never changes. m_KEMass = (MassKG() + EquivalentMassKG()); + m_useSimulatedHypoxia = fUseSimulatedHypoxia; + if (m_KEMass < 0) // Should be impossible but bad if it happens. { m_KEMass = 100; @@ -66,10 +203,17 @@ void Bicycle::Init(BicycleConstants constants, double riderMassKG, double bicycl Bicycle::Bicycle(Context *context, BicycleConstants constants, double riderMassKG, double bicycleMassWithoutWheelsKG, BicycleWheel frontWheel, BicycleWheel rearWheel) { Q_UNUSED(context) - Init(constants, riderMassKG, bicycleMassWithoutWheelsKG, frontWheel, rearWheel); + + Init(true, constants, riderMassKG, bicycleMassWithoutWheelsKG, frontWheel, rearWheel); } Bicycle::Bicycle(Context* context) +{ + this->Reset(context); +} + +// Reset - gather new data from athlete settings. +void Bicycle::Reset(Context* context) { // Tolerate NULL context since used by NullTrainer for testing. @@ -106,6 +250,8 @@ Bicycle::Bicycle(Context* context) const double rearTubeOrSealantG = simBikeValues[SimBicyclePage::RearTubeSealantG ]; const double cassetteG = simBikeValues[SimBicyclePage::CassetteG ]; + const double actualTrainerAltitudeM = simBikeValues[SimBicyclePage::ActualTrainerAltitudeM]; + const double frontWheelG = bareFrontWheelG + frontRotorG + frontSkewerG + frontTireG + frontTubeOrSealantG; const double frontWheelRotatingG = frontRimG + frontTireG + frontTubeOrSealantG + (frontSpokeCount * frontSpokeNippleG); const double frontWheelCenterG = frontWheelG - frontWheelRotatingG; @@ -123,9 +269,12 @@ Bicycle::Bicycle(Context* context) simBikeValues[SimBicyclePage::Cm], simBikeValues[SimBicyclePage::Cd], simBikeValues[SimBicyclePage::Am2], - simBikeValues[SimBicyclePage::Tk]); + simBikeValues[SimBicyclePage::Tk], + 1./GetAltitudeAdjustmentFactor(actualTrainerAltitudeM / 1000.)); - Init(constants, riderMassKG, bicycleMassWithoutWheelsG / 1000., frontWheel, rearWheel); + bool useSimulatedHypoxia = appsettings->value(NULL, TRAIN_USESIMULATEDHYPOXIA, false).toBool(); + + Init(useSimulatedHypoxia, constants, riderMassKG, bicycleMassWithoutWheelsG / 1000., frontWheel, rearWheel); } double Bicycle::MassKG() const @@ -278,7 +427,7 @@ double Bicycle::SampleDT() } // Detect and filter obvious power spikes. -double Bicycle::FilterWattIncrease(double watts) +double Bicycle::FilterWattIncrease(double watts, double altitudeMeters) { // Not possible to gain an imperial ton of watts in a single sample. static const double s_imperialWattTon = 800.; @@ -290,6 +439,16 @@ double Bicycle::FilterWattIncrease(double watts) // distance - no free watts. watts = m_state.Watts() + s_wattGrowthLimit; } + + // adjust power due to altitude + if (m_useSimulatedHypoxia) { + double adjustmentFactor = GetAltitudeAdjustmentFactor(altitudeMeters / 1000.); + + double newWatts = watts * adjustmentFactor * m_constants.m_AltitudeCorrectionFactor; + + watts = newWatts; + } + return watts; } @@ -298,7 +457,7 @@ SpeedDistance Bicycle::SampleSpeed(BicycleSimState &nowState) { // Detect and filter obvious power spikes. - nowState.Watts() = FilterWattIncrease(nowState.Watts()); + nowState.Watts() = FilterWattIncrease(nowState.Watts(), nowState.Altitude()); // Record current time and return dt since last sample. double dt = SampleDT(); diff --git a/src/Train/BicycleSim.h b/src/Train/BicycleSim.h index c5a92f885..245aebc75 100644 --- a/src/Train/BicycleSim.h +++ b/src/Train/BicycleSim.h @@ -48,22 +48,24 @@ public: struct BicycleConstants { - double m_crr; // coefficient rolling resistance - double m_Cm; // coefficient of powertrain loss - double m_Cd; // coefficient of drag - double m_A; // frontal area (m2) - double m_T; // temp in kelvin + double m_crr; // coefficient rolling resistance + double m_Cm; // coefficient of powertrain loss + double m_Cd; // coefficient of drag + double m_A; // frontal area (m2) + double m_T; // temp in kelvin + double m_AltitudeCorrectionFactor; // inverse of altitude cp penalty BicycleConstants() : m_crr(0.004), m_Cm(1.0), m_Cd(1.0 - 0.0045), m_A(0.5), - m_T(293.15) + m_T(293.15), + m_AltitudeCorrectionFactor(1.) {} - BicycleConstants(double crr, double Cm, double Cd, double A, double T) : - m_crr(crr), m_Cm(Cm), m_Cd(Cd), m_A(A), m_T(T) + BicycleConstants(double crr, double Cm, double Cd, double A, double T, double acf) : + m_crr(crr), m_Cm(Cm), m_Cd(Cd), m_A(A), m_T(T), m_AltitudeCorrectionFactor(acf) {} }; @@ -138,6 +140,7 @@ class Bicycle BicycleWheel m_frontWheel, m_rearWheel; double m_bicycleMassWithoutWheelsKG, m_riderMassKG; double m_KEMass; // effective mass used for computing kinetic energy + bool m_useSimulatedHypoxia; // Dynamic data persisted for modelling momentum. BicycleSimState m_state; @@ -146,10 +149,12 @@ class Bicycle bool m_isFirstSample; std::chrono::high_resolution_clock::time_point m_prevSampleTime; - void Init(BicycleConstants constants, double riderWeightKG, double bicycleMassWithoutWheelsKG, BicycleWheel frontWheel, BicycleWheel rearWheel); + void Init(bool fUseSimulateHypoxia, BicycleConstants constants, double riderWeightKG, double bicycleMassWithoutWheelsKG, BicycleWheel frontWheel, BicycleWheel rearWheel); - double SampleDT(); // Gather sample time stamp and return dt (in seconds) - double FilterWattIncrease(double watts); // Reduce power spikes by limiting power growth per sample period + double SampleDT(); // Gather sample time stamp and return dt (in seconds) + + // Reduce power spikes by limiting power growth per sample period, also apply altitude penalty. + double FilterWattIncrease(double watts, double altitude); public: @@ -157,6 +162,8 @@ public: Bicycle(Context* context); SpeedDistance SampleSpeed(BicycleSimState &newState); + void Reset(Context* context); + double MassKG() const; // Actual mass of bike and rider. double EquivalentMassKG() const; // Additional mass due to rotational inertia double KEMass() const; // Total effective kinetic mass @@ -167,7 +174,7 @@ public: const BicycleWheel &RearWheel() const { return m_rearWheel; } // Reset timer. This forces next sample to use 0.1s as its dt. - void reset() + void resettimer() { m_isFirstSample = true; } @@ -176,7 +183,7 @@ public: void clear() { m_Velocity = 0.0; - reset(); + resettimer(); this->m_state.Clear(); } diff --git a/src/Train/TrainSidebar.cpp b/src/Train/TrainSidebar.cpp index b7a7736fc..d4024bb0e 100644 --- a/src/Train/TrainSidebar.cpp +++ b/src/Train/TrainSidebar.cpp @@ -695,6 +695,9 @@ TrainSidebar::configChanged(qint32) FTP = context->athlete->zones(false)->getCP(range); WPRIME = context->athlete->zones(false)->getWprime(range); } + + // Reinit Bicycle + bicycle.Reset(context); } /*---------------------------------------------------------------------- @@ -1115,7 +1118,7 @@ void TrainSidebar::Start() // when start button is pressed clearStatusFlags(RT_PAUSED); // Reset speed simulation timer. - bicycle.reset(); + bicycle.resettimer(); maintainLapDistanceState();