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
+30 -1
View File
@@ -1 +1,30 @@
# arshipdesign/hydrostatics
"""
arshipdesign.hydrostatics — motor de hidrostáticos navales.
Módulos
-------
integrator Primitivas numéricas (Simpson/trapecios).
upright UprightHydrostatics (calado único) + compute_upright().
curves_of_form HydrostaticCurves (barrido de calados).
Uso rápido
----------
>>> from arshipdesign.hydrostatics import HydrostaticCurves
>>> curves = HydrostaticCurves.compute(hull, n_points=20)
>>> print(curves.displacements)
"""
from arshipdesign.hydrostatics.upright import (
UprightHydrostatics,
compute_upright,
)
from arshipdesign.hydrostatics.curves_of_form import (
HydrostaticCurves,
CSV_HEADERS,
)
__all__ = [
"UprightHydrostatics",
"compute_upright",
"HydrostaticCurves",
"CSV_HEADERS",
]
+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
],
}
+95 -2
View File
@@ -1,2 +1,95 @@
"""Integradores Simpson. Stub — Sprint 2."""
raise NotImplementedError("integrator — Sprint 2")
"""
Integradores numéricos para hidrostáticos navales.
Regla de Simpson con fallback a trapecios cuando hay menos de 3 puntos.
Conforme con IACS Rec.34 §4.2 — métodos de integración numérica.
Autor: Álvaro Romero
Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
import numpy as np
from scipy.integrate import simpson as _scipy_simpson
# ---------------------------------------------------------------------------
# Integración 1D
# ---------------------------------------------------------------------------
def integrate(y: np.ndarray, x: np.ndarray) -> float:
"""Integra y(x) usando la regla de Simpson (fallback a trapecios ≤ 2 pts).
Parameters
----------
y : array_like, shape (n,)
Ordenadas.
x : array_like, shape (n,)
Abscisas, monótonamente crecientes.
Returns
-------
float
∫ y dx
"""
y = np.asarray(y, dtype=float)
x = np.asarray(x, dtype=float)
n = len(x)
if n < 2:
return 0.0
if n >= 3:
return float(_scipy_simpson(y, x=x))
return float(np.trapz(y, x))
# ---------------------------------------------------------------------------
# Primitivas para plano de flotación y secciones
# ---------------------------------------------------------------------------
def waterplane_strips(offsets_table, draft: float) -> tuple[np.ndarray, np.ndarray]:
"""Devuelve (x_stations, y_half_breadths) en el calado *draft*.
Parameters
----------
offsets_table : OffsetsTable
Tabla de offsets del casco.
draft : float
Calado al que se evalúa el plano de flotación [m].
Returns
-------
x : np.ndarray, shape (n_sta,)
Posiciones longitudinales de las estaciones [m].
y : np.ndarray, shape (n_sta,)
Semi-mangas en el plano draft [m].
"""
x = offsets_table.x_stations
y = np.array([offsets_table.half_breadth(xi, float(draft)) for xi in x])
return x, y
def section_areas_and_centroids(
sections: list, draft: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Devuelve (x, areas, centroides_z) para todas las secciones al calado *draft*.
Parameters
----------
sections : list[Section]
Lista de secciones del casco.
draft : float
Calado de cálculo [m].
Returns
-------
x : np.ndarray, shape (n_sec,)
Posiciones longitudinales [m].
areas : np.ndarray, shape (n_sec,)
Áreas sumergidas [m²].
cz : np.ndarray, shape (n_sec,)
Centroides verticales de cada sección [m desde quilla].
"""
x = np.array([s.x for s in sections])
a = np.array([s.area(draft=draft) for s in sections])
cz = np.array([s.centroid_z(draft=draft) for s in sections])
return x, a, cz
+227 -2
View File
@@ -1,2 +1,227 @@
"""Hidrostáticos vertical. Stub — Sprint 2."""
raise NotImplementedError("upright — Sprint 2")
"""
UprightHydrostatics — hidrostáticos en condición vertical (quilla recta).
Calcula el conjunto completo de variables hidrostáticas para un calado
dado: volumen, desplazamiento, plano de flotación, metacentros, TPC, MCT
y los cuatro coeficientes de forma.
Conforme con IACS Rec.34 §4.3 — verificación analítica y §6 — trazabilidad.
Autor: Álvaro Romero
Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from arshipdesign.hydrostatics.integrator import (
integrate,
waterplane_strips,
section_areas_and_centroids,
)
# ---------------------------------------------------------------------------
# Dataclass de resultado
# ---------------------------------------------------------------------------
@dataclass
class UprightHydrostatics:
"""Conjunto completo de hidrostáticos upright a un calado dado.
Todos los valores referidos a:
- Origen longitudinal: AP (x = 0)
- Origen vertical: quilla (z = 0)
- Plano de crujía: eje de simetría
Atributos
---------
draft : float
Calado T [m].
volume : float
Volumen de desplazamiento V [m³].
displacement : float
Desplazamiento Δ [t] (agua salada ρ = 1025 kg/m³ por defecto).
awp : float
Área del plano de flotación Awp [m²].
lcb : float
Centro longitudinal de carena desde AP LCB [m].
lcf : float
Centro longitudinal de flotación desde AP LCF [m].
kb : float
Altura del centro de carena sobre la quilla KB [m].
it : float
Segundo momento transversal del plano de flotación IT [m⁴].
il : float
Segundo momento longitudinal del plano de flotación IL [m⁴].
bmt : float
Radio metacéntrico transversal BM_T = IT / V [m].
bml : float
Radio metacéntrico longitudinal BM_L = IL / V [m].
kmt : float
Altura del metacentro transversal KM_T = KB + BM_T [m].
kml : float
Altura del metacentro longitudinal KM_L = KB + BM_L [m].
tpc : float
Toneladas por centímetro de inmersión TPC [t/cm].
mct : float
Momento para cambiar asiento 1 cm MCT [t·m/cm].
cb : float
Coeficiente de bloque Cb [-].
cw : float
Coeficiente de plano de flotación Cw [-].
cm : float
Coeficiente de cuaderna maestra Cm [-].
cp : float
Coeficiente prismático Cp [-].
"""
draft: float
volume: float
displacement: float
awp: float
lcb: float
lcf: float
kb: float
it: float
il: float
bmt: float
bml: float
kmt: float
kml: float
tpc: float
mct: float
cb: float
cw: float
cm: float
cp: float
# ---------------------------------------------------------------------------
# Función de cálculo
# ---------------------------------------------------------------------------
def compute_upright(
hull,
draft: float,
rho: float = 1025.0,
kg: float | None = None,
) -> UprightHydrostatics:
"""Calcula todos los hidrostáticos upright para *hull* al calado *draft*.
El cálculo se realiza en una sola pasada sobre las secciones, reutilizando
los arrays intermedios para evitar redundancia.
Parameters
----------
hull : Hull
Casco de referencia (objeto ``arshipdesign.core.hull.Hull``).
draft : float
Calado de cálculo T [m]. Si T ≤ 0, retorna ceros.
rho : float
Densidad del agua [kg/m³]. Default 1025 (agua salada).
kg : float | None
Altura del centro de gravedad KG [m]. Si None se estima
como ``hull.depth × 0.55`` (buque en rosca, conservador).
Returns
-------
UprightHydrostatics
Todos los hidrostáticos al calado *draft*.
"""
T = float(draft)
# Caso degenerado (calado nulo o negativo)
if T <= 1e-6:
return _zero_hydrostatics(T)
# ----------------------------------------------------------------
# 1. Secciones transversales → volumen, LCB, KB
# ----------------------------------------------------------------
sections = hull.offsets.to_sections()
x_s, areas, cz = section_areas_and_centroids(sections, T)
vol = abs(integrate(areas, x_s))
delta = vol * rho / 1000.0
if vol > 1e-12:
lcb = integrate(areas * x_s, x_s) / vol
kb = integrate(areas * cz, x_s) / vol
else:
lcb = hull.lpp / 2.0
kb = T / 2.0
# ----------------------------------------------------------------
# 2. Plano de flotación → Awp, LCF, IT, IL
# ----------------------------------------------------------------
x_wl, y_wl = waterplane_strips(hull.offsets, T)
strip = 2.0 * y_wl # ancho total a cada x
awp = abs(integrate(strip, x_wl))
if awp > 1e-12:
lcf = integrate(strip * x_wl, x_wl) / awp
else:
lcf = hull.lpp / 2.0
# IT = (2/3) · ∫ y³ dx (Rawson & Tupper §3.2)
it = abs(integrate((2.0 / 3.0) * y_wl ** 3, x_wl))
# IL = ∫ 2y · (x LCF)² dx
il = abs(integrate(strip * (x_wl - lcf) ** 2, x_wl))
# ----------------------------------------------------------------
# 3. Radios metacéntricos
# ----------------------------------------------------------------
bmt = it / vol if vol > 1e-12 else 0.0
bml = il / vol if vol > 1e-12 else 0.0
kmt = kb + bmt
kml = kb + bml
# ----------------------------------------------------------------
# 4. TPC y MCT
# ----------------------------------------------------------------
tpc = awp * rho / 100_000.0
kg_val = hull.depth * 0.55 if kg is None else float(kg)
gml = max(kb + bml - kg_val, 0.0)
mct = delta * gml / (100.0 * hull.lpp) if hull.lpp > 1e-12 else 0.0
# ----------------------------------------------------------------
# 5. Coeficientes de forma
# ----------------------------------------------------------------
cb = vol / (hull.lpp * hull.beam * T) if (hull.lpp * hull.beam * T) > 1e-12 else 0.0
cw = awp / (hull.lpp * hull.beam) if (hull.lpp * hull.beam) > 1e-12 else 0.0
# Área de cuaderna maestra: interpolar en x_mid
x_mid = hull.lpp / 2.0
am = float(np.interp(x_mid, x_s, areas))
cm = am / (hull.beam * T) if (hull.beam * T) > 1e-12 else 0.0
cp = vol / (am * hull.lpp) if (am * hull.lpp) > 1e-12 else 0.0
return UprightHydrostatics(
draft=T, volume=vol, displacement=delta,
awp=awp, lcb=lcb, lcf=lcf, kb=kb,
it=it, il=il,
bmt=bmt, bml=bml, kmt=kmt, kml=kml,
tpc=tpc, mct=mct,
cb=cb, cw=cw, cm=cm, cp=cp,
)
# ---------------------------------------------------------------------------
# Auxiliar privado
# ---------------------------------------------------------------------------
def _zero_hydrostatics(draft: float) -> UprightHydrostatics:
"""Devuelve un UprightHydrostatics con todos los valores en cero."""
return UprightHydrostatics(
draft=draft, volume=0.0, displacement=0.0,
awp=0.0, lcb=0.0, lcf=0.0, kb=0.0,
it=0.0, il=0.0,
bmt=0.0, bml=0.0, kmt=0.0, kml=0.0,
tpc=0.0, mct=0.0,
cb=0.0, cw=0.0, cm=0.0, cp=0.0,
)