Unit tests

This commit is contained in:
Julien Papasian 2025-08-20 14:00:21 +02:00
parent 89a7806492
commit 24ecafe734
23 changed files with 685 additions and 321 deletions

View file

@ -91,7 +91,7 @@ object UnitUtils {
locale = context.currentLocale,
showSign = showSign,
useNumberFormatter = SettingsManager.Companion.getInstance(context).useNumberFormatter,
useMeasureFormat = SettingsManager.Companion.getInstance(context).useMeasureFormat
useNumberFormat = SettingsManager.Companion.getInstance(context).useMeasureFormat
)
}
@ -106,7 +106,7 @@ object UnitUtils {
locale = context.currentLocale,
showSign = showSign,
useNumberFormatter = SettingsManager.Companion.getInstance(context).useNumberFormatter,
useMeasureFormat = SettingsManager.Companion.getInstance(context).useMeasureFormat
useNumberFormat = SettingsManager.Companion.getInstance(context).useMeasureFormat
)
}

View file

@ -45,9 +45,13 @@ import org.breezyweather.common.extensions.CLOUD_COVER_FEW
import org.breezyweather.common.extensions.ensurePositive
import org.breezyweather.common.extensions.getIsoFormattedDate
import org.breezyweather.common.extensions.toCalendarWithTimeZone
import org.breezyweather.domain.weather.index.PollutantIndex
import org.breezyweather.domain.weather.model.validate
import org.breezyweather.ui.theme.weatherView.WeatherViewController
import org.breezyweather.unit.computing.computeApparentTemperature
import org.breezyweather.unit.computing.computeDewPoint
import org.breezyweather.unit.computing.computeHumidex
import org.breezyweather.unit.computing.computeRelativeHumidity
import org.breezyweather.unit.computing.computeWindChillTemperature
import org.breezyweather.unit.distance.Distance
import org.breezyweather.unit.distance.Distance.Companion.meters
import org.breezyweather.unit.duration.toValidDailyOrNull
@ -58,14 +62,11 @@ import org.breezyweather.unit.precipitation.Precipitation.Companion.millimeters
import org.breezyweather.unit.pressure.Pressure
import org.breezyweather.unit.pressure.Pressure.Companion.pascals
import org.breezyweather.unit.ratio.Ratio
import org.breezyweather.unit.ratio.Ratio.Companion.fraction
import org.breezyweather.unit.ratio.Ratio.Companion.percent
import org.breezyweather.unit.ratio.Ratio.Companion.permille
import org.breezyweather.unit.speed.Speed
import org.breezyweather.unit.speed.Speed.Companion.kilometersPerHour
import org.breezyweather.unit.speed.Speed.Companion.metersPerSecond
import org.breezyweather.unit.temperature.Temperature
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import org.breezyweather.unit.temperature.Temperature.Companion.deciCelsius
import org.shredzone.commons.suncalc.MoonIllumination
import org.shredzone.commons.suncalc.MoonTimes
@ -74,10 +75,6 @@ import java.util.Calendar
import java.util.Date
import java.util.TimeZone
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
@ -274,121 +271,6 @@ internal fun computeMissingHourlyData(
}
}
/**
* Compute relative humidity from temperature and dew point
* Uses Magnus approximation with Arden Buck best variable set
* TODO: Unit test
*/
private fun computeRelativeHumidity(
temperature: Temperature?,
dewPoint: Temperature?,
): Ratio? {
if (temperature == null || dewPoint == null) return null
val b = if (temperature < 0.celsius) 17.966 else 17.368
val c = if (temperature < 0.celsius) 227.15 else 238.88 // °C
return (
exp((b * dewPoint.inCelsius).div(c + dewPoint.inCelsius)) /
exp((b * temperature.inCelsius).div(c + temperature.inCelsius))
).fraction
}
/**
* Compute dew point from temperature and relative humidity
* Uses Magnus approximation with Arden Buck best variable set
* TODO: Unit test
*
* @param temperature
* @param relativeHumidity
*/
private fun computeDewPoint(
temperature: Temperature?,
relativeHumidity: Ratio?,
): Temperature? {
if (temperature == null || relativeHumidity == null) return null
val b = if (temperature < 0.celsius) 17.966 else 17.368
val c = if (temperature < 0.celsius) 227.15 else 238.88 // °C
val magnus = ln(relativeHumidity.inFraction) + (b * temperature.inCelsius) / (c + temperature.inCelsius)
return ((c * magnus) / (b - magnus)).celsius
}
/**
* Compute apparent temperature from temperature, relative humidity, and wind speed
* Uses Bureau of Meteorology Australia methodology
* Source: http://www.bom.gov.au/info/thermal_stress/#atapproximation
* TODO: Unit test
*
* @param temperature
* @param relativeHumidity
* @param windSpeed
*/
internal fun computeApparentTemperature(
temperature: Temperature?,
relativeHumidity: Ratio?,
windSpeed: Speed?,
): Temperature? {
if (temperature == null || relativeHumidity == null || windSpeed == null) return null
val e = relativeHumidity.inFraction * 6.105 * exp(17.27 * temperature.inCelsius / (237.7 + temperature.inCelsius))
return (temperature.inCelsius + 0.33 * e - 0.7 * windSpeed.inMetersPerSecond - 4.0).celsius
}
/**
* Compute wind chill from temperature and wind speed
* Uses Environment Canada methodology
* Source: https://climate.weather.gc.ca/glossary_e.html#w
* Only valid for (T 0 °C) or (T 10°C and WS 5 km/h)
* TODO: Unit test
*
* @param temperature
* @param windSpeed
*/
internal fun computeWindChillTemperature(
temperature: Temperature?,
windSpeed: Speed?,
): Temperature? {
if (temperature == null || windSpeed == null || temperature > 10.celsius) return null
return if (windSpeed >= 5.kilometersPerHour) {
(
13.12 +
(0.6215 * temperature.inCelsius) -
(11.37 * windSpeed.inKilometersPerHour.pow(0.16)) +
(0.3965 * temperature.inCelsius * windSpeed.inKilometersPerHour.pow(0.16))
).celsius
} else if (temperature <= 0.celsius) {
(temperature.inCelsius + ((-1.59 + 0.1345 * temperature.inCelsius) / 5.0) * windSpeed.inKilometersPerHour)
.celsius
} else {
null
}
}
/**
* Compute humidex from temperature and humidity
* Based on formula from ECCC
*
* @param temperature
* @param dewPoint
*/
internal fun computeHumidex(
temperature: Temperature?,
dewPoint: Temperature?,
): Temperature? {
if (temperature == null || dewPoint == null || temperature < 15.celsius) return null
return (
temperature.inCelsius +
0.5555.times(
6.11.times(
exp(5417.7530.times(1.div(273.15) - 1.div(273.15 + dewPoint.inCelsius)))
).minus(10)
)
).celsius
}
/**
* Convert cardinal points direction to a degree
* Supports up to 3 characters cardinal points
@ -421,108 +303,6 @@ internal fun getWindDegree(
else -> null
}
/**
* Compute mean sea level pressure (MSLP) from barometric pressure and altitude.
* Optional elements can be provided for minor adjustments.
* Source: https://integritext.net/DrKFS/correctiontosealevel.htm
*
* To compute barometric pressure from MSLP,
* simply enter negative altitude.
*
* @param barometricPressure in hPa
* @param altitude in meters
* @param temperature in °C (optional)
* @param humidity in % (optional)
* @param latitude in ° (optional)
*/
internal fun computeMeanSeaLevelPressure(
barometricPressure: Double?,
altitude: Double?,
temperature: Double? = null,
humidity: Double? = null,
latitude: Double? = null,
): Double? {
// There is nothing to calculate if barometric pressure or altitude is null.
if (barometricPressure == null || altitude == null) {
return null
}
// Source: http://www.bom.gov.au/info/thermal_stress/#atapproximation
val waterVaporPressure = if (humidity != null && temperature != null) {
humidity / 100 * 6.105 * exp(17.27 * temperature / (237.7 + temperature))
} else {
0.0
}
// adjustment for temperature
val term1 = 1.0 + 0.0037 * (temperature ?: 0.0)
// adjustment for humidity
val term2 = 1.0 / (1.0 - 0.378 * waterVaporPressure / barometricPressure)
// adjustment for asphericity of the Earth
val term3 = 1.0 / (1.0 - 0.0026 * cos(2 * (latitude ?: 45.0) * Math.PI / 180))
// adjustment for variation of gravitational acceleration with height
val term4 = 1.0 + (altitude / 6367324)
return (10.0).pow(log10(barometricPressure) + altitude / (18400.0 * term1 * term2 * term3 * term4))
}
/**
* Compute pollutant concentration in µg/ when given in ppb.
* Can also be used for converting to mg/ from ppm.
* Source: https://en.wikipedia.org/wiki/Useful_conversions_and_formulas_for_air_dispersion_modeling
*
* Basis for temperature and pressure assumptions:
* https://www.ecfr.gov/current/title-40/chapter-I/subchapter-C/part-50/section-50.3
*
* @param pollutant one of NO2, O3, SO2 or CO
* @param concentrationInPpb in ppb
* @param temperature assumed 25 °C if omitted
* @param barometricPressure assumed 1 atm = 1013.25 hPa if omitted
*/
internal fun computePollutantInUgm3FromPpb(
pollutant: PollutantIndex,
concentrationInPpb: Double?,
temperature: Temperature? = null,
barometricPressure: Pressure? = null,
): Double? {
if (concentrationInPpb == null) return null
if (pollutant.molecularMass == null) return null
return concentrationInPpb *
pollutant.molecularMass /
(8.31446261815324 / (barometricPressure?.inHectopascals ?: 1013.25) * 10) /
(273.15 + (temperature?.inCelsius ?: 25.0))
}
/**
* Compute pollutant concentration in ppb from µg/
* Can also be used for converting to ppm from mg/
* Source: https://en.wikipedia.org/wiki/Useful_conversions_and_formulas_for_air_dispersion_modeling
*
* Basis for temperature and pressure assumptions:
* https://www.ecfr.gov/current/title-40/chapter-I/subchapter-C/part-50/section-50.3
*
* @param pollutant one of NO2, O3, SO2 or CO
* @param concentrationInUgm3 in µg/
* @param temperature assumed 25 °C if omitted
* @param barometricPressure assumed 1 atm = 1013.25 hPa if omitted
*/
internal fun computePollutantInPpbFromUgm3(
pollutant: PollutantIndex,
concentrationInUgm3: Double?,
temperature: Temperature? = null,
barometricPressure: Pressure? = null,
): Double? {
if (concentrationInUgm3 == null) return null
if (pollutant.molecularMass == null) return null
return concentrationInUgm3 /
pollutant.molecularMass *
(8.31446261815324 / (barometricPressure?.inHectopascals ?: 1013.25) * 10) *
(273.15 + (temperature?.inCelsius ?: 25.0))
}
/**
* DAILY FROM HOURLY
*/

