# 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 . """ Tests for navaltoolbox Python bindings. These tests mirror the Rust integration tests to verify Python bindings work correctly. """ import pytest from pathlib import Path import tempfile class TestHullLoading: """Tests for Hull STL file loading.""" def test_load_normal_stl(self): """Test loading a normal STL file (DTMB 5415).""" from navaltoolbox import Hull stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl" ) if not stl_path.exists(): pytest.skip(f"DTMB 5415 STL not found at {stl_path}") hull = Hull(str(stl_path)) num_triangles = hull.num_triangles() bounds = hull.get_bounds() # Verify hull loaded successfully assert num_triangles > 1000, ( f"Expected > 1000 triangles, got {num_triangles}" ) # Verify bounds are reasonable for DTMB 5415 loa = bounds[1] - bounds[0] boa = bounds[3] - bounds[2] assert 140.0 < loa < 160.0, f"LOA should be ~142-153m, got {loa:.2f}m" assert 15.0 < boa < 25.0, f"BOA should be ~19-20m, got {boa:.2f}m" def test_load_box_stl(self): """Test loading a box STL file.""" from navaltoolbox import Hull stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "box_10x10.stl" ) if not stl_path.exists(): pytest.skip(f"Box STL not found at {stl_path}") hull = Hull(str(stl_path)) num_triangles = hull.num_triangles() # Box should have a reasonable number of triangles assert num_triangles > 0, "Box hull should have triangles" def test_empty_stl_file_error(self): """Test that loading an empty file raises an appropriate error.""" from navaltoolbox import Hull with tempfile.NamedTemporaryFile(suffix=".stl", delete=False) as f: temp_path = f.name try: # Attempt to load empty file should raise an error with pytest.raises(Exception) as exc_info: _ = Hull(temp_path) error_msg = str(exc_info.value).lower() # Check that error message is meaningful is_meaningful = ( "empty" in error_msg or "stl" in error_msg or "failed" in error_msg ) assert is_meaningful, ( f"Error message should mention empty/STL/failed, " f"got: {exc_info.value}" ) finally: Path(temp_path).unlink(missing_ok=True) 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}, " f"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.from_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()) cog = (self.LCG, self.TCG, self.VCG) curve = calc.calculate_gz_curve(self.DISPLACEMENT, cog, 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 " f"{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)) cog = (self.LCG, self.TCG, self.VCG) curve = calc.calculate_gz_curve(self.DISPLACEMENT, cog, 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}° " f"(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(φ) """ import math from navaltoolbox import Hull, Vessel, StabilityCalculator # Create box hull using from_box hull = Hull.from_box(10.0, 10.0, 10.0) vessel = Vessel(hull) stab_calc = StabilityCalculator(vessel, water_density=1025.0) # Parameters L, B = 10.0, 10.0 # D not used after box creation T = 5.0 # Draft KG = 2.0 # VCG # Calculated values KB = T / 2.0 # 2.5 BM = (B ** 2) / (12.0 * T) # 1.6667 GM = KB + BM - KG # 2.1667 # Displacement for T=5m volume = L * B * T displacement = volume * 1025.0 # COG: centered at (L/2, 0, KG) cog = (L / 2.0, 0.0, KG) # Calculate GZ curve heels = [0.0, 10.0, 20.0, 30.0] curve = stab_calc.calculate_gz_curve(displacement, cog, heels) # Validate against wall-sided formula # points() returns [(heel, draft, trim, gz), ...] for (heel, draft, trim, gz) in curve.points(): heel_rad = math.radians(heel) tan_phi = math.tan(heel_rad) sin_phi = math.sin(heel_rad) # Wall-sided formula: GZ = (GM + 0.5 * BM * tan²φ) * sin(φ) gz_theoretical = (GM + 0.5 * BM * tan_phi ** 2) * sin_phi # Allow some tolerance (numerical integration vs analytical) tolerance = 0.05 # 5cm error = abs(gz - gz_theoretical) assert error < tolerance, ( f"At heel={heel}°: " f"calculated GZ={gz:.4f}m, " f"theoretical={gz_theoretical:.4f}m, " f"error={error:.4f}m" ) class TestWaterplaneCalculations: """Waterplane property validation tests.""" def test_dtmb5415_metacentric_height(self): """ Validate GMT and KMt against DTMB 5415 reference data. Reference (Semarak paper, Table 3): - Draft: 6.15 m - VCG: 7.555 m - KMt: 9.493 m - GMT: 1.95 m (KMt - VCG) Tolerance: ±0.1 m """ from navaltoolbox import Hull, Vessel, HydrostaticsCalculator stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl" ) if not stl_path.exists(): pytest.skip(f"DTMB 5415 STL not found at {stl_path}") hull = Hull(str(stl_path)) vessel = Vessel(hull) calc = HydrostaticsCalculator(vessel, 1025.0) # Calculate at reference draft with VCG state = calc.from_draft(6.15, vcg=7.555) # Calculate KMt kmt = state.vcb + state.bmt gmt = state.gmt # Reference values ref_kmt = 9.493 ref_gmt = 1.95 tolerance = 0.1 # Validate KMt kmt_error = abs(kmt - ref_kmt) assert kmt_error < tolerance, ( f"KMt error {kmt_error:.3f}m exceeds tolerance {tolerance}m. " f"Calculated: {kmt:.3f}m, Reference: {ref_kmt}m" ) # Validate GMT gmt_error = abs(gmt - ref_gmt) assert gmt_error < tolerance, ( f"GMT error {gmt_error:.3f}m exceeds tolerance {tolerance}m. " f"Calculated: {gmt:.3f}m, Reference: {ref_gmt}m" ) print("\n✅ DTMB 5415 Waterplane Validation:") print(f" KMt: {kmt:.3f}m (ref: {ref_kmt}m, error: {kmt_error:.3f}m)") print(f" GMT: {gmt:.3f}m (ref: {ref_gmt}m, error: {gmt_error:.3f}m)") print(f" Awp: {state.waterplane_area:.1f}m²") def test_waterplane_properties_exposed(self): """Verify waterplane properties are accessible via bindings.""" from navaltoolbox import Hull, Vessel, HydrostaticsCalculator stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl" ) if not stl_path.exists(): pytest.skip("DTMB 5415 STL not found") hull = Hull(str(stl_path)) vessel = Vessel(hull) calc = HydrostaticsCalculator(vessel, 1025.0) state = calc.from_draft(6.15, vcg=7.555) # Check that all waterplane properties exist and are reasonable assert hasattr(state, "waterplane_area") assert hasattr(state, "lcf") assert hasattr(state, "bmt") assert hasattr(state, "bml") assert hasattr(state, "lwl") assert hasattr(state, "bwl") # Values should be positive and reasonable assert state.waterplane_area > 1000.0, "Awp should be > 1000 m²" assert state.bmt > 0.0, "BMt should be positive" assert state.bml > 0.0, "BMl should be positive" assert state.lwl > 100.0, "LWL should be > 100m" assert state.bwl > 10.0, "BWL should be > 10m" def test_gmt_gml_optional_without_vcg(self): """Verify GMT/GML are None when VCG not provided.""" from navaltoolbox import Hull, Vessel, HydrostaticsCalculator stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl" ) if not stl_path.exists(): pytest.skip("DTMB 5415 STL not found") hull = Hull(str(stl_path)) vessel = Vessel(hull) calc = HydrostaticsCalculator(vessel, 1025.0) # Calculate without VCG state = calc.from_draft(6.15) # GMT/GML should be None assert state.gmt is None, "GMT should be None when VCG not provided" assert state.gml is None, "GML should be None when VCG not provided" assert state.cog is None, "COG should be None when VCG not provided" # But waterplane properties should still be calculated assert state.waterplane_area > 0.0 assert state.bmt > 0.0 assert state.bml > 0.0 class TestFreeSurfaceCorrection: """Free surface correction validation tests.""" def test_calc_fsc_box_tank(self): """ Validate FSC calculation for a box tank. Tank: 5m x 5m x 2m FSM_t = L * B³ / 12 = 5 * 125 / 12 = 52.083 m⁴ Displacement (Mass) calculated from box hull. FSC = FSM / Displacement """ from navaltoolbox import Hull, Vessel, Tank, HydrostaticsCalculator # 1. Setup Vessel stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "box_10x10.stl" ) if not stl_path.exists(): # Fallback to DTMB if box not available stl_path = ( Path(__file__).parent.parent.parent / "rust" / "tests" / "data" / "dtmb5415.stl" ) if not stl_path.exists(): pytest.skip("No suitable STL found for FSC test") hull = Hull(str(stl_path)) vessel = Vessel(hull) # 2. Add Tank tank = Tank.from_box( name="TestTank", x_min=0.0, x_max=5.0, y_min=-2.5, y_max=2.5, z_min=0.0, z_max=2.0, fluid_density=1000.0, ) tank.fill_percent = 50.0 vessel.add_tank(tank) expected_fsm = 5.0 * 5.0**3 / 12.0 # 52.083 assert abs(tank.free_surface_moment_t - expected_fsm) < 0.1 # 3. Calculate calc = HydrostaticsCalculator(vessel, 1025.0) state = calc.from_draft(5.0, vcg=5.0) # 4. Validate gmt_wet = state.gmt gmt_dry = state.gmt_dry # Check dry calculation kmt = state.vcb + state.bmt expected_dry = kmt - 5.0 assert abs(gmt_dry - expected_dry) < 1e-6, "GMT dry mismatch" # Check wet calculation # FSC = (FSM * fluid_density) / displacement fluid_density = 1000.0 # Tank fluid density in kg/m³ expected_fsc = ( tank.free_surface_moment_t * fluid_density / state.displacement ) actual_fsc = gmt_dry - gmt_wet assert abs(actual_fsc - expected_fsc) < 1e-6, ( f"FSC mismatch: expected {expected_fsc}, got {actual_fsc}" ) assert gmt_wet < gmt_dry, "Wet GMT should be less than dry GMT" if __name__ == "__main__": pytest.main([__file__, "-v"])