""" Tests Módulo 2 — Motor de Curvas Hidrostáticas. Verifica: - UprightHydrostatics a calado único vs solución analítica Wigley - HydrostaticCurves: barrido de calados, monotonicidad, coeficientes - Exportación CSV y dict - Interpolación at_draft() - IACS Rec.34 V024–V036: verificación y trazabilidad Autor: Álvaro Romero | Módulo 2 — AR-ShipDesign IACS Rec.34 par.4.3, 4.4, 4.5 — verificación analítica, convergencia y simetría. """ from __future__ import annotations import json import math import numpy as np import pytest from arshipdesign.core.hull import Hull from arshipdesign.hydrostatics import ( HydrostaticCurves, UprightHydrostatics, compute_upright, CSV_HEADERS, ) from arshipdesign.parametric import generate_hull, HullFamily # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def wigley_hull() -> Hull: return Hull.from_wigley(lpp=15.0, beam=4.0, draft=1.60, n_stations=41, n_waterlines=21) @pytest.fixture(scope="module") def wigley_upright(wigley_hull: Hull) -> UprightHydrostatics: return compute_upright(wigley_hull, draft=1.60) @pytest.fixture(scope="module") def wigley_curves(wigley_hull: Hull) -> HydrostaticCurves: return HydrostaticCurves.compute(wigley_hull, n_points=20) @pytest.fixture(scope="module") def displacement_hull() -> Hull: return generate_hull(HullFamily.DISPLACEMENT, lpp=20.0, beam=6.0, draft=2.40, depth=3.20, cb=0.55) # --------------------------------------------------------------------------- # 1. UprightHydrostatics — calado único (Wigley analítico) # --------------------------------------------------------------------------- class TestUprightHydrostaticsWigley: """IACS Rec.34 §4.3 — verificación analítica.""" # Constantes analíticas para Wigley Lpp=15, B=4, T=1.60 L, B, T = 15.0, 4.0, 1.60 @property def V_ana(self): return (4.0/9.0) * self.L * self.B * self.T @property def Cb_ana(self): return 4.0/9.0 @property def Awp_ana(self): return (2.0/3.0) * self.L * self.B @property def Cw_ana(self): return 2.0/3.0 @property def KB_ana(self): return 5.0 * self.T / 8.0 @property def IT_ana(self): # IT = (2/3) * (B/2)^3 * (L/2) * ∫₋₁¹(1-u²)³du = 32/35 return (2.0/3.0) * (self.B/2)**3 * (self.L/2) * (32.0/35.0) @property def Cm_ana(self): return 2.0/3.0 def test_volume_analytic(self, wigley_upright): assert abs(wigley_upright.volume - self.V_ana) / self.V_ana < 1e-6 def test_displacement_from_volume(self, wigley_upright): rho = 1025.0 expected = wigley_upright.volume * rho / 1000.0 assert abs(wigley_upright.displacement - expected) < 1e-9 def test_awp_analytic(self, wigley_upright): assert abs(wigley_upright.awp - self.Awp_ana) / self.Awp_ana < 1e-6 def test_cb_analytic(self, wigley_upright): assert abs(wigley_upright.cb - self.Cb_ana) < 1e-5 def test_cw_analytic(self, wigley_upright): assert abs(wigley_upright.cw - self.Cw_ana) < 1e-5 def test_kb_analytic(self, wigley_upright): """IACS Rec.34 §4.3 — KB = 5T/8 para el casco Wigley.""" assert abs(wigley_upright.kb - self.KB_ana) / self.KB_ana < 1e-5 def test_it_analytic(self, wigley_upright): """IT = (2/3)(B/2)³(L/2)(32/35) para el casco Wigley.""" assert abs(wigley_upright.it - self.IT_ana) / self.IT_ana < 0.001 # < 0.1% def test_bmt_equals_it_over_v(self, wigley_upright): expected = wigley_upright.it / wigley_upright.volume assert abs(wigley_upright.bmt - expected) < 1e-9 def test_kmt_equals_kb_plus_bmt(self, wigley_upright): assert abs(wigley_upright.kmt - (wigley_upright.kb + wigley_upright.bmt)) < 1e-9 def test_bml_equals_il_over_v(self, wigley_upright): expected = wigley_upright.il / wigley_upright.volume assert abs(wigley_upright.bml - expected) < 1e-9 def test_kml_equals_kb_plus_bml(self, wigley_upright): assert abs(wigley_upright.kml - (wigley_upright.kb + wigley_upright.bml)) < 1e-9 def test_lcb_symmetry(self, wigley_upright, wigley_hull): """IACS Rec.34 §4.5 — LCB = Lpp/2 para cascos simétricos.""" assert abs(wigley_upright.lcb - wigley_hull.lpp / 2.0) < 1e-4 def test_lcf_symmetry(self, wigley_upright, wigley_hull): """IACS Rec.34 §4.5 — LCF = Lpp/2 para cascos simétricos.""" assert abs(wigley_upright.lcf - wigley_hull.lpp / 2.0) < 1e-4 def test_cm_analytic(self, wigley_upright): assert abs(wigley_upright.cm - self.Cm_ana) < 1e-4 def test_cp_from_cb_and_cm(self, wigley_upright): """Cp = Cb / Cm — relación de identidad fundamental.""" expected = wigley_upright.cb / wigley_upright.cm assert abs(wigley_upright.cp - expected) < 1e-4 def test_tpc_positive_and_consistent(self, wigley_upright): rho = 1025.0 expected = wigley_upright.awp * rho / 100_000.0 assert wigley_upright.tpc > 0.0 assert abs(wigley_upright.tpc - expected) < 1e-9 def test_mct_positive(self, wigley_upright): assert wigley_upright.mct >= 0.0 def test_zero_draft_returns_zeros(self, wigley_hull): uh = compute_upright(wigley_hull, draft=0.0) assert uh.volume == 0.0 assert uh.displacement == 0.0 assert uh.awp == 0.0 def test_cb_bounded(self, wigley_upright): assert 0.0 < wigley_upright.cb <= 1.0 def test_cw_bounded(self, wigley_upright): assert 0.0 < wigley_upright.cw <= 1.0 def test_cm_bounded(self, wigley_upright): assert 0.0 < wigley_upright.cm <= 1.0 def test_cp_bounded(self, wigley_upright): assert 0.0 < wigley_upright.cp <= 1.0 # --------------------------------------------------------------------------- # 2. compute_upright vs métodos de Hull # (IACS Rec.34 §4.3 — consistencia entre métodos) # --------------------------------------------------------------------------- class TestUprightVsHullMethods: """Los resultados de compute_upright deben coincidir con los métodos del Hull.""" def test_volume_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.volume - wigley_hull.volume_of_displacement()) < 1e-9 def test_displacement_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.displacement - wigley_hull.displacement_tonnes()) < 1e-9 def test_awp_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.awp - wigley_hull.waterplane_area()) < 1e-9 def test_kb_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.kb - wigley_hull.vcb()) < 1e-9 def test_kmt_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.kmt - wigley_hull.km_transverse()) < 1e-9 def test_tpc_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.tpc - wigley_hull.tpc()) < 1e-9 def test_cb_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.cb - wigley_hull.block_coefficient()) < 1e-9 def test_cw_matches_hull(self, wigley_hull): uh = compute_upright(wigley_hull, draft=1.60) assert abs(uh.cw - wigley_hull.waterplane_coefficient()) < 1e-9 def test_partial_draft_volume(self, wigley_hull): """compute_upright a T parcial debe coincidir con Hull a ese T.""" T_partial = 0.80 uh = compute_upright(wigley_hull, draft=T_partial) hull_v = wigley_hull.volume_of_displacement(T_partial) assert abs(uh.volume - hull_v) < 1e-9 def test_rho_scaling(self, wigley_hull): """Desplazamiento debe escalar linealmente con rho.""" uh_salt = compute_upright(wigley_hull, draft=1.60, rho=1025.0) uh_fresh = compute_upright(wigley_hull, draft=1.60, rho=1000.0) ratio = uh_salt.displacement / uh_fresh.displacement assert abs(ratio - 1025.0 / 1000.0) < 1e-9 def test_tpc_scales_with_rho(self, wigley_hull): uh_salt = compute_upright(wigley_hull, draft=1.60, rho=1025.0) uh_fresh = compute_upright(wigley_hull, draft=1.60, rho=1000.0) ratio = uh_salt.tpc / uh_fresh.tpc assert abs(ratio - 1025.0 / 1000.0) < 1e-9 # --------------------------------------------------------------------------- # 3. HydrostaticCurves — barrido de calados # --------------------------------------------------------------------------- class TestHydrostaticCurves: def test_default_n_points(self, wigley_hull): c = HydrostaticCurves.compute(wigley_hull) assert len(c) == 20 def test_custom_n_points(self, wigley_hull): c = HydrostaticCurves.compute(wigley_hull, n_points=10) assert len(c) == 10 def test_min_n_points_enforced(self, wigley_hull): """n_points < 5 se eleva a 5.""" c = HydrostaticCurves.compute(wigley_hull, n_points=2) assert len(c) == 5 def test_design_draft_is_last_point(self, wigley_hull, wigley_curves): assert abs(wigley_curves.points[-1].draft - wigley_hull.draft) < 1e-12 def test_design_draft_matches_compute_upright(self, wigley_hull, wigley_curves): """El último punto debe coincidir con compute_upright al calado de diseño.""" uh = compute_upright(wigley_hull, wigley_hull.draft) last = wigley_curves.points[-1] assert abs(last.volume - uh.volume) < 1e-9 assert abs(last.awp - uh.awp) < 1e-9 assert abs(last.kmt - uh.kmt) < 1e-9 def test_hull_name_preserved(self, wigley_hull, wigley_curves): assert wigley_curves.hull_name == wigley_hull.name def test_lpp_beam_preserved(self, wigley_hull, wigley_curves): assert wigley_curves.lpp == wigley_hull.lpp assert wigley_curves.beam == wigley_hull.beam def test_getitem(self, wigley_curves): p = wigley_curves[0] assert isinstance(p, UprightHydrostatics) def test_iter(self, wigley_curves): pts = list(wigley_curves) assert len(pts) == len(wigley_curves) def test_repr_contains_hull_name(self, wigley_curves): assert "Wigley" in repr(wigley_curves) # --------------------------------------------------------------------------- # 4. Monotonicidad de las curvas # (IACS Rec.34 §4.4 — verificación de tendencias) # --------------------------------------------------------------------------- class TestCurvesMonotonicity: """V024–V028: las curvas hidrostáticas deben ser monótonamente crecientes.""" def test_v024_displacement_monotone(self, wigley_curves): """V024 — Δ(T) es monótonamente creciente.""" d = np.diff(wigley_curves.displacements) assert np.all(d > 0), f"Δ no es monótono: diff mín = {d.min():.6f}" def test_v025_volume_monotone(self, wigley_curves): """V025 — V(T) es monótonamente creciente.""" d = np.diff(wigley_curves.volumes) assert np.all(d > 0), f"V no es monótono: diff mín = {d.min():.6f}" def test_v026_kb_monotone(self, wigley_curves): """V026 — KB(T) es monótonamente creciente.""" d = np.diff(wigley_curves.kb_values) assert np.all(d > 0), f"KB no es monótono: diff mín = {d.min():.6f}" def test_v027_awp_monotone(self, wigley_curves): """V027 — Awp(T) es monótonamente creciente (para el casco Wigley).""" d = np.diff(wigley_curves.awp_values) assert np.all(d > 0), f"Awp no es monótono: diff mín = {d.min():.6f}" def test_v028_tpc_monotone(self, wigley_curves): """V028 — TPC(T) es monótonamente creciente.""" d = np.diff(wigley_curves.tpc_values) assert np.all(d > 0), f"TPC no es monótono: diff mín = {d.min():.6f}" def test_cb_within_bounds_all_drafts(self, wigley_curves): """Cb ∈ (0, 1) para todos los calados.""" cb = wigley_curves.cb_values assert np.all(cb > 0) assert np.all(cb <= 1.0) def test_cw_within_bounds_all_drafts(self, wigley_curves): """Cw ∈ (0, 1] para todos los calados.""" cw = wigley_curves.cw_values assert np.all(cw > 0) assert np.all(cw <= 1.0) def test_all_families_monotone_displacement(self): """V029 — Δ monótono para las 5 familias paramétricas.""" for family in HullFamily: hull = generate_hull(family, lpp=12.0, beam=3.5, draft=1.20, depth=2.00) c = HydrostaticCurves.compute(hull, n_points=10) d = np.diff(c.displacements) assert np.all(d > 0), \ f"{family.value}: Δ no es monótono (diff mín={d.min():.6f})" # --------------------------------------------------------------------------- # 5. Interpolación at_draft() # --------------------------------------------------------------------------- class TestAtDraft: def test_at_design_draft_matches_last_point(self, wigley_hull, wigley_curves): T_d = wigley_hull.draft interp = wigley_curves.at_draft(T_d) last = wigley_curves.points[-1] assert abs(interp.volume - last.volume) < 1e-9 assert abs(interp.kmt - last.kmt) < 1e-9 def test_at_min_draft_matches_first_point(self, wigley_curves): T_min = wigley_curves.points[0].draft interp = wigley_curves.at_draft(T_min) first = wigley_curves.points[0] assert abs(interp.volume - first.volume) < 1e-9 def test_clamp_below_min(self, wigley_curves): T_min = wigley_curves.points[0].draft interp = wigley_curves.at_draft(-1.0) first = wigley_curves.points[0] assert abs(interp.volume - first.volume) < 1e-9 def test_clamp_above_max(self, wigley_curves): T_max = wigley_curves.points[-1].draft interp = wigley_curves.at_draft(T_max + 5.0) last = wigley_curves.points[-1] assert abs(interp.volume - last.volume) < 1e-9 def test_mid_draft_between_bounds(self, wigley_curves): """Valor interpolado intermedio debe estar entre los extremos.""" T_mid = (wigley_curves.drafts[0] + wigley_curves.drafts[-1]) / 2.0 interp = wigley_curves.at_draft(T_mid) assert wigley_curves.points[0].volume < interp.volume < wigley_curves.points[-1].volume def test_returns_upright_hydrostatics_instance(self, wigley_curves): assert isinstance(wigley_curves.at_draft(1.0), UprightHydrostatics) # --------------------------------------------------------------------------- # 6. Vectorized array properties # --------------------------------------------------------------------------- class TestArrayProperties: def test_drafts_length(self, wigley_curves): assert len(wigley_curves.drafts) == len(wigley_curves) def test_volumes_length(self, wigley_curves): assert len(wigley_curves.volumes) == len(wigley_curves) def test_displacements_positive(self, wigley_curves): assert np.all(wigley_curves.displacements > 0) def test_kb_positive(self, wigley_curves): assert np.all(wigley_curves.kb_values > 0) def test_kmt_positive(self, wigley_curves): assert np.all(wigley_curves.kmt_values > 0) def test_tpc_positive(self, wigley_curves): assert np.all(wigley_curves.tpc_values > 0) def test_mct_non_negative(self, wigley_curves): assert np.all(wigley_curves.mct_values >= 0) def test_all_array_attrs_same_length(self, wigley_curves): n = len(wigley_curves) for attr in ("drafts", "volumes", "displacements", "awp_values", "lcb_values", "lcf_values", "kb_values", "bmt_values", "bml_values", "kmt_values", "kml_values", "tpc_values", "mct_values", "cb_values", "cw_values", "cm_values", "cp_values"): arr = getattr(wigley_curves, attr) assert len(arr) == n, f"{attr} tiene longitud {len(arr)} ≠ {n}" # --------------------------------------------------------------------------- # 7. Exportación CSV y dict # --------------------------------------------------------------------------- class TestExport: def test_csv_header_count(self, wigley_curves): lines = wigley_curves.to_csv_lines() assert len(lines) == len(wigley_curves) + 1 # header + datos def test_csv_header_matches_constant(self, wigley_curves): lines = wigley_curves.to_csv_lines() assert lines[0] == ",".join(CSV_HEADERS) def test_csv_row_column_count(self, wigley_curves): lines = wigley_curves.to_csv_lines() n_cols = len(CSV_HEADERS) for row in lines[1:]: assert len(row.split(",")) == n_cols def test_csv_first_value_is_draft(self, wigley_curves): lines = wigley_curves.to_csv_lines() first_draft = float(lines[1].split(",")[0]) assert abs(first_draft - wigley_curves.points[0].draft) < 1e-3 def test_csv_semicolon_separator(self, wigley_curves): lines = wigley_curves.to_csv_lines(sep=";") assert ";" in lines[0] assert "," not in lines[0] def test_csv_decimal_comma(self, wigley_curves): lines = wigley_curves.to_csv_lines(sep=";", decimal=",") # Los números deben usar coma decimal row_parts = lines[1].split(";") assert "," in row_parts[0] # e.g. "0,1600" def test_to_dict_has_required_keys(self, wigley_curves): d = wigley_curves.to_dict() for key in ("hull_name", "lpp", "beam", "design_draft", "rho", "headers", "points"): assert key in d, f"Falta clave '{key}' en to_dict()" def test_to_dict_json_serializable(self, wigley_curves): d = wigley_curves.to_dict() txt = json.dumps(d) assert len(txt) > 100 def test_to_dict_points_count(self, wigley_curves): d = wigley_curves.to_dict() assert len(d["points"]) == len(wigley_curves) def test_to_dict_first_point_keys(self, wigley_curves): d = wigley_curves.to_dict() pt = d["points"][0] for key in ("T", "V", "Delta", "Awp", "LCB", "LCF", "KB", "IT", "IL", "BMT", "BML", "KMT", "KML", "TPC", "MCT", "Cb", "Cw", "Cm", "Cp"): assert key in pt, f"Falta clave '{key}' en points[0]" # --------------------------------------------------------------------------- # 8. IACS Rec.34 — verificaciones V030–V036 # --------------------------------------------------------------------------- class TestIACSVerification: """Verificaciones adicionales según IACS Rec.34 §4.""" def test_v030_wigley_cb_accuracy_at_design_draft(self, wigley_curves): """V030 — Cb en calado de diseño ≈ 4/9 (error < 0.1%).""" cb_last = wigley_curves.points[-1].cb assert abs(cb_last - 4.0/9.0) < 0.001, \ f"Cb={cb_last:.6f} ≠ 4/9={4/9:.6f}" def test_v031_wigley_cw_at_design_draft(self, wigley_curves): """V031 — Cw en calado de diseño ≈ 2/3 (error < 0.1%).""" cw_last = wigley_curves.points[-1].cw assert abs(cw_last - 2.0/3.0) < 0.001, \ f"Cw={cw_last:.6f} ≠ 2/3={2/3:.6f}" def test_v032_wigley_symmetry_all_drafts(self, wigley_hull, wigley_curves): """V032 — LCB = LCF = Lpp/2 en todos los calados (simetría).""" L_mid = wigley_hull.lpp / 2.0 for p in wigley_curves: assert abs(p.lcb - L_mid) < 1e-3, \ f"T={p.draft:.3f}: LCB={p.lcb:.4f} ≠ {L_mid}" assert abs(p.lcf - L_mid) < 1e-3, \ f"T={p.draft:.3f}: LCF={p.lcf:.4f} ≠ {L_mid}" def test_v033_kmt_greater_than_kb(self, wigley_curves): """V033 — KMT > KB en todos los calados (BMT > 0).""" for p in wigley_curves: assert p.kmt > p.kb, \ f"T={p.draft:.3f}: KMT={p.kmt:.4f} ≤ KB={p.kb:.4f}" def test_v034_kml_greater_than_kmt(self, wigley_curves): """V034 — KML > KMT en todos los calados (BML >> BMT).""" for p in wigley_curves: assert p.kml > p.kmt, \ f"T={p.draft:.3f}: KML={p.kml:.4f} ≤ KMT={p.kmt:.4f}" def test_v035_cp_identity(self, wigley_curves): """V035 — Cp = Cb / Cm (identidad de coeficientes).""" for p in wigley_curves: if p.cm > 0.01: expected = p.cb / p.cm assert abs(p.cp - expected) < 1e-4, \ f"T={p.draft:.3f}: Cp={p.cp:.4f} ≠ Cb/Cm={expected:.4f}" def test_v036_displacement_hull_monotone(self, displacement_hull): """V036 — desplazamiento monótono para casco de desplazamiento parametrico.""" c = HydrostaticCurves.compute(displacement_hull, n_points=15) d = np.diff(c.displacements) assert np.all(d > 0), \ f"DISPLACEMENT: Δ no monótono (diff mín={d.min():.6f})" def test_v037_mesh_convergence_volume(self, wigley_hull): """V037 — IACS §4.4: convergencia de malla. V con 41 sta ≈ V con 81 sta.""" hull_fine = Hull.from_wigley( lpp=15.0, beam=4.0, draft=1.60, n_stations=81, n_waterlines=41 ) uh_coarse = compute_upright(wigley_hull, 1.60) uh_fine = compute_upright(hull_fine, 1.60) # Convergencia < 0.1% diff_pct = abs(uh_coarse.volume - uh_fine.volume) / uh_fine.volume * 100 assert diff_pct < 0.1, f"Convergencia de malla: error V = {diff_pct:.4f}%"