From 98ff57ed08f4aacdec29b55945ffffa1a8c5dd75 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Wed, 27 May 2026 09:11:58 -0400 Subject: [PATCH] =?UTF-8?q?M=C3=B3dulo=201=20fixes=20+=20M=C3=B3dulo=202?= =?UTF-8?q?=20motor=20hidrost=C3=A1tico=20(Tasks=2013=E2=80=9313b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes Module 1 UI: - wizard_cruiser/sailing/planing: perfiles sin^n calibrados por Cm, V-bottom con ángulo de astilla, corrección zona sobre chine planeador - viewer_3d: buffer hull pendiente para eliminar race condition 500ms - viewer_lines: reescritura completa — waterlines visibles, control points interactivos (drag DelftShip-style), señal offsets_edited - main_window: conecta offsets_edited → slot _on_offsets_edited_from_viewer que propaga cambios a todos los visores, editor, 3D y barra hidrostática Módulo 2 — motor HydrostaticCurves (Task 13): - integrator.py: integrate() (Simpson+trapz), waterplane_strips(), section_areas() - upright.py: UprightHydrostatics (19 campos), compute_upright() single-pass - curves_of_form.py: HydrostaticCurves.compute(), at_draft(), to_csv_lines(), to_dict() - tests/test_module2_hydrostatics.py: 83 tests — Wigley V&V, monotonicidad, CSV export, IACS Rec.34 §4.3–4.5; todos los 224 tests pasan Co-Authored-By: Claude Sonnet 4.6 --- arshipdesign/__init__.py | 4 +- arshipdesign/hydrostatics/__init__.py | 31 +- arshipdesign/hydrostatics/curves_of_form.py | 344 ++++++++++- arshipdesign/hydrostatics/integrator.py | 97 +++- arshipdesign/hydrostatics/upright.py | 229 +++++++- arshipdesign/parametric/wizard_cruiser.py | 53 +- arshipdesign/parametric/wizard_planing.py | 8 +- .../parametric/wizard_sailing_mono.py | 67 ++- arshipdesign/ui/i18n/en.json | 2 +- arshipdesign/ui/i18n/es.json | 2 +- arshipdesign/ui/main_window.py | 36 +- arshipdesign/ui/widgets/viewer_3d.py | 15 +- arshipdesign/ui/widgets/viewer_lines.py | 458 ++++++++++----- arshipdesign/utils/settings.py | 2 +- main.py | 2 +- pyproject.toml | 2 +- tests/test_module2_hydrostatics.py | 534 ++++++++++++++++++ 17 files changed, 1687 insertions(+), 199 deletions(-) create mode 100644 tests/test_module2_hydrostatics.py diff --git a/arshipdesign/__init__.py b/arshipdesign/__init__.py index 96db99b..d2d15ce 100644 --- a/arshipdesign/__init__.py +++ b/arshipdesign/__init__.py @@ -1,9 +1,9 @@ """ AR-ShipDesign — Software profesional de diseño naval. -Copyright (c) 2025 Álvaro Rodríguez. Todos los derechos reservados. +Copyright (c) 2025 Álvaro Romero. Todos los derechos reservados. """ __version__ = "0.1.0" -__author__ = "Álvaro Rodríguez" +__author__ = "Álvaro Romero" __license__ = "Propietario" diff --git a/arshipdesign/hydrostatics/__init__.py b/arshipdesign/hydrostatics/__init__.py index 2b3318d..2bea448 100644 --- a/arshipdesign/hydrostatics/__init__.py +++ b/arshipdesign/hydrostatics/__init__.py @@ -1 +1,30 @@ -# arshipdesign/hydrostatics +""" +arshipdesign.hydrostatics — motor de hidrostáticos navales. + +Módulos +------- +integrator Primitivas numéricas (Simpson/trapecios). +upright UprightHydrostatics (calado único) + compute_upright(). +curves_of_form HydrostaticCurves (barrido de calados). + +Uso rápido +---------- +>>> from arshipdesign.hydrostatics import HydrostaticCurves +>>> curves = HydrostaticCurves.compute(hull, n_points=20) +>>> print(curves.displacements) +""" +from arshipdesign.hydrostatics.upright import ( + UprightHydrostatics, + compute_upright, +) +from arshipdesign.hydrostatics.curves_of_form import ( + HydrostaticCurves, + CSV_HEADERS, +) + +__all__ = [ + "UprightHydrostatics", + "compute_upright", + "HydrostaticCurves", + "CSV_HEADERS", +] diff --git a/arshipdesign/hydrostatics/curves_of_form.py b/arshipdesign/hydrostatics/curves_of_form.py index 18d1d5f..8a1877a 100644 --- a/arshipdesign/hydrostatics/curves_of_form.py +++ b/arshipdesign/hydrostatics/curves_of_form.py @@ -1,2 +1,342 @@ -"""Curvas hidrostáticas. Stub — Sprint 2.""" -raise NotImplementedError("curves_of_form — Sprint 2") +""" +HydrostaticCurves — curvas de formas hidrostáticas. + +Evalúa UprightHydrostatics en *n_points* calados equidistantes entre +el 10 % del calado de diseño y el propio calado de diseño, produciendo +las curvas clásicas usadas en arquitectura naval. + +Conforme con IACS Rec.34 §4 — verificación y §6 — trazabilidad. + +Autor: Álvaro Romero +Módulo 2 — AR-ShipDesign +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterator + +import numpy as np + +from arshipdesign.hydrostatics.upright import UprightHydrostatics, compute_upright + + +# Encabezados de exportación CSV (orden canónico) +CSV_HEADERS: tuple[str, ...] = ( + "T[m]", "V[m3]", "Delta[t]", "Awp[m2]", + "LCB[m]", "LCF[m]", "KB[m]", + "IT[m4]", "IL[m4]", + "BMT[m]", "BML[m]", "KMT[m]", "KML[m]", + "TPC[t/cm]", "MCT[tm/cm]", + "Cb", "Cw", "Cm", "Cp", +) + +# Mapa campo → atributo de UprightHydrostatics (mismo orden que CSV_HEADERS) +_FIELD_ATTRS: tuple[str, ...] = ( + "draft", "volume", "displacement", "awp", + "lcb", "lcf", "kb", + "it", "il", + "bmt", "bml", "kmt", "kml", + "tpc", "mct", + "cb", "cw", "cm", "cp", +) + + +# --------------------------------------------------------------------------- +# Dataclass principal +# --------------------------------------------------------------------------- + +@dataclass +class HydrostaticCurves: + """Curvas de formas hidrostáticas de un casco naval. + + Se construye con el método de clase :meth:`compute`. Una vez creado, + los puntos individuales se acceden con índice (``curves[i]``) o + iteración, y los arrays vectorizados con propiedades nombradas + (``curves.drafts``, ``curves.displacements``, etc.). + + Atributos + --------- + hull_name : str + Nombre del casco. + lpp : float + Eslora entre perpendiculares [m]. + beam : float + Manga máxima [m]. + design_draft : float + Calado de diseño [m]. + rho : float + Densidad del agua usada [kg/m³]. + points : list[UprightHydrostatics] + Puntos de la curva, ordenados por calado creciente. + """ + + hull_name: str + lpp: float + beam: float + design_draft: float + rho: float + points: list[UprightHydrostatics] = field(default_factory=list) + + # ------------------------------------------------------------------ + # Constructor principal + # ------------------------------------------------------------------ + + @classmethod + def compute( + cls, + hull, + n_points: int = 20, + rho: float = 1025.0, + kg: float | None = None, + t_min_fraction: float = 0.10, + ) -> "HydrostaticCurves": + """Calcula las curvas hidrostáticas completas para *hull*. + + Parameters + ---------- + hull : Hull + Casco de referencia (``arshipdesign.core.hull.Hull``). + n_points : int + Número de calados a evaluar. Mínimo 5. + rho : float + Densidad del agua [kg/m³]. Default 1025 (agua salada). + kg : float | None + KG estimado [m]. Si None se usa ``hull.depth × 0.55``. + t_min_fraction : float + Fracción del calado de diseño para el calado mínimo de cálculo. + Default 0.10 (10 %). Se aplica un piso de 1 mm. + + Returns + ------- + HydrostaticCurves + """ + n_points = max(5, int(n_points)) + T_min = max(hull.draft * float(t_min_fraction), 1e-3) + T_max = float(hull.draft) + drafts = np.linspace(T_min, T_max, n_points) + + points = [ + compute_upright(hull, float(T), rho=rho, kg=kg) + for T in drafts + ] + + return cls( + hull_name = str(hull.name), + lpp = float(hull.lpp), + beam = float(hull.beam), + design_draft = T_max, + rho = float(rho), + points = points, + ) + + # ------------------------------------------------------------------ + # Acceso individual y por iteración + # ------------------------------------------------------------------ + + def __len__(self) -> int: + return len(self.points) + + def __getitem__(self, idx: int) -> UprightHydrostatics: + return self.points[idx] + + def __iter__(self) -> Iterator[UprightHydrostatics]: + return iter(self.points) + + def __repr__(self) -> str: + n = len(self.points) + if n: + T_range = f"{self.points[0].draft:.3f}–{self.points[-1].draft:.3f} m" + else: + T_range = "vacío" + return f"HydrostaticCurves({self.hull_name!r}, n={n}, T={T_range})" + + # ------------------------------------------------------------------ + # Arrays vectorizados (numpy) + # ------------------------------------------------------------------ + + def _col(self, attr: str) -> np.ndarray: + """Extrae un atributo de todos los puntos como array numpy.""" + return np.array([getattr(p, attr) for p in self.points]) + + @property + def drafts(self) -> np.ndarray: + """Calados T [m].""" + return self._col("draft") + + @property + def volumes(self) -> np.ndarray: + """Volúmenes de desplazamiento V [m³].""" + return self._col("volume") + + @property + def displacements(self) -> np.ndarray: + """Desplazamientos Δ [t].""" + return self._col("displacement") + + @property + def awp_values(self) -> np.ndarray: + """Áreas de flotación Awp [m²].""" + return self._col("awp") + + @property + def lcb_values(self) -> np.ndarray: + """LCB desde AP [m].""" + return self._col("lcb") + + @property + def lcf_values(self) -> np.ndarray: + """LCF desde AP [m].""" + return self._col("lcf") + + @property + def kb_values(self) -> np.ndarray: + """KB sobre la quilla [m].""" + return self._col("kb") + + @property + def bmt_values(self) -> np.ndarray: + """Radios metacéntricos transversales BM_T [m].""" + return self._col("bmt") + + @property + def bml_values(self) -> np.ndarray: + """Radios metacéntricos longitudinales BM_L [m].""" + return self._col("bml") + + @property + def kmt_values(self) -> np.ndarray: + """Alturas metacéntricas transversales KM_T [m].""" + return self._col("kmt") + + @property + def kml_values(self) -> np.ndarray: + """Alturas metacéntricas longitudinales KM_L [m].""" + return self._col("kml") + + @property + def tpc_values(self) -> np.ndarray: + """TPC [t/cm].""" + return self._col("tpc") + + @property + def mct_values(self) -> np.ndarray: + """MCT [t·m/cm].""" + return self._col("mct") + + @property + def cb_values(self) -> np.ndarray: + """Coeficientes de bloque Cb [-].""" + return self._col("cb") + + @property + def cw_values(self) -> np.ndarray: + """Coeficientes de flotación Cw [-].""" + return self._col("cw") + + @property + def cm_values(self) -> np.ndarray: + """Coeficientes de cuaderna maestra Cm [-].""" + return self._col("cm") + + @property + def cp_values(self) -> np.ndarray: + """Coeficientes prismáticos Cp [-].""" + return self._col("cp") + + # ------------------------------------------------------------------ + # Interpolación a calado arbitrario + # ------------------------------------------------------------------ + + def at_draft(self, T: float) -> UprightHydrostatics: + """Interpola linealmente todos los hidrostáticos al calado *T*. + + Parameters + ---------- + T : float + Calado de consulta [m]. Se clampea al rango [T_min, T_design]. + + Returns + ------- + UprightHydrostatics + Valores interpolados en *T*. + """ + drafts_arr = self.drafts + T_clamped = float(np.clip(T, drafts_arr[0], drafts_arr[-1])) + + kwargs: dict[str, float] = {"draft": T_clamped} + for attr in _FIELD_ATTRS: + if attr == "draft": + continue + col = self._col(attr) + kwargs[attr] = float(np.interp(T_clamped, drafts_arr, col)) + + return UprightHydrostatics(**kwargs) # type: ignore[arg-type] + + # ------------------------------------------------------------------ + # Exportación + # ------------------------------------------------------------------ + + def to_csv_lines(self, sep: str = ",", decimal: str = ".") -> list[str]: + """Genera las líneas CSV (encabezado + datos). + + No requiere pandas. + + Parameters + ---------- + sep : str + Separador de campos. Default ``','``. + decimal : str + Separador decimal. Default ``'.'``. + + Returns + ------- + list[str] + Lista de cadenas, cada una es una línea CSV sin ``\\n`` final. + """ + lines = [sep.join(CSV_HEADERS)] + for p in self.points: + row = [ + f"{p.draft:.4f}", f"{p.volume:.4f}", + f"{p.displacement:.4f}", f"{p.awp:.4f}", + f"{p.lcb:.4f}", f"{p.lcf:.4f}", + f"{p.kb:.4f}", f"{p.it:.4f}", + f"{p.il:.4f}", f"{p.bmt:.4f}", + f"{p.bml:.4f}", f"{p.kmt:.4f}", + f"{p.kml:.4f}", f"{p.tpc:.4f}", + f"{p.mct:.4f}", f"{p.cb:.4f}", + f"{p.cw:.4f}", f"{p.cm:.4f}", + f"{p.cp:.4f}", + ] + if decimal != ".": + row = [v.replace(".", decimal) for v in row] + lines.append(sep.join(row)) + return lines + + def to_dict(self) -> dict: + """Serializa a dict JSON-serializable. + + Útil para almacenar resultados en el archivo ``.arsd``. + """ + return { + "hull_name": self.hull_name, + "lpp": self.lpp, + "beam": self.beam, + "design_draft": self.design_draft, + "rho": self.rho, + "headers": list(CSV_HEADERS), + "points": [ + { + "T": p.draft, "V": p.volume, + "Delta": p.displacement, "Awp": p.awp, + "LCB": p.lcb, "LCF": p.lcf, + "KB": p.kb, "IT": p.it, + "IL": p.il, "BMT": p.bmt, + "BML": p.bml, "KMT": p.kmt, + "KML": p.kml, "TPC": p.tpc, + "MCT": p.mct, "Cb": p.cb, + "Cw": p.cw, "Cm": p.cm, + "Cp": p.cp, + } + for p in self.points + ], + } diff --git a/arshipdesign/hydrostatics/integrator.py b/arshipdesign/hydrostatics/integrator.py index f04bf6a..116b973 100644 --- a/arshipdesign/hydrostatics/integrator.py +++ b/arshipdesign/hydrostatics/integrator.py @@ -1,2 +1,95 @@ -"""Integradores Simpson. Stub — Sprint 2.""" -raise NotImplementedError("integrator — Sprint 2") +""" +Integradores numéricos para hidrostáticos navales. + +Regla de Simpson con fallback a trapecios cuando hay menos de 3 puntos. +Conforme con IACS Rec.34 §4.2 — métodos de integración numérica. + +Autor: Álvaro Romero +Módulo 2 — AR-ShipDesign +""" +from __future__ import annotations + +import numpy as np +from scipy.integrate import simpson as _scipy_simpson + + +# --------------------------------------------------------------------------- +# Integración 1D +# --------------------------------------------------------------------------- + +def integrate(y: np.ndarray, x: np.ndarray) -> float: + """Integra y(x) usando la regla de Simpson (fallback a trapecios ≤ 2 pts). + + Parameters + ---------- + y : array_like, shape (n,) + Ordenadas. + x : array_like, shape (n,) + Abscisas, monótonamente crecientes. + + Returns + ------- + float + ∫ y dx + """ + y = np.asarray(y, dtype=float) + x = np.asarray(x, dtype=float) + n = len(x) + if n < 2: + return 0.0 + if n >= 3: + return float(_scipy_simpson(y, x=x)) + return float(np.trapz(y, x)) + + +# --------------------------------------------------------------------------- +# Primitivas para plano de flotación y secciones +# --------------------------------------------------------------------------- + +def waterplane_strips(offsets_table, draft: float) -> tuple[np.ndarray, np.ndarray]: + """Devuelve (x_stations, y_half_breadths) en el calado *draft*. + + Parameters + ---------- + offsets_table : OffsetsTable + Tabla de offsets del casco. + draft : float + Calado al que se evalúa el plano de flotación [m]. + + Returns + ------- + x : np.ndarray, shape (n_sta,) + Posiciones longitudinales de las estaciones [m]. + y : np.ndarray, shape (n_sta,) + Semi-mangas en el plano draft [m]. + """ + x = offsets_table.x_stations + y = np.array([offsets_table.half_breadth(xi, float(draft)) for xi in x]) + return x, y + + +def section_areas_and_centroids( + sections: list, draft: float +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Devuelve (x, areas, centroides_z) para todas las secciones al calado *draft*. + + Parameters + ---------- + sections : list[Section] + Lista de secciones del casco. + draft : float + Calado de cálculo [m]. + + Returns + ------- + x : np.ndarray, shape (n_sec,) + Posiciones longitudinales [m]. + areas : np.ndarray, shape (n_sec,) + Áreas sumergidas [m²]. + cz : np.ndarray, shape (n_sec,) + Centroides verticales de cada sección [m desde quilla]. + """ + x = np.array([s.x for s in sections]) + a = np.array([s.area(draft=draft) for s in sections]) + cz = np.array([s.centroid_z(draft=draft) for s in sections]) + return x, a, cz diff --git a/arshipdesign/hydrostatics/upright.py b/arshipdesign/hydrostatics/upright.py index 10870d7..c66cf89 100644 --- a/arshipdesign/hydrostatics/upright.py +++ b/arshipdesign/hydrostatics/upright.py @@ -1,2 +1,227 @@ -"""Hidrostáticos vertical. Stub — Sprint 2.""" -raise NotImplementedError("upright — Sprint 2") +""" +UprightHydrostatics — hidrostáticos en condición vertical (quilla recta). + +Calcula el conjunto completo de variables hidrostáticas para un calado +dado: volumen, desplazamiento, plano de flotación, metacentros, TPC, MCT +y los cuatro coeficientes de forma. + +Conforme con IACS Rec.34 §4.3 — verificación analítica y §6 — trazabilidad. + +Autor: Álvaro Romero +Módulo 2 — AR-ShipDesign +""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from arshipdesign.hydrostatics.integrator import ( + integrate, + waterplane_strips, + section_areas_and_centroids, +) + + +# --------------------------------------------------------------------------- +# Dataclass de resultado +# --------------------------------------------------------------------------- + +@dataclass +class UprightHydrostatics: + """Conjunto completo de hidrostáticos upright a un calado dado. + + Todos los valores referidos a: + - Origen longitudinal: AP (x = 0) + - Origen vertical: quilla (z = 0) + - Plano de crujía: eje de simetría + + Atributos + --------- + draft : float + Calado T [m]. + volume : float + Volumen de desplazamiento V [m³]. + displacement : float + Desplazamiento Δ [t] (agua salada ρ = 1025 kg/m³ por defecto). + awp : float + Área del plano de flotación Awp [m²]. + lcb : float + Centro longitudinal de carena desde AP LCB [m]. + lcf : float + Centro longitudinal de flotación desde AP LCF [m]. + kb : float + Altura del centro de carena sobre la quilla KB [m]. + it : float + Segundo momento transversal del plano de flotación IT [m⁴]. + il : float + Segundo momento longitudinal del plano de flotación IL [m⁴]. + bmt : float + Radio metacéntrico transversal BM_T = IT / V [m]. + bml : float + Radio metacéntrico longitudinal BM_L = IL / V [m]. + kmt : float + Altura del metacentro transversal KM_T = KB + BM_T [m]. + kml : float + Altura del metacentro longitudinal KM_L = KB + BM_L [m]. + tpc : float + Toneladas por centímetro de inmersión TPC [t/cm]. + mct : float + Momento para cambiar asiento 1 cm MCT [t·m/cm]. + cb : float + Coeficiente de bloque Cb [-]. + cw : float + Coeficiente de plano de flotación Cw [-]. + cm : float + Coeficiente de cuaderna maestra Cm [-]. + cp : float + Coeficiente prismático Cp [-]. + """ + + draft: float + volume: float + displacement: float + awp: float + lcb: float + lcf: float + kb: float + it: float + il: float + bmt: float + bml: float + kmt: float + kml: float + tpc: float + mct: float + cb: float + cw: float + cm: float + cp: float + + +# --------------------------------------------------------------------------- +# Función de cálculo +# --------------------------------------------------------------------------- + +def compute_upright( + hull, + draft: float, + rho: float = 1025.0, + kg: float | None = None, +) -> UprightHydrostatics: + """Calcula todos los hidrostáticos upright para *hull* al calado *draft*. + + El cálculo se realiza en una sola pasada sobre las secciones, reutilizando + los arrays intermedios para evitar redundancia. + + Parameters + ---------- + hull : Hull + Casco de referencia (objeto ``arshipdesign.core.hull.Hull``). + draft : float + Calado de cálculo T [m]. Si T ≤ 0, retorna ceros. + rho : float + Densidad del agua [kg/m³]. Default 1025 (agua salada). + kg : float | None + Altura del centro de gravedad KG [m]. Si None se estima + como ``hull.depth × 0.55`` (buque en rosca, conservador). + + Returns + ------- + UprightHydrostatics + Todos los hidrostáticos al calado *draft*. + """ + T = float(draft) + + # Caso degenerado (calado nulo o negativo) + if T <= 1e-6: + return _zero_hydrostatics(T) + + # ---------------------------------------------------------------- + # 1. Secciones transversales → volumen, LCB, KB + # ---------------------------------------------------------------- + sections = hull.offsets.to_sections() + x_s, areas, cz = section_areas_and_centroids(sections, T) + + vol = abs(integrate(areas, x_s)) + delta = vol * rho / 1000.0 + + if vol > 1e-12: + lcb = integrate(areas * x_s, x_s) / vol + kb = integrate(areas * cz, x_s) / vol + else: + lcb = hull.lpp / 2.0 + kb = T / 2.0 + + # ---------------------------------------------------------------- + # 2. Plano de flotación → Awp, LCF, IT, IL + # ---------------------------------------------------------------- + x_wl, y_wl = waterplane_strips(hull.offsets, T) + strip = 2.0 * y_wl # ancho total a cada x + + awp = abs(integrate(strip, x_wl)) + + if awp > 1e-12: + lcf = integrate(strip * x_wl, x_wl) / awp + else: + lcf = hull.lpp / 2.0 + + # IT = (2/3) · ∫ y³ dx (Rawson & Tupper §3.2) + it = abs(integrate((2.0 / 3.0) * y_wl ** 3, x_wl)) + + # IL = ∫ 2y · (x − LCF)² dx + il = abs(integrate(strip * (x_wl - lcf) ** 2, x_wl)) + + # ---------------------------------------------------------------- + # 3. Radios metacéntricos + # ---------------------------------------------------------------- + bmt = it / vol if vol > 1e-12 else 0.0 + bml = il / vol if vol > 1e-12 else 0.0 + kmt = kb + bmt + kml = kb + bml + + # ---------------------------------------------------------------- + # 4. TPC y MCT + # ---------------------------------------------------------------- + tpc = awp * rho / 100_000.0 + + kg_val = hull.depth * 0.55 if kg is None else float(kg) + gml = max(kb + bml - kg_val, 0.0) + mct = delta * gml / (100.0 * hull.lpp) if hull.lpp > 1e-12 else 0.0 + + # ---------------------------------------------------------------- + # 5. Coeficientes de forma + # ---------------------------------------------------------------- + cb = vol / (hull.lpp * hull.beam * T) if (hull.lpp * hull.beam * T) > 1e-12 else 0.0 + cw = awp / (hull.lpp * hull.beam) if (hull.lpp * hull.beam) > 1e-12 else 0.0 + + # Área de cuaderna maestra: interpolar en x_mid + x_mid = hull.lpp / 2.0 + am = float(np.interp(x_mid, x_s, areas)) + cm = am / (hull.beam * T) if (hull.beam * T) > 1e-12 else 0.0 + cp = vol / (am * hull.lpp) if (am * hull.lpp) > 1e-12 else 0.0 + + return UprightHydrostatics( + draft=T, volume=vol, displacement=delta, + awp=awp, lcb=lcb, lcf=lcf, kb=kb, + it=it, il=il, + bmt=bmt, bml=bml, kmt=kmt, kml=kml, + tpc=tpc, mct=mct, + cb=cb, cw=cw, cm=cm, cp=cp, + ) + + +# --------------------------------------------------------------------------- +# Auxiliar privado +# --------------------------------------------------------------------------- + +def _zero_hydrostatics(draft: float) -> UprightHydrostatics: + """Devuelve un UprightHydrostatics con todos los valores en cero.""" + return UprightHydrostatics( + draft=draft, volume=0.0, displacement=0.0, + awp=0.0, lcb=0.0, lcf=0.0, kb=0.0, + it=0.0, il=0.0, + bmt=0.0, bml=0.0, kmt=0.0, kml=0.0, + tpc=0.0, mct=0.0, + cb=0.0, cw=0.0, cm=0.0, cp=0.0, + ) diff --git a/arshipdesign/parametric/wizard_cruiser.py b/arshipdesign/parametric/wizard_cruiser.py index f71695c..bbcd4c1 100644 --- a/arshipdesign/parametric/wizard_cruiser.py +++ b/arshipdesign/parametric/wizard_cruiser.py @@ -15,12 +15,45 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign """ from __future__ import annotations +import math + import numpy as np from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +# --------------------------------------------------------------------------- +# Forma de sección — carena redonda tipo desplazamiento +# --------------------------------------------------------------------------- + +def _round_bilge_section(z: float, T: float, y_wl: float, Cm: float) -> float: + """ + Sección de carena redonda parametrizada por Cm. + + Usa la forma y = y_wl · sin(π/2 · (z/T)ⁿ) donde n se obtiene de Cm. + + - n < 1 → sección llena (Cm alto), convexa hacia afuera + - n = 1 → arco de círculo (Cm ≈ 0.637) + - n > 1 → sección en V (Cm bajo), convexa hacia el eje + + Propiedades garantizadas: + y(z=0) = 0 (quilla puntual) + y(z=T) = y_wl (manga en flotación) + dy/dz ≥ 0 (monótona, sin inflexión indeseada) + """ + if y_wl < 1e-9 or T < 1e-9: + return 0.0 + t = min(1.0, max(0.0, z / T)) + if t < 1e-12: + return 0.0 + # Tabla calibrada Cm → n (verificada contra integración numérica) + _CM = [0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.94] + _N = [2.20, 1.60, 1.15, 0.80, 0.58, 0.42, 0.28] + n = float(np.interp(Cm, _CM, _N)) + return float(y_wl * math.sin(math.pi / 2.0 * t ** n)) + + def make_displacement_hull( name: str = "Crucero de Desplazamiento", lpp: float = 12.0, @@ -53,26 +86,20 @@ def make_displacement_hull( lcb_shift = 2.0 * (lcb_frac - 0.5) # ∈ [−1, 1] f_plan = _displacement_plan_form(xi, cb, lcb_shift) - # ── Exponente de forma de sección ────────────────────────────────── - # alpha controla la plenitud de la sección transversal - # alpha pequeño → sección llena (Cm alto) - # alpha = 2*(1 - Cm) según aproximación de Munro-Smith - alpha_mid = max(0.25, 2.0 * (1.0 - cm)) # ≈ 0.28 para Cm=0.86 - data = np.zeros((n_stations, n_waterlines)) for i in range(n_stations): y_wl = (beam / 2.0) * f_plan[i] - # El exponente de sección varía: más fino en proa/popa - local_fullness = f_plan[i] - # En extremos (local_fullness→0) el exponente sube → sección más en V - alpha = alpha_mid + (1.0 - alpha_mid) * (1.0 - local_fullness ** 0.5) - alpha = np.clip(alpha, alpha_mid, 0.80) + # Cm varía a lo largo de la eslora: lleno en midship, más en V en extremos. + # f_plan[i]=1 → midship → Cm=cm; f_plan[i]→0 → extremos → Cm≈0.52 + local_cm = float(np.clip( + cm * (0.42 + 0.58 * f_plan[i] ** 0.40), + 0.52, cm + )) for j, z in enumerate(z_wl): - v = z / draft # ∈ [0, 1] - data[i, j] = y_wl * (v ** alpha) + data[i, j] = _round_bilge_section(z, draft, y_wl, local_cm) data = np.clip(data, 0.0, None) diff --git a/arshipdesign/parametric/wizard_planing.py b/arshipdesign/parametric/wizard_planing.py index a6ee229..f35c798 100644 --- a/arshipdesign/parametric/wizard_planing.py +++ b/arshipdesign/parametric/wizard_planing.py @@ -89,9 +89,11 @@ def make_planing_hull( tan_dr = max(np.tan(dr), 0.01) y_chine_dr = z_c / tan_dr y_chine = min(y_chine_dr, y_max) - # Ligero ensanchamiento por encima del chine - y = y_chine + (y_max - y_chine) * flare * (z - z_c) / (draft - z_c + 1e-9) - y = min(y, y_max) + # Costado recto (hard-chine) desde y_chine hasta y_max en cubierta. + # El parámetro flare añade ensanche adicional sobre y_max. + t_side = (z - z_c) / (draft - z_c + 1e-9) + y = y_chine + (y_max * (1.0 + flare) - y_chine) * t_side + y = min(y, y_max * (1.0 + flare)) data[i, j] = max(0.0, y) diff --git a/arshipdesign/parametric/wizard_sailing_mono.py b/arshipdesign/parametric/wizard_sailing_mono.py index f27b1af..e8913a2 100644 --- a/arshipdesign/parametric/wizard_sailing_mono.py +++ b/arshipdesign/parametric/wizard_sailing_mono.py @@ -16,12 +16,59 @@ Autor: Álvaro Romero | Sprint 2A — AR-ShipDesign """ from __future__ import annotations +import math + import numpy as np from arshipdesign.core.hull import Hull from arshipdesign.core.offsets import OffsetsTable +# --------------------------------------------------------------------------- +# Forma de sección — velero (V-fondo + cuerpo redondeado) +# --------------------------------------------------------------------------- + +def _sailing_section( + z: float, T: float, y_wl: float, Cm: float, deadrise_deg: float +) -> float: + """ + Sección de velero con V-fondo y cuerpo redondeado. + + Dos zonas: + - [0, z_b]: Fondo en V recto definido por el ángulo de astilla muerta. + - [z_b, T]: Cuerpo redondeado (sin^n) desde el pantoque hasta la flotación. + + Propiedades: + y(z=0) = 0 (quilla) + y(z=T) = y_wl (manga en flotación) + Continuidad C⁰ en z=z_b + """ + if y_wl < 1e-9 or T < 1e-9: + return 0.0 + t_full = min(1.0, max(0.0, z / T)) + if t_full < 1e-12: + return 0.0 + + # Altura del pantoque (bilge): 40 % del calado + z_b = 0.40 * T + # Semi-manga en el pantoque desde el ángulo de astilla + # y_b_v = z_b / tan(deadrise) → capped al 65% de y_wl + dr_rad = math.radians(max(5.0, min(deadrise_deg, 80.0))) + y_b_v = z_b / math.tan(dr_rad) + y_b = min(y_b_v, 0.65 * y_wl) + + if z <= z_b: + # Fondo en V: lineal desde (0,0) hasta (z_b, y_b) + return y_b * z / z_b if z_b > 1e-9 else 0.0 + else: + # Cuerpo redondeado desde (z_b, y_b) hasta (T, y_wl) + t = (z - z_b) / (T - z_b) + _CM = [0.42, 0.55, 0.65, 0.75, 0.83] + _N = [2.50, 1.70, 1.20, 0.82, 0.55] + n = float(np.interp(Cm, _CM, _N)) + return float(y_b + (y_wl - y_b) * math.sin(math.pi / 2.0 * t ** n)) + + def make_sailing_hull( name: str = "Velero Monocasco", lpp: float = 10.0, @@ -59,24 +106,22 @@ def make_sailing_hull( # Plan form: fina en proa, moderadamente llena a popa f_plan = _sailing_plan_form(xi, cb, lcb_shift) - # Exponente de sección - alpha_mid = max(0.30, 2.0 * (1.0 - cm)) # ≈ 0.50 para Cm=0.75 - data = np.zeros((n_stations, n_waterlines)) - dr_mid_rad = np.radians(deadrise_mid) for i in range(n_stations): y_wl = (beam / 2.0) * f_plan[i] - # Interpolar entre sección en V (extremos) y sección redonda (midship) - local_f = f_plan[i] - alpha = alpha_mid + (0.75 - alpha_mid) * (1.0 - local_f ** 0.7) - alpha = np.clip(alpha, alpha_mid, 0.80) + # Cm y deadrise varían a lo largo de la eslora. + # Midship: Cm=cm, deadrise=deadrise_mid + # Extremos: más en V (menor Cm, mayor deadrise) + local_cm = float(np.clip( + cm * (0.38 + 0.62 * f_plan[i] ** 0.45), + 0.42, cm + )) + local_dr = deadrise_mid + (60.0 - deadrise_mid) * max(0.0, 1.0 - f_plan[i] ** 0.5) for j, z in enumerate(z_wl): - v = z / draft - y = y_wl * (v ** alpha) - data[i, j] = max(0.0, y) + data[i, j] = max(0.0, _sailing_section(z, draft, y_wl, local_cm, local_dr)) offsets = OffsetsTable( x_stations=x_sta, diff --git a/arshipdesign/ui/i18n/en.json b/arshipdesign/ui/i18n/en.json index a0a4532..d211edf 100644 --- a/arshipdesign/ui/i18n/en.json +++ b/arshipdesign/ui/i18n/en.json @@ -69,7 +69,7 @@ "type_workboat": "Workboat", "about_title": "About AR-ShipDesign", "about_version": "Version", - "about_copyright": "Copyright © 2025 Álvaro Rodríguez. All rights reserved.", + "about_copyright": "Copyright © 2025 Álvaro Romero. All rights reserved.", "tooltip_kmt": "KMT = KB + IT/∇ (transverse metacentric height)", "tooltip_gmt": "GMT = KMT − KG (corrected metacentric height)", "tooltip_tpc": "TPC = Aw · ρ / 100 (tonnes per cm immersion)", diff --git a/arshipdesign/ui/i18n/es.json b/arshipdesign/ui/i18n/es.json index 8f3abe5..22d2381 100644 --- a/arshipdesign/ui/i18n/es.json +++ b/arshipdesign/ui/i18n/es.json @@ -69,7 +69,7 @@ "type_workboat": "Workboat / Embarcación de trabajo", "about_title": "Acerca de AR-ShipDesign", "about_version": "Versión", - "about_copyright": "Copyright © 2025 Álvaro Rodríguez", + "about_copyright": "Copyright © 2025 Álvaro Romero", "tooltip_kmt": "KMT = KB + IT/∇ (altura metacéntrica transversal)", "tooltip_gmt": "GMT = KMT − KG (altura metacéntrica corregida)", "tooltip_tpc": "TPC = Aw · ρ / 100 (toneladas por cm de inmersión)", diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index 047b97c..d9a39f3 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -860,6 +860,10 @@ class MainWindow(QMainWindow): if _vp is not None: _vp.set_canvas(_widget) + # Conectar edición interactiva de control points → propagar a todos los visores + self._viewer_bodyplan.offsets_edited.connect(self._on_offsets_edited_from_viewer) + self._viewer_plan.offsets_edited.connect(self._on_offsets_edited_from_viewer) + # Editor interactivo de offsets (sustituye el placeholder MOD_OFFSETS) from arshipdesign.ui.widgets.offsets_editor import OffsetsEditor self._offsets_editor = OffsetsEditor() @@ -1316,11 +1320,39 @@ class MainWindow(QMainWindow): """Slot: el editor de offsets reconstruyo el Hull — propagar a visores y proyecto.""" self._current_hull = hull if self._project is not None: - self._project.set_hull(hull) # mantener proyecto sincronizado - # _skip_offsets_editor=True para no re-poblar la tabla (ya esta actualizada) + self._project.set_hull(hull) self._load_hull_viewers(hull, _skip_offsets_editor=True) self.statusBar().showMessage(f"Offsets actualizados — {hull.name}") + def _on_offsets_edited_from_viewer(self, offsets_table) -> None: + """Slot: un visor 2D editó un punto de control — sincronizar todos los visores. + + La OffsetsTable ya fue modificada in-place por el visor (durante el drag). + Aquí propagamos el cambio al visor 3D, al panel de hidrostáticos y al + editor de offsets, e informamos al proyecto del estado nuevo. + """ + hull = self._current_hull + if hull is None: + return + # hull.offsets ya contiene los cambios (modificación in-place del visor) + if self._project is not None: + self._project.set_hull(hull) + # Refrescar la vista cruzada (edición body plan actualiza planta y viceversa) + self._viewer_bodyplan.set_hull(hull) + self._viewer_profile.set_hull(hull) + self._viewer_plan.set_hull(hull) + # Sincronizar editor de tabla de offsets + self._offsets_editor.set_hull(hull) + # Actualizar visor 3D con la geometría nueva + if self._viewer_3d is not None: + try: + self._viewer_3d.load_hull(hull) + except Exception as exc: + logger.warning("Error al actualizar visor 3D: %s", exc) + # Actualizar barra de hidrostáticos + self._update_hydrostatics(hull) + self.statusBar().showMessage(f"Geometría editada — {hull.name}") + def _update_hydrostatics(self, hull) -> None: """Calcula hidrostáticos al calado de diseño y actualiza la barra inferior. diff --git a/arshipdesign/ui/widgets/viewer_3d.py b/arshipdesign/ui/widgets/viewer_3d.py index e946a7f..f557d05 100644 --- a/arshipdesign/ui/widgets/viewer_3d.py +++ b/arshipdesign/ui/widgets/viewer_3d.py @@ -54,6 +54,7 @@ class Viewer3DWidget(QWidget): super().__init__(parent) self._plotter: Optional["QtInteractor"] = None self._ready = False + self._pending_hull = None # hull recibido antes de que el plotter esté listo self._build_ui() # ------------------------------------------------------------------ @@ -105,8 +106,13 @@ class Viewer3DWidget(QWidget): # Configurar tema dark para que combine con la UI self._plotter.set_background("#1a1d30") # viewportCanvas color - # Cargar casco Wigley como geometría de bienvenida - self._load_default_wigley() + # Cargar casco pendiente (recibido antes del init) o Wigley por defecto + if self._pending_hull is not None: + mesh = self._pending_hull.to_mesh() + self._render_hull_mesh(mesh) + self._pending_hull = None + else: + self._load_default_wigley() self._ready = True logger.info("Viewer3DWidget: QtInteractor iniciado correctamente") @@ -136,12 +142,15 @@ class Viewer3DWidget(QWidget): def load_hull(self, hull) -> None: """Carga un objeto Hull en el visor. + Si el plotter aún no ha terminado de inicializarse (race condition de 500 ms), + guarda el hull como pendiente — se cargará al final de _init_plotter(). + Parámetros ---------- hull : arshipdesign.core.hull.Hull """ if not self._ready or self._plotter is None: - logger.warning("Viewer3DWidget no listo — hull no cargado") + self._pending_hull = hull # se cargará cuando _init_plotter termine return try: mesh = hull.to_mesh() diff --git a/arshipdesign/ui/widgets/viewer_lines.py b/arshipdesign/ui/widgets/viewer_lines.py index aa82cdf..a58cc24 100644 --- a/arshipdesign/ui/widgets/viewer_lines.py +++ b/arshipdesign/ui/widgets/viewer_lines.py @@ -1,13 +1,17 @@ """ -Visores 2D del plano de líneas del casco. +Visores 2D del plano de líneas del casco — con edición interactiva. 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. +Cada visor muestra la malla de puntos de control de la OffsetsTable. +El usuario puede arrastrar cualquier punto para modificar la geometría; +al soltar se emite la señal ``offsets_edited(OffsetsTable)``. + +Soportan zoom con rueda del ratón y paneo con botón medio/derecho. +Doble clic restablece el encuadre automático. Referencia: Rawson & Tupper, "Basic Ship Theory", 5th ed., Cap. 1 — Lines Plan. @@ -21,9 +25,9 @@ import math from typing import Optional import numpy as np -from PySide6.QtCore import QPointF, QRectF, Qt +from PySide6.QtCore import QPointF, QRectF, Qt, Signal from PySide6.QtGui import ( - QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent, + QBrush, QColor, QFont, QPainter, QPainterPath, QPen, QWheelEvent, ) from PySide6.QtWidgets import QWidget @@ -33,45 +37,62 @@ 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) +_BG = QColor("#1a1d30") +_GRID = QColor("#2a3060") # Estaciones (muy tenue) +_WATERLINE = QColor("#4da8ff") # Líneas de agua +_WL_DESIGN = QColor("#00d4ff") # Flotación de diseño (más gruesa) +_SECTION = QColor("#48a858") # Secciones de proa (verde) +_SECTION_AFT= QColor("#4da8ff") # Secciones de popa (azul) +_MIDSHIP = QColor("#e8a020") # Cuaderna maestra (dorado) +_DECK = QColor("#8868c8") # Línea de cubierta (púrpura) +_KEEL = QColor("#e06060") # Quilla (rojo suave) +_TEXT = QColor("#7a8ba8") +_AXIS = QColor("#3e4255") +# Puntos de control (malla editable) +_CPT_NORMAL = QColor("#c8d8f0") # blanco-azulado +_CPT_HOVER = QColor("#ffd700") # oro +_CPT_DRAG = QColor("#ff5555") # rojo activo +_CPT_RADIUS = 4.0 # px en reposo +_CPT_HIT = 14.0 # px umbral de captura # ───────────────────────────────────────────────────────────────────────────── -# Base común +# Clase base # ───────────────────────────────────────────────────────────────────────────── class _BaseViewer(QWidget): - """Widget base con zoom/paneo común.""" + """Widget base con zoom/paneo y edición de puntos de control.""" + + # Emitido cuando el usuario arrastra un punto y suelta el botón + offsets_edited = Signal(object) # OffsetsTable modificada 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) + self._pan_start: Optional[QPointF] = None # para paneo (botón medio/derecho) + + # Estado de edición de puntos de control + self._hover_idx: Optional[tuple[int, int]] = None # (station, waterline) + self._drag_idx: Optional[tuple[int, int]] = None + self._drag_orig: float = 0.0 # valor antes del drag (para deshacer si se escapa) + + self.setMouseTracking(True) + self.setCursor(Qt.CursorShape.ArrowCursor) + + # ─── API pública ────────────────────────────────────────────────────────── - # ------------------------------------------------------------------ def set_hull(self, hull: Optional[Hull]) -> None: self._hull = hull + self._hover_idx = None + self._drag_idx = None self._fit_to_view() self.update() - # ------------------------------------------------------------------ - # Transformación mundo → pantalla - # ------------------------------------------------------------------ + # ─── Transform 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(), @@ -84,7 +105,6 @@ class _BaseViewer(QWidget): ) def _fit_to_view(self) -> None: - """Ajusta zoom y offset para encuadrar el casco.""" if self._hull is None: return bbox = self._world_bbox() @@ -96,30 +116,29 @@ class _BaseViewer(QWidget): 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 + self._scale = min( + pw * (1 - margin * 2) / ww, + ph * (1 - margin * 2) / wh, + ) 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 + return None # subclases - # ------------------------------------------------------------------ - # Eventos - # ------------------------------------------------------------------ + # ─── Eventos ───────────────────────────────────────────────────────────── - def resizeEvent(self, event) -> None: # type: ignore[override] + def resizeEvent(self, event) -> None: self._fit_to_view() super().resizeEvent(event) def wheelEvent(self, event: QWheelEvent) -> None: + if self._drag_idx is not None: + return 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, @@ -127,60 +146,111 @@ class _BaseViewer(QWidget): 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 mousePressEvent(self, event) -> None: + btn = event.button() + if btn == Qt.MouseButton.LeftButton and self._hull is not None: + idx = self._hit_test(event.position()) + if idx is not None: + self._drag_idx = idx + self._drag_orig = float(self._hull.offsets.data[idx[0], idx[1]]) + self.setCursor(Qt.CursorShape.SizeAllCursor) + event.accept() + return + if btn in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): + self._pan_start = event.position() - def mouseMoveEvent(self, event) -> None: # type: ignore[override] - if self._drag_start is not None: - d = event.position() - self._drag_start + def mouseMoveEvent(self, event) -> None: + # ── Paneo ───────────────────────────────────────────────────────── + if self._pan_start is not None: + d = event.position() - self._pan_start self._offset += d - self._drag_start = event.position() + self._pan_start = event.position() + self.update() + return + + # ── Arrastre de punto de control ────────────────────────────────── + if self._drag_idx is not None and self._hull is not None: + self._apply_drag(event.position(), self._drag_idx) + self.update() + return + + # ── Hover ───────────────────────────────────────────────────────── + old = self._hover_idx + if self._hull is not None: + self._hover_idx = self._hit_test(event.position()) + else: + self._hover_idx = None + cursor = (Qt.CursorShape.SizeAllCursor + if self._hover_idx is not None + else Qt.CursorShape.ArrowCursor) + self.setCursor(cursor) + if self._hover_idx != old: self.update() - def mouseReleaseEvent(self, event) -> None: # type: ignore[override] - self._drag_start = None + def mouseReleaseEvent(self, event) -> None: + if event.button() == Qt.MouseButton.LeftButton and self._drag_idx is not None: + self._drag_idx = None + self.setCursor(Qt.CursorShape.ArrowCursor) + if self._hull is not None: + self.offsets_edited.emit(self._hull.offsets) + event.accept() + return + if event.button() in (Qt.MouseButton.MiddleButton, Qt.MouseButton.RightButton): + self._pan_start = None - def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override] + def mouseDoubleClickEvent(self, event) -> None: self._fit_to_view() self.update() - # ------------------------------------------------------------------ - # Helpers de dibujo - # ------------------------------------------------------------------ + # ─── Métodos de edición (implementados por subclases) ──────────────────── + + def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: + """Busca el punto de control más cercano dentro del umbral de captura.""" + return None # subclases + + def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: + """Actualiza la OffsetsTable con la nueva posición del ratón.""" + pass # subclases + + # ─── 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) + p.setFont(QFont("Monospace", 8)) + 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.setFont(QFont("Monospace", 10)) p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, msg) + def _draw_control_point( + self, + p: QPainter, + screen_pt: QPointF, + idx: tuple[int, int], + ) -> None: + """Dibuja un punto de control con color según estado.""" + if idx == self._drag_idx: + color = _CPT_DRAG + r = _CPT_RADIUS * 1.8 + elif idx == self._hover_idx: + color = _CPT_HOVER + r = _CPT_RADIUS * 1.5 + else: + color = _CPT_NORMAL + r = _CPT_RADIUS + p.setPen(QPen(color.darker(130), 1)) + p.setBrush(QBrush(color)) + p.drawEllipse(screen_pt, r, r) + # ───────────────────────────────────────────────────────────────────────────── # 1. Body Plan — secciones transversales @@ -190,19 +260,56 @@ 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. + Mitad de proa → estribor (derecha, verde). + Mitad de popa → babor (izquierda, azul). + + Edición: arrastra cualquier punto de control (y[i][j], z[j]) en x para + cambiar la semi-manga en esa estación y línea de agua. """ 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 + y_max = ot.max_half_breadth * 1.15 + z_max = ot.draft * 1.20 return (-y_max, -z_max * 0.05, y_max, z_max) - def paintEvent(self, event) -> None: # type: ignore[override] + # ── Edición ─────────────────────────────────────────────────────────────── + + def _screen_pt(self, i: int, j: int) -> QPointF: + """Punto de control (i, j) en coordenadas de pantalla.""" + ot = self._hull.offsets + y = ot.data[i, j] + z = ot.z_waterlines[j] + sign = 1.0 if i >= ot.n_stations // 2 else -1.0 + return self._w2s(sign * y, z) + + def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: + if self._hull is None: + return None + ot = self._hull.offsets + best_d, best_idx = _CPT_HIT, None + for i in range(ot.n_stations): + for j in range(ot.n_waterlines): + d = _dist(pos, self._screen_pt(i, j)) + if d < best_d: + best_d, best_idx = d, (i, j) + return best_idx + + def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: + ot = self._hull.offsets + i, j = idx + sign = 1.0 if i >= ot.n_stations // 2 else -1.0 + wx, _ = self._s2w(pos.x(), pos.y()) + new_y = max(0.0, sign * wx) + # Limitar al doble de la manga para evitar explosiones + new_y = min(new_y, self._hull.beam) + ot.data[i, j] = new_y + + # ── Dibujo ──────────────────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) @@ -216,71 +323,74 @@ class BodyPlanViewer(_BaseViewer): 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íneas de agua — grilla horizontal ──────────────────────── + x_max = ot.max_half_breadth * 1.15 + for j, z in enumerate(ot.z_waterlines): + is_design = abs(z - T) < 1e-6 + if is_design: + p.setPen(QPen(_WL_DESIGN, 1.2, Qt.PenStyle.DashLine)) + else: + p.setPen(QPen(_WATERLINE.darker(160), 0.6, Qt.PenStyle.DotLine)) + p.drawLine(self._w2s(-x_max, z), self._w2s(x_max, z)) - # 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 + # Línea de flotación de diseño (más visible) + p.setPen(QPen(_WL_DESIGN, 1.5, Qt.PenStyle.DashLine)) p.drawLine(self._w2s(-x_max, T), self._w2s(x_max, T)) - # ── Dibujar secciones ────────────────────────────────────── + # ── Secciones ───────────────────────────────────────────────── for i in range(n): - # Progreso de AP a FP: proa a estribor, popa a babor - is_forward = i >= n // 2 + is_fwd = i >= n // 2 + is_mid = i == n // 2 - if is_forward: - pen = QPen(_SECTION, 1.2) # verde: mitad de proa (estribor) + if is_mid: + pen = QPen(_MIDSHIP, 2.5) + elif is_fwd: + pen = QPen(_SECTION, 1.4) 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) + pen = QPen(_SECTION_AFT, 1.4) 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 + sign = 1.0 if is_fwd else -1.0 path = QPainterPath() - started = False - for y, z in zip(y_arr, z_arr): + for k, (y, z) in enumerate(zip(y_arr, z_arr)): pt = self._w2s(sign * y, z) - if not started: + if k == 0: path.moveTo(pt) - started = True else: path.lineTo(pt) + # Cerrar en quilla + path.lineTo(self._w2s(0.0, 0.0)) p.drawPath(path) - # ── Ejes ────────────────────────────────────────────────── + # ── 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 + p.drawLine(self._w2s(-x_max, 0), self._w2s(x_max, 0)) # quilla + p.setPen(QPen(_AXIS, 0.8, Qt.PenStyle.DashLine)) + p.drawLine(self._w2s(0, 0), self._w2s(0, T * 1.15)) # eje crujía + + # ── Puntos de control ───────────────────────────────────────── + p.setRenderHint(QPainter.RenderHint.Antialiasing, True) + for i in range(n): + for j in range(ot.n_waterlines): + self._draw_control_point(p, self._screen_pt(i, j), (i, j)) self._draw_label(p, "BODY PLAN") p.end() # ───────────────────────────────────────────────────────────────────────────── -# 2. Profile Viewer — vista lateral +# 2. Profile Viewer — vista lateral (solo lectura) # ───────────────────────────────────────────────────────────────────────────── 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. + Muestra líneas de agua, perfil de cubierta y quilla. + No es editable (las z son constantes en la OffsetsTable). """ def _world_bbox(self) -> Optional[tuple]: @@ -290,10 +400,10 @@ class ProfileViewer(_BaseViewer): -self._hull.lpp * 0.05, -self._hull.draft * 0.15, self._hull.lpp * 1.05, - self._hull.draft * 1.25, + self._hull.draft * 1.30, ) - def paintEvent(self, event) -> None: # type: ignore[override] + def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) @@ -307,23 +417,24 @@ class ProfileViewer(_BaseViewer): T = self._hull.draft Lpp = self._hull.lpp - # ── Grilla de estaciones ─────────────────────────────────── + # ── 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)) + p.drawLine(self._w2s(x, -T * 0.1), self._w2s(x, T * 1.2)) - # ── Líneas de agua en perfil (ancho máximo a cada z) ──────── + # ── Líneas de agua en perfil ─────────────────────────────────── 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 + is_design = abs(z - T) < 1e-6 + if is_design: + p.setPen(QPen(_WL_DESIGN, 1.8)) + else: + frac = j / max(ot.n_waterlines - 1, 1) + color = QColor(_WATERLINE) + color.setAlphaF(0.40 + 0.50 * frac) + p.setPen(QPen(color, 0.9)) p.drawLine(self._w2s(0, z), self._w2s(Lpp, z)) - # ── Cubierta (z = puntal) ────────────────────────────────── + # ── Cubierta ────────────────────────────────────────────────── p.setPen(QPen(_DECK, 1.8)) path_deck = QPainterPath() for k, x in enumerate(ot.x_stations): @@ -334,24 +445,23 @@ class ProfileViewer(_BaseViewer): path_deck.lineTo(pt) p.drawPath(path_deck) - # ── Quilla ───────────────────────────────────────────────── + # ── Quilla ──────────────────────────────────────────────────── p.setPen(QPen(_KEEL, 2.0)) p.drawLine(self._w2s(0, 0), self._w2s(Lpp, 0)) - # ── Perpendiculares AP y FP ──────────────────────────────── + # ── Perpendiculares AP / 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") + _lbl = lambda text, x, z: p.drawText( + QRectF(self._w2s(x, z).x() - 14, self._w2s(x, z).y() - 8, 28, 14), + Qt.AlignmentFlag.AlignCenter, text + ) + _lbl("AP", 0, -T * 0.12) + _lbl("FP", Lpp, -T * 0.12) self._draw_label(p, "PERFIL LATERAL") p.end() @@ -365,7 +475,9 @@ 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. + + Edición: arrastra un punto de contorno (x[i], y[i][j]) en y para cambiar + la semi-manga de esa estación en esa línea de agua. """ def _world_bbox(self) -> Optional[tuple]: @@ -376,10 +488,37 @@ class PlanViewer(_BaseViewer): -self._hull.lpp * 0.05, -y_max * 0.15, self._hull.lpp * 1.05, - y_max * 1.20, + y_max * 1.25, ) - def paintEvent(self, event) -> None: # type: ignore[override] + # ── Edición ─────────────────────────────────────────────────────────────── + + def _screen_pt(self, i: int, j: int) -> QPointF: + ot = self._hull.offsets + return self._w2s(ot.x_stations[i], ot.data[i, j]) + + def _hit_test(self, pos: QPointF) -> Optional[tuple[int, int]]: + if self._hull is None: + return None + ot = self._hull.offsets + best_d, best_idx = _CPT_HIT, None + for i in range(ot.n_stations): + for j in range(ot.n_waterlines): + d = _dist(pos, self._screen_pt(i, j)) + if d < best_d: + best_d, best_idx = d, (i, j) + return best_idx + + def _apply_drag(self, pos: QPointF, idx: tuple[int, int]) -> None: + ot = self._hull.offsets + i, j = idx + _, wy = self._s2w(pos.x(), pos.y()) + new_y = max(0.0, min(wy, self._hull.beam)) + ot.data[i, j] = new_y + + # ── Dibujo ──────────────────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) self._draw_background(p) @@ -391,44 +530,57 @@ class PlanViewer(_BaseViewer): ot = self._hull.offsets T = self._hull.draft - - # ── Líneas de agua como contornos ────────────────────────── n_wl = ot.n_waterlines + + # ── Líneas de agua como contornos ───────────────────────────── 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 + frac = j / max(n_wl - 1, 1) - p.setPen(QPen(c, width)) + if is_design: + color = QColor(_WL_DESIGN) + color.setAlphaF(1.0) + width = 2.0 + else: + color = QColor(_WATERLINE) + color.setAlphaF(0.30 + 0.55 * frac) + width = 0.9 + + p.setPen(QPen(color, width)) path = QPainterPath() x_arr = ot.x_stations y_arr = ot.data[:, j] - started = False - for x, y in zip(x_arr, y_arr): + for k, (x, y) in enumerate(zip(x_arr, y_arr)): pt = self._w2s(x, y) - if not started: + if k == 0: path.moveTo(pt) - started = True else: path.lineTo(pt) p.drawPath(path) - # ── Eje de crujía ────────────────────────────────────────── + # ── 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), - ) + p.drawLine(self._w2s(0, 0), self._w2s(self._hull.lpp, 0)) - # ── Estaciones (líneas verticales tenues) ────────────────── + # ── Estaciones ──────────────────────────────────────────────── 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)) + p.drawLine(self._w2s(x, 0), self._w2s(x, y_max * 1.15)) + + # ── Puntos de control ───────────────────────────────────────── + for i in range(ot.n_stations): + for j in range(n_wl): + self._draw_control_point(p, self._screen_pt(i, j), (i, j)) self._draw_label(p, "VISTA DE PLANTA") p.end() + + +# ───────────────────────────────────────────────────────────────────────────── +# Utilidad interna +# ───────────────────────────────────────────────────────────────────────────── + +def _dist(a: QPointF, b: QPointF) -> float: + return math.hypot(a.x() - b.x(), a.y() - b.y()) diff --git a/arshipdesign/utils/settings.py b/arshipdesign/utils/settings.py index 66dc946..a9cc038 100644 --- a/arshipdesign/utils/settings.py +++ b/arshipdesign/utils/settings.py @@ -9,7 +9,7 @@ from __future__ import annotations from PySide6.QtCore import QSettings APP_NAME = "ARShipDesign" -ORG_NAME = "AlvaroRodriguez" +ORG_NAME = "AlvaroRomero" # Claves de configuración KEY_LANGUAGE = "ui/language" diff --git a/main.py b/main.py index 7902f28..fe6563c 100644 --- a/main.py +++ b/main.py @@ -40,7 +40,7 @@ def main() -> int: app = QApplication(sys.argv) app.setApplicationName("AR-ShipDesign") - app.setOrganizationName("AlvaroRodriguez") + app.setOrganizationName("AlvaroRomero") app.setApplicationVersion("0.1.0") # Fuente por defecto diff --git a/pyproject.toml b/pyproject.toml index b3c0c77..6860748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.backends.legacy:build" name = "arshipdesign" version = "0.1.0" description = "Software profesional de diseño naval — AR-ShipDesign" -authors = [{ name = "Álvaro Rodríguez" }] +authors = [{ name = "Álvaro Romero" }] license = { file = "LICENSE.txt" } readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_module2_hydrostatics.py b/tests/test_module2_hydrostatics.py new file mode 100644 index 0000000..53017de --- /dev/null +++ b/tests/test_module2_hydrostatics.py @@ -0,0 +1,534 @@ +""" +Tests Módulo 2 — Motor de Curvas Hidrostáticas. + +Verifica: + - UprightHydrostatics a calado único vs solución analítica Wigley + - HydrostaticCurves: barrido de calados, monotonicidad, coeficientes + - Exportación CSV y dict + - Interpolación at_draft() + - IACS Rec.34 V024–V036: verificación y trazabilidad + +Autor: Álvaro Romero | Módulo 2 — AR-ShipDesign +IACS Rec.34 par.4.3, 4.4, 4.5 — verificación analítica, convergencia y simetría. +""" +from __future__ import annotations + +import json +import math + +import numpy as np +import pytest + +from arshipdesign.core.hull import Hull +from arshipdesign.hydrostatics import ( + HydrostaticCurves, + UprightHydrostatics, + compute_upright, + CSV_HEADERS, +) +from arshipdesign.parametric import generate_hull, HullFamily + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def wigley_hull() -> Hull: + return Hull.from_wigley(lpp=15.0, beam=4.0, draft=1.60, + n_stations=41, n_waterlines=21) + + +@pytest.fixture(scope="module") +def wigley_upright(wigley_hull: Hull) -> UprightHydrostatics: + return compute_upright(wigley_hull, draft=1.60) + + +@pytest.fixture(scope="module") +def wigley_curves(wigley_hull: Hull) -> HydrostaticCurves: + return HydrostaticCurves.compute(wigley_hull, n_points=20) + + +@pytest.fixture(scope="module") +def displacement_hull() -> Hull: + return generate_hull(HullFamily.DISPLACEMENT, lpp=20.0, beam=6.0, + draft=2.40, depth=3.20, cb=0.55) + + +# --------------------------------------------------------------------------- +# 1. UprightHydrostatics — calado único (Wigley analítico) +# --------------------------------------------------------------------------- + +class TestUprightHydrostaticsWigley: + """IACS Rec.34 §4.3 — verificación analítica.""" + + # Constantes analíticas para Wigley Lpp=15, B=4, T=1.60 + L, B, T = 15.0, 4.0, 1.60 + + @property + def V_ana(self): return (4.0/9.0) * self.L * self.B * self.T + @property + def Cb_ana(self): return 4.0/9.0 + @property + def Awp_ana(self): return (2.0/3.0) * self.L * self.B + @property + def Cw_ana(self): return 2.0/3.0 + @property + def KB_ana(self): return 5.0 * self.T / 8.0 + @property + def IT_ana(self): + # IT = (2/3) * (B/2)^3 * (L/2) * ∫₋₁¹(1-u²)³du = 32/35 + return (2.0/3.0) * (self.B/2)**3 * (self.L/2) * (32.0/35.0) + @property + def Cm_ana(self): return 2.0/3.0 + + def test_volume_analytic(self, wigley_upright): + assert abs(wigley_upright.volume - self.V_ana) / self.V_ana < 1e-6 + + def test_displacement_from_volume(self, wigley_upright): + rho = 1025.0 + expected = wigley_upright.volume * rho / 1000.0 + assert abs(wigley_upright.displacement - expected) < 1e-9 + + def test_awp_analytic(self, wigley_upright): + assert abs(wigley_upright.awp - self.Awp_ana) / self.Awp_ana < 1e-6 + + def test_cb_analytic(self, wigley_upright): + assert abs(wigley_upright.cb - self.Cb_ana) < 1e-5 + + def test_cw_analytic(self, wigley_upright): + assert abs(wigley_upright.cw - self.Cw_ana) < 1e-5 + + def test_kb_analytic(self, wigley_upright): + """IACS Rec.34 §4.3 — KB = 5T/8 para el casco Wigley.""" + assert abs(wigley_upright.kb - self.KB_ana) / self.KB_ana < 1e-5 + + def test_it_analytic(self, wigley_upright): + """IT = (2/3)(B/2)³(L/2)(32/35) para el casco Wigley.""" + assert abs(wigley_upright.it - self.IT_ana) / self.IT_ana < 0.001 # < 0.1% + + def test_bmt_equals_it_over_v(self, wigley_upright): + expected = wigley_upright.it / wigley_upright.volume + assert abs(wigley_upright.bmt - expected) < 1e-9 + + def test_kmt_equals_kb_plus_bmt(self, wigley_upright): + assert abs(wigley_upright.kmt - (wigley_upright.kb + wigley_upright.bmt)) < 1e-9 + + def test_bml_equals_il_over_v(self, wigley_upright): + expected = wigley_upright.il / wigley_upright.volume + assert abs(wigley_upright.bml - expected) < 1e-9 + + def test_kml_equals_kb_plus_bml(self, wigley_upright): + assert abs(wigley_upright.kml - (wigley_upright.kb + wigley_upright.bml)) < 1e-9 + + def test_lcb_symmetry(self, wigley_upright, wigley_hull): + """IACS Rec.34 §4.5 — LCB = Lpp/2 para cascos simétricos.""" + assert abs(wigley_upright.lcb - wigley_hull.lpp / 2.0) < 1e-4 + + def test_lcf_symmetry(self, wigley_upright, wigley_hull): + """IACS Rec.34 §4.5 — LCF = Lpp/2 para cascos simétricos.""" + assert abs(wigley_upright.lcf - wigley_hull.lpp / 2.0) < 1e-4 + + def test_cm_analytic(self, wigley_upright): + assert abs(wigley_upright.cm - self.Cm_ana) < 1e-4 + + def test_cp_from_cb_and_cm(self, wigley_upright): + """Cp = Cb / Cm — relación de identidad fundamental.""" + expected = wigley_upright.cb / wigley_upright.cm + assert abs(wigley_upright.cp - expected) < 1e-4 + + def test_tpc_positive_and_consistent(self, wigley_upright): + rho = 1025.0 + expected = wigley_upright.awp * rho / 100_000.0 + assert wigley_upright.tpc > 0.0 + assert abs(wigley_upright.tpc - expected) < 1e-9 + + def test_mct_positive(self, wigley_upright): + assert wigley_upright.mct >= 0.0 + + def test_zero_draft_returns_zeros(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=0.0) + assert uh.volume == 0.0 + assert uh.displacement == 0.0 + assert uh.awp == 0.0 + + def test_cb_bounded(self, wigley_upright): + assert 0.0 < wigley_upright.cb <= 1.0 + + def test_cw_bounded(self, wigley_upright): + assert 0.0 < wigley_upright.cw <= 1.0 + + def test_cm_bounded(self, wigley_upright): + assert 0.0 < wigley_upright.cm <= 1.0 + + def test_cp_bounded(self, wigley_upright): + assert 0.0 < wigley_upright.cp <= 1.0 + + +# --------------------------------------------------------------------------- +# 2. compute_upright vs métodos de Hull +# (IACS Rec.34 §4.3 — consistencia entre métodos) +# --------------------------------------------------------------------------- + +class TestUprightVsHullMethods: + """Los resultados de compute_upright deben coincidir con los métodos del Hull.""" + + def test_volume_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.volume - wigley_hull.volume_of_displacement()) < 1e-9 + + def test_displacement_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.displacement - wigley_hull.displacement_tonnes()) < 1e-9 + + def test_awp_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.awp - wigley_hull.waterplane_area()) < 1e-9 + + def test_kb_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.kb - wigley_hull.vcb()) < 1e-9 + + def test_kmt_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.kmt - wigley_hull.km_transverse()) < 1e-9 + + def test_tpc_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.tpc - wigley_hull.tpc()) < 1e-9 + + def test_cb_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.cb - wigley_hull.block_coefficient()) < 1e-9 + + def test_cw_matches_hull(self, wigley_hull): + uh = compute_upright(wigley_hull, draft=1.60) + assert abs(uh.cw - wigley_hull.waterplane_coefficient()) < 1e-9 + + def test_partial_draft_volume(self, wigley_hull): + """compute_upright a T parcial debe coincidir con Hull a ese T.""" + T_partial = 0.80 + uh = compute_upright(wigley_hull, draft=T_partial) + hull_v = wigley_hull.volume_of_displacement(T_partial) + assert abs(uh.volume - hull_v) < 1e-9 + + def test_rho_scaling(self, wigley_hull): + """Desplazamiento debe escalar linealmente con rho.""" + uh_salt = compute_upright(wigley_hull, draft=1.60, rho=1025.0) + uh_fresh = compute_upright(wigley_hull, draft=1.60, rho=1000.0) + ratio = uh_salt.displacement / uh_fresh.displacement + assert abs(ratio - 1025.0 / 1000.0) < 1e-9 + + def test_tpc_scales_with_rho(self, wigley_hull): + uh_salt = compute_upright(wigley_hull, draft=1.60, rho=1025.0) + uh_fresh = compute_upright(wigley_hull, draft=1.60, rho=1000.0) + ratio = uh_salt.tpc / uh_fresh.tpc + assert abs(ratio - 1025.0 / 1000.0) < 1e-9 + + +# --------------------------------------------------------------------------- +# 3. HydrostaticCurves — barrido de calados +# --------------------------------------------------------------------------- + +class TestHydrostaticCurves: + def test_default_n_points(self, wigley_hull): + c = HydrostaticCurves.compute(wigley_hull) + assert len(c) == 20 + + def test_custom_n_points(self, wigley_hull): + c = HydrostaticCurves.compute(wigley_hull, n_points=10) + assert len(c) == 10 + + def test_min_n_points_enforced(self, wigley_hull): + """n_points < 5 se eleva a 5.""" + c = HydrostaticCurves.compute(wigley_hull, n_points=2) + assert len(c) == 5 + + def test_design_draft_is_last_point(self, wigley_hull, wigley_curves): + assert abs(wigley_curves.points[-1].draft - wigley_hull.draft) < 1e-12 + + def test_design_draft_matches_compute_upright(self, wigley_hull, wigley_curves): + """El último punto debe coincidir con compute_upright al calado de diseño.""" + uh = compute_upright(wigley_hull, wigley_hull.draft) + last = wigley_curves.points[-1] + assert abs(last.volume - uh.volume) < 1e-9 + assert abs(last.awp - uh.awp) < 1e-9 + assert abs(last.kmt - uh.kmt) < 1e-9 + + def test_hull_name_preserved(self, wigley_hull, wigley_curves): + assert wigley_curves.hull_name == wigley_hull.name + + def test_lpp_beam_preserved(self, wigley_hull, wigley_curves): + assert wigley_curves.lpp == wigley_hull.lpp + assert wigley_curves.beam == wigley_hull.beam + + def test_getitem(self, wigley_curves): + p = wigley_curves[0] + assert isinstance(p, UprightHydrostatics) + + def test_iter(self, wigley_curves): + pts = list(wigley_curves) + assert len(pts) == len(wigley_curves) + + def test_repr_contains_hull_name(self, wigley_curves): + assert "Wigley" in repr(wigley_curves) + + +# --------------------------------------------------------------------------- +# 4. Monotonicidad de las curvas +# (IACS Rec.34 §4.4 — verificación de tendencias) +# --------------------------------------------------------------------------- + +class TestCurvesMonotonicity: + """V024–V028: las curvas hidrostáticas deben ser monótonamente crecientes.""" + + def test_v024_displacement_monotone(self, wigley_curves): + """V024 — Δ(T) es monótonamente creciente.""" + d = np.diff(wigley_curves.displacements) + assert np.all(d > 0), f"Δ no es monótono: diff mín = {d.min():.6f}" + + def test_v025_volume_monotone(self, wigley_curves): + """V025 — V(T) es monótonamente creciente.""" + d = np.diff(wigley_curves.volumes) + assert np.all(d > 0), f"V no es monótono: diff mín = {d.min():.6f}" + + def test_v026_kb_monotone(self, wigley_curves): + """V026 — KB(T) es monótonamente creciente.""" + d = np.diff(wigley_curves.kb_values) + assert np.all(d > 0), f"KB no es monótono: diff mín = {d.min():.6f}" + + def test_v027_awp_monotone(self, wigley_curves): + """V027 — Awp(T) es monótonamente creciente (para el casco Wigley).""" + d = np.diff(wigley_curves.awp_values) + assert np.all(d > 0), f"Awp no es monótono: diff mín = {d.min():.6f}" + + def test_v028_tpc_monotone(self, wigley_curves): + """V028 — TPC(T) es monótonamente creciente.""" + d = np.diff(wigley_curves.tpc_values) + assert np.all(d > 0), f"TPC no es monótono: diff mín = {d.min():.6f}" + + def test_cb_within_bounds_all_drafts(self, wigley_curves): + """Cb ∈ (0, 1) para todos los calados.""" + cb = wigley_curves.cb_values + assert np.all(cb > 0) + assert np.all(cb <= 1.0) + + def test_cw_within_bounds_all_drafts(self, wigley_curves): + """Cw ∈ (0, 1] para todos los calados.""" + cw = wigley_curves.cw_values + assert np.all(cw > 0) + assert np.all(cw <= 1.0) + + def test_all_families_monotone_displacement(self): + """V029 — Δ monótono para las 5 familias paramétricas.""" + for family in HullFamily: + hull = generate_hull(family, lpp=12.0, beam=3.5, + draft=1.20, depth=2.00) + c = HydrostaticCurves.compute(hull, n_points=10) + d = np.diff(c.displacements) + assert np.all(d > 0), \ + f"{family.value}: Δ no es monótono (diff mín={d.min():.6f})" + + +# --------------------------------------------------------------------------- +# 5. Interpolación at_draft() +# --------------------------------------------------------------------------- + +class TestAtDraft: + def test_at_design_draft_matches_last_point(self, wigley_hull, wigley_curves): + T_d = wigley_hull.draft + interp = wigley_curves.at_draft(T_d) + last = wigley_curves.points[-1] + assert abs(interp.volume - last.volume) < 1e-9 + assert abs(interp.kmt - last.kmt) < 1e-9 + + def test_at_min_draft_matches_first_point(self, wigley_curves): + T_min = wigley_curves.points[0].draft + interp = wigley_curves.at_draft(T_min) + first = wigley_curves.points[0] + assert abs(interp.volume - first.volume) < 1e-9 + + def test_clamp_below_min(self, wigley_curves): + T_min = wigley_curves.points[0].draft + interp = wigley_curves.at_draft(-1.0) + first = wigley_curves.points[0] + assert abs(interp.volume - first.volume) < 1e-9 + + def test_clamp_above_max(self, wigley_curves): + T_max = wigley_curves.points[-1].draft + interp = wigley_curves.at_draft(T_max + 5.0) + last = wigley_curves.points[-1] + assert abs(interp.volume - last.volume) < 1e-9 + + def test_mid_draft_between_bounds(self, wigley_curves): + """Valor interpolado intermedio debe estar entre los extremos.""" + T_mid = (wigley_curves.drafts[0] + wigley_curves.drafts[-1]) / 2.0 + interp = wigley_curves.at_draft(T_mid) + assert wigley_curves.points[0].volume < interp.volume < wigley_curves.points[-1].volume + + def test_returns_upright_hydrostatics_instance(self, wigley_curves): + assert isinstance(wigley_curves.at_draft(1.0), UprightHydrostatics) + + +# --------------------------------------------------------------------------- +# 6. Vectorized array properties +# --------------------------------------------------------------------------- + +class TestArrayProperties: + def test_drafts_length(self, wigley_curves): + assert len(wigley_curves.drafts) == len(wigley_curves) + + def test_volumes_length(self, wigley_curves): + assert len(wigley_curves.volumes) == len(wigley_curves) + + def test_displacements_positive(self, wigley_curves): + assert np.all(wigley_curves.displacements > 0) + + def test_kb_positive(self, wigley_curves): + assert np.all(wigley_curves.kb_values > 0) + + def test_kmt_positive(self, wigley_curves): + assert np.all(wigley_curves.kmt_values > 0) + + def test_tpc_positive(self, wigley_curves): + assert np.all(wigley_curves.tpc_values > 0) + + def test_mct_non_negative(self, wigley_curves): + assert np.all(wigley_curves.mct_values >= 0) + + def test_all_array_attrs_same_length(self, wigley_curves): + n = len(wigley_curves) + for attr in ("drafts", "volumes", "displacements", "awp_values", + "lcb_values", "lcf_values", "kb_values", + "bmt_values", "bml_values", "kmt_values", "kml_values", + "tpc_values", "mct_values", + "cb_values", "cw_values", "cm_values", "cp_values"): + arr = getattr(wigley_curves, attr) + assert len(arr) == n, f"{attr} tiene longitud {len(arr)} ≠ {n}" + + +# --------------------------------------------------------------------------- +# 7. Exportación CSV y dict +# --------------------------------------------------------------------------- + +class TestExport: + def test_csv_header_count(self, wigley_curves): + lines = wigley_curves.to_csv_lines() + assert len(lines) == len(wigley_curves) + 1 # header + datos + + def test_csv_header_matches_constant(self, wigley_curves): + lines = wigley_curves.to_csv_lines() + assert lines[0] == ",".join(CSV_HEADERS) + + def test_csv_row_column_count(self, wigley_curves): + lines = wigley_curves.to_csv_lines() + n_cols = len(CSV_HEADERS) + for row in lines[1:]: + assert len(row.split(",")) == n_cols + + def test_csv_first_value_is_draft(self, wigley_curves): + lines = wigley_curves.to_csv_lines() + first_draft = float(lines[1].split(",")[0]) + assert abs(first_draft - wigley_curves.points[0].draft) < 1e-3 + + def test_csv_semicolon_separator(self, wigley_curves): + lines = wigley_curves.to_csv_lines(sep=";") + assert ";" in lines[0] + assert "," not in lines[0] + + def test_csv_decimal_comma(self, wigley_curves): + lines = wigley_curves.to_csv_lines(sep=";", decimal=",") + # Los números deben usar coma decimal + row_parts = lines[1].split(";") + assert "," in row_parts[0] # e.g. "0,1600" + + def test_to_dict_has_required_keys(self, wigley_curves): + d = wigley_curves.to_dict() + for key in ("hull_name", "lpp", "beam", "design_draft", "rho", + "headers", "points"): + assert key in d, f"Falta clave '{key}' en to_dict()" + + def test_to_dict_json_serializable(self, wigley_curves): + d = wigley_curves.to_dict() + txt = json.dumps(d) + assert len(txt) > 100 + + def test_to_dict_points_count(self, wigley_curves): + d = wigley_curves.to_dict() + assert len(d["points"]) == len(wigley_curves) + + def test_to_dict_first_point_keys(self, wigley_curves): + d = wigley_curves.to_dict() + pt = d["points"][0] + for key in ("T", "V", "Delta", "Awp", "LCB", "LCF", "KB", + "IT", "IL", "BMT", "BML", "KMT", "KML", + "TPC", "MCT", "Cb", "Cw", "Cm", "Cp"): + assert key in pt, f"Falta clave '{key}' en points[0]" + + +# --------------------------------------------------------------------------- +# 8. IACS Rec.34 — verificaciones V030–V036 +# --------------------------------------------------------------------------- + +class TestIACSVerification: + """Verificaciones adicionales según IACS Rec.34 §4.""" + + def test_v030_wigley_cb_accuracy_at_design_draft(self, wigley_curves): + """V030 — Cb en calado de diseño ≈ 4/9 (error < 0.1%).""" + cb_last = wigley_curves.points[-1].cb + assert abs(cb_last - 4.0/9.0) < 0.001, \ + f"Cb={cb_last:.6f} ≠ 4/9={4/9:.6f}" + + def test_v031_wigley_cw_at_design_draft(self, wigley_curves): + """V031 — Cw en calado de diseño ≈ 2/3 (error < 0.1%).""" + cw_last = wigley_curves.points[-1].cw + assert abs(cw_last - 2.0/3.0) < 0.001, \ + f"Cw={cw_last:.6f} ≠ 2/3={2/3:.6f}" + + def test_v032_wigley_symmetry_all_drafts(self, wigley_hull, wigley_curves): + """V032 — LCB = LCF = Lpp/2 en todos los calados (simetría).""" + L_mid = wigley_hull.lpp / 2.0 + for p in wigley_curves: + assert abs(p.lcb - L_mid) < 1e-3, \ + f"T={p.draft:.3f}: LCB={p.lcb:.4f} ≠ {L_mid}" + assert abs(p.lcf - L_mid) < 1e-3, \ + f"T={p.draft:.3f}: LCF={p.lcf:.4f} ≠ {L_mid}" + + def test_v033_kmt_greater_than_kb(self, wigley_curves): + """V033 — KMT > KB en todos los calados (BMT > 0).""" + for p in wigley_curves: + assert p.kmt > p.kb, \ + f"T={p.draft:.3f}: KMT={p.kmt:.4f} ≤ KB={p.kb:.4f}" + + def test_v034_kml_greater_than_kmt(self, wigley_curves): + """V034 — KML > KMT en todos los calados (BML >> BMT).""" + for p in wigley_curves: + assert p.kml > p.kmt, \ + f"T={p.draft:.3f}: KML={p.kml:.4f} ≤ KMT={p.kmt:.4f}" + + def test_v035_cp_identity(self, wigley_curves): + """V035 — Cp = Cb / Cm (identidad de coeficientes).""" + for p in wigley_curves: + if p.cm > 0.01: + expected = p.cb / p.cm + assert abs(p.cp - expected) < 1e-4, \ + f"T={p.draft:.3f}: Cp={p.cp:.4f} ≠ Cb/Cm={expected:.4f}" + + def test_v036_displacement_hull_monotone(self, displacement_hull): + """V036 — desplazamiento monótono para casco de desplazamiento parametrico.""" + c = HydrostaticCurves.compute(displacement_hull, n_points=15) + d = np.diff(c.displacements) + assert np.all(d > 0), \ + f"DISPLACEMENT: Δ no monótono (diff mín={d.min():.6f})" + + def test_v037_mesh_convergence_volume(self, wigley_hull): + """V037 — IACS §4.4: convergencia de malla. V con 41 sta ≈ V con 81 sta.""" + hull_fine = Hull.from_wigley( + lpp=15.0, beam=4.0, draft=1.60, + n_stations=81, n_waterlines=41 + ) + uh_coarse = compute_upright(wigley_hull, 1.60) + uh_fine = compute_upright(hull_fine, 1.60) + # Convergencia < 0.1% + diff_pct = abs(uh_coarse.volume - uh_fine.volume) / uh_fine.volume * 100 + assert diff_pct < 0.1, f"Convergencia de malla: error V = {diff_pct:.4f}%"