Modulo 1: serializacion Hull / Project en formato .arsd (Task 11)

- hull.py: Hull.to_dict() serializa a dict JSON con formato hull_v1
  (arrays numpy -> listas Python); Hull.from_dict() deserializa con
  validacion de claves y forma de array.

- project.py: Project.hull (property lazy) deserializa el Hull desde
  ship_data; Project.set_hull() persiste el Hull y marca is_modified.

- main_window.py: _on_new_project guarda el Hull en el proyecto;
  _on_project_loaded restaura el Hull en todos los visores al abrir
  un archivo .arsd; _on_hull_changed_from_editor mantiene el proyecto
  sincronizado con ediciones en el editor de offsets.

- test_serialization.py: 26 tests (round-trip dict, round-trip ZIP,
  5 familias parametricas, escritura atomica, proyecto sin Hull).

Suite total: 112 tests -- 112 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 08:33:34 -04:00
parent 2137b0a228
commit 3b0d5e9e50
4 changed files with 376 additions and 1 deletions
+70
View File
@@ -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
# ------------------------------------------------------------------
+39
View File
@@ -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})"
+10 -1
View File
@@ -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}")
+257
View File
@@ -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()