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:
@@ -870,6 +870,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._offsets_editor.hull_changed.connect(self._on_hull_changed_from_editor)
|
self._offsets_editor.hull_changed.connect(self._on_hull_changed_from_editor)
|
||||||
self._module_area.set_module_widget(ModuleArea.MOD_OFFSETS, self._offsets_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
|
# Dock izquierdo — capas
|
||||||
self._layers_panel = LayersPanel(self._strings)
|
self._layers_panel = LayersPanel(self._strings)
|
||||||
self._dock_layers = QDockWidget("Capas", self)
|
self._dock_layers = QDockWidget("Capas", self)
|
||||||
@@ -964,9 +969,12 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# ── Tab ANÁLISIS ──────────────────────────────────────────
|
# ── Tab ANÁLISIS ──────────────────────────────────────────
|
||||||
g = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Hidrostática")
|
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",
|
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 = self._ribbon.new_group(RibbonBar.TAB_ANALYSIS, "Estabilidad")
|
||||||
g.add_button(_spi(sp.SP_FileDialogDetailedView), "Curva GZ", "Curva GZ estática",
|
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")
|
m = mb.addMenu("Análisis")
|
||||||
|
|
||||||
sm = m.addMenu("Hidrostática")
|
sm = m.addMenu("Hidrostática")
|
||||||
self._add_action(sm, "Calcular hidrostáticos", enabled=False)
|
self._add_action(sm, "Calcular hidrostáticos",
|
||||||
self._add_action(sm, "Curvas hidrostáticas", slot=lambda: self._module_area.activate(M.MOD_CURVES), enabled=False)
|
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")
|
sm = m.addMenu("Estabilidad")
|
||||||
self._add_action(sm, "Curva GZ — Estabilidad estática", slot=lambda: self._module_area.activate(M.MOD_STABILITY), enabled=False)
|
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:
|
except Exception as exc:
|
||||||
logger.warning("Error al calcular hidrostáticos: %s", 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:
|
def _ask_save(self) -> bool:
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Cambios sin guardar",
|
self, "Cambios sin guardar",
|
||||||
|
|||||||
@@ -477,3 +477,40 @@ QToolTip {
|
|||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
font-size: 12px;
|
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)
|
||||||
@@ -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%"
|
||||||
Reference in New Issue
Block a user