Files
AR-Shipdesign/tests/test_module2_hydrostatics.py
T
alro65 98ff57ed08 Módulo 1 fixes + Módulo 2 motor hidrostático (Tasks 13–13b)
Fixes Module 1 UI:
- wizard_cruiser/sailing/planing: perfiles sin^n calibrados por Cm, V-bottom
  con ángulo de astilla, corrección zona sobre chine planeador
- viewer_3d: buffer hull pendiente para eliminar race condition 500ms
- viewer_lines: reescritura completa — waterlines visibles, control points
  interactivos (drag DelftShip-style), señal offsets_edited
- main_window: conecta offsets_edited → slot _on_offsets_edited_from_viewer
  que propaga cambios a todos los visores, editor, 3D y barra hidrostática

Módulo 2 — motor HydrostaticCurves (Task 13):
- integrator.py: integrate() (Simpson+trapz), waterplane_strips(), section_areas()
- upright.py: UprightHydrostatics (19 campos), compute_upright() single-pass
- curves_of_form.py: HydrostaticCurves.compute(), at_draft(), to_csv_lines(), to_dict()
- tests/test_module2_hydrostatics.py: 83 tests — Wigley V&V, monotonicidad,
  CSV export, IACS Rec.34 §4.3–4.5; todos los 224 tests pasan

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

535 lines
22 KiB
Python
Raw 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 — 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 V024V036: 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:
"""V024V028: 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 V030V036
# ---------------------------------------------------------------------------
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}%"