ContentsFoundations to FrontierBezhig

eng-lab/labs/el01_hover/tests/test_hover.py

All lab code. Download this file.

"""EL01 lab tests for hwe/core/hover.py (steps 1-3).

Run with `python labs/el01_hover/check.py N` from the eng-lab folder.
Every expected number is worked out by hand in a comment, so a failing test
shows you the arithmetic it expected.
"""
import unittest

from _impl import load
from hwe.units import G0, Q_, DimensionalityError

hover = load("hover")

G = 9.80665          # standard gravity, m/s^2 (exact)


def close(test, quantity, expected, unit, rel=1e-9):
    """Assert quantity, converted to `unit`, equals expected to a relative tolerance."""
    value = quantity.to(unit).m
    test.assertAlmostEqual(value, expected, delta=abs(expected) * rel + 1e-12,
                           msg=f"expected {expected} {unit}, got {value} {unit}")


# ----------------------------------------------------------------------
class Step1Weight(unittest.TestCase):
    def test_weight_of_1_5_kg(self):
        # 1.5 kg x 9.80665 m/s^2 = 14.709975 N
        close(self, hover.weight(Q_(1.5, "kg")), 14.709975, "N")

    def test_weight_accepts_grams(self):
        # 650 g = 0.650 kg; 0.650 x 9.80665 = 6.3743225 N
        close(self, hover.weight(Q_(650, "g")), 6.3743225, "N")

    def test_weight_uses_the_g_it_is_given(self):
        # Ingenuity on Mars: 1.8 kg x 3.73 m/s^2 = 6.714 N (same mass, different weight)
        close(self, hover.weight(Q_(1.8, "kg"), Q_(3.73, "m/s^2")), 6.714, "N")

    def test_weight_is_a_force(self):
        self.assertEqual(hover.weight(Q_(1, "kg")).dimensionality, Q_(1, "N").dimensionality)

    def test_bare_number_is_refused(self):
        with self.assertRaises(TypeError):
            hover.weight(1.5)


# ----------------------------------------------------------------------
class Step2Newton(unittest.TestCase):
    def test_net_force_four_motors(self):
        # 4 x 4 N = 16 N up; weight 14.709975 N down; net 1.290025 N up
        close(self, hover.net_force([Q_(4, "N")] * 4, Q_(1.5, "kg")), 1.290025, "N")

    def test_hover_in_gram_force_is_zero_net_force(self):
        # 4 x 375 gf = 1500 gf, exactly the weight of 1.5 kg: a hover
        net = hover.net_force([Q_(375, "gf")] * 4, Q_(1.5, "kg"))
        self.assertAlmostEqual(net.to("N").m, 0.0, delta=1e-9)

    def test_unequal_thrusts_all_count(self):
        # 3 + 4 + 5 + 6 = 18 N; 18 - 1.2 x 9.80665 = 18 - 11.76798 = 6.23202 N
        thrusts = [Q_(3, "N"), Q_(4, "N"), Q_(5, "N"), Q_(6, "N")]
        close(self, hover.net_force(thrusts, Q_(1.2, "kg")), 6.23202, "N")

    def test_acceleration_climbing(self):
        # 4 x 5 N = 20 N; weight 11.76798 N; net 8.23202 N; a = 8.23202 / 1.2 = 6.860016667 m/s^2
        close(self, hover.acceleration([Q_(5, "N")] * 4, Q_(1.2, "kg")), 6.860016666666667, "m/s^2")

    def test_acceleration_sinking_is_negative(self):
        # 4 x 2 N = 8 N < 11.76798 N: a = (8 - 11.76798) / 1.2 = -3.139983333 m/s^2
        close(self, hover.acceleration([Q_(2, "N")] * 4, Q_(1.2, "kg")), -3.1399833333333333, "m/s^2")

    def test_net_force_and_acceleration_use_the_g_they_are_given(self):
        # On Mars: 16 N up, 1.5 kg x 3.73 m/s^2 = 5.595 N down, net 10.405 N; a = 10.405 / 1.5 = 6.936667
        mars = Q_(3.73, "m/s^2")
        close(self, hover.net_force([Q_(4, "N")] * 4, Q_(1.5, "kg"), mars), 10.405, "N")
        close(self, hover.acceleration([Q_(4, "N")] * 4, Q_(1.5, "kg"), mars), 10.405 / 1.5, "m/s^2")

    def test_no_thrust_is_free_fall(self):
        # With zero thrust only gravity acts: a = -g
        close(self, hover.acceleration([Q_(0, "N")] * 4, Q_(0.8, "kg")), -G, "m/s^2")

    def test_accelerometer_at_rest_reads_plus_g(self):
        close(self, hover.accelerometer_reading(Q_(0, "m/s^2")), G, "m/s^2")

    def test_accelerometer_in_free_fall_reads_zero(self):
        reading = hover.accelerometer_reading(-G0)
        self.assertAlmostEqual(reading.to("m/s^2").m, 0.0, delta=1e-12)

    def test_accelerometer_while_speeding_up_upward(self):
        # a = +2 m/s^2: reading = 2 + 9.80665 = 11.80665 m/s^2
        close(self, hover.accelerometer_reading(Q_(2, "m/s^2")), 11.80665, "m/s^2")

    def test_accelerometer_on_mars_at_rest(self):
        close(self, hover.accelerometer_reading(Q_(0, "m/s^2"), Q_(3.73, "m/s^2")), 3.73, "m/s^2")

    def test_accelerometer_reads_thrust_per_unit_mass(self):
        # The key idea of section 2.6: reading = (sum of thrusts) / mass, whatever the motion.
        thrusts, m = [Q_(3, "N"), Q_(4, "N"), Q_(5, "N"), Q_(6, "N")], Q_(1.2, "kg")
        reading = hover.accelerometer_reading(hover.acceleration(thrusts, m))
        close(self, reading, 18 / 1.2, "m/s^2")


