Files
AR-Shipdesign/arshipdesign/ui/widgets/hydrostatics_chart.py
T
alro65 4630d2d19f Módulo 2 completo: HydrostaticsChartWidget + integración + tests (Tasks 14–16)
Task 14 — HydrostaticsChartWidget (QPainter):
- 9 paneles cuadrícula 3x3: Δ, V, Awp, LCB/LCF, KB/BMT, KMT/KML,
  TPC, MCT, Cb/Cw/Cm
- Cursor vertical compartido: clic/arrastre en cualquier panel mueve
  el cursor en todos y actualiza la barra de valores
- _InfoBar: franja superior con valores interpolados al calado activo
- _nice_ticks(): escala de ejes legible sin dependencias externas
- Sin dependencias externas (sólo PySide6 + numpy)

Task 15 — Integración en MainWindow:
- MOD_CURVES cargado con HydrostaticsChartWidget (sustituye placeholder)
- _on_compute_hydrostatics(): calcula HydrostaticCurves.compute(n=30)
- _on_show_hydrostatics(): abre el módulo (calculando si no hay datos)
- _on_export_hydrostatics_csv(): exporta CSV con QFileDialog
- Ribbon tab Análisis: botones Calcular, Curvas, Exp. CSV activos
- Menú Análisis → Hidrostática: 3 acciones funcionando
- dark.qss: estilos para hydrostaticsChart, hydroInfoBar, hydroPlaceholder

Task 16 — Tests V&V (58 tests):
- Widget headless W-01..W-08: construcción, set_curves, señales, clampeo
- CSV V037..V044: columnas, filas, monotonicidad, separadores, decimal coma
- at_draft V045..V049: interpolación lineal, clampeo, tipo retorno
- 5 familias V050..V055: Δ monótona, V>0, Cb∈(0,1), KMT>KB, KML>KMT, TPC>0
- IACS Rec.34 §4.3 V056..V062: Cb=4/9, Cw=2/3, KB, LCB=LCF=L/2, Cp=Cb/Cm,
  convergencia de malla <2%

Total: 282 tests, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:21:27 -04:00