View file

@ -63,8 +63,6 @@ import org.breezyweather.common.source.WeatherSource.Companion.PRIORITY_HIGHEST
import org.breezyweather.common.source.WeatherSource.Companion.PRIORITY_NONE
import org.breezyweather.domain.settings.SourceConfigStore
import org.breezyweather.domain.weather.index.PollutantIndex
import org.breezyweather.sources.computeMeanSeaLevelPressure
import org.breezyweather.sources.computePollutantInUgm3FromPpb
import org.breezyweather.sources.cwa.json.CwaAirQualityResult
import org.breezyweather.sources.cwa.json.CwaAlertResult
import org.breezyweather.sources.cwa.json.CwaAssistantResult
@ -77,6 +75,8 @@ import org.breezyweather.sources.nlsc.NlscService.Companion.MATSU_BBOX
import org.breezyweather.sources.nlsc.NlscService.Companion.PENGHU_BBOX
import org.breezyweather.sources.nlsc.NlscService.Companion.TAIWAN_BBOX
import org.breezyweather.sources.nlsc.NlscService.Companion.WUQIU_BBOX
import org.breezyweather.unit.computing.computeMeanSeaLevelPressure
import org.breezyweather.unit.computing.computePollutantInUgm3FromPpb
import org.breezyweather.unit.pollutant.PollutantConcentration.Companion.microgramsPerCubicMeter
import org.breezyweather.unit.pressure.Pressure
import org.breezyweather.unit.pressure.Pressure.Companion.hectopascals
@ -506,25 +506,25 @@ class CwaService @Inject constructor(
pM25 = it.pm25?.toDoubleOrNull()?.microgramsPerCubicMeter,
pM10 = it.pm10?.toDoubleOrNull()?.microgramsPerCubicMeter,
sO2 = computePollutantInUgm3FromPpb(
PollutantIndex.SO2,
PollutantIndex.SO2.molecularMass,
it.so2?.toDoubleOrNull(),
temperature,
pressure
)?.microgramsPerCubicMeter,
nO2 = computePollutantInUgm3FromPpb(
PollutantIndex.NO2,
PollutantIndex.NO2.molecularMass,
it.no2?.toDoubleOrNull(),
temperature,
pressure
)?.microgramsPerCubicMeter,
o3 = computePollutantInUgm3FromPpb(
PollutantIndex.O3,
PollutantIndex.O3.molecularMass,
it.o3?.toDoubleOrNull(),
temperature,
pressure
)?.microgramsPerCubicMeter,
cO = computePollutantInUgm3FromPpb(
PollutantIndex.CO,
PollutantIndex.CO.molecularMass,
it.co?.toDoubleOrNull(),
temperature,
pressure

View file

@ -51,7 +51,6 @@ import org.breezyweather.common.source.WeatherSource
import org.breezyweather.common.source.WeatherSource.Companion.PRIORITY_HIGHEST
import org.breezyweather.common.source.WeatherSource.Companion.PRIORITY_NONE
import org.breezyweather.common.utils.ISO8601Utils
import org.breezyweather.sources.computeMeanSeaLevelPressure
import org.breezyweather.sources.getWindDegree
import org.breezyweather.sources.nws.json.NwsAlert
import org.breezyweather.sources.nws.json.NwsAlertsResult
@ -64,6 +63,7 @@ import org.breezyweather.sources.nws.json.NwsValueDoubleContainer
import org.breezyweather.sources.nws.json.NwsValueIntContainer
import org.breezyweather.sources.nws.json.NwsValueWeatherContainer
import org.breezyweather.sources.nws.json.NwsValueWeatherValue
import org.breezyweather.unit.computing.computeMeanSeaLevelPressure
import org.breezyweather.unit.distance.Distance.Companion.meters
import org.breezyweather.unit.precipitation.Precipitation.Companion.millimeters
import org.breezyweather.unit.pressure.Pressure.Companion.hectopascals

View file

@ -1,69 +0,0 @@
/**
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.option.unit
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.breezyweather.unit.formatting.format
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* TODO: Move to unit lib
* TODO: Add sign testing
*/
class UnitUtilsTest {
@Test
fun formatDouble() = runTest {
7.00646.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "7,01"
7.00246.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "7"
14.34234.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "14,34"
14.34834.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "14,35"
14.34834.format(
decimals = 3,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "14,348"
14.34864.format(
decimals = 3,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "14,349"
}
@Test
fun formatInt() = runTest {
14.format(
decimals = 0,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "14"
16.format(
decimals = 0,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "16"
}
}

View file

@ -2,11 +2,10 @@ package org.breezyweather.sources
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import org.junit.jupiter.api.Test
/**
* To be completed
* TODO: To be completed
*/
class CommonConverterTest {
@ -17,13 +16,4 @@ class CommonConverterTest {
getWindDegree("SSO") shouldBe 202.5
getWindDegree("VR") shouldBe -1.0
}
@Test
fun computeHumidexTest() = runTest {
computeHumidex(null, null) shouldBe null
computeHumidex(null, 13.0.celsius) shouldBe null
computeHumidex(20.0.celsius, null) shouldBe null
computeHumidex(20.0.celsius, 13.0.celsius)?.inCelsius shouldBe 22.8
computeHumidex(39.0.celsius, 26.0.celsius)?.inCelsius shouldBe 52.5
}
}

View file

@ -30,7 +30,7 @@ You can omit any of the following properties to let the user configure their own
*Instructions for members of the organization.*
1) Test your debug build.
2) Run tests `./gradlew testBasicReleaseUnitTest`.
2) Run tests `./gradlew testBasicDebugUnitTest` and `./gradlew testDebugUnitTest`.
3) Try to assemble a release `./gradlew assembleBasicRelease`.
4) Update versionCode and versionName in `app/build.gradle`.
5) Write changelog in `CHANGELOG.md`.

