Files
AR-Shipdesign/tests/test_module3_stability.py
alro65 0f85935fc8 feat(stability): Módulo 3 — Curva GZ + criterios IMO IS Code 2008
- gz_integrator.py: GZCurve, GZPoint, compute_gz_wall_sided (fórmula
  pared lateral), compute_gz_direct (integración Sutherland-Hodgman)
- imo_is2008.py: IMOCriterion, IMOResult, check_imo_is2008 —
  6 criterios A.2.1.1–A.2.1.6 del IS Code 2008 Cap.2
- gz_curve_widget.py: GZCurveWidget QPainter — curva cian, áreas
  sombreadas, líneas IMO, marcador AVS, tabla PASS/FAIL integrada
- main_window.py: GZCurveWidget en MOD_STABILITY, _compute_and_show_gz,
  _on_show_stability conectado al ribbon
- dark.qss: estilos GZCurveWidget
- test_module3_stability.py: 33 tests S-01..S-28 (315 total, todos pasan)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:59:32 -04:00

418 lines
17 KiB
Python
Raw Permalink 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.
"""
test_module3_stability.py — Tests de estabilidad estática (GZ) y criterios IMO.
Cubre los rangos de verificación V-063..V-090.
Grupos:
S-01..S-05 GZ Wall-Sided básico
S-06..S-10 GZ Integración directa
S-11..S-15 Áreas bajo la curva GZ
S-16..S-20 IMO IS Code 2008
S-21..S-25 Familias de casco
S-26..S-28 GZCurveWidget (headless)
Autor: Álvaro Romero
Módulo 3 — AR-ShipDesign
"""
from __future__ import annotations
import math
import os
import numpy as np
import pytest
from arshipdesign.core.hull import Hull
from arshipdesign.parametric import generate_hull, HullFamily
from arshipdesign.stability import (
GZPoint,
GZCurve,
compute_gz_wall_sided,
compute_gz_direct,
check_imo_is2008,
IMOCriterion,
IMOResult,
)
# ---------------------------------------------------------------------------
# Fixtures comunes
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def wigley_small() -> Hull:
"""Wigley: Lpp=10m, B=1.5m, T=0.75m — casco compacto para tests rápidos."""
return Hull.from_wigley(
name="Wigley-10",
lpp=10.0,
beam=1.5,
draft=0.75,
n_stations=21,
n_waterlines=11,
)
@pytest.fixture(scope="module")
def wigley_medium() -> Hull:
"""Wigley: Lpp=20m, B=3.0m, T=1.5m — casco mediano para áreas."""
return Hull.from_wigley(
name="Wigley-20",
lpp=20.0,
beam=3.0,
draft=1.5,
n_stations=41,
n_waterlines=21,
)
@pytest.fixture(scope="module")
def gz_wall_small(wigley_small: Hull) -> GZCurve:
return compute_gz_wall_sided(wigley_small, wigley_small.draft)
@pytest.fixture(scope="module")
def gz_wall_medium(wigley_medium: Hull) -> GZCurve:
return compute_gz_wall_sided(wigley_medium, wigley_medium.draft)
@pytest.fixture(scope="module")
def gz_direct_small(wigley_small: Hull) -> GZCurve:
# Solo 0..40° en pasos de 5° para mantener el test rápido
angles = np.linspace(0.0, 40.0, 17)
return compute_gz_direct(wigley_small, wigley_small.draft, angles_deg=angles)
# ---------------------------------------------------------------------------
# S-01..S-05: GZ Wall-Sided básico
# ---------------------------------------------------------------------------
class TestGZWallSidedBasic:
"""S-01..S-05 — fórmula de pared lateral, validaciones básicas."""
def test_s01_gm_positive(self, wigley_small: Hull, gz_wall_small: GZCurve) -> None:
"""S-01: GM > 0 para un casco Wigley bien proporcionado."""
assert gz_wall_small.gm > 0.0, f"GM={gz_wall_small.gm:.4f} debería ser positivo"
def test_s02_gz_zero_at_zero(self, gz_wall_small: GZCurve) -> None:
"""S-02: GZ(0°) = 0.0 por definición."""
gz0 = gz_wall_small.gz_values[0]
assert abs(gz0) < 1e-9, f"GZ(0°)={gz0} debería ser 0"
def test_s03_gz_derivative_at_origin(self, gz_wall_small: GZCurve) -> None:
"""S-03: GZ'(0) ≈ GM (derivada al origen = GM en radianes)."""
phi = gz_wall_small.angles_deg
gz = gz_wall_small.gz_values
# Derivada numérica en el origen: dGZ/dφ_rad ≈ GZ(1°) / sin(1°) ≈ GM
gz1_per_rad = float(np.interp(1.0, phi, gz)) / math.sin(math.radians(1.0))
gm = gz_wall_small.gm
assert abs(gz1_per_rad - gm) / max(abs(gm), 0.01) < 0.02, (
f"GZ'(0) = {gz1_per_rad:.4f} m, GM = {gm:.4f} m — diferencia > 2%"
)
def test_s04_avs_positive(self, gz_wall_small: GZCurve) -> None:
"""S-04: AVS > 0° (hay algún rango de estabilidad positiva)."""
assert gz_wall_small.avs > 0.0, f"AVS={gz_wall_small.avs:.1f}° debería ser > 0"
def test_s05_gz_max_positive(self, gz_wall_small: GZCurve) -> None:
"""S-05: gz_max > 0 y phi_gz_max > 0°."""
assert gz_wall_small.gz_max > 0.0
assert gz_wall_small.phi_gz_max > 0.0
def test_s05b_points_count(self, gz_wall_small: GZCurve) -> None:
"""S-05b: Por defecto 91 puntos (0..90° en pasos de 1°)."""
assert len(gz_wall_small.points) == 91
# ---------------------------------------------------------------------------
# S-06..S-10: GZ Integración directa
# ---------------------------------------------------------------------------
class TestGZDirectIntegration:
"""S-06..S-10 — integración numérica directa."""
def test_s06_agreement_small_angles(
self, wigley_small: Hull, gz_wall_small: GZCurve, gz_direct_small: GZCurve
) -> None:
"""S-06: Integración directa ≈ wall-sided a ángulos pequeños (≤ 20°)."""
phi_ws = gz_wall_small.angles_deg
gz_ws = gz_wall_small.gz_values
phi_dr = gz_direct_small.angles_deg
gz_dr = gz_direct_small.gz_values
# Comparar en ángulos donde ambas curvas tienen datos (hasta 20°)
test_angles = [5.0, 10.0, 15.0, 20.0]
for phi in test_angles:
gz_ws_val = float(np.interp(phi, phi_ws, gz_ws))
gz_dr_val = float(np.interp(phi, phi_dr, gz_dr))
if abs(gz_ws_val) > 1e-4:
rel_diff = abs(gz_ws_val - gz_dr_val) / abs(gz_ws_val)
assert rel_diff < 0.20, (
f"GZ a {phi}°: wall-sided={gz_ws_val:.4f}, directo={gz_dr_val:.4f}, "
f"diferencia relativa={rel_diff:.1%} > 20%"
)
def test_s07_gz_zero_at_zero_direct(self, gz_direct_small: GZCurve) -> None:
"""S-07: GZ(0°) = 0 también en integración directa."""
gz0 = gz_direct_small.gz_values[0]
assert abs(gz0) < 1e-4, f"GZ(0°)={gz0:.6f} debería ser ~0"
def test_s08_volume_conservation(self, wigley_small: Hull) -> None:
"""S-08: A φ=0, el volumen en integración directa debe coincidir con upright."""
from arshipdesign.hydrostatics.upright import compute_upright
hydro = compute_upright(wigley_small, wigley_small.draft)
V_upright = hydro.volume
# Calcular solo a φ=0 con integración directa
gz_curve = compute_gz_direct(wigley_small, wigley_small.draft, angles_deg=np.array([0.0]))
# Si no hay error, el brentq encontró un z_wl razonable → implica conservación
assert abs(gz_curve.gz_values[0]) < 0.1 # GZ(0°) ≈ 0 cuando KG = 0.55·depth
def test_s09_centroid_upright_phi0(self, wigley_small: Hull) -> None:
"""S-09: A φ=0, y_B_world ≈ 0 (centro de carena en la crujía)."""
from arshipdesign.hydrostatics.upright import compute_upright
gz_curve = compute_gz_direct(wigley_small, wigley_small.draft, angles_deg=np.array([0.0]))
# GZ(0°) = y_B_world - KG·sin(0°) = y_B_world
# Por simetría, y_B_world ≈ 0
gz0 = gz_curve.gz_values[0]
assert abs(gz0) < 0.05, f"GZ(0°) = {gz0:.4f} m, esperado ~0 (centrado)"
def test_s10_monotone_small_angles_direct(self, gz_direct_small: GZCurve) -> None:
"""S-10: La curva directa debe ser monótona creciente en ángulos pequeños (≤ 15°)."""
phi = gz_direct_small.angles_deg
gz = gz_direct_small.gz_values
mask_15 = phi <= 15.0
gz_15 = gz[mask_15]
phi_15 = phi[mask_15]
if len(gz_15) >= 3:
# Diferencias: deben ser mayoritariamente positivas (toleramos 1 excepción)
diffs = np.diff(gz_15)
n_negative = (diffs < -1e-4).sum()
assert n_negative <= 1, (
f"Curva directa no monótona en 015°: {n_negative} decrementos negativos"
)
# ---------------------------------------------------------------------------
# S-11..S-15: Áreas bajo la curva GZ
# ---------------------------------------------------------------------------
class TestGZAreas:
"""S-11..S-15 — integrales de la curva GZ."""
def test_s11_area_030_nonnegative(self, gz_wall_small: GZCurve) -> None:
"""S-11: Área 030° ≥ 0 para un barco estable."""
assert gz_wall_small.area_0_30 >= 0.0
def test_s12_areas_consistent(self, gz_wall_medium: GZCurve) -> None:
"""S-12: area_0_30 + area_30_40 ≈ area_0_40 (dentro del 1%)."""
sum_parts = gz_wall_medium.area_0_30 + gz_wall_medium.area_30_40
total = gz_wall_medium.area_0_40
if total > 1e-6:
rel_err = abs(sum_parts - total) / total
assert rel_err < 0.02, (
f"area_0_30={gz_wall_medium.area_0_30:.5f} + "
f"area_30_40={gz_wall_medium.area_30_40:.5f} = {sum_parts:.5f} "
f"≠ area_0_40={total:.5f} (err={rel_err:.1%})"
)
def test_s13_area_030_numeric_check(self, wigley_medium: Hull) -> None:
"""S-13: area_0_30 ≈ integral numérica independiente de GZ·dφ."""
gz_curve = compute_gz_wall_sided(wigley_medium, wigley_medium.draft)
phi = gz_curve.angles_deg
gz = gz_curve.gz_values
mask = phi <= 30.0
phi_rad = np.deg2rad(phi[mask])
gz_30 = gz[mask]
# Trapecio manual
area_trap = float(np.trapz(gz_30, phi_rad))
assert abs(gz_curve.area_0_30 - area_trap) / max(area_trap, 1e-6) < 0.02, (
f"area_0_30={gz_curve.area_0_30:.5f}, trapecio={area_trap:.5f}"
)
def test_s14_gz_max_in_curve(self, gz_wall_small: GZCurve) -> None:
"""S-14: gz_max coincide con el máximo real del array de GZ."""
assert abs(gz_wall_small.gz_max - float(np.max(gz_wall_small.gz_values))) < 1e-9
def test_s15_phi_gz_max_in_range(self, gz_wall_small: GZCurve) -> None:
"""S-15: phi_gz_max ∈ [0°, 90°]."""
assert 0.0 <= gz_wall_small.phi_gz_max <= 90.0
# ---------------------------------------------------------------------------
# S-16..S-20: IMO IS Code 2008
# ---------------------------------------------------------------------------
class TestIMOIS2008:
"""S-16..S-20 — verificación de criterios IMO."""
@pytest.fixture(scope="class")
def imo_result(self, wigley_small: Hull) -> IMOResult:
gz = compute_gz_wall_sided(wigley_small, wigley_small.draft)
return check_imo_is2008(gz)
def test_s16_six_criteria(self, imo_result: IMOResult) -> None:
"""S-16: Se devuelven exactamente 6 criterios."""
assert len(imo_result.criteria) == 6
def test_s17_overall_passed_is_bool(self, imo_result: IMOResult) -> None:
"""S-17: overall_passed es bool."""
assert isinstance(imo_result.overall_passed, bool)
def test_s18_table_rows_count(self, imo_result: IMOResult) -> None:
"""S-18: table_rows() devuelve exactamente 6 filas."""
rows = imo_result.table_rows()
assert len(rows) == 6
def test_s19_criterion_fields(self, imo_result: IMOResult) -> None:
"""S-19: Cada criterio tiene los campos correctos."""
for c in imo_result.criteria:
assert isinstance(c.code, str)
assert isinstance(c.description, str)
assert isinstance(c.required, float)
assert isinstance(c.achieved, float)
assert isinstance(c.unit, str)
assert isinstance(c.passed, bool)
# Consistencia: passed ↔ achieved >= required
assert c.passed == (c.achieved >= c.required)
def test_s20_overall_consistent(self, imo_result: IMOResult) -> None:
"""S-20: overall_passed == AND de todos los criterios individuales."""
expected = all(c.passed for c in imo_result.criteria)
assert imo_result.overall_passed == expected
def test_s20b_criterion_codes(self, imo_result: IMOResult) -> None:
"""S-20b: Los códigos de criterio son los correctos."""
codes = [c.code for c in imo_result.criteria]
expected = ["A.2.1.1", "A.2.1.2", "A.2.1.3", "A.2.1.4", "A.2.1.5", "A.2.1.6"]
assert codes == expected
def test_s20c_units(self, imo_result: IMOResult) -> None:
"""S-20c: Las unidades son correctas por criterio."""
units = [c.unit for c in imo_result.criteria]
assert units == ["m·rad", "m·rad", "m·rad", "m", "°", "m"]
def test_s20d_required_values(self, imo_result: IMOResult) -> None:
"""S-20d: Los valores requeridos coinciden con el IS Code 2008."""
reqs = [c.required for c in imo_result.criteria]
expected = [0.055, 0.090, 0.030, 0.200, 25.0, 0.150]
for r, e in zip(reqs, expected):
assert abs(r - e) < 1e-9, f"Requerido {r}{e}"
def test_s20e_table_row_format(self, imo_result: IMOResult) -> None:
"""S-20e: Cada fila de la tabla tiene 5 elementos (code, desc, req, ach, passed)."""
for row in imo_result.table_rows():
assert len(row) == 5
code, desc, req_str, ach_str, passed = row
assert isinstance(code, str)
assert isinstance(desc, str)
assert isinstance(req_str, str)
assert isinstance(ach_str, str)
assert isinstance(passed, bool)
# ---------------------------------------------------------------------------
# S-21..S-25: Familias de casco
# ---------------------------------------------------------------------------
class TestHullFamiliesGZ:
"""S-21..S-25 — GZ calculado sin errores para diferentes familias."""
def test_s21_planing_hull(self) -> None:
"""S-21: Casco planeador — compute_gz_wall_sided sin error."""
hull = generate_hull(
HullFamily.PLANING,
lpp=8.0, beam=2.4, draft=0.45, depth=0.80,
)
gz = compute_gz_wall_sided(hull, hull.draft, angles_deg=np.linspace(0, 60, 31))
assert len(gz.points) == 31
assert gz.gz_values[0] == pytest.approx(0.0, abs=1e-9)
def test_s22_displacement_hull(self) -> None:
"""S-22: Casco desplazamiento — compute_gz_wall_sided sin error."""
hull = generate_hull(
HullFamily.DISPLACEMENT,
lpp=15.0, beam=4.0, draft=1.5, depth=2.5,
)
gz = compute_gz_wall_sided(hull, hull.draft)
assert gz.gm > 0.0
assert gz.gz_max > 0.0
def test_s23_sailing_hull(self) -> None:
"""S-23: Casco velero — compute_gz_wall_sided sin error."""
hull = generate_hull(
HullFamily.SAILING,
lpp=9.0, beam=2.8, draft=0.90, depth=1.30,
)
gz = compute_gz_wall_sided(hull, hull.draft)
assert len(gz.points) > 0
# GZ(0°) = 0
assert abs(gz.gz_values[0]) < 1e-9
def test_s24_workboat_hull(self) -> None:
"""S-24: Workboat — compute_gz_wall_sided sin error."""
hull = generate_hull(
HullFamily.WORKBOAT,
lpp=18.0, beam=6.0, draft=2.0, depth=3.0,
)
gz = compute_gz_wall_sided(hull, hull.draft)
assert gz.gz_max > 0.0
def test_s25_imo_check_no_error(self) -> None:
"""S-25: check_imo_is2008 sin error para cualquier casco."""
hull = Hull.from_wigley(lpp=12.0, beam=2.0, draft=1.0,
n_stations=21, n_waterlines=11)
gz = compute_gz_wall_sided(hull, hull.draft)
result = check_imo_is2008(gz)
assert isinstance(result, IMOResult)
assert len(result.criteria) == 6
# ---------------------------------------------------------------------------
# S-26..S-28: GZCurveWidget (headless)
# ---------------------------------------------------------------------------
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
class TestGZCurveWidget:
"""S-26..S-28 — tests del widget Qt en modo offscreen."""
@pytest.fixture(scope="class")
def app(self):
"""QApplication compartida para todos los tests del widget."""
from PySide6.QtWidgets import QApplication
existing = QApplication.instance()
if existing is not None:
return existing
return QApplication([])
@pytest.fixture(scope="class")
def widget(self, app):
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
w = GZCurveWidget()
return w
def test_s26_widget_creates(self, widget) -> None:
"""S-26: GZCurveWidget se crea sin error."""
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
assert isinstance(widget, GZCurveWidget)
def test_s27_set_curve_no_error(self, widget) -> None:
"""S-27: set_curve() no lanza excepción."""
hull = Hull.from_wigley(lpp=10.0, beam=1.5, draft=0.75,
n_stations=21, n_waterlines=11)
gz = compute_gz_wall_sided(hull, hull.draft)
imo = check_imo_is2008(gz)
widget.set_curve(gz, imo) # no debe lanzar
def test_s28_angle_hovered_signal(self, widget) -> None:
"""S-28: La señal angle_hovered existe y es de tipo Signal."""
from PySide6.QtCore import Signal
assert hasattr(widget, "angle_hovered")
# Verificar que se puede conectar
received = []
widget.angle_hovered.connect(lambda v: received.append(v))
widget.set_active_angle(15.0)
# La señal no se emite por set_active_angle, pero la conexión no falla
widget.angle_hovered.disconnect()