Skip to content

Commit a946072

Browse files
committed
timer: Refactor ramp generators and add FadeStepRamp
1 parent 39991d3 commit a946072

File tree

3 files changed

+138
-16
lines changed

3 files changed

+138
-16
lines changed

docs/timer.rst

+7
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,10 @@ Ramp Generators
6161
---------------
6262

6363
.. autoclass:: AbstractRamp
64+
65+
.. autoclass:: IntRamp
66+
.. autoclass:: FloatRamp
67+
.. autoclass:: HSVRamp
68+
.. autoclass:: RGBHSVRamp
69+
.. autoclass:: RGBWHSVRamp
70+
.. autoclass:: FadeStepRamp

shc/timer.py

+87-15
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from typing import List, Optional, Callable, Any, Type, Union, Tuple, Iterable, Generic, TypeVar
2121

2222
from .base import Subscribable, LogicHandler, Readable, Writable, T, UninitializedError, Reading
23-
from .datatypes import RangeFloat1, RangeUInt8, RangeInt0To100, HSVFloat1, RGBUInt8, RGBWUInt8
23+
from .datatypes import RangeFloat1, RangeUInt8, RangeInt0To100, HSVFloat1, RGBUInt8, RGBWUInt8, FadeStep, AbstractStep
2424
from .expressions import ExpressionWrapper
2525

