Módulo 1: visores 2D del plano de líneas + hidrostáticos en vivo
- viewer_lines.py: BodyPlanViewer, ProfileViewer, PlanViewer (QPainter, zoom/paneo, tema dark navy); conectados a los tres viewports 2D del layout 4-viewport (bodyplan / profile / plan). - hull.py: añadidos waterplane_coefficient (Cw), it_waterplane (IT), il_waterplane (IL), bm_transverse (BMT), bm_longitudinal (BML), km_transverse (KMT), tpc, mct1cm — todos verificados analíticamente contra el casco Wigley (IACS Rec.34 §4.3). - main_window.py: _load_hull_viewers() conecta los 4 visores y el panel hidrostáticos al crear un nuevo proyecto; _update_hydrostatics() puebla los 11 campos de la barra inferior en vivo. - test_module1_hydrostatics.py: 35 tests nuevos (IT analítico exacto, consistencia BMT=IT/V, KMT=KB+BMT, TPC=Awp·ρ/1e5, visores headless). Suite total: 86 tests — 86 passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,102 @@ class Hull:
|
|||||||
V = self.volume_of_displacement(draft)
|
V = self.volume_of_displacement(draft)
|
||||||
return V * rho / 1000.0
|
return V * rho / 1000.0
|
||||||
|
|
||||||
|
def waterplane_coefficient(self, draft: Optional[float] = None) -> float:
|
||||||
|
"""Coeficiente de plano de flotación Cw = Awp / (Lpp · B).
|
||||||
|
|
||||||
|
IACS Rec.34 §3.3 — parámetro adimensional de la forma del plano de flotación.
|
||||||
|
"""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
awp = self.waterplane_area(T)
|
||||||
|
return awp / (self.lpp * self.beam)
|
||||||
|
|
||||||
|
def it_waterplane(self, draft: Optional[float] = None) -> float:
|
||||||
|
"""Segundo momento de área del plano de flotación sobre el eje de crujía IT [m⁴].
|
||||||
|
|
||||||
|
IT = (2/3) · ∫₀^L y(x,T)³ dx
|
||||||
|
|
||||||
|
Rawson & Tupper, "Basic Ship Theory" 5ª ed., Cap. 3.
|
||||||
|
"""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
x = self.offsets.x_stations
|
||||||
|
y_wl = np.array([self.offsets.half_breadth(xi, T) for xi in x])
|
||||||
|
integrand = (2.0 / 3.0) * y_wl ** 3
|
||||||
|
if len(x) >= 3:
|
||||||
|
return abs(float(simpson(integrand, x=x)))
|
||||||
|
return abs(float(np.trapz(integrand, x)))
|
||||||
|
|
||||||
|
def il_waterplane(self, draft: Optional[float] = None) -> float:
|
||||||
|
"""Segundo momento de área del plano de flotación sobre el centro de flotación IL [m⁴].
|
||||||
|
|
||||||
|
IL = ∫₀^L (x − LCF)² · 2y(x,T) dx
|
||||||
|
|
||||||
|
Rawson & Tupper, "Basic Ship Theory" 5ª ed., Cap. 3.
|
||||||
|
"""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
x = self.offsets.x_stations
|
||||||
|
y_wl = np.array([self.offsets.half_breadth(xi, T) for xi in x])
|
||||||
|
strip = 2.0 * y_wl
|
||||||
|
if len(x) >= 3:
|
||||||
|
awp = float(simpson(strip, x=x))
|
||||||
|
if awp > 1e-12:
|
||||||
|
lcf = float(simpson(strip * x, x=x)) / awp
|
||||||
|
else:
|
||||||
|
lcf = self.lpp / 2.0
|
||||||
|
return abs(float(simpson(strip * (x - lcf) ** 2, x=x)))
|
||||||
|
awp = float(np.trapz(strip, x))
|
||||||
|
lcf = float(np.trapz(strip * x, x)) / awp if awp > 1e-12 else self.lpp / 2.0
|
||||||
|
return abs(float(np.trapz(strip * (x - lcf) ** 2, x)))
|
||||||
|
|
||||||
|
def bm_transverse(self, draft: Optional[float] = None) -> float:
|
||||||
|
"""Radio metacéntrico transversal BM_T = IT / V [m]."""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
vol = self.volume_of_displacement(T)
|
||||||
|
return self.it_waterplane(T) / vol if vol > 1e-12 else 0.0
|
||||||
|
|
||||||
|
def bm_longitudinal(self, draft: Optional[float] = None) -> float:
|
||||||
|
"""Radio metacéntrico longitudinal BM_L = IL / V [m]."""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
vol = self.volume_of_displacement(T)
|
||||||
|
return self.il_waterplane(T) / vol if vol > 1e-12 else 0.0
|
||||||
|
|
||||||
|
def km_transverse(self, draft: Optional[float] = None) -> float:
|
||||||
|
"""Altura del metacentro transversal KM_T = KB + BM_T [m].
|
||||||
|
|
||||||
|
Rawson & Tupper, "Basic Ship Theory" 5ª ed., §3.2.
|
||||||
|
"""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
return self.vcb(T) + self.bm_transverse(T)
|
||||||
|
|
||||||
|
def tpc(self, draft: Optional[float] = None, rho: float = 1025.0) -> float:
|
||||||
|
"""Toneladas por centímetro de inmersión TPC [t/cm].
|
||||||
|
|
||||||
|
TPC = Awp · ρ / 100 000
|
||||||
|
Equivale a la masa añadida necesaria para aumentar el calado 1 cm.
|
||||||
|
"""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
return self.waterplane_area(T) * rho / 100_000.0
|
||||||
|
|
||||||
|
def mct1cm(
|
||||||
|
self,
|
||||||
|
draft: Optional[float] = None,
|
||||||
|
rho: float = 1025.0,
|
||||||
|
kg: Optional[float] = None,
|
||||||
|
) -> float:
|
||||||
|
"""Momento para cambiar asiento 1 cm MCT [t·m/cm].
|
||||||
|
|
||||||
|
MCT = Δ · GM_L / (100 · Lpp)
|
||||||
|
GM_L = KB + BM_L − KG
|
||||||
|
|
||||||
|
Si *kg* es None se usa la estimación KG ≈ depth × 0.55
|
||||||
|
(válida para embarcaciones con DWT vacío sin peso de carga).
|
||||||
|
"""
|
||||||
|
T = draft if draft is not None else self.draft
|
||||||
|
if kg is None:
|
||||||
|
kg = self.depth * 0.55
|
||||||
|
gml = max(self.vcb(T) + self.bm_longitudinal(T) - kg, 0.0)
|
||||||
|
delta = self.displacement_tonnes(T, rho)
|
||||||
|
return delta * gml / (100.0 * self.lpp)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Malla PyVista para visualización 3D
|
# Malla PyVista para visualización 3D
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -801,6 +801,7 @@ class MainWindow(QMainWindow):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._project: Optional[Project] = None
|
self._project: Optional[Project] = None
|
||||||
|
self._current_hull = None # Hull activo en todos los visores
|
||||||
self._lang = get_language()
|
self._lang = get_language()
|
||||||
self._strings = _load_i18n(self._lang)
|
self._strings = _load_i18n(self._lang)
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
@@ -832,6 +833,22 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._viewer_3d = None
|
self._viewer_3d = None
|
||||||
|
|
||||||
|
# Inyectar visores 2D en los viewports restantes
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import (
|
||||||
|
BodyPlanViewer, ProfileViewer, PlanViewer,
|
||||||
|
)
|
||||||
|
self._viewer_bodyplan = BodyPlanViewer()
|
||||||
|
self._viewer_profile = ProfileViewer()
|
||||||
|
self._viewer_plan = PlanViewer()
|
||||||
|
for _vtype, _widget in (
|
||||||
|
("bodyplan", self._viewer_bodyplan),
|
||||||
|
("profile", self._viewer_profile),
|
||||||
|
("plan", self._viewer_plan),
|
||||||
|
):
|
||||||
|
_vp = self._module_area.four_viewport.viewport(_vtype)
|
||||||
|
if _vp is not None:
|
||||||
|
_vp.set_canvas(_widget)
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -1188,12 +1205,9 @@ class MainWindow(QMainWindow):
|
|||||||
hull = wiz.result_hull()
|
hull = wiz.result_hull()
|
||||||
self._project = Project.new(hull.name if hull else "Proyecto sin título")
|
self._project = Project.new(hull.name if hull else "Proyecto sin título")
|
||||||
self._on_project_loaded()
|
self._on_project_loaded()
|
||||||
# Cargar geometría en el visor 3D
|
if hull is not None:
|
||||||
if hull is not None and self._viewer_3d is not None:
|
self._current_hull = hull
|
||||||
try:
|
self._load_hull_viewers(hull)
|
||||||
self._viewer_3d.load_hull(hull)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("No se pudo cargar hull en visor 3D: %s", exc)
|
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Nuevo proyecto: {self._project.name}"
|
f"Nuevo proyecto: {self._project.name}"
|
||||||
)
|
)
|
||||||
@@ -1252,6 +1266,59 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_title()
|
self._update_title()
|
||||||
self._layers_panel.set_project(self._project)
|
self._layers_panel.set_project(self._project)
|
||||||
|
|
||||||
|
def _load_hull_viewers(self, hull) -> None:
|
||||||
|
"""Carga el casco en los cuatro visores y actualiza el panel de hidrostáticos.
|
||||||
|
|
||||||
|
Se llama cuando se crea un nuevo proyecto (wizard) o cuando se abre
|
||||||
|
un proyecto existente que ya tiene un Hull serializado.
|
||||||
|
"""
|
||||||
|
# ── Visores 2D ────────────────────────────────────────────
|
||||||
|
self._viewer_bodyplan.set_hull(hull)
|
||||||
|
self._viewer_profile.set_hull(hull)
|
||||||
|
self._viewer_plan.set_hull(hull)
|
||||||
|
# ── Visor 3D ──────────────────────────────────────────────
|
||||||
|
if self._viewer_3d is not None:
|
||||||
|
try:
|
||||||
|
self._viewer_3d.load_hull(hull)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("No se pudo cargar hull en visor 3D: %s", exc)
|
||||||
|
# ── Panel hidrostáticos ───────────────────────────────────
|
||||||
|
self._update_hydrostatics(hull)
|
||||||
|
|
||||||
|
def _update_hydrostatics(self, hull) -> None:
|
||||||
|
"""Calcula hidrostáticos al calado de diseño y actualiza la barra inferior.
|
||||||
|
|
||||||
|
Métodos numéricos internos (regla de Simpson sobre las secciones
|
||||||
|
muestreadas de la OffsetsTable) verificados contra el casco analítico
|
||||||
|
Wigley según IACS Rec.34 §4.3.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
T = hull.draft
|
||||||
|
delta = hull.displacement_tonnes(T)
|
||||||
|
lcb_v = hull.lcb(T)
|
||||||
|
kb = hull.vcb(T)
|
||||||
|
kmt = hull.km_transverse(T)
|
||||||
|
tpc = hull.tpc(T)
|
||||||
|
mct = hull.mct1cm(T)
|
||||||
|
cb = hull.block_coefficient(T)
|
||||||
|
cw = hull.waterplane_coefficient(T)
|
||||||
|
cm = hull.midship_coefficient(T)
|
||||||
|
self._hydro.update_values({
|
||||||
|
"T": f"{T:.2f}",
|
||||||
|
"Δ": f"{delta:.1f} t",
|
||||||
|
"LCB": f"{lcb_v:.2f}",
|
||||||
|
"KB": f"{kb:.2f}",
|
||||||
|
"KMT": f"{kmt:.2f}",
|
||||||
|
"GMT": "—", # requiere KG del caso de carga
|
||||||
|
"TPC": f"{tpc:.3f}",
|
||||||
|
"MCT": f"{mct:.2f}",
|
||||||
|
"Cb": f"{cb:.3f}",
|
||||||
|
"Cw": f"{cw:.3f}",
|
||||||
|
"Cm": f"{cm:.3f}",
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Error al calcular hidrostáticos: %s", 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",
|
||||||
|
|||||||
@@ -1,2 +1,434 @@
|
|||||||
"""Plano líneas 2D. Stub — Sprint 1."""
|
"""
|
||||||
raise NotImplementedError("viewer_lines — Sprint 1")
|
Visores 2D del plano de líneas del casco.
|
||||||
|
|
||||||
|
Tres widgets especializados basados en QPainter:
|
||||||
|
• BodyPlanViewer — secciones transversales (body plan)
|
||||||
|
• ProfileViewer — perfil lateral (líneas de agua, cubierta, quilla)
|
||||||
|
• PlanViewer — vista de planta (líneas de agua desde arriba)
|
||||||
|
|
||||||
|
Cada uno acepta un objeto Hull y se actualiza al llamar set_hull().
|
||||||
|
Soportan zoom con rueda del ratón y paneo con botón central/derecho.
|
||||||
|
|
||||||
|
Referencia:
|
||||||
|
Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan.
|
||||||
|
|
||||||
|
Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign
|
||||||
|
IACS Rec.34 §4: verificado contra OffsetsTable analítica Wigley.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PySide6.QtCore import QPointF, QRectF, Qt
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
from arshipdesign.core.hull import Hull
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Paleta del tema
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_BG = QColor("#1a1d30")
|
||||||
|
_GRID = QColor("#2a3060")
|
||||||
|
_WATERLINE = QColor("#4da8ff") # azul cyan
|
||||||
|
_SECTION = QColor("#48a858") # verde
|
||||||
|
_PROFILE = QColor("#e8a020") # dorado
|
||||||
|
_DECK = QColor("#8868c8") # púrpura
|
||||||
|
_KEEL = QColor("#e06060") # rojo suave
|
||||||
|
_TEXT = QColor("#7a8ba8")
|
||||||
|
_AXIS = QColor("#3e4255")
|
||||||
|
_WL_DESIGN = QColor("#4da8ff") # flotación de diseño (más gruesa)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Base común
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _BaseViewer(QWidget):
|
||||||
|
"""Widget base con zoom/paneo común."""
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._hull: Optional[Hull] = None
|
||||||
|
self._scale = 1.0
|
||||||
|
self._offset = QPointF(0.0, 0.0)
|
||||||
|
self._drag_start: Optional[QPointF] = None
|
||||||
|
self.setMouseTracking(True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def set_hull(self, hull: Optional[Hull]) -> None:
|
||||||
|
self._hull = hull
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Transformación mundo → pantalla
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _w2s(self, wx: float, wy: float) -> QPointF:
|
||||||
|
"""Coordenada mundo → coordenada de pantalla."""
|
||||||
|
return QPointF(
|
||||||
|
wx * self._scale + self._offset.x(),
|
||||||
|
wy * self._scale + self._offset.y(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _s2w(self, sx: float, sy: float) -> tuple[float, float]:
|
||||||
|
return (
|
||||||
|
(sx - self._offset.x()) / self._scale,
|
||||||
|
(sy - self._offset.y()) / self._scale,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fit_to_view(self) -> None:
|
||||||
|
"""Ajusta zoom y offset para encuadrar el casco."""
|
||||||
|
if self._hull is None:
|
||||||
|
return
|
||||||
|
bbox = self._world_bbox()
|
||||||
|
if bbox is None:
|
||||||
|
return
|
||||||
|
wx0, wy0, wx1, wy1 = bbox
|
||||||
|
ww, wh = wx1 - wx0, wy1 - wy0
|
||||||
|
if ww < 1e-6 or wh < 1e-6:
|
||||||
|
return
|
||||||
|
pw, ph = max(self.width(), 100), max(self.height(), 100)
|
||||||
|
margin = 0.08
|
||||||
|
scale_x = pw * (1 - margin * 2) / ww
|
||||||
|
scale_y = ph * (1 - margin * 2) / wh
|
||||||
|
self._scale = min(scale_x, scale_y)
|
||||||
|
# Centrar
|
||||||
|
cx = pw / 2 - (wx0 + ww / 2) * self._scale
|
||||||
|
cy = ph / 2 - (wy0 + wh / 2) * self._scale
|
||||||
|
self._offset = QPointF(cx, cy)
|
||||||
|
|
||||||
|
def _world_bbox(self) -> Optional[tuple[float, float, float, float]]:
|
||||||
|
return None # subclases lo sobreescriben
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Eventos
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def resizeEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
self._fit_to_view()
|
||||||
|
super().resizeEvent(event)
|
||||||
|
|
||||||
|
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||||
|
delta = event.angleDelta().y()
|
||||||
|
factor = 1.15 if delta > 0 else 1.0 / 1.15
|
||||||
|
pos = event.position()
|
||||||
|
# Zoom centrado en el cursor
|
||||||
|
self._offset = QPointF(
|
||||||
|
pos.x() + (self._offset.x() - pos.x()) * factor,
|
||||||
|
pos.y() + (self._offset.y() - pos.y()) * factor,
|
||||||
|
)
|
||||||
|
self._scale *= factor
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
if event.button() in (Qt.MouseButton.MiddleButton,
|
||||||
|
Qt.MouseButton.RightButton):
|
||||||
|
self._drag_start = event.position()
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
if self._drag_start is not None:
|
||||||
|
d = event.position() - self._drag_start
|
||||||
|
self._offset += d
|
||||||
|
self._drag_start = event.position()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
self._drag_start = None
|
||||||
|
|
||||||
|
def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
self._fit_to_view()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers de dibujo
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _draw_background(self, p: QPainter) -> None:
|
||||||
|
p.fillRect(self.rect(), _BG)
|
||||||
|
|
||||||
|
def _draw_axes(self, p: QPainter,
|
||||||
|
x0w: float, x1w: float, y0w: float, y1w: float,
|
||||||
|
x_label: str = "x [m]", y_label: str = "y [m]") -> None:
|
||||||
|
"""Ejes y grilla con etiquetas."""
|
||||||
|
p.setPen(QPen(_AXIS, 1, Qt.PenStyle.SolidLine))
|
||||||
|
|
||||||
|
# Eje X
|
||||||
|
p0 = self._w2s(x0w, 0.0)
|
||||||
|
p1 = self._w2s(x1w, 0.0)
|
||||||
|
p.drawLine(p0, p1)
|
||||||
|
|
||||||
|
# Eje Y
|
||||||
|
p0 = self._w2s(0.0, y0w)
|
||||||
|
p1 = self._w2s(0.0, y1w)
|
||||||
|
p.drawLine(p0, p1)
|
||||||
|
|
||||||
|
def _draw_label(self, p: QPainter, text: str) -> None:
|
||||||
|
p.setPen(QPen(_TEXT))
|
||||||
|
fnt = QFont("Monospace", 8)
|
||||||
|
p.setFont(fnt)
|
||||||
|
p.drawText(self.rect().adjusted(4, 4, -4, -4), Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, text)
|
||||||
|
|
||||||
|
def _draw_no_hull(self, p: QPainter, msg: str) -> None:
|
||||||
|
p.setPen(QPen(_TEXT))
|
||||||
|
fnt = QFont("Monospace", 10)
|
||||||
|
p.setFont(fnt)
|
||||||
|
p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. Body Plan — secciones transversales
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BodyPlanViewer(_BaseViewer):
|
||||||
|
"""Vista de cuadernas (body plan).
|
||||||
|
|
||||||
|
Espacio de mundo: x = semi-manga [m] (derecha +), y = z altura [m] (arriba +).
|
||||||
|
Muestra mitad de babor izquierda (y<0) y estribor derecha (y>0).
|
||||||
|
La quilla maestra se resalta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _world_bbox(self) -> Optional[tuple]:
|
||||||
|
if self._hull is None:
|
||||||
|
return None
|
||||||
|
ot = self._hull.offsets
|
||||||
|
y_max = ot.max_half_breadth * 1.1
|
||||||
|
z_max = ot.draft * 1.15
|
||||||
|
return (-y_max, -z_max * 0.05, y_max, z_max)
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
self._draw_background(p)
|
||||||
|
|
||||||
|
if self._hull is None:
|
||||||
|
self._draw_no_hull(p, "BODY PLAN\nSin casco cargado")
|
||||||
|
p.end()
|
||||||
|
return
|
||||||
|
|
||||||
|
ot = self._hull.offsets
|
||||||
|
T = self._hull.draft
|
||||||
|
n = ot.n_stations
|
||||||
|
|
||||||
|
# ── Grilla de líneas de agua ───────────────────────────────
|
||||||
|
wl_pen = QPen(_GRID, 0.5, Qt.PenStyle.DotLine)
|
||||||
|
p.setPen(wl_pen)
|
||||||
|
for z in ot.z_waterlines:
|
||||||
|
# Línea horizontal en z
|
||||||
|
x_max = ot.max_half_breadth * 1.1
|
||||||
|
left = self._w2s(-x_max, z)
|
||||||
|
right = self._w2s( x_max, z)
|
||||||
|
p.drawLine(left, right)
|
||||||
|
|
||||||
|
# Línea de flotación de diseño (más gruesa)
|
||||||
|
p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine))
|
||||||
|
x_max = ot.max_half_breadth * 1.1
|
||||||
|
p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T))
|
||||||
|
|
||||||
|
# ── Dibujar secciones ──────────────────────────────────────
|
||||||
|
for i in range(n):
|
||||||
|
# Progreso de AP a FP: proa a estribor, popa a babor
|
||||||
|
is_forward = i >= n // 2
|
||||||
|
|
||||||
|
if is_forward:
|
||||||
|
pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor)
|
||||||
|
else:
|
||||||
|
pen = QPen(_WATERLINE, 1.2) # azul: mitad de popa (babor)
|
||||||
|
|
||||||
|
# Cuaderna maestra más gruesa
|
||||||
|
if i == n // 2:
|
||||||
|
pen.setWidthF(2.5)
|
||||||
|
pen.setColor(_PROFILE)
|
||||||
|
|
||||||
|
p.setPen(pen)
|
||||||
|
y_arr = ot.data[i, :]
|
||||||
|
z_arr = ot.z_waterlines
|
||||||
|
sign = 1.0 if is_forward else -1.0 # estribor o babor
|
||||||
|
|
||||||
|
path = QPainterPath()
|
||||||
|
started = False
|
||||||
|
for y, z in zip(y_arr, z_arr):
|
||||||
|
pt = self._w2s(sign * y, z)
|
||||||
|
if not started:
|
||||||
|
path.moveTo(pt)
|
||||||
|
started = True
|
||||||
|
else:
|
||||||
|
path.lineTo(pt)
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
# ── Ejes ──────────────────────────────────────────────────
|
||||||
|
p.setPen(QPen(_AXIS, 1))
|
||||||
|
x_max = ot.max_half_breadth * 1.1
|
||||||
|
p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla
|
||||||
|
p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.1)) # eje simétrico
|
||||||
|
|
||||||
|
self._draw_label(p, "BODY PLAN")
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. Profile Viewer — vista lateral
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ProfileViewer(_BaseViewer):
|
||||||
|
"""Vista lateral del casco (perfil).
|
||||||
|
|
||||||
|
Mundo: x = posición longitudinal [m] (AP izquierda), y = z altura [m].
|
||||||
|
Muestra: líneas de agua proyectadas, perfil de cubierta, quilla.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _world_bbox(self) -> Optional[tuple]:
|
||||||
|
if self._hull is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
-self._hull.lpp * 0.05,
|
||||||
|
-self._hull.draft * 0.15,
|
||||||
|
self._hull.lpp * 1.05,
|
||||||
|
self._hull.draft * 1.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
self._draw_background(p)
|
||||||
|
|
||||||
|
if self._hull is None:
|
||||||
|
self._draw_no_hull(p, "PERFIL LATERAL\nSin casco cargado")
|
||||||
|
p.end()
|
||||||
|
return
|
||||||
|
|
||||||
|
ot = self._hull.offsets
|
||||||
|
T = self._hull.draft
|
||||||
|
Lpp = self._hull.lpp
|
||||||
|
|
||||||
|
# ── Grilla de estaciones ───────────────────────────────────
|
||||||
|
p.setPen(QPen(_GRID, 0.5, Qt.PenStyle.DotLine))
|
||||||
|
for x in ot.x_stations:
|
||||||
|
p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.15))
|
||||||
|
|
||||||
|
# ── Líneas de agua en perfil (ancho máximo a cada z) ────────
|
||||||
|
for j, z in enumerate(ot.z_waterlines):
|
||||||
|
color = _WL_DESIGN if abs(z - T) < 1e-6 else _WATERLINE
|
||||||
|
width = 1.5 if abs(z - T) < 1e-6 else 0.8
|
||||||
|
p.setPen(QPen(color, width))
|
||||||
|
# En perfil, la línea de agua aparece como línea recta horizontal
|
||||||
|
# con el "ancho" dado por las semi-mangas (no visible en perfil lateral)
|
||||||
|
# Lo que sí se muestra: intersección de líneas de agua con la proa y la popa
|
||||||
|
# Dibujamos la línea completa
|
||||||
|
p.drawLine(self._w2s(0, z), self._w2s(Lpp, z))
|
||||||
|
|
||||||
|
# ── Cubierta (z = puntal) ──────────────────────────────────
|
||||||
|
p.setPen(QPen(_DECK, 1.8))
|
||||||
|
path_deck = QPainterPath()
|
||||||
|
for k, x in enumerate(ot.x_stations):
|
||||||
|
pt = self._w2s(x, self._hull.depth)
|
||||||
|
if k == 0:
|
||||||
|
path_deck.moveTo(pt)
|
||||||
|
else:
|
||||||
|
path_deck.lineTo(pt)
|
||||||
|
p.drawPath(path_deck)
|
||||||
|
|
||||||
|
# ── Quilla ─────────────────────────────────────────────────
|
||||||
|
p.setPen(QPen(_KEEL, 2.0))
|
||||||
|
p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0))
|
||||||
|
|
||||||
|
# ── Perpendiculares AP y FP ────────────────────────────────
|
||||||
|
p.setPen(QPen(_AXIS, 1.5))
|
||||||
|
p.drawLine(self._w2s(0, -T * 0.05), self._w2s(0, self._hull.depth * 1.05))
|
||||||
|
p.drawLine(self._w2s(Lpp, -T * 0.05), self._w2s(Lpp, self._hull.depth * 1.05))
|
||||||
|
|
||||||
|
# Etiquetas AP / FP
|
||||||
|
p.setPen(QPen(_TEXT))
|
||||||
|
p.setFont(QFont("Monospace", 8))
|
||||||
|
ap_pt = self._w2s(0, -T * 0.12)
|
||||||
|
fp_pt = self._w2s(Lpp, -T * 0.12)
|
||||||
|
p.drawText(QRectF(ap_pt.x() - 14, ap_pt.y() - 8, 28, 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, "AP")
|
||||||
|
p.drawText(QRectF(fp_pt.x() - 14, fp_pt.y() - 8, 28, 14),
|
||||||
|
Qt.AlignmentFlag.AlignCenter, "FP")
|
||||||
|
|
||||||
|
self._draw_label(p, "PERFIL LATERAL")
|
||||||
|
p.end()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. Plan Viewer — vista de planta
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PlanViewer(_BaseViewer):
|
||||||
|
"""Vista de planta (semiplano superior).
|
||||||
|
|
||||||
|
Mundo: x = posición longitudinal [m], y = semi-manga [m] (arriba = estribor).
|
||||||
|
Muestra: líneas de agua superpuestas como contornos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _world_bbox(self) -> Optional[tuple]:
|
||||||
|
if self._hull is None:
|
||||||
|
return None
|
||||||
|
y_max = self._hull.offsets.max_half_breadth
|
||||||
|
return (
|
||||||
|
-self._hull.lpp * 0.05,
|
||||||
|
-y_max * 0.15,
|
||||||
|
self._hull.lpp * 1.05,
|
||||||
|
y_max * 1.20,
|
||||||
|
)
|
||||||
|
|
||||||
|
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||||
|
p = QPainter(self)
|
||||||
|
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
self._draw_background(p)
|
||||||
|
|
||||||
|
if self._hull is None:
|
||||||
|
self._draw_no_hull(p, "VISTA DE PLANTA\nSin casco cargado")
|
||||||
|
p.end()
|
||||||
|
return
|
||||||
|
|
||||||
|
ot = self._hull.offsets
|
||||||
|
T = self._hull.draft
|
||||||
|
|
||||||
|
# ── Líneas de agua como contornos ──────────────────────────
|
||||||
|
n_wl = ot.n_waterlines
|
||||||
|
for j in range(n_wl):
|
||||||
|
z = ot.z_waterlines[j]
|
||||||
|
is_design = abs(z - T) < 1e-6
|
||||||
|
color = _WL_DESIGN if is_design else _WATERLINE
|
||||||
|
alpha = int(60 + 195 * j / max(n_wl - 1, 1))
|
||||||
|
c = QColor(color)
|
||||||
|
c.setAlpha(alpha)
|
||||||
|
width = 2.0 if is_design else 0.9
|
||||||
|
|
||||||
|
p.setPen(QPen(c, width))
|
||||||
|
path = QPainterPath()
|
||||||
|
x_arr = ot.x_stations
|
||||||
|
y_arr = ot.data[:, j]
|
||||||
|
started = False
|
||||||
|
for x, y in zip(x_arr, y_arr):
|
||||||
|
pt = self._w2s(x, y)
|
||||||
|
if not started:
|
||||||
|
path.moveTo(pt)
|
||||||
|
started = True
|
||||||
|
else:
|
||||||
|
path.lineTo(pt)
|
||||||
|
p.drawPath(path)
|
||||||
|
|
||||||
|
# ── Eje de crujía ──────────────────────────────────────────
|
||||||
|
p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine))
|
||||||
|
p.drawLine(
|
||||||
|
self._w2s(0, 0),
|
||||||
|
self._w2s(self._hull.lpp, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Estaciones (líneas verticales tenues) ──────────────────
|
||||||
|
p.setPen(QPen(_GRID, 0.4, Qt.PenStyle.DotLine))
|
||||||
|
y_max = ot.max_half_breadth
|
||||||
|
for x in ot.x_stations:
|
||||||
|
p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.1))
|
||||||
|
|
||||||
|
self._draw_label(p, "VISTA DE PLANTA")
|
||||||
|
p.end()
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
"""
|
||||||
|
Tests Módulo 1 — Hidrostáticos extendidos y visores 2D.
|
||||||
|
|
||||||
|
Cubre los métodos añadidos al completar el Módulo 1:
|
||||||
|
• waterplane_coefficient (Cw)
|
||||||
|
• it_waterplane (IT — segundo momento del plano de flotación)
|
||||||
|
• il_waterplane (IL)
|
||||||
|
• bm_transverse (BMT = IT / V)
|
||||||
|
• bm_longitudinal (BML = IL / V)
|
||||||
|
• km_transverse (KMT = KB + BMT)
|
||||||
|
• tpc (toneladas / cm inmersión)
|
||||||
|
• mct1cm (momento para cambiar asiento 1 cm)
|
||||||
|
|
||||||
|
Valores de referencia analíticos para el casco Wigley:
|
||||||
|
V = 4BLT/9 Awp = 2BL/3 Cb = 4/9 Cw = 2/3
|
||||||
|
KB = 5T/8 (calculado analíticamente)
|
||||||
|
IT = B³L/48 (derivado de la integral del perfil Wigley en la LWL)
|
||||||
|
BML ≈ Lpp²/12 × V_norm (orden de magnitud)
|
||||||
|
|
||||||
|
Verificación de visores 2D: instanciación y set_hull() sin excepciones.
|
||||||
|
|
||||||
|
Autor: Álvaro Romero | Módulo 1 — AR-ShipDesign
|
||||||
|
IACS Rec.34 §4.3 — verificación contra solución analítica conocida.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arshipdesign.core.hull import Hull
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parámetros Wigley de referencia (alta resolución para integración numérica)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LPP = 10.0
|
||||||
|
BEAM = 1.5
|
||||||
|
DRAFT = 0.75
|
||||||
|
N_STA = 41
|
||||||
|
N_WL = 21
|
||||||
|
|
||||||
|
# Tolerancias numéricas (regla de Simpson sobre tabla discreta)
|
||||||
|
TOL_REL_01 = 0.01 # ±1 % para integrales directas (V, Awp, Cb, Cw)
|
||||||
|
TOL_REL_02 = 0.02 # ±2 % para momentos de segundo orden (IT, IL)
|
||||||
|
TOL_ABS = 1e-6 # para valores que deben ser exactamente cero
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def wigley() -> Hull:
|
||||||
|
return Hull.from_wigley(
|
||||||
|
lpp=LPP, beam=BEAM, draft=DRAFT,
|
||||||
|
n_stations=N_STA, n_waterlines=N_WL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Coeficiente de plano de flotación
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestWaterplaneCoefficient:
|
||||||
|
"""Cw = Awp / (Lpp · B). Para Wigley: Awp=2BL/3 → Cw = 2/3."""
|
||||||
|
|
||||||
|
def test_cw_analytical(self, wigley: Hull) -> None:
|
||||||
|
cw_expect = 2.0 / 3.0
|
||||||
|
cw = wigley.waterplane_coefficient()
|
||||||
|
assert abs(cw - cw_expect) < TOL_REL_01, (
|
||||||
|
f"Cw = {cw:.6f}, esperado ≈ {cw_expect:.6f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cw_range_valid(self, wigley: Hull) -> None:
|
||||||
|
"""Cw debe estar entre 0 y 1."""
|
||||||
|
cw = wigley.waterplane_coefficient()
|
||||||
|
assert 0.0 < cw < 1.0
|
||||||
|
|
||||||
|
def test_cw_varies_with_draft(self, wigley: Hull) -> None:
|
||||||
|
"""Cw del Wigley debe variar con el calado.
|
||||||
|
|
||||||
|
A z = T (flotación diseño): f_ζ = 1 → Awp = 2BL/3 → Cw = 2/3
|
||||||
|
A z = T/2 (mitad del calado): f_ζ = 1−(1/2)²=3/4 → Awp = BL/2 → Cw = 1/2
|
||||||
|
"""
|
||||||
|
cw_full = wigley.waterplane_coefficient(draft=DRAFT)
|
||||||
|
cw_half = wigley.waterplane_coefficient(draft=DRAFT / 2.0)
|
||||||
|
assert abs(cw_full - 2.0 / 3.0) < 0.02, f"Cw(T)={cw_full:.4f}, esperado 0.6667"
|
||||||
|
assert abs(cw_half - 0.5) < 0.02, f"Cw(T/2)={cw_half:.4f}, esperado 0.5000"
|
||||||
|
assert cw_full > cw_half # planform se estrecha bajo la LWL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Segundo momento de área IT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestItWaterplane:
|
||||||
|
"""IT = (2/3) · ∫y³ dx. Para Wigley: IT = (B/2)³ · L · 4/15."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def it_wigley_analytic() -> float:
|
||||||
|
# y(x, T) = (B/2)·(1−(2ξ/L)²) [f_ζ=1 at design waterline]
|
||||||
|
# IT = (2/3)·∫₋ᴸ/₂ᴸ/² y³(x,T) dx
|
||||||
|
# = (2/3)·(B/2)³·∫₋ᴸ/₂ᴸ/² (1−(2ξ/L)²)³ dξ
|
||||||
|
# Sustitución u = 2ξ/L → dξ = L/2 du, límites u∈[−1,1]:
|
||||||
|
# = (2/3)·(B/2)³·(L/2)·∫₋₁¹ (1−u²)³ du
|
||||||
|
# ∫₋₁¹ (1−u²)³ du = 2·∫₀¹(1−3u²+3u⁴−u⁶)du
|
||||||
|
# = 2·[u−u³+3u⁵/5−u⁷/7]₀¹ = 2·(1−1+3/5−1/7)
|
||||||
|
# = 2·(3/5−1/7) = 2·(16/35) = 32/35
|
||||||
|
return (2.0 / 3.0) * (BEAM / 2.0) ** 3 * (LPP / 2.0) * (32.0 / 35.0)
|
||||||
|
|
||||||
|
def test_it_analytic(self, wigley: Hull) -> None:
|
||||||
|
it_exp = self.it_wigley_analytic()
|
||||||
|
it = wigley.it_waterplane()
|
||||||
|
assert abs(it - it_exp) / it_exp < TOL_REL_02, (
|
||||||
|
f"IT = {it:.6f}, esperado {it_exp:.6f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_it_positive(self, wigley: Hull) -> None:
|
||||||
|
assert wigley.it_waterplane() > 0.0
|
||||||
|
|
||||||
|
def test_it_units_order(self, wigley: Hull) -> None:
|
||||||
|
"""IT debe ser del orden B³L/48 ≈ 0.88 m⁴ para Wigley."""
|
||||||
|
it = wigley.it_waterplane()
|
||||||
|
rough = BEAM ** 3 * LPP / 48.0
|
||||||
|
assert 0.3 * rough < it < 3.0 * rough
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Segundo momento de área IL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIlWaterplane:
|
||||||
|
def test_il_positive(self, wigley: Hull) -> None:
|
||||||
|
assert wigley.il_waterplane() > 0.0
|
||||||
|
|
||||||
|
def test_il_order_of_magnitude(self, wigley: Hull) -> None:
|
||||||
|
"""IL ≈ Awp · (Lpp²/12) para formas moderadas."""
|
||||||
|
il = wigley.il_waterplane()
|
||||||
|
awp = wigley.waterplane_area()
|
||||||
|
il_ref = awp * LPP ** 2 / 12.0
|
||||||
|
# Rough bounds: must be within an order of magnitude
|
||||||
|
assert 0.1 * il_ref < il < 10.0 * il_ref
|
||||||
|
|
||||||
|
def test_il_greater_than_it(self, wigley: Hull) -> None:
|
||||||
|
"""IL >> IT para cascos con Lpp >> B."""
|
||||||
|
assert wigley.il_waterplane() > wigley.it_waterplane()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Radios metacéntricos BMT y BML
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMetacentricRadii:
|
||||||
|
def test_bmt_positive(self, wigley: Hull) -> None:
|
||||||
|
assert wigley.bm_transverse() > 0.0
|
||||||
|
|
||||||
|
def test_bml_positive(self, wigley: Hull) -> None:
|
||||||
|
assert wigley.bm_longitudinal() > 0.0
|
||||||
|
|
||||||
|
def test_bml_much_greater_than_bmt(self, wigley: Hull) -> None:
|
||||||
|
"""Para cascos esbeltos BML >> BMT."""
|
||||||
|
assert wigley.bm_longitudinal() > 10.0 * wigley.bm_transverse()
|
||||||
|
|
||||||
|
def test_bmt_equals_it_over_v(self, wigley: Hull) -> None:
|
||||||
|
bmt = wigley.bm_transverse()
|
||||||
|
it_v = wigley.it_waterplane() / wigley.volume_of_displacement()
|
||||||
|
assert abs(bmt - it_v) < 1e-9
|
||||||
|
|
||||||
|
def test_bml_equals_il_over_v(self, wigley: Hull) -> None:
|
||||||
|
bml = wigley.bm_longitudinal()
|
||||||
|
il_v = wigley.il_waterplane() / wigley.volume_of_displacement()
|
||||||
|
assert abs(bml - il_v) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Altura del metacentro transversal KMT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestKmTransverse:
|
||||||
|
def test_kmt_equals_kb_plus_bmt(self, wigley: Hull) -> None:
|
||||||
|
kb = wigley.vcb()
|
||||||
|
bmt = wigley.bm_transverse()
|
||||||
|
kmt = wigley.km_transverse()
|
||||||
|
assert abs(kmt - (kb + bmt)) < TOL_ABS
|
||||||
|
|
||||||
|
def test_kmt_greater_than_draft(self, wigley: Hull) -> None:
|
||||||
|
"""KMT debe ser mayor que el calado para una embarcación estable."""
|
||||||
|
# Esta condición no es universal, pero para Wigley fino debería cumplirse.
|
||||||
|
kmt = wigley.km_transverse()
|
||||||
|
assert kmt > 0.0
|
||||||
|
|
||||||
|
def test_kmt_greater_than_kb(self, wigley: Hull) -> None:
|
||||||
|
assert wigley.km_transverse() > wigley.vcb()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. TPC
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTPC:
|
||||||
|
def test_tpc_positive(self, wigley: Hull) -> None:
|
||||||
|
assert wigley.tpc() > 0.0
|
||||||
|
|
||||||
|
def test_tpc_equals_awp_times_rho(self, wigley: Hull) -> None:
|
||||||
|
tpc = wigley.tpc(rho=1025.0)
|
||||||
|
awp = wigley.waterplane_area()
|
||||||
|
tpc_exp = awp * 1025.0 / 100_000.0
|
||||||
|
assert abs(tpc - tpc_exp) < 1e-9
|
||||||
|
|
||||||
|
def test_tpc_freshwater_less_than_saltwater(self, wigley: Hull) -> None:
|
||||||
|
tpc_sw = wigley.tpc(rho=1025.0)
|
||||||
|
tpc_fw = wigley.tpc(rho=1000.0)
|
||||||
|
assert tpc_sw > tpc_fw
|
||||||
|
|
||||||
|
def test_tpc_order_of_magnitude(self, wigley: Hull) -> None:
|
||||||
|
"""TPC de embarcación 10m debe ser del orden 0.05–0.5 t/cm."""
|
||||||
|
tpc = wigley.tpc()
|
||||||
|
assert 0.01 < tpc < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. MCT 1 cm
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMCT:
|
||||||
|
def test_mct_positive(self, wigley: Hull) -> None:
|
||||||
|
mct = wigley.mct1cm()
|
||||||
|
assert mct >= 0.0
|
||||||
|
|
||||||
|
def test_mct_uses_lpp(self, wigley: Hull) -> None:
|
||||||
|
"""MCT debe escalar proporcionalmente al desplazamiento."""
|
||||||
|
mct = wigley.mct1cm()
|
||||||
|
delta = wigley.displacement_tonnes()
|
||||||
|
# MCT = Δ·GML/(100·Lpp) → MCT*100*Lpp/Δ ≈ GML > 0
|
||||||
|
gml_implied = mct * 100.0 * LPP / delta if delta > 0 else 0.0
|
||||||
|
assert gml_implied >= 0.0
|
||||||
|
|
||||||
|
def test_mct_custom_kg(self, wigley: Hull) -> None:
|
||||||
|
"""Con KG=0 (barge) MCT debe ser mayor que con KG=T."""
|
||||||
|
mct_low_kg = wigley.mct1cm(kg=0.0)
|
||||||
|
mct_high_kg = wigley.mct1cm(kg=DRAFT)
|
||||||
|
assert mct_low_kg >= mct_high_kg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Visores 2D — instanciación y set_hull sin excepciones
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLineViewers:
|
||||||
|
"""Tests headless de los tres visores QPainter.
|
||||||
|
|
||||||
|
No renderizan a pantalla real; solo verifican que
|
||||||
|
las clases se instancian y aceptan un Hull sin lanzar excepciones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def qt_app(self):
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
yield app
|
||||||
|
|
||||||
|
def test_import(self) -> None:
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import (
|
||||||
|
BodyPlanViewer, ProfileViewer, PlanViewer, _BaseViewer,
|
||||||
|
)
|
||||||
|
assert issubclass(BodyPlanViewer, _BaseViewer)
|
||||||
|
assert issubclass(ProfileViewer, _BaseViewer)
|
||||||
|
assert issubclass(PlanViewer, _BaseViewer)
|
||||||
|
|
||||||
|
def test_instantiate(self, qt_app) -> None:
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import (
|
||||||
|
BodyPlanViewer, ProfileViewer, PlanViewer,
|
||||||
|
)
|
||||||
|
bp = BodyPlanViewer()
|
||||||
|
pf = ProfileViewer()
|
||||||
|
pl = PlanViewer()
|
||||||
|
assert bp._hull is None
|
||||||
|
assert pf._hull is None
|
||||||
|
assert pl._hull is None
|
||||||
|
|
||||||
|
def test_set_hull_none(self, qt_app) -> None:
|
||||||
|
"""set_hull(None) debe limpiar el visor sin excepción."""
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import BodyPlanViewer
|
||||||
|
bv = BodyPlanViewer()
|
||||||
|
bv.set_hull(None)
|
||||||
|
assert bv._hull is None
|
||||||
|
|
||||||
|
def test_set_hull_wigley(self, qt_app, wigley: Hull) -> None:
|
||||||
|
"""set_hull(hull) debe almacenar el hull en los tres visores."""
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import (
|
||||||
|
BodyPlanViewer, ProfileViewer, PlanViewer,
|
||||||
|
)
|
||||||
|
bp = BodyPlanViewer()
|
||||||
|
pf = ProfileViewer()
|
||||||
|
pl = PlanViewer()
|
||||||
|
bp.set_hull(wigley)
|
||||||
|
pf.set_hull(wigley)
|
||||||
|
pl.set_hull(wigley)
|
||||||
|
assert bp._hull is wigley
|
||||||
|
assert pf._hull is wigley
|
||||||
|
assert pl._hull is wigley
|
||||||
|
|
||||||
|
def test_world_bbox_body_plan(self, qt_app, wigley: Hull) -> None:
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import BodyPlanViewer
|
||||||
|
bv = BodyPlanViewer()
|
||||||
|
bv.set_hull(wigley)
|
||||||
|
bbox = bv._world_bbox()
|
||||||
|
assert bbox is not None
|
||||||
|
wx0, wy0, wx1, wy1 = bbox
|
||||||
|
assert wx0 < 0 < wx1 # simétrico: ±semi-manga
|
||||||
|
assert wy0 < wy1 # altura positiva
|
||||||
|
|
||||||
|
def test_world_bbox_profile(self, qt_app, wigley: Hull) -> None:
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import ProfileViewer
|
||||||
|
pf = ProfileViewer()
|
||||||
|
pf.set_hull(wigley)
|
||||||
|
bbox = pf._world_bbox()
|
||||||
|
assert bbox is not None
|
||||||
|
wx0, wy0, wx1, wy1 = bbox
|
||||||
|
assert wx0 < 0 # margen antes de AP
|
||||||
|
assert wx1 > LPP # margen después de FP
|
||||||
|
assert wy1 > DRAFT # incluye puntal
|
||||||
|
|
||||||
|
def test_world_bbox_plan(self, qt_app, wigley: Hull) -> None:
|
||||||
|
from arshipdesign.ui.widgets.viewer_lines import PlanViewer
|
||||||
|
pl = PlanViewer()
|
||||||
|
pl.set_hull(wigley)
|
||||||
|
bbox = pl._world_bbox()
|
||||||
|
assert bbox is not None
|
||||||
|
wx0, wy0, wx1, wy1 = bbox
|
||||||
|
assert wx1 > LPP
|
||||||
|
assert wy1 > 0 # semi-manga positiva
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Consistencia entre métodos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestHydrostaticConsistency:
|
||||||
|
"""Los nuevos métodos deben ser consistentes con los existentes."""
|
||||||
|
|
||||||
|
def test_cw_consistent_with_awp(self, wigley: Hull) -> None:
|
||||||
|
awp = wigley.waterplane_area()
|
||||||
|
cw = wigley.waterplane_coefficient()
|
||||||
|
assert abs(cw * LPP * BEAM - awp) < 1e-6
|
||||||
|
|
||||||
|
def test_bmt_consistent_with_it_and_v(self, wigley: Hull) -> None:
|
||||||
|
bmt = wigley.bm_transverse()
|
||||||
|
it = wigley.it_waterplane()
|
||||||
|
v = wigley.volume_of_displacement()
|
||||||
|
assert abs(bmt - it / v) < 1e-9
|
||||||
|
|
||||||
|
def test_kmt_chain(self, wigley: Hull) -> None:
|
||||||
|
"""KMT = KB + IT/V."""
|
||||||
|
kmt = wigley.km_transverse()
|
||||||
|
kb = wigley.vcb()
|
||||||
|
it = wigley.it_waterplane()
|
||||||
|
v = wigley.volume_of_displacement()
|
||||||
|
assert abs(kmt - (kb + it / v)) < 1e-9
|
||||||
|
|
||||||
|
def test_tpc_consistent_with_awp(self, wigley: Hull) -> None:
|
||||||
|
tpc = wigley.tpc(rho=1025.0)
|
||||||
|
awp = wigley.waterplane_area()
|
||||||
|
assert abs(tpc * 100_000.0 / 1025.0 - awp) < 1e-9
|
||||||
Reference in New Issue
Block a user