diff --git a/arshipdesign/core/hull.py b/arshipdesign/core/hull.py index 17e1074..fe1441d 100644 --- a/arshipdesign/core/hull.py +++ b/arshipdesign/core/hull.py @@ -389,6 +389,76 @@ class Hull: mesh = pv.PolyData(all_pts, faces_arr) return mesh.triangulate() + # ------------------------------------------------------------------ + # Serialización JSON (.arsd) + # ------------------------------------------------------------------ + + def to_dict(self) -> dict: + """Serializa el Hull a un diccionario JSON-serializable. + + Formato interno: ``hull_v1``. + Los arrays numpy se convierten a listas de Python para compatibilidad + con json.dumps sin dependencias adicionales. + + IACS Rec.34 §6 — trazabilidad de datos de entrada (offsets guardados + fielmente con la precisión de la tabla original). + """ + ot = self.offsets + return { + "format": "hull_v1", + "name": self.name, + "lpp": self.lpp, + "beam": self.beam, + "depth": self.depth, + "draft": self.draft, + "offsets": { + "lpp": ot.lpp, + "beam": ot.beam, + "draft": ot.draft, + "x_stations": ot.x_stations.tolist(), + "z_waterlines": ot.z_waterlines.tolist(), + "station_labels": list(ot.station_labels), + "data": ot.data.tolist(), # (n_sta, n_wl) + }, + } + + @classmethod + def from_dict(cls, data: dict) -> "Hull": + """Deserializa un Hull desde un diccionario (leído de un archivo .arsd). + + Compatible con los formatos ``hull_v1`` y datos heredados sin versión. + + Parameters + ---------- + data : dict + Diccionario generado por ``Hull.to_dict()``. + + Raises + ------ + KeyError + Si faltan campos obligatorios. + ValueError + Si las dimensiones de la tabla son inconsistentes. + """ + od = data["offsets"] + offsets = OffsetsTable( + x_stations = np.array(od["x_stations"], dtype=float), + z_waterlines = np.array(od["z_waterlines"], dtype=float), + data = np.array(od["data"], dtype=float), + station_labels = od.get("station_labels", []), + lpp = float(od["lpp"]), + beam = float(od["beam"]), + draft = float(od["draft"]), + ) + return cls( + name = str(data["name"]), + lpp = float(data["lpp"]), + beam = float(data["beam"]), + depth = float(data["depth"]), + draft = float(data["draft"]), + offsets = offsets, + ) + # ------------------------------------------------------------------ # Dunder # ------------------------------------------------------------------ diff --git a/arshipdesign/core/project.py b/arshipdesign/core/project.py index 9e61680..0cb92f3 100644 --- a/arshipdesign/core/project.py +++ b/arshipdesign/core/project.py @@ -248,6 +248,45 @@ class Project: """Marca el proyecto como modificado.""" self._is_modified = True + # ────────────────────────────────────────────── + # HULL + # ────────────────────────────────────────────── + + @property + def hull(self): + """Hull activo del proyecto, o None si no hay geometría guardada. + + El Hull se deserializa bajo demanda desde ship_data["hull"]. + La primera llamada realiza la conversión; las siguientes también + (el objeto no se cachea para mantener la coherencia con ediciones + posteriores de ship_data). + + Returns + ------- + Hull | None + """ + hull_data = self.ship_data.get("hull") + if not hull_data or hull_data.get("format") not in ("hull_v1",): + return None + try: + from arshipdesign.core.hull import Hull + return Hull.from_dict(hull_data) + except Exception as exc: + logger.warning("No se pudo deserializar el Hull: %s", exc) + return None + + def set_hull(self, hull) -> None: + """Serializa el Hull en ship_data y marca el proyecto como modificado. + + Parameters + ---------- + hull : Hull + El casco a guardar. Se serializa llamando a ``hull.to_dict()``. + """ + self.ship_data["hull"] = hull.to_dict() + self._is_modified = True + logger.debug("Hull '%s' guardado en proyecto '%s'", hull.name, self.name) + def __repr__(self) -> str: return f"Project(name={self.name!r}, path={self.path})" diff --git a/arshipdesign/ui/main_window.py b/arshipdesign/ui/main_window.py index ffda87d..047b97c 100644 --- a/arshipdesign/ui/main_window.py +++ b/arshipdesign/ui/main_window.py @@ -1224,6 +1224,7 @@ class MainWindow(QMainWindow): self._on_project_loaded() if hull is not None: self._current_hull = hull + self._project.set_hull(hull) # persistir en ship_data self._load_hull_viewers(hull) self.statusBar().showMessage( f"Nuevo proyecto: {self._project.name}" @@ -1282,6 +1283,12 @@ class MainWindow(QMainWindow): def _on_project_loaded(self) -> None: self._update_title() self._layers_panel.set_project(self._project) + # Restaurar Hull si el proyecto contiene geometría guardada + hull = self._project.hull + if hull is not None: + self._current_hull = hull + self._load_hull_viewers(hull) + logger.info("Hull '%s' restaurado desde proyecto", hull.name) def _load_hull_viewers(self, hull, *, _skip_offsets_editor: bool = False) -> None: """Carga el casco en todos los visores (2D, 3D, offsets) y actualiza hidrostáticos. @@ -1306,8 +1313,10 @@ class MainWindow(QMainWindow): self._update_hydrostatics(hull) def _on_hull_changed_from_editor(self, hull) -> None: - """Slot: el editor de offsets reconstruyo el Hull — propagar a los demas visores.""" + """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._load_hull_viewers(hull, _skip_offsets_editor=True) self.statusBar().showMessage(f"Offsets actualizados — {hull.name}") diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..237339e --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,257 @@ +""" +Tests Modulo 1 -- Serializacion Hull / Project (.arsd). + +Verifica que: + - Hull.to_dict() produce un dict JSON-serializable + - Hull.from_dict(Hull.to_dict(h)) recupera el mismo Hull bit a bit + - Project.set_hull() / Project.hull guarda y restaura el Hull + - Project.save() / Project.load() persiste el Hull en el ZIP .arsd + - Proyectos sin Hull (legados o nuevos vacios) cargan sin error + +Autor: Alvaro Romero | Modulo 1 -- AR-ShipDesign +IACS Rec.34 par.6 -- trazabilidad de datos de entrada. +""" +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +from arshipdesign.core.hull import Hull +from arshipdesign.core.offsets import OffsetsTable +from arshipdesign.core.project import Project +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=21, n_waterlines=11) + + +@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. Hull.to_dict +# --------------------------------------------------------------------------- + +class TestHullToDict: + def test_format_key(self, wigley_hull: Hull) -> None: + d = wigley_hull.to_dict() + assert d["format"] == "hull_v1" + + def test_required_scalar_keys(self, wigley_hull: Hull) -> None: + d = wigley_hull.to_dict() + for key in ("name", "lpp", "beam", "depth", "draft"): + assert key in d, f"Falta clave '{key}' en Hull.to_dict()" + + def test_offsets_sub_dict(self, wigley_hull: Hull) -> None: + od = wigley_hull.to_dict()["offsets"] + for key in ("lpp", "beam", "draft", "x_stations", "z_waterlines", + "station_labels", "data"): + assert key in od, f"Falta clave '{key}' en offsets dict" + + def test_json_serializable(self, wigley_hull: Hull) -> None: + """El dict debe poder pasarse por json.dumps sin error.""" + d = wigley_hull.to_dict() + txt = json.dumps(d) + assert len(txt) > 100 + + def test_data_shape(self, wigley_hull: Hull) -> None: + od = wigley_hull.to_dict()["offsets"] + n_sta = wigley_hull.offsets.n_stations + n_wl = wigley_hull.offsets.n_waterlines + assert len(od["data"]) == n_sta + assert len(od["data"][0]) == n_wl + + def test_values_preserved(self, wigley_hull: Hull) -> None: + d = wigley_hull.to_dict() + assert abs(d["lpp"] - wigley_hull.lpp) < 1e-12 + assert abs(d["draft"] - wigley_hull.draft) < 1e-12 + assert d["name"] == wigley_hull.name + + +# --------------------------------------------------------------------------- +# 2. Hull.from_dict round-trip +# --------------------------------------------------------------------------- + +class TestHullFromDict: + def test_roundtrip_dimensions(self, wigley_hull: Hull) -> None: + h2 = Hull.from_dict(wigley_hull.to_dict()) + assert abs(h2.lpp - wigley_hull.lpp) < 1e-12 + assert abs(h2.beam - wigley_hull.beam) < 1e-12 + assert abs(h2.depth - wigley_hull.depth) < 1e-12 + assert abs(h2.draft - wigley_hull.draft) < 1e-12 + assert h2.name == wigley_hull.name + + def test_roundtrip_offsets_shape(self, wigley_hull: Hull) -> None: + h2 = Hull.from_dict(wigley_hull.to_dict()) + assert h2.offsets.n_stations == wigley_hull.offsets.n_stations + assert h2.offsets.n_waterlines == wigley_hull.offsets.n_waterlines + + def test_roundtrip_offsets_values(self, wigley_hull: Hull) -> None: + h2 = Hull.from_dict(wigley_hull.to_dict()) + np.testing.assert_array_almost_equal( + h2.offsets.data, wigley_hull.offsets.data, decimal=12 + ) + + def test_roundtrip_hydrostatics(self, wigley_hull: Hull) -> None: + h2 = Hull.from_dict(wigley_hull.to_dict()) + assert abs(h2.volume_of_displacement() - wigley_hull.volume_of_displacement()) < 1e-9 + assert abs(h2.block_coefficient() - wigley_hull.block_coefficient()) < 1e-12 + assert abs(h2.km_transverse() - wigley_hull.km_transverse()) < 1e-9 + + def test_roundtrip_station_labels(self, wigley_hull: Hull) -> None: + h2 = Hull.from_dict(wigley_hull.to_dict()) + assert h2.offsets.station_labels == wigley_hull.offsets.station_labels + + def test_from_dict_displacement_hull(self, displacement_hull: Hull) -> None: + h2 = Hull.from_dict(displacement_hull.to_dict()) + assert abs(h2.lpp - displacement_hull.lpp) < 1e-9 + assert h2.offsets.n_stations == displacement_hull.offsets.n_stations + + def test_missing_key_raises(self, wigley_hull: Hull) -> None: + d = wigley_hull.to_dict() + del d["offsets"]["x_stations"] + with pytest.raises((KeyError, Exception)): + Hull.from_dict(d) + + +# --------------------------------------------------------------------------- +# 3. Project.set_hull / Project.hull property +# --------------------------------------------------------------------------- + +class TestProjectHull: + def test_new_project_has_no_hull(self) -> None: + proj = Project.new("Sin casco") + assert proj.hull is None + + def test_set_hull_stores_format(self, wigley_hull: Hull) -> None: + proj = Project.new("Test") + proj.set_hull(wigley_hull) + assert proj.ship_data["hull"]["format"] == "hull_v1" + + def test_set_hull_marks_modified(self, wigley_hull: Hull) -> None: + proj = Project.new("Test") + proj._is_modified = False # reset manually + proj.set_hull(wigley_hull) + assert proj.is_modified + + def test_hull_property_returns_hull(self, wigley_hull: Hull) -> None: + proj = Project.new("Test") + proj.set_hull(wigley_hull) + h = proj.hull + assert h is not None + assert abs(h.lpp - wigley_hull.lpp) < 1e-9 + + def test_hull_property_preserves_hydrostatics(self, wigley_hull: Hull) -> None: + proj = Project.new("Test") + proj.set_hull(wigley_hull) + h = proj.hull + assert abs(h.block_coefficient() - wigley_hull.block_coefficient()) < 1e-9 + + def test_corrupt_hull_data_returns_none(self) -> None: + proj = Project.new("Test") + proj.ship_data["hull"] = {"format": "unknown_v99", "bad": True} + assert proj.hull is None + + +# --------------------------------------------------------------------------- +# 4. Project.save / Project.load (.arsd round-trip) +# --------------------------------------------------------------------------- + +class TestProjectSaveLoad: + def test_save_and_load_preserves_hull(self, wigley_hull: Hull) -> None: + proj = Project.new("Velero de prueba") + proj.set_hull(wigley_hull) + + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "velero.arsd" + proj.save(p) + assert p.exists() + assert p.stat().st_size > 500 # no vacio + + proj2 = Project.load(p) + h2 = proj2.hull + assert h2 is not None + assert h2.name == wigley_hull.name + assert abs(h2.lpp - wigley_hull.lpp) < 1e-9 + np.testing.assert_array_almost_equal( + h2.offsets.data, wigley_hull.offsets.data, decimal=12 + ) + + def test_save_and_load_no_hull(self) -> None: + """Un proyecto sin Hull debe cargarse sin error y retornar hull=None.""" + proj = Project.new("Proyecto vacio") + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "vacio.arsd" + proj.save(p) + proj2 = Project.load(p) + assert proj2.hull is None + + def test_file_is_valid_zip(self, wigley_hull: Hull) -> None: + import zipfile + proj = Project.new("Test ZIP") + proj.set_hull(wigley_hull) + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "test.arsd" + proj.save(p) + assert zipfile.is_zipfile(p) + + def test_ship_json_inside_zip(self, wigley_hull: Hull) -> None: + import zipfile + proj = Project.new("Test ZIP") + proj.set_hull(wigley_hull) + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "test.arsd" + proj.save(p) + with zipfile.ZipFile(p, "r") as zf: + assert "ship.json" in zf.namelist() + ship = json.loads(zf.read("ship.json").decode("utf-8")) + assert ship["hull"]["format"] == "hull_v1" + + def test_roundtrip_five_hull_families(self) -> None: + """Todos los generadores parametricos deben sobrevivir el round-trip.""" + for family in HullFamily: + hull = generate_hull(family, lpp=12.0, beam=3.5, + draft=1.20, depth=2.00) + d = hull.to_dict() + h2 = Hull.from_dict(d) + assert abs(h2.lpp - hull.lpp) < 1e-9, f"{family.value}: Lpp no coincide" + # Hidrostáticos reproducibles + assert abs(h2.volume_of_displacement() - + hull.volume_of_displacement()) < 1e-6, \ + f"{family.value}: V no coincide" + + def test_save_is_atomic(self, wigley_hull: Hull) -> None: + """El archivo temporal .arsd.tmp no debe quedar si save tiene exito.""" + proj = Project.new("Atomic test") + proj.set_hull(wigley_hull) + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "atomic.arsd" + proj.save(p) + tmp_p = p.with_suffix(".arsd.tmp") + assert not tmp_p.exists(), ".arsd.tmp debe eliminarse tras save exitoso" + + def test_save_as_updates_path(self, wigley_hull: Hull) -> None: + proj = Project.new("SaveAs test") + proj.set_hull(wigley_hull) + with tempfile.TemporaryDirectory() as tmp: + p1 = Path(tmp) / "first.arsd" + p2 = Path(tmp) / "second.arsd" + proj.save(p1) + assert proj.path == p1 + proj.save(p2) + assert proj.path == p2 + assert p2.exists()