From 17c61e93dd272ad07c27dea510c405d27f9d770e Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 2 Oct 2026 21:08:23 +0900 Subject: [PATCH 1/5] Qualify the DeckModel docstring references to its row classes The DeckModel methods named their row classes (Line, Point, Body, ...) by bare name, which Sphinx resolved to the live solver views in cabledyn.objects. Name them as cabledyn.builder classes so the links stay unambiguous as more classes with these names are documented. --- doc/conf.py | 25 +++++++++++ python/cabledyn/builder.py | 89 ++++++++++++++++++++------------------ 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 809d633..ecdcc44 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -169,6 +169,31 @@ def _cmake_project_version() -> str: ) ] +nitpick_ignore += [ + ("py:class", f"cabledyn.builder.{name}") + for name in ( + "Attachment", + "Body", + "Control", + "EndConnection", + "EquivalentBuoyancy", + "ExternalLoad", + "Failure", + "Line", + "LineType", + "MoorDynBody", + "OptionSet", + "OutputList", + "Point", + "Rod", + "RodEnd", + "RodType", + "Section", + "SyropeIC", + "Turbine", + ) +] + # -- LaTeX / PDF output ------------------------------------------------------ # XeLaTeX handles the Unicode used throughout the manual (Greek symbols, arrows, the diff --git a/python/cabledyn/builder.py b/python/cabledyn/builder.py index 7166674..56daa53 100644 --- a/python/cabledyn/builder.py +++ b/python/cabledyn/builder.py @@ -1216,9 +1216,9 @@ class DeckModel: Absolute nominal deck path. caller_driven : bool Validation route. - options : OptionSet + options : ~cabledyn.builder.OptionSet The ``OPTIONS`` rows. - outputs : OutputList + outputs : ~cabledyn.builder.OutputList The ``OUTPUTS`` channels. Examples @@ -1387,7 +1387,10 @@ def rod_types(self) -> ObjectCollection[RodType]: @property def bodies(self) -> ObjectCollection[AnyBody]: - """``BODIES`` rows (:class:`Body` or :class:`MoorDynBody`), looked up by id.""" + """``BODIES`` rows, looked up by id. + + Each is a :class:`~cabledyn.builder.Body` or a :class:`~cabledyn.builder.MoorDynBody`. + """ return ObjectCollection(self._bodies, lambda item: item.id, "body") @property @@ -1494,14 +1497,14 @@ def rod_end(self, rod: Rod | int, end: str) -> RodEnd: Parameters ---------- - rod : Rod | int + rod : ~cabledyn.builder.Rod | int The rod or its id. end : str ``"A"`` or ``"B"``. Returns ------- - RodEnd + ~cabledyn.builder.RodEnd The rod end. """ if isinstance(rod, Rod): @@ -1577,11 +1580,11 @@ def add_line_type( irt: float | None = None, irn: float | None = None, ) -> LineType: - """Add a ``LINE TYPES`` row; see :class:`LineType` for the fields. + """Add a ``LINE TYPES`` row; see :class:`~cabledyn.builder.LineType` for the fields. Returns ------- - LineType + ~cabledyn.builder.LineType The new line type. Raises @@ -1608,11 +1611,11 @@ def add_rod_type( cd_ax: float | None = None, ca_ax: float | None = None, ) -> RodType: - """Add a ``ROD TYPES`` row; see :class:`RodType` for the fields. + """Add a ``ROD TYPES`` row; see :class:`~cabledyn.builder.RodType` for the fields. Returns ------- - RodType + ~cabledyn.builder.RodType The new rod type. Raises @@ -1646,11 +1649,11 @@ def add_body( ca: float = 0.0, inertia: Vec3 | None = None, ) -> Body: - """Add a CableDyn ``BODIES`` row; see :class:`Body` for the fields. + """Add a CableDyn ``BODIES`` row; see :class:`~cabledyn.builder.Body` for the fields. Returns ------- - Body + ~cabledyn.builder.Body The new body. Raises @@ -1683,11 +1686,11 @@ def add_moordyn_body( cda: float | tuple[float, ...] = 0.0, ca: float | tuple[float, ...] = 0.0, ) -> MoorDynBody: - """Add a 14-column MoorDyn ``BODIES`` row; see :class:`MoorDynBody`. + """Add a 14-column MoorDyn ``BODIES`` row; see :class:`~cabledyn.builder.MoorDynBody`. Returns ------- - MoorDynBody + ~cabledyn.builder.MoorDynBody The new body. Raises @@ -1714,14 +1717,14 @@ def add_rod( outputs: str = "-", body: AnyBody | int | None = None, ) -> Rod: - """Add a ``RODS`` row; see :class:`Rod` for the fields. + """Add a ``RODS`` row; see :class:`~cabledyn.builder.Rod` for the fields. ``type`` may name a body directly (``"Body1"``, ``"Body1Pinned"``) or be ``"Body"``/``"BodyPinned"`` together with ``body``. Returns ------- - Rod + ~cabledyn.builder.Rod The new rod. Raises @@ -1747,11 +1750,11 @@ def add_rod( def add_turbine( self, turbine_id: int, x: float, y: float, z: float, *, ptfm: Sequence[float] | None = None ) -> Turbine: - """Add a ``TURBINES`` row; see :class:`Turbine` for the fields. + """Add a ``TURBINES`` row; see :class:`~cabledyn.builder.Turbine` for the fields. Returns ------- - Turbine + ~cabledyn.builder.Turbine The new turbine. Raises @@ -1780,7 +1783,7 @@ def add_point( rod_end: RodEnd | None = None, turbine: int | None = None, ) -> Point: - """Add a ``POINTS`` row; see :class:`Point` for the fields. + """Add a ``POINTS`` row; see :class:`~cabledyn.builder.Point` for the fields. ``type`` may name the referenced object directly (``"Body1"``, ``"Rod2A"``, ``"Turbine3"``) or be ``"Body"``/``"Rod"``/``"Turbine"`` @@ -1788,7 +1791,7 @@ def add_point( Returns ------- - Point + ~cabledyn.builder.Point The new point. Raises @@ -1838,10 +1841,10 @@ def add_line( ---------- line_id : int Unique line id. - end_a, end_b : Point | RodEnd | int | str + end_a, end_b : ~cabledyn.builder.Point | ~cabledyn.builder.RodEnd | int | str End A (fairlead side) and End B (anchor side): a point, a point - id, a :class:`RodEnd`, or a rod-end token such as ``"R1A"``. - line_type : LineType | str | None + id, a :class:`~cabledyn.builder.RodEnd`, or a rod-end token such as ``"R1A"``. + line_type : ~cabledyn.builder.LineType | str | None Line type of a first section; give ``length`` and ``num_segs`` too. length : float | None First-section unstretched length, in m. @@ -1854,7 +1857,7 @@ def add_line( Returns ------- - Line + ~cabledyn.builder.Line The new line. Raises @@ -1888,9 +1891,9 @@ def add_section( Parameters ---------- - line : Line | int + line : ~cabledyn.builder.Line | int The line or its id. - line_type : LineType | str + line_type : ~cabledyn.builder.LineType | str The section's line type or its name. length : float Unstretched length, in m. @@ -1902,7 +1905,7 @@ def add_section( Returns ------- - Section + ~cabledyn.builder.Section The new section. """ target = self._line(line) @@ -1924,11 +1927,11 @@ def add_end_connection( normal: Sequence[float] | None = None, pretwist: float | None = None, ) -> EndConnection: - """Add an ``END CONNECTIONS`` row; see :class:`EndConnection`. + """Add an ``END CONNECTIONS`` row; see :class:`~cabledyn.builder.EndConnection`. Returns ------- - EndConnection + ~cabledyn.builder.EndConnection The new row. Raises @@ -1971,11 +1974,11 @@ def add_end_connection( def add_equivalent_buoyancy( self, line_type: LineType | str, diam: float, submerged_weight: float ) -> EquivalentBuoyancy: - """Add an ``EQUIVALENT BUOYANCY`` row; see :class:`EquivalentBuoyancy`. + """Add an ``EQUIVALENT BUOYANCY`` row; see :class:`~cabledyn.builder.EquivalentBuoyancy`. Returns ------- - EquivalentBuoyancy + ~cabledyn.builder.EquivalentBuoyancy The new row. """ item = EquivalentBuoyancy(self._line_type(line_type), diam, submerged_weight) @@ -1993,11 +1996,11 @@ def add_attachment( ca: float = 0.0, cdax: float | None = None, ) -> Attachment: - """Add an ``ATTACHMENTS`` row; see :class:`Attachment`. + """Add an ``ATTACHMENTS`` row; see :class:`~cabledyn.builder.Attachment`. Returns ------- - Attachment + ~cabledyn.builder.Attachment The new row. """ item = Attachment(self._line(line), arc_length, mass, volume, cda, ca, cdax) @@ -2007,11 +2010,11 @@ def add_attachment( def add_syrope_ic( self, lines: Line | int | Iterable[Line | int], tmax0: float, tmean0: float ) -> SyropeIC: - """Add a ``SYROPE IC`` row; see :class:`SyropeIC`. + """Add a ``SYROPE IC`` row; see :class:`~cabledyn.builder.SyropeIC`. Returns ------- - SyropeIC + ~cabledyn.builder.SyropeIC The new row. """ item = SyropeIC(self._lines_of(lines), tmax0, tmean0) @@ -2030,7 +2033,7 @@ def add_failure( Returns ------- - Failure + ~cabledyn.builder.Failure The new row. """ item = Failure(self._point(point), self._lines_of(lines), fail_time, fail_tension) @@ -2038,11 +2041,11 @@ def add_failure( return item def add_control(self, channel: int, lines: Line | int | Iterable[Line | int]) -> Control: - """Add a ``CONTROL`` row; see :class:`Control`. + """Add a ``CONTROL`` row; see :class:`~cabledyn.builder.Control`. Returns ------- - Control + ~cabledyn.builder.Control The new row. """ item = Control(channel, self._lines_of(lines)) @@ -2059,11 +2062,11 @@ def add_external_load( blin: float | tuple[float, ...] = 0.0, bquad: float | tuple[float, ...] = 0.0, ) -> ExternalLoad: - """Add an ``EXTERNAL LOADS`` row; see :class:`ExternalLoad`. + """Add an ``EXTERNAL LOADS`` row; see :class:`~cabledyn.builder.ExternalLoad`. Returns ------- - ExternalLoad + ~cabledyn.builder.ExternalLoad The new row. """ item = ExternalLoad(load_id, self._body(body), csys, force, blin, bquad) @@ -2304,8 +2307,12 @@ def rename( Parameters ---------- - obj : LineType | RodType | Body | MoorDynBody | Rod | Turbine | Point | Line - The object. + obj : object + The object: a :class:`~cabledyn.builder.LineType`, + :class:`~cabledyn.builder.RodType`, :class:`~cabledyn.builder.Body`, + :class:`~cabledyn.builder.MoorDynBody`, :class:`~cabledyn.builder.Rod`, + :class:`~cabledyn.builder.Turbine`, :class:`~cabledyn.builder.Point`, or + :class:`~cabledyn.builder.Line`. new : int | str The new id, or the new name of a line or rod type. From 5da04a87c578a04e004bc741e4575600e186237d Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 2 Oct 2026 21:08:29 +0900 Subject: [PATCH 2/5] Add the cabledyn.project object model with deck and project-file adapters A GUI-independent model of a complete CableDyn model: ModelObject classes with typed, unit-aware property descriptors (physics, construction, appearance and meta roles), references between objects with refusing or cascading removal, change events that bubble to the project, undoable commands on a CommandStack, an SI units layer with display preferences, and three validation layers whose last one writes the deck and applies the native DeckFile rules. Numeric property limits come from the deck reader's own constants. DeckReader and DeckWriter go through DeckModel and keep deck ids, keyword spellings and option order as hints, so every repository deck that DeckModel accepts (99 of 105 .dat files) reads and writes back to its canonical text and re-reads to the same object graph. ProjectStore writes the .cdproj archive or folder with a versioned schema and keeps objects of unknown type verbatim. Floaters have a shared FloaterType with a slot for a hydrodynamic database; the deck's Vessel keywords map to them. --- python/cabledyn/project/__init__.py | 350 +++++ python/cabledyn/project/appearance.py | 186 +++ python/cabledyn/project/base.py | 538 ++++++++ python/cabledyn/project/bodies.py | 136 ++ python/cabledyn/project/collection.py | 170 +++ python/cabledyn/project/commands.py | 691 ++++++++++ python/cabledyn/project/deck.py | 1749 ++++++++++++++++++++++++ python/cabledyn/project/descriptors.py | 1018 ++++++++++++++ python/cabledyn/project/environment.py | 317 +++++ python/cabledyn/project/events.py | 193 +++ python/cabledyn/project/issues.py | 59 + python/cabledyn/project/lines.py | 313 +++++ python/cabledyn/project/points.py | 157 +++ python/cabledyn/project/project.py | 181 +++ python/cabledyn/project/references.py | 206 +++ python/cabledyn/project/rods.py | 72 + python/cabledyn/project/rows.py | 162 +++ python/cabledyn/project/schema.py | 101 ++ python/cabledyn/project/settings.py | 126 ++ python/cabledyn/project/store.py | 220 +++ python/cabledyn/project/studio.py | 75 + python/cabledyn/project/types.py | 376 +++++ python/cabledyn/project/units.py | 456 ++++++ python/cabledyn/project/validation.py | 48 + python/tests/test_project_commands.py | 445 ++++++ python/tests/test_project_deck.py | 676 +++++++++ python/tests/test_project_model.py | 662 +++++++++ python/tests/test_project_store.py | 147 ++ python/tests/test_project_units.py | 95 ++ 29 files changed, 9925 insertions(+) create mode 100644 python/cabledyn/project/__init__.py create mode 100644 python/cabledyn/project/appearance.py create mode 100644 python/cabledyn/project/base.py create mode 100644 python/cabledyn/project/bodies.py create mode 100644 python/cabledyn/project/collection.py create mode 100644 python/cabledyn/project/commands.py create mode 100644 python/cabledyn/project/deck.py create mode 100644 python/cabledyn/project/descriptors.py create mode 100644 python/cabledyn/project/environment.py create mode 100644 python/cabledyn/project/events.py create mode 100644 python/cabledyn/project/issues.py create mode 100644 python/cabledyn/project/lines.py create mode 100644 python/cabledyn/project/points.py create mode 100644 python/cabledyn/project/project.py create mode 100644 python/cabledyn/project/references.py create mode 100644 python/cabledyn/project/rods.py create mode 100644 python/cabledyn/project/rows.py create mode 100644 python/cabledyn/project/schema.py create mode 100644 python/cabledyn/project/settings.py create mode 100644 python/cabledyn/project/store.py create mode 100644 python/cabledyn/project/studio.py create mode 100644 python/cabledyn/project/types.py create mode 100644 python/cabledyn/project/units.py create mode 100644 python/cabledyn/project/validation.py create mode 100644 python/tests/test_project_commands.py create mode 100644 python/tests/test_project_deck.py create mode 100644 python/tests/test_project_model.py create mode 100644 python/tests/test_project_store.py create mode 100644 python/tests/test_project_units.py diff --git a/python/cabledyn/project/__init__.py b/python/cabledyn/project/__init__.py new file mode 100644 index 0000000..f9a47f0 --- /dev/null +++ b/python/cabledyn/project/__init__.py @@ -0,0 +1,350 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The CableDyn project object model: typed objects, units, commands and adapters. + +:mod:`cabledyn.project` describes a complete CableDyn model as a graph of +plain Python objects, without any GUI toolkit: + +* :class:`ModelObject` subclasses with typed, unit-aware property + descriptors (:class:`Quantity`, :class:`Ref`, :class:`Children`, ...), + each flagged ``physics`` (written to the deck), ``construction``, + ``appearance`` or ``meta``; +* references between objects instead of names or ids, with safe rename and + cascading removal (:mod:`cabledyn.project.references`); +* change events (:mod:`cabledyn.project.events`) and undoable commands on a + :class:`CommandStack` (:mod:`cabledyn.project.commands`); +* three validation layers (:mod:`cabledyn.project.validation`); +* an SI units layer with display preferences (:mod:`cabledyn.project.units`); +* the deck adapters :class:`DeckReader` and :class:`DeckWriter`, built on + :class:`cabledyn.DeckModel`, and the ``.cdproj`` :class:`ProjectStore`. + +Examples +-------- +>>> from cabledyn.project import DeckReader, DeckWriter, ProjectStore +>>> project = DeckReader.read("examples/spread_3line_chain.dat") # doctest: +SKIP +>>> project.lines[0].sections[0].length = 850.0 # doctest: +SKIP +>>> ProjectStore.save(project, "spread.cdproj") # doctest: +SKIP +>>> DeckWriter(project).write("spread.dat") # doctest: +SKIP +""" + +from __future__ import annotations + +from cabledyn.project.appearance import ( + CableAppearance, + ChainAppearance, + EnvironmentAppearance, + FibreRopeAppearance, + LineAppearance, + MeshAsset, + ModuleGeometry, + PlainLineAppearance, + SeabedAppearance, + WireRopeAppearance, +) +from cabledyn.project.base import ( + REGISTRY, + ModelObject, + SerialContext, + TypeRegistry, + UnknownTypeError, + model_type, +) +from cabledyn.project.bodies import Body, Floater, Point3Body, Rigid6Body, Turbine +from cabledyn.project.collection import ObjectCollection +from cabledyn.project.commands import ( + AddObject, + ApplyLibraryItem, + Command, + CommandStack, + ImportDeck, + MacroCommand, + MoveInCollection, + Reconnect, + RemoveObject, + ReplaceStrategy, + SetProperty, + SplitSection, + StackChanged, +) +from cabledyn.project.deck import DeckExportError, DeckReader, DeckWriter, IdMap +from cabledyn.project.descriptors import ( + Bool, + Child, + Children, + Choice, + Colour, + FilePath, + Integer, + JsonValue, + OptionalBool, + OptionalColour, + OptionalInteger, + OptionalQuantity, + OptionalText, + OptionalVec3, + Property, + Quantity, + Ref, + RefList, + Role, + Strategy, + Text, + TextList, + Vec3, + Vector, +) +from cabledyn.project.environment import ( + BathymetrySeabed, + CurrentModel, + Environment, + FlatSeabed, + FloaterMotionRecord, + FloaterRAO, + JonswapWave, + MotionFile, + MotionSource, + MultiTrainSea, + NoCurrent, + NoMotion, + NoWaves, + OchiHubbleWave, + ProfileCurrent, + RegularWave, + Seabed, + SpectrumWave, + UniformCurrent, + WaveModel, + WaveTrain, +) +from cabledyn.project.events import ( + ChildAdded, + ChildMoved, + ChildRemoved, + Event, + EventHub, + PropertyChanged, + ReferenceChanged, + Subscription, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.lines import ( + Attachment, + BendRestrictor, + BendStiffener, + BuoyancyModule, + ClumpWeight, + EndConnection, + Line, + LineAncillary, + Section, + TouchdownProtection, +) +from cabledyn.project.points import ( + BodyPoint, + ConnectPoint, + FixedPoint, + FloaterPoint, + FreePoint, + LineEndTarget, + Point, + RodEndpoint, + RodPoint, + TurbinePoint, +) +from cabledyn.project.project import Project +from cabledyn.project.references import ObjectInUseError, RemovalPlan, plan_removal, referrers +from cabledyn.project.rods import Rod +from cabledyn.project.rows import ( + Channel, + Control, + EquivalentBuoyancy, + ExternalLoad, + Failure, + NamedChannel, + ObjectChannel, + OutputRequest, + SyropeIC, +) +from cabledyn.project.settings import AnalysisSettings, ExtraOption +from cabledyn.project.store import SCHEMA_VERSION, ProjectFormatError, ProjectStore +from cabledyn.project.studio import CameraBookmark, Group, StudioData +from cabledyn.project.types import ( + AxialModel, + BendingModel, + CableType, + ChainType, + FibreRopeType, + FloaterType, + GenericLineType, + HydroDatabase, + HydroDatabaseFile, + LinearAxial, + LineType, + NoHydroDatabase, + RodType, + SyropeAxial, + ViscoelasticAxial, + WireRopeType, +) +from cabledyn.project.units import Dimension, Unit, UnitError, UnitSystem +from cabledyn.project.validation import validate_project + +__all__ = [ + "REGISTRY", + "SCHEMA_VERSION", + "AddObject", + "AnalysisSettings", + "ApplyLibraryItem", + "Attachment", + "AxialModel", + "BathymetrySeabed", + "BendRestrictor", + "BendStiffener", + "BendingModel", + "Body", + "BodyPoint", + "Bool", + "BuoyancyModule", + "CableAppearance", + "CableType", + "CameraBookmark", + "ChainAppearance", + "ChainType", + "Channel", + "Child", + "ChildAdded", + "ChildMoved", + "ChildRemoved", + "Children", + "Choice", + "ClumpWeight", + "Colour", + "Command", + "CommandStack", + "ConnectPoint", + "Control", + "CurrentModel", + "DeckExportError", + "DeckReader", + "DeckWriter", + "Dimension", + "EndConnection", + "Environment", + "EnvironmentAppearance", + "EquivalentBuoyancy", + "Event", + "EventHub", + "ExternalLoad", + "ExtraOption", + "Failure", + "FibreRopeAppearance", + "FibreRopeType", + "FilePath", + "FixedPoint", + "FlatSeabed", + "Floater", + "FloaterMotionRecord", + "FloaterPoint", + "FloaterRAO", + "FloaterType", + "FreePoint", + "GenericLineType", + "Group", + "HydroDatabase", + "HydroDatabaseFile", + "IdMap", + "ImportDeck", + "Integer", + "Issue", + "JonswapWave", + "JsonValue", + "Line", + "LineAncillary", + "LineAppearance", + "LineEndTarget", + "LineType", + "LinearAxial", + "MacroCommand", + "MeshAsset", + "ModelObject", + "ModuleGeometry", + "MotionFile", + "MotionSource", + "MoveInCollection", + "MultiTrainSea", + "NamedChannel", + "NoCurrent", + "NoHydroDatabase", + "NoMotion", + "NoWaves", + "ObjectChannel", + "ObjectCollection", + "ObjectInUseError", + "OchiHubbleWave", + "OptionalBool", + "OptionalColour", + "OptionalInteger", + "OptionalQuantity", + "OptionalText", + "OptionalVec3", + "OutputRequest", + "PlainLineAppearance", + "Point", + "Point3Body", + "ProfileCurrent", + "Project", + "ProjectFormatError", + "ProjectStore", + "Property", + "PropertyChanged", + "Quantity", + "Reconnect", + "Ref", + "RefList", + "ReferenceChanged", + "RegularWave", + "RemovalPlan", + "RemoveObject", + "ReplaceStrategy", + "Rigid6Body", + "Rod", + "RodEndpoint", + "RodPoint", + "RodType", + "Role", + "Seabed", + "SeabedAppearance", + "Section", + "SerialContext", + "SetProperty", + "Severity", + "SpectrumWave", + "SplitSection", + "StackChanged", + "Strategy", + "StudioData", + "Subscription", + "SyropeAxial", + "SyropeIC", + "Text", + "TextList", + "TouchdownProtection", + "Turbine", + "TurbinePoint", + "TypeRegistry", + "UniformCurrent", + "Unit", + "UnitError", + "UnitSystem", + "UnknownTypeError", + "Vec3", + "Vector", + "ViscoelasticAxial", + "WaveModel", + "WaveTrain", + "WireRopeAppearance", + "WireRopeType", + "model_type", + "plan_removal", + "referrers", + "validate_project", +] diff --git a/python/cabledyn/project/appearance.py b/python/cabledyn/project/appearance.py new file mode 100644 index 0000000..d8c17ff --- /dev/null +++ b/python/cabledyn/project/appearance.py @@ -0,0 +1,186 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Appearance sub-objects: cosmetic data for the 3D views. + +Every property here has the ``appearance`` role: the deck writer never reads +it. The presentation view generates geometry from these properties together +with the physics properties of the owning object (for example a chain's +nominal diameter and its link proportions). ``None`` means "derive from the +physics data". +""" + +from __future__ import annotations + +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Bool, + Choice, + Colour, + FilePath, + Integer, + OptionalColour, + OptionalQuantity, + OptionalText, + Quantity, + Role, + Text, + Vec3, +) +from cabledyn.project.units import ANGLE, DIMENSIONLESS, LENGTH + +__all__ = [ + "CableAppearance", + "ChainAppearance", + "EnvironmentAppearance", + "FibreRopeAppearance", + "LineAppearance", + "MeshAsset", + "ModuleGeometry", + "PlainLineAppearance", + "SeabedAppearance", + "WireRopeAppearance", +] + +_A = Role.APPEARANCE + + +class LineAppearance(ModelObject): + """Base class of the surface descriptions of a line type.""" + + type_label = "Line appearance" + abstract = True + + +@model_type("appearance.line.plain") +class PlainLineAppearance(LineAppearance): + """A smooth tube in the line type's colour.""" + + type_label = "Plain tube" + roughness = Quantity(DIMENSIONLESS, 0.5, minimum=0.0, maximum=1.0, role=_A, group="Surface") + + +@model_type("appearance.line.chain") +class ChainAppearance(LineAppearance): + """Chain links generated from the nominal diameter and link proportions.""" + + type_label = "Chain links" + finish = Choice(("black", "galvanised", "rusted", "fouled"), role=_A, group="Surface") + link_length = OptionalQuantity( + LENGTH, role=_A, group="Links", doc="Outer link length; derived from the diameter if unset." + ) + link_width = OptionalQuantity( + LENGTH, role=_A, group="Links", doc="Outer link width; derived from the diameter if unset." + ) + section_end_links = Bool( + False, role=_A, group="Links", doc="Draw enlarged links at section ends." + ) + + +@model_type("appearance.line.wire_rope") +class WireRopeAppearance(LineAppearance): + """Helical strands, optionally under a polymer sheath.""" + + type_label = "Wire rope" + lay_length = OptionalQuantity(LENGTH, role=_A, group="Lay", doc="Strand lay length.") + lay_direction = Choice(("right", "left"), role=_A, group="Lay") + finish = Choice(("galvanised", "bright"), role=_A, group="Surface") + sheath_colour = OptionalColour(role=_A, group="Sheath", doc="Unset: no sheath.") + sheath_thickness = OptionalQuantity(LENGTH, role=_A, group="Sheath") + + +@model_type("appearance.line.fibre_rope") +class FibreRopeAppearance(LineAppearance): + """A braided jacket with optional marker stripes.""" + + type_label = "Fibre rope" + jacket_colour = Colour("#f2f2f2", group="Jacket") + marker_stripes = Integer(1, minimum=0, role=_A, group="Markers") + marker_colour = Colour("#1f4e9c", group="Markers") + lay_length = OptionalQuantity(LENGTH, role=_A, group="Markers", doc="Stripe lay length.") + + +@model_type("appearance.line.cable") +class CableAppearance(LineAppearance): + """A served power-cable exterior with stripes, marker bands and an optional cutaway.""" + + type_label = "Cable serving" + serving_colour = Colour("#1a1a1a", group="Serving") + lay_angle = Quantity(ANGLE, 15.0, minimum=0.0, maximum=90.0, role=_A, group="Serving") + stripe_count = Integer(1, minimum=0, role=_A, group="Stripes") + stripe_colour = Colour("#f2c200", group="Stripes") + stripe_width = OptionalQuantity(LENGTH, role=_A, group="Stripes") + marker_band_spacing = OptionalQuantity( + LENGTH, role=_A, group="Markers", doc="Spacing of marker bands; unset for none." + ) + printed_id = Text("", role=_A, group="Markers", doc="Identification text printed along it.") + cutaway = Bool(False, role=_A, group="Close-up", doc="Show cores and armour in close-ups.") + + +@model_type("appearance.module_geometry") +class ModuleGeometry(ModelObject): + """The shape of a buoyancy module (two half-shells around the line).""" + + type_label = "Module geometry" + abstract = False + outer_diameter = OptionalQuantity( + LENGTH, role=_A, group="Shape", doc="Unset: derived from the module volume." + ) + length = OptionalQuantity(LENGTH, role=_A, group="Shape") + bore = OptionalQuantity(LENGTH, role=_A, group="Shape", doc="Unset: the line diameter.") + end_radius = OptionalQuantity(LENGTH, role=_A, group="Shape") + seam = Bool(True, role=_A, group="Detail", doc="Draw the half-shell seam.") + straps = Integer(2, minimum=0, role=_A, group="Detail") + strap_width = OptionalQuantity(LENGTH, role=_A, group="Detail") + colour = Colour("#f2a900", group="Surface") + + +@model_type("appearance.mesh") +class MeshAsset(ModelObject): + """A 3D mesh drawn for a body or a floater type (glTF first; OBJ, STL).""" + + type_label = "Mesh" + abstract = False + file = FilePath(role=_A, group="File", doc="Mesh file; unset for a generic shape.") + generic_shape = Choice( + ("box", "cylinder", "semi_submersible", "spar", "barge", "buoy"), + role=_A, + group="File", + doc="Built-in shape drawn when no file is given.", + ) + scale = Quantity(DIMENSIONLESS, 1.0, minimum=0.0, role=_A, group="Placement") + origin = Vec3(LENGTH, role=_A, group="Placement", doc="Mesh origin in body axes.") + orientation = Vec3(ANGLE, role=_A, group="Placement", doc="Mesh rotation in body axes.") + file_units = Choice(("m", "mm", "cm", "ft", "in"), role=_A, group="Placement") + material = OptionalText(role=_A, group="Surface", doc="Material preset; unset: the file's.") + colour = OptionalColour(role=_A, group="Surface") + waterline = Bool(False, role=_A, group="Detail", doc="Mark the still-water line.") + + +@model_type("appearance.environment") +class EnvironmentAppearance(ModelObject): + """Sea, sky and underwater look of the presentation view.""" + + type_label = "Environment appearance" + abstract = False + sea_colour = Colour("#1d4f6e", group="Sea") + clarity = Quantity(DIMENSIONLESS, 0.5, minimum=0.0, maximum=1.0, role=_A, group="Sea") + ripples = Quantity(DIMENSIONLESS, 0.5, minimum=0.0, maximum=1.0, role=_A, group="Sea") + sun_azimuth = Quantity(ANGLE, 200.0, role=_A, group="Sky") + sun_elevation = Quantity(ANGLE, 30.0, minimum=-90.0, maximum=90.0, role=_A, group="Sky") + sky_map = FilePath(role=_A, group="Sky", doc="Environment map; unset for the default sky.") + underwater_fog = Quantity( + DIMENSIONLESS, 0.5, minimum=0.0, maximum=1.0, role=_A, group="Underwater" + ) + + +@model_type("appearance.seabed") +class SeabedAppearance(ModelObject): + """Seabed texture and colouring of the presentation view.""" + + type_label = "Seabed appearance" + abstract = False + texture = Choice(("sand", "silt", "gravel", "rock"), role=_A, group="Surface") + colour = Colour("#9c8a6a", group="Surface") + depth_colouring = Bool(False, role=_A, group="Surface", doc="Colour by depth.") + trench_exaggeration = Quantity( + DIMENSIONLESS, 1.0, minimum=0.0, role=_A, group="Detail", doc="Cosmetic, labelled." + ) diff --git a/python/cabledyn/project/base.py b/python/cabledyn/project/base.py new file mode 100644 index 0000000..6fe479c --- /dev/null +++ b/python/cabledyn/project/base.py @@ -0,0 +1,538 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The :class:`ModelObject` base class, the type registry, and serialisation. + +Every object of a CableDyn project is a :class:`ModelObject`: it has a stable +``uid``, a ``name``, a ``parent``, typed properties declared with the +descriptors of :mod:`cabledyn.project.descriptors`, change events, validation, +and a JSON form. Concrete classes register under a stable type key with +:func:`model_type`, which the project file uses to rebuild them. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Callable, Iterator +from typing import Any, ClassVar, TypeVar + +from cabledyn.project.collection import ObjectCollection +from cabledyn.project.descriptors import ( + Child, + Children, + ModelObjectLike, + Property, + Ref, + RefList, + Role, + Text, + TextList, +) +from cabledyn.project.events import Event, EventHub, PropertyChanged, ReferenceChanged +from cabledyn.project.issues import Issue + +__all__ = [ + "REGISTRY", + "ModelObject", + "SerialContext", + "TypeRegistry", + "UnknownTypeError", + "model_type", +] + +M = TypeVar("M", bound="ModelObject") + + +class UnknownTypeError(KeyError): + """A type key is not registered.""" + + +class TypeRegistry: + """Maps stable type keys to model classes (and back). + + The project file stores each object's key; a plugin registers new object + kinds here (see :func:`model_type`). + """ + + def __init__(self) -> None: + self._by_key: dict[str, type[ModelObject]] = {} + + def register(self, key: str, cls: type[ModelObject]) -> None: + """Register ``cls`` under ``key``. + + Raises + ------ + ValueError + If ``key`` is taken by another class. + """ + existing = self._by_key.get(key) + if existing is not None and existing is not cls: + raise ValueError(f"type key {key!r} is already used by {existing.__name__}") + self._by_key[key] = cls + + def get(self, key: str) -> type[ModelObject]: + """Return the class registered under ``key``. + + Raises + ------ + UnknownTypeError + If no class is registered under ``key``. + """ + try: + return self._by_key[key] + except KeyError as exc: + raise UnknownTypeError(key) from exc + + def __contains__(self, key: object) -> bool: + return key in self._by_key + + def keys(self) -> list[str]: + """Return the registered keys, sorted.""" + return sorted(self._by_key) + + def subclasses_of(self, base: type[ModelObject]) -> list[type[ModelObject]]: + """Return the registered concrete classes that are ``base`` or derive from it.""" + return [ + cls + for cls in self._by_key.values() + if issubclass(cls, base) and not cls.__dict__.get("abstract", False) + ] + + +REGISTRY = TypeRegistry() +"""The global registry of model classes.""" + + +def model_type(key: str) -> Callable[[type[M]], type[M]]: + """Class decorator registering a model class under a stable type key. + + Parameters + ---------- + key : str + Stable key written to project files, for example ``"line"``. + + Returns + ------- + Callable + The decorator; it returns the class unchanged. + + Examples + -------- + >>> from cabledyn.project import ModelObject, Quantity, model_type + >>> @model_type("example.marker") + ... class Marker(ModelObject): + ... height = Quantity(default=1.0) + >>> Marker("m1").height + 1.0 + """ + + def register(cls: type[M]) -> type[M]: + cls.type_key = key + REGISTRY.register(key, cls) + return cls + + return register + + +class SerialContext: + """State of one project-file read or write. + + It maps uids to rebuilt objects, defers references until every object + exists, and collects objects of unknown type (kept verbatim) and warnings. + + Parameters + ---------- + fresh_uids : bool + Give rebuilt objects new uids (for copy and paste) instead of the + stored ones. + """ + + def __init__(self, *, fresh_uids: bool = False) -> None: + self.fresh_uids = fresh_uids + self.objects: dict[str, ModelObject] = {} + self.unknown: list[dict[str, Any]] = [] + self.warnings: list[str] = [] + self._pending: list[tuple[ModelObject, Ref[Any] | RefList[Any], Any]] = [] + + def warn(self, message: str) -> None: + """Record a warning.""" + self.warnings.append(message) + + def build( + self, + data: Any, + *, + owner: ModelObject | None = None, + slot: str | None = None, + index: int | None = None, + ) -> ModelObject | None: + """Rebuild one object from its dictionary, or keep it verbatim if unknown. + + Returns + ------- + ModelObject | None + The object, or ``None`` for an unknown type (recorded in + ``unknown``). + """ + if not isinstance(data, dict) or not isinstance(data.get("type"), str): + self.warn(f"skipped a malformed object entry: {data!r:.80}") + return None + key = data["type"] + if key not in REGISTRY: + self.unknown.append( + { + "owner": None if owner is None else owner.uid, + "slot": slot, + "index": index, + "data": data, + } + ) + self.warn(f"kept an object of unknown type {key!r} verbatim") + return None + cls = REGISTRY.get(key) + obj = cls.__new__(cls) + obj._setup() + stored = data.get("uid") + if isinstance(stored, str) and stored and not self.fresh_uids: + obj._uid = stored + if isinstance(stored, str) and stored: + self.objects[stored] = obj + hints = data.get("deck_hints") + if isinstance(hints, dict): + obj.deck_hints = dict(hints) + values = data.get("properties", {}) + if not isinstance(values, dict): + values = {} + for prop in cls.properties(): + if prop.name not in values: + continue + try: + value = prop.from_json(values[prop.name], self, obj) + except (TypeError, ValueError) as exc: + self.warn(f"{cls.__name__}.{prop.name}: kept the default ({exc})") + continue + if not isinstance(prop, Children): + old = obj._values[prop.name] + if isinstance(prop, Child) and old is not value: + old._detach() + obj._values[prop.name] = value + for name in values: + if name not in cls._property_map(): + self.warn(f"{cls.__name__}: ignored unknown property {name!r}") + return obj + + def defer(self, obj: ModelObject, prop: Ref[Any] | RefList[Any], data: Any) -> None: + """Resolve a reference once every object exists.""" + self._pending.append((obj, prop, data)) + + def lookup(self, uid: Any) -> ModelObject: + """Return the rebuilt object with ``uid``. + + Raises + ------ + KeyError + If no rebuilt object has ``uid``. + """ + if not isinstance(uid, str) or uid not in self.objects: + raise KeyError(f"no object with uid {uid!r}") + return self.objects[uid] + + def resolve(self) -> None: + """Resolve every deferred reference; dangling ones become warnings.""" + for obj, prop, data in self._pending: + try: + obj._values[prop.name] = prop.resolve(data, self) + except (KeyError, TypeError) as exc: + self.warn(f"{obj.label()}: dropped reference {prop.name} ({exc})") + self._pending.clear() + + +class ModelObject(ModelObjectLike): + """Base class of every object in a CableDyn project. + + Parameters + ---------- + name : str + Object name, shown in the browser. Names need not be unique, except + where the deck needs it (line and rod type names). + **values : object + Initial property values, by property name. + + Raises + ------ + AttributeError + If a keyword is not a settable property of the class. + + Notes + ----- + Assigning a property (``line.outputs = "pt"``) validates its type and + publishes a change event, but is not undoable. Interactive edits go + through :mod:`cabledyn.project.commands` and a + :class:`~cabledyn.project.commands.CommandStack`. + """ + + type_key: ClassVar[str] = "object" + """Stable type key in project files (set by :func:`model_type`).""" + type_label: ClassVar[str] = "Object" + """Human-readable class name for the GUI.""" + abstract: ClassVar[bool] = True + """Abstract classes are not offered by :meth:`Strategy.options`.""" + + name = Text("", role=Role.META, group="Identity", doc="Name shown in the model browser.") + description = Text("", multiline=True, role=Role.META, group="Identity", doc="Free-text notes.") + tags = TextList((), role=Role.META, group="Identity", doc="User tags for grouping.") + + _properties_cache: ClassVar[dict[type, tuple[Property[Any], ...]]] = {} + + def __init__(self, name: str = "", /, **values: object) -> None: + self._setup() + if name: + self._values["name"] = type(self).name.coerce(name) + props = type(self)._property_map() + for key, value in values.items(): + prop = props.get(key) + if prop is None or isinstance(prop, Children): + raise AttributeError(f"{type(self).__name__} has no settable property {key!r}") + setattr(self, key, value) + + def _setup(self) -> None: + self._uid = uuid.uuid4().hex + self._parent: ModelObject | None = None + self._slot: str | None = None + self.events = EventHub() + self.deck_hints: dict[str, Any] = {} + self._values: dict[str, Any] = {} + for prop in type(self).properties(): + self._values[prop.name] = prop.initial(self) + + # ------------------------------------------------------------------ introspection + + @classmethod + def properties(cls) -> tuple[Property[Any], ...]: + """Return the property descriptors, base-class properties first.""" + cached = ModelObject._properties_cache.get(cls) + if cached is not None: + return cached + found: dict[str, Property[Any]] = {} + for klass in reversed(cls.__mro__): + for key, value in vars(klass).items(): + if isinstance(value, Property): + found[key] = value + result = tuple(found.values()) + ModelObject._properties_cache[cls] = result + return result + + @classmethod + def _property_map(cls) -> dict[str, Property[Any]]: + return {prop.name: prop for prop in cls.properties()} + + @classmethod + def get_property(cls, name: str) -> Property[Any]: + """Return the descriptor called ``name``. + + Raises + ------ + KeyError + If the class has no such property. + """ + try: + return cls._property_map()[name] + except KeyError as exc: + raise KeyError(f"{cls.__name__} has no property {name!r}") from exc + + @property + def uid(self) -> str: + """Stable unique identifier (32 hexadecimal characters).""" + return self._uid + + @property + def parent(self) -> ModelObject | None: + """The owning object, or ``None`` for a root or a detached object.""" + return self._parent + + @property + def slot(self) -> str | None: + """Name of the parent's property that owns this object.""" + return self._slot + + def label(self) -> str: + """Return a short description such as ``Line 'Mooring 1'``.""" + return f"{self.type_label} {self.name!r}" if self.name else self.type_label + + def root(self) -> ModelObject: + """Return the top-most ancestor (the project, when attached).""" + node: ModelObject = self + while node._parent is not None: + node = node._parent + return node + + def get_value(self, name: str) -> Any: + """Return the value of property ``name``.""" + type(self).get_property(name) + return self._values[name] + + def set_value(self, name: str, value: object) -> None: + """Set property ``name`` (as attribute assignment does).""" + prop = type(self).get_property(name) + prop.__set__(self, value) + + # ------------------------------------------------------------------ ownership + + def _attach(self, parent: ModelObject, slot: str) -> None: + self._parent = parent + self._slot = slot + + def _detach(self) -> None: + self._parent = None + self._slot = None + + def children(self) -> Iterator[ModelObject]: + """Yield the directly owned sub-objects, in property order.""" + for prop in type(self).properties(): + if isinstance(prop, Children): + yield from self._values[prop.name] + elif isinstance(prop, Child): + yield self._values[prop.name] + + def walk(self) -> Iterator[ModelObject]: + """Yield this object and every owned descendant, depth first.""" + yield self + for child in self.children(): + yield from child.walk() + + def references(self) -> list[tuple[str, ModelObject]]: + """Return the outgoing references as ``(property name, target)`` pairs.""" + found: list[tuple[str, ModelObject]] = [] + for prop in type(self).properties(): + if isinstance(prop, Ref): + target = self._values[prop.name] + if target is not None: + found.append((prop.name, target)) + elif isinstance(prop, RefList): + found.extend((prop.name, target) for target in self._values[prop.name]) + return found + + # ------------------------------------------------------------------ changes + + def _assign(self, prop: Property[Any], value: Any) -> None: + if prop.read_only: + raise AttributeError(f"{prop.name} is read-only") + old = self._values[prop.name] + if prop.same(old, value): + return + if isinstance(prop, Child): + if value.parent is not None: + raise ValueError(f"{value.label()} already belongs to {value.parent.label()}") + old._detach() + value._attach(self, prop.name) + self._values[prop.name] = value + event_type = ReferenceChanged if prop.is_reference else PropertyChanged + self.emit(event_type(self, prop.name, old, value)) + + def emit(self, event: Event) -> None: + """Publish ``event`` on this object's hub and on every ancestor's hub.""" + node: ModelObject | None = self + while node is not None: + node.events.emit(event) + node = node._parent + + # ------------------------------------------------------------------ validation + + def invariants(self) -> list[Issue]: + """Return the layer-2 issues of this object (structural invariants). + + Subclasses extend this; the base checks references and collection + sizes declared by the descriptors. + """ + return [] + + def validate(self, *, recursive: bool = False) -> list[Issue]: + """Return the layer-1 and layer-2 issues of this object. + + Parameters + ---------- + recursive : bool + Also validate every owned descendant. + + Returns + ------- + list[Issue] + Property-check issues (layer 1), then reference and invariant + issues (layer 2). + """ + found: list[Issue] = [] + for prop in type(self).properties(): + found.extend(prop.check(self, self._values[prop.name])) + found.extend(self.invariants()) + if recursive: + for child in self.children(): + found.extend(child.validate(recursive=True)) + return found + + # ------------------------------------------------------------------ serialisation + + def to_dict(self, ctx: SerialContext | None = None) -> dict[str, Any]: + """Return the JSON-compatible form of this object and its descendants. + + References are written as the target's uid. + """ + context = ctx or SerialContext() + data: dict[str, Any] = { + "type": self.type_key, + "uid": self._uid, + "properties": { + prop.name: prop.to_json(self._values[prop.name], context) + for prop in type(self).properties() + }, + } + if self.deck_hints: + data["deck_hints"] = _json_copy(self.deck_hints) + return data + + @classmethod + def from_dict(cls: type[M], data: dict[str, Any], *, fresh_uids: bool = False) -> M: + """Rebuild an object (and its descendants) written by :meth:`to_dict`. + + References to objects outside ``data`` are dropped with a warning. + + Parameters + ---------- + data : dict + The dictionary. + fresh_uids : bool + Give the rebuilt objects new uids. + + Returns + ------- + ModelObject + The rebuilt object. + + Raises + ------ + TypeError + If ``data`` does not describe a ``cls`` object. + """ + ctx = SerialContext(fresh_uids=fresh_uids) + obj = ctx.build(data) + if not isinstance(obj, cls): + raise TypeError(f"data does not describe a {cls.__name__}") + ctx.resolve() + return obj + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.name!r} {self._uid[:8]}>" + + +def _json_copy(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_copy(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_copy(item) for item in value] + return value + + +def collection_of(obj: ModelObject) -> ObjectCollection[Any] | None: + """Return the collection that holds ``obj``, or ``None`` (a single child or a root).""" + parent = obj.parent + if parent is None or obj.slot is None: + return None + value = parent._values[obj.slot] + return value if isinstance(value, ObjectCollection) else None diff --git a/python/cabledyn/project/bodies.py b/python/cabledyn/project/bodies.py new file mode 100644 index 0000000..ad3d280 --- /dev/null +++ b/python/cabledyn/project/bodies.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Bodies and FAST.Farm turbines. + +A body is one deck ``BODIES`` row. The deck's row types map to classes: +``Rigid6`` (and the MoorDyn ``Free`` row) to :class:`Rigid6Body`, ``Point3`` +to :class:`Point3Body`, and ``Coupled``/``Vessel`` (with the MoorDyn aliases) +to :class:`Floater`. The term *floater* is used throughout the object model; +the deck keeps its own ``Vessel`` keyword, which the deck adapter maps. +""" + +from __future__ import annotations + +from cabledyn.project.appearance import MeshAsset +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Child, + Choice, + Integer, + OptionalColour, + Quantity, + Ref, + Role, + Vec3, + Vector, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.types import FloaterType +from cabledyn.project.units import ( + ANGLE, + AREA, + DIMENSIONLESS, + INERTIA, + LENGTH, + MASS, + ROTATIONAL_STIFFNESS, + STIFFNESS, + VOLUME, +) + +__all__ = ["Body", "Floater", "Point3Body", "Rigid6Body", "Turbine"] + +_ROW_FORMATS = ("cabledyn", "moordyn") + + +class Body(ModelObject): + """Base class of the bodies: pose, mass properties and hydrodynamics. + + The deck has two ``BODIES`` row layouts. The CableDyn layout carries the + hydrostatic restoring terms and three optional inertias; the MoorDyn + layout carries a centre of gravity and multi-valued drag and added-mass + entries. ``row_format`` records which one the body uses. + """ + + type_label = "Body" + abstract = True + position = Vec3(LENGTH, limit="coordinate", group="Pose", doc="Reference position.") + orientation = Vec3(ANGLE, group="Pose", doc="Roll, pitch, yaw.") + mass = Quantity(MASS, 0.0, group="Mass") + volume = Quantity(VOLUME, 0.0, group="Hydrostatics", doc="Displaced volume.") + centre_of_gravity = Vector( + LENGTH, (0.0,), lengths=(1, 3), group="Mass", doc="CG z, or (x, y, z); MoorDyn layout." + ) + inertia = Vector( + INERTIA, (), lengths=(0, 1, 3), group="Mass", doc="Ixx, Iyy, Izz (one value: all three)." + ) + heave_stiffness = Quantity(STIFFNESS, 0.0, group="Hydrostatics", doc="C33; CableDyn layout.") + roll_stiffness = Quantity(ROTATIONAL_STIFFNESS, 0.0, group="Hydrostatics", doc="C44.") + pitch_stiffness = Quantity(ROTATIONAL_STIFFNESS, 0.0, group="Hydrostatics", doc="C55.") + drag_area = Vector(AREA, (0.0,), lengths=(1, 2, 3, 6), group="Hydrodynamics", doc="CdA.") + added_mass = Vector(DIMENSIONLESS, (0.0,), lengths=(1, 3), group="Hydrodynamics", doc="Ca.") + row_format = Choice( + _ROW_FORMATS, role=Role.META, group="Deck", doc="BODIES row layout used by the deck." + ) + colour = OptionalColour(group="Appearance") + mesh = Child(MeshAsset, role=Role.APPEARANCE) + + def invariants(self) -> list[Issue]: + found = super().invariants() + if self.row_format == "cabledyn": + for name in ("drag_area", "added_mass"): + if len(self.get_value(name)) != 1: + found.append( + Issue(Severity.ERROR, "the CableDyn layout takes one value", self, name, 2) + ) + if len(self.inertia) not in (0, 3): + found.append( + Issue(Severity.ERROR, "the CableDyn layout takes 0 or 3", self, "inertia", 2) + ) + elif not self.inertia: + found.append(Issue(Severity.ERROR, "the MoorDyn layout needs it", self, "inertia", 2)) + return found + + +@model_type("body.rigid6") +class Rigid6Body(Body): + """A free six-degree-of-freedom rigid body (deck ``Rigid6``, MoorDyn ``Free``).""" + + type_label = "Rigid body" + + +@model_type("body.point3") +class Point3Body(Body): + """A three-degree-of-freedom point body (deck ``Point3``).""" + + type_label = "Point body" + + +@model_type("body.floater") +class Floater(Body): + """A floater moved by the coupling host or by the prescribed motion. + + The deck body types ``Coupled`` and ``Vessel`` map to ``kind`` + ``"coupled"`` and ``"floater"``; the solver moves both kinds alike. + """ + + type_label = "Floater" + kind = Choice(("coupled", "floater"), group="Motion", doc="Deck body type Coupled or Vessel.") + floater_type = Ref(FloaterType, required=False, role=Role.CONSTRUCTION, group="Type") + + +@model_type("turbine") +class Turbine(ModelObject): + """A FAST.Farm turbine of a standalone farm deck (``TURBINES`` row).""" + + type_label = "Turbine" + abstract = False + number = Integer(1, minimum=1, maximum=10000, group="Farm", doc="Turbine number J.") + position = Vec3(LENGTH, limit="coordinate", group="Farm", doc="Farm-global reference.") + platform_displacement = Vector( + DIMENSIONLESS, + (), + lengths=(0, 6), + limit="coordinate", + group="Farm", + doc="Initial surge, sway, heave (m) and roll, pitch, yaw (deg); empty for none.", + ) diff --git a/python/cabledyn/project/collection.py b/python/cabledyn/project/collection.py new file mode 100644 index 0000000..78b6807 --- /dev/null +++ b/python/cabledyn/project/collection.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Ordered, observable collections of owned model objects.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING, Generic, TypeVar, overload + +from cabledyn.project.events import ChildAdded, ChildMoved, ChildRemoved + +if TYPE_CHECKING: + from cabledyn.project.base import ModelObject + +__all__ = ["ObjectCollection"] + +ObjT = TypeVar("ObjT", bound="ModelObject") + + +class ObjectCollection(Generic[ObjT]): + """An ordered list of objects owned by one model object. + + The collection is the value of a :class:`~cabledyn.project.Children` + property. Inserting an object makes the owner its parent; removing it + clears the parent. Every change publishes :class:`ChildAdded`, + :class:`ChildRemoved`, or :class:`ChildMoved` on the owner. An object can + belong to one collection at a time. + + Parameters + ---------- + owner : ModelObject + The owning object. + name : str + The property name of the collection on ``owner``. + item_type : type + The class every item must be an instance of. + """ + + def __init__(self, owner: ModelObject, name: str, item_type: type[ObjT]) -> None: + self._owner = owner + self._name = name + self._type = item_type + self._items: list[ObjT] = [] + + @property + def owner(self) -> ModelObject: + """The owning object.""" + return self._owner + + @property + def name(self) -> str: + """The collection's property name on its owner.""" + return self._name + + @property + def item_type(self) -> type[ObjT]: + """The class of the items.""" + return self._type + + def __iter__(self) -> Iterator[ObjT]: + return iter(tuple(self._items)) + + def __len__(self) -> int: + return len(self._items) + + def __bool__(self) -> bool: + return bool(self._items) + + @overload + def __getitem__(self, index: int) -> ObjT: ... + + @overload + def __getitem__(self, index: slice) -> list[ObjT]: ... + + def __getitem__(self, index: int | slice) -> ObjT | list[ObjT]: + return self._items[index] + + def __contains__(self, item: object) -> bool: + return any(existing is item for existing in self._items) + + def index(self, item: ObjT) -> int: + """Return the position of ``item`` (identity, not equality). + + Raises + ------ + ValueError + If ``item`` is not in the collection. + """ + for position, existing in enumerate(self._items): + if existing is item: + return position + raise ValueError(f"{item!r} is not in {self._name}") + + def find(self, name: str) -> ObjT | None: + """Return the first item called ``name`` (case-insensitive), or ``None``.""" + wanted = name.casefold() + for item in self._items: + if item.name.casefold() == wanted: + return item + return None + + def by_uid(self, uid: str) -> ObjT | None: + """Return the item with ``uid``, or ``None``.""" + for item in self._items: + if item.uid == uid: + return item + return None + + def insert(self, index: int, item: ObjT) -> None: + """Insert ``item`` before ``index`` (clamped to the collection). + + Raises + ------ + TypeError + If ``item`` has the wrong type. + ValueError + If ``item`` already has a parent. + """ + if not isinstance(item, self._type): + raise TypeError( + f"{self._name} holds {self._type.__name__} objects, got {type(item).__name__}" + ) + if item.parent is not None: + raise ValueError(f"{item.label()} already belongs to {item.parent.label()}") + position = max(0, min(index, len(self._items))) + self._items.insert(position, item) + item._attach(self._owner, self._name) + self._owner.emit(ChildAdded(self._owner, self._name, item, position)) + + def append(self, item: ObjT) -> ObjT: + """Append ``item`` and return it.""" + self.insert(len(self._items), item) + return item + + def extend(self, items: list[ObjT]) -> None: + """Append several items in order.""" + for item in items: + self.append(item) + + def remove(self, item: ObjT) -> int: + """Remove ``item`` and return its former position. + + Raises + ------ + ValueError + If ``item`` is not in the collection. + """ + position = self.index(item) + del self._items[position] + item._detach() + self._owner.emit(ChildRemoved(self._owner, self._name, item, position)) + return position + + def move(self, item: ObjT, index: int) -> None: + """Move ``item`` to position ``index`` (clamped).""" + old = self.index(item) + new = max(0, min(index, len(self._items) - 1)) + if new == old: + return + del self._items[old] + self._items.insert(new, item) + self._owner.emit(ChildMoved(self._owner, self._name, item, old, new)) + + def clear(self) -> None: + """Remove every item, last first.""" + for item in reversed(self._items[:]): + self.remove(item) + + def __repr__(self) -> str: + names = [item.name or item.uid[:8] for item in self._items] + return f"<{self._name}: {names!r}>" diff --git a/python/cabledyn/project/commands.py b/python/cabledyn/project/commands.py new file mode 100644 index 0000000..56ac4ca --- /dev/null +++ b/python/cabledyn/project/commands.py @@ -0,0 +1,691 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Undoable commands and the command stack. + +Every interactive change to a project is a :class:`Command` pushed onto a +:class:`CommandStack`, which executes it and records it for undo and redo. +Undoing a command restores the project exactly (its +:meth:`~cabledyn.project.ModelObject.to_dict` is unchanged), and object +identity is preserved, so references to removed-and-restored objects stay +valid. + +Examples +-------- +>>> from cabledyn.project import CommandStack, FixedPoint, Project +>>> from cabledyn.project.commands import AddObject, SetProperty +>>> project = Project() +>>> stack = CommandStack() +>>> anchor = FixedPoint("anchor") +>>> stack.push(AddObject(project.points, anchor)) +>>> stack.push(SetProperty(anchor, "position", (400.0, 0.0, -50.0))) +>>> stack.undo() +>>> anchor.position +(0.0, 0.0, 0.0) +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Callable, Iterator, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from cabledyn.project.base import ModelObject, collection_of +from cabledyn.project.collection import ObjectCollection +from cabledyn.project.descriptors import Child, Children +from cabledyn.project.lines import Line, Section +from cabledyn.project.points import LineEndTarget +from cabledyn.project.references import RemovalPlan, plan_removal + +if TYPE_CHECKING: + from cabledyn.project.project import Project + +__all__ = [ + "AddObject", + "ApplyLibraryItem", + "Command", + "CommandStack", + "ImportDeck", + "MacroCommand", + "MoveInCollection", + "Reconnect", + "RemoveObject", + "ReplaceStrategy", + "SetProperty", + "SplitSection", + "StackChanged", +] + + +class Command: + """Base class of an undoable change. + + Subclasses implement :meth:`do` and :meth:`undo`; :meth:`do` is called + again for a redo and must give the same result. + + Attributes + ---------- + text : str + Description for the Edit menu ("Set length of Section"). + """ + + text = "Edit" + + def do(self) -> None: + """Apply the change.""" + raise NotImplementedError + + def undo(self) -> None: + """Revert the change.""" + raise NotImplementedError + + def merge_with(self, other: Command) -> bool: + """Absorb ``other`` (already applied) into this command, if compatible. + + Returns + ------- + bool + Whether ``other`` was absorbed; the stack then discards it. + """ + return False + + def affected(self) -> tuple[ModelObject, ...]: + """Return the objects the command changes.""" + return () + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.text!r}>" + + +class SetProperty(Command): + """Set one property of one object. + + Parameters + ---------- + obj : ModelObject + The object. + name : str + Property name. + value : object + New value (SI units); checked against the property type at once. + merge : bool + Merge with an immediately following mergeable ``SetProperty`` of the + same object and property (one undo step for a drag). + text : str | None + Menu text; generated when ``None``. + + Raises + ------ + KeyError + If the object has no such property. + TypeError, ValueError + If the value does not fit the property. + AttributeError + If the property is read-only or a collection. + """ + + def __init__( + self, + obj: ModelObject, + name: str, + value: object, + *, + merge: bool = False, + text: str | None = None, + ) -> None: + self.obj = obj + self.prop = type(obj).get_property(name) + if self.prop.read_only or isinstance(self.prop, Children): + raise AttributeError(f"{name} cannot be set") + self.new = self.prop.coerce(value) + self.old: Any = obj._values[name] + self._applied = False + self.merge = merge + self.text = text or f"Set {self.prop.label.lower()} of {obj.label()}" + + def do(self) -> None: + if not self._applied: + self.old = self.obj._values[self.prop.name] + self._applied = True + self.obj._assign(self.prop, self.new) + + def undo(self) -> None: + self.obj._assign(self.prop, self.old) + + def merge_with(self, other: Command) -> bool: + if ( + self.merge + and isinstance(other, SetProperty) + and other.merge + and other.obj is self.obj + and other.prop is self.prop + ): + self.new = other.new + return True + return False + + def affected(self) -> tuple[ModelObject, ...]: + return (self.obj,) + + +class ReplaceStrategy(SetProperty): + """Replace a strategy sub-object (axial model, wave model, seabed, ...). + + Values of properties the old and new classes share are copied to the new + object unless ``copy_common`` is false. + + Parameters + ---------- + obj : ModelObject + The owner. + name : str + The :class:`~cabledyn.project.Strategy` (or :class:`~cabledyn.project.Child`) + property. + new : ModelObject | type + The new sub-object, or a class to instantiate. + copy_common : bool + Copy shared property values from the current sub-object. + """ + + def __init__( + self, + obj: ModelObject, + name: str, + new: ModelObject | type[ModelObject], + *, + copy_common: bool = True, + ) -> None: + prop = type(obj).get_property(name) + if not isinstance(prop, Child): + raise TypeError(f"{name} is not a sub-object property") + replacement = new() if isinstance(new, type) else new + if copy_common: + current = obj._values[name] + shared = {p.name for p in type(current).properties()} + for target in type(replacement).properties(): + if target.name in shared and not target.is_owner and not target.read_only: + with contextlib.suppress(TypeError, ValueError): + value = target.coerce(current._values[target.name]) + replacement._values[target.name] = value + super().__init__( + obj, name, replacement, text=f"Change {prop.label.lower()} of {obj.label()}" + ) + + +class Reconnect(SetProperty): + """Attach one end of a line to another point or rod end. + + Parameters + ---------- + line : Line + The line. + end : str + ``"A"`` or ``"B"``. + target : LineEndTarget + The new attachment. + """ + + def __init__(self, line: Line, end: str, target: LineEndTarget) -> None: + letter = end.upper() + if letter not in {"A", "B"}: + raise ValueError(f"end must be A or B, got {end!r}") + super().__init__( + line, + "end_a" if letter == "A" else "end_b", + target, + text=f"Connect End {letter} of {line.label()} to {target.label()}", + ) + + +class AddObject(Command): + """Insert a new object into a collection. + + Parameters + ---------- + collection : ObjectCollection + The target collection, for example ``project.points``. + obj : ModelObject + The new object (without a parent). + index : int | None + Position; appended when ``None``. + """ + + def __init__( + self, collection: ObjectCollection[Any], obj: ModelObject, index: int | None = None + ) -> None: + if not isinstance(obj, collection.item_type): + raise TypeError( + f"{collection.name} holds {collection.item_type.__name__}, not {type(obj).__name__}" + ) + if obj.parent is not None: + raise ValueError(f"{obj.label()} already belongs to {obj.parent.label()}") + self.collection = collection + self.obj = obj + self.index = len(collection) if index is None else index + self.text = f"Add {obj.label()}" + + def do(self) -> None: + self.collection.insert(self.index, self.obj) + + def undo(self) -> None: + self.collection.remove(self.obj) + + def affected(self) -> tuple[ModelObject, ...]: + return (self.collection.owner, self.obj) + + +class RemoveObject(Command): + """Remove objects, refusing or cascading when they are still referred to. + + The cascade (see :mod:`cabledyn.project.references`) is worked out when + the command is first applied and captured, so undo restores every removed + dependant and every cleared reference. + + Parameters + ---------- + root : ModelObject + The object graph, normally the project. + objects : ModelObject | Sequence[ModelObject] + The object(s) to remove. + cascade : bool + Remove or update dependants instead of refusing. + + Raises + ------ + ObjectInUseError + When applied without ``cascade`` to a referenced object. + """ + + def __init__( + self, + root: ModelObject, + objects: ModelObject | Sequence[ModelObject], + *, + cascade: bool = False, + ) -> None: + self.root = root + self.targets = [objects] if isinstance(objects, ModelObject) else list(objects) + self.cascade = cascade + self.plan: RemovalPlan | None = None + self._positions: list[tuple[ObjectCollection[Any], ModelObject, int]] = [] + first = self.targets[0].label() if self.targets else "nothing" + self.text = f"Remove {first}" if len(self.targets) < 2 else f"Remove {len(self.targets)}" + + def do(self) -> None: + if self.plan is None: + self.plan = plan_removal(self.root, self.targets, cascade=self.cascade) + for obj, name, _old, new in self.plan.reference_updates: + obj._assign(type(obj).get_property(name), new) + self._positions = [] + for obj in self.plan.removed: + collection = collection_of(obj) + assert collection is not None + self._positions.append((collection, obj, collection.remove(obj))) + + def undo(self) -> None: + for collection, obj, index in reversed(self._positions): + collection.insert(index, obj) + assert self.plan is not None + for obj, name, old, _new in reversed(self.plan.reference_updates): + obj._assign(type(obj).get_property(name), old) + + def affected(self) -> tuple[ModelObject, ...]: + if self.plan is None: + return tuple(self.targets) + return tuple(self.plan.removed) + tuple(obj for obj, *_ in self.plan.reference_updates) + + +class MoveInCollection(Command): + """Move an object to another position within its collection. + + Parameters + ---------- + obj : ModelObject + The object. + index : int + New position. + """ + + def __init__(self, obj: ModelObject, index: int) -> None: + collection = collection_of(obj) + if collection is None: + raise ValueError(f"{obj.label()} is not in a collection") + self.collection = collection + self.obj = obj + self.index = index + self.old = collection.index(obj) + self.text = f"Move {obj.label()}" + + def do(self) -> None: + self.old = self.collection.index(self.obj) + self.collection.move(self.obj, self.index) + + def undo(self) -> None: + self.collection.move(self.obj, self.old) + + def affected(self) -> tuple[ModelObject, ...]: + return (self.obj,) + + +class MacroCommand(Command): + """Several commands applied and undone as one. + + If a command fails while the macro is applied, the commands already + applied are undone and the error is raised. + + Parameters + ---------- + text : str + Menu text. + commands : Sequence[Command] + The commands, in order. + """ + + def __init__(self, text: str, commands: Sequence[Command] = ()) -> None: + self.text = text + self.commands = list(commands) + + def do(self) -> None: + done: list[Command] = [] + try: + for command in self.commands: + command.do() + done.append(command) + except Exception: + for command in reversed(done): + command.undo() + raise + + def undo(self) -> None: + for command in reversed(self.commands): + command.undo() + + def affected(self) -> tuple[ModelObject, ...]: + found: list[ModelObject] = [] + for command in self.commands: + for obj in command.affected(): + if all(obj is not other for other in found): + found.append(obj) + return tuple(found) + + +class SplitSection(MacroCommand): + """Split a section in two at an arc length within it. + + The elements are shared in proportion to the lengths (at least one each). + + Parameters + ---------- + section : Section + The section; it must belong to a line. + at : float + Distance from the section's start, in m, strictly inside it. + + Raises + ------ + ValueError + If ``at`` is not inside the section or the section has one element. + """ + + def __init__(self, section: Section, at: float) -> None: + collection = collection_of(section) + if collection is None: + raise ValueError("the section does not belong to a line") + if not 0.0 < at < section.length: + raise ValueError(f"split point {at!r} is not inside the section") + if section.segments < 2: + raise ValueError("a section with one element cannot be split") + first = max(1, min(section.segments - 1, round(section.segments * at / section.length))) + second = Section( + section.name, + line_type=section.line_type, + length=section.length - at, + segments=section.segments - first, + colour=section.colour, + ) + super().__init__( + f"Split {section.label()}", + [ + SetProperty(section, "length", at), + SetProperty(section, "segments", first), + AddObject(collection, second, collection.index(section) + 1), + ], + ) + self.new_section = second + + +class ApplyLibraryItem(MacroCommand): + """Set several properties of an object from a library entry. + + Parameters + ---------- + obj : ModelObject + The object, typically a line type. + values : Mapping[str, object] + Property values by name (SI units). + text : str | None + Menu text. + """ + + def __init__( + self, obj: ModelObject, values: Mapping[str, object], text: str | None = None + ) -> None: + super().__init__( + text or f"Apply library data to {obj.label()}", + [SetProperty(obj, name, value) for name, value in values.items()], + ) + + +class ImportDeck(MacroCommand): + """Add the objects of a deck (or of another project) to a project. + + Libraries, objects and rows are appended; the environment, seabed, + motion and settings of the imported model replace the project's when + ``replace_environment`` is true. Name clashes of line or rod types are + left for validation to report. + + Parameters + ---------- + project : Project + The receiving project. + source : Project + A project built by :class:`~cabledyn.project.DeckReader`; its objects + move into ``project``. + replace_environment : bool + Also take the environment, seabed, motion and settings. + """ + + _SINGLE = ("environment", "seabed", "motion", "settings") + + def __init__(self, project: Project, source: Project, *, replace_environment: bool = True): + commands: list[Command] = [] + for prop in type(source).properties(): + if not isinstance(prop, Children): + continue + items = list(source._values[prop.name]) + target = project._values[prop.name] + for item in items: + source._values[prop.name].remove(item) + commands.append(AddObject(target, item)) + for channel in list(source.outputs.channels): + source.outputs.channels.remove(channel) + commands.append(AddObject(project.outputs.channels, channel)) + if replace_environment: + for name in self._SINGLE: + replacement = source._values[name] + replacement._detach() + source._values[name] = type(source).get_property(name).initial(source) + commands.append(SetProperty(project, name, replacement)) + super().__init__(f"Import {source.title}", commands) + + +@dataclass(frozen=True) +class StackChanged: + """Published by :class:`CommandStack` after every change of its state. + + Attributes + ---------- + action : str + ``"push"``, ``"merge"``, ``"undo"``, ``"redo"``, ``"clean"``, or ``"clear"``. + command : Command | None + The command concerned. + """ + + action: str + command: Command | None + + +class CommandStack: + """Undo and redo history of commands. + + Parameters + ---------- + limit : int + Most commands kept (oldest dropped first); ``0`` keeps all. + """ + + def __init__(self, limit: int = 0) -> None: + self._commands: list[Command] = [] + self._index = 0 + self._clean: int | None = 0 + self._limit = limit + self._macro: list[MacroCommand] = [] + self._listeners: list[Callable[[StackChanged], None]] = [] + + # ------------------------------------------------------------------ state + + @property + def index(self) -> int: + """Number of applied commands in the history.""" + return self._index + + def __len__(self) -> int: + return len(self._commands) + + @property + def can_undo(self) -> bool: + """Whether there is a command to undo.""" + return self._index > 0 and not self._macro + + @property + def can_redo(self) -> bool: + """Whether there is a command to redo.""" + return self._index < len(self._commands) and not self._macro + + @property + def undo_text(self) -> str: + """Text of the command :meth:`undo` would revert ("" if none).""" + return self._commands[self._index - 1].text if self.can_undo else "" + + @property + def redo_text(self) -> str: + """Text of the command :meth:`redo` would apply ("" if none).""" + return self._commands[self._index].text if self.can_redo else "" + + @property + def is_clean(self) -> bool: + """Whether the state equals the last :meth:`set_clean` (for example a save).""" + return self._clean == self._index + + def commands(self) -> tuple[Command, ...]: + """Return the recorded commands, oldest first.""" + return tuple(self._commands) + + def subscribe(self, listener: Callable[[StackChanged], None]) -> Callable[[], None]: + """Call ``listener`` after every stack change; returns an unsubscribe function.""" + self._listeners.append(listener) + + def cancel() -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + return cancel + + def _notify(self, action: str, command: Command | None) -> None: + for listener in tuple(self._listeners): + listener(StackChanged(action, command)) + + # ------------------------------------------------------------------ actions + + def push(self, command: Command) -> None: + """Apply ``command`` and record it (inside a macro: add it to the macro). + + Raises + ------ + Exception + Whatever the command raises; nothing is recorded then. + """ + command.do() + self._record(command) + + def _record(self, command: Command) -> None: + if self._macro: + self._macro[-1].commands.append(command) + return + del self._commands[self._index :] + if self._clean is not None and self._clean > self._index: + self._clean = None + top = self._commands[-1] if self._commands else None + if top is not None and self._clean != self._index and top.merge_with(command): + self._notify("merge", top) + return + self._commands.append(command) + self._index += 1 + if self._limit and len(self._commands) > self._limit: + del self._commands[0] + self._index -= 1 + self._clean = None if not self._clean else self._clean - 1 + self._notify("push", command) + + def undo(self) -> None: + """Revert the last applied command (no-op if none).""" + if not self.can_undo: + return + self._index -= 1 + command = self._commands[self._index] + command.undo() + self._notify("undo", command) + + def redo(self) -> None: + """Re-apply the next undone command (no-op if none).""" + if not self.can_redo: + return + command = self._commands[self._index] + command.do() + self._index += 1 + self._notify("redo", command) + + def set_clean(self) -> None: + """Mark the current state as saved.""" + self._clean = self._index + self._notify("clean", None) + + def clear(self) -> None: + """Forget the history (the model is unchanged).""" + self._commands.clear() + self._index = 0 + self._clean = 0 + self._notify("clear", None) + + @contextlib.contextmanager + def macro(self, text: str) -> Iterator[MacroCommand]: + """Group the commands pushed in a ``with`` block into one undo step. + + If the block raises, the commands pushed in it are undone and the + error propagates. + + Parameters + ---------- + text : str + Menu text of the group. + """ + group = MacroCommand(text) + self._macro.append(group) + try: + yield group + except BaseException: + self._macro.pop() + group.undo() + raise + self._macro.pop() + if group.commands: + self._record(group) diff --git a/python/cabledyn/project/deck.py b/python/cabledyn/project/deck.py new file mode 100644 index 0000000..0ec8c73 --- /dev/null +++ b/python/cabledyn/project/deck.py @@ -0,0 +1,1749 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Deck adapters: read a CableDyn deck into a project, and write a project as a deck. + +Both directions go through :class:`cabledyn.DeckModel` and +:class:`cabledyn.DeckFile`, so the deck grammar and the native deck rules +live in one place. :class:`DeckReader` builds the object graph from a +:class:`~cabledyn.DeckModel`; :class:`DeckWriter` builds a fresh +:class:`~cabledyn.DeckModel` from the graph and renders it with +:meth:`~cabledyn.DeckModel.to_text`. The writer returns an :class:`IdMap` +between objects and deck ids. + +Deck ids, keyword spellings, option order and option descriptions are kept +as *hints* (``deck_hints``) on the objects, so reading a deck and writing it +back gives :meth:`DeckModel.load(deck).to_text() ` +exactly; deck comments and column alignment are not kept. Option rows the +object model does not type are kept verbatim as +:class:`~cabledyn.project.ExtraOption` rows. + +The deck vocabulary is kept as it is: ``Vessel`` points and bodies, and the +``vesselMotion``, ``vesselRAO`` and ``vesselRef`` options, map to the +floater classes and properties of the object model. +""" + +from __future__ import annotations + +import os +import re +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from cabledyn import builder as _b +from cabledyn.builder import DeckModel, DeckReferenceError +from cabledyn.deck_file import ( + _ROD_ATTACHMENT_ALIASES, + DeckFile, + _native_int, + _option_key, +) +from cabledyn.errors import DeckFormatError +from cabledyn.project.base import ModelObject +from cabledyn.project.bodies import Body, Floater, Point3Body, Rigid6Body, Turbine +from cabledyn.project.environment import ( + BathymetrySeabed, + CurrentModel, + FloaterMotionRecord, + FloaterRAO, + JonswapWave, + MotionFile, + MotionSource, + MultiTrainSea, + NoCurrent, + NoMotion, + NoWaves, + OchiHubbleWave, + ProfileCurrent, + RegularWave, + SpectrumWave, + UniformCurrent, + WaveModel, + WaveTrain, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.lines import ( + Attachment, + BuoyancyModule, + ClumpWeight, + EndConnection, + Line, + Section, +) +from cabledyn.project.points import ( + BodyPoint, + ConnectPoint, + FixedPoint, + FloaterPoint, + FreePoint, + LineEndTarget, + Point, + RodEndpoint, + RodPoint, + TurbinePoint, +) +from cabledyn.project.project import Project +from cabledyn.project.rods import Rod +from cabledyn.project.rows import ( + Control, + EquivalentBuoyancy, + ExternalLoad, + Failure, + NamedChannel, + ObjectChannel, + SyropeIC, +) +from cabledyn.project.settings import ExtraOption +from cabledyn.project.types import ( + BendingModel, + GenericLineType, + LinearAxial, + LineType, + RodType, + SyropeAxial, + ViscoelasticAxial, +) + +__all__ = ["DeckExportError", "DeckReader", "DeckWriter", "IdMap"] + +_TRUE = frozenset({"true", "t", "yes", "y", "on", "1"}) +_FALSE = frozenset({"false", "f", "no", "n", "off", "0"}) +_DISABLED = frozenset({"0", "none"}) +_PINNED = frozenset({"pinned", "free", "zero"}) +_RIGID = frozenset({"rigid", "infinity", "inf"}) +_SPECTRA = { + "pm": "pm", + "piersonmoskowitz": "pm", + "pierson-moskowitz": "pm", + "issc": "issc", + "bretschneider": "bretschneider", + "torsethaugen": "torsethaugen", +} +_OCHI = frozenset({"ochihubble", "ochi-hubble", "ochi_hubble"}) +_MOTION_KEYS = { + "motionfile": MotionFile, + "vesselmotion": FloaterMotionRecord, + "vesselrao": FloaterRAO, +} +_MOTION_KEYWORDS = {MotionFile: "motionFile", FloaterMotionRecord: "vesselMotion"} +_SEA_WATER_DENSITY = 1025.0 + + +class DeckExportError(ValueError): + """The project cannot be written as a deck. + + Attributes + ---------- + obj : ModelObject | None + The object that could not be written, if known. + """ + + def __init__(self, message: str, obj: ModelObject | None = None) -> None: + super().__init__(message) + self.obj = obj + + +# --------------------------------------------------------------------------- option codecs + + +def _fnum(token: str) -> float: + return _b._f(token) + + +def _num(value: float) -> str: + return repr(float(value)) + + +@dataclass(frozen=True) +class _Scalar: + identity: str + owner: str + attr: str + codec: str + keyword: str + + +_SCALARS: tuple[_Scalar, ...] = ( + _Scalar("gravity", "environment", "gravity", "float", "g"), + _Scalar("water_density", "environment", "water_density", "float", "rhoW"), + _Scalar("water_depth", "environment", "water_depth", "float", "WtrDpth"), + _Scalar("kbot", "seabed", "stiffness", "float", "kBot"), + _Scalar("cbot", "seabed", "damping", "float", "cBot"), + _Scalar("friction", "seabed", "friction", "friction", "frictionMu"), + _Scalar("friction_axial", "seabed", "friction_axial", "float", "frictionMuAxial"), + _Scalar("friction_lateral", "seabed", "friction_lateral", "float", "frictionMuLateral"), + _Scalar("bodyic", "settings", "body_initial_condition", "text", "bodyIC"), + _Scalar("icmode", "settings", "initial_condition_mode", "text", "ICmode"), + _Scalar("bodywetting", "settings", "body_wetting", "text", "bodyWetting"), + _Scalar("bodyhydro", "settings", "body_hydrodynamics", "text", "bodyHydro"), + _Scalar("rodhydro", "settings", "rod_hydrodynamics", "text", "rodHydro"), + _Scalar("bodyscheme", "settings", "body_scheme", "text", "bodyScheme"), + _Scalar("bodysubstep", "settings", "body_substep", "text", "bodySubstep"), + _Scalar("rhoinf", "settings", "spectral_radius", "float", "rhoInf"), + _Scalar("max_strain", "settings", "max_strain", "float", "maxStrain"), + _Scalar("modified_newton", "settings", "modified_newton", "bool", "modified_newton"), + _Scalar( + "cable_load_feedback", "settings", "cable_load_feedback", "bool", "cable_load_feedback" + ), + _Scalar("adaptive_mesh", "settings", "adaptive_mesh", "bool", "adaptive_mesh"), + _Scalar("cable_statics", "settings", "cable_statics", "text", "cable_statics"), + _Scalar("alpha_force_blend", "settings", "alpha_force_blend", "bool", "alpha_force_blend"), + _Scalar("tensile_safety", "settings", "tensile_safety", "text", "tensile_safety"), + _Scalar( + "tensile_strain_tolerance", + "settings", + "tensile_strain_tolerance", + "float", + "tensile_strain_tolerance", + ), + _Scalar( + "recovery_max_substeps", "settings", "recovery_max_substeps", "int", "recovery_max_substeps" + ), + _Scalar( + "axial_quadrature_order", + "settings", + "axial_quadrature_order", + "int", + "axial_quadrature_order", + ), + _Scalar( + "bending_quadrature_order", + "settings", + "bending_quadrature_order", + "int", + "bending_quadrature_order", + ), + _Scalar("dtm", "settings", "time_step", "float", "dtM"), + _Scalar("tmax", "settings", "duration", "float", "TMax"), + _Scalar("range_start", "settings", "range_start", "float", "RangeStart"), + _Scalar("n_modes", "settings", "mode_count", "int", "nModes"), + _Scalar("tscheme", "settings", "time_scheme", "text", "tScheme"), + _Scalar("dtic", "settings", "ic_time_step", "float", "dtIC"), + _Scalar("tmaxic", "settings", "ic_duration", "float", "TmaxIC"), + _Scalar("cdscaleic", "settings", "ic_drag_scale", "float", "CdScaleIC"), + _Scalar("threshic", "settings", "ic_threshold", "float", "threshIC"), + _Scalar("writelog", "settings", "write_log", "int", "WriteLog"), + _Scalar("dtout", "settings", "output_interval", "float", "dtOut"), + _Scalar("ramp_time", "environment", "ramp_time", "float", "rampTime"), + _Scalar("wave_seed", "environment", "wave_seed", "int", "WaveSeed"), + _Scalar("wave_spreading", "environment", "wave_spreading", "float", "WaveSpreading"), + _Scalar("wave_directions", "environment", "wave_directions", "int", "WaveDirections"), + _Scalar("wave_components", "environment", "wave_components", "int", "WaveComponents"), + _Scalar("stream_order", "environment", "stream_order", "int", "StreamOrder"), + _Scalar("dtwave", "environment", "wave_time_step", "float", "dtWave"), + _Scalar("waterkin", "environment", "water_kinematics", "text", "WaterKin"), + _Scalar("currents", "environment", "current_mode", "int", "Currents"), +) +_SCALAR_BY_ID = {item.identity: item for item in _SCALARS} + + +def _decode_scalar(codec: str, values: tuple[str, ...]) -> Any: + if len(values) != 1: + raise ValueError("expected one value") + token = values[0] + if codec == "float": + return _fnum(token) + if codec == "friction": + return 0.0 if token.lower() == "none" else _fnum(token) + if codec == "int": + return _native_int(token) + if codec == "bool": + lowered = token.lower() + if lowered in _TRUE: + return True + if lowered in _FALSE: + return False + raise ValueError(f"{token!r} is not a logical value") + return token + + +def _encode_scalar(codec: str, value: Any) -> tuple[str, ...]: + if codec in {"float", "friction"}: + return (_num(value),) + if codec == "int": + return (str(int(value)),) + if codec == "bool": + return ("True" if value else "False",) + return (str(value),) + + +def _floats(values: Iterable[str]) -> list[float]: + return [_fnum(token) for token in values] + + +def _decode_wave(values: tuple[str, ...], train: bool) -> WaveModel: + mode = values[0].lower() + numbers = _floats(values[1:]) + if mode == "none" and not numbers and not train: + return NoWaves() + if mode in {"airy", "stream", "dean"} and len(numbers) == 3: + if train and mode != "airy": + raise ValueError("only airy regular trains") + return RegularWave(theory=mode, height=numbers[0], period=numbers[1], direction=numbers[2]) + if mode == "jonswap" and len(numbers) == 4: + return JonswapWave( + significant_height=numbers[0], + peak_period=numbers[1], + peak_enhancement=numbers[2], + direction=numbers[3], + ) + if mode in _SPECTRA and len(numbers) == 3: + return SpectrumWave( + spectrum=_SPECTRA[mode], + significant_height=numbers[0], + peak_period=numbers[1], + direction=numbers[2], + ) + if mode in _OCHI and len(numbers) == 7: + return OchiHubbleWave( + height_1=numbers[0], + period_1=numbers[1], + shape_1=numbers[2], + height_2=numbers[3], + period_2=numbers[4], + shape_2=numbers[5], + direction=numbers[6], + ) + raise ValueError(f"unrecognised wave row {values!r}") + + +def _encode_wave(wave: WaveModel) -> tuple[str, ...]: + if isinstance(wave, NoWaves): + return ("none",) + if isinstance(wave, RegularWave): + return (wave.theory, _num(wave.height), _num(wave.period), _num(wave.direction)) + if isinstance(wave, JonswapWave): + return ( + "jonswap", + _num(wave.significant_height), + _num(wave.peak_period), + _num(wave.peak_enhancement), + _num(wave.direction), + ) + if isinstance(wave, SpectrumWave): + return ( + wave.spectrum, + _num(wave.significant_height), + _num(wave.peak_period), + _num(wave.direction), + ) + if isinstance(wave, OchiHubbleWave): + values = ( + wave.height_1, + wave.period_1, + wave.shape_1, + wave.height_2, + wave.period_2, + wave.shape_2, + wave.direction, + ) + return ("ochihubble", *(_num(value) for value in values)) + raise DeckExportError(f"{wave.label()} cannot be written as a waves row", wave) + + +def _decode_train(values: tuple[str, ...]) -> WaveTrain: + mode = values[0].lower() + if mode == "airy": + return WaveTrain(wave=_decode_wave(values, True)) + *head, spread = values + train = WaveTrain(wave=_decode_wave(tuple(head), True)) + train.spreading = _fnum(spread) + return train + + +def _encode_train(train: WaveTrain) -> tuple[str, ...]: + tokens = _encode_wave(train.wave) + if isinstance(train.wave, RegularWave): + return tokens + return (*tokens, _num(train.spreading or 0.0)) + + +def _decode_current(values: tuple[str, ...]) -> CurrentModel: + mode = values[0].lower() + numbers = _floats(values[1:]) + if mode == "none" and not numbers: + return NoCurrent() + if mode == "uniform" and len(numbers) == 3: + return UniformCurrent(velocity=numbers) + if mode == "profile" and len(numbers) == 8: + return ProfileCurrent( + z_1=numbers[0], velocity_1=numbers[1:4], z_2=numbers[4], velocity_2=numbers[5:8] + ) + raise ValueError(f"unrecognised current row {values!r}") + + +def _encode_current(current: CurrentModel) -> tuple[str, ...]: + if isinstance(current, UniformCurrent): + return ("uniform", *(_num(v) for v in current.velocity)) + if isinstance(current, ProfileCurrent): + return ( + "profile", + _num(current.z_1), + *(_num(v) for v in current.velocity_1), + _num(current.z_2), + *(_num(v) for v in current.velocity_2), + ) + if isinstance(current, NoCurrent): + return ("none",) + raise DeckExportError(f"{current.label()} cannot be written as a current row", current) + + +def _decode_vector(values: tuple[str, ...]) -> tuple[float, float, float]: + parts = "|".join(values).split("|") + if len(parts) != 3: + raise ValueError("expected x|y|z") + x, y, z = _floats(parts) + return (x, y, z) + + +def _encode_vector(value: tuple[float, float, float]) -> tuple[str, ...]: + return ("|".join(_num(v) for v in value),) + + +_SOLVER = ( + ("solver_relative_tolerance", "float"), + ("solver_absolute_tolerance", "float"), + ("solver_max_iterations", "int"), + ("solver_backtracks", "int"), +) + + +# --------------------------------------------------------------------------- id map + + +@dataclass +class IdMap: + """Correspondence between objects and deck ids, names and text rows. + + Attributes + ---------- + keys : dict[str, tuple[str, int | str]] + Object uid to ``(kind, deck id or name)``; kinds are ``line_type``, + ``rod_type``, ``body``, ``rod``, ``turbine``, ``point``, ``line`` + and ``external_load``. + objects : dict[tuple[str, int | str], ModelObject] + The reverse mapping (names in lower case). + rows : dict[int, ModelObject] + Zero-based line number of the written deck text to the object that + produced the row (filled by :meth:`DeckWriter.to_text`). + """ + + keys: dict[str, tuple[str, int | str]] = field(default_factory=dict) + objects: dict[tuple[str, int | str], ModelObject] = field(default_factory=dict) + rows: dict[int, ModelObject] = field(default_factory=dict) + + def add(self, obj: ModelObject, kind: str, key: int | str) -> None: + """Record that ``obj`` is written as ``kind`` ``key``.""" + self.keys[obj.uid] = (kind, key) + self.objects[(kind, key.lower() if isinstance(key, str) else key)] = obj + + def deck_id(self, obj: ModelObject) -> int | str | None: + """Return the deck id (or name) of ``obj``, or ``None``.""" + found = self.keys.get(obj.uid) + return None if found is None else found[1] + + def object_for(self, kind: str, key: int | str) -> ModelObject | None: + """Return the object written as ``kind`` ``key`` (names case-insensitive).""" + return self.objects.get((kind, key.lower() if isinstance(key, str) else key)) + + def object_at_row(self, line: int) -> ModelObject | None: + """Return the object behind zero-based deck text line ``line``, if any.""" + return self.rows.get(line) + + +# --------------------------------------------------------------------------- reader + + +class DeckReader: + """Build a :class:`~cabledyn.project.Project` from a CableDyn deck. + + Examples + -------- + >>> from cabledyn.project import DeckReader + >>> project = DeckReader.read("examples/spread_3line_chain.dat") # doctest: +SKIP + """ + + @classmethod + def read(cls, path: str | os.PathLike[str], *, caller_driven: bool = False) -> Project: + """Read and validate a deck file. + + Parameters + ---------- + path : str | os.PathLike + Deck file. + caller_driven : bool + Validate for the OpenFAST coupling instead of the standalone driver. + + Returns + ------- + Project + The project. + + Raises + ------ + DeckFormatError + If the deck violates the native deck contract. + """ + return cls.from_model(DeckModel.load(path, caller_driven=caller_driven)) + + @classmethod + def from_text( + cls, + text: str, + *, + path: str | os.PathLike[str] = "deck.dat", + caller_driven: bool = False, + ) -> Project: + """Validate deck text and return its project (see :meth:`read`).""" + return cls.from_model(DeckModel.from_text(text, path=path, caller_driven=caller_driven)) + + @classmethod + def from_model(cls, model: DeckModel) -> Project: + """Build a project from a :class:`~cabledyn.DeckModel`. + + Parameters + ---------- + model : DeckModel + The deck model (it is not modified). + + Returns + ------- + Project + The project, with deck ids and spellings kept as hints. + """ + return _Reader(model).run() + + +class _Reader: + def __init__(self, model: DeckModel) -> None: + self.model = model + self.project = Project() + self.line_types: dict[int, LineType] = {} + self.rod_types: dict[int, RodType] = {} + self.bodies: dict[int, Body] = {} + self.rods: dict[int, Rod] = {} + self.points: dict[int, Point] = {} + self.lines: dict[int, Line] = {} + + def run(self) -> Project: + model, project = self.model, self.project + project.name = model.path.stem + project.title = model.title + project.caller_driven = model.caller_driven + project.deck_path = str(model.path) + for line_type in model.line_types: + self.line_type(line_type) + for rod_type in model.rod_types: + self.rod_type(rod_type) + for body in model.bodies: + self.body(body) + for rod in model.rods: + self.rod(rod) + for turbine in model.turbines: + project.turbines.append( + Turbine( + f"Turbine {turbine.id}", + number=turbine.id, + position=(turbine.x, turbine.y, turbine.z), + platform_displacement=turbine.ptfm or (), + ) + ) + for point in model.points: + self.point(point) + for line in model.lines: + self.line(line) + for index, connection in enumerate(model.end_connections): + self.end_connection(connection, index) + for eq in model.equivalent_buoyancy: + project.equivalent_buoyancy.append( + EquivalentBuoyancy( + f"Equivalent {eq.line_type.name}", + line_type=self.line_types[id(eq.line_type)], + diameter=eq.diam, + submerged_weight=eq.submerged_weight, + ) + ) + for index, attachment in enumerate(model.attachments): + self.attachment(attachment, index) + for history in model.syrope_ic: + project.syrope_history.append( + SyropeIC( + lines=self._lines(history.lines), + max_tension=history.tmax0, + mean_tension=history.tmean0, + ) + ) + for number, failure in enumerate(model.failures, start=1): + project.failures.append( + Failure( + f"Failure {number}", + point=self.points[id(failure.point)], + lines=self._lines(failure.lines), + time=failure.fail_time, + tension=failure.fail_tension, + ) + ) + for control in model.controls: + project.controls.append( + Control( + f"Control {control.channel}", + channel=control.channel, + lines=self._lines(control.lines), + ) + ) + for load in model.external_loads: + self.external_load(load) + self.options() + self.outputs() + return project + + # ------------------------------------------------------------------ libraries + + def _lines(self, rows: Iterable[_b.Line]) -> list[Line]: + return [self.lines[id(line)] for line in rows] + + def line_type(self, row: _b.LineType) -> None: + damping = row.ba if isinstance(row.ba, tuple) else (row.ba,) + axial: LinearAxial | ViscoelasticAxial | SyropeAxial + if isinstance(row.ea, _b.SyropeEA): + axial = SyropeAxial( + settings_file=row.ea.settings, alpha=row.ea.alpha, beta=row.ea.beta, damping=damping + ) + elif isinstance(row.ea, tuple): + if len(row.ea) == 2: + axial = ViscoelasticAxial( + static_stiffness=row.ea[0], dynamic_stiffness=row.ea[1], damping=damping + ) + elif len(row.ea) == 3: + axial = ViscoelasticAxial( + static_stiffness=row.ea[0], alpha_mbl=row.ea[1], beta=row.ea[2], damping=damping + ) + else: + raise DeckFormatError(f"line type {row.name!r}: unsupported EA {row.ea!r}") + else: + axial = LinearAxial(stiffness=row.ea, damping=damping) + bending = BendingModel( + bending_stiffness=row.ei, + shear_stiffness=row.gas, + torsional_stiffness=row.gj, + rotary_inertia_axial=row.irt, + rotary_inertia_normal=row.irn, + ) + item = GenericLineType( + row.name, + diameter=row.diam, + mass_per_length=row.mass, + axial=axial, + bending=bending, + drag_normal=row.cdn, + drag_axial=row.cdt, + added_mass_normal=row.can, + added_mass_axial=row.cat, + ) + self.project.line_types.append(item) + self.line_types[id(row)] = item + + def rod_type(self, row: _b.RodType) -> None: + item = RodType( + row.name, + diameter=row.diam, + mass_per_length=row.mass, + drag=row.cd, + added_mass=row.ca, + end_drag=row.cd_end, + end_added_mass=row.ca_end, + axial_drag=row.cd_ax, + axial_added_mass=row.ca_ax, + ) + self.project.rod_types.append(item) + self.rod_types[id(row)] = item + + # ------------------------------------------------------------------ objects + + def body(self, row: _b.Body | _b.MoorDynBody) -> None: + kind = row.type.lower() + item: Body + if kind in {"rigid6", "free"}: + item = Rigid6Body(f"Body {row.id}") + elif kind == "point3": + item = Point3Body(f"Body {row.id}") + elif kind in {"coupled", "cpld", "vessel", "ves"}: + item = Floater(f"Body {row.id}") + item.kind = "coupled" if kind in {"coupled", "cpld"} else "floater" + else: + raise DeckFormatError(f"body {row.id}: unsupported type {row.type!r}") + item.position = (row.x, row.y, row.z) + item.orientation = (row.roll, row.pitch, row.yaw) + item.mass = row.mass + item.volume = row.volume + if isinstance(row, _b.MoorDynBody): + item.row_format = "moordyn" + item.centre_of_gravity = _tuple(row.cg) + item.inertia = _tuple(row.inertia) + item.drag_area = _tuple(row.cda) + item.added_mass = _tuple(row.ca) + else: + item.row_format = "cabledyn" + item.heave_stiffness = row.c33 + item.roll_stiffness = row.c44 + item.pitch_stiffness = row.c55 + item.drag_area = (row.cda,) + item.added_mass = (row.ca,) + item.inertia = row.inertia or () + item.deck_hints.update(deck_id=row.id, deck_type=row.type) + self.project.bodies.append(item) + self.bodies[id(row)] = item + + def rod(self, row: _b.Rod) -> None: + kind = _ROD_ATTACHMENT_ALIASES.get(row.type.lower(), row.type.lower()) + attachment = { + "vessel": "floater", + "bodypinned": "body_pinned", + "bodypin": "body_pinned", + }.get(kind, kind) + item = Rod( + f"Rod {row.id}", + rod_type=self.rod_types[id(row.rod_type)], + attachment=attachment, + body=None if row.body is None else self.bodies[id(row.body)], + position_a=row.end_a, + position_b=row.end_b, + segments=row.num_segs, + outputs=row.outputs, + ) + item.deck_hints.update(deck_id=row.id, deck_type=row.type) + self.project.rods.append(item) + self.rods[id(row)] = item + + def _endpoint(self, end: _b.RodEnd) -> RodEndpoint: + return self.rods[id(end.rod)].endpoint(end.end) + + def point(self, row: _b.Point) -> None: + kind = row.type.lower() + item: Point + if kind == "fixed": + item = FixedPoint() + elif kind in {"coupled", "vessel"}: + item = FloaterPoint(kind="coupled" if kind == "coupled" else "floater") + elif kind == "free": + item = FreePoint() + elif kind == "connect": + item = ConnectPoint() + elif kind == "body" and row.body is not None: + item = BodyPoint(body=self.bodies[id(row.body)]) + elif kind == "rod" and row.rod_end is not None: + item = RodPoint(rod_end=self._endpoint(row.rod_end)) + elif kind in {"turbine", "t"} and row.turbine is not None: + item = TurbinePoint(turbine=row.turbine) + else: + raise DeckFormatError(f"point {row.id}: unsupported type {row.type!r}") + item.name = f"Point {row.id}" + item.position = (row.x, row.y, row.z) + item.mass = row.mass + item.volume = row.volume + item.drag_area = row.cda + item.added_mass = row.ca + item.deck_hints.update(deck_id=row.id, deck_type=row.type) + self.project.points.append(item) + self.points[id(row)] = item + + def _target(self, end: _b.Point | _b.RodEnd) -> LineEndTarget: + if isinstance(end, _b.RodEnd): + return self._endpoint(end) + return self.points[id(end)] + + def line(self, row: _b.Line) -> None: + item = Line( + f"Line {row.id}", + end_a=self._target(row.end_a), + end_b=self._target(row.end_b), + outputs=row.outputs, + ) + for index, section in enumerate(row.sections, start=1): + item.sections.append( + Section( + f"Section {index}", + line_type=self.line_types[id(section.line_type)], + length=section.length, + segments=section.num_segs, + ) + ) + item.deck_hints["deck_id"] = row.id + if row.stock_row: + item.deck_hints["stock_row"] = True + self.project.lines.append(item) + self.lines[id(row)] = item + + def end_connection(self, row: _b.EndConnection, index: int) -> None: + item = EndConnection(f"End {row.end}", end=row.end, direction=row.direction) + if isinstance(row.stiffness, str): + item.rotation = "pinned" if row.stiffness.lower() in _PINNED else "rigid" + item.deck_hints["rotation_token"] = row.stiffness + else: + item.rotation = "stiffness" + item.rotational_stiffness = row.stiffness + if row.torsion_stiffness is not None: + if isinstance(row.torsion_stiffness, str): + lowered = row.torsion_stiffness.lower() + item.torsion = "free" if lowered in _PINNED else "rigid" + item.deck_hints["torsion_token"] = row.torsion_stiffness + else: + item.torsion = "stiffness" + item.torsional_stiffness = row.torsion_stiffness + item.normal = row.normal + item.pretwist = row.pretwist + item.deck_hints["row"] = index + self.lines[id(row.line)].end_connections.append(item) + + def attachment(self, row: _b.Attachment, index: int) -> None: + density = _option_density(self.model, _SEA_WATER_DENSITY) + cls = BuoyancyModule if row.volume * density > row.mass else ClumpWeight + item: Attachment = cls( + mass=row.mass, + volume=row.volume, + drag_area=row.cda, + added_mass=row.ca, + axial_drag_area=row.cdax, + ) + if isinstance(row.arc_length, tuple): + item.arc_length, item.pitch, item.last_arc_length = row.arc_length + else: + item.arc_length = row.arc_length + line = self.lines[id(row.line)] + item.name = f"{cls.type_label} {len(line.attachments) + 1}" + item.deck_hints["row"] = index + line.attachments.append(item) + + def external_load(self, row: _b.ExternalLoad) -> None: + item = ExternalLoad( + f"Load {row.id}", + body=self.bodies[id(row.body)], + axes="body" if row.csys.upper() == "L" else "global", + force=_tuple(row.force), + linear_damping=_tuple(row.blin), + quadratic_damping=_tuple(row.bquad), + ) + item.deck_hints.update(deck_id=row.id, csys=row.csys) + self.project.external_loads.append(item) + + # ------------------------------------------------------------------ options + + def options(self) -> None: + project = self.project + rows = list(self.model.options) + hints: dict[str, Any] = {} + typed: dict[str, int] = {} + motion_rows: list[int] = [] + trains: list[int] = [] + for position, row in enumerate(rows): + identity = _option_key(row.keyword) + if identity in _MOTION_KEYS: + if row.values and row.values[0].lower() not in _DISABLED: + motion_rows.append(position) + continue + if identity == "wavetrain": + trains.append(position) + else: + typed[identity] = position + if trains: + typed.pop("waves", None) + accepted: set[int] = set() + for identity, position in typed.items(): + row = rows[position] + values = tuple(row.values) + try: + encoded = self._apply(identity, values) + except (TypeError, ValueError): + continue + if encoded is None: + continue + accepted.add(position) + hints[identity] = _hint(position, row, encoded) + if motion_rows: + position = motion_rows[-1] + row = rows[position] + source = _MOTION_KEYS[_option_key(row.keyword)](file=row.values[0]) + reference = project.motion.reference + project.motion = source + source.reference = reference + accepted.add(position) + hints["motion"] = _hint(position, row, tuple(row.values)) + if trains: + sea = MultiTrainSea() + for position in trains: + row = rows[position] + try: + train = _decode_train(tuple(row.values)) + except (TypeError, ValueError): + continue + train.name = f"Train {len(sea.trains) + 1}" + train.deck_hints["option"] = _hint(position, row, _encode_train(train)) + sea.trains.append(train) + accepted.add(position) + if sea.trains: + project.environment.waves = sea + for position, row in enumerate(rows): + if position in accepted: + continue + extra = ExtraOption(row.keyword, keyword=row.keyword, values=row.values) + extra.note = row.description + extra.deck_hints["position"] = position + project.settings.extra_options.append(extra) + project.deck_hints["options"] = hints + + def _apply(self, identity: str, values: tuple[str, ...]) -> tuple[str, ...] | None: + project = self.project + scalar = _SCALAR_BY_ID.get(identity) + if scalar is not None: + value = _decode_scalar(scalar.codec, values) + owner = _owner(project, scalar.owner) + owner.set_value(scalar.attr, value) + return _encode_scalar(scalar.codec, value) + if identity == "bathymetry": + old = project.seabed + seabed = BathymetrySeabed(file=values[0]) + for name in ("stiffness", "damping", "friction", "friction_axial", "friction_lateral"): + seabed.set_value(name, old.get_value(name)) + project.seabed = seabed + return values + if identity == "waves": + wave = _decode_wave(values, False) + project.environment.waves = wave + return _encode_wave(wave) + if identity == "current": + current = _decode_current(values) + project.environment.current = current + return _encode_current(current) + if identity == "vesselref": + reference = _decode_vector(values) + project.motion.reference = reference + return _encode_vector(reference) + if identity == "dynamic_solver": + if len(values) not in (4, 5): + raise ValueError("four or five values") + settings = project.settings + decoded = [_decode_scalar(codec, (values[i],)) for i, (_, codec) in enumerate(_SOLVER)] + for (name, _), value in zip(_SOLVER, decoded, strict=True): + settings.set_value(name, value) + if len(values) == 5: + settings.solver_spectral_radius = _fnum(values[4]) + return _encode_solver(settings) + return None + + def outputs(self) -> None: + targets: dict[tuple[str, int], ModelObject] = {} + for line in self.lines.values(): + targets[("line", int(line.deck_hints["deck_id"]))] = line + for point in self.points.values(): + targets[("point", int(point.deck_hints["deck_id"]))] = point + for body in self.bodies.values(): + targets[("body", int(body.deck_hints["deck_id"]))] = body + for rod in self.rods.values(): + targets[("rod", int(rod.deck_hints["deck_id"]))] = rod + channels = self.project.outputs.channels + for name in self.model.outputs: + reference = _b._channel_reference(name) + target = None if reference is None else targets.get(reference[:2]) + if reference is not None and target is not None: + match = reference[2] + channels.append( + ObjectChannel( + name, target=target, quantity=match.group(1), qualifier=match.group(3) + ) + ) + else: + channels.append(NamedChannel(name, channel=name)) + + +def _option_density(model: DeckModel, fallback: float) -> float: + value = model.options.value("water_density") + if value is None: + return fallback + try: + return _fnum(value) + except ValueError: + return fallback + + +def _tuple(value: float | tuple[float, ...]) -> tuple[float, ...]: + return tuple(value) if isinstance(value, tuple) else (float(value),) + + +def _multi(value: tuple[float, ...]) -> float | tuple[float, ...]: + return value[0] if len(value) == 1 else tuple(value) + + +def _hint(position: int, row: _b.Option, encoded: tuple[str, ...]) -> dict[str, Any]: + return { + "position": position, + "keyword": row.keyword, + "values": list(row.values), + "description": row.description, + "encoded": list(encoded), + } + + +def _owner(project: Project, name: str) -> ModelObject: + owner: ModelObject = project.get_value(name) + return owner + + +def _encode_solver(settings: Any) -> tuple[str, ...]: + tokens = [_encode_scalar(codec, settings.get_value(name))[0] for name, codec in _SOLVER] + if settings.solver_spectral_radius is not None: + tokens.append(_num(settings.solver_spectral_radius)) + return tuple(tokens) + + +# --------------------------------------------------------------------------- writer + +_BODY_TOKENS = { + (Rigid6Body, "cabledyn"): ("Rigid6", frozenset({"rigid6"})), + (Rigid6Body, "moordyn"): ("Free", frozenset({"free"})), + (Point3Body, "cabledyn"): ("Point3", frozenset({"point3"})), + (Point3Body, "moordyn"): ("Point3", frozenset({"point3"})), +} +_ROD_TOKENS = { + "free": "Free", + "fixed": "Fixed", + "pinned": "Pinned", + "coupled": "Coupled", + "floater": "Vessel", + "body": "Body", + "body_pinned": "BodyPinned", +} +_TABLE_SECTIONS = ( + "LINE TYPES", + "BODIES", + "ROD TYPES", + "RODS", + "TURBINES", + "POINTS", + "LINES", + "SYROPE IC", + "SECTIONS", + "END CONNECTIONS", + "EQUIVALENT BUOYANCY", + "ATTACHMENTS", + "FAILURE", + "CONTROL", + "EXTERNAL LOADS", +) + + +def _hinted(obj: ModelObject, canonical: str, accepted: frozenset[str]) -> str: + """Return the deck token hint of ``obj`` if it still means the same, else ``canonical``.""" + hint = obj.deck_hints.get("deck_type") + if isinstance(hint, str) and hint.lower() in accepted: + return hint + return canonical + + +@dataclass +class _Row: + position: float + keyword: str + values: tuple[str, ...] + description: str | None + owner: ModelObject + + +class DeckWriter: + """Write a :class:`~cabledyn.project.Project` as a CableDyn deck. + + Parameters + ---------- + project : Project + The project. Only ``physics`` properties are written. + + Examples + -------- + >>> from cabledyn.project import DeckReader, DeckWriter + >>> project = DeckReader.read("examples/spread_3line_chain.dat") # doctest: +SKIP + >>> text = DeckWriter(project).to_text() # doctest: +SKIP + """ + + def __init__(self, project: Project) -> None: + self.project = project + self.id_map = IdMap() + self._sections: dict[str, list[ModelObject]] = {} + self._deck_bodies: dict[int, Any] = {} + self._deck_rods: dict[int, _b.Rod] = {} + self._deck_points: dict[int, _b.Point] = {} + self._deck_lines: dict[int, _b.Line] = {} + + # ------------------------------------------------------------------ public + + def to_model(self) -> tuple[DeckModel, IdMap]: + """Build a fresh :class:`~cabledyn.DeckModel` from the project. + + Returns + ------- + tuple[DeckModel, IdMap] + The deck model and the id map. + + Raises + ------ + DeckExportError + If an object cannot be expressed as a deck row (a missing + reference, a bad value); ``obj`` names it. + """ + self.id_map = IdMap() + self._sections = {name: [] for name in (*_TABLE_SECTIONS, "OPTIONS", "OUTPUTS")} + project = self.project + model = DeckModel.new( + title=project.title, + path=project.deck_path or "deck.dat", + caller_driven=project.caller_driven, + ) + self._ids() + self._libraries(model) + self._bodies_and_rods(model) + self._points(model) + self._lines(model) + self._rows(model) + self._options(model) + self._outputs(model) + return model, self.id_map + + def to_text(self, *, validate: bool = True) -> str: + """Return the canonical deck text of the project. + + Parameters + ---------- + validate : bool + Check the text with the native deck rules (:class:`cabledyn.DeckFile`). + + Raises + ------ + DeckExportError + If an object cannot be written. + DeckFormatError + If ``validate`` is true and the deck violates the native rules. + """ + model, _ = self.to_model() + text = self._render(model) + if validate: + DeckFile.from_text(text, path=model.path, caller_driven=model.caller_driven) + return text + + def write(self, path: str | os.PathLike[str], *, overwrite: bool = False) -> Path: + """Validate the project and write it as a deck file. + + Relative ancillary paths are rebased to the target folder (see + :meth:`cabledyn.DeckModel.save`). + + Returns + ------- + pathlib.Path + The written file. + """ + model, _ = self.to_model() + self._render(model) + return model.save(path, overwrite=overwrite) + + def validate(self) -> list[Issue]: + """Write the deck and apply the native deck rules (validation layer 3). + + Returns + ------- + list[Issue] + Layer-3 issues, mapped to objects where the failing row is known. + """ + try: + model, _ = self.to_model() + text = self._render(model) + except DeckExportError as exc: + return [Issue(Severity.ERROR, str(exc), exc.obj or self.project, None, 3)] + label = "" + try: + DeckFile.from_text( + text, path=model.path, caller_driven=model.caller_driven, label=label + ) + except DeckFormatError as exc: + message = str(exc) + obj: ModelObject = self.project + match = re.match(rf"^{re.escape(label)}:(\d+): ?(.*)$", message, re.DOTALL) + if match is not None: + obj = self.id_map.object_at_row(int(match.group(1)) - 1) or self.project + message = match.group(2) + elif message.startswith(f"{label}: "): + message = message[len(label) + 2 :] + return [Issue(Severity.ERROR, message, obj, None, 3)] + return [] + + # ------------------------------------------------------------------ helpers + + def _render(self, model: DeckModel) -> str: + try: + text = model.to_text(validate=False) + except (DeckReferenceError, TypeError, ValueError) as exc: + raise DeckExportError(str(exc)) from exc + self._map_rows(text) + return text + + def _map_rows(self, text: str) -> None: + rows = self.id_map.rows + rows.clear() + current: list[ModelObject] | None = None + skip = 0 + count = 0 + for index, line in enumerate(text.splitlines()): + if line.startswith("---------------------"): + name = line.strip("- ").strip() + current = self._sections.get(name) + skip = 2 if name in _TABLE_SECTIONS else 0 + count = 0 + continue + if current is None: + continue + if skip: + skip -= 1 + continue + if count < len(current): + rows[index] = current[count] + count += 1 + + def _fail(self, obj: ModelObject, message: str) -> DeckExportError: + return DeckExportError(f"{obj.label()}: {message}", obj) + + def _call(self, obj: ModelObject, action: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + try: + return action(*args, **kwargs) + except DeckExportError: + raise + except (DeckReferenceError, KeyError, TypeError, ValueError) as exc: + raise self._fail(obj, str(exc)) from exc + + def _ids(self) -> None: + project = self.project + for kind, items in ( + ("body", list(project.bodies)), + ("rod", list(project.rods)), + ("point", list(project.points)), + ("line", list(project.lines)), + ): + used: set[int] = set() + assigned: dict[int, int] = {} + for position, item in enumerate(items): + hint = item.deck_hints.get("deck_id") + if isinstance(hint, int) and not isinstance(hint, bool) and hint >= 1: + if hint in used: + continue + used.add(hint) + assigned[position] = hint + following = max(used, default=0) + for position, item in enumerate(items): + if position not in assigned: + following += 1 + while following in used: + following += 1 + used.add(following) + assigned[position] = following + self.id_map.add(item, kind, assigned[position]) + + def _id(self, obj: ModelObject) -> int: + found = self.id_map.deck_id(obj) + if not isinstance(found, int): + raise self._fail(obj, "is not part of the project") + return found + + def _libraries(self, model: DeckModel) -> None: + for item in self.project.line_types: + self._call(item, self._line_type, model, item) + self.id_map.add(item, "line_type", item.name) + self._sections["LINE TYPES"].append(item) + for rod_type in self.project.rod_types: + self._call( + rod_type, + model.add_rod_type, + rod_type.name, + diam=rod_type.diameter, + mass=rod_type.mass_per_length, + cd=rod_type.drag, + ca=rod_type.added_mass, + cd_end=rod_type.end_drag, + ca_end=rod_type.end_added_mass, + cd_ax=rod_type.axial_drag, + ca_ax=rod_type.axial_added_mass, + ) + self.id_map.add(rod_type, "rod_type", rod_type.name) + self._sections["ROD TYPES"].append(rod_type) + + def _line_type(self, model: DeckModel, item: LineType) -> None: + axial = item.axial + ea: float | tuple[float, ...] | _b.SyropeEA + if isinstance(axial, LinearAxial): + ea = axial.stiffness + elif isinstance(axial, ViscoelasticAxial): + if axial.dynamic_stiffness is not None: + ea = (axial.static_stiffness, axial.dynamic_stiffness) + elif axial.alpha_mbl is not None and axial.beta is not None: + ea = (axial.static_stiffness, axial.alpha_mbl, axial.beta) + else: + raise self._fail(item, "the viscoelastic model needs its dynamic stiffness") + elif isinstance(axial, SyropeAxial): + if not axial.settings_file: + raise self._fail(item, "the Syrope model needs a settings file") + ea = _b.SyropeEA(axial.settings_file, axial.alpha, axial.beta) + else: + raise self._fail(item, f"{axial.label()} cannot be written") + bending = item.bending + model.add_line_type( + item.name, + diam=item.diameter, + mass=item.mass_per_length, + ea=ea, + ba=_multi(axial.damping), + ei=bending.bending_stiffness, + cdn=item.drag_normal, + cdt=item.drag_axial, + can=item.added_mass_normal, + cat=item.added_mass_axial, + gas=bending.shear_stiffness, + gj=bending.torsional_stiffness, + irt=bending.rotary_inertia_axial, + irn=bending.rotary_inertia_normal, + ) + + def _bodies_and_rods(self, model: DeckModel) -> None: + self._deck_bodies = {} + for body in self.project.bodies: + self._deck_bodies[id(body)] = self._call(body, self._body, model, body) + self._sections["BODIES"].append(body) + self._deck_rods = {} + for rod in self.project.rods: + self._deck_rods[id(rod)] = self._call(rod, self._rod, model, rod) + self._sections["RODS"].append(rod) + for turbine in self.project.turbines: + ptfm = turbine.platform_displacement or None + self._call(turbine, model.add_turbine, turbine.number, *turbine.position, ptfm=ptfm) + self.id_map.add(turbine, "turbine", turbine.number) + self._sections["TURBINES"].append(turbine) + + def _body(self, model: DeckModel, body: Body) -> Any: + if isinstance(body, Floater): + if body.kind == "coupled": + token = _hinted(body, "Coupled", frozenset({"coupled", "cpld"})) + else: + token = _hinted(body, "Vessel", frozenset({"vessel", "ves"})) + else: + canonical, accepted = _BODY_TOKENS[(type(body), body.row_format)] + token = _hinted(body, canonical, accepted) + x, y, z = body.position + roll, pitch, yaw = body.orientation + if body.row_format == "moordyn": + return model.add_moordyn_body( + self._id(body), + token, + x, + y, + z, + roll=roll, + pitch=pitch, + yaw=yaw, + mass=body.mass, + cg=_multi(body.centre_of_gravity), + inertia=_multi(body.inertia) if body.inertia else 0.0, + volume=body.volume, + cda=_multi(body.drag_area), + ca=_multi(body.added_mass), + ) + if len(body.drag_area) != 1 or len(body.added_mass) != 1: + raise self._fail(body, "the CableDyn layout takes one drag area and one added mass") + inertia: tuple[float, float, float] | None = None + if body.inertia: + if len(body.inertia) != 3: + raise self._fail(body, "the CableDyn layout takes three inertias") + inertia = (body.inertia[0], body.inertia[1], body.inertia[2]) + return model.add_body( + self._id(body), + token, + x, + y, + z, + roll=roll, + pitch=pitch, + yaw=yaw, + mass=body.mass, + volume=body.volume, + c33=body.heave_stiffness, + c44=body.roll_stiffness, + c55=body.pitch_stiffness, + cda=body.drag_area[0], + ca=body.added_mass[0], + inertia=inertia, + ) + + def _rod(self, model: DeckModel, rod: Rod) -> _b.Rod: + if rod.rod_type is None: + raise self._fail(rod, "has no rod type") + canonical = _ROD_TOKENS[rod.attachment] + accepted = frozenset( + token + for token in {canonical.lower(), *_ROD_ATTACHMENT_ALIASES, "bodypin"} + if _rod_kind(token) == rod.attachment + ) + token = _hinted(rod, canonical, accepted) + body = None + if rod.body is not None: + body = self._deck_bodies.get(id(rod.body)) + if body is None: + raise self._fail(rod, "its body is not part of the project") + rod_type = model.rod_types[rod.rod_type.name] + return model.add_rod( + self._id(rod), + rod_type, + token, + rod.position_a, + rod.position_b, + rod.segments, + outputs=rod.outputs, + body=body, + ) + + def _rod_end(self, model: DeckModel, endpoint: RodEndpoint) -> _b.RodEnd: + rod = endpoint.parent + if not isinstance(rod, Rod) or id(rod) not in self._deck_rods: + raise DeckExportError(f"{endpoint.label()} is not part of the project", endpoint) + return model.rod_end(self._deck_rods[id(rod)], endpoint.end) + + def _points(self, model: DeckModel) -> None: + self._deck_points = {} + for point in self.project.points: + self._deck_points[id(point)] = self._call(point, self._point, model, point) + self._sections["POINTS"].append(point) + + def _point(self, model: DeckModel, point: Point) -> _b.Point: + body = None + rod_end = None + turbine = None + if isinstance(point, FixedPoint): + token = _hinted(point, "Fixed", frozenset({"fixed"})) + elif isinstance(point, FloaterPoint): + if point.kind == "coupled": + token = _hinted(point, "Coupled", frozenset({"coupled"})) + else: + token = _hinted(point, "Vessel", frozenset({"vessel"})) + elif isinstance(point, FreePoint): + token = _hinted(point, "Free", frozenset({"free"})) + elif isinstance(point, ConnectPoint): + token = _hinted(point, "Connect", frozenset({"connect"})) + elif isinstance(point, BodyPoint): + token = _hinted(point, "Body", frozenset({"body"})) + if point.body is None or id(point.body) not in self._deck_bodies: + raise self._fail(point, "is not attached to a body of the project") + body = self._deck_bodies[id(point.body)] + elif isinstance(point, RodPoint): + token = _hinted(point, "Rod", frozenset({"rod"})) + if point.rod_end is None: + raise self._fail(point, "is not attached to a rod end") + rod_end = self._rod_end(model, point.rod_end) + elif isinstance(point, TurbinePoint): + token = _hinted(point, "Turbine", frozenset({"turbine", "t"})) + turbine = point.turbine + else: + raise self._fail(point, "has no deck point type") + x, y, z = point.position + return model.add_point( + self._id(point), + token, + x, + y, + z, + mass=point.mass, + volume=point.volume, + cda=point.drag_area, + ca=point.added_mass, + body=body, + rod_end=rod_end, + turbine=turbine, + ) + + def _end(self, model: DeckModel, line: Line, target: LineEndTarget | None) -> Any: + if target is None: + raise self._fail(line, "has an unattached end") + if isinstance(target, RodEndpoint): + return self._rod_end(model, target) + if id(target) not in self._deck_points: + raise self._fail(line, f"is attached to {target.label()}, outside the project") + return self._deck_points[id(target)] + + def _lines(self, model: DeckModel) -> None: + self._deck_lines = {} + for line in self.project.lines: + deck_line = self._call(line, self._line, model, line) + self._deck_lines[id(line)] = deck_line + self._sections["LINES"].append(line) + if not (deck_line.stock_row and len(deck_line.sections) == 1): + self._sections["SECTIONS"].extend(line.sections) + + def _line(self, model: DeckModel, line: Line) -> _b.Line: + deck_line = model.add_line( + self._id(line), + self._end(model, line, line.end_a), + self._end(model, line, line.end_b), + outputs=line.outputs, + stock_row=bool(line.deck_hints.get("stock_row", False)), + ) + for section in line.sections: + if section.line_type is None: + raise self._fail(section, "has no line type") + if section.line_type.parent is not self.project: + raise self._fail(section, "uses a line type outside the project") + model.add_section( + deck_line, + model.line_types[section.line_type.name], + section.length, + section.segments, + ) + return deck_line + + def _line_of(self, obj: ModelObject, line: Line | None) -> _b.Line: + if line is None or id(line) not in self._deck_lines: + raise self._fail(obj, "refers to a line outside the project") + return self._deck_lines[id(line)] + + def _lines_of(self, obj: ModelObject, lines: Iterable[Line]) -> list[_b.Line]: + return [self._line_of(obj, line) for line in lines] + + def _rows(self, model: DeckModel) -> None: + project = self.project + for row in project.syrope_history: + lines = self._lines_of(row, row.lines) + self._call(row, model.add_syrope_ic, lines, row.max_tension, row.mean_tension) + self._sections["SYROPE IC"].append(row) + connections = _ordered( + (row, line) for line in project.lines for row in line.end_connections + ) + for connection, line in connections: + self._call(connection, self._end_connection, model, connection, line) + self._sections["END CONNECTIONS"].append(connection) + for eq in project.equivalent_buoyancy: + line_type = eq.line_type + if line_type is None or line_type.parent is not project: + raise self._fail(eq, "needs a line type of the project") + deck_type = model.line_types[line_type.name] + self._call( + eq, model.add_equivalent_buoyancy, deck_type, eq.diameter, eq.submerged_weight + ) + self._sections["EQUIVALENT BUOYANCY"].append(eq) + attachments = _ordered((row, line) for line in project.lines for row in line.attachments) + for attachment, line in attachments: + self._call(attachment, self._attachment, model, attachment, line) + self._sections["ATTACHMENTS"].append(attachment) + for failure in project.failures: + point = failure.point + if point is None or id(point) not in self._deck_points: + raise self._fail(failure, "needs a point of the project") + self._call( + failure, + model.add_failure, + self._deck_points[id(point)], + self._lines_of(failure, failure.lines), + fail_time=failure.time, + fail_tension=failure.tension, + ) + self._sections["FAILURE"].append(failure) + for control in project.controls: + lines = self._lines_of(control, control.lines) + self._call(control, model.add_control, control.channel, lines) + self._sections["CONTROL"].append(control) + for number, load in enumerate(project.external_loads, start=1): + if load.body is None or id(load.body) not in self._deck_bodies: + raise self._fail(load, "needs a body of the project") + hint = load.deck_hints.get("csys") + letter = "L" if load.axes == "body" else "G" + csys = hint if isinstance(hint, str) and hint.upper() == letter else letter + deck_id = load.deck_hints.get("deck_id") + load_id = deck_id if isinstance(deck_id, int) else number + self._call( + load, + model.add_external_load, + load_id, + self._deck_bodies[id(load.body)], + csys=csys, + force=_multi(load.force), + blin=_multi(load.linear_damping), + bquad=_multi(load.quadratic_damping), + ) + self.id_map.add(load, "external_load", load_id) + self._sections["EXTERNAL LOADS"].append(load) + + def _end_connection(self, model: DeckModel, row: EndConnection, line: Line) -> None: + stiffness: float | str + if row.rotation == "stiffness": + stiffness = row.rotational_stiffness + else: + accepted = _PINNED if row.rotation == "pinned" else _RIGID + hint = row.deck_hints.get("rotation_token") + canonical = "Pinned" if row.rotation == "pinned" else "Rigid" + stiffness = hint if isinstance(hint, str) and hint.lower() in accepted else canonical + torsion: float | str | None = None + if row.torsion == "stiffness": + torsion = row.torsional_stiffness + elif row.torsion in {"free", "rigid"}: + accepted = _PINNED if row.torsion == "free" else _RIGID + hint = row.deck_hints.get("torsion_token") + canonical = "Free" if row.torsion == "free" else "Rigid" + torsion = hint if isinstance(hint, str) and hint.lower() in accepted else canonical + model.add_end_connection( + self._line_of(row, line), + row.end, + stiffness, + row.direction, + torsion_stiffness=torsion, + normal=row.normal if torsion is not None else None, + pretwist=row.pretwist if torsion is not None else None, + ) + + def _attachment(self, model: DeckModel, row: Attachment, line: Line) -> None: + arc: float | tuple[float, float, float] = row.arc_length + if row.pitch is not None or row.last_arc_length is not None: + if row.pitch is None or row.last_arc_length is None: + raise self._fail(row, "a series needs both pitch and last_arc_length") + arc = (row.arc_length, row.pitch, row.last_arc_length) + model.add_attachment( + self._line_of(row, line), + arc, + mass=row.mass, + volume=row.volume, + cda=row.drag_area, + ca=row.added_mass, + cdax=row.axial_drag_area, + ) + + # ------------------------------------------------------------------ options + + def _options(self, model: DeckModel) -> None: + project = self.project + stored = project.deck_hints.get("options") + hints: dict[str, Any] = stored if isinstance(stored, dict) else {} + rows: list[_Row] = [] + fresh = 1.0e9 + + def add(identity: str, keyword: str, encoded: tuple[str, ...], owner: ModelObject) -> None: + nonlocal fresh + hint = hints.get(identity) + if isinstance(hint, dict): + same = list(encoded) == hint.get("encoded") + values = tuple(hint["values"]) if same else encoded + rows.append( + _Row( + float(hint["position"]), + str(hint.get("keyword") or keyword), + values, + hint.get("description"), + owner, + ) + ) + else: + fresh += 1.0 + rows.append(_Row(fresh, keyword, encoded, None, owner)) + + for scalar in _SCALARS: + owner = _owner(project, scalar.owner) + value = owner.get_value(scalar.attr) + if value is not None: + add(scalar.identity, scalar.keyword, _encode_scalar(scalar.codec, value), owner) + seabed = project.seabed + if isinstance(seabed, BathymetrySeabed) and seabed.file: + add("bathymetry", "bathymetryFile", (seabed.file,), seabed) + settings = project.settings + if settings.solver_row_given(): + add("dynamic_solver", "dynamic_solver", _encode_solver(settings), settings) + environment = project.environment + waves = environment.waves + if isinstance(waves, MultiTrainSea): + for train in waves.trains: + encoded = _encode_train(train) + hint = train.deck_hints.get("option") + if isinstance(hint, dict): + same = list(encoded) == hint.get("encoded") + rows.append( + _Row( + float(hint["position"]), + str(hint.get("keyword") or "wavetrain"), + tuple(hint["values"]) if same else encoded, + hint.get("description"), + train, + ) + ) + else: + fresh += 1.0 + rows.append(_Row(fresh, "wavetrain", encoded, None, train)) + elif not isinstance(waves, NoWaves) or "waves" in hints: + add("waves", "waves", _encode_wave(waves), environment) + current = environment.current + if not isinstance(current, NoCurrent) or "current" in hints: + add("current", "current", _encode_current(current), environment) + motion = project.motion + self._motion(motion, hints, add) + if motion.reference is not None: + add("vesselref", "vesselRef", _encode_vector(motion.reference), motion) + for extra in settings.extra_options: + position = extra.deck_hints.get("position") + if isinstance(position, int): + order = float(position) + else: + fresh += 1.0 + order = fresh + rows.append(_Row(order, extra.keyword, tuple(extra.values), extra.note, extra)) + rows.sort(key=lambda row: row.position) + for row in rows: + self._call( + row.owner, model.options.add, row.keyword, *row.values, description=row.description + ) + self._sections["OPTIONS"].append(row.owner) + + def _motion( + self, + motion: MotionSource, + hints: dict[str, Any], + add: Callable[[str, str, tuple[str, ...], ModelObject], None], + ) -> None: + if isinstance(motion, NoMotion): + return + file = motion.get_value("file") + if not file: + raise self._fail(motion, "needs a file") + keyword = "vesselRAO" if isinstance(motion, FloaterRAO) else _MOTION_KEYWORDS[type(motion)] + hint = hints.get("motion") + same_keyword = isinstance(hint, dict) and _option_key(str(hint.get("keyword"))) == ( + keyword.lower() + ) + add("motion" if same_keyword else "motion:changed", keyword, (file,), motion) + + def _outputs(self, model: DeckModel) -> None: + for channel in self.project.outputs.channels: + if isinstance(channel, ObjectChannel): + target = channel.target + deck_id = None if target is None else self.id_map.deck_id(target) + if not isinstance(deck_id, int): + raise self._fail(channel, "refers to an object without a deck id") + name = channel.channel_name(deck_id) + else: + name = channel.channel_name(None) + self._call(channel, model.outputs.add, name) + self._sections["OUTPUTS"].append(channel) + + +def _rod_kind(token: str) -> str: + kind = _ROD_ATTACHMENT_ALIASES.get(token, token) + return { + "vessel": "floater", + "bodypinned": "body_pinned", + "bodypin": "body_pinned", + }.get(kind, kind) + + +def _ordered(pairs: Iterable[tuple[ModelObject, Line]]) -> list[tuple[Any, Line]]: + items = list(pairs) + keyed = [] + for sequence, (obj, line) in enumerate(items): + row = obj.deck_hints.get("row") + keyed.append( + ((float(row) if isinstance(row, int) else 1.0e9 + sequence), sequence, obj, line) + ) + keyed.sort(key=lambda item: (item[0], item[1])) + return [(obj, line) for _, _, obj, line in keyed] diff --git a/python/cabledyn/project/descriptors.py b/python/cabledyn/project/descriptors.py new file mode 100644 index 0000000..3a9f959 --- /dev/null +++ b/python/cabledyn/project/descriptors.py @@ -0,0 +1,1018 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Typed, unit-aware, introspectable property descriptors. + +A model class declares its data as class attributes:: + + class Section(ModelObject): + line_type = Ref(LineType) + length = Quantity(LENGTH, 100.0, limit="section_length") + segments = Integer(10, limit="segments") + +Each descriptor knows its type, SI dimension, default, limits, role, group +and documentation. The descriptors drive the property panel, the project file, +validation layer 1, and the deck writer (which reads only ``physics`` +properties). Values are stored in SI units. +""" + +from __future__ import annotations + +import copy +import math +import numbers +import re +from collections.abc import Callable, Sequence +from enum import Enum +from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload + +from cabledyn.project.collection import ObjectCollection +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.schema import deck_limit +from cabledyn.project.units import DIMENSIONLESS, Dimension + +if TYPE_CHECKING: + from cabledyn.project.base import ModelObject, SerialContext + +__all__ = [ + "Bool", + "Child", + "Children", + "Choice", + "Colour", + "FilePath", + "Integer", + "JsonValue", + "OptionalBool", + "OptionalColour", + "OptionalInteger", + "OptionalQuantity", + "OptionalText", + "OptionalVec3", + "Property", + "Quantity", + "Ref", + "RefList", + "Role", + "Strategy", + "Text", + "TextList", + "Vec3", + "Vector", +] + +T = TypeVar("T") +ObjT = TypeVar("ObjT", bound="ModelObject") + +Vec3Value = tuple[float, float, float] + + +class Role(str, Enum): + """What a property describes, and whether the deck writer reads it.""" + + PHYSICS = "physics" + """Solver input, written to the deck.""" + CONSTRUCTION = "construction" + """Construction or library data (chain grade, rope construction, floater + hydrodynamics); not written to the deck today.""" + APPEARANCE = "appearance" + """Cosmetic data for the 3D views; never written to the deck.""" + DERIVED = "derived" + """Computed, read-only, shown for information.""" + META = "meta" + """Identity and bookkeeping (name, description, tags, GUI data).""" + + +def _is_number(value: object) -> bool: + return isinstance(value, numbers.Real) and not isinstance(value, bool) + + +def _same(a: object, b: object) -> bool: + if isinstance(a, float) and isinstance(b, float): + return a == b or (math.isnan(a) and math.isnan(b)) + if isinstance(a, tuple) and isinstance(b, tuple): + return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b, strict=True)) + if isinstance(a, ModelObjectLike) or isinstance(b, ModelObjectLike): + return a is b + return bool(a == b) + + +class ModelObjectLike: + """Marker base of objects compared by identity (set on ``ModelObject``).""" + + +class Property(Generic[T]): + """Base class of every property descriptor. + + Parameters + ---------- + default : object + Value of a new object. + role : Role + Physics, construction, appearance, derived, or meta. + group : str + Property-panel group, for example ``"Hydrodynamics"``. + label : str + Display label; derived from the attribute name when empty. + doc : str + One-sentence description for tooltips and generated documentation. + read_only : bool + Shown but not editable in the property panel. + """ + + kind = "property" + """Short type name for editors and documentation.""" + + def __init__( + self, + default: T, + *, + role: Role = Role.PHYSICS, + group: str = "", + label: str = "", + doc: str = "", + read_only: bool = False, + ) -> None: + self.default = default + self.role = role + self.group = group + self.label = label + self.doc = doc + self.read_only = read_only + self.name = "" + + def __set_name__(self, owner: type[Any], name: str) -> None: + self.name = name + if not self.label: + self.label = name.replace("_", " ").capitalize() + + @overload + def __get__(self, obj: None, owner: type[Any]) -> Property[T]: ... + + @overload + def __get__(self, obj: ModelObject, owner: type[Any]) -> T: ... + + def __get__(self, obj: ModelObject | None, owner: type[Any]) -> Property[T] | T: + if obj is None: + return self + value: T = obj._values[self.name] + return value + + def __set__(self, obj: ModelObject, value: T) -> None: + obj._assign(self, self.coerce(value)) + + @property + def is_reference(self) -> bool: + """Whether the property refers to objects owned elsewhere.""" + return False + + @property + def is_owner(self) -> bool: + """Whether the property owns child objects.""" + return False + + @property + def dimension(self) -> Dimension: + """The SI dimension of the value (dimensionless for non-quantities).""" + return DIMENSIONLESS + + def initial(self, obj: ModelObject) -> T: + """Return the value of a new object (a copy of the default).""" + return copy.deepcopy(self.default) + + def coerce(self, value: object) -> T: + """Return ``value`` converted to the stored type. + + Raises + ------ + TypeError + If ``value`` has the wrong type. + ValueError + If ``value`` cannot be stored at all (for example a bad choice). + """ + raise NotImplementedError + + def check(self, obj: ModelObject, value: T) -> list[Issue]: + """Return the layer-1 issues of ``value`` (finiteness and limits).""" + return [] + + def same(self, a: T, b: T) -> bool: + """Whether two values are equal for change detection.""" + return _same(a, b) + + def to_json(self, value: T, ctx: SerialContext) -> Any: + """Return a JSON-compatible form of ``value``.""" + return value + + def from_json(self, data: Any, ctx: SerialContext, obj: ModelObject) -> T: + """Rebuild a value written by :meth:`to_json`.""" + return self.coerce(data) + + def describe(self) -> dict[str, Any]: + """Return a JSON-compatible description, for documentation and editors.""" + return { + "name": self.name, + "kind": self.kind, + "label": self.label, + "role": self.role.value, + "group": self.group, + "doc": self.doc, + "dimension": self.dimension.key, + "read_only": self.read_only, + } + + def _issue(self, obj: ModelObject, message: str, *, layer: int = 1) -> Issue: + return Issue(Severity.ERROR, message, obj, self.name, layer) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.name!r}>" + + +class _NumberMixin: + """Limits shared by :class:`Quantity` and :class:`Integer`.""" + + minimum: float | None + maximum: float | None + limit: str | None + + def _limit_issues(self, prop: Property[Any], obj: ModelObject, value: float) -> list[Issue]: + if not math.isfinite(value): + return [prop._issue(obj, f"must be finite, got {value!r}")] + found: list[Issue] = [] + if self.minimum is not None and value < self.minimum: + found.append(prop._issue(obj, f"must be at least {self.minimum:g}")) + if self.maximum is not None and value > self.maximum: + found.append(prop._issue(obj, f"must be at most {self.maximum:g}")) + if self.limit is not None: + message = deck_limit(self.limit).violation(value) + if message is not None: + found.append(prop._issue(obj, message)) + return found + + +def _float(value: object, what: str) -> float: + if not _is_number(value): + raise TypeError(f"{what} must be a number, got {value!r}") + return float(value) # type: ignore[arg-type] + + +def _int(value: object, what: str) -> int: + if isinstance(value, bool) or not isinstance(value, numbers.Integral): + if isinstance(value, float) and value.is_integer(): + return int(value) + raise TypeError(f"{what} must be an integer, got {value!r}") + return int(value) + + +class Quantity(_NumberMixin, Property[float]): + """A real number with a physical dimension, stored in SI units. + + Parameters + ---------- + dimension : Dimension + What the value measures. + default : float + Value of a new object. + minimum, maximum : float | None + Inclusive bounds checked by validation layer 1. + limit : str | None + Key of a shared deck limit (:data:`cabledyn.project.schema.DECK_LIMITS`). + **kwargs + Role, group, label, doc, read_only (see :class:`Property`). + """ + + kind = "quantity" + + def __init__( + self, + dimension: Dimension = DIMENSIONLESS, + default: float = 0.0, + *, + minimum: float | None = None, + maximum: float | None = None, + limit: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(float(default), **kwargs) + self._dimension = dimension + self.minimum = minimum + self.maximum = maximum + self.limit = limit + + @property + def dimension(self) -> Dimension: + return self._dimension + + def coerce(self, value: object) -> float: + return _float(value, self.name) + + def check(self, obj: ModelObject, value: float) -> list[Issue]: + return self._limit_issues(self, obj, value) + + def describe(self) -> dict[str, Any]: + info = super().describe() + info.update(minimum=self.minimum, maximum=self.maximum, limit=self.limit) + return info + + +class OptionalQuantity(_NumberMixin, Property[float | None]): + """A :class:`Quantity` that may be unset (``None``). + + Parameters are those of :class:`Quantity`; the default is ``None``. + """ + + kind = "quantity" + + def __init__( + self, + dimension: Dimension = DIMENSIONLESS, + default: float | None = None, + *, + minimum: float | None = None, + maximum: float | None = None, + limit: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(None if default is None else float(default), **kwargs) + self._dimension = dimension + self.minimum = minimum + self.maximum = maximum + self.limit = limit + + @property + def dimension(self) -> Dimension: + return self._dimension + + def coerce(self, value: object) -> float | None: + return None if value is None else _float(value, self.name) + + def check(self, obj: ModelObject, value: float | None) -> list[Issue]: + return [] if value is None else self._limit_issues(self, obj, value) + + +class Integer(_NumberMixin, Property[int]): + """An integer. + + Parameters + ---------- + default : int + Value of a new object. + minimum, maximum : int | None + Inclusive bounds. + limit : str | None + Key of a shared deck limit. + **kwargs + See :class:`Property`. + """ + + kind = "integer" + + def __init__( + self, + default: int = 0, + *, + minimum: int | None = None, + maximum: int | None = None, + limit: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(default, **kwargs) + self.minimum = minimum + self.maximum = maximum + self.limit = limit + + def coerce(self, value: object) -> int: + return _int(value, self.name) + + def check(self, obj: ModelObject, value: int) -> list[Issue]: + return self._limit_issues(self, obj, float(value)) + + +class OptionalInteger(_NumberMixin, Property[int | None]): + """An :class:`Integer` that may be unset (``None``).""" + + kind = "integer" + + def __init__( + self, + default: int | None = None, + *, + minimum: int | None = None, + maximum: int | None = None, + limit: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(default, **kwargs) + self.minimum = minimum + self.maximum = maximum + self.limit = limit + + def coerce(self, value: object) -> int | None: + return None if value is None else _int(value, self.name) + + def check(self, obj: ModelObject, value: int | None) -> list[Issue]: + return [] if value is None else self._limit_issues(self, obj, float(value)) + + +class Bool(Property[bool]): + """A Boolean flag.""" + + kind = "bool" + + def __init__(self, default: bool = False, **kwargs: Any) -> None: + super().__init__(default, **kwargs) + + def coerce(self, value: object) -> bool: + if not isinstance(value, bool): + raise TypeError(f"{self.name} must be True or False, got {value!r}") + return value + + +class OptionalBool(Property[bool | None]): + """A Boolean flag that may be unset (``None``: the native default applies).""" + + kind = "bool" + + def __init__(self, default: bool | None = None, **kwargs: Any) -> None: + super().__init__(default, **kwargs) + + def coerce(self, value: object) -> bool | None: + if value is not None and not isinstance(value, bool): + raise TypeError(f"{self.name} must be True, False, or None, got {value!r}") + return value + + +class Choice(Property[str]): + """One of a fixed set of strings. + + Parameters + ---------- + choices : Sequence[str] + The admissible values. + default : str + Value of a new object; the first choice when empty. + **kwargs + See :class:`Property`. + """ + + kind = "choice" + + def __init__(self, choices: Sequence[str], default: str = "", **kwargs: Any) -> None: + if not choices: + raise ValueError("a Choice needs at least one choice") + super().__init__(default or choices[0], **kwargs) + self.choices = tuple(choices) + if self.default not in self.choices: + raise ValueError(f"default {self.default!r} is not one of {self.choices}") + + def coerce(self, value: object) -> str: + if not isinstance(value, str): + raise TypeError(f"{self.name} must be a string, got {value!r}") + if value not in self.choices: + raise ValueError(f"{self.name} must be one of {', '.join(self.choices)}; got {value!r}") + return value + + def describe(self) -> dict[str, Any]: + info = super().describe() + info["choices"] = list(self.choices) + return info + + +class Text(Property[str]): + """A free-text string (single line unless ``multiline``).""" + + kind = "text" + + def __init__(self, default: str = "", *, multiline: bool = False, **kwargs: Any) -> None: + super().__init__(default, **kwargs) + self.multiline = multiline + + def coerce(self, value: object) -> str: + if not isinstance(value, str): + raise TypeError(f"{self.name} must be a string, got {value!r}") + return value + + +class OptionalText(Property[str | None]): + """A string that may be unset (``None``).""" + + kind = "text" + + def __init__(self, default: str | None = None, **kwargs: Any) -> None: + super().__init__(default, **kwargs) + + def coerce(self, value: object) -> str | None: + if value is not None and not isinstance(value, str): + raise TypeError(f"{self.name} must be a string or None, got {value!r}") + return value + + +class FilePath(OptionalText): + """A file path, relative to the project's deck folder or absolute; may be unset.""" + + kind = "file" + + +class TextList(Property[tuple[str, ...]]): + """An ordered tuple of strings (for example tags).""" + + kind = "text_list" + + def __init__(self, default: Sequence[str] = (), **kwargs: Any) -> None: + super().__init__(tuple(default), **kwargs) + + def coerce(self, value: object) -> tuple[str, ...]: + if isinstance(value, str) or not isinstance(value, (list, tuple)): + raise TypeError(f"{self.name} must be a sequence of strings, got {value!r}") + if not all(isinstance(item, str) for item in value): + raise TypeError(f"{self.name} must hold only strings") + return tuple(value) + + def to_json(self, value: tuple[str, ...], ctx: SerialContext) -> Any: + return list(value) + + +_COLOUR = re.compile(r"^#[0-9a-fA-F]{6}$") + + +def _colour(value: object, what: str) -> str: + if not isinstance(value, str) or _COLOUR.match(value) is None: + raise ValueError(f"{what} must be a colour '#rrggbb', got {value!r}") + return value.lower() + + +class Colour(Property[str]): + """An sRGB colour written ``#rrggbb``; an appearance property by default.""" + + kind = "colour" + + def __init__(self, default: str = "#808080", **kwargs: Any) -> None: + kwargs.setdefault("role", Role.APPEARANCE) + super().__init__(_colour(default, "default"), **kwargs) + + def coerce(self, value: object) -> str: + return _colour(value, self.name) + + +class OptionalColour(Property[str | None]): + """A :class:`Colour` that may be unset (``None``: inherit).""" + + kind = "colour" + + def __init__(self, default: str | None = None, **kwargs: Any) -> None: + kwargs.setdefault("role", Role.APPEARANCE) + super().__init__(default, **kwargs) + + def coerce(self, value: object) -> str | None: + return None if value is None else _colour(value, self.name) + + +def _floats(value: object, what: str) -> tuple[float, ...]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{what} must be a sequence of numbers, got {value!r}") + return tuple(_float(item, what) for item in value) + + +class Vector(_NumberMixin, Property[tuple[float, ...]]): + """A tuple of real numbers of one dimension, with admissible lengths. + + Used for deck fields that take one or several values (``a|b|c``), such + as a body's drag areas. + + Parameters + ---------- + dimension : Dimension + Dimension of every component. + default : Sequence[float] + Value of a new object. + lengths : Sequence[int] + Admissible numbers of components. + limit : str | None + Shared deck limit applied to every component. + **kwargs + See :class:`Property`. + """ + + kind = "vector" + + def __init__( + self, + dimension: Dimension = DIMENSIONLESS, + default: Sequence[float] = (0.0,), + *, + lengths: Sequence[int] = (1, 3), + limit: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(tuple(float(v) for v in default), **kwargs) + self._dimension = dimension + self.lengths = tuple(lengths) + self.minimum = None + self.maximum = None + self.limit = limit + + @property + def dimension(self) -> Dimension: + return self._dimension + + def coerce(self, value: object) -> tuple[float, ...]: + values = _floats(value, self.name) + if len(values) not in self.lengths: + sizes = " or ".join(str(n) for n in self.lengths) + raise ValueError(f"{self.name} must have {sizes} values, got {len(values)}") + return values + + def check(self, obj: ModelObject, value: tuple[float, ...]) -> list[Issue]: + found: list[Issue] = [] + for item in value: + found.extend(self._limit_issues(self, obj, item)) + return found + + def to_json(self, value: tuple[float, ...], ctx: SerialContext) -> Any: + return list(value) + + def describe(self) -> dict[str, Any]: + info = super().describe() + info["lengths"] = list(self.lengths) + return info + + +class Vec3(_NumberMixin, Property[Vec3Value]): + """A three-component vector ``(x, y, z)`` of one dimension.""" + + kind = "vec3" + + def __init__( + self, + dimension: Dimension = DIMENSIONLESS, + default: Sequence[float] = (0.0, 0.0, 0.0), + *, + limit: str | None = None, + **kwargs: Any, + ) -> None: + x, y, z = (float(v) for v in default) + super().__init__((x, y, z), **kwargs) + self._dimension = dimension + self.minimum = None + self.maximum = None + self.limit = limit + + @property + def dimension(self) -> Dimension: + return self._dimension + + def coerce(self, value: object) -> Vec3Value: + values = _floats(value, self.name) + if len(values) != 3: + raise ValueError(f"{self.name} must have three values, got {len(values)}") + return (values[0], values[1], values[2]) + + def check(self, obj: ModelObject, value: Vec3Value) -> list[Issue]: + found: list[Issue] = [] + for item in value: + found.extend(self._limit_issues(self, obj, item)) + return found + + def to_json(self, value: Vec3Value, ctx: SerialContext) -> Any: + return list(value) + + +class OptionalVec3(_NumberMixin, Property[Vec3Value | None]): + """A :class:`Vec3` that may be unset (``None``).""" + + kind = "vec3" + + def __init__( + self, + dimension: Dimension = DIMENSIONLESS, + default: Sequence[float] | None = None, + *, + limit: str | None = None, + **kwargs: Any, + ) -> None: + start: Vec3Value | None = None + if default is not None: + x, y, z = (float(v) for v in default) + start = (x, y, z) + super().__init__(start, **kwargs) + self._dimension = dimension + self.minimum = None + self.maximum = None + self.limit = limit + + @property + def dimension(self) -> Dimension: + return self._dimension + + def coerce(self, value: object) -> Vec3Value | None: + if value is None: + return None + values = _floats(value, self.name) + if len(values) != 3: + raise ValueError(f"{self.name} must have three values, got {len(values)}") + return (values[0], values[1], values[2]) + + def check(self, obj: ModelObject, value: Vec3Value | None) -> list[Issue]: + found: list[Issue] = [] + for item in value or (): + found.extend(self._limit_issues(self, obj, item)) + return found + + def to_json(self, value: Vec3Value | None, ctx: SerialContext) -> Any: + return None if value is None else list(value) + + +class JsonValue(Property[Any]): + """Opaque JSON-compatible data (GUI layouts, plot specifications, ...). + + The value is deep-copied on assignment; it is never written to a deck. + """ + + kind = "json" + + def __init__(self, default: Any = None, **kwargs: Any) -> None: + kwargs.setdefault("role", Role.META) + super().__init__(default, **kwargs) + + def coerce(self, value: object) -> Any: + return copy.deepcopy(value) + + +class Ref(Property[ObjT | None], Generic[ObjT]): + """A reference to another object of the model (not to a name or an id). + + Renaming the target never breaks the reference. Removing a referenced + object either fails or cascades (see + :class:`cabledyn.project.commands.RemoveObject`): a referrer whose + ``required`` reference loses its target is removed with it, and an + optional reference is cleared. + + Parameters + ---------- + target : type + The class (or common base class) of admissible targets. + required : bool + A missing target is an error (validation layer 2). + **kwargs + See :class:`Property`. + """ + + kind = "ref" + + def __init__(self, target: type[ObjT], *, required: bool = True, **kwargs: Any) -> None: + super().__init__(None, **kwargs) + self.target = target + self.required = required + + @property + def is_reference(self) -> bool: + return True + + def initial(self, obj: ModelObject) -> ObjT | None: + return None + + def coerce(self, value: object) -> ObjT | None: + if value is not None and not isinstance(value, self.target): + raise TypeError( + f"{self.name} must refer to a {self.target.__name__}, got {type(value).__name__}" + ) + return value + + def same(self, a: ObjT | None, b: ObjT | None) -> bool: + return a is b + + def check(self, obj: ModelObject, value: ObjT | None) -> list[Issue]: + if value is None: + if self.required: + return [self._issue(obj, "refers to nothing", layer=2)] + return [] + if value.root() is not obj.root(): + return [self._issue(obj, f"refers to {value.label()}, outside this model", layer=2)] + return [] + + def to_json(self, value: ObjT | None, ctx: SerialContext) -> Any: + return None if value is None else value.uid + + def from_json(self, data: Any, ctx: SerialContext, obj: ModelObject) -> ObjT | None: + if data is not None: + ctx.defer(obj, self, data) + return None + + def resolve(self, data: Any, ctx: SerialContext) -> ObjT | None: + """Return the target of a uid written by :meth:`to_json`.""" + return self.coerce(ctx.lookup(data)) + + def describe(self) -> dict[str, Any]: + info = super().describe() + info.update(target=self.target.__name__, required=self.required) + return info + + +class RefList(Property[tuple[ObjT, ...]], Generic[ObjT]): + """An ordered tuple of references to other objects. + + Parameters + ---------- + target : type + Class of admissible targets. + min_items : int + Fewest targets; a removal that leaves fewer removes the referrer + when cascading. + **kwargs + See :class:`Property`. + """ + + kind = "ref_list" + + def __init__(self, target: type[ObjT], *, min_items: int = 0, **kwargs: Any) -> None: + super().__init__((), **kwargs) + self.target = target + self.min_items = min_items + + @property + def is_reference(self) -> bool: + return True + + def initial(self, obj: ModelObject) -> tuple[ObjT, ...]: + return () + + def coerce(self, value: object) -> tuple[ObjT, ...]: + if isinstance(value, (str, bytes)) or not isinstance(value, Sequence): + raise TypeError(f"{self.name} must be a sequence of objects, got {value!r}") + items = tuple(value) + for item in items: + if not isinstance(item, self.target): + raise TypeError( + f"{self.name} must refer to {self.target.__name__} objects, " + f"got {type(item).__name__}" + ) + return items + + def same(self, a: tuple[ObjT, ...], b: tuple[ObjT, ...]) -> bool: + return len(a) == len(b) and all(x is y for x, y in zip(a, b, strict=True)) + + def check(self, obj: ModelObject, value: tuple[ObjT, ...]) -> list[Issue]: + found: list[Issue] = [] + if len(value) < self.min_items: + found.append(self._issue(obj, f"needs at least {self.min_items} item(s)", layer=2)) + for item in value: + if item.root() is not obj.root(): + found.append( + self._issue(obj, f"refers to {item.label()}, outside this model", layer=2) + ) + return found + + def to_json(self, value: tuple[ObjT, ...], ctx: SerialContext) -> Any: + return [item.uid for item in value] + + def from_json(self, data: Any, ctx: SerialContext, obj: ModelObject) -> tuple[ObjT, ...]: + if data: + ctx.defer(obj, self, data) + return () + + def resolve(self, data: Any, ctx: SerialContext) -> tuple[ObjT, ...]: + """Return the targets of uids written by :meth:`to_json`.""" + return self.coerce([ctx.lookup(uid) for uid in data]) + + def describe(self) -> dict[str, Any]: + info = super().describe() + info.update(target=self.target.__name__, min_items=self.min_items) + return info + + +class Child(Property[ObjT], Generic[ObjT]): + """One owned sub-object, created with the owner and replaceable as a whole. + + Parameters + ---------- + base : type + Class of the sub-object (subclasses are admissible). + factory : Callable[[], ModelObject] | None + Creates the default sub-object; ``base`` itself when ``None``. + **kwargs + See :class:`Property`. + """ + + kind = "child" + + def __init__( + self, base: type[ObjT], factory: Callable[[], ObjT] | None = None, **kwargs: Any + ) -> None: + super().__init__(None, **kwargs) # type: ignore[arg-type] + self.base = base + self.factory: Callable[[], ObjT] = factory or base + + @property + def is_owner(self) -> bool: + return True + + def initial(self, obj: ModelObject) -> ObjT: + child = self.factory() + child._attach(obj, self.name) + return child + + def coerce(self, value: object) -> ObjT: + if not isinstance(value, self.base): + raise TypeError( + f"{self.name} must be a {self.base.__name__}, got {type(value).__name__}" + ) + return value + + def same(self, a: ObjT, b: ObjT) -> bool: + return a is b + + def to_json(self, value: ObjT, ctx: SerialContext) -> Any: + return value.to_dict(ctx) + + def from_json(self, data: Any, ctx: SerialContext, obj: ModelObject) -> ObjT: + built = ctx.build(data, owner=obj, slot=self.name) + if built is None or not isinstance(built, self.base): + return self.initial(obj) + built._attach(obj, self.name) + return built + + def describe(self) -> dict[str, Any]: + info = super().describe() + info["base"] = self.base.__name__ + return info + + +class Strategy(Child[ObjT], Generic[ObjT]): + """A polymorphic sub-object such as an axial model or a wave model. + + Any registered subclass of ``base`` can be swapped in (see + :class:`cabledyn.project.commands.ReplaceStrategy`); :meth:`options` lists + them for an editor. + """ + + kind = "strategy" + + def options(self) -> list[type[ModelObject]]: + """Return the registered concrete classes admissible here.""" + from cabledyn.project.base import REGISTRY + + return REGISTRY.subclasses_of(self.base) + + +class Children(Property[ObjectCollection[ObjT]], Generic[ObjT]): + """An owned, ordered, observable collection of sub-objects. + + The collection itself cannot be replaced; insert, remove, and move items + through it (or through commands, for undo). + + Parameters + ---------- + item_type : type + Class of the items. + min_items : int + Fewest items; a cascading removal that leaves fewer removes the owner. + **kwargs + See :class:`Property`. + """ + + kind = "children" + + def __init__(self, item_type: type[ObjT], *, min_items: int = 0, **kwargs: Any) -> None: + super().__init__(None, **kwargs) # type: ignore[arg-type] + self.item_type = item_type + self.min_items = min_items + + @property + def is_owner(self) -> bool: + return True + + def __set__(self, obj: ModelObject, value: ObjectCollection[ObjT]) -> None: + raise AttributeError(f"{self.name} is a collection; insert or remove its items") + + def initial(self, obj: ModelObject) -> ObjectCollection[ObjT]: + return ObjectCollection(obj, self.name, self.item_type) + + def coerce(self, value: object) -> ObjectCollection[ObjT]: + raise AttributeError(f"{self.name} is a collection; insert or remove its items") + + def same(self, a: ObjectCollection[ObjT], b: ObjectCollection[ObjT]) -> bool: + return a is b + + def check(self, obj: ModelObject, value: ObjectCollection[ObjT]) -> list[Issue]: + if len(value) < self.min_items: + return [self._issue(obj, f"needs at least {self.min_items} item(s)", layer=2)] + return [] + + def to_json(self, value: ObjectCollection[ObjT], ctx: SerialContext) -> Any: + return [item.to_dict(ctx) for item in value] + + def from_json(self, data: Any, ctx: SerialContext, obj: ModelObject) -> ObjectCollection[ObjT]: + collection: ObjectCollection[ObjT] = obj._values[self.name] + for index, item in enumerate(data): + built = ctx.build(item, owner=obj, slot=self.name, index=index) + if built is None: + continue + if not isinstance(built, self.item_type): + ctx.warn(f"{self.name}: dropped a {type(built).__name__}, not a valid item") + continue + collection._items.append(built) + built._attach(obj, self.name) + return collection + + def describe(self) -> dict[str, Any]: + info = super().describe() + info.update(item_type=self.item_type.__name__, min_items=self.min_items) + return info diff --git a/python/cabledyn/project/environment.py b/python/cabledyn/project/environment.py new file mode 100644 index 0000000..cf1cc05 --- /dev/null +++ b/python/cabledyn/project/environment.py @@ -0,0 +1,317 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Environment, sea state, current, seabed, and prescribed motion. + +These objects hold the deck's environment ``OPTIONS``. An option property +left unset (``None``) is not written, so the solver's own default applies; +the defaults are deliberately not restated here. +""" + +from __future__ import annotations + +from cabledyn.project.appearance import EnvironmentAppearance, SeabedAppearance +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Child, + Children, + Choice, + FilePath, + OptionalInteger, + OptionalQuantity, + OptionalText, + OptionalVec3, + Quantity, + Role, + Strategy, + Vec3, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.units import ( + ACCELERATION, + ANGLE, + DENSITY, + DIMENSIONLESS, + LENGTH, + PRESSURE_PER_LENGTH, + PRESSURE_TIME_PER_LENGTH, + TIME, + VELOCITY, +) + +__all__ = [ + "BathymetrySeabed", + "CurrentModel", + "Environment", + "FlatSeabed", + "FloaterMotionRecord", + "FloaterRAO", + "JonswapWave", + "MotionFile", + "MotionSource", + "MultiTrainSea", + "NoCurrent", + "NoMotion", + "NoWaves", + "OchiHubbleWave", + "ProfileCurrent", + "RegularWave", + "Seabed", + "SpectrumWave", + "UniformCurrent", + "WaveModel", + "WaveTrain", +] + +_SEA = "Sea state" + + +# --------------------------------------------------------------------------- waves + + +class WaveModel(ModelObject): + """Base class of the wave models (the deck's ``waves`` and ``wavetrain`` rows).""" + + type_label = "Waves" + abstract = True + + +@model_type("wave.none") +class NoWaves(WaveModel): + """Still water.""" + + type_label = "No waves" + + +@model_type("wave.regular") +class RegularWave(WaveModel): + """A regular wave: linear (Airy) or nonlinear stream-function theory.""" + + type_label = "Regular wave" + theory = Choice( + ("airy", "stream", "dean"), group=_SEA, doc="Airy, or Dean stream-function theory." + ) + height = Quantity(LENGTH, 2.0, limit="wave_height", group=_SEA, doc="Wave height H.") + period = Quantity(TIME, 10.0, limit="wave_period", group=_SEA, doc="Wave period T.") + direction = Quantity(ANGLE, 0.0, limit="coordinate", group=_SEA, doc="Propagation direction.") + + +@model_type("wave.jonswap") +class JonswapWave(WaveModel): + """A JONSWAP sea state.""" + + type_label = "JONSWAP spectrum" + significant_height = Quantity(LENGTH, 2.0, limit="wave_height", group=_SEA, doc="Hs.") + peak_period = Quantity(TIME, 10.0, limit="wave_period", group=_SEA, doc="Tp.") + peak_enhancement = Quantity(DIMENSIONLESS, 3.3, minimum=1.0, group=_SEA, doc="Gamma.") + direction = Quantity(ANGLE, 0.0, limit="coordinate", group=_SEA) + + +@model_type("wave.spectrum") +class SpectrumWave(WaveModel): + """A two-parameter spectral sea state (Pierson-Moskowitz family, Torsethaugen).""" + + type_label = "Spectral sea" + spectrum = Choice(("pm", "issc", "bretschneider", "torsethaugen"), group=_SEA) + significant_height = Quantity(LENGTH, 2.0, limit="wave_height", group=_SEA, doc="Hs.") + peak_period = Quantity(TIME, 10.0, limit="wave_period", group=_SEA, doc="Tp.") + direction = Quantity(ANGLE, 0.0, limit="coordinate", group=_SEA) + + +@model_type("wave.ochi_hubble") +class OchiHubbleWave(WaveModel): + """A two-peak Ochi-Hubble sea state.""" + + type_label = "Ochi-Hubble spectrum" + height_1 = Quantity(LENGTH, 2.0, limit="wave_height", group=_SEA, doc="Hs of the first peak.") + period_1 = Quantity(TIME, 12.0, limit="wave_period", group=_SEA, doc="Tp of the first peak.") + shape_1 = Quantity(DIMENSIONLESS, 3.0, group=_SEA, doc="Shape parameter of the first peak.") + height_2 = Quantity(LENGTH, 1.0, minimum=0.0, group=_SEA, doc="Hs of the second peak.") + period_2 = Quantity(TIME, 7.0, limit="wave_period", group=_SEA, doc="Tp of the second peak.") + shape_2 = Quantity(DIMENSIONLESS, 1.0, group=_SEA, doc="Shape parameter of the second peak.") + direction = Quantity(ANGLE, 0.0, limit="coordinate", group=_SEA) + + +@model_type("wave.train") +class WaveTrain(ModelObject): + """One wave train of a multi-train sea (one deck ``wavetrain`` row).""" + + type_label = "Wave train" + abstract = False + wave = Strategy(WaveModel, RegularWave, group=_SEA, doc="The train's wave or spectrum.") + spreading = OptionalQuantity( + DIMENSIONLESS, group=_SEA, doc="cos-2s spreading exponent (spectral trains)." + ) + + def invariants(self) -> list[Issue]: + found = super().invariants() + if isinstance(self.wave, (NoWaves, MultiTrainSea)): + found.append(Issue(Severity.ERROR, "a train needs a wave or spectrum", self, "wave", 2)) + elif isinstance(self.wave, RegularWave) and self.wave.theory != "airy": + found.append(Issue(Severity.ERROR, "a regular train must be airy", self, "wave", 2)) + return found + + +@model_type("wave.multi_train") +class MultiTrainSea(WaveModel): + """A sea made of several wave trains (swell and wind sea, for example).""" + + type_label = "Multi-train sea" + trains = Children(WaveTrain, min_items=1, group=_SEA) + + +# --------------------------------------------------------------------------- current + + +class CurrentModel(ModelObject): + """Base class of the current models (the deck's ``current`` row).""" + + type_label = "Current" + abstract = True + + +@model_type("current.none") +class NoCurrent(CurrentModel): + """No current.""" + + type_label = "No current" + + +@model_type("current.uniform") +class UniformCurrent(CurrentModel): + """A depth-uniform current.""" + + type_label = "Uniform current" + velocity = Vec3(VELOCITY, (0.5, 0.0, 0.0), limit="fluid_speed", group="Current") + + +@model_type("current.profile") +class ProfileCurrent(CurrentModel): + """A current varying linearly between two depths.""" + + type_label = "Two-level current" + z_1 = Quantity(LENGTH, -50.0, limit="coordinate", group="Current", doc="First level z.") + velocity_1 = Vec3(VELOCITY, limit="fluid_speed", group="Current") + z_2 = Quantity(LENGTH, 0.0, limit="coordinate", group="Current", doc="Second level z.") + velocity_2 = Vec3(VELOCITY, (0.5, 0.0, 0.0), limit="fluid_speed", group="Current") + + +# --------------------------------------------------------------------------- environment + + +@model_type("environment") +class Environment(ModelObject): + """Water, gravity, waves and current.""" + + type_label = "Environment" + abstract = False + gravity = OptionalQuantity(ACCELERATION, limit="gravity", group="Constants") + water_density = OptionalQuantity(DENSITY, limit="water_density", group="Constants") + water_depth = OptionalQuantity( + LENGTH, + limit="water_depth", + group="Water", + doc="Flat-seabed depth; unset with a bathymetry file or a host-supplied depth.", + ) + waves = Strategy(WaveModel, NoWaves, group=_SEA) + current = Strategy(CurrentModel, NoCurrent, group="Current") + ramp_time = OptionalQuantity(TIME, minimum=0.0, group=_SEA, doc="Start-up ramp of the waves.") + wave_seed = OptionalInteger(group=_SEA, doc="Random-phase seed of spectral seas.") + wave_spreading = OptionalQuantity(DIMENSIONLESS, group=_SEA, doc="cos-2s exponent.") + wave_directions = OptionalInteger(group=_SEA, doc="Direction bins of a spread sea.") + wave_components = OptionalInteger(group=_SEA, doc="Frequency components per direction.") + stream_order = OptionalInteger(group=_SEA, doc="Fourier terms of a stream-function wave.") + wave_time_step = OptionalQuantity(TIME, group=_SEA, doc="Resampling step of wave files.") + water_kinematics = OptionalText( + group="Kinematics", doc="Kinematics source: a file, a host selector, or a mode number." + ) + current_mode = OptionalInteger(group="Kinematics", doc="Current-profile file mode.") + appearance = Child(EnvironmentAppearance, role=Role.APPEARANCE) + + +# --------------------------------------------------------------------------- seabed + + +class Seabed(ModelObject): + """Base class of the seabed descriptions; holds the contact properties.""" + + type_label = "Seabed" + abstract = True + stiffness = OptionalQuantity( + PRESSURE_PER_LENGTH, limit="coefficient", group="Contact", doc="Penalty stiffness base." + ) + damping = OptionalQuantity( + PRESSURE_TIME_PER_LENGTH, limit="coefficient", group="Contact", doc="Normal damping base." + ) + friction = OptionalQuantity(DIMENSIONLESS, minimum=0.0, group="Friction") + friction_axial = OptionalQuantity(DIMENSIONLESS, minimum=0.0, group="Friction") + friction_lateral = OptionalQuantity(DIMENSIONLESS, minimum=0.0, group="Friction") + appearance = Child(SeabedAppearance, role=Role.APPEARANCE) + + +@model_type("seabed.flat") +class FlatSeabed(Seabed): + """A horizontal seabed at the environment's water depth.""" + + type_label = "Flat seabed" + + +@model_type("seabed.bathymetry") +class BathymetrySeabed(Seabed): + """A seabed from a structured x/y/depth bathymetry file.""" + + type_label = "Bathymetry" + file = FilePath(group="Bathymetry", doc="Bathymetry file.") + + def invariants(self) -> list[Issue]: + found = super().invariants() + if not self.file: + found.append(Issue(Severity.ERROR, "needs a bathymetry file", self, "file", 2)) + return found + + +# --------------------------------------------------------------------------- motion + + +class MotionSource(ModelObject): + """Base class of the prescribed-motion sources of a standalone run. + + The deck keywords ``motionFile``, ``vesselMotion``, ``vesselRAO`` and + ``vesselRef`` map to :class:`MotionFile`, :class:`FloaterMotionRecord`, + :class:`FloaterRAO` and ``reference``. + """ + + type_label = "Prescribed motion" + abstract = True + reference = OptionalVec3( + LENGTH, group="Motion", doc="Floater reference point, rotation centre and RAO origin." + ) + + +@model_type("motion.none") +class NoMotion(MotionSource): + """No prescribed motion.""" + + type_label = "No prescribed motion" + + +@model_type("motion.file") +class MotionFile(MotionSource): + """Point, rod-end and body motion from a time-series file.""" + + type_label = "Motion file" + file = FilePath(group="Motion") + + +@model_type("motion.floater_record") +class FloaterMotionRecord(MotionSource): + """A 6-DOF floater record moving every floater-borne point rigidly.""" + + type_label = "Floater motion record" + file = FilePath(group="Motion") + + +@model_type("motion.floater_rao") +class FloaterRAO(MotionSource): + """Floater motion as the RAO response to the waves.""" + + type_label = "Floater RAO" + file = FilePath(group="Motion") diff --git a/python/cabledyn/project/events.py b/python/cabledyn/project/events.py new file mode 100644 index 0000000..9009815 --- /dev/null +++ b/python/cabledyn/project/events.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Change events published by model objects. + +Every :class:`~cabledyn.project.ModelObject` owns an :class:`EventHub`. A +change is published on the changed object's hub and then on the hub of every +ancestor, so a subscriber on the :class:`~cabledyn.project.Project` sees every +change in the model. The events are plain, immutable records; a GUI bridge +re-emits them as toolkit signals. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from cabledyn.project.base import ModelObject + +__all__ = [ + "ChildAdded", + "ChildMoved", + "ChildRemoved", + "Event", + "EventHub", + "PropertyChanged", + "ReferenceChanged", + "Subscription", +] + + +@dataclass(frozen=True) +class Event: + """Base class of every model event. + + Attributes + ---------- + source : ModelObject + The object whose state changed. + """ + + source: ModelObject + + +@dataclass(frozen=True) +class PropertyChanged(Event): + """A value property (number, text, choice, child object) changed. + + Attributes + ---------- + name : str + Property name. + old, new : object + Previous and new value. + """ + + name: str + old: Any + new: Any + + +@dataclass(frozen=True) +class ReferenceChanged(Event): + """A reference property (:class:`~cabledyn.project.Ref` or + :class:`~cabledyn.project.RefList`) now points elsewhere. + + Attributes + ---------- + name : str + Property name. + old, new : object + Previous and new target (an object, a tuple of objects, or ``None``). + """ + + name: str + old: Any + new: Any + + +@dataclass(frozen=True) +class ChildAdded(Event): + """An object was inserted into a child collection of ``source``. + + Attributes + ---------- + collection : str + Name of the :class:`~cabledyn.project.Children` property. + child : ModelObject + The inserted object. + index : int + Its position. + """ + + collection: str + child: ModelObject + index: int + + +@dataclass(frozen=True) +class ChildRemoved(Event): + """An object was removed from a child collection of ``source``. + + Attributes + ---------- + collection : str + Name of the collection. + child : ModelObject + The removed object. + index : int + Its former position. + """ + + collection: str + child: ModelObject + index: int + + +@dataclass(frozen=True) +class ChildMoved(Event): + """An object moved within a child collection of ``source``. + + Attributes + ---------- + collection : str + Name of the collection. + child : ModelObject + The moved object. + old_index, new_index : int + Former and new position. + """ + + collection: str + child: ModelObject + old_index: int + new_index: int + + +Listener = Callable[[Event], None] + + +class Subscription: + """Handle returned by :meth:`EventHub.subscribe`; call :meth:`cancel` to stop.""" + + def __init__(self, hub: EventHub, listener: Listener) -> None: + self._hub = hub + self._listener = listener + + def cancel(self) -> None: + """Stop delivering events to the listener (idempotent).""" + self._hub._remove(self._listener) + + +class EventHub: + """A list of listeners called synchronously, in subscription order.""" + + def __init__(self) -> None: + self._listeners: list[Listener] = [] + + def subscribe(self, listener: Listener) -> Subscription: + """Call ``listener(event)`` for every event published on this hub. + + Parameters + ---------- + listener : Callable[[Event], None] + The callback. + + Returns + ------- + Subscription + Handle to cancel the subscription. + """ + self._listeners.append(listener) + return Subscription(self, listener) + + def _remove(self, listener: Listener) -> None: + for index, existing in enumerate(self._listeners): + if existing is listener: + del self._listeners[index] + return + + def emit(self, event: Event) -> None: + """Deliver ``event`` to every listener. + + Parameters + ---------- + event : Event + The event. + """ + for listener in tuple(self._listeners): + listener(event) + + def __len__(self) -> int: + return len(self._listeners) diff --git a/python/cabledyn/project/issues.py b/python/cabledyn/project/issues.py new file mode 100644 index 0000000..91b297f --- /dev/null +++ b/python/cabledyn/project/issues.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Validation issues reported by the three validation layers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cabledyn.project.base import ModelObject + +__all__ = ["Issue", "Severity"] + + +class Severity(str, Enum): + """How serious an :class:`Issue` is.""" + + ERROR = "error" + """The model cannot be written as a valid deck, or is inconsistent.""" + WARNING = "warning" + """The model is valid but probably not what was meant.""" + INFO = "info" + """A note, for example an option kept verbatim.""" + + +@dataclass(frozen=True) +class Issue: + """One validation finding, attached to an object and optionally a property. + + Attributes + ---------- + severity : Severity + Error, warning, or note. + message : str + Human-readable message. + obj : ModelObject | None + The object concerned, or ``None`` for a model-wide finding. + prop : str | None + The property concerned, if any. + layer : int + ``1`` property checks, ``2`` object invariants, ``3`` native deck rules. + """ + + severity: Severity + message: str + obj: ModelObject | None = None + prop: str | None = None + layer: int = 1 + + @property + def is_error(self) -> bool: + """Whether the issue is an error.""" + return self.severity is Severity.ERROR + + def __str__(self) -> str: + where = "" if self.obj is None else f"{self.obj.label()}: " + field = "" if self.prop is None else f"{self.prop}: " + return f"{self.severity.value}: {where}{field}{self.message}" diff --git a/python/cabledyn/project/lines.py b/python/cabledyn/project/lines.py new file mode 100644 index 0000000..6277b59 --- /dev/null +++ b/python/cabledyn/project/lines.py @@ -0,0 +1,313 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Lines and what they own: sections, end connections, attachments and ancillaries.""" + +from __future__ import annotations + +import itertools + +from cabledyn.project.appearance import ModuleGeometry +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Bool, + Child, + Children, + Choice, + Integer, + OptionalColour, + OptionalQuantity, + OptionalVec3, + Quantity, + Ref, + Role, + Text, + Vec3, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.points import LineEndTarget +from cabledyn.project.types import LineType +from cabledyn.project.units import ( + ANGLE, + AREA, + DIMENSIONLESS, + LENGTH, + MASS, + ROTATIONAL_STIFFNESS, + VOLUME, +) + +__all__ = [ + "Attachment", + "BendRestrictor", + "BendStiffener", + "BuoyancyModule", + "ClumpWeight", + "EndConnection", + "Line", + "LineAncillary", + "Section", + "TouchdownProtection", +] + +_A = Role.APPEARANCE + + +@model_type("section") +class Section(ModelObject): + """A length of one line type (one deck ``SECTIONS`` row), from End A.""" + + type_label = "Section" + abstract = False + line_type = Ref(LineType, group="Section") + length = Quantity(LENGTH, 100.0, limit="section_length", group="Section", doc="Unstretched.") + segments = Integer(10, limit="segments", group="Section", doc="Number of elements.") + colour = OptionalColour(group="Appearance", doc="Overrides the line type's colour.") + + +@model_type("end_connection") +class EndConnection(ModelObject): + """The bending (and optional torsion) boundary at one end of a finite-EI line. + + One deck ``END CONNECTIONS`` row. ``rotation`` ``stiffness`` uses + ``rotational_stiffness``; ``pinned`` and ``rigid`` are the deck + keywords. ``torsion`` ``none`` writes the six-column row. + """ + + type_label = "End connection" + abstract = False + end = Choice(("A", "B"), group="Connection") + rotation = Choice(("stiffness", "pinned", "rigid"), group="Bending") + rotational_stiffness = Quantity(ROTATIONAL_STIFFNESS, 0.0, minimum=0.0, group="Bending") + direction = Vec3( + DIMENSIONLESS, (0.0, 0.0, -1.0), group="Bending", doc="Reference direction, End A to B." + ) + torsion = Choice(("none", "stiffness", "free", "rigid"), group="Torsion") + torsional_stiffness = Quantity(ROTATIONAL_STIFFNESS, 0.0, minimum=0.0, group="Torsion") + normal = OptionalVec3(DIMENSIONLESS, group="Torsion", doc="Zero-twist reference normal.") + pretwist = OptionalQuantity(ANGLE, group="Torsion", doc="Roll of the end frame.") + + def invariants(self) -> list[Issue]: + found = super().invariants() + if self.direction == (0.0, 0.0, 0.0): + found.append(Issue(Severity.ERROR, "must not be zero", self, "direction", 2)) + if self.torsion == "none": + if self.normal is not None or self.pretwist is not None: + found.append( + Issue(Severity.ERROR, "normal and pretwist need a torsion", self, "torsion", 2) + ) + elif self.normal is None: + found.append(Issue(Severity.ERROR, "a torsion needs a normal", self, "normal", 2)) + return found + + +class Attachment(ModelObject): + """Base class of discrete items on a finite-EI line (deck ``ATTACHMENTS``). + + The location is an arc length from End A, or a series ``arc_length``, + ``arc_length + pitch``, ... up to ``last_arc_length``. + """ + + type_label = "Attachment" + abstract = True + arc_length = Quantity(LENGTH, 0.0, minimum=0.0, group="Location", doc="From End A.") + pitch = OptionalQuantity(LENGTH, group="Location", doc="Spacing of a series.") + last_arc_length = OptionalQuantity(LENGTH, group="Location", doc="Last item of a series.") + mass = Quantity(MASS, 0.0, group="Properties", doc="Dry mass of one item.") + volume = Quantity(VOLUME, 0.0, group="Properties", doc="Displaced volume of one item.") + drag_area = Quantity(AREA, 0.0, group="Properties", doc="Normal drag area.") + added_mass = Quantity(DIMENSIONLESS, 0.0, group="Properties") + axial_drag_area = OptionalQuantity(AREA, group="Properties") + + def is_series(self) -> bool: + """Whether the attachment is a series of identical items.""" + return self.pitch is not None + + def locations(self) -> list[float]: + """Return the arc length of every item (one value for a single item).""" + if self.pitch is None or self.last_arc_length is None or self.pitch <= 0.0: + return [self.arc_length] + found: list[float] = [] + count = int((self.last_arc_length - self.arc_length) / self.pitch + 1.0e-9) + 1 + for index in range(max(count, 0)): + found.append(self.arc_length + index * self.pitch) + return found + + def invariants(self) -> list[Issue]: + found = super().invariants() + if (self.pitch is None) != (self.last_arc_length is None): + found.append( + Issue(Severity.ERROR, "give pitch and last_arc_length together", self, "pitch", 2) + ) + line = self.parent + if isinstance(line, Line): + total = line.length + for location in self.locations()[:1] + self.locations()[-1:]: + if location > total: + found.append( + Issue( + Severity.ERROR, + f"arc length {location:g} m is beyond the line ({total:g} m)", + self, + "arc_length", + 2, + ) + ) + break + return found + + +@model_type("attachment.buoyancy_module") +class BuoyancyModule(Attachment): + """A buoyancy module (or a series of them).""" + + type_label = "Buoyancy module" + geometry = Child(ModuleGeometry, role=_A) + + def geometric_volume(self) -> float | None: + """Return the volume of the drawn shells, or ``None`` if not fully given.""" + g = self.geometry + if g.outer_diameter is None or g.length is None: + return None + bore = g.bore or 0.0 + return 3.141592653589793 / 4.0 * (g.outer_diameter**2 - bore**2) * g.length + + def invariants(self) -> list[Issue]: + found = super().invariants() + drawn = self.geometric_volume() + if drawn is not None and self.volume > 0.0 and abs(drawn - self.volume) > 0.1 * self.volume: + found.append( + Issue( + Severity.WARNING, + f"the drawn module volume {drawn:.3g} m^3 differs from the physics " + f"volume {self.volume:.3g} m^3 by more than 10 %", + self, + "geometry", + 2, + ) + ) + return found + + +@model_type("attachment.clump_weight") +class ClumpWeight(Attachment): + """A clump weight (or a series of them).""" + + type_label = "Clump weight" + style = Choice(("cylinder", "chain_clump"), role=_A, group="Appearance") + clump_diameter = OptionalQuantity(LENGTH, minimum=0.0, role=_A, group="Appearance") + clump_length = OptionalQuantity(LENGTH, minimum=0.0, role=_A, group="Appearance") + + +class LineAncillary(ModelObject): + """Base class of line ancillaries drawn over an arc-length range. + + Ancillaries are cosmetic: the stiffness and mass they add are modelled + with sections and attachments. They have no deck rows. + """ + + type_label = "Ancillary" + abstract = True + start = Quantity(LENGTH, 0.0, minimum=0.0, role=_A, group="Location", doc="From End A.") + colour = OptionalColour(group="Appearance") + + def extent(self) -> tuple[float, float]: + """Return the covered arc-length range ``(start, end)``.""" + raise NotImplementedError + + def invariants(self) -> list[Issue]: + found = super().invariants() + low, high = self.extent() + if high < low: + found.append(Issue(Severity.ERROR, "ends before it starts", self, "start", 2)) + line = self.parent + if isinstance(line, Line) and high > line.length: + found.append(Issue(Severity.ERROR, "extends beyond the line", self, "start", 2)) + return found + + +@model_type("ancillary.bend_stiffener") +class BendStiffener(LineAncillary): + """A conical bend stiffener at a hang-off.""" + + type_label = "Bend stiffener" + length = Quantity(LENGTH, 5.0, minimum=0.0, role=_A, group="Shape") + root_diameter = Quantity(LENGTH, 0.8, minimum=0.0, role=_A, group="Shape") + tip_diameter = Quantity(LENGTH, 0.25, minimum=0.0, role=_A, group="Shape") + profile = Choice(("linear", "curved"), role=_A, group="Shape") + flange = Bool(True, role=_A, group="Shape", doc="Steel root flange.") + + def extent(self) -> tuple[float, float]: + return (self.start, self.start + self.length) + + +@model_type("ancillary.bend_restrictor") +class BendRestrictor(LineAncillary): + """Interlocking vertebrae limiting the bend radius.""" + + type_label = "Bend restrictor" + end = Quantity(LENGTH, 5.0, minimum=0.0, role=_A, group="Location") + element_length = Quantity(LENGTH, 0.3, minimum=0.0, role=_A, group="Shape") + outer_diameter = Quantity(LENGTH, 0.5, minimum=0.0, role=_A, group="Shape") + locking_radius = OptionalQuantity(LENGTH, minimum=0.0, role=_A, group="Shape") + + def extent(self) -> tuple[float, float]: + return (self.start, self.end) + + +@model_type("ancillary.touchdown_protection") +class TouchdownProtection(LineAncillary): + """Half-shell protection sleeves over the touchdown zone.""" + + type_label = "Touchdown protection" + end = Quantity(LENGTH, 20.0, minimum=0.0, role=_A, group="Location") + segment_length = Quantity(LENGTH, 1.0, minimum=0.0, role=_A, group="Shape") + outer_diameter = Quantity(LENGTH, 0.4, minimum=0.0, role=_A, group="Shape") + + def extent(self) -> tuple[float, float]: + return (self.start, self.end) + + +@model_type("line") +class Line(ModelObject): + """A line from End A (fairlead side) to End B (anchor side). + + One deck ``LINES`` row with its ordered sections. + """ + + type_label = "Line" + abstract = False + end_a = Ref(LineEndTarget, group="Ends", doc="End A attachment.") + end_b = Ref(LineEndTarget, group="Ends", doc="End B attachment.") + sections = Children(Section, min_items=1, group="Sections") + end_connections = Children(EndConnection, group="Ends") + attachments = Children(Attachment, group="Attachments") + ancillaries = Children(LineAncillary, role=_A, group="Ancillaries") + outputs = Text("-", group="Outputs", doc="Output flags: '-', or p, t, r combined.") + colour = OptionalColour(group="Appearance") + + @property + def length(self) -> float: + """Total unstretched length, the sum of the section lengths.""" + return float(sum(section.length for section in self.sections)) + + @property + def segment_count(self) -> int: + """Total number of elements.""" + return sum(section.segments for section in self.sections) + + def invariants(self) -> list[Issue]: + found = super().invariants() + if self.end_a is not None and self.end_a is self.end_b: + found.append(Issue(Severity.ERROR, "both ends attach to one object", self, "end_b", 2)) + seen: set[str] = set() + for row in self.end_connections: + if row.end in seen: + found.append( + Issue(Severity.ERROR, f"End {row.end} has two end connections", row, "end", 2) + ) + seen.add(row.end) + ranges = sorted((item.extent(), item.uid) for item in self.ancillaries) + for (first, _), (second, uid) in itertools.pairwise(ranges): + if second[0] < first[1]: + other = next(item for item in self.ancillaries if item.uid == uid) + found.append(Issue(Severity.ERROR, "overlaps another ancillary", other, "start", 2)) + return found diff --git a/python/cabledyn/project/points.py b/python/cabledyn/project/points.py new file mode 100644 index 0000000..31277de --- /dev/null +++ b/python/cabledyn/project/points.py @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Points and the things a line end can attach to. + +A line end refers to a :class:`LineEndTarget`: a :class:`Point` of any kind, +or one end of a rod (:class:`RodEndpoint`). The deck's ``POINTS`` types map +to the point classes; ``Coupled`` and ``Vessel`` points are +:class:`FloaterPoint` objects. +""" + +from __future__ import annotations + +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.bodies import Body +from cabledyn.project.descriptors import ( + Choice, + Integer, + OptionalColour, + OptionalQuantity, + Quantity, + Ref, + Role, + Vec3, +) +from cabledyn.project.units import AREA, DIMENSIONLESS, LENGTH, MASS, VOLUME + +__all__ = [ + "BodyPoint", + "ConnectPoint", + "FixedPoint", + "FloaterPoint", + "FreePoint", + "LineEndTarget", + "Point", + "RodEndpoint", + "RodPoint", + "TurbinePoint", +] + +_A = Role.APPEARANCE + + +class LineEndTarget(ModelObject): + """Base class of everything a line end can attach to.""" + + type_label = "Attachment target" + abstract = True + + +@model_type("rod.endpoint") +class RodEndpoint(LineEndTarget): + """End A or End B of a rod; owned by the rod, never removed on its own.""" + + type_label = "Rod end" + abstract = False + end = Choice(("A", "B"), read_only=True, role=Role.META, group="Identity") + + @classmethod + def make(cls, end: str) -> RodEndpoint: + """Return a new endpoint for ``end`` (``"A"`` or ``"B"``).""" + item = cls(f"End {end}") + item._values["end"] = type(item).end.coerce(end) + return item + + def label(self) -> str: + rod = self.parent + owner = "" if rod is None else f"{rod.label()} " + return f"{owner}End {self.end}" + + +class Point(LineEndTarget): + """Base class of the points: position and lumped hydrodynamic properties.""" + + type_label = "Point" + abstract = True + position = Vec3(LENGTH, limit="coordinate", group="Position") + mass = Quantity(MASS, 0.0, limit="coefficient", group="Properties") + volume = Quantity(VOLUME, 0.0, limit="coefficient", group="Properties", doc="Displaced volume.") + drag_area = Quantity(AREA, 0.0, limit="coefficient", group="Properties", doc="CdA.") + added_mass = Quantity(DIMENSIONLESS, 0.0, limit="small_coefficient", group="Properties") + connector = Choice( + ( + "none", + "shackle", + "h_link", + "triplate", + "subsea_connector", + "bell_mouth", + "hang_off", + ), + role=_A, + group="Appearance", + doc="Hardware drawn at the point.", + ) + colour = OptionalColour(group="Appearance") + + +@model_type("point.fixed") +class FixedPoint(Point): + """A point fixed in space, such as an anchor (deck ``Fixed``).""" + + type_label = "Fixed point" + anchor = Choice( + ("none", "drag", "suction_pile", "driven_pile", "gravity"), + role=_A, + group="Appearance", + doc="Anchor drawn at the point.", + ) + anchor_size = OptionalQuantity(LENGTH, minimum=0.0, role=_A, group="Appearance") + + +@model_type("point.floater") +class FloaterPoint(Point): + """A point moved with the floater (deck ``Coupled`` or ``Vessel``).""" + + type_label = "Floater point" + kind = Choice(("coupled", "floater"), group="Motion", doc="Deck point type Coupled or Vessel.") + + +@model_type("point.free") +class FreePoint(Point): + """A free point solved for (deck ``Free``).""" + + type_label = "Free point" + + +@model_type("point.connect") +class ConnectPoint(Point): + """A free connection point joining several lines (deck ``Connect``).""" + + type_label = "Connection point" + + +@model_type("point.turbine") +class TurbinePoint(Point): + """A point carried by a FAST.Farm turbine (deck ``Turbine``/``T``). + + The position is relative to the turbine's reference. + """ + + type_label = "Turbine point" + turbine = Integer(1, minimum=1, maximum=10000, group="Farm", doc="Turbine number J.") + + +@model_type("point.body") +class BodyPoint(Point): + """A point fixed to a body; the position is in body axes (deck ``Body``).""" + + type_label = "Body point" + body = Ref(Body, group="Attachment") + + +@model_type("point.rod") +class RodPoint(Point): + """A point at a rod end (deck ``RodA``/``RodB``).""" + + type_label = "Rod-end point" + rod_end = Ref(RodEndpoint, group="Attachment") diff --git a/python/cabledyn/project/project.py b/python/cabledyn/project/project.py new file mode 100644 index 0000000..5b71321 --- /dev/null +++ b/python/cabledyn/project/project.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The :class:`Project`: the root of the object graph.""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator +from typing import Any, TypeVar + +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.bodies import Body, Turbine +from cabledyn.project.descriptors import ( + Bool, + Child, + Children, + OptionalText, + Role, + Strategy, + Text, +) +from cabledyn.project.environment import ( + Environment, + FlatSeabed, + MotionSource, + NoMotion, + Seabed, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.lines import Line +from cabledyn.project.points import Point +from cabledyn.project.rods import Rod +from cabledyn.project.rows import ( + Control, + EquivalentBuoyancy, + ExternalLoad, + Failure, + OutputRequest, + SyropeIC, +) +from cabledyn.project.settings import AnalysisSettings +from cabledyn.project.studio import StudioData +from cabledyn.project.types import FloaterType, LineType, RodType + +__all__ = ["Project"] + +M = TypeVar("M", bound=ModelObject) + + +@model_type("project") +class Project(ModelObject): + """A CableDyn model: environment, settings, libraries, objects and GUI data. + + Examples + -------- + >>> from cabledyn.project import ( + ... FixedPoint, FloaterPoint, GenericLineType, Line, Project, Section) + >>> project = Project("Single chain") + >>> chain = project.line_types.append(GenericLineType("chain", diameter=0.252)) + >>> fairlead = project.points.append(FloaterPoint("fairlead", position=(0, 0, -14))) + >>> anchor = project.points.append(FixedPoint("anchor", position=(400, 0, -50))) + >>> line = project.lines.append(Line("mooring", end_a=fairlead, end_b=anchor)) + >>> _ = line.sections.append(Section(line_type=chain, length=410.0, segments=41)) + >>> line.length + 410.0 + """ + + type_label = "Project" + abstract = False + title = Text("CableDyn model", role=Role.META, group="Project", doc="Deck title.") + caller_driven = Bool( + False, + role=Role.META, + group="Project", + doc="Validate the deck for the OpenFAST coupling instead of the standalone driver.", + ) + deck_path = OptionalText( + role=Role.META, + group="Project", + doc="Nominal deck path; relative files (motion, bathymetry, ...) resolve from its folder.", + ) + environment = Child(Environment) + seabed = Strategy(Seabed, FlatSeabed) + motion = Strategy(MotionSource, NoMotion, doc="Prescribed motion of a standalone run.") + settings = Child(AnalysisSettings) + line_types = Children(LineType, group="Libraries") + rod_types = Children(RodType, group="Libraries") + floater_types = Children(FloaterType, group="Libraries") + bodies = Children(Body, group="Objects") + rods = Children(Rod, group="Objects") + turbines = Children(Turbine, group="Objects") + points = Children(Point, group="Objects") + lines = Children(Line, group="Objects") + equivalent_buoyancy = Children(EquivalentBuoyancy, group="Line rows") + syrope_history = Children(SyropeIC, group="Line rows") + failures = Children(Failure, group="Events") + controls = Children(Control, group="Events") + external_loads = Children(ExternalLoad, group="Loads") + outputs = Child(OutputRequest) + studio = Child(StudioData, role=Role.META) + + def _setup(self) -> None: + super()._setup() + self.unknown_objects: list[dict[str, Any]] = [] + self.load_warnings: list[str] = [] + + def find(self, uid: str) -> ModelObject | None: + """Return the object with ``uid`` anywhere in the project, or ``None``.""" + for obj in self.walk(): + if obj.uid == uid: + return obj + return None + + def objects_of(self, kind: type[M]) -> Iterator[M]: + """Yield every object of class ``kind`` in the project, depth first.""" + for obj in self.walk(): + if isinstance(obj, kind): + yield obj + + def used_by(self, obj: ModelObject) -> list[tuple[ModelObject, str]]: + """Return the objects (and property names) that refer to ``obj`` or its descendants. + + Referrers inside ``obj`` itself are not listed. + """ + from cabledyn.project.references import referrers + + return referrers(self, obj) + + def invariants(self) -> list[Issue]: + found = super().invariants() + found.extend(_unique_names(self.line_types, "line type")) + found.extend(_unique_names(self.rod_types, "rod type")) + numbers: dict[int, Turbine] = {} + for turbine in self.turbines: + if turbine.number in numbers: + found.append( + Issue(Severity.ERROR, "turbine number is used twice", turbine, "number", 2) + ) + numbers[turbine.number] = turbine + if self.unknown_objects: + found.append( + Issue( + Severity.WARNING, + f"{len(self.unknown_objects)} object(s) of unknown type are kept but " + "not written to the deck", + self, + None, + 2, + ) + ) + return found + + def validate_all(self, *, deck: bool = True) -> list[Issue]: + """Run every validation layer over the whole project. + + Parameters + ---------- + deck : bool + Also run layer 3 (write the deck and apply the native deck rules). + + Returns + ------- + list[Issue] + Every issue found. + """ + from cabledyn.project.validation import validate_project + + return validate_project(self, deck=deck) + + +def _unique_names(items: Iterable[ModelObject], what: str) -> list[Issue]: + found: list[Issue] = [] + seen: set[str] = set() + for item in items: + key = item.name.casefold() + if not item.name: + found.append(Issue(Severity.ERROR, f"a {what} needs a name", item, "name", 2)) + elif key in seen: + found.append( + Issue(Severity.ERROR, f"{what} name {item.name!r} is used twice", item, "name", 2) + ) + seen.add(key) + return found diff --git a/python/cabledyn/project/references.py b/python/cabledyn/project/references.py new file mode 100644 index 0000000..69780cc --- /dev/null +++ b/python/cabledyn/project/references.py @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Reverse references ("used by") and the cascade rules of object removal. + +Objects refer to each other through :class:`~cabledyn.project.Ref` and +:class:`~cabledyn.project.RefList` properties. Removing an object that is +still referred to either fails (:class:`ObjectInUseError`) or cascades: + +* a referrer whose *required* reference loses its target is removed too; +* an optional reference is cleared; +* a reference list drops the target, and a referrer left with fewer targets + than its ``min_items`` is removed; +* an owner whose child collection falls below its ``min_items`` (a line + without sections) is removed; + +and so on until nothing changes. Only objects held in a collection can be +removed; an object owned through a single :class:`~cabledyn.project.Child` +slot (a rod end, the environment) goes with its owner. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from cabledyn.project.base import ModelObject, collection_of +from cabledyn.project.descriptors import Children, Ref, RefList + +__all__ = ["ObjectInUseError", "RemovalPlan", "plan_removal", "referrers"] + + +class ObjectInUseError(ValueError): + """An object cannot be removed without cascading: other objects refer to it. + + Attributes + ---------- + referrers : tuple[tuple[ModelObject, str], ...] + The referring objects and property names. + """ + + def __init__(self, message: str, found: list[tuple[ModelObject, str]]) -> None: + super().__init__(message) + self.referrers = tuple(found) + + +def _ids(obj: ModelObject) -> set[int]: + return {id(item) for item in obj.walk()} + + +def referrers(root: ModelObject, obj: ModelObject) -> list[tuple[ModelObject, str]]: + """Return ``(referrer, property name)`` pairs that point into ``obj``. + + Parameters + ---------- + root : ModelObject + The object graph to search (normally the project). + obj : ModelObject + The referenced object; references to its descendants count too. + + Returns + ------- + list[tuple[ModelObject, str]] + Referrers outside ``obj``, in model order, each property once. + """ + inside = _ids(obj) + found: list[tuple[ModelObject, str]] = [] + for item in root.walk(): + if id(item) in inside: + continue + names: list[str] = [] + for name, target in item.references(): + if id(target) in inside and name not in names: + names.append(name) + found.extend((item, name) for name in names) + return found + + +@dataclass +class RemovalPlan: + """What removing objects does to the model. + + Attributes + ---------- + removed : list[ModelObject] + Objects to take out of their collections (none inside another). + reference_updates : list[tuple[ModelObject, str, Any, Any]] + ``(object, property, old value, new value)`` for surviving referrers. + """ + + removed: list[ModelObject] = field(default_factory=list) + reference_updates: list[tuple[ModelObject, str, Any, Any]] = field(default_factory=list) + + +def _removable(obj: ModelObject) -> bool: + return collection_of(obj) is not None + + +def plan_removal( + root: ModelObject, targets: list[ModelObject], *, cascade: bool = False +) -> RemovalPlan: + """Work out the cascade of removing ``targets`` from ``root``. + + Parameters + ---------- + root : ModelObject + The object graph (normally the project). + targets : list[ModelObject] + Objects to remove; each must sit in a collection. + cascade : bool + Allow dependants to be removed or updated; otherwise refuse when + anything outside the targets refers to them. + + Returns + ------- + RemovalPlan + The objects to remove and the references to update. + + Raises + ------ + ValueError + If a target is not held in a collection. + ObjectInUseError + If ``cascade`` is false and a target is still referred to. + """ + for target in targets: + if not _removable(target): + raise ValueError(f"{target.label()} is not in a collection and cannot be removed") + if not cascade: + found: list[tuple[ModelObject, str]] = [] + doomed_ids: set[int] = set() + for target in targets: + doomed_ids |= _ids(target) + for target in targets: + for item, name in referrers(root, target): + if id(item) not in doomed_ids and (item, name) not in found: + found.append((item, name)) + if found: + names = ", ".join(f"{item.label()}.{name}" for item, name in found) + raise ObjectInUseError( + f"{targets[0].label()} is still used by {names}; remove those first or cascade", + found, + ) + doomed: list[ModelObject] = [] + for target in targets: + if all(item is not target for item in doomed): + doomed.append(target) + changed = True + while changed: + changed = False + doomed_ids = set() + for item in doomed: + doomed_ids |= _ids(item) + candidates: list[ModelObject] = [] + for item in doomed: + owner = item.parent + collection = collection_of(item) + if owner is None or collection is None or id(owner) in doomed_ids: + continue + prop = type(owner).get_property(collection.name) + if isinstance(prop, Children) and prop.min_items > 0: + remaining = [x for x in collection if id(x) not in doomed_ids] + if len(remaining) < prop.min_items and _removable(owner): + candidates.append(owner) + for item in root.walk(): + if id(item) in doomed_ids or not _removable(item): + continue + for prop in type(item).properties(): + value = item._values[prop.name] + if isinstance(prop, Ref) and prop.required: + if value is not None and id(value) in doomed_ids: + candidates.append(item) + break + elif isinstance(prop, RefList) and prop.min_items > 0: + remaining = [x for x in value if id(x) not in doomed_ids] + if len(remaining) != len(value) and len(remaining) < prop.min_items: + candidates.append(item) + break + for item in candidates: + if id(item) not in doomed_ids and all(x is not item for x in doomed): + doomed.append(item) + changed = True + doomed_ids = set() + for item in doomed: + doomed_ids |= _ids(item) + plan = RemovalPlan() + for item in root.walk(): + if id(item) in doomed_ids: + continue + for prop in type(item).properties(): + value = item._values[prop.name] + if isinstance(prop, Ref) and value is not None and id(value) in doomed_ids: + plan.reference_updates.append((item, prop.name, value, None)) + elif isinstance(prop, RefList): + kept = tuple(x for x in value if id(x) not in doomed_ids) + if len(kept) != len(value): + plan.reference_updates.append((item, prop.name, value, kept)) + for item in doomed: + ancestor = item.parent + nested = False + while ancestor is not None: + if any(ancestor is other for other in doomed): + nested = True + break + ancestor = ancestor.parent + if not nested: + plan.removed.append(item) + return plan diff --git a/python/cabledyn/project/rods.py b/python/cabledyn/project/rods.py new file mode 100644 index 0000000..dcdcd85 --- /dev/null +++ b/python/cabledyn/project/rods.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Rods: rigid cylinders with two ends that lines and points can attach to.""" + +from __future__ import annotations + +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.bodies import Body +from cabledyn.project.descriptors import ( + Child, + Choice, + Integer, + OptionalColour, + Ref, + Text, + Vec3, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.points import RodEndpoint +from cabledyn.project.types import RodType +from cabledyn.project.units import LENGTH + +__all__ = ["Rod"] + +_BODY_KINDS = frozenset({"body", "body_pinned"}) + + +@model_type("rod") +class Rod(ModelObject): + """A rod (one deck ``RODS`` row). + + ``attachment`` ``coupled`` and ``floater`` correspond to the deck + types ``Coupled`` and ``Vessel``; ``body`` and ``body_pinned`` need + ``body``, and the end positions are then in body axes. + """ + + type_label = "Rod" + abstract = False + rod_type = Ref(RodType, group="Type") + attachment = Choice( + ("free", "fixed", "pinned", "coupled", "floater", "body", "body_pinned"), + group="Attachment", + ) + body = Ref(Body, required=False, group="Attachment") + position_a = Vec3(LENGTH, limit="coordinate", group="Geometry", doc="End A position.") + position_b = Vec3( + LENGTH, (0.0, 0.0, -10.0), limit="coordinate", group="Geometry", doc="End B position." + ) + segments = Integer(1, minimum=0, group="Discretisation", doc="0 for a zero-length rod.") + outputs = Text("-", group="Outputs", doc="Output flags: '-' or 'p'.") + end_a = Child(RodEndpoint, lambda: RodEndpoint.make("A")) + end_b = Child(RodEndpoint, lambda: RodEndpoint.make("B")) + colour = OptionalColour(group="Appearance") + + def endpoint(self, end: str) -> RodEndpoint: + """Return the endpoint object of ``end`` (``"A"`` or ``"B"``).""" + if end.upper() not in {"A", "B"}: + raise ValueError(f"end must be A or B, got {end!r}") + return self.end_a if end.upper() == "A" else self.end_b + + def invariants(self) -> list[Issue]: + found = super().invariants() + if (self.attachment in _BODY_KINDS) != (self.body is not None): + found.append( + Issue( + Severity.ERROR, + "a body or body_pinned rod needs its body, and only those have one", + self, + "body", + 2, + ) + ) + return found diff --git a/python/cabledyn/project/rows.py b/python/cabledyn/project/rows.py new file mode 100644 index 0000000..0209ddf --- /dev/null +++ b/python/cabledyn/project/rows.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Model-wide rows: equivalent buoyancy, Syrope history, failures, control, +external loads, and output channels.""" + +from __future__ import annotations + +from cabledyn.project.appearance import ModuleGeometry +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.bodies import Body +from cabledyn.project.descriptors import ( + Child, + Children, + Choice, + Integer, + OptionalQuantity, + Quantity, + Ref, + RefList, + Role, + Text, + Vector, +) +from cabledyn.project.lines import Line +from cabledyn.project.points import Point +from cabledyn.project.types import LineType +from cabledyn.project.units import ( + FORCE, + FORCE_PER_LENGTH, + LENGTH, + LINEAR_DAMPING, + QUADRATIC_DAMPING, + TIME, +) + +__all__ = [ + "Channel", + "Control", + "EquivalentBuoyancy", + "ExternalLoad", + "Failure", + "NamedChannel", + "ObjectChannel", + "OutputRequest", + "SyropeIC", +] + + +@model_type("equivalent_buoyancy") +class EquivalentBuoyancy(ModelObject): + """A line type given by its net submerged weight (smeared buoyancy). + + One deck ``EQUIVALENT BUOYANCY`` row; it rewrites the referenced type. + """ + + type_label = "Equivalent buoyancy" + abstract = False + line_type = Ref(LineType, group="Section") + diameter = Quantity(LENGTH, 0.5, limit="line_diameter", group="Section") + submerged_weight = Quantity( + FORCE_PER_LENGTH, 0.0, group="Section", doc="Net submerged weight (negative: uplift)." + ) + module_geometry = Child(ModuleGeometry, role=Role.APPEARANCE) + module_pitch = OptionalQuantity( + LENGTH, minimum=0.0, role=Role.APPEARANCE, group="Appearance", doc="Cosmetic spacing." + ) + + +@model_type("syrope_ic") +class SyropeIC(ModelObject): + """The prior load history of Syrope lines (one deck ``SYROPE IC`` row).""" + + type_label = "Syrope history" + abstract = False + lines = RefList(Line, min_items=1, group="History") + max_tension = Quantity(FORCE, 0.0, minimum=0.0, group="History", doc="Running maximum.") + mean_tension = Quantity(FORCE, 0.0, group="History") + + +@model_type("failure") +class Failure(ModelObject): + """Lines detaching from a point at a time or tension (one deck ``FAILURE`` row).""" + + type_label = "Failure" + abstract = False + point = Ref(Point, group="Failure") + lines = RefList(Line, min_items=1, group="Failure") + time = Quantity(TIME, 0.0, minimum=0.0, group="Trigger", doc="0 disables the time trigger.") + tension = Quantity( + FORCE, 0.0, minimum=0.0, group="Trigger", doc="0 disables the tension trigger." + ) + + +@model_type("control") +class Control(ModelObject): + """Lines whose length follows a coupling-host control channel (deck ``CONTROL``).""" + + type_label = "Control" + abstract = False + channel = Integer(1, minimum=1, group="Control") + lines = RefList(Line, min_items=1, group="Control") + + +@model_type("external_load") +class ExternalLoad(ModelObject): + """A constant force and damping on a rigid body (deck ``EXTERNAL LOADS``).""" + + type_label = "External load" + abstract = False + body = Ref(Body, group="Load") + axes = Choice(("global", "body"), group="Load", doc="Global axes or body axes.") + force = Vector(FORCE, (0.0,), lengths=(1, 3), group="Load") + linear_damping = Vector(LINEAR_DAMPING, (0.0,), lengths=(1, 3), group="Damping") + quadratic_damping = Vector(QUADRATIC_DAMPING, (0.0,), lengths=(1, 3), group="Damping") + + +class Channel(ModelObject): + """Base class of a requested output channel.""" + + type_label = "Channel" + abstract = True + + def channel_name(self, deck_id: int | None) -> str: + """Return the deck channel name, given the target's deck id (if any).""" + raise NotImplementedError + + +@model_type("channel.object") +class ObjectChannel(Channel): + """A channel of one object, such as a line's fairlead tension. + + The deck name is ``quantity`` + the target's deck id + ``qualifier`` + (``FairTen`` + ``1``; ``Ten`` + ``2`` + ``N5``). The channel follows its + target when the target is renumbered and is removed with it. + """ + + type_label = "Object channel" + target = Ref(ModelObject, group="Channel") + quantity = Text("", group="Channel", doc="Channel stem, for example FairTen.") + qualifier = Text("", group="Channel", doc="Suffix after the id, for example N5 or pz.") + + def channel_name(self, deck_id: int | None) -> str: + return f"{self.quantity}{'' if deck_id is None else deck_id}{self.qualifier}" + + +@model_type("channel.named") +class NamedChannel(Channel): + """A channel given by its literal deck name.""" + + type_label = "Named channel" + channel = Text("", group="Channel") + + def channel_name(self, deck_id: int | None) -> str: + return self.channel + + +@model_type("outputs") +class OutputRequest(ModelObject): + """The requested output channels (the deck's ``OUTPUTS`` list).""" + + type_label = "Outputs" + abstract = False + channels = Children(Channel, group="Channels") diff --git a/python/cabledyn/project/schema.py b/python/cabledyn/project/schema.py new file mode 100644 index 0000000..ac3ed9f --- /dev/null +++ b/python/cabledyn/project/schema.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Numeric deck limits shared by the property checks and the deck reader. + +The limits are not restated here: each entry refers to the admissible-range +constant that :class:`cabledyn.DeckFile` applies, so a property check +(validation layer 1) and the native deck rules (layer 3) cannot drift apart. +Layer 1 is an early, per-field hint; the deck rules remain the authority. + +The table is the hook for a machine-readable deck schema: when the deck +reader takes its limits from a schema file, :data:`DECK_LIMITS` is the one +place to read it from. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from cabledyn import deck_file as _deck + +__all__ = ["DECK_LIMITS", "DeckLimit", "deck_limit"] + + +@dataclass(frozen=True) +class DeckLimit: + """An admissible range of one deck field. + + Attributes + ---------- + minimum, maximum : float | None + Inclusive bounds, or ``None`` for no bound. + absolute : bool + Apply the bounds to the magnitude of the value. + exclusive_minimum : bool + The minimum itself is not admissible. + """ + + minimum: float | None = None + maximum: float | None = None + absolute: bool = False + exclusive_minimum: bool = False + + def violation(self, value: float) -> str | None: + """Return a message if ``value`` is outside the range, else ``None``.""" + checked = abs(value) if self.absolute else value + what = "magnitude" if self.absolute else "value" + if self.minimum is not None: + low = checked <= self.minimum if self.exclusive_minimum else checked < self.minimum + if low: + relation = "greater than" if self.exclusive_minimum else "at least" + return f"{what} must be {relation} {self.minimum:g}" + if self.maximum is not None and checked > self.maximum: + return f"{what} must be at most {self.maximum:g}" + return None + + +_PERIOD_LOW, _PERIOD_HIGH = _deck._NATIVE_WAVE_PERIOD_RANGE + +DECK_LIMITS: dict[str, DeckLimit] = { + "coordinate": DeckLimit(maximum=_deck._NATIVE_MAX_COORDINATE, absolute=True), + "coefficient": DeckLimit(maximum=_deck._NATIVE_MAX_COEFFICIENT, absolute=True), + "small_coefficient": DeckLimit(maximum=_deck._NATIVE_MAX_SMALL_COEFFICIENT, absolute=True), + "gravity": DeckLimit(maximum=_deck._NATIVE_MAX_GRAVITY), + "water_density": DeckLimit(maximum=_deck._NATIVE_MAX_WATER_DENSITY), + "water_depth": DeckLimit(maximum=_deck._NATIVE_MAX_COORDINATE), + "line_diameter": DeckLimit( + minimum=_deck._NATIVE_MIN_DIAMETER, maximum=_deck._NATIVE_MAX_DIAMETER + ), + "line_mass": DeckLimit(maximum=_deck._NATIVE_MAX_ABS_MASS, absolute=True), + "axial_stiffness": DeckLimit(maximum=_deck._NATIVE_MAX_EA), + "section_length": DeckLimit( + minimum=0.0, maximum=_deck._NATIVE_MAX_SECTION_LENGTH, exclusive_minimum=True + ), + "segments": DeckLimit(minimum=1, maximum=_deck._NATIVE_MAX_SEGMENTS), + "wave_height": DeckLimit( + minimum=0.0, maximum=_deck._NATIVE_MAX_WAVE_HEIGHT, exclusive_minimum=True + ), + "wave_period": DeckLimit(minimum=_PERIOD_LOW, maximum=_PERIOD_HIGH), + "fluid_speed": DeckLimit(maximum=_deck._NATIVE_MAX_FLUID_SPEED, absolute=True), +} +"""Shared limits by key, each taken from the native deck reader's constant.""" + + +def deck_limit(key: str) -> DeckLimit: + """Return the shared limit ``key``. + + Parameters + ---------- + key : str + A key of :data:`DECK_LIMITS`. + + Returns + ------- + DeckLimit + The limit. + + Raises + ------ + KeyError + If the key is unknown. + """ + return DECK_LIMITS[key] diff --git a/python/cabledyn/project/settings.py b/python/cabledyn/project/settings.py new file mode 100644 index 0000000..573768b --- /dev/null +++ b/python/cabledyn/project/settings.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Analysis settings: the solver ``OPTIONS`` of statics, dynamics and modal runs. + +Every option property is optional: ``None`` writes no row and leaves the +solver's own default in force. Options the model does not type are kept +verbatim as :class:`ExtraOption` rows, so nothing in a deck is lost. +""" + +from __future__ import annotations + +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Children, + OptionalBool, + OptionalInteger, + OptionalQuantity, + OptionalText, + Text, + TextList, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.units import DIMENSIONLESS, TIME + +__all__ = ["AnalysisSettings", "ExtraOption"] + +_RUN = "Run" +_STATICS = "Statics" +_DYNAMICS = "Dynamics" +_SOLVER = "Dynamic solver" +_BODIES = "Bodies and rods" +_COMPAT = "Compatibility" + + +@model_type("option.extra") +class ExtraOption(ModelObject): + """An ``OPTIONS`` row kept verbatim (keyword, value tokens, description).""" + + type_label = "Option" + abstract = False + keyword = Text("", group="Option") + values = TextList((), group="Option", doc="Value tokens as written.") + note = OptionalText(group="Option", doc="Description written after ' - '.") + + +@model_type("settings") +class AnalysisSettings(ModelObject): + """Run, statics, dynamics, modal and compatibility options.""" + + type_label = "Analysis settings" + abstract = False + time_step = OptionalQuantity(TIME, minimum=0.0, group=_RUN, doc="Solver time step (dtM).") + duration = OptionalQuantity(TIME, minimum=0.0, group=_RUN, doc="Run duration (TMax).") + range_start = OptionalQuantity(TIME, minimum=0.0, group=_RUN, doc="Range-graph start.") + cable_statics = OptionalText(group=_STATICS, doc="Finite-EI static route order.") + body_initial_condition = OptionalText(group=_STATICS, doc="Start of free bodies (bodyIC).") + initial_condition_mode = OptionalText(group=_STATICS, doc="Initial-condition mode (ICmode).") + modified_newton = OptionalBool(group=_STATICS, doc="Guarded tangent reuse.") + adaptive_mesh = OptionalBool(group=_STATICS, doc="Automatic finite-EI refinement.") + spectral_radius = OptionalQuantity( + DIMENSIONLESS, minimum=0.0, maximum=1.0, group=_DYNAMICS, doc="rhoInf." + ) + max_strain = OptionalQuantity(DIMENSIONLESS, minimum=0.0, group=_DYNAMICS) + alpha_force_blend = OptionalBool(group=_DYNAMICS) + tensile_safety = OptionalText(group=_DYNAMICS, doc="Element axial-force audit mode.") + tensile_strain_tolerance = OptionalQuantity(DIMENSIONLESS, minimum=0.0, group=_DYNAMICS) + recovery_max_substeps = OptionalInteger(group=_DYNAMICS) + axial_quadrature_order = OptionalInteger(group=_DYNAMICS) + bending_quadrature_order = OptionalInteger(group=_DYNAMICS) + solver_relative_tolerance = OptionalQuantity(DIMENSIONLESS, group=_SOLVER) + solver_absolute_tolerance = OptionalQuantity(DIMENSIONLESS, group=_SOLVER) + solver_max_iterations = OptionalInteger(group=_SOLVER) + solver_backtracks = OptionalInteger(group=_SOLVER) + solver_spectral_radius = OptionalQuantity( + DIMENSIONLESS, group=_SOLVER, doc="Optional sixth value of the solver row." + ) + cable_load_feedback = OptionalBool(group=_DYNAMICS, doc="Return cable reactions to the host.") + body_wetting = OptionalText(group=_BODIES) + body_hydrodynamics = OptionalText(group=_BODIES) + rod_hydrodynamics = OptionalText(group=_BODIES) + body_scheme = OptionalText(group=_BODIES) + body_substep = OptionalText(group=_BODIES) + mode_count = OptionalInteger(group="Modal", doc="Lowest natural modes per line.") + time_scheme = OptionalText(group=_COMPAT, doc="Accepted for compatibility.") + ic_time_step = OptionalQuantity(TIME, group=_COMPAT, doc="Accepted for compatibility.") + ic_duration = OptionalQuantity(TIME, group=_COMPAT, doc="Accepted for compatibility.") + ic_drag_scale = OptionalQuantity(DIMENSIONLESS, group=_COMPAT) + ic_threshold = OptionalQuantity(DIMENSIONLESS, group=_COMPAT) + write_log = OptionalInteger(group=_COMPAT) + output_interval = OptionalQuantity(TIME, group=_COMPAT) + extra_options = Children(ExtraOption, group="Other options") + + _SOLVER_FIELDS = ( + "solver_relative_tolerance", + "solver_absolute_tolerance", + "solver_max_iterations", + "solver_backtracks", + ) + + def solver_row_given(self) -> bool: + """Whether the dynamic-solver values are given (the four required ones).""" + return self.solver_relative_tolerance is not None + + def invariants(self) -> list[Issue]: + found = super().invariants() + given = [self.get_value(name) is not None for name in self._SOLVER_FIELDS] + if any(given) and not all(given): + found.append( + Issue( + Severity.ERROR, + "give the four dynamic-solver values together", + self, + "solver_relative_tolerance", + 2, + ) + ) + if self.solver_spectral_radius is not None and not all(given): + found.append( + Issue( + Severity.ERROR, + "the solver spectral radius needs the four solver values", + self, + "solver_spectral_radius", + 2, + ) + ) + return found diff --git a/python/cabledyn/project/store.py b/python/cabledyn/project/store.py new file mode 100644 index 0000000..6835862 --- /dev/null +++ b/python/cabledyn/project/store.py @@ -0,0 +1,220 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The ``.cdproj`` project file. + +A project file is a zip archive (or, for version control, an unzipped +folder) holding: + +``project.json`` + The whole object graph (physics, construction, appearance and GUI data) + with uids, SI values and a schema version. +``model.dat`` + The deck written from the project, so the driver can run it directly; + left out (and noted in ``project.json``) when the project is not yet a + valid deck. +``assets/`` + Meshes, textures and other files the project uses (reserved). +``results.json`` + References to solver runs (reserved; empty for now). + +Objects of a type this version does not know are kept verbatim and written +back unchanged. +""" + +from __future__ import annotations + +import io +import json +import os +import zipfile +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from cabledyn.errors import DeckFormatError +from cabledyn.project.base import SerialContext +from cabledyn.project.deck import DeckExportError, DeckWriter +from cabledyn.project.project import Project + +__all__ = ["FORMAT", "SCHEMA_VERSION", "ProjectFormatError", "ProjectStore"] + +FORMAT = "cabledyn-project" +"""The ``format`` value of ``project.json``.""" +SCHEMA_VERSION = 1 +"""The schema version written by this release.""" + +_PROJECT = "project.json" +_DECK = "model.dat" +_RESULTS = "results.json" +_ASSETS = "assets/" + +Migration = Callable[[dict[str, Any]], dict[str, Any]] +_MIGRATIONS: dict[int, Migration] = {} +"""Upgrades from schema version ``n`` to ``n + 1``, applied in turn on load.""" + + +class ProjectFormatError(ValueError): + """A project file is not a CableDyn project or is from a newer release.""" + + +def _package_version() -> str: + from cabledyn import __version__ + + return str(__version__) + + +class ProjectStore: + """Save and load ``.cdproj`` project files.""" + + @staticmethod + def to_json(project: Project) -> dict[str, Any]: + """Return the ``project.json`` document of ``project``. + + Returns + ------- + dict + JSON-compatible data. + """ + ctx = SerialContext() + return { + "format": FORMAT, + "schema_version": SCHEMA_VERSION, + "generator": f"cabledyn {_package_version()}", + "project": project.to_dict(ctx), + "unknown_objects": list(project.unknown_objects), + } + + @staticmethod + def from_json(data: Any) -> Project: + """Rebuild a project from a ``project.json`` document. + + Raises + ------ + ProjectFormatError + If the document is not a project or its schema is newer than + this release. + """ + if not isinstance(data, dict) or data.get("format") != FORMAT: + raise ProjectFormatError("not a CableDyn project document") + version = data.get("schema_version") + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + raise ProjectFormatError(f"invalid schema version {version!r}") + if version > SCHEMA_VERSION: + raise ProjectFormatError( + f"the project uses schema version {version}; this release reads up to " + f"{SCHEMA_VERSION}" + ) + while version < SCHEMA_VERSION: + data = _MIGRATIONS[version](data) + version += 1 + ctx = SerialContext() + project = ctx.build(data.get("project")) + if not isinstance(project, Project): + raise ProjectFormatError("the document holds no project") + ctx.resolve() + kept = data.get("unknown_objects", []) + project.unknown_objects = [*(kept if isinstance(kept, list) else []), *ctx.unknown] + project.load_warnings = list(ctx.warnings) + return project + + @classmethod + def save( + cls, + project: Project, + path: str | os.PathLike[str], + *, + folder: bool = False, + overwrite: bool = False, + ) -> Path: + """Write ``project`` to a project file. + + Parameters + ---------- + project : Project + The project. + path : str | os.PathLike + Target, normally ending in ``.cdproj``. + folder : bool + Write an unzipped folder instead of a zip archive. + overwrite : bool + Replace an existing file or folder contents. + + Returns + ------- + pathlib.Path + The written path. + + Raises + ------ + FileExistsError + If ``path`` exists and ``overwrite`` is false. + """ + target = Path(path).expanduser().resolve() + if target.exists() and not overwrite: + raise FileExistsError(f"{target} already exists") + document = cls.to_json(project) + deck_text: str | None = None + try: + deck_text = DeckWriter(project).to_text() + document["deck"] = {"written": True, "file": _DECK} + except (DeckExportError, DeckFormatError) as exc: + document["deck"] = {"written": False, "reason": str(exc)} + payload = json.dumps(document, indent=1, sort_keys=False, allow_nan=True) + results = json.dumps({"runs": []}, indent=1) + if folder: + target.mkdir(parents=True, exist_ok=True) + (target / _PROJECT).write_text(payload, encoding="utf-8") + (target / _RESULTS).write_text(results, encoding="utf-8") + (target / _ASSETS).mkdir(exist_ok=True) + deck_file = target / _DECK + if deck_text is not None: + deck_file.write_text(deck_text, encoding="utf-8") + elif deck_file.exists(): + deck_file.unlink() + return target + target.parent.mkdir(parents=True, exist_ok=True) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(_PROJECT, payload) + archive.writestr(_RESULTS, results) + archive.writestr(zipfile.ZipInfo(_ASSETS), "") + if deck_text is not None: + archive.writestr(_DECK, deck_text) + target.write_bytes(buffer.getvalue()) + return target + + @classmethod + def load(cls, path: str | os.PathLike[str]) -> Project: + """Read a project file (zip archive or folder). + + Returns + ------- + Project + The project; ``Project.load_warnings`` lists anything + skipped, and objects of unknown type are kept for the next save. + + Raises + ------ + FileNotFoundError + If ``path`` does not exist. + ProjectFormatError + If the file is not a readable project. + """ + source = Path(path).expanduser().resolve() + if source.is_dir(): + document = source / _PROJECT + if not document.is_file(): + raise ProjectFormatError(f"{source} has no {_PROJECT}") + text = document.read_text(encoding="utf-8") + elif source.is_file(): + try: + with zipfile.ZipFile(source) as archive: + text = archive.read(_PROJECT).decode("utf-8") + except (zipfile.BadZipFile, KeyError) as exc: + raise ProjectFormatError(f"{source} is not a CableDyn project file") from exc + else: + raise FileNotFoundError(f"{source} does not exist") + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ProjectFormatError(f"{source}: {_PROJECT} is not valid JSON") from exc + return cls.from_json(data) diff --git a/python/cabledyn/project/studio.py b/python/cabledyn/project/studio.py new file mode 100644 index 0000000..81d6de7 --- /dev/null +++ b/python/cabledyn/project/studio.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: Apache-2.0 +"""GUI-only project data: groups, camera bookmarks, layouts and specifications. + +None of this is written to a deck. The layouts, plot and report +specifications are opaque JSON placeholders owned by the GUI. +""" + +from __future__ import annotations + +from typing import Any + +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Children, + Choice, + JsonValue, + OptionalColour, + Quantity, + RefList, + Role, + Vec3, +) +from cabledyn.project.units import ANGLE, LENGTH, UnitSystem + +__all__ = ["CameraBookmark", "Group", "StudioData"] + +_M = Role.META + + +@model_type("studio.group") +class Group(ModelObject): + """A user group of objects in the model browser.""" + + type_label = "Group" + abstract = False + members = RefList(ModelObject, role=_M, group="Group") + colour = OptionalColour(group="Group") + + +@model_type("studio.camera") +class CameraBookmark(ModelObject): + """A saved camera of a 3D view.""" + + type_label = "Camera" + abstract = False + view = Choice(("engineering", "presentation"), role=_M, group="Camera") + target = Vec3(LENGTH, role=_M, group="Camera", doc="Point looked at.") + distance = Quantity(LENGTH, 100.0, minimum=0.0, role=_M, group="Camera") + azimuth = Quantity(ANGLE, 270.0, role=_M, group="Camera") + elevation = Quantity(ANGLE, 20.0, minimum=-90.0, maximum=90.0, role=_M, group="Camera") + roll = Quantity(ANGLE, 0.0, role=_M, group="Camera") + projection = Choice(("perspective", "parallel"), role=_M, group="Camera") + + +@model_type("studio") +class StudioData(ModelObject): + """Everything the GUI stores in a project besides the model itself.""" + + type_label = "Studio data" + abstract = False + groups = Children(Group, role=_M) + cameras = Children(CameraBookmark, role=_M) + units = JsonValue({}, group="Preferences", doc="Display-unit choices by dimension.") + layouts = JsonValue({}, group="Workspace", doc="Window layouts.") + plot_specs = JsonValue([], group="Workspace", doc="Saved graph specifications.") + report_specs = JsonValue([], group="Workspace", doc="Saved report specifications.") + + def unit_system(self) -> UnitSystem: + """Return the project's display units.""" + data: dict[str, Any] = self.units or {} + return UnitSystem.from_dict(data) + + def set_unit_system(self, system: UnitSystem) -> None: + """Store display-unit choices (not an undoable command).""" + self.units = system.to_dict() diff --git a/python/cabledyn/project/types.py b/python/cabledyn/project/types.py new file mode 100644 index 0000000..278d94a --- /dev/null +++ b/python/cabledyn/project/types.py @@ -0,0 +1,376 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Type libraries: line types, rod types and floater types. + +A line type carries three kinds of data on one object, kept apart by the +property roles: physics written to the deck (diameter, mass, the axial and +bending models, hydrodynamic coefficients), construction data (chain grade, +rope construction, cable build; not written to the deck today), and the +appearance sub-object used by the presentation view. +""" + +from __future__ import annotations + +from cabledyn.project.appearance import ( + CableAppearance, + ChainAppearance, + FibreRopeAppearance, + LineAppearance, + MeshAsset, + PlainLineAppearance, + WireRopeAppearance, +) +from cabledyn.project.base import ModelObject, model_type +from cabledyn.project.descriptors import ( + Child, + Choice, + Colour, + FilePath, + Integer, + OptionalQuantity, + OptionalText, + OptionalVec3, + Quantity, + Role, + Strategy, + Vector, +) +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.units import ( + AREA, + AXIAL_DAMPING, + BENDING_STIFFNESS, + DIMENSIONLESS, + FORCE, + INERTIA, + LENGTH, + MASS, + MASS_PER_LENGTH, + ROTARY_INERTIA_PER_LENGTH, + VOLUME, +) + +__all__ = [ + "AxialModel", + "BendingModel", + "CableType", + "ChainType", + "FibreRopeType", + "FloaterType", + "GenericLineType", + "HydroDatabase", + "HydroDatabaseFile", + "LineType", + "LinearAxial", + "NoHydroDatabase", + "RodType", + "SyropeAxial", + "ViscoelasticAxial", + "WireRopeType", +] + +_C = Role.CONSTRUCTION +_HYDRO = "Hydrodynamics" + + +# --------------------------------------------------------------------------- axial models + + +class AxialModel(ModelObject): + """Base class of the axial (tension-strain) models of a line type.""" + + type_label = "Axial model" + abstract = True + damping = Vector( + AXIAL_DAMPING, + (0.0,), + lengths=(1, 2), + limit="coefficient", + group="Axial", + doc="Axial damping BA (negative: damping ratio); two values (static, dynamic).", + ) + + +@model_type("axial.linear") +class LinearAxial(AxialModel): + """Linear-elastic axial stiffness EA.""" + + type_label = "Linear EA" + stiffness = Quantity(FORCE, 1.0e9, limit="axial_stiffness", group="Axial", doc="EA.") + + +@model_type("axial.viscoelastic") +class ViscoelasticAxial(AxialModel): + """Two-spring viscoelastic stiffness: ``Es|Ed`` or ``Es|alphaMBL|vbeta``.""" + + type_label = "Viscoelastic EA" + static_stiffness = Quantity( + FORCE, 1.0e8, limit="axial_stiffness", group="Axial", doc="Static stiffness Es." + ) + dynamic_stiffness = OptionalQuantity( + FORCE, limit="axial_stiffness", group="Axial", doc="Constant dynamic stiffness Ed." + ) + alpha_mbl = OptionalQuantity( + DIMENSIONLESS, group="Axial", doc="Load-dependent dynamic stiffness intercept (x MBL)." + ) + beta = OptionalQuantity( + DIMENSIONLESS, group="Axial", doc="Load-dependent dynamic stiffness slope." + ) + + def invariants(self) -> list[Issue]: + found = super().invariants() + constant = self.dynamic_stiffness is not None + loaded = (self.alpha_mbl is not None, self.beta is not None) + if constant == any(loaded) or (any(loaded) and not all(loaded)): + found.append( + Issue( + Severity.ERROR, + "give either dynamic_stiffness or both alpha_mbl and beta", + self, + None, + 2, + ) + ) + return found + + +@model_type("axial.syrope") +class SyropeAxial(AxialModel): + """Syrope working-curve stiffness with a tension-dependent fast spring.""" + + type_label = "Syrope" + settings_file = FilePath(group="Axial", doc="Syrope settings file.") + alpha = Quantity(FORCE, 1.0e8, group="Axial", doc="Fast-spring stiffness intercept.") + beta = Quantity(DIMENSIONLESS, 20.0, group="Axial", doc="Fast-spring slope on tension.") + + def invariants(self) -> list[Issue]: + found = super().invariants() + if not self.settings_file: + found.append(Issue(Severity.ERROR, "needs a settings file", self, "settings_file", 2)) + return found + + +@model_type("bending") +class BendingModel(ModelObject): + """Bending, shear and torsion data of a line type (the finite-EI columns).""" + + type_label = "Bending model" + abstract = False + bending_stiffness = Quantity( + BENDING_STIFFNESS, 0.0, minimum=0.0, limit="coefficient", group="Bending", doc="EI." + ) + shear_stiffness = OptionalQuantity(FORCE, group="Finite EI", doc="GAs.") + torsional_stiffness = OptionalQuantity(BENDING_STIFFNESS, group="Finite EI", doc="GJ.") + rotary_inertia_axial = OptionalQuantity( + ROTARY_INERTIA_PER_LENGTH, group="Finite EI", doc="Axial rotary inertia Irt." + ) + rotary_inertia_normal = OptionalQuantity( + ROTARY_INERTIA_PER_LENGTH, group="Finite EI", doc="Normal rotary inertia Irn." + ) + + def extended(self) -> bool: + """Whether the four optional finite-EI columns are given.""" + return self.shear_stiffness is not None + + def invariants(self) -> list[Issue]: + found = super().invariants() + given = [ + value is not None + for value in ( + self.shear_stiffness, + self.torsional_stiffness, + self.rotary_inertia_axial, + self.rotary_inertia_normal, + ) + ] + if any(given) and not all(given): + found.append( + Issue( + Severity.ERROR, + "give all four of the shear, torsion and rotary-inertia values or none", + self, + None, + 2, + ) + ) + return found + + +# --------------------------------------------------------------------------- line types + + +class LineType(ModelObject): + """Base class of the line types (one deck ``LINE TYPES`` row each).""" + + type_label = "Line type" + abstract = True + diameter = Quantity( + LENGTH, 0.1, limit="line_diameter", group="Geometry", doc="Volume-equivalent diameter." + ) + mass_per_length = Quantity( + MASS_PER_LENGTH, 100.0, limit="line_mass", group="Geometry", doc="Dry mass per metre." + ) + axial = Strategy(AxialModel, LinearAxial, group="Axial") + bending = Child(BendingModel, group="Bending") + drag_normal = Quantity( + DIMENSIONLESS, 1.2, limit="small_coefficient", group=_HYDRO, doc="Normal drag Cd_n." + ) + drag_axial = Quantity( + DIMENSIONLESS, 0.0, limit="small_coefficient", group=_HYDRO, doc="Axial drag Cd_t." + ) + added_mass_normal = Quantity( + DIMENSIONLESS, 1.0, limit="small_coefficient", group=_HYDRO, doc="Normal Ca_n." + ) + added_mass_axial = Quantity( + DIMENSIONLESS, 0.0, limit="small_coefficient", group=_HYDRO, doc="Axial Ca_t." + ) + breaking_load = OptionalQuantity( + FORCE, minimum=0.0, role=_C, group="Strength", doc="Minimum breaking load (MBL)." + ) + colour = Colour("#d9c21a", group="Appearance", doc="Colour in the engineering view.") + appearance = Strategy(LineAppearance, PlainLineAppearance, role=Role.APPEARANCE) + + +@model_type("line_type.generic") +class GenericLineType(LineType): + """A line type given only by its physics (the form a deck import produces).""" + + type_label = "Line type" + + +@model_type("line_type.chain") +class ChainType(LineType): + """Mooring chain.""" + + type_label = "Chain" + grade = Choice(("R3", "R3S", "R4", "R4S", "R5", "R6"), role=_C, group="Construction") + link = Choice(("studless", "studlink"), role=_C, group="Construction") + nominal_diameter = OptionalQuantity(LENGTH, role=_C, group="Construction", doc="Bar d.") + appearance = Strategy(LineAppearance, ChainAppearance, role=Role.APPEARANCE) + + +@model_type("line_type.wire_rope") +class WireRopeType(LineType): + """Steel wire rope.""" + + type_label = "Wire rope" + construction = Choice( + ("six_strand", "spiral_strand", "sheathed_spiral_strand"), role=_C, group="Construction" + ) + nominal_diameter = OptionalQuantity(LENGTH, role=_C, group="Construction") + appearance = Strategy(LineAppearance, WireRopeAppearance, role=Role.APPEARANCE) + + +@model_type("line_type.fibre_rope") +class FibreRopeType(LineType): + """Synthetic fibre rope.""" + + type_label = "Fibre rope" + material = Choice(("polyester", "nylon", "hmpe", "other"), role=_C, group="Construction") + construction = Choice( + ("eight_strand", "twelve_strand", "parallel_strand"), role=_C, group="Construction" + ) + nominal_diameter = OptionalQuantity(LENGTH, role=_C, group="Construction") + appearance = Strategy(LineAppearance, FibreRopeAppearance, role=Role.APPEARANCE) + + +@model_type("line_type.cable") +class CableType(LineType): + """Power or umbilical cable.""" + + type_label = "Cable" + cores = Integer(3, minimum=0, role=_C, group="Construction") + armour_layers = Integer(1, minimum=0, role=_C, group="Construction") + storage_bend_radius = OptionalQuantity( + LENGTH, minimum=0.0, role=_C, group="Strength", doc="Minimum bend radius in storage." + ) + dynamic_bend_radius = OptionalQuantity( + LENGTH, minimum=0.0, role=_C, group="Strength", doc="Minimum bend radius in service." + ) + appearance = Strategy(LineAppearance, CableAppearance, role=Role.APPEARANCE) + + +# --------------------------------------------------------------------------- rod types + + +@model_type("rod_type") +class RodType(ModelObject): + """A rod type (one deck ``ROD TYPES`` row).""" + + type_label = "Rod type" + abstract = False + diameter = Quantity(LENGTH, 1.0, limit="line_diameter", group="Geometry") + mass_per_length = Quantity(MASS_PER_LENGTH, 100.0, limit="line_mass", group="Geometry") + drag = Quantity(DIMENSIONLESS, 0.6, limit="small_coefficient", group=_HYDRO, doc="Cd.") + added_mass = Quantity(DIMENSIONLESS, 1.0, limit="small_coefficient", group=_HYDRO, doc="Ca.") + end_drag = Quantity(DIMENSIONLESS, 0.0, limit="small_coefficient", group=_HYDRO) + end_added_mass = Quantity(DIMENSIONLESS, 0.0, limit="small_coefficient", group=_HYDRO) + axial_drag = OptionalQuantity(DIMENSIONLESS, limit="small_coefficient", group=_HYDRO) + axial_added_mass = OptionalQuantity(DIMENSIONLESS, limit="small_coefficient", group=_HYDRO) + colour = Colour("#b0b0b0", group="Appearance") + + def invariants(self) -> list[Issue]: + found = super().invariants() + if (self.axial_drag is None) != (self.axial_added_mass is None): + found.append( + Issue(Severity.ERROR, "give both axial coefficients or neither", self, None, 2) + ) + return found + + +# --------------------------------------------------------------------------- floater types + + +class HydroDatabase(ModelObject): + """Base class of a floater type's hydrodynamic database. + + This is the extension point for a time-domain floater solver: a database + kind (added mass, radiation damping and retardation functions, first-order + excitation and RAOs, second-order drift, hydrostatics, wind and current + load coefficients, imported from a radiation-diffraction tool) registers + as a subclass with :func:`cabledyn.project.model_type`. Today floaters + move by prescribed motion or as rigid bodies, and no database is written + to the deck. + """ + + type_label = "Hydrodynamic database" + abstract = True + + +@model_type("hydro.none") +class NoHydroDatabase(HydroDatabase): + """No hydrodynamic database.""" + + type_label = "None" + + +@model_type("hydro.file") +class HydroDatabaseFile(HydroDatabase): + """A reference to a hydrodynamic database file produced by another tool.""" + + type_label = "Database file" + file = FilePath(role=_C, group=_HYDRO) + source_format = OptionalText(role=_C, group=_HYDRO, doc="Name of the producing tool or format.") + water_depth = OptionalQuantity(LENGTH, role=_C, group=_HYDRO, doc="Depth of the analysis.") + + +@model_type("floater_type") +class FloaterType(ModelObject): + """A shared description of a floater (hull), referenced by :class:`Floater` objects. + + The physical description and the hydrodynamic database are construction + data: the deck holds each floater as a prescribed-motion or rigid body + row, so none of this is written to the deck today. + """ + + type_label = "Floater type" + abstract = False + length = OptionalQuantity(LENGTH, minimum=0.0, role=_C, group="Dimensions") + beam = OptionalQuantity(LENGTH, minimum=0.0, role=_C, group="Dimensions") + draft = OptionalQuantity(LENGTH, minimum=0.0, role=_C, group="Dimensions") + mass = OptionalQuantity(MASS, minimum=0.0, role=_C, group="Mass") + centre_of_gravity = OptionalVec3(LENGTH, role=_C, group="Mass") + inertia = OptionalVec3(INERTIA, role=_C, group="Mass", doc="Ixx, Iyy, Izz about the CG.") + displaced_volume = OptionalQuantity(VOLUME, minimum=0.0, role=_C, group="Hydrostatics") + waterplane_area = OptionalQuantity(AREA, minimum=0.0, role=_C, group="Hydrostatics") + hydrodynamics = Strategy(HydroDatabase, NoHydroDatabase, role=_C, group=_HYDRO) + mesh = Child(MeshAsset, role=Role.APPEARANCE) diff --git a/python/cabledyn/project/units.py b/python/cabledyn/project/units.py new file mode 100644 index 0000000..f350ab1 --- /dev/null +++ b/python/cabledyn/project/units.py @@ -0,0 +1,456 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Physical dimensions, units, and display-unit preferences. + +Every quantity in :mod:`cabledyn.project` is stored in SI units. A +:class:`Dimension` names what a quantity measures, a :class:`Unit` converts +between a display unit and SI, and a :class:`UnitSystem` holds the display unit +chosen for each dimension. Conversions are linear (``si = value * scale``); +no supported unit has an offset. + +Examples +-------- +>>> from cabledyn.project.units import FORCE, UnitSystem, convert +>>> convert(2.5, "kN", "N") +2500.0 +>>> UnitSystem.engineering().format(1.5e6, FORCE) +'1500 kN' +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass + +__all__ = [ + "ACCELERATION", + "ANGLE", + "AREA", + "AXIAL_DAMPING", + "BENDING_STIFFNESS", + "DENSITY", + "DIMENSIONLESS", + "DIMENSIONS", + "FORCE", + "FORCE_PER_LENGTH", + "FREQUENCY", + "INERTIA", + "LENGTH", + "LINEAR_DAMPING", + "MASS", + "MASS_PER_LENGTH", + "PRESSURE_PER_LENGTH", + "PRESSURE_TIME_PER_LENGTH", + "QUADRATIC_DAMPING", + "ROTARY_INERTIA_PER_LENGTH", + "ROTATIONAL_STIFFNESS", + "STIFFNESS", + "TIME", + "UNITS", + "VELOCITY", + "VOLUME", + "Dimension", + "Unit", + "UnitError", + "UnitSystem", + "convert", + "parse_quantity", + "unit", + "units_for", +] + + +class UnitError(ValueError): + """A unit is unknown or does not measure the expected dimension.""" + + +@dataclass(frozen=True) +class Dimension: + """A physical dimension and its SI unit symbol. + + Attributes + ---------- + key : str + Stable identifier, used in project files and preferences. + label : str + Human-readable name. + si : str + Symbol of the SI unit in which values are stored. + """ + + key: str + label: str + si: str + + +DIMENSIONLESS = Dimension("dimensionless", "Dimensionless", "-") +LENGTH = Dimension("length", "Length", "m") +AREA = Dimension("area", "Area", "m^2") +VOLUME = Dimension("volume", "Volume", "m^3") +MASS = Dimension("mass", "Mass", "kg") +TIME = Dimension("time", "Time", "s") +FREQUENCY = Dimension("frequency", "Frequency", "Hz") +ANGLE = Dimension("angle", "Angle", "deg") +VELOCITY = Dimension("velocity", "Velocity", "m/s") +ACCELERATION = Dimension("acceleration", "Acceleration", "m/s^2") +DENSITY = Dimension("density", "Density", "kg/m^3") +MASS_PER_LENGTH = Dimension("mass_per_length", "Mass per length", "kg/m") +FORCE = Dimension("force", "Force", "N") +FORCE_PER_LENGTH = Dimension("force_per_length", "Force per length", "N/m") +STIFFNESS = Dimension("stiffness", "Stiffness", "N/m") +ROTATIONAL_STIFFNESS = Dimension("rotational_stiffness", "Rotational stiffness", "N m/rad") +BENDING_STIFFNESS = Dimension("bending_stiffness", "Bending stiffness", "N m^2") +AXIAL_DAMPING = Dimension("axial_damping", "Axial damping", "N s") +LINEAR_DAMPING = Dimension("linear_damping", "Linear damping", "N s/m") +QUADRATIC_DAMPING = Dimension("quadratic_damping", "Quadratic damping", "N s^2/m^2") +INERTIA = Dimension("inertia", "Moment of inertia", "kg m^2") +ROTARY_INERTIA_PER_LENGTH = Dimension( + "rotary_inertia_per_length", "Rotary inertia per length", "kg m" +) +PRESSURE_PER_LENGTH = Dimension("pressure_per_length", "Pressure per length", "Pa/m") +PRESSURE_TIME_PER_LENGTH = Dimension( + "pressure_time_per_length", "Pressure-time per length", "Pa s/m" +) + +DIMENSIONS: dict[str, Dimension] = { + dim.key: dim + for dim in ( + DIMENSIONLESS, + LENGTH, + AREA, + VOLUME, + MASS, + TIME, + FREQUENCY, + ANGLE, + VELOCITY, + ACCELERATION, + DENSITY, + MASS_PER_LENGTH, + FORCE, + FORCE_PER_LENGTH, + STIFFNESS, + ROTATIONAL_STIFFNESS, + BENDING_STIFFNESS, + AXIAL_DAMPING, + LINEAR_DAMPING, + QUADRATIC_DAMPING, + INERTIA, + ROTARY_INERTIA_PER_LENGTH, + PRESSURE_PER_LENGTH, + PRESSURE_TIME_PER_LENGTH, + ) +} +"""Every known dimension, by key.""" + + +@dataclass(frozen=True) +class Unit: + """A display unit: ``si_value = value * scale``. + + Attributes + ---------- + symbol : str + Unit symbol, unique across all dimensions. + dimension : Dimension + What the unit measures. + scale : float + SI value of one unit. + """ + + symbol: str + dimension: Dimension + scale: float + + def to_si(self, value: float) -> float: + """Convert a value in this unit to SI.""" + return value * self.scale + + def from_si(self, value: float) -> float: + """Convert an SI value to this unit.""" + return value / self.scale + + +def _units() -> tuple[Unit, ...]: + table: list[tuple[str, Dimension, float]] = [ + ("-", DIMENSIONLESS, 1.0), + ("%", DIMENSIONLESS, 0.01), + ("m", LENGTH, 1.0), + ("mm", LENGTH, 1.0e-3), + ("cm", LENGTH, 1.0e-2), + ("km", LENGTH, 1.0e3), + ("in", LENGTH, 0.0254), + ("ft", LENGTH, 0.3048), + ("m^2", AREA, 1.0), + ("mm^2", AREA, 1.0e-6), + ("cm^2", AREA, 1.0e-4), + ("m^3", VOLUME, 1.0), + ("l", VOLUME, 1.0e-3), + ("kg", MASS, 1.0), + ("t", MASS, 1.0e3), + ("s", TIME, 1.0), + ("min", TIME, 60.0), + ("h", TIME, 3600.0), + ("Hz", FREQUENCY, 1.0), + ("rad/s", FREQUENCY, 1.0 / (2.0 * math.pi)), + ("deg", ANGLE, 1.0), + ("rad", ANGLE, 180.0 / math.pi), + ("m/s", VELOCITY, 1.0), + ("kn", VELOCITY, 1852.0 / 3600.0), + ("m/s^2", ACCELERATION, 1.0), + ("g0", ACCELERATION, 9.80665), + ("kg/m^3", DENSITY, 1.0), + ("t/m^3", DENSITY, 1.0e3), + ("kg/m", MASS_PER_LENGTH, 1.0), + ("t/m", MASS_PER_LENGTH, 1.0e3), + ("N", FORCE, 1.0), + ("kN", FORCE, 1.0e3), + ("MN", FORCE, 1.0e6), + ("GN", FORCE, 1.0e9), + ("tf", FORCE, 9.80665e3), + ("N/m", FORCE_PER_LENGTH, 1.0), + ("kN/m", FORCE_PER_LENGTH, 1.0e3), + ("N/m", STIFFNESS, 1.0), + ("kN/m", STIFFNESS, 1.0e3), + ("MN/m", STIFFNESS, 1.0e6), + ("N m/rad", ROTATIONAL_STIFFNESS, 1.0), + ("kN m/rad", ROTATIONAL_STIFFNESS, 1.0e3), + ("MN m/rad", ROTATIONAL_STIFFNESS, 1.0e6), + ("N m/deg", ROTATIONAL_STIFFNESS, 180.0 / math.pi), + ("N m^2", BENDING_STIFFNESS, 1.0), + ("kN m^2", BENDING_STIFFNESS, 1.0e3), + ("MN m^2", BENDING_STIFFNESS, 1.0e6), + ("N s", AXIAL_DAMPING, 1.0), + ("kN s", AXIAL_DAMPING, 1.0e3), + ("N s/m", LINEAR_DAMPING, 1.0), + ("kN s/m", LINEAR_DAMPING, 1.0e3), + ("N s^2/m^2", QUADRATIC_DAMPING, 1.0), + ("kN s^2/m^2", QUADRATIC_DAMPING, 1.0e3), + ("kg m^2", INERTIA, 1.0), + ("t m^2", INERTIA, 1.0e3), + ("kg m", ROTARY_INERTIA_PER_LENGTH, 1.0), + ("Pa/m", PRESSURE_PER_LENGTH, 1.0), + ("kPa/m", PRESSURE_PER_LENGTH, 1.0e3), + ("Pa s/m", PRESSURE_TIME_PER_LENGTH, 1.0), + ("kPa s/m", PRESSURE_TIME_PER_LENGTH, 1.0e3), + ] + return tuple(Unit(symbol, dim, scale) for symbol, dim, scale in table) + + +UNITS: tuple[Unit, ...] = _units() +"""Every known unit. A symbol may measure more than one dimension (``N/m``).""" + + +def unit(symbol: str, dimension: Dimension | None = None) -> Unit: + """Return the unit with ``symbol``. + + Parameters + ---------- + symbol : str + Unit symbol, for example ``"kN"``. + dimension : Dimension | None + The dimension it must measure; needed only when the symbol is + shared by several dimensions (``N/m``: force per length or stiffness). + + Returns + ------- + Unit + The unit. + + Raises + ------ + UnitError + If the symbol is unknown, does not measure ``dimension``, or is + ambiguous without one. + """ + found = [item for item in UNITS if item.symbol == symbol] + if dimension is not None: + found = [item for item in found if item.dimension == dimension] + if not found: + where = "" if dimension is None else f" for {dimension.label.lower()}" + raise UnitError(f"unknown unit {symbol!r}{where}") + if len(found) > 1: + raise UnitError(f"unit {symbol!r} is ambiguous; give its dimension") + return found[0] + + +def units_for(dimension: Dimension) -> list[Unit]: + """Return the units that measure ``dimension``, SI unit first.""" + found = [item for item in UNITS if item.dimension == dimension] + found.sort(key=lambda item: item.scale != 1.0) + return found + + +def convert(value: float, source: str, target: str, dimension: Dimension | None = None) -> float: + """Convert ``value`` from unit ``source`` to unit ``target``. + + Parameters + ---------- + value : float + Value in ``source`` units. + source, target : str + Unit symbols of the same dimension. + dimension : Dimension | None + The dimension, needed only for shared symbols such as ``N/m``. + + Returns + ------- + float + Value in ``target`` units. + + Raises + ------ + UnitError + If a unit is unknown or the dimensions differ. + """ + a, b = unit(source, dimension), unit(target, dimension) + if a.dimension != b.dimension: + raise UnitError(f"cannot convert {source} ({a.dimension.label}) to {target}") + if a is b: + return float(value) + return b.from_si(a.to_si(float(value))) + + +_QUANTITY = re.compile(r"^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*(.*?)\s*$") + + +def parse_quantity(text: str, dimension: Dimension, default_unit: str | None = None) -> float: + """Parse text such as ``"850 m"`` or ``"1.2 MN"`` into an SI value. + + Parameters + ---------- + text : str + A number optionally followed by a unit symbol. + dimension : Dimension + The expected dimension. + default_unit : str | None + Unit of a bare number; SI when ``None``. + + Returns + ------- + float + The SI value. + + Raises + ------ + UnitError + If the text is not a number with a unit of ``dimension``. + """ + match = _QUANTITY.match(text) + if match is None: + raise UnitError(f"{text!r} is not a number with an optional unit") + value = float(match.group(1)) + symbol = match.group(2) or default_unit or dimension.si + return unit(symbol, dimension).to_si(value) + + +class UnitSystem: + """Display-unit preferences: one unit per dimension (SI where unset). + + Parameters + ---------- + choices : Mapping[str, str] | None + Dimension key to unit symbol. + + Raises + ------ + UnitError + If a dimension or unit is unknown, or a unit measures another + dimension. + """ + + def __init__(self, choices: Mapping[str, str] | None = None) -> None: + self._choices: dict[str, str] = {} + for key, symbol in (choices or {}).items(): + self.set(key, symbol) + + @classmethod + def si(cls) -> UnitSystem: + """Return SI display units for every dimension.""" + return cls() + + @classmethod + def engineering(cls) -> UnitSystem: + """Return offshore-engineering display units (kN, t, kN m^2, ...).""" + return cls( + { + "force": "kN", + "mass": "t", + "force_per_length": "kN/m", + "stiffness": "kN/m", + "bending_stiffness": "kN m^2", + "rotational_stiffness": "kN m/rad", + "axial_damping": "kN s", + "inertia": "t m^2", + } + ) + + def set(self, dimension: str, symbol: str) -> None: + """Choose the display unit of a dimension. + + Parameters + ---------- + dimension : str + Dimension key, for example ``"force"``. + symbol : str + Unit symbol of that dimension. + """ + if dimension not in DIMENSIONS: + raise UnitError(f"unknown dimension {dimension!r}") + unit(symbol, DIMENSIONS[dimension]) + self._choices[dimension] = symbol + + def unit_for(self, dimension: Dimension) -> Unit: + """Return the display unit chosen for ``dimension``.""" + return unit(self._choices.get(dimension.key, dimension.si), dimension) + + def to_display(self, value: float, dimension: Dimension) -> float: + """Convert an SI value to the display unit of ``dimension``.""" + return self.unit_for(dimension).from_si(value) + + def from_display(self, value: float, dimension: Dimension) -> float: + """Convert a value in the display unit of ``dimension`` to SI.""" + return self.unit_for(dimension).to_si(value) + + def format(self, value: float, dimension: Dimension, digits: int = 6) -> str: + """Format an SI value in its display unit, for example ``'850 m'``. + + Parameters + ---------- + value : float + SI value. + dimension : Dimension + Its dimension. + digits : int + Significant digits. + + Returns + ------- + str + Number and unit symbol (no symbol for dimensionless values). + """ + shown = self.unit_for(dimension) + number = f"{shown.from_si(value):.{digits}g}" + return number if shown.symbol == "-" else f"{number} {shown.symbol}" + + def to_dict(self) -> dict[str, str]: + """Return the non-SI choices, for a project file.""" + return dict(self._choices) + + @classmethod + def from_dict(cls, data: Mapping[str, str]) -> UnitSystem: + """Rebuild preferences written by :meth:`to_dict`.""" + return cls(data) + + def dimensions(self) -> Iterable[str]: + """Return the dimension keys with a non-SI choice.""" + return tuple(self._choices) + + def __eq__(self, other: object) -> bool: + return isinstance(other, UnitSystem) and self._choices == other._choices + + __hash__ = None # type: ignore[assignment] + + def __repr__(self) -> str: + return f"UnitSystem({self._choices!r})" diff --git a/python/cabledyn/project/validation.py b/python/cabledyn/project/validation.py new file mode 100644 index 0000000..9f57fa5 --- /dev/null +++ b/python/cabledyn/project/validation.py @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The three validation layers of a project. + +1. **Property checks**: type, finiteness and limits of each value, the + numeric limits shared with the deck reader (:mod:`cabledyn.project.schema`). +2. **Object invariants**: references resolve, required collections are not + empty, arc locations lie on their line, names the deck needs are unique. +3. **Native deck rules**: the project is written as a deck and checked by + :class:`cabledyn.DeckFile`; a failure is mapped back to the object whose + row failed. No deck rule is restated in the object model. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from cabledyn.project.deck import DeckWriter +from cabledyn.project.issues import Issue, Severity +from cabledyn.project.project import Project + +__all__ = ["errors", "validate_project"] + + +def validate_project(project: Project, *, deck: bool = True) -> list[Issue]: + """Validate a project with every layer. + + Parameters + ---------- + project : Project + The project. + deck : bool + Run layer 3. It runs only when layers 1 and 2 report no error, + because a deck cannot be written from a structurally broken model. + + Returns + ------- + list[Issue] + Layer-1 and layer-2 issues of every object, then layer-3 issues. + """ + found = project.validate(recursive=True) + if deck and not any(issue.is_error for issue in found): + found.extend(DeckWriter(project).validate()) + return found + + +def errors(issues: Iterable[Issue]) -> list[Issue]: + """Return the issues that are errors.""" + return [issue for issue in issues if issue.severity is Severity.ERROR] diff --git a/python/tests/test_project_commands.py b/python/tests/test_project_commands.py new file mode 100644 index 0000000..8f46e18 --- /dev/null +++ b/python/tests/test_project_commands.py @@ -0,0 +1,445 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Commands, the undo stack, and reference semantics on rename and removal.""" + +from __future__ import annotations + +import random +from pathlib import Path +from typing import Any + +import pytest + +from cabledyn.project import ( + AddObject, + ApplyLibraryItem, + BathymetrySeabed, + Body, + BodyPoint, + ChainType, + Command, + CommandStack, + Control, + DeckReader, + EndConnection, + Failure, + FixedPoint, + FlatSeabed, + FloaterPoint, + GenericLineType, + Group, + ImportDeck, + Line, + MacroCommand, + MoveInCollection, + ObjectChannel, + ObjectInUseError, + Project, + Reconnect, + RemoveObject, + ReplaceStrategy, + Rigid6Body, + Rod, + RodPoint, + RodType, + Section, + SetProperty, + SplitSection, + StackChanged, + SyropeIC, + ViscoelasticAxial, + plan_removal, + referrers, +) +from cabledyn.project.references import RemovalPlan + +EXAMPLES = Path(__file__).resolve().parents[2] / "examples" + + +def build() -> dict[str, Any]: + project = Project("demo") + chain = project.line_types.append(GenericLineType("chain", diameter=0.2)) + wire = project.line_types.append(GenericLineType("wire", diameter=0.1)) + fairlead = project.points.append(FloaterPoint("fairlead", position=(0, 0, -14))) + anchor = project.points.append(FixedPoint("anchor", position=(400, 0, -50))) + line = project.lines.append(Line("mooring", end_a=fairlead, end_b=anchor)) + s1 = line.sections.append(Section("s1", line_type=chain, length=200.0, segments=20)) + s2 = line.sections.append(Section("s2", line_type=wire, length=210.0, segments=21)) + second = project.lines.append(Line("second", end_a=fairlead, end_b=anchor)) + second.sections.append(Section("only", line_type=wire, length=410.0, segments=41)) + project.failures.append(Failure(point=fairlead, lines=[line, second])) + project.controls.append(Control(lines=[line])) + project.syrope_history.append(SyropeIC(lines=[second])) + project.outputs.channels.append(ObjectChannel(target=line, quantity="FairTen")) + group = project.studio.groups.append(Group("all", members=[line, second, anchor])) + return { + "project": project, + "chain": chain, + "wire": wire, + "fairlead": fairlead, + "anchor": anchor, + "line": line, + "second": second, + "s1": s1, + "s2": s2, + "group": group, + } + + +def check_undo_redo(project: Project, command: Command, stack: CommandStack | None = None) -> None: + stack = stack or CommandStack() + before = project.to_dict() + stack.push(command) + after = project.to_dict() + assert after != before + stack.undo() + assert project.to_dict() == before + stack.redo() + assert project.to_dict() == after + stack.undo() + assert project.to_dict() == before + + +# --------------------------------------------------------------------------- basic commands + + +def test_set_property_and_merging() -> None: + m = build() + project, s1 = m["project"], m["s1"] + check_undo_redo(project, SetProperty(s1, "length", 250.0)) + stack = CommandStack() + stack.push(SetProperty(s1, "length", 201.0, merge=True)) + stack.push(SetProperty(s1, "length", 202.0, merge=True)) + stack.push(SetProperty(s1, "length", 203.0, merge=True)) + assert len(stack) == 1 and s1.length == 203.0 + stack.push(SetProperty(s1, "segments", 30, merge=True)) + assert len(stack) == 2 + stack.undo() + stack.undo() + assert s1.length == 200.0 + with pytest.raises(AttributeError, match="cannot be set"): + SetProperty(project, "lines", []) + with pytest.raises(TypeError): + SetProperty(s1, "length", "long") + with pytest.raises(KeyError): + SetProperty(s1, "nothing", 1) + assert SetProperty(s1, "length", 1.0).affected() == (s1,) + assert "Set length of Section 's1'" in SetProperty(s1, "length", 1.0).text + + +def test_rename_keeps_every_reference() -> None: + m = build() + project, chain, fairlead = m["project"], m["chain"], m["fairlead"] + stack = CommandStack() + stack.push(SetProperty(chain, "name", "studless R4")) + stack.push(SetProperty(fairlead, "name", "hang-off")) + assert m["s1"].line_type is chain and m["line"].end_a is fairlead + assert project.failures[0].point is fairlead + stack.undo() + assert fairlead.name == "fairlead" + + +def test_add_move_and_reconnect() -> None: + m = build() + project = m["project"] + extra = FixedPoint("extra") + check_undo_redo(project, AddObject(project.points, extra, 0)) + stack = CommandStack() + stack.push(AddObject(project.points, extra)) + assert project.points[-1] is extra + assert AddObject(project.points, FixedPoint()).affected()[0] is project + with pytest.raises(TypeError, match="holds Point"): + AddObject(project.points, Line()) + with pytest.raises(ValueError, match="already belongs"): + AddObject(project.points, extra) + check_undo_redo(project, MoveInCollection(extra, 0)) + assert MoveInCollection(extra, 0).affected() == (extra,) + with pytest.raises(ValueError, match="not in a collection"): + MoveInCollection(project.environment, 0) + check_undo_redo(project, Reconnect(m["line"], "b", extra)) + check_undo_redo(project, Reconnect(m["line"], "A", extra)) + with pytest.raises(ValueError, match="A or B"): + Reconnect(m["line"], "C", extra) + + +def test_replace_strategy_copies_shared_values() -> None: + m = build() + project = m["project"] + project.seabed.stiffness = 3.0e5 + project.seabed.friction = 0.6 + command = ReplaceStrategy(project, "seabed", BathymetrySeabed) + check_undo_redo(project, command) + CommandStack().push(command) + assert isinstance(project.seabed, BathymetrySeabed) + assert project.seabed.stiffness == 3.0e5 and project.seabed.friction == 0.6 + chain = m["chain"] + chain.axial.damping = (-1.0,) + check_undo_redo(project, ReplaceStrategy(chain, "axial", ViscoelasticAxial())) + plain = ReplaceStrategy(chain, "axial", ViscoelasticAxial(), copy_common=False) + assert plain.new.damping == (0.0,) + with pytest.raises(TypeError, match="not a sub-object"): + ReplaceStrategy(chain, "diameter", FlatSeabed) + + +def test_split_section_and_library_item() -> None: + m = build() + project, s1, line = m["project"], m["s1"], m["line"] + command = SplitSection(s1, 50.0) + check_undo_redo(project, command) + CommandStack().push(command) + assert [s.length for s in line.sections] == [50.0, 150.0, 210.0] + assert [s.segments for s in line.sections] == [5, 15, 21] + assert command.new_section.line_type is m["chain"] + with pytest.raises(ValueError, match="not inside"): + SplitSection(s1, 60.0) + with pytest.raises(ValueError, match="does not belong"): + SplitSection(Section(length=10.0, segments=4), 5.0) + one = line.sections.append(Section(line_type=m["chain"], length=10.0, segments=1)) + with pytest.raises(ValueError, match="one element"): + SplitSection(one, 5.0) + chain = project.line_types.append(ChainType("R4")) + item = ApplyLibraryItem(chain, {"diameter": 0.23, "mass_per_length": 290.0, "grade": "R4"}) + check_undo_redo(project, item) + assert chain in item.affected() + + +def test_macro_command_rolls_back_on_failure() -> None: + m = build() + project, s1 = m["project"], m["s1"] + + class Boom(Command): + def do(self) -> None: + raise RuntimeError("boom") + + before = project.to_dict() + macro = MacroCommand("bad", [SetProperty(s1, "length", 1.0), Boom()]) + with pytest.raises(RuntimeError): + CommandStack().push(macro) + assert project.to_dict() == before + with pytest.raises(NotImplementedError): + Command().do() + with pytest.raises(NotImplementedError): + Command().undo() + assert Command().affected() == () and not Command().merge_with(Command()) + assert repr(Command()) == "" + + +# --------------------------------------------------------------------------- removal + + +def test_remove_refuses_while_referenced() -> None: + m = build() + project, fairlead = m["project"], m["fairlead"] + with pytest.raises(ObjectInUseError) as raised: + CommandStack().push(RemoveObject(project, fairlead)) + referrers_found = {(obj.name, name) for obj, name in raised.value.referrers} + assert ("mooring", "end_a") in referrers_found and ("second", "end_a") in referrers_found + assert len(project.points) == 2 + with pytest.raises(ValueError, match="not in a collection"): + plan_removal(project, [project.environment]) + lonely = project.points.append(FixedPoint("lonely")) + check_undo_redo(project, RemoveObject(project, lonely)) + + +def test_cascading_removal_of_a_point() -> None: + m = build() + project, fairlead = m["project"], m["fairlead"] + command = RemoveObject(project, fairlead, cascade=True) + check_undo_redo(project, command) + CommandStack().push(command) + assert len(project.lines) == 0 + assert len(project.failures) == 0 and len(project.controls) == 0 + assert len(project.syrope_history) == 0 and len(project.outputs.channels) == 0 + assert m["group"].members == (m["anchor"],) + assert fairlead in command.affected() + + +def test_cascading_removal_of_a_line_type_removes_emptied_lines() -> None: + m = build() + project, wire = m["project"], m["wire"] + stack = CommandStack() + before = project.to_dict() + stack.push(RemoveObject(project, wire, cascade=True)) + assert [line.name for line in project.lines] == ["mooring"] + assert [s.name for s in m["line"].sections] == ["s1"] + assert project.failures[0].lines == (m["line"],) + assert len(project.syrope_history) == 0 + stack.undo() + assert project.to_dict() == before + assert m["second"].parent is project and m["s2"].line_type is wire + + +def test_removal_of_a_section_and_a_rod() -> None: + m = build() + project = m["project"] + check_undo_redo(project, RemoveObject(project, [m["s1"], m["s2"]], cascade=True)) + rod_type = project.rod_types.append(RodType("rt")) + rod = project.rods.append(Rod("r", rod_type=rod_type)) + tip = project.points.append(RodPoint("tip", rod_end=rod.end_b)) + tether = project.lines.append(Line("tether", end_a=rod.end_a, end_b=m["anchor"])) + tether.sections.append(Section(line_type=m["chain"], length=10.0, segments=2)) + assert {obj.name for obj, _ in referrers(project, rod)} == {"tip", "tether"} + with pytest.raises(ObjectInUseError): + plan_removal(project, [rod]) + plan = plan_removal(project, [rod], cascade=True) + assert isinstance(plan, RemovalPlan) + assert {obj.name for obj in plan.removed} == {"r", "tip", "tether"} + check_undo_redo(project, RemoveObject(project, rod, cascade=True)) + del tip + + +def test_removal_of_a_body_with_optional_references() -> None: + project = Project() + body = project.bodies.append(Rigid6Body("b")) + rod_type = project.rod_types.append(RodType("rt")) + rod = project.rods.append(Rod("r", rod_type=rod_type, attachment="free", body=body)) + point = project.points.append(BodyPoint("p", body=body)) + plan = plan_removal(project, [body], cascade=True) + assert plan.removed == [body, point] + assert plan.reference_updates == [(rod, "body", body, None)] + check_undo_redo(project, RemoveObject(project, body, cascade=True)) + assert isinstance(body, Body) + + +def test_removing_nested_targets_once() -> None: + m = build() + project = m["project"] + plan = plan_removal(project, [m["line"], m["s1"], m["line"]], cascade=True) + assert plan.removed[0] is m["line"] + assert m["s1"] not in plan.removed + command = RemoveObject(project, []) + assert command.text == "Remove nothing" + assert RemoveObject(project, [m["s1"], m["s2"]]).text == "Remove 2" + end = m["line"].end_connections.append(EndConnection()) + check_undo_redo(project, RemoveObject(project, end)) + + +# --------------------------------------------------------------------------- stack + + +def test_stack_state_and_notifications() -> None: + m = build() + s1 = m["s1"] + stack = CommandStack() + seen: list[StackChanged] = [] + cancel = stack.subscribe(seen.append) + assert not stack.can_undo and not stack.can_redo + assert stack.undo_text == "" and stack.redo_text == "" + stack.undo() + stack.redo() + assert stack.is_clean + stack.push(SetProperty(s1, "length", 1.0)) + assert not stack.is_clean and stack.can_undo and "length" in stack.undo_text + stack.push(SetProperty(s1, "length", 2.0, merge=True)) + stack.set_clean() + stack.push(SetProperty(s1, "length", 3.0, merge=True)) + assert len(stack) == 3 # no merge across the clean point + stack.undo() + assert stack.is_clean and stack.redo_text + stack.undo() + stack.undo() + stack.push(SetProperty(s1, "segments", 3)) + assert not stack.is_clean and len(stack) == 1 and stack.index == 1 + assert [type(c) for c in stack.commands()] == [SetProperty] + stack.clear() + assert len(stack) == 0 and stack.is_clean + cancel() + cancel() + stack.push(SetProperty(s1, "segments", 4)) + actions = [event.action for event in seen] + assert actions[:4] == ["push", "push", "clean", "push"] and actions[-1] == "clear" + assert "undo" in actions + + +def test_stack_limit() -> None: + m = build() + s1 = m["s1"] + stack = CommandStack(limit=2) + stack.set_clean() + for value in (1.0, 2.0, 3.0): + stack.push(SetProperty(s1, "length", value)) + assert len(stack) == 2 and not stack.is_clean + stack.undo() + stack.undo() + assert s1.length == 1.0 and not stack.can_undo + stack = CommandStack(limit=1) + stack.push(SetProperty(s1, "length", 4.0)) + stack.set_clean() + stack.push(SetProperty(s1, "length", 5.0)) + assert not stack.is_clean + + +def test_macro_context_groups_and_rolls_back() -> None: + m = build() + project, s1, s2 = m["project"], m["s1"], m["s2"] + stack = CommandStack() + before = project.to_dict() + with stack.macro("Lengthen") as group: + stack.push(SetProperty(s1, "length", 300.0)) + stack.push(SetProperty(s2, "length", 300.0)) + assert not stack.can_undo + assert len(stack) == 1 and stack.undo_text == "Lengthen" and len(group.commands) == 2 + assert set(group.affected()) == {s1, s2} + stack.undo() + assert project.to_dict() == before + with pytest.raises(RuntimeError), stack.macro("fails"): + stack.push(SetProperty(s1, "length", 1.0)) + raise RuntimeError("stop") + assert s1.length == 200.0 and len(stack) == 1 + with stack.macro("empty"): + pass + assert len(stack) == 1 + + +def test_import_deck_command() -> None: + project = Project() + source = DeckReader.read(EXAMPLES / "spread_3line_chain.dat") + counts = (len(source.lines), len(source.points), len(source.outputs.channels)) + environment = source.environment + command = ImportDeck(project, source) + before = project.to_dict() + stack = CommandStack() + stack.push(command) + assert (len(project.lines), len(project.points), len(project.outputs.channels)) == counts + assert project.environment is environment + stack.undo() + assert project.to_dict() == before + stack.redo() + assert project.lines[0].end_a in project.points + keep = Project() + keep_env = keep.environment + stack.push( + ImportDeck( + keep, DeckReader.read(EXAMPLES / "spread_3line_chain.dat"), replace_environment=False + ) + ) + assert keep.environment is keep_env + + +def test_random_command_sequences_undo_to_the_start() -> None: + rng = random.Random(7) + m = build() + project = m["project"] + stack = CommandStack() + start = project.to_dict() + for _ in range(200): + lines = list(project.lines) + sections = [s for line in lines for s in line.sections] + choice = rng.randrange(5) + if choice == 0 and sections: + stack.push(SetProperty(rng.choice(sections), "length", rng.uniform(5.0, 300.0))) + elif choice == 1: + stack.push(AddObject(project.points, FixedPoint(position=(rng.random(), 0, -10)))) + elif choice == 2 and len(project.points) > 2: + stack.push(RemoveObject(project, project.points[-1], cascade=True)) + elif choice == 3 and lines: + stack.push(MoveInCollection(rng.choice(lines), rng.randrange(len(lines)))) + elif choice == 4 and lines: + target = rng.choice(list(project.points)) + line = rng.choice(lines) + if target is not line.end_b: + stack.push(Reconnect(line, "A", target)) + if rng.random() < 0.2: + stack.undo() + while stack.can_undo: + stack.undo() + assert project.to_dict() == start diff --git a/python/tests/test_project_deck.py b/python/tests/test_project_deck.py new file mode 100644 index 0000000..26e562b --- /dev/null +++ b/python/tests/test_project_deck.py @@ -0,0 +1,676 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Deck adapters: round trips over every repository deck, and the writer's mapping rules.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from cabledyn import builder as _b +from cabledyn.builder import DeckModel +from cabledyn.errors import DeckFormatError +from cabledyn.project import ( + AxialModel, + BathymetrySeabed, + BendingModel, + BodyPoint, + BuoyancyModule, + ChainType, + ClumpWeight, + CommandStack, + ConnectPoint, + Control, + CurrentModel, + DeckExportError, + DeckReader, + DeckWriter, + EndConnection, + EquivalentBuoyancy, + ExternalLoad, + ExtraOption, + Failure, + FixedPoint, + Floater, + FloaterMotionRecord, + FloaterPoint, + FloaterRAO, + FreePoint, + GenericLineType, + JonswapWave, + Line, + LinearAxial, + MotionFile, + MultiTrainSea, + NamedChannel, + NoCurrent, + NoMotion, + NoWaves, + ObjectChannel, + OchiHubbleWave, + Point, + Point3Body, + ProfileCurrent, + Project, + RegularWave, + RemoveObject, + Rigid6Body, + Rod, + RodPoint, + RodType, + Section, + SpectrumWave, + SyropeAxial, + SyropeIC, + Turbine, + TurbinePoint, + UniformCurrent, + ViscoelasticAxial, + WaveModel, + WaveTrain, + model_type, + validate_project, +) +from cabledyn.project.deck import ( + _decode_current, + _decode_scalar, + _decode_train, + _decode_vector, + _decode_wave, + _encode_current, + _encode_wave, +) +from cabledyn.project.validation import errors + +ROOT = Path(__file__).resolve().parents[2] + + +@model_type("test.wave.odd") +class OddWave(WaveModel): + """A wave model without a deck form.""" + + +@model_type("test.current.odd") +class OddCurrent(CurrentModel): + """A current model without a deck form.""" + + +DECK_FOLDERS = ("examples", "tests/data", "validation") + + +def _candidates() -> list[Path]: + found: list[Path] = [] + for folder in DECK_FOLDERS: + found.extend(sorted((ROOT / folder).rglob("*.dat"))) + return found + + +def _load(path: Path) -> DeckModel | None: + for caller_driven in (False, True): + try: + return DeckModel.load(path, caller_driven=caller_driven) + except DeckFormatError: + continue + return None + + +CANDIDATES = _candidates() + + +def normalise(project: Project) -> Any: + """Return the project's dictionary with uids replaced by their traversal order.""" + data = project.to_dict() + uids: dict[str, str] = {} + + def collect(node: Any) -> None: + if isinstance(node, dict): + if "uid" in node and "type" in node: + uids[node["uid"]] = f"#{len(uids)}" + for value in node.values(): + collect(value) + elif isinstance(node, list): + for value in node: + collect(value) + + def substitute(node: Any) -> Any: + if isinstance(node, dict): + return {key: substitute(value) for key, value in node.items()} + if isinstance(node, list): + return [substitute(value) for value in node] + return uids.get(node, node) if isinstance(node, str) else node + + collect(data) + return substitute(data) + + +def test_the_round_trip_covers_the_repository_decks() -> None: + accepted = [path for path in CANDIDATES if _load(path) is not None] + rejected = sorted(path.name for path in CANDIDATES if path not in accepted) + # Not decks (Syrope tables, a harmonics table, a plot table) and two MoorDyn + # driver decks without the dtM/TMax that a standalone CableDyn run requires. + assert rejected == [ + "cable_200m.dat", + "lozon_coupled_harmonics.dat", + "marker_curvature_plot.dat", + "mooring_80m.dat", + "syrope_owc.dat", + "syrope_settings.dat", + ] + assert len(accepted) >= 99 + + +@pytest.mark.parametrize("path", CANDIDATES, ids=lambda p: p.relative_to(ROOT).as_posix()) +def test_deck_round_trip(path: Path, tmp_path: Path) -> None: + model = _load(path) + if model is None: + pytest.skip("not a deck the deck model accepts") + canonical = model.to_text() + project = DeckReader.from_model(model) + text = DeckWriter(project).to_text() + assert text == canonical + again = DeckReader.from_text(text, path=model.path, caller_driven=model.caller_driven) + assert normalise(again) == normalise(project) + assert DeckWriter(again).to_text() == canonical + assert errors(validate_project(project)) == [] + + +# --------------------------------------------------------------------------- reader details + + +def test_reader_maps_deck_vocabulary_to_classes() -> None: + project = DeckReader.read(ROOT / "examples" / "mixed_body_rods_points.dat") + kinds = {type(point).__name__ for point in project.points} + assert {"BodyPoint", "FixedPoint"} <= kinds + assert project.bodies and project.rods + assert project.title + assert Path(project.deck_path or "").name == "mixed_body_rods_points.dat" + lazy = DeckReader.read(ROOT / "examples" / "lazy_wave_buoyancy_modules.dat") + attachments = [a for line in lazy.lines for a in line.attachments] + assert attachments and all(isinstance(a, BuoyancyModule) for a in attachments) + rao = DeckReader.read(ROOT / "examples" / "lazy_wave_vessel_rao.dat") + assert isinstance(rao.motion, FloaterRAO) and rao.motion.reference is not None + record = DeckReader.read(ROOT / "examples" / "lazy_wave_vessel_motion.dat") + assert isinstance(record.motion, (FloaterMotionRecord, MotionFile)) + trains = DeckReader.read(ROOT / "examples" / "chain_two_train_sea.dat") + assert isinstance(trains.environment.waves, MultiTrainSea) + assert len(trains.environment.waves.trains) == 2 + clump = DeckReader.read(ROOT / "examples" / "clump_weight_free_point.dat") + assert any(isinstance(p, FreePoint) for p in clump.points) + + +def test_reader_keeps_unknown_and_shadowed_options_verbatim() -> None: + original = DeckModel.load(ROOT / "examples" / "chain_catenary_r3_100m.dat") + original.options.add("dtM", 0.5) + original.options.add("dtM", 0.25, description="the effective step") + original.options.add("motionFile", "0") + original.options.add("modified_newton", "maybe") + original.options.add("waves", "airy", "1", "8") + project = DeckReader.from_model(original) + extras = [(row.keyword, tuple(row.values)) for row in project.settings.extra_options] + assert ("dtM", ("0.5",)) in extras + assert ("motionFile", ("0",)) in extras + assert ("modified_newton", ("maybe",)) in extras + assert ("waves", ("airy", "1", "8")) in extras + assert project.settings.time_step == 0.25 + assert isinstance(project.motion, NoMotion) + assert DeckWriter(project).to_text(validate=False) == original.to_text(validate=False) + source = DeckModel.load(ROOT / "examples" / "chain_catenary_r3_100m.dat") + source.options.add("motionFile", "a.txt") + source.options.add("vesselMotion", "b.txt") + source.options.add("wavetrain", "airy", "1", "8", "0") + source.options.add("wavetrain", "airy", "x", "8", "0") + project = DeckReader.from_model(source) + assert isinstance(project.motion, FloaterMotionRecord) + assert isinstance(project.environment.waves, MultiTrainSea) + assert len(project.environment.waves.trains) == 1 + assert DeckWriter(project).to_text(validate=False) == source.to_text(validate=False) + + +def test_codecs() -> None: + assert _decode_scalar("friction", ("none",)) == 0.0 + assert _decode_scalar("bool", ("yes",)) is True + assert _decode_scalar("bool", ("off",)) is False + with pytest.raises(ValueError, match="logical"): + _decode_scalar("bool", ("maybe",)) + with pytest.raises(ValueError, match="one value"): + _decode_scalar("float", ("1", "2")) + assert _decode_scalar("text", ("SeaState",)) == "SeaState" + assert isinstance(_decode_wave(("none",), False), NoWaves) + for values, cls in ( + (("airy", "2", "8", "0"), RegularWave), + (("jonswap", "2", "8", "3.3", "0"), JonswapWave), + (("PiersonMoskowitz", "2", "8", "0"), SpectrumWave), + (("ochi-hubble", "2", "12", "3", "1", "7", "1", "0"), OchiHubbleWave), + ): + wave = _decode_wave(values, False) + assert isinstance(wave, cls) + assert _decode_wave(_encode_wave(wave), False).to_dict()["properties"] == { + **wave.to_dict()["properties"] + } + with pytest.raises(ValueError, match="unrecognised"): + _decode_wave(("airy", "1"), False) + with pytest.raises(ValueError, match="only airy"): + _decode_wave(("stream", "1", "8", "0"), True) + train = _decode_train(("jonswap", "3", "7", "3.3", "0", "6")) + assert train.spreading == 6.0 + assert _decode_train(("airy", "1", "8", "0")).spreading is None + current = _decode_current(("profile", "-30", "0.2", "0", "0", "0", "0.5", "0", "0")) + assert isinstance(current, ProfileCurrent) + assert _encode_current(current)[0] == "profile" + assert isinstance(_decode_current(("uniform", "1", "0", "0")), UniformCurrent) + assert isinstance(_decode_current(("none",)), NoCurrent) + with pytest.raises(ValueError, match="unrecognised"): + _decode_current(("uniform", "1")) + assert _decode_vector(("1|2|3",)) == (1.0, 2.0, 3.0) + with pytest.raises(ValueError, match=r"x\|y\|z"): + _decode_vector(("1|2",)) + with pytest.raises(DeckExportError): + _encode_wave(MultiTrainSea()) + with pytest.raises(DeckExportError): + _encode_current(Project()) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- writer + + +def full_project() -> Project: + """A project using most object kinds, written by hand (no hints).""" + p = Project("hand built", title="hand built") + chain = p.line_types.append( + GenericLineType("chain", diameter=0.1, mass_per_length=50.0, drag_normal=1.2) + ) + chain.axial = LinearAxial(stiffness=5.0e8, damping=(-1.0,)) + cable = p.line_types.append(ChainType("cable", diameter=0.2, mass_per_length=80.0)) + cable.bending = BendingModel(bending_stiffness=1.0e4) + rod_type = p.rod_types.append(RodType("pipe", diameter=1.0, mass_per_length=100.0)) + body = p.bodies.append( + Rigid6Body( + "buoy", position=(50.0, 0.0, -20.0), mass=1.0e4, volume=20.0, inertia=(1e5, 1e5, 1e5) + ) + ) + rod = p.rods.append( + Rod( + "spar", + rod_type=rod_type, + attachment="pinned", + position_a=(0, 50, -5), + position_b=(0, 50, -25), + segments=4, + ) + ) + fairlead = p.points.append(FloaterPoint("fairlead", position=(0.0, 0.0, -10.0))) + anchor = p.points.append(FixedPoint("anchor", position=(300.0, 0.0, -100.0))) + on_body = p.points.append(BodyPoint("pad", body=body, position=(0.0, 0.0, -1.0))) + p.points.append(RodPoint("tip", rod_end=rod.end_b)) + line = p.lines.append(Line("main", end_a=fairlead, end_b=anchor, outputs="pt")) + line.sections.append(Section(line_type=chain, length=150.0, segments=15)) + line.sections.append(Section(line_type=chain, length=180.0, segments=18)) + riser = p.lines.append(Line("riser", end_a=on_body, end_b=anchor)) + riser.sections.append(Section(line_type=chain, length=280.0, segments=28)) + tether = p.lines.append(Line("tether", end_a=rod.end_a, end_b=anchor)) + tether.sections.append(Section(line_type=chain, length=320.0, segments=32)) + p.environment.water_depth = 100.0 + p.environment.gravity = 9.81 + p.settings.time_step = 0.01 + p.settings.duration = 1.0 + p.outputs.channels.append(ObjectChannel(target=line, quantity="FairTen")) + p.outputs.channels.append(ObjectChannel(target=on_body, quantity="Point", qualifier="pz")) + p.outputs.channels.append(NamedChannel(channel="AnchTen1")) + del cable + return p + + +def test_writer_builds_a_valid_deck_from_scratch() -> None: + project = full_project() + writer = DeckWriter(project) + text = writer.to_text() + assert "FairTen1" in text and "Point3pz" in text + assert "R1A" in text and "Rod1B" in text and "Body1" in text + + assert "9.81" in text and "WtrDpth" in text + id_map = writer.id_map + model, _ = DeckWriter(project).to_model() + assert id_map.deck_id(project.lines[1]) == 2 + assert id_map.object_for("line_type", "CHAIN") is project.line_types[0] + assert id_map.object_for("point", 4) is project.points[3] + assert id_map.deck_id(Line()) is None + row = text.splitlines().index(next(t for t in text.splitlines() if t.startswith("3 "))) + assert id_map.object_at_row(row) is not None + again = DeckReader.from_text(text) + assert DeckWriter(again).to_text() == text + assert len(model.lines) == 3 + + +def test_writer_numbering_after_edits() -> None: + project = DeckReader.read(ROOT / "examples" / "spread_3line_chain.dat") + original = DeckWriter(project).to_text() + CommandStack().push(RemoveObject(project, project.lines[0], cascade=True)) + extra = project.points.append(FixedPoint("new anchor", position=(10.0, 10.0, -20.0))) + _, id_map = DeckWriter(project).to_model() + ids = [id_map.deck_id(line) for line in project.lines] + assert ids == [2, 3] + assert ( + id_map.deck_id(extra) + == max(id_map.deck_id(point) or 0 for point in project.points if point is not extra) + 1 + ) + project.points[0].deck_hints["deck_id"] = project.points[1].deck_hints["deck_id"] + _, id_map = DeckWriter(project).to_model() + assert len({id_map.deck_id(point) for point in project.points}) == len(project.points) + project.line_types[0].name = "renamed chain" + text = DeckWriter(project).to_text() + assert '"renamed chain"' in text and text != original + + +def test_writer_follows_edited_option_values() -> None: + project = DeckReader.read(ROOT / "examples" / "lazy_wave_vessel_rao.dat") + text = DeckWriter(project).to_text() + assert "vesselRAO" in text + rao = project.motion + assert isinstance(rao, FloaterRAO) + project.motion = MotionFile(file=rao.file, reference=rao.reference) + text = DeckWriter(project).to_text(validate=False) + assert "motionFile" in text and "vesselRAO" not in text + project.environment.water_density = 1030.0 + text = DeckWriter(project).to_text(validate=False) + assert "1030.0" in text + project.motion = NoMotion() + text = DeckWriter(project).to_text(validate=False) + assert "motionFile" not in text and "vesselRef" not in text + + +def test_writer_writes_every_option_kind() -> None: + project = full_project() + env, settings = project.environment, project.settings + env.waves = JonswapWave(significant_height=3.0, peak_period=9.0) + env.current = UniformCurrent(velocity=(0.3, 0.0, 0.0)) + env.ramp_time = 10.0 + env.wave_seed = 3 + settings.time_step = 0.02 + settings.duration = 10.0 + settings.modified_newton = True + settings.solver_relative_tolerance = 1.0e-8 + settings.solver_absolute_tolerance = 1.0e-14 + settings.solver_max_iterations = 30 + settings.solver_backtracks = 12 + settings.solver_spectral_radius = 0.4 + project.seabed.stiffness = 2.0e5 + settings.extra_options.append(ExtraOption(keyword="WriteLog", values=("0",), note="log")) + text = DeckWriter(project).to_text() + for fragment in ( + "jonswap 3.0 9.0 3.3 0.0 waves", + "uniform 0.3 0.0 0.0 current", + "dynamic_solver 1e-08 1e-14 30 12 0.4", + "True modified_newton", + "0 WriteLog - log", + ): + assert fragment in text + env.waves = MultiTrainSea() + env.waves.trains.append(WaveTrain(wave=JonswapWave(), spreading=4.0)) + env.waves.trains.append(WaveTrain(wave=RegularWave(height=1.0))) + text = DeckWriter(project).to_text(validate=False) + assert "wavetrain" in text and "waves" not in text.split("OPTIONS")[1].split("\n")[1] + env.waves = OchiHubbleWave() + env.current = ProfileCurrent() + project.seabed = BathymetrySeabed(file="bathy.txt") + env.water_depth = None + project.motion = FloaterMotionRecord(file="motion.txt", reference=(0.0, 0.0, 0.0)) + text = DeckWriter(project).to_text(validate=False) + project.seabed.friction = 0.5 + text = DeckWriter(project).to_text(validate=False) + for fragment in ( + "ochihubble", + "profile", + "bathymetryFile", + "vesselMotion", + "vesselRef", + "0.5 frictionMu", + ): + assert fragment in text + + +def test_writer_body_rod_and_row_variants() -> None: + project = full_project() + chain = project.line_types[0] + line = project.lines[0] + body = project.bodies[0] + project.bodies.append(Point3Body("p3", position=(0.0, -50.0, -10.0))) + project.bodies.append(Floater("hull", kind="coupled")) + project.bodies.append(Floater("hull2", kind="floater", row_format="moordyn", inertia=(1.0,))) + project.bodies.append( + Rigid6Body("md", row_format="moordyn", inertia=(1.0, 2.0, 3.0), drag_area=(1.0, 2.0)) + ) + project.turbines.append(Turbine(number=2, platform_displacement=(1, 0, 0, 0, 0, 0))) + project.points.append(TurbinePoint("tp", turbine=2)) + project.points.append(ConnectPoint("c")) + project.points.append(FloaterPoint("v", kind="floater")) + line.end_connections.append(EndConnection(end="A", rotation="pinned")) + line.end_connections.append( + EndConnection(end="B", rotation="rigid", torsion="rigid", normal=(1, 0, 0), pretwist=5.0) + ) + project.lines[1].end_connections.append( + EndConnection( + end="A", + rotation="stiffness", + rotational_stiffness=1e5, + torsion="free", + normal=(1, 0, 0), + ) + ) + project.lines[2].end_connections.append( + EndConnection(end="A", torsion="stiffness", torsional_stiffness=3.0, normal=(0, 1, 0)) + ) + line.attachments.append(BuoyancyModule(arc_length=10.0, volume=1.0, mass=10.0)) + line.attachments.append( + ClumpWeight( + arc_length=20.0, pitch=10.0, last_arc_length=40.0, mass=5.0, axial_drag_area=1.0 + ) + ) + project.equivalent_buoyancy.append(EquivalentBuoyancy(line_type=chain, diameter=0.3)) + project.syrope_history.append(SyropeIC(lines=[line])) + project.failures.append(Failure(point=project.points[0], lines=[line], time=5.0)) + project.controls.append(Control(channel=2, lines=[line])) + project.external_loads.append(ExternalLoad(body=body, axes="body", force=(1.0, 2.0, 3.0))) + rod = project.rods[0] + for attachment in ("free", "fixed", "coupled", "floater"): + rod.attachment = attachment + DeckWriter(project).to_model() + rod.attachment = "body_pinned" + rod.body = body + model, _ = DeckWriter(project).to_model() + text = model.to_text(validate=False) + for fragment in ( + "Point3", + "Coupled", + "Vessel", + "Free", + "Turbine2", + "Connect", + "Pinned", + "Rigid", + "20.0:10.0:40.0", + "SYROPE IC", + "FAILURE", + "CONTROL", + "EXTERNAL LOADS", + "Body1Pinned", + "EQUIVALENT BUOYANCY", + "Free ", + ): + assert fragment in text, fragment + + +def test_writer_axial_models() -> None: + project = full_project() + chain = project.line_types[0] + chain.axial = ViscoelasticAxial(static_stiffness=1e8, dynamic_stiffness=2e8, damping=(1.0, 2.0)) + assert "100000000.0|200000000.0" in DeckWriter(project).to_text(validate=False) + chain.axial = ViscoelasticAxial(static_stiffness=1e8, alpha_mbl=10.0, beta=0.2) + assert "100000000.0|10.0|0.2" in DeckWriter(project).to_text(validate=False) + chain.axial = SyropeAxial(settings_file="settings.dat") + assert "SYROPE:settings.dat|" in DeckWriter(project).to_text(validate=False) + chain.bending = BendingModel( + bending_stiffness=1.0, + shear_stiffness=2.0, + torsional_stiffness=3.0, + rotary_inertia_axial=4.0, + rotary_inertia_normal=5.0, + ) + assert "GAs" in DeckWriter(project).to_text(validate=False) + + +@pytest.mark.parametrize( + "breaker", + [ + lambda p: setattr(p.lines[0], "end_a", None), + lambda p: setattr(p.lines[0].sections[0], "line_type", None), + lambda p: setattr(p.lines[0].sections[0], "line_type", GenericLineType("x")), + lambda p: setattr(p.points[2], "body", None), + lambda p: setattr(p.points[2], "body", Rigid6Body()), + lambda p: setattr(p.points[3], "rod_end", None), + lambda p: setattr(p.points[3], "rod_end", Rod().end_a), + lambda p: setattr(p.rods[0], "rod_type", None), + lambda p: setattr(p.lines[0], "end_b", FixedPoint()), + lambda p: setattr(p.bodies[0], "drag_area", (1.0, 2.0, 3.0)), + lambda p: setattr(p.bodies[0], "inertia", (1.0,)), + lambda p: setattr(p.line_types[0], "axial", ViscoelasticAxial()), + lambda p: setattr(p.line_types[0], "axial", SyropeAxial()), + lambda p: setattr(p.line_types[0], "name", "bad#name"), + lambda p: setattr(p, "motion", MotionFile()), + lambda p: p.outputs.channels.append(ObjectChannel(target=p.line_types[0])), + lambda p: p.outputs.channels.append(NamedChannel(channel="FairTen1")), + lambda p: p.external_loads.append(ExternalLoad()), + lambda p: p.failures.append(Failure(lines=[p.lines[0]])), + lambda p: p.controls.append(Control(lines=[Line()])), + lambda p: p.equivalent_buoyancy.append(EquivalentBuoyancy()), + lambda p: p.lines[0].attachments.append(ClumpWeight(pitch=1.0)), + lambda p: setattr(p.rods[0], "attachment", "body"), + lambda p: setattr(p.rods[0], "body", Rigid6Body()), + lambda p: setattr(p.environment, "waves", OddWave()), + lambda p: setattr(p.environment, "current", OddCurrent()), + ], +) +def test_writer_reports_the_failing_object(breaker: Any) -> None: + project = full_project() + breaker(project) + with pytest.raises(DeckExportError) as raised: + DeckWriter(project).to_text(validate=False) + issues = DeckWriter(project).validate() + assert len(issues) == 1 and issues[0].layer == 3 + assert raised.value.obj is None or raised.value.obj is issues[0].obj + + +def test_layer_three_maps_native_errors_to_objects() -> None: + project = full_project() + free = project.points.append(FreePoint("negative", mass=-5.0)) + issues = DeckWriter(project).validate() + assert len(issues) == 1 and issues[0].obj is free and issues[0].layer == 3 + project.points.remove(free) + project.seabed = BathymetrySeabed(file="bathy.txt") + issues = DeckWriter(project).validate() + assert len(issues) == 1 and issues[0].obj is project + assert "WtrDpth" in issues[0].message or "bathymetry" in issues[0].message.lower() + found = validate_project(project) + assert [issue.layer for issue in found] == [3] + assert validate_project(project, deck=False) == [] + + +def test_write_to_file(tmp_path: Path) -> None: + project = full_project() + target = DeckWriter(project).write(tmp_path / "deck.dat") + assert DeckReader.read(target).title == "hand built" + with pytest.raises(FileExistsError): + DeckWriter(project).write(target) + DeckWriter(project).write(target, overwrite=True) + + +@model_type("test.axial.odd") +class OddAxial(AxialModel): + """An axial model subclass the writer does not know.""" + + +class OddPoint(Point): + """A point kind without a deck type (not registered).""" + + +def test_reader_paths_of_an_in_memory_model() -> None: + project = full_project() + line = project.lines[0] + body = project.bodies[0] + project.bodies.append(Point3Body("p3", mass=1.0)) + project.bodies.append(Floater("hull", kind="coupled", mass=1.0)) + project.bodies.append(Floater("hull2", kind="floater", mass=1.0)) + line.end_connections.append(EndConnection(end="A", rotational_stiffness=5.0)) + line.end_connections.append( + EndConnection(end="B", torsion="stiffness", torsional_stiffness=2.0, normal=(1, 0, 0)) + ) + line.attachments.append(ClumpWeight(arc_length=12.0, mass=100.0)) + project.controls.append(Control(channel=3, lines=[line])) + project.external_loads.append(ExternalLoad(body=body)) + project.outputs.channels.append(NamedChannel(channel="FairTen9")) + project.settings.extra_options.append(ExtraOption(keyword="rhoW", values=("abc",))) + project.settings.solver_relative_tolerance = 1e-8 + project.settings.solver_absolute_tolerance = 1e-12 + project.settings.solver_max_iterations = 20 + project.settings.solver_backtracks = 5 + model, _ = DeckWriter(project).to_model() + model.options.add("dynamic_solver", 1, 2, 3) + model.options._rows.append(_b.Option("futurekeyword", ("1",))) + again = DeckReader.from_model(model) + assert [type(b).__name__ for b in again.bodies] == [ + "Rigid6Body", + "Point3Body", + "Floater", + "Floater", + ] + assert again.lines[0].end_connections[0].rotation == "stiffness" + assert again.lines[0].end_connections[1].torsion == "stiffness" + assert isinstance(again.lines[0].attachments[0], ClumpWeight) + assert again.controls[0].channel == 3 + assert again.outputs.channels[-1].name == "FairTen9" + assert isinstance(again.outputs.channels[-1], NamedChannel) + keywords = [row.keyword for row in again.settings.extra_options] + assert keywords == ["dynamic_solver", "rhoW", "dynamic_solver", "futurekeyword"] + with pytest.raises(DeckExportError, match="unknown option keyword"): + DeckWriter(again).to_text(validate=False) + again.settings.extra_options.remove(again.settings.extra_options[-1]) + assert "dynamic_solver 1 2 3" in DeckWriter(again).to_text(validate=False) + + +def test_reader_rejects_rows_it_cannot_type() -> None: + model = DeckModel.new() + model.add_line_type("odd", diam=0.1, mass=1.0, ea=(1.0, 2.0, 3.0, 4.0)) + with pytest.raises(DeckFormatError, match="unsupported EA"): + DeckReader.from_model(model) + model = DeckModel.new() + model.add_body(1, "Weird", 0.0, 0.0, 0.0) + with pytest.raises(DeckFormatError, match="unsupported type"): + DeckReader.from_model(model) + model = DeckModel.new() + model.add_point(1, "Weird", 0.0, 0.0, 0.0) + with pytest.raises(DeckFormatError, match="unsupported type"): + DeckReader.from_model(model) + + +def test_writer_refuses_unknown_subclasses() -> None: + project = full_project() + project.line_types[0].axial = OddAxial() + with pytest.raises(DeckExportError, match="cannot be written"): + DeckWriter(project).to_text(validate=False) + project = full_project() + project.points.append(OddPoint("odd")) + with pytest.raises(DeckExportError, match="no deck point type"): + DeckWriter(project).to_text(validate=False) + + +def test_reader_bathymetry_and_density_fallback() -> None: + project = full_project() + project.environment.water_depth = None + project.seabed = BathymetrySeabed(file="bathy.txt", stiffness=3.0e5, friction=0.2) + project.lines[0].attachments.append(ClumpWeight(arc_length=5.0, mass=1.0, volume=1.0)) + model, _ = DeckWriter(project).to_model() + again = DeckReader.from_model(model) + seabed = again.seabed + assert isinstance(seabed, BathymetrySeabed) + assert (seabed.file, seabed.stiffness, seabed.friction) == ("bathy.txt", 3.0e5, 0.2) + assert isinstance(again.lines[0].attachments[0], BuoyancyModule) diff --git a/python/tests/test_project_model.py b/python/tests/test_project_model.py new file mode 100644 index 0000000..c8c2724 --- /dev/null +++ b/python/tests/test_project_model.py @@ -0,0 +1,662 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Object model of cabledyn.project: descriptors, objects, events, invariants, serialisation.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from cabledyn.project import ( + REGISTRY, + AnalysisSettings, + BathymetrySeabed, + BendingModel, + BendRestrictor, + BendStiffener, + BodyPoint, + BuoyancyModule, + CableType, + ChainType, + ChildAdded, + ChildMoved, + ChildRemoved, + ClumpWeight, + EndConnection, + Environment, + Event, + FibreRopeType, + FixedPoint, + Floater, + FloaterPoint, + FloaterType, + GenericLineType, + Group, + HydroDatabaseFile, + JonswapWave, + Line, + LinearAxial, + ModelObject, + MultiTrainSea, + NoHydroDatabase, + NoWaves, + ObjectChannel, + Project, + PropertyChanged, + Quantity, + ReferenceChanged, + RegularWave, + Rigid6Body, + Rod, + RodType, + Role, + Section, + Severity, + StudioData, + SyropeAxial, + TouchdownProtection, + Turbine, + UnitSystem, + ViscoelasticAxial, + WaveTrain, + WireRopeType, + model_type, +) +from cabledyn.project.base import SerialContext, TypeRegistry, UnknownTypeError, collection_of +from cabledyn.project.descriptors import ( + Bool, + Choice, + Colour, + Integer, + JsonValue, + OptionalBool, + OptionalColour, + OptionalInteger, + OptionalQuantity, + OptionalText, + OptionalVec3, + Text, + TextList, + Vec3, + Vector, +) +from cabledyn.project.issues import Issue +from cabledyn.project.schema import DECK_LIMITS, DeckLimit, deck_limit +from cabledyn.project.units import LENGTH + + +@model_type("test.widget") +class Widget(ModelObject): + """A test class using every scalar descriptor.""" + + type_label = "Widget" + length = Quantity(LENGTH, 2.0, minimum=0.0, maximum=10.0, limit="coordinate") + width = OptionalQuantity(LENGTH, minimum=1.0, maximum=3.0) + count = Integer(3, minimum=1, maximum=5) + spare = OptionalInteger(minimum=0) + flag = Bool(False) + maybe = OptionalBool() + kind = Choice(("a", "b")) + label_text = Text("x") + note = OptionalText() + colour = Colour("#112233") + tint = OptionalColour() + offset = Vec3(LENGTH) + target = OptionalVec3(LENGTH, limit="coordinate") + values = Vector(LENGTH, (1.0,), lengths=(1, 3)) + keys = TextList(("k",)) + blob = JsonValue({"a": 1}) + + +def _events(obj: ModelObject) -> list[Event]: + seen: list[Event] = [] + obj.events.subscribe(seen.append) + return seen + + +def chain_project() -> tuple[Project, GenericLineType, FloaterPoint, FixedPoint, Line]: + project = Project("demo") + chain = project.line_types.append(GenericLineType("chain", diameter=0.2)) + fairlead = project.points.append(FloaterPoint("fairlead", position=(0, 0, -14))) + anchor = project.points.append(FixedPoint("anchor", position=(400, 0, -50))) + line = project.lines.append(Line("mooring", end_a=fairlead, end_b=anchor)) + line.sections.append(Section("s1", line_type=chain, length=410.0, segments=41)) + return project, chain, fairlead, anchor, line + + +# --------------------------------------------------------------------------- descriptors + + +def test_descriptor_defaults_and_coercion() -> None: + w = Widget("w") + assert (w.length, w.width, w.count, w.flag, w.kind) == (2.0, None, 3, False, "a") + assert w.offset == (0.0, 0.0, 0.0) and w.values == (1.0,) and w.keys == ("k",) + w.length = 3 + assert isinstance(w.length, float) + w.count = 4.0 # an integral float is accepted + assert w.count == 4 + w.offset = [1, 2, 3] # type: ignore[assignment] + assert w.offset == (1.0, 2.0, 3.0) + w.colour = "#AABBCC" + assert w.colour == "#aabbcc" + w.values = (1.0, 2.0, 3.0) + w.target = (1, 1, 1) # type: ignore[assignment] + assert w.target == (1.0, 1.0, 1.0) + w.target = None + w.blob = {"b": [1, 2]} + assert w.blob == {"b": [1, 2]} + + +@pytest.mark.parametrize( + ("name", "value", "error"), + [ + ("length", "3", TypeError), + ("length", True, TypeError), + ("count", 2.5, TypeError), + ("count", True, TypeError), + ("flag", 1, TypeError), + ("maybe", "yes", TypeError), + ("kind", "c", ValueError), + ("kind", 1, TypeError), + ("label_text", 1, TypeError), + ("note", 1, TypeError), + ("colour", "red", ValueError), + ("tint", "#12", ValueError), + ("offset", (1, 2), ValueError), + ("offset", "abc", TypeError), + ("target", (1, 2), ValueError), + ("values", (1.0, 2.0), ValueError), + ("keys", "abc", TypeError), + ("keys", (1,), TypeError), + ("width", "x", TypeError), + ("spare", "1", TypeError), + ], +) +def test_descriptor_rejects_bad_values(name: str, value: object, error: type) -> None: + w = Widget() + with pytest.raises(error): + setattr(w, name, value) + + +def test_layer_one_checks() -> None: + w = Widget() + assert w.validate() == [] + w.length = 20.0 + w.width = 0.5 + w.count = 9 + w.spare = -1 + w.target = (2.0e6, 0.0, 0.0) + w.values = (float("nan"),) + messages = {(issue.prop, issue.message) for issue in w.validate()} + assert ("length", "must be at most 10") in messages + assert ("width", "must be at least 1") in messages + assert ("count", "must be at most 5") in messages + assert ("spare", "must be at least 0") in messages + assert ("target", "magnitude must be at most 1e+06") in messages + assert any(prop == "values" and "finite" in message for prop, message in messages) + w.length = -1.0 + assert any(i.message == "must be at least 0" for i in w.validate()) + issue = w.validate()[0] + assert issue.layer == 1 and issue.is_error + assert str(issue).startswith("error: Widget: length:") + + +def test_shared_deck_limits() -> None: + assert deck_limit("segments").violation(0) == "value must be at least 1" + assert deck_limit("section_length").violation(0.0) == "value must be greater than 0" + assert deck_limit("line_mass").violation(-2.0e6) == "magnitude must be at most 1e+06" + assert deck_limit("wave_period").violation(5.0) is None + assert DeckLimit().violation(1.0e30) is None + assert set(DECK_LIMITS) >= {"coordinate", "gravity", "line_diameter"} + + +def test_property_introspection() -> None: + props = {prop.name: prop for prop in Widget.properties()} + assert list(props)[:3] == ["name", "description", "tags"] + length = props["length"] + info = length.describe() + assert info["dimension"] == "length" and info["maximum"] == 10.0 and info["kind"] == "quantity" + assert props["kind"].describe()["choices"] == ["a", "b"] + assert props["values"].describe()["lengths"] == [1, 3] + assert Widget.get_property("count").label == "Count" + with pytest.raises(KeyError, match="no property"): + Widget.get_property("nothing") + assert repr(length) == "" + assert Widget.__dict__["length"].role is Role.PHYSICS + assert props["colour"].role is Role.APPEARANCE + line_props = {prop.name: prop.describe() for prop in Line.properties()} + assert line_props["end_a"]["target"] == "LineEndTarget" + assert line_props["sections"]["min_items"] == 1 + assert {p.name: p.describe() for p in Project.properties()}["seabed"]["base"] == "Seabed" + + +def test_strategy_options_list_concrete_classes() -> None: + options = ChainType.get_property("axial").options() # type: ignore[attr-defined] + assert {cls.__name__ for cls in options} >= {"LinearAxial", "ViscoelasticAxial", "SyropeAxial"} + hydro = FloaterType.get_property("hydrodynamics").options() # type: ignore[attr-defined] + assert NoHydroDatabase in hydro and HydroDatabaseFile in hydro + + +def test_children_property_cannot_be_replaced() -> None: + project = Project() + with pytest.raises(AttributeError): + project.lines = None # type: ignore[assignment] + with pytest.raises(AttributeError): + Project.get_property("lines").coerce([]) + with pytest.raises(AttributeError, match="no settable property"): + Project(lines=[]) + with pytest.raises(AttributeError, match="no settable property"): + Widget(unknown=1) + + +def test_read_only_property() -> None: + rod = Rod() + with pytest.raises(AttributeError, match="read-only"): + rod.end_a.end = "B" + assert rod.endpoint("a") is rod.end_a and rod.endpoint("B") is rod.end_b + with pytest.raises(ValueError, match="A or B"): + rod.endpoint("C") + assert rod.end_a.label() == "Rod End A" + assert rod.end_a.parent is rod + detached = rod.end_a.__class__.make("B") + assert detached.label() == "End B" + + +# --------------------------------------------------------------------------- objects + + +def test_object_identity_and_tree() -> None: + project, chain, fairlead, anchor, line = chain_project() + assert len(project.uid) == 32 and project.uid != chain.uid + assert chain.parent is project and chain.slot == "line_types" + assert line.sections[0].root() is project + assert project.find(line.sections[0].uid) is line.sections[0] + assert project.find("missing") is None + assert list(project.objects_of(Section)) == [line.sections[0]] + assert chain in list(project.children()) + assert line.references() == [("end_a", fairlead), ("end_b", anchor)] + assert project.used_by(chain) == [(line.sections[0], "line_type")] + assert project.used_by(fairlead) == [(line, "end_a")] + assert line.length == 410.0 and line.segment_count == 41 + assert project.get_value("title") == "CableDyn model" + project.set_value("title", "renamed") + assert project.title == "renamed" + assert repr(chain).startswith(" None: + project, chain, fairlead, _anchor, line = chain_project() + seen = _events(project) + chain.diameter = 0.3 + chain.diameter = 0.3 # unchanged: no event + line.end_b = fairlead + extra = project.points.append(FixedPoint("extra")) + project.points.move(extra, 0) + project.points.move(extra, 0) # no move + project.points.remove(extra) + kinds = [type(event) for event in seen] + assert kinds == [PropertyChanged, ReferenceChanged, ChildAdded, ChildMoved, ChildRemoved] + changed = seen[0] + assert isinstance(changed, PropertyChanged) + assert (changed.source, changed.name, changed.old, changed.new) == (chain, "diameter", 0.2, 0.3) + moved = seen[3] + assert isinstance(moved, ChildMoved) and (moved.old_index, moved.new_index) == (2, 0) + assert extra.parent is None + + +def test_subscription_cancel() -> None: + w = Widget() + seen: list[Event] = [] + sub = w.events.subscribe(seen.append) + assert len(w.events) == 1 + w.count = 2 + sub.cancel() + sub.cancel() + w.count = 4 + assert len(seen) == 1 and len(w.events) == 0 + + +def test_collection_api() -> None: + project = Project() + a = project.points.append(FixedPoint("A")) + b = FixedPoint("b") + project.points.insert(-5, b) + assert project.points[0] is b and project.points[0:2] == [b, a] + assert project.points.find("a") is a and project.points.find("zz") is None + assert project.points.by_uid(a.uid) is a and project.points.by_uid("x") is None + assert a in project.points and 1 not in project.points + assert bool(project.points) and not project.lines + assert project.points.owner is project and project.points.name == "points" + assert project.points.item_type.__name__ == "Point" + assert "A" in repr(project.points) + with pytest.raises(TypeError, match="holds Point"): + project.points.append(Line()) # type: ignore[arg-type] + with pytest.raises(ValueError, match="already belongs"): + project.points.append(a) + with pytest.raises(ValueError, match="is not in"): + project.lines.index(Line()) + project.points.extend([FixedPoint("c")]) + project.points.clear() + assert len(project.points) == 0 and a.parent is None + + +def test_child_slot_replacement() -> None: + project = Project() + old = project.environment + new = Environment() + project.environment = new + assert new.parent is project and old.parent is None + with pytest.raises(ValueError, match="already belongs"): + Project().environment = new + with pytest.raises(TypeError): + project.environment = Project() # type: ignore[assignment] + project.seabed = BathymetrySeabed(file="b.txt") + assert project.seabed.parent is project + + +def test_line_types_keep_physics_construction_and_appearance_apart() -> None: + chain = ChainType("R4 chain", grade="R4", nominal_diameter=0.12) + roles = {prop.name: prop.role for prop in ChainType.properties()} + assert roles["diameter"] is Role.PHYSICS + assert roles["grade"] is Role.CONSTRUCTION + assert roles["appearance"] is Role.APPEARANCE + assert type(chain.appearance).__name__ == "ChainAppearance" + assert type(WireRopeType().appearance).__name__ == "WireRopeAppearance" + assert type(FibreRopeType().appearance).__name__ == "FibreRopeAppearance" + assert type(CableType().appearance).__name__ == "CableAppearance" + assert type(GenericLineType().appearance).__name__ == "PlainLineAppearance" + names = [prop.name for prop in ChainType.properties()] + assert names.index("appearance") < names.index("grade") + + +# --------------------------------------------------------------------------- invariants + + +def _messages(obj: ModelObject, *, recursive: bool = False) -> list[str]: + return [issue.message for issue in obj.validate(recursive=recursive)] + + +def test_reference_invariants() -> None: + line = Line() + messages = _messages(line) + assert messages.count("refers to nothing") == 2 + assert "needs at least 1 item(s)" in messages + project, _chain, fairlead, _anchor, real = chain_project() + outsider = FixedPoint("elsewhere") + real.end_b = outsider + assert any("outside this model" in m for m in _messages(real)) + real.end_b = fairlead + assert "both ends attach to one object" in _messages(real) + group = Group(members=[outsider]) + project.studio.groups.append(group) + assert any("outside this model" in m for m in _messages(group)) + from cabledyn.project import SyropeIC + + assert "needs at least 1 item(s)" in _messages(SyropeIC()) + + +def test_line_invariants() -> None: + _project, _chain, _fairlead, _anchor, line = chain_project() + line.end_connections.append(EndConnection(end="A")) + line.end_connections.append(EndConnection(end="A")) + module = BuoyancyModule(arc_length=500.0) + line.attachments.append(module) + series = ClumpWeight(arc_length=10.0, pitch=10.0, last_arc_length=40.0) + line.attachments.append(series) + assert series.is_series() and series.locations() == [10.0, 20.0, 30.0, 40.0] + assert not module.is_series() and module.locations() == [500.0] + line.ancillaries.append(BendStiffener(start=0.0, length=5.0)) + line.ancillaries.append(TouchdownProtection(start=3.0, end=8.0)) + line.ancillaries.append(BendRestrictor(start=20.0, end=10.0)) + line.ancillaries.append(TouchdownProtection(start=400.0, end=500.0)) + messages = _messages(line, recursive=True) + assert "End A has two end connections" in messages + assert any("beyond the line" in m for m in messages) + assert "overlaps another ancillary" in messages + assert "ends before it starts" in messages + assert "extends beyond the line" in messages + half = ClumpWeight(pitch=5.0) + assert "give pitch and last_arc_length together" in _messages(half) + assert half.locations() == [0.0] + + +def test_module_volume_warning() -> None: + module = BuoyancyModule(volume=1.0) + assert module.geometric_volume() is None + module.geometry.outer_diameter = 1.0 + module.geometry.length = 1.0 + issues = module.validate() + assert issues and issues[0].severity is Severity.WARNING + module.geometry.length = 1.27324 + assert module.validate() == [] + + +def test_end_connection_invariants() -> None: + row = EndConnection(direction=(0, 0, 0)) + assert "must not be zero" in _messages(row) + row = EndConnection(normal=(1, 0, 0)) + assert "normal and pretwist need a torsion" in _messages(row) + row = EndConnection(torsion="free") + assert "a torsion needs a normal" in _messages(row) + row.normal = (1.0, 0.0, 0.0) + assert _messages(row) == [] + + +def test_type_invariants() -> None: + assert _messages(ViscoelasticAxial()) == [ + "give either dynamic_stiffness or both alpha_mbl and beta" + ] + assert _messages(ViscoelasticAxial(dynamic_stiffness=2e8)) == [] + assert _messages(ViscoelasticAxial(alpha_mbl=10.0, beta=0.2)) == [] + assert _messages(ViscoelasticAxial(alpha_mbl=10.0)) + assert _messages(SyropeAxial()) == ["needs a settings file"] + assert _messages(BendingModel(shear_stiffness=1.0)) + full = BendingModel( + shear_stiffness=1.0, + torsional_stiffness=1.0, + rotary_inertia_axial=1.0, + rotary_inertia_normal=1.0, + ) + assert full.extended() and _messages(full) == [] + assert _messages(RodType(axial_drag=1.0)) == ["give both axial coefficients or neither"] + assert _messages(BathymetrySeabed()) == ["needs a bathymetry file"] + assert _messages(LinearAxial(stiffness=1e16)) == ["value must be at most 1e+15"] + + +def test_body_and_rod_invariants() -> None: + body = Rigid6Body(drag_area=(1.0, 2.0, 3.0)) + messages = _messages(body) + assert "the CableDyn layout takes one value" in messages + body.inertia = (1.0,) + assert "the CableDyn layout takes 0 or 3" in _messages(body) + moordyn = Rigid6Body(row_format="moordyn") + assert "the MoorDyn layout needs it" in _messages(moordyn) + rod = Rod(attachment="body") + assert any("needs its body" in m for m in _messages(rod)) + + +def test_settings_and_wave_invariants() -> None: + settings = AnalysisSettings(solver_relative_tolerance=1e-8) + assert "give the four dynamic-solver values together" in _messages(settings) + settings = AnalysisSettings(solver_spectral_radius=0.4) + assert "the solver spectral radius needs the four solver values" in _messages(settings) + assert _messages(WaveTrain(wave=NoWaves())) == ["a train needs a wave or spectrum"] + assert _messages(WaveTrain(wave=RegularWave(theory="stream"))) == [ + "a regular train must be airy" + ] + assert _messages(WaveTrain(wave=JonswapWave())) == [] + assert "needs at least 1 item(s)" in _messages(MultiTrainSea()) + + +def test_project_invariants() -> None: + project, _chain, *_ = chain_project() + project.line_types.append(GenericLineType("CHAIN")) + project.line_types.append(GenericLineType("")) + project.rod_types.append(RodType("r")) + project.turbines.append(Turbine(number=1)) + project.turbines.append(Turbine(number=1)) + project.unknown_objects.append({"data": {}}) + messages = _messages(project) + assert "line type name 'CHAIN' is used twice" in messages + assert "a line type needs a name" in messages + assert "turbine number is used twice" in messages + assert any("unknown type" in m for m in messages) + + +# --------------------------------------------------------------------------- serialisation + + +def test_to_dict_and_from_dict_round_trip() -> None: + project, chain, _fairlead, _anchor, line = chain_project() + floater_type = project.floater_types.append(FloaterType("semi", draft=20.0)) + floater_type.hydrodynamics = HydroDatabaseFile(file="semi.hdb", source_format="BEM") + floater = project.bodies.append(Floater("FOWT", floater_type=floater_type)) + project.points.append(BodyPoint("hang-off", body=floater)) + project.outputs.channels.append(ObjectChannel(target=line, quantity="FairTen")) + project.studio.groups.append(Group("g", members=[line, chain])) + project.environment.waves = JonswapWave() + chain.deck_hints["note"] = ("kept", 1) + data = project.to_dict() + copy = Project.from_dict(data) + assert copy.to_dict() == data + assert copy.uid == project.uid + assert copy.lines[0].end_a is copy.points[0] + assert copy.studio.groups[0].members == (copy.lines[0], copy.line_types[0]) + assert copy.bodies[0].floater_type is copy.floater_types[0] # type: ignore[attr-defined] + assert copy.line_types[0].deck_hints["note"] == ["kept", 1] + fresh = Project.from_dict(data, fresh_uids=True) + assert fresh.uid != project.uid + with pytest.raises(TypeError, match="does not describe"): + Line.from_dict(data) + + +def test_from_dict_tolerates_unknown_and_bad_entries() -> None: + project, _chain, *_ = chain_project() + data = project.to_dict() + data["properties"]["points"].append({"type": "plugin.buoy", "uid": "u1", "properties": {}}) + data["properties"]["points"].append("garbage") + data["properties"]["lines"][0]["properties"]["outputs"] = 5 + data["properties"]["lines"][0]["properties"]["bogus"] = 1 + data["properties"]["lines"][0]["properties"]["end_b"] = "no-such-uid" + data["properties"]["line_types"].append( + {"type": "section", "uid": "s", "properties": {"length": 1.0}} + ) + data["properties"]["environment"] = {"type": "plugin.env", "properties": {}} + data["properties"]["outputs"]["properties"] = "bad" + ctx = SerialContext() + rebuilt = ctx.build(data) + ctx.resolve() + assert isinstance(rebuilt, Project) + assert len(rebuilt.points) == 2 + assert rebuilt.lines[0].outputs == "-" + assert rebuilt.lines[0].end_b is None + assert isinstance(rebuilt.environment, Environment) + assert sorted(entry["data"]["type"] for entry in ctx.unknown) == ["plugin.buoy", "plugin.env"] + text = " | ".join(ctx.warnings) + for fragment in ("unknown type", "malformed", "kept the default", "unknown property"): + assert fragment in text + assert "dropped reference end_b" in text + assert "not a valid item" in text + with pytest.raises(KeyError): + ctx.lookup(5) + + +def test_registry() -> None: + registry = TypeRegistry() + registry.register("x", Widget) + registry.register("x", Widget) + with pytest.raises(ValueError, match="already used"): + registry.register("x", Project) + assert registry.get("x") is Widget and "x" in registry and registry.keys() == ["x"] + with pytest.raises(UnknownTypeError): + registry.get("y") + assert REGISTRY.get("line") is Line + assert "test.widget" in REGISTRY + + +def test_studio_data_and_units() -> None: + studio = StudioData() + assert studio.unit_system() == UnitSystem.si() + studio.set_unit_system(UnitSystem.engineering()) + assert studio.unit_system() == UnitSystem.engineering() + + +def test_project_validate_all_runs_every_layer() -> None: + project, *_ = chain_project() + project.environment.water_depth = 50.0 + assert project.validate_all() == [] + project.lines[0].sections[0].segments = 0 + issues = project.validate_all() + assert issues and all(issue.layer == 1 for issue in issues) + assert isinstance(issues[0], Issue) + + +def test_floater_type_extension_slot() -> None: + @model_type("test.hydro.tabulated") + class TabulatedDatabase(HydroDatabaseFile): + """A plugin database kind.""" + + floater_type = FloaterType() + assert isinstance(floater_type.hydrodynamics, NoHydroDatabase) + floater_type.hydrodynamics = TabulatedDatabase(file="x") + data: dict[str, Any] = floater_type.to_dict() + assert data["properties"]["hydrodynamics"]["type"] == "test.hydro.tabulated" + assert isinstance(FloaterType.from_dict(data).hydrodynamics, TabulatedDatabase) + assert TabulatedDatabase in FloaterType.get_property("hydrodynamics").options() # type: ignore[attr-defined] + + +def test_descriptor_edge_cases() -> None: + from cabledyn.project.descriptors import Children, Ref, RefList, _same + + with pytest.raises(ValueError, match="at least one choice"): + Choice(()) + with pytest.raises(ValueError, match="is not one of"): + Choice(("a",), "b") + assert OptionalVec3(LENGTH, (1, 2, 3)).default == (1.0, 2.0, 3.0) + assert OptionalQuantity(LENGTH).dimension is LENGTH + assert OptionalVec3(LENGTH).dimension is LENGTH + assert Vector(LENGTH).dimension is LENGTH + ref = Line.get_property("end_a") + assert isinstance(ref, Ref) + with pytest.raises(TypeError, match="must refer to a LineEndTarget"): + ref.coerce(Section()) + refs = Group.get_property("members") + assert isinstance(refs, RefList) + with pytest.raises(TypeError, match="sequence of objects"): + refs.coerce("abc") + from cabledyn.project import SyropeIC + + with pytest.raises(TypeError, match="Line objects"): + SyropeIC.get_property("lines").coerce([Section()]) + assert refs.describe()["min_items"] == 0 + assert refs.from_json([], SerialContext(), Group()) == () + children = Project.get_property("lines") + assert isinstance(children, Children) and children.is_owner + project = Project() + assert children.same(project.lines, project.lines) + a, b = Section(), Section() + assert _same(a, a) and not _same(a, b) + assert _same(float("nan"), float("nan")) + assert not _same((1.0,), (1.0, 2.0)) + + +def test_misc_edges() -> None: + from cabledyn.project import RemoveObject + + project, _chain, _fairlead, _anchor, line = chain_project() + command = RemoveObject(project, line) + assert command.affected() == (line,) + hub_owner = Widget() + first = hub_owner.events.subscribe(lambda event: None) + second = hub_owner.events.subscribe(lambda event: None) + second.cancel() + first.cancel() + assert len(hub_owner.events) == 0 + assert line.references()[0][0] == "end_a" + line.end_a = None + assert [name for name, _ in line.references()] == ["end_b"] diff --git a/python/tests/test_project_store.py b/python/tests/test_project_store.py new file mode 100644 index 0000000..328099d --- /dev/null +++ b/python/tests/test_project_store.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The .cdproj project file.""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path +from typing import Any + +import pytest + +from cabledyn.project import ( + SCHEMA_VERSION, + CameraBookmark, + ChainType, + DeckReader, + DeckWriter, + FixedPoint, + Group, + Line, + Project, + ProjectFormatError, + ProjectStore, + UnitSystem, +) +from cabledyn.project import store as store_module + +EXAMPLE = Path(__file__).resolve().parents[2] / "examples" / "lazy_wave_buoyancy_modules.dat" + + +def decorated() -> Project: + project = DeckReader.read(EXAMPLE) + chain = project.line_types.append(ChainType("R4 chain", grade="R4", nominal_diameter=0.1)) + chain.appearance.set_value("finish", "rusted") + project.lines[0].colour = "#ff0000" + project.studio.groups.append(Group("cables", members=list(project.lines))) + project.studio.cameras.append(CameraBookmark("overview", azimuth=45.0)) + project.studio.layouts = {"main": {"docks": ["browser", "properties"]}} + project.studio.set_unit_system(UnitSystem.engineering()) + return project + + +@pytest.mark.parametrize("folder", [False, True]) +def test_save_and_load_keep_the_whole_graph(tmp_path: Path, folder: bool) -> None: + project = decorated() + target = ProjectStore.save(project, tmp_path / "model.cdproj", folder=folder) + loaded = ProjectStore.load(target) + assert loaded.to_dict() == project.to_dict() + assert loaded.load_warnings == [] + assert DeckWriter(loaded).to_text() == DeckWriter(project).to_text() + assert loaded.studio.unit_system() == UnitSystem.engineering() + assert loaded.studio.groups[0].members[0] is loaded.lines[0] + if folder: + assert (target / "model.dat").read_text(encoding="utf-8") == DeckWriter(project).to_text() + assert (target / "assets").is_dir() + assert json.loads((target / "results.json").read_text(encoding="utf-8")) == {"runs": []} + else: + with zipfile.ZipFile(target) as archive: + names = set(archive.namelist()) + assert {"project.json", "model.dat", "results.json", "assets/"} <= names + + +def test_save_refuses_to_overwrite(tmp_path: Path) -> None: + project = decorated() + target = ProjectStore.save(project, tmp_path / "a.cdproj") + with pytest.raises(FileExistsError): + ProjectStore.save(project, target) + ProjectStore.save(project, target, overwrite=True) + folder = ProjectStore.save(project, tmp_path / "b.cdproj", folder=True) + project.lines[0].end_a = None + ProjectStore.save(project, folder, folder=True, overwrite=True) + assert not (folder / "model.dat").exists() + document = json.loads((folder / "project.json").read_text(encoding="utf-8")) + assert document["deck"]["written"] is False + assert ProjectStore.load(folder).lines[0].end_a is None + + +def test_invalid_project_still_saves(tmp_path: Path) -> None: + project = Project("draft") + project.points.append(FixedPoint("lonely")) + project.lines.append(Line("unfinished")) + target = ProjectStore.save(project, tmp_path / "draft.cdproj") + with zipfile.ZipFile(target) as archive: + assert "model.dat" not in archive.namelist() + document = json.loads(archive.read("project.json")) + assert document["schema_version"] == SCHEMA_VERSION + assert document["format"] == "cabledyn-project" + assert ProjectStore.load(target).to_dict() == project.to_dict() + + +def test_unknown_object_types_are_kept(tmp_path: Path) -> None: + project = decorated() + document = ProjectStore.to_json(project) + document["project"]["properties"]["points"].append( + {"type": "plugin.special_point", "uid": "abc", "properties": {"size": 3}} + ) + loaded = ProjectStore.from_json(document) + assert len(loaded.points) == len(project.points) + assert loaded.unknown_objects[0]["data"]["type"] == "plugin.special_point" + assert any("unknown type" in warning for warning in loaded.load_warnings) + again = ProjectStore.from_json(ProjectStore.to_json(loaded)) + assert again.unknown_objects == loaded.unknown_objects + target = ProjectStore.save(loaded, tmp_path / "kept.cdproj") + assert ProjectStore.load(target).unknown_objects == loaded.unknown_objects + + +def test_bad_documents(tmp_path: Path) -> None: + with pytest.raises(ProjectFormatError, match="not a CableDyn project document"): + ProjectStore.from_json({"format": "other"}) + with pytest.raises(ProjectFormatError, match="invalid schema version"): + ProjectStore.from_json({"format": "cabledyn-project", "schema_version": "1"}) + with pytest.raises(ProjectFormatError, match="reads up to"): + ProjectStore.from_json({"format": "cabledyn-project", "schema_version": 99}) + with pytest.raises(ProjectFormatError, match="holds no project"): + ProjectStore.from_json( + {"format": "cabledyn-project", "schema_version": 1, "project": {"type": "line"}} + ) + with pytest.raises(FileNotFoundError): + ProjectStore.load(tmp_path / "missing.cdproj") + junk = tmp_path / "junk.cdproj" + junk.write_text("not a zip", encoding="utf-8") + with pytest.raises(ProjectFormatError, match="not a CableDyn project file"): + ProjectStore.load(junk) + empty = tmp_path / "empty.cdproj" + empty.mkdir() + with pytest.raises(ProjectFormatError, match=r"has no project\.json"): + ProjectStore.load(empty) + (empty / "project.json").write_text("{broken", encoding="utf-8") + with pytest.raises(ProjectFormatError, match="not valid JSON"): + ProjectStore.load(empty) + + +def test_migrations_run_in_order(monkeypatch: pytest.MonkeyPatch) -> None: + project = decorated() + document = ProjectStore.to_json(project) + seen: list[int] = [] + + def upgrade(data: dict[str, Any]) -> dict[str, Any]: + seen.append(data["schema_version"]) + return {**data, "schema_version": data["schema_version"] + 1} + + monkeypatch.setattr(store_module, "SCHEMA_VERSION", 2) + monkeypatch.setitem(store_module._MIGRATIONS, 1, upgrade) + loaded = ProjectStore.from_json(document) + assert seen == [1] + assert loaded.to_dict() == project.to_dict() diff --git a/python/tests/test_project_units.py b/python/tests/test_project_units.py new file mode 100644 index 0000000..445bc7b --- /dev/null +++ b/python/tests/test_project_units.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Units layer of the project object model.""" + +from __future__ import annotations + +import math + +import pytest + +from cabledyn.project.units import ( + ANGLE, + DIMENSIONLESS, + FORCE, + FORCE_PER_LENGTH, + FREQUENCY, + LENGTH, + MASS, + STIFFNESS, + UNITS, + UnitError, + UnitSystem, + convert, + parse_quantity, + unit, + units_for, +) + + +def test_convert_between_units_of_one_dimension() -> None: + assert convert(2.5, "kN", "N") == 2500.0 + assert convert(1.0, "ft", "m") == pytest.approx(0.3048) + assert convert(180.0, "deg", "rad") == pytest.approx(math.pi) + assert convert(1.0, "Hz", "rad/s") == pytest.approx(2.0 * math.pi) + assert convert(3.0, "m", "m") == 3.0 + + +def test_convert_refuses_mixed_dimensions_and_unknown_units() -> None: + with pytest.raises(UnitError, match="cannot convert"): + convert(1.0, "m", "kg") + with pytest.raises(UnitError, match="unknown unit"): + convert(1.0, "furlong", "m") + + +def test_shared_symbols_need_a_dimension() -> None: + with pytest.raises(UnitError, match="ambiguous"): + unit("kN/m") + assert unit("kN/m", STIFFNESS).dimension == STIFFNESS + assert unit("kN/m", FORCE_PER_LENGTH).scale == 1.0e3 + assert convert(2.0, "kN/m", "N/m", STIFFNESS) == 2000.0 + with pytest.raises(UnitError, match="for mass"): + unit("m", MASS) + + +def test_unit_round_trip_and_listing() -> None: + tonne = unit("t") + assert tonne.to_si(2.0) == 2000.0 + assert tonne.from_si(2000.0) == 2.0 + lengths = units_for(LENGTH) + assert lengths[0].symbol == "m" + assert {item.symbol for item in lengths} >= {"mm", "km", "ft"} + assert all(item.dimension in {u.dimension for u in UNITS} for item in UNITS) + + +def test_parse_quantity() -> None: + assert parse_quantity("850 m", LENGTH) == 850.0 + assert parse_quantity("1.2 MN", FORCE) == pytest.approx(1.2e6) + assert parse_quantity("12", LENGTH, "mm") == pytest.approx(0.012) + assert parse_quantity("-3e2", LENGTH) == -300.0 + assert parse_quantity("0.5", DIMENSIONLESS) == 0.5 + with pytest.raises(UnitError, match="not a number"): + parse_quantity("about ten", LENGTH) + with pytest.raises(UnitError): + parse_quantity("10 kg", LENGTH) + + +def test_unit_system_display_and_preferences() -> None: + si = UnitSystem.si() + assert si.format(850.0, LENGTH) == "850 m" + assert si.format(0.3, DIMENSIONLESS) == "0.3" + eng = UnitSystem.engineering() + assert eng.format(1.5e6, FORCE) == "1500 kN" + assert eng.to_display(2000.0, MASS) == 2.0 + assert eng.from_display(2.0, MASS) == 2000.0 + assert eng.unit_for(ANGLE).symbol == "deg" + assert "force" in eng.dimensions() + copy = UnitSystem.from_dict(eng.to_dict()) + assert copy == eng + assert copy != si + assert repr(copy).startswith("UnitSystem(") + with pytest.raises(UnitError, match="unknown dimension"): + si.set("colour", "m") + with pytest.raises(UnitError): + si.set("length", "kg") + si.set("frequency", "rad/s") + assert si.to_display(1.0, FREQUENCY) == pytest.approx(2.0 * math.pi) From aba5ae4245f8378bfacf53a9a76443cdb2c45372 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 2 Oct 2026 21:08:54 +0900 Subject: [PATCH 3/5] Document the project object model Add a developer page for cabledyn.project (design, deck and project-file adapters, extension, API reference) and a changelog entry. --- CHANGELOG.md | 6 + doc/index.rst | 1 + doc/project_model.rst | 357 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 doc/project_model.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b0e78..f5c12e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ All notable changes to CableDyn are recorded in this file. The format follows ### Added +- `cabledyn.project`, a GUI-independent object model of a complete CableDyn model: typed, + unit-aware objects that refer to each other by reference, change events, undoable commands, + three validation layers ending in the native deck rules, deck import and export through + `DeckModel` (every repository deck reads and writes back to its canonical text), and the + `.cdproj` project file. Documented in the "Project object model" page. + - Torsion of finite-EI lines in the standalone driver (condensed, quasi-static, uniform torque): `END CONNECTIONS` columns `TorsStiffness NxX NxY NxZ [Pretwist]`, a `motionFile` roll column, the torque returned to Rigid6 bodies, channels `TorqN`, `TwistN` and `Twist`, diff --git a/doc/index.rst b/doc/index.rst index dcb892e..89e0111 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -221,6 +221,7 @@ as described in :doc:`citing`. python python_postprocessing api_python + project_model api_reference .. toctree:: diff --git a/doc/project_model.rst b/doc/project_model.rst new file mode 100644 index 0000000..55ef93b --- /dev/null +++ b/doc/project_model.rst @@ -0,0 +1,357 @@ +.. SPDX-License-Identifier: Apache-2.0 + +Project object model +==================== + +.. module:: cabledyn.project + +:mod:`cabledyn.project` describes a complete CableDyn model as a graph of plain Python objects. +It is the model layer of the planned graphical front end and is also usable from scripts. It +has no GUI-toolkit dependency. + +The package is under development: its classes and the project-file schema may change before +they are used by a released front end. + +Design +------ + +Objects and properties + Every object is a :class:`ModelObject` with a stable ``uid``, a ``name``, a ``parent`` and + typed properties declared with descriptors (:class:`Quantity`, :class:`Integer`, + :class:`Choice`, :class:`Vec3`, :class:`Ref`, :class:`Children`, :class:`Strategy`, ...). + Quantities are stored in SI units. Each property has a role: ``physics`` (written to the + deck), ``construction`` (library and construction data such as a chain grade or a floater's + hydrodynamic database; not written today), ``appearance`` (for the 3D views; never written), + ``derived`` or ``meta``. :meth:`ModelObject.properties` lists the descriptors, which carry + label, group, unit dimension, limits and documentation for editors. + +References, renaming and removal + Objects refer to each other by reference, never by name or deck id, so renaming cannot break + a link. Removing a referenced object either fails with :class:`ObjectInUseError` or cascades + (:func:`plan_removal`): a referrer whose required reference is lost is removed with it, an + optional reference is cleared, and a line left without sections is removed. + :meth:`Project.used_by` answers "used by" questions. + +Events and commands + A change publishes :class:`PropertyChanged`, :class:`ReferenceChanged`, + :class:`ChildAdded`, :class:`ChildRemoved` or :class:`ChildMoved` on the object and on every + ancestor, so one subscription on the :class:`Project` sees the whole model. Interactive edits + are :class:`Command` objects pushed onto a :class:`CommandStack`, which provides undo, redo, + merging of drags, grouping (:meth:`CommandStack.macro`) and a clean (saved) marker. Undo + restores the project's :meth:`~ModelObject.to_dict` exactly. + +Validation + Three layers (:func:`validate_project`): property checks with the numeric limits shared with + the deck reader (:mod:`cabledyn.project.schema`), object invariants (references resolve, + lines have sections, arc locations lie on the line, names the deck needs are unique), and the + native deck rules, applied by writing the deck and reading it with :class:`cabledyn.DeckFile`. + A deck error is mapped back to the object whose row failed. No deck rule is restated in the + object model. + +Units + :class:`UnitSystem` holds the display unit chosen for each :class:`Dimension`; values are + converted only for display. :func:`cabledyn.project.units.parse_quantity` reads text such as + ``"1.2 MN"``. + +Floaters + The object model uses the term *floater*: :class:`Floater` bodies, :class:`FloaterPoint`, + the shared :class:`FloaterType` library entry, and the motion sources + :class:`FloaterMotionRecord` and :class:`FloaterRAO`. The deck keeps its own ``Vessel`` + keywords (``Vessel`` points and bodies, ``vesselMotion``, ``vesselRAO``, ``vesselRef``), which + the deck adapters map. The ``hydrodynamics`` property of :class:`FloaterType` is the slot for + a hydrodynamic database: new database kinds register as :class:`HydroDatabase` subclasses. + +Deck adapters and the project file +---------------------------------- + +:class:`DeckReader` reads a deck through :class:`cabledyn.DeckModel` and builds the object +graph; :class:`DeckWriter` builds a fresh :class:`~cabledyn.DeckModel` and renders it. Deck ids, +keyword spellings, the order and descriptions of ``OPTIONS`` rows are kept as hints, so a +deck read and written back gives exactly ``DeckModel.load(deck).to_text()``. Comments and +column alignment of the original file are not kept. ``OPTIONS`` rows the model does not type are +kept verbatim as :class:`ExtraOption` objects. The writer returns an :class:`IdMap` between +objects and deck ids. + +.. code-block:: python + + from cabledyn.project import CommandStack, DeckReader, DeckWriter, ProjectStore + from cabledyn.project.commands import SetProperty + + project = DeckReader.read("examples/lazy_wave_buoyancy_modules.dat") + stack = CommandStack() + stack.push(SetProperty(project.lines[0].sections[0], "length", 120.0)) + stack.undo() + ProjectStore.save(project, "lazy_wave.cdproj") + text = DeckWriter(project).to_text() + +:class:`ProjectStore` writes a ``.cdproj`` zip archive (or an unzipped folder) with +``project.json`` (the whole graph, physics and appearance, with a schema version), +``model.dat`` (the deck, when the project is valid), ``assets/`` and ``results.json``. Objects of +a type the reader does not know are kept verbatim. + +Extending the model +------------------- + +A new object kind is a :class:`ModelObject` subclass registered with :func:`model_type`: + +.. code-block:: python + + from cabledyn.project import HydroDatabase, model_type + from cabledyn.project.descriptors import FilePath + + @model_type("myplugin.hydro.tabulated") + class TabulatedDatabase(HydroDatabase): + """Hydrodynamic coefficients read from a table.""" + + table = FilePath() + +API reference +------------- + +Base classes, descriptors and events +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: cabledyn.project.ModelObject + :members: + +.. autoclass:: cabledyn.project.ObjectCollection + :members: + +.. autofunction:: cabledyn.project.model_type + +.. autoclass:: cabledyn.project.TypeRegistry + :members: + +.. autoexception:: cabledyn.project.UnknownTypeError + +.. autoclass:: cabledyn.project.SerialContext + :members: + +.. autoclass:: cabledyn.project.Role + :members: + +.. autoclass:: cabledyn.project.Property + :members: describe, coerce, check + +.. autoclass:: cabledyn.project.Quantity +.. autoclass:: cabledyn.project.OptionalQuantity +.. autoclass:: cabledyn.project.Integer +.. autoclass:: cabledyn.project.OptionalInteger +.. autoclass:: cabledyn.project.Bool +.. autoclass:: cabledyn.project.OptionalBool +.. autoclass:: cabledyn.project.Choice +.. autoclass:: cabledyn.project.Text +.. autoclass:: cabledyn.project.OptionalText +.. autoclass:: cabledyn.project.FilePath +.. autoclass:: cabledyn.project.TextList +.. autoclass:: cabledyn.project.Colour +.. autoclass:: cabledyn.project.OptionalColour +.. autoclass:: cabledyn.project.Vector +.. autoclass:: cabledyn.project.Vec3 +.. autoclass:: cabledyn.project.OptionalVec3 +.. autoclass:: cabledyn.project.JsonValue +.. autoclass:: cabledyn.project.Ref +.. autoclass:: cabledyn.project.RefList +.. autoclass:: cabledyn.project.Child +.. autoclass:: cabledyn.project.Strategy + :members: options +.. autoclass:: cabledyn.project.Children + +.. autoclass:: cabledyn.project.EventHub + :members: +.. autoclass:: cabledyn.project.Subscription + :members: +.. autoclass:: cabledyn.project.Event +.. autoclass:: cabledyn.project.PropertyChanged +.. autoclass:: cabledyn.project.ReferenceChanged +.. autoclass:: cabledyn.project.ChildAdded +.. autoclass:: cabledyn.project.ChildRemoved +.. autoclass:: cabledyn.project.ChildMoved + +Project and model objects +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: cabledyn.project.Project + :members: find, objects_of, used_by, validate_all + +.. autoclass:: cabledyn.project.Environment +.. autoclass:: cabledyn.project.WaveModel +.. autoclass:: cabledyn.project.NoWaves +.. autoclass:: cabledyn.project.RegularWave +.. autoclass:: cabledyn.project.JonswapWave +.. autoclass:: cabledyn.project.SpectrumWave +.. autoclass:: cabledyn.project.OchiHubbleWave +.. autoclass:: cabledyn.project.MultiTrainSea +.. autoclass:: cabledyn.project.WaveTrain +.. autoclass:: cabledyn.project.CurrentModel +.. autoclass:: cabledyn.project.NoCurrent +.. autoclass:: cabledyn.project.UniformCurrent +.. autoclass:: cabledyn.project.ProfileCurrent +.. autoclass:: cabledyn.project.Seabed +.. autoclass:: cabledyn.project.FlatSeabed +.. autoclass:: cabledyn.project.BathymetrySeabed +.. autoclass:: cabledyn.project.MotionSource +.. autoclass:: cabledyn.project.NoMotion +.. autoclass:: cabledyn.project.MotionFile +.. autoclass:: cabledyn.project.FloaterMotionRecord +.. autoclass:: cabledyn.project.FloaterRAO +.. autoclass:: cabledyn.project.AnalysisSettings +.. autoclass:: cabledyn.project.ExtraOption + +.. autoclass:: cabledyn.project.LineType +.. autoclass:: cabledyn.project.GenericLineType +.. autoclass:: cabledyn.project.ChainType +.. autoclass:: cabledyn.project.WireRopeType +.. autoclass:: cabledyn.project.FibreRopeType +.. autoclass:: cabledyn.project.CableType +.. autoclass:: cabledyn.project.AxialModel +.. autoclass:: cabledyn.project.LinearAxial +.. autoclass:: cabledyn.project.ViscoelasticAxial +.. autoclass:: cabledyn.project.SyropeAxial +.. autoclass:: cabledyn.project.BendingModel +.. autoclass:: cabledyn.project.RodType +.. autoclass:: cabledyn.project.FloaterType +.. autoclass:: cabledyn.project.HydroDatabase +.. autoclass:: cabledyn.project.NoHydroDatabase +.. autoclass:: cabledyn.project.HydroDatabaseFile + +.. autoclass:: cabledyn.project.Body +.. autoclass:: cabledyn.project.Rigid6Body +.. autoclass:: cabledyn.project.Point3Body +.. autoclass:: cabledyn.project.Floater +.. autoclass:: cabledyn.project.Turbine +.. autoclass:: cabledyn.project.Rod + :members: endpoint +.. autoclass:: cabledyn.project.LineEndTarget +.. autoclass:: cabledyn.project.RodEndpoint +.. autoclass:: cabledyn.project.Point +.. autoclass:: cabledyn.project.FixedPoint +.. autoclass:: cabledyn.project.FloaterPoint +.. autoclass:: cabledyn.project.FreePoint +.. autoclass:: cabledyn.project.ConnectPoint +.. autoclass:: cabledyn.project.TurbinePoint +.. autoclass:: cabledyn.project.BodyPoint +.. autoclass:: cabledyn.project.RodPoint + +.. autoclass:: cabledyn.project.Line + :members: length, segment_count +.. autoclass:: cabledyn.project.Section +.. autoclass:: cabledyn.project.EndConnection +.. autoclass:: cabledyn.project.Attachment + :members: is_series, locations +.. autoclass:: cabledyn.project.BuoyancyModule +.. autoclass:: cabledyn.project.ClumpWeight +.. autoclass:: cabledyn.project.LineAncillary +.. autoclass:: cabledyn.project.BendStiffener +.. autoclass:: cabledyn.project.BendRestrictor +.. autoclass:: cabledyn.project.TouchdownProtection +.. autoclass:: cabledyn.project.EquivalentBuoyancy +.. autoclass:: cabledyn.project.SyropeIC +.. autoclass:: cabledyn.project.Failure +.. autoclass:: cabledyn.project.Control +.. autoclass:: cabledyn.project.ExternalLoad +.. autoclass:: cabledyn.project.OutputRequest +.. autoclass:: cabledyn.project.Channel +.. autoclass:: cabledyn.project.ObjectChannel +.. autoclass:: cabledyn.project.NamedChannel + +Appearance and GUI data +~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: cabledyn.project.LineAppearance +.. autoclass:: cabledyn.project.PlainLineAppearance +.. autoclass:: cabledyn.project.ChainAppearance +.. autoclass:: cabledyn.project.WireRopeAppearance +.. autoclass:: cabledyn.project.FibreRopeAppearance +.. autoclass:: cabledyn.project.CableAppearance +.. autoclass:: cabledyn.project.ModuleGeometry +.. autoclass:: cabledyn.project.MeshAsset +.. autoclass:: cabledyn.project.EnvironmentAppearance +.. autoclass:: cabledyn.project.SeabedAppearance +.. autoclass:: cabledyn.project.StudioData + :members: unit_system, set_unit_system +.. autoclass:: cabledyn.project.Group +.. autoclass:: cabledyn.project.CameraBookmark + +Commands and references +~~~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: cabledyn.project.CommandStack + :members: +.. autoclass:: cabledyn.project.StackChanged +.. autoclass:: cabledyn.project.Command + :members: +.. autoclass:: cabledyn.project.SetProperty +.. autoclass:: cabledyn.project.ReplaceStrategy +.. autoclass:: cabledyn.project.Reconnect +.. autoclass:: cabledyn.project.AddObject +.. autoclass:: cabledyn.project.RemoveObject +.. autoclass:: cabledyn.project.MoveInCollection +.. autoclass:: cabledyn.project.MacroCommand +.. autoclass:: cabledyn.project.SplitSection +.. autoclass:: cabledyn.project.ApplyLibraryItem +.. autoclass:: cabledyn.project.ImportDeck +.. autofunction:: cabledyn.project.plan_removal +.. autofunction:: cabledyn.project.referrers +.. autoclass:: cabledyn.project.RemovalPlan +.. autoexception:: cabledyn.project.ObjectInUseError + +Validation and units +~~~~~~~~~~~~~~~~~~~~ + +.. autofunction:: cabledyn.project.validate_project +.. autoclass:: cabledyn.project.Issue + :members: is_error +.. autoclass:: cabledyn.project.Severity + :members: +.. autoclass:: cabledyn.project.UnitSystem + :members: +.. autoclass:: cabledyn.project.Dimension +.. autoclass:: cabledyn.project.Unit + :members: +.. autoexception:: cabledyn.project.UnitError +.. autofunction:: cabledyn.project.units.convert +.. autofunction:: cabledyn.project.units.parse_quantity +.. autofunction:: cabledyn.project.units.unit +.. autofunction:: cabledyn.project.units.units_for +.. autoclass:: cabledyn.project.schema.DeckLimit + :members: +.. autofunction:: cabledyn.project.schema.deck_limit + +.. autodata:: cabledyn.project.schema.DECK_LIMITS + :no-value: + +Adapters +~~~~~~~~ + +.. autoclass:: cabledyn.project.DeckReader + :members: +.. autoclass:: cabledyn.project.DeckWriter + :members: +.. autoclass:: cabledyn.project.IdMap + :members: +.. autoexception:: cabledyn.project.DeckExportError +.. autoclass:: cabledyn.project.ProjectStore + :members: +.. autoexception:: cabledyn.project.ProjectFormatError + +Modules +~~~~~~~ + +Every public name above is importable from :mod:`cabledyn.project`; the classes are defined in +these modules. + +.. py:module:: cabledyn.project.descriptors + :synopsis: Property descriptors. +.. py:module:: cabledyn.project.events + :synopsis: Change events. +.. py:module:: cabledyn.project.commands + :synopsis: Commands and the command stack. +.. py:module:: cabledyn.project.references + :synopsis: Reverse references and removal cascades. +.. py:module:: cabledyn.project.validation + :synopsis: The validation layers. +.. py:module:: cabledyn.project.units + :synopsis: Dimensions, units and display preferences. +.. py:module:: cabledyn.project.schema + :synopsis: Deck limits shared with the deck reader. From c2e61838566d54def26a47e5b146800b2c360bff Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 2 Oct 2026 21:56:56 +0900 Subject: [PATCH 4/5] Harden the project object model after review - Number EXTERNAL LOADS rows 1..N in collection order on write, so removing or adding a load keeps the deck valid (the other numbered tables already tolerate gaps or are renumbered by DeckModel). - An ExtraOption that sets a typed option never overrides it: the writer places it before the typed row and validation warns about it. - Removal cascades follow conditionally required references (ModelObject.required_references: a body rod's body, a turbine point's turbine); weak references such as group memberships never block a removal, are cleared, and are left out of "used by". - uids are unique within a project: insertion of a used uid and ownership cycles are refused, from_dict gives copies fresh uids by default (with within= to keep references for a paste), and a project file with duplicate uids is rejected. - Axial damping keeps its units apart: a damping in N s or a dimensionless damping ratio (written as the negative BA), plus the dynamic damping. - Turbine points refer to their Turbine; rod endpoints are read-only. - The project file is strict JSON (non-finite numbers refused, JSON-safe GUI data), model.dat is written only for a valid project with its side files rebased to the project folder (missing ones listed), deck_path is stored relative to the project folder, saves are atomic, loader errors are ProjectFormatError, and unknown objects, properties, values and references are written back unchanged and in place. - The command stack refuses re-entrant pushes and keeps its index on a failed undo or redo; ImportDeck moves objects (and option hints) only when applied; AddObject appends at apply time. - Unregistered subclasses cannot be written as their parent; the abstract flag is per class; describe() reports numeric limits; malformed hints are ignored. Tests: a regression per finding, a hint-free write/read check over every repository deck, seeded generated graphs and a hand-written deck with asserted values; test-only types no longer leak into the registry. --- python/cabledyn/project/base.py | 143 +++++- python/cabledyn/project/collection.py | 6 +- python/cabledyn/project/commands.py | 88 +++- python/cabledyn/project/deck.py | 144 ++++-- python/cabledyn/project/descriptors.py | 64 ++- python/cabledyn/project/points.py | 41 +- python/cabledyn/project/project.py | 43 +- python/cabledyn/project/references.py | 21 +- python/cabledyn/project/rods.py | 9 +- python/cabledyn/project/settings.py | 24 + python/cabledyn/project/store.py | 259 ++++++++-- python/cabledyn/project/studio.py | 2 +- python/cabledyn/project/types.py | 48 +- python/cabledyn/project/units.py | 18 +- python/tests/test_project_commands.py | 4 +- python/tests/test_project_deck.py | 43 +- python/tests/test_project_hardening.py | 670 +++++++++++++++++++++++++ python/tests/test_project_model.py | 36 +- 18 files changed, 1455 insertions(+), 208 deletions(-) create mode 100644 python/tests/test_project_hardening.py diff --git a/python/cabledyn/project/base.py b/python/cabledyn/project/base.py index 6fe479c..8bc5a9f 100644 --- a/python/cabledyn/project/base.py +++ b/python/cabledyn/project/base.py @@ -68,6 +68,10 @@ def register(self, key: str, cls: type[ModelObject]) -> None: raise ValueError(f"type key {key!r} is already used by {existing.__name__}") self._by_key[key] = cls + def unregister(self, key: str) -> None: + """Remove the class registered under ``key`` (no-op if none).""" + self._by_key.pop(key, None) + def get(self, key: str) -> type[ModelObject]: """Return the class registered under ``key``. @@ -91,9 +95,7 @@ def keys(self) -> list[str]: def subclasses_of(self, base: type[ModelObject]) -> list[type[ModelObject]]: """Return the registered concrete classes that are ``base`` or derive from it.""" return [ - cls - for cls in self._by_key.values() - if issubclass(cls, base) and not cls.__dict__.get("abstract", False) + cls for cls in self._by_key.values() if issubclass(cls, base) and not cls.is_abstract() ] @@ -125,8 +127,8 @@ def model_type(key: str) -> Callable[[type[M]], type[M]]: """ def register(cls: type[M]) -> type[M]: - cls.type_key = key REGISTRY.register(key, cls) + cls.type_key = key return cls return register @@ -136,19 +138,30 @@ class SerialContext: """State of one project-file read or write. It maps uids to rebuilt objects, defers references until every object - exists, and collects objects of unknown type (kept verbatim) and warnings. + exists, and collects objects of unknown type (kept verbatim), duplicate + uids and warnings. + + Values this version cannot use (an unknown property, a value a property + refuses, a reference to an object that is not rebuilt, a sub-object of + unknown type) are kept raw on the object and written back unchanged by + :meth:`ModelObject.to_dict` until the property is assigned. Parameters ---------- fresh_uids : bool Give rebuilt objects new uids (for copy and paste) instead of the - stored ones. + stored ones. References inside the rebuilt data still resolve. + within : ModelObject | None + An existing object graph (the project a copy is pasted into); + references to its objects resolve to them. """ - def __init__(self, *, fresh_uids: bool = False) -> None: + def __init__(self, *, fresh_uids: bool = False, within: ModelObject | None = None) -> None: self.fresh_uids = fresh_uids + self.within = within self.objects: dict[str, ModelObject] = {} self.unknown: list[dict[str, Any]] = [] + self.duplicates: list[str] = [] self.warnings: list[str] = [] self._pending: list[tuple[ModelObject, Ref[Any] | RefList[Any], Any]] = [] @@ -177,6 +190,9 @@ def build( return None key = data["type"] if key not in REGISTRY: + self.warn(f"kept an object of unknown type {key!r} verbatim") + if owner is not None and index is None: + return None # a sub-object slot: the owner keeps the raw data self.unknown.append( { "owner": None if owner is None else owner.uid, @@ -185,16 +201,19 @@ def build( "data": data, } ) - self.warn(f"kept an object of unknown type {key!r} verbatim") return None cls = REGISTRY.get(key) obj = cls.__new__(cls) obj._setup() stored = data.get("uid") - if isinstance(stored, str) and stored and not self.fresh_uids: - obj._uid = stored if isinstance(stored, str) and stored: - self.objects[stored] = obj + if stored in self.objects: + self.duplicates.append(stored) + self.warn(f"uid {stored} is used by more than one object") + else: + self.objects[stored] = obj + if not self.fresh_uids: + obj._uid = stored hints = data.get("deck_hints") if isinstance(hints, dict): obj.deck_hints = dict(hints) @@ -207,7 +226,11 @@ def build( try: value = prop.from_json(values[prop.name], self, obj) except (TypeError, ValueError) as exc: - self.warn(f"{cls.__name__}.{prop.name}: kept the default ({exc})") + self.warn( + f"{cls.__name__}.{prop.name}: kept the default and the stored value " + f"for a later save ({exc})" + ) + obj._preserved[prop.name] = values[prop.name] continue if not isinstance(prop, Children): old = obj._values[prop.name] @@ -216,7 +239,8 @@ def build( obj._values[prop.name] = value for name in values: if name not in cls._property_map(): - self.warn(f"{cls.__name__}: ignored unknown property {name!r}") + self.warn(f"{cls.__name__}: kept unknown property {name!r} verbatim") + obj._preserved[name] = values[name] return obj def defer(self, obj: ModelObject, prop: Ref[Any] | RefList[Any], data: Any) -> None: @@ -231,9 +255,13 @@ def lookup(self, uid: Any) -> ModelObject: KeyError If no rebuilt object has ``uid``. """ - if not isinstance(uid, str) or uid not in self.objects: - raise KeyError(f"no object with uid {uid!r}") - return self.objects[uid] + if isinstance(uid, str) and uid in self.objects: + return self.objects[uid] + if isinstance(uid, str) and self.within is not None: + for obj in self.within.walk(): + if obj.uid == uid: + return obj + raise KeyError(f"no object with uid {uid!r}") def resolve(self) -> None: """Resolve every deferred reference; dangling ones become warnings.""" @@ -241,7 +269,11 @@ def resolve(self) -> None: try: obj._values[prop.name] = prop.resolve(data, self) except (KeyError, TypeError) as exc: - self.warn(f"{obj.label()}: dropped reference {prop.name} ({exc})") + self.warn( + f"{obj.label()}: reference {prop.name} is unresolved and kept for a " + f"later save ({exc})" + ) + obj._preserved[prop.name] = data self._pending.clear() @@ -274,7 +306,8 @@ class ModelObject(ModelObjectLike): type_label: ClassVar[str] = "Object" """Human-readable class name for the GUI.""" abstract: ClassVar[bool] = True - """Abstract classes are not offered by :meth:`Strategy.options`.""" + """Set ``abstract = True`` in a class body to mark that class (not its + subclasses) abstract; read it with :meth:`is_abstract`.""" name = Text("", role=Role.META, group="Identity", doc="Name shown in the model browser.") description = Text("", multiline=True, role=Role.META, group="Identity", doc="Free-text notes.") @@ -293,8 +326,14 @@ def __init__(self, name: str = "", /, **values: object) -> None: raise AttributeError(f"{type(self).__name__} has no settable property {key!r}") setattr(self, key, value) + @classmethod + def is_abstract(cls) -> bool: + """Whether this class itself is abstract (not offered as a strategy option).""" + return bool(cls.__dict__.get("abstract", False)) + def _setup(self) -> None: self._uid = uuid.uuid4().hex + self._preserved: dict[str, Any] = {} self._parent: ModelObject | None = None self._slot: str | None = None self.events = EventHub() @@ -384,6 +423,20 @@ def _detach(self) -> None: self._parent = None self._slot = None + def _check_adoption(self, obj: ModelObject) -> None: + """Refuse to make ``obj`` a descendant of this object if that forms a cycle.""" + node: ModelObject | None = self + while node is not None: + if node is obj: + raise ValueError(f"{obj.label()} cannot own itself or one of its owners") + node = node._parent + + def _adopt(self, obj: ModelObject) -> None: + """Called on the root when ``obj`` joins its tree (the project tracks uids).""" + + def _release(self, obj: ModelObject) -> None: + """Called on the root when ``obj`` leaves its tree.""" + def children(self) -> Iterator[ModelObject]: """Yield the directly owned sub-objects, in property order.""" for prop in type(self).properties(): @@ -410,6 +463,17 @@ def references(self) -> list[tuple[str, ModelObject]]: found.extend((prop.name, target) for target in self._values[prop.name]) return found + def required_references(self) -> frozenset[str]: + """Return the names of the references this object cannot do without now. + + A cascading removal that takes the target of one of them removes this + object too. The base returns the :class:`~cabledyn.project.Ref` + properties declared ``required``; subclasses add conditional ones. + """ + return frozenset( + prop.name for prop in type(self).properties() if isinstance(prop, Ref) and prop.required + ) + # ------------------------------------------------------------------ changes def _assign(self, prop: Property[Any], value: Any) -> None: @@ -421,9 +485,18 @@ def _assign(self, prop: Property[Any], value: Any) -> None: if isinstance(prop, Child): if value.parent is not None: raise ValueError(f"{value.label()} already belongs to {value.parent.label()}") + self._check_adoption(value) + root = self.root() + root._release(old) + try: + root._adopt(value) + except ValueError: + root._adopt(old) + raise old._detach() value._attach(self, prop.name) self._values[prop.name] = value + self._preserved.pop(prop.name, None) event_type = ReferenceChanged if prop.is_reference else PropertyChanged self.emit(event_type(self, prop.name, old, value)) @@ -472,8 +545,20 @@ def validate(self, *, recursive: bool = False) -> list[Issue]: def to_dict(self, ctx: SerialContext | None = None) -> dict[str, Any]: """Return the JSON-compatible form of this object and its descendants. - References are written as the target's uid. + References are written as the target's uid. Raw values kept from a + project file (see :class:`SerialContext`) are written back unchanged. + + Raises + ------ + TypeError + If the class is not registered with :func:`model_type` (it would + be read back as another class). """ + cls = type(self) + if self.type_key not in REGISTRY or REGISTRY.get(self.type_key) is not cls: + raise TypeError( + f"{cls.__name__} is not registered with model_type and cannot be written" + ) context = ctx or SerialContext() data: dict[str, Any] = { "type": self.type_key, @@ -483,22 +568,34 @@ def to_dict(self, ctx: SerialContext | None = None) -> dict[str, Any]: for prop in type(self).properties() }, } + data["properties"].update(_json_copy(self._preserved)) if self.deck_hints: data["deck_hints"] = _json_copy(self.deck_hints) return data @classmethod - def from_dict(cls: type[M], data: dict[str, Any], *, fresh_uids: bool = False) -> M: + def from_dict( + cls: type[M], + data: dict[str, Any], + *, + fresh_uids: bool = True, + within: ModelObject | None = None, + ) -> M: """Rebuild an object (and its descendants) written by :meth:`to_dict`. - References to objects outside ``data`` are dropped with a warning. + The copy gets new uids by default, so it can be pasted next to the + original. References to objects outside ``data`` are kept raw (see + :class:`SerialContext`). Parameters ---------- data : dict The dictionary. fresh_uids : bool - Give the rebuilt objects new uids. + Give the rebuilt objects new uids; ``False`` keeps the stored ones. + within : ModelObject | None + The graph the copy will join (normally the project): references to + its objects resolve, so a pasted line keeps its end points. Returns ------- @@ -510,7 +607,7 @@ def from_dict(cls: type[M], data: dict[str, Any], *, fresh_uids: bool = False) - TypeError If ``data`` does not describe a ``cls`` object. """ - ctx = SerialContext(fresh_uids=fresh_uids) + ctx = SerialContext(fresh_uids=fresh_uids, within=within) obj = ctx.build(data) if not isinstance(obj, cls): raise TypeError(f"data does not describe a {cls.__name__}") diff --git a/python/cabledyn/project/collection.py b/python/cabledyn/project/collection.py index 78b6807..cec627d 100644 --- a/python/cabledyn/project/collection.py +++ b/python/cabledyn/project/collection.py @@ -113,7 +113,8 @@ def insert(self, index: int, item: ObjT) -> None: TypeError If ``item`` has the wrong type. ValueError - If ``item`` already has a parent. + If ``item`` already has a parent, would own its own owner, or + brings a uid the project already uses. """ if not isinstance(item, self._type): raise TypeError( @@ -121,6 +122,8 @@ def insert(self, index: int, item: ObjT) -> None: ) if item.parent is not None: raise ValueError(f"{item.label()} already belongs to {item.parent.label()}") + self._owner._check_adoption(item) + self._owner.root()._adopt(item) position = max(0, min(index, len(self._items))) self._items.insert(position, item) item._attach(self._owner, self._name) @@ -145,6 +148,7 @@ def remove(self, item: ObjT) -> int: If ``item`` is not in the collection. """ position = self.index(item) + self._owner.root()._release(item) del self._items[position] item._detach() self._owner.emit(ChildRemoved(self._owner, self._name, item, position)) diff --git a/python/cabledyn/project/commands.py b/python/cabledyn/project/commands.py index 56ac4ca..90bd42c 100644 --- a/python/cabledyn/project/commands.py +++ b/python/cabledyn/project/commands.py @@ -25,6 +25,7 @@ from __future__ import annotations import contextlib +import copy from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -260,10 +261,12 @@ def __init__( raise ValueError(f"{obj.label()} already belongs to {obj.parent.label()}") self.collection = collection self.obj = obj - self.index = len(collection) if index is None else index + self.index = index self.text = f"Add {obj.label()}" def do(self) -> None: + if self.index is None: + self.index = len(self.collection) self.collection.insert(self.index, self.obj) def undo(self) -> None: @@ -475,47 +478,83 @@ def __init__( ) -class ImportDeck(MacroCommand): +class ImportDeck(Command): """Add the objects of a deck (or of another project) to a project. Libraries, objects and rows are appended; the environment, seabed, - motion and settings of the imported model replace the project's when - ``replace_environment`` is true. Name clashes of line or rod types are - left for validation to report. + motion and settings of the imported model (and its option spellings and + order) replace the project's when ``replace_environment`` is true. Name + clashes of line or rod types are left for validation to report. The + objects move out of ``source`` when the command is first applied. Parameters ---------- project : Project The receiving project. source : Project - A project built by :class:`~cabledyn.project.DeckReader`; its objects - move into ``project``. + A project built by :class:`~cabledyn.project.DeckReader`. replace_environment : bool - Also take the environment, seabed, motion and settings. + Also take the environment, seabed, motion, settings and option hints. """ _SINGLE = ("environment", "seabed", "motion", "settings") - def __init__(self, project: Project, source: Project, *, replace_environment: bool = True): + def __init__( + self, project: Project, source: Project, *, replace_environment: bool = True + ) -> None: + self.project = project + self.source = source + self.replace_environment = replace_environment + self.text = f"Import {source.title}" + self._inner: MacroCommand | None = None + self._hints: tuple[Any, Any] | None = None + + def _build(self) -> MacroCommand: + project, source = self.project, self.source commands: list[Command] = [] for prop in type(source).properties(): if not isinstance(prop, Children): continue - items = list(source._values[prop.name]) target = project._values[prop.name] - for item in items: + for item in list(source._values[prop.name]): source._values[prop.name].remove(item) commands.append(AddObject(target, item)) for channel in list(source.outputs.channels): source.outputs.channels.remove(channel) commands.append(AddObject(project.outputs.channels, channel)) - if replace_environment: + if self.replace_environment: for name in self._SINGLE: replacement = source._values[name] replacement._detach() source._values[name] = type(source).get_property(name).initial(source) commands.append(SetProperty(project, name, replacement)) - super().__init__(f"Import {source.title}", commands) + self._hints = ( + project.deck_hints.get("options"), + copy.deepcopy(source.deck_hints.get("options")), + ) + return MacroCommand(self.text, commands) + + def do(self) -> None: + if self._inner is None: + self._inner = self._build() + self._inner.do() + if self._hints is not None: + self._set_hints(self._hints[1]) + + def undo(self) -> None: + assert self._inner is not None + self._inner.undo() + if self._hints is not None: + self._set_hints(self._hints[0]) + + def _set_hints(self, hints: Any) -> None: + if hints is None: + self.project.deck_hints.pop("options", None) + else: + self.project.deck_hints["options"] = copy.deepcopy(hints) + + def affected(self) -> tuple[ModelObject, ...]: + return (self.project,) if self._inner is None else (self.project, *self._inner.affected()) @dataclass(frozen=True) @@ -549,6 +588,7 @@ def __init__(self, limit: int = 0) -> None: self._clean: int | None = 0 self._limit = limit self._macro: list[MacroCommand] = [] + self._busy = False self._listeners: list[Callable[[StackChanged], None]] = [] # ------------------------------------------------------------------ state @@ -606,15 +646,29 @@ def _notify(self, action: str, command: Command | None) -> None: # ------------------------------------------------------------------ actions + def _run(self, action: Callable[[], None]) -> None: + if self._busy: + raise RuntimeError( + "the command stack is applying a command; a change listener must not push, " + "undo or redo (schedule it after the current change instead)" + ) + self._busy = True + try: + action() + finally: + self._busy = False + def push(self, command: Command) -> None: """Apply ``command`` and record it (inside a macro: add it to the macro). Raises ------ + RuntimeError + If called while a command is being applied (from a change listener). Exception Whatever the command raises; nothing is recorded then. """ - command.do() + self._run(command.do) self._record(command) def _record(self, command: Command) -> None: @@ -640,9 +694,9 @@ def undo(self) -> None: """Revert the last applied command (no-op if none).""" if not self.can_undo: return + command = self._commands[self._index - 1] + self._run(command.undo) self._index -= 1 - command = self._commands[self._index] - command.undo() self._notify("undo", command) def redo(self) -> None: @@ -650,7 +704,7 @@ def redo(self) -> None: if not self.can_redo: return command = self._commands[self._index] - command.do() + self._run(command.do) self._index += 1 self._notify("redo", command) diff --git a/python/cabledyn/project/deck.py b/python/cabledyn/project/deck.py index 0ec8c73..b985883 100644 --- a/python/cabledyn/project/deck.py +++ b/python/cabledyn/project/deck.py @@ -104,7 +104,7 @@ ViscoelasticAxial, ) -__all__ = ["DeckExportError", "DeckReader", "DeckWriter", "IdMap"] +__all__ = ["DeckExportError", "DeckReader", "DeckWriter", "IdMap", "typed_option"] _TRUE = frozenset({"true", "t", "yes", "y", "on", "1"}) _FALSE = frozenset({"false", "f", "no", "n", "off", "0"}) @@ -603,25 +603,23 @@ def _lines(self, rows: Iterable[_b.Line]) -> list[Line]: return [self.lines[id(line)] for line in rows] def line_type(self, row: _b.LineType) -> None: - damping = row.ba if isinstance(row.ba, tuple) else (row.ba,) axial: LinearAxial | ViscoelasticAxial | SyropeAxial if isinstance(row.ea, _b.SyropeEA): - axial = SyropeAxial( - settings_file=row.ea.settings, alpha=row.ea.alpha, beta=row.ea.beta, damping=damping - ) + axial = SyropeAxial(settings_file=row.ea.settings, alpha=row.ea.alpha, beta=row.ea.beta) elif isinstance(row.ea, tuple): if len(row.ea) == 2: - axial = ViscoelasticAxial( - static_stiffness=row.ea[0], dynamic_stiffness=row.ea[1], damping=damping - ) + axial = ViscoelasticAxial(static_stiffness=row.ea[0], dynamic_stiffness=row.ea[1]) elif len(row.ea) == 3: axial = ViscoelasticAxial( - static_stiffness=row.ea[0], alpha_mbl=row.ea[1], beta=row.ea[2], damping=damping + static_stiffness=row.ea[0], alpha_mbl=row.ea[1], beta=row.ea[2] ) else: raise DeckFormatError(f"line type {row.name!r}: unsupported EA {row.ea!r}") else: - axial = LinearAxial(stiffness=row.ea, damping=damping) + axial = LinearAxial(stiffness=row.ea) + if isinstance(row.ba, tuple) and len(row.ba) > 2: + raise DeckFormatError(f"line type {row.name!r}: unsupported BA {row.ba!r}") + axial.set_deck_damping(row.ba) bending = BendingModel( bending_stiffness=row.ei, shear_stiffness=row.gas, @@ -734,7 +732,11 @@ def point(self, row: _b.Point) -> None: elif kind == "rod" and row.rod_end is not None: item = RodPoint(rod_end=self._endpoint(row.rod_end)) elif kind in {"turbine", "t"} and row.turbine is not None: - item = TurbinePoint(turbine=row.turbine) + farm = {turbine.number: turbine for turbine in self.project.turbines} + if row.turbine in farm: + item = TurbinePoint(turbine=farm[row.turbine]) + else: + item = TurbinePoint(turbine_number=row.turbine) else: raise DeckFormatError(f"point {row.id}: unsupported type {row.type!r}") item.name = f"Point {row.id}" @@ -823,7 +825,7 @@ def external_load(self, row: _b.ExternalLoad) -> None: linear_damping=_tuple(row.blin), quadratic_damping=_tuple(row.bquad), ) - item.deck_hints.update(deck_id=row.id, csys=row.csys) + item.deck_hints["csys"] = row.csys self.project.external_loads.append(item) # ------------------------------------------------------------------ options @@ -1046,11 +1048,50 @@ class _Row: values: tuple[str, ...] description: str | None owner: ModelObject + group: str | None + + +def _valid_hint(hint: Any) -> bool: + """Whether an option hint (possibly from a hand-edited file) has the expected shape.""" + return ( + isinstance(hint, dict) + and isinstance(hint.get("position"), (int, float)) + and not isinstance(hint.get("position"), bool) + and isinstance(hint.get("keyword"), str) + and isinstance(hint.get("values"), list) + and all(isinstance(token, str) for token in hint["values"]) + and isinstance(hint.get("encoded"), list) + and isinstance(hint.get("description"), (str, type(None))) + ) + + +# ``wavetrain`` rows add up (every row is a train), so a kept one never overrides. +_SPECIAL_OPTIONS = frozenset({"waves", "current", "vesselref", "bathymetry", "dynamic_solver"}) + + +def typed_option(keyword: str) -> str | None: + """Return the typed option an ``OPTIONS`` keyword sets, or ``None``. + + ``motionFile``, ``vesselMotion`` and ``vesselRAO`` all set ``"motion"``. + """ + identity = _option_key(keyword) + if identity in _MOTION_KEYS: + return "motion" + if identity in _SCALAR_BY_ID or identity in _SPECIAL_OPTIONS: # one row wins + return identity + return None class DeckWriter: """Write a :class:`~cabledyn.project.Project` as a CableDyn deck. + Relative side-file paths resolve from the folder of the project's + ``deck_path``; with no ``deck_path`` they resolve from the working folder + (:class:`~cabledyn.project.ProjectStore` anchors them at the project + file). Points, lines, bodies and rods keep their deck ids where they can; + external loads are numbered 1, 2, ... in collection order, as the deck + requires. + Parameters ---------- project : Project @@ -1236,16 +1277,12 @@ def _ids(self) -> None: for position, item in enumerate(items): if position not in assigned: following += 1 - while following in used: - following += 1 - used.add(following) assigned[position] = following self.id_map.add(item, kind, assigned[position]) def _id(self, obj: ModelObject) -> int: found = self.id_map.deck_id(obj) - if not isinstance(found, int): - raise self._fail(obj, "is not part of the project") + assert isinstance(found, int) # every numbered object gets an id in _ids return found def _libraries(self, model: DeckModel) -> None: @@ -1294,7 +1331,7 @@ def _line_type(self, model: DeckModel, item: LineType) -> None: diam=item.diameter, mass=item.mass_per_length, ea=ea, - ba=_multi(axial.damping), + ba=axial.deck_damping(), ei=bending.bending_stiffness, cdn=item.drag_normal, cdt=item.drag_axial, @@ -1378,6 +1415,8 @@ def _body(self, model: DeckModel, body: Body) -> Any: def _rod(self, model: DeckModel, rod: Rod) -> _b.Rod: if rod.rod_type is None: raise self._fail(rod, "has no rod type") + if rod.rod_type.parent is not self.project: + raise self._fail(rod, "uses a rod type outside the project") canonical = _ROD_TOKENS[rod.attachment] accepted = frozenset( token @@ -1441,7 +1480,11 @@ def _point(self, model: DeckModel, point: Point) -> _b.Point: rod_end = self._rod_end(model, point.rod_end) elif isinstance(point, TurbinePoint): token = _hinted(point, "Turbine", frozenset({"turbine", "t"})) - turbine = point.turbine + turbine = point.number() + if turbine is None: + raise self._fail(point, "has no turbine") + if point.turbine is not None and point.turbine.parent is not self.project: + raise self._fail(point, "refers to a turbine outside the project") else: raise self._fail(point, "has no deck point type") x, y, z = point.position @@ -1555,19 +1598,17 @@ def _rows(self, model: DeckModel) -> None: hint = load.deck_hints.get("csys") letter = "L" if load.axes == "body" else "G" csys = hint if isinstance(hint, str) and hint.upper() == letter else letter - deck_id = load.deck_hints.get("deck_id") - load_id = deck_id if isinstance(deck_id, int) else number self._call( load, model.add_external_load, - load_id, + number, self._deck_bodies[id(load.body)], csys=csys, force=_multi(load.force), blin=_multi(load.linear_damping), bquad=_multi(load.quadratic_damping), ) - self.id_map.add(load, "external_load", load_id) + self.id_map.add(load, "external_load", number) self._sections["EXTERNAL LOADS"].append(load) def _end_connection(self, model: DeckModel, row: EndConnection, line: Line) -> None: @@ -1622,24 +1663,28 @@ def _options(self, model: DeckModel) -> None: rows: list[_Row] = [] fresh = 1.0e9 - def add(identity: str, keyword: str, encoded: tuple[str, ...], owner: ModelObject) -> None: + def hinted( + hint: Any, keyword: str, encoded: tuple[str, ...], owner: ModelObject, group: str + ) -> None: nonlocal fresh - hint = hints.get(identity) - if isinstance(hint, dict): - same = list(encoded) == hint.get("encoded") - values = tuple(hint["values"]) if same else encoded + if _valid_hint(hint): + same = list(encoded) == hint["encoded"] rows.append( _Row( float(hint["position"]), - str(hint.get("keyword") or keyword), - values, - hint.get("description"), + hint["keyword"] or keyword, + tuple(hint["values"]) if same else encoded, + hint["description"], owner, + group, ) ) else: fresh += 1.0 - rows.append(_Row(fresh, keyword, encoded, None, owner)) + rows.append(_Row(fresh, keyword, encoded, None, owner, group)) + + def add(identity: str, keyword: str, encoded: tuple[str, ...], owner: ModelObject) -> None: + hinted(hints.get(identity), keyword, encoded, owner, identity.split(":")[0]) for scalar in _SCALARS: owner = _owner(project, scalar.owner) @@ -1657,21 +1702,7 @@ def add(identity: str, keyword: str, encoded: tuple[str, ...], owner: ModelObjec if isinstance(waves, MultiTrainSea): for train in waves.trains: encoded = _encode_train(train) - hint = train.deck_hints.get("option") - if isinstance(hint, dict): - same = list(encoded) == hint.get("encoded") - rows.append( - _Row( - float(hint["position"]), - str(hint.get("keyword") or "wavetrain"), - tuple(hint["values"]) if same else encoded, - hint.get("description"), - train, - ) - ) - else: - fresh += 1.0 - rows.append(_Row(fresh, "wavetrain", encoded, None, train)) + hinted(train.deck_hints.get("option"), "wavetrain", encoded, train, "wavetrain") elif not isinstance(waves, NoWaves) or "waves" in hints: add("waves", "waves", _encode_wave(waves), environment) current = environment.current @@ -1681,14 +1712,23 @@ def add(identity: str, keyword: str, encoded: tuple[str, ...], owner: ModelObjec self._motion(motion, hints, add) if motion.reference is not None: add("vesselref", "vesselRef", _encode_vector(motion.reference), motion) + # Kept rows go where they were; a kept row of a typed option is moved + # before every typed row of that option, so the typed property wins. + last_extra: dict[str, float] = {} for extra in settings.extra_options: position = extra.deck_hints.get("position") - if isinstance(position, int): + if isinstance(position, int) and not isinstance(position, bool): order = float(position) else: fresh += 1.0 order = fresh - rows.append(_Row(order, extra.keyword, tuple(extra.values), extra.note, extra)) + group = typed_option(extra.keyword) + rows.append(_Row(order, extra.keyword, tuple(extra.values), extra.note, extra, None)) + if group is not None: + last_extra[group] = max(order, last_extra.get(group, order)) + for row in rows: + if row.group is not None and row.group in last_extra: + row.position = max(row.position, last_extra[row.group] + 0.5) rows.sort(key=lambda row: row.position) for row in rows: self._call( @@ -1709,8 +1749,10 @@ def _motion( raise self._fail(motion, "needs a file") keyword = "vesselRAO" if isinstance(motion, FloaterRAO) else _MOTION_KEYWORDS[type(motion)] hint = hints.get("motion") - same_keyword = isinstance(hint, dict) and _option_key(str(hint.get("keyword"))) == ( - keyword.lower() + same_keyword = ( + isinstance(hint, dict) + and _valid_hint(hint) + and _option_key(str(hint["keyword"])) == keyword.lower() ) add("motion" if same_keyword else "motion:changed", keyword, (file,), motion) diff --git a/python/cabledyn/project/descriptors.py b/python/cabledyn/project/descriptors.py index 3a9f959..fa89161 100644 --- a/python/cabledyn/project/descriptors.py +++ b/python/cabledyn/project/descriptors.py @@ -11,12 +11,13 @@ class Section(ModelObject): Each descriptor knows its type, SI dimension, default, limits, role, group and documentation. The descriptors drive the property panel, the project file, validation layer 1, and the deck writer (which reads only ``physics`` -properties). Values are stored in SI units. +properties). Values are stored in SI units (angles in degrees). """ from __future__ import annotations import copy +import json import math import numbers import re @@ -227,15 +228,18 @@ def __repr__(self) -> str: class _NumberMixin: - """Limits shared by :class:`Quantity` and :class:`Integer`.""" + """Limits shared by the numeric descriptors (and reported by ``describe``).""" minimum: float | None maximum: float | None limit: str | None + def describe(self) -> dict[str, Any]: + info: dict[str, Any] = super().describe() # type: ignore[misc] + info.update(minimum=self.minimum, maximum=self.maximum, limit=self.limit) + return info + def _limit_issues(self, prop: Property[Any], obj: ModelObject, value: float) -> list[Issue]: - if not math.isfinite(value): - return [prop._issue(obj, f"must be finite, got {value!r}")] found: list[Issue] = [] if self.minimum is not None and value < self.minimum: found.append(prop._issue(obj, f"must be at least {self.minimum:g}")) @@ -251,7 +255,10 @@ def _limit_issues(self, prop: Property[Any], obj: ModelObject, value: float) -> def _float(value: object, what: str) -> float: if not _is_number(value): raise TypeError(f"{what} must be a number, got {value!r}") - return float(value) # type: ignore[arg-type] + result = float(value) # type: ignore[arg-type] + if not math.isfinite(result): + raise ValueError(f"{what} must be finite, got {value!r}") + return result def _int(value: object, what: str) -> int: @@ -263,7 +270,7 @@ def _int(value: object, what: str) -> int: class Quantity(_NumberMixin, Property[float]): - """A real number with a physical dimension, stored in SI units. + """A real number with a physical dimension, stored in SI units (angles in degrees). Parameters ---------- @@ -307,11 +314,6 @@ def coerce(self, value: object) -> float: def check(self, obj: ModelObject, value: float) -> list[Issue]: return self._limit_issues(self, obj, value) - def describe(self) -> dict[str, Any]: - info = super().describe() - info.update(minimum=self.minimum, maximum=self.maximum, limit=self.limit) - return info - class OptionalQuantity(_NumberMixin, Property[float | None]): """A :class:`Quantity` that may be unset (``None``). @@ -723,7 +725,9 @@ def to_json(self, value: Vec3Value | None, ctx: SerialContext) -> Any: class JsonValue(Property[Any]): """Opaque JSON-compatible data (GUI layouts, plot specifications, ...). - The value is deep-copied on assignment; it is never written to a deck. + The value must be JSON data (dictionaries with string keys, lists, + strings, finite numbers, Booleans, ``None``); it is copied on assignment + and never written to a deck. """ kind = "json" @@ -733,7 +737,11 @@ def __init__(self, default: Any = None, **kwargs: Any) -> None: super().__init__(default, **kwargs) def coerce(self, value: object) -> Any: - return copy.deepcopy(value) + try: + text = json.dumps(value, allow_nan=False) + except (TypeError, ValueError) as exc: + raise ValueError(f"{self.name} must be JSON data with finite numbers ({exc})") from exc + return json.loads(text) class Ref(Property[ObjT | None], Generic[ObjT]): @@ -750,17 +758,26 @@ class Ref(Property[ObjT | None], Generic[ObjT]): target : type The class (or common base class) of admissible targets. required : bool - A missing target is an error (validation layer 2). + A missing target is an error (validation layer 2). An object can + make a reference required conditionally with + :meth:`ModelObject.required_references`. + weak : bool + A bookkeeping reference (for example a group membership): it never + blocks a removal, is cleared when its target goes, and is left out + of "used by". **kwargs See :class:`Property`. """ kind = "ref" - def __init__(self, target: type[ObjT], *, required: bool = True, **kwargs: Any) -> None: + def __init__( + self, target: type[ObjT], *, required: bool = True, weak: bool = False, **kwargs: Any + ) -> None: super().__init__(None, **kwargs) self.target = target - self.required = required + self.required = required and not weak + self.weak = weak @property def is_reference(self) -> bool: @@ -802,7 +819,7 @@ def resolve(self, data: Any, ctx: SerialContext) -> ObjT | None: def describe(self) -> dict[str, Any]: info = super().describe() - info.update(target=self.target.__name__, required=self.required) + info.update(target=self.target.__name__, required=self.required, weak=self.weak) return info @@ -816,16 +833,21 @@ class RefList(Property[tuple[ObjT, ...]], Generic[ObjT]): min_items : int Fewest targets; a removal that leaves fewer removes the referrer when cascading. + weak : bool + A bookkeeping list (group members): see :class:`Ref`. **kwargs See :class:`Property`. """ kind = "ref_list" - def __init__(self, target: type[ObjT], *, min_items: int = 0, **kwargs: Any) -> None: + def __init__( + self, target: type[ObjT], *, min_items: int = 0, weak: bool = False, **kwargs: Any + ) -> None: super().__init__((), **kwargs) self.target = target - self.min_items = min_items + self.min_items = 0 if weak else min_items + self.weak = weak @property def is_reference(self) -> bool: @@ -874,7 +896,7 @@ def resolve(self, data: Any, ctx: SerialContext) -> tuple[ObjT, ...]: def describe(self) -> dict[str, Any]: info = super().describe() - info.update(target=self.target.__name__, min_items=self.min_items) + info.update(target=self.target.__name__, min_items=self.min_items, weak=self.weak) return info @@ -925,6 +947,8 @@ def to_json(self, value: ObjT, ctx: SerialContext) -> Any: def from_json(self, data: Any, ctx: SerialContext, obj: ModelObject) -> ObjT: built = ctx.build(data, owner=obj, slot=self.name) if built is None or not isinstance(built, self.base): + if isinstance(data, dict): + obj._preserved[self.name] = data return self.initial(obj) built._attach(obj, self.name) return built diff --git a/python/cabledyn/project/points.py b/python/cabledyn/project/points.py index 31277de..ba5d9b7 100644 --- a/python/cabledyn/project/points.py +++ b/python/cabledyn/project/points.py @@ -10,17 +10,18 @@ from __future__ import annotations from cabledyn.project.base import ModelObject, model_type -from cabledyn.project.bodies import Body +from cabledyn.project.bodies import Body, Turbine from cabledyn.project.descriptors import ( Choice, - Integer, OptionalColour, + OptionalInteger, OptionalQuantity, Quantity, Ref, Role, Vec3, ) +from cabledyn.project.issues import Issue, Severity from cabledyn.project.units import AREA, DIMENSIONLESS, LENGTH, MASS, VOLUME __all__ = [ @@ -134,11 +135,43 @@ class ConnectPoint(Point): class TurbinePoint(Point): """A point carried by a FAST.Farm turbine (deck ``Turbine``/``T``). - The position is relative to the turbine's reference. + The point refers to its :class:`~cabledyn.project.Turbine` object, so it + follows a renumbered turbine and is removed with it. In a coupled farm + deck without a ``TURBINES`` table the turbine is known only by its number, + given in ``turbine_number`` instead. The position is relative to the + turbine's reference. """ type_label = "Turbine point" - turbine = Integer(1, minimum=1, maximum=10000, group="Farm", doc="Turbine number J.") + turbine = Ref(Turbine, required=False, group="Farm", doc="The turbine.") + turbine_number = OptionalInteger( + minimum=1, + maximum=10000, + group="Farm", + doc="Turbine number J when the deck has no TURBINES row for it.", + ) + + def number(self) -> int | None: + """Return the turbine number J written to the deck, or ``None``.""" + return self.turbine.number if self.turbine is not None else self.turbine_number + + def required_references(self) -> frozenset[str]: + found = super().required_references() + return found | {"turbine"} if self.turbine_number is None else found + + def invariants(self) -> list[Issue]: + found = super().invariants() + if (self.turbine is None) == (self.turbine_number is None): + found.append( + Issue( + Severity.ERROR, + "give either the turbine or a turbine number", + self, + "turbine", + 2, + ) + ) + return found @model_type("point.body") diff --git a/python/cabledyn/project/project.py b/python/cabledyn/project/project.py index 5b71321..30ef9d5 100644 --- a/python/cabledyn/project/project.py +++ b/python/cabledyn/project/project.py @@ -98,16 +98,51 @@ class Project(ModelObject): studio = Child(StudioData, role=Role.META) def _setup(self) -> None: + self._uid_index: dict[str, ModelObject] | None = None super()._setup() self.unknown_objects: list[dict[str, Any]] = [] self.load_warnings: list[str] = [] + def _index(self) -> dict[str, ModelObject]: + if self._uid_index is None: + index: dict[str, ModelObject] = {} + for obj in self.walk(): + index.setdefault(obj.uid, obj) + self._uid_index = index + return self._uid_index + + def _adopt(self, obj: ModelObject) -> None: + index = self._index() + joining = list(obj.walk()) + for item in joining: + existing = index.get(item.uid) + if existing is not None and existing is not item: + raise ValueError( + f"{item.label()} has uid {item.uid}, already used by {existing.label()}; " + "a copy needs fresh uids" + ) + for item in joining: + index[item.uid] = item + + def _release(self, obj: ModelObject) -> None: + index = self._index() + for item in obj.walk(): + if index.get(item.uid) is item: + del index[item.uid] + + def duplicate_uids(self) -> list[str]: + """Return the uids used by more than one object (empty when consistent).""" + seen: set[str] = set() + found: list[str] = [] + for obj in self.walk(): + if obj.uid in seen and obj.uid not in found: + found.append(obj.uid) + seen.add(obj.uid) + return found + def find(self, uid: str) -> ModelObject | None: """Return the object with ``uid`` anywhere in the project, or ``None``.""" - for obj in self.walk(): - if obj.uid == uid: - return obj - return None + return self._index().get(uid) def objects_of(self, kind: type[M]) -> Iterator[M]: """Yield every object of class ``kind`` in the project, depth first.""" diff --git a/python/cabledyn/project/references.py b/python/cabledyn/project/references.py index 69780cc..dfcd3d5 100644 --- a/python/cabledyn/project/references.py +++ b/python/cabledyn/project/references.py @@ -5,7 +5,11 @@ :class:`~cabledyn.project.RefList` properties. Removing an object that is still referred to either fails (:class:`ObjectInUseError`) or cascades: -* a referrer whose *required* reference loses its target is removed too; +* a referrer whose *required* reference loses its target is removed too + (:meth:`~cabledyn.project.ModelObject.required_references`, which can + depend on the referrer's state, such as a body rod's body); +* a weak reference (a group membership) never blocks a removal and is + cleared; * an optional reference is cleared; * a reference list drops the target, and a referrer left with fewer targets than its ``min_items`` is removed; @@ -46,7 +50,13 @@ def _ids(obj: ModelObject) -> set[int]: return {id(item) for item in obj.walk()} -def referrers(root: ModelObject, obj: ModelObject) -> list[tuple[ModelObject, str]]: +def _is_weak(item: ModelObject, name: str) -> bool: + return bool(getattr(type(item).get_property(name), "weak", False)) + + +def referrers( + root: ModelObject, obj: ModelObject, *, include_weak: bool = False +) -> list[tuple[ModelObject, str]]: """Return ``(referrer, property name)`` pairs that point into ``obj``. Parameters @@ -55,6 +65,8 @@ def referrers(root: ModelObject, obj: ModelObject) -> list[tuple[ModelObject, st The object graph to search (normally the project). obj : ModelObject The referenced object; references to its descendants count too. + include_weak : bool + Also list weak (bookkeeping) references such as group memberships. Returns ------- @@ -68,6 +80,8 @@ def referrers(root: ModelObject, obj: ModelObject) -> list[tuple[ModelObject, st continue names: list[str] = [] for name, target in item.references(): + if not include_weak and _is_weak(item, name): + continue if id(target) in inside and name not in names: names.append(name) found.extend((item, name) for name in names) @@ -163,9 +177,10 @@ def plan_removal( for item in root.walk(): if id(item) in doomed_ids or not _removable(item): continue + required = item.required_references() for prop in type(item).properties(): value = item._values[prop.name] - if isinstance(prop, Ref) and prop.required: + if isinstance(prop, Ref) and prop.name in required: if value is not None and id(value) in doomed_ids: candidates.append(item) break diff --git a/python/cabledyn/project/rods.py b/python/cabledyn/project/rods.py index dcdcd85..f537247 100644 --- a/python/cabledyn/project/rods.py +++ b/python/cabledyn/project/rods.py @@ -47,10 +47,15 @@ class Rod(ModelObject): ) segments = Integer(1, minimum=0, group="Discretisation", doc="0 for a zero-length rod.") outputs = Text("-", group="Outputs", doc="Output flags: '-' or 'p'.") - end_a = Child(RodEndpoint, lambda: RodEndpoint.make("A")) - end_b = Child(RodEndpoint, lambda: RodEndpoint.make("B")) + end_a = Child(RodEndpoint, lambda: RodEndpoint.make("A"), read_only=True) + end_b = Child(RodEndpoint, lambda: RodEndpoint.make("B"), read_only=True) colour = OptionalColour(group="Appearance") + def required_references(self) -> frozenset[str]: + """A body or body_pinned rod cannot do without its body.""" + found = super().required_references() + return found | {"body"} if self.attachment in _BODY_KINDS else found + def endpoint(self, end: str) -> RodEndpoint: """Return the endpoint object of ``end`` (``"A"`` or ``"B"``).""" if end.upper() not in {"A", "B"}: diff --git a/python/cabledyn/project/settings.py b/python/cabledyn/project/settings.py index 573768b..b4f200c 100644 --- a/python/cabledyn/project/settings.py +++ b/python/cabledyn/project/settings.py @@ -41,6 +41,30 @@ class ExtraOption(ModelObject): values = TextList((), group="Option", doc="Value tokens as written.") note = OptionalText(group="Option", doc="Description written after ' - '.") + def invariants(self) -> list[Issue]: + """Warn when the row sets an option the model types. + + Such a row (a duplicate kept from a deck, or added by hand) never + overrides the typed property: the deck writer places it before the + typed row, so the typed value is the one the solver uses. + """ + from cabledyn.project.deck import typed_option + + found = super().invariants() + typed = typed_option(self.keyword) + if typed is not None: + found.append( + Issue( + Severity.WARNING, + f"this row sets the typed option {typed!r}; the typed property wins, so " + "edit it there and remove this row", + self, + "keyword", + 2, + ) + ) + return found + @model_type("settings") class AnalysisSettings(ModelObject): diff --git a/python/cabledyn/project/store.py b/python/cabledyn/project/store.py index 6835862..3a34d25 100644 --- a/python/cabledyn/project/store.py +++ b/python/cabledyn/project/store.py @@ -6,18 +6,25 @@ ``project.json`` The whole object graph (physics, construction, appearance and GUI data) - with uids, SI values and a schema version. + with uids, SI values (angles in degrees) and a schema version. It is + strict JSON: non-finite numbers are never written. ``model.dat`` - The deck written from the project, so the driver can run it directly; - left out (and noted in ``project.json``) when the project is not yet a - valid deck. + The deck written from the project, written only when the project passes + every validation layer. Relative side-file paths (motion, bathymetry, + WaterKin, Syrope files) are rewritten to resolve from the project folder + (a folder project) or from the folder that holds the archive (a zip + project), and the fixed-name kinematics files the solver reads from the + deck folder are copied into a folder project. Side files that cannot be + found are listed in ``project.json`` under ``deck.missing_files``. ``assets/`` Meshes, textures and other files the project uses (reserved). ``results.json`` References to solver runs (reserved; empty for now). -Objects of a type this version does not know are kept verbatim and written -back unchanged. +The project's ``deck_path`` is stored relative to the folder that holds the +project file when it can be. Objects and values this version does not know +are kept and written back unchanged (see +:class:`~cabledyn.project.SerialContext`). """ from __future__ import annotations @@ -25,11 +32,13 @@ import io import json import os +import tempfile import zipfile from collections.abc import Callable -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import Any +from cabledyn import deck_file as _deck from cabledyn.errors import DeckFormatError from cabledyn.project.base import SerialContext from cabledyn.project.deck import DeckExportError, DeckWriter @@ -46,6 +55,8 @@ _DECK = "model.dat" _RESULTS = "results.json" _ASSETS = "assets/" +_MAX_DOCUMENT_BYTES = 256 * 1024 * 1024 +_RELATIVE_MARK = "deck_path_relative_to" Migration = Callable[[dict[str, Any]], dict[str, Any]] _MIGRATIONS: dict[int, Migration] = {} @@ -53,7 +64,7 @@ class ProjectFormatError(ValueError): - """A project file is not a CableDyn project or is from a newer release.""" + """A project file is not a readable CableDyn project, or is from a newer release.""" def _package_version() -> str: @@ -62,36 +73,141 @@ def _package_version() -> str: return str(__version__) +def _reject_constant(token: str) -> Any: + raise ProjectFormatError(f"{_PROJECT} holds the non-standard number {token}") + + +def _is_absolute(value: str) -> bool: + return Path(value).is_absolute() or PureWindowsPath(value).is_absolute() + + +def _find_object(node: Any, uid: str) -> dict[str, Any] | None: + stack = [node] + while stack: + item = stack.pop() + if isinstance(item, dict): + if item.get("uid") == uid and isinstance(item.get("properties"), dict): + return item + stack.extend(item.values()) + elif isinstance(item, list): + stack.extend(item) + return None + + +def _side_files(deck: _deck.DeckFile) -> list[str]: + """Return the side-file paths a deck reads (Syrope settings and path options).""" + found: list[str] = [] + for row in deck.line_types: + ea = row.tokens[3] + if ea.lower().startswith("syrope:"): + path = ea[len("SYROPE:") :].partition("|")[0] + if path: + found.append(path) + for record in deck.options: + keyword = record.keyword.lower() + if keyword not in _deck._PATH_OPTIONS or len(record.values) != 1: + continue + value = record.values[0] + group = _deck._PATH_OPTION_GROUP[keyword] + if value.lower() in _deck._DISABLED_MOTION_FILE_VALUES and group != "bathymetry": + continue + if group == "waterkin": + if value.lower() in {"none", "seastate"}: + continue + try: + _deck._native_float(value) + except ValueError: + pass + else: + continue + found.append(value) + return found + + +def _atomic_write(target: Path, data: bytes) -> None: + handle, temporary = tempfile.mkstemp(prefix=".cdproj-", dir=target.parent) + try: + with os.fdopen(handle, "wb") as stream: + stream.write(data) + os.replace(temporary, target) + except BaseException: + Path(temporary).unlink(missing_ok=True) + raise + + class ProjectStore: """Save and load ``.cdproj`` project files.""" @staticmethod - def to_json(project: Project) -> dict[str, Any]: + def to_json(project: Project, base: str | os.PathLike[str] | None = None) -> dict[str, Any]: """Return the ``project.json`` document of ``project``. + Objects of unknown type are put back at their place in their + collection. + + Parameters + ---------- + project : Project + The project. + base : str | os.PathLike | None + Folder that holds the project file; ``deck_path`` is stored + relative to it when possible. + Returns ------- dict JSON-compatible data. """ ctx = SerialContext() - return { + graph = project.to_dict(ctx) + orphans: list[dict[str, Any]] = [] + placed = sorted( + (entry for entry in project.unknown_objects if isinstance(entry, dict)), + key=lambda entry: int(entry["index"]) if isinstance(entry.get("index"), int) else -1, + ) + for entry in placed: + owner_uid, slot, index = entry.get("owner"), entry.get("slot"), entry.get("index") + owner = _find_object(graph, owner_uid) if isinstance(owner_uid, str) else None + items = None if owner is None else owner["properties"].get(slot) + if isinstance(items, list) and isinstance(index, int): + items.insert(min(max(index, 0), len(items)), entry.get("data")) + else: + orphans.append(entry) + document: dict[str, Any] = { "format": FORMAT, "schema_version": SCHEMA_VERSION, "generator": f"cabledyn {_package_version()}", - "project": project.to_dict(ctx), - "unknown_objects": list(project.unknown_objects), + "project": graph, + "unknown_objects": orphans, } + deck_path = project.deck_path + if base is not None and deck_path and _is_absolute(deck_path): + try: + relative = os.path.relpath(deck_path, Path(base).resolve()) + except ValueError: + relative = None + if relative is not None: + graph["properties"]["deck_path"] = Path(relative).as_posix() + document[_RELATIVE_MARK] = "project_folder" + return document @staticmethod - def from_json(data: Any) -> Project: + def from_json(data: Any, base: str | os.PathLike[str] | None = None) -> Project: """Rebuild a project from a ``project.json`` document. + Parameters + ---------- + data : object + The parsed document. + base : str | os.PathLike | None + Folder that holds the project file (resolves a relative + ``deck_path``). + Raises ------ ProjectFormatError - If the document is not a project or its schema is newer than - this release. + If the document is not a project, is from a newer schema, or + uses one uid for several objects. """ if not isinstance(data, dict) or data.get("format") != FORMAT: raise ProjectFormatError("not a CableDyn project document") @@ -110,12 +226,51 @@ def from_json(data: Any) -> Project: project = ctx.build(data.get("project")) if not isinstance(project, Project): raise ProjectFormatError("the document holds no project") + if ctx.duplicates: + raise ProjectFormatError( + f"uid {ctx.duplicates[0]} is used by more than one object; the file is damaged" + ) ctx.resolve() kept = data.get("unknown_objects", []) - project.unknown_objects = [*(kept if isinstance(kept, list) else []), *ctx.unknown] + project.unknown_objects = [*ctx.unknown, *(kept if isinstance(kept, list) else [])] project.load_warnings = list(ctx.warnings) + deck_path = project.deck_path + if data.get(_RELATIVE_MARK) and base is not None and deck_path: + project._values["deck_path"] = str((Path(base) / deck_path).resolve()) return project + @staticmethod + def _deck(project: Project, destination: Path, folder: bool) -> tuple[str | None, Any]: + """Return the rebased deck text (or ``None``) and its ``deck`` record.""" + from cabledyn.project.validation import errors, validate_project + + problems = errors(validate_project(project)) + if problems: + reasons = "; ".join(str(issue) for issue in problems[:3]) + return None, {"written": False, "reason": reasons, "errors": len(problems)} + writer = DeckWriter(project) + try: + model, _ = writer.to_model() + if not project.deck_path: + model.path = destination / _DECK + text = writer._render(model) + deck = _deck.DeckFile.from_text( + text, path=model.path, caller_driven=model.caller_driven + ) + if destination != model.path.parent: + deck._rebase_relative_inputs(destination) + if folder: + plan = _deck._plan_companion_copies([deck], destination, overwrite=True) + _deck._copy_companions(plan) + except (DeckExportError, DeckFormatError, FileExistsError, OSError) as exc: + return None, {"written": False, "reason": str(exc)} + missing = [ + value + for value in _side_files(deck) + if not (Path(value) if _is_absolute(value) else destination / value).exists() + ] + return deck.text(), {"written": True, "file": _DECK, "missing_files": missing} + @classmethod def save( cls, @@ -127,6 +282,9 @@ def save( ) -> Path: """Write ``project`` to a project file. + Each file is written to a temporary name and then moved into place, + so an interrupted save leaves the previous file intact. + Parameters ---------- project : Project @@ -151,27 +309,24 @@ def save( target = Path(path).expanduser().resolve() if target.exists() and not overwrite: raise FileExistsError(f"{target} already exists") - document = cls.to_json(project) - deck_text: str | None = None - try: - deck_text = DeckWriter(project).to_text() - document["deck"] = {"written": True, "file": _DECK} - except (DeckExportError, DeckFormatError) as exc: - document["deck"] = {"written": False, "reason": str(exc)} - payload = json.dumps(document, indent=1, sort_keys=False, allow_nan=True) - results = json.dumps({"runs": []}, indent=1) + target.parent.mkdir(parents=True, exist_ok=True) + destination = target if folder else target.parent + if folder: + target.mkdir(exist_ok=True) + document = cls.to_json(project, target.parent) + deck_text, document["deck"] = cls._deck(project, destination, folder) + payload = json.dumps(document, indent=1, allow_nan=False).encode("utf-8") + results = json.dumps({"runs": []}, indent=1).encode("utf-8") if folder: - target.mkdir(parents=True, exist_ok=True) - (target / _PROJECT).write_text(payload, encoding="utf-8") - (target / _RESULTS).write_text(results, encoding="utf-8") + _atomic_write(target / _PROJECT, payload) + _atomic_write(target / _RESULTS, results) (target / _ASSETS).mkdir(exist_ok=True) deck_file = target / _DECK if deck_text is not None: - deck_file.write_text(deck_text, encoding="utf-8") + _atomic_write(deck_file, deck_text.encode("utf-8")) elif deck_file.exists(): deck_file.unlink() return target - target.parent.mkdir(parents=True, exist_ok=True) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr(_PROJECT, payload) @@ -179,7 +334,7 @@ def save( archive.writestr(zipfile.ZipInfo(_ASSETS), "") if deck_text is not None: archive.writestr(_DECK, deck_text) - target.write_bytes(buffer.getvalue()) + _atomic_write(target, buffer.getvalue()) return target @classmethod @@ -189,8 +344,8 @@ def load(cls, path: str | os.PathLike[str]) -> Project: Returns ------- Project - The project; ``Project.load_warnings`` lists anything - skipped, and objects of unknown type are kept for the next save. + The project; ``Project.load_warnings`` lists anything kept but + not understood. Raises ------ @@ -200,21 +355,29 @@ def load(cls, path: str | os.PathLike[str]) -> Project: If the file is not a readable project. """ source = Path(path).expanduser().resolve() - if source.is_dir(): - document = source / _PROJECT - if not document.is_file(): - raise ProjectFormatError(f"{source} has no {_PROJECT}") - text = document.read_text(encoding="utf-8") - elif source.is_file(): - try: - with zipfile.ZipFile(source) as archive: - text = archive.read(_PROJECT).decode("utf-8") - except (zipfile.BadZipFile, KeyError) as exc: - raise ProjectFormatError(f"{source} is not a CableDyn project file") from exc - else: - raise FileNotFoundError(f"{source} does not exist") try: - data = json.loads(text) + if source.is_dir(): + document = source / _PROJECT + if not document.is_file(): + raise ProjectFormatError(f"{source} has no {_PROJECT}") + if document.stat().st_size > _MAX_DOCUMENT_BYTES: + raise ProjectFormatError(f"{source}: {_PROJECT} is too large") + text = document.read_bytes().decode("utf-8") + elif source.is_file(): + try: + with zipfile.ZipFile(source) as archive: + if archive.getinfo(_PROJECT).file_size > _MAX_DOCUMENT_BYTES: + raise ProjectFormatError(f"{source}: {_PROJECT} is too large") + text = archive.read(_PROJECT).decode("utf-8") + except (zipfile.BadZipFile, KeyError) as exc: + raise ProjectFormatError(f"{source} is not a CableDyn project file") from exc + else: + raise FileNotFoundError(f"{source} does not exist") + data = json.loads(text, parse_constant=_reject_constant) + return cls.from_json(data, source.parent) + except UnicodeDecodeError as exc: + raise ProjectFormatError(f"{source}: {_PROJECT} is not UTF-8 text") from exc except json.JSONDecodeError as exc: raise ProjectFormatError(f"{source}: {_PROJECT} is not valid JSON") from exc - return cls.from_json(data) + except RecursionError as exc: + raise ProjectFormatError(f"{source}: {_PROJECT} is nested too deeply") from exc diff --git a/python/cabledyn/project/studio.py b/python/cabledyn/project/studio.py index 81d6de7..48dc4e7 100644 --- a/python/cabledyn/project/studio.py +++ b/python/cabledyn/project/studio.py @@ -33,7 +33,7 @@ class Group(ModelObject): type_label = "Group" abstract = False - members = RefList(ModelObject, role=_M, group="Group") + members = RefList(ModelObject, weak=True, role=_M, group="Group") colour = OptionalColour(group="Group") diff --git a/python/cabledyn/project/types.py b/python/cabledyn/project/types.py index 278d94a..0b3ec42 100644 --- a/python/cabledyn/project/types.py +++ b/python/cabledyn/project/types.py @@ -10,6 +10,8 @@ from __future__ import annotations +import math + from cabledyn.project.appearance import ( CableAppearance, ChainAppearance, @@ -32,7 +34,6 @@ Quantity, Role, Strategy, - Vector, ) from cabledyn.project.issues import Issue, Severity from cabledyn.project.units import ( @@ -76,18 +77,47 @@ class AxialModel(ModelObject): - """Base class of the axial (tension-strain) models of a line type.""" + """Base class of the axial (tension-strain) models of a line type. + + The deck's ``BA`` column holds an axial damping in N s, or a negative + value meaning a damping ratio. The two meanings have different units, so + the model keeps them apart: ``damping_mode`` chooses ``damping`` + (``damping``, N s) or ``ratio`` (``damping_ratio``, + dimensionless, written as its negative). ``dynamic_damping`` is the + optional second value ``Bd`` of a two-spring model. + """ type_label = "Axial model" abstract = True - damping = Vector( - AXIAL_DAMPING, - (0.0,), - lengths=(1, 2), - limit="coefficient", - group="Axial", - doc="Axial damping BA (negative: damping ratio); two values (static, dynamic).", + damping_mode = Choice(("damping", "ratio"), group="Axial damping") + damping = Quantity( + AXIAL_DAMPING, 0.0, minimum=0.0, limit="coefficient", group="Axial damping", doc="BA." + ) + damping_ratio = Quantity( + DIMENSIONLESS, 0.0, minimum=0.0, group="Axial damping", doc="Axial damping ratio." ) + dynamic_damping = OptionalQuantity( + AXIAL_DAMPING, minimum=0.0, limit="coefficient", group="Axial damping", doc="Bd." + ) + + def deck_damping(self) -> float | tuple[float, ...]: + """Return the deck ``BA`` value: one number, or ``(BA, Bd)``.""" + static = -self.damping_ratio if self.damping_mode == "ratio" else self.damping + if self.dynamic_damping is None: + return static + return (static, self.dynamic_damping) + + def set_deck_damping(self, value: float | tuple[float, ...]) -> None: + """Set the damping from a deck ``BA`` value (a negative static value is a ratio).""" + values = value if isinstance(value, tuple) else (value,) + static = values[0] + if static < 0.0 or math.copysign(1.0, static) < 0.0: + self.damping_mode = "ratio" + self.damping_ratio = -static + else: + self.damping_mode = "damping" + self.damping = static + self.dynamic_damping = values[1] if len(values) > 1 else None @model_type("axial.linear") diff --git a/python/cabledyn/project/units.py b/python/cabledyn/project/units.py index f350ab1..c8b7bef 100644 --- a/python/cabledyn/project/units.py +++ b/python/cabledyn/project/units.py @@ -1,11 +1,13 @@ # SPDX-License-Identifier: Apache-2.0 """Physical dimensions, units, and display-unit preferences. -Every quantity in :mod:`cabledyn.project` is stored in SI units. A -:class:`Dimension` names what a quantity measures, a :class:`Unit` converts -between a display unit and SI, and a :class:`UnitSystem` holds the display unit -chosen for each dimension. Conversions are linear (``si = value * scale``); -no supported unit has an offset. +Every quantity in :mod:`cabledyn.project` is stored in the unit the deck uses: +SI units, except angles, which are stored in degrees (the deck convention). A +:class:`Dimension` names what a quantity measures and its storage unit, a +:class:`Unit` converts between a display unit and the storage unit, and a +:class:`UnitSystem` holds the display unit chosen for each dimension. +Conversions are linear (``stored = value * scale``); no supported unit has an +offset. Examples -------- @@ -76,7 +78,7 @@ class Dimension: label : str Human-readable name. si : str - Symbol of the SI unit in which values are stored. + Symbol of the unit in which values are stored (SI; degrees for angles). """ key: str @@ -152,7 +154,9 @@ class Unit: Attributes ---------- symbol : str - Unit symbol, unique across all dimensions. + Unit symbol. A symbol may measure several dimensions (``N/m`` is a + force per length or a stiffness); then + :func:`~cabledyn.project.units.unit` needs the dimension. dimension : Dimension What the unit measures. scale : float diff --git a/python/tests/test_project_commands.py b/python/tests/test_project_commands.py index 8f46e18..022a247 100644 --- a/python/tests/test_project_commands.py +++ b/python/tests/test_project_commands.py @@ -172,10 +172,10 @@ def test_replace_strategy_copies_shared_values() -> None: assert isinstance(project.seabed, BathymetrySeabed) assert project.seabed.stiffness == 3.0e5 and project.seabed.friction == 0.6 chain = m["chain"] - chain.axial.damping = (-1.0,) + chain.axial.damping = 3.0 check_undo_redo(project, ReplaceStrategy(chain, "axial", ViscoelasticAxial())) plain = ReplaceStrategy(chain, "axial", ViscoelasticAxial(), copy_common=False) - assert plain.new.damping == (0.0,) + assert plain.new.damping == 0.0 with pytest.raises(TypeError, match="not a sub-object"): ReplaceStrategy(chain, "diameter", FlatSeabed) diff --git a/python/tests/test_project_deck.py b/python/tests/test_project_deck.py index 26e562b..9a78e88 100644 --- a/python/tests/test_project_deck.py +++ b/python/tests/test_project_deck.py @@ -69,7 +69,6 @@ ViscoelasticAxial, WaveModel, WaveTrain, - model_type, validate_project, ) from cabledyn.project.deck import ( @@ -86,12 +85,10 @@ ROOT = Path(__file__).resolve().parents[2] -@model_type("test.wave.odd") class OddWave(WaveModel): """A wave model without a deck form.""" -@model_type("test.current.odd") class OddCurrent(CurrentModel): """A current model without a deck form.""" @@ -144,6 +141,27 @@ def substitute(node: Any) -> Any: return substitute(data) +def semantic(project: Project) -> Any: + """The graph without uids (references become positions), names, paths or hints.""" + copy = Project.from_dict(project.to_dict()) + for obj in copy.walk(): + obj.deck_hints.clear() + order = {obj.uid: index for index, obj in enumerate(copy.walk())} + + def clean(node: Any) -> Any: + if isinstance(node, dict): + return { + key: clean(value) + for key, value in node.items() + if key not in {"uid", "name", "deck_path"} + } + if isinstance(node, list): + return [clean(value) for value in node] + return order.get(node, node) if isinstance(node, str) else node + + return clean(copy.to_dict()["properties"]) + + def test_the_round_trip_covers_the_repository_decks() -> None: accepted = [path for path in CANDIDATES if _load(path) is not None] rejected = sorted(path.name for path in CANDIDATES if path not in accepted) @@ -173,6 +191,14 @@ def test_deck_round_trip(path: Path, tmp_path: Path) -> None: assert normalise(again) == normalise(project) assert DeckWriter(again).to_text() == canonical assert errors(validate_project(project)) == [] + # Without any hint (ids, spellings, option order) the deck still means the same. + stripped = Project.from_dict(project.to_dict()) + for obj in stripped.walk(): + obj.deck_hints.clear() + hint_free = DeckReader.from_text( + DeckWriter(stripped).to_text(), path=model.path, caller_driven=model.caller_driven + ) + assert semantic(hint_free) == semantic(project) # --------------------------------------------------------------------------- reader details @@ -280,7 +306,7 @@ def full_project() -> Project: chain = p.line_types.append( GenericLineType("chain", diameter=0.1, mass_per_length=50.0, drag_normal=1.2) ) - chain.axial = LinearAxial(stiffness=5.0e8, damping=(-1.0,)) + chain.axial = LinearAxial(stiffness=5.0e8, damping_mode="ratio", damping_ratio=1.0) cable = p.line_types.append(ChainType("cable", diameter=0.2, mass_per_length=80.0)) cable.bending = BendingModel(bending_stiffness=1.0e4) rod_type = p.rod_types.append(RodType("pipe", diameter=1.0, mass_per_length=100.0)) @@ -440,8 +466,8 @@ def test_writer_body_rod_and_row_variants() -> None: project.bodies.append( Rigid6Body("md", row_format="moordyn", inertia=(1.0, 2.0, 3.0), drag_area=(1.0, 2.0)) ) - project.turbines.append(Turbine(number=2, platform_displacement=(1, 0, 0, 0, 0, 0))) - project.points.append(TurbinePoint("tp", turbine=2)) + farm = project.turbines.append(Turbine(number=2, platform_displacement=(1, 0, 0, 0, 0, 0))) + project.points.append(TurbinePoint("tp", turbine=farm)) project.points.append(ConnectPoint("c")) project.points.append(FloaterPoint("v", kind="floater")) line.end_connections.append(EndConnection(end="A", rotation="pinned")) @@ -503,7 +529,9 @@ def test_writer_body_rod_and_row_variants() -> None: def test_writer_axial_models() -> None: project = full_project() chain = project.line_types[0] - chain.axial = ViscoelasticAxial(static_stiffness=1e8, dynamic_stiffness=2e8, damping=(1.0, 2.0)) + chain.axial = ViscoelasticAxial( + static_stiffness=1e8, dynamic_stiffness=2e8, damping=1.0, dynamic_damping=2.0 + ) assert "100000000.0|200000000.0" in DeckWriter(project).to_text(validate=False) chain.axial = ViscoelasticAxial(static_stiffness=1e8, alpha_mbl=10.0, beta=0.2) assert "100000000.0|10.0|0.2" in DeckWriter(project).to_text(validate=False) @@ -584,7 +612,6 @@ def test_write_to_file(tmp_path: Path) -> None: DeckWriter(project).write(target, overwrite=True) -@model_type("test.axial.odd") class OddAxial(AxialModel): """An axial model subclass the writer does not know.""" diff --git a/python/tests/test_project_hardening.py b/python/tests/test_project_hardening.py new file mode 100644 index 0000000..13c6afc --- /dev/null +++ b/python/tests/test_project_hardening.py @@ -0,0 +1,670 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Regressions for the hardening review of cabledyn.project (one test per finding class).""" + +from __future__ import annotations + +import json +import math +import os +import zipfile +from pathlib import Path +from typing import Any + +import pytest + +from cabledyn.builder import DeckModel +from cabledyn.errors import DeckFormatError +from cabledyn.project import ( + REGISTRY, + AddObject, + Attachment, + BendStiffener, + BuoyancyModule, + ChainType, + Command, + CommandStack, + DeckExportError, + DeckReader, + DeckWriter, + ExternalLoad, + ExtraOption, + FixedPoint, + Group, + ImportDeck, + Line, + LinearAxial, + ModelObject, + MultiTrainSea, + Project, + ProjectFormatError, + ProjectStore, + RemoveObject, + Rod, + RodEndpoint, + RodType, + Section, + SetProperty, + Severity, + TurbinePoint, + UnitSystem, + WaveTrain, + model_type, + validate_project, +) +from cabledyn.project import store as store_module +from cabledyn.project.settings import AnalysisSettings +from cabledyn.project.units import AXIAL_DAMPING, DIMENSIONLESS +from cabledyn.project.validation import errors + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLES = ROOT / "examples" + + +def _errors(project: Project) -> list[str]: + return [str(issue) for issue in errors(validate_project(project))] + + +# --------------------------------------------------------------------------- P1 ids + + +def test_external_loads_are_numbered_in_order_after_edits() -> None: + first = DeckReader.read(EXAMPLES / "als_volturnus_line_break_tension.dat") + first.external_loads.append(ExternalLoad("second", body=first.bodies[0], force=(1.0, 0.0, 0.0))) + deck = EXAMPLES / "als_volturnus_line_break_tension.dat" + project = DeckReader.from_text(DeckWriter(first).to_text(), path=deck) + body = project.bodies[0] + assert len(project.external_loads) == 2 and _errors(project) == [] + stack = CommandStack() + stack.push(RemoveObject(project, project.external_loads[0])) + assert _errors(project) == [] + stack.push(AddObject(project.external_loads, ExternalLoad("third", body=body))) + model, id_map = DeckWriter(project).to_model() + assert [row.id for row in model.external_loads] == [1, 2] + assert [id_map.deck_id(load) for load in project.external_loads] == [1, 2] + assert _errors(project) == [] + + +# --------------------------------------------------------------------------- P2 + + +def test_a_kept_option_never_overrides_the_typed_property() -> None: + project = DeckReader.read(EXAMPLES / "chain_catenary_r3_100m.dat") + project.settings.time_step = 0.1 + project.settings.duration = 1.0 + project.settings.extra_options.append(ExtraOption(keyword="dtM", values=("0.5",))) + issues = validate_project(project) + warning = [i for i in issues if i.severity is Severity.WARNING and i.prop == "keyword"] + assert warning and "typed property wins" in warning[0].message + assert errors(issues) == [] + again = DeckReader.from_text(DeckWriter(project).to_text()) + assert again.settings.time_step == 0.1 + project.settings.extra_options.append(ExtraOption(keyword="MyOption", values=("1",))) + assert ExtraOption(keyword="wavetrain").validate() == [] + assert ExtraOption(keyword="vesselMotion").validate() + + +def test_removing_a_body_removes_the_rods_that_need_it() -> None: + project = DeckReader.read(EXAMPLES / "mixed_body_rods_points.dat") + body_rods = [rod for rod in project.rods if rod.attachment in {"body", "body_pinned"}] + assert body_rods + body = body_rods[0].body + assert body is not None + before = project.to_dict() + stack = CommandStack() + stack.push(RemoveObject(project, body, cascade=True)) + assert all(rod.body is not None for rod in project.rods if rod.attachment.startswith("body")) + assert all(issue.layer != 2 for issue in validate_project(project, deck=False)) + stack.undo() + assert project.to_dict() == before + free = Rod(attachment="free") + assert free.required_references() == frozenset({"rod_type"}) + assert Rod(attachment="body").required_references() == frozenset({"rod_type", "body"}) + + +def test_group_membership_never_blocks_a_removal() -> None: + project = Project() + anchor = project.points.append(FixedPoint("anchor")) + group = project.studio.groups.append(Group("moorings", members=[anchor])) + assert project.used_by(anchor) == [] + stack = CommandStack() + stack.push(RemoveObject(project, anchor)) + assert group.members == () + stack.undo() + assert group.members == (anchor,) + describe = Group.get_property("members").describe() + assert describe["weak"] is True + + +def test_uids_stay_unique() -> None: + project = DeckReader.read(EXAMPLES / "spread_3line_chain.dat") + line = project.lines[0] + clone = Line.from_dict(line.to_dict(), within=project) + assert clone.uid != line.uid and clone.end_a is line.end_a + assert clone.sections[0].line_type is line.sections[0].line_type + project.lines.append(clone) + DeckWriter(project).to_model() + same = Line.from_dict(line.to_dict(), fresh_uids=False) + with pytest.raises(ValueError, match="already used"): + project.lines.append(same) + assert same.parent is None and project.find(line.uid) is line + environment = project.environment + clash = type(environment)() + clash._uid = line.uid + with pytest.raises(ValueError, match="already used"): + project.environment = clash + assert project.environment is environment and project.find(environment.uid) is environment + same_uid = type(environment).from_dict(environment.to_dict(), fresh_uids=False) + project.environment = same_uid # replacing an object by a copy of itself is fine + assert project.find(same_uid.uid) is same_uid + project.points[0]._uid = project.points[1].uid + assert project.duplicate_uids() == [project.points[1].uid] + document = ProjectStore.to_json(project) + points = document["project"]["properties"]["points"] + points[1]["uid"] = points[0]["uid"] + with pytest.raises(ProjectFormatError, match="more than one object"): + ProjectStore.from_json(document) + + +def test_ownership_cycles_are_refused() -> None: + sea = MultiTrainSea() + train = sea.trains.append(WaveTrain()) + with pytest.raises(ValueError, match="cannot own itself"): + train.wave = sea + inner = WaveTrain() + nested = MultiTrainSea() + inner.wave = nested + nested.trains.append(WaveTrain()) + with pytest.raises(ValueError, match="cannot own itself"): + nested.trains.append(inner) + assert sea.root() is sea and inner.root() is inner + + +def test_axial_damping_keeps_its_units_apart() -> None: + axial = LinearAxial() + axial.set_deck_damping(-0.8) + assert (axial.damping_mode, axial.damping_ratio) == ("ratio", 0.8) + assert axial.deck_damping() == -0.8 + assert LinearAxial.get_property("damping_ratio").dimension is DIMENSIONLESS + assert LinearAxial.get_property("damping").dimension is AXIAL_DAMPING + engineering = UnitSystem.engineering() + assert engineering.to_display(axial.damping_ratio, DIMENSIONLESS) == 0.8 + axial.set_deck_damping(-0.0) + assert math.copysign(1.0, axial.deck_damping()) < 0.0 # type: ignore[arg-type] + axial.set_deck_damping((3.0e4, 2.0e5)) + assert axial.deck_damping() == (3.0e4, 2.0e5) and axial.damping_mode == "damping" + model = DeckModel.new() + model.add_line_type("odd", diam=0.1, mass=1.0, ea=1.0, ba=(1.0, 2.0, 3.0)) + with pytest.raises(DeckFormatError, match="unsupported BA"): + DeckReader.from_model(model) + + +def test_the_project_deck_is_runnable_from_the_project_folder(tmp_path: Path) -> None: + deck = EXAMPLES / "torsion_lazy_wave_hangoff_twist.dat" + project = DeckReader.read(deck) + folder = ProjectStore.save(project, tmp_path / "sub" / "twist.cdproj", folder=True) + model = DeckModel.load(folder / "model.dat") + motion = model.options.value("motionFile") + assert motion is not None and (folder / motion).is_file() + document = json.loads((folder / "project.json").read_text(encoding="utf-8")) + assert document["deck"]["missing_files"] == [] + stored = document["project"]["properties"]["deck_path"] + same_drive = os.path.splitdrive(str(tmp_path))[0] == os.path.splitdrive(str(deck))[0] + assert Path(stored).is_absolute() != same_drive + loaded = ProjectStore.load(folder) + assert Path(loaded.deck_path or "") == deck.resolve() + archive = ProjectStore.save(project, tmp_path / "zip" / "twist.cdproj") + with zipfile.ZipFile(archive) as opened: + text = opened.read("model.dat").decode("utf-8") + zipped = DeckModel.from_text(text, path=archive.parent / "model.dat") + zipped_motion = zipped.options.value("motionFile") + assert zipped_motion is not None and (archive.parent / zipped_motion).is_file() + project.motion.set_value("file", "no_such_motion.txt") + target = ProjectStore.save(project, tmp_path / "missing.cdproj", folder=True) + document = json.loads((target / "project.json").read_text(encoding="utf-8")) + assert document["deck"]["missing_files"] + fresh = Project("new") + fresh.points.append(FixedPoint("p")) + ProjectStore.save(fresh, tmp_path / "fresh.cdproj", folder=True) + + +def test_project_file_side_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from types import SimpleNamespace + + from cabledyn.project.deck import DeckWriter as Writer + + work = tmp_path / "work" + work.mkdir() + (work / "deck.dat").write_text( + (EXAMPLES / "spread_3line_chain.dat").read_text(encoding="utf-8"), encoding="utf-8" + ) + project = DeckReader.read(work / "deck.dat") + target = ProjectStore.save(project, work / "p.cdproj") + document = json.loads(zipfile.ZipFile(target).read("project.json")) + assert document["project"]["properties"]["deck_path"] == "deck.dat" + moved = tmp_path / "moved" + moved.mkdir() + (moved / "p.cdproj").write_bytes(target.read_bytes()) + assert Path(ProjectStore.load(moved / "p.cdproj").deck_path or "") == moved / "deck.dat" + syrope = DeckReader.read(EXAMPLES / "syrope_polyester_mooring.dat") + folder = ProjectStore.save(syrope, tmp_path / "syrope.cdproj", folder=True) + text = (folder / "model.dat").read_text(encoding="utf-8") + assert "SYROPE:" in text + record = SimpleNamespace(keyword="WaterKin", values=("SeaState",)) + numeric = SimpleNamespace(keyword="WaterKin", values=("3",)) + path = SimpleNamespace(keyword="WaterKin", values=("kin.dat",)) + off = SimpleNamespace(keyword="motionFile", values=("0",)) + fake = SimpleNamespace(line_types=[], options=[record, numeric, path, off]) + assert store_module._side_files(fake) == ["kin.dat"] # type: ignore[arg-type] + + def fail(self: Any, target: Any) -> None: + raise OSError("no access") + + monkeypatch.setattr(store_module._deck.DeckFile, "_rebase_relative_inputs", fail) + folder = ProjectStore.save(project, tmp_path / "fails.cdproj", folder=True) + document = json.loads((folder / "project.json").read_text(encoding="utf-8")) + assert document["deck"] == {"written": False, "reason": "no access"} + assert Writer is DeckWriter + + +def test_project_files_are_strict_json(tmp_path: Path) -> None: + project = Project() + with pytest.raises(ValueError, match="JSON data"): + project.studio.layouts = {"k": {1, 2}} + with pytest.raises(ValueError, match="JSON data"): + project.studio.layouts = {"k": float("nan")} + with pytest.raises(ValueError, match="finite"): + project.settings.time_step = float("nan") + project.studio.layouts = {"k": [1, 2]} + target = ProjectStore.save(project, tmp_path / "p.cdproj", folder=True) + text = (target / "project.json").read_text(encoding="utf-8") + (target / "project.json").write_text(text.replace('"layouts": {', '"bad": NaN, "layouts": {')) + with pytest.raises(ProjectFormatError, match="non-standard number NaN"): + ProjectStore.load(target) + + +def test_model_dat_needs_every_validation_layer(tmp_path: Path) -> None: + project = DeckReader.read(EXAMPLES / "spread_3line_chain.dat") + line = project.lines[0] + line.ancillaries.append(BendStiffener(start=0.0, length=5.0)) + line.ancillaries.append(BendStiffener(start=2.0, length=5.0)) + target = ProjectStore.save(project, tmp_path / "p.cdproj", folder=True) + document = json.loads((target / "project.json").read_text(encoding="utf-8")) + assert document["deck"]["written"] is False and "overlaps" in document["deck"]["reason"] + assert not (target / "model.dat").exists() + + +# --------------------------------------------------------------------------- P3 + + +def test_unknown_objects_keep_their_place_and_references(tmp_path: Path) -> None: + project = DeckReader.read(EXAMPLES / "spread_3line_chain.dat") + document = ProjectStore.to_json(project) + points = document["project"]["properties"]["points"] + retyped = points[1] + retyped["type"] = "plugin.special_point" + loaded = ProjectStore.from_json(document) + assert loaded.lines[0].end_b is None or loaded.lines[0].end_b.uid != retyped["uid"] + again = ProjectStore.to_json(loaded) + assert again["project"]["properties"]["points"][1] == retyped + assert again["unknown_objects"] == [] + line_dicts = again["project"]["properties"]["lines"] + ends = [item["properties"][end] for item in line_dicts for end in ("end_a", "end_b")] + assert retyped["uid"] in ends + document["unknown_objects"] = [{"owner": "gone", "slot": "points", "index": 0, "data": {}}] + kept = ProjectStore.from_json(document) + assert ProjectStore.to_json(kept)["unknown_objects"][0]["owner"] == "gone" + + +def test_a_listener_cannot_push_during_a_change() -> None: + project = Project() + point = project.points.append(FixedPoint("p")) + stack = CommandStack() + failures: list[Exception] = [] + + def listener(event: Any) -> None: + try: + stack.push(SetProperty(point, "volume", 2.0)) + except RuntimeError as exc: + failures.append(exc) + + project.events.subscribe(listener) + stack.push(SetProperty(point, "mass", 1.0)) + assert failures and point.volume == 0.0 + assert [type(c).__name__ for c in stack.commands()] == ["SetProperty"] + + +def test_failed_undo_and_redo_keep_the_stack_consistent() -> None: + class Fragile(Command): + def __init__(self) -> None: + self.fail_undo = False + self.fail_do = False + + def do(self) -> None: + if self.fail_do: + raise RuntimeError("do") + + def undo(self) -> None: + if self.fail_undo: + raise RuntimeError("undo") + + stack = CommandStack() + command = Fragile() + stack.push(command) + command.fail_undo = True + with pytest.raises(RuntimeError): + stack.undo() + assert stack.index == 1 and stack.can_undo + command.fail_undo = False + stack.undo() + command.fail_do = True + with pytest.raises(RuntimeError): + stack.redo() + assert stack.index == 0 and stack.can_redo + + +def test_malformed_hints_are_ignored(tmp_path: Path) -> None: + project = DeckReader.read(EXAMPLES / "chain_catenary_r3_100m.dat") + options = project.deck_hints["options"] + first = next(iter(options)) + options[first] = {"keyword": "g", "values": ["9.8"]} + options["motion"] = "garbage" + project.settings.extra_options.append(ExtraOption(keyword="WriteLog", values=("0",))) + project.settings.extra_options[-1].deck_hints["position"] = True + assert _errors(project) == [] + ProjectStore.save(project, tmp_path / "p.cdproj") + train_sea = MultiTrainSea() + train = train_sea.trains.append(WaveTrain()) + train.deck_hints["option"] = {"position": "x"} + project.environment.waves = train_sea + assert "wavetrain" in DeckWriter(project).to_text(validate=False) + + +def test_loader_errors_are_project_format_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + folder = tmp_path / "bad.cdproj" + folder.mkdir() + (folder / "project.json").write_bytes(b"\xff\xfe{") + with pytest.raises(ProjectFormatError, match="UTF-8"): + ProjectStore.load(folder) + (folder / "project.json").write_text("[" * 100000 + "]" * 100000, encoding="utf-8") + with pytest.raises(ProjectFormatError, match=r"nested too deeply|not a CableDyn"): + ProjectStore.load(folder) + good = ProjectStore.save(Project(), tmp_path / "good.cdproj") + monkeypatch.setattr(store_module, "_MAX_DOCUMENT_BYTES", 10) + with pytest.raises(ProjectFormatError, match="too large"): + ProjectStore.load(good) + good_folder = ProjectStore.save(Project(), tmp_path / "good_folder.cdproj", folder=True) + with pytest.raises(ProjectFormatError, match="too large"): + ProjectStore.load(good_folder) + + +def test_an_interrupted_save_keeps_the_previous_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + target = ProjectStore.save(Project("first"), tmp_path / "p.cdproj") + before = target.read_bytes() + + def broken(source: Any, destination: Any) -> None: + raise OSError("disk full") + + monkeypatch.setattr(os, "replace", broken) + with pytest.raises(OSError, match="disk full"): + ProjectStore.save(Project("second"), target, overwrite=True) + assert target.read_bytes() == before + assert [p.name for p in tmp_path.iterdir()] == ["p.cdproj"] + + +def test_unregistered_subclasses_are_not_written_as_their_parent() -> None: + class MyChain(ChainType): + """Not registered.""" + + with pytest.raises(TypeError, match="not registered"): + MyChain().to_dict() + + class Other(ModelObject): + """Collides with a registered key.""" + + with pytest.raises(ValueError, match="already used"): + model_type("line")(Other) + assert Other.type_key == "object" + assert "line" in REGISTRY + + +def test_abstract_flag_is_per_class() -> None: + assert Attachment.is_abstract() and not BuoyancyModule.is_abstract() + assert not FixedPoint.is_abstract() + + +def test_dynamic_solver_with_spectral_radius() -> None: + project = DeckReader.read(EXAMPLES / "chain_catenary_r3_100m.dat") + model, _ = DeckWriter(project).to_model() + model.options.set("dynamic_solver", "1e-8", "1e-14", "30", "12", "0.4") + read = DeckReader.from_model(model) + assert read.settings.solver_spectral_radius == 0.4 + assert "dynamic_solver 1e-8 1e-14 30 12 0.4" in DeckWriter(read).to_text(validate=False) + + +def test_describe_reports_numeric_limits() -> None: + from cabledyn.project import Point + + assert Point.get_property("position").dimension.key == "length" + assert AnalysisSettings.get_property("spectral_radius").describe()["maximum"] == 1.0 + assert Rod.get_property("segments").describe()["minimum"] == 0 + assert Section.get_property("segments").describe()["limit"] == "segments" + + +def test_rod_endpoints_cannot_be_replaced() -> None: + rod = Rod() + with pytest.raises(AttributeError): + SetProperty(rod, "end_b", RodEndpoint.make("B")) + with pytest.raises(AttributeError): + rod.end_b = RodEndpoint.make("B") + + +def test_import_deck_moves_objects_only_when_applied() -> None: + project = Project() + source = DeckReader.read(EXAMPLES / "spread_3line_chain.dat") + lines = len(source.lines) + command = ImportDeck(project, source) + assert len(source.lines) == lines and command.affected() == (project,) + stack = CommandStack() + stack.push(command) + assert project.deck_hints["options"] and len(project.lines) == lines + text = DeckWriter(project).to_text() + assert text == DeckModel.load(EXAMPLES / "spread_3line_chain.dat").to_text().replace( + DeckModel.load(EXAMPLES / "spread_3line_chain.dat").title, project.title + ) + stack.undo() + assert "options" not in project.deck_hints and len(project.lines) == 0 + stack.redo() + assert len(project.lines) == lines + + +def test_turbine_points_follow_their_turbine() -> None: + deck = ROOT / "validation" / "bodies" / "cases" / "V-S1m" / "cabledyn.dat" + project = DeckReader.read(deck) + points = [p for p in project.points if isinstance(p, TurbinePoint)] + assert points and all(p.turbine is not None for p in points) + turbine = points[0].turbine + assert turbine is not None + turbine.number = 7 + assert "Turbine7" in DeckWriter(project).to_text(validate=False) or "T7" in DeckWriter( + project + ).to_text(validate=False) + stack = CommandStack() + stack.push(RemoveObject(project, turbine, cascade=True)) + assert all(p.turbine is not turbine for p in project.objects_of(TurbinePoint)) + farm = DeckModel.new() + farm.add_point(1, "Turbine3", 0.0, 0.0, -10.0) + read = DeckReader.from_model(farm) + assert read.points[0].turbine_number == 3 # type: ignore[attr-defined] + loose = TurbinePoint(turbine_number=3) + assert loose.number() == 3 and loose.validate() == [] + assert TurbinePoint().validate() + project.points.append(TurbinePoint("orphan", turbine=type(turbine)(number=9))) + with pytest.raises(DeckExportError, match="outside the project"): + DeckWriter(project).to_text(validate=False) + project.points[-1].turbine = None # type: ignore[attr-defined] + with pytest.raises(DeckExportError, match="has no turbine"): + DeckWriter(project).to_text(validate=False) + + +def test_rod_types_must_belong_to_the_project() -> None: + project = DeckReader.read(EXAMPLES / "mixed_body_rods_points.dat") + project.rods[0].rod_type = RodType(project.rod_types[0].name) + with pytest.raises(DeckExportError, match="outside the project"): + DeckWriter(project).to_text(validate=False) + + +# --------------------------------------------------------------------------- oracles + + +def _strip_hints(project: Project) -> None: + for obj in project.walk(): + obj.deck_hints.clear() + + +def _semantic(project: Project) -> Any: + """The project's graph without uids (references become positions), names and hints.""" + graph = Project.from_dict(project.to_dict()) + _strip_hints(graph) + data = graph.to_dict() + order: dict[str, int] = {obj.uid: index for index, obj in enumerate(graph.walk())} + + def clean(node: Any) -> Any: + if isinstance(node, dict): + return { + key: clean(value) + for key, value in node.items() + if key not in {"uid", "name", "deck_path"} + } + if isinstance(node, list): + return [clean(value) for value in node] + if isinstance(node, str) and node in order: + return order[node] + return node + + return clean(data["properties"]) + + +@pytest.mark.parametrize( + "name", + [ + "spread_3line_chain.dat", + "lazy_wave_buoyancy_modules.dat", + "mixed_body_rods_points.dat", + "chain_two_train_sea.dat", + "lazy_wave_vessel_rao.dat", + "torsion_lazy_wave_hangoff_twist.dat", + "als_volturnus_line_break_tension.dat", + "syrope_polyester_mooring.dat", + ], +) +def test_hint_free_write_keeps_the_semantics(name: str) -> None: + project = DeckReader.read(EXAMPLES / name) + reference = _semantic(project) + _strip_hints(project) + text = DeckWriter(project).to_text() + stripped = DeckReader.from_text(text, path=EXAMPLES / name) + assert _semantic(stripped) == reference + + +HAND_DECK = """--------------------- CableDyn Input File ------------------------------------ +hand-written check +--------------------- LINE TYPES --------------------------------------------- +TypeName Diam MassDenInAir EA BA/-zeta EI Cd_n Cd_t Ca_n Ca_t +(-) (m) (kg/m) (N) (N-s/-) (N-m^2) (-) (-) (-) (-) +chain 0.126 105.6 7.518e8 -0.8 0.0 1.37 0.64 1.0 0.0 +--------------------- POINTS ------------------------------------------------- +ID Type X Y Z Mass Vol CdA Ca +(-) (-) (m) (m) (m) (kg) (m^3) (m^2) (-) +7 Vessel 1.0 2.0 -5.0 0 0 0 0 +3 Fixed 100.0 0.0 -30.0 0 0 0 0 +--------------------- LINES -------------------------------------------------- +ID NodeA NodeB Outputs +(-) (-) (-) (-) +4 7 3 pt +--------------------- SECTIONS ----------------------------------------------- +LineID LineType Length NumSegs +(-) (-) (m) (-) +4 chain 60.0 6 +4 chain 50.0 5 +--------------------- OPTIONS ------------------------------------------------ +30.0 WtrDpth +1025 rhoW +0.01 dtM +1.0 TMax +uniform 0.5 0.0 0.0 current +--------------------- OUTPUTS ------------------------------------------------ +"FairTen4" +--------------------- need this line ----------------------------------------- +""" + + +def test_a_hand_written_deck_reads_to_the_expected_objects() -> None: + project = DeckReader.from_text(HAND_DECK) + chain = project.line_types[0] + assert (chain.name, chain.diameter, chain.mass_per_length) == ("chain", 0.126, 105.6) + assert isinstance(chain.axial, LinearAxial) and chain.axial.stiffness == 7.518e8 + assert (chain.axial.damping_mode, chain.axial.damping_ratio) == ("ratio", 0.8) + assert (chain.drag_normal, chain.drag_axial, chain.added_mass_normal) == (1.37, 0.64, 1.0) + fairlead, anchor = project.points + assert type(fairlead).__name__ == "FloaterPoint" and fairlead.kind == "floater" # type: ignore[attr-defined] + assert fairlead.position == (1.0, 2.0, -5.0) and anchor.position == (100.0, 0.0, -30.0) + line = project.lines[0] + assert (line.end_a, line.end_b, line.outputs) == (fairlead, anchor, "pt") + assert [(s.length, s.segments) for s in line.sections] == [(60.0, 6), (50.0, 5)] + assert line.length == 110.0 + assert project.environment.water_depth == 30.0 and project.environment.water_density == 1025 + assert project.environment.current.velocity == (0.5, 0.0, 0.0) # type: ignore[attr-defined] + channel = project.outputs.channels[0] + assert channel.target is line # type: ignore[attr-defined] + _strip_hints(project) + text = DeckWriter(project).to_text() + assert "-0.8" in text and "FairTen1" in text and "Vessel" in text + + +@pytest.mark.parametrize("seed", range(20)) +def test_generated_graphs_survive_write_and_read(seed: int) -> None: + """Seeded random spreads of chain lines read back to the same object graph.""" + import random + + from cabledyn.project import FloaterPoint, GenericLineType + + rng = random.Random(seed) + project = Project(f"generated {seed}") + project.environment.water_depth = 100.0 + types = [ + project.line_types.append( + GenericLineType( + f"type {index}", + diameter=rng.uniform(0.05, 0.2), + mass_per_length=rng.uniform(20.0, 200.0), + axial=LinearAxial(stiffness=rng.uniform(1e8, 1e9)), + ) + ) + for index in range(rng.randint(1, 3)) + ] + for index in range(rng.randint(1, 4)): + angle = rng.uniform(0.0, 2.0 * math.pi) + fairlead = project.points.append( + FloaterPoint(position=(10 * math.cos(angle), 10 * math.sin(angle), -10.0)) + ) + reach = rng.uniform(200.0, 400.0) + anchor = project.points.append( + FixedPoint(position=(reach * math.cos(angle), reach * math.sin(angle), -100.0)) + ) + line = project.lines.append(Line(f"line {index}", end_a=fairlead, end_b=anchor)) + for _ in range(rng.randint(1, 3)): + line.sections.append( + Section( + line_type=rng.choice(types), + length=rng.uniform(80.0, 200.0), + segments=rng.randint(4, 20), + ) + ) + text = DeckWriter(project).to_text() + again = DeckReader.from_text(text) + assert _semantic(again) == _semantic(project) + assert DeckWriter(again).to_text() == text diff --git a/python/tests/test_project_model.py b/python/tests/test_project_model.py index c8c2724..0f295ff 100644 --- a/python/tests/test_project_model.py +++ b/python/tests/test_project_model.py @@ -3,6 +3,7 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any import pytest @@ -85,7 +86,6 @@ from cabledyn.project.units import LENGTH -@model_type("test.widget") class Widget(ModelObject): """A test class using every scalar descriptor.""" @@ -108,6 +108,14 @@ class Widget(ModelObject): blob = JsonValue({"a": 1}) +@pytest.fixture(autouse=True, scope="module") +def _registered_widget() -> Iterator[None]: + """Register the test class for this module only (no leak into other tests).""" + model_type("test.widget")(Widget) + yield + REGISTRY.unregister("test.widget") + + def _events(obj: ModelObject) -> list[Event]: seen: list[Event] = [] obj.events.subscribe(seen.append) @@ -186,14 +194,17 @@ def test_layer_one_checks() -> None: w.count = 9 w.spare = -1 w.target = (2.0e6, 0.0, 0.0) - w.values = (float("nan"),) messages = {(issue.prop, issue.message) for issue in w.validate()} assert ("length", "must be at most 10") in messages assert ("width", "must be at least 1") in messages assert ("count", "must be at most 5") in messages assert ("spare", "must be at least 0") in messages assert ("target", "magnitude must be at most 1e+06") in messages - assert any(prop == "values" and "finite" in message for prop, message in messages) + for bad in (float("nan"), float("inf")): + with pytest.raises(ValueError, match="finite"): + w.length = bad + with pytest.raises(ValueError, match="finite"): + w.values = (bad,) w.length = -1.0 assert any(i.message == "must be at least 0" for i in w.validate()) issue = w.validate()[0] @@ -522,7 +533,7 @@ def test_to_dict_and_from_dict_round_trip() -> None: project.environment.waves = JonswapWave() chain.deck_hints["note"] = ("kept", 1) data = project.to_dict() - copy = Project.from_dict(data) + copy = Project.from_dict(data, fresh_uids=False) assert copy.to_dict() == data assert copy.uid == project.uid assert copy.lines[0].end_a is copy.points[0] @@ -556,11 +567,18 @@ def test_from_dict_tolerates_unknown_and_bad_entries() -> None: assert rebuilt.lines[0].outputs == "-" assert rebuilt.lines[0].end_b is None assert isinstance(rebuilt.environment, Environment) - assert sorted(entry["data"]["type"] for entry in ctx.unknown) == ["plugin.buoy", "plugin.env"] + assert [entry["data"]["type"] for entry in ctx.unknown] == ["plugin.buoy"] + written = rebuilt.to_dict()["properties"] + assert written["environment"] == {"type": "plugin.env", "properties": {}} + assert written["lines"][0]["properties"]["bogus"] == 1 + assert written["lines"][0]["properties"]["outputs"] == 5 + assert written["lines"][0]["properties"]["end_b"] == "no-such-uid" + rebuilt.lines[0].outputs = "pt" + assert rebuilt.to_dict()["properties"]["lines"][0]["properties"]["outputs"] == "pt" text = " | ".join(ctx.warnings) for fragment in ("unknown type", "malformed", "kept the default", "unknown property"): assert fragment in text - assert "dropped reference end_b" in text + assert "reference end_b is unresolved" in text assert "not a valid item" in text with pytest.raises(KeyError): ctx.lookup(5) @@ -596,11 +614,13 @@ def test_project_validate_all_runs_every_layer() -> None: assert isinstance(issues[0], Issue) -def test_floater_type_extension_slot() -> None: - @model_type("test.hydro.tabulated") +def test_floater_type_extension_slot(request: pytest.FixtureRequest) -> None: class TabulatedDatabase(HydroDatabaseFile): """A plugin database kind.""" + model_type("test.hydro.tabulated")(TabulatedDatabase) + request.addfinalizer(lambda: REGISTRY.unregister("test.hydro.tabulated")) + floater_type = FloaterType() assert isinstance(floater_type.hydrodynamics, NoHydroDatabase) floater_type.hydrodynamics = TabulatedDatabase(file="x") From d3e8a627d5b94ee8ea1da6aee8ee7e580986dbd7 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 2 Oct 2026 21:56:56 +0900 Subject: [PATCH 5/5] Document the project-model identity, removal and project-file rules --- doc/project_model.rst | 42 ++++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/doc/project_model.rst b/doc/project_model.rst index 55ef93b..324dc24 100644 --- a/doc/project_model.rst +++ b/doc/project_model.rst @@ -19,17 +19,28 @@ Objects and properties Every object is a :class:`ModelObject` with a stable ``uid``, a ``name``, a ``parent`` and typed properties declared with descriptors (:class:`Quantity`, :class:`Integer`, :class:`Choice`, :class:`Vec3`, :class:`Ref`, :class:`Children`, :class:`Strategy`, ...). - Quantities are stored in SI units. Each property has a role: ``physics`` (written to the - deck), ``construction`` (library and construction data such as a chain grade or a floater's - hydrodynamic database; not written today), ``appearance`` (for the 3D views; never written), - ``derived`` or ``meta``. :meth:`ModelObject.properties` lists the descriptors, which carry - label, group, unit dimension, limits and documentation for editors. + Quantities are stored in SI units (angles in degrees, as in the deck); non-finite numbers + are refused. Each property has a role: ``physics`` (written to the deck), ``construction`` + (library and construction data such as a chain grade or a floater's hydrodynamic database; + not written today), ``appearance`` (for the 3D views; never written), ``derived`` or + ``meta``. :meth:`ModelObject.properties` lists the descriptors, which carry label, group, + unit dimension, limits and documentation for editors. A value whose meaning changes with its + sign in the deck is split: the axial damping is either a damping in N s or a dimensionless + damping ratio (:class:`AxialModel`). + +Identity and ownership + Every object has one owner and a uid that is unique in its project: inserting an object whose + uid is already used, or making an object own one of its owners, is refused. + :meth:`ModelObject.from_dict` gives a copy fresh uids by default; pass ``within=project`` + so the copy keeps its references to the project's objects (paste). References, renaming and removal Objects refer to each other by reference, never by name or deck id, so renaming cannot break a link. Removing a referenced object either fails with :class:`ObjectInUseError` or cascades - (:func:`plan_removal`): a referrer whose required reference is lost is removed with it, an - optional reference is cleared, and a line left without sections is removed. + (:func:`plan_removal`): a referrer whose required reference is lost is removed with it + (:meth:`ModelObject.required_references`, which can depend on the referrer's state, such as + a body rod's body), an optional reference is cleared, and a line left without sections is + removed. Weak references, such as group memberships, never block a removal and are cleared. :meth:`Project.used_by` answers "used by" questions. Events and commands @@ -38,7 +49,8 @@ Events and commands ancestor, so one subscription on the :class:`Project` sees the whole model. Interactive edits are :class:`Command` objects pushed onto a :class:`CommandStack`, which provides undo, redo, merging of drags, grouping (:meth:`CommandStack.macro`) and a clean (saved) marker. Undo - restores the project's :meth:`~ModelObject.to_dict` exactly. + restores the project's :meth:`~ModelObject.to_dict` exactly. A change listener must not push, + undo or redo while a command is being applied; the stack refuses it. Validation Three layers (:func:`validate_project`): property checks with the numeric limits shared with @@ -69,7 +81,9 @@ graph; :class:`DeckWriter` builds a fresh :class:`~cabledyn.DeckModel` and rende keyword spellings, the order and descriptions of ``OPTIONS`` rows are kept as hints, so a deck read and written back gives exactly ``DeckModel.load(deck).to_text()``. Comments and column alignment of the original file are not kept. ``OPTIONS`` rows the model does not type are -kept verbatim as :class:`ExtraOption` objects. The writer returns an :class:`IdMap` between +kept verbatim as :class:`ExtraOption` objects. An extra row that sets a typed option (a duplicate +kept from a deck, for example) never overrides the typed property: the writer places it before +the typed row, and validation warns about it. The writer returns an :class:`IdMap` between objects and deck ids. .. code-block:: python @@ -85,9 +99,13 @@ objects and deck ids. text = DeckWriter(project).to_text() :class:`ProjectStore` writes a ``.cdproj`` zip archive (or an unzipped folder) with -``project.json`` (the whole graph, physics and appearance, with a schema version), -``model.dat`` (the deck, when the project is valid), ``assets/`` and ``results.json``. Objects of -a type the reader does not know are kept verbatim. +``project.json`` (the whole graph, physics and appearance, as strict JSON with a schema +version), ``model.dat``, ``assets/`` and ``results.json``. ``model.dat`` is written only when +the project passes every validation layer. Its relative side-file paths (motion, bathymetry, +WaterKin and Syrope files) are rewritten to resolve from the project folder (a folder project) +or from the folder that holds the archive (a zip project), and side files that cannot be found +are listed in ``project.json``. Each file is replaced atomically. Objects, properties and +references the reader does not understand are kept and written back unchanged. Extending the model -------------------