View file

@ -7,6 +7,12 @@ android {
namespace = "com.google.maps.android"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
}
dependencies {
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform)
}

View file

@ -1,3 +1,19 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package com.google.maps.android
import com.google.maps.android.model.LatLng

View file

@ -12,6 +12,7 @@ Some precision may be lost during conversions.
Remains to do:
- Add missing non-English Android translations (for the units we use in Breezy Weather)
- Unit testing
- Plus and minus operations
- Parse from string

View file

@ -15,4 +15,7 @@ android {
dependencies {
implementation(libs.annotation.jvm)
implementation(libs.core.ktx)
testImplementation(libs.bundles.test)
testRuntimeOnly(libs.junit.platform)
}

View file

@ -213,7 +213,7 @@ interface WeatherUnit {
locale = locale,
showSign = showSign,
useNumberFormatter = useNumberFormatter,
useMeasureFormat = useMeasureFormat
useNumberFormat = useMeasureFormat
)
)

View file

@ -46,7 +46,7 @@ interface WeatherValue<T : WeatherUnit> {
decimals = unit.getPrecision(width),
locale = locale,
useNumberFormatter = useNumberFormatter,
useMeasureFormat = useMeasureFormat
useNumberFormat = useMeasureFormat
)
}

View file

@ -0,0 +1,65 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.computing
import org.breezyweather.unit.ratio.Ratio
import org.breezyweather.unit.ratio.Ratio.Companion.fraction
import org.breezyweather.unit.temperature.Temperature
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import kotlin.math.exp
import kotlin.math.ln
/**
* Compute relative humidity from temperature and dew point
* Uses Magnus approximation with Arden Buck best variable set
* TODO: Unit test
*/
fun computeRelativeHumidity(
temperature: Temperature?,
dewPoint: Temperature?,
): Ratio? {
if (temperature == null || dewPoint == null) return null
val b = if (temperature < 0.celsius) 17.966 else 17.368
val c = if (temperature < 0.celsius) 227.15 else 238.88 // °C
return (
exp((b * dewPoint.inCelsius).div(c + dewPoint.inCelsius)) /
exp((b * temperature.inCelsius).div(c + temperature.inCelsius))
).fraction
}
/**
* Compute dew point from temperature and relative humidity
* Uses Magnus approximation with Arden Buck best variable set
* TODO: Unit test
*
* @param temperature
* @param relativeHumidity
*/
fun computeDewPoint(
temperature: Temperature?,
relativeHumidity: Ratio?,
): Temperature? {
if (temperature == null || relativeHumidity == null) return null
val b = if (temperature < 0.celsius) 17.966 else 17.368
val c = if (temperature < 0.celsius) 227.15 else 238.88 // °C
val magnus = ln(relativeHumidity.inFraction) + (b * temperature.inCelsius) / (c + temperature.inCelsius)
return ((c * magnus) / (b - magnus)).celsius
}

