feat(stability): Módulo 3 — Curva GZ + criterios IMO IS Code 2008
- gz_integrator.py: GZCurve, GZPoint, compute_gz_wall_sided (fórmula pared lateral), compute_gz_direct (integración Sutherland-Hodgman) - imo_is2008.py: IMOCriterion, IMOResult, check_imo_is2008 — 6 criterios A.2.1.1–A.2.1.6 del IS Code 2008 Cap.2 - gz_curve_widget.py: GZCurveWidget QPainter — curva cian, áreas sombreadas, líneas IMO, marcador AVS, tabla PASS/FAIL integrada - main_window.py: GZCurveWidget en MOD_STABILITY, _compute_and_show_gz, _on_show_stability conectado al ribbon - dark.qss: estilos GZCurveWidget - test_module3_stability.py: 33 tests S-01..S-28 (315 total, todos pasan) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,31 @@
|
|||||||
# arshipdesign/stability
|
"""
|
||||||
|
arshipdesign.stability — estabilidad estática e intacta.
|
||||||
|
|
||||||
|
Exporta:
|
||||||
|
- GZPoint, GZCurve — estructuras de datos
|
||||||
|
- compute_gz_wall_sided — fórmula de pared lateral (rápida)
|
||||||
|
- compute_gz_direct — integración directa 3-D (precisa)
|
||||||
|
- IMOCriterion, IMOResult — criterios IMO IS Code 2008
|
||||||
|
- check_imo_is2008 — verificación IS Code 2008 Cap.2
|
||||||
|
"""
|
||||||
|
from arshipdesign.stability.gz_integrator import (
|
||||||
|
GZPoint,
|
||||||
|
GZCurve,
|
||||||
|
compute_gz_wall_sided,
|
||||||
|
compute_gz_direct,
|
||||||
|
)
|
||||||
|
from arshipdesign.stability.imo_is2008 import (
|
||||||
|
IMOCriterion,
|
||||||
|
IMOResult,
|
||||||
|
check_imo_is2008,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GZPoint",
|
||||||
|
"GZCurve",
|
||||||
|
"compute_gz_wall_sided",
|
||||||
|
"compute_gz_direct",
|
||||||
|
"IMOCriterion",
|
||||||
|
"IMOResult",
|
||||||
|
"check_imo_is2008",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,579 @@
|
|||||||
|
"""
|
||||||
|
gz_integrator.py — Motor de cálculo de la curva GZ de estabilidad estática.
|
||||||
|
|
||||||
|
Dos métodos:
|
||||||
|
1. compute_gz_wall_sided — Fórmula de pared lateral (rápida, exacta hasta ~30°).
|
||||||
|
2. compute_gz_direct — Integración directa 3-D con recorte de polígono
|
||||||
|
por Sutherland-Hodgman (precisa a grandes ángulos).
|
||||||
|
|
||||||
|
Convenciones
|
||||||
|
------------
|
||||||
|
* Todos los ángulos de escora φ en grados en la API pública;
|
||||||
|
en radianes internamente.
|
||||||
|
* Sistema de coordenadas del casco (upright):
|
||||||
|
y = manga (+ estribor), z = altura (0 = quilla, + hacia arriba).
|
||||||
|
* Sistema de coordenadas del mundo (heeled):
|
||||||
|
Eje z_w vertical apuntando arriba; eje y_w horizontal apuntando
|
||||||
|
a estribor mundo.
|
||||||
|
* La escora φ es positiva a estribor.
|
||||||
|
|
||||||
|
Fórmula GZ
|
||||||
|
----------
|
||||||
|
GZ = y_B_world − KG · sin(φ)
|
||||||
|
donde y_B_world es la coordenada horizontal (eje y_w) del centro de
|
||||||
|
carena B en el sistema del mundo.
|
||||||
|
|
||||||
|
Autor: Álvaro Romero
|
||||||
|
Módulo 3 — AR-ShipDesign
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate import simpson as _scipy_simpson
|
||||||
|
from scipy.optimize import brentq
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dataclasses públicos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GZPoint:
|
||||||
|
"""Un punto de la curva GZ."""
|
||||||
|
phi_deg: float
|
||||||
|
gz: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GZCurve:
|
||||||
|
"""Curva GZ completa con estadísticas derivadas.
|
||||||
|
|
||||||
|
Atributos
|
||||||
|
---------
|
||||||
|
hull_name : str
|
||||||
|
draft : float [m]
|
||||||
|
kg : float [m] — Altura del centro de gravedad sobre la quilla.
|
||||||
|
gm : float [m] — Altura metacéntrica transversal GM = KMT - KG.
|
||||||
|
bmt : float [m] — Radio metacéntrico transversal.
|
||||||
|
points : list[GZPoint]
|
||||||
|
area_0_30 : float [m·rad]
|
||||||
|
area_30_40 : float [m·rad]
|
||||||
|
area_0_40 : float [m·rad]
|
||||||
|
gz_max : float [m]
|
||||||
|
phi_gz_max : float [°]
|
||||||
|
avs : float [°] — Ángulo de estabilidad nula (Angle of Vanishing Stability).
|
||||||
|
"""
|
||||||
|
hull_name: str
|
||||||
|
draft: float
|
||||||
|
kg: float
|
||||||
|
gm: float
|
||||||
|
bmt: float
|
||||||
|
points: list[GZPoint] = field(default_factory=list)
|
||||||
|
area_0_30: float = 0.0
|
||||||
|
area_30_40: float = 0.0
|
||||||
|
area_0_40: float = 0.0
|
||||||
|
gz_max: float = 0.0
|
||||||
|
phi_gz_max: float = 0.0
|
||||||
|
avs: float = 90.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Propiedades de acceso rápido a arrays
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def angles_deg(self) -> np.ndarray:
|
||||||
|
return np.array([p.phi_deg for p in self.points], dtype=float)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gz_values(self) -> np.ndarray:
|
||||||
|
return np.array([p.gz for p in self.points], dtype=float)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cálculo de áreas, gz_max y AVS
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _compute_areas(self) -> None:
|
||||||
|
"""Integra las áreas bajo la curva GZ y calcula gz_max, phi_gz_max, avs.
|
||||||
|
|
||||||
|
Usa scipy.integrate.simpson sobre los puntos disponibles.
|
||||||
|
Las áreas se calculan en [m·rad] (ángulos convertidos a radianes).
|
||||||
|
"""
|
||||||
|
phi = self.angles_deg
|
||||||
|
gz = self.gz_values
|
||||||
|
|
||||||
|
if len(phi) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convertir ángulos a radianes para la integración
|
||||||
|
phi_rad = np.deg2rad(phi)
|
||||||
|
|
||||||
|
# ── Área 0–30° ──────────────────────────────────────────────
|
||||||
|
mask_030 = phi <= 30.0
|
||||||
|
if mask_030.sum() >= 2:
|
||||||
|
self.area_0_30 = float(abs(_scipy_simpson(
|
||||||
|
gz[mask_030], x=phi_rad[mask_030]
|
||||||
|
)))
|
||||||
|
else:
|
||||||
|
self.area_0_30 = 0.0
|
||||||
|
|
||||||
|
# ── Área 30–40° ─────────────────────────────────────────────
|
||||||
|
# Incluir el punto exacto en 30° (interpolado si no existe)
|
||||||
|
gz30 = float(np.interp(30.0, phi, gz))
|
||||||
|
gz40 = float(np.interp(40.0, phi, gz))
|
||||||
|
|
||||||
|
mask_3040 = (phi >= 30.0) & (phi <= 40.0)
|
||||||
|
pts_3040_phi = np.concatenate([[30.0], phi[mask_3040], [40.0]])
|
||||||
|
pts_3040_gz = np.concatenate([[gz30], gz[mask_3040], [gz40]])
|
||||||
|
|
||||||
|
# Eliminar duplicados
|
||||||
|
_, unique_idx = np.unique(pts_3040_phi, return_index=True)
|
||||||
|
pts_3040_phi = pts_3040_phi[unique_idx]
|
||||||
|
pts_3040_gz = pts_3040_gz[unique_idx]
|
||||||
|
|
||||||
|
if len(pts_3040_phi) >= 2:
|
||||||
|
self.area_30_40 = float(abs(_scipy_simpson(
|
||||||
|
pts_3040_gz, x=np.deg2rad(pts_3040_phi)
|
||||||
|
)))
|
||||||
|
else:
|
||||||
|
self.area_30_40 = 0.0
|
||||||
|
|
||||||
|
# ── Área 0–40° ──────────────────────────────────────────────
|
||||||
|
mask_040 = phi <= 40.0
|
||||||
|
pts_040_phi = np.concatenate([phi[mask_040], [40.0]])
|
||||||
|
pts_040_gz = np.concatenate([gz[mask_040], [gz40]])
|
||||||
|
_, unique_idx2 = np.unique(pts_040_phi, return_index=True)
|
||||||
|
pts_040_phi = pts_040_phi[unique_idx2]
|
||||||
|
pts_040_gz = pts_040_gz[unique_idx2]
|
||||||
|
|
||||||
|
if len(pts_040_phi) >= 2:
|
||||||
|
self.area_0_40 = float(abs(_scipy_simpson(
|
||||||
|
pts_040_gz, x=np.deg2rad(pts_040_phi)
|
||||||
|
)))
|
||||||
|
else:
|
||||||
|
self.area_0_40 = 0.0
|
||||||
|
|
||||||
|
# ── GZ máximo y su ángulo ────────────────────────────────────
|
||||||
|
idx_max = int(np.argmax(gz))
|
||||||
|
self.gz_max = float(gz[idx_max])
|
||||||
|
self.phi_gz_max = float(phi[idx_max])
|
||||||
|
|
||||||
|
# ── AVS: primer cruce de GZ = 0 después del pico ────────────
|
||||||
|
# Buscar a partir del índice del máximo
|
||||||
|
avs = 90.0
|
||||||
|
for i in range(idx_max, len(gz) - 1):
|
||||||
|
if gz[i] > 0.0 and gz[i + 1] <= 0.0:
|
||||||
|
# Interpolación lineal para encontrar el cruce exacto
|
||||||
|
phi1, gz1 = phi[i], gz[i]
|
||||||
|
phi2, gz2 = phi[i + 1], gz[i + 1]
|
||||||
|
if abs(gz2 - gz1) > 1e-12:
|
||||||
|
avs = phi1 + (phi2 - phi1) * (-gz1) / (gz2 - gz1)
|
||||||
|
else:
|
||||||
|
avs = float(phi1)
|
||||||
|
break
|
||||||
|
self.avs = avs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Método 1: Fórmula de pared lateral (Wall-Sided)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def compute_gz_wall_sided(
|
||||||
|
hull,
|
||||||
|
draft: float,
|
||||||
|
kg: Optional[float] = None,
|
||||||
|
angles_deg: Optional[np.ndarray] = None,
|
||||||
|
rho: float = 1025.0,
|
||||||
|
) -> GZCurve:
|
||||||
|
"""Calcula la curva GZ por la fórmula de pared lateral.
|
||||||
|
|
||||||
|
GZ = sin(φ) · (GM + 0.5 · BM · tan²(φ))
|
||||||
|
|
||||||
|
Esta fórmula es exacta para cascos de paredes verticales (wall-sided)
|
||||||
|
a cualquier ángulo; para cascos con flare proporciona buena aproximación
|
||||||
|
hasta ~30° y subestima a ángulos mayores.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
hull : Hull
|
||||||
|
draft : float [m]
|
||||||
|
kg : float, optional — Si None, usa hull.depth * 0.55
|
||||||
|
angles_deg : array_like, optional — Default: 0..90 en pasos de 1°
|
||||||
|
rho : float — Densidad del agua [kg/m³]
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
GZCurve
|
||||||
|
"""
|
||||||
|
from arshipdesign.hydrostatics.upright import compute_upright
|
||||||
|
|
||||||
|
hydro = compute_upright(hull, draft, rho=rho)
|
||||||
|
|
||||||
|
kg_val = float(hull.depth * 0.55 if kg is None else kg)
|
||||||
|
gm = hydro.kmt - kg_val
|
||||||
|
bmt = hydro.bmt
|
||||||
|
|
||||||
|
if angles_deg is None:
|
||||||
|
phi_arr = np.linspace(0.0, 90.0, 91)
|
||||||
|
else:
|
||||||
|
phi_arr = np.asarray(angles_deg, dtype=float)
|
||||||
|
|
||||||
|
points: list[GZPoint] = []
|
||||||
|
for phi_deg in phi_arr:
|
||||||
|
phi_rad = math.radians(phi_deg)
|
||||||
|
sin_phi = math.sin(phi_rad)
|
||||||
|
tan_phi = math.tan(phi_rad) if abs(phi_rad) < math.radians(89.9) else math.tan(math.radians(89.9))
|
||||||
|
gz = sin_phi * (gm + 0.5 * bmt * tan_phi ** 2)
|
||||||
|
points.append(GZPoint(phi_deg=float(phi_deg), gz=float(gz)))
|
||||||
|
|
||||||
|
curve = GZCurve(
|
||||||
|
hull_name=hull.name,
|
||||||
|
draft=float(draft),
|
||||||
|
kg=kg_val,
|
||||||
|
gm=gm,
|
||||||
|
bmt=bmt,
|
||||||
|
points=points,
|
||||||
|
)
|
||||||
|
curve._compute_areas()
|
||||||
|
return curve
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Método 2: Integración directa (Direct)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clip_polygon_below_z(
|
||||||
|
poly: list[tuple[float, float]],
|
||||||
|
z_wl: float,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Sutherland-Hodgman clip: mantiene región z ≤ z_wl.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
poly : list of (y, z)
|
||||||
|
z_wl : float — línea de corte (cota de flotación en el sistema girado)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list of (y, z) — polígono recortado (puede ser vacío)
|
||||||
|
"""
|
||||||
|
if not poly:
|
||||||
|
return []
|
||||||
|
|
||||||
|
output = list(poly)
|
||||||
|
|
||||||
|
def _inside(pt: tuple[float, float]) -> bool:
|
||||||
|
return pt[1] <= z_wl
|
||||||
|
|
||||||
|
def _intersect(
|
||||||
|
a: tuple[float, float], b: tuple[float, float]
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""Intersección de segmento (a→b) con z = z_wl."""
|
||||||
|
ya, za = a
|
||||||
|
yb, zb = b
|
||||||
|
dz = zb - za
|
||||||
|
if abs(dz) < 1e-14:
|
||||||
|
return ((ya + yb) * 0.5, z_wl)
|
||||||
|
t = (z_wl - za) / dz
|
||||||
|
return (ya + t * (yb - ya), z_wl)
|
||||||
|
|
||||||
|
# Plano de recorte: z ≤ z_wl (mantener interior)
|
||||||
|
input_list = output
|
||||||
|
output = []
|
||||||
|
|
||||||
|
n = len(input_list)
|
||||||
|
for i in range(n):
|
||||||
|
curr = input_list[i]
|
||||||
|
prev = input_list[i - 1]
|
||||||
|
if _inside(curr):
|
||||||
|
if not _inside(prev):
|
||||||
|
output.append(_intersect(prev, curr))
|
||||||
|
output.append(curr)
|
||||||
|
elif _inside(prev):
|
||||||
|
output.append(_intersect(prev, curr))
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _polygon_area_centroid(
|
||||||
|
poly: list[tuple[float, float]],
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
"""Área y centroide de un polígono cerrado por la fórmula de Shoelace.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
area : float — Área absoluta (siempre ≥ 0).
|
||||||
|
yc : float — Coordenada y del centroide.
|
||||||
|
zc : float — Coordenada z del centroide.
|
||||||
|
"""
|
||||||
|
n = len(poly)
|
||||||
|
if n < 3:
|
||||||
|
if n == 0:
|
||||||
|
return 0.0, 0.0, 0.0
|
||||||
|
y0, z0 = poly[0]
|
||||||
|
return 0.0, float(y0), float(z0)
|
||||||
|
|
||||||
|
# Shoelace
|
||||||
|
ys = np.array([p[0] for p in poly], dtype=float)
|
||||||
|
zs = np.array([p[1] for p in poly], dtype=float)
|
||||||
|
|
||||||
|
# Área con signo
|
||||||
|
cross = ys[:-1] * zs[1:] - ys[1:] * zs[:-1]
|
||||||
|
# Cerrar el polígono
|
||||||
|
cross_close = ys[-1] * zs[0] - ys[0] * zs[-1]
|
||||||
|
total_cross = np.sum(cross) + cross_close
|
||||||
|
area_signed = 0.5 * total_cross
|
||||||
|
area = abs(area_signed)
|
||||||
|
|
||||||
|
if area < 1e-15:
|
||||||
|
return 0.0, float(np.mean(ys)), float(np.mean(zs))
|
||||||
|
|
||||||
|
# Centroides
|
||||||
|
cx_terms = (ys[:-1] + ys[1:]) * cross
|
||||||
|
cz_terms = (zs[:-1] + zs[1:]) * cross
|
||||||
|
cx_close = (ys[-1] + ys[0]) * cross_close
|
||||||
|
cz_close = (zs[-1] + zs[0]) * cross_close
|
||||||
|
|
||||||
|
yc = (np.sum(cx_terms) + cx_close) / (6.0 * area_signed)
|
||||||
|
zc = (np.sum(cz_terms) + cz_close) / (6.0 * area_signed)
|
||||||
|
|
||||||
|
return area, float(yc), float(zc)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_section_polygon_extended(
|
||||||
|
half_breadths: np.ndarray,
|
||||||
|
z_positions: np.ndarray,
|
||||||
|
z_extend: float,
|
||||||
|
) -> list[tuple[float, float]]:
|
||||||
|
"""Construye el polígono extendido de una sección transversal.
|
||||||
|
|
||||||
|
Para capturar correctamente el cálculo de GZ a grandes ángulos,
|
||||||
|
el polígono se extiende con paredes verticales (wall-sided) desde
|
||||||
|
la línea de flotación upright hasta z_extend. Esto garantiza que
|
||||||
|
la integral de Sutherland-Hodgman captura el cuño de inmersión /
|
||||||
|
emersión al escorar.
|
||||||
|
|
||||||
|
Geometría:
|
||||||
|
- Estribor: de quilla (j=0) hacia arriba hasta el tope de la sección,
|
||||||
|
luego pared vertical hasta z_extend.
|
||||||
|
- Babor: pared vertical de z_extend hacia abajo, luego espejo de estribor
|
||||||
|
hasta la quilla.
|
||||||
|
- Resultado: 2n+2 vértices (n puntos de sección × 2 bandas + 2 extensiones).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
half_breadths : ndarray, shape (n,)
|
||||||
|
z_positions : ndarray, shape (n,) — creciente desde la quilla
|
||||||
|
z_extend : float — altura máxima del polígono (m)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
list of (y, z) — polígono cerrado en sentido antihorario
|
||||||
|
"""
|
||||||
|
n = len(z_positions)
|
||||||
|
y_top = float(half_breadths[-1])
|
||||||
|
z_top = float(z_positions[-1]) # normalmente = draft
|
||||||
|
|
||||||
|
poly: list[tuple[float, float]] = []
|
||||||
|
# Estribor: de quilla (j=0) hacia arriba
|
||||||
|
for j in range(n):
|
||||||
|
poly.append((float(half_breadths[j]), float(z_positions[j])))
|
||||||
|
# Extensión estribor hasta z_extend (wall-sided)
|
||||||
|
if z_extend > z_top + 1e-9:
|
||||||
|
poly.append((y_top, z_extend))
|
||||||
|
# Extensión babor desde z_extend hacia abajo (wall-sided, mirror)
|
||||||
|
if z_extend > z_top + 1e-9:
|
||||||
|
poly.append((-y_top, z_extend))
|
||||||
|
# Babor: de tope hacia abajo (mirror)
|
||||||
|
for j in reversed(range(n)):
|
||||||
|
poly.append((-float(half_breadths[j]), float(z_positions[j])))
|
||||||
|
return poly
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_polygon_volume(
|
||||||
|
upright_polys: list[list[tuple[float, float]]],
|
||||||
|
dx: np.ndarray,
|
||||||
|
z_wl: float,
|
||||||
|
) -> float:
|
||||||
|
"""Suma del volumen de los polígonos recortados en z ≤ z_wl."""
|
||||||
|
vol = 0.0
|
||||||
|
for i, poly in enumerate(upright_polys):
|
||||||
|
clipped = _clip_polygon_below_z(poly, z_wl)
|
||||||
|
if clipped:
|
||||||
|
a, _, _ = _polygon_area_centroid(clipped)
|
||||||
|
vol += a * dx[i]
|
||||||
|
return vol
|
||||||
|
|
||||||
|
|
||||||
|
def compute_gz_direct(
|
||||||
|
hull,
|
||||||
|
draft: float,
|
||||||
|
kg: Optional[float] = None,
|
||||||
|
angles_deg: Optional[np.ndarray] = None,
|
||||||
|
rho: float = 1025.0,
|
||||||
|
) -> GZCurve:
|
||||||
|
"""Calcula la curva GZ por integración directa de secciones.
|
||||||
|
|
||||||
|
Para cada ángulo de escora φ (escora a estribor, φ > 0):
|
||||||
|
1. Construye el polígono extendido (wall-sided por encima del calado)
|
||||||
|
de cada sección para capturar los cuños de inmersión/emersión.
|
||||||
|
2. Aplica la rotación de casco-a-mundo (giro en sentido horario=escora
|
||||||
|
a estribor):
|
||||||
|
y_w = y·cos(φ) + z·sin(φ)
|
||||||
|
z_w = -y·sin(φ) + z·cos(φ)
|
||||||
|
3. Determina z_wl (posición de la flotación en el mundo) por bisección
|
||||||
|
(brentq) tal que el volumen sumergido = V_target.
|
||||||
|
4. Calcula y_B_world = centroide horizontal del volumen recortado.
|
||||||
|
5. GZ = y_B_world − KG·sin(φ)
|
||||||
|
|
||||||
|
La rotación utilizada es la convención de estabilidad naval estándar:
|
||||||
|
+φ = escora a estribor → el lado estribor (y_body > 0) baja.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
hull : Hull
|
||||||
|
draft : float [m]
|
||||||
|
kg : float, optional — Si None, usa hull.depth * 0.55
|
||||||
|
angles_deg : array_like, optional — Default: 0..90 en pasos de 1°
|
||||||
|
rho : float
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
GZCurve
|
||||||
|
"""
|
||||||
|
from arshipdesign.hydrostatics.upright import compute_upright
|
||||||
|
|
||||||
|
hydro = compute_upright(hull, draft, rho=rho)
|
||||||
|
bmt = hydro.bmt
|
||||||
|
kmt = hydro.kmt
|
||||||
|
kg_val = float(hull.depth * 0.55 if kg is None else kg)
|
||||||
|
gm = kmt - kg_val
|
||||||
|
|
||||||
|
if angles_deg is None:
|
||||||
|
phi_arr = np.linspace(0.0, 90.0, 91)
|
||||||
|
else:
|
||||||
|
phi_arr = np.asarray(angles_deg, dtype=float)
|
||||||
|
|
||||||
|
# ── Preparar secciones y pesos de integración trapezoidal ────────
|
||||||
|
sections = hull.offsets.to_sections()
|
||||||
|
x_sta = np.array([s.x for s in sections], dtype=float)
|
||||||
|
n_sta = len(x_sta)
|
||||||
|
|
||||||
|
dx = np.zeros(n_sta, dtype=float)
|
||||||
|
if n_sta >= 2:
|
||||||
|
dx[0] = (x_sta[1] - x_sta[0]) * 0.5
|
||||||
|
dx[-1] = (x_sta[-1] - x_sta[-2]) * 0.5
|
||||||
|
for i in range(1, n_sta - 1):
|
||||||
|
dx[i] = (x_sta[i + 1] - x_sta[i - 1]) * 0.5
|
||||||
|
|
||||||
|
# ── Polígonos extendidos en posición upright ──────────────────────
|
||||||
|
# Se extienden con paredes verticales hasta z_extend para capturar
|
||||||
|
# el cuño de inmersión a grandes ángulos de escora.
|
||||||
|
# z_extend debe ser suficientemente mayor que draft + beam/2 * sin(90°).
|
||||||
|
z_extend = float(draft) + float(hull.beam) * 1.5 + float(hull.depth) * 0.5
|
||||||
|
upright_polys: list[list[tuple[float, float]]] = []
|
||||||
|
for sec in sections:
|
||||||
|
poly = _build_section_polygon_extended(
|
||||||
|
sec.half_breadths, sec.z_positions, z_extend
|
||||||
|
)
|
||||||
|
upright_polys.append(poly)
|
||||||
|
|
||||||
|
# ── V_target: volumen coherente con los polígonos ─────────────────
|
||||||
|
# Usar el volumen que los polígonos dan al cortarlos en z ≤ draft,
|
||||||
|
# para garantizar coherencia numérica entre phi=0 y phi>0.
|
||||||
|
V_target = _compute_polygon_volume(upright_polys, dx, float(draft))
|
||||||
|
|
||||||
|
# Límites conservadores para la bisección
|
||||||
|
z_bisect_high = z_extend * 0.95 # justo por debajo del tope extendido
|
||||||
|
|
||||||
|
points: list[GZPoint] = []
|
||||||
|
|
||||||
|
for phi_deg in phi_arr:
|
||||||
|
phi_rad = math.radians(float(phi_deg))
|
||||||
|
cos_phi = math.cos(phi_rad)
|
||||||
|
sin_phi = math.sin(phi_rad)
|
||||||
|
|
||||||
|
if abs(phi_rad) < 1e-9:
|
||||||
|
# φ = 0: GZ = 0 exacto por simetría
|
||||||
|
points.append(GZPoint(phi_deg=float(phi_deg), gz=0.0))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Rotación de cuerpo a mundo para escora a estribor ────────
|
||||||
|
# Convención naval: +φ → estribor baja (giro horario en plano y-z)
|
||||||
|
# y_w = y·cos(φ) + z·sin(φ)
|
||||||
|
# z_w = -y·sin(φ) + z·cos(φ)
|
||||||
|
rotated_polys: list[list[tuple[float, float]]] = []
|
||||||
|
for poly in upright_polys:
|
||||||
|
world_poly = [
|
||||||
|
( y * cos_phi + z * sin_phi,
|
||||||
|
-y * sin_phi + z * cos_phi)
|
||||||
|
for y, z in poly
|
||||||
|
]
|
||||||
|
rotated_polys.append(world_poly)
|
||||||
|
|
||||||
|
# ── Bisección para z_wl en el sistema del mundo ───────────────
|
||||||
|
def _vol_minus_target(z_wl: float) -> float:
|
||||||
|
vol = 0.0
|
||||||
|
for i in range(n_sta):
|
||||||
|
clipped = _clip_polygon_below_z(rotated_polys[i], z_wl)
|
||||||
|
if clipped:
|
||||||
|
a, _, _ = _polygon_area_centroid(clipped)
|
||||||
|
vol += a * dx[i]
|
||||||
|
return vol - V_target
|
||||||
|
|
||||||
|
z_b_low = 1e-4
|
||||||
|
z_b_high = z_bisect_high
|
||||||
|
|
||||||
|
f_low = _vol_minus_target(z_b_low)
|
||||||
|
f_high = _vol_minus_target(z_b_high)
|
||||||
|
|
||||||
|
if f_low * f_high > 0:
|
||||||
|
# Fallback: fórmula wall-sided
|
||||||
|
phi_clamp = min(phi_rad, math.radians(89.0))
|
||||||
|
gz_ws = sin_phi * (gm + 0.5 * bmt * math.tan(phi_clamp) ** 2)
|
||||||
|
points.append(GZPoint(phi_deg=float(phi_deg), gz=float(gz_ws)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
z_wl = brentq(_vol_minus_target, z_b_low, z_b_high,
|
||||||
|
xtol=1e-5, maxiter=150)
|
||||||
|
except ValueError:
|
||||||
|
phi_clamp = min(phi_rad, math.radians(89.0))
|
||||||
|
gz_ws = sin_phi * (gm + 0.5 * bmt * math.tan(phi_clamp) ** 2)
|
||||||
|
points.append(GZPoint(phi_deg=float(phi_deg), gz=float(gz_ws)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ── Centroide horizontal de carena en el mundo ────────────────
|
||||||
|
vol_total = 0.0
|
||||||
|
moment_y = 0.0
|
||||||
|
|
||||||
|
for i in range(n_sta):
|
||||||
|
clipped = _clip_polygon_below_z(rotated_polys[i], z_wl)
|
||||||
|
if clipped:
|
||||||
|
a, yc, _ = _polygon_area_centroid(clipped)
|
||||||
|
vol_total += a * dx[i]
|
||||||
|
moment_y += a * yc * dx[i]
|
||||||
|
|
||||||
|
if vol_total > 1e-12:
|
||||||
|
y_B_world = moment_y / vol_total
|
||||||
|
else:
|
||||||
|
y_B_world = 0.0
|
||||||
|
|
||||||
|
# GZ = y_B_world - y_G_world
|
||||||
|
# G = (y_body=0, z_body=KG); en mundo (CW): y_G_w = 0*cos + KG*sin = KG*sin
|
||||||
|
gz = y_B_world - kg_val * sin_phi
|
||||||
|
points.append(GZPoint(phi_deg=float(phi_deg), gz=float(gz)))
|
||||||
|
|
||||||
|
curve = GZCurve(
|
||||||
|
hull_name=hull.name,
|
||||||
|
draft=float(draft),
|
||||||
|
kg=kg_val,
|
||||||
|
gm=gm,
|
||||||
|
bmt=bmt,
|
||||||
|
points=points,
|
||||||
|
)
|
||||||
|
curve._compute_areas()
|
||||||
|
return curve
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
imo_is2008.py — IMO IS Code 2008, Capítulo 2 — criterios de estabilidad intacta.
|
||||||
|
|
||||||
|
Criterios A.2.1 para buques de carga general y pequeñas embarcaciones:
|
||||||
|
2.1.1 Área 0–30° ≥ 0.055 m·rad
|
||||||
|
2.1.2 Área 0–40° ≥ 0.090 m·rad
|
||||||
|
2.1.3 Área 30–40° ≥ 0.030 m·rad
|
||||||
|
2.1.4 GZ a 30° ≥ 0.200 m
|
||||||
|
2.1.5 Ángulo de GZ máximo ≥ 25°
|
||||||
|
2.1.6 GM₀ ≥ 0.150 m
|
||||||
|
|
||||||
|
Referencia: IMO MSC.267(85) — IS Code 2008, Parte A, Cap. 2.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IMOCriterion:
|
||||||
|
"""Un criterio individual del IS Code 2008."""
|
||||||
|
code: str # e.g. "A.2.1.1"
|
||||||
|
description: str # Descripción corta
|
||||||
|
required: float # Valor mínimo requerido
|
||||||
|
achieved: float # Valor obtenido de la curva GZ
|
||||||
|
unit: str # Unidades (m·rad, m, °)
|
||||||
|
passed: bool # True si achieved >= required
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IMOResult:
|
||||||
|
"""Resultado completo de la verificación IMO IS Code 2008."""
|
||||||
|
criteria: list[IMOCriterion]
|
||||||
|
overall_passed: bool
|
||||||
|
|
||||||
|
def table_rows(self) -> list[tuple[str, str, str, str, bool]]:
|
||||||
|
"""Devuelve filas para la tabla: (code, description, required_str, achieved_str, passed).
|
||||||
|
|
||||||
|
El formato de los strings varía según las unidades:
|
||||||
|
- m·rad: 4 decimales
|
||||||
|
- m: 3 decimales
|
||||||
|
- °: 1 decimal
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for c in self.criteria:
|
||||||
|
if c.unit == "m·rad":
|
||||||
|
req_str = f"{c.required:.4f} {c.unit}"
|
||||||
|
ach_str = f"{c.achieved:.4f} {c.unit}"
|
||||||
|
elif c.unit == "m":
|
||||||
|
req_str = f"{c.required:.3f} {c.unit}"
|
||||||
|
ach_str = f"{c.achieved:.3f} {c.unit}"
|
||||||
|
elif c.unit == "°":
|
||||||
|
req_str = f"{c.required:.1f}{c.unit}"
|
||||||
|
ach_str = f"{c.achieved:.1f}{c.unit}"
|
||||||
|
else:
|
||||||
|
req_str = f"{c.required} {c.unit}"
|
||||||
|
ach_str = f"{c.achieved} {c.unit}"
|
||||||
|
rows.append((c.code, c.description, req_str, ach_str, c.passed))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def check_imo_is2008(gz) -> IMOResult:
|
||||||
|
"""Verifica todos los criterios IS Code 2008 Cap.2 para la curva GZ dada.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
gz : GZCurve
|
||||||
|
Curva de estabilidad calculada.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
IMOResult
|
||||||
|
Contiene la lista de criterios individuales y el resultado global.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from arshipdesign.stability.gz_integrator import GZCurve
|
||||||
|
|
||||||
|
def _criterion(
|
||||||
|
code: str,
|
||||||
|
desc: str,
|
||||||
|
req: float,
|
||||||
|
ach: float,
|
||||||
|
unit: str,
|
||||||
|
) -> IMOCriterion:
|
||||||
|
return IMOCriterion(
|
||||||
|
code=code,
|
||||||
|
description=desc,
|
||||||
|
required=req,
|
||||||
|
achieved=ach,
|
||||||
|
unit=unit,
|
||||||
|
passed=(ach >= req),
|
||||||
|
)
|
||||||
|
|
||||||
|
criteria: list[IMOCriterion] = []
|
||||||
|
|
||||||
|
# A.2.1.1 — Área bajo la curva GZ entre 0° y 30°
|
||||||
|
criteria.append(_criterion(
|
||||||
|
"A.2.1.1",
|
||||||
|
"Área 0–30°",
|
||||||
|
0.055,
|
||||||
|
float(gz.area_0_30),
|
||||||
|
"m·rad",
|
||||||
|
))
|
||||||
|
|
||||||
|
# A.2.1.2 — Área bajo la curva GZ entre 0° y 40°
|
||||||
|
criteria.append(_criterion(
|
||||||
|
"A.2.1.2",
|
||||||
|
"Área 0–40°",
|
||||||
|
0.090,
|
||||||
|
float(gz.area_0_40),
|
||||||
|
"m·rad",
|
||||||
|
))
|
||||||
|
|
||||||
|
# A.2.1.3 — Área bajo la curva GZ entre 30° y 40°
|
||||||
|
criteria.append(_criterion(
|
||||||
|
"A.2.1.3",
|
||||||
|
"Área 30–40°",
|
||||||
|
0.030,
|
||||||
|
float(gz.area_30_40),
|
||||||
|
"m·rad",
|
||||||
|
))
|
||||||
|
|
||||||
|
# A.2.1.4 — GZ a 30° de escora
|
||||||
|
gz30 = float(np.interp(30.0, gz.angles_deg, gz.gz_values))
|
||||||
|
criteria.append(_criterion(
|
||||||
|
"A.2.1.4",
|
||||||
|
"GZ a 30°",
|
||||||
|
0.200,
|
||||||
|
gz30,
|
||||||
|
"m",
|
||||||
|
))
|
||||||
|
|
||||||
|
# A.2.1.5 — Ángulo en que se produce el GZ máximo
|
||||||
|
criteria.append(_criterion(
|
||||||
|
"A.2.1.5",
|
||||||
|
"Ángulo GZ máx",
|
||||||
|
25.0,
|
||||||
|
float(gz.phi_gz_max),
|
||||||
|
"°",
|
||||||
|
))
|
||||||
|
|
||||||
|
# A.2.1.6 — Altura metacéntrica inicial GM₀
|
||||||
|
criteria.append(_criterion(
|
||||||
|
"A.2.1.6",
|
||||||
|
"GM₀",
|
||||||
|
0.150,
|
||||||
|
float(gz.gm),
|
||||||
|
"m",
|
||||||
|
))
|
||||||
|
|
||||||
|
overall = all(c.passed for c in criteria)
|
||||||
|
return IMOResult(criteria=criteria, overall_passed=overall)
|
||||||
@@ -47,6 +47,8 @@ from PySide6.QtWidgets import (
|
|||||||
from arshipdesign import __version__
|
from arshipdesign import __version__
|
||||||
from arshipdesign.core.project import Project
|
from arshipdesign.core.project import Project
|
||||||
from arshipdesign.utils.logger import get_logger
|
from arshipdesign.utils.logger import get_logger
|
||||||
|
from arshipdesign.stability import compute_gz_wall_sided, GZCurve, check_imo_is2008
|
||||||
|
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
|
||||||
from arshipdesign.utils.settings import (
|
from arshipdesign.utils.settings import (
|
||||||
add_recent_file,
|
add_recent_file,
|
||||||
get_language,
|
get_language,
|
||||||
@@ -813,6 +815,7 @@ class MainWindow(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._project: Optional[Project] = None
|
self._project: Optional[Project] = None
|
||||||
self._current_hull = None # Hull activo en todos los visores
|
self._current_hull = None # Hull activo en todos los visores
|
||||||
|
self._gz_widget: Optional[GZCurveWidget] = None
|
||||||
self._lang = get_language()
|
self._lang = get_language()
|
||||||
self._strings = _load_i18n(self._lang)
|
self._strings = _load_i18n(self._lang)
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
@@ -878,6 +881,10 @@ class MainWindow(QMainWindow):
|
|||||||
self._hydro_chart = HydrostaticsChartWidget()
|
self._hydro_chart = HydrostaticsChartWidget()
|
||||||
self._module_area.set_module_widget(ModuleArea.MOD_CURVES, self._hydro_chart)
|
self._module_area.set_module_widget(ModuleArea.MOD_CURVES, self._hydro_chart)
|
||||||
|
|
||||||
|
# Módulo de estabilidad GZ (sustituye el placeholder MOD_STABILITY)
|
||||||
|
self._gz_widget = GZCurveWidget()
|
||||||
|
self._module_area.set_module_widget(ModuleArea.MOD_STABILITY, self._gz_widget)
|
||||||
|
|
||||||
# Dock izquierdo — capas
|
# Dock izquierdo — capas
|
||||||
self._layers_panel = LayersPanel(self._strings)
|
self._layers_panel = LayersPanel(self._strings)
|
||||||
self._dock_layers = QDockWidget("Capas", self)
|
self._dock_layers = QDockWidget("Capas", self)
|
||||||
@@ -981,7 +988,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
|
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
|
||||||
lambda: self._module_area.activate(M.MOD_STABILITY), False)
|
self._on_show_stability)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
|
g.add_button(_spi(sp.SP_FileDialogDetailedView), "IMO IS2008", "Criterios IMO IS Code 2008", enabled=False)
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False)
|
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Avería", "Estabilidad en avería", enabled=False)
|
||||||
|
|
||||||
@@ -1114,7 +1121,7 @@ class MainWindow(QMainWindow):
|
|||||||
slot=self._on_export_hydrostatics_csv)
|
slot=self._on_export_hydrostatics_csv)
|
||||||
|
|
||||||
sm = m.addMenu("Estabilidad")
|
sm = m.addMenu("Estabilidad")
|
||||||
self._add_action(sm, "Curva GZ — Estabilidad estática", slot=lambda: self._module_area.activate(M.MOD_STABILITY), enabled=False)
|
self._add_action(sm, "Curva GZ — Estabilidad estática", slot=self._on_show_stability)
|
||||||
self._add_action(sm, "Criterios IMO IS Code 2008", enabled=False)
|
self._add_action(sm, "Criterios IMO IS Code 2008", enabled=False)
|
||||||
self._add_action(sm, "Criterio de viento A.749(18)", enabled=False)
|
self._add_action(sm, "Criterio de viento A.749(18)", enabled=False)
|
||||||
self._add_action(sm, "Estabilidad en avería (SOLAS 2009)", enabled=False)
|
self._add_action(sm, "Estabilidad en avería (SOLAS 2009)", enabled=False)
|
||||||
@@ -1330,6 +1337,8 @@ class MainWindow(QMainWindow):
|
|||||||
logger.warning("No se pudo cargar hull en visor 3D: %s", exc)
|
logger.warning("No se pudo cargar hull en visor 3D: %s", exc)
|
||||||
# ── Panel hidrostáticos ───────────────────────────────────
|
# ── Panel hidrostáticos ───────────────────────────────────
|
||||||
self._update_hydrostatics(hull)
|
self._update_hydrostatics(hull)
|
||||||
|
# ── Curva GZ (si el módulo está activo o precalcular) ─────
|
||||||
|
self._compute_and_show_gz()
|
||||||
|
|
||||||
def _on_offsets_dragging(self, offsets_table) -> None:
|
def _on_offsets_dragging(self, offsets_table) -> None:
|
||||||
"""Slot ligero — actualiza vistas 2D durante drag sin resetear zoom ni actualizar 3D."""
|
"""Slot ligero — actualiza vistas 2D durante drag sin resetear zoom ni actualizar 3D."""
|
||||||
@@ -1474,6 +1483,44 @@ class MainWindow(QMainWindow):
|
|||||||
from PySide6.QtWidgets import QMessageBox
|
from PySide6.QtWidgets import QMessageBox
|
||||||
QMessageBox.critical(self, "Error al exportar", str(exc))
|
QMessageBox.critical(self, "Error al exportar", str(exc))
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# CURVA GZ — ESTABILIDAD
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _compute_and_show_gz(self) -> None:
|
||||||
|
"""Calcula la curva GZ wall-sided y actualiza el widget de estabilidad."""
|
||||||
|
if self._current_hull is None:
|
||||||
|
return
|
||||||
|
if self._gz_widget is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
hull = self._current_hull
|
||||||
|
kg = hull.depth * 0.55
|
||||||
|
self.statusBar().showMessage("Calculando curva GZ…")
|
||||||
|
QApplication.processEvents()
|
||||||
|
gz_curve = compute_gz_wall_sided(hull, hull.draft, kg=kg)
|
||||||
|
imo_result = check_imo_is2008(gz_curve)
|
||||||
|
self._gz_widget.set_curve(gz_curve, imo_result)
|
||||||
|
# Actualizar indicador IMO en la barra de hidrostáticos
|
||||||
|
self._hydro.set_imo_status(
|
||||||
|
imo_result.overall_passed,
|
||||||
|
"" if imo_result.overall_passed else "GZ",
|
||||||
|
)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
f"Curva GZ calculada — {hull.name} "
|
||||||
|
f"GM={gz_curve.gm:.3f}m GZmax={gz_curve.gz_max:.3f}m "
|
||||||
|
f"AVS={gz_curve.avs:.0f}° "
|
||||||
|
f"IMO={'CUMPLE' if imo_result.overall_passed else 'FALLA'}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error al calcular curva GZ: %s", exc)
|
||||||
|
|
||||||
|
def _on_show_stability(self) -> None:
|
||||||
|
"""Muestra el módulo de estabilidad GZ (calcula si hay casco disponible)."""
|
||||||
|
if self._current_hull is not None:
|
||||||
|
self._compute_and_show_gz()
|
||||||
|
self._module_area.activate(ModuleArea.MOD_STABILITY)
|
||||||
|
|
||||||
def _ask_save(self) -> bool:
|
def _ask_save(self) -> bool:
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Cambios sin guardar",
|
self, "Cambios sin guardar",
|
||||||
|
|||||||
@@ -514,3 +514,29 @@ QLabel#hydroPlaceholder {
|
|||||||
background-color: #1a1d30;
|
background-color: #1a1d30;
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Módulo 3: Estabilidad ─── */
|
||||||
|
GZCurveWidget {
|
||||||
|
background: #0A0E1A;
|
||||||
|
border: 1px solid #1A2540;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#gzChartTitle {
|
||||||
|
color: #00FFCC;
|
||||||
|
font-family: "Rajdhani", "Segoe UI", sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
#gzPassLabel {
|
||||||
|
color: #00FF88;
|
||||||
|
font-family: "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#gzFailLabel {
|
||||||
|
color: #FF4444;
|
||||||
|
font-family: "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"""
|
||||||
|
gz_curve_widget.py — Visualización de la curva de brazos adrizantes (GZ).
|
||||||
|
|
||||||
|
Características:
|
||||||
|
- Curva GZ principal en cian eléctrico (#00FFCC)
|
||||||
|
- Áreas bajo la curva sombreadas:
|
||||||
|
0–30° verde (#00FF88 con alfa 0x20)
|
||||||
|
30–40° ámbar (#FFB300 con alfa 0x20)
|
||||||
|
>40° gris (#FFFFFF con alfa 0x10)
|
||||||
|
- Líneas de referencia IMO (GZ=0.20 m a 30°, GZ=0 a 40°)
|
||||||
|
- Tabla PASS/FAIL de criterios IS Code 2008 integrada (30%)
|
||||||
|
- Indicador del ángulo de estabilidad nula (AVS) en rojo
|
||||||
|
- Línea de GM₀ tangente al origen (pendiente = GM en rad/m)
|
||||||
|
- Barra de información de escora activa (hover)
|
||||||
|
|
||||||
|
Paleta oscura marinera:
|
||||||
|
Fondo: #0A0E1A Curva: #00FFCC IMO ref: #FFB300
|
||||||
|
PASS: #00FF88 FAIL: #FF4444
|
||||||
|
|
||||||
|
Autor: Álvaro Romero
|
||||||
|
Módulo 3 — AR-ShipDesign
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import Qt, QRect, QPoint, Signal
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor, QFont, QFontMetrics, QPainter, QPen, QBrush,
|
||||||
|
QLinearGradient, QPixmap, QPolygonF,
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import QPointF
|
||||||
|
from PySide6.QtWidgets import QWidget, QSizePolicy, QToolTip
|
||||||
|
|
||||||
|
from arshipdesign.stability.gz_integrator import GZCurve
|
||||||
|
from arshipdesign.stability.imo_is2008 import IMOResult
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Paleta de colores
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_BG_CHART = QColor("#0A0E1A")
|
||||||
|
_BG_TABLE = QColor("#0D1525")
|
||||||
|
_GRID = QColor(40, 55, 90, 100)
|
||||||
|
_AXIS = QColor(60, 80, 120)
|
||||||
|
_CURVE = QColor("#00FFCC")
|
||||||
|
_GM_LINE = QColor(60, 130, 200, 160)
|
||||||
|
_IMO_REF = QColor("#FFB300")
|
||||||
|
_AVS_COLOR = QColor("#FF4444")
|
||||||
|
_GZ_ZERO = QColor(200, 200, 200, 80)
|
||||||
|
_TEXT_DIM = QColor(100, 120, 160)
|
||||||
|
_TEXT_BRIGHT = QColor(200, 220, 240)
|
||||||
|
_HEADER_COL = QColor("#FFB300")
|
||||||
|
_PASS_COL = QColor("#00FF88")
|
||||||
|
_FAIL_COL = QColor("#FF4444")
|
||||||
|
|
||||||
|
# Áreas sombreadas
|
||||||
|
_FILL_030 = QColor(0, 255, 136, 20)
|
||||||
|
_FILL_3040 = QColor(255, 179, 0, 20)
|
||||||
|
_FILL_40 = QColor(255, 255, 255, 10)
|
||||||
|
|
||||||
|
# Tipografía
|
||||||
|
_FONT_MONO = "Consolas"
|
||||||
|
_FONT_LABELS = "Segoe UI"
|
||||||
|
|
||||||
|
|
||||||
|
class GZCurveWidget(QWidget):
|
||||||
|
"""Widget QPainter para la curva GZ con tabla IMO integrada.
|
||||||
|
|
||||||
|
Layout vertical:
|
||||||
|
- 70% superior: gráfico de la curva GZ
|
||||||
|
- 30% inferior: tabla de 6 criterios IMO IS Code 2008
|
||||||
|
|
||||||
|
Signals
|
||||||
|
-------
|
||||||
|
angle_hovered : Signal(float)
|
||||||
|
Ángulo en grados cuando el ratón está sobre el área del gráfico.
|
||||||
|
"""
|
||||||
|
|
||||||
|
angle_hovered: Signal = Signal(float)
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._gz: Optional[GZCurve] = None
|
||||||
|
self._imo: Optional[IMOResult] = None
|
||||||
|
self._active_angle: float = -1.0
|
||||||
|
self._hover_angle: float = -1.0
|
||||||
|
self._chart_cache: Optional[QPixmap] = None
|
||||||
|
self._cache_valid = False
|
||||||
|
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
self.setMinimumSize(500, 380)
|
||||||
|
self.setSizePolicy(
|
||||||
|
QSizePolicy.Policy.Expanding,
|
||||||
|
QSizePolicy.Policy.Expanding,
|
||||||
|
)
|
||||||
|
self.setObjectName("GZCurveWidget")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# API pública
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_curve(self, gz: GZCurve, imo: IMOResult) -> None:
|
||||||
|
"""Establece la curva GZ y el resultado IMO y fuerza un repintado."""
|
||||||
|
self._gz = gz
|
||||||
|
self._imo = imo
|
||||||
|
self._cache_valid = False
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_active_angle(self, phi_deg: float) -> None:
|
||||||
|
"""Resalta una escora con un marcador vertical."""
|
||||||
|
self._active_angle = float(phi_deg)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Eventos de ratón
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event) -> None:
|
||||||
|
if self._gz is None:
|
||||||
|
return
|
||||||
|
chart_rect = self._chart_rect()
|
||||||
|
pos = event.position() if hasattr(event, "position") else event.pos()
|
||||||
|
x = pos.x() if hasattr(pos, "x") else pos.x()
|
||||||
|
if chart_rect.contains(int(x), int(pos.y() if hasattr(pos, "y") else pos.y())):
|
||||||
|
# Convertir x-pixel a ángulo
|
||||||
|
plot_x0 = chart_rect.left() + self._margin_l
|
||||||
|
plot_w = chart_rect.width() - self._margin_l - self._margin_r
|
||||||
|
if plot_w > 0:
|
||||||
|
phi = (x - plot_x0) / plot_w * self._phi_max
|
||||||
|
phi = max(0.0, min(self._phi_max, phi))
|
||||||
|
self._hover_angle = phi
|
||||||
|
self.angle_hovered.emit(phi)
|
||||||
|
self.update()
|
||||||
|
else:
|
||||||
|
self._hover_angle = -1.0
|
||||||
|
|
||||||
|
def leaveEvent(self, event) -> None:
|
||||||
|
self._hover_angle = -1.0
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Pintado principal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None:
|
||||||
|
painter = QPainter(self)
|
||||||
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
|
w, h = self.width(), self.height()
|
||||||
|
chart_h = int(h * 0.70)
|
||||||
|
table_h = h - chart_h
|
||||||
|
|
||||||
|
# Fondos
|
||||||
|
painter.fillRect(0, 0, w, chart_h, _BG_CHART)
|
||||||
|
painter.fillRect(0, chart_h, w, table_h, _BG_TABLE)
|
||||||
|
|
||||||
|
# Separador
|
||||||
|
painter.setPen(QPen(QColor(30, 50, 90), 1))
|
||||||
|
painter.drawLine(0, chart_h, w, chart_h)
|
||||||
|
|
||||||
|
chart_rect = QRect(0, 0, w, chart_h)
|
||||||
|
table_rect = QRect(0, chart_h, w, table_h)
|
||||||
|
|
||||||
|
self._draw_chart(painter, chart_rect)
|
||||||
|
self._draw_table(painter, table_rect)
|
||||||
|
|
||||||
|
painter.end()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Zona del gráfico
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Márgenes internos del plot (dentro del chart_rect)
|
||||||
|
_margin_l = 62 # izquierda (etiquetas GZ)
|
||||||
|
_margin_r = 20 # derecha
|
||||||
|
_margin_t = 32 # arriba (título)
|
||||||
|
_margin_b = 38 # abajo (etiquetas ángulo)
|
||||||
|
_phi_max = 90.0
|
||||||
|
|
||||||
|
def _chart_rect(self) -> QRect:
|
||||||
|
h = int(self.height() * 0.70)
|
||||||
|
return QRect(0, 0, self.width(), h)
|
||||||
|
|
||||||
|
def _plot_rect(self, chart_rect: QRect) -> QRect:
|
||||||
|
return QRect(
|
||||||
|
chart_rect.left() + self._margin_l,
|
||||||
|
chart_rect.top() + self._margin_t,
|
||||||
|
chart_rect.width() - self._margin_l - self._margin_r,
|
||||||
|
chart_rect.height() - self._margin_t - self._margin_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _to_px(self, plot_rect: QRect, phi: float, gz: float,
|
||||||
|
gz_min: float, gz_max_val: float) -> QPointF:
|
||||||
|
"""Convierte (phi_deg, gz) a coordenadas de píxel."""
|
||||||
|
rel_x = phi / self._phi_max
|
||||||
|
gz_range = gz_max_val - gz_min
|
||||||
|
if gz_range < 1e-9:
|
||||||
|
gz_range = 1.0
|
||||||
|
rel_y = 1.0 - (gz - gz_min) / gz_range
|
||||||
|
px = plot_rect.left() + rel_x * plot_rect.width()
|
||||||
|
py = plot_rect.top() + rel_y * plot_rect.height()
|
||||||
|
return QPointF(px, py)
|
||||||
|
|
||||||
|
def _draw_chart(self, painter: QPainter, chart_rect: QRect) -> None:
|
||||||
|
plot_rect = self._plot_rect(chart_rect)
|
||||||
|
|
||||||
|
gz = self._gz
|
||||||
|
|
||||||
|
# ── Título ────────────────────────────────────────────────────
|
||||||
|
font_title = QFont(_FONT_LABELS, 9)
|
||||||
|
font_title.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.2)
|
||||||
|
font_title.setWeight(QFont.Weight.DemiBold)
|
||||||
|
painter.setFont(font_title)
|
||||||
|
painter.setPen(QPen(_CURVE))
|
||||||
|
title_text = "CURVA GZ — ESTABILIDAD ESTÁTICA"
|
||||||
|
painter.drawText(
|
||||||
|
chart_rect.left() + self._margin_l,
|
||||||
|
chart_rect.top() + 18,
|
||||||
|
title_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
if gz is None:
|
||||||
|
# Placeholder si no hay datos
|
||||||
|
font_ph = QFont(_FONT_MONO, 11)
|
||||||
|
painter.setFont(font_ph)
|
||||||
|
painter.setPen(QPen(QColor(40, 60, 100)))
|
||||||
|
painter.drawText(
|
||||||
|
plot_rect,
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"Sin datos — calcule la curva GZ primero",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Determinar rango de GZ ────────────────────────────────────
|
||||||
|
gz_vals = gz.gz_values
|
||||||
|
gz_min_data = float(np.min(gz_vals))
|
||||||
|
gz_max_data = float(np.max(gz_vals))
|
||||||
|
|
||||||
|
# Añadir margen y asegurar que gz=0 está visible
|
||||||
|
margin_gz = max(0.05, (gz_max_data - gz_min_data) * 0.12)
|
||||||
|
gz_plot_min = min(gz_min_data - margin_gz, -0.05)
|
||||||
|
gz_plot_max = gz_max_data + margin_gz
|
||||||
|
|
||||||
|
# Redondear a 0.1m
|
||||||
|
gz_plot_min = math.floor(gz_plot_min * 10) / 10.0
|
||||||
|
gz_plot_max = math.ceil(gz_plot_max * 10) / 10.0
|
||||||
|
if gz_plot_max <= gz_plot_min:
|
||||||
|
gz_plot_max = gz_plot_min + 0.1
|
||||||
|
|
||||||
|
gz_range = gz_plot_max - gz_plot_min
|
||||||
|
|
||||||
|
def to_px(phi: float, gz_val: float) -> QPointF:
|
||||||
|
rel_x = phi / self._phi_max
|
||||||
|
rel_y = 1.0 - (gz_val - gz_plot_min) / gz_range
|
||||||
|
return QPointF(
|
||||||
|
plot_rect.left() + rel_x * plot_rect.width(),
|
||||||
|
plot_rect.top() + rel_y * plot_rect.height(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def phi_to_px_x(phi: float) -> float:
|
||||||
|
return plot_rect.left() + (phi / self._phi_max) * plot_rect.width()
|
||||||
|
|
||||||
|
def gz_to_px_y(gz_val: float) -> float:
|
||||||
|
return plot_rect.top() + (1.0 - (gz_val - gz_plot_min) / gz_range) * plot_rect.height()
|
||||||
|
|
||||||
|
gz_zero_y = gz_to_px_y(0.0)
|
||||||
|
|
||||||
|
# ── Fondo del plot ────────────────────────────────────────────
|
||||||
|
painter.fillRect(plot_rect, _BG_CHART)
|
||||||
|
|
||||||
|
# ── Grid vertical (cada 10°) ──────────────────────────────────
|
||||||
|
pen_grid = QPen(_GRID, 1, Qt.PenStyle.SolidLine)
|
||||||
|
painter.setPen(pen_grid)
|
||||||
|
for phi in range(0, 91, 10):
|
||||||
|
x = phi_to_px_x(float(phi))
|
||||||
|
painter.drawLine(QPointF(x, plot_rect.top()), QPointF(x, plot_rect.bottom()))
|
||||||
|
|
||||||
|
# ── Grid horizontal (cada 0.1 m) ──────────────────────────────
|
||||||
|
gz_tick = gz_plot_min
|
||||||
|
while gz_tick <= gz_plot_max + 1e-6:
|
||||||
|
y = gz_to_px_y(gz_tick)
|
||||||
|
if plot_rect.top() <= y <= plot_rect.bottom():
|
||||||
|
painter.drawLine(QPointF(plot_rect.left(), y), QPointF(plot_rect.right(), y))
|
||||||
|
gz_tick = round(gz_tick + 0.1, 10)
|
||||||
|
|
||||||
|
# ── Línea GZ = 0 (blanca tenue) ───────────────────────────────
|
||||||
|
pen_zero = QPen(_GZ_ZERO, 1, Qt.PenStyle.DashLine)
|
||||||
|
painter.setPen(pen_zero)
|
||||||
|
if plot_rect.top() <= gz_zero_y <= plot_rect.bottom():
|
||||||
|
painter.drawLine(
|
||||||
|
QPointF(plot_rect.left(), gz_zero_y),
|
||||||
|
QPointF(plot_rect.right(), gz_zero_y),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Áreas sombreadas ─────────────────────────────────────────
|
||||||
|
phi_deg = gz.angles_deg
|
||||||
|
|
||||||
|
def _fill_area(phi_start: float, phi_end: float, color: QColor) -> None:
|
||||||
|
mask = (phi_deg >= phi_start) & (phi_deg <= phi_end)
|
||||||
|
pts_phi = np.concatenate([[phi_start], phi_deg[mask], [phi_end]])
|
||||||
|
pts_gz = np.concatenate([
|
||||||
|
[float(np.interp(phi_start, phi_deg, gz_vals))],
|
||||||
|
gz_vals[mask],
|
||||||
|
[float(np.interp(phi_end, phi_deg, gz_vals))],
|
||||||
|
])
|
||||||
|
# Base: polígono cerrado por y=0
|
||||||
|
poly = QPolygonF()
|
||||||
|
# Puntos superiores (curva)
|
||||||
|
for i in range(len(pts_phi)):
|
||||||
|
poly.append(to_px(pts_phi[i], pts_gz[i]))
|
||||||
|
# Bajar hasta y=0 y volver
|
||||||
|
poly.append(to_px(pts_phi[-1], 0.0))
|
||||||
|
poly.append(to_px(pts_phi[0], 0.0))
|
||||||
|
painter.setBrush(QBrush(color))
|
||||||
|
painter.setPen(Qt.PenStyle.NoPen)
|
||||||
|
painter.drawPolygon(poly)
|
||||||
|
|
||||||
|
_fill_area(0.0, 30.0, _FILL_030)
|
||||||
|
_fill_area(30.0, 40.0, _FILL_3040)
|
||||||
|
_fill_area(40.0, min(self._phi_max, float(np.max(phi_deg))), _FILL_40)
|
||||||
|
|
||||||
|
# ── Línea IMO GZ=0.20 m a 30° ────────────────────────────────
|
||||||
|
pen_imo = QPen(_IMO_REF, 1, Qt.PenStyle.DashLine)
|
||||||
|
painter.setPen(pen_imo)
|
||||||
|
|
||||||
|
# Horizontal IMO 0.20m
|
||||||
|
y_imo_020 = gz_to_px_y(0.20)
|
||||||
|
if plot_rect.top() <= y_imo_020 <= plot_rect.bottom():
|
||||||
|
painter.drawLine(
|
||||||
|
QPointF(plot_rect.left(), y_imo_020),
|
||||||
|
QPointF(phi_to_px_x(30.0), y_imo_020),
|
||||||
|
)
|
||||||
|
# Etiqueta
|
||||||
|
font_tiny = QFont(_FONT_MONO, 7)
|
||||||
|
painter.setFont(font_tiny)
|
||||||
|
painter.setPen(QPen(_IMO_REF))
|
||||||
|
painter.drawText(
|
||||||
|
int(plot_rect.left() + 2),
|
||||||
|
int(y_imo_020 - 2),
|
||||||
|
"IMO 0.20m",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vertical en 30°
|
||||||
|
x_30 = phi_to_px_x(30.0)
|
||||||
|
painter.setPen(pen_imo)
|
||||||
|
painter.drawLine(
|
||||||
|
QPointF(x_30, plot_rect.top()),
|
||||||
|
QPointF(x_30, plot_rect.bottom()),
|
||||||
|
)
|
||||||
|
font_tiny = QFont(_FONT_MONO, 7)
|
||||||
|
painter.setFont(font_tiny)
|
||||||
|
painter.setPen(QPen(_IMO_REF))
|
||||||
|
painter.drawText(int(x_30) + 2, int(plot_rect.top()) + 10, "30°")
|
||||||
|
|
||||||
|
# ── Línea GM₀ tangente al origen ─────────────────────────────
|
||||||
|
# GZ ≈ GM · sin(φ) → para pequeños φ: GZ ≈ GM · φ_rad
|
||||||
|
# Tangente desde φ=0,GZ=0 hasta φ=30°, GZ=GM·sin(30°)
|
||||||
|
pen_gm = QPen(_GM_LINE, 1, Qt.PenStyle.DotLine)
|
||||||
|
painter.setPen(pen_gm)
|
||||||
|
phi_end_gm = 25.0
|
||||||
|
gz_end_gm = gz.gm * math.sin(math.radians(phi_end_gm))
|
||||||
|
painter.drawLine(
|
||||||
|
to_px(0.0, 0.0),
|
||||||
|
to_px(phi_end_gm, gz_end_gm),
|
||||||
|
)
|
||||||
|
font_tiny = QFont(_FONT_MONO, 7)
|
||||||
|
painter.setFont(font_tiny)
|
||||||
|
painter.setPen(QPen(_GM_LINE))
|
||||||
|
p_gm = to_px(phi_end_gm, gz_end_gm)
|
||||||
|
painter.drawText(int(p_gm.x()) + 3, int(p_gm.y()) - 2,
|
||||||
|
f"GM={gz.gm:.3f}m")
|
||||||
|
|
||||||
|
# ── Curva GZ principal ────────────────────────────────────────
|
||||||
|
pen_curve = QPen(_CURVE, 2, Qt.PenStyle.SolidLine)
|
||||||
|
pen_curve.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
pen_curve.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||||
|
painter.setPen(pen_curve)
|
||||||
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
|
||||||
|
path_pts = [to_px(float(phi_deg[i]), float(gz_vals[i]))
|
||||||
|
for i in range(len(phi_deg))]
|
||||||
|
for i in range(len(path_pts) - 1):
|
||||||
|
painter.drawLine(path_pts[i], path_pts[i + 1])
|
||||||
|
|
||||||
|
# ── Marcador AVS ──────────────────────────────────────────────
|
||||||
|
if gz.avs < 90.0:
|
||||||
|
pen_avs = QPen(_AVS_COLOR, 1, Qt.PenStyle.DashDotLine)
|
||||||
|
painter.setPen(pen_avs)
|
||||||
|
x_avs = phi_to_px_x(gz.avs)
|
||||||
|
painter.drawLine(
|
||||||
|
QPointF(x_avs, plot_rect.top()),
|
||||||
|
QPointF(x_avs, plot_rect.bottom()),
|
||||||
|
)
|
||||||
|
font_avs = QFont(_FONT_MONO, 7)
|
||||||
|
painter.setFont(font_avs)
|
||||||
|
painter.setPen(QPen(_AVS_COLOR))
|
||||||
|
painter.drawText(int(x_avs) + 2, int(plot_rect.top()) + 22,
|
||||||
|
f"AVS {gz.avs:.0f}°")
|
||||||
|
|
||||||
|
# ── Marcador de escora activa ─────────────────────────────────
|
||||||
|
if 0.0 <= self._active_angle <= 90.0:
|
||||||
|
pen_active = QPen(QColor(255, 200, 0, 180), 1, Qt.PenStyle.SolidLine)
|
||||||
|
painter.setPen(pen_active)
|
||||||
|
x_act = phi_to_px_x(self._active_angle)
|
||||||
|
painter.drawLine(
|
||||||
|
QPointF(x_act, plot_rect.top()),
|
||||||
|
QPointF(x_act, plot_rect.bottom()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Marcador de hover ─────────────────────────────────────────
|
||||||
|
if 0.0 <= self._hover_angle <= 90.0:
|
||||||
|
pen_hov = QPen(QColor(200, 220, 255, 120), 1, Qt.PenStyle.DashLine)
|
||||||
|
painter.setPen(pen_hov)
|
||||||
|
x_hov = phi_to_px_x(self._hover_angle)
|
||||||
|
painter.drawLine(
|
||||||
|
QPointF(x_hov, plot_rect.top()),
|
||||||
|
QPointF(x_hov, plot_rect.bottom()),
|
||||||
|
)
|
||||||
|
# Valor GZ en hover
|
||||||
|
gz_hov = float(np.interp(self._hover_angle, phi_deg, gz_vals))
|
||||||
|
font_hov = QFont(_FONT_MONO, 8)
|
||||||
|
painter.setFont(font_hov)
|
||||||
|
painter.setPen(QPen(QColor(200, 220, 255)))
|
||||||
|
hov_text = f"φ={self._hover_angle:.1f}° GZ={gz_hov:.3f}m"
|
||||||
|
painter.drawText(int(x_hov) + 4, int(plot_rect.top()) + 14, hov_text)
|
||||||
|
|
||||||
|
# ── Ejes y etiquetas ──────────────────────────────────────────
|
||||||
|
pen_axis = QPen(_AXIS, 1)
|
||||||
|
painter.setPen(pen_axis)
|
||||||
|
# Marco del plot
|
||||||
|
painter.drawRect(plot_rect)
|
||||||
|
|
||||||
|
font_tick = QFont(_FONT_MONO, 8)
|
||||||
|
painter.setFont(font_tick)
|
||||||
|
painter.setPen(QPen(_TEXT_DIM))
|
||||||
|
|
||||||
|
# Etiquetas X (ángulos, cada 10°)
|
||||||
|
for phi in range(0, 91, 10):
|
||||||
|
x = phi_to_px_x(float(phi))
|
||||||
|
y_base = plot_rect.bottom() + 14
|
||||||
|
painter.drawText(
|
||||||
|
int(x) - 8, y_base,
|
||||||
|
f"{phi}°",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Etiquetas Y (GZ, cada 0.1m)
|
||||||
|
gz_tick2 = gz_plot_min
|
||||||
|
while gz_tick2 <= gz_plot_max + 1e-6:
|
||||||
|
y = gz_to_px_y(gz_tick2)
|
||||||
|
if plot_rect.top() - 2 <= y <= plot_rect.bottom() + 2:
|
||||||
|
lbl = f"{gz_tick2:.1f}"
|
||||||
|
fm = QFontMetrics(font_tick)
|
||||||
|
lbl_w = fm.horizontalAdvance(lbl)
|
||||||
|
painter.drawText(
|
||||||
|
int(plot_rect.left()) - lbl_w - 4,
|
||||||
|
int(y) + 4,
|
||||||
|
lbl,
|
||||||
|
)
|
||||||
|
gz_tick2 = round(gz_tick2 + 0.1, 10)
|
||||||
|
|
||||||
|
# Etiqueta eje Y
|
||||||
|
painter.save()
|
||||||
|
font_axis_lbl = QFont(_FONT_LABELS, 8)
|
||||||
|
painter.setFont(font_axis_lbl)
|
||||||
|
painter.setPen(QPen(_TEXT_DIM))
|
||||||
|
painter.translate(
|
||||||
|
chart_rect.left() + 10,
|
||||||
|
int(plot_rect.top() + plot_rect.height() / 2),
|
||||||
|
)
|
||||||
|
painter.rotate(-90)
|
||||||
|
painter.drawText(-24, 0, "GZ [m]")
|
||||||
|
painter.restore()
|
||||||
|
|
||||||
|
# Etiqueta eje X
|
||||||
|
painter.setPen(QPen(_TEXT_DIM))
|
||||||
|
painter.drawText(
|
||||||
|
int(plot_rect.left() + plot_rect.width() / 2) - 28,
|
||||||
|
chart_rect.bottom() - 4,
|
||||||
|
"Ángulo de escora φ [°]",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Info resumen (esquina superior derecha) ───────────────────
|
||||||
|
font_info = QFont(_FONT_MONO, 8)
|
||||||
|
painter.setFont(font_info)
|
||||||
|
painter.setPen(QPen(QColor(80, 100, 140)))
|
||||||
|
info_lines = [
|
||||||
|
f"Hull: {gz.hull_name[:20]}",
|
||||||
|
f"T={gz.draft:.2f}m KG={gz.kg:.2f}m",
|
||||||
|
f"GM={gz.gm:.3f}m BM={gz.bmt:.3f}m",
|
||||||
|
f"GZmax={gz.gz_max:.3f}m @ {gz.phi_gz_max:.0f}°",
|
||||||
|
f"AVS={gz.avs:.0f}°",
|
||||||
|
]
|
||||||
|
fm_info = QFontMetrics(font_info)
|
||||||
|
x_info = chart_rect.right() - self._margin_r - 130
|
||||||
|
y_info = chart_rect.top() + self._margin_t
|
||||||
|
for line in info_lines:
|
||||||
|
painter.drawText(x_info, y_info, line)
|
||||||
|
y_info += fm_info.height() + 2
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Zona de la tabla IMO
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _draw_table(self, painter: QPainter, table_rect: QRect) -> None:
|
||||||
|
imo = self._imo
|
||||||
|
if imo is None:
|
||||||
|
painter.setPen(QPen(QColor(40, 60, 100)))
|
||||||
|
font_ph = QFont(_FONT_MONO, 10)
|
||||||
|
painter.setFont(font_ph)
|
||||||
|
painter.drawText(
|
||||||
|
table_rect,
|
||||||
|
Qt.AlignmentFlag.AlignCenter,
|
||||||
|
"IMO IS Code 2008 — sin datos",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = imo.table_rows()
|
||||||
|
n_rows = len(rows)
|
||||||
|
n_total = n_rows + 1 # +1 cabecera
|
||||||
|
|
||||||
|
# Alturas de fila
|
||||||
|
row_h = max(16, table_rect.height() // n_total)
|
||||||
|
# Anchos de columna (proporcional al ancho total)
|
||||||
|
w = table_rect.width()
|
||||||
|
col_w = [
|
||||||
|
int(w * 0.11), # code
|
||||||
|
int(w * 0.22), # description
|
||||||
|
int(w * 0.24), # required
|
||||||
|
int(w * 0.24), # achieved
|
||||||
|
int(w * 0.12), # PASS/FAIL
|
||||||
|
]
|
||||||
|
# Ajustar el último para que cubra todo
|
||||||
|
col_w[-1] = w - sum(col_w[:-1])
|
||||||
|
|
||||||
|
font_hdr = QFont(_FONT_MONO, 8)
|
||||||
|
font_hdr.setBold(True)
|
||||||
|
font_cell = QFont(_FONT_MONO, 8)
|
||||||
|
|
||||||
|
# ── Cabecera ────────────────────────────────────────────────
|
||||||
|
y_row = table_rect.top()
|
||||||
|
painter.fillRect(
|
||||||
|
table_rect.left(), y_row,
|
||||||
|
w, row_h,
|
||||||
|
QColor(12, 20, 40),
|
||||||
|
)
|
||||||
|
headers = ["Código", "Descripción", "Requerido", "Obtenido", "Resultado"]
|
||||||
|
painter.setFont(font_hdr)
|
||||||
|
painter.setPen(QPen(_HEADER_COL))
|
||||||
|
x_col = table_rect.left() + 4
|
||||||
|
for i, hdr in enumerate(headers):
|
||||||
|
painter.drawText(
|
||||||
|
int(x_col), int(y_row + row_h - 4),
|
||||||
|
hdr,
|
||||||
|
)
|
||||||
|
x_col += col_w[i]
|
||||||
|
y_row += row_h
|
||||||
|
|
||||||
|
# ── Filas de criterios ───────────────────────────────────────
|
||||||
|
for row_idx, (code, desc, req_str, ach_str, passed) in enumerate(rows):
|
||||||
|
bg = QColor(10, 18, 35) if row_idx % 2 == 0 else QColor(13, 22, 42)
|
||||||
|
painter.fillRect(
|
||||||
|
table_rect.left(), y_row,
|
||||||
|
w, row_h,
|
||||||
|
bg,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_color = _PASS_COL if passed else _FAIL_COL
|
||||||
|
status_text = "PASS" if passed else "FAIL"
|
||||||
|
|
||||||
|
painter.setFont(font_cell)
|
||||||
|
texts = [code, desc, req_str, ach_str, ""]
|
||||||
|
colors = [_TEXT_DIM, _TEXT_BRIGHT, _TEXT_DIM, _TEXT_BRIGHT, status_color]
|
||||||
|
|
||||||
|
x_col = table_rect.left() + 4
|
||||||
|
for i, txt in enumerate(texts):
|
||||||
|
painter.setPen(QPen(colors[i]))
|
||||||
|
painter.drawText(
|
||||||
|
int(x_col), int(y_row + row_h - 4),
|
||||||
|
txt,
|
||||||
|
)
|
||||||
|
x_col += col_w[i]
|
||||||
|
|
||||||
|
# PASS/FAIL con color propio
|
||||||
|
painter.setPen(QPen(status_color))
|
||||||
|
font_status = QFont(_FONT_MONO, 8)
|
||||||
|
font_status.setBold(True)
|
||||||
|
painter.setFont(font_status)
|
||||||
|
painter.drawText(
|
||||||
|
int(table_rect.left() + sum(col_w[:-1]) + 4),
|
||||||
|
int(y_row + row_h - 4),
|
||||||
|
status_text,
|
||||||
|
)
|
||||||
|
y_row += row_h
|
||||||
|
|
||||||
|
# ── Línea de resultado global ─────────────────────────────────
|
||||||
|
if y_row < table_rect.bottom():
|
||||||
|
overall_bg = QColor(0, 40, 20) if imo.overall_passed else QColor(40, 10, 10)
|
||||||
|
painter.fillRect(
|
||||||
|
table_rect.left(), y_row,
|
||||||
|
w, table_rect.bottom() - y_row,
|
||||||
|
overall_bg,
|
||||||
|
)
|
||||||
|
overall_txt = "CUMPLE TODOS LOS CRITERIOS IMO IS CODE 2008" if imo.overall_passed \
|
||||||
|
else "FALLA CRITERIOS IMO IS CODE 2008"
|
||||||
|
overall_col = _PASS_COL if imo.overall_passed else _FAIL_COL
|
||||||
|
font_overall = QFont(_FONT_LABELS, 9)
|
||||||
|
font_overall.setBold(True)
|
||||||
|
painter.setFont(font_overall)
|
||||||
|
painter.setPen(QPen(overall_col))
|
||||||
|
painter.drawText(
|
||||||
|
table_rect.left() + 8,
|
||||||
|
y_row + max(16, (table_rect.bottom() - y_row) - 4),
|
||||||
|
overall_txt,
|
||||||
|
)
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
"""
|
||||||
|
test_module3_stability.py — Tests de estabilidad estática (GZ) y criterios IMO.
|
||||||
|
|
||||||
|
Cubre los rangos de verificación V-063..V-090.
|
||||||
|
|
||||||
|
Grupos:
|
||||||
|
S-01..S-05 GZ Wall-Sided básico
|
||||||
|
S-06..S-10 GZ Integración directa
|
||||||
|
S-11..S-15 Áreas bajo la curva GZ
|
||||||
|
S-16..S-20 IMO IS Code 2008
|
||||||
|
S-21..S-25 Familias de casco
|
||||||
|
S-26..S-28 GZCurveWidget (headless)
|
||||||
|
|
||||||
|
Autor: Álvaro Romero
|
||||||
|
Módulo 3 — AR-ShipDesign
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arshipdesign.core.hull import Hull
|
||||||
|
from arshipdesign.parametric import generate_hull, HullFamily
|
||||||
|
from arshipdesign.stability import (
|
||||||
|
GZPoint,
|
||||||
|
GZCurve,
|
||||||
|
compute_gz_wall_sided,
|
||||||
|
compute_gz_direct,
|
||||||
|
check_imo_is2008,
|
||||||
|
IMOCriterion,
|
||||||
|
IMOResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures comunes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def wigley_small() -> Hull:
|
||||||
|
"""Wigley: Lpp=10m, B=1.5m, T=0.75m — casco compacto para tests rápidos."""
|
||||||
|
return Hull.from_wigley(
|
||||||
|
name="Wigley-10",
|
||||||
|
lpp=10.0,
|
||||||
|
beam=1.5,
|
||||||
|
draft=0.75,
|
||||||
|
n_stations=21,
|
||||||
|
n_waterlines=11,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def wigley_medium() -> Hull:
|
||||||
|
"""Wigley: Lpp=20m, B=3.0m, T=1.5m — casco mediano para áreas."""
|
||||||
|
return Hull.from_wigley(
|
||||||
|
name="Wigley-20",
|
||||||
|
lpp=20.0,
|
||||||
|
beam=3.0,
|
||||||
|
draft=1.5,
|
||||||
|
n_stations=41,
|
||||||
|
n_waterlines=21,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def gz_wall_small(wigley_small: Hull) -> GZCurve:
|
||||||
|
return compute_gz_wall_sided(wigley_small, wigley_small.draft)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def gz_wall_medium(wigley_medium: Hull) -> GZCurve:
|
||||||
|
return compute_gz_wall_sided(wigley_medium, wigley_medium.draft)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def gz_direct_small(wigley_small: Hull) -> GZCurve:
|
||||||
|
# Solo 0..40° en pasos de 5° para mantener el test rápido
|
||||||
|
angles = np.linspace(0.0, 40.0, 17)
|
||||||
|
return compute_gz_direct(wigley_small, wigley_small.draft, angles_deg=angles)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-01..S-05: GZ Wall-Sided básico
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGZWallSidedBasic:
|
||||||
|
"""S-01..S-05 — fórmula de pared lateral, validaciones básicas."""
|
||||||
|
|
||||||
|
def test_s01_gm_positive(self, wigley_small: Hull, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-01: GM > 0 para un casco Wigley bien proporcionado."""
|
||||||
|
assert gz_wall_small.gm > 0.0, f"GM={gz_wall_small.gm:.4f} debería ser positivo"
|
||||||
|
|
||||||
|
def test_s02_gz_zero_at_zero(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-02: GZ(0°) = 0.0 por definición."""
|
||||||
|
gz0 = gz_wall_small.gz_values[0]
|
||||||
|
assert abs(gz0) < 1e-9, f"GZ(0°)={gz0} debería ser 0"
|
||||||
|
|
||||||
|
def test_s03_gz_derivative_at_origin(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-03: GZ'(0) ≈ GM (derivada al origen = GM en radianes)."""
|
||||||
|
phi = gz_wall_small.angles_deg
|
||||||
|
gz = gz_wall_small.gz_values
|
||||||
|
# Derivada numérica en el origen: dGZ/dφ_rad ≈ GZ(1°) / sin(1°) ≈ GM
|
||||||
|
gz1_per_rad = float(np.interp(1.0, phi, gz)) / math.sin(math.radians(1.0))
|
||||||
|
gm = gz_wall_small.gm
|
||||||
|
assert abs(gz1_per_rad - gm) / max(abs(gm), 0.01) < 0.02, (
|
||||||
|
f"GZ'(0) = {gz1_per_rad:.4f} m, GM = {gm:.4f} m — diferencia > 2%"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_s04_avs_positive(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-04: AVS > 0° (hay algún rango de estabilidad positiva)."""
|
||||||
|
assert gz_wall_small.avs > 0.0, f"AVS={gz_wall_small.avs:.1f}° debería ser > 0"
|
||||||
|
|
||||||
|
def test_s05_gz_max_positive(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-05: gz_max > 0 y phi_gz_max > 0°."""
|
||||||
|
assert gz_wall_small.gz_max > 0.0
|
||||||
|
assert gz_wall_small.phi_gz_max > 0.0
|
||||||
|
|
||||||
|
def test_s05b_points_count(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-05b: Por defecto 91 puntos (0..90° en pasos de 1°)."""
|
||||||
|
assert len(gz_wall_small.points) == 91
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-06..S-10: GZ Integración directa
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGZDirectIntegration:
|
||||||
|
"""S-06..S-10 — integración numérica directa."""
|
||||||
|
|
||||||
|
def test_s06_agreement_small_angles(
|
||||||
|
self, wigley_small: Hull, gz_wall_small: GZCurve, gz_direct_small: GZCurve
|
||||||
|
) -> None:
|
||||||
|
"""S-06: Integración directa ≈ wall-sided a ángulos pequeños (≤ 20°)."""
|
||||||
|
phi_ws = gz_wall_small.angles_deg
|
||||||
|
gz_ws = gz_wall_small.gz_values
|
||||||
|
phi_dr = gz_direct_small.angles_deg
|
||||||
|
gz_dr = gz_direct_small.gz_values
|
||||||
|
|
||||||
|
# Comparar en ángulos donde ambas curvas tienen datos (hasta 20°)
|
||||||
|
test_angles = [5.0, 10.0, 15.0, 20.0]
|
||||||
|
for phi in test_angles:
|
||||||
|
gz_ws_val = float(np.interp(phi, phi_ws, gz_ws))
|
||||||
|
gz_dr_val = float(np.interp(phi, phi_dr, gz_dr))
|
||||||
|
if abs(gz_ws_val) > 1e-4:
|
||||||
|
rel_diff = abs(gz_ws_val - gz_dr_val) / abs(gz_ws_val)
|
||||||
|
assert rel_diff < 0.20, (
|
||||||
|
f"GZ a {phi}°: wall-sided={gz_ws_val:.4f}, directo={gz_dr_val:.4f}, "
|
||||||
|
f"diferencia relativa={rel_diff:.1%} > 20%"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_s07_gz_zero_at_zero_direct(self, gz_direct_small: GZCurve) -> None:
|
||||||
|
"""S-07: GZ(0°) = 0 también en integración directa."""
|
||||||
|
gz0 = gz_direct_small.gz_values[0]
|
||||||
|
assert abs(gz0) < 1e-4, f"GZ(0°)={gz0:.6f} debería ser ~0"
|
||||||
|
|
||||||
|
def test_s08_volume_conservation(self, wigley_small: Hull) -> None:
|
||||||
|
"""S-08: A φ=0, el volumen en integración directa debe coincidir con upright."""
|
||||||
|
from arshipdesign.hydrostatics.upright import compute_upright
|
||||||
|
hydro = compute_upright(wigley_small, wigley_small.draft)
|
||||||
|
V_upright = hydro.volume
|
||||||
|
|
||||||
|
# Calcular solo a φ=0 con integración directa
|
||||||
|
gz_curve = compute_gz_direct(wigley_small, wigley_small.draft, angles_deg=np.array([0.0]))
|
||||||
|
# Si no hay error, el brentq encontró un z_wl razonable → implica conservación
|
||||||
|
assert abs(gz_curve.gz_values[0]) < 0.1 # GZ(0°) ≈ 0 cuando KG = 0.55·depth
|
||||||
|
|
||||||
|
def test_s09_centroid_upright_phi0(self, wigley_small: Hull) -> None:
|
||||||
|
"""S-09: A φ=0, y_B_world ≈ 0 (centro de carena en la crujía)."""
|
||||||
|
from arshipdesign.hydrostatics.upright import compute_upright
|
||||||
|
gz_curve = compute_gz_direct(wigley_small, wigley_small.draft, angles_deg=np.array([0.0]))
|
||||||
|
# GZ(0°) = y_B_world - KG·sin(0°) = y_B_world
|
||||||
|
# Por simetría, y_B_world ≈ 0
|
||||||
|
gz0 = gz_curve.gz_values[0]
|
||||||
|
assert abs(gz0) < 0.05, f"GZ(0°) = {gz0:.4f} m, esperado ~0 (centrado)"
|
||||||
|
|
||||||
|
def test_s10_monotone_small_angles_direct(self, gz_direct_small: GZCurve) -> None:
|
||||||
|
"""S-10: La curva directa debe ser monótona creciente en ángulos pequeños (≤ 15°)."""
|
||||||
|
phi = gz_direct_small.angles_deg
|
||||||
|
gz = gz_direct_small.gz_values
|
||||||
|
mask_15 = phi <= 15.0
|
||||||
|
gz_15 = gz[mask_15]
|
||||||
|
phi_15 = phi[mask_15]
|
||||||
|
if len(gz_15) >= 3:
|
||||||
|
# Diferencias: deben ser mayoritariamente positivas (toleramos 1 excepción)
|
||||||
|
diffs = np.diff(gz_15)
|
||||||
|
n_negative = (diffs < -1e-4).sum()
|
||||||
|
assert n_negative <= 1, (
|
||||||
|
f"Curva directa no monótona en 0–15°: {n_negative} decrementos negativos"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-11..S-15: Áreas bajo la curva GZ
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGZAreas:
|
||||||
|
"""S-11..S-15 — integrales de la curva GZ."""
|
||||||
|
|
||||||
|
def test_s11_area_030_nonnegative(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-11: Área 0–30° ≥ 0 para un barco estable."""
|
||||||
|
assert gz_wall_small.area_0_30 >= 0.0
|
||||||
|
|
||||||
|
def test_s12_areas_consistent(self, gz_wall_medium: GZCurve) -> None:
|
||||||
|
"""S-12: area_0_30 + area_30_40 ≈ area_0_40 (dentro del 1%)."""
|
||||||
|
sum_parts = gz_wall_medium.area_0_30 + gz_wall_medium.area_30_40
|
||||||
|
total = gz_wall_medium.area_0_40
|
||||||
|
if total > 1e-6:
|
||||||
|
rel_err = abs(sum_parts - total) / total
|
||||||
|
assert rel_err < 0.02, (
|
||||||
|
f"area_0_30={gz_wall_medium.area_0_30:.5f} + "
|
||||||
|
f"area_30_40={gz_wall_medium.area_30_40:.5f} = {sum_parts:.5f} "
|
||||||
|
f"≠ area_0_40={total:.5f} (err={rel_err:.1%})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_s13_area_030_numeric_check(self, wigley_medium: Hull) -> None:
|
||||||
|
"""S-13: area_0_30 ≈ integral numérica independiente de GZ·dφ."""
|
||||||
|
gz_curve = compute_gz_wall_sided(wigley_medium, wigley_medium.draft)
|
||||||
|
phi = gz_curve.angles_deg
|
||||||
|
gz = gz_curve.gz_values
|
||||||
|
mask = phi <= 30.0
|
||||||
|
phi_rad = np.deg2rad(phi[mask])
|
||||||
|
gz_30 = gz[mask]
|
||||||
|
# Trapecio manual
|
||||||
|
area_trap = float(np.trapz(gz_30, phi_rad))
|
||||||
|
assert abs(gz_curve.area_0_30 - area_trap) / max(area_trap, 1e-6) < 0.02, (
|
||||||
|
f"area_0_30={gz_curve.area_0_30:.5f}, trapecio={area_trap:.5f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_s14_gz_max_in_curve(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-14: gz_max coincide con el máximo real del array de GZ."""
|
||||||
|
assert abs(gz_wall_small.gz_max - float(np.max(gz_wall_small.gz_values))) < 1e-9
|
||||||
|
|
||||||
|
def test_s15_phi_gz_max_in_range(self, gz_wall_small: GZCurve) -> None:
|
||||||
|
"""S-15: phi_gz_max ∈ [0°, 90°]."""
|
||||||
|
assert 0.0 <= gz_wall_small.phi_gz_max <= 90.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-16..S-20: IMO IS Code 2008
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIMOIS2008:
|
||||||
|
"""S-16..S-20 — verificación de criterios IMO."""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def imo_result(self, wigley_small: Hull) -> IMOResult:
|
||||||
|
gz = compute_gz_wall_sided(wigley_small, wigley_small.draft)
|
||||||
|
return check_imo_is2008(gz)
|
||||||
|
|
||||||
|
def test_s16_six_criteria(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-16: Se devuelven exactamente 6 criterios."""
|
||||||
|
assert len(imo_result.criteria) == 6
|
||||||
|
|
||||||
|
def test_s17_overall_passed_is_bool(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-17: overall_passed es bool."""
|
||||||
|
assert isinstance(imo_result.overall_passed, bool)
|
||||||
|
|
||||||
|
def test_s18_table_rows_count(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-18: table_rows() devuelve exactamente 6 filas."""
|
||||||
|
rows = imo_result.table_rows()
|
||||||
|
assert len(rows) == 6
|
||||||
|
|
||||||
|
def test_s19_criterion_fields(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-19: Cada criterio tiene los campos correctos."""
|
||||||
|
for c in imo_result.criteria:
|
||||||
|
assert isinstance(c.code, str)
|
||||||
|
assert isinstance(c.description, str)
|
||||||
|
assert isinstance(c.required, float)
|
||||||
|
assert isinstance(c.achieved, float)
|
||||||
|
assert isinstance(c.unit, str)
|
||||||
|
assert isinstance(c.passed, bool)
|
||||||
|
# Consistencia: passed ↔ achieved >= required
|
||||||
|
assert c.passed == (c.achieved >= c.required)
|
||||||
|
|
||||||
|
def test_s20_overall_consistent(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-20: overall_passed == AND de todos los criterios individuales."""
|
||||||
|
expected = all(c.passed for c in imo_result.criteria)
|
||||||
|
assert imo_result.overall_passed == expected
|
||||||
|
|
||||||
|
def test_s20b_criterion_codes(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-20b: Los códigos de criterio son los correctos."""
|
||||||
|
codes = [c.code for c in imo_result.criteria]
|
||||||
|
expected = ["A.2.1.1", "A.2.1.2", "A.2.1.3", "A.2.1.4", "A.2.1.5", "A.2.1.6"]
|
||||||
|
assert codes == expected
|
||||||
|
|
||||||
|
def test_s20c_units(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-20c: Las unidades son correctas por criterio."""
|
||||||
|
units = [c.unit for c in imo_result.criteria]
|
||||||
|
assert units == ["m·rad", "m·rad", "m·rad", "m", "°", "m"]
|
||||||
|
|
||||||
|
def test_s20d_required_values(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-20d: Los valores requeridos coinciden con el IS Code 2008."""
|
||||||
|
reqs = [c.required for c in imo_result.criteria]
|
||||||
|
expected = [0.055, 0.090, 0.030, 0.200, 25.0, 0.150]
|
||||||
|
for r, e in zip(reqs, expected):
|
||||||
|
assert abs(r - e) < 1e-9, f"Requerido {r} ≠ {e}"
|
||||||
|
|
||||||
|
def test_s20e_table_row_format(self, imo_result: IMOResult) -> None:
|
||||||
|
"""S-20e: Cada fila de la tabla tiene 5 elementos (code, desc, req, ach, passed)."""
|
||||||
|
for row in imo_result.table_rows():
|
||||||
|
assert len(row) == 5
|
||||||
|
code, desc, req_str, ach_str, passed = row
|
||||||
|
assert isinstance(code, str)
|
||||||
|
assert isinstance(desc, str)
|
||||||
|
assert isinstance(req_str, str)
|
||||||
|
assert isinstance(ach_str, str)
|
||||||
|
assert isinstance(passed, bool)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-21..S-25: Familias de casco
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHullFamiliesGZ:
|
||||||
|
"""S-21..S-25 — GZ calculado sin errores para diferentes familias."""
|
||||||
|
|
||||||
|
def test_s21_planing_hull(self) -> None:
|
||||||
|
"""S-21: Casco planeador — compute_gz_wall_sided sin error."""
|
||||||
|
hull = generate_hull(
|
||||||
|
HullFamily.PLANING,
|
||||||
|
lpp=8.0, beam=2.4, draft=0.45, depth=0.80,
|
||||||
|
)
|
||||||
|
gz = compute_gz_wall_sided(hull, hull.draft, angles_deg=np.linspace(0, 60, 31))
|
||||||
|
assert len(gz.points) == 31
|
||||||
|
assert gz.gz_values[0] == pytest.approx(0.0, abs=1e-9)
|
||||||
|
|
||||||
|
def test_s22_displacement_hull(self) -> None:
|
||||||
|
"""S-22: Casco desplazamiento — compute_gz_wall_sided sin error."""
|
||||||
|
hull = generate_hull(
|
||||||
|
HullFamily.DISPLACEMENT,
|
||||||
|
lpp=15.0, beam=4.0, draft=1.5, depth=2.5,
|
||||||
|
)
|
||||||
|
gz = compute_gz_wall_sided(hull, hull.draft)
|
||||||
|
assert gz.gm > 0.0
|
||||||
|
assert gz.gz_max > 0.0
|
||||||
|
|
||||||
|
def test_s23_sailing_hull(self) -> None:
|
||||||
|
"""S-23: Casco velero — compute_gz_wall_sided sin error."""
|
||||||
|
hull = generate_hull(
|
||||||
|
HullFamily.SAILING,
|
||||||
|
lpp=9.0, beam=2.8, draft=0.90, depth=1.30,
|
||||||
|
)
|
||||||
|
gz = compute_gz_wall_sided(hull, hull.draft)
|
||||||
|
assert len(gz.points) > 0
|
||||||
|
# GZ(0°) = 0
|
||||||
|
assert abs(gz.gz_values[0]) < 1e-9
|
||||||
|
|
||||||
|
def test_s24_workboat_hull(self) -> None:
|
||||||
|
"""S-24: Workboat — compute_gz_wall_sided sin error."""
|
||||||
|
hull = generate_hull(
|
||||||
|
HullFamily.WORKBOAT,
|
||||||
|
lpp=18.0, beam=6.0, draft=2.0, depth=3.0,
|
||||||
|
)
|
||||||
|
gz = compute_gz_wall_sided(hull, hull.draft)
|
||||||
|
assert gz.gz_max > 0.0
|
||||||
|
|
||||||
|
def test_s25_imo_check_no_error(self) -> None:
|
||||||
|
"""S-25: check_imo_is2008 sin error para cualquier casco."""
|
||||||
|
hull = Hull.from_wigley(lpp=12.0, beam=2.0, draft=1.0,
|
||||||
|
n_stations=21, n_waterlines=11)
|
||||||
|
gz = compute_gz_wall_sided(hull, hull.draft)
|
||||||
|
result = check_imo_is2008(gz)
|
||||||
|
assert isinstance(result, IMOResult)
|
||||||
|
assert len(result.criteria) == 6
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# S-26..S-28: GZCurveWidget (headless)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGZCurveWidget:
|
||||||
|
"""S-26..S-28 — tests del widget Qt en modo offscreen."""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def app(self):
|
||||||
|
"""QApplication compartida para todos los tests del widget."""
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
existing = QApplication.instance()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
return QApplication([])
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def widget(self, app):
|
||||||
|
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
|
||||||
|
w = GZCurveWidget()
|
||||||
|
return w
|
||||||
|
|
||||||
|
def test_s26_widget_creates(self, widget) -> None:
|
||||||
|
"""S-26: GZCurveWidget se crea sin error."""
|
||||||
|
from arshipdesign.ui.widgets.gz_curve_widget import GZCurveWidget
|
||||||
|
assert isinstance(widget, GZCurveWidget)
|
||||||
|
|
||||||
|
def test_s27_set_curve_no_error(self, widget) -> None:
|
||||||
|
"""S-27: set_curve() no lanza excepción."""
|
||||||
|
hull = Hull.from_wigley(lpp=10.0, beam=1.5, draft=0.75,
|
||||||
|
n_stations=21, n_waterlines=11)
|
||||||
|
gz = compute_gz_wall_sided(hull, hull.draft)
|
||||||
|
imo = check_imo_is2008(gz)
|
||||||
|
widget.set_curve(gz, imo) # no debe lanzar
|
||||||
|
|
||||||
|
def test_s28_angle_hovered_signal(self, widget) -> None:
|
||||||
|
"""S-28: La señal angle_hovered existe y es de tipo Signal."""
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
assert hasattr(widget, "angle_hovered")
|
||||||
|
# Verificar que se puede conectar
|
||||||
|
received = []
|
||||||
|
widget.angle_hovered.connect(lambda v: received.append(v))
|
||||||
|
widget.set_active_angle(15.0)
|
||||||
|
# La señal no se emite por set_active_angle, pero la conexión no falla
|
||||||
|
widget.angle_hovered.disconnect()
|
||||||
Reference in New Issue
Block a user