diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index cb0783c6bee..6a1cab5e1bf 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -37,6 +37,8 @@ from homeassistant.const import ( LENGTH_MILES, PRESSURE_HPA, PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant @@ -45,6 +47,7 @@ from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as distance_convert from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.speed import convert as speed_convert from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( @@ -166,7 +169,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): ) if wind_speed: wind_speed = round( - distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) data = { @@ -188,7 +194,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): wind_gust = self.wind_gust if wind_gust and self.hass.config.units.is_metric: wind_gust = round( - distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + self.wind_gust, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) cloud_cover = self.cloud_cover return { @@ -236,7 +245,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the wind speed.""" if self.hass.config.units.is_metric and self._wind_speed: return round( - distance_convert(self._wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + speed_convert( + self._wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ), + 4, ) return self._wind_speed diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index d938ede5cb2..04ce37a388e 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -27,11 +27,11 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_MILES, LENGTH_MILLIMETERS, PRESSURE_HPA, PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -46,6 +46,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from .const import ( ATTR_FORECAST_PRECIPITATION, @@ -226,7 +227,9 @@ class MetWeather(CoordinatorEntity, WeatherEntity): if self._is_metric or speed_km_h is None: return speed_km_h - speed_mi_h = convert_distance(speed_km_h, LENGTH_KILOMETERS, LENGTH_MILES) + speed_mi_h = convert_speed( + speed_km_h, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR + ) return int(round(speed_mi_h)) @property diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 3a8eed0d7be..ddd39d82c3b 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -13,11 +13,11 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, LENGTH_INCHES, - LENGTH_METERS, - LENGTH_MILES, LENGTH_MILLIMETERS, PRESSURE_HPA, PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.helpers.entity import DeviceInfo @@ -25,6 +25,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP @@ -130,8 +131,9 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): if self._is_metric or speed_m_s is None: return speed_m_s - speed_mi_s = convert_distance(speed_m_s, LENGTH_METERS, LENGTH_MILES) - speed_mi_h = speed_mi_s / 3600.0 + speed_mi_h = convert_speed( + speed_m_s, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR + ) return int(round(speed_mi_h)) @property diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 49387896962..35bbcef838d 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -4,12 +4,12 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, - LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, PERCENTAGE, PRESSURE_INHG, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) @@ -19,6 +19,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from . import base_unique_id, device_info from .const import ( @@ -86,7 +87,9 @@ class NWSSensor(CoordinatorEntity, SensorEntity): # Set alias to unit property -> prevent unnecessary hasattr calls unit_of_measurement = self.native_unit_of_measurement if unit_of_measurement == SPEED_MILES_PER_HOUR: - return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) + return round( + convert_speed(value, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) + ) if unit_of_measurement == LENGTH_MILES: return round(convert_distance(value, LENGTH_METERS, LENGTH_MILES)) if unit_of_measurement == PRESSURE_INHG: diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index dc76ebc25e5..3a795095c5d 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -19,6 +19,8 @@ from homeassistant.const import ( PRESSURE_HPA, PRESSURE_INHG, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -28,6 +30,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature from . import base_unique_id, device_info @@ -196,7 +199,9 @@ class NWSWeather(WeatherEntity): if self.is_metric: wind = wind_km_hr else: - wind = convert_distance(wind_km_hr, LENGTH_KILOMETERS, LENGTH_MILES) + wind = convert_speed( + wind_km_hr, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR + ) return round(wind) @property @@ -271,7 +276,9 @@ class NWSWeather(WeatherEntity): if wind_speed is not None: if self.is_metric: data[ATTR_FORECAST_WIND_SPEED] = round( - convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + convert_speed( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ) ) else: data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) diff --git a/homeassistant/const.py b/homeassistant/const.py index 229fe704348..3ce1a15cf0d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -695,7 +695,7 @@ MASS: Final = "mass" PRESSURE: Final = "pressure" VOLUME: Final = "volume" TEMPERATURE: Final = "temperature" -SPEED_MS: Final = "speed_ms" +SPEED: Final = "speed" ILLUMINANCE: Final = "illuminance" WEEKDAYS: Final[list[str]] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py new file mode 100644 index 00000000000..f3fc652e90f --- /dev/null +++ b/homeassistant/util/speed.py @@ -0,0 +1,56 @@ +"""Distance util functions.""" +from __future__ import annotations + +from numbers import Number + +from homeassistant.const import ( + SPEED, + SPEED_INCHES_PER_DAY, + SPEED_INCHES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, + UNIT_NOT_RECOGNIZED_TEMPLATE, +) + +VALID_UNITS: tuple[str, ...] = ( + SPEED_METERS_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, + SPEED_INCHES_PER_DAY, + SPEED_INCHES_PER_HOUR, +) + +HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +KM_TO_M = 1000 # 1 km = 1000 m +KM_TO_MILE = 0.62137119 # 1 km = 0.62137119 mi +M_TO_IN = 39.3700787 # 1 m = 39.3700787 in + +# Units in terms of m/s +UNIT_CONVERSION: dict[str, float] = { + SPEED_METERS_PER_SECOND: 1, + SPEED_KILOMETERS_PER_HOUR: HRS_TO_SECS / KM_TO_M, + SPEED_MILES_PER_HOUR: HRS_TO_SECS * KM_TO_MILE / KM_TO_M, + SPEED_MILLIMETERS_PER_DAY: (24 * HRS_TO_SECS) * 1000, + SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) * M_TO_IN, + SPEED_INCHES_PER_HOUR: HRS_TO_SECS * M_TO_IN, +} + + +def convert(value: float, unit_1: str, unit_2: str) -> float: + """Convert one unit of measurement to another.""" + if unit_1 not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, SPEED)) + if unit_2 not in VALID_UNITS: + raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, SPEED)) + + if not isinstance(value, Number): + raise TypeError(f"{value} is not of numeric type") + + if unit_1 == unit_2: + return value + + meters_per_second = value / UNIT_CONVERSION[unit_1] + return meters_per_second * UNIT_CONVERSION[unit_2] diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 4f4b140dbf9..c7387d4bf8c 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -25,11 +25,14 @@ from homeassistant.const import ( PRESSURE_HPA, PRESSURE_INHG, PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature NWS_CONFIG = { @@ -80,8 +83,12 @@ SENSOR_EXPECTED_OBSERVATION_IMPERIAL = { "windChill": str(round(convert_temperature(5, TEMP_CELSIUS, TEMP_FAHRENHEIT))), "heatIndex": str(round(convert_temperature(15, TEMP_CELSIUS, TEMP_FAHRENHEIT))), "relativeHumidity": "10", - "windSpeed": str(round(convert_distance(10, LENGTH_KILOMETERS, LENGTH_MILES))), - "windGust": str(round(convert_distance(20, LENGTH_KILOMETERS, LENGTH_MILES))), + "windSpeed": str( + round(convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR)) + ), + "windGust": str( + round(convert_speed(20, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR)) + ), "windDirection": "180", "barometricPressure": str( round(convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2) @@ -98,7 +105,7 @@ WEATHER_EXPECTED_OBSERVATION_IMPERIAL = { ), ATTR_WEATHER_WIND_BEARING: 180, ATTR_WEATHER_WIND_SPEED: round( - convert_distance(10, LENGTH_KILOMETERS, LENGTH_MILES) + convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) ), ATTR_WEATHER_PRESSURE: round( convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2 @@ -152,7 +159,7 @@ EXPECTED_FORECAST_METRIC = { ATTR_FORECAST_TIME: "2019-08-12T20:00:00-04:00", ATTR_FORECAST_TEMP: round(convert_temperature(10, TEMP_FAHRENHEIT, TEMP_CELSIUS)), ATTR_FORECAST_WIND_SPEED: round( - convert_distance(10, LENGTH_MILES, LENGTH_KILOMETERS) + convert_speed(10, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR) ), ATTR_FORECAST_WIND_BEARING: 180, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90, diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py new file mode 100644 index 00000000000..7f52c67ed50 --- /dev/null +++ b/tests/util/test_speed.py @@ -0,0 +1,70 @@ +"""Test Home Assistant speed utility functions.""" +import pytest + +from homeassistant.const import ( + SPEED_INCHES_PER_DAY, + SPEED_INCHES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, +) +import homeassistant.util.speed as speed_util + +INVALID_SYMBOL = "bob" +VALID_SYMBOL = SPEED_KILOMETERS_PER_HOUR + + +def test_convert_same_unit(): + """Test conversion from any unit to same unit.""" + assert speed_util.convert(2, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_DAY) == 2 + assert speed_util.convert(3, SPEED_INCHES_PER_HOUR, SPEED_INCHES_PER_HOUR) == 3 + assert ( + speed_util.convert(4, SPEED_KILOMETERS_PER_HOUR, SPEED_KILOMETERS_PER_HOUR) == 4 + ) + assert speed_util.convert(5, SPEED_METERS_PER_SECOND, SPEED_METERS_PER_SECOND) == 5 + assert speed_util.convert(6, SPEED_MILES_PER_HOUR, SPEED_MILES_PER_HOUR) == 6 + assert ( + speed_util.convert(7, SPEED_MILLIMETERS_PER_DAY, SPEED_MILLIMETERS_PER_DAY) == 7 + ) + + +def test_convert_invalid_unit(): + """Test exception is thrown for invalid units.""" + with pytest.raises(ValueError): + speed_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) + + with pytest.raises(ValueError): + speed_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) + + +def test_convert_nonnumeric_value(): + """Test exception is thrown for nonnumeric type.""" + with pytest.raises(TypeError): + speed_util.convert("a", SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) + + +@pytest.mark.parametrize( + "from_value, from_unit, expected, to_unit", + [ + # 5 km/h / 1.609 km/mi = 3.10686 mi/h + (5, SPEED_KILOMETERS_PER_HOUR, 3.10686, SPEED_MILES_PER_HOUR), + # 5 mi/h * 1.609 km/mi = 8.04672 km/h + (5, SPEED_MILES_PER_HOUR, 8.04672, SPEED_KILOMETERS_PER_HOUR), + # 5 in/day * 25.4 mm/in = 127 mm/day + (5, SPEED_INCHES_PER_DAY, 127, SPEED_MILLIMETERS_PER_DAY), + # 5 mm/day / 25.4 mm/in = 0.19685 in/day + (5, SPEED_MILLIMETERS_PER_DAY, 0.19685, SPEED_INCHES_PER_DAY), + # 5 in/hr * 24 hr/day = 3048 mm/day + (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), + # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 + (5, SPEED_METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), + # 5000 in/hr / 39.3701 in/m / 3600 s/hr = 0.03528 m/s + (5000, SPEED_INCHES_PER_HOUR, 0.03528, SPEED_METERS_PER_SECOND), + ], +) +def test_convert_different_units(from_value, from_unit, expected, to_unit): + """Test conversion between units.""" + assert speed_util.convert(from_value, from_unit, to_unit) == pytest.approx( + expected, rel=1e-4 + )