View file

@ -0,0 +1,76 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.computing
import org.breezyweather.unit.pressure.Pressure
import org.breezyweather.unit.temperature.Temperature
/**
* TODO: Use our typed values
* Compute pollutant concentration in µg/ when given in ppb.
* Can also be used for converting to mg/ from ppm.
* Source: https://en.wikipedia.org/wiki/Useful_conversions_and_formulas_for_air_dispersion_modeling
*
* Basis for temperature and pressure assumptions:
* https://www.ecfr.gov/current/title-40/chapter-I/subchapter-C/part-50/section-50.3
*
* @param molecularMass
* @param concentrationInPpb in ppb
* @param temperature assumed 25 °C if omitted
* @param barometricPressure assumed 1 atm = 1013.25 hPa if omitted
*/
fun computePollutantInUgm3FromPpb(
molecularMass: Double?,
concentrationInPpb: Double?,
temperature: Temperature? = null,
barometricPressure: Pressure? = null,
): Double? {
if (concentrationInPpb == null) return null
if (molecularMass == null) return null
return concentrationInPpb *
molecularMass /
(8.31446261815324 / (barometricPressure?.inHectopascals ?: 1013.25) * 10) /
(273.15 + (temperature?.inCelsius ?: 25.0))
}
/**
* TODO: Use our typed values
* Compute pollutant concentration in ppb from µg/
* Can also be used for converting to ppm from mg/
* Source: https://en.wikipedia.org/wiki/Useful_conversions_and_formulas_for_air_dispersion_modeling
*
* Basis for temperature and pressure assumptions:
* https://www.ecfr.gov/current/title-40/chapter-I/subchapter-C/part-50/section-50.3
*
* @param molecularMass
* @param concentrationInUgm3 in µg/
* @param temperature assumed 25 °C if omitted
* @param barometricPressure assumed 1 atm = 1013.25 hPa if omitted
*/
fun computePollutantInPpbFromUgm3(
molecularMass: Double?,
concentrationInUgm3: Double?,
temperature: Temperature? = null,
barometricPressure: Pressure? = null,
): Double? {
if (concentrationInUgm3 == null) return null
if (molecularMass == null) return null
return concentrationInUgm3 /
molecularMass *
(8.31446261815324 / (barometricPressure?.inHectopascals ?: 1013.25) * 10) *
(273.15 + (temperature?.inCelsius ?: 25.0))
}

