98ff57ed08
Fixes Module 1 UI: - wizard_cruiser/sailing/planing: perfiles sin^n calibrados por Cm, V-bottom con ángulo de astilla, corrección zona sobre chine planeador - viewer_3d: buffer hull pendiente para eliminar race condition 500ms - viewer_lines: reescritura completa — waterlines visibles, control points interactivos (drag DelftShip-style), señal offsets_edited - main_window: conecta offsets_edited → slot _on_offsets_edited_from_viewer que propaga cambios a todos los visores, editor, 3D y barra hidrostática Módulo 2 — motor HydrostaticCurves (Task 13): - integrator.py: integrate() (Simpson+trapz), waterplane_strips(), section_areas() - upright.py: UprightHydrostatics (19 campos), compute_upright() single-pass - curves_of_form.py: HydrostaticCurves.compute(), at_draft(), to_csv_lines(), to_dict() - tests/test_module2_hydrostatics.py: 83 tests — Wigley V&V, monotonicidad, CSV export, IACS Rec.34 §4.3–4.5; todos los 224 tests pasan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""
|
||
HydrostaticCurves — curvas de formas hidrostáticas.
|
||
|
||
Evalúa UprightHydrostatics en *n_points* calados equidistantes entre
|
||
el 10 % del calado de diseño y el propio calado de diseño, produciendo
|
||
las curvas clásicas usadas en arquitectura naval.
|
||
|
||
Conforme con IACS Rec.34 §4 — verificación y §6 — trazabilidad.
|
||
|
||
Autor: Álvaro Romero
|
||
Módulo 2 — AR-ShipDesign
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Iterator
|
||
|
||
import numpy as np
|
||
|
||
from arshipdesign.hydrostatics.upright import UprightHydrostatics, compute_upright
|
||
|
||
|
||
# Encabezados de exportación CSV (orden canónico)
|
||
CSV_HEADERS: tuple[str, ...] = (
|
||
"T[m]", "V[m3]", "Delta[t]", "Awp[m2]",
|
||
"LCB[m]", "LCF[m]", "KB[m]",
|
||
"IT[m4]", "IL[m4]",
|
||
"BMT[m]", "BML[m]", "KMT[m]", "KML[m]",
|
||
"TPC[t/cm]", "MCT[tm/cm]",
|
||
"Cb", "Cw", "Cm", "Cp",
|
||
)
|
||
|
||
# Mapa campo → atributo de UprightHydrostatics (mismo orden que CSV_HEADERS)
|
||
_FIELD_ATTRS: tuple[str, ...] = (
|
||
"draft", "volume", "displacement", "awp",
|
||
"lcb", "lcf", "kb",
|
||
"it", "il",
|
||
"bmt", "bml", "kmt", "kml",
|
||
"tpc", "mct",
|
||
"cb", "cw", "cm", "cp",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dataclass principal
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class HydrostaticCurves:
|
||
"""Curvas de formas hidrostáticas de un casco naval.
|
||
|
||
Se construye con el método de clase :meth:`compute`. Una vez creado,
|
||
los puntos individuales se acceden con índice (``curves[i]``) o
|
||
iteración, y los arrays vectorizados con propiedades nombradas
|
||
(``curves.drafts``, ``curves.displacements``, etc.).
|
||
|
||
Atributos
|
||
---------
|
||
hull_name : str
|
||
Nombre del casco.
|
||
lpp : float
|
||
Eslora entre perpendiculares [m].
|
||
beam : float
|
||
Manga máxima [m].
|
||
design_draft : float
|
||
Calado de diseño [m].
|
||
rho : float
|
||
Densidad del agua usada [kg/m³].
|
||
points : list[UprightHydrostatics]
|
||
Puntos de la curva, ordenados por calado creciente.
|
||
"""
|
||
|
||
hull_name: str
|
||
lpp: float
|
||
beam: float
|
||
design_draft: float
|
||
rho: float
|
||
points: list[UprightHydrostatics] = field(default_factory=list)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Constructor principal
|
||
# ------------------------------------------------------------------
|
||
|
||
@classmethod
|
||
def compute(
|
||
cls,
|
||
hull,
|
||
n_points: int = 20,
|
||
rho: float = 1025.0,
|
||
kg: float | None = None,
|
||
t_min_fraction: float = 0.10,
|
||
) -> "HydrostaticCurves":
|
||
"""Calcula las curvas hidrostáticas completas para *hull*.
|
||
|
||
Parameters
|
||
----------
|
||
hull : Hull
|
||
Casco de referencia (``arshipdesign.core.hull.Hull``).
|
||
n_points : int
|
||
Número de calados a evaluar. Mínimo 5.
|
||
rho : float
|
||
Densidad del agua [kg/m³]. Default 1025 (agua salada).
|
||
kg : float | None
|
||
KG estimado [m]. Si None se usa ``hull.depth × 0.55``.
|
||
t_min_fraction : float
|
||
Fracción del calado de diseño para el calado mínimo de cálculo.
|
||
Default 0.10 (10 %). Se aplica un piso de 1 mm.
|
||
|
||
Returns
|
||
-------
|
||
HydrostaticCurves
|
||
"""
|
||
n_points = max(5, int(n_points))
|
||
T_min = max(hull.draft * float(t_min_fraction), 1e-3)
|
||
T_max = float(hull.draft)
|
||
drafts = np.linspace(T_min, T_max, n_points)
|
||
|
||
points = [
|
||
compute_upright(hull, float(T), rho=rho, kg=kg)
|
||
for T in drafts
|
||
]
|
||
|
||
return cls(
|
||
hull_name = str(hull.name),
|
||
lpp = float(hull.lpp),
|
||
beam = float(hull.beam),
|
||
design_draft = T_max,
|
||
rho = float(rho),
|
||
points = points,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Acceso individual y por iteración
|
||
# ------------------------------------------------------------------
|
||
|
||
def __len__(self) -> int:
|
||
return len(self.points)
|
||
|
||
def __getitem__(self, idx: int) -> UprightHydrostatics:
|
||
return self.points[idx]
|
||
|
||
def __iter__(self) -> Iterator[UprightHydrostatics]:
|
||
return iter(self.points)
|
||
|
||
def __repr__(self) -> str:
|
||
n = len(self.points)
|
||
if n:
|
||
T_range = f"{self.points[0].draft:.3f}–{self.points[-1].draft:.3f} m"
|
||
else:
|
||
T_range = "vacío"
|
||
return f"HydrostaticCurves({self.hull_name!r}, n={n}, T={T_range})"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Arrays vectorizados (numpy)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _col(self, attr: str) -> np.ndarray:
|
||
"""Extrae un atributo de todos los puntos como array numpy."""
|
||
return np.array([getattr(p, attr) for p in self.points])
|
||
|
||
@property
|
||
def drafts(self) -> np.ndarray:
|
||
"""Calados T [m]."""
|
||
return self._col("draft")
|
||
|
||
@property
|
||
def volumes(self) -> np.ndarray:
|
||
"""Volúmenes de desplazamiento V [m³]."""
|
||
return self._col("volume")
|
||
|
||
@property
|
||
def displacements(self) -> np.ndarray:
|
||
"""Desplazamientos Δ [t]."""
|
||
return self._col("displacement")
|
||
|
||
@property
|
||
def awp_values(self) -> np.ndarray:
|
||
"""Áreas de flotación Awp [m²]."""
|
||
return self._col("awp")
|
||
|
||
@property
|
||
def lcb_values(self) -> np.ndarray:
|
||
"""LCB desde AP [m]."""
|
||
return self._col("lcb")
|
||
|
||
@property
|
||
def lcf_values(self) -> np.ndarray:
|
||
"""LCF desde AP [m]."""
|
||
return self._col("lcf")
|
||
|
||
@property
|
||
def kb_values(self) -> np.ndarray:
|
||
"""KB sobre la quilla [m]."""
|
||
return self._col("kb")
|
||
|
||
@property
|
||
def bmt_values(self) -> np.ndarray:
|
||
"""Radios metacéntricos transversales BM_T [m]."""
|
||
return self._col("bmt")
|
||
|
||
@property
|
||
def bml_values(self) -> np.ndarray:
|
||
"""Radios metacéntricos longitudinales BM_L [m]."""
|
||
return self._col("bml")
|
||
|
||
@property
|
||
def kmt_values(self) -> np.ndarray:
|
||
"""Alturas metacéntricas transversales KM_T [m]."""
|
||
return self._col("kmt")
|
||
|
||
@property
|
||
def kml_values(self) -> np.ndarray:
|
||
"""Alturas metacéntricas longitudinales KM_L [m]."""
|
||
return self._col("kml")
|
||
|
||
@property
|
||
def tpc_values(self) -> np.ndarray:
|
||
"""TPC [t/cm]."""
|
||
return self._col("tpc")
|
||
|
||
@property
|
||
def mct_values(self) -> np.ndarray:
|
||
"""MCT [t·m/cm]."""
|
||
return self._col("mct")
|
||
|
||
@property
|
||
def cb_values(self) -> np.ndarray:
|
||
"""Coeficientes de bloque Cb [-]."""
|
||
return self._col("cb")
|
||
|
||
@property
|
||
def cw_values(self) -> np.ndarray:
|
||
"""Coeficientes de flotación Cw [-]."""
|
||
return self._col("cw")
|
||
|
||
@property
|
||
def cm_values(self) -> np.ndarray:
|
||
"""Coeficientes de cuaderna maestra Cm [-]."""
|
||
return self._col("cm")
|
||
|
||
@property
|
||
def cp_values(self) -> np.ndarray:
|
||
"""Coeficientes prismáticos Cp [-]."""
|
||
return self._col("cp")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Interpolación a calado arbitrario
|
||
# ------------------------------------------------------------------
|
||
|
||
def at_draft(self, T: float) -> UprightHydrostatics:
|
||
"""Interpola linealmente todos los hidrostáticos al calado *T*.
|
||
|
||
Parameters
|
||
----------
|
||
T : float
|
||
Calado de consulta [m]. Se clampea al rango [T_min, T_design].
|
||
|
||
Returns
|
||
-------
|
||
UprightHydrostatics
|
||
Valores interpolados en *T*.
|
||
"""
|
||
drafts_arr = self.drafts
|
||
T_clamped = float(np.clip(T, drafts_arr[0], drafts_arr[-1]))
|
||
|
||
kwargs: dict[str, float] = {"draft": T_clamped}
|
||
for attr in _FIELD_ATTRS:
|
||
if attr == "draft":
|
||
continue
|
||
col = self._col(attr)
|
||
kwargs[attr] = float(np.interp(T_clamped, drafts_arr, col))
|
||
|
||
return UprightHydrostatics(**kwargs) # type: ignore[arg-type]
|
||
|
||
# ------------------------------------------------------------------
|
||
# Exportación
|
||
# ------------------------------------------------------------------
|
||
|
||
def to_csv_lines(self, sep: str = ",", decimal: str = ".") -> list[str]:
|
||
"""Genera las líneas CSV (encabezado + datos).
|
||
|
||
No requiere pandas.
|
||
|
||
Parameters
|
||
----------
|
||
sep : str
|
||
Separador de campos. Default ``','``.
|
||
decimal : str
|
||
Separador decimal. Default ``'.'``.
|
||
|
||
Returns
|
||
-------
|
||
list[str]
|
||
Lista de cadenas, cada una es una línea CSV sin ``\\n`` final.
|
||
"""
|
||
lines = [sep.join(CSV_HEADERS)]
|
||
for p in self.points:
|
||
row = [
|
||
f"{p.draft:.4f}", f"{p.volume:.4f}",
|
||
f"{p.displacement:.4f}", f"{p.awp:.4f}",
|
||
f"{p.lcb:.4f}", f"{p.lcf:.4f}",
|
||
f"{p.kb:.4f}", f"{p.it:.4f}",
|
||
f"{p.il:.4f}", f"{p.bmt:.4f}",
|
||
f"{p.bml:.4f}", f"{p.kmt:.4f}",
|
||
f"{p.kml:.4f}", f"{p.tpc:.4f}",
|
||
f"{p.mct:.4f}", f"{p.cb:.4f}",
|
||
f"{p.cw:.4f}", f"{p.cm:.4f}",
|
||
f"{p.cp:.4f}",
|
||
]
|
||
if decimal != ".":
|
||
row = [v.replace(".", decimal) for v in row]
|
||
lines.append(sep.join(row))
|
||
return lines
|
||
|
||
def to_dict(self) -> dict:
|
||
"""Serializa a dict JSON-serializable.
|
||
|
||
Útil para almacenar resultados en el archivo ``.arsd``.
|
||
"""
|
||
return {
|
||
"hull_name": self.hull_name,
|
||
"lpp": self.lpp,
|
||
"beam": self.beam,
|
||
"design_draft": self.design_draft,
|
||
"rho": self.rho,
|
||
"headers": list(CSV_HEADERS),
|
||
"points": [
|
||
{
|
||
"T": p.draft, "V": p.volume,
|
||
"Delta": p.displacement, "Awp": p.awp,
|
||
"LCB": p.lcb, "LCF": p.lcf,
|
||
"KB": p.kb, "IT": p.it,
|
||
"IL": p.il, "BMT": p.bmt,
|
||
"BML": p.bml, "KMT": p.kmt,
|
||
"KML": p.kml, "TPC": p.tpc,
|
||
"MCT": p.mct, "Cb": p.cb,
|
||
"Cw": p.cw, "Cm": p.cm,
|
||
"Cp": p.cp,
|
||
}
|
||
for p in self.points
|
||
],
|
||
}
|