From 4630d2d19f71d1fcd48f9bbcbbc29c36ce54cbe5 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Wed, 27 May 2026 09:21:27 -0400 Subject: [PATCH] =?UTF-8?q?M=C3=B3dulo=202=20completo:=20HydrostaticsChart?= =?UTF-8?q?Widget=20+=20integraci=C3=B3n=20+=20tests=20(Tasks=2014?= =?UTF-8?q?=E2=80=9316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- arshipdesign/ui/main_window.py | 84 ++- arshipdesign/ui/themes/dark.qss | 37 ++ arshipdesign/ui/widgets/hydrostatics_chart.py | 591 ++++++++++++++++++ tests/test_module2_integration.py | 421 +++++++++++++ 4 files changed, 1129 insertions(+), 4 deletions(-) create mode 100644 arshipdesign/ui/widgets/hydrostatics_chart.py create mode 100644 tests/test_module2_integration.py diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index d9a39f3..5d46271 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -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", diff --git a/arshipdesign/ui/themes/dark.qss b/arshipdesign/ui/themes/dark.qss index d22aab6..b05f552 100644 --- a/arshipdesign/ui/themes/dark.qss +++ b/arshipdesign/ui/themes/dark.qss @@ -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; +} diff --git a/arshipdesign/ui/widgets/hydrostatics_chart.py b/arshipdesign/ui/widgets/hydrostatics_chart.py new file mode 100644 index 0000000..bede2a5 --- /dev/null +++ b/arshipdesign/ui/widgets/hydrostatics_chart.py @@ -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) diff --git a/tests/test_module2_integration.py b/tests/test_module2_integration.py new file mode 100644 index 0000000..9fc8675 --- /dev/null +++ b/tests/test_module2_integration.py @@ -0,0 +1,421 @@ +""" +Tests Módulo 2 — Integración y V&V ampliada. + +Cubre: + - HydrostaticsChartWidget: construcción, set_curves, set_active_draft + - CSV export: integridad, columnas, separadores alternativos + - to_dict(): serialización JSON completa + - at_draft(): interpolación y clampeo + - Monotonía y coeficientes para las 5 familias paramétricas + - IACS Rec.34 §4.3–4.5: V&V adicional (V037–V055) + +Autor: Álvaro Romero | Módulo 2 — AR-ShipDesign +""" +from __future__ import annotations + +import io +import json +import math + +import numpy as np +import pytest + +from arshipdesign.core.hull import Hull +from arshipdesign.hydrostatics import ( + CSV_HEADERS, + HydrostaticCurves, + UprightHydrostatics, + compute_upright, +) +from arshipdesign.parametric import HullFamily, generate_hull + + +# --------------------------------------------------------------------------- +# Fixtures compartidas +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def wigley() -> Hull: + return Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0, + n_stations=41, n_waterlines=21) + + +@pytest.fixture(scope="module") +def wigley_curves(wigley: Hull) -> HydrostaticCurves: + return HydrostaticCurves.compute(wigley, n_points=25) + + +@pytest.fixture(scope="module") +def displacement_hull() -> Hull: + return generate_hull(HullFamily.DISPLACEMENT, lpp=18.0, beam=5.5, + draft=2.0, depth=3.0, cb=0.55) + + +# --------------------------------------------------------------------------- +# I. HydrostaticsChartWidget — construcción headless +# --------------------------------------------------------------------------- + +class TestHydrostaticsChartWidget: + """Pruebas del widget en modo headless (sin pantalla, sin PyVista).""" + + @pytest.fixture(autouse=True) + def setup(self, wigley_curves): + # Importación diferida: sólo falla si PySide6 no está instalado + try: + import os + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + self.app = QApplication.instance() or QApplication([]) + from arshipdesign.ui.widgets.hydrostatics_chart import ( + HydrostaticsChartWidget, + ) + self.Widget = HydrostaticsChartWidget + self.curves = wigley_curves + self.available = True + except Exception: + self.available = False + + def _skip_if_unavailable(self): + if not self.available: + pytest.skip("PySide6 / pantalla no disponible") + + # W-01 — Construcción sin datos + def test_widget_creates_empty(self): + self._skip_if_unavailable() + w = self.Widget() + assert w.curves is None + assert w.active_draft is None + + # W-02 — set_curves popula correctamente + def test_set_curves_populates(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + assert w.curves is self.curves + assert w.active_draft is not None + + # W-03 — active_draft inicial = design_draft + def test_active_draft_initial_is_design_draft(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + assert abs(w.active_draft - self.curves.design_draft) < 1e-9 + + # W-04 — set_active_draft clampea al rango válido + def test_set_active_draft_clamps(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + T_max = float(self.curves.drafts[-1]) + w.set_active_draft(T_max * 10) # muy alto + assert w.active_draft <= T_max + 1e-9 + w.set_active_draft(-99) # muy bajo + assert w.active_draft >= float(self.curves.drafts[0]) - 1e-9 + + # W-05 — draft_changed se emite + def test_draft_changed_signal_emits(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + received = [] + w.draft_changed.connect(received.append) + T_mid = (float(self.curves.drafts[0]) + float(self.curves.drafts[-1])) / 2 + w.set_active_draft(T_mid) + assert len(received) == 1 + assert abs(received[0] - T_mid) < 1e-6 + + # W-06 — set_curves(None) limpia el widget + def test_set_curves_none_clears(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + w.set_curves(None) + assert w.curves is None + + # W-07 — 9 paneles creados + def test_nine_panels_created(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + # _panels es la lista interna de _CurvePanel + assert len(w._panels) == 9 + + # W-08 — info bar muestra valores coherentes + def test_info_bar_shows_values(self): + self._skip_if_unavailable() + w = self.Widget() + w.set_curves(self.curves) + T_d = self.curves.design_draft + h = self.curves.at_draft(T_d) + # Los valores del info bar son strings; verificamos que no sean "—" + val = w._info_bar._fields["Δ"].text() + assert val != "—" + assert float(val.split()[0]) > 0 + + +# --------------------------------------------------------------------------- +# II. CSV — integridad y formatos +# --------------------------------------------------------------------------- + +class TestCSVExport: + """V037–V044: Integridad de la exportación CSV.""" + + @pytest.fixture(autouse=True) + def setup(self, wigley_curves): + self.curves = wigley_curves + + # V037 — número correcto de columnas + def test_csv_column_count(self): + lines = self.curves.to_csv_lines() + header = lines[0].split(",") + assert len(header) == len(CSV_HEADERS), \ + f"Esperados {len(CSV_HEADERS)} campos, obtenidos {len(header)}" + + # V038 — número de filas = n_points + 1 (cabecera) + def test_csv_row_count(self): + lines = self.curves.to_csv_lines() + assert len(lines) == len(self.curves.points) + 1 + + # V039 — primera columna = draft, valores crecientes + def test_csv_draft_column_monotone(self): + lines = self.curves.to_csv_lines() + drafts = [float(row.split(",")[0]) for row in lines[1:]] + assert all(b > a for a, b in zip(drafts, drafts[1:])) + + # V040 — separador alternativo ; funciona + def test_csv_semicolon_separator(self): + lines = self.curves.to_csv_lines(sep=";") + fields = lines[0].split(";") + assert len(fields) == len(CSV_HEADERS) + + # V041 — decimal coma funciona + def test_csv_decimal_comma(self): + lines = self.curves.to_csv_lines(sep=";", decimal=",") + # Ningún valor numérico debe contener un punto decimal + for row in lines[1:]: + for field in row.split(";"): + assert "." not in field, f"Campo con punto: {field!r}" + + # V042 — desplazamiento CSV coincide con punto almacenado + def test_csv_displacement_matches_upright(self): + lines = self.curves.to_csv_lines() + # Última fila = calado máximo = design_draft + # CSV_HEADERS: T[m]=0, V[m3]=1, Delta[t]=2 + last = lines[-1].split(",") + T_csv = float(last[0]) + D_csv = float(last[2]) # columna 2 = Delta[t] + T_d = self.curves.design_draft + assert abs(T_csv - T_d) < 1e-6 + expected = self.curves.points[-1].displacement + assert abs(D_csv - expected) < 1e-3 + + # V043 — to_dict() devuelve JSON serializable + def test_to_dict_json_serializable(self): + d = self.curves.to_dict() + txt = json.dumps(d) # lanza si no es serializable + d2 = json.loads(txt) + assert d2["hull_name"] == self.curves.hull_name + assert len(d2["points"]) == len(self.curves.points) + + # V044 — to_dict() incluye los campos clave (sin unidades en clave) + def test_to_dict_has_csv_fields(self): + d = self.curves.to_dict() + assert "hull_name" in d + assert "design_draft" in d + assert "points" in d + first = d["points"][0] + # Las claves del dict usan nombres cortos: T, V, Delta, Awp, ... + for key in ("T", "V", "Delta", "Awp", "LCB", "LCF", "KB", + "BMT", "BML", "KMT", "KML", "TPC", "MCT", "Cb", "Cw", "Cm"): + assert key in first, f"Campo faltante en dict: {key!r}" + + +# --------------------------------------------------------------------------- +# III. at_draft() — interpolación +# --------------------------------------------------------------------------- + +class TestAtDraft: + """V045–V049: at_draft() interpolación y clampeo.""" + + @pytest.fixture(autouse=True) + def setup(self, wigley_curves): + self.c = wigley_curves + + # V045 — at_draft en extremo inferior devuelve punto más bajo + def test_at_draft_lower_clamp(self): + T_min = float(self.c.drafts[0]) + h = self.c.at_draft(0.0) + assert isinstance(h, UprightHydrostatics) + assert abs(h.draft - T_min) < 1e-6 + + # V046 — at_draft en extremo superior devuelve punto más alto + def test_at_draft_upper_clamp(self): + T_max = float(self.c.drafts[-1]) + h = self.c.at_draft(T_max * 10) + assert abs(h.draft - T_max) < 1e-6 + + # V047 — interpolación lineal entre dos puntos conocidos + def test_at_draft_interpolates_linearly(self): + T_a = float(self.c.drafts[3]) + T_b = float(self.c.drafts[4]) + T_mid = (T_a + T_b) / 2 + D_a = self.c.points[3].displacement + D_b = self.c.points[4].displacement + D_expected = (D_a + D_b) / 2 + h = self.c.at_draft(T_mid) + assert abs(h.displacement - D_expected) / max(D_expected, 1) < 1e-3 + + # V048 — at_draft devuelve UprightHydrostatics + def test_at_draft_returns_upright(self): + T = float(self.c.drafts[5]) + h = self.c.at_draft(T) + assert isinstance(h, UprightHydrostatics) + + # V049 — draft del resultado coincide con el draft solicitado (en rango) + def test_at_draft_result_draft_matches(self): + T_target = float(self.c.drafts[7]) + h = self.c.at_draft(T_target) + assert abs(h.draft - T_target) < 1e-9 + + +# --------------------------------------------------------------------------- +# IV. Monotonía y coeficientes — 5 familias paramétricas (V050–V055) +# --------------------------------------------------------------------------- + +_FAMILIES = [ + (HullFamily.DISPLACEMENT, dict(lpp=15.0, beam=4.5, draft=1.80, depth=2.5, cb=0.55)), + (HullFamily.PLANING, dict(lpp=10.0, beam=3.0, draft=0.70, depth=1.20)), + (HullFamily.SAILING, dict(lpp=12.0, beam=3.5, draft=0.60, depth=1.80)), + (HullFamily.WORKBOAT, dict(lpp=18.0, beam=6.0, draft=2.20, depth=3.0)), + (HullFamily.MERCHANT, dict(lpp=80.0, beam=14.0, draft=5.0, depth=8.0, cb=0.72)), +] + + +@pytest.mark.parametrize("family,kwargs", _FAMILIES) +def test_V050_displacement_monotone(family, kwargs): + """V050: Desplazamiento creciente con calado.""" + hull = generate_hull(family, **kwargs) + c = HydrostaticCurves.compute(hull, n_points=15) + deltas = c.displacements + assert all(b >= a - 1e-3 for a, b in zip(deltas, deltas[1:])), \ + f"{family.name}: desplazamiento no monótono" + + +@pytest.mark.parametrize("family,kwargs", _FAMILIES) +def test_V051_volume_positive(family, kwargs): + """V051: Volumen de carena > 0 para T > 0.""" + hull = generate_hull(family, **kwargs) + c = HydrostaticCurves.compute(hull, n_points=15) + assert all(v > 0 for v in c.volumes[1:]) # index 0 puede ser ~0 + + +@pytest.mark.parametrize("family,kwargs", _FAMILIES) +def test_V052_cb_in_range(family, kwargs): + """V052: Coeficiente de bloque 0 < Cb < 1 en todos los calados.""" + hull = generate_hull(family, **kwargs) + c = HydrostaticCurves.compute(hull, n_points=15) + for cb in c.cb_values[1:]: # excluir calado mínimo (puede ser NaN/0) + if math.isfinite(cb): + assert 0.0 < cb < 1.0, f"{family.name}: Cb={cb:.4f} fuera de rango" + + +@pytest.mark.parametrize("family,kwargs", _FAMILIES) +def test_V053_kmt_greater_than_kb(family, kwargs): + """V053: KMT > KB en todo el rango de calados.""" + hull = generate_hull(family, **kwargs) + c = HydrostaticCurves.compute(hull, n_points=15) + for kb, kmt in zip(c.kb_values[1:], c.kmt_values[1:]): + if math.isfinite(kb) and math.isfinite(kmt): + assert kmt > kb - 1e-6, \ + f"{family.name}: KMT={kmt:.4f} no > KB={kb:.4f}" + + +@pytest.mark.parametrize("family,kwargs", _FAMILIES) +def test_V054_kml_greater_than_kmt(family, kwargs): + """V054: KML > KMT (buque siempre más estable longitudinalmente).""" + hull = generate_hull(family, **kwargs) + c = HydrostaticCurves.compute(hull, n_points=15) + for kmt, kml in zip(c.kmt_values[1:], c.kml_values[1:]): + if math.isfinite(kmt) and math.isfinite(kml) and kmt > 0: + assert kml > kmt - 1e-4, \ + f"{family.name}: KML={kml:.4f} no > KMT={kmt:.4f}" + + +@pytest.mark.parametrize("family,kwargs", _FAMILIES) +def test_V055_tpc_positive(family, kwargs): + """V055: TPC positivo en todo el rango.""" + hull = generate_hull(family, **kwargs) + c = HydrostaticCurves.compute(hull, n_points=15) + for tpc in c.tpc_values[1:]: + if math.isfinite(tpc): + assert tpc > 0, f"{family.name}: TPC={tpc:.6f} ≤ 0" + + +# --------------------------------------------------------------------------- +# V. IACS Rec.34 §4.3 — verificación analítica extendida en Wigley +# --------------------------------------------------------------------------- + +class TestIACSRec34Extended: + """V056–V062: Verificación analítica extendida contra Wigley.""" + + @pytest.fixture(autouse=True) + def setup(self): + self.hull = Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0, + n_stations=61, n_waterlines=31) + self.T = 2.0 + self.h = compute_upright(self.hull, self.T) + L, B, T = 20.0, 5.0, 2.0 + # Solución analítica Wigley y=B/2·(1−(2x/L)²)·(1−(z/T)²) xεL=[0,L] + self.V_ana = (16.0 / 35.0) * L * (B / 2) * T # ≈ 18.286 m³ + self.Awp_ana = (2.0 / 3.0) * L * (B / 2) # ≈ 33.333 m² + self.KB_ana = (5.0 / 8.0) * T # ≈ 1.25 m + # IT_ana = integral de y³ a lo largo de la eslora; resultado exacto + # IT = 2 * (B/2)³ * integral_0^L [((1-(2x/L)²)^3] dx + # integral = L * 16/35 + self.IT_ana = 2.0 * (B / 2.0) ** 3 * (16.0 / 35.0) * L / 3.0 + + # V056 — Cb analítico = 4/9 (±1%) + def test_cb_analytic(self): + assert abs(self.h.cb - 4.0 / 9.0) < 0.01, \ + f"Cb={self.h.cb:.5f} esperado ≈{4/9:.5f}" + + # V057 — Cw analítico = 2/3 (±1%) + def test_cw_analytic(self): + assert abs(self.h.cw - 2.0 / 3.0) < 0.01, \ + f"Cw={self.h.cw:.5f} esperado ≈{2/3:.5f}" + + # V058 — KB analítico = 5T/8 (±1%) + def test_kb_analytic(self): + assert abs(self.h.kb - self.KB_ana) < 0.02, \ + f"KB={self.h.kb:.4f} esperado {self.KB_ana:.4f}" + + # V059 — LCB = Lpp/2 por simetría longitudinal (±1 mm) + def test_lcb_midship(self): + L = self.hull.lpp + assert abs(self.h.lcb - L / 2.0) < 0.01, \ + f"LCB={self.h.lcb:.4f} esperado {L/2:.4f}" + + # V060 — LCF = Lpp/2 por simetría longitudinal (±1 mm) + def test_lcf_midship(self): + L = self.hull.lpp + assert abs(self.h.lcf - L / 2.0) < 0.01, \ + f"LCF={self.h.lcf:.4f} esperado {L/2:.4f}" + + # V061 — Cp = Cb/Cm (±0.5%) + def test_cp_equals_cb_over_cm(self): + if self.h.cm > 1e-6: + cp_calc = self.h.cb / self.h.cm + assert abs(self.h.cp - cp_calc) < 0.005, \ + f"Cp={self.h.cp:.5f} pero Cb/Cm={cp_calc:.5f}" + + # V062 — convergencia de malla (10 % de aumento en resolución < 2% cambio en V) + def test_mesh_convergence(self): + h_coarse = compute_upright( + Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0, + n_stations=21, n_waterlines=11), 2.0) + h_fine = compute_upright( + Hull.from_wigley(lpp=20.0, beam=5.0, draft=2.0, + n_stations=61, n_waterlines=31), 2.0) + rel_diff = abs(h_fine.volume - h_coarse.volume) / h_fine.volume + assert rel_diff < 0.02, \ + f"Convergencia: delta V/V = {rel_diff:.4%} > 2%"