Files
AR-Shipdesign/arshipdesign/ui/widgets/gz_curve_widget.py
T
alro65 0f85935fc8 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>
2026-05-27 13:59:32 -04:00

618 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
030° verde (#00FF88 con alfa 0x20)
3040° á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,
)