592 lines
21 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.
"""
HydrostaticsChartWidget — visor de curvas hidrostáticas (QPainter).
Distribuye 9 paneles en cuadrícula 3×3. Cada panel muestra una o varias
magnitudes hidrostáticas en función del calado T. Un cursor vertical
compartido indica el calado activo — clic o arrastre en cualquier panel
actualiza el cursor en todos y muestra los valores interpolados.
Sin dependencias externas: sólo PySide6 + numpy.
Autor: Álvaro Romero
Módulo 2 — AR-ShipDesign
"""
from __future__ import annotations
import math
from typing import List, Optional, Tuple
import numpy as np
from PySide6.QtCore import Qt, QPointF, QRectF, Signal
from PySide6.QtGui import (
QColor, QFont, QFontMetricsF, QPainter, QPainterPath, QPen,
)
from PySide6.QtWidgets import (
QFrame, QGridLayout, QHBoxLayout, QLabel, QScrollArea,
QSizePolicy, QVBoxLayout, QWidget,
)
try:
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
except ImportError:
HydrostaticCurves = None # type: ignore[assignment,misc]
# ── Paleta (misma base que viewer_lines) ──────────────────────────────────
_BG = QColor("#1a1d30")
_PANEL_BG = QColor("#1e2240")
_GRID_C = QColor("#253060")
_AXIS_C = QColor("#3a4870")
_LABEL_C = QColor("#7a8aaa")
_TITLE_C = QColor("#c8d8f0")
_CURSOR_C = QColor("#ffd700")
_BORDER_C = QColor("#2e3870")
_CURVE_COLORS = [
QColor("#4da8ff"), # azul eléctrico
QColor("#ffd700"), # dorado
QColor("#6ee7b7"), # verde agua
QColor("#ff6b6b"), # coral
QColor("#a78bfa"), # lavanda
]
# ── Fuentes ───────────────────────────────────────────────────────────────
_F_TITLE = QFont("Segoe UI", 8, QFont.Weight.Bold)
_F_LABEL = QFont("Consolas", 7)
_F_VALUE = QFont("Consolas", 8, QFont.Weight.Bold)
# ── Márgenes por panel (píxeles) ─────────────────────────────────────────
_ML = 56 # izquierda — etiquetas eje Y
_MR = 10 # derecha
_MT = 24 # arriba — título del panel
_MB = 26 # abajo — etiquetas eje X (calado)
# ── Definición de los 9 paneles ───────────────────────────────────────────
# (título, [(attr_HydrostaticCurves, etiqueta_leyenda)])
_PANEL_DEFS: List[Tuple[str, List[Tuple[str, str]]]] = [
("Desplazamiento Δ [t]", [("displacements", "Δ")]),
("Volumen de Carena V [m³]", [("volumes", "V")]),
("Área de Flotación Awp [m²]", [("awp_values", "Awp")]),
("Centros Longitudinales [m]", [("lcb_values", "LCB"), ("lcf_values", "LCF")]),
("Centros Verticales [m]", [("kb_values", "KB"), ("bmt_values", "BMT")]),
("Metacentro KMT / KML [m]", [("kmt_values", "KMT"), ("kml_values", "KML")]),
("TPC [t/cm]", [("tpc_values", "TPC")]),
("MCT 1cm [t·m/cm]", [("mct_values", "MCT")]),
("Coeficientes de Forma", [("cb_values", "Cb"), ("cw_values", "Cw"), ("cm_values", "Cm")]),
]
# ── Utilidad de ticks ─────────────────────────────────────────────────────
def _nice_ticks(vmin: float, vmax: float, n: int = 4) -> List[float]:
"""Valores 'redondos' para los ejes (algoritmo estándar)."""
if not (math.isfinite(vmin) and math.isfinite(vmax)) or vmax - vmin < 1e-12:
return [vmin]
raw = (vmax - vmin) / n
mag = 10 ** math.floor(math.log10(max(raw, 1e-15)))
r = raw / mag
step = (1 if r < 1.5 else 2 if r < 3 else 5 if r < 7 else 10) * mag
start = math.ceil(vmin / step) * step
ticks: List[float] = []
t = start
while t <= vmax + step * 0.01:
ticks.append(round(t, 12))
t += step
return ticks
# ── Panel individual ──────────────────────────────────────────────────────
class _CurvePanel(QWidget):
"""Panel QPainter de una o varias curvas hidrostáticas vs calado T."""
draft_picked = Signal(float)
def __init__(
self,
title: str,
curve_defs: List[Tuple[str, str]],
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent)
self._title = title
self._cdefs = curve_defs
self._drafts: Optional[np.ndarray] = None
self._ys: List[Optional[np.ndarray]] = []
self._active_T: Optional[float] = None
self.setMinimumSize(180, 160)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMouseTracking(True)
# ── API ──────────────────────────────────────────────────────────────
def set_data(
self,
drafts: np.ndarray,
ys_list: List[Optional[np.ndarray]],
active_T: Optional[float] = None,
) -> None:
self._drafts = drafts
self._ys = ys_list
self._active_T = active_T
self.update()
def set_active_draft(self, T: float) -> None:
self._active_T = T
self.update()
def clear(self) -> None:
self._drafts = None
self._ys = []
self._active_T = None
self.update()
# ── Ratón ────────────────────────────────────────────────────────────
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
T = self._px_to_T(event.position().x())
if T is not None:
self.draft_picked.emit(T)
def mouseMoveEvent(self, event) -> None:
if event.buttons() & Qt.MouseButton.LeftButton:
T = self._px_to_T(event.position().x())
if T is not None:
self.draft_picked.emit(T)
def _px_to_T(self, px: float) -> Optional[float]:
if self._drafts is None or len(self._drafts) < 2:
return None
x0, x1 = float(_ML), float(self.width() - _MR)
if x1 <= x0:
return None
frac = max(0.0, min(1.0, (px - x0) / (x1 - x0)))
T_min, T_max = float(self._drafts[0]), float(self._drafts[-1])
return T_min + frac * (T_max - T_min)
# ── Pintura ──────────────────────────────────────────────────────────
def paintEvent(self, event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
W, H = self.width(), self.height()
p.fillRect(0, 0, W, H, _PANEL_BG)
p.setPen(QPen(_BORDER_C, 1))
p.drawRect(0, 0, W - 1, H - 1)
# Título
p.setFont(_F_TITLE)
p.setPen(_TITLE_C)
p.drawText(
QRectF(_ML, 3, W - _ML - _MR, _MT - 4),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
self._title,
)
if self._drafts is None or len(self._drafts) < 2 or not self._ys:
self._no_data(p, W, H)
p.end()
return
x0, x1 = float(_ML), float(W - _MR)
y0, y1 = float(_MT), float(H - _MB)
pw, ph = x1 - x0, y1 - y0
if pw < 20 or ph < 20:
p.end()
return
T_min = float(self._drafts[0])
T_max = float(self._drafts[-1])
# Rango Y global
y_all: List[float] = []
for ys in self._ys:
if ys is not None:
fin = ys[np.isfinite(ys)]
if len(fin):
y_all.extend(fin.tolist())
if not y_all:
self._no_data(p, W, H)
p.end()
return
Y_min, Y_max = float(np.min(y_all)), float(np.max(y_all))
if Y_max - Y_min < 1e-9:
Y_min -= 0.5
Y_max += 0.5
pad = (Y_max - Y_min) * 0.07
Y_min -= pad
Y_max += pad
def wx(T: float) -> float:
return x0 + (T - T_min) / (T_max - T_min) * pw
def wy(y: float) -> float:
return y1 - (y - Y_min) / (Y_max - Y_min) * ph
self._draw_axes(p, x0, x1, y0, y1, T_min, T_max, Y_min, Y_max, wx, wy)
for k, (_, leg) in enumerate(self._cdefs):
if k >= len(self._ys) or self._ys[k] is None:
continue
col = _CURVE_COLORS[k % len(_CURVE_COLORS)]
self._draw_curve(p, self._drafts, self._ys[k], wx, wy, col)
self._draw_legend(p, k, leg, col, x1, y1)
if self._active_T is not None:
T_c = float(np.clip(self._active_T, T_min, T_max))
self._draw_cursor(p, T_c, x0, x1, y0, y1, wx, wy)
p.end()
def _no_data(self, p: QPainter, W: int, H: int) -> None:
p.setFont(_F_LABEL)
p.setPen(_LABEL_C)
p.drawText(QRectF(0, _MT, W, H - _MT), Qt.AlignmentFlag.AlignCenter, "Sin datos")
def _draw_axes(
self, p: QPainter,
x0: float, x1: float, y0: float, y1: float,
T_min: float, T_max: float, Y_min: float, Y_max: float,
wx, wy,
) -> None:
fm = QFontMetricsF(_F_LABEL)
p.setFont(_F_LABEL)
# Grid + etiquetas Y
for yv in _nice_ticks(Y_min, Y_max, 4):
yp = wy(yv)
if not (y0 - 1 <= yp <= y1 + 1):
continue
p.setPen(QPen(_GRID_C, 1))
p.drawLine(QPointF(x0, yp), QPointF(x1, yp))
lbl = f"{yv:.3g}"
lw = fm.horizontalAdvance(lbl)
p.setPen(_LABEL_C)
p.drawText(QPointF(x0 - lw - 3, yp + fm.ascent() * 0.4), lbl)
# Grid + etiquetas X (calado)
for Tv in _nice_ticks(T_min, T_max, 4):
xp = wx(Tv)
if not (x0 - 1 <= xp <= x1 + 1):
continue
p.setPen(QPen(_GRID_C, 1))
p.drawLine(QPointF(xp, y0), QPointF(xp, y1))
lbl = f"{Tv:.2f}"
lw = fm.horizontalAdvance(lbl)
p.setPen(_LABEL_C)
p.drawText(QPointF(xp - lw / 2, y1 + fm.ascent() + 3), lbl)
# Ejes sólidos
p.setPen(QPen(_AXIS_C, 1))
p.drawLine(QPointF(x0, y0), QPointF(x0, y1))
p.drawLine(QPointF(x0, y1), QPointF(x1, y1))
def _draw_curve(
self, p: QPainter,
drafts: np.ndarray, ys: np.ndarray,
wx, wy, color: QColor,
) -> None:
path = QPainterPath()
started = False
for T, y in zip(drafts, ys):
Tf, yf = float(T), float(y)
if not (math.isfinite(Tf) and math.isfinite(yf)):
started = False
continue
xp, yp = wx(Tf), wy(yf)
if not started:
path.moveTo(xp, yp)
started = True
else:
path.lineTo(xp, yp)
pen = QPen(color, 1.8, Qt.PenStyle.SolidLine,
Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawPath(path)
def _draw_legend(
self, p: QPainter,
idx: int, label: str, color: QColor,
x1: float, y1: float,
) -> None:
p.setFont(_F_LABEL)
fm = QFontMetricsF(_F_LABEL)
line_w = 14
lw = fm.horizontalAdvance(label)
total = line_w + 4 + lw
x = x1 - total - 4
y = y1 - 6 - idx * (int(fm.height()) + 2)
if y < float(_MT) + 4:
return
pen = QPen(color, 2)
p.setPen(pen)
p.drawLine(QPointF(x, y), QPointF(x + line_w, y))
p.drawText(QPointF(x + line_w + 4, y + fm.ascent() * 0.4), label)
def _draw_cursor(
self, p: QPainter,
T_c: float, x0: float, x1: float, y0: float, y1: float,
wx, wy,
) -> None:
xc = wx(T_c)
p.setPen(QPen(_CURSOR_C, 1.0, Qt.PenStyle.DashLine))
p.drawLine(QPointF(xc, y0), QPointF(xc, y1))
fm = QFontMetricsF(_F_VALUE)
p.setFont(_F_VALUE)
for k, (_, leg) in enumerate(self._cdefs):
if k >= len(self._ys) or self._ys[k] is None:
continue
ys = self._ys[k]
if self._drafts is None:
continue
yv = float(np.interp(T_c, self._drafts, ys))
yp = wy(yv)
col = _CURVE_COLORS[k % len(_CURVE_COLORS)]
# Dot at intersection
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(col)
p.drawEllipse(QPointF(xc, yp), 3.5, 3.5)
p.setBrush(Qt.BrushStyle.NoBrush)
# Value label (right or left of cursor to avoid clipping)
txt = f"{yv:.4g}"
tw = fm.horizontalAdvance(txt)
tx = xc + 5 if xc + 5 + tw < x1 else xc - tw - 5
p.setPen(_CURSOR_C)
p.drawText(QPointF(tx, yp - 4), txt)
# ── Barra de información ──────────────────────────────────────────────────
class _InfoBar(QFrame):
"""Franja superior: nombre del casco + valores al calado activo."""
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setObjectName("hydroInfoBar")
self.setFixedHeight(30)
lo = QHBoxLayout(self)
lo.setContentsMargins(8, 0, 8, 0)
lo.setSpacing(0)
self._name_lbl = QLabel("Sin datos")
self._name_lbl.setObjectName("hydroInfoName")
lo.addWidget(self._name_lbl)
sep = self._sep()
lo.addWidget(sep)
mono = QFont("Consolas", 9)
self._fields: dict[str, QLabel] = {}
_items = [
("T", "Calado activo [m]"),
("Δ", "Desplazamiento [t]"),
("LCB", "LCB [m desde AP]"),
("KB", "KB [m]"),
("KMT", "KMT [m]"),
("TPC", "TPC [t/cm]"),
("MCT", "MCT [t·m/cm]"),
("Cb", "Cb"),
("Cw", "Cw"),
("Cm", "Cm"),
]
for key, tip in _items:
k = QLabel(f" {key} ")
k.setObjectName("hydroInfoKey")
k.setToolTip(tip)
v = QLabel("—")
v.setObjectName("hydroInfoVal")
v.setFont(mono)
v.setMinimumWidth(46)
v.setToolTip(tip)
self._fields[key] = v
lo.addWidget(k)
lo.addWidget(v)
lo.addWidget(self._sep())
lo.addStretch()
@staticmethod
def _sep() -> QFrame:
f = QFrame()
f.setFrameShape(QFrame.Shape.VLine)
f.setObjectName("hydroInfoSep")
f.setFixedWidth(1)
return f
def set_hull_name(self, name: str) -> None:
self._name_lbl.setText(name)
def update_hydrostatics(self, h) -> None:
"""h: UprightHydrostatics o None."""
if h is None:
for v in self._fields.values():
v.setText("—")
return
mapping = {
"T": f"{h.draft:.3f}",
"Δ": f"{h.displacement:.1f} t",
"LCB": f"{h.lcb:.3f}",
"KB": f"{h.kb:.3f}",
"KMT": f"{h.kmt:.3f}",
"TPC": f"{h.tpc:.4f}",
"MCT": f"{h.mct:.4f}",
"Cb": f"{h.cb:.4f}",
"Cw": f"{h.cw:.4f}",
"Cm": f"{h.cm:.4f}",
}
for key, val in mapping.items():
if key in self._fields:
self._fields[key].setText(val)
# ── Widget principal ──────────────────────────────────────────────────────
class HydrostaticsChartWidget(QWidget):
"""
Visor de curvas hidrostáticas con 9 paneles en cuadrícula 3×3.
Uso:
----
>>> w = HydrostaticsChartWidget()
>>> curves = HydrostaticCurves.compute(hull, n_points=30)
>>> w.set_curves(curves)
>>> w.set_active_draft(hull.draft) # opcional: posición inicial del cursor
"""
draft_changed = Signal(float) # se emite cuando el cursor se mueve
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.setObjectName("hydrostaticsChart")
self._curves: Optional["HydrostaticCurves"] = None
self._active_T: Optional[float] = None
self._panels: List[_CurvePanel] = []
self._build_ui()
# ── Construcción UI ──────────────────────────────────────────────────
def _build_ui(self) -> None:
lo = QVBoxLayout(self)
lo.setContentsMargins(0, 0, 0, 0)
lo.setSpacing(0)
# Barra informativa superior
self._info_bar = _InfoBar()
lo.addWidget(self._info_bar)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setObjectName("hydroChartSep")
lo.addWidget(sep)
# Área con scroll para la cuadrícula de paneles
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setObjectName("hydroChartScroll")
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
container.setObjectName("hydroChartContainer")
grid = QGridLayout(container)
grid.setContentsMargins(4, 4, 4, 4)
grid.setSpacing(3)
for col in range(3):
grid.setColumnStretch(col, 1)
for row in range(3):
grid.setRowStretch(row, 1)
self._panels = []
for i, (title, cdefs) in enumerate(_PANEL_DEFS):
row, col = divmod(i, 3)
panel = _CurvePanel(title, cdefs)
panel.draft_picked.connect(self.set_active_draft)
grid.addWidget(panel, row, col)
self._panels.append(panel)
scroll.setWidget(container)
lo.addWidget(scroll, 1)
# Mensaje placeholder (oculto cuando hay datos)
self._placeholder = QLabel(
"Sin curvas hidrostáticas.\n"
"Crear un proyecto y ejecutar Análisis → Hidrostática → Calcular."
)
self._placeholder.setObjectName("hydroPlaceholder")
self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._placeholder.setWordWrap(True)
lo.addWidget(self._placeholder)
self._toggle_placeholder(True)
def _toggle_placeholder(self, show: bool) -> None:
self._placeholder.setVisible(show)
# El scroll ya está en el layout, sólo cambiamos su visibilidad
scroll = self.findChild(QScrollArea, "hydroChartScroll")
if scroll is not None:
scroll.setVisible(not show)
# ── API pública ──────────────────────────────────────────────────────
def set_curves(self, curves: "HydrostaticCurves") -> None:
"""Carga un objeto HydrostaticCurves y repinta todos los paneles."""
self._curves = curves
if curves is None:
for panel in self._panels:
panel.clear()
self._info_bar.set_hull_name("Sin datos")
self._info_bar.update_hydrostatics(None)
self._toggle_placeholder(True)
return
# Cursor inicial al calado de diseño
self._active_T = float(curves.design_draft)
self._info_bar.set_hull_name(curves.hull_name)
self._populate_panels()
self._update_info_bar()
self._toggle_placeholder(False)
def set_active_draft(self, T: float) -> None:
"""Mueve el cursor de calado en todos los paneles y actualiza la barra."""
if self._curves is None:
return
T_min = float(self._curves.drafts[0])
T_max = float(self._curves.drafts[-1])
self._active_T = max(T_min, min(T_max, float(T)))
for panel in self._panels:
panel.set_active_draft(self._active_T)
self._update_info_bar()
self.draft_changed.emit(self._active_T)
@property
def curves(self) -> Optional["HydrostaticCurves"]:
return self._curves
@property
def active_draft(self) -> Optional[float]:
return self._active_T
# ── Helpers internos ─────────────────────────────────────────────────
def _populate_panels(self) -> None:
if self._curves is None:
return
drafts = self._curves.drafts
for i, (_, cdefs) in enumerate(_PANEL_DEFS):
ys_list: List[Optional[np.ndarray]] = []
for attr, _ in cdefs:
ys_list.append(getattr(self._curves, attr, None))
self._panels[i].set_data(drafts, ys_list, self._active_T)
def _update_info_bar(self) -> None:
if self._curves is None or self._active_T is None:
self._info_bar.update_hydrostatics(None)
return
try:
h = self._curves.at_draft(self._active_T)
self._info_bar.update_hydrostatics(h)
except Exception:
self._info_bar.update_hydrostatics(None)