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>
This commit is contained in:
2026-05-27 13:59:32 -04:00
parent 62de89d63c
commit 0f85935fc8
7 changed files with 1871 additions and 3 deletions
+417
View File
@@ -0,0 +1,417 @@
"""
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()