View file

@ -0,0 +1,79 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.computing
import org.breezyweather.unit.pressure.Pressure
import org.breezyweather.unit.ratio.Ratio
import org.breezyweather.unit.ratio.Ratio.Companion.fraction
import org.breezyweather.unit.speed.Speed
import org.breezyweather.unit.speed.Speed.Companion.kilometersPerHour
import org.breezyweather.unit.temperature.Temperature
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
import kotlin.math.pow
/**
* TODO: Use our typed values
* Compute mean sea level pressure (MSLP) from barometric pressure and altitude.
* Optional elements can be provided for minor adjustments.
* Source: https://integritext.net/DrKFS/correctiontosealevel.htm
*
* To compute barometric pressure from MSLP,
* simply enter negative altitude.
*
* @param barometricPressure in hPa
* @param altitude in meters
* @param temperature in °C (optional)
* @param humidity in % (optional)
* @param latitude in ° (optional)
*/
fun computeMeanSeaLevelPressure(
barometricPressure: Double?,
altitude: Double?,
temperature: Double? = null,
humidity: Double? = null,
latitude: Double? = null,
): Double? {
// There is nothing to calculate if barometric pressure or altitude is null.
if (barometricPressure == null || altitude == null) {
return null
}
// Source: http://www.bom.gov.au/info/thermal_stress/#atapproximation
val waterVaporPressure = if (humidity != null && temperature != null) {
humidity / 100 * 6.105 * exp(17.27 * temperature / (237.7 + temperature))
} else {
0.0
}
// adjustment for temperature
val term1 = 1.0 + 0.0037 * (temperature ?: 0.0)
// adjustment for humidity
val term2 = 1.0 / (1.0 - 0.378 * waterVaporPressure / barometricPressure)
// adjustment for asphericity of the Earth
val term3 = 1.0 / (1.0 - 0.0026 * cos(2 * (latitude ?: 45.0) * Math.PI / 180))
// adjustment for variation of gravitational acceleration with height
val term4 = 1.0 + (altitude / 6367324)
return (10.0).pow(log10(barometricPressure) + altitude / (18400.0 * term1 * term2 * term3 * term4))
}

