diff --git a/controller/common/constants.py b/controller/common/constants.py index 3dc5c4f..4a32289 100644 --- a/controller/common/constants.py +++ b/controller/common/constants.py @@ -1 +1,7 @@ """Constants used across the controller package.""" + +# meters, trim tab chord width is not included +CHORD_WIDTH_MAIN_SAIL = 0.23 + +# {m^2 / s at 10degC} and air density at 1.225 {kg / m^3} +KINEMATIC_VISCOSITY = 0.000014207 diff --git a/controller/wingsail/controllers.py b/controller/wingsail/controllers.py new file mode 100644 index 0000000..619f174 --- /dev/null +++ b/controller/wingsail/controllers.py @@ -0,0 +1,76 @@ +import math + +from controller.common.lut import LUT + + +class WingsailController: + """ + The controller class for computing trim tab angles for controlling the mainsail. + + Args: + - chord_width_main_sail (float): The chord width of the main sail. + - kinematic_viscosity (float): The kinematic viscosity of the fluid. + - lut (LUT): A lookup table containing Reynolds numbers and corresponding desired angles of + attack. + """ + + def __init__(self, chord_width_main_sail: float, kinematic_viscosity: float, lut: LUT): + self.chord_width_main_sail = chord_width_main_sail + self.kinematic_viscosity = kinematic_viscosity + self.lut: LUT = lut + + def _compute_reynolds_number(self, apparent_wind_speed: float) -> float: + """ + Computes the Reynolds number for the main sail. + + Args: + - apparent_wind_speed (float): The apparent wind speed in meters per second. + + Returns: + - reynolds_number (float): The computed Reynolds number for the main sail. + """ + reynolds_number: float = ( + apparent_wind_speed * self.chord_width_main_sail + ) / self.kinematic_viscosity + return reynolds_number + + def _compute_trim_tab_angle( + self, reynolds_number: float, apparent_wind_direction: float + ) -> float: + """ + Computes the trim tab angle based on Reynolds number and apparent wind direction. + + Args: + - reynolds_number (float): The Reynolds number. + - apparent_wind_direction (float): The absolute bearing (true heading) of the wind. + Degrees, 0° means the apparent wind is blowing from the bow to the stern of the boat, + increase CW + Range: -180 < direction <= 180 for symmetry + + Returns: + - trim_tab_angle (float): The computed trim tab angle based on the provided + Reynolds number and apparent wind direction. + """ + desired_alpha: float = self.lut(reynolds_number) # Using __call__ method + return math.copysign(desired_alpha, apparent_wind_direction) + + def get_trim_tab_angle( + self, apparent_wind_speed: float, apparent_wind_direction: float + ) -> float: + """ + Computes and returns the final trim tab angle. + + Range: -180 < direction <= 180 for symmetry + + Args: + - apparent_wind_speed (float): The apparent wind speed in meters per second. + - apparent_wind_direction (float): The apparent wind direction in degrees. + + Returns: + - trim_tab_angle (float): The computed trim tab angle. + """ + reynolds_number: float = self._compute_reynolds_number(apparent_wind_speed) + trim_tab_angle: float = self._compute_trim_tab_angle( + reynolds_number, apparent_wind_direction + ) + return trim_tab_angle diff --git a/tests/unit/wingsail/test_controllers.py b/tests/unit/wingsail/test_controllers.py new file mode 100644 index 0000000..d2e2b65 --- /dev/null +++ b/tests/unit/wingsail/test_controllers.py @@ -0,0 +1,107 @@ +import numpy as np +import pytest + +from controller.common.constants import CHORD_WIDTH_MAIN_SAIL, KINEMATIC_VISCOSITY +from controller.common.lut import LUT +from controller.wingsail.controllers import WingsailController + +# Define test data +test_lut_data = np.array( + [[50000, 5.75], [100000, 6.75], [200000, 7], [500000, 9.25], [1000000, 10]] +) +test_lut = LUT(test_lut_data) +test_chord_width = CHORD_WIDTH_MAIN_SAIL +test_kinematic_viscosity = KINEMATIC_VISCOSITY + + +class TestWingsailController: + """ + Tests the functionality of the WingsailController class. + """ + + @pytest.fixture + def wingsail_controller(self): + """ + Fixture to create an instance of WingsailController for testing. + """ + return WingsailController(test_chord_width, test_kinematic_viscosity, test_lut) + + @pytest.mark.parametrize( + "apparent_wind_speed, expected_reynolds_number", + [ + (0, 0), + (3, 3 * CHORD_WIDTH_MAIN_SAIL / KINEMATIC_VISCOSITY), + (5, 5 * CHORD_WIDTH_MAIN_SAIL / KINEMATIC_VISCOSITY), + (10, 10 * CHORD_WIDTH_MAIN_SAIL / KINEMATIC_VISCOSITY), + (20, 20 * CHORD_WIDTH_MAIN_SAIL / KINEMATIC_VISCOSITY), + ], + ) + def test_compute_reynolds_number( + self, wingsail_controller, apparent_wind_speed, expected_reynolds_number + ): + """ + Tests the computation of Reynolds number. + + Args: + wingsail_controller: Instance of WingsailController. + """ + computed_reynolds_number = wingsail_controller._compute_reynolds_number( + apparent_wind_speed + ) + assert np.isclose(computed_reynolds_number, expected_reynolds_number) + + @pytest.mark.parametrize( + "reynolds_number, apparent_wind_direction, expected_trim_tab_angle", + [ + (1250, 45.0, 5.75), + (15388, -90.0, -5.75), + (210945, 170.0, 7.0820875), + (824000, -120.0, -9.736), + (2000000, 0, 10.0), + ], + ) + def test_compute_trim_tab_angle( + self, + wingsail_controller, + reynolds_number, + apparent_wind_direction, + expected_trim_tab_angle, + ): + """ + Tests the computation of trim tab angle. + + Args: + wingsail_controller: Instance of WingsailController. + """ + computed_trim_tab_angle = wingsail_controller._compute_trim_tab_angle( + reynolds_number, apparent_wind_direction + ) + assert np.isclose(computed_trim_tab_angle, expected_trim_tab_angle) + + @pytest.mark.parametrize( + "apparent_wind_speed, apparent_wind_direction, expected_trim_tab_angle", + [ + (10.0, 0, 6.720859435489), + (4.0, 90.0, 5.75), + (10.0, 180.0, 6.720859435489), + (15.0, -50.0, -6.869536144), + (20.0, -120.0, -6.99271485887), + ], + ) + def test_get_trim_tab_angle( + self, + wingsail_controller, + apparent_wind_speed, + apparent_wind_direction, + expected_trim_tab_angle, + ): + """ + Tests the computation of final trim tab angle. + + Args: + wingsail_controller: Instance of WingsailController. + """ + computed_trim_tab_angle = wingsail_controller.get_trim_tab_angle( + apparent_wind_speed, apparent_wind_direction + ) + assert np.isclose(computed_trim_tab_angle, expected_trim_tab_angle)