feat(sprint1): motor NURBS, modelos de casco, visor 3D PyVista

Geometría:
  - BSplineCurve: interpolación scipy, arc_length, tangente, chord-length
  - LoftedSurface: lofting de secciones → RectBivariateSpline bivariate

Core (casco Wigley como caso de prueba):
  - Section: área, centroide_z, max_half_breadth, curva B-spline
  - OffsetsTable: from_wigley(), to_sections(), interpolación xy
  - Hull: volumen, Awp, LCB, VCB, Cb, Cm, Cp, desplazamiento, to_mesh()

UI:
  - Viewer3DWidget (pyvistaqt.QtInteractor): casco Wigley por defecto
    al arrancar, fondo navy, waterplane semi-transparente, fallback
    graceful si PyVista no disponible
  - MainWindow: Viewer3DWidget inyectado en viewport Perspectiva 3D

Tests: 39 nuevos tests, fórmulas analíticas Wigley verificadas (±1%)
  V = 4BLT/9, Cb = 4/9, Awp = 2BL/3 (derivación correcta)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 01:07:35 -04:00
parent 135f097079
commit 503e00bfc9
8 changed files with 1519 additions and 12 deletions
+355
View File
@@ -0,0 +1,355 @@
"""
Tests de Sprint 1 — Geometría NURBS, modelos de casco, hidrostáticos Wigley.
El casco Wigley tiene soluciones analíticas exactas para sus hidrostáticos
que se usan como valores de referencia para verificar la implementación numérica.
Soluciones analíticas del casco Wigley:
y(ξ, ζ) = (B/2) · [1 - (2ξ/L)²] · [1 - (ζ/T)²]
con ξ ∈ [-L/2, L/2], ζ ∈ [-T, 0]
Volumen de desplazamiento (derivación exacta):
V = B · ∫₋ᴸ/₂ᴸ/²[1-(2ξ/L)²]dξ · ∫₋ᵀ⁰[1-(ζ/T)²]dζ
= B · (2L/3) · (2T/3)
= 4·B·L·T / 9
Área plano de flotación (ζ=0 → f_ζ=1, ambas bandas):
Awp = 2 · ∫₋ᴸ/₂ᴸ/² (B/2) · [1-(2ξ/L)²] dξ
= B · (2L/3) = 2BL/3
Cb = V/(L·B·T) = 4/9 ≈ 0.4444
LCB: x = Lpp/2 por simetría longitudinal del casco Wigley.
"""
import math
import numpy as np
import pytest
from arshipdesign.geometry.nurbs_curve import BSplineCurve
from arshipdesign.geometry.nurbs_surface import LoftedSurface
from arshipdesign.core.section import Section
from arshipdesign.core.offsets import OffsetsTable
from arshipdesign.core.hull import Hull
# ---------------------------------------------------------------------------
# Parámetros del casco Wigley de referencia
# ---------------------------------------------------------------------------
LPP = 10.0
BEAM = 1.5
DRAFT = 0.75
N_STA = 41 # alta resolución para precisión numérica
N_WL = 21
@pytest.fixture(scope="module")
def wigley_offsets() -> OffsetsTable:
return OffsetsTable.from_wigley(
lpp=LPP, beam=BEAM, draft=DRAFT,
n_stations=N_STA, n_waterlines=N_WL
)
@pytest.fixture(scope="module")
def wigley_hull() -> Hull:
return Hull.from_wigley(
lpp=LPP, beam=BEAM, draft=DRAFT,
n_stations=N_STA, n_waterlines=N_WL
)
# ---------------------------------------------------------------------------
# 1. BSplineCurve
# ---------------------------------------------------------------------------
class TestBSplineCurve:
def test_creation_2d(self):
pts = np.array([[0, 0], [1, 1], [2, 0.5], [3, 0]], dtype=float)
c = BSplineCurve(pts, degree=3)
assert c.degree == 3
assert c.dim == 2
def test_creation_3d(self):
pts = np.random.rand(6, 3)
c = BSplineCurve(pts, degree=3)
assert c.dim == 3
def test_evaluate_scalar(self):
pts = np.array([[0, 0], [1, 1], [2, 0.5], [3, 0]], dtype=float)
c = BSplineCurve(pts, degree=3)
p = c.evaluate(0.5)
assert p.shape == (2,)
def test_evaluate_array(self):
pts = np.array([[0, 0], [1, 1], [2, 0.5], [3, 0]], dtype=float)
c = BSplineCurve(pts, degree=3)
t = np.linspace(0, 1, 50)
pts_out = c.evaluate(t)
assert pts_out.shape == (50, 2)
def test_endpoints_interpolated(self):
"""Los extremos de la curva deben pasar por los puntos originales."""
pts = np.array([[0.0, 0.0], [1.0, 2.0], [3.0, 1.5], [4.0, 0.0]])
c = BSplineCurve(pts, degree=3)
p0 = c.evaluate(0.0)
p1 = c.evaluate(1.0)
np.testing.assert_allclose(p0, pts[0], atol=1e-8)
np.testing.assert_allclose(p1, pts[-1], atol=1e-8)
def test_sample_returns_n_points(self):
pts = np.array([[0, 0], [1, 1], [2, 0.5], [3, 0]], dtype=float)
c = BSplineCurve(pts, degree=3)
sampled = c.sample(80)
assert sampled.shape == (80, 2)
def test_arc_length_line(self):
"""Línea recta: longitud = distancia euclidiana."""
pts = np.array([[0, 0], [1, 0], [2, 0], [3, 0]], dtype=float)
c = BSplineCurve(pts, degree=1)
L = c.arc_length()
assert abs(L - 3.0) < 0.01
def test_too_few_points_raises(self):
pts = np.array([[0, 0], [1, 1]], dtype=float)
with pytest.raises(ValueError):
BSplineCurve(pts, degree=3)
def test_wrong_shape_raises(self):
with pytest.raises(ValueError):
BSplineCurve(np.array([1, 2, 3]), degree=1)
# ---------------------------------------------------------------------------
# 2. Section
# ---------------------------------------------------------------------------
class TestSection:
def _make_rect_section(self, half_b: float = 2.0, height: float = 3.0) -> Section:
"""Sección rectangular: área = 2·B·H."""
return Section(
station=5, x=5.0,
half_breadths=np.array([half_b, half_b]),
z_positions=np.array([0.0, height]),
)
def _make_triangular_section(self, half_b: float = 2.0, height: float = 3.0) -> Section:
"""Sección triangular (quilla aguda): área = B·H.
Usa 5 puntos para que la integración numérica sea precisa.
"""
n = 5
z = np.linspace(0.0, height, n)
y = (half_b / height) * z # lineal 0 → half_b
return Section(station=5, x=5.0, half_breadths=y, z_positions=z)
def test_area_rectangular(self):
s = self._make_rect_section(half_b=1.0, height=2.0)
# Área completa (ambas bandas) = 2 * 1.0 * 2.0 = 4.0
assert abs(s.area() - 4.0) < 1e-6
def test_area_triangular(self):
s = self._make_triangular_section(half_b=1.0, height=2.0)
# Área completa = 2 * (0.5 * 1.0 * 2.0) = 2.0
assert abs(s.area() - 2.0) < 1e-6
def test_area_at_draft(self):
s = self._make_rect_section(half_b=1.0, height=4.0)
# Recortar a draft=2.0 → área = 2*1*2 = 4.0
assert abs(s.area(draft=2.0) - 4.0) < 1e-6
def test_area_zero_below_keel(self):
s = self._make_rect_section()
assert s.area(draft=0.0) == 0.0
def test_centroid_z_rectangular(self):
s = self._make_rect_section(half_b=1.0, height=2.0)
# Centroide de rectángulo = H/2 = 1.0
assert abs(s.centroid_z() - 1.0) < 1e-6
def test_centroid_z_triangular(self):
s = self._make_triangular_section(half_b=1.0, height=3.0)
# Centroide de sección triangular (y lineal, keel=0): 2H/3
# ∫y·z dz / ∫y dz = (H²/3)/(H/2) = 2H/3 = 2.0
assert abs(s.centroid_z() - 2.0) < 1e-4
def test_max_half_breadth(self):
s = self._make_rect_section(half_b=2.5)
assert abs(s.max_half_breadth() - 2.5) < 1e-9
def test_curve_returns_bspline(self):
s = self._make_rect_section()
c = s.curve()
assert isinstance(c, BSplineCurve)
def test_repr(self):
s = self._make_rect_section()
assert "Section" in repr(s)
# ---------------------------------------------------------------------------
# 3. OffsetsTable — casco Wigley
# ---------------------------------------------------------------------------
class TestOffsetsTable:
def test_shape(self, wigley_offsets):
ot = wigley_offsets
assert ot.data.shape == (N_STA, N_WL)
def test_endpoints_zero(self, wigley_offsets):
"""AP y FP deben tener semi-manga cero (extremos agudos)."""
ot = wigley_offsets
assert ot.data[0, :].max() < 1e-9, "AP no es cero"
assert ot.data[-1, :].max() < 1e-9, "FP no es cero"
def test_keel_zero(self, wigley_offsets):
"""En la quilla (z=0, ζ=-T) la semi-manga es cero."""
ot = wigley_offsets
# Primera columna: z=0 → ζ = 0 - T = -T → f_zeta = 1 - (-T/T)² = 0
assert ot.data[:, 0].max() < 1e-9, "Quilla no es cero"
def test_max_half_breadth(self, wigley_offsets):
"""El máximo debe ser B/2 en el midship en la línea de agua."""
ot = wigley_offsets
assert abs(ot.max_half_breadth - BEAM / 2.0) < 1e-6
def test_to_sections(self, wigley_offsets):
sections = wigley_offsets.to_sections()
assert len(sections) == N_STA
for s in sections:
assert isinstance(s, Section)
def test_interpolation(self, wigley_offsets):
"""Verificar interpolación en punto conocido: midship, waterline."""
ot = wigley_offsets
x_mid = LPP / 2.0
z_wl = DRAFT
y_interp = ot.half_breadth(x_mid, z_wl)
# Wigley: y = B/2 * (1-0) * (1-1) = 0 → ζ = 0 - T = -T → f_zeta=0
# En z=draft, ζ=0 → f_zeta = 1 - (0/T)² = 1
# y = B/2 * 1 * 1 = B/2
assert abs(y_interp - BEAM / 2.0) < 1e-3
def test_repr(self, wigley_offsets):
r = repr(wigley_offsets)
assert "OffsetsTable" in r
# ---------------------------------------------------------------------------
# 4. Hull — hidrostáticos del casco Wigley
# ---------------------------------------------------------------------------
class TestHullHydrostatics:
"""
Tolerancias para verificación numérica:
- Volumen: ±1 % respecto al valor analítico
- LCB: ±1 % de Lpp
- Awp: ±1 %
- Cb: ±1 %
"""
# Valores analíticos exactos del casco Wigley
# V = 2·(B/2)·∫[1-(2ξ/L)²]dξ · ∫[1-(ζ/T)²]dζ
# = B · (2L/3) · (2T/3) = 4BLT/9
V_ANALYTIC = (4.0 * BEAM * LPP * DRAFT) / 9.0
# Awp = 2·∫(B/2)·[1-(2ξ/L)²]dξ = B · 2L/3 = 2BL/3
AWP_ANALYTIC = (2.0 * BEAM * LPP) / 3.0
LCB_ANALYTIC = LPP / 2.0 # simetría longitudinal
CB_ANALYTIC = 4.0 / 9.0 # V/(LBT) = 4BLT/9 / (LBT) = 4/9
def test_volume_within_1pct(self, wigley_hull):
V = wigley_hull.volume_of_displacement()
err_pct = abs(V - self.V_ANALYTIC) / self.V_ANALYTIC * 100
assert err_pct < 1.0, (
f"Volumen {V:.4f} m³ difiere {err_pct:.2f}% del analítico {self.V_ANALYTIC:.4f}"
)
def test_waterplane_area_within_1pct(self, wigley_hull):
Awp = wigley_hull.waterplane_area()
err_pct = abs(Awp - self.AWP_ANALYTIC) / self.AWP_ANALYTIC * 100
assert err_pct < 1.0, (
f"Awp {Awp:.4f} m² difiere {err_pct:.2f}% del analítico {self.AWP_ANALYTIC:.4f}"
)
def test_lcb_symmetric(self, wigley_hull):
lcb = wigley_hull.lcb()
err = abs(lcb - self.LCB_ANALYTIC)
assert err < 0.1 * LPP, (
f"LCB {lcb:.4f} m difiere {err:.4f} m del midship {self.LCB_ANALYTIC}"
)
def test_block_coefficient(self, wigley_hull):
Cb = wigley_hull.block_coefficient()
err_pct = abs(Cb - self.CB_ANALYTIC) / self.CB_ANALYTIC * 100
assert err_pct < 1.0, (
f"Cb {Cb:.4f} difiere {err_pct:.2f}% del analítico {self.CB_ANALYTIC:.4f}"
)
def test_vcb_positive(self, wigley_hull):
kb = wigley_hull.vcb()
assert 0.0 < kb < DRAFT, f"VCB {kb:.4f} fuera del rango [0, T={DRAFT}]"
def test_midship_coefficient_less_than_1(self, wigley_hull):
cm = wigley_hull.midship_coefficient()
assert 0.0 < cm <= 1.0, f"Cm={cm:.4f} fuera del rango (0,1]"
def test_displacement_positive(self, wigley_hull):
D = wigley_hull.displacement_tonnes()
assert D > 0.0
def test_displacement_freshwater_less(self, wigley_hull):
D_sw = wigley_hull.displacement_tonnes(rho=1025.0)
D_fw = wigley_hull.displacement_tonnes(rho=1000.0)
assert D_sw > D_fw
def test_repr(self, wigley_hull):
assert "Hull" in repr(wigley_hull)
# ---------------------------------------------------------------------------
# 5. LoftedSurface básico
# ---------------------------------------------------------------------------
class TestLoftedSurface:
def _make_simple_surface(self) -> LoftedSurface:
"""Superficie sencilla: 5 secciones rectangulares."""
sections = []
for i, u in enumerate(np.linspace(0.0, 1.0, 6)):
# Semi-manga varía de 0 a 1 a 0 (forma Wigley simplificada)
y_max = 1.0 - abs(2 * u - 1.0) ** 2
pts = np.array([[0.0, 0.0], [y_max, 0.5], [y_max, 1.0]])
sections.append((u, pts))
return LoftedSurface(sections, degree_u=3, degree_v=2)
def test_creation(self):
surf = self._make_simple_surface()
assert surf.n_sections == 6
def test_evaluate_scalar(self):
surf = self._make_simple_surface()
pt = surf.evaluate(0.5, 0.5)
assert pt.shape == (2,)
def test_section_at_returns_curve(self):
surf = self._make_simple_surface()
curve = surf.section_at(0.5)
assert isinstance(curve, BSplineCurve)
def test_insufficient_sections_raises(self):
pts = np.array([[0.0, 0.0], [0.5, 1.0], [0.0, 2.0]])
with pytest.raises(ValueError):
LoftedSurface([(0.0, pts), (0.5, pts)], degree_u=3)
# ---------------------------------------------------------------------------
# 6. Wigley hull mesh (si PyVista disponible)
# ---------------------------------------------------------------------------
def test_wigley_hull_mesh(wigley_hull):
"""Malla PyVista del casco Wigley debe tener puntos válidos."""
pv = pytest.importorskip("pyvista", reason="PyVista no instalado")
mesh = wigley_hull.to_mesh(n_u=20, n_v=10)
assert mesh.n_points > 0
assert mesh.n_cells > 0
# Sin puntos NaN
pts = mesh.points
assert not np.any(np.isnan(pts)), "La malla contiene puntos NaN"