From fd860436a9dc2864e4b96cad65a39c51b143cdaa Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:30:06 +0800 Subject: [PATCH 1/3] Fix rotation composition with quaternions Store a normalized quaternion behind the existing Euler accessors so ordered rotation composition no longer adds Euler components. Apply Resource.rotate increments in the parent coordinate frame while preserving the serialized x/y/z shape. Add regressions for non-commuting rotations, hierarchy composition, normalization, gimbal-lock representations, and serialization. Assisted-by: OpenAI Codex --- pylabrobot/resources/resource.py | 10 +- pylabrobot/resources/resource_tests.py | 41 ++++- pylabrobot/resources/rotation.py | 220 +++++++++++++++++++------ pylabrobot/resources/rotation_tests.py | 75 +++++++++ pylabrobot/utils/linalg.py | 2 +- 5 files changed, 291 insertions(+), 57 deletions(-) create mode 100644 pylabrobot/resources/rotation_tests.py diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 6d367ac8f2c..cb65cf2a4c4 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -364,7 +364,7 @@ def get_absolute_location(self, x: str = "l", y: str = "f", z: str = "b") -> Coo # carry no location yet still rotate what hangs from them, so the rotation is taken from the # whole tree rather than from the chain. rotation = chain[0].get_absolute_rotation() - matrix = rotation.get_rotation_matrix() if (rotation.x or rotation.y or rotation.z) else None + matrix = None if rotation.is_identity() else rotation.get_rotation_matrix() position = cast(Coordinate, chain[0].location) # 2b. Accumulate each child's offset in its parent's frame @@ -376,7 +376,7 @@ def get_absolute_location(self, x: str = "l", y: str = "f", z: str = "b") -> Coo position += Coordinate(*matrix_vector_multiply_3x3(matrix, anchor.vector())) + Coordinate( *matrix_vector_multiply_3x3(matrix, location.vector()) ) - if child.rotation.x or child.rotation.y or child.rotation.z: + if not child.rotation.is_identity(): rotation = rotation + child.rotation matrix = rotation.get_rotation_matrix() @@ -891,11 +891,9 @@ def location(self, location: Optional[Coordinate]) -> None: self._state_updated() def rotate(self, x: float = 0, y: float = 0, z: float = 0): - """Rotate counter-clockwise by the given number of degrees.""" + """Rotate counter-clockwise around the parent-coordinate axes by the given degrees.""" - self.rotation.x = (self.rotation.x + x) % 360 - self.rotation.y = (self.rotation.y + y) % 360 - self.rotation.z = (self.rotation.z + z) % 360 + self.rotation._prepend(Rotation(x=x, y=y, z=z)) # Rotation is part of the resource's state; notify subscribers (e.g. the # Visualizer) so they can re-render. self._state_updated() diff --git a/pylabrobot/resources/resource_tests.py b/pylabrobot/resources/resource_tests.py index 1ebd65841f7..0a46efc7fb4 100644 --- a/pylabrobot/resources/resource_tests.py +++ b/pylabrobot/resources/resource_tests.py @@ -24,7 +24,7 @@ from pylabrobot.resources.resource import Resource from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.tip import Tip -from pylabrobot.utils.linalg import matrix_vector_multiply_3x3 +from pylabrobot.utils.linalg import matrix_multiply_3x3, matrix_vector_multiply_3x3 def _make_test_deck() -> Deck: @@ -438,6 +438,45 @@ def level_by_level(resource: Resource, x="l", y="f", z="b") -> Coordinate: for anchors in (("l", "f", "b"), ("c", "c", "c"), ("r", "b", "t")): self.assertEqual(node.get_absolute_location(*anchors), level_by_level(node, *anchors)) + def test_rotate_composes_around_fixed_axes(self): + resource = Resource("resource", size_x=10, size_y=10, size_z=10, rotation=Rotation(z=90)) + expected = matrix_multiply_3x3( + Rotation(x=90).get_rotation_matrix(), + resource.rotation.get_rotation_matrix(), + ) + + resource.rotate(x=90) + + actual = resource.rotation.get_rotation_matrix() + for actual_row, expected_row in zip(actual, expected): + for actual_value, expected_value in zip(actual_row, expected_row): + self.assertAlmostEqual(actual_value, expected_value) + + def test_rotate_keeps_angles_normalized(self): + resource = Resource("resource", size_x=10, size_y=10, size_z=10) + rotation = resource.rotation + + resource.rotate(z=270) + self.assertIs(resource.rotation, rotation) + self.assertEqual(resource.rotation.z, 270) + + resource.rotate(z=90) + self.assertEqual(resource.rotation.z, 0) + + def test_absolute_rotation_composes_parent_and_child(self): + parent = Resource("parent", size_x=10, size_y=10, size_z=10, rotation=Rotation(x=90)) + child = Resource("child", size_x=5, size_y=5, size_z=5, rotation=Rotation(z=90)) + parent.assign_child_resource(child, location=Coordinate.zero()) + expected = matrix_multiply_3x3( + parent.rotation.get_rotation_matrix(), + child.rotation.get_rotation_matrix(), + ) + + actual = child.get_absolute_rotation().get_rotation_matrix() + for actual_row, expected_row in zip(actual, expected): + for actual_value, expected_value in zip(actual_row, expected_row): + self.assertAlmostEqual(actual_value, expected_value) + class TestResourceCallback(unittest.TestCase): def setUp(self) -> None: diff --git a/pylabrobot/resources/rotation.py b/pylabrobot/resources/rotation.py index 59b7e822adb..85223171937 100644 --- a/pylabrobot/resources/rotation.py +++ b/pylabrobot/resources/rotation.py @@ -1,71 +1,193 @@ import math from pylabrobot.serializer import SerializableMixin -from pylabrobot.utils.linalg import matrix_multiply_3x3 + +# Quaternions use (w, x, y, z); Euler angles retain the Rz * Ry * Rx convention. +_Quaternion = tuple[float, float, float, float] + + +def _normalize_quaternion(quaternion: _Quaternion) -> _Quaternion: + """Return a unit-length quaternion.""" + norm = math.sqrt(sum(component * component for component in quaternion)) + w, x, y, z = quaternion + return w / norm, x / norm, y / norm, z / norm + + +def _multiply_quaternions(left: _Quaternion, right: _Quaternion) -> _Quaternion: + """Compose two quaternions using the Hamilton product.""" + left_w, left_x, left_y, left_z = left + right_w, right_x, right_y, right_z = right + return _normalize_quaternion( + ( + left_w * right_w - left_x * right_x - left_y * right_y - left_z * right_z, + left_w * right_x + left_x * right_w + left_y * right_z - left_z * right_y, + left_w * right_y - left_x * right_z + left_y * right_w + left_z * right_x, + left_w * right_z + left_x * right_y - left_y * right_x + left_z * right_w, + ) + ) + + +def _quaternion_from_euler(x: float, y: float, z: float) -> _Quaternion: + """Convert roll, pitch, and yaw in degrees to a quaternion.""" + half_roll = math.radians(x) / 2 + half_pitch = math.radians(y) / 2 + half_yaw = math.radians(z) / 2 + cos_roll, sin_roll = math.cos(half_roll), math.sin(half_roll) + cos_pitch, sin_pitch = math.cos(half_pitch), math.sin(half_pitch) + cos_yaw, sin_yaw = math.cos(half_yaw), math.sin(half_yaw) + return _normalize_quaternion( + ( + cos_roll * cos_pitch * cos_yaw + sin_roll * sin_pitch * sin_yaw, + sin_roll * cos_pitch * cos_yaw - cos_roll * sin_pitch * sin_yaw, + cos_roll * sin_pitch * cos_yaw + sin_roll * cos_pitch * sin_yaw, + cos_roll * cos_pitch * sin_yaw - sin_roll * sin_pitch * cos_yaw, + ) + ) + + +def _quaternion_to_euler(quaternion: _Quaternion) -> tuple[float, float, float]: + """Convert a quaternion to roll, pitch, and yaw in degrees.""" + w, x, y, z = quaternion + sin_pitch = max(-1.0, min(1.0, 2 * (w * y - z * x))) + roll = math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)) + pitch = math.asin(sin_pitch) + yaw = math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) + return math.degrees(roll), math.degrees(pitch), math.degrees(yaw) + + +def _nearest_equivalent_angle(angle: float, reference: float) -> float: + """Shift an angle by full turns so it stays close to a reference value.""" + equivalent = angle + 360 * round((reference - angle) / 360) + if math.isclose(equivalent, reference, rel_tol=0.0, abs_tol=1e-12): + return reference + return equivalent class Rotation(SerializableMixin): """Represents a 3D rotation.""" def __init__(self, x: float = 0, y: float = 0, z: float = 0): - self.x = x # around x-axis, roll - self.y = y # around y-axis, pitch - self.z = z # around z-axis, yaw - - def get_rotation_matrix(self): - # Create rotation matrices for each axis - Rz = [ - [ - math.cos(math.radians(self.z)), - -math.sin(math.radians(self.z)), - 0, - ], - [ - math.sin(math.radians(self.z)), - math.cos(math.radians(self.z)), - 0, - ], - [0, 0, 1], - ] - Ry = [ - [ - math.cos(math.radians(self.y)), - 0, - math.sin(math.radians(self.y)), - ], - [0, 1, 0], - [ - -math.sin(math.radians(self.y)), - 0, - math.cos(math.radians(self.y)), - ], + self._x = x # around x-axis, roll + self._y = y # around y-axis, pitch + self._z = z # around z-axis, yaw + self._quaternion = _quaternion_from_euler(x=x, y=y, z=z) + + @classmethod + def _from_quaternion( + cls, quaternion: _Quaternion, reference: tuple[float, float, float] + ) -> "Rotation": + """Create a rotation using the equivalent Euler angles closest to a reference.""" + quaternion = _normalize_quaternion(quaternion) + w, x, y, z = quaternion + sin_pitch = 2 * (w * y - z * x) + cos_pitch = math.hypot(1 - 2 * (y * y + z * z), 2 * (x * y + w * z)) + if cos_pitch < 1e-12: + coupled_angle = math.degrees(2 * math.atan2(x, w)) + if sin_pitch > 0: + difference = _nearest_equivalent_angle(coupled_angle, reference[0] - reference[2]) + euler = ( + (reference[0] + reference[2] + difference) / 2, + _nearest_equivalent_angle(90, reference[1]), + (reference[0] + reference[2] - difference) / 2, + ) + else: + total = _nearest_equivalent_angle(coupled_angle, reference[0] + reference[2]) + euler = ( + (reference[0] - reference[2] + total) / 2, + _nearest_equivalent_angle(-90, reference[1]), + (reference[2] - reference[0] + total) / 2, + ) + rotation = cls(x=euler[0], y=euler[1], z=euler[2]) + rotation._quaternion = quaternion + return rotation + + principal = _quaternion_to_euler(quaternion) + alternate = (principal[0] + 180, 180 - principal[1], principal[2] + 180) + candidates = [ + tuple(_nearest_equivalent_angle(angle, target) for angle, target in zip(candidate, reference)) + for candidate in (principal, alternate) ] - Rx = [ - [1, 0, 0], - [ - 0, - math.cos(math.radians(self.x)), - -math.sin(math.radians(self.x)), - ], - [ - 0, - math.sin(math.radians(self.x)), - math.cos(math.radians(self.x)), - ], + x, y, z = min( + candidates, + key=lambda candidate: sum( + (angle - target) ** 2 for angle, target in zip(candidate, reference) + ), + ) + rotation = cls(x=x, y=y, z=z) + rotation._quaternion = quaternion + return rotation + + def get_rotation_matrix(self) -> list[list[float]]: + """Return the rotation as a 3x3 matrix.""" + w, x, y, z = self._quaternion + return [ + [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)], + [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)], + [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], ] - # Combine rotations: The order of multiplication matters and defines the behavior significantly. - # This is a common order: Rz * Ry * Rx - return matrix_multiply_3x3(matrix_multiply_3x3(Rz, Ry), Rx) + + def is_identity(self) -> bool: + """Return whether this rotation leaves coordinates unchanged.""" + _, x, y, z = self._quaternion + return math.isclose(x, 0.0, abs_tol=1e-12) and math.isclose( + y, 0.0, abs_tol=1e-12 + ) and math.isclose(z, 0.0, abs_tol=1e-12) + + def serialize(self) -> dict: + """Serialize using the public Euler-angle representation.""" + return {"x": self.x, "y": self.y, "z": self.z, "type": self.__class__.__name__} def __str__(self) -> str: return f"Rotation(x={self.x}, y={self.y}, z={self.z})" - def __add__(self, other) -> "Rotation": - return Rotation(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) + def __add__(self, other: "Rotation") -> "Rotation": + """Compose rotations so the resulting matrix is ``self * other``.""" + reference = (self.x + other.x, self.y + other.y, self.z + other.z) + return self._from_quaternion( + _multiply_quaternions(self._quaternion, other._quaternion), reference=reference + ) + + def _prepend(self, other: "Rotation") -> None: + """Apply another rotation in the current reference frame while keeping this instance.""" + combined = other + self + self._x = combined.x % 360 + self._y = combined.y % 360 + self._z = combined.z % 360 + self._quaternion = combined._quaternion def __repr__(self) -> str: return self.__str__() + @property + def x(self) -> float: + """Rotation around the x-axis in degrees (roll).""" + return self._x + + @x.setter + def x(self, value: float) -> None: + self._x = value + self._quaternion = _quaternion_from_euler(x=self._x, y=self._y, z=self._z) + + @property + def y(self) -> float: + """Rotation around the y-axis in degrees (pitch).""" + return self._y + + @y.setter + def y(self, value: float) -> None: + self._y = value + self._quaternion = _quaternion_from_euler(x=self._x, y=self._y, z=self._z) + + @property + def z(self) -> float: + """Rotation around the z-axis in degrees (yaw).""" + return self._z + + @z.setter + def z(self, value: float) -> None: + self._z = value + self._quaternion = _quaternion_from_euler(x=self._x, y=self._y, z=self._z) + @property def roll(self) -> float: return self.x diff --git a/pylabrobot/resources/rotation_tests.py b/pylabrobot/resources/rotation_tests.py new file mode 100644 index 00000000000..325dacd7b9f --- /dev/null +++ b/pylabrobot/resources/rotation_tests.py @@ -0,0 +1,75 @@ +import unittest + +from pylabrobot.resources.rotation import Rotation +from pylabrobot.serializer import deserialize +from pylabrobot.utils.linalg import matrix_multiply_3x3 + + +class TestRotation(unittest.TestCase): + def assertMatricesAlmostEqual(self, actual, expected): + for actual_row, expected_row in zip(actual, expected): + for actual_value, expected_value in zip(actual_row, expected_row): + self.assertAlmostEqual(actual_value, expected_value) + + def test_add_composes_rotations_in_order(self): + first = Rotation(x=90) + second = Rotation(z=90) + + combined = first + second + + self.assertMatricesAlmostEqual( + combined.get_rotation_matrix(), + matrix_multiply_3x3(first.get_rotation_matrix(), second.get_rotation_matrix()), + ) + + def test_add_identity_preserves_euler_representation(self): + rotation = Rotation(x=-180, y=100, z=270) + + combined = rotation + Rotation() + + self.assertEqual((combined.x, combined.y, combined.z), (-180, 100, 270)) + + def test_add_identity_preserves_euler_representation_at_gimbal_lock(self): + for x, pitch, z in ((30, -90, 20), (-180, -90, -165), (30, 90, 20)): + with self.subTest(x=x, pitch=pitch, z=z): + rotation = Rotation(x=x, y=pitch, z=z) + + combined = rotation + Rotation() + + self.assertAlmostEqual(combined.x, x) + self.assertEqual(combined.y, pitch) + self.assertAlmostEqual(combined.z, z) + + def test_component_assignment_updates_quaternion(self): + rotation = Rotation(y=20, z=30) + + rotation.x = 10 + + self.assertMatricesAlmostEqual( + rotation.get_rotation_matrix(), Rotation(x=10, y=20, z=30).get_rotation_matrix() + ) + + def test_is_identity(self): + self.assertTrue(Rotation().is_identity()) + self.assertTrue(Rotation(z=360).is_identity()) + self.assertFalse(Rotation(z=90).is_identity()) + + def test_serialize_preserves_euler_shape(self): + rotation = Rotation(x=-180, y=100, z=270) + + serialized = rotation.serialize() + + self.assertEqual(serialized, {"x": -180, "y": 100, "z": 270, "type": "Rotation"}) + restored = deserialize(serialized) + self.assertIsInstance(restored, Rotation) + self.assertMatricesAlmostEqual(restored.get_rotation_matrix(), rotation.get_rotation_matrix()) + + def test_composed_rotation_serialization_round_trip(self): + combined = Rotation(x=490.472537, y=270, z=263.015663) + Rotation( + x=-336.171880, y=89.999999, z=-47.350878 + ) + + restored = deserialize(combined.serialize()) + + self.assertIsInstance(restored, Rotation) + self.assertMatricesAlmostEqual(restored.get_rotation_matrix(), combined.get_rotation_matrix()) diff --git a/pylabrobot/utils/linalg.py b/pylabrobot/utils/linalg.py index b0b459a7da9..547c0718f55 100644 --- a/pylabrobot/utils/linalg.py +++ b/pylabrobot/utils/linalg.py @@ -1,5 +1,5 @@ def matrix_multiply_3x3(A, B): - """Multiplies two 3x3 matrices A and B.""" + """Multiply two 3x3 matrices as an independent reference implementation for tests.""" return [[sum(A[i][k] * B[k][j] for k in range(3)) for j in range(3)] for i in range(3)] From 6330eb50e336ad7cec463b724dcfb643867a08fc Mon Sep 17 00:00:00 2001 From: fly1d <309400591+fly1d@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:49:21 +0800 Subject: [PATCH 2/3] Address rotation performance review Use the stored identity quaternion in the absolute-location hot path and document why the matrix helper remains as an independent test reference. Assisted-by: OpenAI Codex --- pylabrobot/resources/resource.py | 6 ++++-- pylabrobot/resources/rotation.py | 7 ------- pylabrobot/resources/rotation_tests.py | 5 ----- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index cb65cf2a4c4..4c603a9c65a 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -364,7 +364,9 @@ def get_absolute_location(self, x: str = "l", y: str = "f", z: str = "b") -> Coo # carry no location yet still rotate what hangs from them, so the rotation is taken from the # whole tree rather than from the chain. rotation = chain[0].get_absolute_rotation() - matrix = None if rotation.is_identity() else rotation.get_rotation_matrix() + matrix = ( + None if rotation._quaternion == (1.0, 0.0, 0.0, 0.0) else rotation.get_rotation_matrix() + ) position = cast(Coordinate, chain[0].location) # 2b. Accumulate each child's offset in its parent's frame @@ -376,7 +378,7 @@ def get_absolute_location(self, x: str = "l", y: str = "f", z: str = "b") -> Coo position += Coordinate(*matrix_vector_multiply_3x3(matrix, anchor.vector())) + Coordinate( *matrix_vector_multiply_3x3(matrix, location.vector()) ) - if not child.rotation.is_identity(): + if child.rotation._quaternion != (1.0, 0.0, 0.0, 0.0): rotation = rotation + child.rotation matrix = rotation.get_rotation_matrix() diff --git a/pylabrobot/resources/rotation.py b/pylabrobot/resources/rotation.py index 85223171937..f30e6636c37 100644 --- a/pylabrobot/resources/rotation.py +++ b/pylabrobot/resources/rotation.py @@ -126,13 +126,6 @@ def get_rotation_matrix(self) -> list[list[float]]: [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)], ] - def is_identity(self) -> bool: - """Return whether this rotation leaves coordinates unchanged.""" - _, x, y, z = self._quaternion - return math.isclose(x, 0.0, abs_tol=1e-12) and math.isclose( - y, 0.0, abs_tol=1e-12 - ) and math.isclose(z, 0.0, abs_tol=1e-12) - def serialize(self) -> dict: """Serialize using the public Euler-angle representation.""" return {"x": self.x, "y": self.y, "z": self.z, "type": self.__class__.__name__} diff --git a/pylabrobot/resources/rotation_tests.py b/pylabrobot/resources/rotation_tests.py index 325dacd7b9f..f602baeff8f 100644 --- a/pylabrobot/resources/rotation_tests.py +++ b/pylabrobot/resources/rotation_tests.py @@ -49,11 +49,6 @@ def test_component_assignment_updates_quaternion(self): rotation.get_rotation_matrix(), Rotation(x=10, y=20, z=30).get_rotation_matrix() ) - def test_is_identity(self): - self.assertTrue(Rotation().is_identity()) - self.assertTrue(Rotation(z=360).is_identity()) - self.assertFalse(Rotation(z=90).is_identity()) - def test_serialize_preserves_euler_shape(self): rotation = Rotation(x=-180, y=100, z=270) From b7b73f881ce5dbf79860a8fcd9026f3c783b37f3 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 10 Sep 2026 11:17:19 -0700 Subject: [PATCH 3/3] Tolerate yaw rounding when placing resources in holders --- pylabrobot/resources/resource_holder.py | 7 +++++-- pylabrobot/resources/resource_holder_tests.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pylabrobot/resources/resource_holder.py b/pylabrobot/resources/resource_holder.py index 3bc0713b50c..ef84f695397 100644 --- a/pylabrobot/resources/resource_holder.py +++ b/pylabrobot/resources/resource_holder.py @@ -1,3 +1,4 @@ +import math from typing import Any, Mapping, Optional from pylabrobot.resources.coordinate import Coordinate @@ -13,14 +14,16 @@ def get_child_location(resource: Resource) -> Coordinate: """ if not resource.rotation.y == resource.rotation.x == 0: raise ValueError("Resource rotation must be 0 around the x and y axes") - if not resource.rotation.z % 90 == 0: + z = resource.rotation.z % 360 + snapped_z = round(z / 90) * 90 + if not math.isclose(z, snapped_z, rel_tol=0, abs_tol=1e-7): raise ValueError("Resource rotation must be a multiple of 90 degrees on the z axis") location = { 0.0: Coordinate(x=0, y=0, z=0), 90.0: Coordinate(x=resource.get_size_y(), y=0, z=0), 180.0: Coordinate(x=resource.get_size_x(), y=resource.get_size_y(), z=0), 270.0: Coordinate(x=0, y=resource.get_size_x(), z=0), - }[resource.rotation.z % 360] + }[snapped_z % 360] return location diff --git a/pylabrobot/resources/resource_holder_tests.py b/pylabrobot/resources/resource_holder_tests.py index 0efcacae13c..a82d7ff3c12 100644 --- a/pylabrobot/resources/resource_holder_tests.py +++ b/pylabrobot/resources/resource_holder_tests.py @@ -1,5 +1,6 @@ import unittest +from .coordinate import Coordinate from .resource import Resource from .resource_holder import ResourceHolder @@ -15,6 +16,20 @@ def test_assign_via_property(self): self.assertEqual(self.holder.resource, self.resource) self.assertEqual(self.resource.parent, self.holder) + def test_assign_with_yaw_rounding_error(self): + self.resource.rotation.z = 269.999999999999 + + self.holder.assign_child_resource(self.resource) + + self.assertEqual(self.resource.location, Coordinate(0, 1, 0)) + self.assertEqual(self.resource.rotation.z, 269.999999999999) + + def test_reject_misaligned_yaw(self): + self.resource.rotation.z = 269.99 + + with self.assertRaisesRegex(ValueError, "multiple of 90 degrees"): + self.holder.assign_child_resource(self.resource) + def test_over_assignment(self): self.holder.resource = self.resource with self.assertRaises(ValueError):