Simulated Hypoxia for Train Mode (#3699)

Fixes #3698
This commit is contained in:
ericchristoffersen
2020-12-10 02:41:39 -08:00
committed by GitHub
parent 80086ace46
commit b887f69a1b
6 changed files with 202 additions and 22 deletions

View File

@@ -232,6 +232,7 @@
#define TRAIN_AUTOHIDE "<global-trainmode>train/autohide"
#define TRAIN_LAPALERT "<global-trainmode>train/lapalert"
#define TRAIN_USESIMULATEDSPEED "<global-trainmode>train/usesimulatedspeed"
#define TRAIN_USESIMULATEDHYPOXIA "<global-trainmode>train/usesimulatedhypoxia"
#define GC_REMOTE_START "<global-trainmode>remote/start"
#define GC_REMOTE_STOP "<global-trainmode>remote/stop"
#define GC_REMOTE_LAP "<global-trainmode>remote/lap"
@@ -340,6 +341,7 @@
#define GC_SIM_BICYCLE_Cd "<athlete-preferences>sim_bicycle/Cd"
#define GC_SIM_BICYCLE_Am2 "<athlete-preferences>sim_bicycle/Am2"
#define GC_SIM_BICYCLE_Tk "<athlete-preferences>sim_bicycle/Tk"
#define GC_SIM_BICYCLE_ACTUALTRAINERALTITUDEM "<athlete-preferences>sim_bicycle/ActualTrainerAltitudeM"
#define GC_RWGPSUSER "<athlete-private>rwgps/user"
#define GC_RWGPSPASS "<athlete-private>rwgps/pass"

View File

@@ -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);

View File

@@ -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
};

View File

