Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,13 @@ add_test(NAME c_api_host_rotation
WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
set_tests_properties(c_api_host_rotation PROPERTIES LABELS "c;unit;capi;shared;dynamic;torsion")

# CableDyn_GetInitNotes on the point route, the aggregate and a host fluid field.
add_executable(test_c_api_init_notes tests/test_c_api_init_notes.c)
target_include_directories(test_c_api_init_notes PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src")
target_link_libraries(test_c_api_init_notes PRIVATE cabledyn_shared)
add_test(NAME c_api_init_notes COMMAND test_c_api_init_notes WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
set_tests_properties(c_api_init_notes PROPERTIES LABELS "c;unit;capi;shared")

find_package(Threads QUIET)
if(Threads_FOUND AND CMAKE_USE_PTHREADS_INIT)
add_executable(test_c_api_concurrency tests/test_c_api_concurrency.c)
Expand Down Expand Up @@ -1815,6 +1822,19 @@ if(Python3_Interpreter_FOUND)
TIMEOUT 600
ENVIRONMENT "${_cabledyn_python_test_environment}")

# The note on grounded lines that a current moves over a seabed without friction: once per run
# on every standalone route, and never without a grounded node, a current, or a march, or
# with friction.
add_test(NAME seabed_drift_note
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_seabed_drift_note.py
--exe $<TARGET_FILE:cabledyn>
--examples ${CMAKE_CURRENT_SOURCE_DIR}/examples
--output ${CMAKE_BINARY_DIR}/seabed_drift_note)
set_tests_properties(seabed_drift_note PROPERTIES
LABELS "python;driver;integration;contact"
TIMEOUT 600
ENVIRONMENT "${_cabledyn_python_test_environment}")

# A rod numbered as a body is not one of the body's points: a Rod1B end POINT listed before
# the Body1 points gives the run of the same deck with the rod renumbered, bit for bit.
# A lazy wave restrained in torsion starts in an in-plane current (its untwisted state's
Expand Down
11 changes: 11 additions & 0 deletions doc/api_python.rst
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,17 @@ library is located. Array ordering, frames, and units at the coupling boundary f
:returns: The message, or an empty string when there is none.
:rtype: str

.. py:property:: init_notes

The notes of the last deck initialisation, successful or not, on the point and aggregate
routes alike: the ``Note:`` lines a driver run of the deck prints at its start, without
the prefix. They take what is known at initialisation: the deck's current, also with a
host field (:meth:`set_host_fluid`), and not a field the host applies afterwards. The
notes of the march are not among them.

:returns: One string per note, empty when there are none.
:rtype: list[str]

.. py:method:: init_deck(deck)

Initialise from a deck file: parse it, build the model, and solve the static initial
Expand Down
31 changes: 31 additions & 0 deletions doc/capi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ MoorDyn's C API.
- read the current coupled-point motion
* - ``CableDyn_GetLastError``
- diagnostic text for the last failure on a handle
* - ``CableDyn_GetInitNotes``
- the notes of the last deck initialisation on a handle
* - ``CableDyn_IsInitialized``, ``CableDyn_NCoupledDOF``, ``CableDyn_NPoints``,
``CableDyn_NLines``
- state and size queries
Expand Down Expand Up @@ -1757,6 +1759,35 @@ After ``CableDyn_InitDeckAt`` the host-driven blocks hold their entries of ``q0`
Use it to seed the host's coupled vectors before the first step. The buffers are set to zero
before the copy and stay zero if the call fails. The model is not changed.

``CableDyn_GetInitNotes``
~~~~~~~~~~~~~~~~~~~~~~~~~

.. code-block:: c

int CableDyn_GetInitNotes(void *handle, char *notes, int notes_len);

ABI 1 minor extension 4. Copies the notes of the last deck initialisation on the handle, on the
point route and the aggregate alike: the ``Note:`` lines a driver run of the deck prints at its
start, without the prefix, one per line, each ended by a new line. The library prints no more or
less than before; the text is for a host that does not read its standard streams. The notes of
the march are not among them, and a raw-line initialisation has none.

The buffer follows ``CableDyn_GetLastError``: it is always ``NUL``-terminated when ``notes_len``
is positive and ``notes`` is not ``NULL``, and the text is truncated to ``notes_len - 1``
characters. The return value is the length of the whole text without the terminator, so a ``NULL``
buffer asks for the size. It is 0 without notes and -1 on a bad handle.

The notes belong to the last attempt: a failed initialisation replaces them, although the handle
keeps its previous model. They take what is known at initialisation. With
``CableDyn_SetHostFluid`` the deck's steady current sets the static start and counts; a field the
host applies afterwards does not.

.. code-block:: c

int size = CableDyn_GetInitNotes(h, NULL, 0);
char *notes = malloc((size_t)size + 1);
CableDyn_GetInitNotes(h, notes, size + 1);

``CableDyn_GetLastError``
~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion doc/coupling_boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ whose section "Coupling a host (CFD) solver" gives the call sequence of a CFD ho
| Initialisation | `CableDyn_InitDeck` (a supported `EI = 0` point-system deck, through the deck parser and static solve, or a deck with finite-EI lines, free bodies or rods on the coupled aggregate); `CableDyn_InitLine` (one meshed `EI = 0` line); `CableDyn_InitLines` (several lines with a compact coupled-DOF map) |
| Stepping | `CableDyn_UpdateStates` (set coupled kinematics and recompute internal accelerations), `CableDyn_UpdatePointFluidFields`, `CableDyn_Step` |
| Output | `CableDyn_CalcOutput`, `CableDyn_CalcOutputDerivatives`, `CableDyn_GetCoupledMotion` |
| Queries | `CableDyn_NCoupledDOF`, `CableDyn_NPoints`, `CableDyn_NLines`, `CableDyn_GetLastError` |
| Queries | `CableDyn_NCoupledDOF`, `CableDyn_NPoints`, `CableDyn_NLines`, `CableDyn_GetLastError`, `CableDyn_GetInitNotes` |
| Object queries (ABI 1.1) | `CableDyn_GetAbiMinor`, `CableDyn_NObjects`, `CableDyn_GetObjectInfo`, `CableDyn_GetLineValues`, `CableDyn_GetPointState`, `CableDyn_GetBodyState`, `CableDyn_GetRodState`, `CableDyn_EvalChannel`: the model's lines, points, bodies and rods, their committed state, and any `OUTPUTS` channel |
| Host coupling (ABI 1.2) | `CableDyn_InitDeckAt` (`CableDyn_InitDeck` with the statics solved at the host's positions of the `Coupled` points), `CableDyn_SaveState`, `CableDyn_RestoreState` (a one-slot in-memory save of the whole model for outer correctors), `CableDyn_GetCoupledLayout` (the deck point id and type of every block of the coupled vector) |
| Host rotations (ABI 1.3) | `CableDyn_InitDeckRotating` (`CableDyn_InitDeck` for a host that turns its coupled points, so torsion and `Rigid` end connections turn with it), `CableDyn_UpdateRotations` (each coupled point's global-to-body DCM, angular velocity and acceleration), `CableDyn_GetCoupledMoments` (the moments the lines apply to the host) |
Expand Down
7 changes: 7 additions & 0 deletions doc/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,13 @@ Warnings and notes that do not stop a run
on the seabed). The node is a deck node, numbered as the ``<J>`` of the node channels
(1 = End A). As above, the layout is an equilibrium of the deck as written; check the
line length before using it.
* - ``Note: line(s) ... on the seabed at the static start with no seabed friction``
- a dynamic deck with a current and interior line nodes in seabed contact at the static
start: nothing holds those nodes against the current, so the layout can drift along the
seabed through the run. Set ``frictionMu``; the note says so where the deck's route rejects
it. It is printed
once per run, and nothing in the solution changes. On the coupled route it takes the
current known at initialisation (the deck's, or the host's steady current).
* - ``Note: line ...: the equilibrium in the current is held in the vertical plane of the line; it
is unstable out of that plane ...``
- in a current along the plane of the line the static state is a planar equilibrium that a
Expand Down
15 changes: 15 additions & 0 deletions python/cabledyn/_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,21 @@ def _bind_host_fluid() -> bool:
_has_host_fluid = _bind_host_fluid()


def _bind_init_notes() -> bool:
# Bound on its own: a library of ABI minor 4 may predate this entry point, and nothing but
# ``CableDyn.init_notes`` needs it.
try:
get_notes = _lib.CableDyn_GetInitNotes
except AttributeError:
return False
get_notes.argtypes = [c_void_p, c_char_p, c_int]
get_notes.restype = c_int
return True


_has_init_notes = _bind_init_notes()


def abi_minor() -> int:
"""The loaded library's ABI 1 extension level (0 before the object queries)."""
if not _has_object_queries:
Expand Down
23 changes: 23 additions & 0 deletions python/cabledyn/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,29 @@ def last_error(self) -> str:
with self._lock:
return self._last_error()

@property
def init_notes(self) -> list[str]:
"""The notes of the last deck initialisation, successful or not.

These are the ``Note:`` lines a driver run of the deck prints at its start, without the
prefix, on the point and aggregate routes alike. They take what is known at
initialisation: the deck's current, not a field a host applies later. A raw-line
initialisation has none.
"""
with self._lock:
if not _lib_module._has_init_notes:
# not a C status: the loaded library lacks the entry point
raise RuntimeError(
"the loaded CableDyn library predates CableDyn_GetInitNotes; "
"rebuild it from this checkout"
)
size = _lib.CableDyn_GetInitNotes(self._handle, None, 0)
if size <= 0:
return []
buf = ctypes.create_string_buffer(size + 1)
_lib.CableDyn_GetInitNotes(self._handle, buf, len(buf))
return buf.value.decode("utf-8", errors="replace").splitlines()

# -- initialization -------------------------------------------------------

def init_deck(self, deck: str | os.PathLike[str]) -> None:
Expand Down
227 changes: 227 additions & 0 deletions python/tests/test_init_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
"""The notes of an initialisation, read in-process: the ``Note:`` lines of a driver run."""

from __future__ import annotations

import os
import re
import subprocess
from pathlib import Path

import pytest

try:
from cabledyn import _lib
except OSError as exc:
pytest.skip(f"CableDyn shared library not available: {exc}", allow_module_level=True)

from cabledyn.errors import CableDynError
from cabledyn.model import CableDyn

ROOT = Path(__file__).resolve().parents[2]
EXAMPLES = ROOT / "examples"
DRIVER = os.environ.get("CABLEDYN_TEST_DRIVER", "").strip() or os.environ.get("CABLEDYN_DRIVER", "")
DRIFT = "on the seabed at the static start with no seabed friction"
CURRENT = "uniform 0.5 0 0 current"
# a Currents 2 table that holds one current over its 1 s
CONSTANT_GRID = (
"dynamic\n\n\n\n-50.0 0.0\nt ux... uy...\n0.0 0.1 0.1 0.0 0.0\n1.0 0.1 0.1 0.0 0.0\n"
)
_BAR = "-" * 21
_DECK = f"""{_BAR} CableDyn Input File {_BAR}
a grounded chain
{_BAR} LINE TYPES {_BAR}
Name Diam Mass EA BA EI Cdn Cdt Can Cat
chain 0.252 390.0 1.674e9 -1.0 @EI@ 1.37 0.64 1.0 0.0
{_BAR} POINTS {_BAR}
ID Type X Y Z Mass Vol CdA Ca
1 Fixed 400.0 0.0 -50.0 0 0 0 0
2 Coupled 0.0 0.0 0.0 0 0 0 0
{_BAR} LINES {_BAR}
ID NodeA NodeB Outputs
1 2 1 -
{_BAR} SECTIONS {_BAR}
LineID LineType Length NumSegs
1 chain 410.0 41
{_BAR} OPTIONS {_BAR}
9.80665 g
1025.0 rhoW
50.0 WtrDpth
@DTM@ dtM
10.0 TMax
@OPTIONS@
{_BAR} OUTPUTS {_BAR}
FairTen1
@OUTPUTS@
{_BAR} need this line {_BAR}
"""


def _deck(
tmp_path: Path,
*options: str,
ei: float = 0.0,
dtm: float = 0.02,
outputs: str = "",
name: str = "deck.dat",
) -> Path:
path = tmp_path / name
text = _DECK.replace("@EI@", str(ei)).replace("@DTM@", str(dtm))
text = text.replace("@OPTIONS@", "\n".join(options)).replace("@OUTPUTS@", outputs)
path.write_text(text, encoding="utf-8")
return path


def _drift(notes: list[str]) -> list[str]:
return [note for note in notes if DRIFT in note]


@pytest.mark.parametrize("ei", [0.0, 1.0e6], ids=["point", "aggregate"])
def test_the_drift_note_reaches_an_in_process_host(tmp_path, ei):
with CableDyn(_deck(tmp_path, CURRENT, ei=ei)) as model:
notes = _drift(model.init_notes)
assert len(notes) == 1
assert "line 1 lies" in notes[0] and "Set frictionMu to hold it" in notes[0]
assert notes[0].endswith(".") and not notes[0].startswith("Note:")


@pytest.mark.parametrize("ei", [0.0, 1.0e6], ids=["point", "aggregate"])
@pytest.mark.parametrize("rows", [(), (CURRENT, "0.5 frictionMu")], ids=["still", "friction"])
def test_no_drift_note_where_it_is_not_true(tmp_path, ei, rows):
with CableDyn(_deck(tmp_path, *rows, ei=ei)) as model:
assert _drift(model.init_notes) == []


@pytest.mark.parametrize("ei", [0.0, 1.0e6], ids=["ei0", "finite"])
def test_a_host_field_keeps_the_deck_steady_current(tmp_path, ei):
with CableDyn() as model:
model.set_host_fluid(True)
model.init_deck(_deck(tmp_path, CURRENT, ei=ei))
assert len(_drift(model.init_notes)) == 1


def test_a_field_applied_after_init_is_not_known(tmp_path):
with CableDyn() as model:
model.set_host_fluid(True)
model.init_deck(_deck(tmp_path))
assert _drift(model.init_notes) == []


def test_the_notes_of_a_deck_on_both_routes(tmp_path):
# rhoInf and the node numbering come from the deck; the step from the line
rows = ("0.99 rhoInf",)
for ei in (0.0, 1.0e6):
with CableDyn(_deck(tmp_path, *rows, ei=ei, dtm=0.1, outputs="Ten1N5")) as model:
notes = "\n".join(model.init_notes)
assert "rhoInf = 0.990 leaves almost no numerical dissipation" in notes
assert "line nodes are numbered from 1 = End A" in notes
assert "LINE 1: dtM = 0.1000 s resolves its axial period" in notes


def test_a_note_of_the_parse_is_kept_once(tmp_path):
# the deck is read twice, to find its route and to build it
(tmp_path / "current_profile_dynamic.txt").write_text(CONSTANT_GRID, encoding="utf-8")
with CableDyn(_deck(tmp_path, "2 Currents")) as model:
notes = model.init_notes
assert len([note for note in notes if "TMax outlasts the last sample" in note]) == 1
assert len(notes) == len(set(notes))


def _free_point_deck(tmp_path: Path, friction: str = "none") -> Path:
# the shipped clump weight in a current, on a chain long enough for a run on the seabed
text = (EXAMPLES / "clump_weight_free_point.dat").read_text(encoding="utf-8")
for old, new in (
("chain 250.0", "chain 300.0"),
("none current", CURRENT),
("none frictionMu", f"{friction} frictionMu"),
):
assert old in text, old
text = text.replace(old, new)
path = tmp_path / "free_point.dat"
path.write_text(text, encoding="utf-8")
return path


@pytest.mark.parametrize("host_fluid", [False, True], ids=["point", "aggregate"])
def test_a_route_without_friction_says_so(tmp_path, host_fluid):
with CableDyn() as model:
model.set_host_fluid(host_fluid)
model.init_deck(_free_point_deck(tmp_path))
notes = _drift(model.init_notes)
assert len(notes) == 1
assert "line 1 lies" in notes[0]
assert "Seabed friction (frictionMu), which would hold it, is not supported" in notes[0]
with pytest.raises(CableDynError, match="seabed friction is not supported"):
model.init_deck(_free_point_deck(tmp_path, "0.5"))


def test_a_failed_initialisation_keeps_its_notes_and_the_model(tmp_path):
with CableDyn(_deck(tmp_path, CURRENT)) as model:
assert _drift(model.init_notes)
n_lines = model.n_lines
(tmp_path / "current_profile_dynamic.txt").write_text(CONSTANT_GRID, encoding="utf-8")
bad = _deck(tmp_path, "2 Currents", "r.txt vesselRAO", name="bad.dat")
with pytest.raises(CableDynError):
model.init_deck(bad)
assert _drift(model.init_notes) == []
assert model.n_lines == n_lines


def test_a_new_initialisation_replaces_the_notes(tmp_path):
with CableDyn(_deck(tmp_path, CURRENT)) as model:
assert _drift(model.init_notes)
model.init_deck(_deck(tmp_path, name="still.dat"))
assert _drift(model.init_notes) == []


def test_a_new_handle_has_no_notes():
with CableDyn() as model:
assert model.init_notes == []


@pytest.mark.skipif(
not DRIVER or not Path(DRIVER).is_file(),
reason="set CABLEDYN_TEST_DRIVER to a built native driver to compare the notes of a run",
)
@pytest.mark.parametrize(
("deck", "host_fluid"),
[
("dynamic_chain_current", False),
("dynamic_chain_held", False),
("connect_weighted_point", False),
("lazy_wave_buoyancy_smeared", False),
("clump_weight_free_point", False),
("rod_moored_spar", True),
],
)
def test_the_notes_match_those_of_a_driver_run(tmp_path, deck, host_fluid):
text = (EXAMPLES / f"{deck}.dat").read_text(encoding="utf-8")
text = re.sub(r"^[ \t]*\S+[ \t]+TMax\b.*$", "0.1 TMax", text, count=1, flags=re.M)
text = re.sub(r"^[ \t]*\S+[ \t]+dtM\b.*$", "0.05 dtM", text, count=1, flags=re.M)
path = tmp_path / f"{deck}.dat"
path.write_text(text, encoding="utf-8")
run = subprocess.run(
[DRIVER, path.name, deck],
cwd=tmp_path,
capture_output=True,
text=True,
timeout=600,
check=False,
)
assert run.returncode == 0, run.stdout + run.stderr
# stderr holds the initialisation report; stdout the start of the run, then the march
start = run.stdout.split("Dynamic simulation:")[0]
printed = {m.group(1).strip() for m in re.finditer(r"Note: (.*)", run.stderr + "\n" + start)}
with CableDyn() as model:
model.set_host_fluid(host_fluid)
model.init_deck(path)
notes = set(model.init_notes)
assert printed <= notes, printed - notes


def test_a_library_without_the_notes_call_still_loads(monkeypatch):
# a library of ABI minor 4 can predate the entry point: only init_notes may refuse
monkeypatch.setattr(_lib, "_has_init_notes", False)
with CableDyn() as model, pytest.raises(RuntimeError, match="CableDyn_GetInitNotes"):
_ = model.init_notes
Loading
Loading