295 lines
9.5 KiB
Python
295 lines
9.5 KiB
Python
# Copyright (C) 2026 Antoine ANCEAU
|
|
#
|
|
# This file is part of navaltoolbox.
|
|
#
|
|
# navaltoolbox is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
Tests for navaltoolbox Python bindings.
|
|
|
|
These tests mirror the Rust integration tests to verify Python bindings work correctly.
|
|
"""
|
|
|
|
import pytest
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
class TestTank:
|
|
"""Tests for Tank class."""
|
|
|
|
def test_box_tank_volume(self):
|
|
"""Test box tank volume calculation."""
|
|
from navaltoolbox import Tank
|
|
|
|
# 10 x 5 x 2 = 100 m³
|
|
tank = Tank.from_box("Test", 0.0, 10.0, 0.0, 5.0, 0.0, 2.0, 1000.0)
|
|
|
|
expected_volume = 100.0
|
|
error = abs(tank.total_volume - expected_volume)
|
|
|
|
assert error < 1.0, f"Tank volume error: expected {expected_volume}, got {tank.total_volume}"
|
|
|
|
def test_tank_fill_level(self):
|
|
"""Test tank fill level management."""
|
|
from navaltoolbox import Tank
|
|
|
|
tank = Tank.from_box("Test", 0.0, 10.0, 0.0, 5.0, 0.0, 2.0, 1000.0)
|
|
|
|
# Set 50% fill
|
|
tank.fill_percent = 50.0
|
|
|
|
assert abs(tank.fill_level - 0.5) < 1e-6
|
|
assert abs(tank.fill_percent - 50.0) < 1e-6
|
|
|
|
# Fill volume should be ~50 m³
|
|
assert abs(tank.fill_volume - 50.0) < 1.0, f"Fill volume should be ~50, got {tank.fill_volume}"
|
|
|
|
# Fluid mass should be ~50000 kg
|
|
assert abs(tank.fluid_mass - 50000.0) < 100.0, f"Fluid mass should be ~50000, got {tank.fluid_mass}"
|
|
|
|
def test_tank_center_of_gravity(self):
|
|
"""Test tank center of gravity calculation."""
|
|
from navaltoolbox import Tank
|
|
|
|
tank = Tank.from_box("Test", 0.0, 10.0, -2.5, 2.5, 0.0, 2.0, 1000.0)
|
|
tank.fill_percent = 50.0
|
|
|
|
cog = tank.center_of_gravity
|
|
|
|
# For a box tank at 50% fill:
|
|
# x should be at center (5.0)
|
|
# y should be at center (0.0)
|
|
# z should be at half of fill height
|
|
assert abs(cog[0] - 5.0) < 0.1, f"CoG x should be ~5.0, got {cog[0]}"
|
|
assert abs(cog[1]) < 0.1, f"CoG y should be ~0.0, got {cog[1]}"
|
|
assert cog[2] < 1.0, f"CoG z should be < 1.0 (half fill), got {cog[2]}"
|
|
|
|
def test_tank_free_surface_moment(self):
|
|
"""Test free surface moment calculation."""
|
|
from navaltoolbox import Tank
|
|
|
|
tank = Tank.from_box("Test", 0.0, 10.0, -2.5, 2.5, 0.0, 2.0, 1000.0)
|
|
tank.fill_percent = 50.0
|
|
|
|
fsm_t = tank.free_surface_moment_t
|
|
fsm_l = tank.free_surface_moment_l
|
|
|
|
assert fsm_t > 0.0, "FSM_t should be > 0 at partial fill"
|
|
assert fsm_l > 0.0, "FSM_l should be > 0 at partial fill"
|
|
|
|
# For box tank:
|
|
# I_t = L * B³ / 12 = 10 * 5³ / 12 ≈ 104.17
|
|
# I_l = B * L³ / 12 = 5 * 10³ / 12 ≈ 416.67
|
|
assert abs(fsm_t - 104.17) < 5.0, f"FSM_t should be ~104.17, got {fsm_t}"
|
|
assert abs(fsm_l - 416.67) < 20.0, f"FSM_l should be ~416.67, got {fsm_l}"
|
|
|
|
def test_tank_no_free_surface_when_full(self):
|
|
"""Test no free surface when tank is full."""
|
|
from navaltoolbox import Tank
|
|
|
|
tank = Tank.from_box("Test", 0.0, 10.0, -2.5, 2.5, 0.0, 2.0, 1000.0)
|
|
tank.fill_percent = 100.0
|
|
|
|
assert tank.free_surface_moment_t == 0.0, "FSM_t should be 0 when full"
|
|
assert tank.free_surface_moment_l == 0.0, "FSM_l should be 0 when full"
|
|
|
|
|
|
class TestVessel:
|
|
"""Tests for Vessel class."""
|
|
|
|
def test_vessel_from_tank_manager(self):
|
|
"""Test vessel tank management."""
|
|
from navaltoolbox import Vessel, Tank, Hull
|
|
|
|
# We need a hull to create a vessel - use the DTMB5415
|
|
stl_path = Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl"
|
|
if not stl_path.exists():
|
|
pytest.skip("DTMB5415 STL file not found")
|
|
|
|
hull = Hull(str(stl_path))
|
|
vessel = Vessel(hull)
|
|
|
|
assert vessel.num_tanks() == 0
|
|
|
|
# Add a tank
|
|
tank = Tank.from_box("Fuel1", 50.0, 60.0, -5.0, 5.0, 0.0, 3.0, 850.0)
|
|
vessel.add_tank(tank)
|
|
|
|
assert vessel.num_tanks() == 1
|
|
|
|
|
|
class TestDTMB5415:
|
|
"""DTMB5415 validation tests."""
|
|
|
|
# Reference GZ data from Ariffin (2017) PhD Thesis Figure 3.15
|
|
REFERENCE_DATA = {
|
|
0: 0.000,
|
|
5: 0.171,
|
|
10: 0.339,
|
|
15: 0.505,
|
|
20: 0.674,
|
|
25: 0.848,
|
|
30: 0.993,
|
|
35: 1.069,
|
|
40: 1.077,
|
|
45: 1.025,
|
|
50: 0.924,
|
|
55: 0.789,
|
|
60: 0.625,
|
|
}
|
|
|
|
# Loading condition
|
|
DISPLACEMENT = 8635000.0 # kg
|
|
LCG = 71.670
|
|
TCG = 0.0
|
|
VCG = 7.555
|
|
|
|
@pytest.fixture
|
|
def dtmb5415_vessel(self):
|
|
"""Load DTMB5415 hull and create vessel."""
|
|
from navaltoolbox import Hull, Vessel
|
|
|
|
stl_path = Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl"
|
|
if not stl_path.exists():
|
|
pytest.skip("DTMB5415 STL file not found")
|
|
|
|
hull = Hull(str(stl_path))
|
|
return Vessel(hull)
|
|
|
|
def test_dtmb5415_hull_loads(self):
|
|
"""Test DTMB5415 hull loads correctly."""
|
|
from navaltoolbox import Hull
|
|
|
|
stl_path = Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl"
|
|
if not stl_path.exists():
|
|
pytest.skip("DTMB5415 STL file not found")
|
|
|
|
hull = Hull(str(stl_path))
|
|
|
|
assert hull.num_triangles() > 1000, "Hull should have many triangles"
|
|
|
|
bounds = hull.get_bounds()
|
|
loa = bounds[1] - bounds[0]
|
|
boa = bounds[3] - bounds[2]
|
|
|
|
print(f"DTMB5415: LOA={loa:.2f}m, BOA={boa:.2f}m")
|
|
|
|
assert 150.0 < loa < 160.0, f"LOA should be ~153m, got {loa}"
|
|
assert 18.0 < boa < 22.0, f"BOA should be ~20.5m, got {boa}"
|
|
|
|
def test_dtmb5415_volume_at_reference_draft(self, dtmb5415_vessel):
|
|
"""Test volume at reference draft."""
|
|
from navaltoolbox import HydrostaticsCalculator
|
|
|
|
calc = HydrostaticsCalculator(dtmb5415_vessel, 1025.0)
|
|
state = calc.calculate_at_draft(6.15, 0.0, 0.0, self.VCG)
|
|
|
|
print(f"DTMB5415 at T=6.15m: Volume={state.volume:.1f}m³")
|
|
|
|
# Reference: ~8424 m³
|
|
assert 7500.0 < state.volume < 9000.0, f"Volume should be ~8424m³, got {state.volume:.1f}"
|
|
|
|
def test_dtmb5415_gz_curve_accuracy(self, dtmb5415_vessel):
|
|
"""Test GZ curve accuracy against reference data."""
|
|
from navaltoolbox import StabilityCalculator
|
|
|
|
calc = StabilityCalculator(dtmb5415_vessel, 1025.0)
|
|
|
|
heels = list(self.REFERENCE_DATA.keys())
|
|
curve = calc.calculate_gz_curve(
|
|
self.DISPLACEMENT,
|
|
(self.LCG, self.TCG, self.VCG),
|
|
heels
|
|
)
|
|
|
|
tolerance = 0.10 # 10cm
|
|
|
|
passed = 0
|
|
total = len(heels)
|
|
|
|
print("\nDTMB5415 GZ Validation:")
|
|
print("Heel Calc GZ Ref GZ Error")
|
|
print("-" * 45)
|
|
|
|
for heel, gz in zip(curve.heels(), curve.values()):
|
|
ref_gz = self.REFERENCE_DATA.get(int(heel), 0)
|
|
error = abs(gz - ref_gz)
|
|
|
|
status = "✓" if error < tolerance else "✗"
|
|
if error < tolerance:
|
|
passed += 1
|
|
|
|
print(f"{heel:5.1f}° {gz:7.3f}m {ref_gz:7.3f}m {error:.3f}m {status}")
|
|
|
|
pass_rate = passed / total
|
|
print(f"\nPassed: {passed}/{total} ({pass_rate * 100:.0f}%)")
|
|
|
|
assert pass_rate >= 0.7, f"At least 70% should pass, got {pass_rate * 100:.0f}%"
|
|
|
|
def test_dtmb5415_max_gz_location(self, dtmb5415_vessel):
|
|
"""Test maximum GZ location."""
|
|
from navaltoolbox import StabilityCalculator
|
|
|
|
calc = StabilityCalculator(dtmb5415_vessel, 1025.0)
|
|
|
|
heels = list(range(0, 65, 5))
|
|
curve = calc.calculate_gz_curve(
|
|
self.DISPLACEMENT,
|
|
(self.LCG, self.TCG, self.VCG),
|
|
heels
|
|
)
|
|
|
|
# Find max GZ
|
|
gz_values = curve.values()
|
|
max_gz = max(gz_values)
|
|
max_idx = gz_values.index(max_gz)
|
|
max_heel = heels[max_idx]
|
|
|
|
print(f"DTMB5415 Max GZ: {max_gz:.3f}m at {max_heel}° (Ref: ~1.08m at ~38°)")
|
|
|
|
# Max should be between 30-50°
|
|
assert 30.0 <= max_heel <= 50.0, f"Max GZ at wrong angle: {max_heel}°"
|
|
|
|
# Max GZ should be ~1.0m
|
|
assert 0.7 < max_gz < 1.5, f"Max GZ should be ~1.0m, got {max_gz:.3f}m"
|
|
|
|
|
|
class TestWallSidedFormula:
|
|
"""Wall-sided GZ formula validation (box barge)."""
|
|
|
|
def test_wall_sided_gz(self):
|
|
"""
|
|
Validate GZ against exact Wall-Sided Formula.
|
|
|
|
Box: L=10, B=10, D=10.
|
|
Draft T = 5.0m.
|
|
KB = T/2 = 2.50m.
|
|
BM = B² / 12T = 100 / 60 = 1.6667m.
|
|
KM = KB + BM = 4.1667m.
|
|
KG = 2.0m.
|
|
GM = KM - KG = 2.1667m.
|
|
|
|
Formula: GZ = (GM + 0.5 * BM * tan²(φ)) * sin(φ)
|
|
"""
|
|
# This test requires creating a box mesh programmatically
|
|
# Since we can't do that easily in Python, we skip this test
|
|
# The equivalent test is run in Rust
|
|
pytest.skip("Box mesh creation not available in Python bindings")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|