View file

@ -0,0 +1,104 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.computing
import org.breezyweather.unit.pressure.Pressure
import org.breezyweather.unit.ratio.Ratio
import org.breezyweather.unit.ratio.Ratio.Companion.fraction
import org.breezyweather.unit.speed.Speed
import org.breezyweather.unit.speed.Speed.Companion.kilometersPerHour
import org.breezyweather.unit.temperature.Temperature
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.log10
import kotlin.math.pow
/**
* Compute apparent temperature from temperature, relative humidity, and wind speed
* Uses Bureau of Meteorology Australia methodology
* Source: http://www.bom.gov.au/info/thermal_stress/#atapproximation
* TODO: Unit test
*
* @param temperature
* @param relativeHumidity
* @param windSpeed
*/
fun computeApparentTemperature(
temperature: Temperature?,
relativeHumidity: Ratio?,
windSpeed: Speed?,
): Temperature? {
if (temperature == null || relativeHumidity == null || windSpeed == null) return null
val e = relativeHumidity.inFraction * 6.105 * exp(17.27 * temperature.inCelsius / (237.7 + temperature.inCelsius))
return (temperature.inCelsius + 0.33 * e - 0.7 * windSpeed.inMetersPerSecond - 4.0).celsius
}
/**
* Compute wind chill from temperature and wind speed
* Uses Environment Canada methodology
* Source: https://climate.weather.gc.ca/glossary_e.html#w
* Only valid for (T 0 °C) or (T 10°C and WS 5 km/h)
* TODO: Unit test
*
* @param temperature
* @param windSpeed
*/
fun computeWindChillTemperature(
temperature: Temperature?,
windSpeed: Speed?,
): Temperature? {
if (temperature == null || windSpeed == null || temperature > 10.celsius) return null
return if (windSpeed >= 5.kilometersPerHour) {
(
13.12 +
(0.6215 * temperature.inCelsius) -
(11.37 * windSpeed.inKilometersPerHour.pow(0.16)) +
(0.3965 * temperature.inCelsius * windSpeed.inKilometersPerHour.pow(0.16))
).celsius
} else if (temperature <= 0.celsius) {
(temperature.inCelsius + ((-1.59 + 0.1345 * temperature.inCelsius) / 5.0) * windSpeed.inKilometersPerHour)
.celsius
} else {
null
}
}
/**
* Compute humidex from temperature and humidity
* Based on formula from ECCC
*
* @param temperature
* @param dewPoint
*/
fun computeHumidex(
temperature: Temperature?,
dewPoint: Temperature?,
): Temperature? {
if (temperature == null || dewPoint == null || temperature < 15.celsius) return null
return (
temperature.inCelsius +
0.5555.times(
6.11.times(
exp(5417.7530.times(1.div(273.15) - 1.div(273.15 + dewPoint.inCelsius)))
).minus(10)
)
).celsius
}

