diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..78edb496 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,109 @@ +import math +import pytest +from pythrust.propulsion.models import ( + MotorSpec, + BatterySpec, + SystemSpec, + PropellerSpec, + OperatingPoint +) + + +def test_motor_spec_no_load_current(): + # 1. Base case: rpm <= 0 + motor = MotorSpec( + kv_rpm_per_v=980.0, + resistance_ohm=0.06, + no_load_current_a=1.2, + current_max_a=30.0 + ) + assert motor.get_no_load_current(-10.0) == 1.2 + assert motor.get_no_load_current(0.0) == 1.2 + + # 2. Power-law iron loss (iron_loss_exponent > 0) + # rpm_0 = kv_rpm_per_v * no_load_voltage_v = 980.0 * 10.0 = 9800.0 + # rpm = 4900.0 -> (4900.0 / 9800.0)^1.5 = 0.5^1.5 approx 0.35355 + # no_load_current = 1.2 * 0.35355 = 0.42426 + motor_iron = MotorSpec( + kv_rpm_per_v=980.0, + resistance_ohm=0.06, + no_load_current_a=1.2, + current_max_a=30.0, + no_load_voltage_v=10.0, + iron_loss_exponent=1.5 + ) + expected_rpm_0 = 980.0 * 10.0 + expected_current = 1.2 * (4900.0 / expected_rpm_0) ** 1.5 + assert math.isclose(motor_iron.get_no_load_current(4900.0), expected_current) + + # 3. Drela's quadratic speed model (iron_loss_exponent = 0.0) + # omega = rpm * pi / 30 = 3000 * pi / 30 = 100 * pi + # Io = Io0 + Io1 * omega + Io2 * omega^2 + motor_drela = MotorSpec( + kv_rpm_per_v=980.0, + resistance_ohm=0.06, + no_load_current_a=1.2, + current_max_a=30.0, + no_load_current_linear=0.001, + no_load_current_quadratic=0.00002 + ) + omega = 3000.0 * (math.pi / 30.0) + expected_drela = 1.2 + 0.001 * omega + 0.00002 * (omega ** 2) + assert math.isclose(motor_drela.get_no_load_current(3000.0), expected_drela) + + +def test_motor_spec_winding_resistance(): + # 1. Base case: resistance_quadratic <= 0 + motor = MotorSpec( + kv_rpm_per_v=980.0, + resistance_ohm=0.06, + no_load_current_a=1.2, + current_max_a=30.0 + ) + assert motor.get_winding_resistance(10.0) == 0.06 + assert motor.get_winding_resistance(-5.0) == 0.06 + + # 2. Quadratic resistance (resistance_quadratic > 0) + # R = R_base + R_quad * I^2 = 0.06 + 0.002 * 10^2 = 0.06 + 0.2 = 0.26 + motor_quad = MotorSpec( + kv_rpm_per_v=980.0, + resistance_ohm=0.06, + no_load_current_a=1.2, + current_max_a=30.0, + resistance_quadratic=0.002 + ) + assert math.isclose(motor_quad.get_winding_resistance(10.0), 0.26) + + +def test_other_specs(): + battery = BatterySpec(voltage_v=11.1, discharge_efficiency=0.98) + assert battery.voltage_v == 11.1 + assert battery.discharge_efficiency == 0.98 + + system = SystemSpec(resistance_ohm=0.015) + assert system.resistance_ohm == 0.015 + + propeller = PropellerSpec(diameter_m=0.254, blade_count=3, pitch_m=0.114) + assert propeller.diameter_m == 0.254 + assert propeller.blade_count == 3 + assert propeller.pitch_m == 0.114 + + +def test_operating_point(): + op = OperatingPoint( + rpm=8000.0, + advance_ratio=0.4, + ct=0.08, + cp=0.04, + thrust_n=15.0, + torque_nm=0.3, + shaft_power_w=250.0, + motor_power_w=300.0, + battery_power_w=310.0, + motor_current_a=25.0, + motor_voltage_v=12.0, + is_feasible=True + ) + assert op.rpm == 8000.0 + assert op.is_feasible is True + assert op.infeasible_reason is None diff --git a/tests/test_propellers.py b/tests/test_propellers.py new file mode 100644 index 00000000..afd194e4 --- /dev/null +++ b/tests/test_propellers.py @@ -0,0 +1,200 @@ +import json +import math +import pytest +from pathlib import Path +from pythrust.propellers.database import ( + PropellerDatabase, + PropellerMetadata, + PropellerDataPoint, + PropellerEntry, + _find_bracketing_indices, + _find_insert_index +) + + +def test_find_insert_index(): + values = [0.1, 0.2, 0.4, 0.8] + assert _find_insert_index(values, 0.0) == 1 + assert _find_insert_index(values, 0.15) == 1 + assert _find_insert_index(values, 0.3) == 2 + assert _find_insert_index(values, 0.5) == 3 + assert _find_insert_index(values, 1.0) == 3 + + +def test_find_bracketing_indices(): + values = [1000.0, 2000.0, 3000.0] + assert _find_bracketing_indices(values, 500.0) == (0, 0) + assert _find_bracketing_indices(values, 3500.0) == (2, 2) + assert _find_bracketing_indices(values, 1500.0) == (0, 1) + assert _find_bracketing_indices(values, 2000.0) == (0, 1) + + +def test_propeller_entry_properties(): + meta = PropellerMetadata( + id="apc_10x4.7", + manufacturer="APC", + model="SF", + diameter_in=10.0, + pitch_in=4.7, + blade_count=2, + data_csv="apc_10x4.7.csv" + ) + + data = { + 1000.0: [PropellerDataPoint(j=0.0, ct=0.1, cp=0.05)], + 2000.0: [PropellerDataPoint(j=0.0, ct=0.12, cp=0.06)] + } + + entry = PropellerEntry(metadata=meta, data_by_rpm=data) + + assert math.isclose(entry.diameter_m, 10.0 * 0.0254) + assert math.isclose(entry.pitch_m, 4.7 * 0.0254) + assert entry.rpm_levels == [1000.0, 2000.0] + + +def test_propeller_entry_get_coefficients(): + meta = PropellerMetadata( + id="apc_10x4.7", + manufacturer="APC", + model="SF", + diameter_in=10.0, + pitch_in=4.7, + blade_count=2, + data_csv="apc_10x4.7.csv" + ) + + # RPM band 1: 1000.0, J range: 0.0 -> 0.4 -> 0.8 + # RPM band 2: 2000.0, J range: 0.0 -> 0.4 -> 0.8 + data = { + 1000.0: [ + PropellerDataPoint(j=0.0, ct=0.1, cp=0.05), + PropellerDataPoint(j=0.4, ct=0.08, cp=0.04), + PropellerDataPoint(j=0.8, ct=0.02, cp=0.01) + ], + 2000.0: [ + PropellerDataPoint(j=0.0, ct=0.12, cp=0.06), + PropellerDataPoint(j=0.4, ct=0.10, cp=0.05), + PropellerDataPoint(j=0.8, ct=0.04, cp=0.02) + ] + } + + entry = PropellerEntry(metadata=meta, data_by_rpm=data) + + # 1. Test empty data + empty_entry = PropellerEntry(metadata=meta, data_by_rpm={}) + assert empty_entry.get_coefficients(1000.0, 0.2) == (0.0, 0.0) + + # 2. Test clamping on J (low J) + ct, cp = entry.get_coefficients(1000.0, -0.1) + assert ct == 0.1 and cp == 0.05 + + # 3. Test clamping on J (high J - no extrapolation) + ct, cp = entry.get_coefficients(1000.0, 0.9) + assert ct == 0.0 and cp == 0.0 + + # 4. Test exact RPM and J interpolation + # J = 0.2, RPM = 1000.0 -> midway between J=0.0 and J=0.4 + # expected Ct = 0.1 - 0.5 * (0.1 - 0.08) = 0.09 + # expected Cp = 0.05 - 0.5 * (0.05 - 0.04) = 0.045 + ct, cp = entry.get_coefficients(1000.0, 0.2) + assert math.isclose(ct, 0.09) + assert math.isclose(cp, 0.045) + + # 5. Test RPM interpolation (blend) + # J = 0.4, RPM = 1500.0 -> midway between 1000.0 and 2000.0 + # RPM 1000 Ct=0.08, RPM 2000 Ct=0.10 -> expected = 0.09 + # RPM 1000 Cp=0.04, RPM 2000 Cp=0.05 -> expected = 0.045 + ct, cp = entry.get_coefficients(1500.0, 0.4) + assert math.isclose(ct, 0.09) + assert math.isclose(cp, 0.045) + + # 6. Test RPM clamping (above max RPM level) + # RPM = 3000.0 -> clamp to 2000.0. J = 0.4 -> expected Ct=0.10, Cp=0.05 + ct, cp = entry.get_coefficients(3000.0, 0.4) + assert math.isclose(ct, 0.10) + assert math.isclose(cp, 0.05) + + +def test_propeller_database_load(tmp_path): + # Create mock JSON and CSV file in tmp_path + prop_id = "test_prop" + json_content = { + "id": prop_id, + "manufacturer": "APC", + "model": "Thin Electric", + "diameter_in": 12.0, + "pitch_in": 6.0, + "blade_count": 2, + "data_csv": "test_prop_data.csv" + } + + csv_content = ( + "rpm,advance_ratio,thrust_coeff,power_coeff\n" + "1000,0.0,0.1,0.05\n" + "1000,0.5,0.05,0.02\n" + "2000,0.0,0.11,0.06\n" + "2000,0.5,0.06,0.03\n" + ) + + json_file = tmp_path / f"{prop_id}.json" + csv_file = tmp_path / "test_prop_data.csv" + + json_file.write_text(json.dumps(json_content)) + csv_file.write_text(csv_content) + + db = PropellerDatabase() + assert not db.is_loaded + + # Test load + success = db.load(tmp_path) + assert success + assert db.is_loaded + assert db.propeller_count == 1 + assert db.list_propellers() == [prop_id] + + entry = db.get(prop_id) + assert entry is not None + assert entry.metadata.id == prop_id + + # Test find_by_size + found = db.find_by_size(12.1, 5.9, blade_count=2, tolerance=0.5) + assert found is not None + assert found.metadata.id == prop_id + + # Test not found by blade_count + assert db.find_by_size(12.1, 5.9, blade_count=3) is None + + # Test get_interpolated_coefficients + ct, cp, ok = db.get_interpolated_coefficients(12.0, 6.0, 2, 1500.0, 0.25) + assert ok + # RPM 1000 J 0.25 -> 0.1 - 0.5 * 0.05 = 0.075 + # RPM 2000 J 0.25 -> 0.11 - 0.5 * 0.05 = 0.085 + # RPM 1500 J 0.25 -> 0.08 + assert math.isclose(ct, 0.08) + + +def test_propeller_database_strict_validation(tmp_path): + # 1. Invalid columns CSV + prop_id = "bad_cols" + json_content = { + "id": prop_id, + "manufacturer": "APC", + "model": "EP", + "diameter_in": 10.0, + "pitch_in": 5.0, + "blade_count": 2, + "data_csv": "bad_cols.csv" + } + bad_csv = "rpm,wrong_column,thrust_coeff,power_coeff\n1000,0.1,0.1,0.05\n" + (tmp_path / f"{prop_id}.json").write_text(json.dumps(json_content)) + (tmp_path / "bad_cols.csv").write_text(bad_csv) + + db = PropellerDatabase() + + # Non-strict should just skip + db.load(tmp_path, strict=False) + assert db.propeller_count == 0 + + # Strict should raise ValueError + with pytest.raises(ValueError, match="Missing required columns"): + db.load(tmp_path, strict=True) diff --git a/tests/test_solver.py b/tests/test_solver.py new file mode 100644 index 00000000..828d2755 --- /dev/null +++ b/tests/test_solver.py @@ -0,0 +1,173 @@ +import math +import pytest +from pythrust.propellers.database import ( + PropellerMetadata, + PropellerDataPoint, + PropellerEntry +) +from pythrust.propulsion.models import ( + MotorSpec, + BatterySpec, + SystemSpec, + PropellerSpec, + OperatingPoint +) +from pythrust.propulsion.solver import PropulsionSolver, SolverConfig + + +@pytest.fixture +def test_setup(): + motor = MotorSpec( + kv_rpm_per_v=980.0, + resistance_ohm=0.06, + no_load_current_a=1.2, + current_max_a=30.0, + torque_constant_kv_ratio=1.0, + magnetic_lag_tau=0.0001 + ) + battery = BatterySpec(voltage_v=11.1, discharge_efficiency=0.98) + system = SystemSpec(resistance_ohm=0.015) + propeller = PropellerSpec(diameter_m=0.254, blade_count=2, pitch_m=0.114) + + meta = PropellerMetadata( + id="apc_10x4.7", + manufacturer="APC", + model="SF", + diameter_in=10.0, + pitch_in=4.7, + blade_count=2, + data_csv="apc_10x4.7.csv" + ) + + data = { + 1000.0: [ + PropellerDataPoint(j=0.0, ct=0.11, cp=0.055), + PropellerDataPoint(j=0.4, ct=0.08, cp=0.04), + PropellerDataPoint(j=0.8, ct=0.02, cp=0.01) + ], + 8000.0: [ + PropellerDataPoint(j=0.0, ct=0.12, cp=0.06), + PropellerDataPoint(j=0.4, ct=0.09, cp=0.045), + PropellerDataPoint(j=0.8, ct=0.03, cp=0.015) + ] + } + + prop_entry = PropellerEntry(metadata=meta, data_by_rpm=data) + + return motor, battery, system, propeller, prop_entry + + +def test_solver_zero_throttle(test_setup): + motor, battery, system, propeller, prop_entry = test_setup + solver = PropulsionSolver() + + op = solver.solve_operating_point( + motor=motor, + battery=battery, + system=system, + propeller=propeller, + prop_entry=prop_entry, + rho=1.225, + airspeed_mps=0.0, + throttle=0.0 + ) + + assert op.rpm == 0.0 + assert op.is_feasible is False + assert op.infeasible_reason == "throttle<=0" + + +def test_solver_successful_solve(test_setup): + motor, battery, system, propeller, prop_entry = test_setup + solver = PropulsionSolver() + + # Use static thrust (airspeed = 0.0) to avoid numeric edge issues on J + op = solver.solve_operating_point( + motor=motor, + battery=battery, + system=system, + propeller=propeller, + prop_entry=prop_entry, + rho=1.225, + airspeed_mps=0.0, + throttle=0.8 + ) + + # It should successfully converge to a positive RPM and be feasible + assert op.is_feasible is True + assert op.rpm > 0.0 + assert op.advance_ratio == 0.0 + assert op.ct > 0.0 + assert op.cp > 0.0 + assert op.thrust_n > 0.0 + assert op.torque_nm > 0.0 + + # Test backing variables consistency + # V_applied = 0.8 * 11.1 = 8.88V + # V_motor + I * R_sys = V_applied + v_applied = 0.8 * battery.voltage_v + v_motor = op.motor_voltage_v + i_sys = op.motor_current_a + assert math.isclose(v_motor + i_sys * system.resistance_ohm, v_applied, rel_tol=1e-3) + + +def test_solver_current_limit_exceeded(test_setup): + motor, battery, system, propeller, prop_entry = test_setup + # Create a motor with a very low current limit to force current_limit infeasibility + low_limit_motor = MotorSpec( + kv_rpm_per_v=motor.kv_rpm_per_v, + resistance_ohm=motor.resistance_ohm, + no_load_current_a=motor.no_load_current_a, + current_max_a=1.0 # Extremely low current limit + ) + + solver = PropulsionSolver() + op = solver.solve_operating_point( + motor=low_limit_motor, + battery=battery, + system=system, + propeller=propeller, + prop_entry=prop_entry, + rho=1.225, + airspeed_mps=0.0, + throttle=0.9 + ) + + assert op.is_feasible is False + assert op.infeasible_reason == "current_limit" + + +def test_solver_no_bracket(test_setup): + motor, battery, system, propeller, prop_entry = test_setup + # Set high airspeed, so at low RPM the advance ratio is huge (past J_max) + # causing cp <= 0 and evaluate_state returning inf, or v_motor > v_applied. + solver = PropulsionSolver() + op = solver.solve_operating_point( + motor=motor, + battery=battery, + system=system, + propeller=propeller, + prop_entry=prop_entry, + rho=1.225, + airspeed_mps=100.0, # Unrealistic high speed + throttle=0.1 # Very low throttle + ) + + assert op.is_feasible is False + assert op.infeasible_reason == "no_bracket" + + +def test_estimate_j_max(test_setup): + _, _, _, _, prop_entry = test_setup + + # 1. Standard estimation + # j_max values from data: 0.8 at 1000.0 RPM, 0.8 at 8000.0 RPM. + # List of j_maxes is [0.8, 0.8]. Sorted. + # len is 2. start = 2 // 4 = 0. + # Should return j_maxes[0] = 0.8 + assert PropulsionSolver._estimate_j_max(prop_entry) == 0.8 + + # 2. Empty data_by_rpm + empty_meta = PropellerMetadata("empty", "A", "B", 10.0, 5.0, 2, "csv") + empty_entry = PropellerEntry(metadata=empty_meta, data_by_rpm={}) + assert PropulsionSolver._estimate_j_max(empty_entry) == 0.6