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>
This commit is contained in:
2026-05-27 09:21:27 -04:00
parent 98ff57ed08
commit 4630d2d19f
4 changed files with 1129 additions and 4 deletions
+80 -4
View File
@@ -870,6 +870,11 @@ class MainWindow(QMainWindow):
self._offsets_editor.hull_changed.connect(self._on_hull_changed_from_editor)
self._module_area.set_module_widget(ModuleArea.MOD_OFFSETS, self._offsets_editor)
# Visor de curvas hidrostáticas (sustituye el placeholder MOD_CURVES)
from arshipdesign.ui.widgets.hydrostatics_chart import HydrostaticsChartWidget
self._hydro_chart = HydrostaticsChartWidget()
self._module_area.set_module_widget(ModuleArea.MOD_CURVES, self._hydro_chart)
# Dock izquierdo — capas
self._layers_panel = LayersPanel(self._strings)
self._dock_layers = QDockWidget("Capas", self)
@@ -964,9 +969,12 @@ class MainWindow(QMainWindow):
# ── Tab ANÁLISIS ──────────────────────────────────────────
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática")
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular hidrostáticos", enabled=False)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Calcular", "Calcular curvas hidrostáticas",
self._on_compute_hydrostatics)
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curvas", "Curvas hidrostáticas",
lambda: self._module_area.activate(M.MOD_CURVES), False)
self._on_show_hydrostatics)
g.add_button(_spi(sp.SP_DialogSaveButton), "Exp. CSV", "Exportar curvas como CSV",
self._on_export_hydrostatics_csv)
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
@@ -1095,8 +1103,12 @@ class MainWindow(QMainWindow):
m = mb.addMenu("Análisis")
sm = m.addMenu("Hidrostática")
self._add_action(sm, "Calcular hidrostáticos", enabled=False)
self._add_action(sm, "Curvas hidrostáticas", slot=lambda: self._module_area.activate(M.MOD_CURVES), enabled=False)
self._add_action(sm, "Calcular hidrostáticos",
slot=self._on_compute_hydrostatics)
self._add_action(sm, "Curvas hidrostáticas",
slot=self._on_show_hydrostatics)
self._add_action(sm, "Exportar curvas CSV…",
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)
@@ -1387,6 +1399,70 @@ class MainWindow(QMainWindow):
except Exception as exc:
logger.warning("Error al calcular hidrostáticos: %s", exc)
# ─────────────────────────────────────────────────────────
# CURVAS HIDROSTÁTICAS
# ─────────────────────────────────────────────────────────
def _on_compute_hydrostatics(self) -> None:
"""Calcula las curvas hidrostáticas y muestra el módulo."""
if self._current_hull is None:
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(
self, "Sin casco", "Crea o abre un proyecto con un casco definido."
)
return
try:
from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves
self.statusBar().showMessage("Calculando curvas hidrostáticas…")
QApplication.processEvents()
curves = HydrostaticCurves.compute(
self._current_hull, n_points=30, rho=1025.0
)
self._hydro_chart.set_curves(curves)
self._module_area.activate(ModuleArea.MOD_CURVES)
self.statusBar().showMessage(
f"Curvas hidrostáticas calculadas — {curves.hull_name} "
f"({len(curves.points)} puntos, T: "
f"{curves.drafts[0]:.2f}{curves.drafts[-1]:.2f} m)"
)
except Exception as exc:
logger.error("Error al calcular curvas: %s", exc)
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error al calcular", str(exc))
def _on_show_hydrostatics(self) -> None:
"""Muestra el módulo de curvas (sin recalcular si ya hay datos)."""
if self._hydro_chart.curves is None and self._current_hull is not None:
self._on_compute_hydrostatics()
else:
self._module_area.activate(ModuleArea.MOD_CURVES)
def _on_export_hydrostatics_csv(self) -> None:
"""Exporta las curvas hidrostáticas como CSV."""
if self._hydro_chart.curves is None:
from PySide6.QtWidgets import QMessageBox
QMessageBox.information(
self, "Sin datos", "Calcula las curvas hidrostáticas primero."
)
return
curves = self._hydro_chart.curves
default_name = f"{curves.hull_name}_hidrostatics.csv".replace(" ", "_")
path, _ = QFileDialog.getSaveFileName(
self, "Exportar curvas hidrostáticas",
str(Path.home() / default_name),
"CSV (*.csv);;Todos los archivos (*)",
)
if not path:
return
try:
lines = curves.to_csv_lines(sep=",", decimal=".")
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
self.statusBar().showMessage(f"CSV exportado: {path}")
except Exception as exc:
logger.error("Error al exportar CSV: %s", exc)
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error al exportar", str(exc))
def _ask_save(self) -> bool:
reply = QMessageBox.question(
self, "Cambios sin guardar",
+37
View File
@@ -477,3 +477,40 @@ QToolTip {
padding: 4px 6px;
font-size: 12px;
}
/* ─── MÓDULO: CURVAS HIDROSTÁTICAS ─────────────────────────── */
QWidget#hydrostaticsChart { background-color: #1a1d30; }
QFrame#hydroInfoBar {
background-color: #1e2240;
border-bottom: 1px solid #2e3870;
}
QLabel#hydroInfoName {
color: #4da8ff;
font-size: 12px;
font-weight: bold;
padding: 0 10px 0 4px;
letter-spacing: 1px;
}
QFrame#hydroInfoSep { color: #2e3870; }
QLabel#hydroInfoKey { color: #6878a0; font-size: 10px; padding: 0 2px; }
QLabel#hydroInfoVal {
color: #c8d8f0;
font-family: "Consolas", monospace;
font-size: 10px;
min-width: 46px;
}
QFrame#hydroChartSep { color: #2e3870; }
QScrollArea#hydroChartScroll, QWidget#hydroChartContainer {
background-color: #1a1d30;
border: none;
}
QLabel#hydroPlaceholder {
color: #3a4870;
font-size: 14px;
background-color: #1a1d30;
padding: 40px;
}
@@ -0,0 +1,591 @@
"""
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)