Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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
200 changes: 200 additions & 0 deletions tests/test_propellers.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading