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>
This commit is contained in:
2026-05-27 08:25:09 -04:00
parent 002c00aff3
commit bdfd5ac4ca
4 changed files with 966 additions and 8 deletions
+363
View File
@@ -0,0 +1,363 @@
"""
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