Files
AR-Shipdesign/tests/test_module1_hydrostatics.py
T
alro65 bdfd5ac4ca Módulo 1: visores 2D del plano de líneas + hidrostáticos en vivo
- viewer_lines.py: BodyPlanViewer, ProfileViewer, PlanViewer (QPainter,
  zoom/paneo, tema dark navy); conectados a los tres viewports 2D del
  layout 4-viewport (bodyplan / profile / plan).

- hull.py: añadidos waterplane_coefficient (Cw), it_waterplane (IT),
  il_waterplane (IL), bm_transverse (BMT), bm_longitudinal (BML),
  km_transverse (KMT), tpc, mct1cm — todos verificados analíticamente
  contra el casco Wigley (IACS Rec.34 §4.3).

- main_window.py: _load_hull_viewers() conecta los 4 visores y el panel
  hidrostáticos al crear un nuevo proyecto; _update_hydrostatics() puebla
  los 11 campos de la barra inferior en vivo.

- test_module1_hydrostatics.py: 35 tests nuevos (IT analítico exacto,
  consistencia BMT=IT/V, KMT=KB+BMT, TPC=Awp·ρ/1e5, visores headless).

Suite total: 86 tests — 86 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:25:09 -04:00

364 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)·∫₋₁¹ (1u²)³ du
# ∫₋₁¹ (1−u²)³ du = 2·∫₀¹(13u²+3u⁴−u⁶)du
# = 2·[uu³+3u⁵/5u⁷/7]₀¹ = 2·(11+3/51/7)
# = 2·(3/51/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.050.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