Repository: moonshot-nagayama-pj/PnPQ
Pull request #119: Add velparams functions to K10CR1 waveplate driver, stub and tests
Author: login: master-of-ppap
Created: 2025-04-15T08:29:00Z | Merged: 2025-04-17T13:09:23Z

Helps in closing #104

## Additions
* Added `get_velparams` and `set_velparams` to `WaveplateThorlabsK10CR1` driver.
* Added three new messages `MGMSG_MOT_SET_VELPARAMS`, `MGMSG_MOT_REQ_VELPARAMS` and `MGMSG_MOT_GET_VELPARAMS`. 
  * Also added the corresponding unit tests.
* Also updated the stub K10CR1 device as well.
* Added a `current_velocity_params` to save the current parameters on the stub device.
* Updated the protocol to have the message store data in a `Quantity` instead of `int` so users can easily change units.



Diff:
diff --git a/hardware_tests/test_thorlabs_k10cr1.py b/hardware_tests/test_thorlabs_k10cr1.py
index 6ec6805..a51005b 100644
--- a/hardware_tests/test_thorlabs_k10cr1.py
+++ b/hardware_tests/test_thorlabs_k10cr1.py
@@ -1,3 +1,5 @@
+from typing import Generator
+
 import pytest
 
 from pnpq.apt.connection import AptConnection
@@ -5,11 +7,12 @@
 from pnpq.units import pnpq_ureg
 
 
-@pytest.fixture(name="device", scope="module")
-def device_fixture() -> WaveplateThorlabsK10CR1:
-    connection = AptConnection(serial_number="55407714")
-    return WaveplateThorlabsK10CR1(connection=connection)
+@pytest.fixture(name="device", scope="function")
+def device_fixture() -> Generator[WaveplateThorlabsK10CR1]:
+    with AptConnection(serial_number="55409764") as connection:
+        yield WaveplateThorlabsK10CR1(connection=connection)
 
 
 def test_move_absolute(device: WaveplateThorlabsK10CR1) -> None:
     device.move_absolute(0 * pnpq_ureg.degree)
+    device.move_absolute(24575940 * pnpq_ureg.k10cr1_steps)
diff --git a/src/pnpq/apt/protocol.py b/src/pnpq/apt/protocol.py
index 3abb968..ee60aac 100644
--- a/src/pnpq/apt/protocol.py
+++ b/src/pnpq/apt/protocol.py
@@ -48,6 +48,10 @@ class AptMessageId(int, Enum):
     MGMSG_POL_REQ_PARAMS = 0x0531
     MGMSG_POL_SET_PARAMS = 0x0530
 
+    MGMSG_MOT_SET_VELPARAMS = 0x0413
+    MGMSG_MOT_REQ_VELPARAMS = 0x0414
+    MGMSG_MOT_GET_VELPARAMS = 0x0415
+
     MGMSG_MOT_MOVE_JOG = 0x046A
     MGMSG_MOT_MOVE_STOP = 0x0465
     MGMSG_MOT_MOVE_STOPPED = 0x0466
@@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
+    minimum_velocity: int
+    acceleration: int
+    maximum_velocity: int
+
+    @classmethod
+    def from_bytes(cls, raw: bytes) -> Self:
+        (
+            message_id,
+            data_length,
+            destination,
+            source,
+            chan_ident,
+            minimum_velocity,
+            acceleration,
+            maximum_velocity,
+        ) = cls.message_struct.unpack(raw)
+
+        if message_id != cls.message_id:
+            raise ValueError(
+                f"Expected message ID {cls.message_id.value}, but received {message_id} instead. Full raw data was {raw!r}"
+            )
+        if data_length != cls.data_length:
+            raise ValueError(
+                f"Expected data packet length {cls.data_length}, but received {data_length} instead. Full raw data was {raw!r}"
+            )
+        if destination & 0x80 != 0x80:
+            raise ValueError(
+                f"Expected the destination's highest bit to be 1, indicating that a data packet follows, but it was 0. Full raw data was {raw!r}"
+            )
+
+        return cls(
+            destination=Address(destination & 0x7F),
+            source=Address(source),
+            chan_ident=ChanIdent(chan_ident),
+            minimum_velocity=minimum_velocity,
+            acceleration=acceleration,
+            maximum_velocity=maximum_velocity,
+        )
+
+    def to_bytes(self) -> bytes:
+        return self.message_struct.pack(
+            self.message_id,
+            self.data_length,
+            self.destination_serialization,
+            self.source,
+            self.chan_ident,
+            self.minimum_velocity,
+            self.acceleration,
+            self.maximum_velocity,
+        )
+
+
 @dataclass(frozen=True, kw_only=True)
 class AptMessageWithDataPolParams(AptMessageWithData):
     data_length: ClassVar[int] = 12