View file

@ -59,7 +59,7 @@ fun Duration.formatValue(
decimals = unit.getPrecision(width),
locale = locale,
useNumberFormatter = useNumberFormatter,
useMeasureFormat = useMeasureFormat
useNumberFormat = useMeasureFormat
)
}

View file

@ -35,7 +35,7 @@ fun Number.format(
locale: Locale = Locale.getDefault(),
showSign: Boolean = false,
useNumberFormatter: Boolean = true,
useMeasureFormat: Boolean = true,
useNumberFormat: Boolean = true,
): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && useNumberFormatter) {
(NumberFormatter.withLocale(locale) as LocalizedNumberFormatter)
@ -43,7 +43,7 @@ fun Number.format(
.sign(if (showSign) NumberFormatter.SignDisplay.ALWAYS else NumberFormatter.SignDisplay.AUTO)
.format(this)
.toString()
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && useMeasureFormat && !showSign) {
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && useNumberFormat && !showSign) {
// showSign not supported by NumberFormat, skip
NumberFormat.getNumberInstance(locale)
.apply { maximumFractionDigits = decimals }

View file

@ -93,7 +93,7 @@ enum class RatioUnit(
locale = locale,
showSign = showSign,
useNumberFormatter = useNumberFormatter,
useMeasureFormat = useMeasureFormat
useNumberFormat = useMeasureFormat
)
}

View file

@ -0,0 +1,37 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.computing
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import org.junit.jupiter.api.Test
/**
* TODO: To be completed
*/
class TemperatureComputingTest {
@Test
fun computeHumidexTest() = runTest {
computeHumidex(null, null) shouldBe null
computeHumidex(null, 13.0.celsius) shouldBe null
computeHumidex(20.0.celsius, null) shouldBe null
computeHumidex(20.0.celsius, 13.0.celsius)?.inCelsius shouldBe 22.8
computeHumidex(39.0.celsius, 26.0.celsius)?.inCelsius shouldBe 52.5
}
}

View file

@ -0,0 +1,159 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.formatting
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.util.Locale
/**
* TODO: Currently, NumberFormatter and NumberFormat never works, as SDK is always 0
* TODO: Thousand separator tests
*/
class NumberFormattingTest {
@Test
fun `Format double with decimals and French-style number formatting, using NumberFormatter`() = runTest {
formatDoubleWithDecimalsAndFrenchLocale(useNumberFormatter = true, useNumberFormat = false)
}
@Test
fun `Format double with decimals and French-style number formatting, using NumberFormat`() = runTest {
formatDoubleWithDecimalsAndFrenchLocale(useNumberFormatter = false, useNumberFormat = true)
}
@Test
fun `Format double with decimals and French-style number formatting, using DecimalFormat`() = runTest {
formatDoubleWithDecimalsAndFrenchLocale(useNumberFormatter = false, useNumberFormat = false)
}
fun formatDoubleWithDecimalsAndFrenchLocale(
useNumberFormatter: Boolean,
useNumberFormat: Boolean,
) {
7.00646.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build(),
showSign = false,
useNumberFormatter = useNumberFormatter,
useNumberFormat = useNumberFormat
) shouldBe "7,01"
14.34234.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build(),
showSign = false,
useNumberFormatter = useNumberFormatter,
useNumberFormat = useNumberFormat
) shouldBe "14,34"
14.34834.format(
decimals = 2,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build(),
showSign = false,
useNumberFormatter = useNumberFormatter,
useNumberFormat = useNumberFormat
) shouldBe "14,35"
14.34834.format(
decimals = 3,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build(),
showSign = false,
useNumberFormatter = useNumberFormatter,
useNumberFormat = useNumberFormat
) shouldBe "14,348"
14.34864.format(
decimals = 3,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build(),
showSign = false,
useNumberFormatter = useNumberFormatter,
useNumberFormat = useNumberFormat
) shouldBe "14,349"
}
@Test
fun `Format double without values ending with a 0`() = runTest {
7.00246.format(
decimals = 2,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = false,
useNumberFormatter = true,
useNumberFormat = false
) shouldBe "7"
7.00246.format(
decimals = 2,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = false,
useNumberFormatter = false,
useNumberFormat = true
) shouldBe "7"
7.00246.format(
decimals = 2,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = false,
useNumberFormatter = false,
useNumberFormat = false
) shouldBe "7"
}
@Test
fun `Format int`() = runTest {
14.format(
decimals = 0,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = false,
useNumberFormatter = true,
useNumberFormat = false
) shouldBe "14"
14.format(
decimals = 0,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = false,
useNumberFormatter = false,
useNumberFormat = true
) shouldBe "14"
14.format(
decimals = 0,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = false,
useNumberFormatter = false,
useNumberFormat = false
) shouldBe "14"
}
@Test
fun `Format with leading sign`() = runTest {
7.3.format(
decimals = 1,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = true,
useNumberFormatter = true,
useNumberFormat = false
) shouldBe "+7.3"
7.3.format(
decimals = 1,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = true,
useNumberFormatter = false,
useNumberFormat = true
) shouldBe "+7.3"
7.3.format(
decimals = 1,
locale = Locale.Builder().setLanguage("en").setRegion("US").build(),
showSign = true,
useNumberFormatter = false,
useNumberFormat = false
) shouldBe "+7.3"
}
}

