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/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/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..324dc24 --- /dev/null +++ b/doc/project_model.rst @@ -0,0 +1,375 @@ +.. 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 (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 + (: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 + 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. 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 + 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. 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 + + 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, 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 +------------------- + +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. 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. 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..8bc5a9f --- /dev/null +++ b/python/cabledyn/project/base.py @@ -0,0 +1,635 @@ +# 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 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``. + + 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.is_abstract() + ] + + +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]: + REGISTRY.register(key, cls) + cls.type_key = key + 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), 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. 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, 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]] = [] + + 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.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, + "slot": slot, + "index": index, + "data": data, + } + ) + return None + cls = REGISTRY.get(key) + obj = cls.__new__(cls) + obj._setup() + stored = data.get("uid") + if isinstance(stored, str) and stored: + 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) + 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 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] + 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__}: 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: + """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 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.""" + 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()}: reference {prop.name} is unresolved and kept for a " + f"later save ({exc})" + ) + obj._preserved[prop.name] = data + 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 + """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.") + 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) + + @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() + 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 _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(): + 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 + + 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: + 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()}") + 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)) + + 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. 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, + "uid": self._uid, + "properties": { + prop.name: prop.to_json(self._values[prop.name], context) + 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 = True, + within: ModelObject | None = None, + ) -> M: + """Rebuild an object (and its descendants) written by :meth:`to_dict`. + + 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; ``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 + ------- + ModelObject + The rebuilt object. + + Raises + ------ + TypeError + If ``data`` does not describe a ``cls`` object. + """ + 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__}") + 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..cec627d --- /dev/null +++ b/python/cabledyn/project/collection.py @@ -0,0 +1,174 @@ +# 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, would own its own owner, or + brings a uid the project already uses. + """ + 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()}") + 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) + 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) + self._owner.root()._release(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..90bd42c --- /dev/null +++ b/python/cabledyn/project/commands.py @@ -0,0 +1,745 @@ +# 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 +import copy +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 = 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: + 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(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 (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`. + replace_environment : bool + 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 + ) -> 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 + target = project._values[prop.name] + 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 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)) + 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) +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._busy = False + 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 _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. + """ + self._run(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 + command = self._commands[self._index - 1] + self._run(command.undo) + self._index -= 1 + 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] + self._run(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..b985883 --- /dev/null +++ b/python/cabledyn/project/deck.py @@ -0,0 +1,1791 @@ +# 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", "typed_option"] + +_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: + 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) + elif isinstance(row.ea, tuple): + if len(row.ea) == 2: + 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] + ) + else: + raise DeckFormatError(f"line type {row.name!r}: unsupported EA {row.ea!r}") + else: + 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, + 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: + 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}" + 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["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 + 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 + 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 + assigned[position] = following + self.id_map.add(item, kind, assigned[position]) + + def _id(self, obj: ModelObject) -> int: + found = self.id_map.deck_id(obj) + assert isinstance(found, int) # every numbered object gets an id in _ids + 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=axial.deck_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") + 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 + 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.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 + 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 + self._call( + load, + model.add_external_load, + 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", number) + 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 hinted( + hint: Any, keyword: str, encoded: tuple[str, ...], owner: ModelObject, group: str + ) -> None: + nonlocal fresh + if _valid_hint(hint): + same = list(encoded) == hint["encoded"] + rows.append( + _Row( + float(hint["position"]), + 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, 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) + 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) + 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 + 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) + # 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) and not isinstance(position, bool): + order = float(position) + else: + fresh += 1.0 + order = fresh + 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( + 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 _valid_hint(hint) + and _option_key(str(hint["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..fa89161 --- /dev/null +++ b/python/cabledyn/project/descriptors.py @@ -0,0 +1,1042 @@ +# 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 (angles in degrees). +""" + +from __future__ import annotations + +import copy +import json +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 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]: + 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}") + 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: + 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 (angles in degrees). + + 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) + + +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 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" + + def __init__(self, default: Any = None, **kwargs: Any) -> None: + kwargs.setdefault("role", Role.META) + super().__init__(default, **kwargs) + + def coerce(self, value: object) -> Any: + 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]): + """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). 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, weak: bool = False, **kwargs: Any + ) -> None: + super().__init__(None, **kwargs) + self.target = target + self.required = required and not weak + self.weak = weak + + @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, weak=self.weak) + 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. + 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, weak: bool = False, **kwargs: Any + ) -> None: + super().__init__((), **kwargs) + self.target = target + self.min_items = 0 if weak else min_items + self.weak = weak + + @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, weak=self.weak) + 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): + if isinstance(data, dict): + obj._preserved[self.name] = data + 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..ba5d9b7 --- /dev/null +++ b/python/cabledyn/project/points.py @@ -0,0 +1,190 @@ +# 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, Turbine +from cabledyn.project.descriptors import ( + Choice, + 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__ = [ + "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 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 = 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") +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..30ef9d5 --- /dev/null +++ b/python/cabledyn/project/project.py @@ -0,0 +1,216 @@ +# 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: + 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``.""" + 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.""" + 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..dfcd3d5 --- /dev/null +++ b/python/cabledyn/project/references.py @@ -0,0 +1,221 @@ +# 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 + (: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; +* 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 _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 + ---------- + root : ModelObject + 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 + ------- + 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 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) + 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 + required = item.required_references() + for prop in type(item).properties(): + value = item._values[prop.name] + if isinstance(prop, Ref) and prop.name in 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..f537247 --- /dev/null +++ b/python/cabledyn/project/rods.py @@ -0,0 +1,77 @@ +# 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"), 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"}: + 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..b4f200c --- /dev/null +++ b/python/cabledyn/project/settings.py @@ -0,0 +1,150 @@ +# 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 ' - '.") + + 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): + """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..3a34d25 --- /dev/null +++ b/python/cabledyn/project/store.py @@ -0,0 +1,383 @@ +# 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 (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, 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). + +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 + +import io +import json +import os +import tempfile +import zipfile +from collections.abc import Callable +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 +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/" +_MAX_DOCUMENT_BYTES = 256 * 1024 * 1024 +_RELATIVE_MARK = "deck_path_relative_to" + +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 readable CableDyn project, or is from a newer release.""" + + +def _package_version() -> str: + from cabledyn import __version__ + + 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, 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() + 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": 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, 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, 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") + 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") + 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 = [*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, + project: Project, + path: str | os.PathLike[str], + *, + folder: bool = False, + overwrite: bool = False, + ) -> 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 + 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") + 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: + _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: + _atomic_write(deck_file, deck_text.encode("utf-8")) + elif deck_file.exists(): + deck_file.unlink() + return target + 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) + _atomic_write(target, 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 kept but + not understood. + + Raises + ------ + FileNotFoundError + If ``path`` does not exist. + ProjectFormatError + If the file is not a readable project. + """ + source = Path(path).expanduser().resolve() + try: + 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 + 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 new file mode 100644 index 0000000..48dc4e7 --- /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, weak=True, 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..0b3ec42 --- /dev/null +++ b/python/cabledyn/project/types.py @@ -0,0 +1,406 @@ +# 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 + +import math + +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, +) +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. + + 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_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") +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..c8b7bef --- /dev/null +++ b/python/cabledyn/project/units.py @@ -0,0 +1,460 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Physical dimensions, units, and display-unit preferences. + +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 +-------- +>>> 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 unit in which values are stored (SI; degrees for angles). + """ + + 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. 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 + 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..022a247 --- /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 = 3.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..9a78e88 --- /dev/null +++ b/python/tests/test_project_deck.py @@ -0,0 +1,703 @@ +# 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, + 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] + + +class OddWave(WaveModel): + """A wave model without a deck form.""" + + +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 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) + # 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)) == [] + # 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 + + +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_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)) + 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)) + ) + 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")) + 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, 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) + 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) + + +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_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 new file mode 100644 index 0000000..0f295ff --- /dev/null +++ b/python/tests/test_project_model.py @@ -0,0 +1,682 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Object model of cabledyn.project: descriptors, objects, events, invariants, serialisation.""" + +from __future__ import annotations + +from collections.abc import Iterator +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 + + +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}) + + +@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) + 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) + 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 + 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] + 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, fresh_uids=False) + 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 [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 "reference end_b is unresolved" 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(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") + 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)