Add relative grade, relative elevation gain data fields (#108)
* Add relative grade, relative elevation gain data fields * Fix wind speed conversion * Fix percent * Fix inverted direction in relative grade calculation * Remove streamvalues class * Default to 70 kg rider weight if not set * Add additional relative grade estimation tests * Increase default CdA value * Additional tests * Add relative grade to README
This commit is contained in:
parent
028df70fe7
commit
8da69f0542
@ -39,6 +39,8 @@ After installing this app on your Karoo and opening it once from the main menu,
|
||||
- Tailwind (graphical, 1x1 field): Similar to the tailwind and riding speed field, but shows tailwind speed, wind speed and wind gust speed instead of riding speed.
|
||||
- Weather forecast (graphical, 2x1 field): Shows three columns indicating the current weather conditions (sunny, cloudy, ...), wind direction, precipitation and temperature forecasted for the next three hours. Tap on this widget to cycle through the 12 hour forecast. If you have a route loaded, the forecast widget will show the forecasted weather along points of the route, with an estimated traveled distance per hour of 20 km / 12 miles by default.
|
||||
- Current weather (graphical, 1x1 field): Shows current weather conditions (same as forecast widget, but only for the current time). Tap on this widget to open the headwind app with a forecast overview.
|
||||
- Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind).
|
||||
- Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride.
|
||||
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.
|
||||
|
||||
The app can use OpenMeteo or OpenWeatherMap as providers for live weather data.
|
||||
|
||||
@ -93,13 +93,12 @@ tasks.register("generateManifest") {
|
||||
"latestVersionCode" to android.defaultConfig.versionCode,
|
||||
"developer" to "github.com/timklge",
|
||||
"description" to "Open-source extension that provides headwind direction, wind speed and other weather data fields.",
|
||||
"releaseNotes" to "* Fix precipitation forecast field\n" +
|
||||
"releaseNotes" to "* Add relative grade, relative elevation gain data fields\n" +
|
||||
"* Fix precipitation forecast field\n" +
|
||||
"* Interpolate between forecasted and current weather data\n" +
|
||||
"* Colorize field background instead of text\n" +
|
||||
"* Add OpenWeatherMap support contributed by lockevod\n" +
|
||||
"* Add tailwind field\n" +
|
||||
"* Add full-width variants of tailwind fields\n" +
|
||||
"* Open weather menu on click of fields\n"
|
||||
"* Add tailwind field\n"
|
||||
)
|
||||
|
||||
val gson = groovy.json.JsonBuilder(manifest).toPrettyString()
|
||||
@ -126,4 +125,5 @@ dependencies {
|
||||
implementation(libs.androidx.glance.appwidget.preview)
|
||||
implementation(libs.androidx.glance.preview)
|
||||
implementation(libs.firebase.crashlytics)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
@ -4,12 +4,16 @@ import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.models.OnLocationChanged
|
||||
import io.hammerhead.karooext.models.OnNavigationState
|
||||
import io.hammerhead.karooext.models.OnStreamState
|
||||
import io.hammerhead.karooext.models.RideState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.sample
|
||||
import kotlinx.coroutines.flow.transform
|
||||
|
||||
fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow<StreamState> {
|
||||
return callbackFlow {
|
||||
@ -44,14 +48,20 @@ fun KarooSystemService.streamNavigationState(): Flow<OnNavigationState> {
|
||||
}
|
||||
}
|
||||
|
||||
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = flow {
|
||||
var lastEmissionTime = 0L
|
||||
fun KarooSystemService.streamRideState(): Flow<RideState> {
|
||||
return callbackFlow {
|
||||
val listenerId = addConsumer { event: RideState ->
|
||||
trySendBlocking(event)
|
||||
}
|
||||
awaitClose {
|
||||
removeConsumer(listenerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect { value ->
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - lastEmissionTime >= timeout) {
|
||||
emit(value)
|
||||
lastEmissionTime = currentTime
|
||||
}
|
||||
}
|
||||
fun<T> Flow<T>.throttle(timeout: Long): Flow<T> = this
|
||||
.conflate()
|
||||
.transform {
|
||||
emit(it)
|
||||
delay(timeout)
|
||||
}
|
||||
@ -11,6 +11,8 @@ import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
|
||||
import de.timklge.karooheadwind.datatypes.HeadwindSpeedDataType
|
||||
import de.timklge.karooheadwind.datatypes.PrecipitationDataType
|
||||
import de.timklge.karooheadwind.datatypes.PrecipitationForecastDataType
|
||||
import de.timklge.karooheadwind.datatypes.RelativeElevationGainDataType
|
||||
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
|
||||
import de.timklge.karooheadwind.datatypes.RelativeHumidityDataType
|
||||
import de.timklge.karooheadwind.datatypes.SealevelPressureDataType
|
||||
import de.timklge.karooheadwind.datatypes.SurfacePressureDataType
|
||||
@ -84,7 +86,9 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
|
||||
PrecipitationForecastDataType(karooSystem),
|
||||
WindForecastDataType(karooSystem),
|
||||
GraphicalForecastDataType(karooSystem),
|
||||
TailwindDataType(karooSystem, applicationContext)
|
||||
TailwindDataType(karooSystem, applicationContext),
|
||||
RelativeGradeDataType(karooSystem, applicationContext),
|
||||
RelativeElevationGainDataType(karooSystem, applicationContext),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package de.timklge.karooheadwind.datatypes
|
||||
|
||||
import android.content.Context
|
||||
import de.timklge.karooheadwind.streamRideState
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.extension.DataTypeImpl
|
||||
import io.hammerhead.karooext.internal.Emitter
|
||||
import io.hammerhead.karooext.internal.ViewEmitter
|
||||
import io.hammerhead.karooext.models.DataPoint
|
||||
import io.hammerhead.karooext.models.DataType
|
||||
import io.hammerhead.karooext.models.RideState
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UpdateGraphicConfig
|
||||
import io.hammerhead.karooext.models.ViewConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.time.Instant
|
||||
|
||||
class RelativeElevationGainDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeElevationGain") {
|
||||
fun updateAccumulatedWindElevation(
|
||||
previousAccumulatedWindElevation: Double,
|
||||
relativeGrade: Double,
|
||||
actualGrade: Double,
|
||||
riderSpeed: Double,
|
||||
deltaTime: Double
|
||||
): Double {
|
||||
val gradeDifferenceDueToWind = relativeGrade - actualGrade
|
||||
var intervalWindElevation = 0.0
|
||||
|
||||
if (gradeDifferenceDueToWind > 0) {
|
||||
val distanceCovered = riderSpeed * deltaTime
|
||||
intervalWindElevation = distanceCovered * gradeDifferenceDueToWind
|
||||
}
|
||||
|
||||
return previousAccumulatedWindElevation + intervalWindElevation
|
||||
}
|
||||
|
||||
private var currentWindElevationGain = 0.0
|
||||
private val currentWindElevationGainLock = Mutex()
|
||||
|
||||
override fun startStream(emitter: Emitter<StreamState>) {
|
||||
val resetJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
karooSystemService.streamRideState().collect { rideState ->
|
||||
if (rideState is RideState.Idle) {
|
||||
currentWindElevationGainLock.withLock { currentWindElevationGain = 0.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
val job = CoroutineScope(Dispatchers.IO).launch {
|
||||
var lastTime: Long? = null
|
||||
|
||||
RelativeGradeDataType.streamRelativeGrade(karooSystemService, context).collect { streamValues ->
|
||||
val now = Instant.now().toEpochMilli()
|
||||
val deltaTime = (now - (lastTime ?: now)) / 1000.0
|
||||
lastTime = now
|
||||
|
||||
val windElevation = currentWindElevationGainLock.withLock {
|
||||
currentWindElevationGain = updateAccumulatedWindElevation(
|
||||
currentWindElevationGain,
|
||||
streamValues.relativeGrade,
|
||||
streamValues.actualGrade,
|
||||
streamValues.riderSpeed,
|
||||
deltaTime
|
||||
)
|
||||
|
||||
currentWindElevationGain
|
||||
}
|
||||
|
||||
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to windElevation))))
|
||||
}
|
||||
}
|
||||
emitter.setCancellable {
|
||||
resetJob.cancel()
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
|
||||
emitter.onNext(UpdateGraphicConfig(formatDataTypeId = DataType.Type.ELEVATION_GAIN))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package de.timklge.karooheadwind.datatypes
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import de.timklge.karooheadwind.HeadingResponse
|
||||
import de.timklge.karooheadwind.KarooHeadwindExtension
|
||||
import de.timklge.karooheadwind.WeatherDataProvider
|
||||
import de.timklge.karooheadwind.getRelativeHeadingFlow
|
||||
import de.timklge.karooheadwind.streamCurrentWeatherData
|
||||
import de.timklge.karooheadwind.streamDataFlow
|
||||
import de.timklge.karooheadwind.streamSettings
|
||||
import de.timklge.karooheadwind.streamUserProfile
|
||||
import io.hammerhead.karooext.KarooSystemService
|
||||
import io.hammerhead.karooext.extension.DataTypeImpl
|
||||
import io.hammerhead.karooext.internal.Emitter
|
||||
import io.hammerhead.karooext.internal.ViewEmitter
|
||||
import io.hammerhead.karooext.models.DataPoint
|
||||
import io.hammerhead.karooext.models.DataType
|
||||
import io.hammerhead.karooext.models.StreamState
|
||||
import io.hammerhead.karooext.models.UpdateGraphicConfig
|
||||
import io.hammerhead.karooext.models.UserProfile
|
||||
import io.hammerhead.karooext.models.ViewConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
|
||||
class RelativeGradeDataType(private val karooSystemService: KarooSystemService, private val context: Context): DataTypeImpl("karoo-headwind", "relativeGrade") {
|
||||
data class RelativeGradeResponse(val relativeGrade: Double, val actualGrade: Double, val riderSpeed: Double)
|
||||
|
||||
companion object {
|
||||
// Default physical constants - adjust as needed
|
||||
const val DEFAULT_GRAVITY = 9.80665 // Acceleration due to gravity (m/s^2)
|
||||
const val DEFAULT_AIR_DENSITY = 1.225 // Air density at sea level, 15°C (kg/m^3)
|
||||
const val DEFAULT_CDA = 0.4 // Default coefficient of drag * frontal area (m^2). Varies significantly with rider position and equipment.
|
||||
const val DEFAULT_BIKE_WEIGHT = 10.0 // Default bike weight (kg).
|
||||
|
||||
/**
|
||||
* Estimates the "relative grade" experienced by a cyclist.
|
||||
*
|
||||
* Relative grade is the hypothetical grade (%) at which the rider would experience
|
||||
* the same total resistance force as they currently experience, but under the
|
||||
* assumption of zero wind. It quantifies the perceived effort due to wind in
|
||||
* terms of an equivalent slope.
|
||||
*
|
||||
* @param actualGrade The current gradient of the road (unitless, e.g., 0.05 for 5%).
|
||||
* @param riderSpeed The speed of the rider relative to the ground (m/s). Must be non-negative.
|
||||
* @param windSpeed The speed of the wind relative to the ground (m/s). Must be non-negative.
|
||||
* @param windDirectionDegrees The direction of the wind relative to the rider's direction
|
||||
* of travel (degrees).
|
||||
* 0 = direct headwind, 90 = crosswind right,
|
||||
* 180 = direct tailwind, 270 = crosswind left.
|
||||
* @param totalMass The combined mass of the rider and the bike (kg). Must be positive.
|
||||
* @param cda The rider's coefficient of drag multiplied by their frontal area (m^2).
|
||||
* Defaults to DEFAULT_CDA. Represents aerodynamic efficiency.
|
||||
* @param airDensity The density of the air (kg/m^3). Defaults to DEFAULT_AIR_DENSITY.
|
||||
* @param g The acceleration due to gravity (m/s^2). Defaults to DEFAULT_GRAVITY.
|
||||
* @return The calculated relative grade (unitless, e.g., 0.08 for 8%), or Double.NaN
|
||||
* if input parameters are invalid.
|
||||
*/
|
||||
fun estimateRelativeGrade(
|
||||
actualGrade: Double,
|
||||
riderSpeed: Double,
|
||||
windSpeed: Double,
|
||||
windDirectionDegrees: Double,
|
||||
totalMass: Double,
|
||||
cda: Double = DEFAULT_CDA,
|
||||
airDensity: Double = DEFAULT_AIR_DENSITY,
|
||||
g: Double = DEFAULT_GRAVITY,
|
||||
): Double {
|
||||
// --- Input Validation ---
|
||||
if (totalMass <= 0.0 || riderSpeed < 0.0 || windSpeed < 0.0 || g <= 0.0 || airDensity < 0.0 || cda < 0.0) {
|
||||
Log.w(KarooHeadwindExtension.TAG, "Warning: Invalid input parameters. Mass/g must be positive; speeds, airDensity, Cda must be non-negative.")
|
||||
return Double.NaN
|
||||
}
|
||||
if (riderSpeed == 0.0 && windSpeed == 0.0) {
|
||||
// If no movement and no wind, relative grade is just the actual grade
|
||||
return actualGrade
|
||||
}
|
||||
|
||||
// 1. Calculate the component of wind speed parallel to the rider's direction of travel.
|
||||
// cos(0 rad) = 1 (headwind), cos(PI rad) = -1 (tailwind)
|
||||
val windComponentParallel = windSpeed * cos(Math.toRadians(windDirectionDegrees))
|
||||
|
||||
// 2. Calculate the effective air speed the rider experiences.
|
||||
// This is rider speed + the parallel wind component.
|
||||
val effectiveAirSpeed = riderSpeed + windComponentParallel
|
||||
|
||||
// 3. Calculate the aerodynamic resistance factor constant part.
|
||||
val aeroFactor = 0.5 * airDensity * cda
|
||||
|
||||
// 4. Calculate the gravitational force component denominator.
|
||||
val gravitationalFactor = totalMass * g
|
||||
|
||||
// 5. Calculate the difference in the aerodynamic drag force term between
|
||||
// the current situation (with wind) and the hypothetical no-wind situation.
|
||||
// Drag Force = aeroFactor * effectiveAirSpeed * abs(effectiveAirSpeed)
|
||||
// We use speed * abs(speed) to ensure drag always opposes relative air motion.
|
||||
val dragForceDifference = aeroFactor * ( (effectiveAirSpeed * abs(effectiveAirSpeed)) - (riderSpeed * abs(riderSpeed)) )
|
||||
|
||||
// 6. Calculate the relative grade.
|
||||
// It's the actual grade plus the equivalent grade change caused by the wind.
|
||||
// Equivalent Grade Change = Drag Force Difference / Gravitational Force Component
|
||||
val relativeGrade = actualGrade + (dragForceDifference / gravitationalFactor)
|
||||
|
||||
return relativeGrade
|
||||
}
|
||||
|
||||
fun streamRelativeGrade(karooSystemService: KarooSystemService, context: Context): Flow<RelativeGradeResponse> {
|
||||
val relativeWindDirectionFlow = karooSystemService.getRelativeHeadingFlow(context).filterIsInstance<HeadingResponse.Value>().map { it.diff + 180 }
|
||||
val speedFlow = karooSystemService.streamDataFlow(DataType.Type.SPEED).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue ?: 0.0 }
|
||||
val actualGradeFlow = karooSystemService.streamDataFlow(DataType.Type.ELEVATION_GRADE).filterIsInstance<StreamState.Streaming>().map { it.dataPoint.singleValue }.filterNotNull().map { it / 100.0 } // Convert to decimal grade
|
||||
val totalMassFlow = karooSystemService.streamUserProfile().map {
|
||||
if (it.weight in 30.0f..300.0f){
|
||||
it.weight
|
||||
} else {
|
||||
Log.w(KarooHeadwindExtension.TAG, "Invalid rider weight ${it.weight} kg, defaulting to 70 kg")
|
||||
70.0f // Default to 70 kg if weight is invalid
|
||||
} + DEFAULT_BIKE_WEIGHT
|
||||
}
|
||||
|
||||
val windSpeedFlow = combine(context.streamSettings(karooSystemService), karooSystemService.streamUserProfile(), context.streamCurrentWeatherData(karooSystemService).filterNotNull()) { settings, profile, weatherData ->
|
||||
val isOpenMeteo = settings.weatherProvider == WeatherDataProvider.OPEN_METEO
|
||||
val profileIsImperial = profile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL
|
||||
|
||||
if (isOpenMeteo) {
|
||||
if (profileIsImperial) { // OpenMeteo returns wind speed in mph
|
||||
val windSpeedInMilesPerHour = weatherData.windSpeed
|
||||
|
||||
windSpeedInMilesPerHour * 0.44704
|
||||
} else { // Wind speed reported by openmeteo is in km/h
|
||||
val windSpeedInKmh = weatherData.windSpeed
|
||||
|
||||
windSpeedInKmh * 0.277778
|
||||
}
|
||||
} else {
|
||||
if (profileIsImperial) { // OpenWeatherMap returns wind speed in mph
|
||||
val windSpeedInMilesPerHour = weatherData.windSpeed
|
||||
|
||||
windSpeedInMilesPerHour * 0.44704
|
||||
} else { // Wind speed reported by openweathermap is in m/s
|
||||
weatherData.windSpeed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class StreamValues(
|
||||
val relativeWindDirection: Double,
|
||||
val speed: Double,
|
||||
val windSpeed: Double,
|
||||
val actualGrade: Double,
|
||||
val totalMass: Double
|
||||
)
|
||||
|
||||
return combine(relativeWindDirectionFlow, speedFlow, windSpeedFlow, actualGradeFlow, totalMassFlow) { windDirection, speed, windSpeed, actualGrade, totalMass ->
|
||||
StreamValues(windDirection, speed, windSpeed, actualGrade, totalMass)
|
||||
}.distinctUntilChanged().map { (windDirection, speed, windSpeed, actualGrade, totalMass) ->
|
||||
val relativeGrade = estimateRelativeGrade(actualGrade, speed, windSpeed, windDirection, totalMass)
|
||||
|
||||
Log.d(KarooHeadwindExtension.TAG, "Relative grade: $relativeGrade - Wind Direction: $windDirection - Speed: $speed - Wind Speed: $windSpeed - Actual Grade: $actualGrade - Total Mass: $totalMass")
|
||||
|
||||
RelativeGradeResponse(relativeGrade, actualGrade, speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startStream(emitter: Emitter<StreamState>) {
|
||||
val job = CoroutineScope(Dispatchers.IO).launch {
|
||||
val relativeGradeFlow = streamRelativeGrade(karooSystemService, context)
|
||||
|
||||
relativeGradeFlow.collect { response ->
|
||||
emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to response.relativeGrade * 100))))
|
||||
}
|
||||
}
|
||||
emitter.setCancellable {
|
||||
Log.d(KarooHeadwindExtension.TAG, "stop $dataTypeId stream")
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
|
||||
emitter.onNext(UpdateGraphicConfig(formatDataTypeId = DataType.Type.ELEVATION_GRADE))
|
||||
}
|
||||
}
|
||||
@ -41,4 +41,8 @@
|
||||
<string name="graphical_forecast_description">Current graphical weather forecast</string>
|
||||
<string name="tailwind">Tailwind</string>
|
||||
<string name="tailwind_description">Current tailwind, wind speed and gust speed</string>
|
||||
<string name="relativeGrade">Relative Grade</string>
|
||||
<string name="relativeGrade_description">Perceived grade in percent</string>
|
||||
<string name="relativeElevationGain">Relative Elevation Gain</string>
|
||||
<string name="relativeElevationGain_description">Perceived elevation gain in meters</string>
|
||||
</resources>
|
||||
@ -144,4 +144,18 @@
|
||||
graphical="false"
|
||||
icon="@drawable/thermometer"
|
||||
typeId="temperature" />
|
||||
|
||||
<DataType
|
||||
description="@string/relativeGrade_description"
|
||||
displayName="@string/relativeGrade"
|
||||
graphical="false"
|
||||
icon="@drawable/wind"
|
||||
typeId="relativeGrade" />
|
||||
|
||||
<DataType
|
||||
description="@string/relativeElevationGain_description"
|
||||
displayName="@string/relativeElevationGain"
|
||||
graphical="false"
|
||||
icon="@drawable/wind"
|
||||
typeId="relativeElevationGain" />
|
||||
</ExtensionInfo>
|
||||
|
||||
135
app/src/test/kotlin/RelativeGradeTest.kt
Normal file
135
app/src/test/kotlin/RelativeGradeTest.kt
Normal file
@ -0,0 +1,135 @@
|
||||
import de.timklge.karooheadwind.datatypes.RelativeGradeDataType
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class RelativeGradeTest {
|
||||
@Test
|
||||
fun testHeadwind(){
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 8.0, // m/s (~28.8 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.052, grade1, 0.005) // Expected relative grade is approximately 5.2%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHeadwindLightweight(){
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 8.0, // m/s (~28.8 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 60.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.063, grade1, 0.005) // Expected relative grade is approximately 6.3%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHeadwindFlat(){
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.00, // 0%
|
||||
riderSpeed = 8.0, // m/s (~28.8 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.032, grade1, 0.005) // Expected relative grade is approximately 3.2%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHeadwindSteep(){
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.06, // 6%
|
||||
riderSpeed = 8.0, // m/s (~28.8 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.09, grade1, 0.005) // Expected relative grade is approximately 9%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHeadwindAtHighSpeed() {
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 12.0, // m/s (~43.2 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.065, grade1, 0.005) // Expected relative grade is approximately 6.5%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHeadwindAtLowSpeed() {
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 4.0, // m/s (~14.4 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.040, grade1, 0.005) // Expected relative grade is approximately 4.6%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStrongHeadwind() {
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 8.0, // m/s (~28.8 km/h)
|
||||
windSpeed = 10.0, // m/s (36 km/h)
|
||||
windDirectionDegrees = 0.0, // Direct headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.101, grade1, 0.005) // Expected relative grade is approximately 10.1%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDiagonalHeadwind() {
|
||||
val grade1 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 8.0, // m/s (~28.8 km/h)
|
||||
windSpeed = 5.0, // m/s (18 km/h)
|
||||
windDirectionDegrees = 45.0, // Diagonal headwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.037, grade1, 0.005) // Expected relative grade is approximately 3.7%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTailwind(){
|
||||
val grade2 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.02, // 2%
|
||||
riderSpeed = 8.0, // m/s
|
||||
windSpeed = 5.0, // m/s
|
||||
windDirectionDegrees = 180.0, // Direct tailwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(0.003, grade2, 0.005) // Expected relative grade is approximately 0.3%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStrongTailwind() {
|
||||
val grade2 = RelativeGradeDataType.estimateRelativeGrade(
|
||||
actualGrade = 0.00, // 0%
|
||||
riderSpeed = 8.0, // m/s
|
||||
windSpeed = 10.0, // m/s
|
||||
windDirectionDegrees = 180.0, // Direct tailwind
|
||||
totalMass = 80.0 // kg
|
||||
)
|
||||
|
||||
assertEquals(-0.021, grade2, 0.005) // Expected relative grade is approximately -2.1%
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user