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>
This commit is contained in:
2026-05-27 09:11:58 -04:00
parent 274b3b3f53
commit 98ff57ed08
17 changed files with 1687 additions and 199 deletions
+534
View File
@@ -0,0 +1,534 @@
"""
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}%"