@@ -688,6 +754,21 @@ def to_bytes(self) -> bytes:
 # Concrete message implementation classes
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessage_MGMSG_MOT_SET_VELPARAMS(AptMessageWithDataVelParams):
+    message_id = AptMessageId.MGMSG_MOT_SET_VELPARAMS
+
+
+@dataclass(frozen=True, kw_only=True)
+class AptMessage_MGMSG_MOT_REQ_VELPARAMS(AptMessageHeaderOnlyChanIdent):
+    message_id = AptMessageId.MGMSG_MOT_REQ_VELPARAMS
+
+
+@dataclass(frozen=True, kw_only=True)
+class AptMessage_MGMSG_MOT_GET_VELPARAMS(AptMessageWithDataVelParams):
+    message_id = AptMessageId.MGMSG_MOT_GET_VELPARAMS
+
+
 @dataclass(frozen=True, kw_only=True)
 class AptMessage_MGMSG_HW_DISCONNECT(AptMessageHeaderOnlyNoParams):
     message_id = AptMessageId.MGMSG_HW_DISCONNECT
diff --git a/src/pnpq/devices/polarization_controller_thorlabs_mpc.py b/src/pnpq/devices/polarization_controller_thorlabs_mpc.py
index e779b7c..c2b5146 100644
--- a/src/pnpq/devices/polarization_controller_thorlabs_mpc.py
+++ b/src/pnpq/devices/polarization_controller_thorlabs_mpc.py
@@ -329,7 +329,9 @@ def set_params(
             AptMessage_MGMSG_POL_SET_PARAMS(
                 destination=Address.GENERIC_USB,
                 source=Address.HOST_CONTROLLER,
-                velocity=round(params["velocity"].magnitude),
+                velocity=round(
+                    params["velocity"].magnitude
+                ),  # TODO: Should probably convert these to correct units before getting magnitude.
                 home_position=round(params["home_position"].magnitude),
                 jog_step_1=round(params["jog_step_1"].magnitude),
                 jog_step_2=round(params["jog_step_2"].magnitude),
diff --git a/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py b/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py
index 7dcba3d..c9e3e27 100644
--- a/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py
+++ b/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py
@@ -2,6 +2,7 @@
 import time
 from abc import ABC, abstractmethod
 from dataclasses import dataclass, field
+from typing import TypedDict, cast
 
 import structlog
 from pint import Quantity
@@ -12,11 +13,28 @@
     AptMessage_MGMSG_HW_START_UPDATEMSGS,
     AptMessage_MGMSG_MOD_SET_CHANENABLESTATE,
     AptMessage_MGMSG_MOT_ACK_USTATUSUPDATE,
+    AptMessage_MGMSG_MOT_GET_VELPARAMS,
     AptMessage_MGMSG_MOT_MOVE_ABSOLUTE,
     AptMessage_MGMSG_MOT_MOVE_COMPLETED_20_BYTES,
+    AptMessage_MGMSG_MOT_REQ_VELPARAMS,
+    AptMessage_MGMSG_MOT_SET_VELPARAMS,
     ChanIdent,
     EnableState,
 )
+from ..units import pnpq_ureg
+
+
+class WaveplateVelocityParams(TypedDict):
+    """TypedDict for waveplate velocity parameters.
+    Used in `get_velparams` method.
+    """
+
+    #: Dimensionality must be ([angle] / [time]) or k10cr1_velocity
+    minimum_velocity: Quantity
+    #: Dimensionality must be ([angle] / [time] ** 2) or k10cr1_acceleration
+    acceleration: Quantity
+    #: Dimensionality must be ([angle] / [time]) or k10cr1_velocity
+    maximum_velocity: Quantity
 
 
 class AbstractWaveplateThorlabsK10CR1(ABC):
@@ -30,6 +48,24 @@ def move_absolute(self, position: Quantity) -> None:
         :param position: The angle to move to.
         """
 
+    @abstractmethod
+    def get_velparams(self) -> WaveplateVelocityParams:
+        """Request velocity parameters from the device."""
+
+    @abstractmethod
+    def set_velparams(
+        self,
+        minimum_velocity: None | Quantity = None,
+        acceleration: None | Quantity = None,
+        maximum_velocity: None | Quantity = None,
+    ) -> None:
+        """Set velocity parameters on the device.
+
+        :param minimum_velocity: The minimum velocity.
+        :param acceleration: The acceleration.
+        :param maximum_velocity: The maximum velocity.
+        """
+
 
 @dataclass(frozen=True, kw_only=True)
 class WaveplateThorlabsK10CR1(AbstractWaveplateThorlabsK10CR1):
@@ -137,3 +173,67 @@ def move_absolute(self, position: Quantity) -> None:
         self.log.debug("move_absolute command finished", elapsed_time=elapsed_time)
 
         self.set_channel_enabled(False)
+
+    def get_velparams(self) -> WaveplateVelocityParams:
+
+        params = self.connection.send_message_expect_reply(
+            AptMessage_MGMSG_MOT_REQ_VELPARAMS(
+                chan_ident=self._chan_ident,
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+            ),
+            lambda message: (
+                isinstance(message, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+                and message.chan_ident == self._chan_ident
+                and message.destination == Address.HOST_CONTROLLER
+                and message.source == Address.GENERIC_USB
+            ),
+        )
+        assert isinstance(params, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+
+        result: WaveplateVelocityParams = {
+            "minimum_velocity": params.minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            "acceleration": params.acceleration * pnpq_ureg.k10cr1_acceleration,
+            "maximum_velocity": params.maximum_velocity * pnpq_ureg.k10cr1_velocity,
+        }
+        return result
+
+    def set_velparams(
+        self,
+        minimum_velocity: None | Quantity = None,
+        acceleration: None | Quantity = None,
+        maximum_velocity: None | Quantity = None,
+    ) -> None:
+
+        # First get the current velocity parameters
+        params = self.get_velparams()
+
+        if minimum_velocity is not None:
+            params["minimum_velocity"] = cast(
+                Quantity, minimum_velocity.to("k10cr1_velocity")
+            )
+        if acceleration is not None:
+            params["acceleration"] = cast(
+                Quantity, acceleration.to("k10cr1_acceleration")
+            )
+        if maximum_velocity is not None:
+            params["maximum_velocity"] = cast(
+                Quantity, maximum_velocity.to("k10cr1_velocity")
+            )
+
+        self.connection.send_message_no_reply(
+            AptMessage_MGMSG_MOT_SET_VELPARAMS(
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+                chan_ident=self._chan_ident,
+                minimum_velocity=params["minimum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
+                .magnitude,
+                acceleration=params["acceleration"]
+                .to(pnpq_ureg.k10cr1_acceleration)
+                .magnitude,
+                maximum_velocity=params["maximum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
+                .magnitude,
+            )
+        )
diff --git a/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1_stub.py b/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1_stub.py
index f3ddfc3..df52d80 100644
--- a/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1_stub.py
+++ b/src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1_stub.py
@@ -6,6 +6,7 @@
 
 from pnpq.devices.refactored_waveplate_thorlabs_k10cr1 import (
     AbstractWaveplateThorlabsK10CR1,
+    WaveplateVelocityParams,
 )
 
 from ..apt.protocol import ChanIdent
@@ -23,8 +24,7 @@ class WaveplateThorlabsK10CR1Stub(AbstractWaveplateThorlabsK10CR1):
         ]
     )
 
-    # TODO: Add K10CR1 parameters
-    # current_params = None
+    current_velocity_params: WaveplateVelocityParams = field(init=False)
 
     current_state: dict[ChanIdent, Quantity] = field(init=False)
 
@@ -39,6 +39,17 @@ def __post_init__(self) -> None:
             },
         )
 
+        object.__setattr__(
+            self,
+            "current_velocity_params",
+            {
+                "minimum_velocity": 0 * pnpq_ureg.k10cr1_velocity,
+                "acceleration": 0 * pnpq_ureg.k10cr1_acceleration,
+                "maximum_velocity": 10
+                * pnpq_ureg.k10cr1_velocity,  # Default value set to 10 k10cr1_velocity based on expected operational range
+            },
+        )
+
     def move_absolute(self, position: Quantity) -> None:
         # Convert distance to K1CR10 steps
         # TODO: Check if input is too large or too small for the device
@@ -46,3 +57,29 @@ def move_absolute(self, position: Quantity) -> None:
         self.current_state[self._chan_ident] = cast(Quantity, position_in_steps)
 
         self.log.info(f"[Waveplate Stub] Channel {self._chan_ident} move to {position}")
+
+    def get_velparams(self) -> WaveplateVelocityParams:
+        return self.current_velocity_params
+
+    def set_velparams(
+        self,
+        minimum_velocity: None | Quantity = None,
+        acceleration: None | Quantity = None,
+        maximum_velocity: None | Quantity = None,
+    ) -> None:
+
+        if minimum_velocity is not None:
+            self.current_velocity_params["minimum_velocity"] = cast(
+                Quantity, minimum_velocity.to("k10cr1_velocity")
+            )
+        if acceleration is not None:
+            self.current_velocity_params["acceleration"] = cast(
+                Quantity, acceleration.to("k10cr1_acceleration")
+            )
+        if maximum_velocity is not None:
+            self.current_velocity_params["maximum_velocity"] = cast(
+                Quantity, maximum_velocity.to("k10cr1_velocity")
+            )
+        self.log.info(
+            f"[K10CR1 Stub] Updated parameters: {self.current_velocity_params}"
+        )
diff --git a/tests/apt/test_protocol.py b/tests/apt/test_protocol.py
index ab22066..880458a 100644
--- a/tests/apt/test_protocol.py
+++ b/tests/apt/test_protocol.py
@@ -18,6 +18,7 @@
     AptMessage_MGMSG_MOT_GET_POSCOUNTER,
     AptMessage_MGMSG_MOT_GET_STATUSUPDATE,
     AptMessage_MGMSG_MOT_GET_USTATUSUPDATE,
+    AptMessage_MGMSG_MOT_GET_VELPARAMS,
     AptMessage_MGMSG_MOT_MOVE_ABSOLUTE,
     AptMessage_MGMSG_MOT_MOVE_COMPLETED,
     AptMessage_MGMSG_MOT_MOVE_COMPLETED_6_BYTES,
@@ -32,6 +33,7 @@
     AptMessage_MGMSG_MOT_REQ_USTATUSUPDATE,
     AptMessage_MGMSG_MOT_SET_EEPROMPARAMS,
     AptMessage_MGMSG_MOT_SET_POSCOUNTER,
+    AptMessage_MGMSG_MOT_SET_VELPARAMS,
     AptMessage_MGMSG_POL_GET_PARAMS,
     AptMessage_MGMSG_POL_REQ_PARAMS,
     AptMessage_MGMSG_POL_SET_PARAMS,
@@ -686,8 +688,6 @@ def test_AptMessage_MGMSG_RESTOREFACTORYSETTINGS_to_bytes() -> None:
 # We have not implemented any messages that are to be saved with SET_EEPROMPARAMS,
 # so MGMSG_HW_GET_INFO will temporarily be used as the target for saving in this
 # AptMessage_MGMSG_MOT_SET_EEPROMPARAMS unit test.
-
-
 def test_AptMessage_MGMSG_MOT_SET_EEPROMPARAMS_from_bytes() -> None:
     msg = AptMessage_MGMSG_MOT_SET_EEPROMPARAMS.from_bytes(
         bytes.fromhex("B904 0400 D0 01 0100 0600")
@@ -710,6 +710,60 @@ def test_AptMessage_MGMSG_MOT_SET_EEPROMPARAMS_to_bytes() -> None:
     assert msg.to_bytes() == bytes.fromhex("B904 0400 D0 01 0100 0600")
 
 
+def test_AptMessage_MGMSG_MOT_SET_VELPARAMS_from_bytes() -> None:
+    msg = AptMessage_MGMSG_MOT_SET_VELPARAMS.from_bytes(
+        bytes.fromhex("1304 0E00 A2 01 0100 00000000 B0350000 CDCCCC00")
+    )
+    assert msg.destination == 0x22
+    assert msg.message_id == 0x0413
+    assert msg.source == 0x01
+    assert msg.chan_ident == 0x01
+    assert msg.minimum_velocity == 0x00000000
+    assert msg.acceleration == 0x000035B0
+    assert msg.maximum_velocity == 0x00CCCCCD
+
+
+def test_AptMessage_MGMSG_MOT_SET_VELPARAMS_to_bytes() -> None:
+    msg = AptMessage_MGMSG_MOT_SET_VELPARAMS(
+        destination=Address.BAY_1,
+        source=Address.HOST_CONTROLLER,
+        chan_ident=ChanIdent.CHANNEL_1,
+        minimum_velocity=0x00000000,
+        acceleration=0x000035B0,
+        maximum_velocity=0x00CCCCCD,
+    )
+    assert msg.to_bytes() == bytes.fromhex(
+        "1304 0E00 A2 01 0100 00000000 B0350000 CDCCCC00"
+    )
+
+
+def test_AptMessage_MGMSG_MOT_GET_VELPARAMS_from_bytes() -> None:
+    msg = AptMessage_MGMSG_MOT_GET_VELPARAMS.from_bytes(
+        bytes.fromhex("1504 0E00 A2 01 0100 00000000 B0350000 CDCCCC00")
+    )
+    assert msg.destination == 0x22
+    assert msg.message_id == 0x0415
+    assert msg.source == 0x01
+    assert msg.chan_ident == 0x01
+    assert msg.minimum_velocity == 0x00000000
+    assert msg.acceleration == 0x000035B0
+    assert msg.maximum_velocity == 0x00CCCCCD
+
+
+def test_AptMessage_MGMSG_MOT_GET_VELPARAMS_to_bytes() -> None:
+    msg = AptMessage_MGMSG_MOT_GET_VELPARAMS(
+        destination=Address.BAY_1,
+        source=Address.HOST_CONTROLLER,
+        chan_ident=ChanIdent.CHANNEL_1,
+        minimum_velocity=0x00000000,
+        acceleration=0x000035B0,
+        maximum_velocity=0x00CCCCCD,
+    )
+    assert msg.to_bytes() == bytes.fromhex(
+        "1504 0E00 A2 01 0100 00000000 B0350000 CDCCCC00"
+    )
+
+
 @pytest.mark.parametrize(
     "chan_ident_int, expected_channel",
     [
diff --git a/tests/test_refactored_thorlabs_k10cr1_stub.py b/tests/test_refactored_thorlabs_k10cr1_stub.py
index 94275c0..bcef72c 100644
--- a/tests/test_refactored_thorlabs_k10cr1_stub.py
+++ b/tests/test_refactored_thorlabs_k10cr1_stub.py
@@ -26,3 +26,17 @@ def test_move_absolute(stub_waveplate: AbstractWaveplateThorlabsK10CR1) -> None:
     waveplate_position = stub_waveplate.current_state[ChanIdent.CHANNEL_1]  # type: ignore
 
     assert waveplate_position.to("degree").magnitude == pytest.approx(45)
+
+
+def test_velparams(stub_waveplate: AbstractWaveplateThorlabsK10CR1) -> None:
+    stub_waveplate.set_velparams(
+        minimum_velocity=1 * pnpq_ureg.k10cr1_velocity,
+        acceleration=2 * pnpq_ureg.k10cr1_acceleration,
+        maximum_velocity=3 * pnpq_ureg.k10cr1_velocity,
+    )
+
+    velparams = stub_waveplate.get_velparams()
+
+    assert velparams["minimum_velocity"].to("k10cr1_velocity").magnitude == 1
+    assert velparams["acceleration"].to("k10cr1_acceleration").magnitude == 2
+    assert velparams["maximum_velocity"].to("k10cr1_velocity").magnitude == 3


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.


<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/pnpq/devices/polarization_controller_thorlabs_mpc.py:332**
* [nitpick] The inline comment suggests that the velocity value might not be in the expected units before rounding; ensure that an explicit unit conversion is performed here to avoid subtle runtime issues.
```
velocity=round(params["velocity"].magnitude),  # Note: Should probably convert these to correct units before getting magnitude.
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T08:31:10Z
user: login: master-of-ppap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T11:31:34Z
user: login: auspicacious
body: The code appears to do what it's supposed to do, but I have some architectural comments.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T07:28:21Z
user: login: master-of-ppap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T00:32:28Z
user: login: master-of-ppap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T00:33:24Z
user: login: master-of-ppap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T00:42:59Z
user: login: master-of-ppap
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T00:43:27Z
user: login: auspicacious
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:39:45Z
user: login: auspicacious
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:51:43Z
user: login: auspicacious
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:52:07Z
user: login: auspicacious
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T06:52:30Z
user: login: auspicacious
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:58:27Z

Review comments:
diff_hunk: @@ -39,10 +39,47 @@ def __post_init__(self) -> None:
             },
         )
 
+        object.__setattr__(
+            self,
+            "current_velocity_params",
+            {
+                "minimum_velocity": 0 * pnpq_ureg.k10cr1_velocity,
+                "acceleration": 0 * pnpq_ureg.k10cr1_acceleration,
+                "maximum_velocity": 0
+                * pnpq_ureg.k10cr1_velocity,  # TODO: Search for appropriate initial values
+            },
path: src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1_stub.py
user: login: Copilot
body: [nitpick] The TODO comment indicates that the default value for maximum_velocity is not finalized; consider providing a meaningful default or documenting the expected initial value clearly to avoid potential inconsistencies.
```suggestion
                "maximum_velocity": 10
                * pnpq_ureg.k10cr1_velocity,  # Default value set to 10 k10cr1_velocity based on expected operational range
```
created_at: 2025-04-15T08:31:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,10 +39,47 @@ def __post_init__(self) -> None:
             },
         )
 
+        object.__setattr__(
+            self,
+            "current_velocity_params",
+            {
+                "minimum_velocity": 0 * pnpq_ureg.k10cr1_velocity,
+                "acceleration": 0 * pnpq_ureg.k10cr1_acceleration,
+                "maximum_velocity": 0
+                * pnpq_ureg.k10cr1_velocity,  # TODO: Search for appropriate initial values
+            },
path: src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1_stub.py
user: login: master-of-ppap
body: Sure.. why not...
created_at: 2025-04-15T11:31:34Z
author_association: MEMBER
diff_hunk: @@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
path: src/pnpq/apt/protocol.py
user: login: auspicacious
body: It doesn't matter for now since there's only one channel, but I wonder if the waveplate uses the same ChanIdent scheme as the polarization controller.
created_at: 2025-04-16T06:43:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
+    minimum_velocity: Quantity
+    acceleration: Quantity
+    maximum_velocity: Quantity
+
+    @classmethod
+    def from_bytes(cls, raw: bytes) -> Self:
+        (
+            message_id,
+            data_length,
+            destination,
+            source,
+            chan_ident,
+            minimum_velocity,
+            acceleration,
+            maximum_velocity,
+        ) = cls.message_struct.unpack(raw)
+
+        if message_id != cls.message_id:
+            raise ValueError(
+                f"Expected message ID {cls.message_id.value}, but received {message_id} instead. Full raw data was {raw!r}"
+            )
+        if data_length != cls.data_length:
+            raise ValueError(
+                f"Expected data packet length {cls.data_length}, but received {data_length} instead. Full raw data was {raw!r}"
+            )
+        if destination & 0x80 != 0x80:
+            raise ValueError(
+                f"Expected the destination's highest bit to be 1, indicating that a data packet follows, but it was 0. Full raw data was {raw!r}"
+            )
+
+        return cls(
+            destination=Address(destination & 0x7F),
+            source=Address(source),
+            chan_ident=ChanIdent(chan_ident),
+            minimum_velocity=minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            acceleration=acceleration * pnpq_ureg.k10cr1_acceleration,
+            maximum_velocity=maximum_velocity * pnpq_ureg.k10cr1_velocity,
path: src/pnpq/apt/protocol.py
user: login: auspicacious
body: This message is used with more devices than just the K10CR1. Do you have a plan for how to deal with that? I have generally avoided letting Pint leak into this layer of the implementation for this reason.
created_at: 2025-04-16T06:46:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -687,6 +753,25 @@ def to_bytes(self) -> bytes:
 
 # Concrete message implementation classes
 
+# MGMSG_MOT_SET_VELPARAMS = 0x0413
+# MGMSG_MOT_REQ_VELPARAMS = 0x0414
+# MGMSG_MOT_GET_VELPARAMS = 0x0415
+
+
path: src/pnpq/apt/protocol.py
user: login: auspicacious
body: ```suggestion
```
created_at: 2025-04-16T06:47:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -329,7 +329,9 @@ def set_params(
             AptMessage_MGMSG_POL_SET_PARAMS(
                 destination=Address.GENERIC_USB,
                 source=Address.HOST_CONTROLLER,
-                velocity=round(params["velocity"].magnitude),
+                velocity=round(
+                    params["velocity"].magnitude
+                ),  # Note: Should probably convert these to correct units before getting magnitude.
path: src/pnpq/devices/polarization_controller_thorlabs_mpc.py
user: login: auspicacious
body: Use the keyword `TODO` to make sure that this is found if I search the codebase for `TODO`s.

I didn't realize exactly what you meant until I saw the waveplate code a bit farther down.

That being said, there's two things to consider here:

* The only function that can produce these dicts is `get_params`, and we know that it produces output with the correct units. Doing this conversion again here may be excessive paranoia, although it could catch breaking changes. It's certainly not harmful.
* If we do want to do this conversion, arguably, this is something that should be encapsulated inside a mutable params dataclass. That is to say that instead of using a dict to hold the params, we instead use a dataclass and modify its attributes using [setter functions](https://realpython.com/python-getter-setter/) which do the conversion for us.
created_at: 2025-04-16T06:50:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,3 +173,67 @@ def move_absolute(self, position: Quantity) -> None:
         self.log.debug("move_absolute command finished", elapsed_time=elapsed_time)
 
         self.set_channel_enabled(False)
+
+    def get_velparams(self) -> WaveplateVelocityParams:
+
+        params = self.connection.send_message_expect_reply(
+            AptMessage_MGMSG_MOT_REQ_VELPARAMS(
+                chan_ident=self._chan_ident,
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+            ),
+            lambda message: (
+                isinstance(message, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+                and message.chan_ident == self._chan_ident
+                and message.destination == Address.HOST_CONTROLLER
+                and message.source == Address.GENERIC_USB
+            ),
+        )
+        assert isinstance(params, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+
+        result: WaveplateVelocityParams = {
+            "minimum_velocity": params.minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            "acceleration": params.acceleration * pnpq_ureg.k10cr1_acceleration,
+            "maximum_velocity": params.maximum_velocity * pnpq_ureg.k10cr1_velocity,
+        }
+        return result
+
+    def set_velparams(
+        self,
+        minimum_velocity: None | Quantity = None,
+        acceleration: None | Quantity = None,
+        maximum_velocity: None | Quantity = None,
+    ) -> None:
+
+        # First get the current velocity parameters
+        params = self.get_velparams()
+
+        if minimum_velocity is not None:
+            params["minimum_velocity"] = cast(
+                Quantity, minimum_velocity.to("k10cr1_velocity")
+            )
+        if acceleration is not None:
+            params["acceleration"] = cast(
+                Quantity, acceleration.to("k10cr1_acceleration")
+            )
+        if maximum_velocity is not None:
+            params["maximum_velocity"] = cast(
+                Quantity, maximum_velocity.to("k10cr1_velocity")
+            )
+
+        self.connection.send_message_no_reply(
+            AptMessage_MGMSG_MOT_SET_VELPARAMS(
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+                chan_ident=self._chan_ident,
+                minimum_velocity=params["minimum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
+                .magnitude,
+                acceleration=params["acceleration"]
+                .to(pnpq_ureg.k10cr1_acceleration)
+                .magnitude,
+                maximum_velocity=params["maximum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
path: src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py
user: login: auspicacious
body: I suppose at some point we should decide whether to standardize on unit strings like `"k10cr1_velocity"` or direct references to the unit variables, like `pnpq_ureg.k10cr1velocity`. The latter probably has a number of advantages, since it can be type-checked and is easier to automatically refactor.
created_at: 2025-04-16T07:27:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
path: src/pnpq/apt/protocol.py
user: login: master-of-ppap
body: What do you mean? Like, in the messages should we expect it to also follow the same structure (with ChanIdent) as the MPC320 messages?
created_at: 2025-04-17T00:32:28Z
author_association: MEMBER
diff_hunk: @@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
+    minimum_velocity: Quantity
+    acceleration: Quantity
+    maximum_velocity: Quantity
+
+    @classmethod
+    def from_bytes(cls, raw: bytes) -> Self:
+        (
+            message_id,
+            data_length,
+            destination,
+            source,
+            chan_ident,
+            minimum_velocity,
+            acceleration,
+            maximum_velocity,
+        ) = cls.message_struct.unpack(raw)
+
+        if message_id != cls.message_id:
+            raise ValueError(
+                f"Expected message ID {cls.message_id.value}, but received {message_id} instead. Full raw data was {raw!r}"
+            )
+        if data_length != cls.data_length:
+            raise ValueError(
+                f"Expected data packet length {cls.data_length}, but received {data_length} instead. Full raw data was {raw!r}"
+            )
+        if destination & 0x80 != 0x80:
+            raise ValueError(
+                f"Expected the destination's highest bit to be 1, indicating that a data packet follows, but it was 0. Full raw data was {raw!r}"
+            )
+
+        return cls(
+            destination=Address(destination & 0x7F),
+            source=Address(source),
+            chan_ident=ChanIdent(chan_ident),
+            minimum_velocity=minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            acceleration=acceleration * pnpq_ureg.k10cr1_acceleration,
+            maximum_velocity=maximum_velocity * pnpq_ureg.k10cr1_velocity,
path: src/pnpq/apt/protocol.py
user: login: master-of-ppap
body: Oh.. I did not think of that. That's a good point.
Maybe we should just leave it as `int`s, and have the user change it to the quantities they like? Or, when we create the message, we could specify K10CR1 somehow, and based on that automatically determine the quantities. But that's kind of ugly. I'm not sure. 
created_at: 2025-04-17T00:33:24Z
author_association: MEMBER
diff_hunk: @@ -329,7 +329,9 @@ def set_params(
             AptMessage_MGMSG_POL_SET_PARAMS(
                 destination=Address.GENERIC_USB,
                 source=Address.HOST_CONTROLLER,
-                velocity=round(params["velocity"].magnitude),
+                velocity=round(
+                    params["velocity"].magnitude
+                ),  # Note: Should probably convert these to correct units before getting magnitude.
path: src/pnpq/devices/polarization_controller_thorlabs_mpc.py
user: login: master-of-ppap
body: Makes sense. In the future, it might be good to make a mutable params dataclass like you said. I'm just worried that, 6 months down the line, we encounter some unfixable error and this one line of code is the culprit. 
created_at: 2025-04-17T00:42:59Z
author_association: MEMBER
diff_hunk: @@ -137,3 +173,67 @@ def move_absolute(self, position: Quantity) -> None:
         self.log.debug("move_absolute command finished", elapsed_time=elapsed_time)
 
         self.set_channel_enabled(False)
+
+    def get_velparams(self) -> WaveplateVelocityParams:
+
+        params = self.connection.send_message_expect_reply(
+            AptMessage_MGMSG_MOT_REQ_VELPARAMS(
+                chan_ident=self._chan_ident,
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+            ),
+            lambda message: (
+                isinstance(message, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+                and message.chan_ident == self._chan_ident
+                and message.destination == Address.HOST_CONTROLLER
+                and message.source == Address.GENERIC_USB
+            ),
+        )
+        assert isinstance(params, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+
+        result: WaveplateVelocityParams = {
+            "minimum_velocity": params.minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            "acceleration": params.acceleration * pnpq_ureg.k10cr1_acceleration,
+            "maximum_velocity": params.maximum_velocity * pnpq_ureg.k10cr1_velocity,
+        }
+        return result
+
+    def set_velparams(
+        self,
+        minimum_velocity: None | Quantity = None,
+        acceleration: None | Quantity = None,
+        maximum_velocity: None | Quantity = None,
+    ) -> None:
+
+        # First get the current velocity parameters
+        params = self.get_velparams()
+
+        if minimum_velocity is not None:
+            params["minimum_velocity"] = cast(
+                Quantity, minimum_velocity.to("k10cr1_velocity")
+            )
+        if acceleration is not None:
+            params["acceleration"] = cast(
+                Quantity, acceleration.to("k10cr1_acceleration")
+            )
+        if maximum_velocity is not None:
+            params["maximum_velocity"] = cast(
+                Quantity, maximum_velocity.to("k10cr1_velocity")
+            )
+
+        self.connection.send_message_no_reply(
+            AptMessage_MGMSG_MOT_SET_VELPARAMS(
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+                chan_ident=self._chan_ident,
+                minimum_velocity=params["minimum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
+                .magnitude,
+                acceleration=params["acceleration"]
+                .to(pnpq_ureg.k10cr1_acceleration)
+                .magnitude,
+                maximum_velocity=params["maximum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
path: src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py
user: login: master-of-ppap
body: Oh yes. I've just been using them interchangeably for now, but it might be good to standardize those.
created_at: 2025-04-17T00:43:26Z
author_association: MEMBER
diff_hunk: @@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
path: src/pnpq/apt/protocol.py
user: login: auspicacious
body: Basically, `ChanIdent` represents channels as bitmasks: channel 1 is `0001`, channel 2 is `0010`, channel 3 is `0100`, etc.

I have no evidence one way or another, but I wonder if this device's channel ID actually works this way. We have no way of knowing and there is no change necessary. I was just making a comment.
created_at: 2025-04-17T06:39:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -616,6 +620,68 @@ def to_bytes(self) -> bytes:
         )
 
 
+@dataclass(frozen=True, kw_only=True)
+class AptMessageWithDataVelParams(AptMessageWithData):
+    # Used in MGMSG_MOT_SET_VELPARAMS, MGMSG_MOT_GET_VELPARAMS
+    data_length: ClassVar[int] = 14
+
+    message_struct: ClassVar[Struct] = Struct(
+        f"{AptMessageWithData.header_struct_str}{ATS.WORD}{ATS.LONG}{ATS.LONG}{ATS.LONG}"
+    )
+
+    chan_ident: ChanIdent
+    minimum_velocity: Quantity
+    acceleration: Quantity
+    maximum_velocity: Quantity
+
+    @classmethod
+    def from_bytes(cls, raw: bytes) -> Self:
+        (
+            message_id,
+            data_length,
+            destination,
+            source,
+            chan_ident,
+            minimum_velocity,
+            acceleration,
+            maximum_velocity,
+        ) = cls.message_struct.unpack(raw)
+
+        if message_id != cls.message_id:
+            raise ValueError(
+                f"Expected message ID {cls.message_id.value}, but received {message_id} instead. Full raw data was {raw!r}"
+            )
+        if data_length != cls.data_length:
+            raise ValueError(
+                f"Expected data packet length {cls.data_length}, but received {data_length} instead. Full raw data was {raw!r}"
+            )
+        if destination & 0x80 != 0x80:
+            raise ValueError(
+                f"Expected the destination's highest bit to be 1, indicating that a data packet follows, but it was 0. Full raw data was {raw!r}"
+            )
+
+        return cls(
+            destination=Address(destination & 0x7F),
+            source=Address(source),
+            chan_ident=ChanIdent(chan_ident),
+            minimum_velocity=minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            acceleration=acceleration * pnpq_ureg.k10cr1_acceleration,
+            maximum_velocity=maximum_velocity * pnpq_ureg.k10cr1_velocity,
path: src/pnpq/apt/protocol.py
user: login: auspicacious
body: Another option for messages that we send to the device is that you could just change these lines to refer to class variables called `acceleration_unit` and `velocity_unit` instead of the concrete `pnpq_ureg` values, and then create a K10CR1 subclass that provides values for those variables.

The tricky part is dealing with messages we receive from the device. Right now, the connection class does not know what type of device is attached to it, and it instantiates objects for incoming messages using a very simple approach. We could instead delegate object instantiation to a factory that knows about device type.

That's not a problem to solve in this PR. I suggest just leaving them as ints.
created_at: 2025-04-17T06:51:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -329,7 +329,9 @@ def set_params(
             AptMessage_MGMSG_POL_SET_PARAMS(
                 destination=Address.GENERIC_USB,
                 source=Address.HOST_CONTROLLER,
-                velocity=round(params["velocity"].magnitude),
+                velocity=round(
+                    params["velocity"].magnitude
+                ),  # Note: Should probably convert these to correct units before getting magnitude.
path: src/pnpq/devices/polarization_controller_thorlabs_mpc.py
user: login: auspicacious
body: Sure. Let's just leave this TODO for now.
created_at: 2025-04-17T06:52:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,3 +173,67 @@ def move_absolute(self, position: Quantity) -> None:
         self.log.debug("move_absolute command finished", elapsed_time=elapsed_time)
 
         self.set_channel_enabled(False)
+
+    def get_velparams(self) -> WaveplateVelocityParams:
+
+        params = self.connection.send_message_expect_reply(
+            AptMessage_MGMSG_MOT_REQ_VELPARAMS(
+                chan_ident=self._chan_ident,
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+            ),
+            lambda message: (
+                isinstance(message, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+                and message.chan_ident == self._chan_ident
+                and message.destination == Address.HOST_CONTROLLER
+                and message.source == Address.GENERIC_USB
+            ),
+        )
+        assert isinstance(params, AptMessage_MGMSG_MOT_GET_VELPARAMS)
+
+        result: WaveplateVelocityParams = {
+            "minimum_velocity": params.minimum_velocity * pnpq_ureg.k10cr1_velocity,
+            "acceleration": params.acceleration * pnpq_ureg.k10cr1_acceleration,
+            "maximum_velocity": params.maximum_velocity * pnpq_ureg.k10cr1_velocity,
+        }
+        return result
+
+    def set_velparams(
+        self,
+        minimum_velocity: None | Quantity = None,
+        acceleration: None | Quantity = None,
+        maximum_velocity: None | Quantity = None,
+    ) -> None:
+
+        # First get the current velocity parameters
+        params = self.get_velparams()
+
+        if minimum_velocity is not None:
+            params["minimum_velocity"] = cast(
+                Quantity, minimum_velocity.to("k10cr1_velocity")
+            )
+        if acceleration is not None:
+            params["acceleration"] = cast(
+                Quantity, acceleration.to("k10cr1_acceleration")
+            )
+        if maximum_velocity is not None:
+            params["maximum_velocity"] = cast(
+                Quantity, maximum_velocity.to("k10cr1_velocity")
+            )
+
+        self.connection.send_message_no_reply(
+            AptMessage_MGMSG_MOT_SET_VELPARAMS(
+                destination=Address.GENERIC_USB,
+                source=Address.HOST_CONTROLLER,
+                chan_ident=self._chan_ident,
+                minimum_velocity=params["minimum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
+                .magnitude,
+                acceleration=params["acceleration"]
+                .to(pnpq_ureg.k10cr1_acceleration)
+                .magnitude,
+                maximum_velocity=params["maximum_velocity"]
+                .to(pnpq_ureg.k10cr1_velocity)
path: src/pnpq/devices/refactored_waveplate_thorlabs_k10cr1.py
user: login: auspicacious
body: Let's make a second PR to do that standardization.
created_at: 2025-04-17T06:52:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: auspicacious
created_at: 2025-04-17T07:24:17Z
body: @master-of-ppap See this comment. https://github.com/moonshot-nagayama-pj/PnPQ/pull/119#discussion_r2048349396
author_association: CONTRIBUTOR
user: login: master-of-ppap
created_at: 2025-04-17T13:08:59Z
body: > @master-of-ppap See this comment. [#119 (comment)](https://github.com/moonshot-nagayama-pj/PnPQ/pull/119#discussion_r2048349396)

My bad. I saw it after I posted my reply. And I deleted my reply in hopes you didn't see it. Dang it. Thanks for the code review as always.
author_association: MEMBER