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>
This commit is contained in:
2026-05-27 09:21:27 -04:00
parent 98ff57ed08
commit 4630d2d19f
4 changed files with 1129 additions and 4 deletions
+421
View File
@@ -0,0 +1,421 @@
"""
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%"