diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4084078..85714c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.8.1 hooks: - id: ruff name: ruff unused imports diff --git a/docs/conf.py b/docs/conf.py index 7fa10c2..22fd80a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,7 +89,7 @@ if not RTD_BUILD: nitpick_ignore_regex = [ (re.compile(r'^py:data'), re.compile(r'typing\..+')), - (re.compile(r'^py:class'), re.compile(r'pydantic_core\..+')), + (re.compile(r'^py:class'), re.compile(r'(?:pydantic_core|pydantic)\..+')), # WARNING: py:class reference target not found: sml2mqtt.config.operations.Annotated (re.compile(r'^py:class'), re.compile(r'.+\.Annotated')), ] diff --git a/docs/index.rst b/docs/index.rst index 5df18bc..8795360 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,10 @@ from energy meters and report the values through mqtt. The meters can be read through serial ports or through http (e.g. Tibber) and the values that will be reported can be processed in various ways with operations. +For reading through the serial port an USB to IR adapter is required. +These are sometimes also called "Hichi" reader + + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/docs/operations.rst b/docs/operations.rst index 883fe94..f10197e 100644 --- a/docs/operations.rst +++ b/docs/operations.rst @@ -190,6 +190,24 @@ Example round: 2 +Round To Multiple +-------------------------------------- + +.. autopydantic_model:: RoundToMultiple + :inherited-members: BaseModel + +Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. + YamlModel: RoundToMultiple + +.. code-block:: yaml + + type: round to multiple + value: 20 + round: down + + Workarounds ====================================== diff --git a/readme.md b/readme.md index ef83b51..eef7c38 100644 --- a/readme.md +++ b/readme.md @@ -21,6 +21,10 @@ To read from the serial port an IR to USB reader for energy meter is required. # Changelog +#### 3.4 (2024-12-03) +- Allow rounding to the multiple of a value +- Updated dependencies + #### 3.3 (2024-11-26) - Updated dependencies and docs - Allow rounding to the tenth diff --git a/requirements.txt b/requirements.txt index aa37826..d0fd427 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -r requirements_setup.txt # Testing -pytest == 8.3.3 +pytest == 8.3.4 pre-commit == 4.0.1 pytest-asyncio == 0.24.0 aioresponses == 0.7.7 # Linter -ruff == 0.8.0 +ruff == 0.8.1 diff --git a/requirements_setup.txt b/requirements_setup.txt index 7b031fd..8eebdba 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,6 +1,6 @@ aiomqtt == 2.3.0 pyserial-asyncio == 0.6 easyconfig == 0.3.2 -pydantic == 2.8.2 +pydantic == 2.10.2 smllib == 1.5 -aiohttp == 3.11.7 +aiohttp == 3.11.9 diff --git a/src/sml2mqtt/__version__.py b/src/sml2mqtt/__version__.py index 83935d5..36f7773 100644 --- a/src/sml2mqtt/__version__.py +++ b/src/sml2mqtt/__version__.py @@ -1 +1 @@ -__version__ = '3.3' +__version__ = '3.4' diff --git a/src/sml2mqtt/config/config.py b/src/sml2mqtt/config/config.py index 144588c..78616f3 100644 --- a/src/sml2mqtt/config/config.py +++ b/src/sml2mqtt/config/config.py @@ -1,3 +1,5 @@ +from typing import Annotated + from easyconfig import AppBaseModel, BaseModel, create_app_config from pydantic import Field @@ -42,7 +44,7 @@ class Settings(AppBaseModel): logging: LoggingSettings = Field(default_factory=LoggingSettings) mqtt: MqttConfig = Field(default_factory=MqttConfig) general: GeneralSettings = Field(default_factory=GeneralSettings) - inputs: list[HttpSourceSettings | SerialSourceSettings] = Field(default_factory=list, discriminator='type') + inputs: list[Annotated[HttpSourceSettings | SerialSourceSettings, Field(discriminator='type')]] = [] devices: dict[LowerStr, SmlDeviceConfig] = Field({}, description='Device configuration by ID or url',) diff --git a/src/sml2mqtt/config/operations.py b/src/sml2mqtt/config/operations.py index 8514c83..43a3d31 100644 --- a/src/sml2mqtt/config/operations.py +++ b/src/sml2mqtt/config/operations.py @@ -112,6 +112,15 @@ class Round(BaseModel): digits: int = Field(ge=-4, le=6, alias='round', description='Round to the specified digits, negative for tens') +class RoundToMultiple(BaseModel): + """Rounds to the multiple of a given value. + """ + + type: Literal['round to multiple'] + value: int = Field(description='Round to the multiple of the given value') + round: Literal['up', 'down', 'nearest'] = Field(description='Round up, down or to the nearset multiple') + + # ------------------------------------------------------------------------------------------------- # Workarounds # ------------------------------------------------------------------------------------------------- @@ -264,7 +273,7 @@ class MeanOfInterval(HasIntervalFields): OperationsModels = ( OnChangeFilter, DeltaFilter, HeartbeatAction, RangeFilter, RefreshAction, ThrottleFilter, - Factor, Offset, Round, + Factor, Offset, Round, RoundToMultiple, NegativeOnEnergyMeterWorkaround, Or, Sequence, VirtualMeter, MaxValue, MinValue, diff --git a/src/sml2mqtt/sml_value/operations/__init__.py b/src/sml2mqtt/sml_value/operations/__init__.py index f2b5694..d812620 100644 --- a/src/sml2mqtt/sml_value/operations/__init__.py +++ b/src/sml2mqtt/sml_value/operations/__init__.py @@ -7,7 +7,7 @@ SkipZeroMeterOperation, ThrottleFilterOperation, ) -from .math import FactorOperation, OffsetOperation, RoundOperation +from .math import FactorOperation, OffsetOperation, RoundOperation, RoundToMultipleOperation from .operations import OrOperation, SequenceOperation from .time_series import MaxOfIntervalOperation, MeanOfIntervalOperation, MinOfIntervalOperation from .workarounds import NegativeOnEnergyMeterWorkaroundOperation diff --git a/src/sml2mqtt/sml_value/operations/math.py b/src/sml2mqtt/sml_value/operations/math.py index bca186d..3aa4444 100644 --- a/src/sml2mqtt/sml_value/operations/math.py +++ b/src/sml2mqtt/sml_value/operations/math.py @@ -1,5 +1,6 @@ from collections.abc import Generator -from typing import Final +from math import ceil, floor +from typing import Final, Literal from typing_extensions import override @@ -51,8 +52,6 @@ def process_value(self, value: float | None, info: SmlValueInfo) -> float | None if value is None: return None - if isinstance(value, int): - return value return round(value, self.digits) def __repr__(self) -> str: @@ -61,3 +60,46 @@ def __repr__(self) -> str: @override def describe(self, indent: str = '') -> Generator[str, None, None]: yield f'{indent:s}- Round: {self.digits if self.digits is not None else "integer"}' + + +class RoundToMultipleOperation(ValueOperationBase): + # noinspection PyShadowingBuiltins + def __init__(self, value: int, round: Literal['up', 'down', 'nearest']) -> None: # noqa: A002 + self.multiple: Final = value + + self.round_up: Final = round == 'up' + self.round_down: Final = round == 'down' + + @override + def process_value(self, value: float | None, info: SmlValueInfo) -> float | None: + if value is None: + return None + + if self.round_up: + return self.multiple * int(ceil(value / self.multiple)) + if self.round_down: + return self.multiple * int(floor(value / self.multiple)) + + multiple = self.multiple + div, rest = divmod(value, multiple) + div = int(div) + + if rest >= 0.5 * multiple: + return (div + 1) * multiple + return div * multiple + + def __mode_str(self) -> str: + if self.round_up: + return 'up' + if self.round_down: + return 'down' + return 'nearest' + + def __repr__(self) -> str: + return f'' + + @override + def describe(self, indent: str = '') -> Generator[str, None, None]: + yield f'{indent:s}- Round To Multiple:' + yield f'{indent:s} value: {self.multiple}' + yield f'{indent:s} round: {self.__mode_str()}' diff --git a/src/sml2mqtt/sml_value/setup_operations.py b/src/sml2mqtt/sml_value/setup_operations.py index f0b476c..537cdbd 100644 --- a/src/sml2mqtt/sml_value/setup_operations.py +++ b/src/sml2mqtt/sml_value/setup_operations.py @@ -20,6 +20,7 @@ RangeFilter, RefreshAction, Round, + RoundToMultiple, Sequence, ThrottleFilter, VirtualMeter, @@ -41,6 +42,7 @@ RangeFilterOperation, RefreshActionOperation, RoundOperation, + RoundToMultipleOperation, SequenceOperation, ThrottleFilterOperation, VirtualMeterOperation, @@ -67,6 +69,7 @@ def create_sequence(operations: list[OperationsType]): OnChangeFilter: OnChangeFilterOperation, HeartbeatAction: HeartbeatActionOperation, DeltaFilter: DeltaFilterOperation, + RangeFilter: RangeFilterOperation, RefreshAction: RefreshActionOperation, ThrottleFilter: ThrottleFilterOperation, @@ -74,7 +77,7 @@ def create_sequence(operations: list[OperationsType]): Factor: FactorOperation, Offset: OffsetOperation, Round: RoundOperation, - RangeFilter: RangeFilterOperation, + RoundToMultiple: RoundToMultipleOperation, NegativeOnEnergyMeterWorkaround: create_workaround_negative_on_energy_meter, diff --git a/tests/sml_values/test_operations/test_math.py b/tests/sml_values/test_operations/test_math.py index 11e9d1d..b992c7a 100644 --- a/tests/sml_values/test_operations/test_math.py +++ b/tests/sml_values/test_operations/test_math.py @@ -1,50 +1,68 @@ from tests.sml_values.test_operations.helper import check_description, check_operation_repr +from sml2mqtt.sml_value.base import ValueOperationBase from sml2mqtt.sml_value.operations import ( FactorOperation, OffsetOperation, RoundOperation, + RoundToMultipleOperation, ) +def check_values(o: ValueOperationBase, *values: tuple[float, float]) -> None: + assert o.process_value(None, None) is None + + for _in, _out in values: + _res = o.process_value(_in, None) + assert _res == _out, f'in: {_in} out: {_res} expected: {_out}' + assert isinstance(_res, type(_out)) + + def test_factor() -> None: o = FactorOperation(5) check_operation_repr(o, '5') - assert o.process_value(None, None) is None - assert o.process_value(5, None) == 25 - assert o.process_value(1.25, None) == 6.25 - assert o.process_value(-3, None) == -15 + check_values(o, (5, 25), (1.25, 6.25), (-3, -15)) def test_offset() -> None: o = OffsetOperation(-5) check_operation_repr(o, '-5') - assert o.process_value(None, None) is None - assert o.process_value(5, None) == 0 - assert o.process_value(1.25, None) == -3.75 - assert o.process_value(-3, None) == -8 + check_values(o, (5, 0), (1.25, -3.75), (-3, -8)) def test_round() -> None: o = RoundOperation(0) check_operation_repr(o, '0') - - assert o.process_value(None, None) is None - assert o.process_value(5, None) == 5 - assert o.process_value(1.25, None) == 1 - assert o.process_value(-3, None) == -3 - assert o.process_value(-3.65, None) == -4 + check_values(o, (5, 5), (1.25, 1), (-3, -3), (-3.65, -4)) o = RoundOperation(1) check_operation_repr(o, '1') + check_values(o, (5, 5), (1.25, 1.2), (-3, -3), (-3.65, -3.6)) + + o = RoundOperation(-1) + check_operation_repr(o, '-1') + check_values(o, (5, 0), (6, 10), (-5, 0), (-6, -10)) - assert o.process_value(None, None) is None - assert o.process_value(5, None) == 5 - assert o.process_value(1.25, None) == 1.2 - assert o.process_value(-3, None) == -3 - assert o.process_value(-3.65, None) == -3.6 + +def test_round_to_value() -> None: + o = RoundToMultipleOperation(20, 'up') + check_operation_repr(o, 'value=20 round=up') + check_values( + o, (0, 0), (0.0001, 20), (20, 20), (39.999999, 40), (40, 40), (40.000001, 60), (-20, -20), (-20.000001, -20)) + + o = RoundToMultipleOperation(20, 'down') + check_operation_repr(o, 'value=20 round=down') + check_values( + o, (0, 0), (0.0001, 0), (20, 20), (39.999999, 20), (40, 40), (40.000001, 40), (-20, -20), (-20.000001, -40)) + + o = RoundToMultipleOperation(20, 'nearest') + check_operation_repr(o, 'value=20 round=nearest') + check_values( + o, (0, 0), (9.9999, 0), (10, 20), (29.999999, 20), (30, 40), (40.000001, 40), (-20, -20), + (-30, -20), (-30.1, -40) + ) def test_description() -> None: @@ -77,3 +95,9 @@ def test_description() -> None: RoundOperation(1), '- Round: 1' ) + + for mode in 'up', 'down', 'nearest': + check_description( + RoundToMultipleOperation(50, mode), + ['- Round To Multiple:', ' value: 50', f' round: {mode:s}'] + ) diff --git a/tox.ini b/tox.ini index fc4e20f..dde1b4f 100644 --- a/tox.ini +++ b/tox.ini @@ -47,6 +47,7 @@ commands = [pytest] asyncio_mode = auto +asyncio_default_fixture_loop_scope = function markers = ignore_log_errors: Ignore logged errors