""" 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})" )