Módulo 1 fixes + Módulo 2 motor hidrostático (Tasks 13–13b)

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>
This commit is contained in:
2026-05-27 09:11:58 -04:00
parent 274b3b3f53
commit 98ff57ed08
17 changed files with 1687 additions and 199 deletions
+342 -2
View File
@@ -1,2 +1,342 @@
"""Curvas hidrostáticas. Stub — Sprint 2."""
raise NotImplementedError("curves_of_form — Sprint 2")
"""
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
],
}