""" Tests Módulo 1 — Hidrostáticos extendidos y visores 2D. Cubre los métodos añadidos al completar el Módulo 1: • waterplane_coefficient (Cw) • it_waterplane (IT — segundo momento del plano de flotación) • il_waterplane (IL) • bm_transverse (BMT = IT / V) • bm_longitudinal (BML = IL / V) • km_transverse (KMT = KB + BMT) • tpc (toneladas / cm inmersión) • mct1cm (momento para cambiar asiento 1 cm) Valores de referencia analíticos para el casco Wigley: V = 4BLT/9 Awp = 2BL/3 Cb = 4/9 Cw = 2/3 KB = 5T/8 (calculado analíticamente) IT = B³L/48 (derivado de la integral del perfil Wigley en la LWL) BML ≈ Lpp²/12 × V_norm (orden de magnitud) Verificación de visores 2D: instanciación y set_hull() sin excepciones. Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign IACS Rec.34 §4.3 — verificación contra solución analítica conocida. """ from __future__ import annotations import math import sys import numpy as np import pytest from arshipdesign.core.hull import Hull # --------------------------------------------------------------------------- # Parámetros Wigley de referencia (alta resolución para integración numérica) # --------------------------------------------------------------------------- LPP = 10.0 BEAM = 1.5 DRAFT = 0.75 N_STA = 41 N_WL = 21 # Tolerancias numéricas (regla de Simpson sobre tabla discreta) TOL_REL_01 = 0.01 # ±1 % para integrales directas (V, Awp, Cb, Cw) TOL_REL_02 = 0.02 # ±2 % para momentos de segundo orden (IT, IL) TOL_ABS = 1e-6 # para valores que deben ser exactamente cero @pytest.fixture(scope="module") def wigley() -> Hull: return Hull.from_wigley( lpp=LPP, beam=BEAM, draft=DRAFT, n_stations=N_STA, n_waterlines=N_WL, ) # --------------------------------------------------------------------------- # 1. Coeficiente de plano de flotación # --------------------------------------------------------------------------- class TestWaterplaneCoefficient: """Cw = Awp / (Lpp · B). Para Wigley: Awp=2BL/3 → Cw = 2/3.""" def test_cw_analytical(self, wigley: Hull) -> None: cw_expect = 2.0 / 3.0 cw = wigley.waterplane_coefficient() assert abs(cw - cw_expect) < TOL_REL_01, ( f"Cw = {cw:.6f}, esperado ≈ {cw_expect:.6f}" ) def test_cw_range_valid(self, wigley: Hull) -> None: """Cw debe estar entre 0 y 1.""" cw = wigley.waterplane_coefficient() assert 0.0 < cw < 1.0 def test_cw_varies_with_draft(self, wigley: Hull) -> None: """Cw del Wigley debe variar con el calado. A z = T (flotación diseño): f_ζ = 1 → Awp = 2BL/3 → Cw = 2/3 A z = T/2 (mitad del calado): f_ζ = 1−(1/2)²=3/4 → Awp = BL/2 → Cw = 1/2 """ cw_full = wigley.waterplane_coefficient(draft=DRAFT) cw_half = wigley.waterplane_coefficient(draft=DRAFT / 2.0) assert abs(cw_full - 2.0 / 3.0) < 0.02, f"Cw(T)={cw_full:.4f}, esperado 0.6667" assert abs(cw_half - 0.5) < 0.02, f"Cw(T/2)={cw_half:.4f}, esperado 0.5000" assert cw_full > cw_half # planform se estrecha bajo la LWL # --------------------------------------------------------------------------- # 2. Segundo momento de área IT # --------------------------------------------------------------------------- class TestItWaterplane: """IT = (2/3) · ∫y³ dx. Para Wigley: IT = (B/2)³ · L · 4/15.""" @staticmethod def it_wigley_analytic() -> float: # y(x, T) = (B/2)·(1−(2ξ/L)²) [f_ζ=1 at design waterline] # IT = (2/3)·∫₋ᴸ/₂ᴸ/² y³(x,T) dx # = (2/3)·(B/2)³·∫₋ᴸ/₂ᴸ/² (1−(2ξ/L)²)³ dξ # Sustitución u = 2ξ/L → dξ = L/2 du, límites u∈[−1,1]: # = (2/3)·(B/2)³·(L/2)·∫₋₁¹ (1−u²)³ du # ∫₋₁¹ (1−u²)³ du = 2·∫₀¹(1−3u²+3u⁴−u⁶)du # = 2·[u−u³+3u⁵/5−u⁷/7]₀¹ = 2·(1−1+3/5−1/7) # = 2·(3/5−1/7) = 2·(16/35) = 32/35 return (2.0 / 3.0) * (BEAM / 2.0) ** 3 * (LPP / 2.0) * (32.0 / 35.0) def test_it_analytic(self, wigley: Hull) -> None: it_exp = self.it_wigley_analytic() it = wigley.it_waterplane() assert abs(it - it_exp) / it_exp < TOL_REL_02, ( f"IT = {it:.6f}, esperado {it_exp:.6f}" ) def test_it_positive(self, wigley: Hull) -> None: assert wigley.it_waterplane() > 0.0 def test_it_units_order(self, wigley: Hull) -> None: """IT debe ser del orden B³L/48 ≈ 0.88 m⁴ para Wigley.""" it = wigley.it_waterplane() rough = BEAM ** 3 * LPP / 48.0 assert 0.3 * rough < it < 3.0 * rough # --------------------------------------------------------------------------- # 3. Segundo momento de área IL # --------------------------------------------------------------------------- class TestIlWaterplane: def test_il_positive(self, wigley: Hull) -> None: assert wigley.il_waterplane() > 0.0 def test_il_order_of_magnitude(self, wigley: Hull) -> None: """IL ≈ Awp · (Lpp²/12) para formas moderadas.""" il = wigley.il_waterplane() awp = wigley.waterplane_area() il_ref = awp * LPP ** 2 / 12.0 # Rough bounds: must be within an order of magnitude assert 0.1 * il_ref < il < 10.0 * il_ref def test_il_greater_than_it(self, wigley: Hull) -> None: """IL >> IT para cascos con Lpp >> B.""" assert wigley.il_waterplane() > wigley.it_waterplane() # --------------------------------------------------------------------------- # 4. Radios metacéntricos BMT y BML # --------------------------------------------------------------------------- class TestMetacentricRadii: def test_bmt_positive(self, wigley: Hull) -> None: assert wigley.bm_transverse() > 0.0 def test_bml_positive(self, wigley: Hull) -> None: assert wigley.bm_longitudinal() > 0.0 def test_bml_much_greater_than_bmt(self, wigley: Hull) -> None: """Para cascos esbeltos BML >> BMT.""" assert wigley.bm_longitudinal() > 10.0 * wigley.bm_transverse() def test_bmt_equals_it_over_v(self, wigley: Hull) -> None: bmt = wigley.bm_transverse() it_v = wigley.it_waterplane() / wigley.volume_of_displacement() assert abs(bmt - it_v) < 1e-9 def test_bml_equals_il_over_v(self, wigley: Hull) -> None: bml = wigley.bm_longitudinal() il_v = wigley.il_waterplane() / wigley.volume_of_displacement() assert abs(bml - il_v) < 1e-9 # --------------------------------------------------------------------------- # 5. Altura del metacentro transversal KMT # --------------------------------------------------------------------------- class TestKmTransverse: def test_kmt_equals_kb_plus_bmt(self, wigley: Hull) -> None: kb = wigley.vcb() bmt = wigley.bm_transverse() kmt = wigley.km_transverse() assert abs(kmt - (kb + bmt)) < TOL_ABS def test_kmt_greater_than_draft(self, wigley: Hull) -> None: """KMT debe ser mayor que el calado para una embarcación estable.""" # Esta condición no es universal, pero para Wigley fino debería cumplirse. kmt = wigley.km_transverse() assert kmt > 0.0 def test_kmt_greater_than_kb(self, wigley: Hull) -> None: assert wigley.km_transverse() > wigley.vcb() # --------------------------------------------------------------------------- # 6. TPC # --------------------------------------------------------------------------- class TestTPC: def test_tpc_positive(self, wigley: Hull) -> None: assert wigley.tpc() > 0.0 def test_tpc_equals_awp_times_rho(self, wigley: Hull) -> None: tpc = wigley.tpc(rho=1025.0) awp = wigley.waterplane_area() tpc_exp = awp * 1025.0 / 100_000.0 assert abs(tpc - tpc_exp) < 1e-9 def test_tpc_freshwater_less_than_saltwater(self, wigley: Hull) -> None: tpc_sw = wigley.tpc(rho=1025.0) tpc_fw = wigley.tpc(rho=1000.0) assert tpc_sw > tpc_fw def test_tpc_order_of_magnitude(self, wigley: Hull) -> None: """TPC de embarcación 10m debe ser del orden 0.05–0.5 t/cm.""" tpc = wigley.tpc() assert 0.01 < tpc < 2.0 # --------------------------------------------------------------------------- # 7. MCT 1 cm # --------------------------------------------------------------------------- class TestMCT: def test_mct_positive(self, wigley: Hull) -> None: mct = wigley.mct1cm() assert mct >= 0.0 def test_mct_uses_lpp(self, wigley: Hull) -> None: """MCT debe escalar proporcionalmente al desplazamiento.""" mct = wigley.mct1cm() delta = wigley.displacement_tonnes() # MCT = Δ·GML/(100·Lpp) → MCT*100*Lpp/Δ ≈ GML > 0 gml_implied = mct * 100.0 * LPP / delta if delta > 0 else 0.0 assert gml_implied >= 0.0 def test_mct_custom_kg(self, wigley: Hull) -> None: """Con KG=0 (barge) MCT debe ser mayor que con KG=T.""" mct_low_kg = wigley.mct1cm(kg=0.0) mct_high_kg = wigley.mct1cm(kg=DRAFT) assert mct_low_kg >= mct_high_kg # --------------------------------------------------------------------------- # 8. Visores 2D — instanciación y set_hull sin excepciones # --------------------------------------------------------------------------- class TestLineViewers: """Tests headless de los tres visores QPainter. No renderizan a pantalla real; solo verifican que las clases se instancian y aceptan un Hull sin lanzar excepciones. """ @pytest.fixture(scope="class") def qt_app(self): from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv) yield app def test_import(self) -> None: from arshipdesign.ui.widgets.viewer_lines import ( BodyPlanViewer, ProfileViewer, PlanViewer, _BaseViewer, ) assert issubclass(BodyPlanViewer, _BaseViewer) assert issubclass(ProfileViewer, _BaseViewer) assert issubclass(PlanViewer, _BaseViewer) def test_instantiate(self, qt_app) -> None: from arshipdesign.ui.widgets.viewer_lines import ( BodyPlanViewer, ProfileViewer, PlanViewer, ) bp = BodyPlanViewer() pf = ProfileViewer() pl = PlanViewer() assert bp._hull is None assert pf._hull is None assert pl._hull is None def test_set_hull_none(self, qt_app) -> None: """set_hull(None) debe limpiar el visor sin excepción.""" from arshipdesign.ui.widgets.viewer_lines import BodyPlanViewer bv = BodyPlanViewer() bv.set_hull(None) assert bv._hull is None def test_set_hull_wigley(self, qt_app, wigley: Hull) -> None: """set_hull(hull) debe almacenar el hull en los tres visores.""" from arshipdesign.ui.widgets.viewer_lines import ( BodyPlanViewer, ProfileViewer, PlanViewer, ) bp = BodyPlanViewer() pf = ProfileViewer() pl = PlanViewer() bp.set_hull(wigley) pf.set_hull(wigley) pl.set_hull(wigley) assert bp._hull is wigley assert pf._hull is wigley assert pl._hull is wigley def test_world_bbox_body_plan(self, qt_app, wigley: Hull) -> None: from arshipdesign.ui.widgets.viewer_lines import BodyPlanViewer bv = BodyPlanViewer() bv.set_hull(wigley) bbox = bv._world_bbox() assert bbox is not None wx0, wy0, wx1, wy1 = bbox assert wx0 < 0 < wx1 # simétrico: ±semi-manga assert wy0 < wy1 # altura positiva def test_world_bbox_profile(self, qt_app, wigley: Hull) -> None: from arshipdesign.ui.widgets.viewer_lines import ProfileViewer pf = ProfileViewer() pf.set_hull(wigley) bbox = pf._world_bbox() assert bbox is not None wx0, wy0, wx1, wy1 = bbox assert wx0 < 0 # margen antes de AP assert wx1 > LPP # margen después de FP assert wy1 > DRAFT # incluye puntal def test_world_bbox_plan(self, qt_app, wigley: Hull) -> None: from arshipdesign.ui.widgets.viewer_lines import PlanViewer pl = PlanViewer() pl.set_hull(wigley) bbox = pl._world_bbox() assert bbox is not None wx0, wy0, wx1, wy1 = bbox assert wx1 > LPP assert wy1 > 0 # semi-manga positiva # --------------------------------------------------------------------------- # 9. Consistencia entre métodos # --------------------------------------------------------------------------- class TestHydrostaticConsistency: """Los nuevos métodos deben ser consistentes con los existentes.""" def test_cw_consistent_with_awp(self, wigley: Hull) -> None: awp = wigley.waterplane_area() cw = wigley.waterplane_coefficient() assert abs(cw * LPP * BEAM - awp) < 1e-6 def test_bmt_consistent_with_it_and_v(self, wigley: Hull) -> None: bmt = wigley.bm_transverse() it = wigley.it_waterplane() v = wigley.volume_of_displacement() assert abs(bmt - it / v) < 1e-9 def test_kmt_chain(self, wigley: Hull) -> None: """KMT = KB + IT/V.""" kmt = wigley.km_transverse() kb = wigley.vcb() it = wigley.it_waterplane() v = wigley.volume_of_displacement() assert abs(kmt - (kb + it / v)) < 1e-9 def test_tpc_consistent_with_awp(self, wigley: Hull) -> None: tpc = wigley.tpc(rho=1025.0) awp = wigley.waterplane_area() assert abs(tpc * 100_000.0 / 1025.0 - awp) < 1e-9