View file

@ -0,0 +1,117 @@
/*
* This file is part of Breezy Weather.
*
* Breezy Weather is free software: you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by the
* Free Software Foundation, version 3 of the License.
*
* Breezy Weather is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
* License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Breezy Weather. If not, see <https://www.gnu.org/licenses/>.
*/
package org.breezyweather.unit.temperature
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.breezyweather.unit.temperature.Temperature.Companion.celsius
import org.breezyweather.unit.temperature.Temperature.Companion.deciCelsius
import org.breezyweather.unit.temperature.Temperature.Companion.fahrenheit
import org.junit.jupiter.api.Test
/**
* TODO:
* - Deviation conversion
* - Formatting with Android 16 (currently, SDK is always 0)
* - Display name
*/
class TemperatureTest {
@Test
fun `convert from reference unit to Fahrenheit`() = runTest {
0.deciCelsius.inFahrenheit shouldBe 32.0
100.deciCelsius.inFahrenheit shouldBe 50.0
}
@Test
fun `convert from Fahrenheit to reference unit`() = runTest {
32.0.fahrenheit.value shouldBe 0L
50.0.fahrenheit.value shouldBe 100L
}
@Test
fun `convert from Celsius to Fahrenheit`() = runTest {
0.celsius.inFahrenheit shouldBe 32.0
10.celsius.inFahrenheit shouldBe 50.0
}
@Test
fun `convert from Celsius to Kelvin`() = runTest {
0.celsius.inKelvins shouldBe 273.15
10.celsius.inKelvins shouldBe 283.15
}
/*@Test
fun `format Celsius with narrow unit width`() = runTest {
val context = mockk<Context>().apply {
every { getString(any()) } returns "FAILED"
every { getString(org.breezyweather.unit.R.string.temperature_c_nominative_narrow, any()) } returns "%s°"
every { getString(org.breezyweather.unit.R.string.temperature_k_nominative_narrow, any()) } returns "%sK"
}
101.4.deciCelsius.format(
context = context,
unit = TemperatureUnit.CELSIUS,
valueWidth = UnitWidth.NARROW,
unitWidth = UnitWidth.NARROW,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "10°"
101.4.deciCelsius.format(
context = context,
unit = TemperatureUnit.CELSIUS,
valueWidth = UnitWidth.SHORT,
unitWidth = UnitWidth.NARROW,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "10,1°"
0.deciCelsius.format(
context = context,
unit = TemperatureUnit.KELVIN,
valueWidth = UnitWidth.LONG,
unitWidth = UnitWidth.NARROW,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "273,15K"
}
@Test
fun `format Celsius with short unit width`() = runTest {
val context = mockk<Context>().apply {
every { getString(any()) } returns "FAILED"
every { getString(org.breezyweather.unit.R.string.temperature_c_nominative_short, any()) } returns "%s °C"
every { getString(org.breezyweather.unit.R.string.temperature_k_nominative_short, any()) } returns "%s K"
}
101.4.deciCelsius.format(
context = context,
unit = TemperatureUnit.CELSIUS,
valueWidth = UnitWidth.NARROW,
unitWidth = UnitWidth.SHORT,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "10 °C"
101.4.deciCelsius.format(
context = context,
unit = TemperatureUnit.CELSIUS,
valueWidth = UnitWidth.SHORT,
unitWidth = UnitWidth.SHORT,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "10,1 °C"
0.deciCelsius.format(
context = context,
unit = TemperatureUnit.KELVIN,
valueWidth = UnitWidth.LONG,
unitWidth = UnitWidth.SHORT,
locale = Locale.Builder().setLanguage("fr").setRegion("FR").build()
) shouldBe "273,15 K"
}*/
}