""" 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, )