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.
|
||||
|
||||
Copyright (c) 2025 Álvaro Rodríguez. Todos los derechos reservados.
|
||||
Copyright (c) 2025 Álvaro Romero. Todos los derechos reservados.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Álvaro Rodríguez"
|
||||
__author__ = "Álvaro Romero"
|
||||
__license__ = "Propietario"
|
||||
|
||||
@@ -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
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from arshipdesign.core.hull import Hull
|
||||
from arshipdesign.core.offsets import OffsetsTable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forma de sección — carena redonda tipo desplazamiento
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _round_bilge_section(z: float, T: float, y_wl: float, Cm: float) -> float:
|
||||
"""
|
||||
Sección de carena redonda parametrizada por Cm.
|
||||
|
||||
Usa la forma y = y_wl · sin(π/2 · (z/T)ⁿ) donde n se obtiene de Cm.
|
||||
|
||||
- n < 1 → sección llena (Cm alto), convexa hacia afuera
|
||||
- n = 1 → arco de círculo (Cm ≈ 0.637)
|
||||
- n > 1 → sección en V (Cm bajo), convexa hacia el eje
|
||||
|
||||
Propiedades garantizadas:
|
||||
y(z=0) = 0 (quilla puntual)
|
||||
y(z=T) = y_wl (manga en flotación)
|
||||
dy/dz ≥ 0 (monótona, sin inflexión indeseada)
|
||||
"""
|
||||
if y_wl < 1e-9 or T < 1e-9:
|
||||
return 0.0
|
||||
t = min(1.0, max(0.0, z / T))
|
||||
if t < 1e-12:
|
||||
return 0.0
|
||||
# Tabla calibrada Cm → n (verificada contra integración numérica)
|
||||
_CM = [0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.94]
|
||||
_N = [2.20, 1.60, 1.15, 0.80, 0.58, 0.42, 0.28]
|
||||
n = float(np.interp(Cm, _CM, _N))
|
||||
return float(y_wl * math.sin(math.pi / 2.0 * t ** n))
|
||||
|
||||
|
||||
def make_displacement_hull(
|
||||
name: str = "Crucero de Desplazamiento",
|
||||
lpp: float = 12.0,
|
||||
@@ -53,26 +86,20 @@ def make_displacement_hull(
|
||||
lcb_shift = 2.0 * (lcb_frac - 0.5) # ∈ [−1, 1]
|
||||
f_plan = _displacement_plan_form(xi, cb, lcb_shift)
|
||||
|
||||
# ── Exponente de forma de sección ──────────────────────────────────
|
||||
# alpha controla la plenitud de la sección transversal
|
||||
# alpha pequeño → sección llena (Cm alto)
|
||||
# alpha = 2*(1 - Cm) según aproximación de Munro-Smith
|
||||
alpha_mid = max(0.25, 2.0 * (1.0 - cm)) # ≈ 0.28 para Cm=0.86
|
||||
|
||||
data = np.zeros((n_stations, n_waterlines))
|
||||
|
||||
for i in range(n_stations):
|
||||
y_wl = (beam / 2.0) * f_plan[i]
|
||||
|
||||
# El exponente de sección varía: más fino en proa/popa
|
||||
local_fullness = f_plan[i]
|
||||
# En extremos (local_fullness→0) el exponente sube → sección más en V
|
||||
alpha = alpha_mid + (1.0 - alpha_mid) * (1.0 - local_fullness ** 0.5)
|
||||
alpha = np.clip(alpha, alpha_mid, 0.80)
|
||||
# Cm varía a lo largo de la eslora: lleno en midship, más en V en extremos.
|
||||
# f_plan[i]=1 → midship → Cm=cm; f_plan[i]→0 → extremos → Cm≈0.52
|
||||
local_cm = float(np.clip(
|
||||
cm * (0.42 + 0.58 * f_plan[i] ** 0.40),
|
||||
0.52, cm
|
||||
))
|
||||
|
||||
for j, z in enumerate(z_wl):
|
||||
v = z / draft # ∈ [0, 1]
|
||||
data[i, j] = y_wl * (v ** alpha)
|
||||
data[i, j] = _round_bilge_section(z, draft, y_wl, local_cm)
|
||||
|
||||
data = np.clip(data, 0.0, None)
|
||||
|
||||
|
||||
@@ -89,9 +89,11 @@ def make_planing_hull(
|
||||
tan_dr = max(np.tan(dr), 0.01)
|
||||
y_chine_dr = z_c / tan_dr
|
||||
y_chine = min(y_chine_dr, y_max)
|
||||
# Ligero ensanchamiento por encima del chine
|
||||
y = y_chine + (y_max - y_chine) * flare * (z - z_c) / (draft - z_c + 1e-9)
|
||||
y = min(y, y_max)
|
||||
# Costado recto (hard-chine) desde y_chine hasta y_max en cubierta.
|
||||
# El parámetro flare añade ensanche adicional sobre y_max.
|
||||
t_side = (z - z_c) / (draft - z_c + 1e-9)
|
||||
y = y_chine + (y_max * (1.0 + flare) - y_chine) * t_side
|
||||
y = min(y, y_max * (1.0 + flare))
|
||||
|
||||
data[i, j] = max(0.0, y)
|
||||
|
||||
|
||||
@@ -16,12 +16,59 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from arshipdesign.core.hull import Hull
|
||||
from arshipdesign.core.offsets import OffsetsTable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forma de sección — velero (V-fondo + cuerpo redondeado)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _sailing_section(
|
||||
z: float, T: float, y_wl: float, Cm: float, deadrise_deg: float
|
||||
) -> float:
|
||||
"""
|
||||
Sección de velero con V-fondo y cuerpo redondeado.
|
||||
|
||||
Dos zonas:
|
||||
- [0, z_b]: Fondo en V recto definido por el ángulo de astilla muerta.
|
||||
- [z_b, T]: Cuerpo redondeado (sin^n) desde el pantoque hasta la flotación.
|
||||
|
||||
Propiedades:
|
||||
y(z=0) = 0 (quilla)
|
||||
y(z=T) = y_wl (manga en flotación)
|
||||
Continuidad C⁰ en z=z_b
|
||||
"""
|
||||
if y_wl < 1e-9 or T < 1e-9:
|
||||
return 0.0
|
||||
t_full = min(1.0, max(0.0, z / T))
|
||||
if t_full < 1e-12:
|
||||
return 0.0
|
||||
|
||||
# Altura del pantoque (bilge): 40 % del calado
|
||||
z_b = 0.40 * T
|
||||
# Semi-manga en el pantoque desde el ángulo de astilla
|
||||
# y_b_v = z_b / tan(deadrise) → capped al 65% de y_wl
|
||||
dr_rad = math.radians(max(5.0, min(deadrise_deg, 80.0)))
|
||||
y_b_v = z_b / math.tan(dr_rad)
|
||||
y_b = min(y_b_v, 0.65 * y_wl)
|
||||
|
||||
if z <= z_b:
|
||||
# Fondo en V: lineal desde (0,0) hasta (z_b, y_b)
|
||||
return y_b * z / z_b if z_b > 1e-9 else 0.0
|
||||
else:
|
||||
# Cuerpo redondeado desde (z_b, y_b) hasta (T, y_wl)
|
||||
t = (z - z_b) / (T - z_b)
|
||||
_CM = [0.42, 0.55, 0.65, 0.75, 0.83]
|
||||
_N = [2.50, 1.70, 1.20, 0.82, 0.55]
|
||||
n = float(np.interp(Cm, _CM, _N))
|
||||
return float(y_b + (y_wl - y_b) * math.sin(math.pi / 2.0 * t ** n))
|
||||
|
||||
|
||||
def make_sailing_hull(
|
||||
name: str = "Velero Monocasco",
|
||||
lpp: float = 10.0,
|
||||
@@ -59,24 +106,22 @@ def make_sailing_hull(
|
||||
# Plan form: fina en proa, moderadamente llena a popa
|
||||
f_plan = _sailing_plan_form(xi, cb, lcb_shift)
|
||||
|
||||
# Exponente de sección
|
||||
alpha_mid = max(0.30, 2.0 * (1.0 - cm)) # ≈ 0.50 para Cm=0.75
|
||||
|
||||
data = np.zeros((n_stations, n_waterlines))
|
||||
dr_mid_rad = np.radians(deadrise_mid)
|
||||
|
||||
for i in range(n_stations):
|
||||
y_wl = (beam / 2.0) * f_plan[i]
|
||||
|
||||
# Interpolar entre sección en V (extremos) y sección redonda (midship)
|
||||
local_f = f_plan[i]
|
||||
alpha = alpha_mid + (0.75 - alpha_mid) * (1.0 - local_f ** 0.7)
|
||||
alpha = np.clip(alpha, alpha_mid, 0.80)
|
||||
# Cm y deadrise varían a lo largo de la eslora.
|
||||
# Midship: Cm=cm, deadrise=deadrise_mid
|
||||
# Extremos: más en V (menor Cm, mayor deadrise)
|
||||
local_cm = float(np.clip(
|
||||
cm * (0.38 + 0.62 * f_plan[i] ** 0.45),
|
||||
0.42, cm
|
||||
))
|
||||
local_dr = deadrise_mid + (60.0 - deadrise_mid) * max(0.0, 1.0 - f_plan[i] ** 0.5)
|
||||
|
||||
for j, z in enumerate(z_wl):
|
||||
v = z / draft
|
||||
y = y_wl * (v ** alpha)
|
||||
data[i, j] = max(0.0, y)
|
||||
data[i, j] = max(0.0, _sailing_section(z, draft, y_wl, local_cm, local_dr))
|
||||
|
||||
offsets = OffsetsTable(
|
||||
x_stations=x_sta,
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"type_workboat": "Workboat",
|
||||
"about_title": "About AR-ShipDesign",
|
||||
"about_version": "Version",
|
||||
"about_copyright": "Copyright © 2025 Álvaro Rodríguez. All rights reserved.",
|
||||
"about_copyright": "Copyright © 2025 Álvaro Romero. All rights reserved.",
|
||||
"tooltip_kmt": "KMT = KB + IT/∇ (transverse metacentric height)",
|
||||
"tooltip_gmt": "GMT = KMT − KG (corrected metacentric height)",
|
||||
"tooltip_tpc": "TPC = Aw · ρ / 100 (tonnes per cm immersion)",
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"type_workboat": "Workboat / Embarcación de trabajo",
|
||||
"about_title": "Acerca de AR-ShipDesign",
|
||||
"about_version": "Versión",
|
||||
"about_copyright": "Copyright © 2025 Álvaro Rodríguez",
|
||||
"about_copyright": "Copyright © 2025 Álvaro Romero",
|
||||
"tooltip_kmt": "KMT = KB + IT/∇ (altura metacéntrica transversal)",
|
||||
"tooltip_gmt": "GMT = KMT − KG (altura metacéntrica corregida)",
|
||||
"tooltip_tpc": "TPC = Aw · ρ / 100 (toneladas por cm de inmersión)",
|
||||
|
||||
@@ -860,6 +860,10 @@ class MainWindow(QMainWindow):
|
||||
if _vp is not None:
|
||||
_vp.set_canvas(_widget)
|
||||
|
||||
# Conectar edición interactiva de control points → propagar a todos los visores
|
||||
self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||
self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer)
|
||||
|
||||
# Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS)
|
||||
from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor
|
||||
self._offsets_editor = OffsetsEditor()
|
||||
@@ -1316,11 +1320,39 @@ class MainWindow(QMainWindow):
|
||||
"""Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto."""
|
||||
self._current_hull = hull
|
||||
if self._project is not None:
|
||||
self._project.set_hull(hull) # mantener proyecto sincronizado
|
||||
# _skip_offsets_editor=True para no re-poblar la tabla (ya esta actualizada)
|
||||
self._project.set_hull(hull)
|
||||
self._load_hull_viewers(hull, _skip_offsets_editor=True)
|
||||
self.statusBar().showMessage(f"Offsets actualizados — {hull.name}")
|
||||
|
||||
def _on_offsets_edited_from_viewer(self, offsets_table) -> None:
|
||||
"""Slot: un visor 2D editó un punto de control — sincronizar todos los visores.
|
||||
|
||||
La OffsetsTable ya fue modificada in-place por el visor (durante el drag).
|
||||
Aquí propagamos el cambio al visor 3D, al panel de hidrostáticos y al
|
||||
editor de offsets, e informamos al proyecto del estado nuevo.
|
||||
"""
|
||||
hull = self._current_hull
|
||||
if hull is None:
|
||||
return
|
||||
# hull.offsets ya contiene los cambios (modificación in-place del visor)
|
||||
if self._project is not None:
|
||||
self._project.set_hull(hull)
|
||||
# Refrescar la vista cruzada (edición body plan actualiza planta y viceversa)
|
||||
self._viewer_bodyplan.set_hull(hull)
|
||||
self._viewer_profile.set_hull(hull)
|
||||
self._viewer_plan.set_hull(hull)
|
||||
# Sincronizar editor de tabla de offsets
|
||||
self._offsets_editor.set_hull(hull)
|
||||
# Actualizar visor 3D con la geometría nueva
|
||||
if self._viewer_3d is not None:
|
||||
try:
|
||||
self._viewer_3d.load_hull(hull)
|
||||
except Exception as exc:
|
||||
logger.warning("Error al actualizar visor 3D: %s", exc)
|
||||
# Actualizar barra de hidrostáticos
|
||||
self._update_hydrostatics(hull)
|
||||
self.statusBar().showMessage(f"Geometría editada — {hull.name}")
|
||||
|
||||
def _update_hydrostatics(self, hull) -> None:
|
||||
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ class Viewer3DWidget(QWidget):
|
||||
super().__init__(parent)
|
||||
self._plotter: Optional["QtInteractor"] = None
|
||||
self._ready = False
|
||||
self._pending_hull = None # hull recibido antes de que el plotter esté listo
|
||||
self._build_ui()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -105,7 +106,12 @@ class Viewer3DWidget(QWidget):
|
||||
# Configurar tema dark para que combine con la UI
|
||||
self._plotter.set_background("#1a1d30") # viewportCanvas color
|
||||
|
||||
# Cargar casco Wigley como geometría de bienvenida
|
||||
# Cargar casco pendiente (recibido antes del init) o Wigley por defecto
|
||||
if self._pending_hull is not None:
|
||||
mesh = self._pending_hull.to_mesh()
|
||||
self._render_hull_mesh(mesh)
|
||||
self._pending_hull = None
|
||||
else:
|
||||
self._load_default_wigley()
|
||||
self._ready = True
|
||||
logger.info("Viewer3DWidget: QtInteractor iniciado correctamente")
|
||||
@@ -136,12 +142,15 @@ class Viewer3DWidget(QWidget):
|
||||
def load_hull(self, hull) -> None:
|
||||
"""Carga un objeto Hull en el visor.
|
||||
|
||||
Si el plotter aún no ha terminado de inicializarse (race condition de 500 ms),
|
||||
guarda el hull como pendiente — se cargará al final de _init_plotter().
|
||||
|
||||
Parámetros
|
||||
----------
|
||||
hull : arshipdesign.core.hull.Hull
|
||||
"""
|
||||
if not self._ready or self._plotter is None:
|
||||
logger.warning("Viewer3DWidget no listo — hull no cargado")
|
||||
self._pending_hull = hull # se cargará cuando _init_plotter termine
|
||||
return
|
||||
try:
|
||||
mesh = hull.to_mesh()
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""
|
||||
Visores 2D del plano de líneas del casco.
|
||||
Visores 2D del plano de líneas del casco — con edición interactiva.
|
||||
|
||||
Tres widgets especializados basados en QPainter:
|
||||
• BodyPlanViewer — secciones transversales (body plan)
|
||||
• ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla)
|
||||
• PlanViewer — vista de planta (líneas de agua desde arriba)
|
||||
|
||||
Cada uno acepta un objeto Hull y se actualiza al llamar set_hull().
|
||||
Soportan zoom con rueda del ratón y paneo con botón central/derecho.
|
||||
Cada visor muestra la malla de puntos de control de la OffsetsTable.
|
||||
El usuario puede arrastrar cualquier punto para modificar la geometría;
|
||||
al soltar se emite la señal ``offsets_edited(OffsetsTable)``.
|
||||
|
||||
Soportan zoom con rueda del ratón y paneo con botón medio/derecho.
|
||||
Doble clic restablece el encuadre automático.
|
||||
|
||||
Referencia:
|
||||
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan.
|
||||
@@ -21,9 +25,9 @@ import math
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import (
|
||||
QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
||||
QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
@@ -34,44 +38,61 @@ from arshipdesign.core.hull import Hull
|
||||
# Paleta del tema
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_BG = QColor("#1a1d30")
|
||||
_GRID = QColor("#2a3060")
|
||||
_WATERLINE = QColor("#4da8ff") # azul cyan
|
||||
_SECTION = QColor("#48a858") # verde
|
||||
_PROFILE = QColor("#e8a020") # dorado
|
||||
_DECK = QColor("#8868c8") # púrpura
|
||||
_KEEL = QColor("#e06060") # rojo suave
|
||||
_GRID = QColor("#2a3060") # Estaciones (muy tenue)
|
||||
_WATERLINE = QColor("#4da8ff") # Líneas de agua
|
||||
_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa)
|
||||
_SECTION = QColor("#48a858") # Secciones de proa (verde)
|
||||
_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul)
|
||||
_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado)
|
||||
_DECK = QColor("#8868c8") # Línea de cubierta (púrpura)
|
||||
_KEEL = QColor("#e06060") # Quilla (rojo suave)
|
||||
_TEXT = QColor("#7a8ba8")
|
||||
_AXIS = QColor("#3e4255")
|
||||
_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):
|
||||
"""Widget base con zoom/paneo común."""
|
||||
"""Widget base con zoom/paneo y edición de puntos de control."""
|
||||
|
||||
# Emitido cuando el usuario arrastra un punto y suelta el botón
|
||||
offsets_edited = Signal(object) # OffsetsTable modificada
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._hull: Optional[Hull] = None
|
||||
self._scale = 1.0
|
||||
self._offset = QPointF(0.0, 0.0)
|
||||
self._drag_start: Optional[QPointF] = None
|
||||
self.setMouseTracking(True)
|
||||
self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho)
|
||||
|
||||
# Estado de edición de puntos de control
|
||||
self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline)
|
||||
self._drag_idx: Optional[tuple[int, int]] = None
|
||||
self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa)
|
||||
|
||||
self.setMouseTracking(True)
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
|
||||
# ─── API pública ──────────────────────────────────────────────────────────
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def set_hull(self, hull: Optional[Hull]) -> None:
|
||||
self._hull = hull
|
||||
self._hover_idx = None
|
||||
self._drag_idx = None
|
||||
self._fit_to_view()
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Transformación mundo → pantalla
|
||||
# ------------------------------------------------------------------
|
||||
# ─── Transform mundo ↔ pantalla ──────────────────────────────────────────
|
||||
|
||||
def _w2s(self, wx: float, wy: float) -> QPointF:
|
||||
"""Coordenada mundo → coordenada de pantalla."""
|
||||
return QPointF(
|
||||
wx * self._scale + self._offset.x(),
|
||||
wy * self._scale + self._offset.y(),
|
||||
@@ -84,7 +105,6 @@ class _BaseViewer(QWidget):
|
||||
)
|
||||
|
||||
def _fit_to_view(self) -> None:
|
||||
"""Ajusta zoom y offset para encuadrar el casco."""
|
||||
if self._hull is None:
|
||||
return
|
||||
bbox = self._world_bbox()
|
||||
@@ -96,30 +116,29 @@ class _BaseViewer(QWidget):
|
||||
return
|
||||
pw, ph = max(self.width(), 100), max(self.height(), 100)
|
||||
margin = 0.08
|
||||
scale_x = pw * (1 - margin * 2) / ww
|
||||
scale_y = ph * (1 - margin * 2) / wh
|
||||
self._scale = min(scale_x, scale_y)
|
||||
# Centrar
|
||||
self._scale = min(
|
||||
pw * (1 - margin * 2) / ww,
|
||||
ph * (1 - margin * 2) / wh,
|
||||
)
|
||||
cx = pw / 2 - (wx0 + ww / 2) * self._scale
|
||||
cy = ph / 2 - (wy0 + wh / 2) * self._scale
|
||||
self._offset = QPointF(cx, cy)
|
||||
|
||||
def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
|
||||
return None # subclases lo sobreescriben
|
||||
return None # subclases
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Eventos
|
||||
# ------------------------------------------------------------------
|
||||
# ─── Eventos ─────────────────────────────────────────────────────────────
|
||||
|
||||
def resizeEvent(self, event) -> None: # type: ignore[override]
|
||||
def resizeEvent(self, event) -> None:
|
||||
self._fit_to_view()
|
||||
super().resizeEvent(event)
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
if self._drag_idx is not None:
|
||||
return
|
||||
delta = event.angleDelta().y()
|
||||
factor = 1.15 if delta > 0 else 1.0 / 1.15
|
||||
pos = event.position()
|
||||
# Zoom centrado en el cursor
|
||||
self._offset = QPointF(
|
||||
pos.x() + (self._offset.x() - pos.x()) * factor,
|
||||
pos.y() + (self._offset.y() - pos.y()) * factor,
|
||||
@@ -127,60 +146,111 @@ class _BaseViewer(QWidget):
|
||||
self._scale *= factor
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() in (Qt.MouseButton.MiddleButton,
|
||||
Qt.MouseButton.RightButton):
|
||||
self._drag_start = event.position()
|
||||
def mousePressEvent(self, event) -> None:
|
||||
btn = event.button()
|
||||
if btn == Qt.MouseButton.LeftButton and self._hull is not None:
|
||||
idx = self._hit_test(event.position())
|
||||
if idx is not None:
|
||||
self._drag_idx = idx
|
||||
self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]])
|
||||
self.setCursor(Qt.CursorShape.SizeAllCursor)
|
||||
event.accept()
|
||||
return
|
||||
if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton):
|
||||
self._pan_start = event.position()
|
||||
|
||||
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
|
||||
if self._drag_start is not None:
|
||||
d = event.position() - self._drag_start
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
# ── Paneo ─────────────────────────────────────────────────────────
|
||||
if self._pan_start is not None:
|
||||
d = event.position() - self._pan_start
|
||||
self._offset += d
|
||||
self._drag_start = event.position()
|
||||
self._pan_start = event.position()
|
||||
self.update()
|
||||
return
|
||||
|
||||
# ── Arrastre de punto de control ──────────────────────────────────
|
||||
if self._drag_idx is not None and self._hull is not None:
|
||||
self._apply_drag(event.position(), self._drag_idx)
|
||||
self.update()
|
||||
return
|
||||
|
||||
# ── Hover ─────────────────────────────────────────────────────────
|
||||
old = self._hover_idx
|
||||
if self._hull is not None:
|
||||
self._hover_idx = self._hit_test(event.position())
|
||||
else:
|
||||
self._hover_idx = None
|
||||
cursor = (Qt.CursorShape.SizeAllCursor
|
||||
if self._hover_idx is not None
|
||||
else Qt.CursorShape.ArrowCursor)
|
||||
self.setCursor(cursor)
|
||||
if self._hover_idx != old:
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
|
||||
self._drag_start = None
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None:
|
||||
self._drag_idx = None
|
||||
self.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
if self._hull is not None:
|
||||
self.offsets_edited.emit(self._hull.offsets)
|
||||
event.accept()
|
||||
return
|
||||
if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton):
|
||||
self._pan_start = None
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override]
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
self._fit_to_view()
|
||||
self.update()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers de dibujo
|
||||
# ------------------------------------------------------------------
|
||||
# ─── Métodos de edición (implementados por subclases) ────────────────────
|
||||
|
||||
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
|
||||
"""Busca el punto de control más cercano dentro del umbral de captura."""
|
||||
return None # subclases
|
||||
|
||||
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
|
||||
"""Actualiza la OffsetsTable con la nueva posición del ratón."""
|
||||
pass # subclases
|
||||
|
||||
# ─── Helpers de dibujo ───────────────────────────────────────────────────
|
||||
|
||||
def _draw_background(self, p: QPainter) -> None:
|
||||
p.fillRect(self.rect(), _BG)
|
||||
|
||||
def _draw_axes(self, p: QPainter,
|
||||
x0w: float, x1w: float, y0w: float, y1w: float,
|
||||
x_label: str = "x [m]", y_label: str = "y [m]") -> None:
|
||||
"""Ejes y grilla con etiquetas."""
|
||||
p.setPen(QPen(_AXIS, 1, Qt.PenStyle.SolidLine))
|
||||
|
||||
# Eje X
|
||||
p0 = self._w2s(x0w, 0.0)
|
||||
p1 = self._w2s(x1w, 0.0)
|
||||
p.drawLine(p0, p1)
|
||||
|
||||
# Eje Y
|
||||
p0 = self._w2s(0.0, y0w)
|
||||
p1 = self._w2s(0.0, y1w)
|
||||
p.drawLine(p0, p1)
|
||||
|
||||
def _draw_label(self, p: QPainter, text: str) -> None:
|
||||
p.setPen(QPen(_TEXT))
|
||||
fnt = QFont("Monospace", 8)
|
||||
p.setFont(fnt)
|
||||
p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text)
|
||||
p.setFont(QFont("Monospace", 8))
|
||||
p.drawText(
|
||||
self.rect().adjusted(4, 4, -4, -4),
|
||||
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft,
|
||||
text,
|
||||
)
|
||||
|
||||
def _draw_no_hull(self, p: QPainter, msg: str) -> None:
|
||||
p.setPen(QPen(_TEXT))
|
||||
fnt = QFont("Monospace", 10)
|
||||
p.setFont(fnt)
|
||||
p.setFont(QFont("Monospace", 10))
|
||||
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
|
||||
|
||||
def _draw_control_point(
|
||||
self,
|
||||
p: QPainter,
|
||||
screen_pt: QPointF,
|
||||
idx: tuple[int, int],
|
||||
) -> None:
|
||||
"""Dibuja un punto de control con color según estado."""
|
||||
if idx == self._drag_idx:
|
||||
color = _CPT_DRAG
|
||||
r = _CPT_RADIUS * 1.8
|
||||
elif idx == self._hover_idx:
|
||||
color = _CPT_HOVER
|
||||
r = _CPT_RADIUS * 1.5
|
||||
else:
|
||||
color = _CPT_NORMAL
|
||||
r = _CPT_RADIUS
|
||||
p.setPen(QPen(color.darker(130), 1))
|
||||
p.setBrush(QBrush(color))
|
||||
p.drawEllipse(screen_pt, r, r)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Body Plan — secciones transversales
|
||||
@@ -190,19 +260,56 @@ class BodyPlanViewer(_BaseViewer):
|
||||
"""Vista de cuadernas (body plan).
|
||||
|
||||
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
|
||||
Muestra mitad de babor izquierda (y<0) y estribor derecha (y>0).
|
||||
La quilla maestra se resalta.
|
||||
Mitad de proa → estribor (derecha, verde).
|
||||
Mitad de popa → babor (izquierda, azul).
|
||||
|
||||
Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para
|
||||
cambiar la semi-manga en esa estación y línea de agua.
|
||||
"""
|
||||
|
||||
def _world_bbox(self) -> Optional[tuple]:
|
||||
if self._hull is None:
|
||||
return None
|
||||
ot = self._hull.offsets
|
||||
y_max = ot.max_half_breadth * 1.1
|
||||
z_max = ot.draft * 1.15
|
||||
y_max = ot.max_half_breadth * 1.15
|
||||
z_max = ot.draft * 1.20
|
||||
return (-y_max, -z_max * 0.05, y_max, z_max)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
# ── Edición ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _screen_pt(self, i: int, j: int) -> QPointF:
|
||||
"""Punto de control (i, j) en coordenadas de pantalla."""
|
||||
ot = self._hull.offsets
|
||||
y = ot.data[i, j]
|
||||
z = ot.z_waterlines[j]
|
||||
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
|
||||
return self._w2s(sign * y, z)
|
||||
|
||||
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
|
||||
if self._hull is None:
|
||||
return None
|
||||
ot = self._hull.offsets
|
||||
best_d, best_idx = _CPT_HIT, None
|
||||
for i in range(ot.n_stations):
|
||||
for j in range(ot.n_waterlines):
|
||||
d = _dist(pos, self._screen_pt(i, j))
|
||||
if d < best_d:
|
||||
best_d, best_idx = d, (i, j)
|
||||
return best_idx
|
||||
|
||||
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
|
||||
ot = self._hull.offsets
|
||||
i, j = idx
|
||||
sign = 1.0 if i >= ot.n_stations // 2 else -1.0
|
||||
wx, _ = self._s2w(pos.x(), pos.y())
|
||||
new_y = max(0.0, sign * wx)
|
||||
# Limitar al doble de la manga para evitar explosiones
|
||||
new_y = min(new_y, self._hull.beam)
|
||||
ot.data[i, j] = new_y
|
||||
|
||||
# ── Dibujo ────────────────────────────────────────────────────────────────
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self._draw_background(p)
|
||||
@@ -216,71 +323,74 @@ class BodyPlanViewer(_BaseViewer):
|
||||
T = self._hull.draft
|
||||
n = ot.n_stations
|
||||
|
||||
# ── Grilla de líneas de agua ───────────────────────────────
|
||||
wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine)
|
||||
p.setPen(wl_pen)
|
||||
for z in ot.z_waterlines:
|
||||
# Línea horizontal en z
|
||||
x_max = ot.max_half_breadth * 1.1
|
||||
left = self._w2s(-x_max, z)
|
||||
right = self._w2s( x_max, z)
|
||||
p.drawLine(left, right)
|
||||
|
||||
# Línea de flotación de diseño (más gruesa)
|
||||
# ── Líneas de agua — grilla horizontal ────────────────────────
|
||||
x_max = ot.max_half_breadth * 1.15
|
||||
for j, z in enumerate(ot.z_waterlines):
|
||||
is_design = abs(z - T) < 1e-6
|
||||
if is_design:
|
||||
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
|
||||
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))
|
||||
|
||||
# ── Dibujar secciones ──────────────────────────────────────
|
||||
# ── Secciones ─────────────────────────────────────────────────
|
||||
for i in range(n):
|
||||
# Progreso de AP a FP: proa a estribor, popa a babor
|
||||
is_forward = i >= n // 2
|
||||
is_fwd = i >= n // 2
|
||||
is_mid = i == n // 2
|
||||
|
||||
if is_forward:
|
||||
pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor)
|
||||
if is_mid:
|
||||
pen = QPen(_MIDSHIP, 2.5)
|
||||
elif is_fwd:
|
||||
pen = QPen(_SECTION, 1.4)
|
||||
else:
|
||||
pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor)
|
||||
|
||||
# Cuaderna maestra más gruesa
|
||||
if i == n // 2:
|
||||
pen.setWidthF(2.5)
|
||||
pen.setColor(_PROFILE)
|
||||
pen = QPen(_SECTION_AFT, 1.4)
|
||||
|
||||
p.setPen(pen)
|
||||
y_arr = ot.data[i, :]
|
||||
z_arr = ot.z_waterlines
|
||||
sign = 1.0 if is_forward else -1.0 # estribor o babor
|
||||
sign = 1.0 if is_fwd else -1.0
|
||||
|
||||
path = QPainterPath()
|
||||
started = False
|
||||
for y, z in zip(y_arr, z_arr):
|
||||
for k, (y, z) in enumerate(zip(y_arr, z_arr)):
|
||||
pt = self._w2s(sign * y, z)
|
||||
if not started:
|
||||
if k == 0:
|
||||
path.moveTo(pt)
|
||||
started = True
|
||||
else:
|
||||
path.lineTo(pt)
|
||||
# Cerrar en quilla
|
||||
path.lineTo(self._w2s(0.0, 0.0))
|
||||
p.drawPath(path)
|
||||
|
||||
# ── Ejes ──────────────────────────────────────────────────
|
||||
# ── Ejes ──────────────────────────────────────────────────────
|
||||
p.setPen(QPen(_AXIS, 1))
|
||||
x_max = ot.max_half_breadth * 1.1
|
||||
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.1)) # eje simétrico
|
||||
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía
|
||||
|
||||
# ── Puntos de control ─────────────────────────────────────────
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
for i in range(n):
|
||||
for j in range(ot.n_waterlines):
|
||||
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
|
||||
|
||||
self._draw_label(p, "BODY PLAN")
|
||||
p.end()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Profile Viewer — vista lateral
|
||||
# 2. Profile Viewer — vista lateral (solo lectura)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProfileViewer(_BaseViewer):
|
||||
"""Vista lateral del casco (perfil).
|
||||
|
||||
Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m].
|
||||
Muestra: líneas de agua proyectadas, perfil de cubierta, quilla.
|
||||
Muestra líneas de agua, perfil de cubierta y quilla.
|
||||
No es editable (las z son constantes en la OffsetsTable).
|
||||
"""
|
||||
|
||||
def _world_bbox(self) -> Optional[tuple]:
|
||||
@@ -290,10 +400,10 @@ class ProfileViewer(_BaseViewer):
|
||||
-self._hull.lpp * 0.05,
|
||||
-self._hull.draft * 0.15,
|
||||
self._hull.lpp * 1.05,
|
||||
self._hull.draft * 1.25,
|
||||
self._hull.draft * 1.30,
|
||||
)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
def paintEvent(self, event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self._draw_background(p)
|
||||
@@ -307,23 +417,24 @@ class ProfileViewer(_BaseViewer):
|
||||
T = self._hull.draft
|
||||
Lpp = self._hull.lpp
|
||||
|
||||
# ── Grilla de estaciones ───────────────────────────────────
|
||||
# ── Grilla de estaciones ───────────────────────────────────────
|
||||
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
|
||||
for x in ot.x_stations:
|
||||
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.15))
|
||||
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2))
|
||||
|
||||
# ── Líneas de agua en perfil (ancho máximo a cada z) ────────
|
||||
# ── Líneas de agua en perfil ───────────────────────────────────
|
||||
for j, z in enumerate(ot.z_waterlines):
|
||||
color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE
|
||||
width = 1.5 if abs(z - T) < 1e-6 else 0.8
|
||||
p.setPen(QPen(color, width))
|
||||
# En perfil, la línea de agua aparece como línea recta horizontal
|
||||
# con el "ancho" dado por las semi-mangas (no visible en perfil lateral)
|
||||
# Lo que sí se muestra: intersección de líneas de agua con la proa y la popa
|
||||
# Dibujamos la línea completa
|
||||
is_design = abs(z - T) < 1e-6
|
||||
if is_design:
|
||||
p.setPen(QPen(_WL_DESIGN, 1.8))
|
||||
else:
|
||||
frac = j / max(ot.n_waterlines - 1, 1)
|
||||
color = QColor(_WATERLINE)
|
||||
color.setAlphaF(0.40 + 0.50 * frac)
|
||||
p.setPen(QPen(color, 0.9))
|
||||
p.drawLine(self._w2s(0, z), self._w2s(Lpp, z))
|
||||
|
||||
# ── Cubierta (z = puntal) ──────────────────────────────────
|
||||
# ── Cubierta ──────────────────────────────────────────────────
|
||||
p.setPen(QPen(_DECK, 1.8))
|
||||
path_deck = QPainterPath()
|
||||
for k, x in enumerate(ot.x_stations):
|
||||
@@ -334,24 +445,23 @@ class ProfileViewer(_BaseViewer):
|
||||
path_deck.lineTo(pt)
|
||||
p.drawPath(path_deck)
|
||||
|
||||
# ── Quilla ─────────────────────────────────────────────────
|
||||
# ── Quilla ────────────────────────────────────────────────────
|
||||
p.setPen(QPen(_KEEL, 2.0))
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0))
|
||||
|
||||
# ── Perpendiculares AP y FP ────────────────────────────────
|
||||
# ── Perpendiculares AP / FP ────────────────────────────────────
|
||||
p.setPen(QPen(_AXIS, 1.5))
|
||||
p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05))
|
||||
p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05))
|
||||
|
||||
# Etiquetas AP / FP
|
||||
p.setPen(QPen(_TEXT))
|
||||
p.setFont(QFont("Monospace", 8))
|
||||
ap_pt = self._w2s(0, -T * 0.12)
|
||||
fp_pt = self._w2s(Lpp, -T * 0.12)
|
||||
p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14),
|
||||
Qt.AlignmentFlag.AlignCenter, "AP")
|
||||
p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14),
|
||||
Qt.AlignmentFlag.AlignCenter, "FP")
|
||||
_lbl = lambda text, x, z: p.drawText(
|
||||
QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14),
|
||||
Qt.AlignmentFlag.AlignCenter, text
|
||||
)
|
||||
_lbl("AP", 0, -T * 0.12)
|
||||
_lbl("FP", Lpp, -T * 0.12)
|
||||
|
||||
self._draw_label(p, "PERFIL LATERAL")
|
||||
p.end()
|
||||
@@ -365,7 +475,9 @@ class PlanViewer(_BaseViewer):
|
||||
"""Vista de planta (semiplano superior).
|
||||
|
||||
Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor).
|
||||
Muestra: líneas de agua superpuestas como contornos.
|
||||
|
||||
Edición: arrastra un punto de contorno (x[i], y[i][j]) en y para cambiar
|
||||
la semi-manga de esa estación en esa línea de agua.
|
||||
"""
|
||||
|
||||
def _world_bbox(self) -> Optional[tuple]:
|
||||
@@ -376,10 +488,37 @@ class PlanViewer(_BaseViewer):
|
||||
-self._hull.lpp * 0.05,
|
||||
-y_max * 0.15,
|
||||
self._hull.lpp * 1.05,
|
||||
y_max * 1.20,
|
||||
y_max * 1.25,
|
||||
)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
# ── Edición ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _screen_pt(self, i: int, j: int) -> QPointF:
|
||||
ot = self._hull.offsets
|
||||
return self._w2s(ot.x_stations[i], ot.data[i, j])
|
||||
|
||||
def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]:
|
||||
if self._hull is None:
|
||||
return None
|
||||
ot = self._hull.offsets
|
||||
best_d, best_idx = _CPT_HIT, None
|
||||
for i in range(ot.n_stations):
|
||||
for j in range(ot.n_waterlines):
|
||||
d = _dist(pos, self._screen_pt(i, j))
|
||||
if d < best_d:
|
||||
best_d, best_idx = d, (i, j)
|
||||
return best_idx
|
||||
|
||||
def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None:
|
||||
ot = self._hull.offsets
|
||||
i, j = idx
|
||||
_, wy = self._s2w(pos.x(), pos.y())
|
||||
new_y = max(0.0, min(wy, self._hull.beam))
|
||||
ot.data[i, j] = new_y
|
||||
|
||||
# ── Dibujo ────────────────────────────────────────────────────────────────
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self._draw_background(p)
|
||||
@@ -391,44 +530,57 @@ class PlanViewer(_BaseViewer):
|
||||
|
||||
ot = self._hull.offsets
|
||||
T = self._hull.draft
|
||||
|
||||
# ── Líneas de agua como contornos ──────────────────────────
|
||||
n_wl = ot.n_waterlines
|
||||
|
||||
# ── Líneas de agua como contornos ─────────────────────────────
|
||||
for j in range(n_wl):
|
||||
z = ot.z_waterlines[j]
|
||||
is_design = abs(z - T) < 1e-6
|
||||
color = _WL_DESIGN if is_design else _WATERLINE
|
||||
alpha = int(60 + 195 * j / max(n_wl - 1, 1))
|
||||
c = QColor(color)
|
||||
c.setAlpha(alpha)
|
||||
width = 2.0 if is_design else 0.9
|
||||
frac = j / max(n_wl - 1, 1)
|
||||
|
||||
p.setPen(QPen(c, width))
|
||||
if is_design:
|
||||
color = QColor(_WL_DESIGN)
|
||||
color.setAlphaF(1.0)
|
||||
width = 2.0
|
||||
else:
|
||||
color = QColor(_WATERLINE)
|
||||
color.setAlphaF(0.30 + 0.55 * frac)
|
||||
width = 0.9
|
||||
|
||||
p.setPen(QPen(color, width))
|
||||
path = QPainterPath()
|
||||
x_arr = ot.x_stations
|
||||
y_arr = ot.data[:, j]
|
||||
started = False
|
||||
for x, y in zip(x_arr, y_arr):
|
||||
for k, (x, y) in enumerate(zip(x_arr, y_arr)):
|
||||
pt = self._w2s(x, y)
|
||||
if not started:
|
||||
if k == 0:
|
||||
path.moveTo(pt)
|
||||
started = True
|
||||
else:
|
||||
path.lineTo(pt)
|
||||
p.drawPath(path)
|
||||
|
||||
# ── Eje de crujía ──────────────────────────────────────────
|
||||
# ── Eje de crujía ─────────────────────────────────────────────
|
||||
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
|
||||
p.drawLine(
|
||||
self._w2s(0, 0),
|
||||
self._w2s(self._hull.lpp, 0),
|
||||
)
|
||||
p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0))
|
||||
|
||||
# ── Estaciones (líneas verticales tenues) ──────────────────
|
||||
# ── Estaciones ────────────────────────────────────────────────
|
||||
p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine))
|
||||
y_max = ot.max_half_breadth
|
||||
for x in ot.x_stations:
|
||||
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.1))
|
||||
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15))
|
||||
|
||||
# ── Puntos de control ─────────────────────────────────────────
|
||||
for i in range(ot.n_stations):
|
||||
for j in range(n_wl):
|
||||
self._draw_control_point(p, self._screen_pt(i, j), (i, j))
|
||||
|
||||
self._draw_label(p, "VISTA DE PLANTA")
|
||||
p.end()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Utilidad interna
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dist(a: QPointF, b: QPointF) -> float:
|
||||
return math.hypot(a.x() - b.x(), a.y() - b.y())
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import QSettings
|
||||
|
||||
APP_NAME = "ARShipDesign"
|
||||
ORG_NAME = "AlvaroRodriguez"
|
||||
ORG_NAME = "AlvaroRomero"
|
||||
|
||||
# Claves de configuración
|
||||
KEY_LANGUAGE = "ui/language"
|
||||
|
||||
@@ -40,7 +40,7 @@ def main() -> int:
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("AR-ShipDesign")
|
||||
app.setOrganizationName("AlvaroRodriguez")
|
||||
app.setOrganizationName("AlvaroRomero")
|
||||
app.setApplicationVersion("0.1.0")
|
||||
|
||||
# Fuente por defecto
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ build-backend = "setuptools.backends.legacy:build"
|
||||
name = "arshipdesign"
|
||||
version = "0.1.0"
|
||||
description = "Software profesional de diseño naval — AR-ShipDesign"
|
||||
authors = [{ name = "Álvaro Rodríguez" }]
|
||||
authors = [{ name = "Álvaro Romero" }]
|
||||
license = { file = "LICENSE.txt" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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