Files
AR-Shipdesign/tests/test_module2_integration.py
alro65 4630d2d19f Módulo 2 completo: HydrostaticsChartWidget + integración + tests (Tasks 14–16)
Task 14 — HydrostaticsChartWidget (QPainter):
- 9 paneles cuadrícula 3x3: Δ, V, Awp, LCB/LCF, KB/BMT, KMT/KML,
  TPC, MCT, Cb/Cw/Cm
- Cursor vertical compartido: clic/arrastre en cualquier panel mueve
  el cursor en todos y actualiza la barra de valores
- _InfoBar: franja superior con valores interpolados al calado activo
- _nice_ticks(): escala de ejes legible sin dependencias externas
- Sin dependencias externas (sólo PySide6 + numpy)

Task 15 — Integración en MainWindow:
- MOD_CURVES cargado con HydrostaticsChartWidget (sustituye placeholder)
- _on_compute_hydrostatics(): calcula HydrostaticCurves.compute(n=30)
- _on_show_hydrostatics(): abre el módulo (calculando si no hay datos)
- _on_export_hydrostatics_csv(): exporta CSV con QFileDialog
- Ribbon tab Análisis: botones Calcular, Curvas, Exp. CSV activos
- Menú Análisis → Hidrostática: 3 acciones funcionando
- dark.qss: estilos para hydrostaticsChart, hydroInfoBar, hydroPlaceholder

Task 16 — Tests V&V (58 tests):
- Widget headless W-01..W-08: construcción, set_curves, señales, clampeo
- CSV V037..V044: columnas, filas, monotonicidad, separadores, decimal coma
- at_draft V045..V049: interpolación lineal, clampeo, tipo retorno
- 5 familias V050..V055: Δ monótona, V>0, Cb∈(0,1), KMT>KB, KML>KMT, TPC>0
- IACS Rec.34 §4.3 V056..V062: Cb=4/9, Cw=2/3, KB, LCB=LCF=L/2, Cp=Cb/Cm,
  convergencia de malla <2%

Total: 282 tests, 0 failed.

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

