197 lines
7.2 KiB
Python
197 lines
7.2 KiB
Python
"""
|
||
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
|
||
# Altura de la quilla por estación [m]. Permite quillas inclinadas
|
||
# (rise of keel / rocker) y quillas con perfil curvo sin alterar la
|
||
# cuadrícula de líneas de agua que permanece compartida.
|
||
# Default: cero en todas las estaciones (quilla plana sobre baseline).
|
||
keel_z: np.ndarray = field(default_factory=lambda: np.array([]))
|
||
# Desviación vertical per-nodo [m]. shape (n_sta, n_wl).
|
||
# La Z efectiva del nodo (i, j) = z_waterlines[j] + z_offsets[i, j].
|
||
# Default: ceros → todos los nodos en los planos horizontales de referencia.
|
||
z_offsets: np.ndarray = field(default_factory=lambda: np.zeros((0, 0)))
|
||
# Desviación longitudinal per-nodo [m]. shape (n_sta, n_wl).
|
||
# La X efectiva del nodo (i, j) = x_stations[i] + x_offsets[i, j].
|
||
# x_stations es la referencia paramétrica FIJA; nunca se modifica en drag.
|
||
x_offsets: np.ndarray = field(default_factory=lambda: np.zeros((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)
|
||
self.keel_z = np.asarray(self.keel_z, dtype=float)
|
||
self.z_offsets = np.asarray(self.z_offsets, dtype=float)
|
||
self.x_offsets = np.asarray(self.x_offsets, 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)]
|
||
# Inicializar keel_z si no se proporcionó o tiene dimensión incorrecta
|
||
if self.keel_z.shape != (n_sta,):
|
||
self.keel_z = np.zeros(n_sta)
|
||
# Inicializar z_offsets si no se proporcionó o tiene dimensiones incorrectas
|
||
if self.z_offsets.shape != (n_sta, n_wl):
|
||
self.z_offsets = np.zeros((n_sta, n_wl))
|
||
# Inicializar x_offsets si no se proporcionó o tiene dimensiones incorrectas
|
||
if self.x_offsets.shape != (n_sta, n_wl):
|
||
self.x_offsets = np.zeros((n_sta, n_wl))
|
||
|
||
# ------------------------------------------------------------------
|
||
# 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 + self.z_offsets[i, :]).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]."""
|
||
# Para cada estación: interpola la semi-manga a la altura z usando las
|
||
# z efectivas per-nodo (z_waterlines[j] + z_offsets[i, j]).
|
||
col_y = np.array([
|
||
float(np.interp(z, self.z_waterlines + self.z_offsets[i, :], self.data[i, :]))
|
||
for i in range(len(self.x_stations))
|
||
])
|
||
# Interpolar el resultado en x
|
||
return float(np.interp(x, self.x_stations, 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})"
|
||
)
|