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:
@@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
AR-ShipDesign — Software profesional de diseño naval.
|
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"
|
__version__ = "0.1.0"
|
||||||
__author__ = "Álvaro Rodríguez"
|
__author__ = "Álvaro Romero"
|
||||||
__license__ = "Propietario"
|
__license__ = "Propietario"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -15,12 +15,45 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from arshipdesign.core.hull import Hull
|
from arshipdesign.core.hull import Hull
|
||||||
from arshipdesign.core.offsets import OffsetsTable
|
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(
|
def make_displacement_hull(
|
||||||
name: str = "Crucero de Desplazamiento",
|
name: str = "Crucero de Desplazamiento",
|
||||||
lpp: float = 12.0,
|
lpp: float = 12.0,
|
||||||
@@ -53,26 +86,20 @@ def make_displacement_hull(
|
|||||||
lcb_shift = 2.0 * (lcb_frac - 0.5) # ∈ [−1, 1]
|
lcb_shift = 2.0 * (lcb_frac - 0.5) # ∈ [−1, 1]
|
||||||
f_plan = _displacement_plan_form(xi, cb, lcb_shift)
|
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))
|
data = np.zeros((n_stations, n_waterlines))
|
||||||
|
|
||||||
for i in range(n_stations):
|
for i in range(n_stations):
|
||||||
y_wl = (beam / 2.0) * f_plan[i]
|
y_wl = (beam / 2.0) * f_plan[i]
|
||||||
|
|
||||||
# El exponente de sección varía: más fino en proa/popa
|
# Cm varía a lo largo de la eslora: lleno en midship, más en V en extremos.
|
||||||
local_fullness = f_plan[i]
|
# f_plan[i]=1 → midship → Cm=cm; f_plan[i]→0 → extremos → Cm≈0.52
|
||||||
# En extremos (local_fullness→0) el exponente sube → sección más en V
|
local_cm = float(np.clip(
|
||||||
alpha = alpha_mid + (1.0 - alpha_mid) * (1.0 - local_fullness ** 0.5)
|
cm * (0.42 + 0.58 * f_plan[i] ** 0.40),
|
||||||
alpha = np.clip(alpha, alpha_mid, 0.80)
|
0.52, cm
|
||||||
|
))
|
||||||
|
|
||||||
for j, z in enumerate(z_wl):
|
for j, z in enumerate(z_wl):
|
||||||
v = z / draft # ∈ [0, 1]
|
data[i, j] = _round_bilge_section(z, draft, y_wl, local_cm)
|
||||||
data[i, j] = y_wl * (v ** alpha)
|
|
||||||
|
|
||||||
data = np.clip(data, 0.0, None)
|
data = np.clip(data, 0.0, None)
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,11 @@ def make_planing_hull(
|
|||||||
tan_dr = max(np.tan(dr), 0.01)
|
tan_dr = max(np.tan(dr), 0.01)
|
||||||
y_chine_dr = z_c / tan_dr
|
y_chine_dr = z_c / tan_dr
|
||||||
y_chine = min(y_chine_dr, y_max)
|
y_chine = min(y_chine_dr, y_max)
|
||||||
# Ligero ensanchamiento por encima del chine
|
# Costado recto (hard-chine) desde y_chine hasta y_max en cubierta.
|
||||||
y = y_chine + (y_max - y_chine) * flare * (z - z_c) / (draft - z_c + 1e-9)
|
# El parámetro flare añade ensanche adicional sobre y_max.
|
||||||
y = min(y, 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)
|
data[i, j] = max(0.0, y)
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,59 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from arshipdesign.core.hull import Hull
|
from arshipdesign.core.hull import Hull
|
||||||
from arshipdesign.core.offsets import OffsetsTable
|
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(
|
def make_sailing_hull(
|
||||||
name: str = "Velero Monocasco",
|
name: str = "Velero Monocasco",
|
||||||
lpp: float = 10.0,
|
lpp: float = 10.0,
|
||||||
@@ -59,24 +106,22 @@ def make_sailing_hull(
|
|||||||
# Plan form: fina en proa, moderadamente llena a popa
|
# Plan form: fina en proa, moderadamente llena a popa
|
||||||
f_plan = _sailing_plan_form(xi, cb, lcb_shift)
|
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))
|
data = np.zeros((n_stations, n_waterlines))
|
||||||
dr_mid_rad = np.radians(deadrise_mid)
|
|
||||||
|
|
||||||
for i in range(n_stations):
|
for i in range(n_stations):
|
||||||
y_wl = (beam / 2.0) * f_plan[i]
|
y_wl = (beam / 2.0) * f_plan[i]
|
||||||
|
|
||||||
# Interpolar entre sección en V (extremos) y sección redonda (midship)
|
# Cm y deadrise varían a lo largo de la eslora.
|
||||||
local_f = f_plan[i]
|
# Midship: Cm=cm, deadrise=deadrise_mid
|
||||||
alpha = alpha_mid + (0.75 - alpha_mid) * (1.0 - local_f ** 0.7)
|
# Extremos: más en V (menor Cm, mayor deadrise)
|
||||||
alpha = np.clip(alpha, alpha_mid, 0.80)
|
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):
|
for j, z in enumerate(z_wl):
|
||||||
v = z / draft
|
data[i, j] = max(0.0, _sailing_section(z, draft, y_wl, local_cm, local_dr))
|
||||||
y = y_wl * (v ** alpha)
|
|
||||||
data[i, j] = max(0.0, y)
|
|
||||||
|
|
||||||
offsets = OffsetsTable(
|
offsets = OffsetsTable(
|
||||||
x_stations=x_sta,
|
x_stations=x_sta,
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
"type_workboat": "Workboat",
|
"type_workboat": "Workboat",
|
||||||
"about_title": "About AR-ShipDesign",
|
"about_title": "About AR-ShipDesign",
|
||||||
"about_version": "Version",
|
"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_kmt": "KMT = KB + IT/∇ (transverse metacentric height)",
|
||||||
"tooltip_gmt": "GMT = KMT − KG (corrected metacentric height)",
|
"tooltip_gmt": "GMT = KMT − KG (corrected metacentric height)",
|
||||||
"tooltip_tpc": "TPC = Aw · ρ / 100 (tonnes per cm immersion)",
|
"tooltip_tpc": "TPC = Aw · ρ / 100 (tonnes per cm immersion)",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
"type_workboat": "Workboat / Embarcación de trabajo",
|
"type_workboat": "Workboat / Embarcación de trabajo",
|
||||||
"about_title": "Acerca de AR-ShipDesign",
|
"about_title": "Acerca de AR-ShipDesign",
|
||||||
"about_version": "Versión",
|
"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_kmt": "KMT = KB + IT/∇ (altura metacéntrica transversal)",
|
||||||
"tooltip_gmt": "GMT = KMT − KG (altura metacéntrica corregida)",
|
"tooltip_gmt": "GMT = KMT − KG (altura metacéntrica corregida)",
|
||||||
"tooltip_tpc": "TPC = Aw · ρ / 100 (toneladas por cm de inmersión)",
|
"tooltip_tpc": "TPC = Aw · ρ / 100 (toneladas por cm de inmersión)",
|
||||||
|
|||||||
@@ -860,6 +860,10 @@ class MainWindow(QMainWindow):
|
|||||||
if _vp is not None:
|
if _vp is not None:
|
||||||
_vp.set_canvas(_widget)
|
_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)
|
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
|
||||||
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
|
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
|
||||||
self._offsets_editor = 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."""
|
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
|
||||||
self._current_hull = hull
|
self._current_hull = hull
|
||||||
if self._project is not None:
|
if self._project is not None:
|
||||||
self._project.set_hull(hull) # mantener proyecto sincronizado
|
self._project.set_hull(hull)
|
||||||
# _skip_offsets_editor=True para no re-poblar la tabla (ya esta actualizada)
|
|
||||||
self._load_hull_viewers(hull, _skip_offsets_editor=True)
|
self._load_hull_viewers(hull, _skip_offsets_editor=True)
|
||||||
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
|
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:
|
def _update_hydrostatics(self, hull) -> None:
|
||||||
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class Viewer3DWidget(QWidget):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._plotter: Optional["QtInteractor"] = None
|
self._plotter: Optional["QtInteractor"] = None
|
||||||
self._ready = False
|
self._ready = False
|
||||||
|
self._pending_hull = None # hull recibido antes de que el plotter esté listo
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -105,7 +106,12 @@ class Viewer3DWidget(QWidget):
|
|||||||
# Configurar tema dark para que combine con la UI
|
# Configurar tema dark para que combine con la UI
|
||||||
self._plotter.set_background("#1a1d30") # viewportCanvas color
|
self._plotter.set_background("#1a1d30") # viewportCanvas color
|
||||||
|
|
||||||
# Cargar casco Wigley como geometría de bienvenida
|
# 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._load_default_wigley()
|
||||||
self._ready = True
|
self._ready = True
|
||||||
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
|
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
|
||||||
@@ -136,12 +142,15 @@ class Viewer3DWidget(QWidget):
|
|||||||
def load_hull(self, hull) -> None:
|
def load_hull(self, hull) -> None:
|
||||||
"""Carga un objeto Hull en el visor.
|
"""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
|
Parámetros
|
||||||
----------
|
----------
|
||||||
hull : arshipdesign.core.hull.Hull
|
hull : arshipdesign.core.hull.Hull
|
||||||
"""
|
"""
|
||||||
if not self._ready or self._plotter is None:
|
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
|
return
|
||||||
try:
|
try:
|
||||||
mesh = hull.to_mesh()
|
mesh = hull.to_mesh()
|
||||||
|
|||||||
@@ -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:
|
Tres widgets especializados basados en QPainter:
|
||||||
• BodyPlanViewer — secciones transversales (body plan)
|
• BodyPlanViewer — secciones transversales (body plan)
|
||||||
• ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla)
|
• ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla)
|
||||||
• PlanViewer — vista de planta (líneas de agua desde arriba)
|
• PlanViewer — vista de planta (líneas de agua desde arriba)
|
||||||
|
|
||||||
Cada uno acepta un objeto Hull y se actualiza al llamar set_hull().
|
Cada visor muestra la malla de puntos de control de la OffsetsTable.
|
||||||
Soportan zoom con rueda del ratón y paneo con botón central/derecho.
|
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:
|
Referencia:
|
||||||
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan.
|
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan.
|
||||||
@@ -21,9 +25,9 @@ import math
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import QWidget
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
@@ -34,44 +38,61 @@ from arshipdesign.core.hull import Hull
|
|||||||
# Paleta del tema
|
# Paleta del tema
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
_BG = QColor("#1a1d30")
|
_BG = QColor("#1a1d30")
|
||||||
_GRID = QColor("#2a3060")
|
_GRID = QColor("#2a3060") # Estaciones (muy tenue)
|
||||||
_WATERLINE = QColor("#4da8ff") # azul cyan
|
_WATERLINE = QColor("#4da8ff") # Líneas de agua
|
||||||
_SECTION = QColor("#48a858") # verde
|
_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa)
|
||||||
_PROFILE = QColor("#e8a020") # dorado
|
_SECTION = QColor("#48a858") # Secciones de proa (verde)
|
||||||
_DECK = QColor("#8868c8") # púrpura
|
_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul)
|
||||||
_KEEL = QColor("#e06060") # rojo suave
|
_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado)
|
||||||
|
_DECK = QColor("#8868c8") # Línea de cubierta (púrpura)
|
||||||
|
_KEEL = QColor("#e06060") # Quilla (rojo suave)
|
||||||
_TEXT = QColor("#7a8ba8")
|
_TEXT = QColor("#7a8ba8")
|
||||||
_AXIS = QColor("#3e4255")
|
_AXIS = QColor("#3e4255")
|
||||||
_WL_DESIGN = QColor("#4da8ff") # flotación de diseño (más gruesa)
|
|
||||||
|
|
||||||
|
# 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):
|
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:
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._hull: Optional[Hull] = None
|
self._hull: Optional[Hull] = None
|
||||||
self._scale = 1.0
|
self._scale = 1.0
|
||||||
self._offset = QPointF(0.0, 0.0)
|
self._offset = QPointF(0.0, 0.0)
|
||||||
self._drag_start: Optional[QPointF] = None
|
self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho)
|
||||||
self.setMouseTracking(True)
|
|
||||||
|
# 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:
|
def set_hull(self, hull: Optional[Hull]) -> None:
|
||||||
self._hull = hull
|
self._hull = hull
|
||||||
|
self._hover_idx = None
|
||||||
|
self._drag_idx = None
|
||||||
self._fit_to_view()
|
self._fit_to_view()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
|
||||||
# Transformación mundo → pantalla
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _w2s(self, wx: float, wy: float) -> QPointF:
|
def _w2s(self, wx: float, wy: float) -> QPointF:
|
||||||
"""Coordenada mundo → coordenada de pantalla."""
|
|
||||||
return QPointF(
|
return QPointF(
|
||||||
wx * self._scale + self._offset.x(),
|
wx * self._scale + self._offset.x(),
|
||||||
wy * self._scale + self._offset.y(),
|
wy * self._scale + self._offset.y(),
|
||||||
@@ -84,7 +105,6 @@ class _BaseViewer(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _fit_to_view(self) -> None:
|
def _fit_to_view(self) -> None:
|
||||||
"""Ajusta zoom y offset para encuadrar el casco."""
|
|
||||||
if self._hull is None:
|
if self._hull is None:
|
||||||
return
|
return
|
||||||
bbox = self._world_bbox()
|
bbox = self._world_bbox()
|
||||||
@@ -96,30 +116,29 @@ class _BaseViewer(QWidget):
|
|||||||
return
|
return
|
||||||
pw, ph = max(self.width(), 100), max(self.height(), 100)
|
pw, ph = max(self.width(), 100), max(self.height(), 100)
|
||||||
margin = 0.08
|
margin = 0.08
|
||||||
scale_x = pw * (1 - margin * 2) / ww
|
self._scale = min(
|
||||||
scale_y = ph * (1 - margin * 2) / wh
|
pw * (1 - margin * 2) / ww,
|
||||||
self._scale = min(scale_x, scale_y)
|
ph * (1 - margin * 2) / wh,
|
||||||
# Centrar
|
)
|
||||||
cx = pw / 2 - (wx0 + ww / 2) * self._scale
|
cx = pw / 2 - (wx0 + ww / 2) * self._scale
|
||||||
cy = ph / 2 - (wy0 + wh / 2) * self._scale
|
cy = ph / 2 - (wy0 + wh / 2) * self._scale
|
||||||
self._offset = QPointF(cx, cy)
|
self._offset = QPointF(cx, cy)
|
||||||
|
|
||||||
def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
|
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()
|
self._fit_to_view()
|
||||||
super().resizeEvent(event)
|
super().resizeEvent(event)
|
||||||
|
|
||||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||||
|
if self._drag_idx is not None:
|
||||||
|
return
|
||||||
delta = event.angleDelta().y()
|
delta = event.angleDelta().y()
|
||||||
factor = 1.15 if delta > 0 else 1.0 / 1.15
|
factor = 1.15 if delta > 0 else 1.0 / 1.15
|
||||||
pos = event.position()
|
pos = event.position()
|
||||||
# Zoom centrado en el cursor
|
|
||||||
self._offset = QPointF(
|
self._offset = QPointF(
|
||||||
pos.x() + (self._offset.x() - pos.x()) * factor,
|
pos.x() + (self._offset.x() - pos.x()) * factor,
|
||||||
pos.y() + (self._offset.y() - pos.y()) * factor,
|
pos.y() + (self._offset.y() - pos.y()) * factor,
|
||||||
@@ -127,60 +146,111 @@ class _BaseViewer(QWidget):
|
|||||||
self._scale *= factor
|
self._scale *= factor
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
def mousePressEvent(self, event) -> None:
|
||||||
if event.button() in (Qt.MouseButton.MiddleButton,
|
btn = event.button()
|
||||||
Qt.MouseButton.RightButton):
|
if btn == Qt.MouseButton.LeftButton and self._hull is not None:
|
||||||
self._drag_start = event.position()
|
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]
|
def mouseMoveEvent(self, event) -> None:
|
||||||
if self._drag_start is not None:
|
# ── Paneo ─────────────────────────────────────────────────────────
|
||||||
d = event.position() - self._drag_start
|
if self._pan_start is not None:
|
||||||
|
d = event.position() - self._pan_start
|
||||||
self._offset += d
|
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()
|
self.update()
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
|
def mouseReleaseEvent(self, event) -> None:
|
||||||
self._drag_start = 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._fit_to_view()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ─── Métodos de edición (implementados por subclases) ────────────────────
|
||||||
# Helpers de dibujo
|
|
||||||
# ------------------------------------------------------------------
|
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:
|
def _draw_background(self, p: QPainter) -> None:
|
||||||
p.fillRect(self.rect(), _BG)
|
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:
|
def _draw_label(self, p: QPainter, text: str) -> None:
|
||||||
p.setPen(QPen(_TEXT))
|
p.setPen(QPen(_TEXT))
|
||||||
fnt = QFont("Monospace", 8)
|
p.setFont(QFont("Monospace", 8))
|
||||||
p.setFont(fnt)
|
p.drawText(
|
||||||
p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text)
|
self.rect().adjusted(4, 4, -4, -4),
|
||||||
|
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
|
||||||
def _draw_no_hull(self, p: QPainter, msg: str) -> None:
|
def _draw_no_hull(self, p: QPainter, msg: str) -> None:
|
||||||
p.setPen(QPen(_TEXT))
|
p.setPen(QPen(_TEXT))
|
||||||
fnt = QFont("Monospace", 10)
|
p.setFont(QFont("Monospace", 10))
|
||||||
p.setFont(fnt)
|
|
||||||
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
|
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
|
# 1. Body Plan — secciones transversales
|
||||||
@@ -190,19 +260,56 @@ class BodyPlanViewer(_BaseViewer):
|
|||||||
"""Vista de cuadernas (body plan).
|
"""Vista de cuadernas (body plan).
|
||||||
|
|
||||||
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
|
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).
|
Mitad de proa → estribor (derecha, verde).
|
||||||
La quilla maestra se resalta.
|
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]:
|
def _world_bbox(self) -> Optional[tuple]:
|
||||||
if self._hull is None:
|
if self._hull is None:
|
||||||
return None
|
return None
|
||||||
ot = self._hull.offsets
|
ot = self._hull.offsets
|
||||||
y_max = ot.max_half_breadth * 1.1
|
y_max = ot.max_half_breadth * 1.15
|
||||||
z_max = ot.draft * 1.15
|
z_max = ot.draft * 1.20
|
||||||
return (-y_max, -z_max * 0.05, y_max, z_max)
|
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 = QPainter(self)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
self._draw_background(p)
|
self._draw_background(p)
|
||||||
@@ -216,71 +323,74 @@ class BodyPlanViewer(_BaseViewer):
|
|||||||
T = self._hull.draft
|
T = self._hull.draft
|
||||||
n = ot.n_stations
|
n = ot.n_stations
|
||||||
|
|
||||||
# ── Grilla de líneas de agua ───────────────────────────────
|
# ── Líneas de agua — grilla horizontal ────────────────────────
|
||||||
wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine)
|
x_max = ot.max_half_breadth * 1.15
|
||||||
p.setPen(wl_pen)
|
for j, z in enumerate(ot.z_waterlines):
|
||||||
for z in ot.z_waterlines:
|
is_design = abs(z - T) < 1e-6
|
||||||
# Línea horizontal en z
|
if is_design:
|
||||||
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ínea de flotación de diseño (más gruesa)
|
|
||||||
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
|
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
|
||||||
x_max = ot.max_half_breadth * 1.1
|
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 visible)
|
||||||
|
p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine))
|
||||||
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
|
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
|
||||||
|
|
||||||
# ── Dibujar secciones ──────────────────────────────────────
|
# ── Secciones ─────────────────────────────────────────────────
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
# Progreso de AP a FP: proa a estribor, popa a babor
|
is_fwd = i >= n // 2
|
||||||
is_forward = i >= n // 2
|
is_mid = i == n // 2
|
||||||
|
|
||||||
if is_forward:
|
if is_mid:
|
||||||
pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor)
|
pen = QPen(_MIDSHIP, 2.5)
|
||||||
|
elif is_fwd:
|
||||||
|
pen = QPen(_SECTION, 1.4)
|
||||||
else:
|
else:
|
||||||
pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor)
|
pen = QPen(_SECTION_AFT, 1.4)
|
||||||
|
|
||||||
# Cuaderna maestra más gruesa
|
|
||||||
if i == n // 2:
|
|
||||||
pen.setWidthF(2.5)
|
|
||||||
pen.setColor(_PROFILE)
|
|
||||||
|
|
||||||
p.setPen(pen)
|
p.setPen(pen)
|
||||||
y_arr = ot.data[i, :]
|
y_arr = ot.data[i, :]
|
||||||
z_arr = ot.z_waterlines
|
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()
|
path = QPainterPath()
|
||||||
started = False
|
for k, (y, z) in enumerate(zip(y_arr, z_arr)):
|
||||||
for y, z in zip(y_arr, z_arr):
|
|
||||||
pt = self._w2s(sign * y, z)
|
pt = self._w2s(sign * y, z)
|
||||||
if not started:
|
if k == 0:
|
||||||
path.moveTo(pt)
|
path.moveTo(pt)
|
||||||
started = True
|
|
||||||
else:
|
else:
|
||||||
path.lineTo(pt)
|
path.lineTo(pt)
|
||||||
|
# Cerrar en quilla
|
||||||
|
path.lineTo(self._w2s(0.0, 0.0))
|
||||||
p.drawPath(path)
|
p.drawPath(path)
|
||||||
|
|
||||||
# ── Ejes ──────────────────────────────────────────────────
|
# ── Ejes ──────────────────────────────────────────────────────
|
||||||
p.setPen(QPen(_AXIS, 1))
|
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(-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.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")
|
self._draw_label(p, "BODY PLAN")
|
||||||
p.end()
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 2. Profile Viewer — vista lateral
|
# 2. Profile Viewer — vista lateral (solo lectura)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ProfileViewer(_BaseViewer):
|
class ProfileViewer(_BaseViewer):
|
||||||
"""Vista lateral del casco (perfil).
|
"""Vista lateral del casco (perfil).
|
||||||
|
|
||||||
Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m].
|
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]:
|
def _world_bbox(self) -> Optional[tuple]:
|
||||||
@@ -290,10 +400,10 @@ class ProfileViewer(_BaseViewer):
|
|||||||
-self._hull.lpp * 0.05,
|
-self._hull.lpp * 0.05,
|
||||||
-self._hull.draft * 0.15,
|
-self._hull.draft * 0.15,
|
||||||
self._hull.lpp * 1.05,
|
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 = QPainter(self)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
self._draw_background(p)
|
self._draw_background(p)
|
||||||
@@ -307,23 +417,24 @@ class ProfileViewer(_BaseViewer):
|
|||||||
T = self._hull.draft
|
T = self._hull.draft
|
||||||
Lpp = self._hull.lpp
|
Lpp = self._hull.lpp
|
||||||
|
|
||||||
# ── Grilla de estaciones ───────────────────────────────────
|
# ── Grilla de estaciones ───────────────────────────────────────
|
||||||
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
|
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
|
||||||
for x in ot.x_stations:
|
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):
|
for j, z in enumerate(ot.z_waterlines):
|
||||||
color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE
|
is_design = abs(z - T) < 1e-6
|
||||||
width = 1.5 if abs(z - T) < 1e-6 else 0.8
|
if is_design:
|
||||||
p.setPen(QPen(color, width))
|
p.setPen(QPen(_WL_DESIGN, 1.8))
|
||||||
# En perfil, la línea de agua aparece como línea recta horizontal
|
else:
|
||||||
# con el "ancho" dado por las semi-mangas (no visible en perfil lateral)
|
frac = j / max(ot.n_waterlines - 1, 1)
|
||||||
# Lo que sí se muestra: intersección de líneas de agua con la proa y la popa
|
color = QColor(_WATERLINE)
|
||||||
# Dibujamos la línea completa
|
color.setAlphaF(0.40 + 0.50 * frac)
|
||||||
|
p.setPen(QPen(color, 0.9))
|
||||||
p.drawLine(self._w2s(0, z), self._w2s(Lpp, z))
|
p.drawLine(self._w2s(0, z), self._w2s(Lpp, z))
|
||||||
|
|
||||||
# ── Cubierta (z = puntal) ──────────────────────────────────
|
# ── Cubierta ──────────────────────────────────────────────────
|
||||||
p.setPen(QPen(_DECK, 1.8))
|
p.setPen(QPen(_DECK, 1.8))
|
||||||
path_deck = QPainterPath()
|
path_deck = QPainterPath()
|
||||||
for k, x in enumerate(ot.x_stations):
|
for k, x in enumerate(ot.x_stations):
|
||||||
@@ -334,24 +445,23 @@ class ProfileViewer(_BaseViewer):
|
|||||||
path_deck.lineTo(pt)
|
path_deck.lineTo(pt)
|
||||||
p.drawPath(path_deck)
|
p.drawPath(path_deck)
|
||||||
|
|
||||||
# ── Quilla ─────────────────────────────────────────────────
|
# ── Quilla ────────────────────────────────────────────────────
|
||||||
p.setPen(QPen(_KEEL, 2.0))
|
p.setPen(QPen(_KEEL, 2.0))
|
||||||
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 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.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(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))
|
p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05))
|
||||||
|
|
||||||
# Etiquetas AP / FP
|
|
||||||
p.setPen(QPen(_TEXT))
|
p.setPen(QPen(_TEXT))
|
||||||
p.setFont(QFont("Monospace", 8))
|
p.setFont(QFont("Monospace", 8))
|
||||||
ap_pt = self._w2s(0, -T * 0.12)
|
_lbl = lambda text, x, z: p.drawText(
|
||||||
fp_pt = self._w2s(Lpp, -T * 0.12)
|
QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14),
|
||||||
p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14),
|
Qt.AlignmentFlag.AlignCenter, text
|
||||||
Qt.AlignmentFlag.AlignCenter, "AP")
|
)
|
||||||
p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14),
|
_lbl("AP", 0, -T * 0.12)
|
||||||
Qt.AlignmentFlag.AlignCenter, "FP")
|
_lbl("FP", Lpp, -T * 0.12)
|
||||||
|
|
||||||
self._draw_label(p, "PERFIL LATERAL")
|
self._draw_label(p, "PERFIL LATERAL")
|
||||||
p.end()
|
p.end()
|
||||||
@@ -365,7 +475,9 @@ class PlanViewer(_BaseViewer):
|
|||||||
"""Vista de planta (semiplano superior).
|
"""Vista de planta (semiplano superior).
|
||||||
|
|
||||||
Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor).
|
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]:
|
def _world_bbox(self) -> Optional[tuple]:
|
||||||
@@ -376,10 +488,37 @@ class PlanViewer(_BaseViewer):
|
|||||||
-self._hull.lpp * 0.05,
|
-self._hull.lpp * 0.05,
|
||||||
-y_max * 0.15,
|
-y_max * 0.15,
|
||||||
self._hull.lpp * 1.05,
|
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 = QPainter(self)
|
||||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
self._draw_background(p)
|
self._draw_background(p)
|
||||||
@@ -391,44 +530,57 @@ class PlanViewer(_BaseViewer):
|
|||||||
|
|
||||||
ot = self._hull.offsets
|
ot = self._hull.offsets
|
||||||
T = self._hull.draft
|
T = self._hull.draft
|
||||||
|
|
||||||
# ── Líneas de agua como contornos ──────────────────────────
|
|
||||||
n_wl = ot.n_waterlines
|
n_wl = ot.n_waterlines
|
||||||
|
|
||||||
|
# ── Líneas de agua como contornos ─────────────────────────────
|
||||||
for j in range(n_wl):
|
for j in range(n_wl):
|
||||||
z = ot.z_waterlines[j]
|
z = ot.z_waterlines[j]
|
||||||
is_design = abs(z - T) < 1e-6
|
is_design = abs(z - T) < 1e-6
|
||||||
color = _WL_DESIGN if is_design else _WATERLINE
|
frac = j / max(n_wl - 1, 1)
|
||||||
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
|
|
||||||
|
|
||||||
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()
|
path = QPainterPath()
|
||||||
x_arr = ot.x_stations
|
x_arr = ot.x_stations
|
||||||
y_arr = ot.data[:, j]
|
y_arr = ot.data[:, j]
|
||||||
started = False
|
for k, (x, y) in enumerate(zip(x_arr, y_arr)):
|
||||||
for x, y in zip(x_arr, y_arr):
|
|
||||||
pt = self._w2s(x, y)
|
pt = self._w2s(x, y)
|
||||||
if not started:
|
if k == 0:
|
||||||
path.moveTo(pt)
|
path.moveTo(pt)
|
||||||
started = True
|
|
||||||
else:
|
else:
|
||||||
path.lineTo(pt)
|
path.lineTo(pt)
|
||||||
p.drawPath(path)
|
p.drawPath(path)
|
||||||
|
|
||||||
# ── Eje de crujía ──────────────────────────────────────────
|
# ── Eje de crujía ─────────────────────────────────────────────
|
||||||
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
|
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
|
||||||
p.drawLine(
|
p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0))
|
||||||
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))
|
p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine))
|
||||||
y_max = ot.max_half_breadth
|
y_max = ot.max_half_breadth
|
||||||
for x in ot.x_stations:
|
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")
|
self._draw_label(p, "VISTA DE PLANTA")
|
||||||
p.end()
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Utilidad interna
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _dist(a: QPointF, b: QPointF) -> float:
|
||||||
|
return math.hypot(a.x() - b.x(), a.y() - b.y())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
from PySide6.QtCore import QSettings
|
from PySide6.QtCore import QSettings
|
||||||
|
|
||||||
APP_NAME = "ARShipDesign"
|
APP_NAME = "ARShipDesign"
|
||||||
ORG_NAME = "AlvaroRodriguez"
|
ORG_NAME = "AlvaroRomero"
|
||||||
|
|
||||||
# Claves de configuración
|
# Claves de configuración
|
||||||
KEY_LANGUAGE = "ui/language"
|
KEY_LANGUAGE = "ui/language"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def main() -> int:
|
|||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("AR-ShipDesign")
|
app.setApplicationName("AR-ShipDesign")
|
||||||
app.setOrganizationName("AlvaroRodriguez")
|
app.setOrganizationName("AlvaroRomero")
|
||||||
app.setApplicationVersion("0.1.0")
|
app.setApplicationVersion("0.1.0")
|
||||||
|
|
||||||
# Fuente por defecto
|
# Fuente por defecto
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ build-backend = "setuptools.backends.legacy:build"
|
|||||||
name = "arshipdesign"
|
name = "arshipdesign"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Software profesional de diseño naval — AR-ShipDesign"
|
description = "Software profesional de diseño naval — AR-ShipDesign"
|
||||||
authors = [{ name = "Álvaro Rodríguez" }]
|
authors = [{ name = "Álvaro Romero" }]
|
||||||
license = { file = "LICENSE.txt" }
|
license = { file = "LICENSE.txt" }
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -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 V024–V036: 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:
|
||||||
|
"""V024–V028: 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 V030–V036
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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}%"
|
||||||
Reference in New Issue
Block a user