# ----------------------------------------------------------------------
class Step3ThrustToWeight(unittest.TestCase):
    def test_ttw_five_inch_example(self):
        # 4 x 1500 gf = 6000 gf of thrust; 650 g weighs 650 gf; T/W = 6000 / 650 = 9.230769...
        ttw = hover.thrust_to_weight(Q_(1500, "gf"), 4, Q_(650, "g"))
        self.assertAlmostEqual(ttw.m, 6000 / 650, delta=1e-9)

    def test_ttw_is_a_pure_number(self):
        ttw = hover.thrust_to_weight(Q_(1500, "gf"), 4, Q_(650, "g"))
        self.assertTrue(ttw.dimensionless)

    def test_ttw_in_newtons_example_drone(self):
        # An example 1.5 kg drone: k = 9.1e-6 N/(rad/s)^2 at 900 rad/s -> 7.371 N per motor;
        # 4 x 7.371 = 29.484 N; weight 14.709975 N; T/W = 2.004354...
        ttw = hover.thrust_to_weight(Q_(7.371, "N"), 4, Q_(1.5, "kg"))
        self.assertAlmostEqual(ttw.m, 29.484 / 14.709975, delta=1e-9)

    def test_ttw_counts_every_motor(self):
        # A hexacopter with the same motors has 6/4 the T/W of the quad.
        quad = hover.thrust_to_weight(Q_(1000, "gf"), 4, Q_(1, "kg")).m
        hexa = hover.thrust_to_weight(Q_(1000, "gf"), 6, Q_(1, "kg")).m
        self.assertAlmostEqual(hexa / quad, 1.5, delta=1e-12)

    def test_hover_thrust_per_motor(self):
        # 1.5 kg shared by 4 motors: 14.709975 / 4 = 3.67749375 N each
        close(self, hover.hover_thrust_per_motor(Q_(1.5, "kg"), 4), 3.67749375, "N")

    def test_hover_fraction_is_inverse_of_ttw(self):
        args = (Q_(7.371, "N"), 4, Q_(1.5, "kg"))
        product = hover.hover_thrust_fraction(*args).m * hover.thrust_to_weight(*args).m
        self.assertAlmostEqual(product, 1.0, delta=1e-12)

    def test_hover_fraction_value(self):
        # 650 gf / 6000 gf = 0.108333...
        frac = hover.hover_thrust_fraction(Q_(1500, "gf"), 4, Q_(650, "g"))
        self.assertAlmostEqual(frac.m, 650 / 6000, delta=1e-12)

    def test_hover_fraction_above_one_is_returned_not_raised(self):
        # 4 x 100 gf = 400 gf cannot lift 500 g: it would need 500 / 400 = 1.25 of full thrust
        frac = hover.hover_thrust_fraction(Q_(100, "gf"), 4, Q_(500, "g"))
        self.assertAlmostEqual(frac.m, 1.25, delta=1e-12)

    def test_max_climb_acceleration(self):
        # g (T/W - 1) = 9.80665 x (6000/650 - 1) = 80.71627...
        a = hover.max_climb_acceleration(Q_(1500, "gf"), 4, Q_(650, "g"))
        close(self, a, G * (6000 / 650 - 1), "m/s^2")

    def test_max_climb_acceleration_ttw_two_is_one_g(self):
        # T/W = 2 exactly: the drone can accelerate upward at exactly 1 g
        a = hover.max_climb_acceleration(Q_(500, "gf"), 4, Q_(1000, "g"))
        close(self, a, G, "m/s^2")

    def test_max_climb_acceleration_negative_when_too_heavy(self):
        a = hover.max_climb_acceleration(Q_(100, "gf"), 4, Q_(500, "g"))   # T/W = 0.8
        close(self, a, G * (0.8 - 1), "m/s^2")

    def test_the_family_uses_the_g_it_is_given(self):
        # Mars, g = 3.73 m/s^2. 6000 gf = 58.8399 N of thrust; 650 g weighs 0.65 x 3.73 = 2.4245 N there.
        mars = Q_(3.73, "m/s^2")
        thrust = 4 * 1.5 * 9.80665                      # N
        self.assertAlmostEqual(hover.thrust_to_weight(Q_(1500, "gf"), 4, Q_(650, "g"), mars).m,
                               thrust / (0.65 * 3.73), delta=1e-9)
        close(self, hover.hover_thrust_per_motor(Q_(1.8, "kg"), 4, mars), 1.8 * 3.73 / 4, "N")
        close(self, hover.max_climb_acceleration(Q_(1500, "gf"), 4, Q_(650, "g"), mars),
              (thrust - 0.65 * 3.73) / 0.65, "m/s^2")

    def test_wrong_unit_for_thrust_is_refused(self):
        with self.assertRaises(DimensionalityError):
            hover.thrust_to_weight(Q_(1500, "g"), 4, Q_(650, "g"))   # grams of MASS, not gram-force


if __name__ == "__main__":
    unittest.main()