422 lines
16 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.
"""
Tests Módulo 2 — Integración y V&V ampliada.
Cubre:
- HydrostaticsChartWidget: construcción, set_curves, set_active_draft
- CSV export: integridad, columnas, separadores alternativos
- to_dict(): serialización JSON completa
- at_draft(): interpolación y clampeo
- Monotonía y coeficientes para las 5 familias paramétricas
- IACS Rec.34 §4.34.5: V&V adicional (V037V055)
Autor: Álvaro Romero | Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
import io
import json
import math
import numpy as np
import pytest
from arshipdesign.core.hull import Hull
from arshipdesign.hydrostatics import (
CSV_HEADERS,
HydrostaticCurves,
UprightHydrostatics,
compute_upright,
)
from arshipdesign.parametric import HullFamily, generate_hull
# ---------------------------------------------------------------------------
# Fixtures compartidas
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def wigley() -> Hull:
return Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0,
n_stations=41, n_waterlines=21)
@pytest.fixture(scope="module")
def wigley_curves(wigley: Hull) -> HydrostaticCurves:
return HydrostaticCurves.compute(wigley, n_points=25)
@pytest.fixture(scope="module")
def displacement_hull() -> Hull:
return generate_hull(HullFamily.DISPLACEMENT, lpp=18.0, beam=5.5,
draft=2.0, depth=3.0, cb=0.55)
# ---------------------------------------------------------------------------
# I. HydrostaticsChartWidget — construcción headless
# ---------------------------------------------------------------------------
class TestHydrostaticsChartWidget:
"""Pruebas del widget en modo headless (sin pantalla, sin PyVista)."""
@pytest.fixture(autouse=True)
def setup(self, wigley_curves):
# Importación diferida: sólo falla si PySide6 no está instalado
try:
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
self.app = QApplication.instance() or QApplication([])
from arshipdesign.ui.widgets.hydrostatics_chart import (
HydrostaticsChartWidget,
)
self.Widget = HydrostaticsChartWidget
self.curves = wigley_curves
self.available = True
except Exception:
self.available = False
def _skip_if_unavailable(self):
if not self.available:
pytest.skip("PySide6 / pantalla no disponible")
# W-01 — Construcción sin datos
def test_widget_creates_empty(self):
self._skip_if_unavailable()
w = self.Widget()
assert w.curves is None
assert w.active_draft is None
# W-02 — set_curves popula correctamente
def test_set_curves_populates(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
assert w.curves is self.curves
assert w.active_draft is not None
# W-03 — active_draft inicial = design_draft
def test_active_draft_initial_is_design_draft(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
assert abs(w.active_draft - self.curves.design_draft) < 1e-9
# W-04 — set_active_draft clampea al rango válido
def test_set_active_draft_clamps(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
T_max = float(self.curves.drafts[-1])
w.set_active_draft(T_max * 10) # muy alto
assert w.active_draft <= T_max + 1e-9
w.set_active_draft(-99) # muy bajo
assert w.active_draft >= float(self.curves.drafts[0]) - 1e-9
# W-05 — draft_changed se emite
def test_draft_changed_signal_emits(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
received = []
w.draft_changed.connect(received.append)
T_mid = (float(self.curves.drafts[0]) + float(self.curves.drafts[-1])) / 2
w.set_active_draft(T_mid)
assert len(received) == 1
assert abs(received[0] - T_mid) < 1e-6
# W-06 — set_curves(None) limpia el widget
def test_set_curves_none_clears(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
w.set_curves(None)
assert w.curves is None
# W-07 — 9 paneles creados
def test_nine_panels_created(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
# _panels es la lista interna de _CurvePanel
assert len(w._panels) == 9
# W-08 — info bar muestra valores coherentes
def test_info_bar_shows_values(self):
self._skip_if_unavailable()
w = self.Widget()
w.set_curves(self.curves)
T_d = self.curves.design_draft
h = self.curves.at_draft(T_d)
# Los valores del info bar son strings; verificamos que no sean "—"
val = w._info_bar._fields["Δ"].text()
assert val != "—"
assert float(val.split()[0]) > 0
# ---------------------------------------------------------------------------
# II. CSV — integridad y formatos
# ---------------------------------------------------------------------------
class TestCSVExport:
"""V037V044: Integridad de la exportación CSV."""
@pytest.fixture(autouse=True)
def setup(self, wigley_curves):
self.curves = wigley_curves
# V037 — número correcto de columnas
def test_csv_column_count(self):
lines = self.curves.to_csv_lines()
header = lines[0].split(",")
assert len(header) == len(CSV_HEADERS), \
f"Esperados {len(CSV_HEADERS)} campos, obtenidos {len(header)}"
# V038 — número de filas = n_points + 1 (cabecera)
def test_csv_row_count(self):
lines = self.curves.to_csv_lines()
assert len(lines) == len(self.curves.points) + 1
# V039 — primera columna = draft, valores crecientes
def test_csv_draft_column_monotone(self):
lines = self.curves.to_csv_lines()
drafts = [float(row.split(",")[0]) for row in lines[1:]]
assert all(b > a for a, b in zip(drafts, drafts[1:]))
# V040 — separador alternativo ; funciona
def test_csv_semicolon_separator(self):
lines = self.curves.to_csv_lines(sep=";")
fields = lines[0].split(";")
assert len(fields) == len(CSV_HEADERS)
# V041 — decimal coma funciona
def test_csv_decimal_comma(self):
lines = self.curves.to_csv_lines(sep=";", decimal=",")
# Ningún valor numérico debe contener un punto decimal
for row in lines[1:]:
for field in row.split(";"):
assert "." not in field, f"Campo con punto: {field!r}"
# V042 — desplazamiento CSV coincide con punto almacenado
def test_csv_displacement_matches_upright(self):
lines = self.curves.to_csv_lines()
# Última fila = calado máximo = design_draft
# CSV_HEADERS: T[m]=0, V[m3]=1, Delta[t]=2
last = lines[-1].split(",")
T_csv = float(last[0])
D_csv = float(last[2]) # columna 2 = Delta[t]
T_d = self.curves.design_draft
assert abs(T_csv - T_d) < 1e-6
expected = self.curves.points[-1].displacement
assert abs(D_csv - expected) < 1e-3
# V043 — to_dict() devuelve JSON serializable
def test_to_dict_json_serializable(self):
d = self.curves.to_dict()
txt = json.dumps(d) # lanza si no es serializable
d2 = json.loads(txt)
assert d2["hull_name"] == self.curves.hull_name
assert len(d2["points"]) == len(self.curves.points)
# V044 — to_dict() incluye los campos clave (sin unidades en clave)
def test_to_dict_has_csv_fields(self):
d = self.curves.to_dict()
assert "hull_name" in d
assert "design_draft" in d
assert "points" in d
first = d["points"][0]
# Las claves del dict usan nombres cortos: T, V, Delta, Awp, ...
for key in ("T", "V", "Delta", "Awp", "LCB", "LCF", "KB",
"BMT", "BML", "KMT", "KML", "TPC", "MCT", "Cb", "Cw", "Cm"):
assert key in first, f"Campo faltante en dict: {key!r}"
# ---------------------------------------------------------------------------
# III. at_draft() — interpolación
# ---------------------------------------------------------------------------
class TestAtDraft:
"""V045V049: at_draft() interpolación y clampeo."""
@pytest.fixture(autouse=True)
def setup(self, wigley_curves):
self.c = wigley_curves
# V045 — at_draft en extremo inferior devuelve punto más bajo
def test_at_draft_lower_clamp(self):
T_min = float(self.c.drafts[0])
h = self.c.at_draft(0.0)
assert isinstance(h, UprightHydrostatics)
assert abs(h.draft - T_min) < 1e-6
# V046 — at_draft en extremo superior devuelve punto más alto
def test_at_draft_upper_clamp(self):
T_max = float(self.c.drafts[-1])
h = self.c.at_draft(T_max * 10)
assert abs(h.draft - T_max) < 1e-6
# V047 — interpolación lineal entre dos puntos conocidos
def test_at_draft_interpolates_linearly(self):
T_a = float(self.c.drafts[3])
T_b = float(self.c.drafts[4])
T_mid = (T_a + T_b) / 2
D_a = self.c.points[3].displacement
D_b = self.c.points[4].displacement
D_expected = (D_a + D_b) / 2
h = self.c.at_draft(T_mid)
assert abs(h.displacement - D_expected) / max(D_expected, 1) < 1e-3
# V048 — at_draft devuelve UprightHydrostatics
def test_at_draft_returns_upright(self):
T = float(self.c.drafts[5])
h = self.c.at_draft(T)
assert isinstance(h, UprightHydrostatics)
# V049 — draft del resultado coincide con el draft solicitado (en rango)
def test_at_draft_result_draft_matches(self):
T_target = float(self.c.drafts[7])
h = self.c.at_draft(T_target)
assert abs(h.draft - T_target) < 1e-9
# ---------------------------------------------------------------------------
# IV. Monotonía y coeficientes — 5 familias paramétricas (V050V055)
# ---------------------------------------------------------------------------
_FAMILIES = [
(HullFamily.DISPLACEMENT, dict(lpp=15.0, beam=4.5, draft=1.80, depth=2.5, cb=0.55)),
(HullFamily.PLANING, dict(lpp=10.0, beam=3.0, draft=0.70, depth=1.20)),
(HullFamily.SAILING, dict(lpp=12.0, beam=3.5, draft=0.60, depth=1.80)),
(HullFamily.WORKBOAT, dict(lpp=18.0, beam=6.0, draft=2.20, depth=3.0)),
(HullFamily.MERCHANT, dict(lpp=80.0, beam=14.0, draft=5.0, depth=8.0, cb=0.72)),
]
@pytest.mark.parametrize("family,kwargs", _FAMILIES)
def test_V050_displacement_monotone(family, kwargs):
"""V050: Desplazamiento creciente con calado."""
hull = generate_hull(family, **kwargs)
c = HydrostaticCurves.compute(hull, n_points=15)
deltas = c.displacements
assert all(b >= a - 1e-3 for a, b in zip(deltas, deltas[1:])), \
f"{family.name}: desplazamiento no monótono"
@pytest.mark.parametrize("family,kwargs", _FAMILIES)
def test_V051_volume_positive(family, kwargs):
"""V051: Volumen de carena > 0 para T > 0."""
hull = generate_hull(family, **kwargs)
c = HydrostaticCurves.compute(hull, n_points=15)
assert all(v > 0 for v in c.volumes[1:]) # index 0 puede ser ~0
@pytest.mark.parametrize("family,kwargs", _FAMILIES)
def test_V052_cb_in_range(family, kwargs):
"""V052: Coeficiente de bloque 0 < Cb < 1 en todos los calados."""
hull = generate_hull(family, **kwargs)
c = HydrostaticCurves.compute(hull, n_points=15)
for cb in c.cb_values[1:]: # excluir calado mínimo (puede ser NaN/0)
if math.isfinite(cb):
assert 0.0 < cb < 1.0, f"{family.name}: Cb={cb:.4f} fuera de rango"
@pytest.mark.parametrize("family,kwargs", _FAMILIES)
def test_V053_kmt_greater_than_kb(family, kwargs):
"""V053: KMT > KB en todo el rango de calados."""
hull = generate_hull(family, **kwargs)
c = HydrostaticCurves.compute(hull, n_points=15)
for kb, kmt in zip(c.kb_values[1:], c.kmt_values[1:]):
if math.isfinite(kb) and math.isfinite(kmt):
assert kmt > kb - 1e-6, \
f"{family.name}: KMT={kmt:.4f} no > KB={kb:.4f}"
@pytest.mark.parametrize("family,kwargs", _FAMILIES)
def test_V054_kml_greater_than_kmt(family, kwargs):
"""V054: KML > KMT (buque siempre más estable longitudinalmente)."""
hull = generate_hull(family, **kwargs)
c = HydrostaticCurves.compute(hull, n_points=15)
for kmt, kml in zip(c.kmt_values[1:], c.kml_values[1:]):
if math.isfinite(kmt) and math.isfinite(kml) and kmt > 0:
assert kml > kmt - 1e-4, \
f"{family.name}: KML={kml:.4f} no > KMT={kmt:.4f}"
@pytest.mark.parametrize("family,kwargs", _FAMILIES)
def test_V055_tpc_positive(family, kwargs):
"""V055: TPC positivo en todo el rango."""
hull = generate_hull(family, **kwargs)
c = HydrostaticCurves.compute(hull, n_points=15)
for tpc in c.tpc_values[1:]:
if math.isfinite(tpc):
assert tpc > 0, f"{family.name}: TPC={tpc:.6f} ≤ 0"
# ---------------------------------------------------------------------------
# V. IACS Rec.34 §4.3 — verificación analítica extendida en Wigley
# ---------------------------------------------------------------------------
class TestIACSRec34Extended:
"""V056V062: Verificación analítica extendida contra Wigley."""
@pytest.fixture(autouse=True)
def setup(self):
self.hull = Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0,
n_stations=61, n_waterlines=31)
self.T = 2.0
self.h = compute_upright(self.hull, self.T)
L, B, T = 20.0, 5.0, 2.0
# Solución analítica Wigley y=B/2·(1(2x/L)²)·(1(z/T)²) xεL=[0,L]
self.V_ana = (16.0 / 35.0) * L * (B / 2) * T # ≈ 18.286 m³
self.Awp_ana = (2.0 / 3.0) * L * (B / 2) # ≈ 33.333 m²
self.KB_ana = (5.0 / 8.0) * T # ≈ 1.25 m
# IT_ana = integral de y³ a lo largo de la eslora; resultado exacto
# IT = 2 * (B/2)³ * integral_0^L [((1-(2x/L)²)^3] dx
# integral = L * 16/35
self.IT_ana = 2.0 * (B / 2.0) ** 3 * (16.0 / 35.0) * L / 3.0
# V056 — Cb analítico = 4/9 (±1%)
def test_cb_analytic(self):
assert abs(self.h.cb - 4.0 / 9.0) < 0.01, \
f"Cb={self.h.cb:.5f} esperado ≈{4/9:.5f}"
# V057 — Cw analítico = 2/3 (±1%)
def test_cw_analytic(self):
assert abs(self.h.cw - 2.0 / 3.0) < 0.01, \
f"Cw={self.h.cw:.5f} esperado ≈{2/3:.5f}"
# V058 — KB analítico = 5T/8 (±1%)
def test_kb_analytic(self):
assert abs(self.h.kb - self.KB_ana) < 0.02, \
f"KB={self.h.kb:.4f} esperado {self.KB_ana:.4f}"
# V059 — LCB = Lpp/2 por simetría longitudinal (±1 mm)
def test_lcb_midship(self):
L = self.hull.lpp
assert abs(self.h.lcb - L / 2.0) < 0.01, \
f"LCB={self.h.lcb:.4f} esperado {L/2:.4f}"
# V060 — LCF = Lpp/2 por simetría longitudinal (±1 mm)
def test_lcf_midship(self):
L = self.hull.lpp
assert abs(self.h.lcf - L / 2.0) < 0.01, \
f"LCF={self.h.lcf:.4f} esperado {L/2:.4f}"
# V061 — Cp = Cb/Cm (±0.5%)
def test_cp_equals_cb_over_cm(self):
if self.h.cm > 1e-6:
cp_calc = self.h.cb / self.h.cm
assert abs(self.h.cp - cp_calc) < 0.005, \
f"Cp={self.h.cp:.5f} pero Cb/Cm={cp_calc:.5f}"
# V062 — convergencia de malla (10 % de aumento en resolución < 2% cambio en V)
def test_mesh_convergence(self):
h_coarse = compute_upright(
Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0,
n_stations=21, n_waterlines=11), 2.0)
h_fine = compute_upright(
Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0,
n_stations=61, n_waterlines=31), 2.0)
rel_diff = abs(h_fine.volume - h_coarse.volume) / h_fine.volume
assert rel_diff < 0.02, \
f"Convergencia: delta V/V = {rel_diff:.4%} > 2%"