Skip to content

Commit

Permalink
3.4 (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
spacemanspiff2007 authored Dec 3, 2024
1 parent 21c7641 commit 68f44c9
Show file tree
Hide file tree
Showing 15 changed files with 140 additions and 33 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
]
4 changes: 4 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/operations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
======================================

Expand Down
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions requirements_setup.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/sml2mqtt/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '3.3'
__version__ = '3.4'
4 changes: 3 additions & 1 deletion src/sml2mqtt/config/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Annotated

from easyconfig import AppBaseModel, BaseModel, create_app_config
from pydantic import Field

Expand Down Expand Up @@ -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',)


Expand Down
11 changes: 10 additions & 1 deletion src/sml2mqtt/config/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -------------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/sml2mqtt/sml_value/operations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 45 additions & 3 deletions src/sml2mqtt/sml_value/operations/math.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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'<RoundToMultiple: value={self.multiple} round={self.__mode_str()} at 0x{id(self):x}>'

@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()}'
5 changes: 4 additions & 1 deletion src/sml2mqtt/sml_value/setup_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
RangeFilter,
RefreshAction,
Round,
RoundToMultiple,
Sequence,
ThrottleFilter,
VirtualMeter,
Expand All @@ -41,6 +42,7 @@
RangeFilterOperation,
RefreshActionOperation,
RoundOperation,
RoundToMultipleOperation,
SequenceOperation,
ThrottleFilterOperation,
VirtualMeterOperation,
Expand All @@ -67,14 +69,15 @@ def create_sequence(operations: list[OperationsType]):
OnChangeFilter: OnChangeFilterOperation,
HeartbeatAction: HeartbeatActionOperation,
DeltaFilter: DeltaFilterOperation,
RangeFilter: RangeFilterOperation,

RefreshAction: RefreshActionOperation,
ThrottleFilter: ThrottleFilterOperation,

Factor: FactorOperation,
Offset: OffsetOperation,
Round: RoundOperation,
RangeFilter: RangeFilterOperation,
RoundToMultiple: RoundToMultipleOperation,

NegativeOnEnergyMeterWorkaround: create_workaround_negative_on_energy_meter,

Expand Down
62 changes: 43 additions & 19 deletions tests/sml_values/test_operations/test_math.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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}']
)
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ commands =

[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function

markers =
ignore_log_errors: Ignore logged errors
Expand Down

0 comments on commit 68f44c9

Please sign in to comment.