diff --git a/CMakeLists.txt b/CMakeLists.txt index 6032937..4bbed17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) @@ -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 $ + --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 diff --git a/doc/api_python.rst b/doc/api_python.rst index 0c3a736..ec38d94 100644 --- a/doc/api_python.rst +++ b/doc/api_python.rst @@ -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 diff --git a/doc/capi.rst b/doc/capi.rst index 577f8dc..b9fe926 100644 --- a/doc/capi.rst +++ b/doc/capi.rst @@ -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 @@ -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`` ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/coupling_boundary.md b/doc/coupling_boundary.md index 2695fba..1757e1c 100644 --- a/doc/coupling_boundary.md +++ b/doc/coupling_boundary.md @@ -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) | diff --git a/doc/troubleshooting.rst b/doc/troubleshooting.rst index fa9bceb..78388c1 100644 --- a/doc/troubleshooting.rst +++ b/doc/troubleshooting.rst @@ -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 ```` 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 diff --git a/python/cabledyn/_lib.py b/python/cabledyn/_lib.py index 8db7444..a34bc7d 100644 --- a/python/cabledyn/_lib.py +++ b/python/cabledyn/_lib.py @@ -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: diff --git a/python/cabledyn/model.py b/python/cabledyn/model.py index 014dda9..ac0b6a4 100644 --- a/python/cabledyn/model.py +++ b/python/cabledyn/model.py @@ -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: diff --git a/python/tests/test_init_notes.py b/python/tests/test_init_notes.py new file mode 100644 index 0000000..645e347 --- /dev/null +++ b/python/tests/test_init_notes.py @@ -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 diff --git a/src/CableDyn_CAPI.f90 b/src/CableDyn_CAPI.f90 index 6bd537d..6f8f779 100644 --- a/src/CableDyn_CAPI.f90 +++ b/src/CableDyn_CAPI.f90 @@ -16,12 +16,14 @@ MODULE CableDyn_CAPI USE CableDyn_DeckDriver, ONLY: CD_Init_Deck_System, CD_DECKDRV_OK, CD_DECKDRV_BADINPUT, CD_DECKDRV_SOLVEFAIL, & CD_Deck_Query_dtM, CD_DECK_NAMELEN, CD_Eval_Aggregate_Channel, & CD_Eval_Aggregate_Segment_Tensions, CD_Eval_Aggregate_Line_Values, & - CD_Rigid6_Inventory, CD_Rod_Inventory, CD_Channel_Token_Parses, CD_Cable_Deck_Node + CD_Rigid6_Inventory, CD_Rod_Inventory, CD_Channel_Token_Parses, CD_Cable_Deck_Node, & + CD_Init_Notes_Start, CD_Init_Notes_Add, CD_Init_Notes_Take, CD_INIT_NOTE_LEN USE CableDyn_OpenFAST_Aggregate, ONLY: CD_AGG_ModuleType, CD_AGG_Init_From_Deck, CD_AGG_NMovingPoints, & CD_AGG_UpdateStates_Moving, CD_AGG_Step_Moving, CD_AGG_CalcOutput, & CD_AGG_GetMovingPointMesh, CD_AGG_End, CD_AGG_IsInitialized, CD_AGG_OK, & CD_AGG_BADINPUT, CD_AGG_NOT_INITIALIZED, CD_AGG_ALLOCFAIL, & - CD_AGG_NFluidNodes, CD_AGG_GetFluidNodePositions, CD_AGG_SetFluidFields + CD_AGG_NFluidNodes, CD_AGG_GetFluidNodePositions, CD_AGG_SetFluidFields, & + CD_AGG_GetInitMetadata, CD_AGG_GetInitLine USE CableDyn_Dynamic, ONLY: GenAlphaConfig USE CableDyn_Linalg, ONLY: CD_Blas_Runtime_Check, CD_LINALG_OK USE CableDyn_Model, ONLY: CD_ModelType, CD_Init_Model, CD_End_Model, CD_MODEL_OK, CD_MODEL_BADINPUT, & @@ -50,6 +52,7 @@ MODULE CableDyn_CAPI PUBLIC :: CableDyn_CalcOutputDerivatives PUBLIC :: CableDyn_GetCoupledMotion PUBLIC :: CableDyn_GetLastError + PUBLIC :: CableDyn_GetInitNotes PUBLIC :: CableDyn_GetVersion PUBLIC :: CableDyn_GetVersionString PUBLIC :: CableDyn_IsInitialized @@ -198,6 +201,8 @@ END FUNCTION cabledyn_blas_threads_once REAL(wp) :: saved_agg_t = CD_ZERO REAL(wp), ALLOCATABLE :: saved_dcm(:, :, :), saved_om(:, :), saved_al(:, :) CHARACTER(CD_C_MSG_LEN) :: last_error = '' + ! The notes of the last initialisation (CableDyn_GetInitNotes), each ended by a new line. + CHARACTER(:), ALLOCATABLE :: init_notes END TYPE CD_C_Handle CONTAINS @@ -325,6 +330,7 @@ SUBROUTINE init_deck_at(handle, deck_path_ptr, deck_path_len, q0_ptr, n_q0, call RETURN END IF IF (n_q0 > 0_C_INT .AND. .NOT. C_ASSOCIATED(q0_ptr)) THEN + h%init_notes = '' CALL set_status(h, CD_C_BAD_INPUT, caller//': q0 pointer is null', err_stat) RETURN END IF @@ -343,6 +349,7 @@ SUBROUTINE init_deck_at(handle, deck_path_ptr, deck_path_len, q0_ptr, n_q0, call IF (n_q0 > 0_C_INT) THEN CALL C_F_POINTER(q0_ptr, q0_c, [INT(n_q0)]) IF (.NOT. ALL(IEEE_IS_FINITE(q0_c))) THEN + h%init_notes = '' CALL set_status(h, CD_C_BAD_INPUT, caller//': q0 must be finite', err_stat) RETURN END IF @@ -354,9 +361,27 @@ SUBROUTINE init_deck_at(handle, deck_path_ptr, deck_path_len, q0_ptr, n_q0, call END SUBROUTINE init_deck_at SUBROUTINE init_deck_serialised(handle, deck_path_ptr, deck_path_len, caller, err_stat, positions, rotating) - !! CableDyn_InitDeck / CableDyn_InitDeckAt / CableDyn_InitDeckRotating body; the caller holds - !! the process-wide input lock. positions (3, n): the host positions of CableDyn_InitDeckAt; - !! rotating: the handle takes the host's rotations (CableDyn_InitDeckRotating). + !! init_deck_build with the notes of the attempt kept on the handle, on success or failure; the + !! caller holds the process-wide input lock, which the capture of the notes relies on. + TYPE(C_PTR), VALUE :: handle, deck_path_ptr + INTEGER(C_INT), VALUE :: deck_path_len + CHARACTER(*), INTENT(IN) :: caller + INTEGER(C_INT), INTENT(OUT) :: err_stat + REAL(wp), INTENT(IN), OPTIONAL :: positions(:, :) + LOGICAL, INTENT(IN), OPTIONAL :: rotating + TYPE(CD_C_Handle), POINTER :: h + CHARACTER(:), ALLOCATABLE :: notes + + CALL CD_Init_Notes_Start() + CALL init_deck_build(handle, deck_path_ptr, deck_path_len, caller, err_stat, positions, rotating) + CALL CD_Init_Notes_Take(notes) + IF (validate_handle(handle, h) == CD_C_OK) CALL MOVE_ALLOC(notes, h%init_notes) + END SUBROUTINE init_deck_serialised + + SUBROUTINE init_deck_build(handle, deck_path_ptr, deck_path_len, caller, err_stat, positions, rotating) + !! CableDyn_InitDeck / CableDyn_InitDeckAt / CableDyn_InitDeckRotating body. positions (3, n): + !! the host positions of CableDyn_InitDeckAt; rotating: the handle takes the host's rotations + !! (CableDyn_InitDeckRotating). TYPE(C_PTR), VALUE :: handle, deck_path_ptr INTEGER(C_INT), VALUE :: deck_path_len CHARACTER(*), INTENT(IN) :: caller @@ -439,7 +464,7 @@ SUBROUTINE init_deck_serialised(handle, deck_path_ptr, deck_path_len, caller, er h%host_rot = rot CALL store_status(h, CD_C_OK, '') err_stat = CD_C_OK - END SUBROUTINE init_deck_serialised + END SUBROUTINE init_deck_build SUBROUTINE check_point_start(fast, positions, msg) !! Post-condition of CableDyn_InitDeckAt on the point route: every Coupled block of the new @@ -575,6 +600,7 @@ SUBROUTINE init_deck_aggregate(h, deck_path, has_dtm, dtm, caller, rotating, err RETURN END IF END IF + CALL add_aggregate_notes(new_agg) CALL CD_FAST_End(h%fast, es, em) CALL drop_aggregate(h) CALL drop_saved_state(h) @@ -608,6 +634,25 @@ SUBROUTINE init_deck_aggregate(h, deck_path, has_dtm, dtm, caller, rotating, err err_stat = CD_C_OK END SUBROUTINE init_deck_aggregate + SUBROUTINE add_aggregate_notes(agg) + !! The notes of the lines of a new aggregate, to the notes of its initialisation. + TYPE(CD_AGG_ModuleType), INTENT(IN) :: agg + INTEGER :: i, es, n_lines, n_points, n_sections, n_ei0, n_finite, line_id + REAL(wp) :: tension, force(3), inclination, declination, azimuth + CHARACTER(CD_C_MSG_LEN) :: em + CHARACTER(CD_INIT_NOTE_LEN) :: note + + CALL CD_AGG_GetInitMetadata(agg, n_lines, n_points, n_sections, n_ei0, n_finite, es, em) + IF (es == CD_AGG_OK) THEN + DO i = 1, n_lines + CALL CD_AGG_GetInitLine(agg, i, line_id, tension, force, inclination, declination, azimuth, es, em, note=note) + IF (es /= CD_AGG_OK) EXIT + CALL CD_Init_Notes_Add(note) + END DO + END IF + IF (es /= CD_AGG_OK) CALL CD_Init_Notes_Add('the initialisation notes are incomplete: '//TRIM(em)) + END SUBROUTINE add_aggregate_notes + SUBROUTINE move_aggregate(src, dst) !! Take over an initialized aggregate (deep copy, then release the source). TYPE(CD_AGG_ModuleType), INTENT(INOUT) :: src, dst @@ -742,6 +787,7 @@ SUBROUTINE init_line_serialised(handle, n_nodes, n_elem, q0_ptr, v0_ptr, elem_co err_stat = validate_handle(handle, h) IF (err_stat /= CD_C_OK) RETURN + h%init_notes = '' IF (n_nodes < 2_C_INT .OR. n_elem < 1_C_INT .OR. n_fixed < 0_C_INT) THEN CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_InitLine: invalid node, element, or fixed-DOF count', err_stat) RETURN @@ -870,6 +916,7 @@ SUBROUTINE init_lines_serialised(handle, n_lines, n_nodes_ptr, n_elem_ptr, n_fix NULLIFY (fixed_c, map_c) err_stat = validate_handle(handle, h) IF (err_stat /= CD_C_OK) RETURN + h%init_notes = '' IF (n_lines < 1_C_INT .OR. .NOT. (C_ASSOCIATED(n_nodes_ptr) .AND. C_ASSOCIATED(n_elem_ptr) .AND. & C_ASSOCIATED(n_fixed_ptr))) THEN CALL set_status(h, CD_C_BAD_INPUT, 'CableDyn_InitLines: invalid line count or null count pointer', err_stat) @@ -1543,6 +1590,33 @@ SUBROUTINE CableDyn_GetLastError(handle, message_ptr, message_len) BIND(C, NAME= CALL copy_message_to_c(TRIM(msg), message_c) END SUBROUTINE CableDyn_GetLastError + FUNCTION CableDyn_GetInitNotes(handle, notes_ptr, notes_len) RESULT(needed) BIND(C, NAME='CableDyn_GetInitNotes') + !! Copy the notes of the last deck initialisation on the handle, one per line, into a C-owned + !! character buffer, under the conventions of CableDyn_GetLastError: always null-terminated + !! when `notes_len > 0` and `notes_ptr` is associated, and truncated to notes_len - 1. Returns + !! the length of the whole text without the terminator, so a null buffer asks for the size; + !! -1 on a bad handle. + TYPE(C_PTR), VALUE :: handle, notes_ptr + INTEGER(C_INT), VALUE :: notes_len + INTEGER(C_INT) :: needed + + TYPE(CD_C_Handle), POINTER :: h + CHARACTER(KIND=C_CHAR), POINTER :: notes_c(:) + LOGICAL :: has_buffer + + has_buffer = notes_len >= 1_C_INT .AND. C_ASSOCIATED(notes_ptr) + IF (has_buffer) THEN + CALL C_F_POINTER(notes_ptr, notes_c, [INT(notes_len)]) + notes_c = C_NULL_CHAR + END IF + needed = -1_C_INT + IF (validate_handle(handle, h) /= CD_C_OK) RETURN + needed = 0_C_INT + IF (.NOT. ALLOCATED(h%init_notes)) RETURN + needed = INT(LEN(h%init_notes), C_INT) + IF (has_buffer) CALL copy_message_to_c(h%init_notes, notes_c) + END FUNCTION CableDyn_GetInitNotes + SUBROUTINE CableDyn_Close(handle, err_stat) BIND(C, NAME='CableDyn_Close') !! Release a CableDyn C handle. A null handle is an idempotent no-op !! (CD_C_OK). A non-null pointer that is not a live CableDyn handle (stale, diff --git a/src/CableDyn_CAPI.h b/src/CableDyn_CAPI.h index 1bb4a7e..7f46458 100644 --- a/src/CableDyn_CAPI.h +++ b/src/CableDyn_CAPI.h @@ -106,6 +106,17 @@ void CableDyn_GetCoupledMotion(void *handle, double *q_coupled, void CableDyn_GetLastError(void *handle, char *message, int message_len); +/* ABI 1, minor extension 4. The notes of the last deck initialisation on the + * handle, successful or not: 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 buffer follows CableDyn_GetLastError: always NUL-terminated when + * notes_len > 0 and notes is not NULL, truncated to notes_len - 1. Returns the + * length of the whole text without the terminator, so a NULL buffer asks for + * the size; 0 without notes (a raw-line initialisation has none) and -1 on a + * bad handle. The notes take what is known at initialisation: the deck's + * current, not a field a host applies later. */ +int CableDyn_GetInitNotes(void *handle, char *notes, int notes_len); + bool CableDyn_IsInitialized(void *handle); int CableDyn_NCoupledDOF(void *handle, int *err_stat); /* Stored system point count: the column count CableDyn_UpdatePointFluidFields @@ -127,8 +138,8 @@ int CableDyn_NLines(void *handle, int *err_stat); * aggregate (it needs dtM); other decks keep the point route. Check * CableDyn_GetAbiMinor() >= 1 before using these on a library of unknown * vintage. CABLEDYN_CAPI_ABI_MINOR is the extension level this header - * declares (4: the host-coupling, host-rotation and host fluid calls below are - * present too). */ + * declares (4: the host-coupling, host-rotation and host fluid calls below and + * CableDyn_GetInitNotes above are present too). */ #define CABLEDYN_CAPI_ABI_MINOR 4 #define CD_C_OBJ_LINE 1 diff --git a/src/CableDyn_DeckDriver.f90 b/src/CableDyn_DeckDriver.f90 index 303dbec..4921613 100644 --- a/src/CableDyn_DeckDriver.f90 +++ b/src/CableDyn_DeckDriver.f90 @@ -99,6 +99,7 @@ MODULE CableDyn_DeckDriver CD_HermiteCable_Axial_Damping_Element, CD_HermiteCable_Dyn_End_Force, & CD_HermiteCable_Dyn_Node_Values, CD_HermiteCable_Dyn_Node_Recovery, & CD_HermiteCable_Dyn_Side_EI, CD_HermiteCable_Dyn_Side_Curvature, & + CD_HermiteCable_Dyn_On_Seabed, & CD_HermiteCable_Dyn_Set_Output_Recovery, & CD_HermiteCable_Dyn_Set_ForceBlend, CD_HC_FORCE_BLEND_DEFAULT, & CD_HermiteCable_Dyn_Set_Friction_Anchors, & @@ -197,6 +198,7 @@ MODULE CableDyn_DeckDriver REAL(wp), PARAMETER :: STF_ARC_TOL = 1.0e-9_wp PUBLIC :: CD_Run_Deck_Driver + PUBLIC :: CD_Init_Notes_Start, CD_Init_Notes_Add, CD_Init_Notes_Take PUBLIC :: CD_Deck_Equilibrium_Jacobian_Check PUBLIC :: CD_Init_Deck_Models PUBLIC :: CD_Init_Deck_System @@ -280,6 +282,11 @@ MODULE CableDyn_DeckDriver INTEGER, SAVE :: body_static_pass_limit = 0 INTEGER, PARAMETER, PUBLIC :: CD_MB_PROBE_NCOL = 56 LOGICAL, SAVE :: probe_armed = .FALSE. + ! The notes of one initialisation for an in-process caller (CD_Init_Notes_Start to + ! CD_Init_Notes_Take), one per text line. Module state: the caller holds its process-wide input + ! lock over the whole initialisation. + LOGICAL, SAVE :: capture_notes = .FALSE. + CHARACTER(:), ALLOCATABLE, SAVE :: captured_notes REAL(wp), SAVE :: probe_v0(3) = 0.0_wp, probe_w0(3) = 0.0_wp REAL(wp), ALLOCATABLE, SAVE :: probe_rec(:, :) INTEGER, PARAMETER, PUBLIC :: CD_DECKDRV_OK = 0, CD_DECKDRV_BADINPUT = 1, CD_DECKDRV_SOLVEFAIL = 2 @@ -1160,6 +1167,9 @@ MODULE CableDyn_DeckDriver ! anchor), the fold note of an EI = 0 line (ei0_fold_note), and the static strain note and ! the dtM advisory (axial_period_note) of any line; empty when there is nothing to report. CHARACTER(CD_INIT_NOTE_LEN) :: note = '' + ! seabed_drift: a current moves the line along a seabed without friction (seabed_drift_note); + ! friction_refused: the line's route rejects frictionMu (FOLD_FRICTION_NONE). + LOGICAL :: seabed_drift = .FALSE., friction_refused = .FALSE. END TYPE CD_LineInitSummary TYPE :: CD_DeckAggregateType @@ -1453,8 +1463,161 @@ SUBROUTINE CD_Init_Deck_System(deck_path, system, dtM, gravity, rho_water, ErrSt ALLOCATE (line_ids(SIZE(lines))) line_ids = lines(:)%id END IF + IF (capture_notes .AND. ErrStat == CD_DECKDRV_OK) THEN + CALL deck_start_notes(lines, channels, opts, .TRUE., .FALSE.) + CALL capture_system_notes(lines, points, sections, types, opts, system, dtM) + END IF END SUBROUTINE CD_Init_Deck_System + SUBROUTINE deck_start_notes(lines, channels, opts, marched, printed) + !! The notes of a deck read for a run: printed as the driver prints them, or only captured for + !! an in-process caller, whose library prints nothing new. marched: the run takes time steps. + TYPE(DeckLine), INTENT(IN) :: lines(:) + CHARACTER(NAMELEN), INTENT(IN) :: channels(:) + TYPE(DeckOptions), INTENT(IN) :: opts + LOGICAL, INTENT(IN) :: marched, printed + CHARACTER(8) :: rho + INTEGER :: j + + IF (ANY([(CD_Is_Line_Node_Channel(channels(j)), j=1, SIZE(channels))]) .OR. & + ANY([(line_outputs_requested(lines(j)%outputs), j=1, SIZE(lines))])) & + CALL start_note(CD_LINE_NODE_NUMBERING_NOTE//'.') + ! Torsion (a line torsionally restrained at both ends, or at one with the other free to + ! twist) runs on the standalone cubic-Hermite route, the multibody march and the mixed + ! EI = 0 + finite-EI aggregate, in statics and in dynamics with the nodal twist's polar + ! inertia and the torque-tension coupling. A line restrained at one end only whose torsion + ! is not solved (resolve_one_end_torsion) is noted with the reason. + DO j = 1, SIZE(lines) + IF (COUNT(lines(j)%tors_mode /= TORS_FREE) == 1 .AND. lines(j)%tors_free_end == 0 .AND. & + LEN_TRIM(lines(j)%tors_note) > 0) CALL start_note('line '// & + TRIM(int_to_str(lines(j)%id))//' is torsionally restrained at one end only and its torsion is not '// & + 'solved: '//TRIM(lines(j)%tors_note)//'.') + END DO + ! rhoInf near 1 leaves the march without numerical dissipation of the modes dtM does not + ! resolve (axial waves, slack-taut transitions). In an irregular sea a finite-EI lazy wave + ! at rhoInf = 1 with a 1e-4 Newton tolerance peaked at 2e5 N against 1.02e4 N (3e4 N at + ! 0.99), and was exact at 1e-6; EI=0 lines at rhoInf = 1 and 1e-4 failed the plausibility + ! guard, and at the default tolerance their peaks rose by factors of 2 to 5 at rhoInf >= 0.95. + IF (marched .AND. opts%rho_inf > RHO_INF_NOTE) THEN + WRITE (rho, '(F5.3)') opts%rho_inf + CALL start_note('rhoInf = '//TRIM(rho)//' leaves almost no numerical '// & + 'dissipation: modes dtM does not resolve (axial waves, slack-taut transitions) keep the '// & + 'residual a Newton step leaves and the energy of under-resolved snaps, which can raise '// & + 'tensions several times. Use rhoInf <= 0.95 (default 0.4), or a relative dynamic_solver '// & + 'tolerance of 1e-6 or tighter and a dtM that resolves the snaps.') + END IF + + CONTAINS + + SUBROUTINE start_note(text) + CHARACTER(*), INTENT(IN) :: text + IF (printed) THEN + CALL print_note(output_unit, ' ', text) + ELSE + CALL capture_note(text) + END IF + END SUBROUTINE start_note + END SUBROUTINE deck_start_notes + + SUBROUTINE print_note(unit, lead, text) + !! Print one note at its indent, and capture it for an in-process caller. + INTEGER, INTENT(IN) :: unit + CHARACTER(*), INTENT(IN) :: lead, text + WRITE (unit, '(A)') lead//'Note: '//text + CALL capture_note(text) + END SUBROUTINE print_note + + SUBROUTINE capture_note(text) + !! Keep one note of the initialisation under way, ended by a full stop as it is printed. A + !! deck read twice (the route scan, then the build) repeats the notes of its parse: kept once. + CHARACTER(*), INTENT(IN) :: text + CHARACTER(:), ALLOCATABLE :: note + INTEGER :: n + + IF (.NOT. capture_notes) RETURN + n = LEN_TRIM(text) + IF (n == 0) RETURN + note = text(1:n) + IF (text(n:n) /= '.') note = note//'.' + !$OMP CRITICAL (cabledyn_init_notes) + IF (INDEX(NEW_LINE('a')//captured_notes, NEW_LINE('a')//note//NEW_LINE('a')) == 0) & + captured_notes = captured_notes//note//NEW_LINE('a') + !$OMP END CRITICAL (cabledyn_init_notes) + END SUBROUTINE capture_note + + SUBROUTINE CD_Init_Notes_Start() + !! Capture the notes of the initialisation that follows. The caller holds its process-wide + !! input lock until CD_Init_Notes_Take. + capture_notes = .TRUE. + captured_notes = '' + END SUBROUTINE CD_Init_Notes_Start + + SUBROUTINE CD_Init_Notes_Add(text) + !! Add the notes of a line summary, one per text line, to the initialisation under way. + CHARACTER(*), INTENT(IN) :: text + INTEGER :: a, b, n + + n = LEN_TRIM(text) + a = 1 + DO WHILE (a <= n) + b = INDEX(text(a:n), NEW_LINE('a')) + IF (b == 0) THEN + CALL capture_note(text(a:n)) + RETURN + END IF + CALL capture_note(text(a:a + b - 2)) + a = a + b + END DO + END SUBROUTINE CD_Init_Notes_Add + + SUBROUTINE CD_Init_Notes_Take(notes) + !! End the capture: the notes of the initialisation, each ended by a new line. + CHARACTER(:), ALLOCATABLE, INTENT(OUT) :: notes + notes = captured_notes + capture_notes = .FALSE. + captured_notes = '' + END SUBROUTINE CD_Init_Notes_Take + + SUBROUTINE capture_system_notes(lines, points, sections, types, opts, system, dt) + !! The notes of a point system built for an in-process caller, who marches it: those a run of + !! the deck prints in its initialisation report (print_deck_init_report). dt: the deck's own + !! step, zero without one. A note that cannot be worked out does not stop the initialisation: + !! the notes then say so. + TYPE(DeckLine), INTENT(IN) :: lines(:) + TYPE(DeckPoint), INTENT(IN) :: points(:) + TYPE(DeckSection), INTENT(IN) :: sections(:) + TYPE(DeckLineType), INTENT(IN) :: types(:) + TYPE(DeckOptions), INTENT(IN) :: opts + TYPE(CD_SystemType), INTENT(IN) :: system + REAL(wp), INTENT(IN) :: dt + TYPE(CD_LineInitSummary), ALLOCATABLE :: summaries(:) + CHARACTER(:), ALLOCATABLE :: drift_note + CHARACTER(1024) :: note + INTEGER :: i, es + + CALL collect_system_init_summaries(lines, points, system, summaries, es, note, opts=opts, & + friction_support=MERGE(FOLD_FRICTION_NONE, FOLD_FRICTION_FULL, & + deck_has_dynamic_points(points))) + IF (es /= CD_DECKDRV_OK) THEN + CALL capture_note('the initialisation notes are incomplete: '//TRIM(note)) + RETURN + END IF + DO i = 1, SIZE(summaries) + CALL CD_Init_Notes_Add(summaries(i)%note) + CALL static_strain_note(summaries(i)%line_id, summaries(i)%fair_tension, sections, types, opts, note) + CALL capture_note(note) + IF (dt > CD_ZERO) THEN + CALL axial_period_note(summaries(i)%line_id, dt, sections, types, note) + CALL capture_note(note) + END IF + END DO + IF (ANY(summaries%seabed_drift)) THEN + CALL seabed_drift_note(PACK(summaries%line_id, summaries%seabed_drift), & + ANY(summaries%seabed_drift .AND. summaries%friction_refused), drift_note) + CALL capture_note(drift_note) + END IF + END SUBROUTINE capture_system_notes + SUBROUTINE init_deck_models_from_parsed(types, points, lines, sections, opts, models, ErrStat, ErrMsg, bathymetry) !! Initialize persistent line models from an already parsed and validated deck. TYPE(DeckLineType), INTENT(IN) :: types(:) @@ -8345,6 +8508,29 @@ SUBROUTINE CD_Init_Deck_Aggregate(deck_path, dt, agg, ErrStat, ErrMsg, env_gravi END DO END BLOCK END IF + IF (capture_notes) CALL deck_start_notes(lines, channels, opts,.NOT. static_only, .FALSE.) + ! One note for the run, on the first line it names (every route that prints the notes of this + ! summary shows it): opts_static knows the host's steady current, a current the host brings + ! in later is unknown here. A run that marches no step does not drift. + IF (.NOT. static_only) THEN + BLOCK + CHARACTER(:), ALLOCATABLE :: drift_note + DO c = 1, SIZE(lines) + IF (agg%line_is_cable(lines(c)%id)) THEN + agg%init_lines(c)%seabed_drift = cable_seabed_drift(opts_static, & + agg%cables(agg%line_obj_index(lines(c)%id))) + ELSE IF (agg%has_sys) THEN + agg%init_lines(c)%seabed_drift = model_seabed_drift(opts_static, & + agg%sys%lines(agg%line_obj_index(lines(c)%id))) + END IF + END DO + IF (ANY(agg%init_lines%seabed_drift)) THEN + CALL seabed_drift_note(PACK(agg%init_lines%line_id, agg%init_lines%seabed_drift), & + deck_has_dynamic_points(points), drift_note) + CALL append_note_line(agg%init_lines(FINDLOC(agg%init_lines%seabed_drift, .TRUE., 1))%note, drift_note) + END IF + END BLOCK + END IF ! A static strain above the note level is named in the line's initialisation note (every route that ! prints the notes of this summary shows it), and the standalone mixed route -- the caller that ! takes the deck's own current -- also warns about coarse sections, as the single-topology driver does. @@ -9305,32 +9491,7 @@ SUBROUTINE run_deck_driver(deck_path, out_root, converged, ErrStat, ErrMsg) CALL parse_deck(deck_path, types, bodies, rod_types, rods, points, lines, sections, opts, channels, & ErrStat, ErrMsg, failures=fails) IF (ErrStat /= 0) RETURN - IF (ANY([(CD_Is_Line_Node_Channel(channels(j)), j=1, SIZE(channels))]) .OR. & - ANY([(line_outputs_requested(lines(j)%outputs), j=1, SIZE(lines))])) & - WRITE (output_unit, '(A)') ' Note: '//CD_LINE_NODE_NUMBERING_NOTE//'.' - ! Torsion (a line torsionally restrained at both ends, or at one with the other free to - ! twist) runs on the standalone cubic-Hermite route, the multibody march and the mixed - ! EI = 0 + finite-EI aggregate, in statics and in dynamics with the nodal twist's polar - ! inertia and the torque-tension coupling. A line restrained at one end only whose torsion - ! is not solved (resolve_one_end_torsion) is noted with the reason. - DO li = 1, SIZE(lines) - IF (COUNT(lines(li)%tors_mode /= TORS_FREE) == 1 .AND. lines(li)%tors_free_end == 0 .AND. & - LEN_TRIM(lines(li)%tors_note) > 0) WRITE (output_unit, '(A)') ' Note: line '// & - TRIM(int_to_str(lines(li)%id))//' is torsionally restrained at one end only and its torsion is not '// & - 'solved: '//TRIM(lines(li)%tors_note)//'.' - END DO - ! rhoInf near 1 leaves the march without numerical dissipation of the modes dtM does not - ! resolve (axial waves, slack-taut transitions). In an irregular sea a finite-EI lazy wave - ! at rhoInf = 1 with a 1e-4 Newton tolerance peaked at 2e5 N against 1.02e4 N (3e4 N at - ! 0.99), and was exact at 1e-6; EI=0 lines at rhoInf = 1 and 1e-4 failed the plausibility - ! guard, and at the default tolerance their peaks rose by factors of 2 to 5 at rhoInf >= 0.95. - IF (opts%has_dtm .AND. opts%has_tmax .AND. opts%tmax > CD_ZERO .AND. opts%rho_inf > RHO_INF_NOTE) THEN - WRITE (output_unit, '(A,F5.3,A)') ' Note: rhoInf = ', opts%rho_inf, ' leaves almost no numerical '// & - 'dissipation: modes dtM does not resolve (axial waves, slack-taut transitions) keep the residual a '// & - 'Newton step leaves and the energy of under-resolved snaps, which can raise tensions several times. '// & - 'Use rhoInf <= 0.95 (default 0.4), or a relative dynamic_solver tolerance of 1e-6 or tighter and a '// & - 'dtM that resolves the snaps.' - END IF + CALL deck_start_notes(lines, channels, opts, opts%has_dtm .AND. opts%has_tmax .AND. opts%tmax > CD_ZERO, .TRUE.) IF (ANY([(line_torsion_active(lines(li)), li=1, SIZE(lines))])) THEN IF (opts%has_tmax .AND. opts%tmax > CD_ZERO .AND. .NOT. opts%alpha_force_blend) THEN CALL fail(ErrStat, ErrMsg, TORSION_NEEDS_FORCE_BLEND); RETURN @@ -9492,7 +9653,8 @@ SUBROUTINE run_deck_driver(deck_path, out_root, converged, ErrStat, ErrMsg) IF (ErrStat /= CD_DECKDRV_OK) EXIT END DO IF (ErrStat == CD_DECKDRV_OK) THEN - CALL collect_hermite_init_summaries(lines, hermite_models, init_summaries, ErrStat, ErrMsg) + CALL collect_hermite_init_summaries(lines, hermite_models, init_summaries, opts, FOLD_FRICTION_FULL, & + ErrStat, ErrMsg) END IF IF (ErrStat == CD_DECKDRV_OK .AND. opts%n_modes > 0) & CALL write_deck_modes_hermite(out_root, lines, opts, hermite_models, ErrStat, ErrMsg) @@ -12412,7 +12574,8 @@ END SUBROUTINE ei0_fold_note SUBROUTINE add_model_fold_notes(lines, points, opts, models, summaries, friction_support) !! ei0_fold_note of every EI = 0 line model (index-aligned with lines), appended to the notes - !! of its summary; friction_support as in ei0_fold_note. + !! of its summary, and whether a current moves the line along the seabed (seabed_drift_note); + !! friction_support as in ei0_fold_note. TYPE(DeckLine), INTENT(IN) :: lines(:) TYPE(DeckPoint), INTENT(IN) :: points(:) TYPE(DeckOptions), INTENT(IN) :: opts @@ -12426,9 +12589,80 @@ SUBROUTINE add_model_fold_notes(lines, points, opts, models, summaries, friction CALL ei0_fold_note(lines(i), points, opts, models(i)%q, fold_note, model=models(i), & friction_support=friction_support) IF (LEN_TRIM(fold_note) > 0) CALL append_note_line(summaries(i)%note, fold_note) + summaries(i)%seabed_drift = model_seabed_drift(opts, models(i)) + summaries(i)%friction_refused = friction_support == FOLD_FRICTION_NONE END DO END SUBROUTINE add_model_fold_notes + LOGICAL FUNCTION model_seabed_drift(opts, model) RESULT(drifts) + !! Whether a current moves this EI = 0 line along the seabed: no seabed friction, an interior + !! node in contact in the model's present state (inside the reach of the normal contact law; + !! the end nodes, held or on a point, do not count), and a current on the line. + TYPE(DeckOptions), INTENT(IN) :: opts + TYPE(CD_ModelType), INTENT(IN) :: model + REAL(wp) :: z_floor, gx, gy + INTEGER :: k, es + CHARACTER(160) :: em + + drifts = .FALSE. + IF (opts%has_friction .OR. .NOT. model%has_seabed) RETURN + DO k = 2, SIZE(model%q)/3 - 1 + z_floor = model%seabed_z_floor + gx = CD_ZERO + gy = CD_ZERO + IF (model%has_bathymetry) THEN + CALL CD_Bathymetry_Floor_Gradient(model%bathymetry, model%q(3*k - 2), model%q(3*k - 1), z_floor, gx, gy, & + es, em) + IF (es /= CD_BATHY_OK) CYCLE + END IF + IF (z_floor - model%q(3*k) > -CD_SEABED_CONTACT_BLEND*SQRT(CD_ONE + gx*gx + gy*gy)) THEN + drifts = deck_current_on_line(opts, model%q(3::3)) + RETURN + END IF + END DO + END FUNCTION model_seabed_drift + + LOGICAL FUNCTION cable_seabed_drift(opts, cable) RESULT(drifts) + !! model_seabed_drift of a finite-EI cable. + TYPE(DeckOptions), INTENT(IN) :: opts + TYPE(CD_HFMF_ModuleType), INTENT(IN) :: cable + drifts = .FALSE. + IF (opts%has_friction) RETURN + IF (CD_HermiteCable_Dyn_On_Seabed(cable%line)) drifts = deck_current_on_line(opts, cable%line%q(3::6)) + END FUNCTION cable_seabed_drift + + SUBROUTINE seabed_drift_note(ids, friction_refused, note) + !! The note of a dynamic run whose current moves grounded lines along a seabed without + !! friction, which nothing in the run reports: one note for the run, naming the lines (the + !! first MAX_NAMED of them, so that it fits a line summary's notes) and the remedy. + INTEGER, INTENT(IN) :: ids(:) + LOGICAL, INTENT(IN) :: friction_refused + CHARACTER(:), ALLOCATABLE, INTENT(OUT) :: note + INTEGER, PARAMETER :: MAX_NAMED = 40 + CHARACTER(:), ALLOCATABLE :: names, subject, object + INTEGER :: i + + names = TRIM(int_to_str(ids(1))) + DO i = 2, MIN(SIZE(ids), MAX_NAMED) + names = names//', '//TRIM(int_to_str(ids(i))) + END DO + IF (SIZE(ids) > MAX_NAMED) names = names//' and '//TRIM(int_to_str(SIZE(ids) - MAX_NAMED))//' more' + IF (SIZE(ids) == 1) THEN + subject = 'line '//names//' lies' + object = 'it' + ELSE + subject = 'lines '//names//' lie' + object = 'them' + END IF + note = subject//' on the seabed at the static start with no seabed friction: nothing holds the grounded '// & + 'nodes against the current, so the layout can drift along the seabed through the run. ' + IF (friction_refused) THEN + note = note//'Seabed friction (frictionMu), which would hold '//object//', is not supported on this deck' + ELSE + note = note//'Set frictionMu to hold '//object + END IF + END SUBROUTINE seabed_drift_note + LOGICAL FUNCTION deck_current_on_line(opts, z) RESULT(on) !! Whether the deck current is non-zero at any of the node elevations z: its drag on the !! suspended span pulls a grounded run as well as its drag on the run itself, so a current @@ -13921,9 +14155,10 @@ SUBROUTINE read_wave_elevation_components(path, opts, dt_wave, wave_dir, fft_tma END IF n_window = CEILING(window/dt) n = padded_fft_size(n_window) - IF (n > LONG_WAVE_RECORD) WRITE (error_unit, '(A)') ' Note: reducing the WaterKin wave record ('// & - TRIM(int_to_str(n))//' samples) to Fourier components; this one-time setup grows with the square '// & - 'of the sample count (a larger dtWave shortens it).' + IF (n > LONG_WAVE_RECORD) CALL print_note(error_unit, ' ', 'reducing the WaterKin wave record ('// & + TRIM(int_to_str(n))//' samples) to Fourier components; this one-time '// & + 'setup grows with the square of the sample count (a larger dtWave '// & + 'shortens it).') ALLOCATE (sample_eta(n)) sample_eta = CD_ZERO bracket = 1 @@ -14112,9 +14347,10 @@ SUBROUTINE read_moordyn_c_kinematics(dir, opts, ErrStat, ErrMsg) END IF nt = FLOOR(tl/dt) + 1 IF (MOD(nt, 2) /= 0) nt = nt - 1 - IF (nt > LONG_WAVE_RECORD) WRITE (error_unit, '(A)') ' Note: reducing wave_elevation.txt ('// & - TRIM(int_to_str(nt))//' samples) to Fourier components; this one-time setup grows with the square '// & - 'of the sample count (a larger dtWave shortens it).' + IF (nt > LONG_WAVE_RECORD) CALL print_note(error_unit, ' ', 'reducing wave_elevation.txt ('// & + TRIM(int_to_str(nt))//' samples) to Fourier components; this '// & + 'one-time setup grows with the square of the sample count (a '// & + 'larger dtWave shortens it).') IF (nt < 4) THEN CALL fail(ErrStat, ErrMsg, 'WaveKin 3: wave_elevation.txt spans fewer than four dtWave samples'); RETURN END IF @@ -14239,11 +14475,12 @@ SUBROUTINE read_moordyn_c_kinematics(dir, opts, ErrStat, ErrMsg) ! past its last sample MoorDyn-C's grid repeats (CD_Current_Grid_Velocity) IF (opts%has_tmax .AND. opts%current_grid%nt > 1) THEN tl = REAL(opts%current_grid%nt - 1, wp)*opts%current_grid%dt - IF (opts%tmax > tl) WRITE (error_unit, '(A)') ' Note: Currents '//TRIM(int_to_str(opts%mdc_currents))// & - ': TMax outlasts the last sample of '//TRIM(fname)//' (t = '//TRIM(real_to_str(tl))//' s); as in '// & - 'MoorDyn-C the current then repeats with period '// & - TRIM(real_to_str(REAL(opts%current_grid%nt, wp)*opts%current_grid%dt))// & - ' s, holding its t = 0 value over the last interval of each period.' + IF (opts%tmax > tl) CALL print_note(error_unit, ' ', 'Currents '//TRIM(int_to_str(opts%mdc_currents))// & + ': TMax outlasts the last sample of '//TRIM(fname)//' (t = '// & + TRIM(real_to_str(tl))//' s); as in MoorDyn-C the current then repeats '// & + 'with period '// & + TRIM(real_to_str(REAL(opts%current_grid%nt, wp)*opts%current_grid%dt))// & + ' s, holding its t = 0 value over the last interval of each period.') END IF END IF @@ -22173,7 +22410,9 @@ SUBROUTINE write_multibody_dynamic_out(deck_path, out_root, channels, types, bod IF (ErrStat /= CD_DECKDRV_OK) GOTO 900 END DO IF (SIZE(cab_lines) > 0) THEN - CALL collect_hermite_init_summaries(cab_lines, mb%cables, sum_cab, ErrStat, ErrMsg) + CALL collect_hermite_init_summaries(cab_lines, mb%cables, sum_cab, opts, & + MERGE(FOLD_FRICTION_NONE, FOLD_FRICTION_NO_AS_LAID, & + deck_has_non_rigid6_dynamic_points(points, bodies)), ErrStat, ErrMsg) IF (ErrStat /= CD_DECKDRV_OK) GOTO 900 ELSE ALLOCATE (sum_cab(0)) @@ -26863,8 +27102,8 @@ SUBROUTINE solve_body_equilibrium_core(types, bodies, rod_types, rods, points, l IF (silent) EXIT IF (.NOT. lmove(j)) CYCLE IF (.NOT. line_is_finite_ei(lines(j), types, sections)) CYCLE - WRITE (output_unit, '(A)') ' Note: the body/rod/point static solve treats finite-EI LINE '// & - TRIM(int_to_str(lines(j)%id))//' as a cable without bending stiffness.' + CALL print_note(output_unit, ' ', 'the body/rod/point static solve treats finite-EI LINE '// & + TRIM(int_to_str(lines(j)%id))//' as a cable without bending stiffness.') END DO ALLOCATE (off(nobj), ndof(nobj), lsc(nobj), base_r(3, nobj), base_rot(3, 3, nobj), e1(3, nobj), e2(3, nobj)) @@ -36569,10 +36808,13 @@ SUBROUTINE collect_system_init_summaries(lines, points, system, summaries, ErrSt END IF END SUBROUTINE collect_system_init_summaries - SUBROUTINE collect_hermite_init_summaries(lines, cables, summaries, ErrStat, ErrMsg) + SUBROUTINE collect_hermite_init_summaries(lines, cables, summaries, opts, friction_support, ErrStat, ErrMsg) + !! friction_support: the seabed friction the route accepts (FOLD_FRICTION_*) TYPE(DeckLine), INTENT(IN) :: lines(:) TYPE(CD_HFMF_ModuleType), INTENT(INOUT) :: cables(:) TYPE(CD_LineInitSummary), ALLOCATABLE, INTENT(OUT) :: summaries(:) + TYPE(DeckOptions), INTENT(IN) :: opts + INTEGER, INTENT(IN) :: friction_support INTEGER, INTENT(OUT) :: ErrStat CHARACTER(*), INTENT(OUT) :: ErrMsg INTEGER :: i @@ -36589,6 +36831,8 @@ SUBROUTINE collect_hermite_init_summaries(lines, cables, summaries, ErrStat, Err IF (ErrStat == CD_DECKDRV_OK) CALL CD_HFMF_CalcOutput(cables(i), fair_force, ErrStat, ErrMsg) IF (ErrStat /= CD_DECKDRV_OK) RETURN CALL set_init_summary(lines(i)%id, tension, declination, azimuth, summaries(i), fair_force=fair_force) + summaries(i)%seabed_drift = cable_seabed_drift(opts, cables(i)) + summaries(i)%friction_refused = friction_support == FOLD_FRICTION_NONE END DO END SUBROUTINE collect_hermite_init_summaries @@ -36605,6 +36849,7 @@ SUBROUTINE print_deck_init_report(deck_path, lines, points, sections, n_finite, INTEGER :: i, a, b REAL(wp) :: f_incl CHARACTER(1024) :: strain_note + CHARACTER(:), ALLOCATABLE :: drift_note WRITE (error_unit, '(A)') ' Parsing CableDyn input file: '//TRIM(deck_path) WRITE (error_unit, '(A,I0,A,I0,A,I0,A,I0,A,I0,A)') & @@ -36652,6 +36897,14 @@ SUBROUTINE print_deck_init_report(deck_path, lines, points, sections, n_finite, END IF END IF END DO + ! one note for the run; a run that marches no step does not drift + IF (PRESENT(opts)) THEN + IF (opts%has_dtm .AND. opts%tmax > CD_ZERO .AND. ANY(summaries%seabed_drift)) THEN + CALL seabed_drift_note(PACK(summaries%line_id, summaries%seabed_drift), & + ANY(summaries%seabed_drift .AND. summaries%friction_refused), drift_note) + WRITE (error_unit, '(A)') ' Note: '//drift_note//'.' + END IF + END IF WRITE (error_unit, '(A)') ' CableDyn initialization completed.' END SUBROUTINE print_deck_init_report diff --git a/src/CableDyn_HermiteCableDynamic.f90 b/src/CableDyn_HermiteCableDynamic.f90 index 25aa77b..e752fa0 100644 --- a/src/CableDyn_HermiteCableDynamic.f90 +++ b/src/CableDyn_HermiteCableDynamic.f90 @@ -121,6 +121,7 @@ MODULE CableDyn_HermiteCableDynamic PUBLIC :: CD_HermiteCable_Dyn_Set_SegmentLength PUBLIC :: CD_HermiteCableControlSaveType, CD_HermiteCable_Dyn_Save_Control, CD_HermiteCable_Dyn_Restore_Control PUBLIC :: CD_HermiteCable_Dyn_Set_Control_Seabed + PUBLIC :: CD_HermiteCable_Dyn_On_Seabed PUBLIC :: CD_HermiteCable_Dyn_Set_Irregular_Waves PUBLIC :: CD_HermiteCable_Drag_Element PUBLIC :: CD_HermiteCable_Axial_Damping_Element @@ -1937,6 +1938,34 @@ ELEMENTAL LOGICAL FUNCTION within_ulps(x, y) RESULT(close) END FUNCTION within_ulps END SUBROUTINE CD_HermiteCable_Dyn_Set_Control_Seabed + LOGICAL FUNCTION CD_HermiteCable_Dyn_On_Seabed(model) RESULT(on) + !! Whether an interior node of the cable is in seabed contact in its present state: inside + !! the reach of the normal contact law. The two end nodes, held or driven, do not count. + TYPE(CD_HermiteCableDynType), INTENT(IN) :: model + REAL(wp) :: z_floor, xg, yg, gx, gy + INTEGER :: node, iz, es + CHARACTER(200) :: em + + on = .FALSE. + IF (.NOT. (model%has_contact .OR. model%kn > CD_ZERO)) RETURN + DO node = 2, model%nn - 1 + iz = 6*(node - 1) + 3 + z_floor = model%seabed_z + gx = CD_ZERO + gy = CD_ZERO + IF (model%has_contact_bathymetry) THEN + xg = model%contact_frame_c*model%q(iz - 2) - model%contact_frame_s*model%q(iz - 1) + yg = model%contact_frame_s*model%q(iz - 2) + model%contact_frame_c*model%q(iz - 1) + CALL CD_Bathymetry_Floor_Gradient(model%contact_bathymetry, xg, yg, z_floor, gx, gy, es, em) + IF (es /= CD_BATHY_OK) CYCLE + END IF + IF (z_floor - model%q(iz) > -CD_SEABED_CONTACT_BLEND*SQRT(CD_ONE + gx*gx + gy*gy)) THEN + on = .TRUE. + RETURN + END IF + END DO + END FUNCTION CD_HermiteCable_Dyn_On_Seabed + SUBROUTINE rebuild_length_caches(model, l0_new, ErrStat, ErrMsg) !! Install the unstretched lengths l0_new with every cache built from them, in the order !! initialisation used, so the model matches one built with these lengths bit for bit: diff --git a/src/cabledyn.def b/src/cabledyn.def index 29c0386..5f69420 100644 --- a/src/cabledyn.def +++ b/src/cabledyn.def @@ -12,6 +12,7 @@ EXPORTS CableDyn_GetCoupledMoments CableDyn_GetCoupledMotion CableDyn_GetLastError + CableDyn_GetInitNotes CableDyn_GetLineValues CableDyn_GetObjectInfo CableDyn_GetPointState diff --git a/src/cabledyn.exp b/src/cabledyn.exp index 4537d25..af5b1db 100644 --- a/src/cabledyn.exp +++ b/src/cabledyn.exp @@ -11,6 +11,7 @@ _CableDyn_GetCoupledLayout _CableDyn_GetCoupledMoments _CableDyn_GetCoupledMotion _CableDyn_GetLastError +_CableDyn_GetInitNotes _CableDyn_GetLineValues _CableDyn_GetObjectInfo _CableDyn_GetPointState diff --git a/src/cabledyn.map b/src/cabledyn.map index 7e1bec8..a60d41e 100644 --- a/src/cabledyn.map +++ b/src/cabledyn.map @@ -14,6 +14,7 @@ CableDyn_GetCoupledMoments; CableDyn_GetCoupledMotion; CableDyn_GetLastError; + CableDyn_GetInitNotes; CableDyn_GetLineValues; CableDyn_GetObjectInfo; CableDyn_GetPointState; diff --git a/tests/test_c_api_init_notes.c b/tests/test_c_api_init_notes.c new file mode 100644 index 0000000..6c9074c --- /dev/null +++ b/tests/test_c_api_init_notes.c @@ -0,0 +1,161 @@ +/* File: tests/test_c_api_init_notes.c + * SPDX-License-Identifier: Apache-2.0 + * CableDyn_GetInitNotes through the public header and the shared library. + * + * The test writes a chain with a grounded run on a seabed without friction and checks: + * - the point route, in a deck current: the seabed drift note, once, naming the line; in still + * water no such note; + * - the aggregate route, the same chain with bending stiffness; + * - a host fluid field: the deck's steady current is known at initialisation, so the note stays + * on the aggregate; a deck with no current of its own has none, whatever the host applies later; + * - the buffer: a NULL buffer gives the length, a short buffer is truncated and NUL-terminated, + * a whole one holds exactly that length, and a bad handle gives -1 and an empty string; + * - a failed initialisation replaces the notes, and a raw-line one has none. + * The decks this test writes are removed on every exit path. + */ +#include "CableDyn_CAPI.h" + +#include +#include +#include + +#define CHECK(condition, message) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "FAIL: %s\n", (message)); \ + return 1; \ + } \ + } while (0) + +static const char DRIFT[] = "on the seabed at the static start with no seabed friction"; +static const char DECK_PATH[] = "c_api_init_notes.dat"; + +static int write_deck(double ei, const char *options) +{ + FILE *f = fopen(DECK_PATH, "w"); + if (f == NULL) return 1; + fprintf(f, "--- CableDyn Input File ---\n"); + fprintf(f, "a grounded chain\n--- LINE TYPES ---\n"); + fprintf(f, "Name Diam Mass EA BA EI Cdn Cdt Can Cat\n"); + fprintf(f, "chain 0.252 390.0 1.674e9 -1.0 %g 1.37 0.64 1.0 0.0\n", ei); + fprintf(f, "--- POINTS ---\nID Type X Y Z Mass Vol CdA Ca\n"); + fprintf(f, "1 Fixed 400.0 0.0 -50.0 0 0 0 0\n2 Coupled 0.0 0.0 0.0 0 0 0 0\n"); + fprintf(f, "--- LINES ---\nID NodeA NodeB Outputs\n1 2 1 -\n"); + fprintf(f, "--- SECTIONS ---\nLineID LineType Length NumSegs\n1 chain 410.0 41\n"); + fprintf(f, "--- OPTIONS ---\n9.80665 g\n1025.0 rhoW\n50.0 WtrDpth\n0.05 dtM\n10.0 TMax\n%s\n", options); + fprintf(f, "--- OUTPUTS ---\nFairTen1\n--- need this line ---\n"); + return fclose(f) != 0; +} + +/* How many times the drift note appears in the notes of h, or -1 when they cannot be read. */ +static int drift_notes(void *h) +{ + int count = 0; + const int size = CableDyn_GetInitNotes(h, NULL, 0); + char *text; + const char *at; + if (size < 0) return -1; + text = (char *)malloc((size_t)size + 1); + if (text == NULL) return -1; + if (CableDyn_GetInitNotes(h, text, size + 1) != size || (int)strlen(text) != size) { + free(text); + return -1; + } + for (at = strstr(text, DRIFT); at != NULL; at = strstr(at + 1, DRIFT)) ++count; + if (count > 0 && strstr(text, "line 1 lies") == NULL) count = -1; + free(text); + return count; +} + +/* Initialise a new handle from the deck and count its drift notes. */ +static int notes_of_deck(double ei, const char *options, int host_fluid) +{ + void *h = NULL; + int err = -1, count; + if (write_deck(ei, options) != 0) return -1; + CableDyn_Create(&h, &err); + if (err != CD_C_OK) return -1; + if (host_fluid) CableDyn_SetHostFluid(h, 1, &err); + CableDyn_InitDeck(h, DECK_PATH, (int)strlen(DECK_PATH), &err); + count = err == CD_C_OK ? drift_notes(h) : -1; + CableDyn_Close(&h, &err); + return count; +} + +static int run_routes(void) +{ + const char *current = "uniform 0.5 0 0 current"; + CHECK(notes_of_deck(0.0, current, 0) == 1, "point route: one drift note in the deck current"); + CHECK(notes_of_deck(0.0, "", 0) == 0, "point route: no drift note in still water"); + CHECK(notes_of_deck(0.0, "uniform 0.5 0 0 current\n0.5 frictionMu", 0) == 0, + "point route: no drift note with friction"); + CHECK(notes_of_deck(1.0e6, current, 0) == 1, "aggregate route: one drift note in the deck current"); + CHECK(notes_of_deck(1.0e6, "", 0) == 0, "aggregate route: no drift note in still water"); + CHECK(notes_of_deck(0.0, current, 1) == 1, "host field, EI = 0: the deck's steady current is known"); + CHECK(notes_of_deck(1.0e6, current, 1) == 1, "host field, finite EI: the deck's steady current is known"); + CHECK(notes_of_deck(0.0, "", 1) == 0, "host field: a current applied later is not known at initialisation"); + return 0; +} + +static int run_buffer(void) +{ + void *h = NULL; + int err = -1, size; + char small[9], *whole; + + CHECK(write_deck(0.0, "uniform 0.5 0 0 current") == 0, "write the deck"); + CableDyn_Create(&h, &err); + CHECK(err == CD_C_OK, "create"); + CHECK(CableDyn_GetInitNotes(h, NULL, 0) == 0, "no notes before an initialisation"); + CableDyn_InitDeck(h, DECK_PATH, (int)strlen(DECK_PATH), &err); + CHECK(err == CD_C_OK, "initialise"); + size = CableDyn_GetInitNotes(h, NULL, 0); + CHECK(size > (int)sizeof small, "a NULL buffer gives the length"); + memset(small, 'x', sizeof small); + CHECK(CableDyn_GetInitNotes(h, small, (int)sizeof small) == size, "a short buffer still gives the length"); + CHECK(strlen(small) == sizeof small - 1, "a short buffer is truncated and NUL-terminated"); + whole = (char *)malloc((size_t)size + 1); + CHECK(whole != NULL, "allocate"); + CHECK(CableDyn_GetInitNotes(h, whole, size + 1) == size && (int)strlen(whole) == size, "a whole buffer"); + CHECK(whole[size - 1] == '\n', "every note ends with a new line"); + CHECK(strncmp(whole, small, sizeof small - 1) == 0, "truncation keeps the start"); + free(whole); + + { + /* a failed initialisation replaces the notes */ + static const char missing[] = "c_api_init_notes_missing.dat"; + CableDyn_InitDeck(h, missing, (int)strlen(missing), &err); + CHECK(err != CD_C_OK, "a missing deck fails"); + CHECK(CableDyn_GetInitNotes(h, NULL, 0) == 0, "a failed initialisation replaces the notes"); + } + { + /* a raw-line initialisation has no notes */ + const double q0[6] = {0.0, 0.0, 0.0, 10.0, 0.0, 0.0}, v0[6] = {0.0}; + const double l0[1] = {10.0}, ea[1] = {1.0e6}, rho_a[1] = {10.0}; + const int elem_conn[2] = {1, 2}, fixed[6] = {1, 2, 3, 4, 5, 6}; + CableDyn_InitDeck(h, DECK_PATH, (int)strlen(DECK_PATH), &err); + CHECK(err == CD_C_OK && CableDyn_GetInitNotes(h, NULL, 0) == size, "the deck again gives its notes"); + CableDyn_InitLine(h, 2, 1, q0, v0, elem_conn, l0, ea, rho_a, fixed, 6, 1, 0.8, &err); + CHECK(err == CD_C_OK, "raw line"); + CHECK(CableDyn_GetInitNotes(h, NULL, 0) == 0, "a raw-line initialisation has no notes"); + } + CableDyn_Close(&h, &err); + + /* a bad handle: the closed handle is NULL here */ + small[0] = 'x'; + CHECK(CableDyn_GetInitNotes(h, small, (int)sizeof small) == -1, "a bad handle gives -1"); + CHECK(small[0] == '\0', "and an empty string"); + CHECK(CableDyn_GetInitNotes(h, NULL, 0) == -1, "with or without a buffer"); + return 0; +} + +int main(void) +{ + int failed; + CHECK(CableDyn_GetAbiMinor() >= 4, "ABI minor 4"); + failed = run_routes(); + if (failed == 0) failed = run_buffer(); + remove(DECK_PATH); + if (failed == 0) printf("c_api_init_notes: OK\n"); + return failed; +} diff --git a/tests/test_openfast_aggregate.f90 b/tests/test_openfast_aggregate.f90 index 8b8482b..36b2bb6 100644 --- a/tests/test_openfast_aggregate.f90 +++ b/tests/test_openfast_aggregate.f90 @@ -4861,7 +4861,10 @@ SUBROUTINE case_static_current() !! - on the mixed deck (EI = 0 line and finite-EI cable), held under the host field of the !! same current, the start stays at its loads -- the equilibrium in that current, with no !! second copy of it in the dynamics -- where the still-water start moves away; - !! - static_current needs the host field, and z levels that ascend. + !! - static_current needs the host field, and z levels that ascend; + !! - the grounded chains of the EI = 0 deck in that current, or in the deck's, carry one + !! seabed drift note for the run, naming them; none in still water, with frictionMu or + !! on a run that marches no step. TYPE(CD_AGG_ModuleType) :: agg_w, agg_c, agg_u REAL(wp), PARAMETER :: DT = 0.05_wp REAL(wp), PARAMETER :: UC(3) = [0.4_wp, 0.3_wp, 0.0_wp] @@ -4888,9 +4891,25 @@ SUBROUTINE case_static_current() CALL CD_AGG_GetMovingPointMesh(agg_c, r0, v3, a3, lc3, es, em) CALL require(nan_max_abs(lc3 - lu) <= 1.0e-9_wp*nan_max_abs(lu), & 'static-current:ei0-start-equals-deck-current-start') + CALL require_drift_note(agg_u, 1, 'static-current:deck-current-drift-note') + CALL require_drift_note(agg_c, 1, 'static-current:host-current-drift-note') END IF CALL CD_AGG_End(agg_u, es, em) CALL CD_AGG_End(agg_c, es, em) + CALL CD_AGG_Init_From_Deck(agg_c, 'agg_sc_still.dat', DT, es, em, external_fluid=.TRUE.) + CALL require(es == CD_AGG_OK, 'static-current:ei0-still-init: '//TRIM(em)) + IF (es == CD_AGG_OK) CALL require_drift_note(agg_c, 0, 'static-current:still-water-no-drift-note') + CALL CD_AGG_End(agg_c, es, em) + CALL CD_AGG_Init_From_Deck(agg_c, 'agg_sc_deck.dat', DT, es, em, static_run=.TRUE.) + CALL require(es == CD_AGG_OK, 'static-current:static-run-init: '//TRIM(em)) + IF (es == CD_AGG_OK) CALL require_drift_note(agg_c, 0, 'static-current:static-run-no-drift-note') + CALL CD_AGG_End(agg_c, es, em) + CALL write_volturnus_deck('agg_sc_friction.dat', extra_option='0.5 frictionMu', extra_option2='0.05 dtM', & + extra_option3='60.0 TMax') + CALL CD_AGG_Init_From_Deck(agg_c, 'agg_sc_friction.dat', DT, es, em, external_fluid=.TRUE., static_current=prof) + CALL require(es == CD_AGG_OK, 'static-current:friction-init: '//TRIM(em)) + IF (es == CD_AGG_OK) CALL require_drift_note(agg_c, 0, 'static-current:friction-no-drift-note') + CALL CD_AGG_End(agg_c, es, em) ! (2) mixed deck: held in the host's current, the start in that current stays put CALL write_mixed_deck('agg_sc_mixed.dat') @@ -4920,6 +4939,30 @@ SUBROUTINE case_static_current() CALL CD_AGG_End(agg_c, es, em) END SUBROUTINE case_static_current + SUBROUTINE require_drift_note(agg, expected, label) + !! The seabed drift note appears expected times over the lines of the three-chain deck, and + !! names the three. + TYPE(CD_AGG_ModuleType), INTENT(IN) :: agg + INTEGER, INTENT(IN) :: expected + CHARACTER(*), INTENT(IN) :: label + CHARACTER(*), PARAMETER :: TEXT = 'on the seabed at the static start with no seabed friction' + CHARACTER(2048) :: note + CHARACTER(1024) :: em + REAL(wp) :: tension, force(3), incl, decl, azi + INTEGER :: i, line_id, es, found + + found = 0 + DO i = 1, 3 + CALL CD_AGG_GetInitLine(agg, i, line_id, tension, force, incl, decl, azi, es, em, note=note) + CALL require(es == CD_AGG_OK, label//': line summary: '//TRIM(em)) + IF (INDEX(note, TEXT) == 0) CYCLE + found = found + 1 + CALL require(INDEX(note, 'lines 1, 2, 3 lie') > 0 .AND. INDEX(note, 'Set frictionMu to hold them') > 0, & + label//': names the lines and the remedy: '//TRIM(note)) + END DO + CALL require(found == expected, label) + END SUBROUTINE require_drift_note + SUBROUTINE held_drift(agg, uc, rel) !! Largest relative change of the two moving-point loads of the mixed deck over 1 s with the !! boundary held at its start and the host field the uniform current uc. diff --git a/tests/test_seabed_drift_note.py b/tests/test_seabed_drift_note.py new file mode 100644 index 0000000..08ea1da --- /dev/null +++ b/tests/test_seabed_drift_note.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University +"""The initialisation note on grounded lines that a current moves over a seabed without friction. + +A dynamic deck with an interior line node in seabed contact at the static start, a current and +no friction prints one note for the run, naming the lines and frictionMu. Gates, on a grounded +chain and on the shipped decks of the other routes: + +* the note appears once on an EI = 0 line, a finite-EI line, the mixed aggregate (both lines in + one note), a line to a Free point, the multibody march (EI = 0 lines and a finite-EI cable) and + a TURBINES deck on its motionFile record, and names the lines concerned; a route that rejects + frictionMu says so instead of asking for it; +* no note on a deck with no grounded node (a line clear of the seabed, a taut line that touches + it at its anchor only), in still water or a zero current, with frictionMu, or on a static run + (TMax = 0 or no clock): the shipped lazy wave in its current, grounded over 156 nodes, is silent + at its own TMax = 0 and noted on a short march. + +usage: test_seabed_drift_note.py --exe --examples --output +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +FAILURES: list[str] = [] +NOTE = "on the seabed at the static start with no seabed friction" +BAR = "-" * 21 +CLOCK = "0.05 dtM\n0.1 TMax\n" +CURRENT = "uniform 0.5 0 0 current\n" +DECK = f"""{BAR} CableDyn Input File {BAR} +seabed drift note +{BAR} LINE TYPES {BAR} +Name Diam Mass EA BA EI Cdn Cdt Can Cat +chain 0.252 390.0 1.674e9 -1.0 0.0 1.37 0.64 1.0 0.0 +cable 0.2 60.0 4.0e8 0.0 1.0e4 1.2 0.1 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 +3 Fixed -400.0 0.0 -50.0 0 0 0 0 +{BAR} LINES {BAR} +ID NodeA NodeB Outputs +1 2 1 - +@LINE2@ +{BAR} SECTIONS {BAR} +LineID LineType Length NumSegs +1 @TYPE@ @LENGTH@ 41 +@SECTION2@ +{BAR} OPTIONS {BAR} +9.80665 g +1025.0 rhoW +50.0 WtrDpth +@OPTIONS@ +{BAR} OUTPUTS {BAR} +FairTen1 +{BAR} need this line {BAR} +""" + + +TURBINES = f"""{BAR} CableDyn Input File {BAR} +seabed drift note on a TURBINES deck +{BAR} LINE TYPES {BAR} +Name Diam Mass EA BA EI Cdn Cdt Can Cat +chain 0.1 20.0 1.0e9 -1.0 0.0 1.2 0.2 1.0 0.0 +{BAR} TURBINES {BAR} +J X0 Y0 Z0 PtfmSurge PtfmSway PtfmHeave PtfmRoll PtfmPitch PtfmYaw +1 300.0 0.0 0 0 0 0 0 0 0 +{BAR} POINTS {BAR} +ID Type X Y Z Mass Vol CdA Ca +1 Turbine1 0 0 -10 0 0 0 0 +2 Fixed 0.0 0.0 -100.0 0 0 0 0 +{BAR} LINES {BAR} +ID NodeA NodeB Outputs +1 1 2 - +{BAR} SECTIONS {BAR} +LineID LineType Length NumSegs +1 chain 380.0 20 +{BAR} OPTIONS {BAR} +9.80665 g +1025.0 rhoW +100.0 WtrDpth +@OPTIONS@ +turbine.motion motionFile +{BAR} OUTPUTS {BAR} +AnchTen1 +{BAR} need this line {BAR} +""" + + +def check(ok: bool, message: str) -> None: + print(("PASS: " if ok else "FAIL: ") + message) + if not ok: + FAILURES.append(message) + + +def deck(options: str, line_type: str = "chain", length: str = "410.0", second: bool = False) -> str: + return (DECK.replace("@OPTIONS@", options).replace("@TYPE@", line_type).replace("@LENGTH@", length) + .replace("@LINE2@", "2 2 3 -" if second else "") + .replace("@SECTION2@", "2 cable 420.0 42" if second else "")) + + +def run(exe: str, work: Path, name: str, text: str) -> str: + (work / f"{name}.dat").write_text(text, encoding="utf-8", newline="\n") + done = subprocess.run([exe, f"{name}.dat", name], cwd=work, capture_output=True, text=True, + env=dict(os.environ, OMP_NUM_THREADS="1")) + check(done.returncode == 0, f"{name}: runs (exit {done.returncode})") + if done.returncode != 0: + print(done.stderr[-1500:]) + return done.stdout + done.stderr + + +def noted(exe: str, work: Path, name: str, text: str, names: str, remedy: str = "Set frictionMu to hold") -> None: + log = run(exe, work, name, text) + check(log.count(NOTE) == 1, f"{name}: one note for the run ({log.count(NOTE)})") + line = next((row for row in log.splitlines() if NOTE in row), "") + check(names in line and remedy in line, f"{name}: names {names} and the remedy: {line.strip()}") + + +def silent(exe: str, work: Path, name: str, text: str) -> None: + log = run(exe, work, name, text) + check(NOTE not in log, f"{name}: no note") + + +def set_row(text: str, key: str, value: str | None) -> str: + """Set OPTION ``key`` of a shipped deck; a key it lacks goes first in OPTIONS.""" + pattern = re.compile(r"^(?![ \t]*--)[ \t]*\S+(?:[ \t]+\S+)*?[ \t]+" + key + r"\b.*$", re.M) + if pattern.search(text): + return pattern.sub("" if value is None else value, text, count=1) + head = re.search(r"^-+ *OPTIONS.*\n", text, re.M) + return text[:head.end()] + (value or "") + "\n" + text[head.end():] + + +def shipped(work: Path, name: str, *rows: tuple[str, str | None]) -> str: + text = (work / f"{name}.dat").read_text(encoding="utf-8") + text = set_row(set_row(text, "dtM", "0.005 dtM"), "TMax", "0.01 TMax") + for key, value in rows: + text = set_row(text, key, value) + assert len(re.findall(r"^(?![ \t]*--).*\bTMax\b", text, re.M)) == 1, name + return text + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--exe", required=True) + ap.add_argument("--examples", required=True) + ap.add_argument("--output", required=True) + args = ap.parse_args() + exe = args.exe + out = Path(args.output) + if out.exists(): + shutil.rmtree(out) + shutil.copytree(args.examples, out) + + # the note, on each route + noted(exe, out, "ei0", deck(CLOCK + CURRENT), "line 1 lies") + noted(exe, out, "finite", deck(CLOCK + CURRENT, "cable", "420.0"), "line 1 lies") + noted(exe, out, "mixed", deck(CLOCK + CURRENT, second=True), "lines 1, 2 lie") + noted(exe, out, "profile", deck(CLOCK + "profile -50 0.5 0 0 0 0.5 0 0 current\n"), "line 1 lies") + current = ("current", "uniform 0.5 0 0 current") + refused = "Seabed friction (frictionMu), which would hold it, is not supported on this deck" + clump = shipped(out, "clump_weight_free_point", current).replace("chain 250.0", "chain 300.0") + noted(exe, out, "free_point", clump, "line 1 lies", refused) + noted(exe, out, "multibody_ei0", shipped(out, "spar_pinned_rods", current), "lines 1, 2, 3 lie") + noted(exe, out, "multibody_finite", shipped(out, "buoy_clamped_cable", current), "line 4 lies") + noted(exe, out, "multibody_points", shipped(out, "mixed_body_rods_points", current), "line 8 lies", refused) + + # a TURBINES deck on its per-turbine record: time J x y z q0 q1 q2 q3 and the rates, at rest + rows = [f"{0.05 * k:.2f} 1 300 0 0 1 0 0 0 " + " ".join(["0"] * 12) for k in range(5)] + (out / "turbine.motion").write_text("\n".join(rows) + "\n", encoding="utf-8", newline="\n") + noted(exe, out, "turbines", TURBINES.replace("@OPTIONS@", CLOCK + CURRENT), "line 1 lies") + silent(exe, out, "turbines_still", TURBINES.replace("@OPTIONS@", CLOCK)) + silent(exe, out, "turbines_friction", TURBINES.replace("@OPTIONS@", CLOCK + CURRENT + "0.5 frictionMu\n")) + silent(exe, out, "turbines_static", TURBINES.replace("@OPTIONS@", "0.05 dtM\n0.0 TMax\n" + CURRENT)) + + # no grounded node, on the EI = 0 and finite-EI routes + clear = deck(CLOCK + CURRENT, length="404.0").replace("1 Fixed 400.0 0.0 -50.0", "1 Fixed 400.0 0.0 -30.0") + silent(exe, out, "clear_ei0", clear) + silent(exe, out, "clear_finite", clear.replace("1 chain 404.0", "1 cable 404.0")) + silent(exe, out, "anchor_only_ei0", deck(CLOCK + CURRENT, length="400.0")) + silent(exe, out, "anchor_only_finite", deck(CLOCK + CURRENT, "cable", "400.0")) + + # still water, a zero current and friction, on the single-line routes and the mixed aggregate + routes = (("ei0", {}), ("finite", {"line_type": "cable", "length": "420.0"}), ("mixed", {"second": True})) + for tag, kwargs in routes: + silent(exe, out, f"still_{tag}", deck(CLOCK, **kwargs)) + silent(exe, out, f"zero_{tag}", deck(CLOCK + "uniform 0 0 0 current\n", **kwargs)) + silent(exe, out, f"friction_{tag}", deck(CLOCK + CURRENT + "0.5 frictionMu\n", **kwargs)) + # a static run marches no step + silent(exe, out, f"static_{tag}", deck("0.05 dtM\n0.0 TMax\n" + CURRENT, **kwargs)) + silent(exe, out, "static_no_clock", deck("")) + lazy_wave = (out / "lazy_wave_buoyancy_smeared.dat").read_text(encoding="utf-8") + silent(exe, out, "lazy_wave_static", lazy_wave) + noted(exe, out, "lazy_wave_march", set_row(lazy_wave, "TMax", "0.05 TMax"), "line 1 lies") + silent(exe, out, "static_free_point", set_row(clump, "TMax", "0.0 TMax")) + for name in ("spar_pinned_rods", "buoy_clamped_cable"): + silent(exe, out, f"static_{name}", shipped(out, name, current, ("TMax", "0.0 TMax"))) + silent(exe, out, f"friction_{name}", shipped(out, name, current, ("frictionMu", "0.5 frictionMu"))) + print(f"{len(FAILURES)} failure(s)") + return 1 if FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_static_check.py b/tests/test_static_check.py index ce021d7..1f13691 100644 --- a/tests/test_static_check.py +++ b/tests/test_static_check.py @@ -16,7 +16,8 @@ give TMax = 0. and on one deck of each route (EI = 0 and finite-EI lines, the mixed aggregate, the multibody -march, deck current and waves, vesselMotion, vesselRAO and motionFile records): +march, deck current and waves, vesselMotion, vesselRAO and motionFile records, a TURBINES deck on +its per-turbine record): * TMax = 0 needs no dtM: the deck runs, on records off any step, and writes the output of the run with dtM byte for byte; @@ -44,6 +45,37 @@ "lozon_gomex80_power_cable_motion") +TURBINES = """--------------------- CableDyn Input File --------------------- +a TURBINES deck on its per-turbine record, at 0.1 s +--------------------- LINE TYPES --------------------- +Name Diam Mass EA BA EI Cdn Cdt Can Cat +chain 0.1 20.0 1.0e9 -1.0 0.0 1.2 0.2 1.0 0.0 +--------------------- TURBINES --------------------- +J X0 Y0 Z0 PtfmSurge PtfmSway PtfmHeave PtfmRoll PtfmPitch PtfmYaw +1 300.0 0.0 0 0 0 0 0 0 0 +--------------------- POINTS --------------------- +ID Type X Y Z Mass Vol CdA Ca +1 Turbine1 0 0 -10 0 0 0 0 +2 Fixed 0.0 0.0 -100.0 0 0 0 0 +--------------------- LINES --------------------- +ID NodeA NodeB Outputs +1 1 2 - +--------------------- SECTIONS --------------------- +LineID LineType Length NumSegs +1 chain 380.0 20 +--------------------- OPTIONS --------------------- +9.80665 g +1025.0 rhoW +100.0 WtrDpth +0.1 dtM +4.0 TMax +turbine.motion motionFile +--------------------- OUTPUTS --------------------- +AnchTen1 +--------------------- need this line --------------------- +""" + + def check(ok: bool, message: str) -> None: print(("PASS: " if ok else "FAIL: ") + message) if not ok: @@ -174,7 +206,11 @@ def main() -> int: record = "data/vessel/vessel_surge_heave_pitch_12s_dt005.txt vesselMotion" (work / "mixed_vessel_motion.dat").write_text( set_option(mixed, "TMax", f"{record}\n0|0|0 vesselRef\n0.0"), encoding="utf-8", newline="\n") - for deck_name in (*ROUTES, "mixed_vessel_motion"): + # a TURBINES deck: time J x y z q0 q1 q2 q3 and the rates, at rest, every 0.1 s + rows = [f"{0.1 * k:.1f} 1 300 0 0 1 0 0 0 " + " ".join(["0"] * 12) for k in range(41)] + (work / "turbine.motion").write_text("\n".join(rows) + "\n", encoding="utf-8", newline="\n") + (work / "turbines_motion.dat").write_text(TURBINES, encoding="utf-8", newline="\n") + for deck_name in (*ROUTES, "mixed_vessel_motion", "turbines_motion"): no_dtm(args.exe, work, deck_name) print(f"{len(FAILURES)} failure(s)") return 1 if FAILURES else 0 diff --git a/tests/test_thread_safe_sources.py b/tests/test_thread_safe_sources.py index 442bcd2..de5ee56 100644 --- a/tests/test_thread_safe_sources.py +++ b/tests/test_thread_safe_sources.py @@ -39,6 +39,8 @@ ("CableDyn_OF.f90", "g_serial"): "OpenFAST instance generation counter, at initialisation only", ("CableDyn_DeckDriver.f90", "body_static_pass_limit"): "test hook of the standalone body statics", ("CableDyn_DeckDriver.f90", "probe_armed"): "test hook of the standalone multibody march", + ("CableDyn_DeckDriver.f90", "capture_notes"): "set only under the C API's process-wide input lock", + ("CableDyn_DeckDriver.f90", "captured_notes"): "written only under the C API's process-wide input lock", ("CableDyn_DeckDriver.f90", "probe_v0"): "test hook of the standalone multibody march", ("CableDyn_DeckDriver.f90", "probe_w0"): "test hook of the standalone multibody march", ("CableDyn_DeckDriver.f90", "probe_rec"): "test hook of the standalone multibody march",