0f85935fc8
- 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>
418 lines
17 KiB
Python
418 lines
17 KiB
Python
"""
|
||
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()
|