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
+2 -2
View File
@@ -1,9 +1,9 @@
"""
AR-ShipDesign — Software profesional de diseño naval.
Copyright (c) 2025 Álvaro Rodríguez. Todos los derechos reservados.
Copyright (c) 2025 Álvaro Romero. Todos los derechos reservados.
"""
__version__ = "0.1.0"
__author__ = "Álvaro Rodríguez"
__author__ = "Álvaro Romero"
__license__ = "Propietario"
+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,
)
+40 -13
View File
@@ -15,12 +15,45 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import math
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
# ---------------------------------------------------------------------------
# Forma de sección — carena redonda tipo desplazamiento
# ---------------------------------------------------------------------------
def _round_bilge_section(z: float, T: float, y_wl: float, Cm: float) -> float:
"""
Sección de carena redonda parametrizada por Cm.
Usa la forma y = y_wl · sin(π/2 · (z/T)ⁿ) donde n se obtiene de Cm.
- n < 1 → sección llena (Cm alto), convexa hacia afuera
- n = 1 → arco de círculo (Cm ≈ 0.637)
- n > 1 → sección en V (Cm bajo), convexa hacia el eje
Propiedades garantizadas:
y(z=0) = 0 (quilla puntual)
y(z=T) = y_wl (manga en flotación)
dy/dz ≥ 0 (monótona, sin inflexión indeseada)
"""
if y_wl < 1e-9 or T < 1e-9:
return 0.0
t = min(1.0, max(0.0, z / T))
if t < 1e-12:
return 0.0
# Tabla calibrada Cm → n (verificada contra integración numérica)
_CM = [0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.94]
_N = [2.20, 1.60, 1.15, 0.80, 0.58, 0.42, 0.28]
n = float(np.interp(Cm, _CM, _N))
return float(y_wl * math.sin(math.pi / 2.0 * t ** n))
def make_displacement_hull(
name: str = "Crucero de Desplazamiento",
lpp: float = 12.0,
@@ -53,26 +86,20 @@ def make_displacement_hull(
lcb_shift = 2.0 * (lcb_frac - 0.5) # ∈ [1, 1]
f_plan = _displacement_plan_form(xi, cb, lcb_shift)
# ── Exponente de forma de sección ──────────────────────────────────
# alpha controla la plenitud de la sección transversal
# alpha pequeño → sección llena (Cm alto)
# alpha = 2*(1 - Cm) según aproximación de Munro-Smith
alpha_mid = max(0.25, 2.0 * (1.0 - cm)) # ≈ 0.28 para Cm=0.86
data = np.zeros((n_stations, n_waterlines))
for i in range(n_stations):
y_wl = (beam / 2.0) * f_plan[i]
# El exponente de sección varía: más fino en proa/popa
local_fullness = f_plan[i]
# En extremos (local_fullness→0) el exponente sube → sección más en V
alpha = alpha_mid + (1.0 - alpha_mid) * (1.0 - local_fullness ** 0.5)
alpha = np.clip(alpha, alpha_mid, 0.80)
# Cm varía a lo largo de la eslora: lleno en midship, más en V en extremos.
# f_plan[i]=1 → midship → Cm=cm; f_plan[i]→0 → extremos → Cm≈0.52
local_cm = float(np.clip(
cm * (0.42 + 0.58 * f_plan[i] ** 0.40),
0.52, cm
))
for j, z in enumerate(z_wl):
v = z / draft # ∈ [0, 1]
data[i, j] = y_wl * (v ** alpha)
data[i, j] = _round_bilge_section(z, draft, y_wl, local_cm)
data = np.clip(data, 0.0, None)
+5 -3
View File
@@ -89,9 +89,11 @@ def make_planing_hull(
tan_dr = max(np.tan(dr), 0.01)
y_chine_dr = z_c / tan_dr
y_chine = min(y_chine_dr, y_max)
# Ligero ensanchamiento por encima del chine
y = y_chine + (y_max - y_chine) * flare * (z - z_c) / (draft - z_c + 1e-9)
y = min(y, y_max)
# Costado recto (hard-chine) desde y_chine hasta y_max en cubierta.
# El parámetro flare añade ensanche adicional sobre y_max.
t_side = (z - z_c) / (draft - z_c + 1e-9)
y = y_chine + (y_max * (1.0 + flare) - y_chine) * t_side
y = min(y, y_max * (1.0 + flare))
data[i, j] = max(0.0, y)
+56 -11
View File
@@ -16,12 +16,59 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
"""
from __future__ import annotations
import math
import numpy as np
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
# ---------------------------------------------------------------------------
# Forma de sección — velero (V-fondo + cuerpo redondeado)
# ---------------------------------------------------------------------------
def _sailing_section(
z: float, T: float, y_wl: float, Cm: float, deadrise_deg: float
) -> float:
"""
Sección de velero con V-fondo y cuerpo redondeado.
Dos zonas:
- [0, z_b]: Fondo en V recto definido por el ángulo de astilla muerta.
- [z_b, T]: Cuerpo redondeado (sin^n) desde el pantoque hasta la flotación.
Propiedades:
y(z=0) = 0 (quilla)
y(z=T) = y_wl (manga en flotación)
Continuidad C⁰ en z=z_b
"""
if y_wl < 1e-9 or T < 1e-9:
return 0.0
t_full = min(1.0, max(0.0, z / T))
if t_full < 1e-12:
return 0.0
# Altura del pantoque (bilge): 40 % del calado
z_b = 0.40 * T
# Semi-manga en el pantoque desde el ángulo de astilla
# y_b_v = z_b / tan(deadrise) → capped al 65% de y_wl
dr_rad = math.radians(max(5.0, min(deadrise_deg, 80.0)))
y_b_v = z_b / math.tan(dr_rad)
y_b = min(y_b_v, 0.65 * y_wl)
if z <= z_b:
# Fondo en V: lineal desde (0,0) hasta (z_b, y_b)
return y_b * z / z_b if z_b > 1e-9 else 0.0
else:
# Cuerpo redondeado desde (z_b, y_b) hasta (T, y_wl)
t = (z - z_b) / (T - z_b)
_CM = [0.42, 0.55, 0.65, 0.75, 0.83]
_N = [2.50, 1.70, 1.20, 0.82, 0.55]
n = float(np.interp(Cm, _CM, _N))
return float(y_b + (y_wl - y_b) * math.sin(math.pi / 2.0 * t ** n))
def make_sailing_hull(
name: str = "Velero Monocasco",
lpp: float = 10.0,
@@ -59,24 +106,22 @@ def make_sailing_hull(
# Plan form: fina en proa, moderadamente llena a popa
f_plan = _sailing_plan_form(xi, cb, lcb_shift)
# Exponente de sección
alpha_mid = max(0.30, 2.0 * (1.0 - cm)) # ≈ 0.50 para Cm=0.75
data = np.zeros((n_stations, n_waterlines))
dr_mid_rad = np.radians(deadrise_mid)
for i in range(n_stations):
y_wl = (beam / 2.0) * f_plan[i]
# Interpolar entre sección en V (extremos) y sección redonda (midship)
local_f = f_plan[i]
alpha = alpha_mid + (0.75 - alpha_mid) * (1.0 - local_f ** 0.7)
alpha = np.clip(alpha, alpha_mid, 0.80)
# Cm y deadrise varían a lo largo de la eslora.
# Midship: Cm=cm, deadrise=deadrise_mid
# Extremos: más en V (menor Cm, mayor deadrise)
local_cm = float(np.clip(
cm * (0.38 + 0.62 * f_plan[i] ** 0.45),
0.42, cm
))
local_dr = deadrise_mid + (60.0 - deadrise_mid) * max(0.0, 1.0 - f_plan[i] ** 0.5)
for j, z in enumerate(z_wl):
v = z / draft
y = y_wl * (v ** alpha)
data[i, j] = max(0.0, y)
data[i, j] = max(0.0, _sailing_section(z, draft, y_wl, local_cm, local_dr))
offsets = OffsetsTable(
x_stations=x_sta,
+1 -1
View File
@@ -69,7 +69,7 @@
"type_workboat": "Workboat",
"about_title": "About AR-ShipDesign",
"about_version": "Version",
"about_copyright": "Copyright © 2025 Álvaro Rodríguez. All rights reserved.",
"about_copyright": "Copyright © 2025 Álvaro Romero. All rights reserved.",
"tooltip_kmt": "KMT = KB + IT/∇ (transverse metacentric height)",
"tooltip_gmt": "GMT = KMT KG (corrected metacentric height)",
"tooltip_tpc": "TPC = Aw · ρ / 100 (tonnes per cm immersion)",
+1 -1
View File
@@ -69,7 +69,7 @@
"type_workboat": "Workboat / Embarcación de trabajo",
"about_title": "Acerca de AR-ShipDesign",
"about_version": "Versión",
"about_copyright": "Copyright © 2025 Álvaro Rodríguez",
"about_copyright": "Copyright © 2025 Álvaro Romero",
"tooltip_kmt": "KMT = KB + IT/∇ (altura metacéntrica transversal)",
"tooltip_gmt": "GMT = KMT KG (altura metacéntrica corregida)",
"tooltip_tpc": "TPC = Aw · ρ / 100 (toneladas por cm de inmersión)",
+34 -2
View File
@@ -860,6 +860,10 @@ class MainWindow(QMainWindow):
if _vp is not None:
_vp.set_canvas(_widget)
# Conectar edición interactiva de control points → propagar a todos los visores
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
self._offsets_editor = OffsetsEditor()
@@ -1316,11 +1320,39 @@ class MainWindow(QMainWindow):
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
self._current_hull = hull
if self._project is not None:
self._project.set_hull(hull) # mantener proyecto sincronizado
# _skip_offsets_editor=True para no re-poblar la tabla (ya esta actualizada)
self._project.set_hull(hull)
self._load_hull_viewers(hull, _skip_offsets_editor=True)
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
def _on_offsets_edited_from_viewer(self, offsets_table) -> None:
"""Slot: un visor 2D editó un punto de control — sincronizar todos los visores.
La OffsetsTable ya fue modificada in-place por el visor (durante el drag).
Aquí propagamos el cambio al visor 3D, al panel de hidrostáticos y al
editor de offsets, e informamos al proyecto del estado nuevo.
"""
hull = self._current_hull
if hull is None:
return
# hull.offsets ya contiene los cambios (modificación in-place del visor)
if self._project is not None:
self._project.set_hull(hull)
# Refrescar la vista cruzada (edición body plan actualiza planta y viceversa)
self._viewer_bodyplan.set_hull(hull)
self._viewer_profile.set_hull(hull)
self._viewer_plan.set_hull(hull)
# Sincronizar editor de tabla de offsets
self._offsets_editor.set_hull(hull)
# Actualizar visor 3D con la geometría nueva
if self._viewer_3d is not None:
try:
self._viewer_3d.load_hull(hull)
except Exception as exc:
logger.warning("Error al actualizar visor 3D: %s", exc)
# Actualizar barra de hidrostáticos
self._update_hydrostatics(hull)
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
def _update_hydrostatics(self, hull) -> None:
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
+12 -3
View File
@@ -54,6 +54,7 @@ class Viewer3DWidget(QWidget):
super().__init__(parent)
self._plotter: Optional["QtInteractor"] = None
self._ready = False
self._pending_hull = None # hull recibido antes de que el plotter esté listo
self._build_ui()
# ------------------------------------------------------------------
@@ -105,8 +106,13 @@ class Viewer3DWidget(QWidget):
# Configurar tema dark para que combine con la UI
self._plotter.set_background("#1a1d30") # viewportCanvas color
# Cargar casco Wigley como geometría de bienvenida
self._load_default_wigley()
# Cargar casco pendiente (recibido antes del init) o Wigley por defecto
if self._pending_hull is not None:
mesh = self._pending_hull.to_mesh()
self._render_hull_mesh(mesh)
self._pending_hull = None
else:
self._load_default_wigley()
self._ready = True
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
@@ -136,12 +142,15 @@ class Viewer3DWidget(QWidget):
def load_hull(self, hull) -> None:
"""Carga un objeto Hull en el visor.
Si el plotter aún no ha terminado de inicializarse (race condition de 500 ms),
guarda el hull como pendiente se cargará al final de _init_plotter().
Parámetros
----------
hull : arshipdesign.core.hull.Hull
"""
if not self._ready or self._plotter is None:
logger.warning("Viewer3DWidget no listo — hull no cargado")
self._pending_hull = hull # se cargará cuando _init_plotter termine
return
try:
mesh = hull.to_mesh()
+305 -153
View File
@@ -1,13 +1,17 @@
"""
Visores 2D del plano de líneas del casco.
Visores 2D del plano de líneas del casco con edición interactiva.
Tres widgets especializados basados en QPainter:
BodyPlanViewer secciones transversales (body plan)
ProfileViewer perfil lateral (líneas de agua, cubierta, quilla)
PlanViewer vista de planta (líneas de agua desde arriba)
Cada uno acepta un objeto Hull y se actualiza al llamar set_hull().
Soportan zoom con rueda del ratón y paneo con botón central/derecho.
Cada visor muestra la malla de puntos de control de la OffsetsTable.
El usuario puede arrastrar cualquier punto para modificar la geometría;
al soltar se emite la señal ``offsets_edited(OffsetsTable)``.
Soportan zoom con rueda del ratón y paneo con botón medio/derecho.
Doble clic restablece el encuadre automático.
Referencia:
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 Lines Plan.
@@ -21,9 +25,9 @@ import math
from typing import Optional
import numpy as np
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
from PySide6.QtGui import (
QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
)
from PySide6.QtWidgets import QWidget
@@ -33,45 +37,62 @@ from arshipdesign.core.hull import Hull
# ─────────────────────────────────────────────────────────────────────────────
# Paleta del tema
# ─────────────────────────────────────────────────────────────────────────────
_BG = QColor("#1a1d30")
_GRID = QColor("#2a3060")
_WATERLINE = QColor("#4da8ff") # azul cyan
_SECTION = QColor("#48a858") # verde
_PROFILE = QColor("#e8a020") # dorado
_DECK = QColor("#8868c8") # púrpura
_KEEL = QColor("#e06060") # rojo suave
_TEXT = QColor("#7a8ba8")
_AXIS = QColor("#3e4255")
_WL_DESIGN = QColor("#4da8ff") # flotación de diseño (más gruesa)
_BG = QColor("#1a1d30")
_GRID = QColor("#2a3060") # Estaciones (muy tenue)
_WATERLINE = QColor("#4da8ff") # Líneas de agua
_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa)
_SECTION = QColor("#48a858") # Secciones de proa (verde)
_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul)
_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado)
_DECK = QColor("#8868c8") # Línea de cubierta (púrpura)
_KEEL = QColor("#e06060") # Quilla (rojo suave)
_TEXT = QColor("#7a8ba8")
_AXIS = QColor("#3e4255")
# Puntos de control (malla editable)
_CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado
_CPT_HOVER = QColor("#ffd700") # oro
_CPT_DRAG = QColor("#ff5555") # rojo activo
_CPT_RADIUS = 4.0 # px en reposo
_CPT_HIT = 14.0 # px umbral de captura
# ─────────────────────────────────────────────────────────────────────────────
# Base común
# Clase base
# ─────────────────────────────────────────────────────────────────────────────
class _BaseViewer(QWidget):
"""Widget base con zoom/paneo común."""
"""Widget base con zoom/paneo y edición de puntos de control."""
# Emitido cuando el usuario arrastra un punto y suelta el botón
offsets_edited = Signal(object) # OffsetsTable modificada
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._hull: Optional[Hull] = None
self._scale = 1.0
self._offset = QPointF(0.0, 0.0)
self._drag_start: Optional[QPointF] = None
self.setMouseTracking(True)
self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho)
# Estado de edición de puntos de control
self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline)
self._drag_idx: Optional[tuple[int, int]] = None
self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa)
self.setMouseTracking(True)
self.setCursor(Qt.CursorShape.ArrowCursor)
# ─── API pública ──────────────────────────────────────────────────────────
# ------------------------------------------------------------------
def set_hull(self, hull: Optional[Hull]) -> None:
self._hull = hull
self._hover_idx = None
self._drag_idx = None
self._fit_to_view()
self.update()
# ------------------------------------------------------------------
# Transformación mundo → pantalla
# ------------------------------------------------------------------
# ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
def _w2s(self, wx: float, wy: float) -> QPointF:
"""Coordenada mundo → coordenada de pantalla."""
return QPointF(
wx * self._scale + self._offset.x(),
wy * self._scale + self._offset.y(),
@@ -84,7 +105,6 @@ class _BaseViewer(QWidget):
)
def _fit_to_view(self) -> None:
"""Ajusta zoom y offset para encuadrar el casco."""
if self._hull is None:
return
bbox = self._world_bbox()
@@ -96,30 +116,29 @@ class _BaseViewer(QWidget):
return
pw, ph = max(self.width(), 100), max(self.height(), 100)
margin = 0.08
scale_x = pw * (1 - margin * 2) / ww
scale_y = ph * (1 - margin * 2) / wh
self._scale = min(scale_x, scale_y)
# Centrar
self._scale = min(
pw * (1 - margin * 2) / ww,
ph * (1 - margin * 2) / wh,
)
cx = pw / 2 - (wx0 + ww / 2) * self._scale
cy = ph / 2 - (wy0 + wh / 2) * self._scale
self._offset = QPointF(cx, cy)
def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
return None # subclases lo sobreescriben
return None # subclases
# ------------------------------------------------------------------
# Eventos
# ------------------------------------------------------------------
# ─── Eventos ─────────────────────────────────────────────────────────────
def resizeEvent(self, event) -> None: # type: ignore[override]
def resizeEvent(self, event) -> None:
self._fit_to_view()
super().resizeEvent(event)
def wheelEvent(self, event: QWheelEvent) -> None:
if self._drag_idx is not None:
return
delta = event.angleDelta().y()
factor = 1.15 if delta > 0 else 1.0 / 1.15
pos = event.position()
# Zoom centrado en el cursor
self._offset = QPointF(
pos.x() + (self._offset.x() - pos.x()) * factor,
pos.y() + (self._offset.y() - pos.y()) * factor,
@@ -127,60 +146,111 @@ class _BaseViewer(QWidget):
self._scale *= factor
self.update()
def mousePressEvent(self, event) -> None: # type: ignore[override]
if event.button() in (Qt.MouseButton.MiddleButton,
Qt.MouseButton.RightButton):
self._drag_start = event.position()
def mousePressEvent(self, event) -> None:
btn = event.button()
if btn == Qt.MouseButton.LeftButton and self._hull is not None:
idx = self._hit_test(event.position())
if idx is not None:
self._drag_idx = idx
self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]])
self.setCursor(Qt.CursorShape.SizeAllCursor)
event.accept()
return
if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton):
self._pan_start = event.position()
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
if self._drag_start is not None:
d = event.position() - self._drag_start
def mouseMoveEvent(self, event) -> None:
# ── Paneo ─────────────────────────────────────────────────────────
if self._pan_start is not None:
d = event.position() - self._pan_start
self._offset += d
self._drag_start = event.position()
self._pan_start = event.position()
self.update()
return
# ── Arrastre de punto de control ──────────────────────────────────
if self._drag_idx is not None and self._hull is not None:
self._apply_drag(event.position(), self._drag_idx)
self.update()
return
# ── Hover ─────────────────────────────────────────────────────────
old = self._hover_idx
if self._hull is not None:
self._hover_idx = self._hit_test(event.position())
else:
self._hover_idx = None
cursor = (Qt.CursorShape.SizeAllCursor
if self._hover_idx is not None
else Qt.CursorShape.ArrowCursor)
self.setCursor(cursor)
if self._hover_idx != old:
self.update()
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
self._drag_start = None
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None:
self._drag_idx = None
self.setCursor(Qt.CursorShape.ArrowCursor)
if self._hull is not None:
self.offsets_edited.emit(self._hull.offsets)
event.accept()
return
if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton):
self._pan_start = None
def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override]
def mouseDoubleClickEvent(self, event) -> None:
self._fit_to_view()
self.update()
# ------------------------------------------------------------------
# Helpers de dibujo
# ------------------------------------------------------------------
# ─── Métodos de edición (implementados por subclases) ────────────────────
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
"""Busca el punto de control más cercano dentro del umbral de captura."""
return None # subclases
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
"""Actualiza la OffsetsTable con la nueva posición del ratón."""
pass # subclases
# ─── Helpers de dibujo ───────────────────────────────────────────────────
def _draw_background(self, p: QPainter) -> None:
p.fillRect(self.rect(), _BG)
def _draw_axes(self, p: QPainter,
x0w: float, x1w: float, y0w: float, y1w: float,
x_label: str = "x [m]", y_label: str = "y [m]") -> None:
"""Ejes y grilla con etiquetas."""
p.setPen(QPen(_AXIS, 1, Qt.PenStyle.SolidLine))
# Eje X
p0 = self._w2s(x0w, 0.0)
p1 = self._w2s(x1w, 0.0)
p.drawLine(p0, p1)
# Eje Y
p0 = self._w2s(0.0, y0w)
p1 = self._w2s(0.0, y1w)
p.drawLine(p0, p1)
def _draw_label(self, p: QPainter, text: str) -> None:
p.setPen(QPen(_TEXT))
fnt = QFont("Monospace", 8)
p.setFont(fnt)
p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text)
p.setFont(QFont("Monospace", 8))
p.drawText(
self.rect().adjusted(4, 4, -4, -4),
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
text,
)
def _draw_no_hull(self, p: QPainter, msg: str) -> None:
p.setPen(QPen(_TEXT))
fnt = QFont("Monospace", 10)
p.setFont(fnt)
p.setFont(QFont("Monospace", 10))
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
def _draw_control_point(
self,
p: QPainter,
screen_pt: QPointF,
idx: tuple[int, int],
) -> None:
"""Dibuja un punto de control con color según estado."""
if idx == self._drag_idx:
color = _CPT_DRAG
r = _CPT_RADIUS * 1.8
elif idx == self._hover_idx:
color = _CPT_HOVER
r = _CPT_RADIUS * 1.5
else:
color = _CPT_NORMAL
r = _CPT_RADIUS
p.setPen(QPen(color.darker(130), 1))
p.setBrush(QBrush(color))
p.drawEllipse(screen_pt, r, r)
# ─────────────────────────────────────────────────────────────────────────────
# 1. Body Plan — secciones transversales
@@ -190,19 +260,56 @@ class BodyPlanViewer(_BaseViewer):
"""Vista de cuadernas (body plan).
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
Muestra mitad de babor izquierda (y<0) y estribor derecha (y>0).
La quilla maestra se resalta.
Mitad de proa estribor (derecha, verde).
Mitad de popa babor (izquierda, azul).
Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para
cambiar la semi-manga en esa estación y línea de agua.
"""
def _world_bbox(self) -> Optional[tuple]:
if self._hull is None:
return None
ot = self._hull.offsets
y_max = ot.max_half_breadth * 1.1
z_max = ot.draft * 1.15
y_max = ot.max_half_breadth * 1.15
z_max = ot.draft * 1.20
return (-y_max, -z_max * 0.05, y_max, z_max)
def paintEvent(self, event) -> None: # type: ignore[override]
# ── Edición ───────────────────────────────────────────────────────────────
def _screen_pt(self, i: int, j: int) -> QPointF:
"""Punto de control (i, j) en coordenadas de pantalla."""
ot = self._hull.offsets
y = ot.data[i, j]
z = ot.z_waterlines[j]
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
return self._w2s(sign * y, z)
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
if self._hull is None:
return None
ot = self._hull.offsets
best_d, best_idx = _CPT_HIT, None
for i in range(ot.n_stations):
for j in range(ot.n_waterlines):
d = _dist(pos, self._screen_pt(i, j))
if d < best_d:
best_d, best_idx = d, (i, j)
return best_idx
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
ot = self._hull.offsets
i, j = idx
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
wx, _ = self._s2w(pos.x(), pos.y())
new_y = max(0.0, sign * wx)
# Limitar al doble de la manga para evitar explosiones
new_y = min(new_y, self._hull.beam)
ot.data[i, j] = new_y
# ── Dibujo ────────────────────────────────────────────────────────────────
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
self._draw_background(p)
@@ -216,71 +323,74 @@ class BodyPlanViewer(_BaseViewer):
T = self._hull.draft
n = ot.n_stations
# ── Grilla de líneas de agua ───────────────────────────────
wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine)
p.setPen(wl_pen)
for z in ot.z_waterlines:
# Línea horizontal en z
x_max = ot.max_half_breadth * 1.1
left = self._w2s(-x_max, z)
right = self._w2s( x_max, z)
p.drawLine(left, right)
# ── Líneas de agua — grilla horizontal ────────────────────────
x_max = ot.max_half_breadth * 1.15
for j, z in enumerate(ot.z_waterlines):
is_design = abs(z - T) < 1e-6
if is_design:
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
else:
p.setPen(QPen(_WATERLINE.darker(160), 0.6, Qt.PenStyle.DotLine))
p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z))
# Línea de flotación de diseño (más gruesa)
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
x_max = ot.max_half_breadth * 1.1
# Línea de flotación de diseño (más visible)
p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
# ── Dibujar secciones ──────────────────────────────────────
# ── Secciones ─────────────────────────────────────────────────
for i in range(n):
# Progreso de AP a FP: proa a estribor, popa a babor
is_forward = i >= n // 2
is_fwd = i >= n // 2
is_mid = i == n // 2
if is_forward:
pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor)
if is_mid:
pen = QPen(_MIDSHIP, 2.5)
elif is_fwd:
pen = QPen(_SECTION, 1.4)
else:
pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor)
# Cuaderna maestra más gruesa
if i == n // 2:
pen.setWidthF(2.5)
pen.setColor(_PROFILE)
pen = QPen(_SECTION_AFT, 1.4)
p.setPen(pen)
y_arr = ot.data[i, :]
z_arr = ot.z_waterlines
sign = 1.0 if is_forward else -1.0 # estribor o babor
sign = 1.0 if is_fwd else -1.0
path = QPainterPath()
started = False
for y, z in zip(y_arr, z_arr):
for k, (y, z) in enumerate(zip(y_arr, z_arr)):
pt = self._w2s(sign * y, z)
if not started:
if k == 0:
path.moveTo(pt)
started = True
else:
path.lineTo(pt)
# Cerrar en quilla
path.lineTo(self._w2s(0.0, 0.0))
p.drawPath(path)
# ── Ejes ──────────────────────────────────────────────────
# ── Ejes ──────────────────────────────────────────────────────
p.setPen(QPen(_AXIS, 1))
x_max = ot.max_half_breadth * 1.1
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.1)) # eje simétrico
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía
# ── Puntos de control ─────────────────────────────────────────
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
for i in range(n):
for j in range(ot.n_waterlines):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
self._draw_label(p, "BODY PLAN")
p.end()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Profile Viewer — vista lateral
# 2. Profile Viewer — vista lateral (solo lectura)
# ─────────────────────────────────────────────────────────────────────────────
class ProfileViewer(_BaseViewer):
"""Vista lateral del casco (perfil).
Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m].
Muestra: líneas de agua proyectadas, perfil de cubierta, quilla.
Muestra líneas de agua, perfil de cubierta y quilla.
No es editable (las z son constantes en la OffsetsTable).
"""
def _world_bbox(self) -> Optional[tuple]:
@@ -290,10 +400,10 @@ class ProfileViewer(_BaseViewer):
-self._hull.lpp * 0.05,
-self._hull.draft * 0.15,
self._hull.lpp * 1.05,
self._hull.draft * 1.25,
self._hull.draft * 1.30,
)
def paintEvent(self, event) -> None: # type: ignore[override]
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
self._draw_background(p)
@@ -307,23 +417,24 @@ class ProfileViewer(_BaseViewer):
T = self._hull.draft
Lpp = self._hull.lpp
# ── Grilla de estaciones ───────────────────────────────────
# ── Grilla de estaciones ───────────────────────────────────────
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
for x in ot.x_stations:
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.15))
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2))
# ── Líneas de agua en perfil (ancho máximo a cada z) ────────
# ── Líneas de agua en perfil ───────────────────────────────────
for j, z in enumerate(ot.z_waterlines):
color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE
width = 1.5 if abs(z - T) < 1e-6 else 0.8
p.setPen(QPen(color, width))
# En perfil, la línea de agua aparece como línea recta horizontal
# con el "ancho" dado por las semi-mangas (no visible en perfil lateral)
# Lo que sí se muestra: intersección de líneas de agua con la proa y la popa
# Dibujamos la línea completa
is_design = abs(z - T) < 1e-6
if is_design:
p.setPen(QPen(_WL_DESIGN, 1.8))
else:
frac = j / max(ot.n_waterlines - 1, 1)
color = QColor(_WATERLINE)
color.setAlphaF(0.40 + 0.50 * frac)
p.setPen(QPen(color, 0.9))
p.drawLine(self._w2s(0, z), self._w2s(Lpp, z))
# ── Cubierta (z = puntal) ──────────────────────────────────
# ── Cubierta ──────────────────────────────────────────────────
p.setPen(QPen(_DECK, 1.8))
path_deck = QPainterPath()
for k, x in enumerate(ot.x_stations):
@@ -334,24 +445,23 @@ class ProfileViewer(_BaseViewer):
path_deck.lineTo(pt)
p.drawPath(path_deck)
# ── Quilla ─────────────────────────────────────────────────
# ── Quilla ────────────────────────────────────────────────────
p.setPen(QPen(_KEEL, 2.0))
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0))
# ── Perpendiculares AP y FP ────────────────────────────────
# ── Perpendiculares AP / FP ────────────────────────────────────
p.setPen(QPen(_AXIS, 1.5))
p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05))
p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05))
# Etiquetas AP / FP
p.setPen(QPen(_TEXT))
p.setFont(QFont("Monospace", 8))
ap_pt = self._w2s(0, -T * 0.12)
fp_pt = self._w2s(Lpp, -T * 0.12)
p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14),
Qt.AlignmentFlag.AlignCenter, "AP")
p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14),
Qt.AlignmentFlag.AlignCenter, "FP")
_lbl = lambda text, x, z: p.drawText(
QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14),
Qt.AlignmentFlag.AlignCenter, text
)
_lbl("AP", 0, -T * 0.12)
_lbl("FP", Lpp, -T * 0.12)
self._draw_label(p, "PERFIL LATERAL")
p.end()
@@ -365,7 +475,9 @@ class PlanViewer(_BaseViewer):
"""Vista de planta (semiplano superior).
Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor).
Muestra: líneas de agua superpuestas como contornos.
Edición: arrastra un punto de contorno (x[i], y[i][j]) en y para cambiar
la semi-manga de esa estación en esa línea de agua.
"""
def _world_bbox(self) -> Optional[tuple]:
@@ -376,10 +488,37 @@ class PlanViewer(_BaseViewer):
-self._hull.lpp * 0.05,
-y_max * 0.15,
self._hull.lpp * 1.05,
y_max * 1.20,
y_max * 1.25,
)
def paintEvent(self, event) -> None: # type: ignore[override]
# ── Edición ───────────────────────────────────────────────────────────────
def _screen_pt(self, i: int, j: int) -> QPointF:
ot = self._hull.offsets
return self._w2s(ot.x_stations[i], ot.data[i, j])
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
if self._hull is None:
return None
ot = self._hull.offsets
best_d, best_idx = _CPT_HIT, None
for i in range(ot.n_stations):
for j in range(ot.n_waterlines):
d = _dist(pos, self._screen_pt(i, j))
if d < best_d:
best_d, best_idx = d, (i, j)
return best_idx
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
ot = self._hull.offsets
i, j = idx
_, wy = self._s2w(pos.x(), pos.y())
new_y = max(0.0, min(wy, self._hull.beam))
ot.data[i, j] = new_y
# ── Dibujo ────────────────────────────────────────────────────────────────
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
self._draw_background(p)
@@ -391,44 +530,57 @@ class PlanViewer(_BaseViewer):
ot = self._hull.offsets
T = self._hull.draft
# ── Líneas de agua como contornos ──────────────────────────
n_wl = ot.n_waterlines
# ── Líneas de agua como contornos ─────────────────────────────
for j in range(n_wl):
z = ot.z_waterlines[j]
is_design = abs(z - T) < 1e-6
color = _WL_DESIGN if is_design else _WATERLINE
alpha = int(60 + 195 * j / max(n_wl - 1, 1))
c = QColor(color)
c.setAlpha(alpha)
width = 2.0 if is_design else 0.9
frac = j / max(n_wl - 1, 1)
p.setPen(QPen(c, width))
if is_design:
color = QColor(_WL_DESIGN)
color.setAlphaF(1.0)
width = 2.0
else:
color = QColor(_WATERLINE)
color.setAlphaF(0.30 + 0.55 * frac)
width = 0.9
p.setPen(QPen(color, width))
path = QPainterPath()
x_arr = ot.x_stations
y_arr = ot.data[:, j]
started = False
for x, y in zip(x_arr, y_arr):
for k, (x, y) in enumerate(zip(x_arr, y_arr)):
pt = self._w2s(x, y)
if not started:
if k == 0:
path.moveTo(pt)
started = True
else:
path.lineTo(pt)
p.drawPath(path)
# ── Eje de crujía ──────────────────────────────────────────
# ── Eje de crujía ─────────────────────────────────────────────
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
p.drawLine(
self._w2s(0, 0),
self._w2s(self._hull.lpp, 0),
)
p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0))
# ── Estaciones (líneas verticales tenues) ──────────────────
# ── Estaciones ────────────────────────────────────────────────
p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine))
y_max = ot.max_half_breadth
for x in ot.x_stations:
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.1))
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15))
# ── Puntos de control ─────────────────────────────────────────
for i in range(ot.n_stations):
for j in range(n_wl):
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
self._draw_label(p, "VISTA DE PLANTA")
p.end()
# ─────────────────────────────────────────────────────────────────────────────
# Utilidad interna
# ─────────────────────────────────────────────────────────────────────────────
def _dist(a: QPointF, b: QPointF) -> float:
return math.hypot(a.x() - b.x(), a.y() - b.y())
+1 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PySide6.QtCore import QSettings
APP_NAME = "ARShipDesign"
ORG_NAME = "AlvaroRodriguez"
ORG_NAME = "AlvaroRomero"
# Claves de configuración
KEY_LANGUAGE = "ui/language"
+1 -1
View File
@@ -40,7 +40,7 @@ def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("AR-ShipDesign")
app.setOrganizationName("AlvaroRodriguez")
app.setOrganizationName("AlvaroRomero")
app.setApplicationVersion("0.1.0")
# Fuente por defecto
+1 -1
View File
@@ -6,7 +6,7 @@ build-backend = "setuptools.backends.legacy:build"
name = "arshipdesign"
version = "0.1.0"
description = "Software profesional de diseño naval — AR-ShipDesign"
authors = [{ name = "Álvaro Rodríguez" }]
authors = [{ name = "Álvaro Romero" }]
license = { file = "LICENSE.txt" }
readme = "README.md"
requires-python = ">=3.11"
+534
View File
@@ -0,0 +1,534 @@
"""
Tests Módulo 2 Motor de Curvas Hidrostáticas.
Verifica:
- UprightHydrostatics a calado único vs solución analítica Wigley
- HydrostaticCurves: barrido de calados, monotonicidad, coeficientes
- Exportación CSV y dict
- Interpolación at_draft()
- IACS Rec.34 V024V036: verificación y trazabilidad
Autor: Álvaro Romero | Módulo 2 AR-ShipDesign
IACS Rec.34 par.4.3, 4.4, 4.5 verificación analítica, convergencia y simetría.
"""
from __future__ import annotations
import json
import math
import numpy as np
import pytest
from arshipdesign.core.hull import Hull
from arshipdesign.hydrostatics import (
HydrostaticCurves,
UprightHydrostatics,
compute_upright,
CSV_HEADERS,
)
from arshipdesign.parametric import generate_hull, HullFamily
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def wigley_hull() -> Hull:
return Hull.from_wigley(lpp=15.0, beam=4.0, draft=1.60,
n_stations=41, n_waterlines=21)
@pytest.fixture(scope="module")
def wigley_upright(wigley_hull: Hull) -> UprightHydrostatics:
return compute_upright(wigley_hull, draft=1.60)
@pytest.fixture(scope="module")
def wigley_curves(wigley_hull: Hull) -> HydrostaticCurves:
return HydrostaticCurves.compute(wigley_hull, n_points=20)
@pytest.fixture(scope="module")
def displacement_hull() -> Hull:
return generate_hull(HullFamily.DISPLACEMENT, lpp=20.0, beam=6.0,
draft=2.40, depth=3.20, cb=0.55)
# ---------------------------------------------------------------------------
# 1. UprightHydrostatics — calado único (Wigley analítico)
# ---------------------------------------------------------------------------
class TestUprightHydrostaticsWigley:
"""IACS Rec.34 §4.3 — verificación analítica."""
# Constantes analíticas para Wigley Lpp=15, B=4, T=1.60
L, B, T = 15.0, 4.0, 1.60
@property
def V_ana(self): return (4.0/9.0) * self.L * self.B * self.T
@property
def Cb_ana(self): return 4.0/9.0
@property
def Awp_ana(self): return (2.0/3.0) * self.L * self.B
@property
def Cw_ana(self): return 2.0/3.0
@property
def KB_ana(self): return 5.0 * self.T / 8.0
@property
def IT_ana(self):
# IT = (2/3) * (B/2)^3 * (L/2) * ∫₋₁¹(1-u²)³du = 32/35
return (2.0/3.0) * (self.B/2)**3 * (self.L/2) * (32.0/35.0)
@property
def Cm_ana(self): return 2.0/3.0
def test_volume_analytic(self, wigley_upright):
assert abs(wigley_upright.volume - self.V_ana) / self.V_ana < 1e-6
def test_displacement_from_volume(self, wigley_upright):
rho = 1025.0
expected = wigley_upright.volume * rho / 1000.0
assert abs(wigley_upright.displacement - expected) < 1e-9
def test_awp_analytic(self, wigley_upright):
assert abs(wigley_upright.awp - self.Awp_ana) / self.Awp_ana < 1e-6
def test_cb_analytic(self, wigley_upright):
assert abs(wigley_upright.cb - self.Cb_ana) < 1e-5
def test_cw_analytic(self, wigley_upright):
assert abs(wigley_upright.cw - self.Cw_ana) < 1e-5
def test_kb_analytic(self, wigley_upright):
"""IACS Rec.34 §4.3 — KB = 5T/8 para el casco Wigley."""
assert abs(wigley_upright.kb - self.KB_ana) / self.KB_ana < 1e-5
def test_it_analytic(self, wigley_upright):
"""IT = (2/3)(B/2)³(L/2)(32/35) para el casco Wigley."""
assert abs(wigley_upright.it - self.IT_ana) / self.IT_ana < 0.001 # < 0.1%
def test_bmt_equals_it_over_v(self, wigley_upright):
expected = wigley_upright.it / wigley_upright.volume
assert abs(wigley_upright.bmt - expected) < 1e-9
def test_kmt_equals_kb_plus_bmt(self, wigley_upright):
assert abs(wigley_upright.kmt - (wigley_upright.kb + wigley_upright.bmt)) < 1e-9
def test_bml_equals_il_over_v(self, wigley_upright):
expected = wigley_upright.il / wigley_upright.volume
assert abs(wigley_upright.bml - expected) < 1e-9
def test_kml_equals_kb_plus_bml(self, wigley_upright):
assert abs(wigley_upright.kml - (wigley_upright.kb + wigley_upright.bml)) < 1e-9
def test_lcb_symmetry(self, wigley_upright, wigley_hull):
"""IACS Rec.34 §4.5 — LCB = Lpp/2 para cascos simétricos."""
assert abs(wigley_upright.lcb - wigley_hull.lpp / 2.0) < 1e-4
def test_lcf_symmetry(self, wigley_upright, wigley_hull):
"""IACS Rec.34 §4.5 — LCF = Lpp/2 para cascos simétricos."""
assert abs(wigley_upright.lcf - wigley_hull.lpp / 2.0) < 1e-4
def test_cm_analytic(self, wigley_upright):
assert abs(wigley_upright.cm - self.Cm_ana) < 1e-4
def test_cp_from_cb_and_cm(self, wigley_upright):
"""Cp = Cb / Cm — relación de identidad fundamental."""
expected = wigley_upright.cb / wigley_upright.cm
assert abs(wigley_upright.cp - expected) < 1e-4
def test_tpc_positive_and_consistent(self, wigley_upright):
rho = 1025.0
expected = wigley_upright.awp * rho / 100_000.0
assert wigley_upright.tpc > 0.0
assert abs(wigley_upright.tpc - expected) < 1e-9
def test_mct_positive(self, wigley_upright):
assert wigley_upright.mct >= 0.0
def test_zero_draft_returns_zeros(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=0.0)
assert uh.volume == 0.0
assert uh.displacement == 0.0
assert uh.awp == 0.0
def test_cb_bounded(self, wigley_upright):
assert 0.0 < wigley_upright.cb <= 1.0
def test_cw_bounded(self, wigley_upright):
assert 0.0 < wigley_upright.cw <= 1.0
def test_cm_bounded(self, wigley_upright):
assert 0.0 < wigley_upright.cm <= 1.0
def test_cp_bounded(self, wigley_upright):
assert 0.0 < wigley_upright.cp <= 1.0
# ---------------------------------------------------------------------------
# 2. compute_upright vs métodos de Hull
# (IACS Rec.34 §4.3 — consistencia entre métodos)
# ---------------------------------------------------------------------------
class TestUprightVsHullMethods:
"""Los resultados de compute_upright deben coincidir con los métodos del Hull."""
def test_volume_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.volume - wigley_hull.volume_of_displacement()) < 1e-9
def test_displacement_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.displacement - wigley_hull.displacement_tonnes()) < 1e-9
def test_awp_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.awp - wigley_hull.waterplane_area()) < 1e-9
def test_kb_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.kb - wigley_hull.vcb()) < 1e-9
def test_kmt_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.kmt - wigley_hull.km_transverse()) < 1e-9
def test_tpc_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.tpc - wigley_hull.tpc()) < 1e-9
def test_cb_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.cb - wigley_hull.block_coefficient()) < 1e-9
def test_cw_matches_hull(self, wigley_hull):
uh = compute_upright(wigley_hull, draft=1.60)
assert abs(uh.cw - wigley_hull.waterplane_coefficient()) < 1e-9
def test_partial_draft_volume(self, wigley_hull):
"""compute_upright a T parcial debe coincidir con Hull a ese T."""
T_partial = 0.80
uh = compute_upright(wigley_hull, draft=T_partial)
hull_v = wigley_hull.volume_of_displacement(T_partial)
assert abs(uh.volume - hull_v) < 1e-9
def test_rho_scaling(self, wigley_hull):
"""Desplazamiento debe escalar linealmente con rho."""
uh_salt = compute_upright(wigley_hull, draft=1.60, rho=1025.0)
uh_fresh = compute_upright(wigley_hull, draft=1.60, rho=1000.0)
ratio = uh_salt.displacement / uh_fresh.displacement
assert abs(ratio - 1025.0 / 1000.0) < 1e-9
def test_tpc_scales_with_rho(self, wigley_hull):
uh_salt = compute_upright(wigley_hull, draft=1.60, rho=1025.0)
uh_fresh = compute_upright(wigley_hull, draft=1.60, rho=1000.0)
ratio = uh_salt.tpc / uh_fresh.tpc
assert abs(ratio - 1025.0 / 1000.0) < 1e-9
# ---------------------------------------------------------------------------
# 3. HydrostaticCurves — barrido de calados
# ---------------------------------------------------------------------------
class TestHydrostaticCurves:
def test_default_n_points(self, wigley_hull):
c = HydrostaticCurves.compute(wigley_hull)
assert len(c) == 20
def test_custom_n_points(self, wigley_hull):
c = HydrostaticCurves.compute(wigley_hull, n_points=10)
assert len(c) == 10
def test_min_n_points_enforced(self, wigley_hull):
"""n_points < 5 se eleva a 5."""
c = HydrostaticCurves.compute(wigley_hull, n_points=2)
assert len(c) == 5
def test_design_draft_is_last_point(self, wigley_hull, wigley_curves):
assert abs(wigley_curves.points[-1].draft - wigley_hull.draft) < 1e-12
def test_design_draft_matches_compute_upright(self, wigley_hull, wigley_curves):
"""El último punto debe coincidir con compute_upright al calado de diseño."""
uh = compute_upright(wigley_hull, wigley_hull.draft)
last = wigley_curves.points[-1]
assert abs(last.volume - uh.volume) < 1e-9
assert abs(last.awp - uh.awp) < 1e-9
assert abs(last.kmt - uh.kmt) < 1e-9
def test_hull_name_preserved(self, wigley_hull, wigley_curves):
assert wigley_curves.hull_name == wigley_hull.name
def test_lpp_beam_preserved(self, wigley_hull, wigley_curves):
assert wigley_curves.lpp == wigley_hull.lpp
assert wigley_curves.beam == wigley_hull.beam
def test_getitem(self, wigley_curves):
p = wigley_curves[0]
assert isinstance(p, UprightHydrostatics)
def test_iter(self, wigley_curves):
pts = list(wigley_curves)
assert len(pts) == len(wigley_curves)
def test_repr_contains_hull_name(self, wigley_curves):
assert "Wigley" in repr(wigley_curves)
# ---------------------------------------------------------------------------
# 4. Monotonicidad de las curvas
# (IACS Rec.34 §4.4 — verificación de tendencias)
# ---------------------------------------------------------------------------
class TestCurvesMonotonicity:
"""V024V028: las curvas hidrostáticas deben ser monótonamente crecientes."""
def test_v024_displacement_monotone(self, wigley_curves):
"""V024 — Δ(T) es monótonamente creciente."""
d = np.diff(wigley_curves.displacements)
assert np.all(d > 0), f"Δ no es monótono: diff mín = {d.min():.6f}"
def test_v025_volume_monotone(self, wigley_curves):
"""V025 — V(T) es monótonamente creciente."""
d = np.diff(wigley_curves.volumes)
assert np.all(d > 0), f"V no es monótono: diff mín = {d.min():.6f}"
def test_v026_kb_monotone(self, wigley_curves):
"""V026 — KB(T) es monótonamente creciente."""
d = np.diff(wigley_curves.kb_values)
assert np.all(d > 0), f"KB no es monótono: diff mín = {d.min():.6f}"
def test_v027_awp_monotone(self, wigley_curves):
"""V027 — Awp(T) es monótonamente creciente (para el casco Wigley)."""
d = np.diff(wigley_curves.awp_values)
assert np.all(d > 0), f"Awp no es monótono: diff mín = {d.min():.6f}"
def test_v028_tpc_monotone(self, wigley_curves):
"""V028 — TPC(T) es monótonamente creciente."""
d = np.diff(wigley_curves.tpc_values)
assert np.all(d > 0), f"TPC no es monótono: diff mín = {d.min():.6f}"
def test_cb_within_bounds_all_drafts(self, wigley_curves):
"""Cb ∈ (0, 1) para todos los calados."""
cb = wigley_curves.cb_values
assert np.all(cb > 0)
assert np.all(cb <= 1.0)
def test_cw_within_bounds_all_drafts(self, wigley_curves):
"""Cw ∈ (0, 1] para todos los calados."""
cw = wigley_curves.cw_values
assert np.all(cw > 0)
assert np.all(cw <= 1.0)
def test_all_families_monotone_displacement(self):
"""V029 — Δ monótono para las 5 familias paramétricas."""
for family in HullFamily:
hull = generate_hull(family, lpp=12.0, beam=3.5,
draft=1.20, depth=2.00)
c = HydrostaticCurves.compute(hull, n_points=10)
d = np.diff(c.displacements)
assert np.all(d > 0), \
f"{family.value}: Δ no es monótono (diff mín={d.min():.6f})"
# ---------------------------------------------------------------------------
# 5. Interpolación at_draft()
# ---------------------------------------------------------------------------
class TestAtDraft:
def test_at_design_draft_matches_last_point(self, wigley_hull, wigley_curves):
T_d = wigley_hull.draft
interp = wigley_curves.at_draft(T_d)
last = wigley_curves.points[-1]
assert abs(interp.volume - last.volume) < 1e-9
assert abs(interp.kmt - last.kmt) < 1e-9
def test_at_min_draft_matches_first_point(self, wigley_curves):
T_min = wigley_curves.points[0].draft
interp = wigley_curves.at_draft(T_min)
first = wigley_curves.points[0]
assert abs(interp.volume - first.volume) < 1e-9
def test_clamp_below_min(self, wigley_curves):
T_min = wigley_curves.points[0].draft
interp = wigley_curves.at_draft(-1.0)
first = wigley_curves.points[0]
assert abs(interp.volume - first.volume) < 1e-9
def test_clamp_above_max(self, wigley_curves):
T_max = wigley_curves.points[-1].draft
interp = wigley_curves.at_draft(T_max + 5.0)
last = wigley_curves.points[-1]
assert abs(interp.volume - last.volume) < 1e-9
def test_mid_draft_between_bounds(self, wigley_curves):
"""Valor interpolado intermedio debe estar entre los extremos."""
T_mid = (wigley_curves.drafts[0] + wigley_curves.drafts[-1]) / 2.0
interp = wigley_curves.at_draft(T_mid)
assert wigley_curves.points[0].volume < interp.volume < wigley_curves.points[-1].volume
def test_returns_upright_hydrostatics_instance(self, wigley_curves):
assert isinstance(wigley_curves.at_draft(1.0), UprightHydrostatics)
# ---------------------------------------------------------------------------
# 6. Vectorized array properties
# ---------------------------------------------------------------------------
class TestArrayProperties:
def test_drafts_length(self, wigley_curves):
assert len(wigley_curves.drafts) == len(wigley_curves)
def test_volumes_length(self, wigley_curves):
assert len(wigley_curves.volumes) == len(wigley_curves)
def test_displacements_positive(self, wigley_curves):
assert np.all(wigley_curves.displacements > 0)
def test_kb_positive(self, wigley_curves):
assert np.all(wigley_curves.kb_values > 0)
def test_kmt_positive(self, wigley_curves):
assert np.all(wigley_curves.kmt_values > 0)
def test_tpc_positive(self, wigley_curves):
assert np.all(wigley_curves.tpc_values > 0)
def test_mct_non_negative(self, wigley_curves):
assert np.all(wigley_curves.mct_values >= 0)
def test_all_array_attrs_same_length(self, wigley_curves):
n = len(wigley_curves)
for attr in ("drafts", "volumes", "displacements", "awp_values",
"lcb_values", "lcf_values", "kb_values",
"bmt_values", "bml_values", "kmt_values", "kml_values",
"tpc_values", "mct_values",
"cb_values", "cw_values", "cm_values", "cp_values"):
arr = getattr(wigley_curves, attr)
assert len(arr) == n, f"{attr} tiene longitud {len(arr)}{n}"
# ---------------------------------------------------------------------------
# 7. Exportación CSV y dict
# ---------------------------------------------------------------------------
class TestExport:
def test_csv_header_count(self, wigley_curves):
lines = wigley_curves.to_csv_lines()
assert len(lines) == len(wigley_curves) + 1 # header + datos
def test_csv_header_matches_constant(self, wigley_curves):
lines = wigley_curves.to_csv_lines()
assert lines[0] == ",".join(CSV_HEADERS)
def test_csv_row_column_count(self, wigley_curves):
lines = wigley_curves.to_csv_lines()
n_cols = len(CSV_HEADERS)
for row in lines[1:]:
assert len(row.split(",")) == n_cols
def test_csv_first_value_is_draft(self, wigley_curves):
lines = wigley_curves.to_csv_lines()
first_draft = float(lines[1].split(",")[0])
assert abs(first_draft - wigley_curves.points[0].draft) < 1e-3
def test_csv_semicolon_separator(self, wigley_curves):
lines = wigley_curves.to_csv_lines(sep=";")
assert ";" in lines[0]
assert "," not in lines[0]
def test_csv_decimal_comma(self, wigley_curves):
lines = wigley_curves.to_csv_lines(sep=";", decimal=",")
# Los números deben usar coma decimal
row_parts = lines[1].split(";")
assert "," in row_parts[0] # e.g. "0,1600"
def test_to_dict_has_required_keys(self, wigley_curves):
d = wigley_curves.to_dict()
for key in ("hull_name", "lpp", "beam", "design_draft", "rho",
"headers", "points"):
assert key in d, f"Falta clave '{key}' en to_dict()"
def test_to_dict_json_serializable(self, wigley_curves):
d = wigley_curves.to_dict()
txt = json.dumps(d)
assert len(txt) > 100
def test_to_dict_points_count(self, wigley_curves):
d = wigley_curves.to_dict()
assert len(d["points"]) == len(wigley_curves)
def test_to_dict_first_point_keys(self, wigley_curves):
d = wigley_curves.to_dict()
pt = d["points"][0]
for key in ("T", "V", "Delta", "Awp", "LCB", "LCF", "KB",
"IT", "IL", "BMT", "BML", "KMT", "KML",
"TPC", "MCT", "Cb", "Cw", "Cm", "Cp"):
assert key in pt, f"Falta clave '{key}' en points[0]"
# ---------------------------------------------------------------------------
# 8. IACS Rec.34 — verificaciones V030V036
# ---------------------------------------------------------------------------
class TestIACSVerification:
"""Verificaciones adicionales según IACS Rec.34 §4."""
def test_v030_wigley_cb_accuracy_at_design_draft(self, wigley_curves):
"""V030 — Cb en calado de diseño ≈ 4/9 (error < 0.1%)."""
cb_last = wigley_curves.points[-1].cb
assert abs(cb_last - 4.0/9.0) < 0.001, \
f"Cb={cb_last:.6f} ≠ 4/9={4/9:.6f}"
def test_v031_wigley_cw_at_design_draft(self, wigley_curves):
"""V031 — Cw en calado de diseño ≈ 2/3 (error < 0.1%)."""
cw_last = wigley_curves.points[-1].cw
assert abs(cw_last - 2.0/3.0) < 0.001, \
f"Cw={cw_last:.6f} ≠ 2/3={2/3:.6f}"
def test_v032_wigley_symmetry_all_drafts(self, wigley_hull, wigley_curves):
"""V032 — LCB = LCF = Lpp/2 en todos los calados (simetría)."""
L_mid = wigley_hull.lpp / 2.0
for p in wigley_curves:
assert abs(p.lcb - L_mid) < 1e-3, \
f"T={p.draft:.3f}: LCB={p.lcb:.4f}{L_mid}"
assert abs(p.lcf - L_mid) < 1e-3, \
f"T={p.draft:.3f}: LCF={p.lcf:.4f}{L_mid}"
def test_v033_kmt_greater_than_kb(self, wigley_curves):
"""V033 — KMT > KB en todos los calados (BMT > 0)."""
for p in wigley_curves:
assert p.kmt > p.kb, \
f"T={p.draft:.3f}: KMT={p.kmt:.4f} ≤ KB={p.kb:.4f}"
def test_v034_kml_greater_than_kmt(self, wigley_curves):
"""V034 — KML > KMT en todos los calados (BML >> BMT)."""
for p in wigley_curves:
assert p.kml > p.kmt, \
f"T={p.draft:.3f}: KML={p.kml:.4f} ≤ KMT={p.kmt:.4f}"
def test_v035_cp_identity(self, wigley_curves):
"""V035 — Cp = Cb / Cm (identidad de coeficientes)."""
for p in wigley_curves:
if p.cm > 0.01:
expected = p.cb / p.cm
assert abs(p.cp - expected) < 1e-4, \
f"T={p.draft:.3f}: Cp={p.cp:.4f} ≠ Cb/Cm={expected:.4f}"
def test_v036_displacement_hull_monotone(self, displacement_hull):
"""V036 — desplazamiento monótono para casco de desplazamiento parametrico."""
c = HydrostaticCurves.compute(displacement_hull, n_points=15)
d = np.diff(c.displacements)
assert np.all(d > 0), \
f"DISPLACEMENT: Δ no monótono (diff mín={d.min():.6f})"
def test_v037_mesh_convergence_volume(self, wigley_hull):
"""V037 — IACS §4.4: convergencia de malla. V con 41 sta ≈ V con 81 sta."""
hull_fine = Hull.from_wigley(
lpp=15.0, beam=4.0, draft=1.60,
n_stations=81, n_waterlines=41
)
uh_coarse = compute_upright(wigley_hull, 1.60)
uh_fine = compute_upright(hull_fine, 1.60)
# Convergencia < 0.1%
diff_pct = abs(uh_coarse.volume - uh_fine.volume) / uh_fine.volume * 100
assert diff_pct < 0.1, f"Convergencia de malla: error V = {diff_pct:.4f}%"