diff --git a/arshipdesign/stability/__init__.py b/arshipdesign/stability/__init__.py index b53549b..5135a40 100644 --- a/arshipdesign/stability/__init__.py +++ b/arshipdesign/stability/__init__.py @@ -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", +] diff --git a/arshipdesign/stability/gz_integrator.py b/arshipdesign/stability/gz_integrator.py new file mode 100644 index 0000000..f0a7f71 --- /dev/null +++ b/arshipdesign/stability/gz_integrator.py @@ -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 diff --git a/arshipdesign/stability/imo_is2008.py b/arshipdesign/stability/imo_is2008.py new file mode 100644 index 0000000..11c7a13 --- /dev/null +++ b/arshipdesign/stability/imo_is2008.py @@ -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) diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index 527064b..5631d35 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -47,6 +47,8 @@ from PySide6.QtWidgets import ( from arshipdesign import __version__ from arshipdesign.core.project import Project 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 ( add_recent_file, get_language, @@ -813,6 +815,7 @@ class MainWindow(QMainWindow): super().__init__() self._project: Optional[Project] = None self._current_hull = None # Hull activo en todos los visores + self._gz_widget: Optional[GZCurveWidget] = None self._lang = get_language() self._strings = _load_i18n(self._lang) self._setup_ui() @@ -878,6 +881,10 @@ class MainWindow(QMainWindow): self._hydro_chart = HydrostaticsChartWidget() 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 self._layers_panel = LayersPanel(self._strings) self._dock_layers = QDockWidget("Capas", self) @@ -981,7 +988,7 @@ class MainWindow(QMainWindow): g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad") 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), "Avería", "Estabilidad en avería", enabled=False) @@ -1114,7 +1121,7 @@ class MainWindow(QMainWindow): slot=self._on_export_hydrostatics_csv) 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, "Criterio de viento A.749(18)", 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) # ── Panel hidrostáticos ─────────────────────────────────── 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: """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 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: reply = QMessageBox.question( self, "Cambios sin guardar", diff --git a/arshipdesign/ui/themes/dark.qss b/arshipdesign/ui/themes/dark.qss index b05f552..107c9cf 100644 --- a/arshipdesign/ui/themes/dark.qss +++ b/arshipdesign/ui/themes/dark.qss @@ -514,3 +514,29 @@ QLabel#hydroPlaceholder { background-color: #1a1d30; 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; +} diff --git a/arshipdesign/ui/widgets/gz_curve_widget.py b/arshipdesign/ui/widgets/gz_curve_widget.py new file mode 100644 index 0000000..38a4607 --- /dev/null +++ b/arshipdesign/ui/widgets/gz_curve_widget.py @@ -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, + ) diff --git a/tests/test_module3_stability.py b/tests/test_module3_stability.py new file mode 100644 index 0000000..e525f42 --- /dev/null +++ b/tests/test_module3_stability.py @@ -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()