""" 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.3–4.5: V&V adicional (V037–V055) 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: """V037–V044: 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: """V045–V049: 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 (V050–V055) # --------------------------------------------------------------------------- _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: """V056–V062: 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%"