Files
navaltoolbox-lib/python/tests/test_bindings.py
T

980 lines
32 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
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 TestHullThickness:
"""Tests for hull thickness feature."""
def test_thickness_properties(self):
from navaltoolbox import Hull, Vessel
hull = Hull.from_box(length=10.0, breadth=5.0, depth=2.0)
# Test default is None
assert hull.thickness is None
# Test setter
hull.thickness = 0.015
assert hull.thickness == 0.015
# Test Vessel methods
vessel = Vessel(hull)
assert vessel.get_hull_thickness(0) == 0.015
vessel.set_hull_thickness(0, 0.02)
assert vessel.get_hull_thickness(0) == 0.02
def test_thickness_volume_addition(self):
from navaltoolbox import Hull, Vessel, HydrostaticsCalculator
hull = Hull.from_box(length=10.0, breadth=10.0, depth=10.0)
vessel = Vessel(hull)
calc1 = HydrostaticsCalculator(vessel, 1000.0)
# Draft of 5m -> Wetted surface area = Bottom + 4 sides
# Bottom = 10 * 10 = 100
# 4 Sides = 4 * (10 * 5) = 200
# Total WSA = 300 m^2
state_no_thickness = calc1.from_draft(5.0)
assert state_no_thickness.thickness_volume == 0.0
vessel.set_hull_thickness(0, 0.01)
calc2 = HydrostaticsCalculator(vessel, 1000.0)
state_with_thickness = calc2.from_draft(5.0)
expected_added_volume = 300.0 * 0.01
actual_added = state_with_thickness.thickness_volume
assert abs(actual_added - expected_added_volume) < 1e-4
expected_total = state_no_thickness.volume + expected_added_volume
assert abs(state_with_thickness.volume - expected_total) < 1e-4
def test_thickness_equivalence_gz_and_hydro(self):
"""Test realistic hull thickness on displacement and GZ."""
from navaltoolbox import (
Hull,
Vessel,
HydrostaticsCalculator,
StabilityCalculator,
)
# 10m x 4m x 5m box.
hull_thick = Hull.from_box(length=10.0, breadth=4.0, depth=5.0)
vessel_thick = Vessel(hull_thick)
vessel_thick.set_hull_thickness(0, 0.015) # 15mm realistic thickness
hull_ref = Hull.from_box(length=10.0, breadth=4.0, depth=5.0)
vessel_ref = Vessel(hull_ref)
calc_thick = HydrostaticsCalculator(vessel_thick, 1000.0)
# For a 10x4x5 box at 2m draft:
# Bare Volume = 10 * 4 * 2 = 80 m^3
# WSA = 10*4 (bottom) + 2*(10*2) + 2*(4*2) = 40 + 40 + 16 = 96 m^2
# Added Volume = 96 * 0.015 = 1.44 m^3
# Total Volume = 81.44 m^3
state_thick = calc_thick.from_draft(2.0)
assert abs(state_thick.volume - 81.44) < 1e-4
# Test GZ equivalence at same target displacement.
# This ensures both models submerge to roughly the same draft.
disp = 81440.0
cog = (5.0, 0.0, 1.0) # center of 10m box
heels = [0.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0]
stab_thick = StabilityCalculator(vessel_thick, 1000.0)
stab_ref = StabilityCalculator(vessel_ref, 1000.0)
gz_thick = stab_thick.gz_curve(disp, cog, heels)
gz_ref = stab_ref.gz_curve(disp, cog, heels)
# The thickness model adds volume but does not widen the waterplane.
# For a realistic 15mm plate, the GZ curve should be nearly identical.
for pt_t, pt_r in zip(gz_thick.points(), gz_ref.points()):
assert abs(pt_t[3] - pt_r[3]) < 0.015, f"GZ mismatch at {pt_t[0]}°"
def test_contact_area_wetted_surface(self):
"""Test contact surface detection between two adjacent boxes."""
from navaltoolbox import Hull, Vessel, HydrostaticsCalculator
# Box 1: 10x5x10, shifted to Y=-2.5
hull1 = Hull.from_box(length=10.0, breadth=5.0, depth=10.0)
hull1.transform((0.0, -2.5, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
# Box 2: 10x5x10, shifted to Y=+2.5
hull2 = Hull.from_box(length=10.0, breadth=5.0, depth=10.0)
hull2.transform((0.0, 2.5, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
# Together they form a 10x10x10 box
# Face at Y=0 is shared (10x10)
vessel = Vessel.from_hulls([hull1, hull2])
vessel.set_hull_thickness(0, 0.1)
vessel.set_hull_thickness(1, 0.1)
calc = HydrostaticsCalculator(vessel, 1000.0)
# Submerge to 5m draft
# Each box has submerged surface = Bottom(50) + 2*Ends(50) +
# 2*Sides(100) = 200
# The shared side (10x5=50) should be excluded twice (once per hull)
state = calc.from_draft(5.0)
# Total raw wetted surface of two separate boxes = 400
# Contact surface should be detected as 50
assert abs(state.contact_surface_area - 50.0) < 1e-4
def test_precomputed_contact_surfaces(self):
"""Test the pre-computation of contact surfaces between hulls."""
from navaltoolbox import Hull, Vessel, HydrostaticsCalculator
# Box 1: 10x5x10, shifted to Y=-2.5
hull1 = Hull.from_box(length=10.0, breadth=5.0, depth=10.0)
hull1.transform((0.0, -2.5, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
# Box 2: 10x5x10, shifted to Y=+2.5
hull2 = Hull.from_box(length=10.0, breadth=5.0, depth=10.0)
hull2.transform((0.0, 2.5, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
# create multi-hull vessel, which auto-computes contact surfaces
vessel = Vessel.from_hulls([hull1, hull2])
assert vessel.has_contact_surfaces()
assert vessel.num_contact_surfaces() == 1
surfaces = vessel.get_contact_surfaces()
assert len(surfaces) == 1
cs = surfaces[0]
assert cs.hull_i == 0
assert cs.hull_j == 1
# The two hulls touch on a 10x10 surface, so 100 m^2
assert abs(cs.total_area - 100.0) < 1e-4
assert cs.num_faces_i > 0
assert cs.num_faces_j > 0
# Submerge to 5m draft and check contact surface calculation (50 m^2)
calc = HydrostaticsCalculator(vessel, 1000.0)
state = calc.from_draft(5.0)
assert abs(state.contact_surface_area - 50.0) < 1e-4
# Clear surfaces
vessel.clear_contact_surfaces()
assert not vessel.has_contact_surfaces()
assert vessel.num_contact_surfaces() == 0
# Run hydrostatics again. Fallback should be identical
state2 = calc.from_draft(5.0)
assert abs(state2.contact_surface_area - 50.0) < 1e-4
# Re-compute
vessel.compute_contact_surfaces()
assert vessel.has_contact_surfaces()
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"
def test_tank_permeability(self):
"""Test tank permeability factor."""
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)
# Default permeability should be 1.0
assert abs(tank.permeability - 1.0) < 1e-6
# Initial state at 50% fill
tank.fill_percent = 50.0
orig_volume = tank.fill_volume
orig_mass = tank.fluid_mass
orig_fsm_t = tank.free_surface_moment_t
# Apply 90% permeability
tank.permeability = 0.9
# Property should be preserved
assert abs(tank.permeability - 0.9) < 1e-6
# Values should be 90% of original
assert abs(tank.fill_volume - orig_volume * 0.9) < 1e-6
assert abs(tank.fluid_mass - orig_mass * 0.9) < 1e-6
assert abs(tank.free_surface_moment_t - orig_fsm_t * 0.9) < 1e-6
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.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.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"
def test_kn_curve_binding(self, dtmb5415_vessel):
"""Test KN curve binding with multi-displacement support."""
from navaltoolbox import StabilityCalculator
calc = StabilityCalculator(dtmb5415_vessel, 1025.0)
heels = [0, 10, 20]
# Test with multiple displacements
displacements = [self.DISPLACEMENT, self.DISPLACEMENT * 0.9]
curves = calc.kn_curve(
displacements, heels, lcg=self.LCG, tcg=self.TCG
)
assert len(curves) == 2, "Should return one curve per displacement"
# Verify consistent results with GZ(VCG=0)
kn_curve_0 = curves[0]
# Compare with gz_curve(VCG=0)
gz_curve_0 = calc.gz_curve(
self.DISPLACEMENT, (self.LCG, self.TCG, 0.0), heels
)
for kn_val, gz_val in zip(kn_curve_0.values(), gz_curve_0.values()):
assert abs(kn_val - gz_val) < 1e-6, "KN should equal GZ(VCG=0)"
def test_fixed_trim_differs_from_free_trim(self, dtmb5415_vessel):
"""Test that fixed_trim param actively changes convergence behavior."""
from navaltoolbox import StabilityCalculator
calc = StabilityCalculator(dtmb5415_vessel, 1025.0)
# We need a loading condition where LCG causes a large trim normally.
# dtmb LCG is ~71m. Set LCG to 0.0 (the AP), ship will heavily trim.
heels = [0.0, 10.0, 20.0]
cog_bad_trim = (0.0, 0.0, 6.5)
# 1. Free trim (should result in huge trim)
curve_free = calc.gz_curve(self.DISPLACEMENT, cog_bad_trim, heels)
# 2. Fixed trim (forced 0.0)
curve_fixed = calc.gz_curve(
self.DISPLACEMENT, cog_bad_trim, heels, fixed_trim=0.0
)
assert len(curve_free.get_stability_points()) == len(
curve_fixed.get_stability_points()
)
# The points differ drastically because fixed trim ignores LCG mismatch
point_free = curve_free.get_stability_points()[1] # 10 degrees
point_fixed = curve_fixed.get_stability_points()[1]
# Free trim should be non-zero
assert abs(point_free.trim) > 5.0, \
"Free trim should be huge (unbalanced LCG)"
# Fixed trim MUST remain exactly 0.0
assert abs(point_fixed.trim) < 1e-6, \
"Fixed trim should remain 0.0 exactly"
# The resulting GZ should be different
assert abs(point_free.gz - point_fixed.gz) > 0.1, \
"GZ should differ based on trim"
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.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"
class TestFreeboardImplementation:
"""Freeboard property exposed correctly in StabilityPoint."""
def test_freeboard_with_deck_edge(self):
from navaltoolbox import (
Hull,
Vessel,
DeckEdge,
DeckEdgeSide,
StabilityCalculator,
)
vessel = Vessel(Hull.from_box(10.0, 10.0, 10.0))
# Add a deck edge at z=10.0, matching the hull bounds: x from 0 to 10
edge = DeckEdge.from_points(
"MainDeck",
[
(10.0, 5.0, 10.0),
(0.0, 5.0, 10.0),
(0.0, -5.0, 10.0),
(10.0, -5.0, 10.0)
],
DeckEdgeSide.both()
)
vessel.add_deck_edge(edge)
calc = StabilityCalculator(vessel, 1025.0)
# Displacement for draft = 5.0m is 10 * 10 * 5.0 * 1025.0 = 512500.0 kg
heels = [0.0, 10.0, 20.0]
# Set LCG = 5.0 to align exactly with centroid and maintain 0 trim
curve = calc.gz_curve(512500.0, (5.0, 0.0, 5.0), heels)
points = curve.get_stability_points()
for point in points:
assert point.freeboard is not None
# At 0 heel and 0 trim, freeboard from Deck 10 should
# be exactly 10.0 - draft
expected_freeboard = 10.0 - points[0].draft
assert abs(points[0].freeboard - expected_freeboard) < 1e-4
def test_freeboard_without_deck_edge(self):
from navaltoolbox import Hull, Vessel, StabilityCalculator
vessel = Vessel(Hull.from_box(10.0, 10.0, 10.0))
calc = StabilityCalculator(vessel, 1025.0)
heels = [0.0]
curve = calc.gz_curve(512500.0, (0.0, 0.0, 5.0), heels)
points = curve.get_stability_points()
assert points[0].freeboard is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])