""" 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 0–15°: {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 0–30° ≥ 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()