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:
@@ -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"
|
||||
Reference in New Issue
Block a user