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