2626
logger = logging.getLogger(__name__)
@@ -754,6 +754,17 @@ class AbstractRamp(Readable[T], Subscribable[T], Reading[T], Writable[T], Generi
754754
"""
755755
Abstract base class for all ramp generators
756756
757+
All ramp generators create smooth transitions from incoming value updates by splitting publishing multiple timed
758+
updates each doing a small step towards the target value. They are *Readable* and *Subscribable* to be used in
759+
:ref:`expressions`.
760+
761+
In addition, the Ramp generators are *Writable* and *Reading* in order to connect them to a stateful object (like a
762+
:class:`Variable <shc.variables.Variable>`) which also receives value updates from other sources. For this to work
763+
flawlessly, the Ramp generator will stop the current ramp in progress, when it receives a value (via :meth:`write`)
764+
from the connected object and it will *read* the current value of the connected object and use it as the start value
765+
for a ramp instead of the last value received from the wrapped object. Both of these features are optional, so the
766+
Ramp generator can also be connected to non-readable and non-subscribable objects.
767+
757768
Different derived ramp generator classes for different datatypes exist:
758769
759770
- :class:`IntRamp` (for :class:`int`, :class:`shc.datatypes.RangeUInt8` and :class:`shc.datatypes.RangeInt0To100`)
@@ -763,18 +774,16 @@ class AbstractRamp(Readable[T], Subscribable[T], Reading[T], Writable[T], Generi
763774
- :class:`RGBWHSVRamp` (for :class:`shc.datatypes.RGBWUInt8`; doing a linear ramp in HSV color space plus simple
764775
linear ramp for the white channel)
765776
766-
All ramp generators create smooth transitions from incoming value updates by splitting publishing multiple timed
767-
updates each doing a small step towards the target value. They are *Readable* and *Subscribable* to be used in
768-
:ref:`expressions`.
777+
All of these ramp generator types allow to wrap a Subscribable object of one of their value types to subscribe to
778+
its value updates to turn them into ramps. Alternatively, they can be initialized with only the concrete value type
779+
only, so the :meth:`ramp_to` method can be triggered manually from logic handlers etc.
769780
770-
In addition, the Ramp generators are *Writable* and *Reading* in order to connect them to a stateful object (like a
771-
:class:`Variable <shc.variables.Variable>`) which also receives value updates from other sources. For this to work
772-
flawlessly, the Ramp generator will stop the current ramp in progress, when it receives a value (via :meth:`write`)
773-
from the connected object and it will *read* the current value of the connected object and use it as the start value
774-
for a ramp instead of the last value recived from the wrapped object. Both of these features are optional, so the
775-
Ramp generator can also be connected to non-readable and non-subscribable objects.
781+
As a special case, there's the :class:`FadeStepRamp`, which is a RangeFloat1-typed Connectable object, which wraps
782+
a Subscribable object of type :class:`shc.datatypes.FadeStep` and creates ramps from the received fade steps
783+
(similar to :class:`shc.misc.FadeStepAdapter`).
784+
785+
In addition, all of them take the following init parameters:
776786
777-
:param wrapped: The subscribable object from which the value updates are transformed into smooth ramps.
778787
:param ramp_duration: The duration of the generated ramp/transition. Depending on `dynamic_duration` this is either
779788
the fixed duration of each ramp or it is the duration of a ramp across the full value range, which is
780789
dynamically lowered for smaller ramps.
@@ -792,11 +801,10 @@ class AbstractRamp(Readable[T], Subscribable[T], Reading[T], Writable[T], Generi
792801
"""
793802
is_reading_optional = False
794803

795-
def __init__(self, wrapped: Subscribable[T], ramp_duration: datetime.timedelta, dynamic_duration: bool = True,
804+
def __init__(self, type_: Type[T], ramp_duration: datetime.timedelta, dynamic_duration: bool = True,
796805
max_frequency: float = 25.0, enable_ramp: Optional[Readable[bool]] = None):
797-
self.type = wrapped.type
806+
self.type = type_
798807
super().__init__()
799-
wrapped.trigger(self.ramp_to, synchronous=True)
800808
self.ramp_duration = ramp_duration
801809
self.dynamic_duration = dynamic_duration
802810
self.max_frequency = max_frequency
@@ -843,6 +851,31 @@ async def ramp_to(self, value: T, origin: List[Any]) -> None:
843851
self.__task = task
844852
timer_supervisor.add_temporary_task(task)
845853

854+
async def ramp_by(self, step: AbstractStep[T], origin: List[Any]) -> None:
855+
"""
856+
Start a new ramp of the given step size
857+
"""
858+
begin = await self._from_provider()
859+
if begin is not None:
860+
self._current_value = begin
861+
if self._current_value is None:
862+
logger.warning("Cannot apply FadeStep, since current value is not available.")
863+
return
864+
865+
if self.enable_ramp is not None:
866+
enabled = await self.enable_ramp.read()
867+
if not enabled:
868+
if self.__task:
869+
self.__task.cancel()
870+
await self._publish_and_wait(step.apply_to(self._current_value), origin)
871+
return
872+
873+
self.__new_target_value = step.apply_to(self._current_value)
874+
if not self.__task:
875+
task = asyncio.get_event_loop().create_task(self._ramp())
876+
self.__task = task
877+
timer_supervisor.add_temporary_task(task)
878+
846879
async def _ramp(self) -> None:
847880
step = 0
848881
num_steps = 0
@@ -937,6 +970,14 @@ def _next_step(self, step: int) -> T:
937970

938971

939972
class IntRamp(AbstractRamp[IntRampT], Generic[IntRampT]):
973+
def __init__(self, wrapped_or_type: Union[Subscribable[IntRampT], Type[IntRampT]], *args, **kwargs):
974+
if isinstance(wrapped_or_type, Subscribable):
975+
type_: Type[IntRampT] = wrapped_or_type.type
976+
wrapped_or_type.trigger(self.ramp_to, synchronous=True)
977+
else:
978+
type_ = wrapped_or_type
979+
super().__init__(type_, *args, **kwargs) # type: ignore # Mypy does not understand that type_ *is* FloatRampT
980+
940981
def _calculate_ramp(self, begin: IntRampT, target: IntRampT) -> Tuple[float, int]:
941982
diff = abs(target-begin)
942983
height = (diff/255 if issubclass(self.type, RangeUInt8) else
@@ -955,7 +996,7 @@ def _next_step(self, step: int) -> IntRampT:
955996
FloatRampT = TypeVar('FloatRampT', float, RangeFloat1)
956997

957998

958-
class FloatRamp(AbstractRamp[FloatRampT], Generic[FloatRampT]):
999+
class AbstractFloatRamp(AbstractRamp[FloatRampT], Generic[FloatRampT]):
9591000
def _calculate_ramp(self, begin: FloatRampT, target: FloatRampT) -> Tuple[float, int]:
9601001
diff = abs(target-begin)
9611002
height = diff/1.0 if issubclass(self.type, RangeFloat1) else 1.0
@@ -969,6 +1010,22 @@ def _next_step(self, step: int) -> FloatRampT:
9691010
return self.type(self._begin + self._diff * step)
9701011

9711012

1013+
class FloatRamp(AbstractFloatRamp[FloatRampT], Generic[FloatRampT]):
1014+
def __init__(self, wrapped_or_type: Union[Subscribable[FloatRampT], Type[FloatRampT]], *args, **kwargs):
1015+
if isinstance(wrapped_or_type, Subscribable):
1016+
type_: Type[FloatRampT] = wrapped_or_type.type
1017+
wrapped_or_type.trigger(self.ramp_to, synchronous=True)
1018+
else:
1019+
type_ = wrapped_or_type
1020+
super().__init__(type_, *args, **kwargs) # type: ignore # Mypy does not understand that type_ *is* FloatRampT
1021+
1022+
1023+
class FadeStepRamp(AbstractFloatRamp[RangeFloat1]):
1024+
def __init__(self, wrapped: Subscribable[FadeStep], *args, **kwargs):
1025+
super().__init__(RangeFloat1, *args, **kwargs)
1026+
wrapped.trigger(self.ramp_by, synchronous=True)
1027+
1028+
9721029
def _normalize_hsv_ramp(begin: HSVFloat1, target: HSVFloat1) -> Tuple[HSVFloat1, HSVFloat1]:
9731030
return begin._replace(hue=begin.hue if begin.saturation != 0 else target.hue,
9741031
saturation=begin.saturation if begin.value != 0 else target.saturation),\
@@ -994,6 +1051,11 @@ def _hsv_step(begin: HSVFloat1, diff: Tuple[float, float, float], step: int) ->
9941051

9951052

9961053
class HSVRamp(AbstractRamp[HSVFloat1]):
1054+
def __init__(self, wrapped: Optional[Subscribable[HSVFloat1]], *args, **kwargs):
1055+
super().__init__(HSVFloat1, *args, **kwargs)
1056+
if wrapped is not None:
1057+
wrapped.trigger(self.ramp_to, synchronous=True)
1058+
9971059
def _calculate_ramp(self, begin: HSVFloat1, target: HSVFloat1) -> Tuple[float, int]:
9981060
diff = _hsv_diff(begin, target)
9991061
height = max(abs(diff[0] * 2), abs(diff[1]), abs(diff[2]))
@@ -1008,6 +1070,11 @@ def _next_step(self, step: int) -> HSVFloat1:
10081070

10091071

10101072
class RGBHSVRamp(AbstractRamp[RGBUInt8]):
1073+
def __init__(self, wrapped: Optional[Subscribable[RGBUInt8]], *args, **kwargs):
1074+
super().__init__(RGBUInt8, *args, **kwargs)
1075+
if wrapped is not None:
1076+
wrapped.trigger(self.ramp_to, synchronous=True)
1077+
10111078
def _calculate_ramp(self, begin: RGBUInt8, target: RGBUInt8) -> Tuple[float, int]:
10121079
begin_hsv, target_hsv = _normalize_hsv_ramp(HSVFloat1.from_rgb(begin.as_float()),
10131080
HSVFloat1.from_rgb(target.as_float()))
@@ -1028,6 +1095,11 @@ def _next_step(self, step: int) -> RGBUInt8:
10281095

10291096

10301097
class RGBWHSVRamp(AbstractRamp[RGBWUInt8]):
1098+
def __init__(self, wrapped: Optional[Subscribable[RGBWUInt8]], *args, **kwargs):
1099+
super().__init__(RGBWUInt8, *args, **kwargs)
1100+
if wrapped is not None:
1101+
wrapped.trigger(self.ramp_to, synchronous=True)
1102+
10311103
def _calculate_ramp(self, begin: RGBWUInt8, target: RGBWUInt8) -> Tuple[float, int]:
10321104
begin_hsv, target_hsv = _normalize_hsv_ramp(HSVFloat1.from_rgb(begin.rgb.as_float()),
10331105
HSVFloat1.from_rgb(target.rgb.as_float()))

test/test_timer.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,7 @@ async def test_stateful_target(self) -> None:
727727
writable1._write.assert_called_once_with(datatypes.RangeUInt8(128), [ramp1, variable1])
728728
writable1._write.reset_mock()
729729

730-
# Let's interrupt the ramp be sending a new value directly to the varible
730+
# Let's interrupt the ramp be sending a new value directly to the Variable
731731
await asyncio.sleep(0.25)
732732
await variable1.write(datatypes.RangeUInt8(192), [self])
733733

@@ -741,3 +741,46 @@ async def test_stateful_target(self) -> None:
741741
writable1._write.assert_called_once_with(datatypes.RangeUInt8(96), [ramp1, variable1])
742742
await asyncio.sleep(0.5)
743743
writable1._write.assert_called_with(datatypes.RangeUInt8(0), [ramp1, variable1])
744+
745+
@async_test
746+
async def test_fade_step_ramp(self) -> None:
747+
begin = datetime.datetime(2020, 12, 31, 23, 59, 46)
748+
749+
subscribable1 = ExampleSubscribable(datatypes.FadeStep)
750+
ramp1 = timer.FadeStepRamp(subscribable1, datetime.timedelta(seconds=1), max_frequency=2,
751+
dynamic_duration=False)
752+
variable1 = shc.Variable(datatypes.RangeFloat1).connect(ramp1)
753+
writable1 = ExampleWritable(datatypes.RangeFloat1).connect(variable1)
754+
755+
with ClockMock(begin, actual_sleep=0.05) as clock:
756+
with self.assertLogs() as l:
757+
await subscribable1.publish(datatypes.FadeStep(0.5), [self])
758+
await asyncio.sleep(0.05)
759+
self.assertIn("Cannot apply FadeStep", l.records[0].msg)
760+
writable1._write.assert_not_called()
761+
writable1._write.reset_mock()
762+
763+
await variable1.write(datatypes.RangeFloat1(0.0), [self])
764+
await asyncio.sleep(0.05)
765+
writable1._write.reset_mock()
766+
767+
await subscribable1.publish(datatypes.FadeStep(0.5), [self])
768+
await asyncio.sleep(0.05)
769+
# Assert first step
770+
writable1._write.assert_called_once_with(datatypes.RangeFloat1(0.25), [ramp1, variable1])
771+
writable1._write.reset_mock()
772+
773+
# Let's interrupt the ramp be sending a new value directly to the varible
774+
await asyncio.sleep(0.25)
775+
await variable1.write(datatypes.RangeFloat1(0.75), [self])
776+
777+
await asyncio.sleep(1.0)
778+
writable1._write.assert_called_once_with(datatypes.RangeFloat1(0.75), [self, variable1])
779+
writable1._write.reset_mock()
780+
781+
# And now, let's do a new ramp to 0.25, which should start at 0.75
782+
await subscribable1.publish(datatypes.FadeStep(-0.5), [self])
783+
await asyncio.sleep(0.05)
784+
writable1._write.assert_called_once_with(datatypes.RangeFloat1(0.5), [ramp1, variable1])
785+
await asyncio.sleep(0.5)
786+
writable1._write.assert_called_with(datatypes.RangeFloat1(0.25), [ramp1, variable1])

0 commit comments

Comments
 (0)