bdfd5ac4ca
- 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>
364 lines
14 KiB
Python
364 lines
14 KiB
Python
"""
|
||
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
|