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:
+304
-2
@@ -1,2 +1,304 @@
|
||||
"""Geometría del casco NURBS. Stub — Sprint 1."""
|
||||
raise NotImplementedError("hull — Sprint 1")
|
||||
"""
|
||||
Hull — modelo de casco naval con geometría NURBS y cálculos hidrostáticos básicos.
|
||||
|
||||
El casco se representa como una LoftedSurface construida a partir de las secciones
|
||||
de una OffsetsTable. Los cálculos hidrostáticos usan la regla de Simpson sobre
|
||||
las secciones muestreadas para máxima compatibilidad con cualquier forma de casco.
|
||||
|
||||
Autor: Álvaro Romero
|
||||
Sprint 1 — AR-ShipDesign
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from scipy.integrate import simpson
|
||||
|
||||
from arshipdesign.core.offsets import OffsetsTable
|
||||
from arshipdesign.core.section import Section
|
||||
from arshipdesign.geometry.nurbs_surface import LoftedSurface
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hull:
|
||||
"""Modelo geométrico del casco naval.
|
||||
|
||||
Atributos
|
||||
---------
|
||||
name : str
|
||||
Nombre del casco / proyecto.
|
||||
lpp : float
|
||||
Eslora entre perpendiculares [m].
|
||||
beam : float
|
||||
Manga máxima en flotación [m].
|
||||
depth : float
|
||||
Puntal de trazado [m].
|
||||
draft : float
|
||||
Calado de diseño [m].
|
||||
offsets : OffsetsTable
|
||||
Tabla de offsets del casco.
|
||||
"""
|
||||
|
||||
name: str
|
||||
lpp: float
|
||||
beam: float
|
||||
depth: float
|
||||
draft: float
|
||||
offsets: OffsetsTable
|
||||
_surface: Optional[LoftedSurface] = field(default=None, repr=False, compare=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fábricas
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_wigley(
|
||||
cls,
|
||||
name: str = "Wigley Hull",
|
||||
lpp: float = 10.0,
|
||||
beam: float = 1.5,
|
||||
draft: float = 0.75,
|
||||
n_stations: int = 21,
|
||||
n_waterlines: int = 11,
|
||||
) -> "Hull":
|
||||
"""Crea un casco Wigley estándar.
|
||||
|
||||
El casco Wigley tiene solución analítica exacta para sus
|
||||
hidrostáticos, lo que permite verificar los métodos numéricos.
|
||||
|
||||
Fórmulas analíticas:
|
||||
Volumen de desplazamiento: V = (8/15) · B/2 · T · L/2
|
||||
LCB: en el midship (x = Lpp/2) por simetría
|
||||
Área plano de flotación (T): Awp = (4/3) · (B/2) · L
|
||||
(sólo la contribución f_xi: integral de 1-(2ξ/L)² dξ)
|
||||
"""
|
||||
offsets = OffsetsTable.from_wigley(
|
||||
lpp=lpp, beam=beam, draft=draft,
|
||||
n_stations=n_stations, n_waterlines=n_waterlines,
|
||||
)
|
||||
return cls(
|
||||
name=name,
|
||||
lpp=lpp,
|
||||
beam=beam,
|
||||
depth=draft, # Para Wigley depth = draft
|
||||
draft=draft,
|
||||
offsets=offsets,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Superficie NURBS (lazy)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def surface(self) -> LoftedSurface:
|
||||
"""LoftedSurface construida a partir de la tabla de offsets."""
|
||||
if self._surface is None:
|
||||
self._surface = self._build_surface()
|
||||
return self._surface
|
||||
|
||||
def _build_surface(self) -> LoftedSurface:
|
||||
sections_data = []
|
||||
u_arr = self.offsets.x_stations / self.lpp # normalizar a [0,1]
|
||||
for i, u in enumerate(u_arr):
|
||||
pts = np.column_stack([
|
||||
self.offsets.data[i, :],
|
||||
self.offsets.z_waterlines,
|
||||
])
|
||||
sections_data.append((float(u), pts))
|
||||
n_sec = len(sections_data)
|
||||
deg_u = min(3, n_sec - 1)
|
||||
return LoftedSurface(sections_data, degree_u=deg_u, degree_v=3)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hidrostáticos
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def sections_at_draft(self, draft: Optional[float] = None) -> list[Section]:
|
||||
"""Lista de secciones de la tabla de offsets."""
|
||||
return self.offsets.to_sections()
|
||||
|
||||
def volume_of_displacement(self, draft: Optional[float] = None) -> float:
|
||||
"""Volumen de desplazamiento [m³] hasta *draft*.
|
||||
|
||||
Integra el área de cada sección en la dirección x usando
|
||||
la regla de Simpson sobre todas las estaciones.
|
||||
"""
|
||||
T = draft if draft is not None else self.draft
|
||||
sections = self.offsets.to_sections()
|
||||
x = np.array([s.x for s in sections])
|
||||
areas = np.array([s.area(draft=T) for s in sections])
|
||||
|
||||
if len(x) >= 3:
|
||||
vol = float(simpson(areas, x=x))
|
||||
else:
|
||||
vol = float(np.trapz(areas, x))
|
||||
return abs(vol)
|
||||
|
||||
def waterplane_area(self, draft: Optional[float] = None) -> float:
|
||||
"""Área del plano de flotación [m²] al calado *draft*.
|
||||
|
||||
Integra 2·y(x, z=draft) en la dirección x.
|
||||
"""
|
||||
T = draft if draft is not None else self.draft
|
||||
x = self.offsets.x_stations
|
||||
y_wl = np.array([
|
||||
self.offsets.half_breadth(xi, T) for xi in x
|
||||
])
|
||||
# Área = integral de 2·y(x) dx (ambas bandas)
|
||||
if len(x) >= 3:
|
||||
awp = float(simpson(2.0 * y_wl, x=x))
|
||||
else:
|
||||
awp = float(np.trapz(2.0 * y_wl, x))
|
||||
return abs(awp)
|
||||
|
||||
def lcb(self, draft: Optional[float] = None) -> float:
|
||||
"""Centro longitudinal de carena (LCB) [m desde AP].
|
||||
|
||||
Momento de primer orden del volumen / volumen total.
|
||||
"""
|
||||
T = draft if draft is not None else self.draft
|
||||
sections = self.offsets.to_sections()
|
||||
x = np.array([s.x for s in sections])
|
||||
areas = np.array([s.area(draft=T) for s in sections])
|
||||
|
||||
if len(x) >= 3:
|
||||
vol = float(simpson(areas, x=x))
|
||||
moment = float(simpson(areas * x, x=x))
|
||||
else:
|
||||
vol = float(np.trapz(areas, x))
|
||||
moment = float(np.trapz(areas * x, x))
|
||||
|
||||
if abs(vol) < 1e-12:
|
||||
return self.lpp / 2.0
|
||||
return moment / vol
|
||||
|
||||
def vcb(self, draft: Optional[float] = None) -> float:
|
||||
"""Centro vertical de carena (VCB / KB) [m sobre la quilla]."""
|
||||
T = draft if draft is not None else self.draft
|
||||
sections = self.offsets.to_sections()
|
||||
x = np.array([s.x for s in sections])
|
||||
areas = np.array([s.area(draft=T) for s in sections])
|
||||
cz = np.array([s.centroid_z(draft=T) for s in sections])
|
||||
|
||||
if len(x) >= 3:
|
||||
vol = float(simpson(areas, x=x))
|
||||
moment_z = float(simpson(areas * cz, x=x))
|
||||
else:
|
||||
vol = float(np.trapz(areas, x))
|
||||
moment_z = float(np.trapz(areas * cz, x))
|
||||
|
||||
if abs(vol) < 1e-12:
|
||||
return T / 2.0
|
||||
return moment_z / vol
|
||||
|
||||
def block_coefficient(self, draft: Optional[float] = None) -> float:
|
||||
"""Coeficiente de bloque Cb = V / (Lpp · B · T)."""
|
||||
T = draft if draft is not None else self.draft
|
||||
V = self.volume_of_displacement(T)
|
||||
return V / (self.lpp * self.beam * T)
|
||||
|
||||
def midship_coefficient(self, draft: Optional[float] = None) -> float:
|
||||
"""Coeficiente de cuaderna maestra Cm = Am / (B · T)."""
|
||||
T = draft if draft is not None else self.draft
|
||||
sections = self.offsets.to_sections()
|
||||
# Cuaderna en el midship
|
||||
x_mid = self.lpp / 2.0
|
||||
areas_at_mid = [s.area(draft=T) for s in sections if abs(s.x - x_mid) < 1e-6]
|
||||
if not areas_at_mid:
|
||||
# Interpolar
|
||||
x_arr = np.array([s.x for s in sections])
|
||||
a_arr = np.array([s.area(draft=T) for s in sections])
|
||||
am = float(np.interp(x_mid, x_arr, a_arr))
|
||||
else:
|
||||
am = areas_at_mid[0]
|
||||
return am / (self.beam * T)
|
||||
|
||||
def prismatic_coefficient(self, draft: Optional[float] = None) -> float:
|
||||
"""Coeficiente prismático Cp = V / (Am · Lpp)."""
|
||||
T = draft if draft is not None else self.draft
|
||||
V = self.volume_of_displacement(T)
|
||||
Am = self.midship_coefficient(T) * self.beam * T
|
||||
if Am < 1e-12:
|
||||
return 0.0
|
||||
return V / (Am * self.lpp)
|
||||
|
||||
def displacement_tonnes(
|
||||
self, draft: Optional[float] = None, rho: float = 1025.0
|
||||
) -> float:
|
||||
"""Desplazamiento en toneladas métricas (agua salada por defecto).
|
||||
|
||||
Parámetros
|
||||
----------
|
||||
rho : float
|
||||
Densidad del agua [kg/m³]. Default 1025 kg/m³ (agua salada).
|
||||
"""
|
||||
V = self.volume_of_displacement(draft)
|
||||
return V * rho / 1000.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Malla PyVista para visualización 3D
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_mesh(self, n_u: int = 40, n_v: int = 20) -> "pyvista.PolyData":
|
||||
"""Genera una malla PyVista del casco (ambas bandas).
|
||||
|
||||
Requiere PyVista instalado. Retorna un PolyData triangulado.
|
||||
"""
|
||||
try:
|
||||
import pyvista as pv
|
||||
except ImportError as exc:
|
||||
raise ImportError("PyVista no está instalado") from exc
|
||||
|
||||
surf = self.surface
|
||||
u_range = surf.u_range
|
||||
u_arr = np.linspace(u_range[0], u_range[1], n_u)
|
||||
v_arr = np.linspace(0.0, 1.0, n_v)
|
||||
uu, vv = np.meshgrid(u_arr, v_arr, indexing="ij") # (n_u, n_v)
|
||||
|
||||
# Evaluar (y, z) en la malla
|
||||
y_mat = surf._spline_y(u_arr, v_arr) # (n_u, n_v)
|
||||
z_mat = surf._spline_z(u_arr, v_arr) # (n_u, n_v)
|
||||
|
||||
# x real desde parámetro u
|
||||
x_mat = uu * self.lpp
|
||||
|
||||
# Banda de estribor (y > 0)
|
||||
pts_stbd = np.stack([
|
||||
x_mat.ravel(), y_mat.ravel(), z_mat.ravel()
|
||||
], axis=1)
|
||||
|
||||
# Banda de babor (y < 0)
|
||||
pts_port = np.stack([
|
||||
x_mat.ravel(), -y_mat.ravel(), z_mat.ravel()
|
||||
], axis=1)
|
||||
|
||||
# Unir ambas bandas
|
||||
all_pts = np.vstack([pts_stbd, pts_port])
|
||||
|
||||
# Construir caras de la malla estructurada
|
||||
faces = []
|
||||
offset = n_u * n_v
|
||||
for band in [0, offset]:
|
||||
for i in range(n_u - 1):
|
||||
for j in range(n_v - 1):
|
||||
p0 = band + i * n_v + j
|
||||
p1 = band + (i + 1) * n_v + j
|
||||
p2 = band + (i + 1) * n_v + (j + 1)
|
||||
p3 = band + i * n_v + (j + 1)
|
||||
faces.extend([4, p0, p1, p2, p3])
|
||||
|
||||
faces_arr = np.array(faces, dtype=int)
|
||||
mesh = pv.PolyData(all_pts, faces_arr)
|
||||
return mesh.triangulate()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dunder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Hull({self.name!r}, Lpp={self.lpp} m, B={self.beam} m, "
|
||||
f"T={self.draft} m)"
|
||||
)
|
||||
|
||||
@@ -1,2 +1,170 @@
|
||||
"""Tabla de offsets. Stub — Sprint 1."""
|
||||
raise NotImplementedError("offsets — Sprint 1")
|
||||
"""
|
||||
OffsetsTable — tabla de offsets naval clásica.
|
||||
|
||||
Estructura: filas = estaciones (x), columnas = líneas de agua (z).
|
||||
Valores: semi-manga y(x, z) en metros.
|
||||
|
||||
Autor: Álvaro Romero
|
||||
Sprint 1 — AR-ShipDesign
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from arshipdesign.core.section import Section
|
||||
|
||||
|
||||
@dataclass
|
||||
class OffsetsTable:
|
||||
"""Tabla de offsets del casco.
|
||||
|
||||
Atributos
|
||||
---------
|
||||
x_stations : np.ndarray, shape (n_sta,)
|
||||
Posiciones longitudinales [m] de cada estación, creciente.
|
||||
z_waterlines : np.ndarray, shape (n_wl,)
|
||||
Alturas de líneas de agua [m] desde la quilla, creciente.
|
||||
data : np.ndarray, shape (n_sta, n_wl)
|
||||
Semi-mangas y[i, j] en metros.
|
||||
y[i, j] = semi-manga en la estación x_stations[i], línea z_waterlines[j].
|
||||
station_labels : list[str]
|
||||
Etiquetas opcionales para las estaciones.
|
||||
lpp : float
|
||||
Eslora entre perpendiculares [m].
|
||||
beam : float
|
||||
Manga máxima [m].
|
||||
draft : float
|
||||
Calado de diseño [m].
|
||||
"""
|
||||
|
||||
x_stations: np.ndarray
|
||||
z_waterlines: np.ndarray
|
||||
data: np.ndarray
|
||||
station_labels: list[str] = field(default_factory=list)
|
||||
lpp: float = 0.0
|
||||
beam: float = 0.0
|
||||
draft: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.x_stations = np.asarray(self.x_stations, dtype=float)
|
||||
self.z_waterlines = np.asarray(self.z_waterlines, dtype=float)
|
||||
self.data = np.asarray(self.data, dtype=float)
|
||||
|
||||
n_sta = len(self.x_stations)
|
||||
n_wl = len(self.z_waterlines)
|
||||
if self.data.shape != (n_sta, n_wl):
|
||||
raise ValueError(
|
||||
f"data.shape {self.data.shape} ≠ ({n_sta}, {n_wl})"
|
||||
)
|
||||
if not self.station_labels:
|
||||
self.station_labels = [str(i) for i in range(n_sta)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fábrica: casco Wigley analítico
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_wigley(
|
||||
cls,
|
||||
lpp: float = 10.0,
|
||||
beam: float = 1.5,
|
||||
draft: float = 0.75,
|
||||
n_stations: int = 21,
|
||||
n_waterlines: int = 11,
|
||||
) -> "OffsetsTable":
|
||||
"""Genera tabla de offsets para el casco Wigley matemático.
|
||||
|
||||
Fórmula analítica:
|
||||
y(x, z) = (B/2) · [1 − (2ξ/L)²] · [1 − (ζ/T)²]
|
||||
|
||||
donde ξ ∈ [−L/2, L/2] y ζ ∈ [−T, 0].
|
||||
|
||||
El AP corresponde a x=0, el FP a x=Lpp.
|
||||
La quilla está a z=0, la flotación de diseño a z=draft.
|
||||
"""
|
||||
# Posiciones longitudinales: 0 (AP) → Lpp (FP)
|
||||
x_sta = np.linspace(0.0, lpp, n_stations)
|
||||
# Líneas de agua: 0 (quilla) → draft (calado diseño)
|
||||
z_wl = np.linspace(0.0, draft, n_waterlines)
|
||||
|
||||
# Convertir a coordenadas centradas en el midship
|
||||
xi = x_sta - lpp / 2.0 # ξ ∈ [-Lpp/2, Lpp/2]
|
||||
zeta = z_wl - draft # ζ ∈ [-T, 0]
|
||||
|
||||
# Factores de forma
|
||||
f_xi = 1.0 - (2.0 * xi / lpp) ** 2 # (n_sta,)
|
||||
f_zeta = 1.0 - (zeta / draft) ** 2 # (n_wl,)
|
||||
|
||||
# y[i, j] = B/2 · f_xi[i] · f_zeta[j]
|
||||
data = (beam / 2.0) * np.outer(f_xi, f_zeta)
|
||||
data = np.clip(data, 0.0, None) # evitar negativos por redondeo
|
||||
|
||||
labels = [f"S{i}" for i in range(n_stations)]
|
||||
|
||||
return cls(
|
||||
x_stations=x_sta,
|
||||
z_waterlines=z_wl,
|
||||
data=data,
|
||||
station_labels=labels,
|
||||
lpp=lpp,
|
||||
beam=beam,
|
||||
draft=draft,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Conversión a secciones
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_sections(self) -> list[Section]:
|
||||
"""Convierte la tabla en una lista de objetos Section."""
|
||||
sections = []
|
||||
for i, x in enumerate(self.x_stations):
|
||||
sta = self.station_labels[i] if i < len(self.station_labels) else str(i)
|
||||
sec = Section(
|
||||
station=sta,
|
||||
x=float(x),
|
||||
half_breadths=self.data[i, :].copy(),
|
||||
z_positions=self.z_waterlines.copy(),
|
||||
label=f"x={x:.3f} m",
|
||||
)
|
||||
sections.append(sec)
|
||||
return sections
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Acceso
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def half_breadth(self, x: float, z: float) -> float:
|
||||
"""Interpola la semi-manga en cualquier (x, z) [m]."""
|
||||
# Interpolar en x
|
||||
col_y = np.array([
|
||||
float(np.interp(x, self.x_stations, self.data[:, j]))
|
||||
for j in range(len(self.z_waterlines))
|
||||
])
|
||||
# Interpolar en z
|
||||
return float(np.interp(z, self.z_waterlines, col_y))
|
||||
|
||||
@property
|
||||
def n_stations(self) -> int:
|
||||
return len(self.x_stations)
|
||||
|
||||
@property
|
||||
def n_waterlines(self) -> int:
|
||||
return len(self.z_waterlines)
|
||||
|
||||
@property
|
||||
def max_half_breadth(self) -> float:
|
||||
return float(self.data.max())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dunder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"OffsetsTable(Lpp={self.lpp} m, B={self.beam} m, T={self.draft} m, "
|
||||
f"stations={self.n_stations}, waterlines={self.n_waterlines})"
|
||||
)
|
||||
|
||||
@@ -1,2 +1,132 @@
|
||||
"""Sección transversal. Stub — Sprint 1."""
|
||||
raise NotImplementedError("section — Sprint 1")
|
||||
"""
|
||||
Section — sección transversal del casco naval.
|
||||
|
||||
Una sección es un corte vertical del casco a posición longitudinal x.
|
||||
Se define por puntos (y_half, z) de quilla a cubierta (casco simétrico).
|
||||
|
||||
Autor: Álvaro Romero
|
||||
Sprint 1 — AR-ShipDesign
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from scipy.integrate import simpson
|
||||
|
||||
from arshipdesign.geometry.nurbs_curve import BSplineCurve
|
||||
|
||||
|
||||
@dataclass
|
||||
class Section:
|
||||
"""Sección transversal en una estación.
|
||||
|
||||
Atributos
|
||||
---------
|
||||
station : int or str
|
||||
Número de estación (0 = AP, 20 = FP en sistema de 21 estaciones).
|
||||
x : float
|
||||
Posición longitudinal en metros desde AP.
|
||||
half_breadths : np.ndarray
|
||||
Semi-mangas [m] en cada z (mitad de babor).
|
||||
z_positions : np.ndarray
|
||||
Alturas z [m] desde la quilla (creciente, 0 = quilla).
|
||||
label : str
|
||||
Etiqueta opcional.
|
||||
"""
|
||||
|
||||
station: int | str
|
||||
x: float
|
||||
half_breadths: np.ndarray
|
||||
z_positions: np.ndarray
|
||||
label: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.half_breadths = np.asarray(self.half_breadths, dtype=float)
|
||||
self.z_positions = np.asarray(self.z_positions, dtype=float)
|
||||
if self.half_breadths.shape != self.z_positions.shape:
|
||||
raise ValueError(
|
||||
"half_breadths y z_positions deben tener igual número de elementos"
|
||||
)
|
||||
if len(self.z_positions) < 2:
|
||||
raise ValueError("Se necesitan al menos 2 puntos por sección")
|
||||
if not np.all(np.diff(self.z_positions) >= 0):
|
||||
raise ValueError("z_positions debe ser no-decreciente")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Curva B-spline
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def curve(self, degree: int = 3) -> BSplineCurve:
|
||||
"""Curva B-spline (y_half, z) de esta sección."""
|
||||
pts = np.column_stack([self.half_breadths, self.z_positions])
|
||||
k = min(degree, len(pts) - 1)
|
||||
return BSplineCurve(pts, degree=k)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hidrostáticos de sección
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _clip_to_draft(
|
||||
self, draft: Optional[float]
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Recorta z/y al calado dado. Retorna (z_clipped, y_clipped)."""
|
||||
z, y = self.z_positions, self.half_breadths
|
||||
if draft is None:
|
||||
return z, y
|
||||
z_top = float(draft)
|
||||
if z_top <= z[0]:
|
||||
return np.array([z[0], z[0]]), np.array([0.0, 0.0])
|
||||
if z_top < z[-1]:
|
||||
y_top = float(np.interp(z_top, z, y))
|
||||
mask = z < z_top
|
||||
return (
|
||||
np.concatenate([z[mask], [z_top]]),
|
||||
np.concatenate([y[mask], [y_top]]),
|
||||
)
|
||||
return z, y
|
||||
|
||||
def area(self, draft: Optional[float] = None) -> float:
|
||||
"""Área sumergida de la sección [m²] hasta *draft*.
|
||||
|
||||
Integra 2·y(z) dz (ambas bandas por simetría).
|
||||
"""
|
||||
z_int, y_int = self._clip_to_draft(draft)
|
||||
if len(z_int) < 2:
|
||||
return 0.0
|
||||
if len(z_int) >= 3:
|
||||
a = float(simpson(y_int, x=z_int))
|
||||
else:
|
||||
a = float(np.trapz(y_int, z_int))
|
||||
return 2.0 * abs(a)
|
||||
|
||||
def centroid_z(self, draft: Optional[float] = None) -> float:
|
||||
"""Centroide vertical de la sección sumergida [m desde quilla]."""
|
||||
z_int, y_int = self._clip_to_draft(draft)
|
||||
if len(z_int) < 2:
|
||||
return float(self.z_positions[0])
|
||||
if len(z_int) >= 3:
|
||||
moment = float(simpson(y_int * z_int, x=z_int))
|
||||
area = float(simpson(y_int, x=z_int))
|
||||
else:
|
||||
moment = float(np.trapz(y_int * z_int, z_int))
|
||||
area = float(np.trapz(y_int, z_int))
|
||||
if abs(area) < 1e-12:
|
||||
return float(z_int[0])
|
||||
return moment / area
|
||||
|
||||
def max_half_breadth(self, draft: Optional[float] = None) -> float:
|
||||
"""Máxima semi-manga hasta *draft* [m]."""
|
||||
z, y = self._clip_to_draft(draft)
|
||||
return float(y.max()) if len(y) > 0 else 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dunder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Section(station={self.station!r}, x={self.x:.3f} m, "
|
||||
f"n_pts={len(self.z_positions)})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user