@@ -20,8 +20,143 @@
#include "Settings.h"
#include "Pages.h"
#include "ErgFile.h"
#include "MultiRegressionizer.h"
#include "Integrator.h"
const PolyFit<double>* GetAltitudeFit(unsigned maxOrder) {
// Aerobic Power Adjustment for Altitude
T_MultiRegressionizer<XYVector<double>> 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<std::tuple<double, double>> NathenTownsendAltitudeData =
{
std::make_tuple<double, double>(0 ,1 ),
std::make_tuple<double, double>(0.01 ,1 ),
std::make_tuple<double, double>(0.02 ,1 ),
std::make_tuple<double, double>(0.03 ,1 ),
std::make_tuple<double, double>(0.04 ,1 ),
std::make_tuple<double, double>(0.05 ,1 ),
std::make_tuple<double, double>(0.06 ,1 ),
std::make_tuple<double, double>(0.07 ,1 ),
std::make_tuple<double, double>(0.08 ,1 ),
std::make_tuple<double, double>(0.09 ,1 ),
std::make_tuple<double, double>(0.1 ,0.9996446),
std::make_tuple<double, double>(0.2 ,0.9964848),
std::make_tuple<double, double>(0.3 ,0.9930302),
std::make_tuple<double, double>(0.4 ,0.9892904),
std::make_tuple<double, double>(0.5 ,0.985275 ),
std::make_tuple<double, double>(0.6 ,0.9809936),
std::make_tuple<double, double>(0.7 ,0.9764558),
std::make_tuple<double, double>(0.8 ,0.9716712),
std::make_tuple<double, double>(0.9 ,0.9666494),
std::make_tuple<double, double>(1 ,0.9614 ),
std::make_tuple<double, double>(1.1 ,0.9559326),
std::make_tuple<double, double>(1.2 ,0.9502568),
std::make_tuple<double, double>(1.3 ,0.9443822),
std::make_tuple<double, double>(1.4 ,0.9383184),
std::make_tuple<double, double>(1.5 ,0.932075 ),
std::make_tuple<double, double>(1.6 ,0.9256616),
std::make_tuple<double, double>(1.7 ,0.9190878),
std::make_tuple<double, double>(1.8 ,0.9123632),
std::make_tuple<double, double>(1.9 ,0.9054974),
std::make_tuple<double, double>(2 ,0.8985 ),
std::make_tuple<double, double>(2.1 ,0.8913806),
std::make_tuple<double, double>(2.2 ,0.8841488),
std::make_tuple<double, double>(2.3 ,0.8768142),
std::make_tuple<double, double>(2.4 ,0.8693864),
std::make_tuple<double, double>(2.5 ,0.861875 ),
std::make_tuple<double, double>(2.6 ,0.8542896),
std::make_tuple<double, double>(2.7 ,0.8466398),
std::make_tuple<double, double>(2.8 ,0.8389352),
std::make_tuple<double, double>(2.9 ,0.8311854),
std::make_tuple<double, double>(3 ,0.8234 ),
std::make_tuple<double, double>(3.1 ,0.8155886),
std::make_tuple<double, double>(3.2 ,0.8077608),
std::make_tuple<double, double>(3.3 ,0.7999262),
std::make_tuple<double, double>(3.4 ,0.7920944),
std::make_tuple<double, double>(3.5 ,0.784275 ),
std::make_tuple<double, double>(3.6 ,0.7764776),
std::make_tuple<double, double>(3.7 ,0.7687118),
std::make_tuple<double, double>(3.8 ,0.7609872),
std::make_tuple<double, double>(3.9 ,0.7533134),
std::make_tuple<double, double>(4 ,0.7457 ),
std::make_tuple<double, double>(4.1 ,0.7381566),
std::make_tuple<double, double>(4.2 ,0.7306928),
// Below this line data is projected as linear.
std::make_tuple<double, double>(4.3 ,0.722483 ),
std::make_tuple<double, double>(4.4 ,0.714783 ),
std::make_tuple<double, double>(4.5 ,0.707083 ),
std::make_tuple<double, double>(4.6 ,0.699383 ),
std::make_tuple<double, double>(4.7 ,0.691683 ),
std::make_tuple<double, double>(4.8 ,0.683983 ),
std::make_tuple<double, double>(4.9 ,0.676283 ),
std::make_tuple<double, double>(5 ,0.668583 ),
std::make_tuple<double, double>(5.1 ,0.660883 ),
std::make_tuple<double, double>(5.2 ,0.653183 ),
std::make_tuple<double, double>(5.3 ,0.645483 ),
std::make_tuple<double, double>(5.4 ,0.637783 ),
std::make_tuple<double, double>(5.5 ,0.630083 ),
std::make_tuple<double, double>(5.6 ,0.622383 ),
std::make_tuple<double, double>(5.7 ,0.614683 ),
std::make_tuple<double, double>(5.8 ,0.606983 ),
std::make_tuple<double, double>(5.9 ,0.599283 ),
std::make_tuple<double, double>(6 ,0.591583 ),
std::make_tuple<double, double>(6.1 ,0.583883 ),
std::make_tuple<double, double>(6.2 ,0.576183 ),
std::make_tuple<double, double>(6.3 ,0.568483 ),
std::make_tuple<double, double>(6.4 ,0.560783 ),
std::make_tuple<double, double>(6.5 ,0.553083 ),
std::make_tuple<double, double>(6.6 ,0.545383 ),
std::make_tuple<double, double>(6.7 ,0.537683 ),
std::make_tuple<double, double>(6.8 ,0.529983 ),
std::make_tuple<double, double>(6.9 ,0.522283 ),
std::make_tuple<double, double>(7 ,0.514583 )
};
for (int i = 0; i < NathenTownsendAltitudeData.size(); i++) {
const std::tuple<double, double>& v = NathenTownsendAltitudeData.at(i);
fit.Push({ std::get<0>(v), std::get<1>(v) });
}
const PolyFit<double>* 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<double>* 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<double>* 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();

View File

@@ -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();
}

View File

@@ -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();