503e00bfc9
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>
356 lines
13 KiB
Python
356 lines
13 KiB
Python
"""
|
|
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"
|